화면관리 12컬럼 중간 커밋

This commit is contained in:
kjs 2025-10-13 18:28:03 +09:00
parent 0dc4d53876
commit e8123932ba
14 changed files with 4514 additions and 258 deletions

View File

@ -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 }; // 픽셀 크기
}
// 사용자 입력
<Input type="number" value={width} onChange={...} />
// → 예: 850px 입력 가능 (자유롭지만 일관성 없음)
```
### After (새 시스템)
```typescript
// 행 기반 그리드 시스템
interface ComponentData {
gridRowIndex: number; // 몇 번째 행인가
gridColumnSpan: ColumnSpanPreset; // 너비 (컬럼 수)
gridColumnStart?: number; // 시작 위치 (선택)
size: { height: number }; // 높이만 픽셀 지정
}
// 사용자 선택
<Select value={columnSpan}>
<SelectItem value="full">전체 (12/12)</SelectItem>
<SelectItem value="half">절반 (6/12)</SelectItem>
<SelectItem value="third">1/3 (4/12)</SelectItem>
// ... 정해진 옵션만
</Select>;
// → 예: "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
<Label>너비</Label>
<Input
type="number"
value={width}
onChange={(e) => setWidth(e.target.value)}
/>
// 사용자가 아무 숫자나 입력 가능 (850, 1234 등)
```
**After**:
```tsx
<Label>컴포넌트 너비</Label>
<Select value={columnSpan} onValueChange={setColumnSpan}>
<SelectItem value="full">전체 (12/12 - 100%)</SelectItem>
<SelectItem value="half">절반 (6/12 - 50%)</SelectItem>
<SelectItem value="third">1/3 (4/12 - 33%)</SelectItem>
{/* ... 정해진 옵션만 */}
</Select>
{/* 시각적 프리뷰 */}
<div className="grid grid-cols-12 gap-1 h-6">
{Array.from({ length: 12 }).map((_, i) => (
<div className={i < spanValue ? "bg-blue-500" : "bg-gray-100"} />
))}
</div>
<p>6 / 12 컬럼</p>
```
### 2. 행(Row) 관리 UI (신규)
```tsx
{
/* 각 행에 설정 가능한 옵션 */
}
<RowSettings>
<Label>행 높이</Label>
<Select>
<SelectItem value="auto">자동</SelectItem>
<SelectItem value="fixed">고정</SelectItem>
</Select>
<Label>컴포넌트 간격</Label>
<ButtonGroup>
<Button>없음</Button>
<Button>작게 (16px)</Button>
<Button>보통 (24px)</Button>
<Button>크게 (32px)</Button>
</ButtonGroup>
<Label>정렬</Label>
<ButtonGroup>
<Button>왼쪽</Button>
<Button>중앙</Button>
<Button>오른쪽</Button>
<Button>늘림</Button>
</ButtonGroup>
</RowSettings>;
```
### 3. 드래그앤드롭 경험 개선
```tsx
{
/* 빈 행 */
}
<div className="border-dashed border-2 p-8">
컴포넌트를 여기에 드래그하세요
{/* 빠른 패턴 버튼 */}
<div className="mt-4 flex gap-2">
<Button size="sm">폼 행 추가</Button>
<Button size="sm">2분할 추가</Button>
<Button size="sm">3분할 추가</Button>
</div>
</div>;
{
/* 드롭 시 그리드 가이드라인 표시 */
}
<div className="absolute inset-0 pointer-events-none">
{/* 12개 컬럼 구분선 */}
{Array.from({ length: 12 }).map((_, i) => (
<div className="border-l border-dashed border-blue-300" />
))}
</div>;
```
---
## 🔍 마이그레이션 상세 전략
### 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 유지
<FloatingPanel width={360} height={400} />
```
#### 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주 내에 완전히 새로운 그리드 시스템**을 구축할 수 있습니다! 🎉
**준비되셨나요? 어디서부터 시작하시겠습니까?** 💪

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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<GridLayoutBuilderProps> = ({
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<LayoutRow>) => {
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 (
<div className={containerClasses}>
{/* 그리드 가이드라인 */}
{showGridGuides && (
<div className="pointer-events-none absolute inset-0 z-0">
<div className={globalContainerClasses}>
<div className="grid h-full grid-cols-12">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="h-full border-l border-dashed border-gray-300 opacity-30" />
))}
</div>
</div>
</div>
)}
{/* 메인 컨테이너 */}
<div className={cn(globalContainerClasses, "relative z-10 py-8")}>
{layout.rows.length === 0 ? (
// 빈 레이아웃
<div className="flex min-h-[400px] flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-white">
<Grid3x3 className="mb-4 h-16 w-16 text-gray-300" />
<h3 className="mb-2 text-lg font-medium text-gray-600"> </h3>
<p className="mb-6 text-sm text-gray-500"> </p>
<Button onClick={() => addNewRow()} size="lg">
<Plus className="mr-2 h-5 w-5" />
</Button>
</div>
) : (
// 행 목록
<div className="space-y-4">
{layout.rows.map((row) => (
<LayoutRowRenderer
key={row.id}
row={row}
components={layout.components}
isSelected={selectedRowId === row.id}
selectedComponentId={selectedComponentId}
onSelectRow={() => handleSelectRow(row.id)}
onSelectComponent={handleSelectComponent}
onUpdateRow={(updatedRow) => updateRow(row.id, updatedRow)}
/>
))}
</div>
)}
{/* 새 행 추가 버튼 */}
{layout.rows.length > 0 && (
<div className="mt-6 flex justify-center">
<div className="inline-flex flex-col gap-2">
<Button onClick={() => addNewRow()} variant="outline" size="lg" className="w-full">
<Plus className="mr-2 h-5 w-5" />
</Button>
{/* 빠른 추가 버튼들 */}
<div className="flex gap-2">
<Button
onClick={() =>
addNewRow({
gap: "sm",
padding: "sm",
alignment: "start",
})
}
variant="ghost"
size="sm"
className="flex-1"
>
</Button>
<Button
onClick={() =>
addNewRow({
gap: "md",
padding: "md",
alignment: "stretch",
})
}
variant="ghost"
size="sm"
className="flex-1"
>
2
</Button>
<Button
onClick={() =>
addNewRow({
gap: "none",
padding: "md",
alignment: "stretch",
})
}
variant="ghost"
size="sm"
className="flex-1"
>
</Button>
</div>
</div>
</div>
)}
{/* 레이아웃 정보 */}
<div className="mt-8 rounded-lg border bg-white p-4">
<div className="flex items-center justify-between text-sm text-gray-600">
<div className="flex items-center gap-4">
<span>: {layout.rows.length}</span>
<span>: {layout.components.size}</span>
<span>
:{" "}
{layout.globalSettings.containerMaxWidth === "full" ? "전체" : layout.globalSettings.containerMaxWidth}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400">12 </span>
{showGridGuides && <span className="text-xs text-green-600"> </span>}
</div>
</div>
</div>
</div>
</div>
);
};

