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