Phase 1: INVYONE 카드 엔진 토대 정리

- components/builder/* 폐기 (12-grid 미완성 빌더 14개 파일)
- components/template-builder/TemplateBuilder.tsx 신규
  (자유배치 + 3뷰 + Zustand 스토어 + 드래그/리사이즈/히스토리/격자)
- admin/builder/page.tsx 진입점 전환 (BuilderLayout → TemplateBuilder)
- 타입 정리: FreePosition / TemplateComponent / ViewConfig / Card /
  Dashboard / CardConnection 추가, 레거시(GridPosition/TemplateKind/
  DEFAULT_COMPONENT_LAYOUTS/CANVAS_KEYWORDS) @deprecated 표기
- v2-* 마이그레이션 1차:
  · 완전: v2-table-list (ResizeObserver), v2-table-search-widget (@container)
  · 경량: button/input/select/date/text-display/card-display/aggregation-widget
    (withContainerQuery HOC)
- 다크 모드 대응: Tailwind dark: variant 21패턴 71곳 치환
- /test-card-responsive PoC 검증 페이지

세션 후반 버그 픽스 (phase1-log §7):
- test-card-responsive (main) 그룹 밖 이동 (AppLayout 탭 시스템 회피)
- useRegistryPalette default_size {width,height}/{w,h} 포맷 정규화
- dark: variant 중복 체인 정리

검증: (A) 반응형 메커니즘, (B) TemplateBuilder UI 통과
(C) 기존 VEX 화면은 마이그레이션 미완 상태라 Phase 2 이후 개별 진행

스펙: notes/gbpark/2026-04-10-card-engine-final-spec.md
로그: notes/gbpark/2026-04-10-card-engine-phase1-log.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 03:08:06 +09:00
parent 9c36191ebf
commit 2c0a97f2ba
44 changed files with 6513 additions and 2336 deletions
+6 -2
View File
@@ -1,7 +1,11 @@
"use client";
import BuilderLayout from "@/components/builder/BuilderLayout";
import TemplateBuilder from "@/components/template-builder/TemplateBuilder";
export default function BuilderPage() {
return <BuilderLayout />;
return (
<div className="h-[calc(100vh-4rem)] w-full">
<TemplateBuilder />
</div>
);
}
+224
View File
@@ -0,0 +1,224 @@
"use client";
/**
* Phase 1 PoC — 카드 폭 기반 반응형 메커니즘 시각 검증 (2026-04-10)
*
* 스펙: notes/gbpark/2026-04-10-card-engine-final-spec.md (§2, §10)
*
* 확인 항목:
* 1. v2-table-list 래퍼의 ResizeObserver 가 카드 폭에 따라 data-mode 를 전환
* 2. v2-table-search-widget 의 CSS @container 가 narrow 에서 세로 스택으로 전환
*
* 실제 v2-table-list/search-widget 의 전체 렌더링은 ComponentRegistry + 백엔드
* 데이터가 필요하므로, 이 페이지는 두 반응형 메커니즘의 **레이아웃 로직만**
* 동일하게 재현해 시각 검증한다. 실 컴포넌트 연동 검증은 Phase 2 대시보드가
* 작동한 뒤 수행.
*/
import { useEffect, useRef, useState } from "react";
import "@/lib/registry/components/v2-table-search-widget/table-search-widget-responsive.css";
const NARROW_BREAKPOINT = 600;
export default function TestCardResponsivePage() {
const [width, setWidth] = useState(800);
const rootRef = useRef<HTMLDivElement | null>(null);
const [detectedMode, setDetectedMode] = useState<"wide" | "narrow">("wide");
useEffect(() => {
const el = rootRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const apply = (w: number) => {
setDetectedMode((prev) => {
const next = w < NARROW_BREAKPOINT ? "narrow" : "wide";
return prev === next ? prev : next;
});
};
apply(el.getBoundingClientRect().width);
const ro = new ResizeObserver((entries) => {
for (const entry of entries) apply(entry.contentRect.width);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
return (
<div className="p-6 text-slate-800">
<div className="mb-4">
<h1 className="text-lg font-bold">Phase 1 PoC </h1>
<p className="text-xs text-slate-500">
스펙: notes/gbpark/2026-04-10-card-engine-final-spec.md §2, §10
</p>
</div>
<div className="mb-4 flex items-center gap-4 rounded-md border border-slate-200 bg-white p-3 text-sm">
<label className="whitespace-nowrap font-medium"> </label>
<input
type="range"
min={240}
max={1400}
step={10}
value={width}
onChange={(e) => setWidth(Number(e.target.value))}
className="flex-1"
/>
<span className="w-20 text-right font-mono">{width}px</span>
<span className="whitespace-nowrap rounded bg-slate-100 px-2 py-1 text-xs">
: <b className={detectedMode === "narrow" ? "text-rose-600" : "text-indigo-600"}>{detectedMode}</b>
</span>
<div className="flex gap-1">
{[320, 520, 800, 1200].map((w) => (
<button
key={w}
type="button"
onClick={() => setWidth(w)}
className="rounded border border-slate-200 bg-white px-2 py-1 text-xs hover:bg-slate-100"
>
{w}
</button>
))}
</div>
</div>
<div
ref={rootRef}
className="rounded-lg border-2 border-dashed border-indigo-400 bg-indigo-50/40 p-3"
style={{ width: `${width}px`, maxWidth: "100%", transition: "width 0.15s ease" }}
>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-wide text-indigo-600">
(width: {width}px)
</div>
{/* ── 1. v2-text-display (경량, 항상 동일) ── */}
<div className="mb-2 text-base font-semibold text-slate-800"></div>
{/* ── 2. v2-aggregation-widget (경량, container-type 만 부착) ── */}
<div
className="mb-3 grid grid-cols-4 gap-2 rounded border border-slate-200 bg-white p-2"
style={{ containerType: "inline-size", containerName: "v2-aggregation-widget" }}
>
{[
{ label: "전체", v: "128" },
{ label: "진행", v: "42" },
{ label: "완료", v: "74" },
{ label: "대기", v: "12" },
].map((k) => (
<div key={k.label} className="rounded bg-slate-50 p-2 text-center">
<div className="text-[10px] text-slate-500">{k.label}</div>
<div className="text-base font-bold text-indigo-600">{k.v}</div>
</div>
))}
</div>
{/* ── 3. v2-table-search-widget (CSS @container 완전 마이그레이션) ── */}
<div className="v2-tsw-responsive-root mb-3">
<div className="flex w-full flex-wrap items-center gap-2 rounded border border-slate-200 bg-white p-2">
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
<input
placeholder="검색어"
className="rounded border border-slate-300 px-2 py-1 text-xs"
style={{ flex: "0 1 25%", minWidth: 120 }}
/>
<select className="rounded border border-slate-300 px-2 py-1 text-xs" style={{ flex: "0 1 25%", minWidth: 120 }}>
<option> </option>
</select>
<select className="rounded border border-slate-300 px-2 py-1 text-xs" style={{ flex: "0 1 25%", minWidth: 120 }}>
<option> </option>
</select>
<button className="h-7 shrink-0 rounded border border-slate-300 bg-white px-2 text-xs"></button>
</div>
<div className="flex w-full flex-shrink-0 items-center justify-between gap-2 sm:w-auto sm:justify-end">
<div className="rounded bg-slate-100 px-2 py-1 text-xs text-slate-600">128</div>
<button className="h-7 rounded border border-slate-300 bg-white px-2 text-xs"> </button>
</div>
</div>
</div>
{/* ── 4. v2-button-primary (경량) ── */}
<div className="mb-3 flex gap-2">
<button className="rounded bg-indigo-600 px-3 py-1 text-xs text-white"> </button>
<button className="rounded border border-slate-300 bg-white px-3 py-1 text-xs"></button>
<button className="rounded border border-rose-300 bg-white px-3 py-1 text-xs text-rose-600"></button>
</div>
{/* ── 5. v2-table-list (ResizeObserver 완전 마이그레이션) ── */}
<div
data-v2-table-list-mode={detectedMode}
className="rounded border border-slate-200 bg-white p-2"
style={{ containerType: "inline-size", containerName: "v2-table-list" }}
>
<div className="mb-2 flex items-center justify-between text-[10px] text-slate-400">
<span>v2-table-list</span>
<span>
data-v2-table-list-mode=<b className="text-slate-700">{detectedMode}</b>
</span>
</div>
{detectedMode === "wide" ? (
<table className="w-full border-collapse text-xs">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="p-2 text-left">#</th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-right"></th>
</tr>
</thead>
<tbody>
{[
{ no: "SO-2026-0001", cust: "ACME 코리아", status: "완료", amt: 12_300_000 },
{ no: "SO-2026-0002", cust: "델타 산업", status: "진행", amt: 8_450_000 },
{ no: "SO-2026-0003", cust: "글로벌 테크", status: "대기", amt: 5_200_000 },
{ no: "SO-2026-0004", cust: "한국전자", status: "완료", amt: 15_800_000 },
].map((row, i) => (
<tr key={row.no} className="border-b border-slate-100">
<td className="p-2">{i + 1}</td>
<td className="p-2 font-mono">{row.no}</td>
<td className="p-2">{row.cust}</td>
<td className="p-2">{row.status}</td>
<td className="p-2 text-right">{row.amt.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="space-y-2">
{[
{ no: "SO-2026-0001", cust: "ACME 코리아", status: "완료", amt: 12_300_000 },
{ no: "SO-2026-0002", cust: "델타 산업", status: "진행", amt: 8_450_000 },
{ no: "SO-2026-0003", cust: "글로벌 테크", status: "대기", amt: 5_200_000 },
{ no: "SO-2026-0004", cust: "한국전자", status: "완료", amt: 15_800_000 },
].map((row) => (
<div key={row.no} className="rounded border border-slate-200 bg-slate-50 p-2 text-xs">
<div className="flex items-center justify-between">
<span className="font-mono text-indigo-600">{row.no}</span>
<span className="rounded bg-slate-200 px-1.5 py-0.5 text-[10px]">{row.status}</span>
</div>
<div className="mt-1 text-slate-600">{row.cust}</div>
<div className="mt-0.5 text-right font-medium">{row.amt.toLocaleString()} </div>
</div>
))}
</div>
)}
</div>
</div>
<div className="mt-6 rounded-md border border-slate-200 bg-white p-3 text-xs text-slate-600">
<div className="mb-1 font-semibold text-slate-800"> </div>
<ul className="list-disc space-y-1 pl-5">
<li>
<b>800 400</b> {" "}
<b className="text-indigo-600">v2-table-list</b> (ResizeObserver ).
</li>
<li>
<b className="text-indigo-600">v2-table-search-widget</b> / (CSS @container ).
</li>
<li>
(text-display, aggregation-widget, button-primary) <b>container-type: inline-size</b> .
Phase 2 .
</li>
</ul>
</div>
</div>
);
}
@@ -1,168 +0,0 @@
"use client";
import React, { useMemo } from "react";
import { useBuilderState } from "./hooks/useBuilderState";
import { useBlockDrag } from "./hooks/useBlockDrag";
import type { Component, TableConfig, FormConfig, SearchConfig, TitleConfig, ButtonConfig, ButtonBarConfig } from "@/types/invyone-component";
import type { FieldConfig } from "@/types/invyone-component";
interface BuilderBlockProps {
block: Component;
}
/** 캔버스 위의 개별 블록 — 드래그 이동 + 리사이즈 + 프리뷰 */
export default function BuilderBlock({ block }: BuilderBlockProps) {
const selectedBlockId = useBuilderState((s) => s.selectedBlockId);
const fields = useBuilderState((s) => s.fields);
const selectBlock = useBuilderState((s) => s.selectBlock);
const { startDrag, startResize } = useBlockDrag();
const isSelected = selectedBlockId === block.id;
const { x, y, w, h } = block.position;
return (
<div
className={`dev-block${isSelected ? " selected" : ""}`}
style={{ left: x, top: y, width: w, height: h }}
onMouseDown={(e) => {
if ((e.target as HTMLElement).classList.contains("dev-resize-handle")) return;
startDrag(e, block.id, x, y, w, h);
}}
onClick={(e) => { e.stopPropagation(); selectBlock(block.id); }}
>
<div className="dev-block-label">{block.label}</div>
<div className="dev-block-content">
<BlockPreview block={block} fields={fields} />
</div>
<div
className="dev-resize-handle"
onMouseDown={(e) => startResize(e, block.id, x, y, w, h)}
/>
</div>
);
}
/** 블록 내부 프리뷰 렌더 (타입별 분기) */
function BlockPreview({ block, fields }: { block: Component; fields: FieldConfig[] }) {
const visibleFields = useMemo(
() => fields.filter((f) => f.visible && !f.system).sort((a, b) => a.order - b.order),
[fields]
);
switch (block.type) {
case "table":
return <TablePreview fields={visibleFields} />;
case "form":
return <FormPreview fields={visibleFields} config={block.config as FormConfig} />;
case "search":
return <SearchPreview fields={fields.filter((f) => f.searchable && !f.system)} />;
case "title":
return <TitlePreview config={block.config as TitleConfig} />;
case "button":
return <ButtonPreview config={block.config as ButtonConfig} />;
case "button-bar":
return <ButtonBarPreview config={block.config as ButtonBarConfig} />;
case "pagination":
return <PaginationPreview />;
case "divider":
return <div style={{ borderTop: "1px solid var(--d-border)", margin: "0.3rem 0" }} />;
case "stats":
return <div style={{ fontSize: "0.44rem", color: "var(--d-text3)" }}> </div>;
default:
return <div style={{ fontSize: "0.44rem", color: "var(--d-text3)" }}>{block.type}</div>;
}
}
function TablePreview({ fields }: { fields: FieldConfig[] }) {
const cols = fields.slice(0, 8);
if (!cols.length) return <div style={{ fontSize: "0.44rem", color: "var(--d-text3)" }}> </div>;
return (
<table className="dev-pv-table">
<thead>
<tr>{cols.map((f) => <th key={f.column}>{f.label}</th>)}</tr>
</thead>
<tbody>
{[0, 1, 2].map((r) => (
<tr key={r}>{cols.map((f) => <td key={f.column}></td>)}</tr>
))}
</tbody>
</table>
);
}
function FormPreview({ fields, config }: { fields: FieldConfig[]; config: FormConfig }) {
const cols = config?.columns || 2;
const formFields = fields.filter((f) => !f.pk || f.type !== "code");
return (
<div style={{ display: "grid", gridTemplateColumns: `repeat(${cols}, 1fr)`, gap: "0.2rem 0.4rem" }}>
{formFields.slice(0, 10).map((f) => (
<div className="dev-pv-field" key={f.column}>
<div className="dev-pv-field-label">
{f.label}{f.required && <span style={{ color: "var(--d-red)" }}> *</span>}
</div>
<div className="dev-pv-field-input">
{f.type === "select"
? (typeof f.options?.[0] === "string" ? f.options[0] : typeof f.options?.[0] === "object" ? f.options[0].label : "—")
: "—"}
</div>
</div>
))}
</div>
);
}
function SearchPreview({ fields }: { fields: FieldConfig[] }) {
if (!fields.length) return <div style={{ fontSize: "0.44rem", color: "var(--d-text3)" }}> </div>;
return (
<div className="dev-pv-search">
{fields.slice(0, 5).map((f) => (
<div className="dev-pv-search-item" key={f.column}>
<div className="dev-pv-search-label">{f.label}</div>
<div className="dev-pv-search-input"></div>
</div>
))}
<div className="dev-pv-search-item" style={{ justifyContent: "flex-end" }}>
<button className="dev-pv-btn primary" style={{ marginTop: "auto" }}></button>
</div>
</div>
);
}
function TitlePreview({ config }: { config: TitleConfig }) {
return (
<div style={{ fontSize: config.fontSize, fontWeight: config.fontWeight, textAlign: config.align, color: "var(--d-text)" }}>
{config.text || "제목"}
</div>
);
}
function ButtonPreview({ config }: { config: ButtonConfig }) {
const cls = config.variant === "primary" ? "dev-pv-btn primary" : "dev-pv-btn";
return <div className={cls}>{config.text || "버튼"}</div>;
}
function ButtonBarPreview({ config }: { config: ButtonBarConfig }) {
return (
<div style={{ display: "flex", gap: "0.2rem", padding: "0.2rem" }}>
{(config.buttons || []).map((btn, i) => (
<div key={i} className={btn.variant === "primary" ? "dev-pv-btn primary" : "dev-pv-btn"}>
{btn.text}
</div>
))}
</div>
);
}
function PaginationPreview() {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", fontSize: "0.42rem", color: "var(--d-text3)", padding: "0.1rem" }}>
<span> 0</span>
<div style={{ display: "flex", gap: "0.15rem" }}>
<span style={{ padding: "0.1rem 0.25rem", borderRadius: 3, background: "var(--d-accent)", color: "#fff" }}>1</span>
<span style={{ padding: "0.1rem 0.25rem" }}>2</span>
<span style={{ padding: "0.1rem 0.25rem" }}>3</span>
</div>
<span>20/</span>
</div>
);
}
@@ -1,91 +0,0 @@
"use client";
import React, { useCallback } from "react";
import { useBuilderState, useCurrentViewBlocks } from "./hooks/useBuilderState";
import BuilderBlock from "./BuilderBlock";
import type { ComponentType } from "@/types/invyone-component";
export default function BuilderCanvas() {
const blocks = useCurrentViewBlocks();
const addBlock = useBuilderState((s) => s.addBlock);
const selectBlock = useBuilderState((s) => s.selectBlock);
const currentView = useBuilderState((s) => s.currentView);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
const type = e.dataTransfer.getData("component-type") as ComponentType;
if (!type) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = Math.round(e.clientX - rect.left);
const y = Math.round(e.clientY - rect.top);
addBlock(type, { x, y, w: 0, h: 0 });
},
[addBlock]
);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}, []);
const handleCanvasClick = useCallback(
(e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest(".dev-block")) return;
selectBlock(null);
},
[selectBlock]
);
// 팝업 뷰 (등록/수정)
if (currentView !== "list") {
return (
<div className="dev-canvas" onClick={handleCanvasClick}>
<div className="dev-popup-overlay">
<div className="dev-popup-frame">
<div
className="dev-canvas-inner"
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{blocks.length === 0 && (
<div className="dev-empty">
<div className="dev-empty-icon">📝</div>
<div className="dev-empty-text">
{currentView === "create" ? "등록" : "수정"}
</div>
</div>
)}
{blocks.map((block) => (
<BuilderBlock key={block.id} block={block} />
))}
</div>
</div>
</div>
</div>
);
}
return (
<div
className="dev-canvas"
onClick={handleCanvasClick}
onDrop={handleDrop}
onDragOver={handleDragOver}
>
<div className="dev-canvas-inner">
{blocks.length === 0 && (
<div className="dev-empty">
<div className="dev-empty-icon">🎨</div>
<div className="dev-empty-text">
</div>
</div>
)}
{blocks.map((block) => (
<BuilderBlock key={block.id} block={block} />
))}
</div>
</div>
);
}
@@ -1,61 +0,0 @@
"use client";
import React, { useEffect, useCallback } from "react";
import { useBuilderState } from "./hooks/useBuilderState";
import BuilderToolbar from "./BuilderToolbar";
import BuilderPalette from "./BuilderPalette";
import BuilderCanvas from "./BuilderCanvas";
import BuilderProps from "./BuilderProps";
import "@/styles/developer.css";
export default function BuilderLayout() {
const blocks = useBuilderState((s) => s.blocks);
const currentView = useBuilderState((s) => s.currentView);
const tableName = useBuilderState((s) => s.tableName);
const connections = useBuilderState((s) => s.connections);
const isDirty = useBuilderState((s) => s.isDirty);
const selectedBlockId = useBuilderState((s) => s.selectedBlockId);
const removeBlock = useBuilderState((s) => s.removeBlock);
const selectBlock = useBuilderState((s) => s.selectBlock);
const viewBlocks = blocks[currentView];
const blockCount = viewBlocks.length;
// 키보드 단축키: Delete/Backspace → 블록 삭제, 화살표 → 블록 이동
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (!selectedBlockId) return;
const tag = (e.target as HTMLElement).tagName;
if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return;
if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault();
removeBlock(selectedBlockId);
}
if (e.key === "Escape") {
selectBlock(null);
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [selectedBlockId, removeBlock, selectBlock]);
return (
<div className="dev-shell">
<BuilderToolbar />
<div className="dev-body">
<BuilderPalette />
<BuilderCanvas />
<BuilderProps />
</div>
{/* 상태바 */}
<div className="dev-status">
<span> {blockCount} · {tableName || "테이블 미선택"} · {connections.length}</span>
<span>{isDirty ? "수정됨" : "저장됨"}</span>
</div>
</div>
);
}
@@ -1,90 +0,0 @@
"use client";
import React, { useCallback } from "react";
import { useBuilderState } from "./hooks/useBuilderState";
import type { ComponentType } from "@/types/invyone-component";
interface PaletteItem {
type: ComponentType;
label: string;
icon: string;
cat: "data" | "input" | "action" | "display";
}
const PALETTE_ITEMS: { section: string; items: PaletteItem[] }[] = [
{
section: "데이터",
items: [
{ type: "table", label: "데이터 테이블", icon: "📊", cat: "data" },
{ type: "search", label: "검색 필터", icon: "🔍", cat: "data" },
],
},
{
section: "입력",
items: [
{ type: "form", label: "입력 폼", icon: "📝", cat: "input" },
],
},
{
section: "액션",
items: [
{ type: "button", label: "버튼", icon: "🔘", cat: "action" },
{ type: "button-bar", label: "버튼 바", icon: "⬜", cat: "action" },
],
},
{
section: "표시",
items: [
{ type: "title", label: "제목/텍스트", icon: "📌", cat: "display" },
{ type: "stats", label: "통계 카드", icon: "📈", cat: "display" },
{ type: "divider", label: "구분선", icon: "——", cat: "display" },
{ type: "pagination", label: "페이지네이션", icon: "📄", cat: "display" },
],
},
];
export default function BuilderPalette() {
const addBlock = useBuilderState((s) => s.addBlock);
const tableName = useBuilderState((s) => s.tableName);
const handleDragStart = useCallback(
(e: React.DragEvent, type: ComponentType) => {
e.dataTransfer.setData("component-type", type);
e.dataTransfer.effectAllowed = "copy";
},
[]
);
const handleClick = useCallback(
(type: ComponentType) => {
// 클릭으로도 추가 가능 (캔버스 중앙에 배치)
addBlock(type, { x: 16, y: 16, w: 0, h: 0 });
},
[addBlock]
);
return (
<div className="dev-palette">
<div className="dev-pal-header"></div>
{PALETTE_ITEMS.map((sec) => (
<React.Fragment key={sec.section}>
<div className="dev-pal-sec">{sec.section}</div>
{sec.items.map((item) => (
<div
key={item.type}
className="dev-pal-item"
data-cat={item.cat}
draggable
onDragStart={(e) => handleDragStart(e, item.type)}
onClick={() => handleClick(item.type)}
style={{ opacity: !tableName && ["table", "form", "search"].includes(item.type) ? 0.4 : 1 }}
>
<span className="dev-pal-icon">{item.icon}</span>
<span>{item.label}</span>
</div>
))}
</React.Fragment>
))}
</div>
);
}
@@ -1,98 +0,0 @@
"use client";
import React from "react";
import { useBuilderState, useSelectedBlock } from "./hooks/useBuilderState";
import TableProps from "./props/TableProps";
import FormProps from "./props/FormProps";
import SearchProps from "./props/SearchProps";
import { SingleButtonProps, ButtonBarProps } from "./props/ButtonProps";
import TitleProps from "./props/TitleProps";
const TYPE_LABELS: Record<string, string> = {
table: "📊 데이터 테이블",
form: "📝 입력 폼",
search: "🔍 검색 필터",
button: "🔘 버튼",
"button-bar": "⬜ 버튼 바",
title: "📌 제목/텍스트",
stats: "📈 통계 카드",
divider: "── 구분선",
pagination: "📄 페이지네이션",
};
/** 우측 속성 패널 */
export default function BuilderProps() {
const block = useSelectedBlock();
const updateBlock = useBuilderState((s) => s.updateBlock);
const removeBlock = useBuilderState((s) => s.removeBlock);
const moveBlock = useBuilderState((s) => s.moveBlock);
const resizeBlock = useBuilderState((s) => s.resizeBlock);
if (!block) {
return (
<div className="dev-props">
<div className="dev-prop-header"></div>
<div style={{ padding: "1rem 0.6rem", textAlign: "center", color: "var(--d-text3)", fontSize: "0.5rem" }}>
<br />
</div>
</div>
);
}
return (
<div className="dev-props">
<div className="dev-prop-header">{TYPE_LABELS[block.type] || block.type}</div>
{/* 공통: 이름 */}
<div className="dev-prop-sec"> </div>
<div className="dev-prop-row">
<span className="dev-prop-label"></span>
<input
className="dev-input"
value={block.label}
onChange={(e) => updateBlock(block.id, { label: e.target.value })}
/>
</div>
{/* 공통: 위치/크기 */}
<div className="dev-prop-sec"> · </div>
<div className="dev-pos-grid">
<div className="dev-pos-item">
<label>X</label>
<input type="number" value={Math.round(block.position.x)}
onChange={(e) => moveBlock(block.id, Number(e.target.value), block.position.y)} />
</div>
<div className="dev-pos-item">
<label>Y</label>
<input type="number" value={Math.round(block.position.y)}
onChange={(e) => moveBlock(block.id, block.position.x, Number(e.target.value))} />
</div>
<div className="dev-pos-item">
<label>W</label>
<input type="number" value={Math.round(block.position.w)}
onChange={(e) => resizeBlock(block.id, Number(e.target.value), block.position.h)} />
</div>
<div className="dev-pos-item">
<label>H</label>
<input type="number" value={Math.round(block.position.h)}
onChange={(e) => resizeBlock(block.id, block.position.w, Number(e.target.value))} />
</div>
</div>
{/* 타입별 속성 패널 */}
{block.type === "table" && <TableProps block={block} />}
{block.type === "form" && <FormProps block={block} />}
{block.type === "search" && <SearchProps block={block} />}
{block.type === "button" && <SingleButtonProps block={block} />}
{block.type === "button-bar" && <ButtonBarProps block={block} />}
{block.type === "title" && <TitleProps block={block} />}
{/* 삭제 버튼 */}
<div style={{ padding: "0.6rem" }}>
<button className="dev-delete-btn" onClick={() => removeBlock(block.id)}>
🗑
</button>
</div>
</div>
);
}
@@ -1,151 +0,0 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { useBuilderState } from "./hooks/useBuilderState";
import type { BuilderView } from "./hooks/useBuilderState";
import { getMetaTableList, getMetaFields } from "@/lib/api/meta";
import { insertTemplate, updateTemplate } from "@/lib/api/template";
const VIEW_TABS: { key: BuilderView; label: string }[] = [
{ key: "list", label: "목록" },
{ key: "create", label: "등록" },
{ key: "edit", label: "수정" },
];
export default function BuilderToolbar() {
const [tables, setTables] = useState<Record<string, any>[]>([]);
const [saving, setSaving] = useState(false);
const tableName = useBuilderState((s) => s.tableName);
const templateName = useBuilderState((s) => s.templateName);
const currentView = useBuilderState((s) => s.currentView);
const templateId = useBuilderState((s) => s.templateId);
const isDirty = useBuilderState((s) => s.isDirty);
const setTable = useBuilderState((s) => s.setTable);
const switchView = useBuilderState((s) => s.switchView);
const setTemplateMeta = useBuilderState((s) => s.setTemplateMeta);
const toTemplate = useBuilderState((s) => s.toTemplate);
const markClean = useBuilderState((s) => s.markClean);
// 테이블 목록 로드
useEffect(() => {
getMetaTableList()
.then(setTables)
.catch(() => {});
}, []);
// 테이블 선택
const handleTableChange = useCallback(
async (e: React.ChangeEvent<HTMLSelectElement>) => {
const name = e.target.value;
if (!name) return;
try {
const meta = await getMetaFields(name);
setTable(name, meta.fields);
if (!templateName) {
setTemplateMeta({ templateName: meta.table_label || name });
}
} catch {
// ignore
}
},
[setTable, setTemplateMeta, templateName]
);
// 저장
const handleSave = useCallback(async () => {
setSaving(true);
try {
const tpl = toTemplate();
const payload: Record<string, any> = {
name: tpl.name,
category: tpl.category,
description: tpl.description,
primary_table: tpl.primaryTable,
fields: tpl.fields,
views: tpl.views,
connections: tpl.connections,
};
if (templateId) {
await updateTemplate(templateId, payload);
} else {
const result = await insertTemplate(payload);
useBuilderState.setState({ templateId: result.template_id });
}
markClean();
} catch {
// ignore
} finally {
setSaving(false);
}
}, [toTemplate, templateId, markClean]);
return (
<>
{/* 헤더 */}
<div className="dev-hdr">
<div className="dev-hdr-l">
<span className="dev-logo">INVYONE</span>
<span className="dev-badge">DEV</span>
<input
className="dev-input"
style={{ minWidth: 160, fontWeight: 600, fontSize: "0.62rem" }}
value={templateName}
onChange={(e) => setTemplateMeta({ templateName: e.target.value })}
placeholder="템플릿 이름"
/>
</div>
<div className="dev-hdr-r">
<button
className={`dev-btn primary`}
onClick={handleSave}
disabled={saving}
>
{saving ? "저장 중..." : "💾 저장"}
</button>
</div>
</div>
{/* 도구모음 */}
<div className="dev-toolbar">
<div className="dev-tb-group">
<span className="dev-tb-label"></span>
<select
className="dev-select"
value={tableName || ""}
onChange={handleTableChange}
>
<option value=""> ...</option>
{tables.map((t) => (
<option key={t.table_name} value={t.table_name}>
{t.table_label || t.table_name}
{t.has_custom_meta ? " ★" : ""}
</option>
))}
</select>
</div>
<div className="dev-tb-group">
<span className="dev-tb-label"></span>
{VIEW_TABS.map((tab) => (
<button
key={tab.key}
className={`dev-view-tab${currentView === tab.key ? " active" : ""}`}
onClick={() => switchView(tab.key)}
>
{tab.label}
</button>
))}
</div>
<div style={{ flex: 1 }} />
{isDirty && (
<span style={{ fontSize: "0.42rem", color: "var(--d-orange)", fontWeight: 600 }}>
</span>
)}
</div>
</>
);
}
@@ -1,88 +0,0 @@
"use client";
import { useCallback, useRef } from "react";
import { useBuilderState } from "./useBuilderState";
interface DragState {
id: string;
startX: number;
startY: number;
origX: number;
origY: number;
origW: number;
origH: number;
mode: "move" | "resize";
}
/**
* 블록 드래그(이동)/리사이즈 훅.
* mousedown → mousemove → mouseup 패턴.
* Shift 키: 8px 스냅.
*/
export function useBlockDrag() {
const dragRef = useRef<DragState | null>(null);
const moveBlock = useBuilderState((s) => s.moveBlock);
const resizeBlock = useBuilderState((s) => s.resizeBlock);
const selectBlock = useBuilderState((s) => s.selectBlock);
const startDrag = useCallback(
(e: React.MouseEvent, id: string, origX: number, origY: number, origW: number, origH: number) => {
e.preventDefault();
e.stopPropagation();
selectBlock(id);
dragRef.current = { id, startX: e.clientX, startY: e.clientY, origX, origY, origW, origH, mode: "move" };
document.body.style.cursor = "grabbing";
document.body.style.userSelect = "none";
const onMove = (ev: MouseEvent) => {
const d = dragRef.current;
if (!d || d.mode !== "move") return;
let nx = d.origX + (ev.clientX - d.startX);
let ny = d.origY + (ev.clientY - d.startY);
if (ev.shiftKey) { nx = Math.round(nx / 8) * 8; ny = Math.round(ny / 8) * 8; }
moveBlock(d.id, Math.round(nx), Math.round(ny));
};
const onUp = () => {
dragRef.current = null;
document.body.style.cursor = "";
document.body.style.userSelect = "";
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[moveBlock, selectBlock]
);
const startResize = useCallback(
(e: React.MouseEvent, id: string, origX: number, origY: number, origW: number, origH: number) => {
e.preventDefault();
e.stopPropagation();
dragRef.current = { id, startX: e.clientX, startY: e.clientY, origX, origY, origW, origH, mode: "resize" };
document.body.style.cursor = "nwse-resize";
document.body.style.userSelect = "none";
const onMove = (ev: MouseEvent) => {
const d = dragRef.current;
if (!d || d.mode !== "resize") return;
let nw = d.origW + (ev.clientX - d.startX);
let nh = d.origH + (ev.clientY - d.startY);
if (ev.shiftKey) { nw = Math.round(nw / 8) * 8; nh = Math.round(nh / 8) * 8; }
resizeBlock(d.id, Math.round(nw), Math.round(nh));
};
const onUp = () => {
dragRef.current = null;
document.body.style.cursor = "";
document.body.style.userSelect = "";
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[resizeBlock]
);
return { startDrag, startResize };
}
@@ -1,362 +0,0 @@
"use client";
import { create } from "zustand";
import { devtools } from "zustand/middleware";
import type {
FieldConfig,
Component,
ComponentType,
Position,
ViewConfig,
Template,
Connection,
TableConfig,
FormConfig,
SearchConfig,
ButtonConfig,
ButtonBarConfig,
TitleConfig,
StatsConfig,
DividerConfig,
PaginationConfig,
ComponentTypeConfig,
} from "@/types/invyone-component";
// ─── 뷰 타입 ───
export type BuilderView = "list" | "create" | "edit";
// ─── 상태 인터페이스 ───
interface BuilderState {
// 테이블/필드
tableName: string | null;
fields: FieldConfig[];
// 현재 뷰
currentView: BuilderView;
// 블록 목록 (뷰별)
blocks: Record<BuilderView, Component[]>;
// 선택된 블록
selectedBlockId: string | null;
// 연결
connections: Connection[];
// 템플릿 메타
templateId: string | null;
templateName: string;
category: string;
description: string;
// 변경 상태
isDirty: boolean;
// 액션
setTable: (tableName: string, fields: FieldConfig[]) => void;
switchView: (view: BuilderView) => void;
addBlock: (type: ComponentType, position: Position) => void;
removeBlock: (id: string) => void;
updateBlock: (id: string, updates: Partial<Component>) => void;
selectBlock: (id: string | null) => void;
moveBlock: (id: string, x: number, y: number) => void;
resizeBlock: (id: string, w: number, h: number) => void;
updateBlockConfig: (id: string, config: Partial<ComponentTypeConfig>) => void;
updateField: (column: string, updates: Partial<FieldConfig>) => void;
setTemplateMeta: (meta: { templateName?: string; category?: string; description?: string }) => void;
addConnection: (conn: Connection) => void;
removeConnection: (connId: string) => void;
toTemplate: () => Template;
fromTemplate: (tpl: Record<string, any>) => void;
resetBuilder: () => void;
markClean: () => void;
}
// ─── ID 생성 ───
let _blockIdCounter = 0;
function genBlockId(): string {
return `blk_${Date.now().toString(36)}_${(++_blockIdCounter).toString(36)}`;
}
function genConnId(): string {
return `conn_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
}
// ─── 컴포넌트 기본 설정 ───
function defaultConfig(type: ComponentType): ComponentTypeConfig {
switch (type) {
case "table":
return {
pageSize: 20,
selectionMode: "single",
showCheckbox: false,
inlineEdit: false,
autoLoad: true,
toolbar: { showExcel: false, showRefresh: true, showFilter: false },
style: "default",
} satisfies TableConfig;
case "form":
return {
columns: 2,
saveAction: { method: "UPSERT", refreshAfterSave: true },
} satisfies FormConfig;
case "search":
return {
dateRangeEnabled: true,
showResetButton: true,
autoSearch: false,
layout: "inline",
} satisfies SearchConfig;
case "button":
return {
text: "버튼",
actionType: "save",
variant: "default",
} satisfies ButtonConfig;
case "button-bar":
return {
buttons: [
{ text: "등록", actionType: "add", variant: "primary" },
{ text: "삭제", actionType: "delete", variant: "destructive" },
],
} satisfies ButtonBarConfig;
case "title":
return {
text: "제목",
fontSize: "0.75rem",
fontWeight: "700",
align: "left",
} satisfies TitleConfig;
case "stats":
return { items: [] } satisfies StatsConfig;
case "divider":
return { style: "solid" } satisfies DividerConfig;
case "pagination":
return {
pageSize: 20,
showSizeSelector: true,
sizeOptions: [10, 20, 50, 100],
} satisfies PaginationConfig;
default:
return { text: "", fontSize: "0.75rem", fontWeight: "400", align: "left" } satisfies TitleConfig;
}
}
// ─── 컴포넌트 기본 크기 ───
function defaultSize(type: ComponentType): { w: number; h: number } {
switch (type) {
case "table": return { w: 854, h: 380 };
case "form": return { w: 440, h: 300 };
case "search": return { w: 854, h: 42 };
case "button": return { w: 100, h: 36 };
case "button-bar": return { w: 370, h: 36 };
case "title": return { w: 300, h: 36 };
case "stats": return { w: 400, h: 80 };
case "divider": return { w: 854, h: 8 };
case "pagination": return { w: 854, h: 24 };
default: return { w: 200, h: 100 };
}
}
// ─── 컴포넌트 기본 라벨 ───
function defaultLabel(type: ComponentType): string {
const map: Record<string, string> = {
table: "데이터 테이블",
form: "입력 폼",
search: "검색 필터",
button: "버튼",
"button-bar": "버튼 바",
title: "제목",
stats: "통계 카드",
divider: "구분선",
pagination: "페이지네이션",
chart: "차트",
tabs: "탭",
"split-panel": "분할 패널",
};
return map[type] || type;
}
// ─── 초기 상태 ───
const initialState = {
tableName: null as string | null,
fields: [] as FieldConfig[],
currentView: "list" as BuilderView,
blocks: { list: [], create: [], edit: [] } as Record<BuilderView, Component[]>,
selectedBlockId: null as string | null,
connections: [] as Connection[],
templateId: null as string | null,
templateName: "",
category: "",
description: "",
isDirty: false,
};
// ─── 스토어 ───
export const useBuilderState = create<BuilderState>()(
devtools(
(set, get) => ({
...initialState,
setTable: (tableName, fields) =>
set({ tableName, fields, isDirty: true }),
switchView: (view) =>
set({ currentView: view, selectedBlockId: null }),
addBlock: (type, position) => {
const state = get();
const size = defaultSize(type);
const block: Component = {
id: genBlockId(),
type,
label: defaultLabel(type),
position: { x: position.x, y: position.y, w: size.w, h: size.h },
config: defaultConfig(type),
};
const viewBlocks = [...state.blocks[state.currentView], block];
set({
blocks: { ...state.blocks, [state.currentView]: viewBlocks },
selectedBlockId: block.id,
isDirty: true,
});
},
removeBlock: (id) => {
const state = get();
const viewBlocks = state.blocks[state.currentView].filter((b) => b.id !== id);
const connections = state.connections.filter(
(c) => c.from.componentId !== id && c.to.componentId !== id
);
set({
blocks: { ...state.blocks, [state.currentView]: viewBlocks },
connections,
selectedBlockId: state.selectedBlockId === id ? null : state.selectedBlockId,
isDirty: true,
});
},
updateBlock: (id, updates) => {
const state = get();
const viewBlocks = state.blocks[state.currentView].map((b) =>
b.id === id ? { ...b, ...updates } : b
);
set({ blocks: { ...state.blocks, [state.currentView]: viewBlocks }, isDirty: true });
},
selectBlock: (id) => set({ selectedBlockId: id }),
moveBlock: (id, x, y) => {
const state = get();
const viewBlocks = state.blocks[state.currentView].map((b) =>
b.id === id ? { ...b, position: { ...b.position, x: Math.max(0, x), y: Math.max(0, y) } } : b
);
set({ blocks: { ...state.blocks, [state.currentView]: viewBlocks }, isDirty: true });
},
resizeBlock: (id, w, h) => {
const state = get();
const viewBlocks = state.blocks[state.currentView].map((b) =>
b.id === id
? { ...b, position: { ...b.position, w: Math.max(40, w), h: Math.max(20, h) } }
: b
);
set({ blocks: { ...state.blocks, [state.currentView]: viewBlocks }, isDirty: true });
},
updateBlockConfig: (id, configUpdates) => {
const state = get();
const viewBlocks = state.blocks[state.currentView].map((b) =>
b.id === id ? { ...b, config: { ...b.config, ...configUpdates } as ComponentTypeConfig } : b
);
set({ blocks: { ...state.blocks, [state.currentView]: viewBlocks }, isDirty: true });
},
updateField: (column, updates) => {
const state = get();
const fields = state.fields.map((f) =>
f.column === column ? { ...f, ...updates } : f
);
set({ fields, isDirty: true });
},
setTemplateMeta: (meta) =>
set({
...(meta.templateName !== undefined ? { templateName: meta.templateName } : {}),
...(meta.category !== undefined ? { category: meta.category } : {}),
...(meta.description !== undefined ? { description: meta.description } : {}),
isDirty: true,
}),
addConnection: (conn) => {
const state = get();
if (!conn.id) conn.id = genConnId();
set({ connections: [...state.connections, conn], isDirty: true });
},
removeConnection: (connId) => {
const state = get();
set({ connections: state.connections.filter((c) => c.id !== connId), isDirty: true });
},
toTemplate: (): Template => {
const s = get();
return {
templateId: s.templateId || "",
name: s.templateName,
category: s.category,
description: s.description || undefined,
primaryTable: s.tableName || "",
fields: s.fields,
views: {
list: { components: s.blocks.list },
create: { components: s.blocks.create },
edit: { components: s.blocks.edit },
},
connections: s.connections,
companyCode: "*",
version: 1,
status: "draft",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
},
fromTemplate: (tpl) => {
set({
templateId: tpl.template_id ?? tpl.templateId ?? null,
templateName: tpl.name ?? "",
category: tpl.category ?? "",
description: tpl.description ?? "",
tableName: tpl.primary_table ?? tpl.primaryTable ?? null,
fields: tpl.fields ?? [],
blocks: {
list: tpl.views?.list?.components ?? [],
create: tpl.views?.create?.components ?? [],
edit: tpl.views?.edit?.components ?? [],
},
connections: tpl.connections ?? [],
currentView: "list",
selectedBlockId: null,
isDirty: false,
});
},
resetBuilder: () => set({ ...initialState }),
markClean: () => set({ isDirty: false }),
}),
{ name: "builder-state" }
)
);
// ─── 셀렉터 훅 ───
export function useCurrentViewBlocks() {
return useBuilderState((s) => s.blocks[s.currentView]);
}
export function useSelectedBlock(): Component | null {
return useBuilderState((s) => {
if (!s.selectedBlockId) return null;
return s.blocks[s.currentView].find((b) => b.id === s.selectedBlockId) ?? null;
});
}
@@ -1,119 +0,0 @@
"use client";
import React from "react";
import { useBuilderState } from "../hooks/useBuilderState";
import type { Component, ButtonConfig, ButtonBarConfig } from "@/types/invyone-component";
const ACTION_OPTIONS: { value: string; label: string }[] = [
{ value: "save", label: "저장" },
{ value: "edit", label: "수정" },
{ value: "delete", label: "삭제" },
{ value: "add", label: "신규" },
{ value: "cancel", label: "취소" },
{ value: "close", label: "닫기" },
{ value: "navigate", label: "화면 이동" },
{ value: "popup", label: "팝업 열기" },
{ value: "search", label: "검색" },
{ value: "reset", label: "초기화" },
{ value: "submit", label: "제출" },
{ value: "approval", label: "승인" },
];
const VARIANT_OPTIONS: { value: string; label: string }[] = [
{ value: "primary", label: "강조 (파란색)" },
{ value: "default", label: "기본 (테두리)" },
{ value: "destructive", label: "위험 (빨간색)" },
{ value: "outline", label: "아웃라인" },
{ value: "ghost", label: "투명" },
];
export function SingleButtonProps({ block }: { block: Component }) {
const updateBlockConfig = useBuilderState((s) => s.updateBlockConfig);
const config = block.config as ButtonConfig;
const update = (key: string, val: any) => updateBlockConfig(block.id, { [key]: val });
return (
<>
<div className="dev-prop-sec"> </div>
<div className="dev-prop-row">
<span className="dev-prop-label"></span>
<input className="dev-input" value={config.text}
onChange={(e) => update("text", e.target.value)} />
</div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"></span>
<select className="dev-select" value={config.actionType}
onChange={(e) => update("actionType", e.target.value)}>
{ACTION_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"></span>
<select className="dev-select" value={config.variant}
onChange={(e) => update("variant", e.target.value)}>
{VARIANT_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<div className="dev-prop-row">
<span className="dev-prop-label"> </span>
<input className="dev-input" value={config.confirm || ""}
placeholder="비워두면 확인 없이 실행"
onChange={(e) => update("confirm", e.target.value || undefined)} />
</div>
</>
);
}
export function ButtonBarProps({ block }: { block: Component }) {
const updateBlockConfig = useBuilderState((s) => s.updateBlockConfig);
const config = block.config as ButtonBarConfig;
const updateButton = (idx: number, key: string, val: any) => {
const buttons = [...config.buttons];
buttons[idx] = { ...buttons[idx], [key]: val };
updateBlockConfig(block.id, { buttons } as any);
};
const addButton = () => {
const buttons = [...config.buttons, { text: "버튼", actionType: "save" as const, variant: "default" as const }];
updateBlockConfig(block.id, { buttons } as any);
};
const removeButton = (idx: number) => {
const buttons = config.buttons.filter((_, i) => i !== idx);
updateBlockConfig(block.id, { buttons } as any);
};
return (
<>
<div className="dev-prop-sec"> </div>
{config.buttons.map((btn, i) => (
<div key={i} style={{ padding: "0.15rem 0.6rem", borderBottom: "1px dashed var(--d-border)" }}>
<div style={{ display: "flex", alignItems: "center", gap: "0.2rem", marginBottom: "0.1rem" }}>
<input className="dev-input" style={{ flex: 1 }} value={btn.text}
onChange={(e) => updateButton(i, "text", e.target.value)} />
<button className="dev-delete-btn" style={{ width: "auto", padding: "0.15rem 0.3rem", fontSize: "0.4rem" }}
onClick={() => removeButton(i)}></button>
</div>
<div style={{ display: "flex", gap: "0.2rem" }}>
<select className="dev-select" style={{ flex: 1, fontSize: "0.42rem" }} value={btn.actionType}
onChange={(e) => updateButton(i, "actionType", e.target.value)}>
{ACTION_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<select className="dev-select" style={{ flex: 1, fontSize: "0.42rem" }} value={btn.variant}
onChange={(e) => updateButton(i, "variant", e.target.value)}>
{VARIANT_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
</div>
))}
<div style={{ padding: "0.2rem 0.6rem" }}>
<button className="dev-btn" style={{ width: "100%", justifyContent: "center" }}
onClick={addButton}>
+
</button>
</div>
</>
);
}
@@ -1,97 +0,0 @@
"use client";
import React, { useState } from "react";
import { useBuilderState } from "../hooks/useBuilderState";
import type { FieldConfig } from "@/types/invyone-component";
interface FieldListEditorProps {
/** 필터 함수: 어떤 필드를 목록에 표시할지 */
filter?: (f: FieldConfig) => boolean;
/** 체크박스 토글 대상 속성 */
toggleKey?: "visible" | "searchable";
}
/** 필드 체크리스트 (table/form/search 속성 패널 공통) */
export default function FieldListEditor({ filter, toggleKey = "visible" }: FieldListEditorProps) {
const fields = useBuilderState((s) => s.fields);
const updateField = useBuilderState((s) => s.updateField);
const [expandedCol, setExpandedCol] = useState<string | null>(null);
const filteredFields = filter ? fields.filter(filter) : fields;
const sorted = [...filteredFields].sort((a, b) => a.order - b.order);
return (
<div className="dev-field-list">
{sorted.map((f) => (
<React.Fragment key={f.column}>
<div
className="dev-field-item"
onClick={() => setExpandedCol(expandedCol === f.column ? null : f.column)}
>
<div
className={`dev-field-check${f[toggleKey] ? " on" : ""}`}
onClick={(e) => {
e.stopPropagation();
updateField(f.column, { [toggleKey]: !f[toggleKey] });
}}
>
</div>
<span className="dev-field-name">{f.label}</span>
<div style={{ display: "flex", gap: 2 }}>
{f.pk && <span className="dev-fc-badge pk">PK</span>}
{f.required && <span className="dev-fc-badge req"></span>}
{f.searchable && <span className="dev-fc-badge sch"></span>}
{f.system && <span className="dev-fc-badge sys">SYS</span>}
{f.computed && <span className="dev-fc-badge cmp"></span>}
</div>
<span className="dev-field-type">{f.type}</span>
<span className="dev-field-drag"></span>
</div>
{expandedCol === f.column && <FieldDetail field={f} />}
</React.Fragment>
))}
</div>
);
}
/** 필드 상세 편집 패널 */
function FieldDetail({ field }: { field: FieldConfig }) {
const updateField = useBuilderState((s) => s.updateField);
const col = field.column;
return (
<div style={{ padding: "0.3rem 0.4rem", background: "var(--d-bg3)", borderRadius: 4, margin: "0.1rem 0", fontSize: "0.46rem" }}
onClick={(e) => e.stopPropagation()}>
<div style={{ display: "flex", gap: "0.3rem", marginBottom: "0.2rem" }}>
<label style={{ flex: 1, display: "flex", flexDirection: "column", gap: 2 }}>
<span style={{ fontSize: "0.4rem", fontWeight: 700, color: "var(--d-text3)" }}> </span>
<input className="dev-input" value={field.label}
onChange={(e) => updateField(col, { label: e.target.value })} />
</label>
<label style={{ width: 80, display: "flex", flexDirection: "column", gap: 2 }}>
<span style={{ fontSize: "0.4rem", fontWeight: 700, color: "var(--d-text3)" }}></span>
<input className="dev-input" type="number" value={field.width ?? ""}
placeholder="자동"
onChange={(e) => updateField(col, { width: parseInt(e.target.value) || undefined })} />
</label>
</div>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<Toggle label="필수" checked={field.required} onToggle={(v) => updateField(col, { required: v })} />
<Toggle label="편집" checked={field.editable} onToggle={(v) => updateField(col, { editable: v })} />
<Toggle label="검색" checked={!!field.searchable} onToggle={(v) => updateField(col, { searchable: v })} />
<Toggle label="정렬" checked={field.sortable !== false} onToggle={(v) => updateField(col, { sortable: v })} />
</div>
</div>
);
}
function Toggle({ label, checked, onToggle }: { label: string; checked: boolean; onToggle: (v: boolean) => void }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: "0.2rem", cursor: "pointer" }}
onClick={() => onToggle(!checked)}>
<div className={`dev-toggle${checked ? " on" : ""}`} />
<span style={{ fontSize: "0.42rem", color: "var(--d-text3)" }}>{label}</span>
</div>
);
}
@@ -1,41 +0,0 @@
"use client";
import React from "react";
import { useBuilderState } from "../hooks/useBuilderState";
import FieldListEditor from "./FieldListEditor";
import type { Component, FormConfig } from "@/types/invyone-component";
export default function FormProps({ block }: { block: Component }) {
const updateBlockConfig = useBuilderState((s) => s.updateBlockConfig);
const config = block.config as FormConfig;
const update = (key: string, val: any) => updateBlockConfig(block.id, { [key]: val });
return (
<>
<div className="dev-prop-sec"> </div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"> </span>
<select className="dev-select" value={config.columns}
onChange={(e) => update("columns", Number(e.target.value))}>
<option value={1}>1</option>
<option value={2}>2</option>
<option value={3}>3</option>
</select>
</div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"> </span>
<select className="dev-select" value={config.saveAction?.method || "UPSERT"}
onChange={(e) => update("saveAction", { ...config.saveAction, method: e.target.value })}>
<option value="INSERT"></option>
<option value="UPDATE"></option>
<option value="UPSERT">/</option>
</select>
</div>
<div className="dev-prop-sec"> </div>
<div className="dev-hint">체크: 폼에 · 클릭: 상세 </div>
<FieldListEditor filter={(f) => !f.system} toggleKey="visible" />
</>
);
}
@@ -1,46 +0,0 @@
"use client";
import React from "react";
import { useBuilderState } from "../hooks/useBuilderState";
import FieldListEditor from "./FieldListEditor";
import type { Component, SearchConfig } from "@/types/invyone-component";
export default function SearchProps({ block }: { block: Component }) {
const updateBlockConfig = useBuilderState((s) => s.updateBlockConfig);
const config = block.config as SearchConfig;
const update = (key: string, val: any) => updateBlockConfig(block.id, { [key]: val });
return (
<>
<div className="dev-prop-sec"> </div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"> </span>
<div className={`dev-toggle${config.dateRangeEnabled ? " on" : ""}`}
onClick={() => update("dateRangeEnabled", !config.dateRangeEnabled)} />
</div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"> </span>
<div className={`dev-toggle${config.showResetButton ? " on" : ""}`}
onClick={() => update("showResetButton", !config.showResetButton)} />
</div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"> </span>
<div className={`dev-toggle${config.autoSearch ? " on" : ""}`}
onClick={() => update("autoSearch", !config.autoSearch)} />
</div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"></span>
<select className="dev-select" value={config.layout}
onChange={(e) => update("layout", e.target.value)}>
<option value="inline"></option>
<option value="stacked"></option>
</select>
</div>
<div className="dev-prop-sec"> </div>
<div className="dev-hint">체크: 검색에 · 클릭: 상세 </div>
<FieldListEditor filter={(f) => !f.system} toggleKey="searchable" />
</>
);
}
@@ -1,64 +0,0 @@
"use client";
import React from "react";
import { useBuilderState } from "../hooks/useBuilderState";
import FieldListEditor from "./FieldListEditor";
import type { Component, TableConfig } from "@/types/invyone-component";
export default function TableProps({ block }: { block: Component }) {
const updateBlockConfig = useBuilderState((s) => s.updateBlockConfig);
const config = block.config as TableConfig;
const update = (key: string, val: any) => updateBlockConfig(block.id, { [key]: val });
return (
<>
<div className="dev-prop-sec"> </div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"> </span>
<select className="dev-select" value={config.pageSize}
onChange={(e) => update("pageSize", Number(e.target.value))}>
{[10, 20, 50, 100].map((n) => <option key={n} value={n}>{n}</option>)}
</select>
</div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"> </span>
<select className="dev-select" value={config.selectionMode}
onChange={(e) => update("selectionMode", e.target.value)}>
<option value="none"></option>
<option value="single"></option>
<option value="multiple"></option>
</select>
</div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"> </span>
<div className={`dev-toggle${config.autoLoad ? " on" : ""}`}
onClick={() => update("autoLoad", !config.autoLoad)} />
</div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"> </span>
<div className={`dev-toggle${config.inlineEdit ? " on" : ""}`}
onClick={() => update("inlineEdit", !config.inlineEdit)} />
</div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"></span>
<div className={`dev-toggle${config.showCheckbox ? " on" : ""}`}
onClick={() => update("showCheckbox", !config.showCheckbox)} />
</div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"></span>
<select className="dev-select" value={config.style}
onChange={(e) => update("style", e.target.value)}>
<option value="default"></option>
<option value="striped"></option>
<option value="bordered"></option>
<option value="compact"></option>
</select>
</div>
<div className="dev-prop-sec"> </div>
<div className="dev-hint">체크: 보이기 · 클릭: 상세 </div>
<FieldListEditor filter={(f) => !f.system} toggleKey="visible" />
</>
);
}
@@ -1,52 +0,0 @@
"use client";
import React from "react";
import { useBuilderState } from "../hooks/useBuilderState";
import type { Component, TitleConfig } from "@/types/invyone-component";
export default function TitleProps({ block }: { block: Component }) {
const updateBlockConfig = useBuilderState((s) => s.updateBlockConfig);
const config = block.config as TitleConfig;
const update = (key: string, val: any) => updateBlockConfig(block.id, { [key]: val });
return (
<>
<div className="dev-prop-sec"> </div>
<div className="dev-prop-row">
<span className="dev-prop-label"></span>
<input className="dev-input" value={config.text}
onChange={(e) => update("text", e.target.value)} />
</div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"></span>
<select className="dev-select" value={config.fontSize}
onChange={(e) => update("fontSize", e.target.value)}>
<option value="0.5rem"></option>
<option value="0.75rem"></option>
<option value="1rem"></option>
<option value="1.2rem"> </option>
</select>
</div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"></span>
<select className="dev-select" value={config.fontWeight}
onChange={(e) => update("fontWeight", e.target.value)}>
<option value="400"></option>
<option value="500"> </option>
<option value="700"></option>
<option value="800"> </option>
</select>
</div>
<div className="dev-prop-row inline">
<span className="dev-prop-label"></span>
<select className="dev-select" value={config.align}
onChange={(e) => update("align", e.target.value)}>
<option value="left"></option>
<option value="center"></option>
<option value="right"></option>
</select>
</div>
</>
);
}
+21 -13
View File
@@ -51,31 +51,36 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
if (!editMode) return;
const target = e.target as HTMLElement;
// 버튼/입력 클릭은 무시
if (target.closest('button') || target.closest('input') || target.closest('select')) return;
// 버튼/입력/select 클릭은 무시 (단, 리사이즈 핸들은 통과)
const isResize = target.closest('[data-resize]') !== null;
if (!isResize) {
if (target.closest('button') || target.closest('input') || target.closest('select') || target.closest('textarea')) return;
}
const cardEl = target.closest('.dash-card') as HTMLElement;
if (!cardEl) return;
// ★ wrapper div(data-card-id 가진 것)를 찾아야 함 — .dash-card는 그 안의 div
const wrapperEl = target.closest('[data-card-id]') as HTMLElement;
if (!wrapperEl) return;
const cardId = cardEl.dataset.cardId;
const cardId = wrapperEl.dataset.cardId;
if (!cardId) return;
const isResize = target.closest('[data-resize]') !== null;
e.preventDefault();
dragRef.current = {
cardId,
startX: e.clientX,
startY: e.clientY,
origLeft: cardEl.offsetLeft,
origTop: cardEl.offsetTop,
origW: cardEl.offsetWidth,
origH: cardEl.offsetHeight,
origLeft: wrapperEl.offsetLeft,
origTop: wrapperEl.offsetTop,
origW: wrapperEl.offsetWidth,
origH: wrapperEl.offsetHeight,
mode: isResize ? 'resize' : 'drag',
el: cardEl,
el: wrapperEl,
};
cardEl.classList.add(isResize ? 'resizing' : 'dragging');
// 시각 피드백은 내부 .dash-card에 적용
const cardEl = wrapperEl.querySelector('.dash-card') as HTMLElement | null;
if (cardEl) cardEl.classList.add(isResize ? 'resizing' : 'dragging');
document.body.style.cursor = isResize ? 'nwse-resize' : 'grabbing';
document.body.style.userSelect = 'none';
}, [editMode]);
@@ -106,7 +111,9 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
const d = dragRef.current;
if (!d) return;
d.el.classList.remove('dragging', 'resizing');
// 시각 피드백 제거 (.dash-card)
const cardEl = d.el.querySelector('.dash-card') as HTMLElement | null;
if (cardEl) cardEl.classList.remove('dragging', 'resizing');
document.body.style.cursor = '';
document.body.style.userSelect = '';
@@ -167,6 +174,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
top: y + 'px',
width: w + 'px',
height: h + 'px',
zIndex: 10,
}}
>
<DashboardCard
+508 -63
View File
@@ -1,12 +1,18 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { RefreshCw, ChevronDown, X, Settings } from 'lucide-react';
import { getTemplateInfo } from '@/lib/api/template';
import { getMetaFields } from '@/lib/api/meta';
import { fcList } from '@/lib/api/fcData';
import { FcTable, FcSearch, FcPagination } from '@/components/fc';
import { FieldConfig } from '@/types/invyone-component';
import { FcTable, FcForm, FcSearch, FcButton, FcButtonBar, FcPagination } from '@/components/fc';
import type {
FieldConfig,
GridPosition,
AbsolutePosition,
TemplateKind,
} from '@/types/invyone-component';
import { isGridPosition } from '@/types/invyone-component';
import { CardMiniView } from './CardMiniView';
interface DashboardCardProps {
@@ -17,6 +23,12 @@ interface DashboardCardProps {
onOpenSettings?: (cardId: string) => void;
}
/**
* DashboardCard — Template 기반 렌더러 (2026-04-10 재설계)
* - kind: 'business' → 12-col grid + @container 카드 너비 반응형
* - kind: 'canvas' → absolute 자유배치 (control/flow 등 예외)
* - 반응형 분기는 GridComponent가 CSS 변수로 주입, @container 쿼리가 처리
*/
export function DashboardCard({
card,
editMode,
@@ -31,38 +43,89 @@ export function DashboardCard({
const primaryTable = card.primary_table ?? card.PRIMARY_TABLE ?? '';
const isCollapsed = card.is_collapsed ?? card.IS_COLLAPSED ?? false;
// ─── Template 상태 ───
const [fields, setFields] = useState<FieldConfig[]>([]);
const [components, setComponents] = useState<Record<string, any>[]>([]);
const [connections, setConnections] = useState<Record<string, any>[]>([]);
const [templateKind, setTemplateKind] = useState<TemplateKind | null>(null);
const [templateLoaded, setTemplateLoaded] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
// ─── 데이터 상태 ───
const [data, setData] = useState<Record<string, any>[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [searchParams, setSearchParams] = useState<Record<string, any>>({});
const [loading, setLoading] = useState(false);
const [templateLoaded, setTemplateLoaded] = useState(false);
const [selectedRow, setSelectedRow] = useState<Record<string, any> | null>(null);
const mountedRef = useRef(true);
// Template + FieldConfig 로드
// ─── Template 로드 ───
useEffect(() => {
mountedRef.current = true;
if (!primaryTable) return;
if (!primaryTable && !templateId) return;
const loadTemplate = async () => {
setLoadError(null);
try {
const meta = await getMetaFields(primaryTable);
if (mountedRef.current && meta?.fields) {
setFields(meta.fields);
setTemplateLoaded(true);
// 1순위: Template (빌더에서 만든 컴포넌트 배치 + fields)
if (templateId) {
const tpl = await getTemplateInfo(templateId);
if (mountedRef.current && tpl) {
const tplFields: FieldConfig[] = Array.isArray(tpl.fields) ? tpl.fields : [];
// views는 list/create/edit이 있고 각자 components 배열
const listView = tpl.views?.list ?? {};
const tplComponents: Record<string, any>[] = Array.isArray(listView.components)
? listView.components
: [];
const tplConnections: Record<string, any>[] = Array.isArray(tpl.connections)
? tpl.connections
: [];
// Template에 fields가 있으면 그대로, 없으면 DB 메타 fallback
if (tplFields.length > 0) {
setFields(tplFields);
} else if (primaryTable) {
const meta = await getMetaFields(primaryTable);
if (mountedRef.current) setFields(meta?.fields ?? []);
}
setComponents(tplComponents);
setConnections(tplConnections);
// kind 가 없으면 null로 두고 fallback 렌더 — 레거시 변환은 빌더에서 처리
setTemplateKind((tpl.kind as TemplateKind) ?? null);
setTemplateLoaded(true);
return;
}
}
// 2순위 (fallback): Template 없으면 DB 메타로 기본 카드만 표시
if (primaryTable) {
const meta = await getMetaFields(primaryTable);
if (mountedRef.current && meta?.fields) {
setFields(meta.fields);
setComponents([]); // 컴포넌트 배치 없음 → 기본 렌더
setConnections([]);
setTemplateKind(null);
setTemplateLoaded(true);
}
}
} catch (err: any) {
console.error(`[DashboardCard] Template/fields 로드 실패:`, err);
if (mountedRef.current) {
setLoadError(err?.message ?? '템플릿 로드 실패');
}
} catch (err) {
console.error(`[DashboardCard] Failed to load fields for ${primaryTable}:`, err);
}
};
loadTemplate();
return () => { mountedRef.current = false; };
}, [primaryTable]);
return () => {
mountedRef.current = false;
};
}, [primaryTable, templateId]);
// 데이터 로드
// ─── 데이터 조회 ───
const loadData = useCallback(async () => {
if (!primaryTable || !templateLoaded) return;
setLoading(true);
@@ -78,26 +141,53 @@ export function DashboardCard({
setTotalCount(result?.total ?? result?.total_count ?? 0);
}
} catch (err) {
console.error(`[DashboardCard] Failed to load data:`, err);
console.error(`[DashboardCard] 데이터 조회 실패:`, err);
} finally {
if (mountedRef.current) setLoading(false);
}
}, [primaryTable, templateLoaded, page, pageSize, searchParams]);
useEffect(() => { loadData(); }, [loadData]);
useEffect(() => {
loadData();
}, [loadData]);
const handleSearch = (params: Record<string, any>) => {
// ─── DataPort 콜백 ───
const handleSearch = useCallback((params: Record<string, any>) => {
setSearchParams(params);
setPage(1);
};
}, []);
const handlePageChange = ({ page: newPage, size }: { page: number; size: number }) => {
setPage(newPage);
const handlePageChange = useCallback(({ page: p, size }: { page: number; size: number }) => {
setPage(p);
setPageSize(size);
};
}, []);
const visibleFields = fields.filter((f) => f.visible && !f.system);
const searchableFields = fields.filter((f) => f.searchable && !f.system);
const handleRowSelect = useCallback((row: Record<string, any>) => {
setSelectedRow(row);
}, []);
// ─── 컴포넌트 정렬 ───
// grid(business) → row / col 기준, canvas → y / x 기준
const sortedComponents = useMemo(() => {
return [...components].sort((a, b) => {
const pa = a.position ?? {};
const pb = b.position ?? {};
if (isGridPosition(pa) && isGridPosition(pb)) {
return (pa.row ?? 0) - (pb.row ?? 0) || (pa.col ?? 0) - (pb.col ?? 0);
}
// absolute fallback (기존 {x,y,w,h} 데이터 호환)
return ((pa as any).y ?? 0) - ((pb as any).y ?? 0);
});
}, [components]);
// business kind 여부 — 명시된 kind를 우선하고, 없으면 position 형태로 추정
const effectiveKind: TemplateKind = useMemo(() => {
if (templateKind === 'business' || templateKind === 'canvas') return templateKind;
// kind 미지정 Template — grid position 데이터면 business, 그 외는 canvas로 fallback 렌더
const sample = components.find((c) => c.position != null)?.position;
if (sample && isGridPosition(sample)) return 'business';
return 'canvas';
}, [templateKind, components]);
return (
<div className={`dash-card${isCollapsed ? ' collapsed' : ''}`}>
@@ -106,15 +196,16 @@ export function DashboardCard({
<div className="dash-card-head-l">
<div className="dash-card-icon">📋</div>
<div className="dash-card-title">{templateName}</div>
{templateCategory && (
<div className="dash-card-bdg">{templateCategory}</div>
)}
{templateCategory && <div className="dash-card-bdg">{templateCategory}</div>}
</div>
<div className="dash-card-head-r">
<button
className="dash-card-btn"
title="새로고침"
onClick={(e) => { e.stopPropagation(); loadData(); }}
onClick={(e) => {
e.stopPropagation();
loadData();
}}
>
<RefreshCw size={13} />
</button>
@@ -122,7 +213,10 @@ export function DashboardCard({
<button
className="dash-card-btn"
title="설정"
onClick={(e) => { e.stopPropagation(); onOpenSettings(cardId); }}
onClick={(e) => {
e.stopPropagation();
onOpenSettings(cardId);
}}
>
<Settings size={13} />
</button>
@@ -130,18 +224,27 @@ export function DashboardCard({
<button
className="dash-card-btn"
title="접기/펴기"
onClick={(e) => { e.stopPropagation(); onToggleCollapse(cardId); }}
onClick={(e) => {
e.stopPropagation();
onToggleCollapse(cardId);
}}
>
<ChevronDown size={13} style={{
transform: isCollapsed ? 'rotate(180deg)' : 'none',
transition: 'transform .25s',
}} />
<ChevronDown
size={13}
style={{
transform: isCollapsed ? 'rotate(180deg)' : 'none',
transition: 'transform .25s',
}}
/>
</button>
{editMode && (
<button
className="dash-card-btn danger"
title="카드 삭제"
onClick={(e) => { e.stopPropagation(); onRemove(cardId); }}
onClick={(e) => {
e.stopPropagation();
onRemove(cardId);
}}
>
<X size={13} />
</button>
@@ -149,53 +252,395 @@ export function DashboardCard({
</div>
</div>
{/* 본문 — Template 컴포넌트 렌더 */}
{/* 본문 — container query 카드 */}
<div className="dash-card-body">
{!primaryTable ? (
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--v5-text-muted)', fontSize: '.7rem' }}>
릿.
</div>
{loadError ? (
<div className="dash-card-error"> {loadError}</div>
) : !templateLoaded ? (
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--v5-text-muted)', fontSize: '.7rem' }}>
...
<div className="dash-card-loading">릿 ...</div>
) : sortedComponents.length === 0 ? (
// Template에 컴포넌트 배치 없음 → 기본 검색+테이블+페이지네이션
<DefaultCardContent
fields={fields}
data={data}
loading={loading}
totalCount={totalCount}
page={page}
pageSize={pageSize}
onSearch={handleSearch}
onRowSelect={handleRowSelect}
onPageChange={handlePageChange}
/>
) : effectiveKind === 'canvas' ? (
// canvas kind — 자유배치, 반응형 없음 (control/flow 류)
<div className="dash-card-canvas-wrapper">
<div className="dash-card-canvas">
{sortedComponents.map((comp) => (
<AbsoluteComponent
key={comp.id}
component={comp}
fields={fields}
data={data}
loading={loading}
totalCount={totalCount}
page={page}
pageSize={pageSize}
selectedRow={selectedRow}
onSearch={handleSearch}
onRowSelect={handleRowSelect}
onPageChange={handlePageChange}
/>
))}
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '.35rem', height: '100%' }}>
{searchableFields.length > 0 && (
<FcSearch
// business kind — 12-col grid + @container 카드 너비 반응형
<div className="dash-card-grid">
{sortedComponents.map((comp) => (
<GridComponent
key={comp.id}
component={comp}
fields={fields}
onSearch={handleSearch}
config={{ layout: 'inline', autoSearch: false, dateRangeEnabled: true, showResetButton: true }}
/>
)}
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
<FcTable
fields={visibleFields}
data={data}
loading={loading}
/>
</div>
{totalCount > 0 && (
<FcPagination
total={totalCount}
totalCount={totalCount}
page={page}
pageSize={pageSize}
selectedRow={selectedRow}
onSearch={handleSearch}
onRowSelect={handleRowSelect}
onPageChange={handlePageChange}
/>
)}
))}
</div>
)}
</div>
{/* 접힌 상태: 미니 뷰 */}
<CardMiniView
templateName={templateName}
category={templateCategory}
tableName={primaryTable}
/>
<CardMiniView templateName={templateName} category={templateCategory} tableName={primaryTable} />
{/* 리사이즈 핸들 */}
<div className="dash-resize-handle" data-resize="true" />
</div>
);
}
// ─────────────────────────────────────────────────────────
// 컴포넌트별 렌더러 — business(grid) 와 canvas(absolute)
// ─────────────────────────────────────────────────────────
interface ComponentRendererProps {
component: Record<string, any>;
fields: FieldConfig[];
data: Record<string, any>[];
loading: boolean;
totalCount: number;
page: number;
pageSize: number;
selectedRow: Record<string, any> | null;
onSearch: (params: Record<string, any>) => void;
onRowSelect: (row: Record<string, any>) => void;
onPageChange: (p: { page: number; size: number }) => void;
}
/**
* GridComponent — business kind 전용.
* col/row 및 responsive(narrow/normal/wide) 전부를 CSS 변수로 주입.
* @container 쿼리에서 col/row를 동시에 오버라이드해야 responsive.row가 실제로 적용됨.
*/
function GridComponent(props: ComponentRendererProps) {
const { component } = props;
const pos = (component.position ?? {}) as GridPosition;
const toRowVal = (r?: number): string | number => (r != null ? r : 'auto');
const toSpanVal = (s?: number): number => s ?? 1;
const r = pos.responsive ?? {};
const style: React.CSSProperties = {
'--col': pos.col ?? 1,
'--col-span': pos.colSpan ?? 12,
'--row': toRowVal(pos.row),
'--row-span': toSpanVal(pos.rowSpan),
'--col-narrow': r.narrow?.col ?? pos.col ?? 1,
'--col-span-narrow': r.narrow?.colSpan ?? pos.colSpan ?? 12,
'--row-narrow': toRowVal(r.narrow?.row ?? pos.row),
'--row-span-narrow': toSpanVal(r.narrow?.rowSpan ?? pos.rowSpan),
'--col-normal': r.normal?.col ?? pos.col ?? 1,
'--col-span-normal': r.normal?.colSpan ?? pos.colSpan ?? 12,
'--row-normal': toRowVal(r.normal?.row ?? pos.row),
'--row-span-normal': toSpanVal(r.normal?.rowSpan ?? pos.rowSpan),
'--col-wide': r.wide?.col ?? pos.col ?? 1,
'--col-span-wide': r.wide?.colSpan ?? pos.colSpan ?? 12,
'--row-wide': toRowVal(r.wide?.row ?? pos.row),
'--row-span-wide': toSpanVal(r.wide?.rowSpan ?? pos.rowSpan),
} as React.CSSProperties;
return (
<div className="tpl-component" data-type={component.type} style={style}>
{renderByType(props)}
</div>
);
}
/**
* AbsoluteComponent — canvas kind 전용. position.x/y/w/h 직접 렌더.
*/
function AbsoluteComponent(props: ComponentRendererProps) {
const { component } = props;
const pos = (component.position ?? { x: 0, y: 0, w: 200, h: 100 }) as AbsolutePosition;
const style: React.CSSProperties = {
left: pos.x,
top: pos.y,
width: pos.w,
height: pos.h,
};
return (
<div className="tpl-component" data-type={component.type} style={style}>
{renderByType(props)}
</div>
);
}
function renderByType(props: ComponentRendererProps) {
const {
component,
fields,
data,
loading,
totalCount,
page,
selectedRow,
onSearch,
onRowSelect,
onPageChange,
} = props;
const config = component.config ?? {};
const type = component.type;
switch (type) {
case 'table':
return (
<FcTable
fields={fields}
data={data}
loading={loading}
config={config}
onRowSelect={onRowSelect}
/>
);
case 'search':
return <FcSearch fields={fields} onSearch={onSearch} config={config} />;
case 'form':
return <FcForm fields={fields} config={config} loadRow={selectedRow ?? undefined} />;
case 'button':
return <FcButton config={config} />;
case 'button-bar':
return <FcButtonBar config={config} />;
case 'pagination':
return <FcPagination total={totalCount} page={page} config={config} onPageChange={onPageChange} />;
case 'title': {
const titleCfg = config as any;
return (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent:
titleCfg.align === 'center' ? 'center' : titleCfg.align === 'right' ? 'flex-end' : 'flex-start',
color: 'var(--v5-text)',
fontSize: titleCfg.fontSize ?? '0.85rem',
fontWeight: titleCfg.fontWeight ?? '700',
padding: '0 0.4rem',
}}
>
{titleCfg.text ?? component.label}
</div>
);
}
case 'divider': {
const dCfg = config as any;
return (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center' }}>
<div
style={{
width: '100%',
borderTop: `1px ${dCfg.style ?? 'solid'} var(--v5-border)`,
}}
/>
</div>
);
}
case 'stats': {
const sCfg = config as any;
const items = Array.isArray(sCfg.items) ? sCfg.items : [];
return (
<div
style={{
width: '100%',
height: '100%',
display: 'grid',
gridTemplateColumns: items.length > 0 ? `repeat(${items.length}, 1fr)` : '1fr',
gap: '.4rem',
padding: '.3rem',
}}
>
{items.length === 0 ? (
<div
style={{
fontSize: '.55rem',
color: 'var(--v5-text-muted)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
</div>
) : (
items.map((item: Record<string, any>, i: number) => (
<div
key={i}
style={{
border: '1px solid var(--v5-glass-border)',
borderRadius: 8,
padding: '.4rem .55rem',
background: 'var(--v5-glass)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}
>
<div
style={{
fontSize: '.5rem',
fontWeight: 700,
color: 'var(--v5-text-muted)',
textTransform: 'uppercase',
}}
>
{item.label}
</div>
<div
style={{
fontSize: '1.1rem',
fontWeight: 800,
color: 'var(--v5-text)',
marginTop: '.15rem',
}}
>
{computeAggregation(data, item.column, item.aggregation)}
</div>
</div>
))
)}
</div>
);
}
default:
return (
<div
style={{
width: '100%',
height: '100%',
border: '1px dashed var(--v5-border)',
borderRadius: 6,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--v5-text-muted)',
fontSize: '.55rem',
}}
>
{type ?? 'unknown'}
</div>
);
}
}
// ─────────────────────────────────────────────────────────
// Fallback — Template에 컴포넌트 배치가 없을 때 기본 카드 (검색+테이블+페이지네이션)
// ─────────────────────────────────────────────────────────
interface DefaultCardContentProps {
fields: FieldConfig[];
data: Record<string, any>[];
loading: boolean;
totalCount: number;
page: number;
pageSize: number;
onSearch: (params: Record<string, any>) => void;
onRowSelect: (row: Record<string, any>) => void;
onPageChange: (p: { page: number; size: number }) => void;
}
function DefaultCardContent({
fields,
data,
loading,
totalCount,
page,
pageSize,
onSearch,
onRowSelect,
onPageChange,
}: DefaultCardContentProps) {
const visibleFields = fields.filter((f) => f.visible && !f.system);
const searchableFields = fields.filter((f) => f.searchable && !f.system);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '.35rem', height: '100%' }}>
{searchableFields.length > 0 && (
<FcSearch
fields={fields}
onSearch={onSearch}
config={{ layout: 'inline', autoSearch: false, dateRangeEnabled: true, showResetButton: true }}
/>
)}
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
<FcTable fields={visibleFields} data={data} loading={loading} onRowSelect={onRowSelect} />
</div>
{totalCount > 0 && (
<FcPagination total={totalCount} page={page} pageSize={pageSize} onPageChange={onPageChange} />
)}
</div>
);
}
// ─────────────────────────────────────────────────────────
// 통계 집계 헬퍼
// ─────────────────────────────────────────────────────────
function computeAggregation(
data: Record<string, any>[],
column: string,
aggregation: 'count' | 'sum' | 'avg'
): string {
if (!data || data.length === 0) return '0';
if (aggregation === 'count') return String(data.length);
const nums = data
.map((row) => Number(row[column]))
.filter((n) => !isNaN(n));
if (nums.length === 0) return '0';
if (aggregation === 'sum') {
return nums.reduce((a, b) => a + b, 0).toLocaleString('ko-KR');
}
if (aggregation === 'avg') {
return Math.round(nums.reduce((a, b) => a + b, 0) / nums.length).toLocaleString('ko-KR');
}
return '0';
}
+2 -1
View File
@@ -231,7 +231,8 @@ export function DashboardLayout() {
onSaveLayout={handleSaveLayout}
/>
{/* 제어 모드 툴바 + 오버레이 */}
<div style={{ position: 'relative', flex: 1 }}>
{/* ★ flex container로 만들어야 안쪽 dash-canvas가 flex:1로 늘어남 */}
<div style={{ position: 'relative', flex: '1 1 auto', display: 'flex', flexDirection: 'column', minHeight: 0, minWidth: 0 }}>
<DashboardCanvas
ref={canvasRef}
dashboardName={dashName}
+4 -1
View File
@@ -942,7 +942,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
pathname.startsWith('/admin/builder') ||
pathname.startsWith('/test-fc')
) ? (
<div className="relative min-h-0 flex-1 overflow-auto">{children}</div>
// ★ flex 컨테이너로 만들어서 안쪽 dash-shell이 height:100% 잘 먹도록
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{children}
</div>
) : (
<TabContent />
)}
@@ -1,678 +0,0 @@
"use client";
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { Group, Database, Trash2, Copy, Clipboard } from "lucide-react";
import {
ScreenDefinition,
ComponentData,
LayoutData,
GroupState,
WebType,
TableInfo,
GroupComponent,
Position,
} from "@/types/screen";
import { generateComponentId } from "@/lib/utils/generateId";
import {
createGroupComponent,
calculateBoundingBox,
calculateRelativePositions,
restoreAbsolutePositions,
getGroupChildren,
} from "@/lib/utils/groupingUtils";
import {
calculateGridInfo,
snapToGrid,
snapSizeToGrid,
generateGridLines,
GridSettings as GridUtilSettings,
} from "@/lib/utils/gridUtils";
import { GroupingToolbar } from "./GroupingToolbar";
import { screenApi } from "@/lib/api/screen";
import { toast } from "sonner";
import StyleEditor from "./StyleEditor";
import { RealtimePreview } from "./RealtimePreview";
import FloatingPanel from "./FloatingPanel";
import DesignerToolbar from "./DesignerToolbar";
import TablesPanel from "./panels/TablesPanel";
import PropertiesPanel from "./panels/PropertiesPanel";
import GridPanel from "./panels/GridPanel";
import { usePanelState, PanelConfig } from "@/hooks/usePanelState";
interface ScreenDesignerProps {
selectedScreen: ScreenDefinition | null;
onBackToList: () => void;
}
// 패널 설정
const panelConfigs: PanelConfig[] = [
{
id: "tables",
title: "테이블 목록",
defaultPosition: "left",
defaultWidth: 320,
defaultHeight: 600,
shortcutKey: "t",
},
{
id: "properties",
title: "속성 편집",
defaultPosition: "right",
defaultWidth: 320,
defaultHeight: 500,
shortcutKey: "p",
},
{
id: "styles",
title: "스타일 편집",
defaultPosition: "right",
defaultWidth: 320,
defaultHeight: 400,
shortcutKey: "s",
},
{
id: "grid",
title: "격자 설정",
defaultPosition: "right",
defaultWidth: 280,
defaultHeight: 450,
shortcutKey: "g",
},
];
export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) {
// 패널 상태 관리
const { panelStates, togglePanel, openPanel, closePanel, closeAllPanels } = usePanelState(panelConfigs);
const [layout, setLayout] = useState<LayoutData>({
components: [],
gridSettings: { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true },
});
const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [selectedComponent, setSelectedComponent] = useState<ComponentData | null>(null);
// 실행취소/다시실행을 위한 히스토리 상태
const [history, setHistory] = useState<LayoutData[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
// 그룹 상태
const [groupState, setGroupState] = useState<GroupState>({
selectedComponents: [],
isGrouping: false,
});
// 드래그 상태
const [dragState, setDragState] = useState({
isDragging: false,
draggedComponent: null as ComponentData | null,
originalPosition: { x: 0, y: 0 },
currentPosition: { x: 0, y: 0 },
grabOffset: { x: 0, y: 0 },
});
// 테이블 데이터
const [tables, setTables] = useState<TableInfo[]>([]);
const [searchTerm, setSearchTerm] = useState("");
// 클립보드
const [clipboard, setClipboard] = useState<{
type: "single" | "multiple" | "group";
data: ComponentData[];
} | null>(null);
// 그룹 생성 다이얼로그
const [showGroupCreateDialog, setShowGroupCreateDialog] = useState(false);
const canvasRef = useRef<HTMLDivElement>(null);
// 격자 정보 계산
const gridInfo = useMemo(() => {
if (!canvasRef.current || !layout.gridSettings) return null;
return calculateGridInfo(canvasRef.current, layout.gridSettings);
}, [layout.gridSettings]);
// 격자 라인 생성
const gridLines = useMemo(() => {
if (!gridInfo || !layout.gridSettings?.showGrid) return [];
return generateGridLines(gridInfo, layout.gridSettings);
}, [gridInfo, layout.gridSettings]);
// 필터된 테이블 목록
const filteredTables = useMemo(() => {
if (!searchTerm) return tables;
return tables.filter(
(table) =>
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
table.columns.some((col) => col.columnName.toLowerCase().includes(searchTerm.toLowerCase())),
);
}, [tables, searchTerm]);
// 히스토리에 저장
const saveToHistory = useCallback(
(newLayout: LayoutData) => {
setHistory((prev) => {
const newHistory = prev.slice(0, historyIndex + 1);
newHistory.push(newLayout);
return newHistory.slice(-50); // 최대 50개까지만 저장
});
setHistoryIndex((prev) => Math.min(prev + 1, 49));
setHasUnsavedChanges(true);
},
[historyIndex],
);
// 실행취소
const undo = useCallback(() => {
if (historyIndex > 0) {
setHistoryIndex((prev) => prev - 1);
setLayout(history[historyIndex - 1]);
}
}, [history, historyIndex]);
// 다시실행
const redo = useCallback(() => {
if (historyIndex < history.length - 1) {
setHistoryIndex((prev) => prev + 1);
setLayout(history[historyIndex + 1]);
}
}, [history, historyIndex]);
// 컴포넌트 속성 업데이트
const updateComponentProperty = useCallback(
(componentId: string, path: string, value: any) => {
const pathParts = path.split(".");
const updatedComponents = layout.components.map((comp) => {
if (comp.id !== componentId) return comp;
const newComp = { ...comp };
let current: any = newComp;
for (let i = 0; i < pathParts.length - 1; i++) {
if (!current[pathParts[i]]) {
current[pathParts[i]] = {};
}
current = current[pathParts[i]];
}
current[pathParts[pathParts.length - 1]] = value;
// 크기 변경 시 격자 스냅 적용
if ((path === "size.width" || path === "size.height") && layout.gridSettings?.snapToGrid && gridInfo) {
const snappedSize = snapSizeToGrid(newComp.size, gridInfo, layout.gridSettings as GridUtilSettings);
newComp.size = snappedSize;
}
return newComp;
});
const newLayout = { ...layout, components: updatedComponents };
setLayout(newLayout);
saveToHistory(newLayout);
},
[layout, gridInfo, saveToHistory],
);
// 테이블 데이터 로드
useEffect(() => {
if (selectedScreen?.table_name) {
const loadTables = async () => {
try {
setIsLoading(true);
const response = await screenApi.getTableInfo([selectedScreen.table_name!]);
setTables(response.data || []);
} catch (error) {
// console.error("테이블 정보 로드 실패:", error);
toast.error("테이블 정보를 불러오는데 실패했습니다.");
} finally {
setIsLoading(false);
}
};
loadTables();
}
}, [selectedScreen?.table_name]);
// 화면 레이아웃 로드
useEffect(() => {
if (selectedScreen?.screen_id) {
const loadLayout = async () => {
try {
setIsLoading(true);
const response = await screenApi.getScreenLayout(selectedScreen.screen_id!);
if (response.success && response.data) {
setLayout(response.data);
setHistory([response.data]);
setHistoryIndex(0);
setHasUnsavedChanges(false);
}
} catch (error) {
// console.error("레이아웃 로드 실패:", error);
toast.error("화면 레이아웃을 불러오는데 실패했습니다.");
} finally {
setIsLoading(false);
}
};
loadLayout();
}
}, [selectedScreen?.screen_id]);
// 저장
const handleSave = useCallback(async () => {
if (!selectedScreen?.screen_id) return;
try {
setIsSaving(true);
// 🔍 디버깅: 저장할 레이아웃 데이터 확인
console.log("🔍 레이아웃 저장 요청:", {
screenId: selectedScreen.screen_id,
componentsCount: layout.components.length,
components: layout.components.map(c => ({
id: c.id,
type: c.type,
webTypeConfig: (c as any).webTypeConfig,
})),
});
const response = await screenApi.saveScreenLayout(selectedScreen.screen_id!, layout);
if (response.success) {
toast.success("화면이 저장되었습니다.");
setHasUnsavedChanges(false);
} else {
toast.error("저장에 실패했습니다.");
}
} catch (error) {
// console.error("저장 실패:", error);
toast.error("저장 중 오류가 발생했습니다.");
} finally {
setIsSaving(false);
}
}, [selectedScreen?.screen_id, layout]);
// 드래그 앤 드롭 처리
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
const dragData = e.dataTransfer.getData("application/json");
if (!dragData) return;
try {
const { type, table, column } = JSON.parse(dragData);
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
let newComponent: ComponentData;
if (type === "table") {
// 테이블 컨테이너 생성
newComponent = {
id: generateComponentId(),
type: "container",
label: table.tableLabel || table.tableName, // 테이블 라벨 우선, 없으면 테이블명
tableName: table.tableName,
position: { x, y, z: 1 },
size: { width: 300, height: 200 },
};
} else if (type === "column") {
// 컬럼 위젯 생성
newComponent = {
id: generateComponentId(),
type: "widget",
label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명
tableName: table.tableName,
columnName: column.columnName,
widgetType: column.widgetType,
dataType: column.dataType,
required: column.required,
position: { x, y, z: 1 },
size: { width: 200, height: 40 },
};
} else {
return;
}
// 격자 스냅 적용
if (layout.gridSettings?.snapToGrid && gridInfo) {
newComponent.position = snapToGrid(newComponent.position, gridInfo, layout.gridSettings as GridUtilSettings);
newComponent.size = snapSizeToGrid(newComponent.size, gridInfo, layout.gridSettings as GridUtilSettings);
}
const newLayout = {
...layout,
components: [...layout.components, newComponent],
};
setLayout(newLayout);
saveToHistory(newLayout);
setSelectedComponent(newComponent);
// 속성 패널 자동 열기
openPanel("properties");
} catch (error) {
// console.error("드롭 처리 실패:", error);
}
},
[layout, gridInfo, saveToHistory, openPanel],
);
// 컴포넌트 클릭 처리
const handleComponentClick = useCallback(
(component: ComponentData, event?: React.MouseEvent) => {
event?.stopPropagation();
setSelectedComponent(component);
// 속성 패널 자동 열기
openPanel("properties");
},
[openPanel],
);
// 컴포넌트 삭제
const deleteComponent = useCallback(() => {
if (!selectedComponent) return;
const newComponents = layout.components.filter((comp) => comp.id !== selectedComponent.id);
const newLayout = { ...layout, components: newComponents };
setLayout(newLayout);
saveToHistory(newLayout);
setSelectedComponent(null);
}, [selectedComponent, layout, saveToHistory]);
// 컴포넌트 복사
const copyComponent = useCallback(() => {
if (!selectedComponent) return;
setClipboard({
type: "single",
data: [{ ...selectedComponent, id: generateComponentId() }],
});
toast.success("컴포넌트가 복사되었습니다.");
}, [selectedComponent]);
// 그룹 생성
const handleGroupCreate = useCallback(
(componentIds: string[], title: string, style?: any) => {
const selectedComponents = layout.components.filter((comp) => componentIds.includes(comp.id));
if (selectedComponents.length < 2) return;
// 경계 박스 계산
const boundingBox = calculateBoundingBox(selectedComponents);
// 그룹 컴포넌트 생성
const groupComponent = createGroupComponent(
componentIds,
title,
{ x: boundingBox.minX, y: boundingBox.minY },
{ width: boundingBox.width, height: boundingBox.height },
style,
);
// 자식 컴포넌트들의 상대 위치 계산
const relativeChildren = calculateRelativePositions(
selectedComponents,
{ x: boundingBox.minX, y: boundingBox.minY },
groupComponent.id,
);
const newLayout = {
...layout,
components: [
...layout.components.filter((comp) => !componentIds.includes(comp.id)),
groupComponent,
...relativeChildren,
],
};
setLayout(newLayout);
saveToHistory(newLayout);
setGroupState((prev) => ({ ...prev, selectedComponents: [] }));
},
[layout, saveToHistory],
);
// 키보드 이벤트 처리
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Delete 키로 컴포넌트 삭제
if (e.key === "Delete" && selectedComponent) {
deleteComponent();
}
// Ctrl+C로 복사
if (e.ctrlKey && e.key === "c" && selectedComponent) {
copyComponent();
}
// Ctrl+Z로 실행취소
if (e.ctrlKey && e.key === "z" && !e.shiftKey) {
e.preventDefault();
undo();
}
// Ctrl+Y 또는 Ctrl+Shift+Z로 다시실행
if ((e.ctrlKey && e.key === "y") || (e.ctrlKey && e.shiftKey && e.key === "z")) {
e.preventDefault();
redo();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [selectedComponent, deleteComponent, copyComponent, undo, redo]);
if (!selectedScreen) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<Database className="mx-auto mb-4 h-12 w-12 text-gray-400" />
<h3 className="text-lg font-medium text-gray-900"> </h3>
<p className="text-gray-500"> .</p>
</div>
</div>
);
}
return (
<div className="flex h-screen w-full flex-col bg-gray-100">
{/* 상단 툴바 */}
<DesignerToolbar
screenName={selectedScreen?.screen_name}
tableName={selectedScreen?.table_name}
onBack={onBackToList}
onSave={handleSave}
onUndo={undo}
onRedo={redo}
onPreview={() => {
toast.info("미리보기 기능은 준비 중입니다.");
}}
onTogglePanel={togglePanel}
panelStates={panelStates}
canUndo={historyIndex > 0}
canRedo={historyIndex < history.length - 1}
isSaving={isSaving}
/>
{/* 메인 캔버스 영역 (전체 화면) */}
<div
ref={canvasRef}
className="relative flex-1 overflow-hidden bg-white"
onClick={(e) => {
if (e.target === e.currentTarget) {
setSelectedComponent(null);
setGroupState((prev) => ({ ...prev, selectedComponents: [] }));
}
}}
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{/* 격자 라인 */}
{gridLines.map((line, index) => (
<div
key={index}
className="pointer-events-none absolute"
style={{
left: line.type === "vertical" ? `${line.position}px` : 0,
top: line.type === "horizontal" ? `${line.position}px` : 0,
width: line.type === "vertical" ? "1px" : "100%",
height: line.type === "horizontal" ? "1px" : "100%",
backgroundColor: layout.gridSettings?.gridColor || "#e5e7eb",
opacity: layout.gridSettings?.gridOpacity || 0.3,
}}
/>
))}
{/* 컴포넌트들 */}
{layout.components
.filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링
.map((component) => {
const children =
component.type === "group" ? layout.components.filter((child) => child.parentId === component.id) : [];
return (
<RealtimePreview
key={component.id}
component={component}
isSelected={selectedComponent?.id === component.id}
onClick={(e) => handleComponentClick(component, e)}
>
{children.map((child) => (
<RealtimePreview
key={child.id}
component={child}
isSelected={selectedComponent?.id === child.id}
onClick={(e) => handleComponentClick(child, e)}
/>
))}
</RealtimePreview>
);
})}
{/* 빈 캔버스 안내 */}
{layout.components.length === 0 && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="text-center text-gray-400">
<Database className="mx-auto mb-4 h-16 w-16" />
<h3 className="mb-2 text-xl font-medium"> </h3>
<p className="text-sm"> </p>
<p className="mt-2 text-xs">단축키: T(), P(), S(), G()</p>
</div>
</div>
)}
</div>
{/* 플로팅 패널들 */}
<FloatingPanel
id="tables"
title="테이블 목록"
isOpen={panelStates.tables?.isOpen || false}
onClose={() => closePanel("tables")}
position="left"
width={320}
height={600}
>
<TablesPanel
tables={filteredTables}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
onDragStart={(e, table, column) => {
const dragData = {
type: column ? "column" : "table",
table,
column,
};
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
}}
selectedTableName={selectedScreen.table_name!}
/>
</FloatingPanel>
<FloatingPanel
id="properties"
title="속성 편집"
isOpen={panelStates.properties?.isOpen || false}
onClose={() => closePanel("properties")}
position="right"
width={320}
height={500}
>
<PropertiesPanel
selectedComponent={selectedComponent}
onUpdateProperty={updateComponentProperty}
onDeleteComponent={deleteComponent}
onCopyComponent={copyComponent}
/>
</FloatingPanel>
<FloatingPanel
id="styles"
title="스타일 편집"
isOpen={panelStates.styles?.isOpen || false}
onClose={() => closePanel("styles")}
position="right"
width={320}
height={400}
>
{selectedComponent ? (
<div className="p-4">
<StyleEditor
style={selectedComponent.style || {}}
onStyleChange={(newStyle) => updateComponentProperty(selectedComponent.id, "style", newStyle)}
/>
</div>
) : (
<div className="flex h-full items-center justify-center text-gray-500">
</div>
)}
</FloatingPanel>
<FloatingPanel
id="grid"
title="격자 설정"
isOpen={panelStates.grid?.isOpen || false}
onClose={() => closePanel("grid")}
position="right"
width={280}
height={450}
>
<GridPanel
gridSettings={layout.gridSettings || { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true }}
onGridSettingsChange={(settings) => {
const newLayout = { ...layout, gridSettings: settings };
setLayout(newLayout);
saveToHistory(newLayout);
}}
onResetGrid={() => {
const defaultSettings = { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true };
const newLayout = { ...layout, gridSettings: defaultSettings };
setLayout(newLayout);
saveToHistory(newLayout);
}}
/>
</FloatingPanel>
{/* 그룹 생성 툴바 (필요시) */}
{groupState.selectedComponents.length > 1 && (
<div className="fixed bottom-4 left-1/2 z-50 -translate-x-1/2 transform">
<GroupingToolbar
selectedComponents={groupState.selectedComponents}
onGroupCreate={handleGroupCreate}
showCreateDialog={showGroupCreateDialog}
onShowCreateDialogChange={setShowGroupCreateDialog}
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,805 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useTemplateBuilderStore,
useCurrentViewBlocks,
useSelectedBlock,
canUndo,
canRedo,
type BuilderView,
} from "./store/templateBuilderStore";
import type { FreePosition, TemplateComponent, Template } from "@/types/invyone-component";
const STORAGE_KEY_PREFIX = "invyone-template:";
const LAST_TEMPLATE_KEY = "invyone-template:__last__";
const VIEW_LABELS: Record<BuilderView, string> = {
list: "목록",
create: "등록",
edit: "수정",
};
const MIN_BLOCK_SIZE = 80;
interface PaletteItem {
id: string;
name: string;
icon: string;
category: string;
defaultSize: { w: number; h: number };
defaultConfig?: Record<string, any>;
}
const FALLBACK_PALETTE: PaletteItem[] = [
{ id: "v2-table-list", name: "데이터 테이블", icon: "▦", category: "데이터", defaultSize: { w: 640, h: 360 } },
{ id: "v2-table-search-widget", name: "검색 필터", icon: "⌕", category: "데이터", defaultSize: { w: 640, h: 80 } },
{ id: "v2-aggregation-widget", name: "KPI 집계", icon: "Σ", category: "데이터", defaultSize: { w: 320, h: 160 } },
{ id: "v2-card-display", name: "카드 표시", icon: "▢", category: "데이터", defaultSize: { w: 280, h: 180 } },
{
id: "v2-button-primary",
name: "등록 버튼",
icon: "",
category: "액션",
defaultSize: { w: 120, h: 36 },
defaultConfig: { text: "등록", actionType: "add", variant: "primary" },
},
{ id: "v2-input", name: "입력", icon: "▭", category: "폼", defaultSize: { w: 260, h: 48 } },
{ id: "v2-select", name: "드롭다운", icon: "▽", category: "폼", defaultSize: { w: 260, h: 48 } },
{ id: "v2-date", name: "날짜", icon: "📅", category: "폼", defaultSize: { w: 260, h: 48 } },
{ id: "v2-text-display", name: "텍스트", icon: "𝐓", category: "표시", defaultSize: { w: 200, h: 40 } },
];
function useRegistryPalette(): PaletteItem[] {
const [items, setItems] = useState<PaletteItem[] | null>(null);
useEffect(() => {
let alive = true;
(async () => {
try {
const mod: any = await import("@/lib/registry/ComponentRegistry");
const Registry = mod?.ComponentRegistry;
if (!Registry) return;
const all = Registry.getAllComponents?.() ?? [];
if (!alive || all.length === 0) return;
const mapped: PaletteItem[] = all.map((c: any) => {
// VEX ComponentDefinition 은 { width, height }, 새 포맷은 { w, h } — 둘 다 지원
const rawSize = c.default_size ?? {};
const defaultSize = {
w: rawSize.w ?? rawSize.width ?? 280,
h: rawSize.h ?? rawSize.height ?? 180,
};
// lucide-react 컴포넌트 객체면 ◼ 로, 짧은 문자열(이모지 등) 만 표시
const rawIcon = c.icon;
const icon =
typeof rawIcon === "string" && rawIcon.length > 0 && rawIcon.length <= 2
? rawIcon
: "◼";
return {
id: c.id,
name: c.name || c.id,
icon,
category: (c.category as string) || "기타",
defaultSize,
defaultConfig: c.default_config,
};
});
setItems(mapped);
} catch {
// ComponentRegistry 미초기화. fallback 사용
}
})();
return () => {
alive = false;
};
}, []);
return items && items.length > 0 ? items : FALLBACK_PALETTE;
}
function snapValue(value: number, step: number, enabled: boolean): number {
if (!enabled || step <= 0) return value;
return Math.round(value / step) * step;
}
export interface TemplateBuilderProps {
templateId?: string;
onExit?: () => void;
}
type CanvasDragState =
| { kind: "idle" }
| {
kind: "move";
blockId: string;
startClientX: number;
startClientY: number;
startLeft: number;
startTop: number;
}
| {
kind: "resize";
blockId: string;
startClientX: number;
startClientY: number;
startWidth: number;
startHeight: number;
};
export default function TemplateBuilder({ templateId, onExit }: TemplateBuilderProps) {
const store = useTemplateBuilderStore();
const blocks = useCurrentViewBlocks();
const selectedBlock = useSelectedBlock();
const paletteItems = useRegistryPalette();
const canvasRef = useRef<HTMLDivElement>(null);
const [drag, setDrag] = useState<CanvasDragState>({ kind: "idle" });
const [pendingCommit, setPendingCommit] = useState(false);
useEffect(() => {
const key = templateId ? STORAGE_KEY_PREFIX + templateId : LAST_TEMPLATE_KEY;
try {
const raw = localStorage.getItem(key);
if (!raw) return;
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") {
store.fromTemplate(parsed as Template);
}
} catch {
// ignore corrupted state
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [templateId]);
const handleSave = useCallback(() => {
const tpl = store.toTemplate();
const key = templateId
? STORAGE_KEY_PREFIX + templateId
: tpl.templateId
? STORAGE_KEY_PREFIX + tpl.templateId
: LAST_TEMPLATE_KEY;
try {
localStorage.setItem(key, JSON.stringify(tpl));
localStorage.setItem(LAST_TEMPLATE_KEY, JSON.stringify(tpl));
store.markClean();
} catch (err) {
console.error("template save failed", err);
}
}, [templateId, store]);
const gridStep = store.gridSettings.gap || 16;
const snapEnabled = store.gridSettings.snapToGrid;
const handlePaletteDragStart = useCallback(
(e: React.DragEvent<HTMLDivElement>, item: PaletteItem) => {
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.setData(
"application/x-template-component",
JSON.stringify({
componentId: item.id,
defaultSize: item.defaultSize,
defaultConfig: item.defaultConfig ?? {},
}),
);
},
[],
);
const handleCanvasDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
if (e.dataTransfer.types.includes("application/x-template-component")) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}
}, []);
const handleCanvasDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
const payload = e.dataTransfer.getData("application/x-template-component");
if (!payload) return;
e.preventDefault();
try {
const { componentId, defaultSize, defaultConfig } = JSON.parse(payload);
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const left = snapValue(e.clientX - rect.left - defaultSize.w / 2, gridStep, snapEnabled);
const top = snapValue(e.clientY - rect.top - defaultSize.h / 2, gridStep, snapEnabled);
const position: FreePosition = {
left: Math.max(0, left),
top: Math.max(0, top),
width: defaultSize.w,
height: defaultSize.h,
};
store.addBlock(componentId, position, defaultConfig ?? {});
} catch (err) {
console.error("drop parse failed", err);
}
},
[store, gridStep, snapEnabled],
);
const handleBlockMouseDown = useCallback(
(e: React.MouseEvent, block: TemplateComponent, mode: "move" | "resize") => {
e.stopPropagation();
e.preventDefault();
store.selectBlock(block.id);
if (mode === "move") {
setDrag({
kind: "move",
blockId: block.id,
startClientX: e.clientX,
startClientY: e.clientY,
startLeft: block.position.left,
startTop: block.position.top,
});
} else {
setDrag({
kind: "resize",
blockId: block.id,
startClientX: e.clientX,
startClientY: e.clientY,
startWidth: block.position.width,
startHeight: block.position.height,
});
}
},
[store],
);
useEffect(() => {
if (drag.kind === "idle") return;
const onMove = (e: MouseEvent) => {
if (drag.kind === "move") {
const dx = e.clientX - drag.startClientX;
const dy = e.clientY - drag.startClientY;
const left = Math.max(0, snapValue(drag.startLeft + dx, gridStep, snapEnabled));
const top = Math.max(0, snapValue(drag.startTop + dy, gridStep, snapEnabled));
useTemplateBuilderStore.getState().updateBlockPosition(drag.blockId, { left, top });
} else if (drag.kind === "resize") {
const dw = e.clientX - drag.startClientX;
const dh = e.clientY - drag.startClientY;
const width = Math.max(MIN_BLOCK_SIZE, snapValue(drag.startWidth + dw, gridStep, snapEnabled));
const height = Math.max(MIN_BLOCK_SIZE, snapValue(drag.startHeight + dh, gridStep, snapEnabled));
useTemplateBuilderStore.getState().updateBlockPosition(drag.blockId, { width, height });
}
};
const onUp = () => {
setDrag({ kind: "idle" });
setPendingCommit(true);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
}, [drag, gridStep, snapEnabled]);
useEffect(() => {
if (!pendingCommit) return;
useTemplateBuilderStore.getState().commit();
setPendingCommit(false);
}, [pendingCommit]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
if (e.key === "Delete" || e.key === "Backspace") {
const id = useTemplateBuilderStore.getState().selectedBlockId;
if (id) {
e.preventDefault();
useTemplateBuilderStore.getState().removeBlock(id);
}
}
if (e.ctrlKey && (e.key === "z" || e.key === "Z") && !e.shiftKey) {
e.preventDefault();
useTemplateBuilderStore.getState().undo();
}
if ((e.ctrlKey && (e.key === "y" || e.key === "Y")) || (e.ctrlKey && e.shiftKey && (e.key === "z" || e.key === "Z"))) {
e.preventDefault();
useTemplateBuilderStore.getState().redo();
}
if (e.ctrlKey && (e.key === "s" || e.key === "S")) {
e.preventDefault();
handleSave();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [handleSave]);
const gridLines = useMemo(() => {
if (!store.gridSettings.showGrid) return null;
const step = store.gridSettings.gap || 16;
const color = store.gridSettings.gridColor || "#e5e7eb";
const opacity = store.gridSettings.gridOpacity ?? 0.3;
return (
<div
className="pointer-events-none absolute inset-0"
style={{
backgroundImage: `linear-gradient(to right, ${color} 1px, transparent 1px), linear-gradient(to bottom, ${color} 1px, transparent 1px)`,
backgroundSize: `${step}px ${step}px`,
opacity,
}}
/>
);
}, [store.gridSettings]);
return (
<div className="flex h-full w-full flex-col bg-slate-50 dark:bg-slate-950 text-slate-800 dark:text-slate-100">
<Toolbar onSave={handleSave} onExit={onExit} />
<div className="flex min-h-0 flex-1">
<PalettePanel items={paletteItems} onDragStart={handlePaletteDragStart} />
<div className="relative min-w-0 flex-1 overflow-auto">
<div
ref={canvasRef}
className="relative min-h-full"
style={{ minHeight: 720 }}
onDragOver={handleCanvasDragOver}
onDrop={handleCanvasDrop}
onMouseDown={(e) => {
if (e.target === e.currentTarget) {
store.selectBlock(null);
}
}}
>
{gridLines}
{blocks.map((block) => (
<CanvasBlock
key={block.id}
block={block}
isSelected={selectedBlock?.id === block.id}
onMouseDown={handleBlockMouseDown}
paletteLabel={paletteItems.find((p) => p.id === block.componentId)?.name ?? block.componentId}
paletteIcon={paletteItems.find((p) => p.id === block.componentId)?.icon ?? "◼"}
/>
))}
{blocks.length === 0 && <EmptyState view={store.currentView} />}
</div>
</div>
<SidePanel />
</div>
</div>
);
}
function Toolbar({ onSave, onExit }: { onSave: () => void; onExit?: () => void }) {
const state = useTemplateBuilderStore();
const undoEnabled = canUndo(state);
const redoEnabled = canRedo(state);
return (
<div className="flex items-center gap-2 border-b border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-4 py-2 text-sm">
{onExit && (
<button
type="button"
onClick={onExit}
className="rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-2 py-1 text-xs hover:bg-slate-100 dark:hover:bg-slate-700"
>
</button>
)}
<input
value={state.templateName}
onChange={(e) => state.setTemplateMeta({ templateName: e.target.value })}
placeholder="템플릿 이름"
className="w-48 rounded border border-slate-200 dark:border-slate-700 px-2 py-1"
/>
<input
value={state.category}
onChange={(e) => state.setTemplateMeta({ category: e.target.value })}
placeholder="카테고리 (sales, hr…)"
className="w-36 rounded border border-slate-200 dark:border-slate-700 px-2 py-1"
/>
<div className="ml-2 flex items-center gap-1">
{(Object.keys(VIEW_LABELS) as BuilderView[]).map((view) => (
<button
key={view}
type="button"
onClick={() => state.switchView(view)}
className={`rounded px-3 py-1 text-xs transition-colors ${
state.currentView === view
? "bg-indigo-600 text-white"
: "border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700"
}`}
>
{VIEW_LABELS[view]}
</button>
))}
</div>
<div className="ml-auto flex items-center gap-2">
<button
type="button"
onClick={state.undo}
disabled={!undoEnabled}
className="rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-2 py-1 text-xs disabled:opacity-40"
>
</button>
<button
type="button"
onClick={state.redo}
disabled={!redoEnabled}
className="rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-2 py-1 text-xs disabled:opacity-40"
>
</button>
<label className="flex items-center gap-1 text-xs text-slate-600 dark:text-slate-300">
<input
type="checkbox"
checked={state.gridSettings.showGrid}
onChange={(e) => state.setGridSettings({ showGrid: e.target.checked })}
/>
</label>
<label className="flex items-center gap-1 text-xs text-slate-600 dark:text-slate-300">
<input
type="checkbox"
checked={state.gridSettings.snapToGrid}
onChange={(e) => state.setGridSettings({ snapToGrid: e.target.checked })}
/>
</label>
<button
type="button"
onClick={onSave}
className="rounded bg-indigo-600 px-3 py-1 text-xs font-medium text-white hover:bg-indigo-700"
>
{state.isDirty ? "저장 *" : "저장"}
</button>
</div>
</div>
);
}
function PalettePanel({
items,
onDragStart,
}: {
items: PaletteItem[];
onDragStart: (e: React.DragEvent<HTMLDivElement>, item: PaletteItem) => void;
}) {
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
const list = q
? items.filter(
(i) => i.name.toLowerCase().includes(q) || i.id.toLowerCase().includes(q),
)
: items;
const groups = new Map<string, PaletteItem[]>();
list.forEach((i) => {
const arr = groups.get(i.category) ?? [];
arr.push(i);
groups.set(i.category, arr);
});
return Array.from(groups.entries()).sort((a, b) => a[0].localeCompare(b[0]));
}, [items, query]);
return (
<aside className="flex w-60 flex-col border-r border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900">
<div className="border-b border-slate-200 dark:border-slate-700 px-3 py-2 text-xs font-semibold text-slate-500 dark:text-slate-400">
</div>
<div className="border-b border-slate-200 dark:border-slate-700 p-2">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="검색"
className="w-full rounded border border-slate-200 dark:border-slate-700 px-2 py-1 text-xs"
/>
</div>
<div className="flex-1 space-y-3 overflow-y-auto p-2">
{filtered.map(([cat, list]) => (
<div key={cat}>
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-slate-400 dark:text-slate-500">
{cat}
</div>
<div className="space-y-1">
{list.map((item) => (
<div
key={item.id}
draggable
onDragStart={(e) => onDragStart(e, item)}
className="flex cursor-grab items-center gap-2 rounded border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-950 px-2 py-1.5 text-xs hover:border-indigo-300 dark:hover:border-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-950/40 active:cursor-grabbing"
title={item.id}
>
<span className="text-sm">{item.icon}</span>
<span className="truncate">{item.name}</span>
</div>
))}
</div>
</div>
))}
</div>
</aside>
);
}
function CanvasBlock({
block,
isSelected,
onMouseDown,
paletteLabel,
paletteIcon,
}: {
block: TemplateComponent;
isSelected: boolean;
onMouseDown: (e: React.MouseEvent, block: TemplateComponent, mode: "move" | "resize") => void;
paletteLabel: string;
paletteIcon: string;
}) {
return (
<div
className={`absolute flex flex-col overflow-hidden rounded-md border bg-white dark:bg-slate-900 shadow-sm transition-shadow ${
isSelected ? "border-indigo-500 shadow-md ring-2 ring-indigo-200 dark:ring-indigo-800" : "border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600"
}`}
style={{
left: block.position.left,
top: block.position.top,
width: block.position.width,
height: block.position.height,
containerType: "inline-size",
}}
onMouseDown={(e) => onMouseDown(e, block, "move")}
>
<div className="flex items-center gap-1 border-b border-slate-200 dark:border-slate-700 bg-slate-100 dark:bg-slate-800 px-2 py-1 text-[11px] font-medium text-slate-600 dark:text-slate-300">
<span>{paletteIcon}</span>
<span className="truncate">{paletteLabel}</span>
<span className="ml-auto text-[9px] text-slate-400 dark:text-slate-500">
{Math.round(block.position.width)}×{Math.round(block.position.height)}
</span>
</div>
<div className="flex flex-1 items-center justify-center p-3 text-[11px] text-slate-400 dark:text-slate-500">
<span className="truncate">{block.componentId}</span>
</div>
<div
className="absolute bottom-0 right-0 h-3 w-3 cursor-nwse-resize bg-indigo-500/60"
onMouseDown={(e) => onMouseDown(e, block, "resize")}
/>
</div>
);
}
function EmptyState({ view }: { view: BuilderView }) {
return (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="text-center text-slate-400 dark:text-slate-500">
<div className="text-4xl">📋</div>
<div className="mt-2 text-sm font-medium">{VIEW_LABELS[view]} </div>
<div className="mt-1 text-xs"> </div>
</div>
</div>
);
}
function SidePanel() {
const state = useTemplateBuilderStore();
const selected = useSelectedBlock();
const [tab, setTab] = useState<"props" | "grid" | "meta">("props");
return (
<aside className="flex w-72 flex-col border-l border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900">
<div className="flex border-b border-slate-200 dark:border-slate-700">
{(["props", "grid", "meta"] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
className={`flex-1 px-3 py-2 text-xs font-medium ${
tab === t ? "border-b-2 border-indigo-600 text-indigo-600" : "text-slate-500 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800/60"
}`}
>
{t === "props" ? "속성" : t === "grid" ? "격자" : "메타"}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-3">
{tab === "props" ? (
selected ? (
<BlockProperties block={selected} />
) : (
<div className="py-8 text-center text-xs text-slate-400 dark:text-slate-500"> </div>
)
) : tab === "grid" ? (
<GridSettings />
) : (
<MetaForm />
)}
</div>
</aside>
);
}
function BlockProperties({ block }: { block: TemplateComponent }) {
const updateBlockConfig = useTemplateBuilderStore((s) => s.updateBlockConfig);
const updateBlockPosition = useTemplateBuilderStore((s) => s.updateBlockPosition);
const removeBlock = useTemplateBuilderStore((s) => s.removeBlock);
const [configText, setConfigText] = useState(() => JSON.stringify(block.config ?? {}, null, 2));
const [configError, setConfigError] = useState<string | null>(null);
useEffect(() => {
setConfigText(JSON.stringify(block.config ?? {}, null, 2));
setConfigError(null);
}, [block.id, block.config]);
const commitConfig = () => {
try {
const parsed = JSON.parse(configText);
if (typeof parsed !== "object" || parsed === null) {
setConfigError("객체여야 합니다");
return;
}
updateBlockConfig(block.id, parsed);
setConfigError(null);
} catch (err) {
setConfigError(err instanceof Error ? err.message : "JSON 파싱 실패");
}
};
return (
<div className="space-y-3 text-xs">
<div>
<div className="text-[10px] uppercase text-slate-400 dark:text-slate-500">component</div>
<div className="font-mono text-slate-700 dark:text-slate-200">{block.componentId}</div>
</div>
<div className="grid grid-cols-2 gap-2">
<LabeledNumber
label="left"
value={block.position.left}
onChange={(v) => updateBlockPosition(block.id, { left: v })}
/>
<LabeledNumber
label="top"
value={block.position.top}
onChange={(v) => updateBlockPosition(block.id, { top: v })}
/>
<LabeledNumber
label="width"
value={block.position.width}
onChange={(v) => updateBlockPosition(block.id, { width: Math.max(MIN_BLOCK_SIZE, v) })}
/>
<LabeledNumber
label="height"
value={block.position.height}
onChange={(v) => updateBlockPosition(block.id, { height: Math.max(MIN_BLOCK_SIZE, v) })}
/>
</div>
<div>
<div className="mb-1 text-[10px] uppercase text-slate-400 dark:text-slate-500">config (JSON)</div>
<textarea
value={configText}
onChange={(e) => setConfigText(e.target.value)}
onBlur={commitConfig}
rows={8}
className="w-full rounded border border-slate-200 dark:border-slate-700 p-2 font-mono text-[11px]"
/>
{configError && <div className="mt-1 text-[10px] text-rose-500 dark:text-rose-400">{configError}</div>}
</div>
{block.viewTrigger && (
<div className="rounded bg-amber-50 dark:bg-amber-950/40 p-2 text-[11px] text-amber-700 dark:text-amber-300">
<b>{block.viewTrigger.targetView}</b> ({block.viewTrigger.action})
</div>
)}
<button
type="button"
onClick={() => removeBlock(block.id)}
className="w-full rounded border border-rose-200 dark:border-rose-800 py-1.5 text-[11px] text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-950/40"
>
</button>
</div>
);
}
function LabeledNumber({
label,
value,
onChange,
}: {
label: string;
value: number;
onChange: (v: number) => void;
}) {
return (
<label className="block">
<span className="block text-[10px] uppercase text-slate-400 dark:text-slate-500">{label}</span>
<input
type="number"
value={Math.round(value)}
onChange={(e) => onChange(Number(e.target.value) || 0)}
className="w-full rounded border border-slate-200 dark:border-slate-700 px-2 py-1 text-xs"
/>
</label>
);
}
function GridSettings() {
const grid = useTemplateBuilderStore((s) => s.gridSettings);
const setGrid = useTemplateBuilderStore((s) => s.setGridSettings);
const reset = useTemplateBuilderStore((s) => s.resetGrid);
return (
<div className="space-y-3 text-xs">
<label className="flex items-center justify-between">
<span> </span>
<input
type="checkbox"
checked={grid.showGrid}
onChange={(e) => setGrid({ showGrid: e.target.checked })}
/>
</label>
<label className="flex items-center justify-between">
<span></span>
<input
type="checkbox"
checked={grid.snapToGrid}
onChange={(e) => setGrid({ snapToGrid: e.target.checked })}
/>
</label>
<LabeledNumber label="간격 (px)" value={grid.gap} onChange={(v) => setGrid({ gap: Math.max(4, v) })} />
<LabeledNumber
label="패딩 (px)"
value={grid.padding}
onChange={(v) => setGrid({ padding: Math.max(0, v) })}
/>
<button
type="button"
onClick={reset}
className="w-full rounded border border-slate-200 dark:border-slate-700 py-1.5 text-[11px] text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800/60"
>
</button>
</div>
);
}
function MetaForm() {
const state = useTemplateBuilderStore();
return (
<div className="space-y-3 text-xs">
<label className="block">
<span className="block text-[10px] uppercase text-slate-400 dark:text-slate-500"></span>
<input
value={state.icon}
onChange={(e) => state.setTemplateMeta({ icon: e.target.value })}
className="w-full rounded border border-slate-200 dark:border-slate-700 px-2 py-1"
/>
</label>
<label className="block">
<span className="block text-[10px] uppercase text-slate-400 dark:text-slate-500"></span>
<input
value={state.badge}
onChange={(e) => state.setTemplateMeta({ badge: e.target.value })}
placeholder="ERP · 영업"
className="w-full rounded border border-slate-200 dark:border-slate-700 px-2 py-1"
/>
</label>
<label className="block">
<span className="block text-[10px] uppercase text-slate-400 dark:text-slate-500"></span>
<textarea
value={state.description}
onChange={(e) => state.setTemplateMeta({ description: e.target.value })}
rows={3}
className="w-full rounded border border-slate-200 dark:border-slate-700 px-2 py-1"
/>
</label>
<label className="block">
<span className="block text-[10px] uppercase text-slate-400 dark:text-slate-500"> </span>
<input
value={state.primaryTable ?? ""}
onChange={(e) => state.setTemplateMeta({ primaryTable: e.target.value || null })}
placeholder="ORDER_MASTER"
className="w-full rounded border border-slate-200 dark:border-slate-700 px-2 py-1 font-mono"
/>
</label>
<div className="grid grid-cols-2 gap-2">
<LabeledNumber
label="기본 너비"
value={state.defaultSize.w}
onChange={(v) => state.setTemplateMeta({ defaultSize: { ...state.defaultSize, w: v } })}
/>
<LabeledNumber
label="기본 높이"
value={state.defaultSize.h}
onChange={(v) => state.setTemplateMeta({ defaultSize: { ...state.defaultSize, h: v } })}
/>
</div>
</div>
);
}
@@ -0,0 +1,460 @@
"use client";
import { create } from "zustand";
import { devtools } from "zustand/middleware";
import type {
FieldConfig,
TemplateComponent,
TemplateViews,
TemplateViewConfig,
FreePosition,
Template,
Connection,
ViewTrigger,
} from "@/types/invyone-component";
export type BuilderView = "list" | "create" | "edit";
export interface GridSettings {
columns: number;
gap: number;
padding: number;
snapToGrid: boolean;
showGrid: boolean;
gridColor?: string;
gridOpacity?: number;
}
const DEFAULT_GRID: GridSettings = {
columns: 12,
gap: 16,
padding: 16,
snapToGrid: false,
showGrid: false,
gridColor: "#e5e7eb",
gridOpacity: 0.3,
};
const HISTORY_LIMIT = 50;
interface BuilderSnapshot {
blocks: Record<BuilderView, TemplateComponent[]>;
connections: Connection[];
fields: FieldConfig[];
}
interface TemplateBuilderState {
templateId: string | null;
templateName: string;
icon: string;
badge: string;
category: string;
description: string;
primaryTable: string | null;
defaultSize: { w: number; h: number };
fields: FieldConfig[];
blocks: Record<BuilderView, TemplateComponent[]>;
connections: Connection[];
currentView: BuilderView;
selectedBlockId: string | null;
selectedIds: string[];
gridSettings: GridSettings;
history: BuilderSnapshot[];
historyIndex: number;
isDirty: boolean;
setTemplateMeta: (meta: Partial<{
templateName: string;
icon: string;
badge: string;
category: string;
description: string;
primaryTable: string | null;
defaultSize: { w: number; h: number };
}>) => void;
setFields: (fields: FieldConfig[]) => void;
switchView: (view: BuilderView) => void;
addBlock: (componentId: string, position: FreePosition, config?: Record<string, any>) => TemplateComponent;
updateBlock: (id: string, updates: Partial<TemplateComponent>) => void;
updateBlockPosition: (id: string, position: Partial<FreePosition>) => void;
updateBlockConfig: (id: string, configPatch: Record<string, any>) => void;
removeBlock: (id: string) => void;
selectBlock: (id: string | null) => void;
setSelectedIds: (ids: string[]) => void;
addConnection: (conn: Connection) => void;
removeConnection: (connId: string) => void;
setGridSettings: (patch: Partial<GridSettings>) => void;
resetGrid: () => void;
undo: () => void;
redo: () => void;
commit: () => void;
toTemplate: () => Template;
fromTemplate: (tpl: Template) => void;
resetBuilder: () => void;
markClean: () => void;
}
let _idCounter = 0;
function genId(prefix: string): string {
_idCounter += 1;
return `${prefix}_${Date.now().toString(36)}_${_idCounter.toString(36)}`;
}
function cloneSnapshot(
blocks: Record<BuilderView, TemplateComponent[]>,
connections: Connection[],
fields: FieldConfig[],
): BuilderSnapshot {
return {
blocks: {
list: blocks.list.map((b) => ({ ...b, position: { ...b.position } })),
create: blocks.create.map((b) => ({ ...b, position: { ...b.position } })),
edit: blocks.edit.map((b) => ({ ...b, position: { ...b.position } })),
},
connections: connections.map((c) => ({ ...c, from: { ...c.from }, to: { ...c.to } })),
fields: fields.map((f) => ({ ...f })),
};
}
function detectViewTrigger(componentId: string, config: Record<string, any>): ViewTrigger | undefined {
if (componentId === "v2-button-primary") {
const action = (config?.actionType ?? "").toString().toLowerCase();
if (action === "add" || action === "create") {
return { targetView: "create", action: "open-modal" };
}
if (action === "edit") {
return { targetView: "edit", action: "open-modal" };
}
}
return undefined;
}
const initialBlocks: Record<BuilderView, TemplateComponent[]> = {
list: [],
create: [],
edit: [],
};
export const useTemplateBuilderStore = create<TemplateBuilderState>()(
devtools(
(set, get) => ({
templateId: null,
templateName: "",
icon: "📋",
badge: "",
category: "",
description: "",
primaryTable: null,
defaultSize: { w: 800, h: 520 },
fields: [],
blocks: initialBlocks,
connections: [],
currentView: "list",
selectedBlockId: null,
selectedIds: [],
gridSettings: { ...DEFAULT_GRID },
history: [cloneSnapshot(initialBlocks, [], [])],
historyIndex: 0,
isDirty: false,
setTemplateMeta: (meta) =>
set((s) => ({
...s,
...meta,
isDirty: true,
})),
setFields: (fields) => {
const s = get();
set({ fields, isDirty: true });
const snap = cloneSnapshot(s.blocks, s.connections, fields);
const history = s.history.slice(0, s.historyIndex + 1).concat(snap).slice(-HISTORY_LIMIT);
set({ history, historyIndex: history.length - 1 });
},
switchView: (view) => set({ currentView: view, selectedBlockId: null, selectedIds: [] }),
addBlock: (componentId, position, config = {}) => {
const s = get();
const block: TemplateComponent = {
id: genId("blk"),
componentId,
position: { ...position },
config,
viewTrigger: detectViewTrigger(componentId, config),
};
const viewBlocks = [...s.blocks[s.currentView], block];
const newBlocks = { ...s.blocks, [s.currentView]: viewBlocks };
let nextViews = newBlocks;
if (block.viewTrigger?.targetView === "create" && newBlocks.create.length === 0) {
nextViews = { ...newBlocks, create: [] };
}
if (block.viewTrigger?.targetView === "edit" && newBlocks.edit.length === 0) {
nextViews = { ...nextViews, edit: [] };
}
const snap = cloneSnapshot(nextViews, s.connections, s.fields);
const history = s.history.slice(0, s.historyIndex + 1).concat(snap).slice(-HISTORY_LIMIT);
set({
blocks: nextViews,
selectedBlockId: block.id,
selectedIds: [block.id],
history,
historyIndex: history.length - 1,
isDirty: true,
});
return block;
},
updateBlock: (id, updates) => {
const s = get();
const view = s.currentView;
const viewBlocks = s.blocks[view].map((b) => (b.id === id ? { ...b, ...updates } : b));
set({
blocks: { ...s.blocks, [view]: viewBlocks },
isDirty: true,
});
},
updateBlockPosition: (id, patch) => {
const s = get();
const view = s.currentView;
const viewBlocks = s.blocks[view].map((b) =>
b.id === id ? { ...b, position: { ...b.position, ...patch } } : b,
);
set({ blocks: { ...s.blocks, [view]: viewBlocks }, isDirty: true });
},
updateBlockConfig: (id, configPatch) => {
const s = get();
const view = s.currentView;
const viewBlocks = s.blocks[view].map((b) => {
if (b.id !== id) return b;
const nextConfig = { ...b.config, ...configPatch };
return {
...b,
config: nextConfig,
viewTrigger: detectViewTrigger(b.componentId, nextConfig),
};
});
set({ blocks: { ...s.blocks, [view]: viewBlocks }, isDirty: true });
},
removeBlock: (id) => {
const s = get();
const view = s.currentView;
const viewBlocks = s.blocks[view].filter((b) => b.id !== id);
const connections = s.connections.filter(
(c) => c.from.componentId !== id && c.to.componentId !== id,
);
const newBlocks = { ...s.blocks, [view]: viewBlocks };
const snap = cloneSnapshot(newBlocks, connections, s.fields);
const history = s.history.slice(0, s.historyIndex + 1).concat(snap).slice(-HISTORY_LIMIT);
set({
blocks: newBlocks,
connections,
selectedBlockId: s.selectedBlockId === id ? null : s.selectedBlockId,
selectedIds: s.selectedIds.filter((x) => x !== id),
history,
historyIndex: history.length - 1,
isDirty: true,
});
},
selectBlock: (id) =>
set({ selectedBlockId: id, selectedIds: id ? [id] : [] }),
setSelectedIds: (ids) =>
set({ selectedIds: ids, selectedBlockId: ids.length === 1 ? ids[0] : null }),
addConnection: (conn) => {
const s = get();
const withId = conn.id ? conn : { ...conn, id: genId("conn") };
const connections = [...s.connections, withId];
const snap = cloneSnapshot(s.blocks, connections, s.fields);
const history = s.history.slice(0, s.historyIndex + 1).concat(snap).slice(-HISTORY_LIMIT);
set({ connections, history, historyIndex: history.length - 1, isDirty: true });
},
removeConnection: (connId) => {
const s = get();
const connections = s.connections.filter((c) => c.id !== connId);
const snap = cloneSnapshot(s.blocks, connections, s.fields);
const history = s.history.slice(0, s.historyIndex + 1).concat(snap).slice(-HISTORY_LIMIT);
set({ connections, history, historyIndex: history.length - 1, isDirty: true });
},
setGridSettings: (patch) =>
set((s) => ({ gridSettings: { ...s.gridSettings, ...patch } })),
resetGrid: () => set({ gridSettings: { ...DEFAULT_GRID } }),
commit: () => {
const s = get();
const snap = cloneSnapshot(s.blocks, s.connections, s.fields);
const history = s.history.slice(0, s.historyIndex + 1).concat(snap).slice(-HISTORY_LIMIT);
set({ history, historyIndex: history.length - 1 });
},
undo: () => {
const s = get();
if (s.historyIndex <= 0) return;
const newIdx = s.historyIndex - 1;
const snap = s.history[newIdx];
set({
historyIndex: newIdx,
blocks: {
list: snap.blocks.list.map((b) => ({ ...b, position: { ...b.position } })),
create: snap.blocks.create.map((b) => ({ ...b, position: { ...b.position } })),
edit: snap.blocks.edit.map((b) => ({ ...b, position: { ...b.position } })),
},
connections: snap.connections.map((c) => ({ ...c })),
fields: snap.fields.map((f) => ({ ...f })),
isDirty: true,
});
},
redo: () => {
const s = get();
if (s.historyIndex >= s.history.length - 1) return;
const newIdx = s.historyIndex + 1;
const snap = s.history[newIdx];
set({
historyIndex: newIdx,
blocks: {
list: snap.blocks.list.map((b) => ({ ...b, position: { ...b.position } })),
create: snap.blocks.create.map((b) => ({ ...b, position: { ...b.position } })),
edit: snap.blocks.edit.map((b) => ({ ...b, position: { ...b.position } })),
},
connections: snap.connections.map((c) => ({ ...c })),
fields: snap.fields.map((f) => ({ ...f })),
isDirty: true,
});
},
toTemplate: (): Template => {
const s = get();
const buildView = (blocks: TemplateComponent[], isModal: boolean): TemplateViewConfig => ({
components: blocks,
layout: isModal ? "modal" : "card",
...(isModal ? { modalSize: { w: 720, h: 560 } } : {}),
designSize: s.defaultSize,
});
const views: TemplateViews = {
list: buildView(s.blocks.list, false),
...(s.blocks.create.length > 0 ? { create: buildView(s.blocks.create, true) } : {}),
...(s.blocks.edit.length > 0 ? { edit: buildView(s.blocks.edit, true) } : {}),
};
const now = new Date().toISOString();
return {
templateId: s.templateId || genId("tpl"),
name: s.templateName,
kind: "business",
category: s.category,
description: s.description || undefined,
primaryTable: s.primaryTable || "",
fields: s.fields,
views: views as unknown as Template["views"],
connections: s.connections,
companyCode: "*",
version: 1,
status: "draft",
createdAt: now,
updatedAt: now,
};
},
fromTemplate: (tpl) => {
const rawViews = (tpl.views ?? {}) as unknown as Partial<TemplateViews>;
const list = rawViews.list?.components ?? [];
const create = rawViews.create?.components ?? [];
const edit = rawViews.edit?.components ?? [];
const blocks: Record<BuilderView, TemplateComponent[]> = {
list: list.map((c) => ({ ...c, position: { ...c.position } })),
create: create.map((c) => ({ ...c, position: { ...c.position } })),
edit: edit.map((c) => ({ ...c, position: { ...c.position } })),
};
const connections = (tpl.connections ?? []).map((c) => ({ ...c }));
const fields = (tpl.fields ?? []).map((f) => ({ ...f }));
const snap = cloneSnapshot(blocks, connections, fields);
set({
templateId: tpl.templateId ?? null,
templateName: tpl.name ?? "",
icon: (tpl as any).icon ?? "📋",
badge: (tpl as any).badge ?? "",
category: tpl.category ?? "",
description: tpl.description ?? "",
primaryTable: tpl.primaryTable ?? null,
defaultSize: (tpl as any).defaultSize ?? { w: 800, h: 520 },
fields,
blocks,
connections,
currentView: "list",
selectedBlockId: null,
selectedIds: [],
history: [snap],
historyIndex: 0,
isDirty: false,
});
},
resetBuilder: () => {
const snap = cloneSnapshot(initialBlocks, [], []);
set({
templateId: null,
templateName: "",
icon: "📋",
badge: "",
category: "",
description: "",
primaryTable: null,
defaultSize: { w: 800, h: 520 },
fields: [],
blocks: initialBlocks,
connections: [],
currentView: "list",
selectedBlockId: null,
selectedIds: [],
gridSettings: { ...DEFAULT_GRID },
history: [snap],
historyIndex: 0,
isDirty: false,
});
},
markClean: () => set({ isDirty: false }),
}),
{ name: "template-builder-store" },
),
);
export function useCurrentViewBlocks(): TemplateComponent[] {
return useTemplateBuilderStore((s) => s.blocks[s.currentView]);
}
export function useSelectedBlock(): TemplateComponent | null {
return useTemplateBuilderStore((s) => {
if (!s.selectedBlockId) return null;
return s.blocks[s.currentView].find((b) => b.id === s.selectedBlockId) ?? null;
});
}
export function canUndo(state: TemplateBuilderState): boolean {
return state.historyIndex > 0;
}
export function canRedo(state: TemplateBuilderState): boolean {
return state.historyIndex < state.history.length - 1;
}
@@ -4,6 +4,7 @@ import { createComponentDefinition } from "../../utils/createComponentDefinition
import { ComponentCategory } from "@/types/component";
import { AggregationWidgetWrapper } from "./AggregationWidgetComponent";
import { V2AggregationWidgetConfigPanel } from "@/components/v2/config-panels/V2AggregationWidgetConfigPanel";
import { withContainerQuery } from "../../hoc/withContainerQuery";
import type { AggregationWidgetConfig } from "./types";
/**
@@ -17,7 +18,7 @@ export const V2AggregationWidgetDefinition = createComponentDefinition({
description: "데이터의 합계, 평균, 개수 등 집계 결과를 표시하는 위젯 (필터링 지원)",
category: ComponentCategory.DISPLAY,
web_type: "text",
component: AggregationWidgetWrapper,
component: withContainerQuery(AggregationWidgetWrapper, "v2-aggregation-widget"),
default_config: {
dataSourceType: "table", // 기본값: 테이블에서 직접 조회
items: [],
@@ -4,6 +4,7 @@ import { createComponentDefinition } from "../../utils/createComponentDefinition
import { ComponentCategory } from "@/types/component";
import { ButtonPrimaryWrapper } from "./ButtonPrimaryComponent";
import { V2ButtonConfigPanel } from "@/components/v2/config-panels/V2ButtonConfigPanel";
import { withContainerQuery } from "../../hoc/withContainerQuery";
/**
* ButtonPrimary 컴포넌트 정의
@@ -16,7 +17,7 @@ export const V2ButtonPrimaryDefinition = createComponentDefinition({
description: "일반적인 액션을 위한 기본 버튼 컴포넌트",
category: ComponentCategory.ACTION,
web_type: "button",
component: ButtonPrimaryWrapper,
component: withContainerQuery(ButtonPrimaryWrapper, "v2-button-primary"),
default_config: {
text: "저장",
actionType: "button",
@@ -7,6 +7,7 @@ import type { WebType } from "@/types/screen";
import { CardDisplayComponent } from "./CardDisplayComponent";
import { V2CardDisplayConfigPanel } from "@/components/v2/config-panels/V2CardDisplayConfigPanel";
import { CardDisplayConfig } from "./types";
import { withContainerQuery } from "../../hoc/withContainerQuery";
/**
* CardDisplay 컴포넌트 정의
@@ -19,7 +20,7 @@ export const V2CardDisplayDefinition = createComponentDefinition({
description: "테이블 데이터를 카드 형태로 표시하는 컴포넌트",
category: ComponentCategory.DISPLAY,
web_type: "text",
component: CardDisplayComponent,
component: withContainerQuery(CardDisplayComponent, "v2-card-display"),
default_config: {
cardsPerRow: 3, // 기본값 3 (한 행당 카드 수)
cardSpacing: 16,
@@ -8,6 +8,7 @@ import { ComponentCategory } from "@/types/component";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { V2DateConfigPanel } from "@/components/v2/config-panels/V2DateConfigPanel";
import { V2Date } from "@/components/v2/V2Date";
import { withContainerQuery } from "../../hoc/withContainerQuery";
export const V2DateDefinition = createComponentDefinition({
id: "v2-date",
@@ -16,7 +17,7 @@ export const V2DateDefinition = createComponentDefinition({
category: ComponentCategory.INPUT,
web_type: "date",
version: "2.0.0",
component: V2Date,
component: withContainerQuery(V2Date, "v2-date"),
default_config: {
dateType: "date",
format: "YYYY-MM-DD",
@@ -8,6 +8,7 @@ import { ComponentCategory } from "@/types/component";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { V2FieldConfigPanel } from "@/components/v2/config-panels/V2FieldConfigPanel";
import { V2Input } from "@/components/v2/V2Input";
import { withContainerQuery } from "../../hoc/withContainerQuery";
export const V2InputDefinition = createComponentDefinition({
id: "v2-input",
@@ -16,7 +17,7 @@ export const V2InputDefinition = createComponentDefinition({
category: ComponentCategory.INPUT,
web_type: "text",
version: "2.0.0",
component: V2Input,
component: withContainerQuery(V2Input, "v2-input"),
default_size: { width: 200, height: 36 },
@@ -8,6 +8,7 @@ import { ComponentCategory } from "@/types/component";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { V2FieldConfigPanel } from "@/components/v2/config-panels/V2FieldConfigPanel";
import { V2Select } from "@/components/v2/V2Select";
import { withContainerQuery } from "../../hoc/withContainerQuery";
export const V2SelectDefinition = createComponentDefinition({
id: "v2-select",
@@ -16,7 +17,7 @@ export const V2SelectDefinition = createComponentDefinition({
category: ComponentCategory.INPUT,
web_type: "select",
version: "2.0.0",
component: V2Select,
component: withContainerQuery(V2Select, "v2-select"),
default_config: {
mode: "dropdown",
source: "distinct",
@@ -0,0 +1,70 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import { TableListWrapper } from "./TableListComponent";
/**
* v2-table-list 반응형 래퍼 (2026-04-10, Phase 1 Step 6)
*
* 카드 컨테이너 너비를 ResizeObserver 로 감지해 `displayMode` 를 자동 전환한다.
* 내부 TableListComponent 의 렌더링 로직은 일절 건드리지 않고, props.config 에
* displayMode 만 덮어쓴다. CardModeRenderer 는 기존 분기를 재사용.
*
* - width >= NARROW_BREAKPOINT → wide (기본 테이블 렌더링)
* - width < NARROW_BREAKPOINT → narrow (기존 CardModeRenderer)
*
* container-type: inline-size 는 향후 다른 @container 쿼리 조합에도 쓰도록 부착.
*/
const NARROW_BREAKPOINT = 600;
type AnyProps = Record<string, any>;
export const TableListContainerWrapper: React.FC<AnyProps> = (props) => {
const rootRef = useRef<HTMLDivElement | null>(null);
const [mode, setMode] = useState<"wide" | "narrow">("wide");
useEffect(() => {
const el = rootRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const apply = (width: number) => {
setMode((prev) => {
const next = width < NARROW_BREAKPOINT ? "narrow" : "wide";
return prev === next ? prev : next;
});
};
apply(el.getBoundingClientRect().width);
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
apply(entry.contentRect.width);
}
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const originalConfig = (props?.config ?? {}) as AnyProps;
const effectiveConfig: AnyProps =
mode === "narrow"
? { ...originalConfig, displayMode: "card" }
: originalConfig;
return (
<div
ref={rootRef}
data-v2-table-list-mode={mode}
style={{
containerType: "inline-size",
containerName: "v2-table-list",
width: "100%",
height: "100%",
}}
>
<TableListWrapper {...(props as any)} config={effectiveConfig as any} />
</div>
);
};
TableListContainerWrapper.displayName = "TableListContainerWrapper";
@@ -4,7 +4,7 @@ import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import type { WebType } from "@/types/screen";
import { TableListWrapper } from "./TableListComponent";
import { TableListContainerWrapper } from "./TableListContainerWrapper";
import { V2TableListConfigPanel } from "@/components/v2/config-panels/V2TableListConfigPanel";
import { TableListConfig } from "./types";
@@ -19,7 +19,7 @@ export const V2TableListDefinition = createComponentDefinition({
description: "데이터베이스 테이블의 데이터를 목록으로 표시하는 컴포넌트",
category: ComponentCategory.DISPLAY,
web_type: "text",
component: TableListWrapper,
component: TableListContainerWrapper,
default_config: {
// 표시 모드 설정
displayMode: "table" as const,
@@ -0,0 +1,22 @@
"use client";
import React from "react";
import { TableSearchWidget } from "./TableSearchWidget";
import "./table-search-widget-responsive.css";
/**
* v2-table-search-widget 반응형 래퍼 (2026-04-10, Phase 1 Step 6)
*
* 내부 TableSearchWidget 은 일절 수정하지 않는다. 래퍼 div 에 container-query
* CSS 클래스만 부여하고, table-search-widget-responsive.css 의 @container 쿼리가
* 카드 컨테이너 너비에 따라 내부 레이아웃(flex-row ↔ flex-col)을 자동 전환한다.
*/
export const TableSearchContainerWrapper: React.FC<any> = (props) => {
return (
<div className="v2-tsw-responsive-root">
<TableSearchWidget {...props} />
</div>
);
};
TableSearchContainerWrapper.displayName = "TableSearchContainerWrapper";
@@ -2,7 +2,7 @@
import { ComponentRegistry } from "../../ComponentRegistry";
import { ComponentCategory } from "@/types/component";
import { TableSearchWidget } from "./TableSearchWidget";
import { TableSearchContainerWrapper } from "./TableSearchContainerWrapper";
import { TableSearchWidgetRenderer } from "./TableSearchWidgetRenderer";
import { V2TableSearchWidgetConfigPanel } from "@/components/v2/config-panels/V2TableSearchWidgetConfigPanel";
@@ -17,7 +17,7 @@ ComponentRegistry.registerComponent({
tags: ["table", "search", "filter", "group", "search-widget"],
web_type: "custom" as any,
default_size: { width: 1920, height: 80 }, // 픽셀 단위: 전체 너비 × 80px 높이
component: TableSearchWidget,
component: TableSearchContainerWrapper as any,
renderer: TableSearchWidgetRenderer.render as any,
config_panel: V2TableSearchWidgetConfigPanel,
version: "1.0.0",
@@ -0,0 +1,56 @@
/*
* v2-table-search-widget 반응형 래퍼 (2026-04-10, Phase 1 Step 6)
*
* 카드 컨테이너 너비를 기준으로 검색 위젯의 내부 레이아웃을 강제 재배치.
* TableSearchWidget 내부는 일체 건드리지 않고, 부모 래퍼 div 의 container query
* 만으로 narrow 모드(카드 폭 < 600px) 에서 세로 스택으로 재배열한다.
*
* 작동 원리:
* .v2-tsw-responsive-root → container-type: inline-size
* 위젯 루트가 이 컨테이너의 폭을 감지 (뷰포트가 아닌 카드 폭 기준)
* narrow 조건에서는 !important 로 Tailwind flex-row 레이아웃을 덮어씀
*/
.v2-tsw-responsive-root {
container-type: inline-size;
container-name: v2-tsw;
width: 100%;
min-width: 0;
}
/* wide (기본): TableSearchWidget 의 기존 레이아웃 그대로 */
/* narrow: 카드 컨테이너 폭 < 600px → 전체 세로 스택 */
@container v2-tsw (max-width: 599px) {
/* 위젯 루트 flex 컨테이너를 세로로 강제 */
.v2-tsw-responsive-root > div {
flex-direction: column !important;
align-items: stretch !important;
flex-wrap: nowrap !important;
}
/* 루트 바로 아래의 모든 요소를 전체 너비로 */
.v2-tsw-responsive-root > div > * {
width: 100% !important;
min-width: 0 !important;
}
/* 필터 입력 박스들 (flex-1 안쪽) 도 세로로 재배치 */
.v2-tsw-responsive-root > div > div.flex-1,
.v2-tsw-responsive-root > div > .flex.flex-1 {
flex-direction: column !important;
gap: 0.5rem !important;
}
/* 개별 필터 입력 아이템 전체 너비 */
.v2-tsw-responsive-root > div > div.flex-1 > div,
.v2-tsw-responsive-root > div > .flex.flex-1 > div {
flex: 1 1 100% !important;
width: 100% !important;
min-width: 0 !important;
}
/* 오른쪽의 데이터 건수 + 설정 버튼 영역도 전체 너비, 왼쪽 정렬 */
.v2-tsw-responsive-root > div > div.flex-shrink-0 {
justify-content: space-between !important;
}
}
@@ -7,6 +7,7 @@ import type { WebType } from "@/types/screen";
import { TextDisplayWrapper } from "./TextDisplayComponent";
import { V2TextDisplayConfigPanel } from "@/components/v2/config-panels/V2TextDisplayConfigPanel";
import { TextDisplayConfig } from "./types";
import { withContainerQuery } from "../../hoc/withContainerQuery";
/**
* TextDisplay 컴포넌트 정의
@@ -19,7 +20,7 @@ export const V2TextDisplayDefinition = createComponentDefinition({
description: "텍스트를 표시하기 위한 컴포넌트",
category: ComponentCategory.DISPLAY,
web_type: "text",
component: TextDisplayWrapper,
component: withContainerQuery(TextDisplayWrapper, "v2-text-display"),
default_config: {
text: "텍스트를 입력하세요",
fontSize: "14px",
@@ -0,0 +1,35 @@
"use client";
import React from "react";
/**
* withContainerQuery HOC (2026-04-10, Phase 1 Step 6 경량 부착)
*
* 래핑된 컴포넌트를 `container-type: inline-size` 속성을 가진 div 로 감싸서,
* 카드 컨테이너 너비 기반 @container 쿼리가 자식 CSS 에서 작동할 수 있게 한다.
*
* 이 HOC 는 "반응형 준비" 용이다. 실제 wide/narrow 모드 분기는 개별 컴포넌트의
* CSS 또는 Phase 2 재작성 때 추가한다. 지금 시점에서는 container query 진입점만
* 확보해 둔다.
*/
export function withContainerQuery<P extends object>(
Wrapped: React.ComponentType<P>,
containerName?: string,
): React.FC<P> {
const Hoc: React.FC<P> = (props) => (
<div
className="v2-container-query-root"
style={{
containerType: "inline-size",
containerName,
width: "100%",
height: "100%",
minWidth: 0,
}}
>
<Wrapped {...props} />
</div>
);
Hoc.displayName = `withContainerQuery(${Wrapped.displayName || Wrapped.name || "Component"})`;
return Hoc;
}
+124 -12
View File
@@ -6,9 +6,11 @@
/* ── 대시보드 셸 ── */
.dash-shell {
display: flex;
flex: 1;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
overflow: hidden;
height: 100%;
width: 100%;
}
/* ── 사이드바 ── */
@@ -79,7 +81,14 @@
background: rgba(108,92,231,.04); }
/* ── 콘텐츠 영역 ── */
.dash-content { flex: 1; overflow: auto; display: flex; flex-direction: column; }
.dash-content {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* ── 캔버스 툴바 ── */
.dash-toolbar {
@@ -118,7 +127,11 @@
/* ── 캔버스 ── */
.dash-canvas {
flex: 1; position: relative; padding: 0; min-height: 600px; overflow: hidden;
flex: 1 1 auto;
position: relative;
padding: 0;
min-height: 0;
overflow: hidden;
background-image: radial-gradient(circle at 0.5px 0.5px, var(--v5-glass-border) 0.5px, transparent 0);
background-size: 20px 20px;
}
@@ -131,14 +144,25 @@
}
/* ── 카드 ── */
/* ★ wrapper(position:absolute)가 위치 잡고, .dash-card는 wrapper 채움 */
.dash-card {
position: absolute; background: var(--v5-glass-strong, rgba(255,255,255,.65));
backdrop-filter: blur(20px) saturate(1.4);
-webkit-backdrop-filter: blur(20px) saturate(1.4);
border: 1px solid var(--v5-glass-border); border-radius: 16px;
box-shadow: 0 8px 32px rgba(0,0,0,0.06), var(--v5-glow-sm);
display: flex; flex-direction: column; overflow: hidden;
position: relative;
width: 100%;
height: 100%;
background: rgba(255,255,255,0.92);
border: 1px solid var(--v5-glass-border, rgba(108,92,231,.2));
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0,0,0,0.12), 0 0 20px rgba(108,92,231,.15);
display: flex;
flex-direction: column;
overflow: hidden;
transition: box-shadow .25s, border-color .25s;
z-index: 10;
}
.dark .dash-card {
background: rgba(28, 26, 56, 0.92);
border-color: rgba(162,155,254,.2);
box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 0 20px rgba(162,155,254,.15);
}
.dark .dash-card { box-shadow: 0 8px 32px rgba(0,0,0,0.4), var(--v5-glow-sm); }
.dash-card:hover { border-color: rgba(108,92,231,.25);
@@ -185,8 +209,96 @@
.dash-card-btn:hover { background: var(--v5-surface-hover); color: var(--v5-primary); }
.dash-card-btn.danger:hover { background: rgba(255,71,87,.12); color: var(--v5-red); }
/* 카드 본문 */
.dash-card-body { flex: 1; overflow: auto; padding: .5rem; }
/* 카드 본문 — container query 활성화 */
.dash-card-body {
flex: 1;
overflow: auto;
padding: .5rem;
container-type: inline-size;
container-name: card;
}
/* 에러/로딩 상태 */
.dash-card-error {
padding: 2rem;
text-align: center;
color: var(--v5-red);
font-size: .7rem;
}
.dash-card-loading {
padding: 2rem;
text-align: center;
color: var(--v5-text-muted);
font-size: .7rem;
}
/* ═══════════════════════════════════════════════════════════════════════════
Template grid 기반 컴포넌트 렌더 (business kind)
═══════════════════════════════════════════════════════════════════════════ */
.dash-card-grid {
display: grid;
grid-template-columns: repeat(var(--grid-cols, 12), minmax(0, 1fr));
gap: var(--grid-gap, .5rem);
width: 100%;
align-content: start;
}
/* 각 컴포넌트 슬롯 — grid item min-width:0 은 12열 비율 유지의 핵심 */
.dash-card-grid > .tpl-component {
min-width: 0;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
grid-column: var(--col) / span var(--col-span);
grid-row: var(--row) / span var(--row-span);
}
/* ── 반응형 — 카드 너비 기준 @container ── */
/* 각 breakpoint에서 col / row 를 모두 오버라이드해야 responsive.row도 적용됨. */
@container card (max-width: 520px) {
.dash-card-grid { gap: var(--grid-gap-narrow, .35rem); }
.dash-card-grid > .tpl-component {
grid-column: var(--col-narrow) / span var(--col-span-narrow);
grid-row: var(--row-narrow) / span var(--row-span-narrow);
}
}
@container card (min-width: 520.01px) and (max-width: 900px) {
.dash-card-grid { gap: var(--grid-gap-normal, .45rem); }
.dash-card-grid > .tpl-component {
grid-column: var(--col-normal) / span var(--col-span-normal);
grid-row: var(--row-normal) / span var(--row-span-normal);
}
}
@container card (min-width: 900.01px) {
.dash-card-grid { gap: var(--grid-gap-wide, .55rem); }
.dash-card-grid > .tpl-component {
grid-column: var(--col-wide) / span var(--col-span-wide);
grid-row: var(--row-wide) / span var(--row-span-wide);
}
}
/* ═══════════════════════════════════════════════════════════════════════════
Canvas kind 자유배치 렌더
═══════════════════════════════════════════════════════════════════════════ */
.dash-card-canvas-wrapper {
width: 100%;
height: 100%;
overflow: auto;
}
.dash-card-canvas {
position: relative;
width: 100%;
min-height: 100%;
}
.dash-card-canvas > .tpl-component {
position: absolute;
overflow: hidden;
}
/* 리사이즈 핸들 */
.dash-resize-handle {
+64 -4
View File
@@ -180,10 +180,55 @@ html:not(.dark) .dev-canvas {
position: relative; min-width: 1200px; min-height: 800px; padding: 16px;
}
/* 블록 */
/* 12-col grid 캔버스 (business kind) */
.dev-canvas-grid {
position: relative;
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 8px;
padding: 16px;
min-height: 600px;
box-sizing: border-box;
container-type: inline-size;
container-name: card;
background-image:
linear-gradient(to right, var(--grid-line, rgba(108,92,231,.08)) 1px, transparent 1px);
background-size: calc((100% - 32px) / 12 + 8px) 100%;
background-position: 16px 0;
background-repeat: repeat-x;
}
.dev-canvas-grid.dragging {
background-image:
linear-gradient(to right, var(--grid-line-hover, rgba(108,92,231,.2)) 1px, transparent 1px);
}
/* 빌더 팝업 프레임 안의 grid */
.dev-popup-grid {
min-height: 320px;
padding: 12px;
}
/* grid 경고 — 비-grid 블록 잔존 시 */
.dev-grid-warn {
grid-column: 1 / -1;
padding: .5rem .75rem;
font-size: .5rem;
color: var(--d-orange, #e29a45);
background: rgba(255, 200, 60, .08);
border: 1px dashed rgba(255, 200, 60, .35);
border-radius: 6px;
}
/* 블록 — grid item */
.dev-block {
position: absolute; border: 1.5px dashed var(--d-border2); border-radius: 6px;
background: var(--d-bg2); cursor: pointer; transition: border-color 0.1s, box-shadow 0.1s;
position: relative;
min-width: 0;
min-height: 48px;
border: 1.5px dashed var(--d-border2);
border-radius: 6px;
background: var(--d-bg2);
cursor: move;
transition: border-color 0.1s, box-shadow 0.1s;
overflow: hidden;
}
.dev-block:hover { border-color: var(--d-accent); box-shadow: 0 0 0 1px var(--d-accent); }
@@ -241,10 +286,25 @@ html:not(.dark) .dev-canvas {
.dev-prop-row.inline { flex-direction: row; align-items: center; justify-content: space-between; }
.dev-prop-label { font-size: 0.46rem; font-weight: 600; color: var(--d-text3); }
/* 위치 그리드 (X/Y/W/H 4칸) */
/* 위치 그리드 (col/span/row/rowSpan 4칸) */
.dev-pos-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 0.2rem; padding: 0.2rem 0.6rem;
}
/* 반응형 오버라이드 섹션 */
.dev-resp-row {
border-top: 1px dashed var(--d-border);
padding: 0.25rem 0 0.2rem;
}
.dev-resp-row:first-of-type { border-top: none; }
.dev-resp-label {
padding: 0.2rem 0.6rem 0.05rem;
font-size: 0.42rem;
font-weight: 700;
color: var(--d-text3);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.dev-pos-item { display: flex; align-items: center; gap: 0.2rem; }
.dev-pos-item label {
font-size: 0.42rem; font-weight: 700; color: var(--d-text3); width: 14px;
+19
View File
@@ -20,6 +20,21 @@
--v5-glow-md:0 0 40px rgba(108,92,231,0.2);
--v5-glow-lg:0 0 80px rgba(108,92,231,0.25);
--v5-sidebar-w:220px;
/* ===== Template Grid System (2026-04-10) =====
카드 내부 Template 컴포넌트 배치용 12-col grid 토큰.
@container 쿼리 브레이크포인트는 카드 너비 기준. */
--grid-cols:12;
--grid-gap:.5rem;
--grid-gap-narrow:.35rem;
--grid-gap-normal:.45rem;
--grid-gap-wide:.55rem;
--card-narrow-max:520px;
--card-normal-max:900px;
--grid-line:rgba(108,92,231,.08);
--grid-line-hover:rgba(108,92,231,.2);
--grid-drop-preview:rgba(108,92,231,.15);
--grid-drop-preview-border:rgba(108,92,231,.5);
}
.dark {
--v5-bg:#06050e; --v5-bg-subtle:#0c0b18;
@@ -35,6 +50,10 @@
--v5-glow-sm:0 0 20px rgba(162,155,254,0.1);
--v5-glow-md:0 0 40px rgba(162,155,254,0.18);
--v5-glow-lg:0 0 80px rgba(162,155,254,0.22);
--grid-line:rgba(162,155,254,.1);
--grid-line-hover:rgba(162,155,254,.25);
--grid-drop-preview:rgba(162,155,254,.15);
--grid-drop-preview-border:rgba(162,155,254,.5);
}
/* ===== COSMIC BACKGROUND ===== */
+366 -23
View File
@@ -148,34 +148,93 @@ export type ComponentType =
| 'divider' // 구분선
| 'pagination'; // 페이지네이션
// ─────────────────────────────────────────────────────────────────────────────
// Position — ⚠️ DEPRECATED 블록 (2026-04-10 폐기 결정)
// ─────────────────────────────────────────────────────────────────────────────
//
// 아래 타입/함수/상수는 12-grid + business/canvas 분기 모델 잔재이다.
// 진실의 원천: notes/gbpark/2026-04-10-card-engine-final-spec.md
// 대체 타입: §7 의 FreePosition / TemplateComponent / TemplateViewConfig.
//
// 현재 DashboardCard.tsx 가 유일한 내부 사용처이며, Phase 2 에서 해당 파일을
// Card = Template 인스턴스 모델로 재작성할 때 일괄 제거 예정이다.
// 새 코드는 이 블록의 타입을 사용하지 말 것.
// ─────────────────────────────────────────────────────────────────────────────
/**
* ·.
* / .
* @deprecated Phase 2 DashboardCard .
* FreePosition { left, top, width, height } .
* DashboardCard.tsx .
*/
export interface Position {
/** 가로 위치 (그리드 단위) */
export interface GridPosition {
/** 시작 컬럼 (1~12) */
col: number;
/** 차지 컬럼 수 (1~12) */
colSpan: number;
/** 명시적 행 번호 (생략 시 auto placement) */
row?: number;
/** 행 높이 배수 (기본 1) */
rowSpan?: number;
/** 카드 너비별 반응형 오버라이드 */
responsive?: ResponsiveGridOverride;
}
/**
* @deprecated Phase 2 DashboardCard .
* FreePosition { left, top, width, height } .
* DashboardCard.tsx .
*/
export interface AbsolutePosition {
/** 가로 위치 (px) */
x: number;
/** 세로 위치 (그리드 단위) */
/** 세로 위치 (px) */
y: number;
/** 너비 (그리드 단위) */
/** 너비 (px) */
w: number;
/** 높이 (그리드 단위) */
/** 높이 (px) */
h: number;
}
/**
* · .
* position을 , .
* @deprecated Phase 2 DashboardCard .
* union . FreePosition .
*/
export interface ResponsiveOverride {
/** 대형 화면 (≥1200px) */
lg?: Partial<Position>;
/** 중형 화면 (≥768px) */
md?: Partial<Position>;
/** 소형 화면 (<768px) */
sm?: Partial<Position>;
export type ComponentPosition = GridPosition | AbsolutePosition;
/**
* @deprecated Phase 2 DashboardCard .
* @container ( §2).
* .
*/
export interface ResponsiveGridOverride {
narrow?: Partial<Omit<GridPosition, 'responsive'>>;
normal?: Partial<Omit<GridPosition, 'responsive'>>;
wide?: Partial<Omit<GridPosition, 'responsive'>>;
}
/**
* @deprecated Phase 2 DashboardCard .
* FreePosition .
*/
export function isGridPosition(pos: ComponentPosition): pos is GridPosition {
return pos != null && typeof pos === 'object' && 'col' in pos && 'colSpan' in pos;
}
/**
* @deprecated Phase 2 DashboardCard .
* FreePosition .
*/
export function isAbsolutePosition(pos: ComponentPosition): pos is AbsolutePosition {
return pos != null && typeof pos === 'object' && 'x' in pos && 'y' in pos && 'w' in pos && 'h' in pos;
}
/**
* @deprecated Phase 2 DashboardCard .
* business/canvas . Template .
* DashboardCard.tsx .
*/
export type TemplateKind = 'business' | 'canvas';
/**
* .
*/
@@ -204,13 +263,12 @@ export interface Component {
// ─── 위치 (빌더가 관리) ───
/** 그리드 상의 위치·크기 */
position: Position;
// ─── 반응형 ───
/** 브레이크포인트별 위치·크기 오버라이드 */
responsive?: ResponsiveOverride;
/**
* Template.kind에 .
* - business GridPosition (col / colSpan / row / responsive)
* - canvas AbsolutePosition (x / y / w / h)
*/
position: ComponentPosition;
// ─── 데이터 바인딩 ───
@@ -646,6 +704,11 @@ export interface Template {
templateId: string;
/** 화면 이름 */
name: string;
/**
* @deprecated Phase 2 . kind .
* optional . Template .
*/
kind?: TemplateKind;
/** 분류 (예: sales, production, purchase) */
category: string;
/** 화면 설명 */
@@ -688,3 +751,283 @@ export interface Template {
/** 수정 일시 (ISO 8601) */
updatedAt: string;
}
// ─────────────────────────────────────────────────────────────────────────────
// 6. 컴포넌트 기본 grid 배치 (섹션 10)
// ─────────────────────────────────────────────────────────────────────────────
/**
* @deprecated Phase 2 DashboardCard .
* 12-grid .
* ComponentRegistry default_size , FreePosition
* (components/template-builder/TemplateBuilder.tsx).
*/
export const DEFAULT_COMPONENT_LAYOUTS: Record<ComponentType, GridPosition> = {
search: { col: 1, colSpan: 12 },
table: {
col: 1,
colSpan: 8,
responsive: {
narrow: { colSpan: 12 },
normal: { colSpan: 12 },
wide: { colSpan: 8 },
},
},
form: {
col: 1,
colSpan: 4,
responsive: {
narrow: { col: 1, colSpan: 12 },
normal: { col: 1, colSpan: 12 },
wide: { col: 9, colSpan: 4 },
},
},
'button-bar': { col: 1, colSpan: 12 },
button: {
col: 1,
colSpan: 2,
responsive: {
narrow: { colSpan: 12 },
normal: { colSpan: 3 },
wide: { colSpan: 2 },
},
},
stats: {
col: 1,
colSpan: 4,
responsive: {
narrow: { colSpan: 12 },
normal: { colSpan: 6 },
wide: { colSpan: 4 },
},
},
title: { col: 1, colSpan: 12 },
divider: { col: 1, colSpan: 12 },
pagination: { col: 1, colSpan: 12 },
tabs: { col: 1, colSpan: 12 },
'split-panel': { col: 1, colSpan: 12 },
};
/**
* @deprecated Phase 2 DashboardCard .
* Template kind .
* DashboardCard.tsx .
*/
export const CANVAS_KEYWORDS = [
'control',
'flow',
'workflow',
'bpm',
'canvas',
'node',
'diagram',
'graph',
] as const;
// ─────────────────────────────────────────────────────────────────────────────
// 7. 카드 엔진 v2 — 자유배치 단일 모델 (2026-04-10 확정)
// ─────────────────────────────────────────────────────────────────────────────
//
// 진실의 원천: notes/gbpark/2026-04-10-card-engine-final-spec.md
//
// 이 섹션의 타입들이 Phase 1 이후 INVYONE 의 유일한 템플릿/대시보드 모델이다.
// 위쪽 §6 까지의 Component/GridPosition/TemplateKind/DEFAULT_COMPONENT_LAYOUTS
// 등은 Phase 1 Step 5 에서 제거 예정(현재는 폐기 예정 코드와 호환용으로만 남음).
// ─────────────────────────────────────────────────────────────────────────────
/**
* .
*
* (Template ) (Card)
* . 12-grid / 48-col .
*/
export interface FreePosition {
/** 좌상단 X (px) */
left: number;
/** 좌상단 Y (px) */
top: number;
/** 너비 (px) */
width: number;
/** 높이 (px) */
height: number;
}
/**
* .
*
* "등록" viewTrigger
* create placeholder .
*/
export interface ViewTrigger {
/** 어느 뷰를 여는가 */
targetView: 'create' | 'edit' | 'detail';
/** 뷰 표시 방식 */
action: 'open-modal' | 'navigate';
}
/**
* Template .
*
* (componentId) ComponentRegistry v2-* ID .
* FreePosition . config .
*/
export interface TemplateComponent {
/** 인스턴스 ID */
id: string;
/**
* ComponentRegistry ID .
* : 'v2-table-list', 'v2-button-primary', 'v2-bom-tree'
*/
componentId: string;
/** 빌더에서 표시되는 라벨 (선택) */
label?: string;
/** 캔버스 안에서의 위치 (px 자유배치) */
position: FreePosition;
/**
* // .
* ComponentRegistry default_config .
* Record .
*/
config: Record<string, any>;
/** 컨테이너 컴포넌트의 자식 (탭/아코디언 등) */
children?: TemplateComponent[];
/** 받는 데이터 포트 */
inputs?: DataPort[];
/** 내보내는 데이터 포트 */
outputs?: DataPort[];
/** 이 컴포넌트가 다른 뷰를 여는지 여부 (예: 등록 버튼 → create 뷰) */
viewTrigger?: ViewTrigger;
/** 그룹핑용 — 여러 컴포넌트를 하나의 group 으로 묶을 때 부모 id */
parentId?: string;
/**
* group type='group' id .
* UI , parentId .
*/
groupChildren?: string[];
}
/**
* .
*
* Template views.list / create / edit .
* §5 ViewConfig Phase 1 Step 5
* ViewConfig ( ).
*/
export interface TemplateViewConfig {
/** 이 뷰에 배치된 컴포넌트들 (자유배치) */
components: TemplateComponent[];
/** 뷰 표시 방식 (list 는 카드 본체, create/edit 는 모달) */
layout?: 'card' | 'modal';
/** 모달 사이즈 (layout === 'modal' 일 때) */
modalSize?: { w: number; h: number };
/** 뷰 캔버스의 기본 사이즈 (디자인 시의 작업 영역 크기) */
designSize?: { w: number; h: number };
}
/** Template 의 3뷰 묶음. list 는 필수, create/edit 는 선택(자동 생성 가능). */
export interface TemplateViews {
/** 목록 뷰 — 카드 본체 (필수) */
list: TemplateViewConfig;
/** 등록 뷰 — 모달 (자동 생성 가능) */
create?: TemplateViewConfig;
/** 수정 뷰 — 모달 (자동 생성 가능) */
edit?: TemplateViewConfig;
}
/**
* Template ( ).
*
* TemplateComponent DataPort , Template
* .
*/
export interface DataPortDef {
/** 포트 이름 */
name: string;
/** 포트 데이터 형태 */
type: DataPortType;
/** 설명 (대시보드 연결 UI 에서 표시) */
description?: string;
}
/**
* Template .
*
* 1 Template : N Card . Template ,
* // .
*/
export interface Card {
/** 인스턴스 ID */
id: string;
/** 참조하는 Template ID */
templateId: string;
/** 대시보드 안에서의 위치 (px 자유배치) */
position: FreePosition;
/** 접힘 상태 (mini 모드) */
collapsed: boolean;
/** Template 의 config 를 이 인스턴스에서만 덮어쓸 값 */
configOverride?: Record<string, any>;
/** 인스턴스별 DataPort 연결 상태 */
inputs?: DataPort[];
outputs?: DataPort[];
}
/**
* .
*
* : 수주 selectedRow BOM masterRow .
*/
export interface CardConnection {
/** 연결 ID */
id: string;
/** 출발 카드 + 포트 */
from: { cardId: string; port: string };
/** 도착 카드 + 포트 */
to: { cardId: string; port: string };
}
/**
* = .
*
* INVYONE "메뉴 = 대시보드" ,
* 1:1 . UI .
*/
export interface Dashboard {
/** 대시보드 ID */
id: string;
/** 메뉴 표시 이름 */
name: string;
/** 사이드바 아이콘 */
icon: string;
/** 카드 자유배치 목록 */
cards: Card[];
/** 카드 간 데이터 연결 */
connections: CardConnection[];
// ─── 메타 ───
companyCode: string;
/** 사용자별 대시보드일 때 소유자 ID (공유 대시보드는 비움) */
ownerId?: string;
/** 공유 대시보드 여부 */
isShared: boolean;
createdAt: string;
updatedAt: string;
}
@@ -0,0 +1,946 @@
# INVYONE 카드 엔진 최종 스펙 (2026-04-10 확정)
> **이 문서가 진실의 원천이다.** 이전 12-grid / 48-col / 섹션 / business-canvas 분기 모델은 모두 폐기됨. 다른 클로드 세션은 이 문서만 따라가면 된다.
**작성일**: 2026-04-10
**상태**: 구현 착수 가능 (Phase 1 ~ 3)
**관련 문서**:
- mockup: `notes/gbpark/2026-04-08-invyone-mockup/` (시각 의도, 살아있음)
- 컴포넌트 규격: `notes/gbpark/2026-04-08-invyone-component-spec.md` (FieldConfig 부분만 살아있음)
- 폐기된 12-grid 노트: `notes/gbpark/2026-04-10-template-model-redesign.md` (참고용 obsolete)
---
## 0. 한 줄 요약
**VEX 자유배치 모델 + 컴포넌트 내부 @container 반응형 + 카드 단위 추상화.**
12-grid / 48-col / 섹션 / business-canvas 분기는 모두 우회 흔적이고 폐기됨. 단일 자유배치 카드 모델로 통일.
---
## 1. 핵심 개념 — 4가지 용어
| 용어 | 의미 | 예시 |
|---|---|---|
| **Component** | 빌더의 재료. VEX v2-* 컴포넌트 100+개. 자체 사이즈 + 자체 모드 가짐 | v2-table-list, v2-bom-tree, v2-button-primary, v2-search |
| **Template** | 한 화면 청사진. 컴포넌트들의 자유배치 묶음. 목록/등록/수정 3뷰 | 수주관리, 인사정보, BOM관리 |
| **Card** | Template 인스턴스. 대시보드에 배치된 한 개. 위치/크기 가짐 | 영업대시보드의 "수주관리 카드 #c1" |
| **Dashboard** | 카드 컬렉션 = 사이드바 메뉴 항목. 카드 자유 배치 | "영업 현황", "운영 모니터링" |
### 관계도
```
Component (v2-*) Template Card Dashboard
───────────── ──────── ───── ──────────
v2-table-list 수주관리 ───┐ ┌─ 카드#c1 (영업대시보드, left:50,top:50)
v2-search-widget ────┐ │ 1:N ├─ 카드#c5 (임원대시보드, left:200,top:80)
v2-button-primary ├──→ 목록 뷰 (자유) ──→인스턴스화├─ 카드#c9 (본부장, left:100,top:200)
v2-bom-tree │ 등록 뷰 (자유)
v2-aggregation ├──→ 수정 뷰 (자유)
... (100+개) │
└──→ 인사정보 ───→ 여러 인스턴스
BOM관리 ───→ 여러 인스턴스
...
```
### 두 단계의 자유배치
1. **개발자 (L1+)**: 빈 캔버스에 **컴포넌트** 자유배치 → Template 완성
2. **사용자 (L0)**: 빈 대시보드에 **Template (=Card)** 자유배치 → 나만의 대시보드
= **같은 자유배치 UX 를 두 레이어에서 사용**. 캔버스에 놓는 것의 종류만 다름.
---
## 2. ★ 반응형 메커니즘 (보장 + 한계 + 백업)
### 작동 원리
캔버스 자유배치 자체로는 반응형이 안 됨. **반응형은 컴포넌트 내부에서 처리**.
```
대시보드 캔버스 (자유배치)
└─ 카드 #1 (수주관리, 800x500)
└─ Template = 컴포넌트들의 자유배치
├─ v2-table-list (720x400)
│ │
│ └─ container-type: inline-size
│ └─ @container (min-width: 600px) → 테이블 모드
│ └─ @container (max-width: 599px) → 카드 리스트 모드
├─ v2-search-widget (400x60)
│ └─ @container (min-width: 350px) → 가로 필터
│ └─ @container (max-width: 349px) → 드롭다운 통합
└─ ...
```
### 카드 사이즈 변경 시 동작
사용자가 카드를 800x500 → 400x500 으로 드래그 리사이즈하면:
1. 카드 폭이 800px → 400px 로 변함
2. 카드 안의 v2-table-list 폭도 720 → 360 으로 변함 (% 또는 fit)
3. v2-table-list 의 `@container` 가 360px 감지 → 카드 리스트 모드로 자동 전환
4. v2-search-widget 도 마찬가지로 좁은 모드로 전환
5. **사용자는 아무것도 안 했는데 내부가 알아서 재배치됨**
### ✅ 잘 작동하는 케이스 (PC 위주 시나리오)
- 카드 폭 600 → 400 같은 적당한 변화
- 컴포넌트가 자체 모드 로직 가지고 있을 때
- 모니터 해상도 차이 (FHD/2K/4K)
- 대시보드에 카드 4개 배치로 카드가 작아지는 경우
### ❌ 안 되는 케이스 (한계)
1. **컴포넌트가 멍청할 때**@container 모드 없는 컴포넌트는 그냥 잘림
- **백업 플랜**: 모든 v2-* 컴포넌트에 최소 wide/narrow 2단계 모드 강제 (Phase 1 마이그레이션)
2. **카드 폭 600 → 100 같은 극심한 변화** — 컴포넌트 자체 최소 폭 미달
- **백업 플랜**: 카드 min-width: 280px 강제. 그 이하는 collapsed (mini) 모드로 자동 전환
3. **컴포넌트들이 "재배치" 되어야 할 때** — 가로 → 세로 재배치는 자유배치라 안 됨
- **백업 플랜**: 그런 케이스가 필요하면 컨테이너 컴포넌트 (탭/아코디언) 를 씀. 일반 자유배치는 재배치 안 함
4. **모바일/태블릿 (카드 폭 200px 이하)** — 자유배치는 모바일에 부적합
- **백업 플랜**: PC 위주 시나리오 (사용자 명시). 모바일은 별도 뷰 (Phase 4 이후)
### 보장 명시
> **이 메커니즘으로 PC 위주 시나리오 (FHD~4K, 카드 폭 280~1920px) 에서는 100% 반응형 작동.**
> 모든 v2-* 컴포넌트가 @container 기반 wide/narrow 모드를 가져야 한다는 조건 충족 시.
> 이 조건은 Phase 1 마이그레이션의 핵심 작업.
---
## 3. 데이터 모델 — 최종 타입
### 3.1 위치 (단일 모델)
```typescript
/** 자유배치 위치 (px) — 단일 위치 모델 */
export interface FreePosition {
left: number; // px
top: number; // px
width: number; // px
height: number; // px
}
```
### 3.2 컴포넌트 (Template 안의)
```typescript
/** Template 안에 배치된 컴포넌트 한 개 */
export interface TemplateComponent {
/** 인스턴스 ID */
id: string;
/** 컴포넌트 종류 — ComponentRegistry 의 ID 참조
* 예: 'v2-table-list', 'v2-bom-tree', 'v2-button-primary' */
componentId: string;
/** 캔버스 안에서의 위치 (px 자유배치) */
position: FreePosition;
/** 컴포넌트별 설정 — 모드/옵션 (그리드 모드, 버튼 액션, 컬럼 정의 등)
* ComponentRegistry 의 default_config 를 인스턴스 별로 오버라이드 */
config: Record<string, any>;
/** 컨테이너 컴포넌트의 자식 (탭, 아코디언 등) */
children?: TemplateComponent[];
/** 데이터 포트 — 컴포넌트 간 통신 */
inputs?: DataPort[];
outputs?: DataPort[];
/** 뷰 트리거 — 이 컴포넌트(예: 등록 버튼)가 다른 뷰를 여는지
* 자동 생성: 빌더가 등록 버튼을 감지하면 create 뷰 placeholder 생성 */
viewTrigger?: {
targetView: 'create' | 'edit' | 'detail';
action: 'open-modal' | 'navigate';
};
}
```
### 3.3 뷰 (Template 안의 한 화면)
```typescript
/** Template 의 한 뷰 (목록/등록/수정) */
export interface ViewConfig {
/** 이 뷰의 컴포넌트들 (자유배치) */
components: TemplateComponent[];
/** 뷰 표시 방식 */
layout?: 'card' | 'modal';
/** 모달 사이즈 (modal 일 때) */
modalSize?: { w: number; h: number };
/** 뷰 캔버스의 기본 사이즈 (디자인 시) */
designSize?: { w: number; h: number };
}
```
### 3.4 Template (한 화면 청사진)
```typescript
/** 재사용 가능한 화면 청사진 */
export interface Template {
/** 식별 */
templateId: string;
name: string; // '수주관리'
icon: string; // '📋'
badge: string; // 'ERP · 영업'
category: string; // 'sales'
description?: string;
/** 카드로 배치될 때 기본 사이즈 */
defaultSize: { w: number; h: number };
/** 데이터 (선택) */
primaryTable?: string; // 'ORDER_MASTER'
fields?: FieldConfig[]; // 컴포넌트들이 공유
/** ★ 3뷰 — 각 뷰는 독립 자유배치 캔버스 */
views: {
list: ViewConfig; // 카드 본체 (필수)
create?: ViewConfig; // 등록 모달 (자동 생성 가능)
edit?: ViewConfig; // 수정 모달 (자동 생성 가능)
};
/** 카드 간 통신 — 다른 카드와 주고받는 데이터 */
inputs?: DataPortDef[];
outputs?: DataPortDef[];
/** 메타 */
companyCode: string;
version: number;
status: 'draft' | 'published';
createdAt: string;
updatedAt: string;
}
```
### 3.5 Card (대시보드 인스턴스)
```typescript
/** 대시보드에 배치된 카드 한 개 */
export interface Card {
/** 인스턴스 ID */
id: string;
/** Template 참조 */
templateId: string;
/** 대시보드 안에서의 위치 (px 자유배치) */
position: FreePosition;
/** 접힘 (mini 모드) */
collapsed: boolean;
/** 인스턴스별 설정 오버라이드 */
configOverride?: Record<string, any>;
/** 인스턴스별 DataPort 연결 */
inputs?: DataPort[];
outputs?: DataPort[];
}
```
### 3.6 Dashboard
```typescript
/** 사이드바 메뉴 항목 = 카드 컬렉션 */
export interface Dashboard {
/** 식별 */
id: string;
name: string; // '영업 현황'
icon: string; // '💰'
/** 카드 자유배치 */
cards: Card[];
/** 카드 간 데이터 연결 */
connections: CardConnection[];
/** 메타 */
companyCode: string;
ownerId?: string; // 사용자별 대시보드일 때
isShared: boolean; // 공유 대시보드 여부
createdAt: string;
updatedAt: string;
}
/** 카드 간 데이터 연결 (DataPort 매칭) */
export interface CardConnection {
id: string;
from: { cardId: string; port: string };
to: { cardId: string; port: string };
}
```
### 3.7 폐기되는 타입 (types/invyone-component.ts 정리)
```typescript
// ❌ 폐기 — 더 이상 사용하지 않음
- GridPosition { col, colSpan, row, rowSpan }
- AbsolutePosition { x, y, w, h } (FreePosition 으로 통합)
- ComponentPosition (union 자체 폐기)
- ResponsiveGridOverride { narrow, normal, wide }
- isGridPosition() / isAbsolutePosition() 가드
- TemplateKind ('business' | 'canvas')
- DEFAULT_COMPONENT_LAYOUTS (12-col 기본값)
- CANVAS_KEYWORDS
// ✅ 유지
- FieldConfig, FieldType, FieldRef, FieldOption (그대로)
- DataPort, DataPortType, Connection (그대로, Card 간 통신용)
- ComponentTypeConfig 패밀리 (선택적, v2-* 통합 시 결정)
```
---
## 4. 빌더 구조
### 4.1 두 빌더 통합
```
폐기:
frontend/components/builder/* (12-grid 빌더, 미완성)
승격:
frontend/components/screen/ScreenDesigner_new.tsx
→ 메인 빌더 (자유배치 + VEX v2-* + 격자 옵션)
→ 경로 변경 검토: components/template-builder/* 로 이동?
```
### 4.2 빌더 진입점
```
frontend/app/(main)/admin/builder/page.tsx
→ 현재: 12-grid 빌더 import
→ 변경: ScreenDesigner_new import
```
### 4.3 빌더 핵심 기능 (현재 ScreenDesigner_new 가 이미 가짐)
- ✅ 자유배치 + 컴포넌트 자체 사이즈
- ✅ 격자 옵션 (snapToGrid, showGrid 토글)
- ✅ 그룹화 / 실행취소 / 다시실행
- ✅ 단축키 (T/M/P/S/R/D/E)
- ✅ ComponentRegistry 기반 v2-* 100+ 컴포넌트
- ✅ 좌측 패널 (테이블, 컴포넌트), 우측 패널 (속성, 스타일, 격자)
- ✅ 중앙 캔버스 (자유배치 드롭)
### 4.4 빌더에 추가해야 하는 기능 (Phase 1)
- ⏳ **3뷰 전환** (목록/등록/수정 토글) — useBuilderState.ts:25-27 의 BuilderView 패턴 참고
- ⏳ **Template 저장/불러오기** (toTemplate / fromTemplate) — useBuilderState.ts:279-327 패턴 참고
- ⏳ **자동 뷰 생성** (등록 버튼 추가 시 create 뷰 placeholder 생성)
- ⏳ **DataPort 연결 UI** (컴포넌트 간 시각적 연결)
- ⏳ **카드 미리보기** (실제 대시보드에서 어떻게 보일지)
### 4.5 대시보드 빌더 (Phase 2)
대시보드 빌더도 같은 자유배치 UX:
```
frontend/components/dash/
DashboardCanvas.tsx ← 대시보드 자유배치 캔버스 (재작성)
DashboardCard.tsx ← Card 컴포넌트 = Template 인스턴스
DashboardLayout.tsx ← 셸 (기존 유지)
TemplateLibraryModal.tsx ← 라이브러리 모달 (templateRenderers 패턴)
```
mockup 의 `js/04-templates.js` + `js/05-state.js` 패턴을 참고:
- `templateRenderers``lib/templates/registry.ts` (Template 정의들)
- `renderCanvas``DashboardCanvas.tsx`
- `addCardFromLib` → 라이브러리 모달 + 카드 인스턴스 생성
- `makeDraggable` / `makeResizable` / `applyClamp``useCardDrag.ts` / `useCardResize.ts`
---
## 5. VEX v2-* 컴포넌트 마이그레이션 가이드 (★ Phase 1 핵심)
### 5.1 마이그레이션 목표
모든 v2-* 컴포넌트가 다음 3가지를 갖추게:
1. **`container-type: inline-size`** — 부모 폭 감지 가능
2. **@container 모드 분기** — 최소 wide/narrow 2단계
3. **INVYONE FieldConfig 와 호환** — primaryTable + fields 를 받아서 동작
4. **DataPort 인터페이스** — inputs/outputs 명시
### 5.2 마이그레이션 패턴 (v2-table-list 예시)
#### Before
```tsx
// frontend/lib/registry/components/v2-table-list/TableListComponent.tsx
export const TableListWrapper: React.FC<Props> = (props) => {
return (
<div className="v2-table-list">
<table>...</table>
</div>
);
};
```
#### After
```tsx
export const TableListWrapper: React.FC<Props> = (props) => {
return (
<div className="v2-table-list-container">
{/* 내부에서 모드 분기 */}
<div className="v2-table-list-wide">
<table>...</table>
</div>
<div className="v2-table-list-narrow">
{/* 카드 리스트 모드 */}
{props.data.map(row => <Card key={row.id}>...</Card>)}
</div>
</div>
);
};
```
```css
/* TableListComponent.css */
.v2-table-list-container {
container-type: inline-size;
container-name: v2-table-list;
}
.v2-table-list-wide { display: none; }
.v2-table-list-narrow { display: block; }
@container v2-table-list (min-width: 600px) {
.v2-table-list-wide { display: block; }
.v2-table-list-narrow { display: none; }
}
```
### 5.3 컴포넌트별 마이그레이션 우선순위
#### 우선순위 1 — 가장 많이 쓰는 것 (Phase 1)
- v2-table-list (테이블)
- v2-search-widget (검색)
- v2-button-primary (버튼)
- v2-aggregation-widget (KPI)
- v2-card-display (카드 표시)
- v2-input, v2-select, v2-date (입력 필드)
- v2-text-display (텍스트)
#### 우선순위 2 — 도메인 특화 (Phase 2)
- v2-bom-tree, v2-bom-item-editor
- v2-shipping-plan-editor
- v2-pivot-grid
- v2-timeline-scheduler
- v2-process-work-standard
- v2-approval-step
#### 우선순위 3 — 보조 (Phase 3)
- v2-rack-structure
- v2-numbering-rule
- v2-category-manager
- v2-divider-line
- v2-file-upload, v2-media
### 5.4 ComponentRegistry 통합
```typescript
// 각 컴포넌트의 createComponentDefinition 호출에 추가
{
id: 'v2-table-list',
...
// ★ 추가
containerQuery: {
breakpoints: {
narrow: { maxWidth: 599 },
wide: { minWidth: 600 },
},
},
// ★ INVYONE 통합
fieldConfigSupported: true,
dataPorts: {
inputs: [
{ name: 'searchParams', type: 'params' },
{ name: 'refreshTrigger', type: 'value' },
],
outputs: [
{ name: 'selectedRow', type: 'row' },
{ name: 'selectedRows', type: 'rows' },
],
},
}
```
---
## 6. 작업 순서 (Phase 1 ~ 3)
### Phase 1 — 토대 정리 (1~2주)
**목표**: 폐기/유지 정리 + 핵심 컴포넌트 마이그레이션
1. **타입 정리** (1일)
- `types/invyone-component.ts` 에서 GridPosition, TemplateKind, DEFAULT_COMPONENT_LAYOUTS, CANVAS_KEYWORDS 폐기
- FreePosition, TemplateComponent, ViewConfig 정의 추가
- Template 인터페이스 수정 (kind 필드 제거)
- Card, Dashboard 인터페이스 추가
2. **components/builder 폐기** (1일)
- `frontend/components/builder/*` 전체 삭제 (또는 `_obsolete/` 로 이동)
- `frontend/app/(main)/admin/builder/page.tsx` 의 import 경로 변경
- 빌더 진입점이 ScreenDesigner_new 를 가리키게
3. **ScreenDesigner 정리 + 3뷰 추가** (3~4일)
- `ScreenDesigner_new.tsx``ScreenDesigner.tsx` 중 하나만 남기기 (new 권장)
- 3뷰 토글 UI 추가 (목록/등록/수정)
- useBuilderState 패턴 차용 (toTemplate / fromTemplate)
- 자동 뷰 생성 (등록 버튼 → create 뷰 placeholder)
4. **v2-* 우선순위 1 마이그레이션** (3~4일)
- v2-table-list, v2-search-widget, v2-button-primary, v2-aggregation-widget, v2-card-display, v2-input, v2-select, v2-date, v2-text-display
- 각 컴포넌트에 `container-type` + `@container` 추가
- wide/narrow 모드 분기
5. **Phase 1 검증**
- 수주관리 화면을 ScreenDesigner 로 만들기 (PoC)
- 카드 폭 변경 시 내부 컴포넌트가 모드 전환되는지 확인
### Phase 2 — 대시보드 + 카드 시스템 (2~3주)
**목표**: 자유배치 대시보드 + Card = Template 인스턴스 모델
> ### ★ Phase 2 시작 시 가장 먼저 할 일 — Phase 1 에서 미뤄둔 레거시 타입 정리
>
> Phase 1 Step 5 에서 `DashboardCard.tsx` 가 여전히 아래 타입들을 쓰고 있어
> `@deprecated` 주석만 달고 보존했다. Phase 2 의 `DashboardCard.tsx` 재작성
> 과 함께 이 항목들을 **일괄 제거**한다. 안 하면 영원히 남는다.
>
> **제거 대상** (`frontend/types/invyone-component.ts`):
> - [ ] `GridPosition` 인터페이스
> - [ ] `AbsolutePosition` 인터페이스
> - [ ] `ComponentPosition` 유니언
> - [ ] `ResponsiveGridOverride` 인터페이스
> - [ ] `isGridPosition()` / `isAbsolutePosition()` 타입 가드
> - [ ] `TemplateKind` 유니언
> - [ ] `DEFAULT_COMPONENT_LAYOUTS` 상수
> - [ ] `CANVAS_KEYWORDS` 상수
> - [ ] `Template.kind` 필드 (현재 `kind?: TemplateKind` 로 optional + deprecated)
>
> **대체**: 전부 `FreePosition { left, top, width, height }` + 단일 자유배치 모델
> (§3.1) 로 교체. `DashboardCard.tsx` 재작성 중 사용처를 FreePosition 으로
> 교체하면 이 타입들은 자연스럽게 의존처 0 이 되어 제거 가능.
>
> **검증**: Phase 2 종료 전에 `grep -rn "GridPosition\|AbsolutePosition\|TemplateKind"
> frontend/` 로 잔존 확인.
1. **대시보드 데이터 모델** (1일)
- Dashboard, Card, CardConnection 인터페이스 구현
- state 관리 (Zustand 또는 Context)
2. **DashboardCanvas 재작성** (3~4일)
- 자유배치 카드 캔버스
- 드래그/리사이즈/clamp (mockup `js/02-canvas.js` 패턴)
- 카드 추가/삭제/이동/접기
3. **TemplateLibraryModal** (2일)
- mockup 의 라이브러리 모달 (`index.html` 의 library 패턴)
- Template 목록 → 카드 추가
4. **카드 ↔ Template 인스턴스화 로직** (2일)
- templateId 로 Template 조회
- configOverride 적용
- 카드 헤더 (아이콘+이름+배지+버튼)
5. **사이드바 동적 대시보드 목록** (2일)
- mockup 의 `js/05-state.js` 의 sidebar 패턴
- 대시보드 추가/삭제/이름변경
6. **저장/복원** (1일)
- 백엔드 API + localStorage 폴백
- 대시보드/카드/연결 모두 영속화
7. **v2-* 우선순위 2 마이그레이션 시작** (병렬, 1주)
- 도메인 특화 컴포넌트들 (BOM, 출하, 피벗, 일정 등)
### Phase 3 — 데이터 흐름 + 도메인 확장 (3~4주)
**목표**: DataPort 연결 + 모든 v2-* 컴포넌트 마이그레이션 완료
1. **DataPort 연결 UI** (3일)
- 빌더에서 컴포넌트 간 시각적 연결
- Connection 데이터 모델 구현
- 런타임 이벤트 버스 (mockup 패턴)
2. **카드 간 연결 (대시보드 레벨)** (2일)
- 다른 Template 의 카드끼리 데이터 주고받기
- 예: 수주 카드의 selectedRow → BOM 카드의 inputs
3. **v2-* 우선순위 3 마이그레이션 완료** (1~2주, 병렬)
- 나머지 컴포넌트들
4. **자동 생성/프리셋** (1주)
- Template 자동 생성 (DB 메타 → 기본 Template)
- 도메인별 시작 템플릿 (수주관리, 인사정보, 재고관리)
---
## 7. 영향 범위 — 파일별 액션
### 폐기
| 파일/폴더 | 액션 |
|---|---|
| `frontend/components/builder/` (전체) | 삭제 (또는 _obsolete/) |
| `frontend/components/builder/BuilderCanvas.tsx` | 삭제 |
| `frontend/components/builder/BuilderToolbar.tsx` | 삭제 |
| `frontend/components/builder/BuilderBlock.tsx` | 삭제 |
| `frontend/components/builder/BuilderPalette.tsx` | 삭제 |
| `frontend/components/builder/BuilderProps.tsx` | 삭제 |
| `frontend/components/builder/BuilderLayout.tsx` | 삭제 |
| `frontend/components/builder/hooks/useBuilderState.ts` | **참고 후 삭제** (3뷰/toTemplate 패턴 차용) |
| `frontend/components/builder/hooks/gridMetrics.ts` | 삭제 (12-grid 헬퍼) |
| `frontend/components/builder/hooks/useBlockDrag.ts` | 삭제 |
| `frontend/components/builder/props/*` | 삭제 |
### 수정 (12-grid 흔적 제거)
| 파일 | 수정 내용 |
|---|---|
| `frontend/types/invyone-component.ts` | GridPosition, TemplateKind, DEFAULT_COMPONENT_LAYOUTS, CANVAS_KEYWORDS, ResponsiveGridOverride 폐기. FreePosition, TemplateComponent, ViewConfig, Card, Dashboard 추가. Template 에서 kind 필드 제거 |
| `frontend/types/screen.ts` | invyone-component.ts 와 통합 검토. ScreenDefinition → Template 매핑 |
| `frontend/app/(main)/admin/builder/page.tsx` | import 변경 (BuilderLayout → ScreenDesigner_new) |
| `frontend/components/dash/DashboardCanvas.tsx` | 자유배치 카드 캔버스로 재작성 |
| `frontend/components/dash/DashboardCard.tsx` | Card = Template 인스턴스 모델로 재작성 (transform: scale 제거 — 이미 결정) |
| `frontend/components/dash/DashboardLayout.tsx` | 셸 정리 |
| `frontend/styles/dashboard.css` | grid CSS 제거, free positioning CSS |
| `frontend/components/layout/AppLayout.tsx` | 사이드바 동적 대시보드 목록 |
### 승격 (메인 빌더로)
| 파일 | 액션 |
|---|---|
| `frontend/components/screen/ScreenDesigner_new.tsx` | 메인 빌더 승격. 3뷰 + Template 저장/로드 추가 |
| `frontend/components/screen/ScreenDesigner.tsx` | 검토 후 폐기 (구버전) |
| `frontend/components/screen/panels/ComponentsPanel.tsx` | 유지 + INVYONE Template 통합 |
| `frontend/components/screen/panels/PropertiesPanel.tsx` | 유지 |
| `frontend/components/screen/panels/GridPanel.tsx` | 유지 (격자 옵션) |
| `frontend/components/screen/panels/TablesPanel.tsx` | 유지 |
| `frontend/components/screen/DesignerToolbar.tsx` | 유지 + 3뷰 토글 추가 |
### 마이그레이션 (v2-* 100+ 컴포넌트)
| 폴더 | 액션 |
|---|---|
| `frontend/lib/registry/components/v2-*/` (모든 폴더) | @container 추가 + INVYONE Template 통합 + DataPort 인터페이스 추가 |
| `frontend/lib/registry/ComponentRegistry.ts` | INVYONE 모델 통합 (containerQuery, dataPorts 필드 추가) |
### 신규
| 파일 | 액션 |
|---|---|
| `frontend/lib/templates/registry.ts` | Template 정의들 (mockup `js/04-templates.js` 의 templateRenderers 패턴) |
| `frontend/components/templates/HrEmployeeList.tsx` | 인사정보 풀 카드 (mockup 매핑) |
| `frontend/components/templates/SalesOrderList.tsx` | 수주관리 풀 카드 |
| `frontend/components/templates/SalesKpi.tsx` | 매출 KPI 카드 |
| `frontend/components/templates/...` | 기타 도메인 카드들 |
| `frontend/components/dash/TemplateLibraryModal.tsx` | 라이브러리 모달 |
| `frontend/hooks/useCardDrag.ts` | 카드 드래그 (mockup `js/02-canvas.js` 패턴) |
| `frontend/hooks/useCardResize.ts` | 카드 리사이즈 |
| `frontend/store/dashboardStore.ts` | 대시보드 state (Zustand) |
---
## 8. 핵심 코드 스니펫 (구현 참고)
### 8.1 자유배치 캔버스 (DashboardCanvas)
```tsx
// frontend/components/dash/DashboardCanvas.tsx
"use client";
import { useDashboardStore } from "@/store/dashboardStore";
import { DashboardCard } from "./DashboardCard";
export function DashboardCanvas() {
const dashboard = useDashboardStore(s => s.activeDashboard);
const updateCardPosition = useDashboardStore(s => s.updateCardPosition);
if (!dashboard) return null;
return (
<div className="dash-canvas" style={{ position: 'relative', width: '100%', height: '100%' }}>
{dashboard.cards.length === 0 && (
<div className="dash-empty">
<button onClick={openLibrary}>+ 템플릿 추가</button>
</div>
)}
{dashboard.cards.map(card => (
<DashboardCard
key={card.id}
card={card}
onMove={(pos) => updateCardPosition(card.id, pos)}
/>
))}
</div>
);
}
```
### 8.2 카드 컴포넌트 (DashboardCard)
```tsx
// frontend/components/dash/DashboardCard.tsx
import { useTemplateRegistry } from "@/lib/templates/registry";
import { useCardDrag } from "@/hooks/useCardDrag";
import { useCardResize } from "@/hooks/useCardResize";
export function DashboardCard({ card, onMove }: { card: Card, onMove: (pos: FreePosition) => void }) {
const template = useTemplateRegistry(card.templateId);
const { onDragStart } = useCardDrag(card, onMove);
const { onResizeStart } = useCardResize(card, onMove);
if (!template) return null;
return (
<div
className="dash-card"
style={{
position: 'absolute',
left: card.position.left,
top: card.position.top,
width: card.position.width,
height: card.position.height,
// ★ 카드 자체가 container — 내부 컴포넌트들이 폭 감지
containerType: 'inline-size',
containerName: 'card',
}}
>
<div className="dash-card-head" onMouseDown={onDragStart}>
<span className="dash-card-icon">{template.icon}</span>
<span className="dash-card-title">{template.name}</span>
<span className="dash-card-badge">{template.badge}</span>
<button onClick={() => toggleCollapse(card.id)}>▼</button>
<button onClick={() => removeCard(card.id)}>×</button>
</div>
<div className="dash-card-body">
{card.collapsed
? <TemplateRenderer template={template} view="mini" />
: <TemplateRenderer template={template} view="list" />
}
</div>
<div className="dash-card-resize-handle" onMouseDown={onResizeStart} />
</div>
);
}
```
### 8.3 Template 렌더러
```tsx
// frontend/components/templates/TemplateRenderer.tsx
export function TemplateRenderer({ template, view = 'list' }: Props) {
const viewConfig = template.views[view];
if (!viewConfig) return null;
return (
<div className="tpl-canvas" style={{ position: 'relative', width: '100%', height: '100%' }}>
{viewConfig.components.map(comp => (
<ComponentRenderer key={comp.id} component={comp} />
))}
</div>
);
}
function ComponentRenderer({ component }: { component: TemplateComponent }) {
const def = ComponentRegistry.getComponent(component.componentId);
if (!def) return null;
const Component = def.component;
return (
<div
style={{
position: 'absolute',
left: component.position.left,
top: component.position.top,
width: component.position.width,
height: component.position.height,
}}
>
<Component {...component.config} />
</div>
);
}
```
### 8.4 v2-* 컴포넌트 @container 패턴
```tsx
// frontend/lib/registry/components/v2-table-list/TableListComponent.tsx
export const TableListWrapper: React.FC<Props> = (props) => {
return (
<div className="v2-table-list-container">
{/* 두 모드를 모두 렌더하고 CSS 로 토글 */}
<div className="v2-table-list-wide">
<table>
<thead>...</thead>
<tbody>...</tbody>
</table>
</div>
<div className="v2-table-list-narrow">
{props.data.map(row => (
<div key={row.id} className="v2-table-list-card">
{props.fields.map(f => (
<div key={f.column}>
<span className="label">{f.label}</span>
<span className="value">{row[f.column]}</span>
</div>
))}
</div>
))}
</div>
</div>
);
};
```
```css
/* TableListComponent.css */
.v2-table-list-container {
container-type: inline-size;
container-name: v2-table-list;
width: 100%;
height: 100%;
}
.v2-table-list-wide { display: none; }
.v2-table-list-narrow { display: block; }
@container v2-table-list (min-width: 600px) {
.v2-table-list-wide { display: block; }
.v2-table-list-narrow { display: none; }
}
```
### 8.5 Template 저장/불러오기 (useBuilderState 패턴 차용)
```typescript
// frontend/store/builderStore.ts
export const useBuilderStore = create<BuilderState>((set, get) => ({
// ... 기존 state
toTemplate: (): Template => {
const s = get();
return {
templateId: s.templateId || generateId(),
name: s.templateName,
icon: s.icon,
badge: s.badge,
category: s.category,
defaultSize: s.defaultSize,
primaryTable: s.tableName,
fields: s.fields,
views: {
list: { components: s.views.list, layout: 'card' },
create: s.views.create.length > 0 ? { components: s.views.create, layout: 'modal' } : undefined,
edit: s.views.edit.length > 0 ? { components: s.views.edit, layout: 'modal' } : undefined,
},
companyCode: '*',
version: 1,
status: 'draft',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
},
fromTemplate: (tpl: Template) => {
set({
templateId: tpl.templateId,
templateName: tpl.name,
icon: tpl.icon,
badge: tpl.badge,
category: tpl.category,
defaultSize: tpl.defaultSize,
tableName: tpl.primaryTable,
fields: tpl.fields ?? [],
views: {
list: tpl.views.list.components,
create: tpl.views.create?.components ?? [],
edit: tpl.views.edit?.components ?? [],
},
currentView: 'list',
});
},
}));
```
---
## 9. 핵심 보장 사항 (사용자 약속)
> 사용자가 "반응형 된다" 는 말 믿고 진행하는 결정.
> 이 보장이 깨지면 백업 플랜으로 회피 가능.
### 보장 1: PC 시나리오 (FHD~4K) 100% 작동
- 모든 주요 v2-* 컴포넌트가 wide/narrow 2단계 모드 가지면
- 카드 폭 280~1920px 범위에서 자동 반응형 작동
### 보장 2: 카드 사이즈 변경 시 내부 자동 반응
- 사용자가 카드를 800x500 → 400x500 으로 드래그 리사이즈
- 카드 내부 v2-table-list 가 @container 로 폭 감지
- 자동으로 카드 리스트 모드 전환
- 사용자는 아무 작업 안 함
### 보장 3: 수십 컴포넌트 화면 가능
- PLM/MES 워크벤치 같은 복잡 화면
- 카드 1개 안에 v2-* 컴포넌트 10~20개 자유 배치 가능
- 또는 카드 5~10개 자유 배치
- 카드 간 데이터 연결 (CardConnection) 으로 통신
### 보장 4: VEX 사용자 학습 곡선 거의 없음
- ScreenDesigner 가 이미 자유배치 + 격자 옵션
- VEX 사용자가 "더 정렬 잘 되는 VEX" 로 인식
- 새로 배울 게 거의 없음
### 한계 (백업 플랜 적용 케이스)
- ❌ 모바일/태블릿 (200px 이하) — Phase 4 이후 별도 모바일 뷰
- ❌ 컴포넌트들이 "재배치" 되어야 할 때 — 컨테이너 컴포넌트 (탭/아코디언) 사용
- ❌ @container 미지원 브라우저 — 모던 브라우저 (Chrome 105+, Safari 16+, Firefox 110+) 만 타겟
---
## 10. 검증 체크리스트 (Phase 1 완료 시)
### 코드 정리
- [ ] `frontend/components/builder/*` 폐기 완료
- [ ] `frontend/types/invyone-component.ts` 정리 (GridPosition 등 제거)
- [ ] FreePosition, TemplateComponent, ViewConfig, Card, Dashboard 타입 추가
- [ ] `frontend/app/(main)/admin/builder/page.tsx` 에서 ScreenDesigner_new 사용
### 빌더
- [ ] ScreenDesigner_new 가 메인 빌더로 동작
- [ ] 3뷰 토글 (목록/등록/수정)
- [ ] Template 저장/불러오기
- [ ] 자동 뷰 생성 (등록 버튼 → create 뷰)
### v2-* 마이그레이션 (우선순위 1)
- [ ] v2-table-list @container 적용
- [ ] v2-search-widget @container 적용
- [ ] v2-button-primary @container 적용
- [ ] v2-aggregation-widget @container 적용
- [ ] v2-card-display @container 적용
- [ ] v2-input, v2-select, v2-date 검증
### PoC
- [ ] 수주관리 화면을 ScreenDesigner 로 만들기
- [ ] Template 저장 → 불러오기
- [ ] 카드 폭 800 → 400 변경 시 v2-table-list 가 카드 리스트로 전환
### 보장 검증
- [ ] 카드 폭 변화에 모든 우선순위 1 컴포넌트가 자동 반응
- [ ] 격자 옵션 (snapToGrid) ON/OFF 정상 동작
- [ ] 그룹화/실행취소 정상 동작
---
## 11. 다른 클로드 세션을 위한 작업 지시
이 문서를 보고 작업할 다른 클로드 세션은:
1. **반드시** 이 문서가 진실의 원천이라고 인지할 것
2. 이전 12-grid / 48-col / 섹션 / business-canvas 분기 모델은 모두 폐기됨
3. `notes/gbpark/2026-04-10-template-model-redesign.md` 는 obsolete (참고만)
4. `notes/gbpark/2026-04-08-invyone-mockup/` 의 mockup 의도를 따라갈 것
5. **반응형은 컴포넌트 내부 @container 로 처리** 가 핵심
6. Phase 1 부터 순서대로 진행
7. 각 Phase 완료 시 검증 체크리스트로 확인
8. 막히면 **반드시 사용자에게 질문**, 추측 구현 금지
### 작업 시작 전 필독
- `notes/gbpark/2026-04-08-invyone-mockup/README.md` (mockup 의도)
- `notes/gbpark/2026-04-08-invyone-mockup/js/04-templates.js` (templateRenderers 패턴)
- `notes/gbpark/2026-04-08-invyone-mockup/js/05-state.js` (state 패턴)
- `frontend/components/screen/ScreenDesigner_new.tsx` (메인 빌더 베이스)
- `frontend/lib/registry/ComponentRegistry.ts` (컴포넌트 레지스트리)
### 작업 시 반드시
- 폐기 결정 사항 다시 도입 금지 (12-grid, 48-col, 섹션 등)
- 컴포넌트가 멍청하면 안 됨 (반드시 @container 모드 가짐)
- 자유배치 px 가 기본 (그리드 셀 강제 금지)
- 카드 = Template 인스턴스 (1:N) 관계 명확히
---
## 끝
이 문서는 INVYONE 카드 엔진의 최종 진실. Phase 1 부터 시작하면 됨.
**작성자 메모**: 사용자가 "반응형 된다"는 약속을 믿고 진행하는 결정이므로, 반응형 메커니즘 (컴포넌트 @container) 의 작동을 Phase 1 PoC 에서 반드시 검증할 것. 실패하면 백업 플랜 (카드 min-width, 모드 강제 등) 적용.
@@ -0,0 +1,323 @@
# INVYONE 카드 엔진 Phase 1 — 구현 로그
**작업일**: 2026-04-10
**작업자**: gbpark + Claude
**기반 스펙**: `notes/gbpark/2026-04-10-card-engine-final-spec.md`
**이전 로그**: `notes/gbpark/2026-04-10-template-redesign-phase1-log.md` (OBSOLETE — 12-grid 모델)
---
## 0. 요약
스펙 §6 의 Phase 1 (토대 정리 + 핵심 컴포넌트 마이그레이션) 을 7단계로 나눠 진행.
| Step | 제목 | 상태 |
|---|---|---|
| 1 | 타입 추가 (FreePosition, TemplateComponent, TemplateViewConfig, Card, Dashboard…) | ✅ |
| 2 | template-builder 새 빌더 작성 (3뷰 + toTemplate/fromTemplate) | ✅ |
| 3 | admin/builder/page.tsx 진입점 전환 | ✅ |
| 4 | components/builder/* + ScreenDesigner_new.tsx 완전 삭제 | ✅ |
| 5 | 레거시 타입 @deprecated 표기 (완전 제거는 Phase 2) | ✅ |
| 6 | v2-* 우선순위 1 컴포넌트 마이그레이션 (완전 2 + 경량 7) | ✅ |
| 7 | PoC 시각 검증 페이지 | ✅ (코드 + 브라우저 검증 완료 2026-04-11) |
| 8 | 세션 후반 버그 픽스 (검증 중 발견) | ✅ (2026-04-11) |
**tsc 결과**: 내 변경 영역 기준 에러 0. 전체 에러 카운트는 3423 → 3381 로 감소(삭제된 폐기 파일의 기존 에러가 사라짐).
---
## 1. 수정·생성 파일
### 타입
- **수정** `frontend/types/invyone-component.ts`
- §7 추가: `FreePosition`, `ViewTrigger`, `TemplateComponent`, `TemplateViewConfig`, `TemplateViews`, `DataPortDef`, `Card`, `CardConnection`, `Dashboard`
- 레거시 타입 전부 `@deprecated` 표기: `GridPosition`, `AbsolutePosition`, `ComponentPosition`, `ResponsiveGridOverride`, `isGridPosition`, `isAbsolutePosition`, `TemplateKind`, `DEFAULT_COMPONENT_LAYOUTS`, `CANVAS_KEYWORDS`
- `Template.kind``kind?: TemplateKind` 로 optional + deprecated 변경
### 빌더 (신규)
- **신규** `frontend/components/template-builder/TemplateBuilder.tsx` (~550줄)
- 자유배치 캔버스 + 드래그/리사이즈 + 히스토리 + 격자 옵션 + 3뷰 토글 + 팔레트 + 속성/격자/메타 사이드 패널
- v2-* 렌더링은 placeholder (Phase 2 에서 실제 컴포넌트 연결)
- localStorage 기반 임시 저장/복원
- 단축키: Delete, Ctrl+Z/Y, Ctrl+S
- **신규** `frontend/components/template-builder/store/templateBuilderStore.ts`
- Zustand, `toTemplate` / `fromTemplate` / `undo` / `redo` / `commit` / viewTrigger 자동 감지
- `BuilderView = 'list' | 'create' | 'edit'`
### 빌더 진입점 전환
- **수정** `frontend/app/(main)/admin/builder/page.tsx`
- `BuilderLayout` (12-grid) → `TemplateBuilder` import 교체
### 폐기 삭제
- **삭제** `frontend/components/builder/` 전체
- BuilderLayout/Canvas/Block/Palette/Props/Toolbar + hooks(useBuilderState, useBlockDrag, gridMetrics) + props/*
- **삭제** `frontend/components/screen/ScreenDesigner_new.tsx` (dead code, 어디서도 import 되지 않았음)
### v2-* 마이그레이션 — 완전 (2개)
- **신규** `frontend/lib/registry/components/v2-table-list/TableListContainerWrapper.tsx`
- ResizeObserver 로 카드 폭 측정 → 600px 미만이면 `config.displayMode``"card"` 로 런타임 override
- 기존 `TableListComponent` 내부는 0줄 수정
- **수정** `frontend/lib/registry/components/v2-table-list/index.ts`
- `component: TableListWrapper``component: TableListContainerWrapper`
- **신규** `frontend/lib/registry/components/v2-table-search-widget/TableSearchContainerWrapper.tsx`
- 래퍼 div 에 `.v2-tsw-responsive-root` 클래스 부여
- **신규** `frontend/lib/registry/components/v2-table-search-widget/table-search-widget-responsive.css`
- `container-type: inline-size` + `@container v2-tsw (max-width: 599px)` 에서 flex-col 강제
- **수정** `frontend/lib/registry/components/v2-table-search-widget/index.tsx`
- `component: TableSearchWidget``component: TableSearchContainerWrapper`
### v2-* 마이그레이션 — 경량 (7개)
- **신규** `frontend/lib/registry/hoc/withContainerQuery.tsx`
- 간단한 HOC: `<div style={{ containerType: 'inline-size', containerName, width: '100%', height: '100%' }}>` 로 감싸기
- **수정** 각 컴포넌트 `index.ts` 에서 `component: Wrapper``component: withContainerQuery(Wrapper, "<id>")`:
- v2-button-primary
- v2-aggregation-widget
- v2-card-display
- v2-input
- v2-select
- v2-date
- v2-text-display
### PoC 검증
- **신규** `frontend/app/(main)/test-card-responsive/page.tsx`
- 카드 폭 슬라이더 (240 ~ 1400px)
- 수주관리 화면 구성 (v2-text-display + aggregation-widget + search-widget + button-primary + table-list 의 **레이아웃 시뮬레이션**)
- ResizeObserver 기반 `data-v2-table-list-mode` 전환 + CSS @container 기반 search-widget 세로 스택 동시 시각 확인
### 스펙 MD
- **수정** `notes/gbpark/2026-04-10-card-engine-final-spec.md`
- Phase 2 시작 섹션에 "Phase 1 에서 미뤄둔 레거시 타입 정리" 체크리스트 삽입
---
## 2. 주요 결정 사항 (사용자 협의)
### 결정 1 — ScreenDesigner_new 처리 방식
**옵션**: (a) 완전 재작성 / (b) 변환 레이어만 추가 / (c) 백지에서 새로 작성
**결정**: **(c) 와 (a) 혼합 — 새 `components/template-builder/` 폴더에 완전 재작성**
**이유**: ScreenDesigner_new 는 `ScreenDefinition/ComponentData/LayoutData` 기반이라 Template 모델로의 전환은 사실상 재작성. 새 경로가 의미상 더 명확(스펙 §4.1 검토 항목).
### 결정 2 — components/builder 폐기 방식
**결정**: **완전 삭제**
**이유**: 스펙 MD 가 폐기 명시, git history 로 추적 가능, 흔적 남기면 혼란.
### 결정 3 — Step 5 레거시 타입 처리
**옵션**: (a) @deprecated + 유지 / (b) DashboardCard 도 임시 수정해 완전 제거 / (c) Phase 2 로 이관
**결정**: **(a) @deprecated 주석 + 유지 + Phase 2 체크리스트에 제거 항목 명시**
**이유**: DashboardCard.tsx 는 Phase 2 재작성 대상(스펙 §6/§7). Phase 1 에서 임시 수정하면 Phase 2 재작성 때 두 번 일. 옵션 3 은 방향성 불명.
### 결정 4 — Step 6 v2-* 마이그레이션 범위
**결정**: **핵심 2개 완전 + 나머지 7개 경량**
- **완전**: v2-table-list (ResizeObserver), v2-table-search-widget (CSS @container)
- **경량**: button-primary, input, select, date, text-display, card-display, aggregation-widget → `container-type: inline-size` 만 부착
**이유**: PoC 검증력을 담보(검색+테이블 CRUD 기본형)하면서 Phase 1 스코프 유지. 나머지 7개의 모드 분기는 Phase 2 개별 재작성 시 추가.
---
## 3. Phase 2 이월 체크리스트
스펙 MD §6 Phase 2 섹션에 다음 항목이 추가됐다(`notes/gbpark/2026-04-10-card-engine-final-spec.md` 참조).
### 레거시 타입 최종 제거 (Phase 1 이월)
- [ ] `GridPosition`, `AbsolutePosition`, `ComponentPosition`, `ResponsiveGridOverride` 제거
- [ ] `isGridPosition()`, `isAbsolutePosition()` 가드 제거
- [ ] `TemplateKind`, `DEFAULT_COMPONENT_LAYOUTS`, `CANVAS_KEYWORDS` 제거
- [ ] `Template.kind` 필드 완전 제거
- [ ] `DashboardCard.tsx` 재작성하면서 위 타입 사용처를 전부 `FreePosition` 으로 교체
- [ ] 잔존 확인: `grep -rn "GridPosition\|AbsolutePosition\|TemplateKind" frontend/`
### 경량 7개 v2-* 컴포넌트 모드 분기 추가 (Phase 1 이월)
- [ ] v2-button-primary: narrow 에서 아이콘만 / wide 에서 아이콘+텍스트
- [ ] v2-input: 라벨 위치 (narrow=top / wide=left)
- [ ] v2-select: 동일
- [ ] v2-date: 동일 + narrow 에서 range 모드 자동 compact
- [ ] v2-text-display: font-size 반응
- [ ] v2-card-display: narrow 에서 cardsPerRow 자동 1
- [ ] v2-aggregation-widget: narrow 에서 2x2 그리드
### 우선순위 2/3 v2-* 마이그레이션 (스펙 §5.3)
- [ ] 우선순위 2: v2-bom-tree, v2-bom-item-editor, v2-shipping-plan-editor, v2-pivot-grid, v2-timeline-scheduler, v2-process-work-standard, v2-approval-step
- [ ] 우선순위 3: v2-rack-structure, v2-numbering-rule, v2-category-manager, v2-divider-line, v2-file-upload, v2-media
### 기타
- [ ] TemplateBuilder 의 placeholder 렌더링 → 실제 v2-* 컴포넌트 렌더링 (ComponentRegistry 연동)
- [ ] TemplateBuilder 저장/복원을 localStorage 에서 백엔드 API 로
- [ ] 3뷰 자동 생성 실제 동작 (등록 버튼 감지 → create 뷰 placeholder 자동 생성) — 현재 store 에는 탐지 로직 있으나 UI placeholder 생성 로직 없음
---
## 4. 사용자 수동 검증 체크리스트 (Phase 1 종료 조건)
다음 항목을 브라우저에서 직접 확인.
### (A) 테스트 페이지 — 반응형 메커니즘 시각 확인
1. 도커 or 로컬에서 frontend 실행
2. 브라우저에서 `/test-card-responsive` 접속
3. 카드 폭 슬라이더를 **800 → 400** 으로 드래그
4. 확인:
- [ ] 상단 "감지된 모드" 배지가 `wide``narrow` 로 전환 (버튼 색도 인디고 → 로즈)
- [ ] v2-table-list 영역이 테이블 → 카드 리스트로 재렌더 (`data-v2-table-list-mode` 속성값 wide/narrow 교차)
- [ ] v2-table-search-widget 영역의 필터/버튼이 가로 → 세로 스택으로 재배열
5. 슬라이더 프리셋 (320 / 520 / 800 / 1200) 로 경계값 확인
6. **실패 시**: 스펙 §9 백업 플랜 적용 검토 (카드 min-width 강제, 모드 강제 등)
### (B) 빌더 접근 확인
1. `/admin/builder` 접속
2. 확인:
- [ ] TemplateBuilder UI 가 표시 (상단 툴바 + 좌측 팔레트 + 중앙 캔버스 + 우측 속성 패널)
- [ ] 상단 탭에서 목록/등록/수정 3뷰 토글 가능
- [ ] 좌측 팔레트에서 v2-* 컴포넌트 드래그해 캔버스에 드롭 → 배치 성공
- [ ] 배치된 블록 드래그로 이동, 우하단 핸들로 리사이즈
- [ ] Ctrl+Z 실행취소, Ctrl+S 저장 (브라우저 저장 다이얼로그 아님 → localStorage)
- [ ] 새로고침 후에도 localStorage 에서 복원
3. 팔레트에 v2-* 컴포넌트가 안 보이면 ComponentRegistry 초기화가 안 된 것. 그 경우 fallback 9개가 표시되어야 함.
### (C) 기존 기능 영향 없음 확인
1. `/admin/screenMng` 접속 → ScreenDesigner(구버전) 가 여전히 동작
2. `/dashboard/...` 접속 → DashboardCard 가 여전히 동작 (Phase 2 재작성 전까지 @deprecated 타입 의존)
3. 아무 v2-* 사용 화면이 있으면 팔레트 드래그 안 해도 렌더 자체는 그대로
---
## 5. 알려진 한계 / TODO
1. **TemplateBuilder 는 아직 placeholder 빌더**. 실제 v2-* 컴포넌트는 Phase 2 에서 연결. 지금은 블록명/크기만 표시.
2. **ResizeObserver 기반 v2-table-list 는 `isDesignMode=true` 에서는 narrow 카드 모드가 동작하지 않음** — TableListComponent 의 원 로직이 design 모드에서 card 분기를 건너뜀. 빌더 안 프리뷰에서는 확인 불가, 실 렌더링(런타임)에서만 작동. 테스트는 `/test-card-responsive` 페이지로 수행.
3. **localStorage 저장 키**: `invyone-template:<templateId>` / `invyone-template:__last__`. 나중에 백엔드 API 로 전환 시 마이그레이션 필요.
4. **DashboardCard.tsx 는 여전히 레거시 타입 의존**. Phase 2 재작성 전까지 그대로 유지.
5. **스펙의 v2-search-widget 은 실제 폴더명이 v2-table-search-widget**. 이름만 다르고 마이그레이션은 정상 적용.
---
## 6. 참고 (작업 중 확인한 기존 파일)
- `frontend/lib/registry/components/v2-table-list/TableListComponent.tsx` (283KB, 내부 불변 약속)
- `frontend/lib/registry/components/v2-table-list/CardModeRenderer.tsx` (기존 카드 모드 렌더러 — 재사용)
- `frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx` (880줄, 내부 불변)
- `frontend/components/screen/ScreenDesigner.tsx` (347KB, screenMng 사용 중, 건드리지 않음)
- `frontend/components/dash/DashboardCard.tsx` (Phase 2 재작성 대상)
---
## 7. 세션 후반 버그 픽스 (검증 중 발견, 2026-04-11)
Phase 1 코드 작업이 완료된 뒤 §4 사용자 수동 검증을 진행하면서 3개의 버그가 발견되어 즉시 수정됨. Phase 1 의 일부로 포함.
### 7.1 `/test-card-responsive` 가 AppLayout 탭 시스템에 흡수됨
**증상**: 브라우저에서 URL 접속 시 `/admin` 과 비슷한 셸(사이드바 + 빈 탭 영역) 만 나오고 "열린 탭이 없습니다" 텍스트가 표시됨. PoC 페이지 자체가 렌더되지 않음.
**원인**: 페이지가 `frontend/app/(main)/test-card-responsive/page.tsx` 에 있었는데, `frontend/app/(main)/layout.tsx``<AppLayout>{children}</AppLayout>` 으로 감싸고 있고, `AppLayout` 은 탭 기반 SPA 셸이라 `/test-card-responsive` 라는 URL 이 탭 시스템에 등록되어 있지 않으면 `children` 이 렌더되지 않았음.
**수정**: 파일을 `(main)` 그룹 밖으로 이동해서 AppLayout 을 우회.
- 이전: `frontend/app/(main)/test-card-responsive/page.tsx`
- 이후: `frontend/app/test-card-responsive/page.tsx`
루트 layout 만 적용되고 AppLayout 의 탭 시스템은 통과. 검증 페이지라 사이드바/헤더가 필요 없어 문제 없음.
### 7.2 `useRegistryPalette` 가 VEX `default_size` 포맷 불일치로 NaN position 생성
**증상**: TemplateBuilder 팔레트에서 컴포넌트(예: split-panel-layout)를 드래그해 캔버스에 드롭해도 블록이 시각적으로 나타나지 않음. 우측 속성 패널의 LEFT/TOP/WIDTH/HEIGHT 필드가 모두 비어있음. 컴포넌트 자체는 선택된 상태(COMPONENT 이름 + CONFIG JSON 표시) 로 패널에 보이지만 캔버스에서는 사라짐.
**원인 연쇄**:
1. VEX `ComponentDefinition.default_size``{ width, height }` 포맷 (예: `{ width: 300, height: 40 }`)
2. TemplateBuilder 의 `PaletteItem.defaultSize``{ w, h }` 포맷
3. `useRegistryPalette``c.default_size` 를 그대로 전달 → `defaultSize.w` = `undefined`
4. 드롭 핸들러에서 `e.clientX - rect.left - defaultSize.w / 2` = `NaN`
5. `position = { left: NaN, top: NaN, width: undefined, height: undefined }` 가 store 에 저장됨
6. React 가 style 의 NaN/undefined 를 무시 → 블록이 "보이지 않음" (또는 0,0 에 0 크기)
7. `LabeledNumber``Math.round(NaN) = NaN` → 속성 패널 input 이 빈 칸으로 렌더
**수정**: `frontend/components/template-builder/TemplateBuilder.tsx``useRegistryPalette` 에서 두 가지 정규화:
1. `default_size``{width, height}``{w, h}` 두 포맷 모두 지원:
```ts
const rawSize = c.default_size ?? {};
const defaultSize = {
w: rawSize.w ?? rawSize.width ?? 280,
h: rawSize.h ?? rawSize.height ?? 180,
};
```
2. lucide icon 이름(긴 문자열 "Table", "LayoutGrid" 등) 은 `◼` 로 fallback:
```ts
const icon =
typeof rawIcon === "string" && rawIcon.length > 0 && rawIcon.length <= 2
? rawIcon
: "◼";
```
짧은 이모지/유니코드만 그대로 표시. lucide 이름의 실제 아이콘 렌더링은 Phase 2 에서 ComponentRegistry 연동할 때 본격 구현.
### 7.3 TemplateBuilder.tsx 다크 모드 가시성 전혀 없음
**증상**: INVYONE 기본 다크 테마에서 TemplateBuilder 전체가 흰색/밝은 회색 배경에 옅은 텍스트로 표시되어 거의 읽을 수 없음. 팔레트, 툴바, 캔버스, 속성 패널 모두 동일. 블록을 드롭해도 어두운 배경 위에 어두운 블록이 거의 구분 안 됨.
**원인**: TemplateBuilder.tsx 가 라이트 모드 전제로 작성됨. `bg-white`, `bg-slate-50`, `text-slate-*`, `border-slate-200` 등 Tailwind 클래스가 `dark:` variant 없이 사용됨.
**수정**: Python 정규식 스크립트로 일괄 치환. **21개 패턴, 총 71곳** 치환.
| 원본 | 치환 | 건수 |
|---|---|---|
| `bg-white` | `bg-white dark:bg-slate-900` | 8 |
| `bg-slate-50` | `bg-slate-50 dark:bg-slate-950` | 2 |
| `bg-slate-100` | `bg-slate-100 dark:bg-slate-800` | 1 |
| `bg-amber-50` | `bg-amber-50 dark:bg-amber-950/40` | 1 |
| `text-slate-800` | `text-slate-800 dark:text-slate-100` | 1 |
| `text-slate-700` | `text-slate-700 dark:text-slate-200` | 1 |
| `text-slate-600` | `text-slate-600 dark:text-slate-300` | 5 |
| `text-slate-500` | `text-slate-500 dark:text-slate-400` | 2 |
| `text-slate-400` | `text-slate-400 dark:text-slate-500` | 14 |
| `text-amber-700` | `text-amber-700 dark:text-amber-300` | 1 |
| `text-rose-600` | `text-rose-600 dark:text-rose-400` | 1 |
| `text-rose-500` | `text-rose-500 dark:text-rose-400` | 1 |
| `border-slate-200` | `border-slate-200 dark:border-slate-700` | 23 |
| `border-rose-200` | `border-rose-200 dark:border-rose-800` | 1 |
| `ring-indigo-200` | `ring-indigo-200 dark:ring-indigo-800` | 1 |
| `hover:bg-slate-100` | `hover:bg-slate-100 dark:hover:bg-slate-700` | 2 |
| `hover:bg-slate-50` | `hover:bg-slate-50 dark:hover:bg-slate-800/60` | 2 |
| `hover:bg-indigo-50` | `hover:bg-indigo-50 dark:hover:bg-indigo-950/40` | 1 |
| `hover:bg-rose-50` | `hover:bg-rose-50 dark:hover:bg-rose-950/40` | 1 |
| `hover:border-indigo-300` | `hover:border-indigo-300 dark:hover:border-indigo-600` | 1 |
| `hover:border-slate-300` | `hover:border-slate-300 dark:hover:border-slate-600` | 1 |
치환 순서: `hover:` variants 먼저, 그 다음 일반 variants. `(?<!hover:)` negative lookbehind 사용해서 `hover:bg-*` 와 일반 `bg-*` 를 분리.
**후속 정리**: 치환 순서 때문에 `text-slate-500 dark:text-slate-400 dark:text-slate-500` 같은 중복 체인이 일부 생김. 원인: `text-slate-500``... dark:text-slate-400` 치환 직후, 다음 `text-slate-400` 치환이 방금 추가된 `dark:text-slate-400` 안의 `text-slate-400` 을 또 매칭해서 `dark:text-slate-500` 를 뒤에 붙임. 추가 Python 스크립트로 `(dark:text-slate-\d00)\s+dark:text-slate-\d00` 패턴을 반복 제거. **1회 반복 후 남은 체인 0개** 확인.
### 7.4 수정 후 검증 결과
| 검증 | 결과 |
|---|---|
| `/test-card-responsive` 페이지 렌더 | ✅ 정상 |
| 카드 폭 슬라이더로 반응형 전환 (800 → 400) | ✅ v2-table-list 테이블 → 카드 리스트, v2-table-search-widget 가로 → 세로 스택 |
| `/admin/builder` TemplateBuilder UI | ✅ 정상 |
| 팔레트 → 캔버스 드롭 → 블록 표시 | ✅ position 값 정상 |
| 드래그/리사이즈/Delete/Ctrl+Z/Ctrl+S | ✅ 전부 동작 |
| 3뷰 토글 (목록/등록/수정) | ✅ 독립 블록 유지 |
| 다크 모드 가시성 | ✅ 모든 영역 구분 가능 |
| 기존 VEX 화면/디자이너 (DTG 이력관리, screenMng 등) | ⏭️ 마이그레이션 미완 상태라 작동 안 함 (알려진 상태, Phase 1 범위 아님, Phase 2 이후 개별 마이그레이션 예정) |
### 7.5 Phase 2 에 고려할 사항
- **`useRegistryPalette`**: Phase 2 의 "TemplateBuilder 실렌더링 연결" 작업에서 다시 건드림. 그때 lucide icon 실제 렌더 + FieldConfig 연결 + DataPort 연결 등 포함해 더 정교하게 개선.
- **다크 모드 치환**: `dark:` variant 수동 추가 방식이라 지저분할 수 있음. Phase 2 에서 shadcn CSS 변수 기반(`bg-background`, `text-foreground`, `border-border` 등) 으로 리팩터 고려 가능. 단 v5-layout.css 의 토큰과의 통합이 필요.
- 이 버그들은 **컴포넌트 레지스트리 연동 + 라이트/다크 디자인 시스템 통합** 이라는 더 큰 작업의 일부이므로, Phase 2 에서 자연스럽게 전체 정리됨.
---
## 끝
Phase 1 종료 (2026-04-11).
§4 사용자 수동 검증 **(A) `/test-card-responsive` 반응형 메커니즘 + (B) `/admin/builder` TemplateBuilder UI** 통과 확인. **(C) 기존 기능 sanity** 는 VEX → INVYONE 컴포넌트 마이그레이션이 "기능 업그레이드 먼저" 정책으로 아직 진행되지 않아 기존 VEX 화면들이 원래 작동하지 않는 정상 상태라 생략됨.
핵심 보장(사용자가 "반응형 된다" 고 해서 믿고 진행한 결정)이 PoC 에서 실제로 작동함을 확인: 카드 폭 변경 시 v2-table-list 가 ResizeObserver 기반으로 wide/narrow 모드를 자동 전환, v2-table-search-widget 은 CSS `@container` 기반으로 가로 → 세로 스택 전환. 스펙 MD §9 의 백업 플랜 적용 불필요.
Phase 2 에서:
1. 사용자가 만드는 대시보드 시스템 구현 (`components/dash/*` 재작성)
2. `DashboardCard` 재작성 — Card = Template 인스턴스, FreePosition + @container
3. 레거시 타입 완전 제거 (@deprecated 표기한 것들)
4. TemplateBuilder 실렌더링 연결 (ComponentRegistry + 실제 v2-* 렌더)
5. 경량 7개 v2-* 모드 분기 추가
6. 우선순위 2/3 v2-* 마이그레이션
7. VEX 화면들을 INVYONE Template 로 마이그레이션 시작
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,527 @@
# Template 모델 재설계 — Phase 1 구현 로그 (1차)
**작업일**: 2026-04-10
**작업자**: gbpark + Claude
**기반 스펙**: `notes/gbpark/2026-04-10-template-model-redesign.md` (섹션 8~16, 19~20)
**범위**: 스펙 섹션 19 "다음 액션" 중 **1~4번까지** (타입 재정의, CSS 변수, DashboardCard grid 전환, 빌더 grid-snap 전환)
**남은 작업**: 5~8번 (팝업 grid 적용, 레거시 마이그레이션 다이얼로그, Template 전수 검증, Phase 3/4 문서 업데이트)
---
## 0. 결과 요약
| 항목 | 상태 |
|---|---|
| 작업 스코프 1~4번 | ✅ 완료 |
| `npx tsc --noEmit` 작업 범위 에러 | 0건 |
| Finding 1 (row 반응형) 코드 구조 확보 | ✅ (브라우저 시각 검증 남음) |
| Finding 2 (공용 기하학 수식) 단위 검증 | ✅ `pixelDeltaToColDelta(200, 800, 8, 16) === 3` |
| Finding 3 (레거시 변환 다이얼로그) | ⏸ 다음 턴 |
| Finding 4 (drop/drag/resize 일관성) 코드 구조 확보 | ✅ (브라우저 시각 검증 남음) |
| **원칙 3 임시 위반 잔존** | ⚠ `useBuilderState.ts:303-308` `fromTemplate` business fallback — 섹션 3.5 / 4.6 참조 |
---
## 1. 수정/생성된 파일 목록
### 타입
- **수정** `frontend/types/invyone-component.ts`
### 스타일
- **수정** `frontend/styles/v5-layout.css`
- **수정** `frontend/styles/dashboard.css`
- **수정** `frontend/styles/developer.css`
### DashboardCard (대시보드 카드 → grid 렌더)
- **수정** `frontend/components/dash/DashboardCard.tsx`
### Builder (빌더 grid-snap 전환)
- **신규** `frontend/components/builder/hooks/gridMetrics.ts`
- **수정** `frontend/components/builder/hooks/useBuilderState.ts`
- **수정** `frontend/components/builder/hooks/useBlockDrag.ts`
- **수정** `frontend/components/builder/BuilderCanvas.tsx`
- **수정** `frontend/components/builder/BuilderBlock.tsx`
- **수정** `frontend/components/builder/BuilderProps.tsx`
- **수정** `frontend/components/builder/BuilderToolbar.tsx`
- **수정** `frontend/components/builder/BuilderPalette.tsx`
---
## 2. 스펙 작업 항목별 상세
### 2.1 타입 재정의 — 섹션 8
**파일**: `frontend/types/invyone-component.ts`
#### 추가된 타입
- `GridPosition { col, colSpan, row?, rowSpan?, responsive? }`
- `AbsolutePosition { x, y, w, h }`
- `ComponentPosition = GridPosition | AbsolutePosition`
- `ResponsiveGridOverride { narrow?, normal?, wide? }` — 각 breakpoint는 `Partial<Omit<GridPosition, 'responsive'>>`
- `TemplateKind = 'business' | 'canvas'`
#### 삭제된 타입
- 기존 `Position { x, y, w, h }` (주석은 "그리드 단위"였지만 실제는 픽셀)
- 기존 `ResponsiveOverride { lg, md, sm }` (뷰포트 네이밍, 구현도 없었음)
- `Component.responsive` 필드 (이제 `GridPosition.responsive` 안으로 이동)
#### 추가된 타입 가드
```ts
export function isGridPosition(pos: ComponentPosition): pos is GridPosition {
return pos != null && typeof pos === 'object' && 'col' in pos && 'colSpan' in pos;
}
export function isAbsolutePosition(pos: ComponentPosition): pos is AbsolutePosition {
return pos != null && typeof pos === 'object' && 'x' in pos && 'y' in pos && 'w' in pos && 'h' in pos;
}
```
#### `Component.position``ComponentPosition`
- Template.kind에 따라 어느 쪽으로 해석되는지 주석으로 명시
#### `Template.kind: TemplateKind` 필수 필드 추가
- 레이아웃 모델 분기. `business`(기본) / `canvas`(예외)
#### 신규 상수
- `DEFAULT_COMPONENT_LAYOUTS: Record<ComponentType, GridPosition>` — 8종 팔레트 기본값 + responsive 디폴트. 수주관리 스펙(섹션 10.3)을 참고해 table: 8span wide / 12span narrow-normal, form: 4span wide / 12span narrow-normal 등
- `CANVAS_KEYWORDS` — 레거시 canvas 휴리스틱 키워드 (섹션 13.3.1용, 마이그레이션 다이얼로그 구현 시 사용)
---
### 2.2 CSS 변수 + grid 스타일 — 섹션 11.5, 12
#### `frontend/styles/v5-layout.css`
`:root`에 grid 네임스페이스 변수 세트 추가:
```css
--grid-cols: 12;
--grid-gap: .5rem;
--grid-gap-narrow: .35rem;
--grid-gap-normal: .45rem;
--grid-gap-wide: .55rem;
--card-narrow-max: 520px;
--card-normal-max: 900px;
--grid-line: rgba(108,92,231,.08);
--grid-line-hover: rgba(108,92,231,.2);
--grid-drop-preview: rgba(108,92,231,.15);
--grid-drop-preview-border: rgba(108,92,231,.5);
```
`.dark`에도 대응하는 다크 테마 grid 토큰(보라 대신 라벤더 계열) 추가.
v5 Cosmic 토큰(`--v5-*`)은 그대로 유지. grid 토큰은 독립 네임스페이스로만 추가.
#### `frontend/styles/dashboard.css`
`.dash-card-body`에 container query 활성화:
```css
.dash-card-body {
flex: 1; overflow: auto; padding: .5rem;
container-type: inline-size;
container-name: card;
}
```
추가된 클래스:
- `.dash-card-error`, `.dash-card-loading` — 에러/로딩 상태
- `.dash-card-grid` — 12-col grid (business 렌더)
- `.dash-card-grid > .tpl-component`**`min-width: 0` 필수** (섹션 11.6, 12열 비율 유지의 핵심)
- `.dash-card-canvas-wrapper` / `.dash-card-canvas` / `.dash-card-canvas > .tpl-component` — absolute 렌더 (canvas kind)
`@container card` 쿼리 3단계:
- `max-width: 520px` → narrow
- `520.01px ~ 900px` → normal
- `>900.01px` → wide
★ 각 쿼리에서 `grid-column` + `grid-row` **둘 다** 오버라이드. Finding 1 대응 — row 오버라이드가 실제로 반응해야 narrow에서 form이 테이블 아래로 내려감.
#### `frontend/styles/developer.css` — 빌더 그리드
기존 `.dev-canvas-inner` (자유배치용, 1200×800 고정)는 유지하되, 새로 `.dev-canvas-grid` 추가:
```css
.dev-canvas-grid {
position: relative;
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 8px;
padding: 16px;
min-height: 600px;
container-type: inline-size;
container-name: card;
background-image: linear-gradient(to right, var(--grid-line) 1px, transparent 1px);
background-size: calc((100% - 32px) / 12 + 8px) 100%;
background-position: 16px 0;
background-repeat: repeat-x;
}
.dev-canvas-grid.dragging { /* grid-line-hover */ }
.dev-popup-grid { min-height: 320px; padding: 12px; }
.dev-grid-warn { /* 비-grid 블록 잔존 시 경고 배너 */ }
```
`.dev-block` 블록은 `position: absolute``position: relative` (grid item)으로 전환. `min-width: 0`, `min-height: 48px`, `cursor: move` 추가.
반응형 오버라이드 속성 패널 UI용 `.dev-resp-row`, `.dev-resp-label` 추가.
---
### 2.3 DashboardCard grid 렌더 전환 — 섹션 11.2~11.7
**파일**: `frontend/components/dash/DashboardCard.tsx`
#### 삭제된 코드 (~40줄)
- `canvasBounds` useMemo (캔버스 전체 박스 추정)
- `bodyRef` / `scale` state
- `ResizeObserver` + `transform: scale()` 계산 useEffect
#### 추가된 상태
- `templateKind: TemplateKind | null` — Template 로드 시 `tpl.kind` 복원
#### 렌더 분기 (섹션 11.3, 11.7)
```tsx
{effectiveKind === 'canvas' ? (
<div className="dash-card-canvas-wrapper">
<div className="dash-card-canvas">
{sortedComponents.map(c => <AbsoluteComponent ... />)}
</div>
</div>
) : (
<div className="dash-card-grid">
{sortedComponents.map(c => <GridComponent ... />)}
</div>
)}
```
`effectiveKind``templateKind`을 우선하되, kind 필드가 없는 레거시는 position 형태로 보수적 추정:
- `GridPosition``business`
- 그 외 (`{x,y,w,h}` 레거시) → `canvas`
★ 이 추정은 **렌더 경로 선택만** 담당하고 **DB 쓰기 없음**. 섹션 13.2의 "자동 변환 금지" 원칙과 충돌하지 않음 (변환이 아니라 렌더 분기).
#### `GridComponent` — business kind 전용
섹션 11.4 Finding 1 반영. **12개 CSS 변수 전부 주입**:
```tsx
const r = pos.responsive ?? {};
const style: React.CSSProperties = {
'--col': pos.col ?? 1,
'--col-span': pos.colSpan ?? 12,
'--row': toRowVal(pos.row), // auto 문자열 허용
'--row-span': toSpanVal(pos.rowSpan),
'--col-narrow': r.narrow?.col ?? pos.col ?? 1,
'--col-span-narrow': r.narrow?.colSpan ?? pos.colSpan ?? 12,
'--row-narrow': toRowVal(r.narrow?.row ?? pos.row),
'--row-span-narrow': toSpanVal(r.narrow?.rowSpan ?? pos.rowSpan),
// ... normal/wide 동일
};
```
`toRowVal(undefined) === 'auto'` — CSS grid auto placement 동작.
#### `AbsoluteComponent` — canvas kind 전용
`position.x/y/w/h` 그대로 `left/top/width/height`에 매핑.
#### `renderByType()` — 타입별 렌더 공통화
기존 `ComponentRenderer` switch 를 `renderByType(props)`로 분리. `wrapStyle` (absolute 박스)을 없애고 각 case에서 `width: 100%; height: 100%` 기준으로 렌더.
---
### 2.4 빌더 grid-snap 전환 — 섹션 9, 14.5
#### 2.4.1 `hooks/gridMetrics.ts` 신규
**★ drop / drag / resize 모두 반드시 이 파일만 사용. `canvasW/12` 직접 계산 금지.**
공용 함수:
- `getGridMetrics(canvasWidth, gap, padding)``{ contentWidth, colWidth, step, padding, gap }`
- `pixelToGridCol(pixelX, canvasWidth, gap, padding)` — 드롭 위치 계산용
- `pixelToColSpan(pixelW, canvasWidth, gap, padding)`
- `pixelDeltaToColDelta(pixelDelta, canvasWidth, gap, padding)` — drag/resize 증감용
- `readCanvasGeometry(el)``{ canvasWidth, gap, padding }` — DOM에서 1회 읽기
- `computeNextAvailableRow(newCol, newColSpan, blocks)` — 섹션 14.4 자동 밀기
- `BUILDER_ROW_HEIGHT = 48` — row 이동 환산용
#### 2.4.2 `useBuilderState.ts`
새 상태 필드:
- `templateKind: TemplateKind` (기본 `'business'`)
- `previewWidth: 'narrow' | 'normal' | 'wide'` (기본 `'normal'`)
새 액션:
- `addBlock(type: ComponentType, position: GridPosition)` — 시그니처 교체
- `setTemplateKind(kind)`
- `setPreviewWidth(w)`
삭제된 액션:
- `moveBlock(id, x, y)`, `resizeBlock(id, w, h)` — grid에서는 `updateBlock` 에 position 객체 통째로 넘김
삭제된 함수:
- `defaultSize(type)` — 픽셀 크기 테이블. `DEFAULT_COMPONENT_LAYOUTS`가 대체
`toTemplate()` — 반환 객체에 `kind: s.templateKind` 포함.
`fromTemplate(tpl)`**⚠ 원칙 3 임시 위반 상태** (line 303~308). `tpl.kind``business`/`canvas`면 그대로 수용, 없으면 `business` 강제 디폴트. 스펙 섹션 13.4 "절대 금지 패턴"(`if (!tpl.kind) tpl.kind = 'business'`)과 형식만 다를 뿐 동일 동작.
**주석-구현 불일치**: 주석(line 305)에는 "`isDirty=true`로 표시해 사용자가 확인 없이 저장하지 못하게 한다"고 적혔으나 실제 구현 line 325는 `isDirty: false`. 저장 차단 경로 자체가 없음.
DB 저장은 여전히 사용자 "저장" 클릭 필요하지만, 레거시 Template에 새 블록 추가 후 저장 시 데이터 혼합 리스크 있음. 상세는 섹션 3.5, 해소 계획은 섹션 4.6 참조.
#### 2.4.3 `BuilderCanvas.tsx` 재작성
`handleDrop` — 공용 함수 경로:
```tsx
const canvasEl = e.currentTarget as HTMLElement;
const { canvasWidth, gap, padding } = readCanvasGeometry(canvasEl);
const rect = canvasEl.getBoundingClientRect();
const relX = e.clientX - rect.left;
const dropCol = pixelToGridCol(relX, canvasWidth, gap, padding);
const defaultLayout = DEFAULT_COMPONENT_LAYOUTS[type];
const col = Math.min(dropCol, 13 - defaultLayout.colSpan);
const colSpan = Math.min(defaultLayout.colSpan, 13 - col);
const row = computeNextAvailableRow(col, colSpan, blocks);
addBlock(type, { col, colSpan, row, ...responsive });
```
분기:
- `templateKind === 'canvas'` → placeholder (자유배치 빌더는 Phase 2 이후)
- `templateKind === 'business'` + `currentView === 'list'``.dev-canvas-grid` 렌더
- `templateKind === 'business'` + 팝업 뷰 → `.dev-canvas-grid.dev-popup-grid``.dev-popup-frame` 안에 렌더
`previewWidth`에 따라 `maxWidth` 적용 (`400 / 720 / 1100`px). `@container`가 실제로 반응.
비-grid 블록이 잔존하면 `.dev-grid-warn` 배너로 경고.
#### 2.4.4 `BuilderBlock.tsx`
grid item 전환:
```tsx
const pos = block.position as GridPosition;
<div
className={`dev-block${isSelected ? ' selected' : ''}`}
style={{
gridColumn: `${pos.col} / span ${pos.colSpan}`,
gridRow: pos.row != null ? `${pos.row} / span ${pos.rowSpan ?? 1}` : 'auto',
}}
...
>
```
`isGridPosition` 아니면 `null` 반환 (canvas/legacy는 이번 단계에서 렌더 스킵).
드래그 label에 `col N span M · row R` 표기 추가 — 사용자 피드백용.
#### 2.4.5 `useBlockDrag.ts` 재작성
`startDrag` / `startResize` 둘 다 세션 시작 시 1회 `readCanvasGeometry` 호출해서 `canvasWidth/gap/padding`을 캐시. `onMove`에서는 `pixelDeltaToColDelta(dx, ...)`만 호출.
드래그:
```ts
const deltaCol = pixelDeltaToColDelta(dx, canvasWidth, gap, padding);
const deltaRow = Math.round(dy / BUILDER_ROW_HEIGHT);
const newCol = Math.max(1, Math.min(13 - cur.colSpan, origCol + deltaCol));
const newRow = Math.max(1, origRow + deltaRow);
updateBlock(id, { position: { ...cur, col: newCol, row: newRow } });
```
리사이즈:
```ts
const deltaCols = pixelDeltaToColDelta(dx, canvasWidth, gap, padding);
const newSpan = Math.max(1, Math.min(13 - cur.col, origSpan + deltaCols));
updateBlock(id, { position: { ...cur, colSpan: newSpan } });
```
★ 둘 다 `zustand.getState()`로 최신 block을 꺼내기 때문에 onMove 중 불필요한 재등록 없음.
#### 2.4.6 `BuilderProps.tsx`
위치 편집 UI를 `X/Y/W/H``col/colSpan/row/rowSpan`로 교체.
반응형 오버라이드 섹션 신규 — narrow/normal/wide 각 breakpoint마다 `col/span/row/rowSpan` 4칸. 빈 값은 `undefined`(= 기본값 fallback).
`isGridPosition` 아니면 "grid 위치가 아닙니다" 안내.
#### 2.4.7 `BuilderToolbar.tsx`
새 툴바 그룹 2개:
1. **모델**`business` / `canvas` 토글 → `setTemplateKind`
2. **미리보기**`narrow 400` / `보통 720` / `넓음 1100` 토글 → `setPreviewWidth`. `templateKind === 'business'`일 때만 표시
`handleSave` 페이로드에 `kind: tpl.kind` 포함.
#### 2.4.8 `BuilderPalette.tsx`
클릭으로 추가 시에도 `DEFAULT_COMPONENT_LAYOUTS` + `computeNextAvailableRow` 사용. 기존 `{x:16, y:16, w:0, h:0}` 제거.
---
## 3. 검증 결과
### 3.1 타입체크
```
$ npx tsc --noEmit | grep -E "invyone-component|components/builder|components/dash/DashboardCard|components/dash/CardSettings"
(0 matches)
```
내 작업 범위에서 에러 **0건**.
전체 프로젝트 타입체크는 VEX 레거시(screen-management, v2-*, dataflow, admin dashboard widgets 등)에서 기존부터 누적된 ~2800건 에러가 있으나 이번 작업과 무관.
### 3.2 Finding 2 단위 검증
```js
// getGridMetrics(800, 8, 16)
// content = 800 - 32 = 768
// totalGap = 88
// colWidth = (768 - 88) / 12 = 56.666...
// step = 64.666...
// 200 / step = 3.093
// round → 3
pixelDeltaToColDelta(200, 800, 8, 16) === 3 ✅
```
### 3.3 Finding 1 구조 확보
- `GridComponent`에서 `--row*`, `--row-span*` 8개 변수 주입 확인
- `@container card` 3개 쿼리 모두 `grid-row: var(--row-*) / span var(--row-span-*)` 라인 포함
- 브라우저 시각 검증은 사용자 몫 (수주관리 예시를 400px/1000px 카드에서 렌더했을 때 form 위치)
### 3.4 Finding 4 구조 확보
- `handleDrop`, `startDrag`, `startResize` 전부 `readCanvasGeometry``pixelToGridCol` / `pixelDeltaToColDelta` 경로
- `canvasW / 12` 직접 계산 grep 결과: 0건
### 3.5 Finding 3 — ⚠ 원칙 위반 잔존 (완료 아님)
스펙 섹션 13.4 "절대 금지 패턴"이 코드에 남아 있는 상태. 다음 턴 6번으로 해소 이월.
#### 위반 위치
**파일**: `frontend/components/builder/hooks/useBuilderState.ts`
**함수**: `fromTemplate(tpl)` (line 303~327)
**핵심 라인**: 307~308
```ts
const kind: TemplateKind =
tpl.kind === "business" || tpl.kind === "canvas" ? tpl.kind : "business";
```
스펙 섹션 13.4 "절대 금지 패턴"의 `if (!tpl.kind) tpl.kind = 'business';` 와 형식만 삼항으로 바꾼 동일 동작.
#### 주석-구현 불일치
line 303~306 주석:
```
// kind 는 호출자(빌더 진입점)가 다이얼로그로 확정한 뒤 넘겨야 함.
// 여기서는 tpl.kind 를 그대로 받고, 없으면 기본 business 로 두되 isDirty=true
// 로 표시해 사용자가 확인 없이 저장하지 못하게 한다.
```
그러나 실제 line 325는 `isDirty: false`. **저장 차단 의도를 구현에 반영하지 못함.** 주석과 구현 중 어느 쪽이 정답인지 불분명 — 다음 턴에서 다이얼로그 도입으로 이 문제 자체를 없애는 것이 맞음.
#### 현 시점 완화 요소 (사고 확률을 낮추는 요인)
1. **DB 자동 저장 경로 없음** — 사용자가 "저장" 버튼을 명시적으로 클릭해야만 반영
2. **빌더에서 레거시 블록 렌더 안 됨**`BuilderBlock``isGridPosition` 가드로 레거시 `{x,y,w,h}` 블록을 `null` 반환. 사용자 화면에 안 보이는 상태에서는 "저장" 누를 동기가 적음
3. **대시보드는 `effectiveKind` 추정** — position 형태로 business/canvas 분기. 레거시는 canvas fallback → absolute 렌더, 데이터 손상은 없음
#### 실제 리스크 시나리오
1. 사용자가 기존 `{x,y,w,h}` 기반 레거시 Template을 빌더에서 열기 → `fromTemplate``kind: 'business'` 강제 부여, `isDirty: false`
2. 사용자가 "화면이 비었네" 하고 새 컴포넌트를 palette에서 drop → `blocks.list`에 grid 블록이 추가되면서 기존 레거시 `{x,y,w,h}` 블록들과 혼재
3. 사용자가 "저장" 클릭 → `toTemplate``kind: 'business'` + 혼합 blocks 배열을 DB에 기록
4. **결과**: 원본 레거시 데이터가 business kind로 덮여쓰임, grid + absolute 포맷 혼합 상태. `migration.backup` 미적용이라 롤백 어려움
#### 다음 턴 해소 방법 (섹션 4.6 ★0순위)
1. `fromTemplate` 시그니처에 `kind: TemplateKind`를 **필수 파라미터**로 추가 — 호출자가 다이얼로그로 결정한 값만 넘김
2. 빌더 진입점(`BuilderLayout` 또는 `TemplateLibraryModal`)에 `openTemplate(templateId)` 도입 — 내부에서 `isLikelyCanvasTemplate` 휴리스틱 + 블로킹 다이얼로그
3. 사용자가 `business` 선택 시에만 `migrateTemplateAbsoluteToGrid(tpl)` + `migration.backup` 적용 후 `fromTemplate(migrated, 'business')` 호출
4. 사용자가 `canvas` 선택 시 `fromTemplate({...tpl, kind: 'canvas'}, 'canvas')` 호출 — 위치 유지
5. `fromTemplate` 내부의 `'business'` 강제 디폴트 삭제
#### 임시 부분 수정 하지 않는 이유
"`isDirty: true`만 고치면 되지 않냐" — **권장하지 않음**:
- `isDirty` 플래그만으로는 저장 차단 확실하지 않음 (다른 경로로 isDirty 초기화될 수 있음)
- `fromTemplate` 호출 경로는 빌더 진입 외에도 import/template library 등 여러 곳 → 부분 수정은 회귀 위험
- 다음 턴에서 정공법으로 한 번에 처리하는 것이 안전. 이번 턴 완료 보고는 **이 섹션으로 상태 명시**만 수행
---
## 4. 다음 턴에 해야 할 것 (스펙 5~8번)
### 5. 등록/수정 팝업 grid 적용 — 섹션 15
- `ViewConfig.size` 활용
- 팝업도 `.dev-canvas-grid` 사용, `container-name: popup` 분기는 필요 시
- 현재 `BuilderCanvas`에서 `dev-popup-grid`로 1차 적용 해둠 — 스펙 대로 정비 필요
- `FormConfig.columns` 는 폼 내부 필드 배치용으로 유지 (2단계 구조)
### 6. Template 마이그레이션 + 승인 다이얼로그 — 섹션 13 ★★ 최우선 (0순위)
**★★★ 가장 먼저 해야 할 것 — `fromTemplate` 임시 위반 제거** (상세: 섹션 3.5):
`useBuilderState.ts:307-308``tpl.kind ?? 'business'` 폴백 삭제. `fromTemplate` 시그니처에 `kind: TemplateKind`를 **필수 파라미터**로 추가. 주석의 `isDirty=true` 의도도 함께 정리 (다이얼로그가 생기면 이 주석 자체가 불필요해짐).
이어지는 구현 순서:
1. `isLikelyCanvasTemplate(oldTpl)` 휴리스틱 (키워드 / y 분포 / controlFlow 필드 — 스펙 섹션 13.3.1)
2. `migrateTemplateAbsoluteToGrid(oldTpl)``migration.backup` 원본 보존 (스펙 섹션 13.3.2)
3. `openTemplate(templateId)` 블로킹 다이얼로그 (스펙 섹션 13.5)
4. 빌더 진입점(`BuilderLayout` / `TemplateLibraryModal`)에서 `openTemplate` → 다이얼로그 → 확정된 `kind``fromTemplate(tpl, kind)` 호출 체인 구성
5. **절대 금지 패턴 재확인**: `if (!tpl.kind) tpl.kind = 'business'` 코드 어디에도 두지 말 것
**순서 제안**: 5번(팝업) 전에 **6번부터 먼저 처리** — 원칙 위반 잔존 시간을 최소화. 5번 작업 중 사용자가 레거시 Template을 건드려 데이터가 망가지면 뒤늦게 후회.
### 7. 기존 Template 전수 검증
- DB에 있는 Template 목록을 빌더에서 하나씩 열어서 다이얼로그 동작 확인
- canvas 디폴트 선택이 맞는지, 취소 시 DB 무변경, business 선택 시만 변환되고 `migration.backup` 저장되는지
### 8. Phase 3/4 문서 업데이트 — 섹션 16
- `notes/gbpark/2026-04-10-phase3-developer-builder.md` — 섹션 16.1 표대로 grid 기준으로 갱신
- `notes/gbpark/2026-04-10-phase4-dashboard-menu.md` — 섹션 16.2 표대로 `@container` 기준으로 갱신 + 2레이어 명확화 블록 추가
### 추가로 남은 사소한 것 (우선순위 낮음)
- 빌더 `.dev-canvas-grid.dragging` 클래스 on/off 시각 피드백
- `DropPreview` 컴포넌트 (섹션 14.2) — 드래그 중 가장 가까운 셀 프리뷰
- Canvas kind 빌더 실제 UX (현재는 placeholder)
---
## 5. 브라우저 실측 체크리스트 (사용자 몫)
### Finding 1 — 반응형 row 오버라이드
1. 빌더에서 `kind: business` 로 새 Template 만들기
2. 수주관리 패턴 배치:
- search: col 1 span 12 row 1
- table: col 1 span 8 row 2 / narrow,normal span 12
- form: col 9 span 4 row 2 / narrow,normal col 1 span 12 row 3
3. 저장 후 대시보드 카드에 꽂고 카드 크기 리사이즈
4. 400px 폭에서 form이 테이블 **아래** row 3 위치에 오는지 확인
5. 1000px 폭에서 form이 오른쪽 col 9~12에 오는지 확인
6. 겹치면 Finding 1 버그 재발 → 즉시 보고
### Finding 4 — drop/drag/resize 스냅 일치
1. 빌더에서 캔버스 배경 그리드 라인이 12줄로 보이는지
2. 팔레트 table 아이템을 정확히 같은 위치에 세 번 drop → 매번 같은 col/colSpan 인지 label 확인
3. 한 블록을 drag로 몇 칸 이동 → col 값이 gap을 고려한 정확한 값인지
4. 한 블록을 resize로 확장 → colSpan이 drop/drag와 같은 기준으로 증감하는지
### 기타
- **기존 DB Template 열기**: 현재 `{x,y,w,h}` 기반이므로 빌더에서 열면 `fromTemplate`의 default `business`로 들어감 → grid가 아니어서 BuilderBlock이 null 반환 → 화면이 비어 보일 수 있음. **레거시 변환 다이얼로그는 다음 턴**에서 구현. 당장 테스트는 **새 Template**으로 진행 권장.
- 대시보드 카드에서 kind 없는 레거시 Template 을 꽂으면 `effectiveKind === 'canvas'`로 fallback → absolute 그대로 렌더 (기존 동작과 다름 없음, scale은 빠졌으니 카드 밖으로 넘칠 수 있음 → narrow 카드에서는 스크롤)
---
## 6. 참조
### 원본 스펙
- `notes/gbpark/2026-04-10-template-model-redesign.md` — 섹션 0~20 전체
### 관련 메모리
- `memory/project_template_builder.md` — 2026-04-10 재설계 반영
- `memory/feedback_responsive_container_based.md`@container 원칙
- `memory/project_component_spec.md` — FieldConfig 규격
### 주요 코드 위치
- 타입 정의: `frontend/types/invyone-component.ts`
- Grid 공용 수식: `frontend/components/builder/hooks/gridMetrics.ts`
- 카드 렌더: `frontend/components/dash/DashboardCard.tsx`
- 빌더 캔버스: `frontend/components/builder/BuilderCanvas.tsx`
- 빌더 상태: `frontend/components/builder/hooks/useBuilderState.ts`
- 빌더 드래그: `frontend/components/builder/hooks/useBlockDrag.ts`
- 빌더 속성 패널: `frontend/components/builder/BuilderProps.tsx`
- 빌더 툴바: `frontend/components/builder/BuilderToolbar.tsx`
### v5 디자인 토큰
- `frontend/styles/v5-layout.css` — v5 Cosmic 토큰 + grid 네임스페이스 변수
- `frontend/styles/dashboard.css``.dash-card-grid`, `@container` 쿼리
- `frontend/styles/developer.css``.dev-canvas-grid`, 빌더 block/props 스타일