ERP-node/docs/GRID_SYSTEM_REDESIGN_PLAN.md

35 KiB
Raw Blame History

🎨 화면 관리 시스템 - 제한된 자유도 그리드 시스템 설계

🎯 핵심 철학: "제한된 자유도 (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. 허용되는 컬럼 스팬 (제한된 선택지)

// 실제로 선택 가능한 너비만 제공
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) 기반 배치 시스템

/**
 * 화면은 여러 행(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)            │  ← 긴 입력
└─────────────────────────────────────────┘
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. 타입 정의

// 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. 레이아웃 빌더 컴포넌트

// 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) 렌더러

// 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. 행 설정 패널

// 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. 컴포넌트 너비 설정 패널

// 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>
  );
};

🎭 레이아웃 패턴 템플릿

템플릿 시스템

// 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

<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

// 컴포넌트를 행에 드래그앤드롭
<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 (인터랙티브)

// 컴포넌트 선택 시 너비 조정 핸들 표시
<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>

🔄 마이그레이션 전략

레거시 데이터 변환

// 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;
}

📱 반응형 지원 (추후 확장)

브레이크포인트별 설정

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. 확장 가능: 새로운 패턴 추가 용이

이 설계는 **"정형화된 자유도"**를 제공하여, 사용자가 디자인 원칙을 벗어나지 않으면서도 원하는 레이아웃을 자유롭게 만들 수 있게 합니다! 🎨