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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+22
@@ -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",
|
||||
|
||||
+56
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ===== */
|
||||
|
||||
@@ -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 스타일
|
||||
Reference in New Issue
Block a user