diff --git a/docs/FINAL_GRID_MIGRATION_ROADMAP.md b/docs/FINAL_GRID_MIGRATION_ROADMAP.md new file mode 100644 index 00000000..28b86238 --- /dev/null +++ b/docs/FINAL_GRID_MIGRATION_ROADMAP.md @@ -0,0 +1,553 @@ +# 🗺️ 화면 관리 시스템 - 최종 그리드 마이그레이션 로드맵 + +## 🎯 최종 목표 + +> **"Tailwind CSS 12컬럼 그리드 기반의 제한된 자유도 시스템"** +> +> - ❌ 픽셀 기반 width 완전 제거 +> - ✅ 컬럼 스팬(1-12)으로만 너비 제어 +> - ✅ 행(Row) 기반 레이아웃 구조 +> - ✅ 정형화된 디자인 패턴 제공 + +--- + +## 📊 현재 시스템 vs 새 시스템 + +### Before (현재) + +```typescript +// 픽셀 기반 절대 위치 시스템 +interface ComponentData { + position: { x: number; y: number }; // 픽셀 좌표 + size: { width: number; height: number }; // 픽셀 크기 +} + +// 사용자 입력 + +// → 예: 850px 입력 가능 (자유롭지만 일관성 없음) +``` + +### After (새 시스템) + +```typescript +// 행 기반 그리드 시스템 +interface ComponentData { + gridRowIndex: number; // 몇 번째 행인가 + gridColumnSpan: ColumnSpanPreset; // 너비 (컬럼 수) + gridColumnStart?: number; // 시작 위치 (선택) + size: { height: number }; // 높이만 픽셀 지정 +} + +// 사용자 선택 +; +// → 예: "half" 선택 → 정확히 50% 너비 (일관성 보장) +``` + +--- + +## 🏗️ 새 시스템 구조 + +### 1. 화면 구성 방식 + +``` +화면 (Screen) + ├─ 행 1 (Row 1) [12 컬럼 그리드] + │ ├─ 컴포넌트 A (3 컬럼) + │ ├─ 컴포넌트 B (9 컬럼) + │ └─ [자동 배치] + │ + ├─ 행 2 (Row 2) [12 컬럼 그리드] + │ ├─ 컴포넌트 C (4 컬럼) + │ ├─ 컴포넌트 D (4 컬럼) + │ ├─ 컴포넌트 E (4 컬럼) + │ └─ [자동 배치] + │ + └─ 행 3 (Row 3) [12 컬럼 그리드] + └─ 컴포넌트 F (12 컬럼 - 전체) +``` + +### 2. 허용되는 컬럼 스팬 + +| 프리셋 | 컬럼 수 | 백분율 | 용도 | +| --------------- | ------- | ------ | --------------------------- | +| `full` | 12 | 100% | 전체 너비 (테이블, 제목 등) | +| `threeQuarters` | 9 | 75% | 입력 필드 | +| `twoThirds` | 8 | 67% | 큰 컴포넌트 | +| `half` | 6 | 50% | 2분할 레이아웃 | +| `third` | 4 | 33% | 3분할 레이아웃 | +| `quarter` | 3 | 25% | 라벨, 4분할 | +| `label` | 3 | 25% | 폼 라벨 전용 | +| `input` | 9 | 75% | 폼 입력 전용 | +| `small` | 2 | 17% | 아이콘, 체크박스 | +| `medium` | 4 | 33% | 보통 크기 | +| `large` | 8 | 67% | 큰 컴포넌트 | + +### 3. 사용자 워크플로우 + +``` +1. 새 행 추가 + ↓ +2. 행에 컴포넌트 드래그 & 드롭 + ↓ +3. 컴포넌트 선택 → 컬럼 스팬 선택 + ↓ +4. 필요시 시작 위치 조정 (고급 설정) + ↓ +5. 행 간격, 정렬 등 설정 +``` + +--- + +## 📅 구현 단계 (4주 계획) + +### Week 1: 기반 구축 + +**Day 1-2: 타입 시스템** + +- [ ] `Size` 인터페이스에서 `width` 제거 +- [ ] `BaseComponent`에 `gridColumnSpan`, `gridRowIndex` 추가 +- [ ] `ColumnSpanPreset` 타입 및 상수 정의 +- [ ] `LayoutRow` 인터페이스 정의 + +**Day 3-4: 핵심 UI 컴포넌트** + +- [ ] `PropertiesPanel` - width 입력 → 컬럼 스팬 선택 UI로 변경 +- [ ] 시각적 그리드 프리뷰 추가 (12컬럼 미니 그리드) +- [ ] `RowSettingsPanel` 신규 생성 (행 설정) +- [ ] `ComponentGridPanel` 신규 생성 (컴포넌트 너비 설정) + +**Day 5: 렌더링 로직** + +- [ ] `LayoutRowRenderer` 신규 생성 +- [ ] `ContainerComponent` - `gridColumn` 계산 로직 수정 +- [ ] `RealtimePreview` - 그리드 클래스 적용 + +**Day 6-7: 마이그레이션 준비** + +- [ ] `widthToColumnSpan.ts` 유틸리티 작성 +- [ ] 기존 데이터 변환 함수 작성 +- [ ] Y 좌표 → 행 인덱스 변환 로직 + +### Week 2: 레이아웃 시스템 + +**Day 1-2: GridLayoutBuilder** + +- [ ] `GridLayoutBuilder` 메인 컴포넌트 +- [ ] 행 추가/삭제/순서 변경 기능 +- [ ] 행 선택 및 하이라이트 + +**Day 3-4: 드래그앤드롭** + +- [ ] 컴포넌트를 행에 드롭하는 기능 +- [ ] 행 내에서 컴포넌트 순서 변경 +- [ ] 행 간 컴포넌트 이동 +- [ ] 드롭 가이드라인 표시 + +**Day 5-7: StyleEditor 정리** + +- [ ] `StyleEditor`에서 width 옵션 완전 제거 +- [ ] `ScreenDesigner`에서 width 관련 로직 제거 +- [ ] 높이 설정만 남기기 + +### Week 3: 템플릿 및 패턴 + +**Day 1-3: 템플릿 시스템** + +- [ ] `TemplateComponent` 타입 수정 +- [ ] 기본 폼 템플릿 업데이트 +- [ ] 검색 + 테이블 템플릿 +- [ ] 대시보드 템플릿 +- [ ] 마스터-디테일 템플릿 + +**Day 4-5: 레이아웃 패턴** + +- [ ] 정형화된 레이아웃 패턴 정의 +- [ ] 패턴 선택 UI +- [ ] 패턴 적용 로직 +- [ ] 빠른 패턴 삽입 버튼 + +**Day 6-7: 반응형 기반 (선택)** + +- [ ] 브레이크포인트별 컬럼 스팬 설정 +- [ ] 반응형 편집 UI +- [ ] 반응형 프리뷰 + +### Week 4: 마이그레이션 및 안정화 + +**Day 1-2: 자동 마이그레이션** + +- [ ] 화면 로드 시 자동 변환 로직 +- [ ] 마이그레이션 로그 및 검증 +- [ ] 에러 처리 및 fallback + +**Day 3-4: 통합 테스트** + +- [ ] 새 컴포넌트 생성 테스트 +- [ ] 기존 화면 로드 테스트 +- [ ] 템플릿 적용 테스트 +- [ ] 드래그앤드롭 테스트 +- [ ] 속성 편집 테스트 + +**Day 5: Tailwind 설정** + +- [ ] `tailwind.config.js` safelist 추가 +- [ ] 불필요한 유틸리티 제거 +- [ ] 빌드 테스트 + +**Day 6-7: 문서화 및 배포** + +- [ ] 사용자 가이드 작성 +- [ ] 개발자 문서 업데이트 +- [ ] 릴리즈 노트 작성 +- [ ] 점진적 배포 + +--- + +## 📁 수정/생성 파일 전체 목록 + +### 🆕 신규 생성 파일 (15개) + +1. `frontend/types/grid-system.ts` - 그리드 시스템 타입 +2. `frontend/lib/constants/columnSpans.ts` - 컬럼 스팬 상수 +3. `frontend/lib/utils/widthToColumnSpan.ts` - 마이그레이션 유틸 +4. `frontend/lib/utils/gridLayoutUtils.ts` - 그리드 레이아웃 헬퍼 +5. `frontend/components/screen/GridLayoutBuilder.tsx` - 메인 빌더 +6. `frontend/components/screen/LayoutRowRenderer.tsx` - 행 렌더러 +7. `frontend/components/screen/AddRowButton.tsx` - 행 추가 버튼 +8. `frontend/components/screen/GridGuides.tsx` - 그리드 가이드라인 +9. `frontend/components/screen/GridDropZone.tsx` - 드롭존 +10. `frontend/components/screen/panels/RowSettingsPanel.tsx` - 행 설정 +11. `frontend/components/screen/panels/ComponentGridPanel.tsx` - 컴포넌트 너비 +12. `frontend/components/screen/panels/ResponsivePanel.tsx` - 반응형 설정 +13. `frontend/lib/templates/layoutPatterns.ts` - 레이아웃 패턴 +14. `frontend/hooks/useGridLayout.ts` - 그리드 레이아웃 훅 +15. `frontend/hooks/useRowManagement.ts` - 행 관리 훅 + +### ✏️ 수정 파일 (20개) + +1. `frontend/types/screen-management.ts` - Size에서 width 제거 +2. `frontend/components/screen/panels/PropertiesPanel.tsx` - UI 대폭 수정 +3. `frontend/components/screen/StyleEditor.tsx` - width 옵션 제거 +4. `frontend/components/screen/ScreenDesigner.tsx` - 전체 로직 수정 +5. `frontend/components/screen/RealtimePreviewDynamic.tsx` - 그리드 클래스 +6. `frontend/components/screen/InteractiveScreenViewerDynamic.tsx` - 그리드 클래스 +7. `frontend/components/screen/layout/ContainerComponent.tsx` - 렌더링 수정 +8. `frontend/components/screen/layout/ColumnComponent.tsx` - 렌더링 수정 +9. `frontend/components/screen/layout/RowComponent.tsx` - 렌더링 수정 +10. `frontend/components/screen/panels/TemplatesPanel.tsx` - 템플릿 수정 +11. `frontend/components/screen/panels/DataTableConfigPanel.tsx` - 모달 크기 유지 +12. `frontend/components/screen/panels/DetailSettingsPanel.tsx` - 검토 필요 +13. `frontend/components/screen/panels/ComponentsPanel.tsx` - 컴포넌트 생성 +14. `frontend/components/screen/panels/LayoutsPanel.tsx` - 레이아웃 적용 +15. `frontend/components/screen/templates/DataTableTemplate.tsx` - 템플릿 수정 +16. `frontend/lib/api/screen.ts` - 마이그레이션 로직 추가 +17. `tailwind.config.js` - safelist 추가 +18. `frontend/components/screen/FloatingPanel.tsx` - 검토 (패널 width 유지) +19. `frontend/components/screen/SaveModal.tsx` - 검토 +20. `frontend/components/screen/EditModal.tsx` - 검토 + +--- + +## 🎨 핵심 UI 변경사항 + +### 1. 컴포넌트 너비 설정 (Before → After) + +**Before**: + +```tsx + + setWidth(e.target.value)} +/> +// 사용자가 아무 숫자나 입력 가능 (850, 1234 등) +``` + +**After**: + +```tsx + + + +{/* 시각적 프리뷰 */} +
+ {Array.from({ length: 12 }).map((_, i) => ( +
+ ))} +
+

6 / 12 컬럼

