ERP-node/docs/GRID_SYSTEM_REDESIGN_PLAN.md

1261 lines
35 KiB
Markdown
Raw Permalink Normal View History

2025-10-13 18:28:03 +09:00
# 🎨 화면 관리 시스템 - 제한된 자유도 그리드 시스템 설계
## 🎯 핵심 철학: "제한된 자유도 (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. **확장 가능**: 새로운 패턴 추가 용이
---
이 설계는 **"정형화된 자유도"**를 제공하여, 사용자가 디자인 원칙을 벗어나지 않으면서도 원하는 레이아웃을 자유롭게 만들 수 있게 합니다! 🎨