diff --git a/backend-node/scripts/migrate-input-type-to-web-type.ts b/backend-node/scripts/migrate-input-type-to-web-type.ts new file mode 100644 index 00000000..65c64b14 --- /dev/null +++ b/backend-node/scripts/migrate-input-type-to-web-type.ts @@ -0,0 +1,168 @@ +import { query } from "../src/database/db"; +import { logger } from "../src/utils/logger"; + +/** + * input_type을 web_type으로 마이그레이션하는 스크립트 + * + * 목적: + * - column_labels 테이블의 input_type 값을 읽어서 + * - 해당하는 기본 web_type 값으로 변환 + * - web_type이 null인 경우에만 업데이트 + */ + +// input_type → 기본 web_type 매핑 +const INPUT_TYPE_TO_WEB_TYPE: Record = { + text: "text", // 일반 텍스트 + number: "number", // 정수 + date: "date", // 날짜 + code: "code", // 코드 선택박스 + entity: "entity", // 엔티티 참조 + select: "select", // 선택박스 + checkbox: "checkbox", // 체크박스 + radio: "radio", // 라디오버튼 + direct: "text", // direct는 text로 매핑 +}; + +async function migrateInputTypeToWebType() { + try { + logger.info("=".repeat(60)); + logger.info("input_type → web_type 마이그레이션 시작"); + logger.info("=".repeat(60)); + + // 1. 현재 상태 확인 + const stats = await query<{ + total: string; + has_input_type: string; + has_web_type: string; + needs_migration: string; + }>( + `SELECT + COUNT(*) as total, + COUNT(input_type) FILTER (WHERE input_type IS NOT NULL) as has_input_type, + COUNT(web_type) FILTER (WHERE web_type IS NOT NULL) as has_web_type, + COUNT(*) FILTER (WHERE input_type IS NOT NULL AND web_type IS NULL) as needs_migration + FROM column_labels` + ); + + const stat = stats[0]; + logger.info("\n📊 현재 상태:"); + logger.info(` - 전체 컬럼: ${stat.total}개`); + logger.info(` - input_type 있음: ${stat.has_input_type}개`); + logger.info(` - web_type 있음: ${stat.has_web_type}개`); + logger.info(` - 마이그레이션 필요: ${stat.needs_migration}개`); + + if (parseInt(stat.needs_migration) === 0) { + logger.info("\n✅ 마이그레이션이 필요한 데이터가 없습니다."); + return; + } + + // 2. input_type별 분포 확인 + const distribution = await query<{ + input_type: string; + count: string; + }>( + `SELECT + input_type, + COUNT(*) as count + FROM column_labels + WHERE input_type IS NOT NULL AND web_type IS NULL + GROUP BY input_type + ORDER BY input_type` + ); + + logger.info("\n📋 input_type별 분포:"); + distribution.forEach((item) => { + const webType = + INPUT_TYPE_TO_WEB_TYPE[item.input_type] || item.input_type; + logger.info(` - ${item.input_type} → ${webType}: ${item.count}개`); + }); + + // 3. 마이그레이션 실행 + logger.info("\n🔄 마이그레이션 실행 중..."); + + let totalUpdated = 0; + + for (const [inputType, webType] of Object.entries(INPUT_TYPE_TO_WEB_TYPE)) { + const result = await query( + `UPDATE column_labels + SET + web_type = $1, + updated_date = NOW() + WHERE input_type = $2 + AND web_type IS NULL + RETURNING id, table_name, column_name`, + [webType, inputType] + ); + + if (result.length > 0) { + logger.info( + ` ✓ ${inputType} → ${webType}: ${result.length}개 업데이트` + ); + totalUpdated += result.length; + + // 처음 5개만 출력 + result.slice(0, 5).forEach((row: any) => { + logger.info(` - ${row.table_name}.${row.column_name}`); + }); + if (result.length > 5) { + logger.info(` ... 외 ${result.length - 5}개`); + } + } + } + + // 4. 결과 확인 + const afterStats = await query<{ + total: string; + has_web_type: string; + }>( + `SELECT + COUNT(*) as total, + COUNT(web_type) FILTER (WHERE web_type IS NOT NULL) as has_web_type + FROM column_labels` + ); + + const afterStat = afterStats[0]; + + logger.info("\n" + "=".repeat(60)); + logger.info("✅ 마이그레이션 완료!"); + logger.info("=".repeat(60)); + logger.info(`📊 최종 통계:`); + logger.info(` - 전체 컬럼: ${afterStat.total}개`); + logger.info(` - web_type 설정됨: ${afterStat.has_web_type}개`); + logger.info(` - 업데이트된 컬럼: ${totalUpdated}개`); + logger.info("=".repeat(60)); + + // 5. 샘플 데이터 출력 + logger.info("\n📝 샘플 데이터 (check_report_mng 테이블):"); + const samples = await query<{ + column_name: string; + input_type: string; + web_type: string; + detail_settings: string; + }>( + `SELECT + column_name, + input_type, + web_type, + detail_settings + FROM column_labels + WHERE table_name = 'check_report_mng' + ORDER BY column_name + LIMIT 10` + ); + + samples.forEach((sample) => { + logger.info( + ` ${sample.column_name}: ${sample.input_type} → ${sample.web_type}` + ); + }); + + process.exit(0); + } catch (error) { + logger.error("❌ 마이그레이션 실패:", error); + process.exit(1); + } +} + +// 스크립트 실행 +migrateInputTypeToWebType(); diff --git a/backend-node/src/config/environment.ts b/backend-node/src/config/environment.ts index a4c6c33b..e350642d 100644 --- a/backend-node/src/config/environment.ts +++ b/backend-node/src/config/environment.ts @@ -75,6 +75,8 @@ const getCorsOrigin = (): string[] | boolean => { "http://localhost:9771", // 로컬 개발 환경 "http://192.168.0.70:5555", // 내부 네트워크 접근 "http://39.117.244.52:5555", // 외부 네트워크 접근 + "https://v1.vexplor.com", // 운영 프론트엔드 + "https://api.vexplor.com", // 운영 백엔드 ]; }; diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 7c32bda6..6da8d16a 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -1018,14 +1018,14 @@ export class ScreenManagementService { [tableName] ); - // column_labels 테이블에서 웹타입 정보 조회 (있는 경우) + // column_labels 테이블에서 입력타입 정보 조회 (있는 경우) const webTypeInfo = await query<{ column_name: string; - web_type: string | null; + input_type: string | null; column_label: string | null; detail_settings: any; }>( - `SELECT column_name, web_type, column_label, detail_settings + `SELECT column_name, input_type, column_label, detail_settings FROM column_labels WHERE table_name = $1`, [tableName] @@ -1045,7 +1045,7 @@ export class ScreenManagementService { this.getColumnLabel(column.column_name), dataType: column.data_type, webType: - (webTypeData?.web_type as WebType) || + (webTypeData?.input_type as WebType) || this.inferWebType(column.data_type), isNullable: column.is_nullable, columnDefault: column.column_default || undefined, @@ -1522,7 +1522,7 @@ export class ScreenManagementService { c.column_name, COALESCE(cl.column_label, c.column_name) as column_label, c.data_type, - COALESCE(cl.web_type, 'text') as web_type, + COALESCE(cl.input_type, 'text') as web_type, c.is_nullable, c.column_default, c.character_maximum_length, @@ -1548,7 +1548,7 @@ export class ScreenManagementService { } /** - * 웹 타입 설정 (✅ Raw Query 전환 완료) + * 입력 타입 설정 (✅ Raw Query 전환 완료) */ async setColumnWebType( tableName: string, @@ -1556,16 +1556,16 @@ export class ScreenManagementService { webType: WebType, additionalSettings?: Partial ): Promise { - // UPSERT를 INSERT ... ON CONFLICT로 변환 + // UPSERT를 INSERT ... ON CONFLICT로 변환 (input_type 사용) await query( `INSERT INTO column_labels ( - table_name, column_name, column_label, web_type, detail_settings, + table_name, column_name, column_label, input_type, detail_settings, code_category, reference_table, reference_column, display_column, is_visible, display_order, description, created_date, updated_date ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) ON CONFLICT (table_name, column_name) DO UPDATE SET - web_type = $4, + input_type = $4, column_label = $3, detail_settings = $5, code_category = $6, diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index dd8cb1cc..83f3a696 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -27,13 +27,13 @@ export class TableManagementService { columnName: string ): Promise<{ isCodeType: boolean; codeCategory?: string }> { try { - // column_labels 테이블에서 해당 컬럼의 web_type이 'code'인지 확인 + // column_labels 테이블에서 해당 컬럼의 input_type이 'code'인지 확인 const result = await query( - `SELECT web_type, code_category + `SELECT input_type, code_category FROM column_labels WHERE table_name = $1 AND column_name = $2 - AND web_type = 'code'`, + AND input_type = 'code'`, [tableName, columnName] ); @@ -167,7 +167,7 @@ export class TableManagementService { COALESCE(cl.column_label, c.column_name) as "displayName", c.data_type as "dataType", c.data_type as "dbType", - COALESCE(cl.web_type, 'text') as "webType", + COALESCE(cl.input_type, 'text') as "webType", COALESCE(cl.input_type, 'direct') as "inputType", COALESCE(cl.detail_settings, '') as "detailSettings", COALESCE(cl.description, '') as "description", @@ -483,7 +483,7 @@ export class TableManagementService { table_name: string; column_name: string; column_label: string | null; - web_type: string | null; + input_type: string | null; detail_settings: any; description: string | null; display_order: number | null; @@ -495,7 +495,7 @@ export class TableManagementService { created_date: Date | null; updated_date: Date | null; }>( - `SELECT id, table_name, column_name, column_label, web_type, detail_settings, + `SELECT id, table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, code_category, code_value, reference_table, reference_column, created_date, updated_date FROM column_labels @@ -512,7 +512,7 @@ export class TableManagementService { tableName: columnLabel.table_name || "", columnName: columnLabel.column_name || "", columnLabel: columnLabel.column_label || undefined, - webType: columnLabel.web_type || undefined, + webType: columnLabel.input_type || undefined, detailSettings: columnLabel.detail_settings || undefined, description: columnLabel.description || undefined, displayOrder: columnLabel.display_order || undefined, @@ -539,7 +539,7 @@ export class TableManagementService { } /** - * 컬럼 웹 타입 설정 + * 컬럼 입력 타입 설정 (web_type → input_type 통합) */ async updateColumnWebType( tableName: string, @@ -550,7 +550,7 @@ export class TableManagementService { ): Promise { try { logger.info( - `컬럼 웹 타입 설정 시작: ${tableName}.${columnName} = ${webType}` + `컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${webType}` ); // 웹 타입별 기본 상세 설정 생성 @@ -562,35 +562,28 @@ export class TableManagementService { ...detailSettings, }; - // column_labels UPSERT로 업데이트 또는 생성 + // column_labels UPSERT로 업데이트 또는 생성 (input_type만 사용) await query( `INSERT INTO column_labels ( - table_name, column_name, web_type, detail_settings, input_type, created_date, updated_date - ) VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) + table_name, column_name, input_type, detail_settings, created_date, updated_date + ) VALUES ($1, $2, $3, $4, NOW(), NOW()) ON CONFLICT (table_name, column_name) DO UPDATE SET - web_type = EXCLUDED.web_type, + input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, - input_type = COALESCE(EXCLUDED.input_type, column_labels.input_type), updated_date = NOW()`, - [ - tableName, - columnName, - webType, - JSON.stringify(finalDetailSettings), - inputType || null, - ] + [tableName, columnName, webType, JSON.stringify(finalDetailSettings)] ); logger.info( - `컬럼 웹 타입 설정 완료: ${tableName}.${columnName} = ${webType}` + `컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${webType}` ); } catch (error) { logger.error( - `컬럼 웹 타입 설정 중 오류 발생: ${tableName}.${columnName}`, + `컬럼 입력 타입 설정 중 오류 발생: ${tableName}.${columnName}`, error ); throw new Error( - `컬럼 웹 타입 설정 실패: ${error instanceof Error ? error.message : "Unknown error"}` + `컬럼 입력 타입 설정 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } diff --git a/docs/input-type-detail-type-system.md b/docs/input-type-detail-type-system.md new file mode 100644 index 00000000..e214a256 --- /dev/null +++ b/docs/input-type-detail-type-system.md @@ -0,0 +1,304 @@ +# 입력 타입과 세부 타입 시스템 가이드 + +## 📋 개요 + +화면 관리 시스템에서 사용자가 **입력 타입**과 **세부 타입**을 2단계로 선택할 수 있는 시스템입니다. + +### 구조 + +1. **입력 타입 (Input Type)**: 테이블 타입 관리에서 정의한 8개 핵심 타입 +2. **세부 타입 (Detail Type)**: 입력 타입의 구체적인 구현 방식 (웹타입) + +``` +입력 타입 (PropertiesPanel에서 선택) + ↓ +세부 타입 (DetailSettingsPanel에서 선택) + ↓ +세부 설정 (DetailSettingsPanel에서 설정) +``` + +--- + +## 🎯 8개 핵심 입력 타입과 세부 타입 + +### 1. **텍스트 (text)** + +사용 가능한 세부 타입: + +- `text` - 일반 텍스트 입력 +- `email` - 이메일 주소 입력 +- `tel` - 전화번호 입력 +- `url` - 웹사이트 주소 입력 +- `textarea` - 여러 줄 텍스트 입력 +- `password` - 비밀번호 입력 (마스킹) + +### 2. **숫자 (number)** + +사용 가능한 세부 타입: + +- `number` - 정수 숫자 입력 +- `decimal` - 소수점 포함 숫자 입력 + +### 3. **날짜 (date)** + +사용 가능한 세부 타입: + +- `date` - 날짜 선택 (YYYY-MM-DD) +- `datetime` - 날짜와 시간 선택 +- `time` - 시간 선택 (HH:mm) + +### 4. **코드 (code)** + +세부 타입: + +- `code` - 공통 코드 선택 (세부 타입 고정) +- 코드 카테고리는 상세 설정에서 선택 + +### 5. **엔티티 (entity)** + +세부 타입: + +- `entity` - 다른 테이블 참조 (세부 타입 고정) +- 참조 테이블은 상세 설정에서 선택 + +### 6. **선택박스 (select)** + +사용 가능한 세부 타입: + +- `select` - 기본 드롭다운 선택 +- `dropdown` - 검색 기능이 있는 드롭다운 + +### 7. **체크박스 (checkbox)** + +사용 가능한 세부 타입: + +- `checkbox` - 단일 체크박스 +- `boolean` - On/Off 스위치 + +### 8. **라디오버튼 (radio)** + +세부 타입: + +- `radio` - 라디오 버튼 그룹 (세부 타입 고정) + +--- + +## 🔧 사용 방법 + +### 1. PropertiesPanel - 입력 타입 선택 + +위젯 컴포넌트를 선택하면 **속성 편집** 패널에서 입력 타입을 선택할 수 있습니다. + +```typescript +// 입력 타입 선택 + +``` + +**동작:** + +- 입력 타입을 선택하면 해당 타입의 **기본 세부 타입**이 자동으로 설정됩니다 +- 예: `text` 입력 타입 선택 → `text` 세부 타입 자동 설정 + +### 2. DetailSettingsPanel - 세부 타입 선택 + +**상세 설정** 패널에서 선택한 입력 타입의 세부 타입을 선택할 수 있습니다. + +```typescript +// 세부 타입 선택 + +``` + +**동작:** + +- 입력 타입에 해당하는 세부 타입만 표시됩니다 +- 세부 타입을 변경하면 `widgetType` 속성이 업데이트됩니다 + +### 3. DetailSettingsPanel - 세부 설정 + +세부 타입을 선택한 후, 해당 타입의 상세 설정을 할 수 있습니다. + +예: + +- **날짜 (date)**: 날짜 형식, 최소/최대 날짜 등 +- **숫자 (number)**: 최소/최대값, 소수점 자리수 등 +- **코드 (code)**: 코드 카테고리 선택 +- **엔티티 (entity)**: 참조 테이블, 표시 컬럼 선택 + +--- + +## 📁 파일 구조 + +### 새로 추가된 파일 + +#### `frontend/types/input-type-mapping.ts` + +입력 타입과 세부 타입 매핑 정의 + +```typescript +// 8개 핵심 입력 타입 +export type BaseInputType = "text" | "number" | "date" | ...; + +// 입력 타입별 세부 타입 매핑 +export const INPUT_TYPE_DETAIL_TYPES: Record; + +// 유틸리티 함수들 +export function getBaseInputType(webType: WebType): BaseInputType; +export function getDetailTypes(baseInputType: BaseInputType): DetailTypeOption[]; +export function getDefaultDetailType(baseInputType: BaseInputType): WebType; +``` + +### 수정된 파일 + +#### `frontend/components/screen/panels/PropertiesPanel.tsx` + +- 입력 타입 선택 UI 추가 +- 웹타입 선택 → 입력 타입 선택으로 변경 + +#### `frontend/components/screen/panels/DetailSettingsPanel.tsx` + +- 세부 타입 선택 UI 추가 +- 입력 타입 표시 +- 세부 타입 목록 동적 생성 + +--- + +## 🎨 UI 레이아웃 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 속성 편집 (PropertiesPanel) │ +├─────────────────────────────────────────────────────────────┤ +│ 입력 타입: [텍스트 ▼] ← 8개 중 선택 │ +│ 세부 타입은 "상세 설정"에서 선택하세요 │ +└─────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────┐ +│ 상세 설정 (DetailSettingsPanel) │ +├─────────────────────────────────────────────────────────────┤ +│ 입력 타입: [text] │ +├─────────────────────────────────────────────────────────────┤ +│ 세부 타입 선택: │ +│ [일반 텍스트 ▼] ← 입력 타입에 따라 동적으로 변경 │ +│ - 일반 텍스트 │ +│ - 이메일 │ +│ - 전화번호 │ +│ - URL │ +│ - 여러 줄 텍스트 │ +│ - 비밀번호 │ +├─────────────────────────────────────────────────────────────┤ +│ [세부 설정 영역] │ +│ (선택한 세부 타입에 맞는 설정 패널) │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 🔄 데이터 흐름 + +### 1. 새 컴포넌트 생성 시 + +``` +테이블 컬럼 드래그 + → 컬럼의 dataType 분석 + → 입력 타입 자동 선택 (text, number, date 등) + → 기본 세부 타입 자동 설정 (text, number, date 등) +``` + +### 2. 입력 타입 변경 시 + +``` +PropertiesPanel에서 입력 타입 선택 + → 해당 입력 타입의 기본 세부 타입 설정 + → DetailSettingsPanel 세부 타입 목록 업데이트 +``` + +### 3. 세부 타입 변경 시 + +``` +DetailSettingsPanel에서 세부 타입 선택 + → widgetType 업데이트 + → 해당 세부 타입의 설정 패널 표시 +``` + +--- + +## 🚀 확장 가능성 + +### 세부 타입 추가 + +새로운 세부 타입을 추가하려면: + +1. `frontend/types/input-type-mapping.ts`의 `INPUT_TYPE_DETAIL_TYPES`에 추가 +2. 해당 세부 타입의 설정 패널 구현 +3. DB의 `web_types` 테이블에 레코드 추가 + +### 입력 타입 추가 + +새로운 입력 타입을 추가하려면: + +1. `BaseInputType` 타입에 추가 +2. `BASE_INPUT_TYPE_OPTIONS`에 옵션 추가 +3. `INPUT_TYPE_DETAIL_TYPES`에 세부 타입 목록 정의 +4. 테이블 타입 관리 시스템 업데이트 + +--- + +## ✅ 체크리스트 + +- [x] 8개 핵심 입력 타입 정의 +- [x] 입력 타입별 세부 타입 매핑 +- [x] PropertiesPanel에 입력 타입 선택 UI 추가 +- [x] DetailSettingsPanel에 세부 타입 선택 UI 추가 +- [x] 입력 타입 변경 시 기본 세부 타입 자동 설정 +- [x] 세부 타입 변경 시 widgetType 업데이트 +- [x] 타입 안전성 보장 (TypeScript) + +--- + +## 📝 사용 예시 + +### 텍스트 입력 필드 생성 + +1. **PropertiesPanel**에서 입력 타입을 "텍스트"로 선택 +2. **DetailSettingsPanel**로 이동 +3. 세부 타입에서 "이메일" 선택 +4. 이메일 형식 검증 등 세부 설정 입력 + +### 날짜 입력 필드 생성 + +1. **PropertiesPanel**에서 입력 타입을 "날짜"로 선택 +2. **DetailSettingsPanel**로 이동 +3. 세부 타입에서 "날짜+시간" 선택 +4. 날짜 형식, 최소/최대 날짜 등 설정 + +--- + +## 🐛 문제 해결 + +### 세부 타입이 표시되지 않음 + +- 입력 타입이 올바르게 설정되었는지 확인 +- `getDetailTypes()` 함수가 올바른 값을 반환하는지 확인 + +### 입력 타입 변경 시 세부 타입이 초기화되지 않음 + +- `getDefaultDetailType()` 함수 확인 +- `onUpdateProperty("widgetType", ...)` 호출 확인 + +--- + +## 📚 참고 자료 + +- [테이블 타입 관리 개선 계획서](../테이블_타입_관리_개선_계획서.md) +- [테이블 타입 관리 개선 사용 가이드](../테이블_타입_관리_개선_사용_가이드.md) +- [화면관리 시스템 개요](./screen-management-system.md) diff --git a/docs/그리드_컬럼수_옵션_통합.md b/docs/그리드_컬럼수_옵션_통합.md new file mode 100644 index 00000000..34b1ba41 --- /dev/null +++ b/docs/그리드_컬럼수_옵션_통합.md @@ -0,0 +1,223 @@ +# 그리드 컬럼 수 옵션 통합 + +## 개요 + +"그리드 컬럼 수" 옵션과 "컴포넌트 너비" 옵션이 중복된 기능을 제공하여 혼란을 야기했습니다. +사용자 편의성을 위해 **"컴포넌트 너비" 옵션만 사용**하도록 통합하고, 내부적으로 `gridColumns` 값을 자동 계산하도록 변경했습니다. + +## 문제점 + +### 기존 상황 + +1. **그리드 컬럼 수 옵션**: 1-12 숫자 입력 +2. **컴포넌트 너비 옵션**: 1/12 ~ 12/12 선택 (퍼센트로 변환) + +→ 같은 기능을 두 가지 방식으로 제공하여 사용자 혼란 발생 + +### 예시 + +- 사용자가 "그리드 컬럼 수"를 6으로 설정 +- 하지만 "컴포넌트 너비"가 1/4 (3컬럼)로 설정되어 있음 +- 두 설정이 충돌하여 예상과 다른 결과 발생 + +## 해결 방법 + +### 1. UI 단순화 + +**제거된 옵션**: + +- ❌ PropertiesPanel의 "그리드 컬럼 수 (1-12)" 입력 필드 +- ❌ DataTableConfigPanel의 "그리드 컬럼 수" 선택 상자 + +**유지된 옵션**: + +- ✅ PropertiesPanel의 "컴포넌트 너비" 선택 상자 (1/12 ~ 12/12) + +### 2. 자동 계산 로직 + +컴포넌트 너비 선택 시 `gridColumns` 자동 계산: + +```typescript +// PropertiesPanel.tsx (764-788줄) +const columnsMap: Record = { + twelfth: 1, // 1/12 + small: 2, // 2/12 + quarter: 3, // 3/12 (1/4) + third: 4, // 4/12 (1/3) + "five-twelfths": 5, // 5/12 + half: 6, // 6/12 (절반) + "seven-twelfths": 7, // 7/12 + twoThirds: 8, // 8/12 (2/3) + threeQuarters: 9, // 9/12 (3/4) + "five-sixths": 10, // 10/12 + "eleven-twelfths": 11, // 11/12 + full: 12, // 12/12 (전체) +}; + +// 컴포넌트 너비 변경 시 +onUpdateProperty("style.width", newWidth); // 퍼센트 값 저장 +const gridColumns = columnsMap[value] || 6; +onUpdateProperty("gridColumns", gridColumns); // 컬럼 수 자동 계산 +``` + +### 3. 컴포넌트 생성 시 동작 + +```typescript +// ScreenDesigner.tsx (1756-1772줄) +// 일반 컴포넌트: defaultSize.width를 기준으로 그리드 컬럼 수 계산 +if (layout.gridSettings?.snapToGrid && gridInfo) { + const columnWidth = gridInfo.columnWidth + gridInfo.gap; + const estimatedColumns = Math.round( + component.defaultSize.width / columnWidth + ); + gridColumns = Math.max(1, Math.min(12, estimatedColumns)); // 1-12 범위 +} +``` + +## 변경 사항 + +### 파일 수정 + +#### 1. PropertiesPanel.tsx + +- ❌ 삭제: "그리드 컬럼 수" 입력 필드 (916-940줄) +- ❌ 삭제: `localInputs.gridColumns` 상태 (206-213줄) +- ✅ 추가: 컴포넌트 너비 변경 시 `gridColumns` 자동 계산 (764-788줄) + +#### 2. DataTableConfigPanel.tsx + +- ❌ 삭제: "그리드 컬럼 수" 선택 상자 (1437-1456줄) +- ❌ 삭제: `localValues.gridColumns` 초기화 (72줄, 182줄) + +#### 3. ScreenDesigner.tsx + +- ✅ 개선: 컴포넌트 드롭 시 `defaultSize.width` 기반으로 `gridColumns` 자동 계산 (1756-1772줄) + +## 사용 방법 + +### 컴포넌트 너비 조정 + +#### 방법 1: 드롭다운 선택 + +1. 컴포넌트 선택 +2. 속성 패널 > "컴포넌트 너비" 드롭다운 +3. 원하는 너비 선택 (예: "절반 (6/12)") +4. 자동으로 `style.width`와 `gridColumns` 모두 업데이트됨 + +#### 방법 2: 컴포넌트 생성 시 + +1. 컴포넌트 팔레트에서 드래그 +2. 캔버스에 드롭 +3. `defaultSize.width`를 기준으로 적절한 `gridColumns` 자동 설정 + +### 너비 옵션 설명 + +| 옵션 | 컬럼 수 | 퍼센트 | 설명 | +| ------------ | ------- | ------ | ----------- | +| 1/12 | 1 | 8.33% | 최소 | +| 작게 (2/12) | 2 | 16.67% | 매우 작음 | +| 1/4 (3/12) | 3 | 25% | 4등분의 1 | +| 1/3 (4/12) | 4 | 33.33% | 3등분의 1 | +| 5/12 | 5 | 41.67% | | +| 절반 (6/12) | 6 | 50% | 정확히 절반 | +| 7/12 | 7 | 58.33% | | +| 2/3 (8/12) | 8 | 66.67% | 3등분의 2 | +| 3/4 (9/12) | 9 | 75% | 4등분의 3 | +| 10/12 | 10 | 83.33% | | +| 11/12 | 11 | 91.67% | | +| 전체 (12/12) | 12 | 100% | 최대 | + +## 적용 효과 + +### 1. 사용자 경험 개선 + +- ✅ 단일 옵션으로 간소화 +- ✅ 직관적인 분수 표현 (1/4, 절반, 2/3 등) +- ✅ 설정 충돌 제거 + +### 2. 일관성 보장 + +- ✅ 컴포넌트 너비와 gridColumns 항상 동기화 +- ✅ 그리드 시스템과 자연스러운 통합 + +### 3. 개발자 편의 + +- ✅ 내부적으로 gridColumns는 여전히 사용 가능 +- ✅ 기존 데이터 호환성 유지 (gridColumns 필드 존재) + +## 내부 동작 + +### gridColumns 사용처 + +`gridColumns` 값은 사용자에게 직접 노출되지 않지만, 내부적으로 여전히 중요한 역할을 합니다: + +1. **그리드 레이아웃 계산**: 컴포넌트가 차지할 그리드 셀 수 결정 +2. **자동 배치**: 컴포넌트 자동 정렬 시 참조 +3. **반응형 조정**: 화면 크기 변경 시 비율 유지 + +### 값 동기화 흐름 + +``` +사용자 선택: "절반 (6/12)" + ↓ +1. style.width = "50%" 저장 + ↓ +2. gridColumns = 6 자동 계산 + ↓ +3. 그리드 시스템에서 6컬럼 너비로 렌더링 + ↓ +4. 실제 픽셀 너비 계산 및 적용 +``` + +## 마이그레이션 가이드 + +### 기존 화면 데이터 + +- **영향 없음**: 기존에 저장된 `gridColumns` 값은 그대로 유지 +- **자동 변환**: 컴포넌트 편집 시 `style.width`로부터 재계산 + +### 사용자 교육 + +1. "그리드 컬럼 수" 설정이 제거되었음을 안내 +2. "컴포넌트 너비"로 동일한 기능 사용 가능 +3. 더 직관적인 분수 표현 (1/4, 1/2 등) 강조 + +## 테스트 체크리스트 + +### UI 확인 + +- [ ] PropertiesPanel에 "그리드 컬럼 수" 입력 필드가 없는지 확인 +- [ ] DataTableConfigPanel에 "그리드 컬럼 수" 선택 상자가 없는지 확인 +- [ ] "컴포넌트 너비" 드롭다운이 정상 작동하는지 확인 + +### 기능 확인 + +- [ ] 컴포넌트 너비 변경 시 시각적으로 제대로 반영되는지 확인 +- [ ] 새 컴포넌트 생성 시 적절한 초기 너비로 생성되는지 확인 +- [ ] 그리드 ON/OFF 시 너비가 올바르게 적용되는지 확인 + +### 데이터 확인 + +- [ ] 컴포넌트 너비 변경 후 저장/불러오기 테스트 +- [ ] 기존 화면 데이터가 정상적으로 로드되는지 확인 +- [ ] `gridColumns` 값이 자동으로 계산되는지 확인 + +## 관련 파일 + +### 수정된 파일 + +- `/frontend/components/screen/panels/PropertiesPanel.tsx` +- `/frontend/components/screen/panels/DataTableConfigPanel.tsx` +- `/frontend/components/screen/ScreenDesigner.tsx` + +### 관련 문서 + +- [컴포넌트*기본*너비*설정*가이드.md](./컴포넌트_기본_너비_설정_가이드.md) + +## 버전 히스토리 + +### v1.0.0 (2025-10-14) + +- "그리드 컬럼 수" 옵션 제거 +- "컴포넌트 너비" 옵션으로 통합 +- `gridColumns` 자동 계산 로직 추가 diff --git a/docs/컴포넌트_기본_너비_설정_가이드.md b/docs/컴포넌트_기본_너비_설정_가이드.md new file mode 100644 index 00000000..94c36736 --- /dev/null +++ b/docs/컴포넌트_기본_너비_설정_가이드.md @@ -0,0 +1,225 @@ +# 컴포넌트 기본 너비 설정 가이드 + +## 개요 + +화면 관리에서 각 컴포넌트 타입별로 적절한 기본 너비를 설정하고, 컴포넌트가 지정된 너비를 벗어나지 않도록 스타일을 적용했습니다. + +## 변경 사항 + +### 1. 인풋 컴포넌트 기본 너비 조정 + +각 인풋 타입별로 적절한 기본 크기를 설정했습니다: + +#### 텍스트 입력 계열 + +- **텍스트 입력** (`text-input`): 300px × 40px +- **숫자 입력** (`number-input`): 200px × 40px +- **텍스트 영역** (`textarea-basic`): 400px × 100px + +#### 선택 입력 계열 + +- **선택상자** (`select-basic`): 250px × 40px +- **날짜 선택** (`date-input`): 220px × 40px +- **체크박스** (`checkbox-basic`): 150px × 32px +- **라디오 버튼** (`radio-basic`): 150px × 32px +- **슬라이더** (`slider-basic`): 250px × 40px +- **토글 스위치** (`toggle-switch`): 180px × 40px + +#### 파일 및 기타 + +- **파일 업로드** (`file-upload`): 350px × 40px + +#### 표시 컴포넌트 + +- **기본 버튼** (`button-primary`): 120px × 40px +- **텍스트 표시** (`text-display`): 150px × 24px +- **이미지 표시** (`image-display`): 200px × 200px +- **구분선** (`divider-line`): 400px × 2px +- **아코디언** (`accordion-basic`): 400px × 200px + +#### 데이터 컴포넌트 + +- **테이블 리스트** (`table-list`): 120px × 600px +- **카드 표시** (`card-display`): 기존 유지 + +### 2. 공통 스타일 적용 + +`/frontend/lib/registry/components/common/inputStyles.ts` 파일의 모든 스타일 클래스에 다음을 추가: + +- `max-w-full`: 최대 너비를 부모 컨테이너로 제한 +- `overflow-hidden`: 내용이 넘칠 경우 숨김 처리 + +적용된 클래스: + +- `INPUT_CLASSES.base` +- `INPUT_CLASSES.container` +- `INPUT_CLASSES.textarea` +- `INPUT_CLASSES.select` +- `INPUT_CLASSES.flexContainer` + +### 3. 개별 컴포넌트 스타일 적용 + +#### TextInputComponent + +- 컨테이너 div: `max-w-full overflow-hidden` 추가 +- input 요소: `max-w-full` 추가 +- textarea 요소: `max-w-full` 추가 + +#### RealtimePreviewDynamic + +- 컴포넌트 렌더링 컨테이너: `max-w-full overflow-hidden` 추가 + +## 적용 효과 + +### 1. 일관된 초기 크기 + +- 컴포넌트 드래그 앤 드롭 시 각 타입별로 적절한 기본 크기로 생성됨 +- 사용자가 별도로 크기를 조정할 필요 없이 바로 사용 가능 + +### 2. 그리드 시스템과의 통합 + +- **그리드 활성화 시**: `defaultSize.width`를 기준으로 적절한 그리드 컬럼 수 자동 계산 + - 예: 300px 너비 → 약 3-4 컬럼 (그리드 설정에 따라 다름) + - 계산된 컬럼 수에 맞춰 정확한 너비로 재조정 +- **그리드 비활성화 시**: `defaultSize`의 픽셀 값을 그대로 사용 +- 일관된 사용자 경험 제공 + +### 3. 너비 제한 + +- 컴포넌트가 설정된 너비를 벗어나지 않음 +- 부모 컨테이너 크기에 맞춰 자동으로 조정됨 +- 레이아웃 깨짐 방지 + +### 4. 반응형 대응 + +- `max-w-full` 속성으로 부모 컨테이너에 맞춰 자동 축소 +- `overflow-hidden`으로 내용 넘침 방지 + +## 사용 방법 + +### 새 컴포넌트 생성 시 + +1. 컴포넌트 팔레트에서 원하는 타입 선택 +2. 캔버스에 드래그 앤 드롭 +3. 자동으로 적절한 기본 크기로 생성됨 + +### 크기 조정 + +1. 컴포넌트 선택 +2. 속성 패널에서 "컴포넌트 너비" 선택 +3. 드롭다운에서 원하는 너비 선택 (1/12 ~ 12/12) +4. 또는 직접 픽셀 값 입력 + +## 주의 사항 + +### 기존 화면에 미치는 영향 + +- 이미 생성된 컴포넌트는 영향 받지 않음 +- 새로 생성되는 컴포넌트만 새로운 기본값 적용 + +### 스타일 우선순위 + +1. 인라인 style 속성 +2. componentConfig에서 설정한 크기 +3. defaultSize (새로 적용된 기본값) + +### 커스터마이징 + +- 각 컴포넌트의 `index.ts` 파일에서 `defaultSize` 수정 가능 +- 프로젝트 요구사항에 맞춰 조정 가능 + +## 테스트 방법 + +### 기본 크기 테스트 + +``` +1. 화면 디자이너 열기 +2. 각 인풋 타입 컴포넌트를 캔버스에 드롭 +3. 기본 크기가 적절한지 확인 +4. 여러 컴포넌트를 나란히 배치하여 일관성 확인 +``` + +### 너비 제한 테스트 + +``` +1. 컴포넌트 생성 후 선택 +2. 속성 패널에서 너비를 작은 값으로 설정 (예: 100px) +3. 컴포넌트 내부의 input이 너비를 벗어나지 않는지 확인 +4. 긴 텍스트 입력 시 overflow 처리 확인 +``` + +### 반응형 테스트 + +``` +1. 레이아웃 컨테이너 내부에 컴포넌트 배치 +2. 레이아웃 크기를 조정하여 컴포넌트가 적절히 축소되는지 확인 +3. 다양한 화면 해상도에서 테스트 +``` + +## 관련 파일 + +### 컴포넌트 정의 파일 + +- `/frontend/lib/registry/components/text-input/index.ts` +- `/frontend/lib/registry/components/number-input/index.ts` +- `/frontend/lib/registry/components/select-basic/index.ts` +- `/frontend/lib/registry/components/date-input/index.ts` +- `/frontend/lib/registry/components/textarea-basic/index.ts` +- `/frontend/lib/registry/components/checkbox-basic/index.ts` +- `/frontend/lib/registry/components/radio-basic/index.ts` +- `/frontend/lib/registry/components/file-upload/index.ts` +- `/frontend/lib/registry/components/slider-basic/index.ts` +- `/frontend/lib/registry/components/toggle-switch/index.ts` +- `/frontend/lib/registry/components/button-primary/index.ts` +- `/frontend/lib/registry/components/text-display/index.ts` +- `/frontend/lib/registry/components/image-display/index.ts` +- `/frontend/lib/registry/components/divider-line/index.ts` +- `/frontend/lib/registry/components/accordion-basic/index.ts` +- `/frontend/lib/registry/components/table-list/index.ts` + +### 공통 스타일 파일 + +- `/frontend/lib/registry/components/common/inputStyles.ts` + +### 렌더링 관련 파일 + +- `/frontend/components/screen/RealtimePreviewDynamic.tsx` +- `/frontend/lib/registry/components/text-input/TextInputComponent.tsx` + +### 화면 디자이너 + +- `/frontend/components/screen/ScreenDesigner.tsx` + - `handleComponentDrop` 함수 (1751-1800줄): 컴포넌트 드롭 시 그리드 컬럼 수 자동 계산 + - 그리드 활성화 시: `defaultSize.width` 기반으로 gridColumns 계산 후 너비 재조정 + - 그리드 비활성화 시: `defaultSize` 그대로 사용 + +## 향후 개선 사항 + +### 1. 반응형 기본값 + +- 화면 크기에 따라 다른 기본값 적용 +- 모바일, 태블릿, 데스크톱 각각 최적화 + +### 2. 사용자 정의 기본값 + +- 사용자가 자주 사용하는 크기를 기본값으로 저장 +- 프로젝트별 기본값 설정 기능 + +### 3. 스마트 크기 조정 + +- 주변 컴포넌트에 맞춰 자동으로 크기 조정 +- 레이블 길이에 따른 동적 너비 계산 + +### 4. 프리셋 제공 + +- 폼 레이아웃 프리셋 (라벨-입력 쌍) +- 검색 바 프리셋 +- 로그인 폼 프리셋 + +## 버전 히스토리 + +### v1.0.0 (2025-10-14) + +- 초기 기본 너비 설정 적용 +- 공통 스타일에 max-w-full, overflow-hidden 추가 +- 모든 인풋 컴포넌트 기본 크기 조정 diff --git a/docs/테이블_패널_컴포넌트_기본_너비_설정.md b/docs/테이블_패널_컴포넌트_기본_너비_설정.md new file mode 100644 index 00000000..2b3da3dd --- /dev/null +++ b/docs/테이블_패널_컴포넌트_기본_너비_설정.md @@ -0,0 +1,322 @@ +# 테이블 패널 컴포넌트 기본 너비 설정 + +## 개요 + +테이블 패널에서 컬럼과 필터를 드래그 드롭으로 추가할 때, 각 웹타입별로 적절한 기본 너비(gridColumns)가 자동으로 설정되도록 개선했습니다. + +## 문제점 + +### 기존 방식 + +- **모든 컬럼**: `gridColumns: 2` (2/12, 16.67%) 고정 +- **모든 필터**: `gridColumns: 3` (3/12, 25%) 고정 +- 웹타입에 관계없이 동일한 너비 적용 +- 긴 텍스트 입력이나 짧은 숫자 입력 모두 같은 크기 + +### 문제 사례 + +``` +❌ text (긴 텍스트) → 2컬럼 (너무 좁음) +❌ textarea (여러 줄) → 2컬럼 (너무 좁음) +❌ checkbox (체크박스) → 2컬럼 (너무 넓음) +``` + +## 해결 방법 + +### 웹타입별 기본 너비 함수 추가 + +```typescript +// DataTableConfigPanel.tsx (891-929줄) +const getDefaultGridColumns = (webType: WebType): number => { + const widthMap: Record = { + // 텍스트 입력 계열 (넓게) + text: 4, // 1/3 (33%) + email: 4, // 1/3 (33%) + tel: 3, // 1/4 (25%) + url: 4, // 1/3 (33%) + textarea: 6, // 절반 (50%) + + // 숫자/날짜 입력 (중간) + number: 2, // 2/12 (16.67%) + decimal: 2, // 2/12 (16.67%) + date: 3, // 1/4 (25%) + datetime: 3, // 1/4 (25%) + time: 2, // 2/12 (16.67%) + + // 선택 입력 (중간) + select: 3, // 1/4 (25%) + radio: 3, // 1/4 (25%) + checkbox: 2, // 2/12 (16.67%) + boolean: 2, // 2/12 (16.67%) + + // 코드/참조 (넓게) + code: 3, // 1/4 (25%) + entity: 4, // 1/3 (33%) + + // 파일/이미지 (넓게) + file: 4, // 1/3 (33%) + image: 3, // 1/4 (25%) + + // 기타 + button: 2, // 2/12 (16.67%) + label: 2, // 2/12 (16.67%) + }; + + return widthMap[webType] || 3; // 기본값 3 (1/4, 25%) +}; +``` + +## 적용된 함수 + +### 1. addColumn (컬럼 추가) + +```typescript +// Before +const newColumn: DataTableColumn = { + // ... + gridColumns: 2, // ❌ 모든 타입에 2 고정 + // ... +}; + +// After +const newColumn: DataTableColumn = { + // ... + gridColumns: getDefaultGridColumns(widgetType), // ✅ 웹타입별 자동 계산 + // ... +}; +``` + +### 2. addFilter (필터 추가) + +```typescript +// Before +const newFilter: DataTableFilter = { + // ... + gridColumns: 3, // ❌ 모든 타입에 3 고정 + // ... +}; + +// After +const newFilter: DataTableFilter = { + // ... + gridColumns: getDefaultGridColumns(widgetType), // ✅ 웹타입별 자동 계산 + // ... +}; +``` + +### 3. addVirtualFileColumn (가상 파일 컬럼 추가) + +```typescript +// Before +const newColumn: DataTableColumn = { + // ... + widgetType: "file", + gridColumns: 2, // ❌ 파일 타입에 2 고정 + // ... +}; + +// After +const newColumn: DataTableColumn = { + // ... + widgetType: "file", + gridColumns: getDefaultGridColumns("file"), // ✅ 파일 타입 기본값 (4컬럼, 33%) + // ... +}; +``` + +## 웹타입별 기본 너비 상세 + +### 텍스트 입력 계열 (넓게 설정) + +| 웹타입 | 컬럼 수 | 퍼센트 | 설명 | +| -------- | ------- | ------ | -------------------------- | +| text | 4 | 33% | 일반 텍스트 입력 | +| email | 4 | 33% | 이메일 주소 (길이 필요) | +| tel | 3 | 25% | 전화번호 (중간 길이) | +| url | 4 | 33% | URL 주소 (길이 필요) | +| textarea | 6 | 50% | 여러 줄 텍스트 (가장 넓게) | + +### 숫자/날짜 입력 (중간 설정) + +| 웹타입 | 컬럼 수 | 퍼센트 | 설명 | +| -------- | ------- | ------ | -------------- | +| number | 2 | 16.67% | 정수 입력 | +| decimal | 2 | 16.67% | 소수 입력 | +| date | 3 | 25% | 날짜 선택 | +| datetime | 3 | 25% | 날짜+시간 선택 | +| time | 2 | 16.67% | 시간 선택 | + +### 선택 입력 (중간 설정) + +| 웹타입 | 컬럼 수 | 퍼센트 | 설명 | +| -------- | ------- | ------ | --------------- | +| select | 3 | 25% | 드롭다운 선택 | +| radio | 3 | 25% | 라디오 버튼 | +| checkbox | 2 | 16.67% | 체크박스 (작게) | +| boolean | 2 | 16.67% | 참/거짓 (작게) | + +### 코드/참조 (넓게 설정) + +| 웹타입 | 컬럼 수 | 퍼센트 | 설명 | +| ------ | ------- | ------ | ----------------------- | +| code | 3 | 25% | 코드 선택 | +| entity | 4 | 33% | 엔티티 참조 (길이 필요) | + +### 파일/이미지 (넓게 설정) + +| 웹타입 | 컬럼 수 | 퍼센트 | 설명 | +| ------ | ------- | ------ | ------------- | +| file | 4 | 33% | 파일 업로드 | +| image | 3 | 25% | 이미지 업로드 | + +### 기타 + +| 웹타입 | 컬럼 수 | 퍼센트 | 설명 | +| ---------- | ------- | ------ | ------------------ | +| button | 2 | 16.67% | 버튼 | +| label | 2 | 16.67% | 라벨 | +| **기본값** | 3 | 25% | 정의되지 않은 타입 | + +## 적용 효과 + +### Before (기존) + +``` +[컬럼 추가] +- 이름 (text) → 2컬럼 → 너무 좁음 😞 +- 설명 (textarea) → 2컬럼 → 너무 좁음 😞 +- 나이 (number) → 2컬럼 → 적절함 😐 +- 활성화 (checkbox) → 2컬럼 → 너무 넓음 😞 + +[필터 추가] +- 검색어 (text) → 3컬럼 → 약간 좁음 😐 +- 날짜 (date) → 3컬럼 → 적절함 😐 +- 승인 (boolean) → 3컬럼 → 너무 넓음 😞 +``` + +### After (개선) + +``` +[컬럼 추가] +- 이름 (text) → 4컬럼 (33%) → 적절함 ✅ +- 설명 (textarea) → 6컬럼 (50%) → 충분함 ✅ +- 나이 (number) → 2컬럼 (16.67%) → 적절함 ✅ +- 활성화 (checkbox) → 2컬럼 (16.67%) → 적절함 ✅ + +[필터 추가] +- 검색어 (text) → 4컬럼 (33%) → 충분함 ✅ +- 날짜 (date) → 3컬럼 (25%) → 적절함 ✅ +- 승인 (boolean) → 2컬럼 (16.67%) → 적절함 ✅ +``` + +## 사용 방법 + +### 1. 컬럼 추가 + +1. 테이블 선택 +2. "컬럼 추가" 버튼 클릭 또는 드롭다운에서 컬럼 선택 +3. 웹타입에 맞는 기본 너비로 자동 생성됨 +4. 필요시 속성 패널에서 너비 조정 가능 + +### 2. 필터 추가 + +1. 테이블 선택 +2. "필터 추가" 버튼 클릭 +3. 웹타입에 맞는 기본 너비로 자동 생성됨 +4. 필요시 컬럼별 너비 조정 가능 + +### 3. 가상 파일 컬럼 추가 + +1. "파일 컬럼" 버튼 클릭 +2. 파일 타입에 맞는 기본 너비(4컬럼, 33%)로 생성됨 + +### 4. 너비 조정 (수동) + +**컬럼 너비 조정**: + +- 컬럼 설정 탭에서 각 컬럼별 "컬럼 너비" 드롭다운 선택 +- 1/12 (8.33%)부터 12/12 (100%)까지 선택 가능 +- 기본값은 웹타입에 따라 자동 설정됨 + +**필터 너비 조정**: + +- 필터 설정 탭에서 각 필터별 "필터 너비" 드롭다운 선택 +- 1/12 (8.33%)부터 12/12 (100%)까지 선택 가능 +- 기본값은 웹타입에 따라 자동 설정됨 + +## 주의 사항 + +### 기존 데이터 + +- **영향 없음**: 이미 생성된 컬럼/필터는 변경되지 않음 +- **새로 추가되는 항목만** 새로운 기본값 적용 + +### 커스터마이징 + +- 기본값이 맞지 않으면 수동으로 조정 가능 +- 자주 사용하는 너비가 있다면 `getDefaultGridColumns` 함수 수정 가능 + +### 레이아웃 고려 + +- 한 행에 총 12컬럼까지 배치 가능 +- 예: 4컬럼 + 4컬럼 + 4컬럼 = 12컬럼 (딱 맞음) +- 예: 4컬럼 + 4컬럼 + 6컬럼 = 14컬럼 (넘침 → 다음 줄로 이동) + +## 테스트 체크리스트 + +### 컬럼 추가 테스트 + +- [ ] text 타입 컬럼 추가 → 4컬럼(33%) 확인 +- [ ] number 타입 컬럼 추가 → 2컬럼(16.67%) 확인 +- [ ] textarea 타입 컬럼 추가 → 6컬럼(50%) 확인 +- [ ] select 타입 컬럼 추가 → 3컬럼(25%) 확인 +- [ ] checkbox 타입 컬럼 추가 → 2컬럼(16.67%) 확인 + +### 필터 추가 테스트 + +- [ ] text 타입 필터 추가 → 4컬럼(33%) 확인 +- [ ] date 타입 필터 추가 → 3컬럼(25%) 확인 +- [ ] boolean 타입 필터 추가 → 2컬럼(16.67%) 확인 + +### 가상 파일 컬럼 테스트 + +- [ ] 파일 컬럼 추가 → 4컬럼(33%) 확인 + +### 수동 조정 테스트 + +- [ ] 생성 후 너비 수동 변경 가능한지 확인 +- [ ] 변경된 너비가 저장/로드 시 유지되는지 확인 + +## 관련 파일 + +### 수정된 파일 + +#### 1. `/frontend/components/screen/panels/DataTableConfigPanel.tsx` + +- `getDefaultGridColumns` 함수 추가 (891-929줄) +- `addColumn` 함수 수정 (954줄) - 웹타입별 기본 너비 자동 계산 +- `addFilter` 함수 수정 (781줄) - 웹타입별 기본 너비 자동 계산 +- `addVirtualFileColumn` 함수 수정 (1055줄) - 파일 타입 기본 너비 적용 +- 컬럼 설정 UI 개선 (1652줄) - "그리드 컬럼" → "컬럼 너비" (1/12 ~ 12/12) +- 필터 설정 UI 개선 (2131줄) - "그리드 컬럼" → "필터 너비" (1/12 ~ 12/12) + +#### 2. `/frontend/components/screen/ScreenDesigner.tsx` + +- `getDefaultGridColumns` 함수 추가 (1946-1984줄) - 드래그 드롭 컴포넌트용 +- `getDefaultGridColumnsForTemplate` 함수 추가 (1429-1438줄) - 템플릿 컴포넌트용 +- 템플릿 컴포넌트 생성 시 기본 너비 적용 (1514줄) +- 폼 컨테이너 내 컴포넌트 생성 시 기본 너비 적용 (2151줄) +- 드래그 드롭 컴포넌트 생성 시 기본 너비 적용 (2194줄) + +### 관련 문서 + +- [컴포넌트*기본*너비*설정*가이드.md](./컴포넌트_기본_너비_설정_가이드.md) +- [그리드*컬럼수*옵션\_통합.md](./그리드_컬럼수_옵션_통합.md) + +## 버전 히스토리 + +### v1.0.0 (2025-10-14) + +- 웹타입별 기본 너비 자동 설정 기능 추가 +- `getDefaultGridColumns` 함수 구현 +- `addColumn`, `addFilter`, `addVirtualFileColumn` 함수에 적용 diff --git a/frontend/app/(main)/admin/screenMng/page.tsx b/frontend/app/(main)/admin/screenMng/page.tsx index 54da701b..ae622128 100644 --- a/frontend/app/(main)/admin/screenMng/page.tsx +++ b/frontend/app/(main)/admin/screenMng/page.tsx @@ -17,6 +17,9 @@ export default function ScreenManagementPage() { const [selectedScreen, setSelectedScreen] = useState(null); const [stepHistory, setStepHistory] = useState(["list"]); + // 화면 설계 모드일 때는 전체 화면 사용 + const isDesignMode = currentStep === "design"; + // 단계별 제목과 설명 const stepConfig = { list: { @@ -65,11 +68,20 @@ export default function ScreenManagementPage() { // 현재 단계가 마지막 단계인지 확인 const isLastStep = currentStep === "template"; + // 화면 설계 모드일 때는 레이아웃 없이 전체 화면 사용 (고정 높이) + if (isDesignMode) { + return ( +
+ goToStep("list")} /> +
+ ); + } + return (
-
+
{/* 페이지 제목 */} -
+

화면 관리

화면을 설계하고 템플릿을 관리합니다

@@ -81,40 +93,27 @@ export default function ScreenManagementPage() { {/* 화면 목록 단계 */} {currentStep === "list" && (
-
+

{stepConfig.list.title}

- { - setSelectedScreen(screen); - goToNextStep("design"); - }} - /> -
- )} - - {/* 화면 설계 단계 */} - {currentStep === "design" && ( -
-
-

{stepConfig.design.title}

- -
- goToStep("list")} /> + { + setSelectedScreen(screen); + goToNextStep("design"); + }} + />
)} {/* 템플릿 관리 단계 */} {currentStep === "template" && (
-
+

{stepConfig.template.title}

@@ -156,7 +156,7 @@ export default function ScreenViewPage() { {layout && layout.components.length > 0 ? ( // 캔버스 컴포넌트들을 정확한 해상도로 표시
{/* 그룹 제목 */} {(component as any).title && ( -
{(component as any).title}
+
+ {(component as any).title} +
)} {/* 그룹 내 자식 컴포넌트들 렌더링 */} @@ -201,8 +203,8 @@ export default function ScreenViewPage() { position: "absolute", left: `${child.position.x}px`, top: `${child.position.y}px`, - width: `${child.size.width}px`, - height: `${child.size.height}px`, + width: child.style?.width || `${child.size.width}px`, + height: child.style?.height || `${child.size.height}px`, zIndex: child.position.z || 1, }} > @@ -275,8 +277,8 @@ export default function ScreenViewPage() { position: "absolute", left: `${component.position.x}px`, top: `${component.position.y}px`, - width: `${component.size.width}px`, - height: `${component.size.height}px`, + width: component.style?.width || `${component.size.width}px`, + height: component.style?.height || `${component.size.height}px`, zIndex: component.position.z || 1, }} onMouseEnter={() => { @@ -295,7 +297,13 @@ export default function ScreenViewPage() { {/* 위젯 컴포넌트가 아닌 경우 DynamicComponentRenderer 사용 */} {component.type !== "widget" ? ( { @@ -332,10 +340,10 @@ export default function ScreenViewPage() { webType={(() => { // 유틸리티 함수로 파일 컴포넌트 감지 if (isFileComponent(component)) { - console.log(`🎯 page.tsx - 파일 컴포넌트 감지 → webType: "file"`, { + console.log('🎯 page.tsx - 파일 컴포넌트 감지 → webType: "file"', { componentId: component.id, componentType: component.type, - originalWebType: component.webType + originalWebType: component.webType, }); return "file"; } @@ -382,7 +390,7 @@ export default function ScreenViewPage() { ) : ( // 빈 화면일 때도 깔끔하게 표시
(element.chartConfig || {}); const [queryResult, setQueryResult] = useState(null); const [currentStep, setCurrentStep] = useState<1 | 2>(1); + + // 차트 설정이 필요 없는 위젯 (쿼리/API만 필요) + const isSimpleWidget = + element.subtype === "vehicle-status" || + element.subtype === "vehicle-list" || + element.subtype === "delivery-status" || + element.subtype === "driver-management"; + + // 지도 위젯 (위도/경도 매핑 필요) + const isMapWidget = element.subtype === "vehicle-map"; + // 주석 // 모달이 열릴 때 초기화 useEffect(() => { @@ -118,21 +130,33 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element const isPieChart = element.subtype === "pie" || element.subtype === "donut"; const isApiSource = dataSource.type === "api"; - const canSave = - currentStep === 2 && - queryResult && - queryResult.rows.length > 0 && - chartConfig.xAxis && - (isPieChart || isApiSource - ? // 파이/도넛 차트 또는 REST API: Y축 또는 집계 함수 필요 - chartConfig.yAxis || - (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0) || - chartConfig.aggregation === "count" - : // 일반 차트 (DB): Y축 필수 - chartConfig.yAxis || (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0)); + const canSave = isSimpleWidget + ? // 간단한 위젯: 2단계에서 쿼리 테스트 후 저장 가능 + currentStep === 2 && + queryResult && + queryResult.rows.length > 0 + : isMapWidget + ? // 지도 위젯: 위도/경도 매핑 필요 + currentStep === 2 && + queryResult && + queryResult.rows.length > 0 && + chartConfig.latitudeColumn && + chartConfig.longitudeColumn + : // 차트: 기존 로직 (2단계에서 차트 설정 필요) + currentStep === 2 && + queryResult && + queryResult.rows.length > 0 && + chartConfig.xAxis && + (isPieChart || isApiSource + ? // 파이/도넛 차트 또는 REST API: Y축 또는 집계 함수 필요 + chartConfig.yAxis || + (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0) || + chartConfig.aggregation === "count" + : // 일반 차트 (DB): Y축 필수 + chartConfig.yAxis || (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0)); return ( -
+

{element.title} 설정

- {currentStep === 1 ? "데이터 소스를 선택하세요" : "쿼리를 실행하고 차트를 설정하세요"} + {isSimpleWidget + ? "데이터 소스를 설정하세요" + : currentStep === 1 + ? "데이터 소스를 선택하세요" + : "쿼리를 실행하고 차트를 설정하세요"}

- {/* 진행 상황 표시 */} -
-
-
- 단계 {currentStep} / 2: {currentStep === 1 ? "데이터 소스 선택" : "데이터 설정 및 차트 설정"} + {/* 진행 상황 표시 - 간단한 위젯은 표시 안 함 */} + {!isSimpleWidget && ( +
+
+
+ 단계 {currentStep} / 2: {currentStep === 1 ? "데이터 소스 선택" : "데이터 설정 및 차트 설정"} +
+ {Math.round((currentStep / 2) * 100)}% 완료
- {Math.round((currentStep / 2) * 100)}% 완료 +
- -
+ )} {/* 단계별 내용 */}
@@ -169,7 +199,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element )} {currentStep === 2 && ( -
+
{/* 왼쪽: 데이터 설정 */}
{dataSource.type === "database" ? ( @@ -186,24 +216,44 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element )}
- {/* 오른쪽: 차트 설정 */} -
- {queryResult && queryResult.rows.length > 0 ? ( - - ) : ( -
-
-
데이터를 가져온 후 차트 설정이 표시됩니다
-
-
- )} -
+ {/* 오른쪽: 설정 패널 */} + {!isSimpleWidget && ( +
+ {isMapWidget ? ( + // 지도 위젯: 위도/경도 매핑 패널 + queryResult && queryResult.rows.length > 0 ? ( + + ) : ( +
+
+
데이터를 가져온 후 지도 설정이 표시됩니다
+
+
+ ) + ) : ( + // 차트: 차트 설정 패널 + queryResult && queryResult.rows.length > 0 ? ( + + ) : ( +
+
+
데이터를 가져온 후 차트 설정이 표시됩니다
+
+
+ ) + )} +
+ )}
)}
@@ -219,7 +269,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
- {currentStep > 1 && ( + {!isSimpleWidget && currentStep > 1 && ( {currentStep === 1 ? ( + // 1단계: 다음 버튼 (모든 타입 공통) ) : ( + // 2단계: 저장 버튼 (모든 타입 공통)
{/* 개선된 검증 패널 (선택적 표시) */} @@ -1817,8 +1828,8 @@ export const InteractiveScreenViewer: React.FC = ( style={{ left: `${popupComponent.position.x}px`, top: `${popupComponent.position.y}px`, - width: `${popupComponent.size.width}px`, - height: `${popupComponent.size.height}px`, + width: popupComponent.style?.width || `${popupComponent.size.width}px`, + height: popupComponent.style?.height || `${popupComponent.size.height}px`, zIndex: Math.min(popupComponent.position.z || 1, 20), // 최대 z-index 20으로 제한 }} > diff --git a/frontend/components/screen/MenuAssignmentModal.tsx b/frontend/components/screen/MenuAssignmentModal.tsx index e6685301..6e1c16cb 100644 --- a/frontend/components/screen/MenuAssignmentModal.tsx +++ b/frontend/components/screen/MenuAssignmentModal.tsx @@ -202,17 +202,16 @@ export const MenuAssignmentModal: React.FC = ({ setAssignmentSuccess(true); setAssignmentMessage(successMessage); - // 할당 완료 콜백 호출 + // 할당 완료 콜백 호출 (모달은 아직 열린 상태 유지) if (onAssignmentComplete) { onAssignmentComplete(); } - // 3초 후 자동으로 화면 목록으로 이동 + // 3초 후 자동으로 모달 닫고 화면 목록으로 이동 setTimeout(() => { + onClose(); // 모달 닫기 if (onBackToList) { onBackToList(); - } else { - onClose(); } }, 3000); } catch (error: any) { @@ -232,17 +231,16 @@ export const MenuAssignmentModal: React.FC = ({ setAssignmentSuccess(true); setAssignmentMessage(`"${screenInfo.screenName}" 화면이 저장되었습니다. 나중에 메뉴에 할당할 수 있습니다.`); - // 할당 완료 콜백 호출 + // 할당 완료 콜백 호출 (모달은 아직 열린 상태 유지) if (onAssignmentComplete) { onAssignmentComplete(); } - // 3초 후 자동으로 화면 목록으로 이동 + // 3초 후 자동으로 모달 닫고 화면 목록으로 이동 setTimeout(() => { + onClose(); // 모달 닫기 if (onBackToList) { onBackToList(); - } else { - onClose(); } }, 3000); }; diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index afb720cc..de16484a 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -90,17 +90,43 @@ export const RealtimePreviewDynamic: React.FC = ({ : {}; // 컴포넌트 기본 스타일 - 레이아웃은 항상 맨 아래 + // 너비 우선순위: style.width > size.width (픽셀값) + const getWidth = () => { + // 1순위: style.width가 있으면 우선 사용 + if (componentStyle?.width) { + return componentStyle.width; + } + + // 2순위: size.width (픽셀) + if (component.componentConfig?.type === "table-list") { + return `${Math.max(size?.width || 120, 120)}px`; + } + + return `${size?.width || 100}px`; + }; + + const getHeight = () => { + // 1순위: style.height가 있으면 우선 사용 + if (componentStyle?.height) { + return componentStyle.height; + } + + // 2순위: size.height (픽셀) + if (component.componentConfig?.type === "table-list") { + return `${Math.max(size?.height || 200, 200)}px`; + } + + return `${size?.height || 40}px`; + }; + const baseStyle = { left: `${position.x}px`, top: `${position.y}px`, - width: component.componentConfig?.type === "table-list" - ? `${Math.max(size?.width || 120, 120)}px` // table-list 디폴트를 그리드 1컬럼 크기로 축소 (120px) - : `${size?.width || 100}px`, - height: component.componentConfig?.type === "table-list" - ? `${Math.max(size?.height || 200, 200)}px` // table-list 디폴트 높이도 축소 (200px) - : `${size?.height || 36}px`, + width: getWidth(), + height: getHeight(), zIndex: component.type === "layout" ? 1 : position.z || 2, // 레이아웃은 z-index 1, 다른 컴포넌트는 2 이상 ...componentStyle, + // style.width와 style.height는 이미 getWidth/getHeight에서 처리했으므로 중복 적용됨 }; const handleClick = (e: React.MouseEvent) => { @@ -134,9 +160,11 @@ export const RealtimePreviewDynamic: React.FC = ({ onDragEnd={handleDragEnd} > {/* 동적 컴포넌트 렌더링 */} -
+
= ({ {/* 선택된 컴포넌트 정보 표시 */} {isSelected && ( -
+
{type === "widget" && (
{getWidgetIcon((component as WidgetComponent).widgetType)} diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 6548f987..9bf3d672 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -51,15 +51,16 @@ import DesignerToolbar from "./DesignerToolbar"; import TablesPanel from "./panels/TablesPanel"; import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel"; import { ComponentsPanel } from "./panels/ComponentsPanel"; -import LayoutsPanel from "./panels/LayoutsPanel"; import PropertiesPanel from "./panels/PropertiesPanel"; import DetailSettingsPanel from "./panels/DetailSettingsPanel"; import GridPanel from "./panels/GridPanel"; import ResolutionPanel from "./panels/ResolutionPanel"; import { usePanelState, PanelConfig } from "@/hooks/usePanelState"; -// 레이아웃 초기화 -import "@/lib/registry/layouts"; +// 새로운 통합 UI 컴포넌트 +import { LeftUnifiedToolbar, defaultToolbarButtons } from "./toolbar/LeftUnifiedToolbar"; +import { SlimToolbar } from "./toolbar/SlimToolbar"; +import { UnifiedPropertiesPanel } from "./panels/UnifiedPropertiesPanel"; // 컴포넌트 초기화 (새 시스템) import "@/lib/registry/components"; @@ -71,71 +72,49 @@ interface ScreenDesignerProps { onBackToList: () => void; } -// 패널 설정 +// 패널 설정 (간소화: 템플릿, 격자 제거) const panelConfigs: PanelConfig[] = [ + // 좌측 그룹: 입력/소스 { id: "tables", title: "테이블 목록", defaultPosition: "left", - defaultWidth: 380, - defaultHeight: 700, // 테이블 목록은 그대로 유지 + defaultWidth: 400, + defaultHeight: 700, shortcutKey: "t", }, { - id: "templates", - title: "템플릿", + id: "components", + title: "컴포넌트", defaultPosition: "left", - defaultWidth: 380, + defaultWidth: 400, defaultHeight: 700, - shortcutKey: "m", // template의 m - }, - { - id: "layouts", - title: "레이아웃", - defaultPosition: "left", - defaultWidth: 380, - defaultHeight: 700, - shortcutKey: "l", // layout의 l + shortcutKey: "c", }, + // 좌측 그룹: 편집/설정 { id: "properties", - title: "속성 편집", - defaultPosition: "right", - defaultWidth: 360, - defaultHeight: 400, // autoHeight 시작점 + title: "속성", + defaultPosition: "left", + defaultWidth: 400, + defaultHeight: 700, shortcutKey: "p", }, { id: "styles", - title: "스타일 편집", - defaultPosition: "right", - defaultWidth: 360, - defaultHeight: 400, // autoHeight 시작점 + title: "스타일", + defaultPosition: "left", + defaultWidth: 400, + defaultHeight: 700, shortcutKey: "s", }, - { - id: "grid", - title: "격자 설정", - defaultPosition: "right", - defaultWidth: 320, - defaultHeight: 400, // autoHeight 시작점 - shortcutKey: "r", // grid의 r로 변경 (그룹과 겹치지 않음) - }, - { - id: "detailSettings", - title: "상세 설정", - defaultPosition: "right", - defaultWidth: 400, - defaultHeight: 400, // autoHeight 시작점 - shortcutKey: "d", - }, { id: "resolution", - title: "해상도 설정", - defaultPosition: "right", - defaultWidth: 320, - defaultHeight: 400, - shortcutKey: "e", // resolution의 e + title: "해상도", + defaultPosition: "left", + defaultWidth: 400, + defaultHeight: 700, + shortcutKey: "e", }, ]; @@ -148,9 +127,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD gridSettings: { columns: 12, gap: 16, - padding: 16, + padding: 0, snapToGrid: true, - showGrid: true, + showGrid: false, // 기본값 false로 변경 gridColor: "#d1d5db", gridOpacity: 0.5, }, @@ -171,6 +150,19 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const [selectedComponent, setSelectedComponent] = useState(null); + // 컴포넌트 선택 시 속성 패널 자동 열기 + const handleComponentSelect = useCallback( + (component: ComponentData | null) => { + setSelectedComponent(component); + + // 컴포넌트가 선택되면 속성 패널 자동 열기 + if (component) { + openPanel("properties"); + } + }, + [openPanel], + ); + // 클립보드 상태 const [clipboard, setClipboard] = useState([]); @@ -195,6 +187,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD justFinishedDrag: false, // 드래그 종료 직후 클릭 방지용 }); + // Pan 모드 상태 (스페이스바 + 드래그) + const [isPanMode, setIsPanMode] = useState(false); + const [panState, setPanState] = useState({ + isPanning: false, + startX: 0, + startY: 0, + scrollLeft: 0, + scrollTop: 0, + }); + const canvasContainerRef = useRef(null); + + // Zoom 상태 + const [zoomLevel, setZoomLevel] = useState(1); // 1 = 100% + const MIN_ZOOM = 0.1; // 10% + const MAX_ZOOM = 3; // 300% + // 전역 파일 상태 변경 시 강제 리렌더링을 위한 상태 const [forceRenderTrigger, setForceRenderTrigger] = useState(0); @@ -411,7 +419,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const isLayoutComponent = targetComponent?.type === "layout"; // 레이아웃 컴포넌트의 위치가 변경되는 경우 존에 속한 컴포넌트들도 함께 이동 - let positionDelta = { x: 0, y: 0 }; + const positionDelta = { x: 0, y: 0 }; if (isLayoutComponent && (path === "position.x" || path === "position.y" || path === "position")) { const oldPosition = targetComponent.position; let newPosition = { ...oldPosition }; @@ -464,15 +472,24 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD return comp; } + // 중첩 경로를 고려한 안전한 복사 const newComp = { ...comp }; - let current: any = newComp; + // 경로를 따라 내려가면서 각 레벨을 새 객체로 복사 + let current: any = newComp; for (let i = 0; i < pathParts.length - 1; i++) { - if (!current[pathParts[i]]) { - current[pathParts[i]] = {}; + const key = pathParts[i]; + // 다음 레벨이 없거나 객체가 아니면 새 객체 생성 + if (!current[key] || typeof current[key] !== "object" || Array.isArray(current[key])) { + current[key] = {}; + } else { + // 기존 객체를 복사하여 불변성 유지 + current[key] = { ...current[key] }; } - current = current[pathParts[i]]; + current = current[key]; } + + // 최종 값 설정 current[pathParts[pathParts.length - 1]] = value; console.log("✅ 컴포넌트 업데이트 완료:", { @@ -933,14 +950,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const layoutWithDefaultGrid = { ...layoutToUse, gridSettings: { + ...layoutToUse.gridSettings, // 기존 설정 먼저 적용 columns: 12, gap: 16, - padding: 16, + padding: 0, // padding은 항상 0으로 강제 snapToGrid: true, - showGrid: true, + showGrid: false, // 기본값 false로 변경 gridColor: "#d1d5db", gridOpacity: 0.5, - ...layoutToUse.gridSettings, // 기존 설정이 있으면 덮어쓰기 }, }; @@ -972,6 +989,112 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD } }, [selectedScreen?.screenId]); + // 스페이스바 키 이벤트 처리 (Pan 모드) + 전역 마우스 이벤트 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // 입력 필드에서는 스페이스바 무시 + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + return; + } + + if (e.code === "Space") { + e.preventDefault(); // 스페이스바 기본 스크롤 동작 차단 + if (!isPanMode) { + setIsPanMode(true); + // body에 커서 스타일 추가 + document.body.style.cursor = "grab"; + } + } + }; + + const handleKeyUp = (e: KeyboardEvent) => { + if (e.code === "Space") { + e.preventDefault(); // 스페이스바 기본 스크롤 동작 차단 + setIsPanMode(false); + setPanState((prev) => ({ ...prev, isPanning: false })); + // body 커서 스타일 복원 + document.body.style.cursor = "default"; + } + }; + + const handleMouseDown = (e: MouseEvent) => { + if (isPanMode && canvasContainerRef.current) { + e.preventDefault(); + setPanState({ + isPanning: true, + startX: e.pageX, + startY: e.pageY, + scrollLeft: canvasContainerRef.current.scrollLeft, + scrollTop: canvasContainerRef.current.scrollTop, + }); + // 드래그 중 커서 변경 + document.body.style.cursor = "grabbing"; + } + }; + + const handleMouseMove = (e: MouseEvent) => { + if (isPanMode && panState.isPanning && canvasContainerRef.current) { + e.preventDefault(); + const dx = e.pageX - panState.startX; + const dy = e.pageY - panState.startY; + canvasContainerRef.current.scrollLeft = panState.scrollLeft - dx; + canvasContainerRef.current.scrollTop = panState.scrollTop - dy; + } + }; + + const handleMouseUp = () => { + if (isPanMode) { + setPanState((prev) => ({ ...prev, isPanning: false })); + // 드래그 종료 시 커서 복원 + document.body.style.cursor = "grab"; + } + }; + + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + window.addEventListener("mousedown", handleMouseDown); + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + window.removeEventListener("mousedown", handleMouseDown); + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [isPanMode, panState.isPanning, panState.startX, panState.startY, panState.scrollLeft, panState.scrollTop]); + + // 마우스 휠로 줌 제어 + useEffect(() => { + const handleWheel = (e: WheelEvent) => { + // 캔버스 컨테이너 내에서만 동작 + if (canvasContainerRef.current && canvasContainerRef.current.contains(e.target as Node)) { + // Shift 키를 누르지 않은 경우에만 줌 (Shift + 휠은 수평 스크롤용) + if (!e.shiftKey && !e.ctrlKey && !e.metaKey) { + // 기본 스크롤 동작 방지 + e.preventDefault(); + + const delta = e.deltaY; + const zoomFactor = 0.001; // 줌 속도 조절 + + setZoomLevel((prevZoom) => { + const newZoom = prevZoom - delta * zoomFactor; + return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, newZoom)); + }); + } + } + }; + + // passive: false로 설정하여 preventDefault() 가능하게 함 + canvasContainerRef.current?.addEventListener("wheel", handleWheel, { passive: false }); + + const containerRef = canvasContainerRef.current; + return () => { + containerRef?.removeEventListener("wheel", handleWheel); + }; + }, [MIN_ZOOM, MAX_ZOOM]); + // 격자 설정 업데이트 및 컴포넌트 자동 스냅 const updateGridSettings = useCallback( (newGridSettings: GridSettings) => { @@ -1118,7 +1241,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // 저장 const handleSave = useCallback(async () => { - if (!selectedScreen?.screenId) return; + if (!selectedScreen?.screenId) { + console.error("❌ 저장 실패: selectedScreen 또는 screenId가 없습니다.", selectedScreen); + toast.error("화면 정보가 없습니다."); + return; + } try { setIsSaving(true); @@ -1127,23 +1254,27 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD ...layout, screenResolution: screenResolution, }; - console.log("💾 저장할 레이아웃 데이터:", { + console.log("💾 저장 시작:", { + screenId: selectedScreen.screenId, componentsCount: layoutWithResolution.components.length, gridSettings: layoutWithResolution.gridSettings, screenResolution: layoutWithResolution.screenResolution, }); + await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); + + console.log("✅ 저장 성공! 메뉴 할당 모달 열기"); toast.success("화면이 저장되었습니다."); // 저장 성공 후 메뉴 할당 모달 열기 setShowMenuAssignmentModal(true); } catch (error) { - // console.error("저장 실패:", error); + console.error("❌ 저장 실패:", error); toast.error("저장 중 오류가 발생했습니다."); } finally { setIsSaving(false); } - }, [selectedScreen?.screenId, layout, screenResolution]); + }, [selectedScreen, layout, screenResolution]); // 템플릿 드래그 처리 const handleTemplateDrop = useCallback( @@ -1406,7 +1537,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD borderStyle: "solid", borderColor: "#e5e7eb", borderRadius: 8, - padding: 16, + padding: 0, margin: 0, shadow: "sm", ...(templateComp as any).areaStyle, @@ -1425,6 +1556,33 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // 위젯 컴포넌트 const widgetType = templateComp.widgetType || "text"; + // 웹타입별 기본 그리드 컬럼 수 계산 + const getDefaultGridColumnsForTemplate = (wType: string): number => { + const widthMap: Record = { + text: 4, + email: 4, + tel: 3, + url: 4, + textarea: 6, + number: 2, + decimal: 2, + date: 3, + datetime: 3, + time: 2, + select: 3, + radio: 3, + checkbox: 2, + boolean: 2, + code: 3, + entity: 4, + file: 4, + image: 3, + button: 2, + label: 2, + }; + return widthMap[wType] || 3; + }; + // 웹타입별 기본 설정 생성 const getDefaultWebTypeConfig = (wType: string) => { switch (wType) { @@ -1499,7 +1657,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD size: widgetSize, required: templateComp.required || false, readonly: templateComp.readonly || false, - gridColumns: 1, + gridColumns: getDefaultGridColumnsForTemplate(widgetType), webTypeConfig: getDefaultWebTypeConfig(widgetType), style: { labelDisplay: true, @@ -1746,6 +1904,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD webType: component.webType, category: component.category, defaultConfig: component.defaultConfig, + defaultSize: component.defaultSize, }); // 컴포넌트별 gridColumns 설정 및 크기 계산 @@ -1753,10 +1912,73 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const isCardDisplay = component.id === "card-display"; const isTableList = component.id === "table-list"; - // 컴포넌트별 기본 그리드 컬럼 수 설정 - const gridColumns = isCardDisplay ? 8 : isTableList ? 1 : 1; + // 컴포넌트 타입별 기본 그리드 컬럼 수 설정 + let gridColumns = 1; // 기본값 - if ((isCardDisplay || isTableList) && layout.gridSettings?.snapToGrid && gridInfo) { + // 특수 컴포넌트 + if (isCardDisplay) { + gridColumns = 8; + } else if (isTableList) { + gridColumns = 12; // 테이블은 전체 너비 + } else { + // 웹타입별 적절한 그리드 컬럼 수 설정 + const webType = component.webType; + const componentId = component.id; + + // 웹타입별 기본 컬럼 수 매핑 + const gridColumnsMap: Record = { + // 입력 컴포넌트 (INPUT 카테고리) + "text-input": 4, // 텍스트 입력 (33%) + "number-input": 2, // 숫자 입력 (16.67%) + "email-input": 4, // 이메일 입력 (33%) + "tel-input": 3, // 전화번호 입력 (25%) + "date-input": 3, // 날짜 입력 (25%) + "datetime-input": 4, // 날짜시간 입력 (33%) + "time-input": 2, // 시간 입력 (16.67%) + "textarea-basic": 6, // 텍스트 영역 (50%) + "select-basic": 3, // 셀렉트 (25%) + "checkbox-basic": 2, // 체크박스 (16.67%) + "radio-basic": 3, // 라디오 (25%) + "file-basic": 4, // 파일 (33%) + + // 표시 컴포넌트 (DISPLAY 카테고리) + "label-basic": 2, // 라벨 (16.67%) + "text-display": 3, // 텍스트 표시 (25%) + "card-display": 8, // 카드 (66.67%) + "badge-basic": 1, // 배지 (8.33%) + "alert-basic": 6, // 알림 (50%) + "divider-basic": 12, // 구분선 (100%) + + // 액션 컴포넌트 (ACTION 카테고리) + "button-basic": 1, // 버튼 (8.33%) + "button-primary": 1, // 프라이머리 버튼 (8.33%) + "button-secondary": 1, // 세컨더리 버튼 (8.33%) + "icon-button": 1, // 아이콘 버튼 (8.33%) + + // 레이아웃 컴포넌트 + "container-basic": 6, // 컨테이너 (50%) + "section-basic": 12, // 섹션 (100%) + "panel-basic": 6, // 패널 (50%) + + // 기타 + "image-basic": 4, // 이미지 (33%) + "icon-basic": 1, // 아이콘 (8.33%) + "progress-bar": 4, // 프로그레스 바 (33%) + "chart-basic": 6, // 차트 (50%) + }; + + // componentId 또는 webType으로 매핑, 없으면 기본값 3 + gridColumns = gridColumnsMap[componentId] || gridColumnsMap[webType] || 3; + + console.log("🎯 컴포넌트 타입별 gridColumns 설정:", { + componentId, + webType, + gridColumns, + }); + } + + // 그리드 시스템이 활성화된 경우 gridColumns에 맞춰 너비 재계산 + if (layout.gridSettings?.snapToGrid && gridInfo) { // gridColumns에 맞는 정확한 너비 계산 const calculatedWidth = calculateWidthFromColumns( gridColumns, @@ -1765,7 +1987,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD ); // 컴포넌트별 최소 크기 보장 - const minWidth = isTableList ? 120 : isCardDisplay ? 400 : 100; + const minWidth = isTableList ? 120 : isCardDisplay ? 400 : component.defaultSize.width; componentSize = { ...component.defaultSize, @@ -1777,11 +1999,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD gridColumns, defaultWidth: component.defaultSize.width, calculatedWidth, + finalWidth: componentSize.width, gridInfo, gridSettings: layout.gridSettings, }); } + console.log("🎨 최종 컴포넌트 크기:", { + componentId: component.id, + componentName: component.name, + defaultSize: component.defaultSize, + finalSize: componentSize, + gridColumns, + }); + const newComponent: ComponentData = { id: generateComponentId(), type: "component", // ✅ 새 컴포넌트 시스템 사용 @@ -1925,6 +2156,59 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD snapToGrid: layout.gridSettings?.snapToGrid, }); + // 웹타입별 기본 그리드 컬럼 수 계산 + const getDefaultGridColumns = (widgetType: string): number => { + const widthMap: Record = { + // 텍스트 입력 계열 (넓게) + text: 4, // 1/3 (33%) + email: 4, // 1/3 (33%) + tel: 3, // 1/4 (25%) + url: 4, // 1/3 (33%) + textarea: 6, // 절반 (50%) + + // 숫자/날짜 입력 (중간) + number: 2, // 2/12 (16.67%) + decimal: 2, // 2/12 (16.67%) + date: 3, // 1/4 (25%) + datetime: 3, // 1/4 (25%) + time: 2, // 2/12 (16.67%) + + // 선택 입력 (중간) + select: 3, // 1/4 (25%) + radio: 3, // 1/4 (25%) + checkbox: 2, // 2/12 (16.67%) + boolean: 2, // 2/12 (16.67%) + + // 코드/참조 (넓게) + code: 3, // 1/4 (25%) + entity: 4, // 1/3 (33%) + + // 파일/이미지 (넓게) + file: 4, // 1/3 (33%) + image: 3, // 1/4 (25%) + + // 기타 + button: 2, // 2/12 (16.67%) + label: 2, // 2/12 (16.67%) + }; + + const defaultColumns = widthMap[widgetType] || 3; // 기본값 3 (1/4, 25%) + console.log("🎯 [ScreenDesigner] getDefaultGridColumns:", { widgetType, defaultColumns }); + return defaultColumns; + }; + + // 웹타입별 기본 높이 계산 + const getDefaultHeight = (widgetType: string): number => { + const heightMap: Record = { + textarea: 120, // 텍스트 영역은 3줄 (40 * 3) + checkbox: 80, // 체크박스 그룹 (40 * 2) + radio: 80, // 라디오 버튼 (40 * 2) + file: 240, // 파일 업로드 (40 * 6) + }; + + return heightMap[widgetType] || 40; // 기본값 40 + }; + // 웹타입별 기본 설정 생성 const getDefaultWebTypeConfig = (widgetType: string) => { switch (widgetType) { @@ -2078,6 +2362,26 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const componentId = getComponentIdFromWebType(column.widgetType); // console.log(`🔄 폼 컨테이너 드롭: ${column.widgetType} → ${componentId}`); + // 웹타입별 적절한 gridColumns 계산 + const calculatedGridColumns = getDefaultGridColumns(column.widgetType); + + // gridColumns에 맞는 실제 너비 계산 + const componentWidth = + currentGridInfo && layout.gridSettings?.snapToGrid + ? calculateWidthFromColumns( + calculatedGridColumns, + currentGridInfo, + layout.gridSettings as GridUtilSettings, + ) + : defaultWidth; + + console.log("🎯 폼 컨테이너 컴포넌트 생성:", { + widgetType: column.widgetType, + calculatedGridColumns, + componentWidth, + defaultWidth, + }); + newComponent = { id: generateComponentId(), type: "component", // ✅ 새로운 컴포넌트 시스템 사용 @@ -2089,8 +2393,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD parentId: formContainerId, // 폼 컨테이너의 자식으로 설정 componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입 position: { x: relativeX, y: relativeY, z: 1 } as Position, - size: { width: defaultWidth, height: 40 }, - gridColumns: 1, + size: { width: componentWidth, height: getDefaultHeight(column.widgetType) }, + gridColumns: calculatedGridColumns, // 코드 타입인 경우 코드 카테고리 정보 추가 ...(column.widgetType === "code" && column.codeCategory && { @@ -2122,6 +2426,26 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const componentId = getComponentIdFromWebType(column.widgetType); // console.log(`🔄 캔버스 드롭: ${column.widgetType} → ${componentId}`); + // 웹타입별 적절한 gridColumns 계산 + const calculatedGridColumns = getDefaultGridColumns(column.widgetType); + + // gridColumns에 맞는 실제 너비 계산 + const componentWidth = + currentGridInfo && layout.gridSettings?.snapToGrid + ? calculateWidthFromColumns( + calculatedGridColumns, + currentGridInfo, + layout.gridSettings as GridUtilSettings, + ) + : defaultWidth; + + console.log("🎯 캔버스 컴포넌트 생성:", { + widgetType: column.widgetType, + calculatedGridColumns, + componentWidth, + defaultWidth, + }); + newComponent = { id: generateComponentId(), type: "component", // ✅ 새로운 컴포넌트 시스템 사용 @@ -2132,8 +2456,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD readonly: false, componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입 position: { x, y, z: 1 } as Position, - size: { width: defaultWidth, height: 40 }, - gridColumns: 1, + size: { width: componentWidth, height: getDefaultHeight(column.widgetType) }, + gridColumns: calculatedGridColumns, // 코드 타입인 경우 코드 카테고리 정보 추가 ...(column.widgetType === "code" && column.codeCategory && { @@ -2279,7 +2603,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // 다중 선택 모드 if (isGroupContainer) { // 그룹 컨테이너는 단일 선택으로 처리 - setSelectedComponent(component); + handleComponentSelect(component); setGroupState((prev) => ({ ...prev, selectedComponents: [component.id], @@ -2299,22 +2623,19 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // 마지막 선택된 컴포넌트를 selectedComponent로 설정 if (!isSelected) { // console.log("🎯 컴포넌트 선택 (다중 모드):", component.id); - setSelectedComponent(component); + handleComponentSelect(component); } } else { // 단일 선택 모드 // console.log("🎯 컴포넌트 선택 (단일 모드):", component.id); - setSelectedComponent(component); + handleComponentSelect(component); setGroupState((prev) => ({ ...prev, selectedComponents: [component.id], })); } - - // 속성 패널 자동 열기 - openPanel("properties"); }, - [openPanel, groupState.isGrouping, groupState.selectedComponents, dragState.justFinishedDrag], + [handleComponentSelect, groupState.isGrouping, groupState.selectedComponents, dragState.justFinishedDrag], ); // 컴포넌트 드래그 시작 @@ -2876,9 +3197,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD layout.gridSettings || { columns: 12, gap: 16, - padding: 16, + padding: 0, snapToGrid: true, - showGrid: true, + showGrid: false, gridColor: "#d1d5db", gridOpacity: 0.5, }, @@ -3415,11 +3736,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD } return ( -
- {/* 상단 툴바 */} - + {/* 상단 슬림 툴바 */} + { toast.info("미리보기 기능은 준비 중입니다."); }} - onTogglePanel={togglePanel} - panelStates={panelStates} canUndo={historyIndex > 0} canRedo={historyIndex < history.length - 1} isSaving={isSaving} /> + {/* 메인 컨테이너 (좌측 툴바 + 패널들 + 캔버스) */} +
+ {/* 좌측 통합 툴바 */} + - {/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - 좌우 최소화, 위아래 넉넉한 여유 */} -
- {/* 해상도 정보 표시 - 적당한 여백 */} -
-
- - {screenResolution.name} ({screenResolution.width} × {screenResolution.height}) - + {/* 열린 패널들 (좌측에서 우측으로 누적) */} + {panelStates.tables?.isOpen && ( +
+
+

테이블 목록

+ +
+
+ { + const dragData = { + type: column ? "column" : "table", + table, + column, + }; + e.dataTransfer.setData("application/json", JSON.stringify(dragData)); + }} + selectedTableName={selectedScreen.tableName} + /> +
-
+ )} - {/* 실제 작업 캔버스 (해상도 크기) - 반응형 개선 */} + {panelStates.components?.isOpen && ( +
+
+

컴포넌트

+ +
+
+ +
+
+ )} + + {panelStates.properties?.isOpen && ( +
+
+

속성

+ +
+
+ 0 ? tables[0] : undefined} + currentTableName={selectedScreen?.tableName} + dragState={dragState} + /> +
+
+ )} + + {panelStates.styles?.isOpen && ( +
+
+

스타일

+ +
+
+ {selectedComponent ? ( + { + if (selectedComponent) { + updateComponentProperty(selectedComponent.id, "style", style); + } + }} + /> + ) : ( +
+ 컴포넌트를 선택하여 스타일을 편집하세요 +
+ )} +
+
+ )} + + {panelStates.resolution?.isOpen && ( +
+
+

해상도

+ +
+
+ +
+
+ )} + + {/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - 좌우 최소화, 위아래 넉넉한 여유 */}
+ {/* Pan 모드 안내 */} + {isPanMode && ( +
+ 🖐️ Pan 모드 활성화 - 드래그하여 캔버스 이동 +
+ )} + + {/* 줌 레벨 표시 */} +
+ 🔍 {Math.round(zoomLevel * 100)}% +
+ + {/* 실제 작업 캔버스 (해상도 크기) - 반응형 개선 + 줌 적용 */}
{ - if (e.target === e.currentTarget && !selectionDrag.wasSelecting) { - setSelectedComponent(null); - setGroupState((prev) => ({ ...prev, selectedComponents: [] })); - } - }} - onMouseDown={(e) => { - if (e.target === e.currentTarget) { - startSelectionDrag(e); - } - }} - onDragOver={(e) => { - e.preventDefault(); - e.dataTransfer.dropEffect = "copy"; - }} - onDrop={(e) => { - e.preventDefault(); - // console.log("🎯 캔버스 드롭 이벤트 발생"); - handleDrop(e); + className="mx-auto bg-white shadow-lg" + style={{ + width: screenResolution.width, + height: Math.max(screenResolution.height, 800), // 최소 높이 보장 + minHeight: screenResolution.height, + transform: `scale(${zoomLevel})`, // 줌 레벨에 따라 시각적으로 확대/축소 + transformOrigin: "top center", + transition: "transform 0.1s ease-out", }} > - {/* 격자 라인 */} - {gridLines.map((line, index) => ( -
- ))} +
{ + if (e.target === e.currentTarget && !selectionDrag.wasSelecting && !isPanMode) { + setSelectedComponent(null); + setGroupState((prev) => ({ ...prev, selectedComponents: [] })); + } + }} + onMouseDown={(e) => { + // Pan 모드가 아닐 때만 다중 선택 시작 + if (e.target === e.currentTarget && !isPanMode) { + startSelectionDrag(e); + } + }} + onDragOver={(e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + }} + onDrop={(e) => { + e.preventDefault(); + // console.log("🎯 캔버스 드롭 이벤트 발생"); + handleDrop(e); + }} + > + {/* 격자 라인 */} + {gridLines.map((line, index) => ( +
+ ))} - {/* 컴포넌트들 */} - {layout.components - .filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링 - .map((component) => { - const children = - component.type === "group" - ? layout.components.filter((child) => child.parentId === component.id) - : []; + {/* 컴포넌트들 */} + {layout.components + .filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링 + .map((component) => { + const children = + component.type === "group" + ? layout.components.filter((child) => child.parentId === component.id) + : []; - // 드래그 중 시각적 피드백 (다중 선택 지원) - const isDraggingThis = dragState.isDragging && dragState.draggedComponent?.id === component.id; - const isBeingDragged = - dragState.isDragging && dragState.draggedComponents.some((dragComp) => dragComp.id === component.id); + // 드래그 중 시각적 피드백 (다중 선택 지원) + const isDraggingThis = dragState.isDragging && dragState.draggedComponent?.id === component.id; + const isBeingDragged = + dragState.isDragging && + dragState.draggedComponents.some((dragComp) => dragComp.id === component.id); - let displayComponent = component; - - if (isBeingDragged) { - if (isDraggingThis) { - // 주 드래그 컴포넌트: 마우스 위치 기반으로 실시간 위치 업데이트 - displayComponent = { - ...component, - position: dragState.currentPosition, - style: { - ...component.style, - opacity: 0.8, - transform: "scale(1.02)", - transition: "none", - zIndex: 50, - }, - }; - } else { - // 다른 선택된 컴포넌트들: 상대적 위치로 실시간 업데이트 - const originalComponent = dragState.draggedComponents.find( - (dragComp) => dragComp.id === component.id, - ); - if (originalComponent) { - const deltaX = dragState.currentPosition.x - dragState.originalPosition.x; - const deltaY = dragState.currentPosition.y - dragState.originalPosition.y; + let displayComponent = component; + if (isBeingDragged) { + if (isDraggingThis) { + // 주 드래그 컴포넌트: 마우스 위치 기반으로 실시간 위치 업데이트 displayComponent = { ...component, - position: { - x: originalComponent.position.x + deltaX, - y: originalComponent.position.y + deltaY, - z: originalComponent.position.z || 1, - } as Position, + position: dragState.currentPosition, style: { ...component.style, opacity: 0.8, + transform: "scale(1.02)", transition: "none", - zIndex: 40, // 주 컴포넌트보다 약간 낮게 + zIndex: 50, }, }; + } else { + // 다른 선택된 컴포넌트들: 상대적 위치로 실시간 업데이트 + const originalComponent = dragState.draggedComponents.find( + (dragComp) => dragComp.id === component.id, + ); + if (originalComponent) { + const deltaX = dragState.currentPosition.x - dragState.originalPosition.x; + const deltaY = dragState.currentPosition.y - dragState.originalPosition.y; + + displayComponent = { + ...component, + position: { + x: originalComponent.position.x + deltaX, + y: originalComponent.position.y + deltaY, + z: originalComponent.position.z || 1, + } as Position, + style: { + ...component.style, + opacity: 0.8, + transition: "none", + zIndex: 40, // 주 컴포넌트보다 약간 낮게 + }, + }; + } } } - } - // 전역 파일 상태도 key에 포함하여 실시간 리렌더링 - const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {}; - const globalFiles = globalFileState[component.id] || []; - const componentFiles = (component as any).uploadedFiles || []; - const fileStateKey = `${globalFiles.length}-${JSON.stringify(globalFiles.map((f: any) => f.objid) || [])}-${componentFiles.length}`; + // 전역 파일 상태도 key에 포함하여 실시간 리렌더링 + const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {}; + const globalFiles = globalFileState[component.id] || []; + const componentFiles = (component as any).uploadedFiles || []; + const fileStateKey = `${globalFiles.length}-${JSON.stringify(globalFiles.map((f: any) => f.objid) || [])}-${componentFiles.length}`; - return ( - handleComponentClick(component, e)} - onDoubleClick={(e) => handleComponentDoubleClick(component, e)} - onDragStart={(e) => startComponentDrag(component, e)} - onDragEnd={endDrag} - selectedScreen={selectedScreen} - // onZoneComponentDrop 제거 - onZoneClick={handleZoneClick} - // 설정 변경 핸들러 (테이블 페이지 크기 등 설정을 상세설정에 반영) - onConfigChange={(config) => { - // console.log("📤 테이블 설정 변경을 상세설정에 반영:", config); + return ( + handleComponentClick(component, e)} + onDoubleClick={(e) => handleComponentDoubleClick(component, e)} + onDragStart={(e) => startComponentDrag(component, e)} + onDragEnd={endDrag} + selectedScreen={selectedScreen} + // onZoneComponentDrop 제거 + onZoneClick={handleZoneClick} + // 설정 변경 핸들러 (테이블 페이지 크기 등 설정을 상세설정에 반영) + onConfigChange={(config) => { + // console.log("📤 테이블 설정 변경을 상세설정에 반영:", config); - // 컴포넌트의 componentConfig 업데이트 - const updatedComponents = layout.components.map((comp) => { - if (comp.id === component.id) { - return { - ...comp, - componentConfig: { - ...comp.componentConfig, - ...config, - }, - }; - } - return comp; - }); + // 컴포넌트의 componentConfig 업데이트 + const updatedComponents = layout.components.map((comp) => { + if (comp.id === component.id) { + return { + ...comp, + componentConfig: { + ...comp.componentConfig, + ...config, + }, + }; + } + return comp; + }); - const newLayout = { - ...layout, - components: updatedComponents, - }; + const newLayout = { + ...layout, + components: updatedComponents, + }; - setLayout(newLayout); - saveToHistory(newLayout); + setLayout(newLayout); + saveToHistory(newLayout); - console.log("✅ 컴포넌트 설정 업데이트 완료:", { - componentId: component.id, - updatedConfig: config, - }); - }} - > - {/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */} - {(component.type === "group" || component.type === "container" || component.type === "area") && - layout.components - .filter((child) => child.parentId === component.id) - .map((child) => { - // 자식 컴포넌트에도 드래그 피드백 적용 - const isChildDraggingThis = - dragState.isDragging && dragState.draggedComponent?.id === child.id; - const isChildBeingDragged = - dragState.isDragging && - dragState.draggedComponents.some((dragComp) => dragComp.id === child.id); + console.log("✅ 컴포넌트 설정 업데이트 완료:", { + componentId: component.id, + updatedConfig: config, + }); + }} + > + {/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */} + {(component.type === "group" || component.type === "container" || component.type === "area") && + layout.components + .filter((child) => child.parentId === component.id) + .map((child) => { + // 자식 컴포넌트에도 드래그 피드백 적용 + const isChildDraggingThis = + dragState.isDragging && dragState.draggedComponent?.id === child.id; + const isChildBeingDragged = + dragState.isDragging && + dragState.draggedComponents.some((dragComp) => dragComp.id === child.id); - let displayChild = child; - - if (isChildBeingDragged) { - if (isChildDraggingThis) { - // 주 드래그 자식 컴포넌트 - displayChild = { - ...child, - position: dragState.currentPosition, - style: { - ...child.style, - opacity: 0.8, - transform: "scale(1.02)", - transition: "none", - zIndex: 50, - }, - }; - } else { - // 다른 선택된 자식 컴포넌트들 - const originalChildComponent = dragState.draggedComponents.find( - (dragComp) => dragComp.id === child.id, - ); - if (originalChildComponent) { - const deltaX = dragState.currentPosition.x - dragState.originalPosition.x; - const deltaY = dragState.currentPosition.y - dragState.originalPosition.y; + let displayChild = child; + if (isChildBeingDragged) { + if (isChildDraggingThis) { + // 주 드래그 자식 컴포넌트 displayChild = { ...child, - position: { - x: originalChildComponent.position.x + deltaX, - y: originalChildComponent.position.y + deltaY, - z: originalChildComponent.position.z || 1, - } as Position, + position: dragState.currentPosition, style: { ...child.style, opacity: 0.8, + transform: "scale(1.02)", transition: "none", - zIndex: 8888, + zIndex: 50, }, }; + } else { + // 다른 선택된 자식 컴포넌트들 + const originalChildComponent = dragState.draggedComponents.find( + (dragComp) => dragComp.id === child.id, + ); + if (originalChildComponent) { + const deltaX = dragState.currentPosition.x - dragState.originalPosition.x; + const deltaY = dragState.currentPosition.y - dragState.originalPosition.y; + + displayChild = { + ...child, + position: { + x: originalChildComponent.position.x + deltaX, + y: originalChildComponent.position.y + deltaY, + z: originalChildComponent.position.z || 1, + } as Position, + style: { + ...child.style, + opacity: 0.8, + transition: "none", + zIndex: 8888, + }, + }; + } } } - } - // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정 - const relativeChildComponent = { - ...displayChild, - position: { - x: displayChild.position.x - component.position.x, - y: displayChild.position.y - component.position.y, - z: displayChild.position.z || 1, - }, - }; + // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정 + const relativeChildComponent = { + ...displayChild, + position: { + x: displayChild.position.x - component.position.x, + y: displayChild.position.y - component.position.y, + z: displayChild.position.z || 1, + }, + }; - return ( - f.objid) || [])}`} - component={relativeChildComponent} - isSelected={ - selectedComponent?.id === child.id || groupState.selectedComponents.includes(child.id) - } - isDesignMode={true} // 편집 모드로 설정 - onClick={(e) => handleComponentClick(child, e)} - onDoubleClick={(e) => handleComponentDoubleClick(child, e)} - onDragStart={(e) => startComponentDrag(child, e)} - onDragEnd={endDrag} - selectedScreen={selectedScreen} - // onZoneComponentDrop 제거 - onZoneClick={handleZoneClick} - // 설정 변경 핸들러 (자식 컴포넌트용) - onConfigChange={(config) => { - // console.log("📤 자식 컴포넌트 설정 변경을 상세설정에 알림:", config); - // TODO: 실제 구현은 DetailSettingsPanel과의 연동 필요 - }} - /> - ); - })} - - ); - })} + return ( + f.objid) || [])}`} + component={relativeChildComponent} + isSelected={ + selectedComponent?.id === child.id || groupState.selectedComponents.includes(child.id) + } + isDesignMode={true} // 편집 모드로 설정 + onClick={(e) => handleComponentClick(child, e)} + onDoubleClick={(e) => handleComponentDoubleClick(child, e)} + onDragStart={(e) => startComponentDrag(child, e)} + onDragEnd={endDrag} + selectedScreen={selectedScreen} + // onZoneComponentDrop 제거 + onZoneClick={handleZoneClick} + // 설정 변경 핸들러 (자식 컴포넌트용) + onConfigChange={(config) => { + // console.log("📤 자식 컴포넌트 설정 변경을 상세설정에 알림:", config); + // TODO: 실제 구현은 DetailSettingsPanel과의 연동 필요 + }} + /> + ); + })} + + ); + })} - {/* 드래그 선택 영역 */} - {selectionDrag.isSelecting && ( -
- )} + {/* 드래그 선택 영역 */} + {selectionDrag.isSelecting && ( +
+ )} - {/* 빈 캔버스 안내 */} - {layout.components.length === 0 && ( -
-
- -

캔버스가 비어있습니다

-

좌측 패널에서 테이블/컬럼이나 템플릿을 드래그하여 화면을 설계하세요

-

- 단축키: T(테이블), M(템플릿), P(속성), S(스타일), R(격자), D(상세설정), E(해상도) -

-

- 편집: Ctrl+C(복사), Ctrl+V(붙여넣기), Ctrl+S(저장), Ctrl+Z(실행취소), Delete(삭제) -

-

- ⚠️ 브라우저 기본 단축키가 차단되어 애플리케이션 기능만 작동합니다 -

+ {/* 빈 캔버스 안내 */} + {layout.components.length === 0 && ( +
+
+ +

캔버스가 비어있습니다

+

좌측 패널에서 테이블/컬럼이나 템플릿을 드래그하여 화면을 설계하세요

+

+ 단축키: T(테이블), M(템플릿), P(속성), S(스타일), R(격자), D(상세설정), E(해상도) +

+

+ 편집: Ctrl+C(복사), Ctrl+V(붙여넣기), Ctrl+S(저장), Ctrl+Z(실행취소), Delete(삭제) +

+

+ ⚠️ 브라우저 기본 단축키가 차단되어 애플리케이션 기능만 작동합니다 +

+
-
- )} + )} +
-
- - {/* 플로팅 패널들 */} - closePanel("tables")} - position="left" - width={380} - height={700} - autoHeight={false} - > - { - // console.log("🚀 드래그 시작:", { table: table.tableName, column: column?.columnName }); - const dragData = { - type: column ? "column" : "table", - table, - column, - }; - // console.log("📦 드래그 데이터:", dragData); - e.dataTransfer.setData("application/json", JSON.stringify(dragData)); - }} - selectedTableName={selectedScreen.tableName} - /> - - - closePanel("templates")} - position="left" - width={380} - height={700} - autoHeight={false} - > - { - // React 컴포넌트(icon)를 제외하고 JSON으로 직렬화 가능한 데이터만 전송 - const serializableTemplate = { - id: template.id, - name: template.name, - description: template.description, - category: template.category, - defaultSize: template.defaultSize, - components: template.components, - }; - - const dragData = { - type: "template", - template: serializableTemplate, - }; - e.dataTransfer.setData("application/json", JSON.stringify(dragData)); - }} - /> - - - closePanel("layouts")} - position="left" - width={380} - height={700} - autoHeight={false} - > - { - const dragData = { - type: "layout", - layout: layoutData, - }; - e.dataTransfer.setData("application/json", JSON.stringify(dragData)); - }} - gridSettings={layout.gridSettings || { columns: 12, gap: 16, padding: 16, snapToGrid: true }} - screenResolution={screenResolution} - /> - - - closePanel("components")} - position="left" - width={380} - height={700} - autoHeight={false} - > - - - - closePanel("properties")} - position="right" - width={360} - height={400} - autoHeight={true} - > - { - console.log("🔧 속성 업데이트 요청:", { - componentId: selectedComponent?.id, - componentType: selectedComponent?.type, - path, - value: typeof value === "object" ? JSON.stringify(value).substring(0, 100) + "..." : value, - }); - if (selectedComponent) { - updateComponentProperty(selectedComponent.id, path, value); - } - }} - onDeleteComponent={deleteComponent} - onCopyComponent={copyComponent} - /> - - - closePanel("styles")} - position="right" - width={360} - height={400} - autoHeight={true} - > - {selectedComponent ? ( -
- { - console.log("🔧 StyleEditor 스타일 변경:", { - componentId: selectedComponent.id, - newStyle, - hasHeight: !!newStyle.height, - }); - - // 스타일 업데이트 - updateComponentProperty(selectedComponent.id, "style", newStyle); - - // ✅ 높이만 업데이트 (너비는 gridColumnSpan으로 제어) - if (newStyle.height) { - const height = parseInt(newStyle.height.replace("px", "")); - - console.log("📏 높이 업데이트:", { - originalHeight: selectedComponent.size.height, - newHeight: height, - styleHeight: newStyle.height, - }); - - updateComponentProperty(selectedComponent.id, "size.height", height); - } - }} - /> -
- ) : ( -
- 컴포넌트를 선택하여 스타일을 편집하세요 -
- )} -
- - closePanel("grid")} - position="right" - width={320} - height={400} - autoHeight={true} - > - { - const defaultSettings = { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true }; - updateGridSettings(defaultSettings); - }} - onForceGridUpdate={handleForceGridUpdate} - screenResolution={screenResolution} - /> - - - closePanel("detailSettings")} - position="right" - width={400} - height={400} - autoHeight={true} - > - { - updateComponentProperty(componentId, path, value); - }} - currentTable={tables.length > 0 ? tables[0] : undefined} - currentTableName={selectedScreen?.tableName} - /> - - - closePanel("resolution")} - position="right" - width={320} - height={400} - autoHeight={true} - > -
- -
-
- - {/* 그룹 생성 툴바 (필요시) */} - {false && groupState.selectedComponents.length > 1 && ( -
- groupState.selectedComponents.includes(comp.id))} - allComponents={layout.components} - groupState={groupState} - onGroupStateChange={setGroupState} - onGroupCreate={(componentIds: string[], title: string, style?: any) => { - handleGroupCreate(componentIds, title, style); - }} - onGroupUngroup={() => { - // TODO: 그룹 해제 구현 - }} - showCreateDialog={showGroupCreateDialog} - onShowCreateDialogChange={setShowGroupCreateDialog} - /> -
- )} - +
{" "} + {/* 메인 컨테이너 닫기 */} + {/* 모달들 */} {/* 메뉴 할당 모달 */} - setShowMenuAssignmentModal(false)} - screenInfo={selectedScreen} - onAssignmentComplete={() => { - // console.log("메뉴 할당 완료"); - // 필요시 추가 작업 수행 - }} - onBackToList={onBackToList} - /> - + {showMenuAssignmentModal && selectedScreen && ( + setShowMenuAssignmentModal(false)} + onAssignmentComplete={() => { + // 모달을 즉시 닫지 않고, MenuAssignmentModal이 3초 후 자동으로 닫히도록 함 + // setShowMenuAssignmentModal(false); + // toast.success("메뉴에 화면이 할당되었습니다."); + }} + onBackToList={onBackToList} + /> + )} {/* 파일첨부 상세 모달 */} - + {showFileAttachmentModal && selectedFileComponent && ( + { + setShowFileAttachmentModal(false); + setSelectedFileComponent(null); + }} + component={selectedFileComponent} + screenId={selectedScreen.screenId} + /> + )}
); } diff --git a/frontend/components/screen/StyleEditor.tsx b/frontend/components/screen/StyleEditor.tsx index 8d45013e..2d2e7acd 100644 --- a/frontend/components/screen/StyleEditor.tsx +++ b/frontend/components/screen/StyleEditor.tsx @@ -1,14 +1,11 @@ "use client"; import { useState, useEffect } from "react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Button } from "@/components/ui/button"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Separator } from "@/components/ui/separator"; -import { Palette, Type, Square, Box, Eye, RotateCcw } from "lucide-react"; +import { Palette, Type, Square, Box } from "lucide-react"; import { ComponentStyle } from "@/types/screen"; interface StyleEditorProps { @@ -18,10 +15,10 @@ interface StyleEditorProps { } export default function StyleEditor({ style, onStyleChange, className }: StyleEditorProps) { - const [localStyle, setLocalStyle] = useState(style); + const [localStyle, setLocalStyle] = useState(style || {}); useEffect(() => { - setLocalStyle(style); + setLocalStyle(style || {}); }, [style]); const handleStyleChange = (property: keyof ComponentStyle, value: any) => { @@ -30,243 +27,218 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd onStyleChange(newStyle); }; - const resetStyle = () => { - const resetStyle: ComponentStyle = {}; - setLocalStyle(resetStyle); - onStyleChange(resetStyle); - }; - - const applyStyle = () => { - onStyleChange(localStyle); - }; - return ( -
- - -
- - - 스타일 편집 - -
- - +
+ {/* 여백 섹션 */} +
+
+ +

여백

+
+ +
+
+
+ + handleStyleChange("margin", e.target.value)} + /> +
+
+ + handleStyleChange("padding", e.target.value)} + />
- - - - - - - 여백 - - - - 테두리 - - - - 배경 - - - - 텍스트 - - - {/* 여백 탭 */} - -
-
- - handleStyleChange("margin", e.target.value)} - /> -
-
- - handleStyleChange("padding", e.target.value)} - /> -
-
+
+
+ + handleStyleChange("gap", e.target.value)} + /> +
+
+
+
-
-
- - handleStyleChange("gap", e.target.value)} - /> -
-
- + {/* 테두리 섹션 */} +
+
+ +

테두리

+
+ +
+
+
+ + handleStyleChange("borderWidth", e.target.value)} + /> +
+
+ + +
+
- {/* 테두리 탭 */} - -
-
- - handleStyleChange("borderWidth", e.target.value)} - /> -
-
- - -
-
+
+
+ + handleStyleChange("borderColor", e.target.value)} + /> +
+
+ + handleStyleChange("borderRadius", e.target.value)} + /> +
+
+
+
-
-
- - handleStyleChange("borderColor", e.target.value)} - /> -
-
- - handleStyleChange("borderRadius", e.target.value)} - /> -
-
- + {/* 배경 섹션 */} +
+
+ +

배경

+
+ +
+
+ + handleStyleChange("backgroundColor", e.target.value)} + /> +
- {/* 배경 탭 */} - -
- - handleStyleChange("backgroundColor", e.target.value)} - /> -
+
+ + handleStyleChange("backgroundImage", e.target.value)} + /> +
+
+
-
- - handleStyleChange("backgroundImage", e.target.value)} - /> -
- + {/* 텍스트 섹션 */} +
+
+ +

