# 🎨 ν™”λ©΄ 관리 μ‹œμŠ€ν…œ - μ œν•œλœ μžμœ λ„ κ·Έλ¦¬λ“œ μ‹œμŠ€ν…œ 섀계 ## 🎯 핡심 μ² ν•™: "μ œν•œλœ μžμœ λ„ (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; // μ»΄ν¬λ„ŒνŠΈ μ €μž₯μ†Œ 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 = ({ layout, onUpdateLayout, selectedRowId, selectedComponentId, }) => { return (
{/* κΈ€λ‘œλ²Œ μ»¨ν…Œμ΄λ„ˆ */}
{/* 각 ν–‰ λ Œλ”λ§ */} {layout.rows.map((row) => ( onSelectRow(row.id)} onUpdateRow={(updatedRow) => updateRow(row.id, updatedRow)} /> ))} {/* μƒˆ ν–‰ μΆ”κ°€ λ²„νŠΌ */}
{/* κ·Έλ¦¬λ“œ κ°€μ΄λ“œλΌμΈ (개발 λͺ¨λ“œ) */} {showGridGuides && }
); }; ``` ### 3. ν–‰(Row) λ Œλ”λŸ¬ ```tsx // components/screen/LayoutRowRenderer.tsx interface LayoutRowRendererProps { row: LayoutRow; components: Map; isSelected: boolean; selectedComponentId?: string; onSelectRow: () => void; onUpdateRow: (row: LayoutRow) => void; } export const LayoutRowRenderer: React.FC = ({ 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 (
{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 (
onSelectComponent(component.id)} />
); })}
); }; ``` ### 4. ν–‰ μ„€μ • νŒ¨λ„ ```tsx // components/screen/panels/RowSettingsPanel.tsx interface RowSettingsPanelProps { row: LayoutRow; onUpdateRow: (row: LayoutRow) => void; } export const RowSettingsPanel: React.FC = ({ row, onUpdateRow, }) => { return (
{/* 높이 μ„€μ • */}
{row.height === "fixed" && ( onUpdateRow({ ...row, fixedHeight: parseInt(e.target.value) }) } className="mt-2" placeholder="높이 (px)" /> )}
{/* 간격 μ„€μ • */}
{(Object.keys(GAP_PRESETS) as GapPreset[]).map((preset) => ( ))}
{/* νŒ¨λ”© μ„€μ • */}
{(Object.keys(GAP_PRESETS) as GapPreset[]).map((preset) => ( ))}
{/* μ •λ ¬ μ„€μ • */}
{/* 수직 μ •λ ¬ */}
{/* 배경색 */}
onUpdateRow({ ...row, backgroundColor: e.target.value }) } className="mt-2" />
); }; ``` ### 5. μ»΄ν¬λ„ŒνŠΈ λ„ˆλΉ„ μ„€μ • νŒ¨λ„ ```tsx // components/screen/panels/ComponentGridPanel.tsx interface ComponentGridPanelProps { rowComponent: RowComponent; onUpdate: (rowComponent: RowComponent) => void; } export const ComponentGridPanel: React.FC = ({ rowComponent, onUpdate, }) => { return (
{/* 컬럼 슀팬 선택 */}
{Object.entries(COLUMN_SPAN_PRESETS).map(([key, config]) => ( ))}
{/* μ‹œκ°μ  프리뷰 */}
{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 (
); })}
{COLUMN_SPAN_PRESETS[rowComponent.columnSpan].value} 컬럼 μ°¨μ§€
{/* κ³ κΈ‰ μ˜΅μ…˜ */} {/* μ‹œμž‘ μœ„μΉ˜ λͺ…μ‹œ */}
{/* μ •λ ¬ μˆœμ„œ */}
onUpdate({ ...rowComponent, order: parseInt(e.target.value), }) } placeholder="0 (μžλ™)" />
{/* μ™Όμͺ½ μ˜€ν”„μ…‹ */}
); }; ``` --- ## 🎭 λ ˆμ΄μ•„μ›ƒ νŒ¨ν„΄ ν…œν”Œλ¦Ώ ### ν…œν”Œλ¦Ώ μ‹œμŠ€ν…œ ```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
μƒˆ ν–‰ μΆ”κ°€
{/* λΉ λ₯Έ νŒ¨ν„΄ 선택 */}
``` ### 3. μ»΄ν¬λ„ŒνŠΈ 배치 UI ```tsx // μ»΄ν¬λ„ŒνŠΈλ₯Ό 행에 λ“œλž˜κ·Έμ•€λ“œλ‘­ {row.components.length === 0 ? (
μ»΄ν¬λ„ŒνŠΈλ₯Ό 여기에 λ“œλž˜κ·Έν•˜μ„Έμš”
) : ( // κΈ°μ‘΄ μ»΄ν¬λ„ŒνŠΈ λ Œλ”λ§ )}
``` ### 4. λ„ˆλΉ„ μ‘°μ • UI (μΈν„°λž™ν‹°λΈŒ) ```tsx // μ»΄ν¬λ„ŒνŠΈ 선택 μ‹œ λ„ˆλΉ„ μ‘°μ • ν•Έλ“€ ν‘œμ‹œ
{isSelected && (
{currentSpanLabel}
)}
``` --- ## πŸ”„ λ§ˆμ΄κ·Έλ ˆμ΄μ…˜ μ „λž΅ ### λ ˆκ±°μ‹œ 데이터 λ³€ν™˜ ```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. **ν™•μž₯ κ°€λŠ₯**: μƒˆλ‘œμš΄ νŒ¨ν„΄ μΆ”κ°€ 용이 --- 이 μ„€κ³„λŠ” **"μ •ν˜•ν™”λœ μžμœ λ„"**λ₯Ό μ œκ³΅ν•˜μ—¬, μ‚¬μš©μžκ°€ λ””μžμΈ 원칙을 λ²—μ–΄λ‚˜μ§€ μ•ŠμœΌλ©΄μ„œλ„ μ›ν•˜λŠ” λ ˆμ΄μ•„μ›ƒμ„ 자유둭게 λ§Œλ“€ 수 있게 ν•©λ‹ˆλ‹€! 🎨