+``` + +### 2. 행(Row) 관리 UI (신규) + +```tsx +{ + /* 각 행에 설정 가능한 옵션 */ +} + + + + + + + + + + + + + + + + + + + +; +``` + +### 3. 드래그앤드롭 경험 개선 + +```tsx +{ + /* 빈 행 */ +} +
+ 컴포넌트를 여기에 드래그하세요 + {/* 빠른 패턴 버튼 */} +
+ + + +
+
; + +{ + /* 드롭 시 그리드 가이드라인 표시 */ +} +
+ {/* 12개 컬럼 구분선 */} + {Array.from({ length: 12 }).map((_, i) => ( +
+ ))} +
; +``` + +--- + +## 🔍 마이그레이션 상세 전략 + +### 1. 자동 변환 알고리즘 + +```typescript +// 기존 컴포넌트 변환 프로세스 +function migrateComponent(oldComponent: OldComponentData): ComponentData { + // Step 1: 픽셀 width → 컬럼 스팬 + const gridColumnSpan = convertWidthToColumnSpan( + oldComponent.size.width, + 1920 // 기준 캔버스 너비 + ); + + // Step 2: Y 좌표 → 행 인덱스 + const gridRowIndex = calculateRowIndex( + oldComponent.position.y, + allComponents + ); + + // Step 3: X 좌표 → 시작 컬럼 (같은 행 내) + const gridColumnStart = calculateColumnStart( + oldComponent.position.x, + rowComponents + ); + + return { + ...oldComponent, + gridColumnSpan, + gridRowIndex, + gridColumnStart, + size: { + height: oldComponent.size.height, // 높이만 유지 + // width 제거 + }, + }; +} +``` + +### 2. 변환 정확도 보장 + +```typescript +// 변환 전후 비교 +const before = { + position: { x: 100, y: 50 }, + size: { width: 960, height: 40 }, // 50% 너비 +}; + +const after = { + gridRowIndex: 0, + gridColumnSpan: "half", // 정확히 50% + gridColumnStart: 1, // 자동 계산 + size: { height: 40 }, +}; + +// 시각적으로 동일한 결과 보장 +``` + +### 3. 예외 처리 + +```typescript +// 변환 불가능한 경우 처리 +function migrateWithFallback(component: OldComponentData): ComponentData { + try { + return migrateComponent(component); + } catch (error) { + console.error("마이그레이션 실패:", error); + + // Fallback: 기본값 사용 + return { + ...component, + gridColumnSpan: "half", // 안전한 기본값 + gridRowIndex: 0, + size: { height: component.size.height }, + }; + } +} +``` + +--- + +## ⚠️ 주의사항 및 제약 + +### 1. 제거되는 기능 + +- ❌ 픽셀 단위 정밀 너비 조정 +- ❌ 자유로운 width 숫자 입력 +- ❌ 커스텀 width 값 + +### 2. 유지되는 기능 + +- ✅ 높이(height) 픽셀 입력 +- ✅ 위치(Y 좌표) 조정 +- ✅ 모든 스타일 옵션 (width 제외) + +### 3. 특수 케이스 처리 + +#### 3.1 모달/팝업 + +```typescript +// 모달은 컬럼 스팬 사용 안 함 +interface ModalConfig { + width: "sm" | "md" | "lg" | "xl" | "2xl" | "full"; // 기존 유지 +} +``` + +#### 3.2 FloatingPanel + +```typescript +// 편집 패널 자체는 픽셀 width 유지 + +``` + +#### 3.3 사이드바 + +```typescript +// 사이드바도 컬럼 스팬으로 변경 +interface SidebarConfig { + sidebarSpan: ColumnSpanPreset; // "quarter" | "third" | "half" +} +``` + +--- + +## 🎯 완료 기준 + +### 기능 완성도 + +- [ ] 모든 기존 화면이 새 시스템에서 정상 표시 +- [ ] 새 컴포넌트 생성 및 배치 정상 동작 +- [ ] 템플릿 적용 정상 동작 +- [ ] 드래그앤드롭 정상 동작 + +### 코드 품질 + +- [ ] TypeScript 에러 0개 +- [ ] Linter 경고 0개 +- [ ] 불필요한 width 관련 코드 완전 제거 +- [ ] 주석 및 문서화 완료 + +### 성능 + +- [ ] 렌더링 성능 저하 없음 +- [ ] 마이그레이션 속도 < 1초 (화면당) +- [ ] 메모리 사용량 증가 없음 + +### 사용자 경험 + +- [ ] 직관적인 UI +- [ ] 시각적 프리뷰 제공 +- [ ] 빠른 패턴 적용 +- [ ] 에러 메시지 명확 + +--- + +## 📊 예상 효과 + +### 정량적 효과 + +- 코드 라인 감소: ~500줄 (width 계산 로직 제거) +- 버그 감소: width 관련 버그 100% 제거 +- 개발 속도: 화면 구성 시간 30% 단축 +- 유지보수: width 관련 이슈 0건 + +### 정성적 효과 + +- ✅ 일관된 디자인 시스템 +- ✅ 학습 곡선 감소 +- ✅ Tailwind 표준 준수 +- ✅ 반응형 자동 대응 (추후) +- ✅ 디자인 품질 향상 + +--- + +## 🚀 다음 단계 + +### 즉시 시작 가능 + +1. Phase 1 타입 정의 작성 +2. PropertiesPanel UI 목업 +3. 마이그레이션 유틸리티 스켈레톤 + +### 추후 확장 + +1. 반응형 브레이크포인트 +2. 커스텀 레이아웃 패턴 저장 +3. AI 기반 레이아웃 추천 +4. 컴포넌트 자동 정렬 + +--- + +## 📚 관련 문서 + +- [전체 그리드 시스템 설계](./GRID_SYSTEM_REDESIGN_PLAN.md) +- [Width 제거 상세 계획](./WIDTH_REMOVAL_MIGRATION_PLAN.md) +- Tailwind CSS 그리드 문서 +- 사용자 가이드 (작성 예정) + +--- + +이 로드맵을 따라 진행하면 **4주 내에 완전히 새로운 그리드 시스템**을 구축할 수 있습니다! 🎉 + +**준비되셨나요? 어디서부터 시작하시겠습니까?** 💪 diff --git a/docs/GRID_SYSTEM_REDESIGN_PLAN.md b/docs/GRID_SYSTEM_REDESIGN_PLAN.md new file mode 100644 index 00000000..9a35933f --- /dev/null +++ b/docs/GRID_SYSTEM_REDESIGN_PLAN.md @@ -0,0 +1,1260 @@ +# 🎨 화면 관리 시스템 - 제한된 자유도 그리드 시스템 설계 + +## 🎯 핵심 철학: "제한된 자유도 (Constrained Freedom)" + +> "Tailwind CSS 표준을 벗어나지 않으면서도 충분한 디자인 자유도 제공" + +### 설계 원칙 + +1. ✅ **12컬럼 그리드 기반** - Tailwind 표준 준수 +2. ✅ **정형화된 레이아웃** - 미리 정의된 패턴 사용 +3. ✅ **제한된 선택지** - 무질서한 배치 방지 +4. ✅ **반응형 자동화** - 브레이크포인트 자동 처리 +5. ✅ **일관성 보장** - 모든 화면이 통일된 디자인 + +--- + +## 📐 그리드 시스템 설계 + +### 1. 기본 12컬럼 구조 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 1 2 3 4 5 6 7 8 9 10 11 12│ ← 컬럼 번호 +├─────────────────────────────────────────────────────────┤ +│ [ 전체 너비 (12) ]│ ← 100% +│ [ 절반 (6) ][ 절반 (6) ]│ ← 50% + 50% +│ [ 1/3 ][ 1/3 ][ 1/3 ] │ ← 33% × 3 +│ [1/4][1/4][1/4][1/4] │ ← 25% × 4 +└─────────────────────────────────────────────────────────┘ +``` + +### 2. 허용되는 컬럼 스팬 (제한된 선택지) + +```typescript +// 실제로 선택 가능한 너비만 제공 +export const ALLOWED_COLUMN_SPANS = { + full: 12, // 전체 너비 + half: 6, // 절반 + third: 4, // 1/3 + twoThirds: 8, // 2/3 + quarter: 3, // 1/4 + threeQuarters: 9, // 3/4 + + // 특수 케이스 (라벨-입력 조합 등) + label: 3, // 라벨용 (25%) + input: 9, // 입력용 (75%) + small: 2, // 작은 요소 (체크박스 등) + medium: 4, // 중간 요소 + large: 8, // 큰 요소 +} as const; + +export type ColumnSpanPreset = keyof typeof ALLOWED_COLUMN_SPANS; +``` + +### 3. 행(Row) 기반 배치 시스템 + +```typescript +/** + * 화면은 여러 행(Row)으로 구성 + * 각 행은 독립적인 12컬럼 그리드 + */ +interface RowComponent { + id: string; + type: "row"; + rowIndex: number; // 행 순서 + height?: "auto" | "fixed"; // 높이 모드 + fixedHeight?: number; // 고정 높이 (px) + gap?: 2 | 4 | 6 | 8; // Tailwind gap (제한된 값) + padding?: 2 | 4 | 6 | 8; // Tailwind padding + alignment?: "start" | "center" | "end" | "stretch"; + children: ComponentInRow[]; // 이 행에 속한 컴포넌트들 +} + +interface ComponentInRow { + id: string; + columnSpan: ColumnSpanPreset; // 미리 정의된 값만 + columnStart?: number; // 시작 컬럼 (자동 계산 가능) + component: ComponentData; // 실제 컴포넌트 데이터 +} +``` + +--- + +## 🎨 정형화된 레이아웃 패턴 + +### 패턴 1: 폼 레이아웃 (Form Layout) + +``` +┌─────────────────────────────────────────┐ +│ 라벨 (3) │ 입력 필드 (9) │ ← 기본 폼 행 +├─────────────────────────────────────────┤ +│ 라벨 (3) │ 입력1 (4.5) │ 입력2 (4.5) │ ← 2개 입력 +├─────────────────────────────────────────┤ +│ 라벨 (3) │ 텍스트영역 (9) │ ← 긴 입력 +└─────────────────────────────────────────┘ +``` + +```typescript +const FORM_PATTERNS = { + // 1. 기본 폼 행: 라벨(25%) + 입력(75%) + standardInput: { + label: { span: "label" as const }, // 3 columns + input: { span: "input" as const }, // 9 columns + }, + + // 2. 2컬럼 입력: 라벨 + 입력1 + 입력2 + doubleInput: { + label: { span: "label" as const }, // 3 columns + input1: { span: "quarter" as const }, // 3 columns + input2: { span: "quarter" as const }, // 3 columns + // 나머지 3컬럼은 여백 + }, + + // 3. 전체 너비 입력 (제목 등) + fullWidthInput: { + label: { span: "full" as const }, + input: { span: "full" as const }, + }, + + // 4. 라벨 없는 입력 + noLabelInput: { + input: { span: "full" as const }, + }, +}; +``` + +### 패턴 2: 테이블 레이아웃 + +``` +┌─────────────────────────────────────────┐ +│ [검색 영역 - 전체 너비] │ +├─────────────────────────────────────────┤ +│ [테이블 - 전체 너비] │ +├─────────────────────────────────────────┤ +│ [페이지네이션 - 전체 너비] │ +└─────────────────────────────────────────┘ +``` + +### 패턴 3: 대시보드 레이아웃 + +``` +┌─────────────────────────────────────────┐ +│ [카드1 (4)] │ [카드2 (4)] │ [카드3 (4)]│ ← 3컬럼 +├─────────────────────────────────────────┤ +│ [차트 (8)] │ [통계 (4)] │ ← 2:1 비율 +├─────────────────────────────────────────┤ +│ [전체 너비 테이블 (12)] │ +└─────────────────────────────────────────┘ +``` + +### 패턴 4: 마스터-디테일 레이아웃 + +``` +┌─────────────────────────────────────────┐ +│ [마스터 테이블 (12)] │ ← 전체 +├─────────────────────────────────────────┤ +│ [디테일 정보 (6)] │ [디테일 폼 (6)] │ ← 50:50 +└─────────────────────────────────────────┘ +``` + +--- + +## 🛠️ 구현 상세 설계 + +### 1. 타입 정의 + +```typescript +// frontend/types/grid-system.ts + +/** + * 허용된 컬럼 스팬 프리셋 + */ +export const COLUMN_SPAN_PRESETS = { + full: { value: 12, label: "전체 (100%)", class: "col-span-12" }, + half: { value: 6, label: "절반 (50%)", class: "col-span-6" }, + third: { value: 4, label: "1/3 (33%)", class: "col-span-4" }, + twoThirds: { value: 8, label: "2/3 (67%)", class: "col-span-8" }, + quarter: { value: 3, label: "1/4 (25%)", class: "col-span-3" }, + threeQuarters: { value: 9, label: "3/4 (75%)", class: "col-span-9" }, + label: { value: 3, label: "라벨용 (25%)", class: "col-span-3" }, + input: { value: 9, label: "입력용 (75%)", class: "col-span-9" }, + small: { value: 2, label: "작게 (17%)", class: "col-span-2" }, + medium: { value: 4, label: "보통 (33%)", class: "col-span-4" }, + large: { value: 8, label: "크게 (67%)", class: "col-span-8" }, +} as const; + +export type ColumnSpanPreset = keyof typeof COLUMN_SPAN_PRESETS; + +/** + * 허용된 Gap 값 (Tailwind 표준) + */ +export const GAP_PRESETS = { + none: { value: 0, label: "없음", class: "gap-0" }, + xs: { value: 2, label: "매우 작게 (8px)", class: "gap-2" }, + sm: { value: 4, label: "작게 (16px)", class: "gap-4" }, + md: { value: 6, label: "보통 (24px)", class: "gap-6" }, + lg: { value: 8, label: "크게 (32px)", class: "gap-8" }, +} as const; + +export type GapPreset = keyof typeof GAP_PRESETS; + +/** + * 레이아웃 행 정의 + */ +export interface LayoutRow { + id: string; + rowIndex: number; + height: "auto" | "fixed" | "min" | "max"; + minHeight?: number; + maxHeight?: number; + fixedHeight?: number; + gap: GapPreset; + padding: GapPreset; + backgroundColor?: string; + alignment: "start" | "center" | "end" | "stretch" | "baseline"; + verticalAlignment: "top" | "middle" | "bottom" | "stretch"; + components: RowComponent[]; +} + +/** + * 행 내 컴포넌트 + */ +export interface RowComponent { + id: string; + componentId: string; // 실제 ComponentData의 ID + columnSpan: ColumnSpanPreset; + columnStart?: number; // 명시적 시작 위치 (선택) + order?: number; // 정렬 순서 + offset?: ColumnSpanPreset; // 왼쪽 여백 +} + +/** + * 전체 레이아웃 정의 + */ +export interface GridLayout { + screenId: number; + rows: LayoutRow[]; + components: Map; // 컴포넌트 저장소 + globalSettings: { + containerMaxWidth?: "full" | "7xl" | "6xl" | "5xl" | "4xl"; + containerPadding: GapPreset; + }; +} +``` + +### 2. 레이아웃 빌더 컴포넌트 + +```tsx +// components/screen/GridLayoutBuilder.tsx + +interface GridLayoutBuilderProps { + layout: GridLayout; + onUpdateLayout: (layout: GridLayout) => void; + selectedRowId?: string; + selectedComponentId?: string; +} + +export const GridLayoutBuilder: React.FC = ({ + layout, + onUpdateLayout, + selectedRowId, + selectedComponentId, +}) => { + return ( +
+ {/* 글로벌 컨테이너 */} +
+ {/* 각 행 렌더링 */} + {layout.rows.map((row) => ( + onSelectRow(row.id)} + onUpdateRow={(updatedRow) => updateRow(row.id, updatedRow)} + /> + ))} + + {/* 새 행 추가 버튼 */} + +
+ + {/* 그리드 가이드라인 (개발 모드) */} + {showGridGuides && } +
+ ); +}; +``` + +### 3. 행(Row) 렌더러 + +```tsx +// components/screen/LayoutRowRenderer.tsx + +interface LayoutRowRendererProps { + row: LayoutRow; + components: Map; + isSelected: boolean; + selectedComponentId?: string; + onSelectRow: () => void; + onUpdateRow: (row: LayoutRow) => void; +} + +export const LayoutRowRenderer: React.FC = ({ + row, + components, + isSelected, + selectedComponentId, + onSelectRow, + onUpdateRow, +}) => { + const rowClasses = cn( + // 그리드 기본 + "grid grid-cols-12 w-full", + + // Gap + GAP_PRESETS[row.gap].class, + + // Padding + GAP_PRESETS[row.padding].class.replace("gap-", "p-"), + + // 높이 + row.height === "auto" && "h-auto", + row.height === "fixed" && `h-[${row.fixedHeight}px]`, + row.height === "min" && `min-h-[${row.minHeight}px]`, + row.height === "max" && `max-h-[${row.maxHeight}px]`, + + // 정렬 + row.alignment === "start" && "justify-items-start", + row.alignment === "center" && "justify-items-center", + row.alignment === "end" && "justify-items-end", + row.alignment === "stretch" && "justify-items-stretch", + + row.verticalAlignment === "top" && "items-start", + row.verticalAlignment === "middle" && "items-center", + row.verticalAlignment === "bottom" && "items-end", + row.verticalAlignment === "stretch" && "items-stretch", + + // 선택 상태 + isSelected && "ring-2 ring-blue-500 ring-inset", + + // 배경색 + row.backgroundColor && `bg-${row.backgroundColor}`, + + // 호버 효과 + "hover:bg-gray-100 transition-colors cursor-pointer" + ); + + return ( +
+ {row.components.map((rowComponent) => { + const component = components.get(rowComponent.componentId); + if (!component) return null; + + const componentClasses = cn( + // 컬럼 스팬 + COLUMN_SPAN_PRESETS[rowComponent.columnSpan].class, + + // 명시적 시작 위치 + rowComponent.columnStart && `col-start-${rowComponent.columnStart}`, + + // 오프셋 (여백) + rowComponent.offset && + `ml-[${ + COLUMN_SPAN_PRESETS[rowComponent.offset].value * (100 / 12) + }%]`, + + // 정렬 순서 + rowComponent.order && `order-${rowComponent.order}`, + + // 선택 상태 + selectedComponentId === component.id && + "ring-2 ring-green-500 ring-inset" + ); + + return ( +
+ onSelectComponent(component.id)} + /> +
+ ); + })} +
+ ); +}; +``` + +### 4. 행 설정 패널 + +```tsx +// components/screen/panels/RowSettingsPanel.tsx + +interface RowSettingsPanelProps { + row: LayoutRow; + onUpdateRow: (row: LayoutRow) => void; +} + +export const RowSettingsPanel: React.FC = ({ + row, + onUpdateRow, +}) => { + return ( +
+ {/* 높이 설정 */} +
+ + + + {row.height === "fixed" && ( + + onUpdateRow({ ...row, fixedHeight: parseInt(e.target.value) }) + } + className="mt-2" + placeholder="높이 (px)" + /> + )} +
+ + {/* 간격 설정 */} +
+ +
+ {(Object.keys(GAP_PRESETS) as GapPreset[]).map((preset) => ( + + ))} +
+
+ + {/* 패딩 설정 */} +
+ +
+ {(Object.keys(GAP_PRESETS) as GapPreset[]).map((preset) => ( + + ))} +
+
+ + {/* 정렬 설정 */} +
+ +
+ + + + +
+
+ + {/* 수직 정렬 */} +
+ +
+ + + + +
+
+ + {/* 배경색 */} +
+ + + onUpdateRow({ ...row, backgroundColor: e.target.value }) + } + className="mt-2" + /> +
+
+ ); +}; +``` + +### 5. 컴포넌트 너비 설정 패널 + +```tsx +// components/screen/panels/ComponentGridPanel.tsx + +interface ComponentGridPanelProps { + rowComponent: RowComponent; + onUpdate: (rowComponent: RowComponent) => void; +} + +export const ComponentGridPanel: React.FC = ({ + rowComponent, + onUpdate, +}) => { + return ( +
+ {/* 컬럼 스팬 선택 */} +
+ +
+ {Object.entries(COLUMN_SPAN_PRESETS).map(([key, config]) => ( + + ))} +
+
+ + {/* 시각적 프리뷰 */} +
+ +
+ {Array.from({ length: 12 }).map((_, i) => { + const spanValue = + COLUMN_SPAN_PRESETS[rowComponent.columnSpan].value; + const startCol = rowComponent.columnStart || 1; + const isActive = i + 1 >= startCol && i + 1 < startCol + spanValue; + + return ( +
+ ); + })} +
+
+ {COLUMN_SPAN_PRESETS[rowComponent.columnSpan].value} 컬럼 차지 +
+
+ + {/* 고급 옵션 */} + + + + + + {/* 시작 위치 명시 */} +
+ + +
+ + {/* 정렬 순서 */} +
+ + + onUpdate({ + ...rowComponent, + order: parseInt(e.target.value), + }) + } + placeholder="0 (자동)" + /> +
+ + {/* 왼쪽 오프셋 */} +
+ + +
+
+
+
+ ); +}; +``` + +--- + +## 🎭 레이아웃 패턴 템플릿 + +### 템플릿 시스템 + +```typescript +// lib/templates/layoutPatterns.ts + +export interface LayoutPattern { + id: string; + name: string; + description: string; + category: "form" | "table" | "dashboard" | "master-detail" | "custom"; + thumbnail?: string; + rows: LayoutRow[]; +} + +export const LAYOUT_PATTERNS: LayoutPattern[] = [ + // 1. 기본 폼 레이아웃 + { + id: "basic-form", + name: "기본 폼", + description: "라벨-입력 필드 조합의 표준 폼", + category: "form", + rows: [ + { + id: "row-1", + rowIndex: 0, + height: "auto", + gap: "sm", + padding: "sm", + alignment: "start", + verticalAlignment: "middle", + components: [ + { + id: "comp-1", + componentId: "placeholder-label", + columnSpan: "label", + }, + { + id: "comp-2", + componentId: "placeholder-input", + columnSpan: "input", + }, + ], + }, + ], + }, + + // 2. 2컬럼 폼 + { + id: "two-column-form", + name: "2컬럼 폼", + description: "두 개의 입력 필드를 나란히 배치", + category: "form", + rows: [ + { + id: "row-1", + rowIndex: 0, + height: "auto", + gap: "sm", + padding: "sm", + alignment: "start", + verticalAlignment: "middle", + components: [ + // 왼쪽 폼 + { + id: "comp-1", + componentId: "label-1", + columnSpan: "small", + }, + { + id: "comp-2", + componentId: "input-1", + columnSpan: "quarter", + }, + // 오른쪽 폼 + { + id: "comp-3", + componentId: "label-2", + columnSpan: "small", + }, + { + id: "comp-4", + componentId: "input-2", + columnSpan: "quarter", + }, + ], + }, + ], + }, + + // 3. 검색 + 테이블 레이아웃 + { + id: "search-table", + name: "검색 + 테이블", + description: "검색 영역과 데이터 테이블 조합", + category: "table", + rows: [ + // 검색 영역 + { + id: "row-1", + rowIndex: 0, + height: "auto", + gap: "sm", + padding: "md", + alignment: "start", + verticalAlignment: "middle", + components: [ + { + id: "search-1", + componentId: "search-component", + columnSpan: "full", + }, + ], + }, + // 테이블 + { + id: "row-2", + rowIndex: 1, + height: "auto", + gap: "none", + padding: "md", + alignment: "stretch", + verticalAlignment: "stretch", + components: [ + { + id: "table-1", + componentId: "table-component", + columnSpan: "full", + }, + ], + }, + // 페이지네이션 + { + id: "row-3", + rowIndex: 2, + height: "auto", + gap: "none", + padding: "md", + alignment: "center", + verticalAlignment: "middle", + components: [ + { + id: "pagination-1", + componentId: "pagination-component", + columnSpan: "full", + }, + ], + }, + ], + }, + + // 4. 3컬럼 대시보드 + { + id: "three-column-dashboard", + name: "3컬럼 대시보드", + description: "동일한 크기의 3개 카드", + category: "dashboard", + rows: [ + { + id: "row-1", + rowIndex: 0, + height: "auto", + gap: "md", + padding: "md", + alignment: "stretch", + verticalAlignment: "stretch", + components: [ + { + id: "card-1", + componentId: "card-1", + columnSpan: "third", + }, + { + id: "card-2", + componentId: "card-2", + columnSpan: "third", + }, + { + id: "card-3", + componentId: "card-3", + columnSpan: "third", + }, + ], + }, + ], + }, + + // 5. 마스터-디테일 + { + id: "master-detail", + name: "마스터-디테일", + description: "상단 마스터, 하단 디테일 2분할", + category: "master-detail", + rows: [ + // 마스터 테이블 + { + id: "row-1", + rowIndex: 0, + height: "fixed", + fixedHeight: 400, + gap: "none", + padding: "md", + alignment: "stretch", + verticalAlignment: "stretch", + components: [ + { + id: "master-1", + componentId: "master-table", + columnSpan: "full", + }, + ], + }, + // 디테일 2분할 + { + id: "row-2", + rowIndex: 1, + height: "auto", + gap: "md", + padding: "md", + alignment: "stretch", + verticalAlignment: "stretch", + components: [ + { + id: "detail-left", + componentId: "detail-info", + columnSpan: "half", + }, + { + id: "detail-right", + componentId: "detail-form", + columnSpan: "half", + }, + ], + }, + ], + }, +]; +``` + +--- + +## 🎨 사용자 경험 (UX) 설계 + +### 1. 화면 구성 워크플로우 + +``` +1단계: 레이아웃 패턴 선택 + ↓ +2단계: 행 추가/삭제/순서 변경 + ↓ +3단계: 각 행에 컴포넌트 배치 + ↓ +4단계: 컴포넌트 너비 조정 + ↓ +5단계: 세부 속성 설정 +``` + +### 2. 행 추가 UI + +```tsx +
+
+ + 새 행 추가 +
+ + {/* 빠른 패턴 선택 */} +
+ + + +
+
+``` + +### 3. 컴포넌트 배치 UI + +```tsx +// 컴포넌트를 행에 드래그앤드롭 + + {row.components.length === 0 ? ( +
+ + 컴포넌트를 여기에 드래그하세요 + +
+ ) : ( + // 기존 컴포넌트 렌더링 + )} +
+``` + +### 4. 너비 조정 UI (인터랙티브) + +```tsx +// 컴포넌트 선택 시 너비 조정 핸들 표시 +
+ + + {isSelected && ( +
+ + {currentSpanLabel} + +
+ )} +
+``` + +--- + +## 🔄 마이그레이션 전략 + +### 레거시 데이터 변환 + +```typescript +// lib/utils/legacyMigration.ts + +/** + * 기존 픽셀 기반 레이아웃을 행 기반 그리드로 변환 + */ +export function migratePixelLayoutToGridLayout( + oldLayout: LegacyLayoutData +): GridLayout { + const canvasWidth = 1920; // 기준 캔버스 너비 + const rows: LayoutRow[] = []; + + // Y 좌표로 그룹핑 (같은 행에 속한 컴포넌트들) + const rowGroups = groupComponentsByYPosition(oldLayout.components); + + rowGroups.forEach((components, rowIndex) => { + const rowComponents: RowComponent[] = components.map((comp) => { + // 픽셀 너비를 컬럼 스팬으로 변환 + const columnSpan = determineClosestColumnSpan( + comp.size.width, + canvasWidth + ); + + return { + id: generateId(), + componentId: comp.id, + columnSpan, + columnStart: undefined, // 자동 배치 + }; + }); + + rows.push({ + id: generateId(), + rowIndex, + height: "auto", + gap: "sm", + padding: "sm", + alignment: "start", + verticalAlignment: "middle", + components: rowComponents, + }); + }); + + return { + screenId: oldLayout.screenId, + rows, + components: new Map(oldLayout.components.map((c) => [c.id, c])), + globalSettings: { + containerMaxWidth: "7xl", + containerPadding: "md", + }, + }; +} + +/** + * 픽셀 너비를 가장 가까운 컬럼 스팬으로 변환 + */ +function determineClosestColumnSpan( + pixelWidth: number, + canvasWidth: number +): ColumnSpanPreset { + const percentage = (pixelWidth / canvasWidth) * 100; + + // 가장 가까운 프리셋 찾기 + const presets: Array<[ColumnSpanPreset, number]> = [ + ["full", 100], + ["threeQuarters", 75], + ["twoThirds", 67], + ["half", 50], + ["third", 33], + ["quarter", 25], + ["label", 25], + ["input", 75], + ]; + + let closest: ColumnSpanPreset = "half"; + let minDiff = Infinity; + + for (const [preset, presetPercentage] of presets) { + const diff = Math.abs(percentage - presetPercentage); + if (diff < minDiff) { + minDiff = diff; + closest = preset; + } + } + + return closest; +} + +/** + * Y 좌표 기준으로 컴포넌트 그룹핑 + */ +function groupComponentsByYPosition( + components: ComponentData[] +): ComponentData[][] { + const threshold = 50; // 50px 이내는 같은 행으로 간주 + const sorted = [...components].sort((a, b) => a.position.y - b.position.y); + + const groups: ComponentData[][] = []; + let currentGroup: ComponentData[] = []; + let currentY = sorted[0]?.position.y ?? 0; + + for (const comp of sorted) { + if (Math.abs(comp.position.y - currentY) <= threshold) { + currentGroup.push(comp); + } else { + if (currentGroup.length > 0) { + groups.push(currentGroup); + } + currentGroup = [comp]; + currentY = comp.position.y; + } + } + + if (currentGroup.length > 0) { + groups.push(currentGroup); + } + + return groups; +} +``` + +--- + +## 📱 반응형 지원 (추후 확장) + +### 브레이크포인트별 설정 + +```typescript +interface ResponsiveRowComponent extends RowComponent { + // 기본값 (모바일) + columnSpan: ColumnSpanPreset; + + // 태블릿 + mdColumnSpan?: ColumnSpanPreset; + + // 데스크톱 + lgColumnSpan?: ColumnSpanPreset; +} + +// 렌더링 시 +const classes = cn( + COLUMN_SPAN_PRESETS[component.columnSpan].class, + component.mdColumnSpan && + `md:${COLUMN_SPAN_PRESETS[component.mdColumnSpan].class}`, + component.lgColumnSpan && + `lg:${COLUMN_SPAN_PRESETS[component.lgColumnSpan].class}` +); +``` + +--- + +## ✅ 구현 체크리스트 + +### Phase 1: 타입 및 기본 구조 (Week 1) + +- [ ] 새로운 타입 정의 (`grid-system.ts`) +- [ ] 프리셋 정의 (컬럼 스팬, Gap, Padding) +- [ ] 기본 유틸리티 함수 + +### Phase 2: 레이아웃 빌더 UI (Week 2) + +- [ ] `GridLayoutBuilder` 컴포넌트 +- [ ] `LayoutRowRenderer` 컴포넌트 +- [ ] `AddRowButton` 컴포넌트 +- [ ] 행 선택/삭제/순서 변경 + +### Phase 3: 속성 편집 패널 (Week 2-3) + +- [ ] `RowSettingsPanel` - 행 설정 +- [ ] `ComponentGridPanel` - 컴포넌트 너비 설정 +- [ ] 시각적 프리뷰 UI + +### Phase 4: 드래그앤드롭 (Week 3) + +- [ ] 컴포넌트를 행에 드롭 +- [ ] 행 내에서 컴포넌트 순서 변경 +- [ ] 행 간 컴포넌트 이동 + +### Phase 5: 템플릿 시스템 (Week 3-4) + +- [ ] 레이아웃 패턴 정의 +- [ ] 템플릿 선택 UI +- [ ] 패턴 적용 로직 + +### Phase 6: 마이그레이션 (Week 4) + +- [ ] 레거시 데이터 변환 함수 +- [ ] 자동 마이그레이션 스크립트 +- [ ] 데이터 검증 + +### Phase 7: 테스트 및 문서화 (Week 4) + +- [ ] 단위 테스트 +- [ ] 통합 테스트 +- [ ] 사용자 가이드 작성 + +--- + +## 🎯 핵심 장점 요약 + +### ✅ 제한된 자유도의 이점 + +1. **일관성**: 모든 화면이 동일한 디자인 시스템 따름 +2. **유지보수성**: 정형화된 패턴으로 수정 용이 +3. **품질 보장**: 잘못된 레이아웃 원천 차단 +4. **학습 용이**: 단순한 개념으로 빠른 습득 +5. **반응형**: Tailwind 표준으로 자동 대응 + +### ✅ 충분한 자유도 + +1. **다양한 레이아웃**: 수십 가지 조합 가능 +2. **유연한 배치**: 행 단위로 자유롭게 구성 +3. **세밀한 제어**: 정렬, 간격, 높이 등 조정 +4. **확장 가능**: 새로운 패턴 추가 용이 + +--- + +이 설계는 **"정형화된 자유도"**를 제공하여, 사용자가 디자인 원칙을 벗어나지 않으면서도 원하는 레이아웃을 자유롭게 만들 수 있게 합니다! 🎨 diff --git a/docs/WIDTH_REMOVAL_MIGRATION_PLAN.md b/docs/WIDTH_REMOVAL_MIGRATION_PLAN.md new file mode 100644 index 00000000..cf66b7af --- /dev/null +++ b/docs/WIDTH_REMOVAL_MIGRATION_PLAN.md @@ -0,0 +1,1000 @@ +# 🗑️ Width 속성 완전 제거 계획서 + +## 🎯 목표 + +현재 화면 관리 시스템에서 **픽셀 기반 width 설정을 완전히 제거**하고, **컬럼 수(gridColumnSpan)로만 제어**하도록 변경 + +## 📊 현재 Width 사용 현황 + +### 1. 타입 정의에서의 width + +```typescript +// frontend/types/screen-management.ts +export interface BaseComponent { + size: Size; // ❌ 제거 대상 +} + +export interface Size { + width: number; // ❌ 제거 + height: number; // ✅ 유지 (행 높이 제어용) +} +``` + +### 2. PropertiesPanel에서 width 입력 UI + +**위치**: `frontend/components/screen/panels/PropertiesPanel.tsx` + +- 라인 665-680: 너비 입력 필드 +- 라인 1081-1092: 사이드바 너비 설정 + +### 3. StyleEditor에서 width 스타일 + +**위치**: `frontend/components/screen/ScreenDesigner.tsx` + +- 라인 3874-3891: 스타일에서 width 추출 및 적용 + +### 4. 컴포넌트 렌더링에서 width 사용 + +**위치**: `frontend/components/screen/layout/ContainerComponent.tsx` + +- 라인 27: `gridColumn: span ${component.size.width}` + +### 5. 템플릿에서 width 정의 + +**위치**: `frontend/components/screen/panels/TemplatesPanel.tsx` + +- 라인 48: `defaultSize: { width, height }` +- 라인 54: `size: { width, height }` + +--- + +## 🔄 마이그레이션 전략 + +### Phase 1: 타입 시스템 수정 + +#### 1.1 새로운 타입 정의 + +```typescript +// frontend/types/screen-management.ts + +/** + * 🆕 새로운 Size 인터페이스 (width 제거) + */ +export interface Size { + height: number; // 행 높이만 제어 +} + +/** + * 🆕 BaseComponent 확장 + */ +export interface BaseComponent { + id: string; + type: ComponentType; + position: Position; // y 좌표만 사용 (행 위치) + size: Size; // height만 포함 + + // 🆕 그리드 시스템 속성 + gridColumnSpan: ColumnSpanPreset; // 필수: 컬럼 너비 + gridColumnStart?: number; // 선택: 시작 컬럼 + gridRowIndex: number; // 필수: 행 인덱스 + + parentId?: string; + label?: string; + required?: boolean; + readonly?: boolean; + style?: ComponentStyle; + className?: string; +} + +/** + * 🆕 컬럼 스팬 프리셋 + */ +export type ColumnSpanPreset = + | "full" // 12 컬럼 + | "half" // 6 컬럼 + | "third" // 4 컬럼 + | "twoThirds" // 8 컬럼 + | "quarter" // 3 컬럼 + | "threeQuarters" // 9 컬럼 + | "label" // 3 컬럼 (라벨용) + | "input" // 9 컬럼 (입력용) + | "small" // 2 컬럼 + | "medium" // 4 컬럼 + | "large" // 8 컬럼 + | "auto"; // 자동 계산 + +export const COLUMN_SPAN_VALUES: Record = { + full: 12, + half: 6, + third: 4, + twoThirds: 8, + quarter: 3, + threeQuarters: 9, + label: 3, + input: 9, + small: 2, + medium: 4, + large: 8, + auto: 0, // 자동 계산 +}; +``` + +#### 1.2 ComponentStyle 수정 (width 제거) + +```typescript +export interface ComponentStyle extends CommonStyle { + // ❌ 제거: width + // ✅ 유지: height (컴포넌트 자체 높이) + height?: string; + + // 나머지 스타일 속성들 + margin?: string; + padding?: string; + backgroundColor?: string; + // ... 기타 +} +``` + +--- + +### Phase 2: UI 컴포넌트 수정 + +#### 2.1 PropertiesPanel 수정 + +**파일**: `frontend/components/screen/panels/PropertiesPanel.tsx` + +**변경 전**: + +```typescript +// 라인 665-680 +
+ + { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, width: newValue })); + onUpdateProperty("size.width", Number(newValue)); + }} + className="mt-1" + /> +
+``` + +**변경 후**: + +```typescript +{ + /* 🆕 컬럼 스팬 선택 */ +} +
+ + + + {/* 시각적 프리뷰 */} +
+ +
+ {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; + + return ( +
+ ); + })} +
+