텍스트

+
+ +
+
+
+ + handleStyleChange("color", e.target.value)} + /> +
+
+ + handleStyleChange("fontSize", e.target.value)} + /> +
+
- {/* 텍스트 탭 */} - -
-
- - handleStyleChange("color", e.target.value)} - /> -
-
- - handleStyleChange("fontSize", e.target.value)} - /> -
-
- -
-
- - -
-
- - -
-
-
- - - +
+
+ + +
+
+ + +
+
+
+
); } diff --git a/frontend/components/screen/panels/DataTableConfigPanel.tsx b/frontend/components/screen/panels/DataTableConfigPanel.tsx index 666d071e..163a446d 100644 --- a/frontend/components/screen/panels/DataTableConfigPanel.tsx +++ b/frontend/components/screen/panels/DataTableConfigPanel.tsx @@ -69,7 +69,6 @@ const DataTableConfigPanelComponent: React.FC = ({ showPageSizeSelector: component.pagination?.showPageSizeSelector ?? true, showPageInfo: component.pagination?.showPageInfo ?? true, showFirstLast: component.pagination?.showFirstLast ?? true, - gridColumns: component.gridColumns || 6, }); // 컬럼별 로컬 입력 상태 @@ -110,45 +109,45 @@ const DataTableConfigPanelComponent: React.FC = ({ // 컴포넌트 변경 시 로컬 값 동기화 useEffect(() => { // console.log("🔄 DataTableConfig: 컴포넌트 변경 감지", { - // componentId: component.id, - // title: component.title, - // searchButtonText: component.searchButtonText, - // columnsCount: component.columns.length, - // filtersCount: component.filters.length, - // columnIds: component.columns.map((col) => col.id), - // filterColumnNames: component.filters.map((filter) => filter.columnName), - // timestamp: new Date().toISOString(), + // componentId: component.id, + // title: component.title, + // searchButtonText: component.searchButtonText, + // columnsCount: component.columns.length, + // filtersCount: component.filters.length, + // columnIds: component.columns.map((col) => col.id), + // filterColumnNames: component.filters.map((filter) => filter.columnName), + // timestamp: new Date().toISOString(), // }); // 컬럼과 필터 상세 정보 로그 if (component.columns.length > 0) { // console.log( - // "📋 현재 컬럼 목록:", - // component.columns.map((col) => ({ - // id: col.id, - // columnName: col.columnName, - // label: col.label, - // visible: col.visible, - // gridColumns: col.gridColumns, - // })), + // "📋 현재 컬럼 목록:", + // component.columns.map((col) => ({ + // id: col.id, + // columnName: col.columnName, + // label: col.label, + // visible: col.visible, + // gridColumns: col.gridColumns, + // })), // ); } // 로컬 상태 정보 로그 // console.log("🔧 로컬 상태 정보:", { - // localColumnInputsCount: Object.keys(localColumnInputs).length, - // localColumnCheckboxesCount: Object.keys(localColumnCheckboxes).length, - // localColumnGridColumnsCount: Object.keys(localColumnGridColumns).length, + // localColumnInputsCount: Object.keys(localColumnInputs).length, + // localColumnCheckboxesCount: Object.keys(localColumnCheckboxes).length, + // localColumnGridColumnsCount: Object.keys(localColumnGridColumns).length, // }); if (component.filters.length > 0) { // console.log( - // "🔍 현재 필터 목록:", - // component.filters.map((filter) => ({ - // columnName: filter.columnName, - // widgetType: filter.widgetType, - // label: filter.label, - // })), + // "🔍 현재 필터 목록:", + // component.filters.map((filter) => ({ + // columnName: filter.columnName, + // widgetType: filter.widgetType, + // label: filter.label, + // })), // ); } @@ -179,7 +178,6 @@ const DataTableConfigPanelComponent: React.FC = ({ showPageSizeSelector: component.pagination?.showPageSizeSelector ?? true, showPageInfo: component.pagination?.showPageInfo ?? true, showFirstLast: component.pagination?.showFirstLast ?? true, - gridColumns: component.gridColumns || 6, // 테이블명 동기화 tableName: component.tableName || "", }); @@ -259,9 +257,9 @@ const DataTableConfigPanelComponent: React.FC = ({ if (!(filterKey in newFilterInputs)) { newFilterInputs[filterKey] = filter.label || filter.columnName; // console.log("🆕 새 필터 로컬 상태 추가:", { - // filterKey, - // label: filter.label, - // columnName: filter.columnName, + // filterKey, + // label: filter.label, + // columnName: filter.columnName, // }); } }); @@ -278,9 +276,9 @@ const DataTableConfigPanelComponent: React.FC = ({ }); // console.log("📝 필터 로컬 상태 동기화 완료:", { - // prevCount: Object.keys(prev).length, - // newCount: Object.keys(newFilterInputs).length, - // newKeys: Object.keys(newFilterInputs), + // prevCount: Object.keys(prev).length, + // newCount: Object.keys(newFilterInputs).length, + // newKeys: Object.keys(newFilterInputs), // }); return newFilterInputs; @@ -320,18 +318,18 @@ const DataTableConfigPanelComponent: React.FC = ({ if (!table) return; // console.log("🔄 테이블 변경:", { - // tableName, - // currentTableName: localValues.tableName, - // table, - // columnsCount: table.columns.length, + // tableName, + // currentTableName: localValues.tableName, + // table, + // columnsCount: table.columns.length, // }); // 테이블 변경 시 컬럼을 자동으로 추가하지 않음 (사용자가 수동으로 추가해야 함) const defaultColumns: DataTableColumn[] = []; // console.log("✅ 생성된 컬럼 설정:", { - // defaultColumnsCount: defaultColumns.length, - // visibleColumns: defaultColumns.filter((col) => col.visible).length, + // defaultColumnsCount: defaultColumns.length, + // visibleColumns: defaultColumns.filter((col) => col.visible).length, // }); // 상태 업데이트를 한 번에 처리 @@ -378,10 +376,10 @@ const DataTableConfigPanelComponent: React.FC = ({ try { // TODO: 테이블 타입 관리 API 호출하여 웹 타입과 상세 설정 업데이트 // console.log("📡 테이블 타입 관리 업데이트 필요:", { - // tableName: component.tableName, - // columnName: targetColumn.columnName, - // webType: "radio", - // detailSettings: JSON.stringify(webTypeConfig), + // tableName: component.tableName, + // columnName: targetColumn.columnName, + // webType: "radio", + // detailSettings: JSON.stringify(webTypeConfig), // }); } catch (error) { // console.error("테이블 타입 관리 업데이트 실패:", error); @@ -738,9 +736,9 @@ const DataTableConfigPanelComponent: React.FC = ({ }); // console.log("🗑️ 컬럼 삭제:", { - // columnId, - // columnName: columnToRemove?.columnName, - // remainingColumns: updatedColumns.length, + // columnId, + // columnName: columnToRemove?.columnName, + // remainingColumns: updatedColumns.length, // }); onUpdateComponent({ @@ -780,7 +778,7 @@ const DataTableConfigPanelComponent: React.FC = ({ columnName: targetColumn.columnName, widgetType, label: targetColumn.columnLabel || targetColumn.columnName, - gridColumns: 3, + gridColumns: getDefaultGridColumns(widgetType), // 웹타입별 추가 정보 설정 codeCategory: targetColumn.codeCategory, referenceTable: targetColumn.referenceTable, @@ -789,28 +787,28 @@ const DataTableConfigPanelComponent: React.FC = ({ }; // console.log("➕ 필터 추가 시작:", { - // targetColumnName: targetColumn.columnName, - // targetColumnLabel: targetColumn.columnLabel, - // inferredWidgetType: widgetType, - // currentFiltersCount: component.filters.length, + // targetColumnName: targetColumn.columnName, + // targetColumnLabel: targetColumn.columnLabel, + // inferredWidgetType: widgetType, + // currentFiltersCount: component.filters.length, // }); // console.log("➕ 생성된 새 필터:", { - // columnName: newFilter.columnName, - // widgetType: newFilter.widgetType, - // label: newFilter.label, - // gridColumns: newFilter.gridColumns, + // columnName: newFilter.columnName, + // widgetType: newFilter.widgetType, + // label: newFilter.label, + // gridColumns: newFilter.gridColumns, // }); const updatedFilters = [...component.filters, newFilter]; // console.log("🔄 필터 업데이트 호출:", { - // filtersToAdd: 1, - // totalFiltersAfter: updatedFilters.length, - // updatedFilters: updatedFilters.map((filter) => ({ - // columnName: filter.columnName, - // widgetType: filter.widgetType, - // label: filter.label, - // })), + // filtersToAdd: 1, + // totalFiltersAfter: updatedFilters.length, + // updatedFilters: updatedFilters.map((filter) => ({ + // columnName: filter.columnName, + // widgetType: filter.widgetType, + // label: filter.label, + // })), // }); // 먼저 로컬 상태를 업데이트하고 @@ -821,10 +819,10 @@ const DataTableConfigPanelComponent: React.FC = ({ [filterKey]: newFilter.label, }; // console.log("📝 필터 로컬 상태 업데이트:", { - // filterKey, - // newLabel: newFilter.label, - // prevState: prev, - // newState, + // filterKey, + // newLabel: newFilter.label, + // prevState: prev, + // newState, // }); return newState; }); @@ -836,8 +834,8 @@ const DataTableConfigPanelComponent: React.FC = ({ setActiveTab("filters"); // console.log("🔍 필터 추가 후 탭 이동:", { - // activeTab: "filters", - // isExternalControl: !!onTabChange, + // activeTab: "filters", + // isExternalControl: !!onTabChange, // }); // 강제로 리렌더링을 트리거하기 위해 여러 방법 사용 @@ -859,9 +857,9 @@ const DataTableConfigPanelComponent: React.FC = ({ }, 100); // console.log("✅ 필터 추가 완료 - 로컬 상태와 컴포넌트 모두 업데이트됨", { - // filterKey, - // newFilterLabel: newFilter.label, - // switchedToTab: "filters", + // filterKey, + // newFilterLabel: newFilter.label, + // switchedToTab: "filters", // }); }, [selectedTable, component.filters, onUpdateComponent]); @@ -890,6 +888,48 @@ const DataTableConfigPanelComponent: React.FC = ({ return !nonFilterableTypes.includes(webType); }; + // 웹타입별 기본 컬럼 수 계산 (컴포넌트 너비 기반) + const getDefaultGridColumns = (webType: WebType): number => { + // 각 웹타입별 적절한 기본 너비 설정 + const widthMap: Record = { + // 텍스트 입력 계열 (넓게) + text: 4, // 1/3 (33%) + email: 4, // 1/3 (33%) + tel: 3, // 1/4 (25%) + url: 4, // 1/3 (33%) + textarea: 6, // 절반 (50%) + + // 숫자/날짜 입력 (중간) + number: 2, // 2/12 (16.67%) + decimal: 2, // 2/12 (16.67%) + date: 3, // 1/4 (25%) + datetime: 3, // 1/4 (25%) + time: 2, // 2/12 (16.67%) + + // 선택 입력 (중간) + select: 3, // 1/4 (25%) + radio: 3, // 1/4 (25%) + checkbox: 2, // 2/12 (16.67%) + boolean: 2, // 2/12 (16.67%) + + // 코드/참조 (넓게) + code: 3, // 1/4 (25%) + entity: 4, // 1/3 (33%) + + // 파일/이미지 (넓게) + file: 4, // 1/3 (33%) + image: 3, // 1/4 (25%) + + // 기타 + button: 2, // 2/12 (16.67%) + label: 2, // 2/12 (16.67%) + }; + + const defaultColumns = widthMap[webType] || 3; // 기본값 3 (1/4, 25%) + console.log("🎯 getDefaultGridColumns 호출:", { webType, defaultColumns, widthMap: widthMap[webType] }); + return defaultColumns; + }; + // 컬럼 추가 (테이블에서 선택) const addColumn = useCallback( (columnName?: string) => { @@ -907,38 +947,47 @@ const DataTableConfigPanelComponent: React.FC = ({ : availableColumns[0]; const widgetType = getWidgetTypeFromColumn(targetColumn); + const calculatedGridColumns = getDefaultGridColumns(widgetType); + + console.log("➕ addColumn 호출:", { + columnName: targetColumn.columnName, + widgetType, + calculatedGridColumns, + }); const newColumn: DataTableColumn = { id: generateComponentId(), columnName: targetColumn.columnName, label: targetColumn.columnLabel || targetColumn.columnName, widgetType, - gridColumns: 2, + gridColumns: calculatedGridColumns, visible: true, filterable: isFilterableWebType(widgetType), sortable: true, searchable: ["text", "email", "tel"].includes(widgetType), }; + console.log("✅ 생성된 newColumn:", newColumn); + // 필터는 자동으로 추가하지 않음 (사용자가 수동으로 추가) // console.log("➕ 컬럼 추가 시작:", { - // targetColumnName: targetColumn.columnName, - // targetColumnLabel: targetColumn.columnLabel, - // inferredWidgetType: widgetType, - // currentColumnsCount: component.columns.length, - // currentFiltersCount: component.filters.length, + // targetColumnName: targetColumn.columnName, + // targetColumnLabel: targetColumn.columnLabel, + // inferredWidgetType: widgetType, + // currentColumnsCount: component.columns.length, + // currentFiltersCount: component.filters.length, // }); // console.log("➕ 생성된 새 컬럼:", { - // id: newColumn.id, - // columnName: newColumn.columnName, - // label: newColumn.label, - // widgetType: newColumn.widgetType, - // filterable: newColumn.filterable, - // visible: newColumn.visible, - // sortable: newColumn.sortable, - // searchable: newColumn.searchable, + // id: newColumn.id, + // columnName: newColumn.columnName, + // label: newColumn.label, + // widgetType: newColumn.widgetType, + // filterable: newColumn.filterable, + // visible: newColumn.visible, + // sortable: newColumn.sortable, + // searchable: newColumn.searchable, // }); // 필터는 수동으로만 추가 @@ -950,9 +999,9 @@ const DataTableConfigPanelComponent: React.FC = ({ [newColumn.id]: newColumn.label, }; // console.log("🔄 로컬 컬럼 상태 업데이트:", { - // newColumnId: newColumn.id, - // newLabel: newColumn.label, - // totalLocalInputs: Object.keys(newInputs).length, + // newColumnId: newColumn.id, + // newLabel: newColumn.label, + // totalLocalInputs: Object.keys(newInputs).length, // }); return newInputs; }); @@ -979,14 +1028,14 @@ const DataTableConfigPanelComponent: React.FC = ({ }; // console.log("🔄 컴포넌트 업데이트 호출:", { - // columnsToAdd: 1, - // totalColumnsAfter: updates.columns?.length, - // hasColumns: !!updates.columns, - // updateKeys: Object.keys(updates), + // columnsToAdd: 1, + // totalColumnsAfter: updates.columns?.length, + // hasColumns: !!updates.columns, + // updateKeys: Object.keys(updates), // }); // console.log("🔄 업데이트 상세 내용:", { - // columns: updates.columns?.map((col) => ({ id: col.id, columnName: col.columnName, label: col.label })), + // columns: updates.columns?.map((col) => ({ id: col.id, columnName: col.columnName, label: col.label })), // }); onUpdateComponent(updates); @@ -995,8 +1044,8 @@ const DataTableConfigPanelComponent: React.FC = ({ setActiveTab("columns"); // console.log("📋 컬럼 추가 후 탭 이동:", { - // activeTab: "columns", - // isExternalControl: !!onTabChange, + // activeTab: "columns", + // isExternalControl: !!onTabChange, // }); // console.log("✅ 컬럼 추가 완료 - onUpdateComponent 호출됨"); @@ -1014,7 +1063,7 @@ const DataTableConfigPanelComponent: React.FC = ({ columnName: newColumnName, label: `파일 컬럼 ${fileColumnCount + 1}`, widgetType: "file", - gridColumns: 2, + gridColumns: getDefaultGridColumns("file"), visible: true, filterable: false, // 파일 컬럼은 필터링 불가 sortable: false, // 파일 컬럼은 정렬 불가 @@ -1029,9 +1078,9 @@ const DataTableConfigPanelComponent: React.FC = ({ }; // console.log("📁 가상 파일 컬럼 추가:", { - // columnName: newColumn.columnName, - // label: newColumn.label, - // isVirtualFileColumn: newColumn.isVirtualFileColumn, + // columnName: newColumn.columnName, + // label: newColumn.label, + // isVirtualFileColumn: newColumn.isVirtualFileColumn, // }); // 로컬 상태에 새 컬럼 입력값 추가 @@ -1092,7 +1141,7 @@ const DataTableConfigPanelComponent: React.FC = ({
{ - const gridColumns = parseInt(e.target.value, 10); - // console.log("🔄 테이블 그리드 컬럼 수 변경:", gridColumns); - setLocalValues((prev) => ({ ...prev, gridColumns })); - onUpdateComponent({ gridColumns }); - }} - > - - {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map((num) => ( - - ))} - -
-
= ({
- +
@@ -2023,11 +2058,11 @@ const DataTableConfigPanelComponent: React.FC = ({ const localValue = localFilterInputs[filterKey]; const finalValue = localValue !== undefined ? localValue : filter.label; // console.log("🎯 필터 입력 값 결정:", { - // filterKey, - // localValue, - // filterLabel: filter.label, - // finalValue, - // allLocalInputs: Object.keys(localFilterInputs), + // filterKey, + // localValue, + // filterLabel: filter.label, + // finalValue, + // allLocalInputs: Object.keys(localFilterInputs), // }); return finalValue; })()} @@ -2104,7 +2139,7 @@ const DataTableConfigPanelComponent: React.FC = ({
- +
@@ -2180,7 +2222,7 @@ const DataTableConfigPanelComponent: React.FC = ({
@@ -376,7 +391,7 @@ export const DetailSettingsPanel: React.FC = ({
- + @@ -420,7 +435,7 @@ export const DetailSettingsPanel: React.FC = ({
- + = ({
- + = ({ } className="rounded border-gray-300" /> -
@@ -586,7 +601,7 @@ export const DetailSettingsPanel: React.FC = ({ } className="rounded border-gray-300" /> -
@@ -605,7 +620,7 @@ export const DetailSettingsPanel: React.FC = ({ } className="rounded border-gray-300" /> -
@@ -620,14 +635,14 @@ export const DetailSettingsPanel: React.FC = ({ } className="rounded border-gray-300" /> -
- + = ({
- + = ({ />
- + = ({ const currentConfig = widget.webTypeConfig || {}; // console.log("🎨 DetailSettingsPanel renderWebTypeConfig 호출:", { - // componentId: widget.id, - // widgetType: widget.widgetType, - // currentConfig, - // configExists: !!currentConfig, - // configKeys: Object.keys(currentConfig), - // configStringified: JSON.stringify(currentConfig), - // widgetWebTypeConfig: widget.webTypeConfig, - // widgetWebTypeConfigExists: !!widget.webTypeConfig, - // timestamp: new Date().toISOString(), + // componentId: widget.id, + // widgetType: widget.widgetType, + // currentConfig, + // configExists: !!currentConfig, + // configKeys: Object.keys(currentConfig), + // configStringified: JSON.stringify(currentConfig), + // widgetWebTypeConfig: widget.webTypeConfig, + // widgetWebTypeConfigExists: !!widget.webTypeConfig, + // timestamp: new Date().toISOString(), // }); // console.log("🎨 selectedComponent 전체:", selectedComponent); const handleConfigChange = (newConfig: WebTypeConfig) => { // console.log("🔧 WebTypeConfig 업데이트:", { - // widgetType: widget.widgetType, - // oldConfig: currentConfig, - // newConfig, - // componentId: widget.id, - // isEqual: JSON.stringify(currentConfig) === JSON.stringify(newConfig), + // widgetType: widget.widgetType, + // oldConfig: currentConfig, + // newConfig, + // componentId: widget.id, + // isEqual: JSON.stringify(currentConfig) === JSON.stringify(newConfig), // }); // 강제 새 객체 생성으로 React 변경 감지 보장 @@ -738,40 +753,40 @@ export const DetailSettingsPanel: React.FC = ({ // console.log(`🎨 ✅ ConfigPanelComponent 렌더링 시작`); return ; } else { - // console.log(`🎨 ❌ ConfigPanelComponent가 null - 기본 설정 표시`); + // console.log(`🎨 ❌ ConfigPanelComponent가 null - WebTypeConfigPanel 사용`); return ( -
- ⚙️ 기본 설정 -
- 웹타입 "{widget.widgetType}"은 추가 설정이 없습니다. -
+ ); } } else { - // console.log(`🎨 config_panel이 없음 - 기본 설정 표시`); + // console.log(`🎨 config_panel이 없음 - WebTypeConfigPanel 사용`); return ( -
- ⚙️ 기본 설정 -
- 웹타입 "{widget.widgetType}"은 추가 설정이 없습니다. -
+ ); } }; if (!selectedComponent) { return ( -
+
{/* 헤더 */}
-

상세 설정

+

상세 설정

컴포넌트를 선택하여 상세 설정을 편집하세요

- + {/* 빈 상태 */} -
+

컴포넌트를 선택하세요

위젯 컴포넌트를 선택하면 상세 설정을 편집할 수 있습니다.

@@ -847,9 +862,9 @@ export const DetailSettingsPanel: React.FC = ({ // 새로운 컴포넌트 타입들에 대한 설정 패널 확인 const componentType = selectedComponent?.componentConfig?.type || selectedComponent?.type; // console.log("🔍 DetailSettingsPanel componentType 확인:", { - // selectedComponentType: selectedComponent?.type, - // componentConfigType: selectedComponent?.componentConfig?.type, - // finalComponentType: componentType, + // selectedComponentType: selectedComponent?.type, + // componentConfigType: selectedComponent?.componentConfig?.type, + // finalComponentType: componentType, // }); const hasNewConfigPanel = @@ -880,11 +895,11 @@ export const DetailSettingsPanel: React.FC = ({ {/* 헤더 */}
- +

컴포넌트 설정

- 타입: + 타입: {componentType}
@@ -928,15 +943,17 @@ export const DetailSettingsPanel: React.FC = ({ {/* 헤더 */}
- +

파일 컴포넌트 설정

- 타입: + 타입: 파일 업로드
- {selectedComponent.type === "widget" ? `위젯타입: ${selectedComponent.widgetType}` : `문서 타입: ${fileComponent.fileConfig?.docTypeName || "일반 문서"}`} + {selectedComponent.type === "widget" + ? `위젯타입: ${selectedComponent.widgetType}` + : `문서 타입: ${fileComponent.fileConfig?.docTypeName || "일반 문서"}`}
@@ -993,65 +1010,126 @@ export const DetailSettingsPanel: React.FC = ({ ); } + // 현재 웹타입의 기본 입력 타입 추출 + const currentBaseInputType = webType ? getBaseInputType(webType as any) : null; + + // 선택 가능한 세부 타입 목록 + const availableDetailTypes = currentBaseInputType ? getDetailTypes(currentBaseInputType) : []; + + // 세부 타입 변경 핸들러 + const handleDetailTypeChange = (newDetailType: string) => { + setLocalComponentDetailType(newDetailType); + onUpdateProperty(selectedComponent.id, "componentConfig.webType", newDetailType); + }; + return ( -
+
{/* 헤더 */}
-

상세 설정

+

상세 설정

선택한 컴포넌트의 속성을 편집하세요

{/* 컴포넌트 정보 */} -
+
- 컴포넌트: + 컴포넌트: {componentId}
- {webType && ( + {webType && currentBaseInputType && (
- 웹타입: - {webType} + 입력 타입: + + {currentBaseInputType} +
)} {selectedComponent.columnName && (
- 컬럼: + 컬럼: {selectedComponent.columnName}
)}
+ {/* 세부 타입 선택 영역 */} + {webType && availableDetailTypes.length > 1 && ( +
+
+ + +

+ 입력 타입 "{currentBaseInputType}"에 사용할 구체적인 형태를 선택하세요 +

+
+
+ )} + {/* 컴포넌트 설정 패널 */}
- { - const config = selectedComponent.componentConfig || {}; - // console.log("🔍 DetailSettingsPanel에서 전달하는 config:", config); - // console.log("🔍 selectedComponent 전체:", selectedComponent); - return config; - })()} - screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} - tableColumns={(() => { - // console.log("🔍 DetailSettingsPanel tableColumns 전달:", { +
+ {/* DynamicComponentConfigPanel */} + { + const config = selectedComponent.componentConfig || {}; + // console.log("🔍 DetailSettingsPanel에서 전달하는 config:", config); + // console.log("🔍 selectedComponent 전체:", selectedComponent); + return config; + })()} + screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} + tableColumns={(() => { + // console.log("🔍 DetailSettingsPanel tableColumns 전달:", { // currentTable, // columns: currentTable?.columns, // columnsLength: currentTable?.columns?.length, // sampleColumn: currentTable?.columns?.[0], // deptCodeColumn: currentTable?.columns?.find((col) => col.columnName === "dept_code"), - // }); - return currentTable?.columns || []; - })()} - onChange={(newConfig) => { - // console.log("🔧 컴포넌트 설정 변경:", newConfig); - // 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지 - Object.entries(newConfig).forEach(([key, value]) => { - onUpdateProperty(selectedComponent.id, `componentConfig.${key}`, value); - }); - }} - /> + // }); + return currentTable?.columns || []; + })()} + onChange={(newConfig) => { + // console.log("🔧 컴포넌트 설정 변경:", newConfig); + // 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지 + Object.entries(newConfig).forEach(([key, value]) => { + onUpdateProperty(selectedComponent.id, `componentConfig.${key}`, value); + }); + }} + /> + + {/* 웹타입별 특화 설정 */} + {webType && ( +
+

웹타입 설정

+ { + // 기존 설정과 병합하여 업데이트 + Object.entries(newConfig).forEach(([key, value]) => { + onUpdateProperty(selectedComponent.id, `componentConfig.${key}`, value); + }); + }} + /> +
+ )} +
); @@ -1060,21 +1138,71 @@ export const DetailSettingsPanel: React.FC = ({ // 기존 위젯 시스템 처리 (type: "widget") const widget = selectedComponent as WidgetComponent; + // 현재 웹타입의 기본 입력 타입 추출 + const currentBaseInputType = getBaseInputType(widget.widgetType); + + // 선택 가능한 세부 타입 목록 + const availableDetailTypes = getDetailTypes(currentBaseInputType); + + // 로컬 상태: 세부 타입 선택 + const [localDetailType, setLocalDetailType] = useState(widget.widgetType); + + // 컴포넌트 변경 시 로컬 상태 동기화 + useEffect(() => { + setLocalDetailType(widget.widgetType); + }, [widget.widgetType, widget.id]); + + // 세부 타입 변경 핸들러 + const handleDetailTypeChange = (newDetailType: string) => { + setLocalDetailType(newDetailType); + onUpdateProperty(widget.id, "widgetType", newDetailType); + + // 웹타입 변경 시 기존 설정 초기화 (선택적) + // onUpdateProperty(widget.id, "webTypeConfig", {}); + }; + return (
{/* 헤더 */}
- +

상세 설정

- 웹타입: - {widget.widgetType} + 입력 타입: + + {currentBaseInputType} +
컬럼: {widget.columnName}
+ {/* 세부 타입 선택 영역 */} +
+
+ + +

+ 입력 타입 "{currentBaseInputType}"에 사용할 구체적인 형태를 선택하세요 +

+
+
+ {/* 상세 설정 영역 */}
{renderWebTypeConfig(widget)}
diff --git a/frontend/components/screen/panels/PropertiesPanel.tsx b/frontend/components/screen/panels/PropertiesPanel.tsx index 657ba6c7..9ad60eba 100644 --- a/frontend/components/screen/panels/PropertiesPanel.tsx +++ b/frontend/components/screen/panels/PropertiesPanel.tsx @@ -25,6 +25,12 @@ import { ColumnSpanPreset, COLUMN_SPAN_PRESETS, COLUMN_SPAN_VALUES } from "@/lib import { cn } from "@/lib/utils"; import DataTableConfigPanel from "./DataTableConfigPanel"; import { useWebTypes } from "@/hooks/admin/useWebTypes"; +import { + BaseInputType, + BASE_INPUT_TYPE_OPTIONS, + getBaseInputType, + getDefaultDetailType, +} from "@/types/input-type-mapping"; // DataTableConfigPanel을 위한 안정화된 래퍼 컴포넌트 const DataTableConfigPanelWrapper: React.FC<{ @@ -197,14 +203,6 @@ const PropertiesPanelComponent: React.FC = ({ positionZ: selectedComponent?.position.z?.toString() || "1", width: selectedComponent?.size?.width?.toString() || "0", height: selectedComponent?.size?.height?.toString() || "0", - gridColumns: - selectedComponent?.gridColumns?.toString() || - (selectedComponent?.type === "layout" && (selectedComponent as any)?.layoutType === "card-layout" - ? "8" - : selectedComponent?.type === "component" && - (selectedComponent as any)?.componentConfig?.type === "card-display" - ? "8" - : "1"), labelText: selectedComponent?.style?.labelText || selectedComponent?.label || "", labelFontSize: selectedComponent?.style?.labelFontSize || "12px", labelColor: selectedComponent?.style?.labelColor || "#212121", @@ -217,6 +215,90 @@ const PropertiesPanelComponent: React.FC = ({ (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).widgetType : "text") || "text", }); + // 너비 드롭다운 로컬 상태 - 실시간 업데이트를 위한 별도 관리 + const calculateWidthSpan = (width: string | number | undefined, gridColumns?: number): string => { + // gridColumns 값이 있으면 우선 사용 + if (gridColumns) { + const columnsToSpan: Record = { + 1: "twelfth", // 1/12 + 2: "small", // 2/12 + 3: "quarter", // 3/12 + 4: "third", // 4/12 + 5: "five-twelfths", // 5/12 + 6: "half", // 6/12 + 7: "seven-twelfths", // 7/12 + 8: "twoThirds", // 8/12 + 9: "threeQuarters", // 9/12 + 10: "five-sixths", // 10/12 + 11: "eleven-twelfths", // 11/12 + 12: "full", // 12/12 + }; + + const span = columnsToSpan[gridColumns]; + if (span) { + console.log("🎯 calculateWidthSpan - gridColumns 사용:", { gridColumns, span }); + return span; + } + } + + // gridColumns가 없으면 style.width에서 계산 + if (!width) return "half"; + + if (typeof width === "string" && width.includes("%")) { + const percent = parseFloat(width); + + // 정확한 매핑을 위해 가장 가까운 값 찾기 + // 중복 제거: small(작게) 사용, third(1/3) 사용, twoThirds(2/3) 사용, quarter(1/4) 사용, threeQuarters(3/4) 사용 + const percentToSpan: Record = { + 100: "full", // 12/12 + 91.666667: "eleven-twelfths", // 11/12 + 83.333333: "five-sixths", // 10/12 + 75: "threeQuarters", // 9/12 + 66.666667: "twoThirds", // 8/12 + 58.333333: "seven-twelfths", // 7/12 + 50: "half", // 6/12 + 41.666667: "five-twelfths", // 5/12 + 33.333333: "third", // 4/12 + 25: "quarter", // 3/12 + 16.666667: "small", // 2/12 + 8.333333: "twelfth", // 1/12 + }; + + // 가장 가까운 퍼센트 값 찾기 (오차 범위 ±2% 허용) + let closestSpan = "half"; + let minDiff = Infinity; + + for (const [key, span] of Object.entries(percentToSpan)) { + const diff = Math.abs(percent - parseFloat(key)); + if (diff < minDiff && diff < 5) { + // 5% 오차 범위 내 + minDiff = diff; + closestSpan = span; + } + } + + console.log("🎯 calculateWidthSpan - width% 사용:", { width, percent, closestSpan }); + return closestSpan; + } + + return "half"; + }; + + const [localWidthSpan, setLocalWidthSpan] = useState(() => + calculateWidthSpan(selectedComponent?.style?.width, (selectedComponent as any)?.gridColumns), + ); + + // 컴포넌트 또는 style.width, gridColumns가 변경될 때 로컬 상태 업데이트 + useEffect(() => { + const newSpan = calculateWidthSpan(selectedComponent?.style?.width, (selectedComponent as any)?.gridColumns); + setLocalWidthSpan(newSpan); + console.log("🔄 localWidthSpan 업데이트:", { + gridColumns: (selectedComponent as any)?.gridColumns, + width: selectedComponent?.style?.width, + newSpan, + }); + }, [selectedComponent?.id, selectedComponent?.style?.width, (selectedComponent as any)?.gridColumns]); + useEffect(() => { selectedComponentRef.current = selectedComponent; onUpdatePropertyRef.current = onUpdateProperty; @@ -518,24 +600,27 @@ const PropertiesPanelComponent: React.FC = ({
-
@@ -667,13 +752,75 @@ const PropertiesPanelComponent: React.FC = ({ {/* 카드 레이아웃은 자동 크기 계산으로 너비/높이 설정 숨김 */} {selectedComponent?.type !== "layout" || (selectedComponent as any)?.layoutType !== "card" ? ( <> - {/* 🆕 컬럼 스팬 선택 (width 대체) */} + {/* 🆕 컬럼 스팬 선택 (width를 퍼센트로 변환) - 기존 UI 유지 */}
- {/* 시각적 프리뷰 */} + {/* 시각적 프리뷰 - 기존 UI 유지, localWidthSpan 기반 */}
{Array.from({ length: 12 }).map((_, i) => { - const spanValue = COLUMN_SPAN_VALUES[selectedComponent.gridColumnSpan || "half"]; - const startCol = selectedComponent.gridColumnStart || 1; - const isActive = i + 1 >= startCol && i + 1 < startCol + spanValue; + // localWidthSpan으로부터 활성 컬럼 계산 + const spanValues: Record = { + // 표준 옵션 + twelfth: 1, + small: 2, + quarter: 3, + third: 4, + "five-twelfths": 5, + half: 6, + "seven-twelfths": 7, + twoThirds: 8, + threeQuarters: 9, + "five-sixths": 10, + "eleven-twelfths": 11, + full: 12, + + // 레거시 호환성 + sixth: 2, + label: 3, + medium: 4, + large: 8, + input: 9, + "two-thirds": 8, + "three-quarters": 9, + }; + + const spanValue = spanValues[localWidthSpan] || 6; + const isActive = i < spanValue; return (
= ({ })}

- {COLUMN_SPAN_VALUES[selectedComponent.gridColumnSpan || "half"]} / 12 컬럼 + {(() => { + const spanValues: Record = { + // 표준 옵션 + twelfth: 1, + small: 2, + quarter: 3, + third: 4, + "five-twelfths": 5, + half: 6, + "seven-twelfths": 7, + twoThirds: 8, + threeQuarters: 9, + "five-sixths": 10, + "eleven-twelfths": 11, + full: 12, + + // 레거시 호환성 + sixth: 2, + label: 3, + medium: 4, + large: 8, + input: 9, + "two-thirds": 8, + "three-quarters": 9, + }; + const cols = spanValues[localWidthSpan] || 6; + return `${cols} / 12 컬럼`; + })()}

- - {/* 고급 설정 */} - - - - - -
- - -

"자동"을 선택하면 이전 컴포넌트 다음에 배치됩니다

-
-
-
-
+
- { - const newValue = e.target.value; - setLocalInputs((prev) => ({ ...prev, height: newValue })); - onUpdateProperty("size.height", Number(newValue)); - }} - className="mt-1" - /> +
+ { + const rows = Math.max(1, Math.min(20, Number(e.target.value))); + const newHeight = rows * 40; + setLocalInputs((prev) => ({ ...prev, height: newHeight.toString() })); + onUpdateProperty("size.height", newHeight); + }} + className="flex-1" + /> + 행 = {localInputs.height || 40}px +
+

+ 1행 = 40px (현재 {Math.round((localInputs.height || 40) / 40)}행) +

) : ( @@ -795,32 +974,6 @@ const PropertiesPanelComponent: React.FC = ({ placeholder="1" />
- -
- - { - const newValue = e.target.value; - const numValue = Number(newValue); - if (numValue >= 1 && numValue <= 12) { - setLocalInputs((prev) => ({ ...prev, gridColumns: newValue })); - onUpdateProperty("gridColumns", numValue); - } - }} - placeholder="1" - className="mt-1" - /> -
- 이 컴포넌트가 차지할 그리드 컬럼 수를 설정합니다 (기본: 1) -
-
diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx new file mode 100644 index 00000000..ef188fe1 --- /dev/null +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -0,0 +1,733 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { ChevronDown, Settings, Info, Database, Trash2, Copy } from "lucide-react"; +import { + ComponentData, + WebType, + WidgetComponent, + GroupComponent, + DataTableComponent, + TableInfo, + LayoutComponent, + FileComponent, + AreaComponent, +} from "@/types/screen"; +import { ColumnSpanPreset, COLUMN_SPAN_PRESETS } from "@/lib/constants/columnSpans"; + +// 컬럼 스팬 숫자 배열 (1~12) +const COLUMN_NUMBERS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; +import { cn } from "@/lib/utils"; +import DataTableConfigPanel from "./DataTableConfigPanel"; +import { WebTypeConfigPanel } from "./WebTypeConfigPanel"; +import { FileComponentConfigPanel } from "./FileComponentConfigPanel"; +import { useWebTypes } from "@/hooks/admin/useWebTypes"; +import { isFileComponent } from "@/lib/utils/componentTypeUtils"; +import { + BaseInputType, + BASE_INPUT_TYPE_OPTIONS, + getBaseInputType, + getDefaultDetailType, + getDetailTypes, + DetailTypeOption, +} from "@/types/input-type-mapping"; + +// 새로운 컴포넌트 설정 패널들 +import { ButtonConfigPanel } from "../config-panels/ButtonConfigPanel"; +import { CardConfigPanel } from "../config-panels/CardConfigPanel"; +import { DashboardConfigPanel } from "../config-panels/DashboardConfigPanel"; +import { StatsCardConfigPanel } from "../config-panels/StatsCardConfigPanel"; +import { ProgressBarConfigPanel } from "../config-panels/ProgressBarConfigPanel"; +import { ChartConfigPanel } from "../config-panels/ChartConfigPanel"; +import { AlertConfigPanel } from "../config-panels/AlertConfigPanel"; +import { BadgeConfigPanel } from "../config-panels/BadgeConfigPanel"; +import { DynamicComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel"; + +interface UnifiedPropertiesPanelProps { + selectedComponent?: ComponentData; + tables: TableInfo[]; + onUpdateProperty: (componentId: string, path: string, value: any) => void; + onDeleteComponent?: (componentId: string) => void; + onCopyComponent?: (componentId: string) => void; + currentTable?: TableInfo; + currentTableName?: string; + dragState?: any; +} + +export const UnifiedPropertiesPanel: React.FC = ({ + selectedComponent, + tables, + onUpdateProperty, + onDeleteComponent, + onCopyComponent, + currentTable, + currentTableName, + dragState, +}) => { + const { webTypes } = useWebTypes({ active: "Y" }); + const [activeTab, setActiveTab] = useState("basic"); + const [localComponentDetailType, setLocalComponentDetailType] = useState(""); + + // 새로운 컴포넌트 시스템의 webType 동기화 + useEffect(() => { + if (selectedComponent?.type === "component") { + const webType = selectedComponent.componentConfig?.webType; + if (webType) { + setLocalComponentDetailType(webType); + } + } + }, [selectedComponent?.type, selectedComponent?.componentConfig?.webType, selectedComponent?.id]); + + // 컴포넌트가 선택되지 않았을 때 + if (!selectedComponent) { + return ( +
+ +

컴포넌트를 선택하여

+

속성을 편집하세요

+
+ ); + } + + const handleUpdate = (path: string, value: any) => { + onUpdateProperty(selectedComponent.id, path, value); + }; + + // 드래그 중일 때 실시간 위치 표시 + const currentPosition = + dragState?.isDragging && dragState?.draggedComponent?.id === selectedComponent.id + ? dragState.currentPosition + : selectedComponent.position; + + // 컴포넌트별 설정 패널 렌더링 함수 (DetailSettingsPanel의 로직) + const renderComponentConfigPanel = () => { + if (!selectedComponent) return null; + + const componentType = selectedComponent.componentConfig?.type || selectedComponent.type; + + const handleUpdateProperty = (path: string, value: any) => { + onUpdateProperty(selectedComponent.id, path, value); + }; + + switch (componentType) { + case "button": + case "button-primary": + case "button-secondary": + return ; + + case "card": + return ; + + case "dashboard": + return ; + + case "stats": + case "stats-card": + return ; + + case "progress": + case "progress-bar": + return ; + + case "chart": + case "chart-basic": + return ; + + case "alert": + case "alert-info": + return ; + + case "badge": + case "badge-status": + return ; + + default: + return null; + } + }; + + // 기본 정보 탭 + const renderBasicTab = () => { + const widget = selectedComponent as WidgetComponent; + const group = selectedComponent as GroupComponent; + const area = selectedComponent as AreaComponent; + + return ( +
+ {/* 컴포넌트 정보 */} +
+
+
+ + 컴포넌트 정보 +
+ + {selectedComponent.type} + +
+
+
ID: {selectedComponent.id}
+ {widget.widgetType &&
위젯: {widget.widgetType}
} +
+
+ + {/* 라벨 */} +
+ + handleUpdate("label", e.target.value)} + placeholder="컴포넌트 라벨" + /> +
+ + {/* Placeholder (widget만) */} + {selectedComponent.type === "widget" && ( +
+ + handleUpdate("placeholder", e.target.value)} + placeholder="입력 안내 텍스트" + /> +
+ )} + + {/* Title (group/area) */} + {(selectedComponent.type === "group" || selectedComponent.type === "area") && ( +
+ + handleUpdate("title", e.target.value)} + placeholder="제목" + /> +
+ )} + + {/* Description (area만) */} + {selectedComponent.type === "area" && ( +
+ + handleUpdate("description", e.target.value)} + placeholder="설명" + /> +
+ )} + + {/* 크기 */} +
+ + { + const value = parseInt(e.target.value) || 0; + // 40 단위로 반올림 + const roundedValue = Math.max(40, Math.round(value / 40) * 40); + handleUpdate("size.height", roundedValue); + }} + step={40} + placeholder="40 단위로 입력" + /> +

40 단위로 자동 조정됩니다

+
+ + {/* 컬럼 스팬 */} + {widget.columnSpan !== undefined && ( +
+ + +
+ )} + + {/* Grid Columns */} + {(selectedComponent as any).gridColumns !== undefined && ( +
+ + +
+ )} + + {/* 위치 */} +
+
+ + +
+
+ + +
+
+ + handleUpdate("position.z", parseInt(e.target.value) || 1)} + /> +
+
+ + {/* 라벨 스타일 */} + + + 라벨 스타일 + + + +
+ + handleUpdate("style.labelText", e.target.value)} + /> +
+
+
+ + handleUpdate("style.labelFontSize", e.target.value)} + /> +
+
+ + handleUpdate("style.labelColor", e.target.value)} + /> +
+
+
+ + handleUpdate("style.labelMarginBottom", e.target.value)} + /> +
+
+ handleUpdate("style.labelDisplay", checked)} + /> + +
+
+
+ + {/* 옵션 */} +
+
+ handleUpdate("visible", checked)} + /> + +
+
+ handleUpdate("disabled", checked)} + /> + +
+ {widget.required !== undefined && ( +
+ handleUpdate("required", checked)} + /> + +
+ )} + {widget.readonly !== undefined && ( +
+ handleUpdate("readonly", checked)} + /> + +
+ )} +
+ + {/* 액션 버튼 */} + +
+ {onCopyComponent && ( + + )} + {onDeleteComponent && ( + + )} +
+
+ ); + }; + + // 상세 설정 탭 (DetailSettingsPanel의 전체 로직 통합) + const renderDetailTab = () => { + // 1. DataTable 컴포넌트 + if (selectedComponent.type === "datatable") { + return ( + { + Object.entries(updates).forEach(([key, value]) => { + handleUpdate(key, value); + }); + }} + /> + ); + } + + // 3. 파일 컴포넌트 + if (isFileComponent(selectedComponent)) { + return ( + + ); + } + + // 4. 새로운 컴포넌트 시스템 (button, card 등) + const componentType = selectedComponent.componentConfig?.type || selectedComponent.type; + const hasNewConfigPanel = + componentType && + [ + "button", + "button-primary", + "button-secondary", + "card", + "dashboard", + "stats", + "stats-card", + "progress", + "progress-bar", + "chart", + "chart-basic", + "alert", + "alert-info", + "badge", + "badge-status", + ].includes(componentType); + + if (hasNewConfigPanel) { + const configPanel = renderComponentConfigPanel(); + if (configPanel) { + return
{configPanel}
; + } + } + + // 5. 새로운 컴포넌트 시스템 (type: "component") + if (selectedComponent.type === "component") { + const componentId = (selectedComponent as any).componentType || selectedComponent.componentConfig?.type; + const webType = selectedComponent.componentConfig?.webType; + + if (!componentId) { + return ( +
+

컴포넌트 ID가 설정되지 않았습니다

+
+ ); + } + + // 현재 웹타입의 기본 입력 타입 추출 + const currentBaseInputType = webType ? getBaseInputType(webType as any) : null; + + // 선택 가능한 세부 타입 목록 + const availableDetailTypes = currentBaseInputType ? getDetailTypes(currentBaseInputType) : []; + + // 세부 타입 변경 핸들러 + const handleDetailTypeChange = (newDetailType: string) => { + setLocalComponentDetailType(newDetailType); + handleUpdate("componentConfig.webType", newDetailType); + }; + + return ( +
+ {/* 컴포넌트 정보 */} +
+ 컴포넌트: {componentId} + {webType && currentBaseInputType && ( +
입력 타입: {currentBaseInputType}
+ )} +
+ + {/* 세부 타입 선택 */} + {webType && availableDetailTypes.length > 1 && ( +
+ + +

입력 타입 "{currentBaseInputType}"의 세부 형태를 선택하세요

+
+ )} + + {/* DynamicComponentConfigPanel */} + { + Object.entries(newConfig).forEach(([key, value]) => { + handleUpdate(`componentConfig.${key}`, value); + }); + }} + /> + + {/* 웹타입별 특화 설정 */} + {webType && ( +
+

웹타입 설정

+ { + Object.entries(newConfig).forEach(([key, value]) => { + handleUpdate(`componentConfig.${key}`, value); + }); + }} + /> +
+ )} +
+ ); + } + + // 6. Widget 컴포넌트 + if (selectedComponent.type === "widget") { + const widget = selectedComponent as WidgetComponent; + + // Widget에 webType이 있는 경우 + if (widget.webType) { + return ( +
+ {/* WebType 선택 */} +
+ + +
+ + {/* WebType 설정 패널 */} + { + Object.entries(newConfig).forEach(([key, value]) => { + handleUpdate(`webTypeConfig.${key}`, value); + }); + }} + /> +
+ ); + } + + // 새로운 컴포넌트 시스템 (widgetType이 button, card 등) + if ( + widget.widgetType && + ["button", "card", "dashboard", "stats-card", "progress-bar", "chart", "alert", "badge"].includes( + widget.widgetType, + ) + ) { + return ( + { + Object.entries(newConfig).forEach(([key, value]) => { + handleUpdate(`componentConfig.${key}`, value); + }); + }} + /> + ); + } + } + + // 기본 메시지 + return ( +
+

이 컴포넌트는 추가 설정이 없습니다

+
+ ); + }; + + // 데이터 바인딩 탭 + const renderDataTab = () => { + if (selectedComponent.type !== "widget") { + return ( +
+

이 컴포넌트는 데이터 바인딩을 지원하지 않습니다

+
+ ); + } + + const widget = selectedComponent as WidgetComponent; + + return ( +
+
+
+ + 데이터 바인딩 +
+
+ + {/* 테이블 컬럼 */} +
+ + handleUpdate("columnName", e.target.value)} + placeholder="컬럼명 입력" + /> +
+ + {/* 기본값 */} +
+ + handleUpdate("defaultValue", e.target.value)} + placeholder="기본값 입력" + /> +
+
+ ); + }; + + return ( +
+ {/* 헤더 */} +
+
+
+ +

속성 편집

+
+ {selectedComponent.type} +
+ {selectedComponent.type === "widget" && ( +
+ {(selectedComponent as WidgetComponent).label || selectedComponent.id} +
+ )} +
+ + {/* 탭 컨텐츠 */} +
+ + + 기본 + 상세 + 데이터 + + +
+ + {renderBasicTab()} + + + {renderDetailTab()} + + + {renderDataTab()} + +
+
+
+
+ ); +}; + +export default UnifiedPropertiesPanel; diff --git a/frontend/components/screen/panels/WebTypeConfigPanel.tsx b/frontend/components/screen/panels/WebTypeConfigPanel.tsx new file mode 100644 index 00000000..9227c269 --- /dev/null +++ b/frontend/components/screen/panels/WebTypeConfigPanel.tsx @@ -0,0 +1,419 @@ +"use client"; + +import React from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Separator } from "@/components/ui/separator"; +import { Plus, Trash2 } from "lucide-react"; +import { WebType } from "@/types/screen"; + +interface WebTypeConfigPanelProps { + webType: WebType | string; + config: any; + onUpdateConfig: (config: any) => void; +} + +/** + * 웹타입별 특화 설정 패널 + */ +export const WebTypeConfigPanel: React.FC = ({ webType, config, onUpdateConfig }) => { + // webType을 소문자로 변환하고 기본 타입 추출 + const normalizedWebType = String(webType || "").toLowerCase(); + + // 기본 타입 추출 (예: "radio-horizontal" -> "radio", "checkbox-basic" -> "checkbox") + const getBaseType = (type: string): string => { + if (type.startsWith("radio")) return "radio"; + if (type.startsWith("checkbox")) return "checkbox"; + if (type.startsWith("select")) return "select"; + if (type.startsWith("number")) return "number"; + if (type.startsWith("text")) return "text"; + if (type.startsWith("date")) return "date"; + if (type.startsWith("file")) return "file"; + if (type.startsWith("image")) return "image"; + return type; + }; + + const baseType = getBaseType(normalizedWebType); + + console.log("🎨 WebTypeConfigPanel:", { webType, normalizedWebType, baseType }); + + // 선택형 입력 (select, radio, checkbox) - 옵션 관리 + if (baseType === "select" || baseType === "radio" || baseType === "checkbox") { + const options = config?.options || []; + const multiple = config?.multiple || false; + const searchable = config?.searchable || false; + + return ( +
+
+ + +
+ + {/* 옵션 리스트 */} +
+ {options.map((option: any, index: number) => ( +
+ { + const newOptions = [...options]; + newOptions[index] = { ...option, label: e.target.value }; + onUpdateConfig({ ...config, options: newOptions }); + }} + className="flex-1" + /> + { + const newOptions = [...options]; + newOptions[index] = { ...option, value: e.target.value }; + onUpdateConfig({ ...config, options: newOptions }); + }} + className="flex-1" + /> + +
+ ))} +
+ + {/* Select 전용 설정 */} + {baseType === "select" && ( + <> + +
+ + { + onUpdateConfig({ ...config, multiple: checked }); + }} + /> +
+
+ + { + onUpdateConfig({ ...config, searchable: checked }); + }} + /> +
+ + )} +
+ ); + } + + // 숫자 입력 (number, decimal) - 범위 설정 + if (baseType === "number" || baseType === "decimal") { + const min = config?.min; + const max = config?.max; + const step = config?.step || (baseType === "decimal" ? 0.1 : 1); + + return ( +
+ + +
+
+ + { + onUpdateConfig({ + ...config, + min: e.target.value ? Number(e.target.value) : undefined, + }); + }} + /> +
+
+ + { + onUpdateConfig({ + ...config, + max: e.target.value ? Number(e.target.value) : undefined, + }); + }} + /> +
+
+ +
+ + { + onUpdateConfig({ + ...config, + step: e.target.value ? Number(e.target.value) : undefined, + }); + }} + /> +
+
+ ); + } + + // 날짜/시간 입력 - 형식 설정 + if (baseType === "date" || baseType === "datetime" || baseType === "time") { + const format = + config?.format || (baseType === "date" ? "YYYY-MM-DD" : baseType === "datetime" ? "YYYY-MM-DD HH:mm" : "HH:mm"); + const showTime = config?.showTime || false; + + return ( +
+ + +
+ + +
+ + {baseType === "date" && ( +
+ + { + onUpdateConfig({ ...config, showTime: checked }); + }} + /> +
+ )} +
+ ); + } + + // 텍스트 입력 - 검증 규칙 + if ( + baseType === "text" || + baseType === "email" || + baseType === "tel" || + baseType === "url" || + baseType === "textarea" + ) { + const minLength = config?.minLength; + const maxLength = config?.maxLength; + const pattern = config?.pattern; + + return ( +
+ + +
+
+ + { + onUpdateConfig({ + ...config, + minLength: e.target.value ? Number(e.target.value) : undefined, + }); + }} + /> +
+
+ + { + onUpdateConfig({ + ...config, + maxLength: e.target.value ? Number(e.target.value) : undefined, + }); + }} + /> +
+
+ + {(webType === "text" || webType === "textarea") && ( +
+ + { + onUpdateConfig({ + ...config, + pattern: e.target.value || undefined, + }); + }} + /> +

입력값 검증에 사용할 정규식 패턴

+
+ )} +
+ ); + } + + // 파일 업로드 - 파일 타입 제한 + if (baseType === "file" || baseType === "image") { + const accept = config?.accept || (baseType === "image" ? "image/*" : "*/*"); + const maxSize = config?.maxSize || 10; + const multiple = config?.multiple || false; + + return ( +
+ + +
+ + { + onUpdateConfig({ ...config, accept: e.target.value }); + }} + /> +