View File

@ -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;

View File

@ -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<string, ComponentData>;
isSelected: boolean;
selectedComponentId?: string;
onSelectRow: () => void;
onSelectComponent: (componentId: string) => void;
onUpdateRow?: (row: LayoutRow) => void;
}
export const LayoutRowRenderer: React.FC<LayoutRowRendererProps> = ({
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 (
<div className={rowClasses} style={rowStyle} onClick={onSelectRow} data-row-id={row.id}>
{/* 행 인덱스 표시 */}
<div className="absolute top-1/2 -left-8 -translate-y-1/2 font-mono text-xs text-gray-400">
{row.rowIndex + 1}
</div>
{row.components.length === 0 ? (
// 빈 행
<div className="col-span-12 flex items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8">
<div className="text-center">
<p className="mb-2 text-sm text-gray-400"> </p>
<div className="flex justify-center gap-2">
<button className="rounded border bg-white px-2 py-1 text-xs hover:bg-gray-50"> </button>
<button className="rounded border bg-white px-2 py-1 text-xs hover:bg-gray-50">2</button>
<button className="rounded border bg-white px-2 py-1 text-xs hover:bg-gray-50"></button>
</div>
</div>
</div>
) : (
// 컴포넌트 렌더링
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 (
<div
key={rowComponent.id}
className={componentClasses}
style={componentStyle}
onClick={(e) => {
e.stopPropagation();
onSelectComponent(component.id);
}}
>
<RealtimePreviewDynamic
component={component}
isSelected={selectedComponentId === component.id}
isDesignMode={true}
onClick={(e) => {
e?.stopPropagation();
onSelectComponent(component.id);
}}
/>
</div>
);
})
)}
{/* 선택 시 행 설정 버튼 */}
{isSelected && (
<div className="absolute top-1/2 -right-8 flex -translate-y-1/2 flex-col gap-1">
<button
className="rounded border bg-white p-1 shadow-sm hover:bg-gray-50"
title="행 설정"
onClick={(e) => {
e.stopPropagation();
// 행 설정 패널 열기
}}
>
<svg className="h-4 w-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
<button
className="rounded border bg-white p-1 shadow-sm hover:bg-red-50 hover:text-red-600"
title="행 삭제"
onClick={(e) => {
e.stopPropagation();
// 행 삭제
}}
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
)}
</div>
);
};