+ {COLUMN_SPAN_VALUES[selectedComponent.gridColumnSpan || "half"]} / 12 컬럼 +

+
+
; + +{ + /* 🆕 시작 컬럼 (고급 설정) */ +} + + + + + +
+ + +

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

+
+
+
; + +{ + /* ✅ 높이는 유지 */ +} +
+ + { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, height: newValue })); + onUpdateProperty("size.height", Number(newValue)); + }} + className="mt-1" + /> +
; +``` + +#### 2.2 사이드바 너비 설정 제거 + +**위치**: PropertiesPanel.tsx 라인 1081-1092 + +**변경 전**: + +```typescript +
+ + { + const value = Number(e.target.value); + onUpdateProperty("layoutConfig.sidebarWidth", value); + }} + className="mt-1" + /> +
+``` + +**변경 후**: + +```typescript +
+ + +
+``` + +--- + +### Phase 3: 렌더링 로직 수정 + +#### 3.1 ContainerComponent 수정 + +**파일**: `frontend/components/screen/layout/ContainerComponent.tsx` + +**변경 전**: + +```typescript +const style: React.CSSProperties = { + gridColumn: `span ${component.size.width}`, // ❌ width 사용 + minHeight: `${component.size.height}px`, + // ... +}; +``` + +**변경 후**: + +```typescript +const style: React.CSSProperties = { + // 🆕 gridColumnSpan 사용 + gridColumn: component.gridColumnStart + ? `${component.gridColumnStart} / span ${ + COLUMN_SPAN_VALUES[component.gridColumnSpan] + }` + : `span ${COLUMN_SPAN_VALUES[component.gridColumnSpan]}`, + minHeight: `${component.size.height}px`, + // style.width는 제거 + ...(component.style && { + // width: component.style.width, ❌ 제거 + height: component.style.height, + margin: component.style.margin, + padding: component.style.padding, + // ... 나머지 + }), +}; +``` + +#### 3.2 RealtimePreview 수정 + +**파일**: `frontend/components/screen/RealtimePreviewDynamic.tsx` + +**추가**: + +```typescript +// 컴포넌트 wrapper에 그리드 클래스 적용 +const gridClasses = useMemo(() => { + if (!component.gridColumnSpan) return ""; + + const spanValue = COLUMN_SPAN_VALUES[component.gridColumnSpan]; + const classes = [`col-span-${spanValue}`]; + + if (component.gridColumnStart) { + classes.push(`col-start-${component.gridColumnStart}`); + } + + return classes.join(" "); +}, [component.gridColumnSpan, component.gridColumnStart]); + +return ( +
+ {/* 컴포넌트 렌더링 */} +
+); +``` + +--- + +### Phase 4: StyleEditor 수정 + +#### 4.1 width 스타일 제거 + +**파일**: `frontend/components/screen/ScreenDesigner.tsx` (라인 3874-3891) + +**변경 전**: + +```typescript +// 크기가 변경된 경우 component.size도 업데이트 +if (newStyle.width || newStyle.height) { + const width = newStyle.width + ? parseInt(newStyle.width.replace("px", "")) + : selectedComponent.size.width; + const height = newStyle.height + ? parseInt(newStyle.height.replace("px", "")) + : selectedComponent.size.height; + + updateComponentProperty(selectedComponent.id, "size.width", width); + updateComponentProperty(selectedComponent.id, "size.height", height); +} +``` + +**변경 후**: + +```typescript +// 높이만 업데이트 (너비는 gridColumnSpan으로 제어) +if (newStyle.height) { + const height = parseInt(newStyle.height.replace("px", "")); + updateComponentProperty(selectedComponent.id, "size.height", height); +} +``` + +#### 4.2 StyleEditor 컴포넌트 자체 수정 + +**파일**: `frontend/components/screen/StyleEditor.tsx` (추정) + +```typescript +// width 관련 탭/입력 제거 +// ❌ 제거 대상: +// - 너비 입력 필드 +// - min-width, max-width 설정 +// - width 관련 모든 스타일 옵션 + +// ✅ 유지: +// - height 입력 필드 +// - min-height, max-height 설정 +``` + +--- + +### Phase 5: 템플릿 시스템 수정 + +#### 5.1 TemplateComponent 타입 수정 + +**파일**: `frontend/components/screen/panels/TemplatesPanel.tsx` + +**변경 전**: + +```typescript +export interface TemplateComponent { + id: string; + name: string; + description: string; + category: string; + icon: React.ReactNode; + defaultSize: { width: number; height: number }; // ❌ + components: Array<{ + type: string; + size: { width: number; height: number }; // ❌ + // ... + }>; +} +``` + +**변경 후**: + +```typescript +export interface TemplateComponent { + id: string; + name: string; + description: string; + category: string; + icon: React.ReactNode; + defaultSize: { height: number }; // ✅ width 제거 + components: Array<{ + type: string; + gridColumnSpan: ColumnSpanPreset; // 🆕 추가 + gridColumnStart?: number; // 🆕 추가 + size: { height: number }; // ✅ width 제거 + // ... + }>; +} +``` + +#### 5.2 기본 템플릿 정의 수정 + +**예시 - 폼 템플릿**: + +```typescript +const formTemplates: TemplateComponent[] = [ + { + id: "basic-form-row", + name: "기본 폼 행", + description: "라벨 + 입력 필드", + category: "form", + icon: , + defaultSize: { height: 40 }, + components: [ + { + type: "widget", + widgetType: "text", + label: "라벨", + gridColumnSpan: "label", // 3/12 + size: { height: 40 }, + position: { x: 0, y: 0 }, + }, + { + type: "widget", + widgetType: "text", + placeholder: "입력하세요", + gridColumnSpan: "input", // 9/12 + gridColumnStart: 4, // 4번 컬럼부터 시작 + size: { height: 40 }, + position: { x: 0, y: 0 }, + }, + ], + }, + { + id: "two-column-form", + name: "2컬럼 폼", + description: "2개의 입력 필드를 나란히", + category: "form", + icon: , + defaultSize: { height: 40 }, + components: [ + { + type: "widget", + widgetType: "text", + placeholder: "왼쪽 입력", + gridColumnSpan: "half", // 6/12 + size: { height: 40 }, + position: { x: 0, y: 0 }, + }, + { + type: "widget", + widgetType: "text", + placeholder: "오른쪽 입력", + gridColumnSpan: "half", // 6/12 + gridColumnStart: 7, // 7번 컬럼부터 시작 + size: { height: 40 }, + position: { x: 0, y: 0 }, + }, + ], + }, +]; +``` + +--- + +### Phase 6: 데이터 마이그레이션 + +#### 6.1 기존 데이터 변환 함수 + +```typescript +// lib/utils/widthToColumnSpan.ts + +import { + ColumnSpanPreset, + COLUMN_SPAN_VALUES, +} from "@/types/screen-management"; + +/** + * 기존 픽셀 width를 가장 가까운 ColumnSpanPreset으로 변환 + */ +export function convertWidthToColumnSpan( + width: number, + canvasWidth: number = 1920 +): ColumnSpanPreset { + const percentage = (width / canvasWidth) * 100; + + // 각 프리셋의 백분율 계산 + const presetPercentages: Array<[ColumnSpanPreset, number]> = [ + ["full", 100], + ["threeQuarters", 75], + ["twoThirds", 67], + ["half", 50], + ["third", 33], + ["quarter", 25], + ["label", 25], + ["input", 75], + ["small", 17], + ["medium", 33], + ["large", 67], + ]; + + // 가장 가까운 값 찾기 + let closestPreset: ColumnSpanPreset = "half"; + let minDiff = Infinity; + + for (const [preset, presetPercentage] of presetPercentages) { + const diff = Math.abs(percentage - presetPercentage); + if (diff < minDiff) { + minDiff = diff; + closestPreset = preset; + } + } + + return closestPreset; +} + +/** + * 컴포넌트 배열에서 width를 gridColumnSpan으로 일괄 변환 + */ +export function migrateComponentsToColumnSpan( + components: ComponentData[], + canvasWidth: number = 1920 +): ComponentData[] { + return components.map((component) => { + const gridColumnSpan = convertWidthToColumnSpan( + component.size.width, + canvasWidth + ); + + return { + ...component, + gridColumnSpan, + gridRowIndex: 0, // 초기값 (나중에 Y 좌표로 계산) + size: { + height: component.size.height, + // width 제거 + }, + }; + }); +} + +/** + * Y 좌표를 기준으로 행 인덱스 계산 + */ +export function calculateRowIndices( + components: ComponentData[] +): ComponentData[] { + // Y 좌표로 정렬 + const sorted = [...components].sort((a, b) => a.position.y - b.position.y); + + let currentRowIndex = 0; + let currentY = sorted[0]?.position.y ?? 0; + const threshold = 50; // 50px 차이 이내는 같은 행 + + return sorted.map((component) => { + if (Math.abs(component.position.y - currentY) > threshold) { + currentRowIndex++; + currentY = component.position.y; + } + + return { + ...component, + gridRowIndex: currentRowIndex, + }; + }); +} + +/** + * 전체 레이아웃 마이그레이션 + */ +export function migrateLayoutToGridSystem(layout: LayoutData): LayoutData { + console.log("🔄 레이아웃 마이그레이션 시작:", layout); + + // 1단계: width를 gridColumnSpan으로 변환 + let migratedComponents = migrateComponentsToColumnSpan(layout.components); + + // 2단계: Y 좌표로 행 인덱스 계산 + migratedComponents = calculateRowIndices(migratedComponents); + + // 3단계: 같은 행 내에서 X 좌표로 gridColumnStart 계산 + migratedComponents = calculateColumnStarts(migratedComponents); + + console.log("✅ 마이그레이션 완료:", migratedComponents); + + return { + ...layout, + components: migratedComponents, + }; +} + +/** + * 같은 행 내에서 X 좌표로 시작 컬럼 계산 + */ +function calculateColumnStarts(components: ComponentData[]): ComponentData[] { + // 행별로 그룹화 + const rowGroups = new Map(); + + for (const component of components) { + const rowIndex = component.gridRowIndex; + if (!rowGroups.has(rowIndex)) { + rowGroups.set(rowIndex, []); + } + rowGroups.get(rowIndex)!.push(component); + } + + // 각 행 내에서 X 좌표로 정렬하고 시작 컬럼 계산 + const result: ComponentData[] = []; + + for (const [rowIndex, rowComponents] of rowGroups) { + // X 좌표로 정렬 + const sorted = rowComponents.sort((a, b) => a.position.x - b.position.x); + + let currentColumn = 1; + + for (const component of sorted) { + result.push({ + ...component, + gridColumnStart: currentColumn, + }); + + // 다음 컴포넌트는 현재 컴포넌트 뒤에 배치 + currentColumn += COLUMN_SPAN_VALUES[component.gridColumnSpan]; + } + } + + return result; +} +``` + +#### 6.2 자동 마이그레이션 실행 + +```typescript +// lib/api/screen.ts 또는 적절한 위치 + +/** + * 화면 로드 시 자동으로 마이그레이션 체크 및 실행 + */ +export async function loadScreenLayoutWithMigration( + screenId: number +): Promise { + const layout = await screenApi.getLayout(screenId); + + // 마이그레이션 필요 여부 체크 + const needsMigration = layout.components.some( + (c) => !c.gridColumnSpan || c.size.width !== undefined + ); + + if (needsMigration) { + console.log("🔄 자동 마이그레이션 실행:", screenId); + + const migratedLayout = migrateLayoutToGridSystem(layout); + + // 마이그레이션된 레이아웃 저장 + await screenApi.saveLayout(screenId, migratedLayout); + + return migratedLayout; + } + + return layout; +} +``` + +--- + +### Phase 7: Tailwind 설정 업데이트 + +#### 7.1 safelist 추가 + +```javascript +// tailwind.config.js + +module.exports = { + // ... 기존 설정 + + safelist: [ + // 그리드 컬럼 스팬 (1-12) + ...Array.from({ length: 12 }, (_, i) => `col-span-${i + 1}`), + + // 그리드 시작 위치 (1-12) + ...Array.from({ length: 12 }, (_, i) => `col-start-${i + 1}`), + + // 반응형 (필요시) + ...Array.from({ length: 12 }, (_, i) => `md:col-span-${i + 1}`), + ...Array.from({ length: 12 }, (_, i) => `lg:col-span-${i + 1}`), + ], + + // ... 나머지 설정 +}; +``` + +--- + +## 📋 수정 파일 목록 + +### 필수 수정 파일 + +1. ✏️ `frontend/types/screen-management.ts` - 타입 정의 수정 +2. ✏️ `frontend/components/screen/panels/PropertiesPanel.tsx` - width UI 제거 +3. ✏️ `frontend/components/screen/layout/ContainerComponent.tsx` - 렌더링 수정 +4. ✏️ `frontend/components/screen/layout/ColumnComponent.tsx` - 렌더링 수정 +5. ✏️ `frontend/components/screen/layout/RowComponent.tsx` - 렌더링 수정 +6. ✏️ `frontend/components/screen/ScreenDesigner.tsx` - StyleEditor 로직 수정 +7. ✏️ `frontend/components/screen/StyleEditor.tsx` - width 옵션 제거 +8. ✏️ `frontend/components/screen/panels/TemplatesPanel.tsx` - 템플릿 정의 수정 +9. ✏️ `frontend/components/screen/RealtimePreviewDynamic.tsx` - 그리드 클래스 추가 +10. ✏️ `frontend/components/screen/InteractiveScreenViewerDynamic.tsx` - 그리드 클래스 추가 + +### 새로 생성할 파일 + +11. 🆕 `frontend/lib/utils/widthToColumnSpan.ts` - 마이그레이션 유틸리티 +12. 🆕 `frontend/lib/constants/columnSpans.ts` - 컬럼 스팬 상수 정의 + +### 추가 검토 필요 + +13. ⚠️ `frontend/components/screen/panels/DataTableConfigPanel.tsx` - 모달 width +14. ⚠️ `frontend/components/screen/panels/DetailSettingsPanel.tsx` - 확인 필요 +15. ⚠️ `frontend/components/screen/FloatingPanel.tsx` - 패널 자체 width는 유지 + +--- + +## ✅ 단계별 체크리스트 + +### Phase 1: 타입 시스템 (Day 1) + +- [ ] Size 인터페이스에서 width 제거 +- [ ] BaseComponent에 gridColumnSpan 추가 +- [ ] ColumnSpanPreset 타입 정의 +- [ ] COLUMN_SPAN_VALUES 상수 정의 + +### Phase 2: UI 컴포넌트 (Day 2-3) + +- [ ] PropertiesPanel - width 입력 → 컬럼 스팬 선택으로 변경 +- [ ] PropertiesPanel - 시각적 프리뷰 추가 +- [ ] PropertiesPanel - 사이드바 너비 → 컬럼 스팬으로 변경 +- [ ] StyleEditor - width 옵션 완전 제거 + +### Phase 3: 렌더링 로직 (Day 3-4) + +- [ ] ContainerComponent - gridColumn 계산 로직 수정 +- [ ] ColumnComponent - gridColumn 계산 로직 수정 +- [ ] RowComponent - gridColumn 계산 로직 수정 +- [ ] RealtimePreview - 그리드 클래스 적용 +- [ ] InteractiveScreenViewer - 그리드 클래스 적용 + +### Phase 4: 템플릿 시스템 (Day 4-5) + +- [ ] TemplateComponent 타입 수정 +- [ ] 모든 기본 템플릿 정의 업데이트 +- [ ] 템플릿 적용 로직 수정 + +### Phase 5: 데이터 마이그레이션 (Day 5-6) + +- [ ] widthToColumnSpan 유틸리티 작성 +- [ ] 마이그레이션 함수 작성 +- [ ] 자동 마이그레이션 적용 +- [ ] 기존 화면 데이터 변환 테스트 + +### Phase 6: 테스트 및 검증 (Day 6-7) + +- [ ] 새 컴포넌트 생성 테스트 +- [ ] 기존 화면 로드 테스트 +- [ ] 컬럼 스팬 변경 테스트 +- [ ] 템플릿 적용 테스트 +- [ ] 반응형 동작 확인 + +### Phase 7: Tailwind 설정 (Day 7) + +- [ ] safelist 추가 +- [ ] 불필요한 width 관련 유틸리티 제거 +- [ ] 빌드 테스트 + +--- + +## ⚠️ 주의사항 + +### 1. 호환성 유지 + +- 기존 화면 데이터는 자동 마이그레이션 +- 마이그레이션 전 백업 필수 +- 단계적 배포 권장 + +### 2. 모달/팝업 크기 + +- 모달 크기는 컬럼 스팬이 아닌 기존 방식 유지 +- `sm`, `md`, `lg`, `xl` 등의 사이즈 프리셋 사용 + +### 3. FloatingPanel + +- 편집 패널 자체의 width는 유지 +- 캔버스 내 컴포넌트만 컬럼 스팬 적용 + +### 4. 특수 케이스 + +- 데이터 테이블: 전체 너비(full) 고정 +- 파일 업로드: 설정에 따라 다름 +- 버튼: small, medium, large 프리셋 제공 + +--- + +## 🎯 완료 후 기대 효과 + +### ✅ 개선점 + +1. **일관성**: 모든 컴포넌트가 12컬럼 그리드 기반 +2. **단순성**: 복잡한 픽셀 계산 불필요 +3. **반응형**: Tailwind 표준으로 자동 대응 +4. **유지보수**: width 관련 버그 완전 제거 +5. **성능**: 불필요한 계산 로직 제거 + +### ❌ 제거되는 기능 + +- 픽셀 단위 정밀 너비 조정 +- 자유로운 width 입력 +- 커스텀 width 설정 + +### 🔄 대체 방안 + +- 정밀 조정 필요 시 → 컬럼 스팬 조합 사용 +- 특수 케이스 → 커스텀 CSS 클래스 추가 + +--- + +## 📊 마이그레이션 타임라인 + +``` +Week 1: +- Day 1-2: 타입 시스템 및 UI 컴포넌트 수정 +- Day 3-4: 렌더링 로직 수정 +- Day 5-6: 템플릿 및 마이그레이션 +- Day 7: 테스트 및 Tailwind 설정 + +Week 2: +- 전체 시스템 통합 테스트 +- 기존 화면 마이그레이션 +- 문서화 및 배포 +``` + +--- + +이 계획을 따르면 **width 속성을 완전히 제거**하고 **컬럼 수로만 제어**하는 깔끔한 시스템을 구축할 수 있습니다! 🎯 diff --git a/frontend/components/screen/GridLayoutBuilder.tsx b/frontend/components/screen/GridLayoutBuilder.tsx new file mode 100644 index 00000000..f8d81d83 --- /dev/null +++ b/frontend/components/screen/GridLayoutBuilder.tsx @@ -0,0 +1,269 @@ +"use client"; + +import React, { useState, useCallback } from "react"; +import { cn } from "@/lib/utils"; +import { GridLayout, LayoutRow, RowComponent, CreateRowOptions } from "@/types/grid-system"; +import { ComponentData } from "@/types/screen"; +import { LayoutRowRenderer } from "./LayoutRowRenderer"; +import { Button } from "@/components/ui/button"; +import { Plus, Grid3x3 } from "lucide-react"; +import { GAP_PRESETS } from "@/lib/constants/columnSpans"; + +interface GridLayoutBuilderProps { + layout: GridLayout; + onUpdateLayout: (layout: GridLayout) => void; + selectedRowId?: string; + selectedComponentId?: string; + onSelectRow?: (rowId: string) => void; + onSelectComponent?: (componentId: string) => void; + showGridGuides?: boolean; +} + +export const GridLayoutBuilder: React.FC = ({ + layout, + onUpdateLayout, + selectedRowId, + selectedComponentId, + onSelectRow, + onSelectComponent, + showGridGuides = true, +}) => { + const [isDraggingOver, setIsDraggingOver] = useState(false); + + // 새 행 추가 + const addNewRow = useCallback( + (options?: CreateRowOptions) => { + const newRow: LayoutRow = { + id: `row-${Date.now()}`, + rowIndex: layout.rows.length, + height: options?.height || "auto", + fixedHeight: options?.fixedHeight, + gap: options?.gap || "sm", + padding: options?.padding || "sm", + alignment: options?.alignment || "start", + verticalAlignment: "middle", + components: [], + }; + + onUpdateLayout({ + ...layout, + rows: [...layout.rows, newRow], + }); + + // 새로 추가된 행 선택 + if (onSelectRow) { + onSelectRow(newRow.id); + } + }, + [layout, onUpdateLayout, onSelectRow], + ); + + // 행 삭제 + const deleteRow = useCallback( + (rowId: string) => { + const updatedRows = layout.rows + .filter((row) => row.id !== rowId) + .map((row, index) => ({ + ...row, + rowIndex: index, + })); + + onUpdateLayout({ + ...layout, + rows: updatedRows, + }); + }, + [layout, onUpdateLayout], + ); + + // 행 순서 변경 + const moveRow = useCallback( + (rowId: string, direction: "up" | "down") => { + const rowIndex = layout.rows.findIndex((row) => row.id === rowId); + if (rowIndex === -1) return; + + const newIndex = direction === "up" ? rowIndex - 1 : rowIndex + 1; + if (newIndex < 0 || newIndex >= layout.rows.length) return; + + const updatedRows = [...layout.rows]; + [updatedRows[rowIndex], updatedRows[newIndex]] = [updatedRows[newIndex], updatedRows[rowIndex]]; + + // 인덱스 재정렬 + updatedRows.forEach((row, index) => { + row.rowIndex = index; + }); + + onUpdateLayout({ + ...layout, + rows: updatedRows, + }); + }, + [layout, onUpdateLayout], + ); + + // 행 업데이트 + const updateRow = useCallback( + (rowId: string, updates: Partial) => { + const updatedRows = layout.rows.map((row) => (row.id === rowId ? { ...row, ...updates } : row)); + + onUpdateLayout({ + ...layout, + rows: updatedRows, + }); + }, + [layout, onUpdateLayout], + ); + + // 컴포넌트 선택 + const handleSelectComponent = useCallback( + (componentId: string) => { + if (onSelectComponent) { + onSelectComponent(componentId); + } + }, + [onSelectComponent], + ); + + // 행 선택 + const handleSelectRow = useCallback( + (rowId: string) => { + if (onSelectRow) { + onSelectRow(rowId); + } + }, + [onSelectRow], + ); + + // 컨테이너 클래스 + const containerClasses = cn("w-full h-full overflow-auto bg-gray-50 relative", isDraggingOver && "bg-blue-50"); + + // 글로벌 컨테이너 클래스 + const globalContainerClasses = cn( + "mx-auto relative", + layout.globalSettings.containerMaxWidth === "full" ? "w-full" : `max-w-${layout.globalSettings.containerMaxWidth}`, + GAP_PRESETS[layout.globalSettings.containerPadding].class.replace("gap-", "px-"), + ); + + return ( +
+ {/* 그리드 가이드라인 */} + {showGridGuides && ( +
+
+
+ {Array.from({ length: 12 }).map((_, i) => ( +
+ ))} +
+
+
+ )} + + {/* 메인 컨테이너 */} +
+ {layout.rows.length === 0 ? ( + // 빈 레이아웃 +
+ +

레이아웃이 비어있습니다

+

첫 번째 행을 추가하여 시작하세요

+ +
+ ) : ( + // 행 목록 +
+ {layout.rows.map((row) => ( + handleSelectRow(row.id)} + onSelectComponent={handleSelectComponent} + onUpdateRow={(updatedRow) => updateRow(row.id, updatedRow)} + /> + ))} +
+ )} + + {/* 새 행 추가 버튼 */} + {layout.rows.length > 0 && ( +
+
+ + + {/* 빠른 추가 버튼들 */} +
+ + + +
+
+
+ )} + + {/* 레이아웃 정보 */} +
+
+
+ 행: {layout.rows.length} + 컴포넌트: {layout.components.size} + + 컨테이너:{" "} + {layout.globalSettings.containerMaxWidth === "full" ? "전체" : layout.globalSettings.containerMaxWidth} + +
+
+ 12컬럼 그리드 + {showGridGuides && 가이드 표시됨} +
+
+
+
+
+ ); +}; diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index c5324fae..e5ebfcf2 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -43,6 +43,8 @@ import { FormValidationIndicator } from "@/components/common/FormValidationIndic import { useFormValidation } from "@/hooks/useFormValidation"; import { UnifiedColumnInfo as ColumnInfo } from "@/types"; import { isFileComponent } from "@/lib/utils/componentTypeUtils"; +import { buildGridClasses } from "@/lib/constants/columnSpans"; +import { cn } from "@/lib/utils"; interface InteractiveScreenViewerProps { component: ComponentData; diff --git a/frontend/components/screen/LayoutRowRenderer.tsx b/frontend/components/screen/LayoutRowRenderer.tsx new file mode 100644 index 00000000..b0f49eaa --- /dev/null +++ b/frontend/components/screen/LayoutRowRenderer.tsx @@ -0,0 +1,181 @@ +"use client"; + +import React from "react"; +import { cn } from "@/lib/utils"; +import { LayoutRow } from "@/types/grid-system"; +import { ComponentData } from "@/types/screen"; +import { GAP_PRESETS, buildGridClasses } from "@/lib/constants/columnSpans"; +import { RealtimePreviewDynamic } from "./RealtimePreviewDynamic"; + +interface LayoutRowRendererProps { + row: LayoutRow; + components: Map; + isSelected: boolean; + selectedComponentId?: string; + onSelectRow: () => void; + onSelectComponent: (componentId: string) => void; + onUpdateRow?: (row: LayoutRow) => void; +} + +export const LayoutRowRenderer: React.FC = ({ + row, + components, + isSelected, + selectedComponentId, + onSelectRow, + onSelectComponent, + onUpdateRow, +}) => { + // 행 클래스 생성 + const rowClasses = cn( + // 그리드 기본 + "grid grid-cols-12 w-full relative", + + // Gap (컴포넌트 간격) + GAP_PRESETS[row.gap].class, + + // Padding + GAP_PRESETS[row.padding].class.replace("gap-", "p-"), + + // 높이 + row.height === "auto" && "h-auto", + row.height === "fixed" && row.fixedHeight && `h-[${row.fixedHeight}px]`, + row.height === "min" && row.minHeight && `min-h-[${row.minHeight}px]`, + row.height === "max" && row.maxHeight && `max-h-[${row.maxHeight}px]`, + + // 수평 정렬 + row.alignment === "start" && "justify-items-start", + row.alignment === "center" && "justify-items-center", + row.alignment === "end" && "justify-items-end", + row.alignment === "stretch" && "justify-items-stretch", + row.alignment === "baseline" && "justify-items-baseline", + + // 수직 정렬 + row.verticalAlignment === "top" && "items-start", + row.verticalAlignment === "middle" && "items-center", + row.verticalAlignment === "bottom" && "items-end", + row.verticalAlignment === "stretch" && "items-stretch", + + // 선택 상태 + isSelected && "ring-2 ring-blue-500 ring-inset", + + // 호버 효과 + "hover:bg-gray-50 transition-colors cursor-pointer border-2 border-dashed border-transparent hover:border-gray-300", + ); + + // 배경색 스타일 + const rowStyle: React.CSSProperties = { + ...(row.backgroundColor && { backgroundColor: row.backgroundColor }), + }; + + return ( +
+ {/* 행 인덱스 표시 */} +
+ {row.rowIndex + 1} +
+ + {row.components.length === 0 ? ( + // 빈 행 +
+
+

컴포넌트를 여기에 드래그하세요

+
+ + + +
+
+
+ ) : ( + // 컴포넌트 렌더링 + row.components.map((rowComponent) => { + const component = components.get(rowComponent.componentId); + if (!component) return null; + + // 그리드 클래스 생성 + const componentClasses = cn( + // 컬럼 스팬 + buildGridClasses(rowComponent.columnSpan, rowComponent.columnStart), + + // 정렬 순서 + rowComponent.order && `order-${rowComponent.order}`, + + // 선택 상태 + selectedComponentId === component.id && "ring-2 ring-green-500 ring-inset", + ); + + // 오프셋 스타일 (여백) + const componentStyle: React.CSSProperties = { + ...(rowComponent.offset && { + marginLeft: `${(GAP_PRESETS[rowComponent.offset as any]?.value || 0) * 4}px`, + }), + }; + + return ( +
{ + e.stopPropagation(); + onSelectComponent(component.id); + }} + > + { + e?.stopPropagation(); + onSelectComponent(component.id); + }} + /> +
+ ); + }) + )} + + {/* 선택 시 행 설정 버튼 */} + {isSelected && ( +
+ + +
+ )} +
+ ); +}; diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index d90c3ddc..6548f987 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -42,6 +42,7 @@ import { MenuAssignmentModal } from "./MenuAssignmentModal"; import { FileAttachmentDetailModal } from "./FileAttachmentDetailModal"; import { initializeComponents } from "@/lib/registry/components"; import { ScreenFileAPI } from "@/lib/api/screenFile"; +import { safeMigrateLayout, needsMigration } from "@/lib/utils/widthToColumnSpan"; import StyleEditor from "./StyleEditor"; import { RealtimePreview } from "./RealtimePreviewDynamic"; @@ -198,83 +199,88 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const [forceRenderTrigger, setForceRenderTrigger] = useState(0); // 파일 컴포넌트 데이터 복원 함수 (실제 DB에서 조회) - const restoreFileComponentsData = useCallback(async (components: ComponentData[]) => { - if (!selectedScreen?.screenId) return; - - // console.log("🔄 파일 컴포넌트 데이터 복원 시작:", components.length); - - try { - // 실제 DB에서 화면의 모든 파일 정보 조회 - const fileResponse = await ScreenFileAPI.getScreenComponentFiles(selectedScreen.screenId); - - if (!fileResponse.success) { - // console.warn("⚠️ 파일 정보 조회 실패:", fileResponse); - return; - } + const restoreFileComponentsData = useCallback( + async (components: ComponentData[]) => { + if (!selectedScreen?.screenId) return; - const { componentFiles } = fileResponse; - - if (typeof window !== 'undefined') { - // 전역 파일 상태 초기화 - const globalFileState: {[key: string]: any[]} = {}; - let restoredCount = 0; + // console.log("🔄 파일 컴포넌트 데이터 복원 시작:", components.length); - // DB에서 조회한 파일 정보를 전역 상태로 복원 - Object.keys(componentFiles).forEach(componentId => { - const files = componentFiles[componentId]; - if (files && files.length > 0) { - globalFileState[componentId] = files; - restoredCount++; - - // localStorage에도 백업 - const backupKey = `fileComponent_${componentId}_files`; - localStorage.setItem(backupKey, JSON.stringify(files)); - - console.log("📁 DB에서 파일 컴포넌트 데이터 복원:", { - componentId: componentId, - fileCount: files.length, - files: files.map(f => ({ objid: f.objid, name: f.realFileName })) - }); - } - }); + try { + // 실제 DB에서 화면의 모든 파일 정보 조회 + const fileResponse = await ScreenFileAPI.getScreenComponentFiles(selectedScreen.screenId); - // 전역 상태 업데이트 - (window as any).globalFileState = globalFileState; - - // 모든 파일 컴포넌트에 복원 완료 이벤트 발생 - Object.keys(globalFileState).forEach(componentId => { - const files = globalFileState[componentId]; - const syncEvent = new CustomEvent('globalFileStateChanged', { - detail: { - componentId: componentId, - files: files, - fileCount: files.length, - timestamp: Date.now(), - isRestore: true + if (!fileResponse.success) { + // console.warn("⚠️ 파일 정보 조회 실패:", fileResponse); + return; + } + + const { componentFiles } = fileResponse; + + if (typeof window !== "undefined") { + // 전역 파일 상태 초기화 + const globalFileState: { [key: string]: any[] } = {}; + let restoredCount = 0; + + // DB에서 조회한 파일 정보를 전역 상태로 복원 + Object.keys(componentFiles).forEach((componentId) => { + const files = componentFiles[componentId]; + if (files && files.length > 0) { + globalFileState[componentId] = files; + restoredCount++; + + // localStorage에도 백업 + const backupKey = `fileComponent_${componentId}_files`; + localStorage.setItem(backupKey, JSON.stringify(files)); + + console.log("📁 DB에서 파일 컴포넌트 데이터 복원:", { + componentId: componentId, + fileCount: files.length, + files: files.map((f) => ({ objid: f.objid, name: f.realFileName })), + }); } }); - window.dispatchEvent(syncEvent); - }); - console.log("✅ DB 파일 컴포넌트 데이터 복원 완료:", { - totalComponents: components.length, - restoredFileComponents: restoredCount, - totalFiles: fileResponse.totalFiles, - globalFileState: Object.keys(globalFileState).map(id => ({ - id, - fileCount: globalFileState[id]?.length || 0 - })) - }); + // 전역 상태 업데이트 + (window as any).globalFileState = globalFileState; - if (restoredCount > 0) { - toast.success(`${restoredCount}개 파일 컴포넌트 데이터가 DB에서 복원되었습니다. (총 ${fileResponse.totalFiles}개 파일)`); + // 모든 파일 컴포넌트에 복원 완료 이벤트 발생 + Object.keys(globalFileState).forEach((componentId) => { + const files = globalFileState[componentId]; + const syncEvent = new CustomEvent("globalFileStateChanged", { + detail: { + componentId: componentId, + files: files, + fileCount: files.length, + timestamp: Date.now(), + isRestore: true, + }, + }); + window.dispatchEvent(syncEvent); + }); + + console.log("✅ DB 파일 컴포넌트 데이터 복원 완료:", { + totalComponents: components.length, + restoredFileComponents: restoredCount, + totalFiles: fileResponse.totalFiles, + globalFileState: Object.keys(globalFileState).map((id) => ({ + id, + fileCount: globalFileState[id]?.length || 0, + })), + }); + + if (restoredCount > 0) { + toast.success( + `${restoredCount}개 파일 컴포넌트 데이터가 DB에서 복원되었습니다. (총 ${fileResponse.totalFiles}개 파일)`, + ); + } } + } catch (error) { + // console.error("❌ 파일 컴포넌트 데이터 복원 실패:", error); + toast.error("파일 데이터 복원 중 오류가 발생했습니다."); } - } catch (error) { - // console.error("❌ 파일 컴포넌트 데이터 복원 실패:", error); - toast.error("파일 데이터 복원 중 오류가 발생했습니다."); - } - }, [selectedScreen?.screenId]); + }, + [selectedScreen?.screenId], + ); // 드래그 선택 상태 const [selectionDrag, setSelectionDrag] = useState({ @@ -747,22 +753,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD try { // console.log("🔄 화면 파일 복원 시작:", selectedScreen.screenId); - + // 해당 화면의 모든 파일 조회 const response = await ScreenFileAPI.getScreenComponentFiles(selectedScreen.screenId); - + if (response.success && response.componentFiles) { // console.log("📁 복원할 파일 데이터:", response.componentFiles); - + // 각 컴포넌트별로 파일 복원 (전역 상태와 localStorage 우선 적용) Object.entries(response.componentFiles).forEach(([componentId, serverFiles]) => { if (Array.isArray(serverFiles) && serverFiles.length > 0) { // 🎯 전역 상태와 localStorage에서 현재 파일 상태 확인 - const globalFileState = typeof window !== 'undefined' ? (window as any).globalFileState || {} : {}; + const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {}; const currentGlobalFiles = globalFileState[componentId] || []; - + let currentLocalStorageFiles: any[] = []; - if (typeof window !== 'undefined') { + if (typeof window !== "undefined") { try { const storedFiles = localStorage.getItem(`fileComponent_${componentId}_files`); if (storedFiles) { @@ -772,7 +778,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // console.warn("localStorage 파일 파싱 실패:", e); } } - + // 🎯 우선순위: 전역 상태 > localStorage > 서버 데이터 let finalFiles = serverFiles; if (currentGlobalFiles.length > 0) { @@ -784,43 +790,43 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD } else { // console.log(`📂 컴포넌트 ${componentId} 서버 데이터 적용:`, finalFiles.length, "개"); } - + // 전역 상태에 파일 저장 globalFileState[componentId] = finalFiles; - if (typeof window !== 'undefined') { + if (typeof window !== "undefined") { (window as any).globalFileState = globalFileState; } - + // localStorage에도 백업 - if (typeof window !== 'undefined') { + if (typeof window !== "undefined") { localStorage.setItem(`fileComponent_${componentId}_files`, JSON.stringify(finalFiles)); } } }); - + // 레이아웃의 컴포넌트들에 파일 정보 적용 (전역 상태 우선) - setLayout(prevLayout => { - const updatedComponents = prevLayout.components.map(comp => { + setLayout((prevLayout) => { + const updatedComponents = prevLayout.components.map((comp) => { // 🎯 전역 상태에서 최신 파일 정보 가져오기 - const globalFileState = typeof window !== 'undefined' ? (window as any).globalFileState || {} : {}; + const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {}; const finalFiles = globalFileState[comp.id] || []; - + if (finalFiles.length > 0) { return { ...comp, uploadedFiles: finalFiles, - lastFileUpdate: Date.now() + lastFileUpdate: Date.now(), }; } return comp; }); - + return { ...prevLayout, - components: updatedComponents + components: updatedComponents, }; }); - + // console.log("✅ 화면 파일 복원 완료"); } } catch (error) { @@ -832,14 +838,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD useEffect(() => { const handleGlobalFileStateChange = (event: CustomEvent) => { // console.log("🔄 ScreenDesigner: 전역 파일 상태 변경 감지", event.detail); - setForceRenderTrigger(prev => prev + 1); + setForceRenderTrigger((prev) => prev + 1); }; - if (typeof window !== 'undefined') { - window.addEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener); - + if (typeof window !== "undefined") { + window.addEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener); + return () => { - window.removeEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener); + window.removeEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener); }; } }, []); @@ -897,17 +903,35 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD useEffect(() => { if (selectedScreen?.screenId) { // 현재 화면 ID를 전역 변수로 설정 (파일 업로드 시 사용) - if (typeof window !== 'undefined') { + if (typeof window !== "undefined") { (window as any).__CURRENT_SCREEN_ID__ = selectedScreen.screenId; } - + const loadLayout = async () => { try { const response = await screenApi.getLayout(selectedScreen.screenId); if (response) { + // 🔄 마이그레이션 필요 여부 확인 + let layoutToUse = response; + + if (needsMigration(response)) { + console.log("🔄 픽셀 기반 레이아웃 감지 - 그리드 시스템으로 마이그레이션 시작..."); + + const canvasWidth = response.screenResolution?.width || 1920; + layoutToUse = safeMigrateLayout(response, canvasWidth); + + console.log("✅ 마이그레이션 완료:", { + originalComponents: response.components.length, + migratedComponents: layoutToUse.components.length, + sampleComponent: layoutToUse.components[0], + }); + + toast.success("레이아웃이 새로운 그리드 시스템으로 자동 변환되었습니다."); + } + // 기본 격자 설정 보장 (격자 표시와 스냅 기본 활성화) const layoutWithDefaultGrid = { - ...response, + ...layoutToUse, gridSettings: { columns: 12, gap: 16, @@ -916,14 +940,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD showGrid: true, gridColor: "#d1d5db", gridOpacity: 0.5, - ...response.gridSettings, // 기존 설정이 있으면 덮어쓰기 + ...layoutToUse.gridSettings, // 기존 설정이 있으면 덮어쓰기 }, }; // 저장된 해상도 정보가 있으면 적용, 없으면 기본값 사용 - if (response.screenResolution) { - setScreenResolution(response.screenResolution); - // console.log("💾 저장된 해상도 불러옴:", response.screenResolution); + if (layoutToUse.screenResolution) { + setScreenResolution(layoutToUse.screenResolution); + // console.log("💾 저장된 해상도 불러옴:", layoutToUse.screenResolution); } else { // 기본 해상도 (Full HD) const defaultResolution = @@ -1728,7 +1752,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD let componentSize = component.defaultSize; const isCardDisplay = component.id === "card-display"; const isTableList = component.id === "table-list"; - + // 컴포넌트별 기본 그리드 컬럼 수 설정 const gridColumns = isCardDisplay ? 8 : isTableList ? 1 : 1; @@ -1742,7 +1766,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // 컴포넌트별 최소 크기 보장 const minWidth = isTableList ? 120 : isCardDisplay ? 400 : 100; - + componentSize = { ...component.defaultSize, width: Math.max(calculatedWidth, minWidth), @@ -2197,22 +2221,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD (updates: Partial) => { if (!selectedFileComponent) return; - const updatedComponents = layout.components.map(comp => - comp.id === selectedFileComponent.id - ? { ...comp, ...updates } - : comp + const updatedComponents = layout.components.map((comp) => + comp.id === selectedFileComponent.id ? { ...comp, ...updates } : comp, ); const newLayout = { ...layout, components: updatedComponents }; setLayout(newLayout); saveToHistory(newLayout); - + // selectedFileComponent도 업데이트 - setSelectedFileComponent(prev => prev ? { ...prev, ...updates } : null); - + setSelectedFileComponent((prev) => (prev ? { ...prev, ...updates } : null)); + // selectedComponent가 같은 컴포넌트라면 업데이트 if (selectedComponent?.id === selectedFileComponent.id) { - setSelectedComponent(prev => prev ? { ...prev, ...updates } : null); + setSelectedComponent((prev) => (prev ? { ...prev, ...updates } : null)); } }, [selectedFileComponent, layout, saveToHistory, selectedComponent], @@ -2225,22 +2247,19 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD }, []); // 컴포넌트 더블클릭 처리 - const handleComponentDoubleClick = useCallback( - (component: ComponentData, event?: React.MouseEvent) => { - event?.stopPropagation(); + const handleComponentDoubleClick = useCallback((component: ComponentData, event?: React.MouseEvent) => { + event?.stopPropagation(); - // 파일 컴포넌트인 경우 상세 모달 열기 - if (component.type === "file") { - setSelectedFileComponent(component); - setShowFileAttachmentModal(true); - return; - } + // 파일 컴포넌트인 경우 상세 모달 열기 + if (component.type === "file") { + setSelectedFileComponent(component); + setShowFileAttachmentModal(true); + return; + } - // 다른 컴포넌트 타입의 더블클릭 처리는 여기에 추가 - // console.log("더블클릭된 컴포넌트:", component.type, component.id); - }, - [], - ); + // 다른 컴포넌트 타입의 더블클릭 처리는 여기에 추가 + // console.log("더블클릭된 컴포넌트:", component.type, component.id); + }, []); // 컴포넌트 클릭 처리 (다중선택 지원) const handleComponentClick = useCallback( @@ -3429,10 +3448,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD {/* 실제 작업 캔버스 (해상도 크기) - 반응형 개선 */}
f.objid) || [])}-${componentFiles.length}`; @@ -3556,16 +3575,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // 설정 변경 핸들러 (테이블 페이지 크기 등 설정을 상세설정에 반영) onConfigChange={(config) => { // console.log("📤 테이블 설정 변경을 상세설정에 반영:", config); - + // 컴포넌트의 componentConfig 업데이트 - const updatedComponents = layout.components.map(comp => { + const updatedComponents = layout.components.map((comp) => { if (comp.id === component.id) { return { ...comp, componentConfig: { ...comp.componentConfig, - ...config - } + ...config, + }, }; } return comp; @@ -3573,15 +3592,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const newLayout = { ...layout, - components: updatedComponents + components: updatedComponents, }; setLayout(newLayout); saveToHistory(newLayout); - + console.log("✅ 컴포넌트 설정 업데이트 완료:", { componentId: component.id, - updatedConfig: config + updatedConfig: config, }); }} > @@ -3858,36 +3877,25 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD { - console.log("🔧 StyleEditor 크기 변경:", { + console.log("🔧 StyleEditor 스타일 변경:", { componentId: selectedComponent.id, newStyle, - currentSize: selectedComponent.size, - hasWidth: !!newStyle.width, hasHeight: !!newStyle.height, }); // 스타일 업데이트 updateComponentProperty(selectedComponent.id, "style", newStyle); - // 크기가 변경된 경우 component.size도 업데이트 - if (newStyle.width || newStyle.height) { - const width = newStyle.width - ? parseInt(newStyle.width.replace("px", "")) - : selectedComponent.size.width; - const height = newStyle.height - ? parseInt(newStyle.height.replace("px", "")) - : selectedComponent.size.height; + // ✅ 높이만 업데이트 (너비는 gridColumnSpan으로 제어) + if (newStyle.height) { + const height = parseInt(newStyle.height.replace("px", "")); - console.log("📏 크기 업데이트:", { - originalWidth: selectedComponent.size.width, + console.log("📏 높이 업데이트:", { originalHeight: selectedComponent.size.height, - newWidth: width, newHeight: height, - styleWidth: newStyle.width, styleHeight: newStyle.height, }); - updateComponentProperty(selectedComponent.id, "size.width", width); updateComponentProperty(selectedComponent.id, "size.height", height); } }} diff --git a/frontend/components/screen/layout/ContainerComponent.tsx b/frontend/components/screen/layout/ContainerComponent.tsx index c2cc4453..4001f284 100644 --- a/frontend/components/screen/layout/ContainerComponent.tsx +++ b/frontend/components/screen/layout/ContainerComponent.tsx @@ -2,6 +2,7 @@ import { cn } from "@/lib/utils"; import { ContainerComponent as ContainerComponentType } from "@/types/screen"; +import { buildGridClasses, COLUMN_SPAN_VALUES } from "@/lib/constants/columnSpans"; interface ContainerComponentProps { component: ContainerComponentType; @@ -22,12 +23,20 @@ export default function ContainerComponent({ onMouseDown, isMoving, }: ContainerComponentProps) { + // 그리드 클래스 생성 + const gridClasses = component.gridColumnSpan + ? buildGridClasses(component.gridColumnSpan, component.gridColumnStart) + : ""; + // 스타일 객체 생성 const style: React.CSSProperties = { - gridColumn: `span ${component.size.width}`, + // 🔄 레거시 호환: gridColumnSpan이 없으면 기존 width 사용 + ...(!component.gridColumnSpan && { + gridColumn: `span ${component.size.width}`, + }), minHeight: `${component.size.height}px`, ...(component.style && { - width: component.style.width, + // ❌ width는 제거 (그리드 클래스로 제어) height: component.style.height, margin: component.style.margin, padding: component.style.padding, @@ -63,6 +72,7 @@ export default function ContainerComponent({
= ({ }) => { // 🔍 디버깅: PropertiesPanel 렌더링 및 dragState 전달 확인 // console.log("📍 PropertiesPanel 렌더링:", { - // renderTime: Date.now(), - // selectedComponentId: selectedComponent?.id, - // dragState: dragState - // ? { - // isDragging: dragState.isDragging, - // draggedComponentId: dragState.draggedComponent?.id, - // currentPosition: dragState.currentPosition, - // dragStateRef: dragState, // 객체 참조 확인 - // } - // : "null", + // renderTime: Date.now(), + // selectedComponentId: selectedComponent?.id, + // dragState: dragState + // ? { + // isDragging: dragState.isDragging, + // draggedComponentId: dragState.draggedComponent?.id, + // currentPosition: dragState.currentPosition, + // dragStateRef: dragState, // 객체 참조 확인 + // } + // : "null", // }); // 동적 웹타입 목록 가져오기 - API에서 직접 조회 @@ -161,9 +165,9 @@ const PropertiesPanelComponent: React.FC = ({ const getCurrentPosition = () => { if (dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id) { // console.log("🎯 드래그 중 실시간 위치:", { - // draggedId: dragState.draggedComponent?.id, - // selectedId: selectedComponent?.id, - // currentPosition: dragState.currentPosition, + // draggedId: dragState.draggedComponent?.id, + // selectedId: selectedComponent?.id, + // currentPosition: dragState.currentPosition, // }); return { x: Math.round(dragState.currentPosition.x), @@ -226,20 +230,20 @@ const PropertiesPanelComponent: React.FC = ({ const area = selectedComponent.type === "area" ? (selectedComponent as AreaComponent) : null; // console.log("🔄 PropertiesPanel: 컴포넌트 변경 감지", { - // componentId: selectedComponent.id, - // componentType: selectedComponent.type, - // isDragging: dragState?.isDragging, - // justFinishedDrag: dragState?.justFinishedDrag, - // currentValues: { - // placeholder: widget?.placeholder, - // title: group?.title || area?.title, - // description: area?.description, - // actualPositionX: selectedComponent.position.x, - // actualPositionY: selectedComponent.position.y, - // dragPositionX: dragState?.currentPosition.x, - // dragPositionY: dragState?.currentPosition.y, - // }, - // getCurrentPosResult: getCurrentPosition(), + // componentId: selectedComponent.id, + // componentType: selectedComponent.type, + // isDragging: dragState?.isDragging, + // justFinishedDrag: dragState?.justFinishedDrag, + // currentValues: { + // placeholder: widget?.placeholder, + // title: group?.title || area?.title, + // description: area?.description, + // actualPositionX: selectedComponent.position.x, + // actualPositionY: selectedComponent.position.y, + // dragPositionX: dragState?.currentPosition.x, + // dragPositionY: dragState?.currentPosition.y, + // }, + // getCurrentPosResult: getCurrentPosition(), // }); // 드래그 중이 아닐 때만 localInputs 업데이트 (드래그 완료 후 최종 위치 반영) @@ -271,8 +275,8 @@ const PropertiesPanelComponent: React.FC = ({ }); // console.log("✅ localInputs 업데이트 완료:", { - // positionX: currentPos.x.toString(), - // positionY: currentPos.y.toString(), + // positionX: currentPos.x.toString(), + // positionY: currentPos.y.toString(), // }); } } @@ -290,65 +294,66 @@ const PropertiesPanelComponent: React.FC = ({ if (selectedComponent && selectedComponent.type === "component") { // 삭제 액션 감지 로직 (실제 필드명 사용) const isDeleteAction = () => { - const deleteKeywords = ['삭제', 'delete', 'remove', '제거', 'del']; + const deleteKeywords = ["삭제", "delete", "remove", "제거", "del"]; return ( - selectedComponent.componentConfig?.action?.type === 'delete' || - selectedComponent.config?.action?.type === 'delete' || - selectedComponent.webTypeConfig?.actionType === 'delete' || - selectedComponent.text?.toLowerCase().includes('삭제') || - selectedComponent.text?.toLowerCase().includes('delete') || - selectedComponent.label?.toLowerCase().includes('삭제') || - selectedComponent.label?.toLowerCase().includes('delete') || - deleteKeywords.some(keyword => - selectedComponent.config?.buttonText?.toLowerCase().includes(keyword) || - selectedComponent.config?.text?.toLowerCase().includes(keyword) + selectedComponent.componentConfig?.action?.type === "delete" || + selectedComponent.config?.action?.type === "delete" || + selectedComponent.webTypeConfig?.actionType === "delete" || + selectedComponent.text?.toLowerCase().includes("삭제") || + selectedComponent.text?.toLowerCase().includes("delete") || + selectedComponent.label?.toLowerCase().includes("삭제") || + selectedComponent.label?.toLowerCase().includes("delete") || + deleteKeywords.some( + (keyword) => + selectedComponent.config?.buttonText?.toLowerCase().includes(keyword) || + selectedComponent.config?.text?.toLowerCase().includes(keyword), ) ); }; // 🔍 디버깅: 컴포넌트 구조 확인 // console.log("🔍 PropertiesPanel 삭제 액션 디버깅:", { - // componentType: selectedComponent.type, - // componentId: selectedComponent.id, - // componentConfig: selectedComponent.componentConfig, - // config: selectedComponent.config, - // webTypeConfig: selectedComponent.webTypeConfig, - // actionType1: selectedComponent.componentConfig?.action?.type, - // actionType2: selectedComponent.config?.action?.type, - // actionType3: selectedComponent.webTypeConfig?.actionType, - // isDeleteAction: isDeleteAction(), - // currentLabelColor: selectedComponent.style?.labelColor, + // componentType: selectedComponent.type, + // componentId: selectedComponent.id, + // componentConfig: selectedComponent.componentConfig, + // config: selectedComponent.config, + // webTypeConfig: selectedComponent.webTypeConfig, + // actionType1: selectedComponent.componentConfig?.action?.type, + // actionType2: selectedComponent.config?.action?.type, + // actionType3: selectedComponent.webTypeConfig?.actionType, + // isDeleteAction: isDeleteAction(), + // currentLabelColor: selectedComponent.style?.labelColor, // }); // 액션에 따른 라벨 색상 자동 설정 if (isDeleteAction()) { // 삭제 액션일 때 빨간색으로 설정 (이미 빨간색이 아닌 경우에만) - if (selectedComponent.style?.labelColor !== '#ef4444') { + if (selectedComponent.style?.labelColor !== "#ef4444") { // console.log("🔴 삭제 액션 감지: 라벨 색상을 빨간색으로 자동 설정"); onUpdateProperty("style", { ...selectedComponent.style, - labelColor: '#ef4444' + labelColor: "#ef4444", }); - + // 로컬 입력 상태도 업데이트 - setLocalInputs(prev => ({ + setLocalInputs((prev) => ({ ...prev, - labelColor: '#ef4444' + labelColor: "#ef4444", })); } } else { // 다른 액션일 때 기본 파란색으로 리셋 (현재 빨간색인 경우에만) - if (selectedComponent.style?.labelColor === '#ef4444') { + if (selectedComponent.style?.labelColor === "#ef4444") { // console.log("🔵 일반 액션 감지: 라벨 색상을 기본 파란색으로 리셋"); onUpdateProperty("style", { ...selectedComponent.style, - labelColor: '#212121' + labelColor: "#212121", }); - + // 로컬 입력 상태도 업데이트 - setLocalInputs(prev => ({ + setLocalInputs((prev) => ({ ...prev, - labelColor: '#212121' + labelColor: "#212121", })); } } @@ -360,16 +365,16 @@ const PropertiesPanelComponent: React.FC = ({ selectedComponent?.id, selectedComponent?.style?.labelColor, // 라벨 색상 변경도 감지 JSON.stringify(selectedComponent?.componentConfig), // 전체 componentConfig 변경 감지 - onUpdateProperty + onUpdateProperty, ]); // 렌더링 시마다 실행되는 직접적인 드래그 상태 체크 if (dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id) { // console.log("🎯 렌더링 중 드래그 상태 감지:", { - // isDragging: dragState.isDragging, - // draggedId: dragState.draggedComponent?.id, - // selectedId: selectedComponent?.id, - // currentPosition: dragState.currentPosition, + // isDragging: dragState.isDragging, + // draggedId: dragState.draggedComponent?.id, + // selectedId: selectedComponent?.id, + // currentPosition: dragState.currentPosition, // }); const newPosition = { @@ -380,8 +385,8 @@ const PropertiesPanelComponent: React.FC = ({ // 위치가 변경되었는지 확인 if (lastDragPosition.x !== newPosition.x || lastDragPosition.y !== newPosition.y) { // console.log("🔄 위치 변경 감지됨:", { - // oldPosition: lastDragPosition, - // newPosition: newPosition, + // oldPosition: lastDragPosition, + // newPosition: newPosition, // }); // 다음 렌더링 사이클에서 업데이트 setTimeout(() => { @@ -409,7 +414,7 @@ const PropertiesPanelComponent: React.FC = ({
- + 데이터 테이블 설정
@@ -450,7 +455,7 @@ const PropertiesPanelComponent: React.FC = ({
- +

속성 편집

@@ -491,7 +496,7 @@ const PropertiesPanelComponent: React.FC = ({ {/* 기본 정보 */}
- +

기본 정보

@@ -507,7 +512,7 @@ const PropertiesPanelComponent: React.FC = ({ value={selectedComponent.columnName || ""} readOnly placeholder="데이터베이스 컬럼명" - className="mt-1 bg-gray-50 text-muted-foreground" + className="text-muted-foreground mt-1 bg-gray-50" title="컬럼명은 변경할 수 없습니다" />
@@ -517,7 +522,7 @@ const PropertiesPanelComponent: React.FC = ({ 위젯 타입 { - const newValue = e.target.value; - setLocalInputs((prev) => ({ ...prev, width: newValue })); - onUpdateProperty("size.width", Number(newValue)); + {/* 🆕 컬럼 스팬 선택 (width 대체) */} +
+ + + + {/* 시각적 프리뷰 */} +
+ +
+ {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; + + return ( +
+ ); + })} +
+

+ {COLUMN_SPAN_VALUES[selectedComponent.gridColumnSpan || "half"]} / 12 컬럼 +

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

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

+
+
+
= ({
) : ( -
-

카드 레이아웃은 자동으로 크기가 계산됩니다

+
+

카드 레이아웃은 자동으로 크기가 계산됩니다

카드 개수와 간격 설정은 상세설정에서 조정하세요

)} @@ -756,7 +829,7 @@ const PropertiesPanelComponent: React.FC = ({ {/* 라벨 스타일 */}
- +

라벨 설정

@@ -840,7 +913,7 @@ const PropertiesPanelComponent: React.FC = ({ 폰트 굵기 onUpdateProperty("style.labelTextAlign", e.target.value)} > @@ -900,7 +973,7 @@ const PropertiesPanelComponent: React.FC = ({ {/* 그룹 설정 */}
- +

그룹 설정

@@ -931,7 +1004,7 @@ const PropertiesPanelComponent: React.FC = ({ {/* 영역 설정 */}
- +

영역 설정

@@ -974,7 +1047,7 @@ const PropertiesPanelComponent: React.FC = ({ 레이아웃 타입 onUpdateProperty("layoutConfig.justifyContent", e.target.value)} > @@ -1069,7 +1142,7 @@ const PropertiesPanelComponent: React.FC = ({
onUpdateRow({ height: value })} + > + + + + + 자동 (컨텐츠에 맞춤) + 고정 높이 + 최소 높이 + 최대 높이 + + + + {/* 고정 높이 입력 */} + {row.height === "fixed" && ( +
+ + onUpdateRow({ fixedHeight: parseInt(e.target.value) })} + className="mt-1" + placeholder="100" + min={50} + max={1000} + /> +
+ )} + + {/* 최소 높이 입력 */} + {row.height === "min" && ( +
+ + onUpdateRow({ minHeight: parseInt(e.target.value) })} + className="mt-1" + placeholder="50" + min={0} + max={1000} + /> +
+ )} + + {/* 최대 높이 입력 */} + {row.height === "max" && ( +
+ + onUpdateRow({ maxHeight: parseInt(e.target.value) })} + className="mt-1" + placeholder="500" + min={0} + max={2000} + /> +
+ )} +
+ + + + {/* 간격 설정 */} +
+ +
+ {(Object.keys(GAP_PRESETS) as GapPreset[]).map((preset) => ( + + ))} +
+

현재: {GAP_PRESETS[row.gap].pixels}

+
+ + + + {/* 패딩 설정 */} +
+ +
+ {(Object.keys(GAP_PRESETS) as GapPreset[]).map((preset) => ( + + ))} +
+

현재: {GAP_PRESETS[row.padding].pixels}

+
+ + + + {/* 수평 정렬 */} +
+
+ + +
+
+ + + + +
+
+ + + + {/* 수직 정렬 */} +
+
+ + +
+
+ + + + +
+
+ + + + {/* 배경색 */} +
+ +
+ onUpdateRow({ backgroundColor: e.target.value })} + className="h-10 w-20 cursor-pointer p-1" + /> + onUpdateRow({ backgroundColor: e.target.value })} + placeholder="#ffffff" + className="flex-1" + /> + {row.backgroundColor && ( + + )} +
+
+
+ ); +}; diff --git a/frontend/lib/constants/columnSpans.ts b/frontend/lib/constants/columnSpans.ts new file mode 100644 index 00000000..2494850f --- /dev/null +++ b/frontend/lib/constants/columnSpans.ts @@ -0,0 +1,264 @@ +/** + * 🎨 그리드 시스템 - 컬럼 스팬 상수 정의 + * + * Tailwind CSS 12컬럼 그리드 시스템을 위한 프리셋 정의 + */ + +/** + * 컬럼 스팬 프리셋 타입 + */ +export type ColumnSpanPreset = + | "full" // 12 컬럼 (100%) + | "half" // 6 컬럼 (50%) + | "third" // 4 컬럼 (33%) + | "twoThirds" // 8 컬럼 (67%) + | "quarter" // 3 컬럼 (25%) + | "threeQuarters" // 9 컬럼 (75%) + | "label" // 3 컬럼 (25%) - 폼 라벨 전용 + | "input" // 9 컬럼 (75%) - 폼 입력 전용 + | "small" // 2 컬럼 (17%) + | "medium" // 4 컬럼 (33%) + | "large" // 8 컬럼 (67%) + | "auto"; // 자동 계산 + +/** + * 컬럼 스팬 값 매핑 + */ +export const COLUMN_SPAN_VALUES: Record = { + full: 12, + half: 6, + third: 4, + twoThirds: 8, + quarter: 3, + threeQuarters: 9, + label: 3, + input: 9, + small: 2, + medium: 4, + large: 8, + auto: 0, // 자동 계산 시 0 +}; + +/** + * 컬럼 스팬 프리셋 정보 + */ +export interface ColumnSpanPresetInfo { + value: number; + label: string; + percentage: string; + class: string; + description?: string; +} + +/** + * 컬럼 스팬 프리셋 상세 정보 + */ +export const COLUMN_SPAN_PRESETS: Record = { + full: { + value: 12, + label: "전체", + percentage: "100%", + class: "col-span-12", + 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: "큰 컴포넌트", + }, + quarter: { + value: 3, + label: "1/4", + percentage: "25%", + class: "col-span-3", + description: "4분할 레이아웃", + }, + threeQuarters: { + value: 9, + label: "3/4", + percentage: "75%", + class: "col-span-9", + description: "입력 필드", + }, + label: { + value: 3, + label: "라벨용", + percentage: "25%", + class: "col-span-3", + description: "폼 라벨 전용", + }, + input: { + value: 9, + label: "입력용", + percentage: "75%", + class: "col-span-9", + description: "폼 입력 전용", + }, + small: { + value: 2, + label: "작게", + percentage: "17%", + class: "col-span-2", + description: "아이콘, 체크박스", + }, + medium: { + value: 4, + label: "보통", + percentage: "33%", + class: "col-span-4", + description: "보통 크기 컴포넌트", + }, + large: { + value: 8, + label: "크게", + percentage: "67%", + class: "col-span-8", + description: "큰 컴포넌트", + }, + auto: { + value: 0, + label: "자동", + percentage: "auto", + class: "", + description: "자동 계산", + }, +}; + +/** + * Gap/Spacing 프리셋 타입 + */ +export type GapPreset = "none" | "xs" | "sm" | "md" | "lg" | "xl"; + +/** + * Gap 프리셋 정보 + */ +export interface GapPresetInfo { + value: number; + label: string; + pixels: string; + class: string; +} + +/** + * Gap 프리셋 상세 정보 + */ +export const GAP_PRESETS: Record = { + none: { + value: 0, + label: "없음", + pixels: "0px", + class: "gap-0", + }, + xs: { + value: 2, + label: "매우 작게", + pixels: "8px", + class: "gap-2", + }, + sm: { + value: 4, + label: "작게", + pixels: "16px", + class: "gap-4", + }, + md: { + value: 6, + label: "보통", + pixels: "24px", + class: "gap-6", + }, + lg: { + value: 8, + label: "크게", + pixels: "32px", + class: "gap-8", + }, + xl: { + value: 12, + label: "매우 크게", + pixels: "48px", + class: "gap-12", + }, +}; + +/** + * 컬럼 스팬 유효성 검사 + */ +export function isValidColumnSpan(value: string): value is ColumnSpanPreset { + return value in COLUMN_SPAN_VALUES; +} + +/** + * 컬럼 스팬 값 가져오기 + */ +export function getColumnSpanValue(preset: ColumnSpanPreset): number { + return COLUMN_SPAN_VALUES[preset]; +} + +/** + * Tailwind 그리드 클래스 생성 + */ +export function getColumnSpanClass(preset: ColumnSpanPreset): string { + return COLUMN_SPAN_PRESETS[preset].class; +} + +/** + * 컬럼 시작 위치 클래스 생성 + */ +export function getColumnStartClass(start: number): string { + if (start < 1 || start > 12) { + console.warn(`Invalid column start: ${start}. Must be between 1 and 12.`); + return ""; + } + return `col-start-${start}`; +} + +/** + * 전체 그리드 클래스 조합 + */ +export function buildGridClasses(columnSpan: ColumnSpanPreset, columnStart?: number): string { + const classes: string[] = []; + + // 컬럼 스팬 + const spanClass = getColumnSpanClass(columnSpan); + if (spanClass) { + classes.push(spanClass); + } + + // 시작 위치 + if (columnStart !== undefined && columnStart > 0) { + classes.push(getColumnStartClass(columnStart)); + } + + return classes.join(" "); +} + +/** + * Gap 클래스 생성 + */ +export function getGapClass(preset: GapPreset): string { + return GAP_PRESETS[preset].class; +} + +/** + * Padding 클래스 생성 (gap 클래스를 padding으로 변환) + */ +export function getPaddingClass(preset: GapPreset): string { + return GAP_PRESETS[preset].class.replace("gap-", "p-"); +} diff --git a/frontend/lib/utils/widthToColumnSpan.ts b/frontend/lib/utils/widthToColumnSpan.ts new file mode 100644 index 00000000..5b230091 --- /dev/null +++ b/frontend/lib/utils/widthToColumnSpan.ts @@ -0,0 +1,266 @@ +/** + * 🔄 Width를 컬럼 스팬으로 변환하는 마이그레이션 유틸리티 + * + * 기존 픽셀 기반 width 값을 새로운 그리드 시스템의 컬럼 스팬으로 변환 + */ + +import { ColumnSpanPreset, COLUMN_SPAN_VALUES, getColumnSpanValue } from "@/lib/constants/columnSpans"; +import { ComponentData, LayoutData } from "@/types/screen"; + +/** + * 픽셀 width를 가장 가까운 ColumnSpanPreset으로 변환 + * + * @param width 픽셀 너비 + * @param canvasWidth 캔버스 전체 너비 (기본: 1920px) + * @returns 가장 가까운 컬럼 스팬 프리셋 + */ +export function convertWidthToColumnSpan(width: number, canvasWidth: number = 1920): ColumnSpanPreset { + if (width <= 0 || canvasWidth <= 0) { + return "half"; // 기본값 + } + + const percentage = (width / canvasWidth) * 100; + + // 각 프리셋의 백분율 계산 + const presetPercentages: Array<[ColumnSpanPreset, number]> = [ + ["full", 100], + ["threeQuarters", 75], + ["twoThirds", 67], + ["half", 50], + ["third", 33], + ["quarter", 25], + ["label", 25], + ["input", 75], + ["small", 17], + ["medium", 33], + ["large", 67], + ]; + + // 가장 가까운 값 찾기 + let closestPreset: ColumnSpanPreset = "half"; + let minDiff = Infinity; + + for (const [preset, presetPercentage] of presetPercentages) { + const diff = Math.abs(percentage - presetPercentage); + if (diff < minDiff) { + minDiff = diff; + closestPreset = preset; + } + } + + return closestPreset; +} + +/** + * Y 좌표를 기준으로 행 인덱스 계산 + * + * @param components 컴포넌트 배열 + * @param threshold 같은 행으로 간주할 Y 좌표 차이 (기본: 50px) + * @returns 행 인덱스가 추가된 컴포넌트 배열 + */ +export function calculateRowIndices(components: ComponentData[], threshold: number = 50): ComponentData[] { + if (components.length === 0) return []; + + // Y 좌표로 정렬 + const sorted = [...components].sort((a, b) => a.position.y - b.position.y); + + let currentRowIndex = 0; + let currentY = sorted[0]?.position.y ?? 0; + + return sorted.map((component) => { + if (Math.abs(component.position.y - currentY) > threshold) { + currentRowIndex++; + currentY = component.position.y; + } + + return { + ...component, + gridRowIndex: currentRowIndex, + }; + }); +} + +/** + * 같은 행 내에서 X 좌표로 시작 컬럼 계산 + * + * @param components 컴포넌트 배열 (gridRowIndex 필요) + * @returns 시작 컬럼이 추가된 컴포넌트 배열 + */ +export function calculateColumnStarts(components: ComponentData[]): ComponentData[] { + // 행별로 그룹화 + const rowGroups = new Map(); + + for (const component of components) { + const rowIndex = component.gridRowIndex ?? 0; + if (!rowGroups.has(rowIndex)) { + rowGroups.set(rowIndex, []); + } + rowGroups.get(rowIndex)!.push(component); + } + + // 각 행 내에서 X 좌표로 정렬하고 시작 컬럼 계산 + const result: ComponentData[] = []; + + for (const [rowIndex, rowComponents] of rowGroups) { + // X 좌표로 정렬 + const sorted = rowComponents.sort((a, b) => a.position.x - b.position.x); + + let currentColumn = 1; + + for (const component of sorted) { + const columnSpan = component.gridColumnSpan || "half"; + const spanValue = getColumnSpanValue(columnSpan); + + // 현재 컬럼이 12를 넘으면 다음 줄로 (실제로는 같은 행이지만 자동 줄바꿈) + if (currentColumn + spanValue > 13) { + currentColumn = 1; + } + + result.push({ + ...component, + gridColumnStart: currentColumn, + }); + + // 다음 컴포넌트는 현재 컴포넌트 뒤에 배치 + currentColumn += spanValue; + } + } + + return result; +} + +/** + * 컴포넌트 배열에서 width를 gridColumnSpan으로 일괄 변환 + * + * @param components 컴포넌트 배열 + * @param canvasWidth 캔버스 너비 (기본: 1920px) + * @returns gridColumnSpan이 추가된 컴포넌트 배열 + */ +export function migrateComponentsToColumnSpan( + components: ComponentData[], + canvasWidth: number = 1920, +): ComponentData[] { + return components.map((component) => { + // 이미 gridColumnSpan이 있으면 유지 + if (component.gridColumnSpan) { + return component; + } + + // width를 컬럼 스팬으로 변환 + const gridColumnSpan = convertWidthToColumnSpan(component.size.width, canvasWidth); + + return { + ...component, + gridColumnSpan, + gridRowIndex: component.gridRowIndex ?? 0, // 초기값 + }; + }); +} + +/** + * 전체 레이아웃 마이그레이션 + * + * @param layout 기존 레이아웃 데이터 + * @param canvasWidth 캔버스 너비 (기본: 1920px) + * @returns 새로운 그리드 시스템으로 변환된 레이아웃 + */ +export function migrateLayoutToGridSystem(layout: LayoutData, canvasWidth: number = 1920): LayoutData { + console.log("🔄 레이아웃 마이그레이션 시작:", { + screenId: layout.screenId, + componentCount: layout.components.length, + }); + + // 1단계: width를 gridColumnSpan으로 변환 + let migratedComponents = migrateComponentsToColumnSpan(layout.components, canvasWidth); + + // 2단계: Y 좌표로 행 인덱스 계산 + migratedComponents = calculateRowIndices(migratedComponents); + + // 3단계: 같은 행 내에서 X 좌표로 gridColumnStart 계산 + migratedComponents = calculateColumnStarts(migratedComponents); + + console.log("✅ 마이그레이션 완료:", { + componentCount: migratedComponents.length, + sampleComponent: migratedComponents[0], + }); + + return { + ...layout, + components: migratedComponents, + }; +} + +/** + * 단일 컴포넌트 마이그레이션 + * + * @param component 기존 컴포넌트 + * @param canvasWidth 캔버스 너비 + * @returns 마이그레이션된 컴포넌트 + */ +export function migrateComponent(component: ComponentData, canvasWidth: number = 1920): ComponentData { + // 이미 그리드 속성이 있으면 그대로 반환 + if (component.gridColumnSpan && component.gridRowIndex !== undefined) { + return component; + } + + const gridColumnSpan = component.gridColumnSpan || convertWidthToColumnSpan(component.size.width, canvasWidth); + + return { + ...component, + gridColumnSpan, + gridRowIndex: component.gridRowIndex ?? 0, + gridColumnStart: component.gridColumnStart, + }; +} + +/** + * 마이그레이션 필요 여부 확인 + * + * @param layout 레이아웃 데이터 + * @returns 마이그레이션 필요 여부 + */ +export function needsMigration(layout: LayoutData): boolean { + return layout.components.some((c) => !c.gridColumnSpan || c.gridRowIndex === undefined); +} + +/** + * 안전한 마이그레이션 (에러 처리 포함) + * + * @param layout 레이아웃 데이터 + * @param canvasWidth 캔버스 너비 + * @returns 마이그레이션된 레이아웃 또는 원본 (실패 시) + */ +export function safeMigrateLayout(layout: LayoutData, canvasWidth: number = 1920): LayoutData { + try { + if (!needsMigration(layout)) { + console.log("⏭️ 마이그레이션 불필요 - 이미 최신 형식"); + return layout; + } + + return migrateLayoutToGridSystem(layout, canvasWidth); + } catch (error) { + console.error("❌ 마이그레이션 실패:", error); + console.warn("⚠️ 원본 레이아웃 반환 - 수동 확인 필요"); + return layout; + } +} + +/** + * 백업 데이터 생성 + * + * @param layout 레이아웃 데이터 + * @returns JSON 문자열 + */ +export function createLayoutBackup(layout: LayoutData): string { + return JSON.stringify(layout, null, 2); +} + +/** + * 백업에서 복원 + * + * @param backupJson JSON 문자열 + * @returns 레이아웃 데이터 + */ +export function restoreFromBackup(backupJson: string): LayoutData { + return JSON.parse(backupJson); +} diff --git a/frontend/types/grid-system.ts b/frontend/types/grid-system.ts new file mode 100644 index 00000000..eed82cde --- /dev/null +++ b/frontend/types/grid-system.ts @@ -0,0 +1,107 @@ +/** + * 🎨 그리드 시스템 타입 정의 + * + * 행(Row) 기반 12컬럼 그리드 레이아웃 시스템 + */ + +import { ColumnSpanPreset, GapPreset } from "@/lib/constants/columnSpans"; +import { ComponentData } from "./screen-management"; + +/** + * 레이아웃 행 정의 + */ +export interface LayoutRow { + id: string; + rowIndex: number; + height: "auto" | "fixed" | "min" | "max"; + minHeight?: number; + maxHeight?: number; + fixedHeight?: number; + gap: GapPreset; + padding: GapPreset; + backgroundColor?: string; + alignment: "start" | "center" | "end" | "stretch" | "baseline"; + verticalAlignment: "top" | "middle" | "bottom" | "stretch"; + components: RowComponent[]; +} + +/** + * 행 내 컴포넌트 + */ +export interface RowComponent { + id: string; + componentId: string; // 실제 ComponentData의 ID + columnSpan: ColumnSpanPreset; + columnStart?: number; // 명시적 시작 위치 (선택) + order?: number; // 정렬 순서 + offset?: ColumnSpanPreset; // 왼쪽 여백 +} + +/** + * 전체 그리드 레이아웃 정의 + */ +export interface GridLayout { + screenId: number; + rows: LayoutRow[]; + components: Map; // 컴포넌트 저장소 + globalSettings: GridGlobalSettings; +} + +/** + * 그리드 전역 설정 + */ +export interface GridGlobalSettings { + containerMaxWidth?: "full" | "7xl" | "6xl" | "5xl" | "4xl"; + containerPadding: GapPreset; +} + +/** + * 행 생성 옵션 + */ +export interface CreateRowOptions { + height?: "auto" | "fixed"; + fixedHeight?: number; + gap?: GapPreset; + padding?: GapPreset; + alignment?: "start" | "center" | "end" | "stretch"; +} + +/** + * 컴포넌트 배치 옵션 + */ +export interface PlaceComponentOptions { + columnSpan: ColumnSpanPreset; + columnStart?: number; + rowIndex: number; +} + +/** + * 행 업데이트 옵션 + */ +export type UpdateRowOptions = Partial>; + +/** + * 드래그앤드롭 상태 + */ +export interface GridDragState { + isDragging: boolean; + draggedComponentId?: string; + targetRowId?: string; + targetColumnIndex?: number; + previewPosition?: { + rowIndex: number; + columnStart: number; + columnSpan: ColumnSpanPreset; + }; +} + +/** + * 그리드 가이드라인 옵션 + */ +export interface GridGuideOptions { + showGrid: boolean; + showColumnLines: boolean; + showRowBorders: boolean; + gridColor?: string; + gridOpacity?: number; +} diff --git a/frontend/types/screen-management.ts b/frontend/types/screen-management.ts index 8af12c3b..7dc2f060 100644 --- a/frontend/types/screen-management.ts +++ b/frontend/types/screen-management.ts @@ -17,6 +17,7 @@ import { ActiveStatus, isWebType, } from "./unified-core"; +import { ColumnSpanPreset } from "@/lib/constants/columnSpans"; // ===== 기본 컴포넌트 인터페이스 ===== @@ -26,16 +27,25 @@ import { export interface BaseComponent { id: string; type: ComponentType; - position: Position; - size: Size; + + // 🔄 레거시 위치/크기 (단계적 제거 예정) + position: Position; // y 좌표는 유지 (행 정렬용) + size: Size; // height만 사용 + + // 🆕 그리드 시스템 속성 + gridColumnSpan?: ColumnSpanPreset; // 컬럼 너비 + gridColumnStart?: number; // 시작 컬럼 (1-12) + gridRowIndex?: number; // 행 인덱스 + parentId?: string; label?: string; required?: boolean; readonly?: boolean; style?: ComponentStyle; className?: string; + // 새 컴포넌트 시스템에서 필요한 속성들 - gridColumns?: number; // 그리드에서 차지할 컬럼 수 (1-12) + gridColumns?: number; // 🔄 deprecated - gridColumnSpan 사용 zoneId?: string; // 레이아웃 존 ID componentConfig?: any; // 컴포넌트별 설정 componentType?: string; // 새 컴포넌트 시스템의 ID @@ -132,7 +142,13 @@ export interface ComponentComponent extends BaseComponent { /** * 통합 컴포넌트 데이터 타입 */ -export type ComponentData = WidgetComponent | ContainerComponent | GroupComponent | DataTableComponent | FileComponent | ComponentComponent; +export type ComponentData = + | WidgetComponent + | ContainerComponent + | GroupComponent + | DataTableComponent + | FileComponent + | ComponentComponent; // ===== 웹타입별 설정 인터페이스 =====