쉼표로 구분된 파일 확장자 또는 MIME 타입

+
+ +
+ + { + onUpdateConfig({ + ...config, + maxSize: e.target.value ? Number(e.target.value) : 10, + }); + }} + /> +
+ +
+ + { + onUpdateConfig({ ...config, multiple: checked }); + }} + /> +
+
+ ); + } + + // 기본 메시지 (설정 불필요) + return ( +
+

이 웹타입은 추가 설정이 필요하지 않습니다.

+
+ ); +}; diff --git a/frontend/components/screen/toolbar/LeftUnifiedToolbar.tsx b/frontend/components/screen/toolbar/LeftUnifiedToolbar.tsx new file mode 100644 index 00000000..91407caf --- /dev/null +++ b/frontend/components/screen/toolbar/LeftUnifiedToolbar.tsx @@ -0,0 +1,115 @@ +"use client"; + +import React from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Database, Layout, Cog, Settings, Palette, Monitor } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export interface ToolbarButton { + id: string; + label: string; + icon: React.ReactNode; + shortcut: string; + group: "source" | "editor"; + panelWidth: number; +} + +interface LeftUnifiedToolbarProps { + buttons: ToolbarButton[]; + panelStates: Record; + onTogglePanel: (panelId: string) => void; +} + +export const LeftUnifiedToolbar: React.FC = ({ buttons, panelStates, onTogglePanel }) => { + // 그룹별로 버튼 분류 + const sourceButtons = buttons.filter((btn) => btn.group === "source"); + const editorButtons = buttons.filter((btn) => btn.group === "editor"); + + const renderButton = (button: ToolbarButton) => { + const isActive = panelStates[button.id]?.isOpen || false; + + return ( + + ); + }; + + return ( +
+ {/* 입력/소스 그룹 */} +
{sourceButtons.map(renderButton)}
+ + {/* 편집/설정 그룹 */} +
{editorButtons.map(renderButton)}
+ + {/* 하단 여백 */} +
+
+ ); +}; + +// 기본 버튼 설정 +export const defaultToolbarButtons: ToolbarButton[] = [ + // 입력/소스 그룹 + { + id: "tables", + label: "테이블", + icon: , + shortcut: "T", + group: "source", + panelWidth: 380, + }, + { + id: "components", + label: "컴포넌트", + icon: , + shortcut: "C", + group: "source", + panelWidth: 350, + }, + + // 편집/설정 그룹 + { + id: "properties", + label: "속성", + icon: , + shortcut: "P", + group: "editor", + panelWidth: 400, + }, + { + id: "styles", + label: "스타일", + icon: , + shortcut: "S", + group: "editor", + panelWidth: 360, + }, + { + id: "resolution", + label: "해상도", + icon: , + shortcut: "E", + group: "editor", + panelWidth: 300, + }, +]; + +export default LeftUnifiedToolbar; diff --git a/frontend/components/screen/toolbar/SlimToolbar.tsx b/frontend/components/screen/toolbar/SlimToolbar.tsx new file mode 100644 index 00000000..3cc2bc20 --- /dev/null +++ b/frontend/components/screen/toolbar/SlimToolbar.tsx @@ -0,0 +1,114 @@ +"use client"; + +import React from "react"; +import { Button } from "@/components/ui/button"; +import { Database, ArrowLeft, Undo, Redo, Play, Save, Monitor } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { ScreenResolution } from "@/types/screen"; + +interface SlimToolbarProps { + screenName?: string; + tableName?: string; + screenResolution?: ScreenResolution; + onBack: () => void; + onSave: () => void; + onUndo: () => void; + onRedo: () => void; + onPreview: () => void; + canUndo: boolean; + canRedo: boolean; + isSaving?: boolean; +} + +export const SlimToolbar: React.FC = ({ + screenName, + tableName, + screenResolution, + onBack, + onSave, + onUndo, + onRedo, + onPreview, + canUndo, + canRedo, + isSaving = false, +}) => { + return ( +
+ {/* 좌측: 네비게이션 및 화면 정보 */} +
+ + +
+ +
+
+

