ERP-node/docs/GRID_SYSTEM_REDESIGN_PLAN.md

1261 lines
35 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 🎨 화면 관리 시스템 - 제한된 자유도 그리드 시스템 설계
## 🎯 핵심 철학: "제한된 자유도 (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<string, ComponentData>; // 컴포넌트 저장소
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<GridLayoutBuilderProps> = ({
layout,
onUpdateLayout,
selectedRowId,
selectedComponentId,
}) => {
return (
<div className="w-full h-full overflow-auto bg-gray-50">
{/* 글로벌 컨테이너 */}
<div
className={cn(
"mx-auto",
layout.globalSettings.containerMaxWidth === "full"
? "w-full"
: `max-w-${layout.globalSettings.containerMaxWidth}`,
GAP_PRESETS[layout.globalSettings.containerPadding].class.replace(
"gap-",
"px-"
)
)}
>
{/* 각 행 렌더링 */}
{layout.rows.map((row) => (
<LayoutRowRenderer
key={row.id}
row={row}
components={layout.components}
isSelected={selectedRowId === row.id}
selectedComponentId={selectedComponentId}
onSelectRow={() => onSelectRow(row.id)}
onUpdateRow={(updatedRow) => updateRow(row.id, updatedRow)}
/>
))}
{/* 새 행 추가 버튼 */}
<AddRowButton onClick={addNewRow} />
</div>
{/* 그리드 가이드라인 (개발 모드) */}
{showGridGuides && <GridGuides />}
</div>
);
};
```
### 3. 행(Row) 렌더러
```tsx
// components/screen/LayoutRowRenderer.tsx
interface LayoutRowRendererProps {
row: LayoutRow;
components: Map<string, ComponentData>;
isSelected: boolean;
selectedComponentId?: string;
onSelectRow: () => void;
onUpdateRow: (row: LayoutRow) => void;
}
export const LayoutRowRenderer: React.FC<LayoutRowRendererProps> = ({
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 (
<div className={rowClasses} onClick={onSelectRow} data-row-id={row.id}>
{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 (
<div key={rowComponent.id} className={componentClasses}>
<RealtimePreview
component={component}
isSelected={selectedComponentId === component.id}
onSelect={() => onSelectComponent(component.id)}
/>
</div>
);
})}
</div>
);
};
```
### 4. 행 설정 패널
```tsx
// components/screen/panels/RowSettingsPanel.tsx
interface RowSettingsPanelProps {
row: LayoutRow;
onUpdateRow: (row: LayoutRow) => void;
}
export const RowSettingsPanel: React.FC<RowSettingsPanelProps> = ({
row,
onUpdateRow,
}) => {
return (
<div className="space-y-6 p-4">
{/* 높이 설정 */}
<div>
<Label> 높이</Label>
<Select
value={row.height}
onValueChange={(value) =>
onUpdateRow({ ...row, height: value as any })
}
>
<SelectItem value="auto">자동</SelectItem>
<SelectItem value="fixed">고정</SelectItem>
<SelectItem value="min">최소 높이</SelectItem>
<SelectItem value="max">최대 높이</SelectItem>
</Select>
{row.height === "fixed" && (
<Input
type="number"
value={row.fixedHeight || 100}
onChange={(e) =>
onUpdateRow({ ...row, fixedHeight: parseInt(e.target.value) })
}
className="mt-2"
placeholder="높이 (px)"
/>
)}
</div>
{/* 간격 설정 */}
<div>
<Label>컴포넌트 간격</Label>
<div className="grid grid-cols-5 gap-2 mt-2">
{(Object.keys(GAP_PRESETS) as GapPreset[]).map((preset) => (
<Button
key={preset}
variant={row.gap === preset ? "default" : "outline"}
size="sm"
onClick={() => onUpdateRow({ ...row, gap: preset })}
>
{GAP_PRESETS[preset].label.split(" ")[0]}
</Button>
))}
</div>
</div>
{/* 패딩 설정 */}
<div>
<Label> 패딩</Label>
<div className="grid grid-cols-5 gap-2 mt-2">
{(Object.keys(GAP_PRESETS) as GapPreset[]).map((preset) => (
<Button
key={preset}
variant={row.padding === preset ? "default" : "outline"}
size="sm"
onClick={() => onUpdateRow({ ...row, padding: preset })}
>
{GAP_PRESETS[preset].label.split(" ")[0]}
</Button>
))}
</div>
</div>
{/* 정렬 설정 */}
<div>
<Label>수평 정렬</Label>
<div className="grid grid-cols-4 gap-2 mt-2">
<Button
variant={row.alignment === "start" ? "default" : "outline"}
size="sm"
onClick={() => onUpdateRow({ ...row, alignment: "start" })}
>
왼쪽
</Button>
<Button
variant={row.alignment === "center" ? "default" : "outline"}
size="sm"
onClick={() => onUpdateRow({ ...row, alignment: "center" })}
>
중앙
</Button>
<Button
variant={row.alignment === "end" ? "default" : "outline"}
size="sm"
onClick={() => onUpdateRow({ ...row, alignment: "end" })}
>
오른쪽
</Button>
<Button
variant={row.alignment === "stretch" ? "default" : "outline"}
size="sm"
onClick={() => onUpdateRow({ ...row, alignment: "stretch" })}
>
늘림
</Button>
</div>
</div>
{/* 수직 정렬 */}
<div>
<Label>수직 정렬</Label>
<div className="grid grid-cols-4 gap-2 mt-2">
<Button
variant={row.verticalAlignment === "top" ? "default" : "outline"}
size="sm"
onClick={() => onUpdateRow({ ...row, verticalAlignment: "top" })}
>
</Button>
<Button
variant={row.verticalAlignment === "middle" ? "default" : "outline"}
size="sm"
onClick={() => onUpdateRow({ ...row, verticalAlignment: "middle" })}
>
중앙
</Button>
<Button
variant={row.verticalAlignment === "bottom" ? "default" : "outline"}
size="sm"
onClick={() => onUpdateRow({ ...row, verticalAlignment: "bottom" })}
>
아래
</Button>
<Button
variant={
row.verticalAlignment === "stretch" ? "default" : "outline"
}
size="sm"
onClick={() =>
onUpdateRow({ ...row, verticalAlignment: "stretch" })
}
>
늘림
</Button>
</div>
</div>
{/* 배경색 */}
<div>
<Label>배경색</Label>
<Input
type="color"
value={row.backgroundColor || "#ffffff"}
onChange={(e) =>
onUpdateRow({ ...row, backgroundColor: e.target.value })
}
className="mt-2"
/>
</div>
</div>
);
};
```
### 5. 컴포넌트 너비 설정 패널
```tsx
// components/screen/panels/ComponentGridPanel.tsx
interface ComponentGridPanelProps {
rowComponent: RowComponent;
onUpdate: (rowComponent: RowComponent) => void;
}
export const ComponentGridPanel: React.FC<ComponentGridPanelProps> = ({
rowComponent,
onUpdate,
}) => {
return (
<div className="space-y-4">
{/* 컬럼 스팬 선택 */}
<div>
<Label>컴포넌트 너비</Label>
<div className="grid grid-cols-2 gap-2 mt-2">
{Object.entries(COLUMN_SPAN_PRESETS).map(([key, config]) => (
<Button
key={key}
variant={rowComponent.columnSpan === key ? "default" : "outline"}
size="sm"
onClick={() =>
onUpdate({
...rowComponent,
columnSpan: key as ColumnSpanPreset,
})
}
className="justify-between"
>
<span>{config.label}</span>
<span className="text-xs text-gray-500">{config.value}/12</span>
</Button>
))}
</div>
</div>
{/* 시각적 프리뷰 */}
<div>
<Label>미리보기</Label>
<div className="grid grid-cols-12 gap-1 mt-2 h-12">
{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 (
<div
key={i}
className={cn(
"border rounded",
isActive
? "bg-blue-500 border-blue-600"
: "bg-gray-100 border-gray-300"
)}
/>
);
})}
</div>
<div className="text-xs text-gray-500 mt-1 text-center">
{COLUMN_SPAN_PRESETS[rowComponent.columnSpan].value} 컬럼 차지
</div>
</div>
{/* 고급 옵션 */}
<Collapsible>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="w-full">
고급 옵션
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 mt-4">
{/* 시작 위치 명시 */}
<div>
<Label>시작 컬럼 (선택)</Label>
<Select
value={rowComponent.columnStart?.toString() || "auto"}
onValueChange={(value) =>
onUpdate({
...rowComponent,
columnStart: value === "auto" ? undefined : parseInt(value),
})
}
>
<SelectItem value="auto">자동</SelectItem>
{Array.from({ length: 12 }, (_, i) => (
<SelectItem key={i + 1} value={(i + 1).toString()}>
{i + 1} 컬럼부터
</SelectItem>
))}
</Select>
</div>
{/* 정렬 순서 */}
<div>
<Label>정렬 순서</Label>
<Input
type="number"
value={rowComponent.order || 0}
onChange={(e) =>
onUpdate({
...rowComponent,
order: parseInt(e.target.value),
})
}
placeholder="0 (자동)"
/>
</div>
{/* 왼쪽 오프셋 */}
<div>
<Label>왼쪽 여백</Label>
<Select
value={rowComponent.offset || "none"}
onValueChange={(value) =>
onUpdate({
...rowComponent,
offset:
value === "none" ? undefined : (value as ColumnSpanPreset),
})
}
>
<SelectItem value="none">없음</SelectItem>
{Object.entries(COLUMN_SPAN_PRESETS).map(([key, config]) => (
<SelectItem key={key} value={key}>
{config.label}
</SelectItem>
))}
</Select>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
```
---
## 🎭 레이아웃 패턴 템플릿
### 템플릿 시스템
```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
<div className="border-2 border-dashed border-gray-300 rounded-lg p-4 hover:border-blue-400 cursor-pointer">
<div className="flex items-center justify-center gap-2">
<Plus className="w-5 h-5" />
<span> 추가</span>
</div>
{/* 빠른 패턴 선택 */}
<div className="grid grid-cols-3 gap-2 mt-4">
<Button variant="outline" size="sm">
</Button>
<Button variant="outline" size="sm">
전체 너비
</Button>
<Button variant="outline" size="sm">
2분할
</Button>
</div>
</div>
```
### 3. 컴포넌트 배치 UI
```tsx
// 컴포넌트를 행에 드래그앤드롭
<RowComponent onDrop={handleComponentDrop}>
{row.components.length === 0 ? (
<div className="col-span-12 border-2 border-dashed border-gray-300 rounded p-8 text-center">
<span className="text-gray-400">
컴포넌트를 여기에 드래그하세요
</span>
</div>
) : (
// 기존 컴포넌트 렌더링
)}
</RowComponent>
```
### 4. 너비 조정 UI (인터랙티브)
```tsx
// 컴포넌트 선택 시 너비 조정 핸들 표시
<div className="relative group">
<RealtimePreview component={component} />
{isSelected && (
<div className="absolute top-0 right-0 flex gap-1 p-1 bg-white shadow-lg rounded">
<Button size="xs" variant="ghost" onClick={() => adjustWidth("decrease")}>
</Button>
<span className="text-xs px-2 py-1">{currentSpanLabel}</span>
<Button size="xs" variant="ghost" onClick={() => adjustWidth("increase")}>
+
</Button>
</div>
)}
</div>
```
---
## 🔄 마이그레이션 전략
### 레거시 데이터 변환
```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. **확장 가능**: 새로운 패턴 추가 용이
---
이 설계는 **"정형화된 자유도"**를 제공하여, 사용자가 디자인 원칙을 벗어나지 않으면서도 원하는 레이아웃을 자유롭게 만들 수 있게 합니다! 🎨