View File

@ -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<ComponentData>) => {
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
{/* 실제 작업 캔버스 (해상도 크기) - 반응형 개선 */}
<div
className="mx-auto bg-white shadow-lg"
style={{
style={{
width: screenResolution.width,
height: Math.max(screenResolution.height, 800), // 최소 높이 보장
minHeight: screenResolution.height
minHeight: screenResolution.height,
}}
>
<div
@ -3533,7 +3552,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
// 전역 파일 상태도 key에 포함하여 실시간 리렌더링
const globalFileState = typeof window !== 'undefined' ? (window as any).globalFileState || {} : {};
const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
const globalFiles = globalFileState[component.id] || [];
const componentFiles = (component as any).uploadedFiles || [];
const fileStateKey = `${globalFiles.length}-${JSON.stringify(globalFiles.map((f: any) => f.objid) || [])}-${componentFiles.length}`;
@ -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
<StyleEditor
style={selectedComponent.style || {}}
onStyleChange={(newStyle) => {
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);
}
}}

View File

@ -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({
<div
className={cn(
"rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-4",
gridClasses, // 🆕 그리드 클래스 추가
isSelected && "border-primary bg-accent",
isMoving && "cursor-move",
className,

View File

@ -6,8 +6,10 @@ import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
// import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; // 임시 비활성화
// import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ChevronDown } from "lucide-react";
import { Settings, Move, Type, Trash2, Copy, Group, Ungroup } from "lucide-react";
import {
ComponentData,
@ -19,6 +21,8 @@ import {
AreaLayoutType,
TableInfo,
} from "@/types/screen";
import { ColumnSpanPreset, COLUMN_SPAN_PRESETS, COLUMN_SPAN_VALUES } from "@/lib/constants/columnSpans";
import { cn } from "@/lib/utils";
import DataTableConfigPanel from "./DataTableConfigPanel";
import { useWebTypes } from "@/hooks/admin/useWebTypes";
@ -124,16 +128,16 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
}) => {
// 🔍 디버깅: 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<PropertiesPanelProps> = ({
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<PropertiesPanelProps> = ({
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<PropertiesPanelProps> = ({
});
// 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<PropertiesPanelProps> = ({
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<PropertiesPanelProps> = ({
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<PropertiesPanelProps> = ({
// 위치가 변경되었는지 확인
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<PropertiesPanelProps> = ({
<div className="border-b border-gray-200 p-4">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center space-x-2">
<Settings className="h-5 w-5 text-muted-foreground" />
<Settings className="text-muted-foreground h-5 w-5" />
<span className="text-lg font-semibold"> </span>
</div>
<Badge variant="secondary" className="text-xs">
@ -450,7 +455,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
<div className="border-b border-gray-200 p-4">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center space-x-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<Settings className="text-muted-foreground h-4 w-4" />
<h3 className="font-medium text-gray-900"> </h3>
</div>
<Badge variant="secondary" className="text-xs">
@ -491,7 +496,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
{/* 기본 정보 */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Type className="h-4 w-4 text-muted-foreground" />
<Type className="text-muted-foreground h-4 w-4" />
<h4 className="font-medium text-gray-900"> </h4>
</div>
@ -507,7 +512,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
value={selectedComponent.columnName || ""}
readOnly
placeholder="데이터베이스 컬럼명"
className="mt-1 bg-gray-50 text-muted-foreground"
className="text-muted-foreground mt-1 bg-gray-50"
title="컬럼명은 변경할 수 없습니다"
/>
</div>
@ -517,7 +522,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
</Label>
<select
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:ring-1 focus:ring-blue-500 focus:outline-none"
className="focus:border-primary mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:ring-blue-500 focus:outline-none"
value={localInputs.widgetType}
onChange={(e) => {
const value = e.target.value as WebType;
@ -594,7 +599,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
{/* 위치 및 크기 */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Move className="h-4 w-4 text-muted-foreground" />
<Move className="text-muted-foreground h-4 w-4" />
<h4 className="font-medium text-gray-900"> </h4>
</div>
@ -622,7 +627,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
}}
className={`mt-1 ${
dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id
? "border-blue-300 bg-accent text-blue-700"
? "bg-accent border-blue-300 text-blue-700"
: ""
}`}
readOnly={dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id}
@ -652,7 +657,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
}}
className={`mt-1 ${
dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id
? "border-blue-300 bg-accent text-blue-700"
? "bg-accent border-blue-300 text-blue-700"
: ""
}`}
readOnly={dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id}
@ -662,26 +667,94 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
{/* 카드 레이아웃은 자동 크기 계산으로 너비/높이 설정 숨김 */}
{selectedComponent?.type !== "layout" || (selectedComponent as any)?.layoutType !== "card" ? (
<>
<div>
<Label htmlFor="width" className="text-sm font-medium">
</Label>
<Input
id="width"
type="number"
value={localInputs.width}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, width: newValue }));
onUpdateProperty("size.width", Number(newValue));
{/* 🆕 컬럼 스팬 선택 (width 대체) */}
<div className="col-span-2">
<Label className="text-sm font-medium"> </Label>
<Select
value={selectedComponent.gridColumnSpan || "half"}
onValueChange={(value) => {
onUpdateProperty("gridColumnSpan", value as ColumnSpanPreset);
}}
className="mt-1"
/>
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(COLUMN_SPAN_PRESETS)
.filter(([key]) => key !== "auto")
.map(([key, info]) => (
<SelectItem key={key} value={key}>
<div className="flex w-full items-center justify-between gap-4">
<span>{info.label}</span>
<span className="text-xs text-gray-500">
{info.value}/12 ({info.percentage})
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{/* 시각적 프리뷰 */}
<div className="mt-3 space-y-2">
<Label className="text-xs text-gray-500"></Label>
<div className="grid h-6 grid-cols-12 gap-0.5 overflow-hidden rounded border">
{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 (
<div
key={i}
className={cn("h-full transition-colors", isActive ? "bg-blue-500" : "bg-gray-100")}
/>
);
})}
</div>
<p className="text-center text-xs text-gray-500">
{COLUMN_SPAN_VALUES[selectedComponent.gridColumnSpan || "half"]} / 12
</p>
</div>
{/* 고급 설정 */}
<Collapsible className="mt-3">
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="w-full justify-between">
<span className="text-xs"> </span>
<ChevronDown className="h-3 w-3" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 space-y-2">
<div>
<Label className="text-xs"> </Label>
<Select
value={selectedComponent.gridColumnStart?.toString() || "auto"}
onValueChange={(value) => {
onUpdateProperty("gridColumnStart", value === "auto" ? undefined : parseInt(value));
}}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto"></SelectItem>
{Array.from({ length: 12 }, (_, i) => (
<SelectItem key={i + 1} value={(i + 1).toString()}>
{i + 1}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-gray-500">"자동" </p>
</div>
</CollapsibleContent>
</Collapsible>
</div>
<div>
<Label htmlFor="height" className="text-sm font-medium">
(px)
</Label>
<Input
id="height"
@ -697,8 +770,8 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
</div>
</>
) : (
<div className="col-span-2 rounded-lg bg-accent p-3 text-center">
<p className="text-sm text-primary"> </p>
<div className="bg-accent col-span-2 rounded-lg p-3 text-center">
<p className="text-primary text-sm"> </p>
<p className="mt-1 text-xs text-blue-500"> </p>
</div>
)}
@ -756,7 +829,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
{/* 라벨 스타일 */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Type className="h-4 w-4 text-muted-foreground" />
<Type className="text-muted-foreground h-4 w-4" />
<h4 className="font-medium text-gray-900"> </h4>
</div>
@ -840,7 +913,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
</Label>
<select
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:ring-1 focus:ring-blue-500 focus:outline-none"
className="focus:border-primary mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:ring-blue-500 focus:outline-none"
value={selectedComponent.style?.labelFontWeight || "500"}
onChange={(e) => onUpdateProperty("style.labelFontWeight", e.target.value)}
>
@ -863,7 +936,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
</Label>
<select
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:ring-1 focus:ring-blue-500 focus:outline-none"
className="focus:border-primary mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:ring-blue-500 focus:outline-none"
value={selectedComponent.style?.labelTextAlign || "left"}
onChange={(e) => onUpdateProperty("style.labelTextAlign", e.target.value)}
>
@ -900,7 +973,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
{/* 그룹 설정 */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Group className="h-4 w-4 text-muted-foreground" />
<Group className="text-muted-foreground h-4 w-4" />
<h4 className="font-medium text-gray-900"> </h4>
</div>
@ -931,7 +1004,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
{/* 영역 설정 */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<Settings className="text-muted-foreground h-4 w-4" />
<h4 className="font-medium text-gray-900"> </h4>
</div>
@ -974,7 +1047,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
</Label>
<select
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:ring-1 focus:ring-blue-500 focus:outline-none"
className="focus:border-primary mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:ring-blue-500 focus:outline-none"
value={(selectedComponent as AreaComponent).layoutType}
onChange={(e) => onUpdateProperty("layoutType", e.target.value as AreaLayoutType)}
>
@ -1035,7 +1108,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
<div>
<Label className="text-xs"> </Label>
<select
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:ring-1 focus:ring-blue-500 focus:outline-none"
className="focus:border-primary mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:ring-blue-500 focus:outline-none"
value={(selectedComponent as AreaComponent).layoutConfig?.justifyContent || "flex-start"}
onChange={(e) => onUpdateProperty("layoutConfig.justifyContent", e.target.value)}
>
@ -1069,7 +1142,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
<div>
<Label className="text-xs"> </Label>
<select
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:ring-1 focus:ring-blue-500 focus:outline-none"
className="focus:border-primary mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:ring-blue-500 focus:outline-none"
value={(selectedComponent as AreaComponent).layoutConfig?.sidebarPosition || "left"}
onChange={(e) => onUpdateProperty("layoutConfig.sidebarPosition", e.target.value)}
>

View File

@ -0,0 +1,247 @@
"use client";
import React from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { LayoutRow } from "@/types/grid-system";
import { GapPreset, GAP_PRESETS } from "@/lib/constants/columnSpans";
import { Rows, AlignHorizontalJustifyCenter, AlignVerticalJustifyCenter } from "lucide-react";
interface RowSettingsPanelProps {
row: LayoutRow;
onUpdateRow: (updates: Partial<LayoutRow>) => void;
}
export const RowSettingsPanel: React.FC<RowSettingsPanelProps> = ({ row, onUpdateRow }) => {
return (
<div className="space-y-6 p-4">
{/* 헤더 */}
<div className="flex items-center gap-2">
<Rows className="text-primary h-5 w-5" />
<h3 className="text-lg font-semibold"> </h3>
<span className="text-sm text-gray-500">#{row.rowIndex + 1}</span>
</div>
<Separator />
{/* 높이 설정 */}
<div className="space-y-3">
<Label className="text-sm font-medium"> </Label>
<Select
value={row.height}
onValueChange={(value: "auto" | "fixed" | "min" | "max") => onUpdateRow({ height: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto"> ( )</SelectItem>
<SelectItem value="fixed"> </SelectItem>
<SelectItem value="min"> </SelectItem>
<SelectItem value="max"> </SelectItem>
</SelectContent>
</Select>
{/* 고정 높이 입력 */}
{row.height === "fixed" && (
<div>
<Label className="text-xs text-gray-500"> (px)</Label>
<Input
type="number"
value={row.fixedHeight || 100}
onChange={(e) => onUpdateRow({ fixedHeight: parseInt(e.target.value) })}
className="mt-1"
placeholder="100"
min={50}
max={1000}
/>
</div>
)}
{/* 최소 높이 입력 */}
{row.height === "min" && (
<div>
<Label className="text-xs text-gray-500"> (px)</Label>
<Input
type="number"
value={row.minHeight || 50}
onChange={(e) => onUpdateRow({ minHeight: parseInt(e.target.value) })}
className="mt-1"
placeholder="50"
min={0}
max={1000}
/>
</div>
)}
{/* 최대 높이 입력 */}
{row.height === "max" && (
<div>
<Label className="text-xs text-gray-500"> (px)</Label>
<Input
type="number"
value={row.maxHeight || 500}
onChange={(e) => onUpdateRow({ maxHeight: parseInt(e.target.value) })}
className="mt-1"
placeholder="500"
min={0}
max={2000}
/>
</div>
)}
</div>
<Separator />
{/* 간격 설정 */}
<div className="space-y-3">
<Label className="text-sm font-medium"> </Label>
<div className="grid grid-cols-3 gap-2">
{(Object.keys(GAP_PRESETS) as GapPreset[]).map((preset) => (
<Button
key={preset}
variant={row.gap === preset ? "default" : "outline"}
size="sm"
onClick={() => onUpdateRow({ gap: preset })}
className="text-xs"
>
{GAP_PRESETS[preset].label}
</Button>
))}
</div>
<p className="text-xs text-gray-500">: {GAP_PRESETS[row.gap].pixels}</p>
</div>
<Separator />
{/* 패딩 설정 */}
<div className="space-y-3">
<Label className="text-sm font-medium"> </Label>
<div className="grid grid-cols-3 gap-2">
{(Object.keys(GAP_PRESETS) as GapPreset[]).map((preset) => (
<Button
key={preset}
variant={row.padding === preset ? "default" : "outline"}
size="sm"
onClick={() => onUpdateRow({ padding: preset })}
className="text-xs"
>
{GAP_PRESETS[preset].label}
</Button>
))}
</div>
<p className="text-xs text-gray-500">: {GAP_PRESETS[row.padding].pixels}</p>
</div>
<Separator />
{/* 수평 정렬 */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<AlignHorizontalJustifyCenter className="h-4 w-4 text-gray-600" />
<Label className="text-sm font-medium"> </Label>
</div>
<div className="grid grid-cols-2 gap-2">
<Button
variant={row.alignment === "start" ? "default" : "outline"}
size="sm"
onClick={() => onUpdateRow({ alignment: "start" })}
>
</Button>
<Button
variant={row.alignment === "center" ? "default" : "outline"}
size="sm"
onClick={() => onUpdateRow({ alignment: "center" })}
>
</Button>
<Button
variant={row.alignment === "end" ? "default" : "outline"}
size="sm"
onClick={() => onUpdateRow({ alignment: "end" })}
>
</Button>
<Button
variant={row.alignment === "stretch" ? "default" : "outline"}
size="sm"
onClick={() => onUpdateRow({ alignment: "stretch" })}
>
</Button>
</div>
</div>
<Separator />
{/* 수직 정렬 */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<AlignVerticalJustifyCenter className="h-4 w-4 text-gray-600" />
<Label className="text-sm font-medium"> </Label>
</div>
<div className="grid grid-cols-2 gap-2">
<Button
variant={row.verticalAlignment === "top" ? "default" : "outline"}
size="sm"
onClick={() => onUpdateRow({ verticalAlignment: "top" })}
>
</Button>
<Button
variant={row.verticalAlignment === "middle" ? "default" : "outline"}
size="sm"
onClick={() => onUpdateRow({ verticalAlignment: "middle" })}
>
</Button>
<Button
variant={row.verticalAlignment === "bottom" ? "default" : "outline"}
size="sm"
onClick={() => onUpdateRow({ verticalAlignment: "bottom" })}
>
</Button>
<Button
variant={row.verticalAlignment === "stretch" ? "default" : "outline"}
size="sm"
onClick={() => onUpdateRow({ verticalAlignment: "stretch" })}
>
</Button>
</div>
</div>
<Separator />
{/* 배경색 */}
<div className="space-y-3">
<Label className="text-sm font-medium"></Label>
<div className="flex gap-2">
<Input
type="color"
value={row.backgroundColor || "#ffffff"}
onChange={(e) => onUpdateRow({ backgroundColor: e.target.value })}
className="h-10 w-20 cursor-pointer p-1"
/>
<Input
type="text"
value={row.backgroundColor || ""}
onChange={(e) => onUpdateRow({ backgroundColor: e.target.value })}
placeholder="#ffffff"
className="flex-1"
/>
{row.backgroundColor && (
<Button variant="ghost" size="sm" onClick={() => onUpdateRow({ backgroundColor: undefined })}>
</Button>
)}
</div>
</div>
</div>
);
};

View File

@ -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<ColumnSpanPreset, number> = {
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<ColumnSpanPreset, ColumnSpanPresetInfo> = {
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<GapPreset, GapPresetInfo> = {
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-");
}

View File

@ -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<number, ComponentData[]>();
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);
}

View File

@ -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<string, ComponentData>; // 컴포넌트 저장소
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<Omit<LayoutRow, "id" | "rowIndex" | "components">>;
/**
*
*/
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;
}

View File

@ -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;
// ===== 웹타입별 설정 인터페이스 =====