{screenName || "화면 설계"}

+ {tableName && ( +
+ + {tableName} +
+ )} +
+
+ + {/* 해상도 정보 표시 */} + {screenResolution && ( + <> +
+
+ + {screenResolution.name} + + ({screenResolution.width} × {screenResolution.height}) + +
+ + )} +
+ + {/* 우측: 액션 버튼들 */} +
+ + + + +
+ + + + +
+
+ ); +}; + +export default SlimToolbar; diff --git a/frontend/hooks/usePanelState.ts b/frontend/hooks/usePanelState.ts index 407a6953..ae550baa 100644 --- a/frontend/hooks/usePanelState.ts +++ b/frontend/hooks/usePanelState.ts @@ -58,27 +58,56 @@ export const usePanelState = (panels: PanelConfig[]) => { }); }, [panels]); - // 패널 토글 + // 패널 토글 (다른 패널 자동 닫기) const togglePanel = useCallback((panelId: string) => { - setPanelStates((prev) => ({ - ...prev, - [panelId]: { - ...prev[panelId], - isOpen: !prev[panelId]?.isOpen, - }, - })); + setPanelStates((prev) => { + const isCurrentlyOpen = prev[panelId]?.isOpen; + const newStates = { ...prev }; + + // 다른 모든 패널 닫기 + Object.keys(newStates).forEach((id) => { + if (id !== panelId) { + newStates[id] = { + ...newStates[id], + isOpen: false, + }; + } + }); + + // 현재 패널 토글 + newStates[panelId] = { + ...newStates[panelId], + isOpen: !isCurrentlyOpen, + }; + + return newStates; + }); }, []); - // 패널 열기 + // 패널 열기 (다른 패널 자동 닫기) const openPanel = useCallback((panelId: string) => { // console.log("📂 패널 열기:", panelId); - setPanelStates((prev) => ({ - ...prev, - [panelId]: { - ...prev[panelId], + setPanelStates((prev) => { + const newStates = { ...prev }; + + // 다른 모든 패널 닫기 + Object.keys(newStates).forEach((id) => { + if (id !== panelId) { + newStates[id] = { + ...newStates[id], + isOpen: false, + }; + } + }); + + // 현재 패널 열기 + newStates[panelId] = { + ...newStates[panelId], isOpen: true, - }, - })); + }; + + return newStates; + }); }, []); // 패널 닫기 diff --git a/frontend/lib/constants/columnSpans.ts b/frontend/lib/constants/columnSpans.ts index 2494850f..3c7ba55a 100644 --- a/frontend/lib/constants/columnSpans.ts +++ b/frontend/lib/constants/columnSpans.ts @@ -9,14 +9,19 @@ */ export type ColumnSpanPreset = | "full" // 12 컬럼 (100%) - | "half" // 6 컬럼 (50%) - | "third" // 4 컬럼 (33%) - | "twoThirds" // 8 컬럼 (67%) - | "quarter" // 3 컬럼 (25%) + | "eleven-twelfths" // 11 컬럼 (92%) + | "five-sixths" // 10 컬럼 (83%) | "threeQuarters" // 9 컬럼 (75%) + | "twoThirds" // 8 컬럼 (67%) + | "seven-twelfths" // 7 컬럼 (58%) + | "half" // 6 컬럼 (50%) + | "five-twelfths" // 5 컬럼 (42%) + | "third" // 4 컬럼 (33%) + | "quarter" // 3 컬럼 (25%) + | "small" // 2 컬럼 (17%) + | "twelfth" // 1 컬럼 (8%) | "label" // 3 컬럼 (25%) - 폼 라벨 전용 | "input" // 9 컬럼 (75%) - 폼 입력 전용 - | "small" // 2 컬럼 (17%) | "medium" // 4 컬럼 (33%) | "large" // 8 컬럼 (67%) | "auto"; // 자동 계산 @@ -26,14 +31,19 @@ export type ColumnSpanPreset = */ export const COLUMN_SPAN_VALUES: Record = { full: 12, - half: 6, - third: 4, - twoThirds: 8, - quarter: 3, + "eleven-twelfths": 11, + "five-sixths": 10, threeQuarters: 9, + twoThirds: 8, + "seven-twelfths": 7, + half: 6, + "five-twelfths": 5, + third: 4, + quarter: 3, + small: 2, + twelfth: 1, label: 3, input: 9, - small: 2, medium: 4, large: 8, auto: 0, // 자동 계산 시 0 @@ -54,33 +64,19 @@ export interface ColumnSpanPresetInfo { * 컬럼 스팬 프리셋 상세 정보 */ export const COLUMN_SPAN_PRESETS: Record = { - full: { - value: 12, - label: "전체", - percentage: "100%", - class: "col-span-12", - description: "전체 너비 (테이블, 제목 등)", + twelfth: { + value: 1, + label: "1/12", + percentage: "8.33%", + class: "col-span-1", + description: "최소 너비", }, - half: { - value: 6, - label: "절반", - percentage: "50%", - class: "col-span-6", - description: "2분할 레이아웃", - }, - third: { - value: 4, - label: "1/3", - percentage: "33%", - class: "col-span-4", - description: "3분할 레이아웃", - }, - twoThirds: { - value: 8, - label: "2/3", - percentage: "67%", - class: "col-span-8", - description: "큰 컴포넌트", + small: { + value: 2, + label: "1/6", + percentage: "16.67%", + class: "col-span-2", + description: "아이콘, 체크박스", }, quarter: { value: 3, @@ -89,6 +85,41 @@ export const COLUMN_SPAN_PRESETS: Record class: "col-span-3", description: "4분할 레이아웃", }, + third: { + value: 4, + label: "1/3", + percentage: "33.33%", + class: "col-span-4", + description: "3분할 레이아웃", + }, + "five-twelfths": { + value: 5, + label: "5/12", + percentage: "41.67%", + class: "col-span-5", + description: "중간 크기", + }, + half: { + value: 6, + label: "절반", + percentage: "50%", + class: "col-span-6", + description: "2분할 레이아웃", + }, + "seven-twelfths": { + value: 7, + label: "7/12", + percentage: "58.33%", + class: "col-span-7", + description: "큰 크기", + }, + twoThirds: { + value: 8, + label: "2/3", + percentage: "66.67%", + class: "col-span-8", + description: "큰 컴포넌트", + }, threeQuarters: { value: 9, label: "3/4", @@ -96,6 +127,27 @@ export const COLUMN_SPAN_PRESETS: Record class: "col-span-9", description: "입력 필드", }, + "five-sixths": { + value: 10, + label: "5/6", + percentage: "83.33%", + class: "col-span-10", + description: "매우 큰 컴포넌트", + }, + "eleven-twelfths": { + value: 11, + label: "11/12", + percentage: "91.67%", + class: "col-span-11", + description: "거의 전체", + }, + full: { + value: 12, + label: "전체", + percentage: "100%", + class: "col-span-12", + description: "전체 너비 (테이블, 제목 등)", + }, label: { value: 3, label: "라벨용", @@ -110,24 +162,17 @@ export const COLUMN_SPAN_PRESETS: Record class: "col-span-9", description: "폼 입력 전용", }, - small: { - value: 2, - label: "작게", - percentage: "17%", - class: "col-span-2", - description: "아이콘, 체크박스", - }, medium: { value: 4, label: "보통", - percentage: "33%", + percentage: "33.33%", class: "col-span-4", description: "보통 크기 컴포넌트", }, large: { value: 8, label: "크게", - percentage: "67%", + percentage: "66.67%", class: "col-span-8", description: "큰 컴포넌트", }, diff --git a/frontend/lib/registry/components/accordion-basic/index.ts b/frontend/lib/registry/components/accordion-basic/index.ts index a509395e..ce740bc1 100644 --- a/frontend/lib/registry/components/accordion-basic/index.ts +++ b/frontend/lib/registry/components/accordion-basic/index.ts @@ -50,7 +50,7 @@ export const AccordionBasicDefinition = createComponentDefinition({ collapsible: true, defaultValue: "item-1", }, - defaultSize: { width: 300, height: 200 }, + defaultSize: { width: 400, height: 200 }, configPanel: AccordionBasicConfigPanel, icon: "ChevronDown", tags: ["아코디언", "접기", "펼치기", "콘텐츠", "섹션"], diff --git a/frontend/lib/registry/components/button-primary/index.ts b/frontend/lib/registry/components/button-primary/index.ts index d98339de..f9e19a14 100644 --- a/frontend/lib/registry/components/button-primary/index.ts +++ b/frontend/lib/registry/components/button-primary/index.ts @@ -30,7 +30,7 @@ export const ButtonPrimaryDefinition = createComponentDefinition({ errorMessage: "저장 중 오류가 발생했습니다.", }, }, - defaultSize: { width: 120, height: 36 }, + defaultSize: { width: 120, height: 40 }, configPanel: ButtonPrimaryConfigPanel, icon: "MousePointer", tags: ["버튼", "액션", "클릭"], diff --git a/frontend/lib/registry/components/checkbox-basic/CheckboxBasicComponent.tsx b/frontend/lib/registry/components/checkbox-basic/CheckboxBasicComponent.tsx index e2480e0d..cd725849 100644 --- a/frontend/lib/registry/components/checkbox-basic/CheckboxBasicComponent.tsx +++ b/frontend/lib/registry/components/checkbox-basic/CheckboxBasicComponent.tsx @@ -1,8 +1,10 @@ "use client"; -import React from "react"; +import React, { useState } from "react"; import { ComponentRendererProps } from "@/types/component"; import { CheckboxBasicConfig } from "./types"; +import { cn } from "@/lib/registry/components/common/inputStyles"; +import { filterDOMProps } from "@/lib/utils/domPropsFilter"; export interface CheckboxBasicComponentProps extends ComponentRendererProps { config?: CheckboxBasicConfig; @@ -33,6 +35,13 @@ export const CheckboxBasicComponent: React.FC = ({ ...component.config, } as CheckboxBasicConfig; + // webType에 따른 세부 타입 결정 (TextInputComponent와 동일한 방식) + const webType = component.componentConfig?.webType || "checkbox"; + + // 상태 관리 + const [isChecked, setIsChecked] = useState(component.value === true || component.value === "true"); + const [checkedValues, setCheckedValues] = useState([]); + // 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외) const componentStyle: React.CSSProperties = { width: "100%", @@ -53,116 +62,116 @@ export const CheckboxBasicComponent: React.FC = ({ onClick?.(); }; - // DOM에 전달하면 안 되는 React-specific props 필터링 - const { - selectedScreen, - onZoneComponentDrop, - onZoneClick, - componentConfig: _componentConfig, - component: _component, - isSelected: _isSelected, - onClick: _onClick, - onDragStart: _onDragStart, - onDragEnd: _onDragEnd, - size: _size, - position: _position, - style: _style, - screenId: _screenId, - tableName: _tableName, - onRefresh: _onRefresh, - onClose: _onClose, - ...domProps - } = props; + const handleCheckboxChange = (checked: boolean) => { + setIsChecked(checked); + if (component.onChange) { + component.onChange(checked); + } + if (isInteractive && onFormDataChange && component.columnName) { + onFormDataChange(component.columnName, checked); + } + }; + + const handleGroupChange = (value: string, checked: boolean) => { + const newValues = checked ? [...checkedValues, value] : checkedValues.filter((v) => v !== value); + setCheckedValues(newValues); + if (isInteractive && onFormDataChange && component.columnName) { + onFormDataChange(component.columnName, newValues.join(",")); + } + }; + + // DOM 안전한 props만 필터링 + const safeDomProps = filterDOMProps(props); + + // 세부 타입별 렌더링 + const renderCheckboxByWebType = () => { + // boolean: On/Off 스위치 + if (webType === "boolean") { + return ( +