From 2c0a97f2ba004baf09ce58aa215446261a759bd2 Mon Sep 17 00:00:00 2001 From: gbpark Date: Sat, 11 Apr 2026 03:08:06 +0900 Subject: [PATCH] =?UTF-8?q?Phase=201:=20INVYONE=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EC=97=94=EC=A7=84=20=ED=86=A0=EB=8C=80=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- frontend/app/(main)/admin/builder/page.tsx | 8 +- frontend/app/test-card-responsive/page.tsx | 224 ++ frontend/components/builder/BuilderBlock.tsx | 168 -- frontend/components/builder/BuilderCanvas.tsx | 91 - frontend/components/builder/BuilderLayout.tsx | 61 - .../components/builder/BuilderPalette.tsx | 90 - frontend/components/builder/BuilderProps.tsx | 98 - .../components/builder/BuilderToolbar.tsx | 151 -- .../components/builder/hooks/useBlockDrag.ts | 88 - .../builder/hooks/useBuilderState.ts | 362 ---- .../components/builder/props/ButtonProps.tsx | 119 - .../builder/props/FieldListEditor.tsx | 97 - .../components/builder/props/FormProps.tsx | 41 - .../components/builder/props/SearchProps.tsx | 46 - .../components/builder/props/TableProps.tsx | 64 - .../components/builder/props/TitleProps.tsx | 52 - frontend/components/dash/DashboardCanvas.tsx | 34 +- frontend/components/dash/DashboardCard.tsx | 571 ++++- frontend/components/dash/DashboardLayout.tsx | 3 +- frontend/components/layout/AppLayout.tsx | 5 +- .../components/screen/ScreenDesigner_new.tsx | 678 ------ .../template-builder/TemplateBuilder.tsx | 805 +++++++ .../store/templateBuilderStore.ts | 460 ++++ .../components/v2-aggregation-widget/index.ts | 3 +- .../components/v2-button-primary/index.ts | 3 +- .../components/v2-card-display/index.ts | 3 +- .../lib/registry/components/v2-date/index.ts | 3 +- .../lib/registry/components/v2-input/index.ts | 3 +- .../registry/components/v2-select/index.ts | 3 +- .../TableListContainerWrapper.tsx | 70 + .../components/v2-table-list/index.ts | 4 +- .../TableSearchContainerWrapper.tsx | 22 + .../v2-table-search-widget/index.tsx | 4 +- .../table-search-widget-responsive.css | 56 + .../components/v2-text-display/index.ts | 3 +- .../lib/registry/hoc/withContainerQuery.tsx | 35 + frontend/styles/dashboard.css | 136 +- frontend/styles/developer.css | 68 +- frontend/styles/v5-layout.css | 19 + frontend/types/invyone-component.ts | 389 +++- .../2026-04-10-card-engine-final-spec.md | 946 ++++++++ .../2026-04-10-card-engine-phase1-log.md | 323 +++ .../2026-04-10-template-model-redesign.md | 1913 +++++++++++++++++ ...2026-04-10-template-redesign-phase1-log.md | 527 +++++ 44 files changed, 6513 insertions(+), 2336 deletions(-) create mode 100644 frontend/app/test-card-responsive/page.tsx delete mode 100644 frontend/components/builder/BuilderBlock.tsx delete mode 100644 frontend/components/builder/BuilderCanvas.tsx delete mode 100644 frontend/components/builder/BuilderLayout.tsx delete mode 100644 frontend/components/builder/BuilderPalette.tsx delete mode 100644 frontend/components/builder/BuilderProps.tsx delete mode 100644 frontend/components/builder/BuilderToolbar.tsx delete mode 100644 frontend/components/builder/hooks/useBlockDrag.ts delete mode 100644 frontend/components/builder/hooks/useBuilderState.ts delete mode 100644 frontend/components/builder/props/ButtonProps.tsx delete mode 100644 frontend/components/builder/props/FieldListEditor.tsx delete mode 100644 frontend/components/builder/props/FormProps.tsx delete mode 100644 frontend/components/builder/props/SearchProps.tsx delete mode 100644 frontend/components/builder/props/TableProps.tsx delete mode 100644 frontend/components/builder/props/TitleProps.tsx delete mode 100644 frontend/components/screen/ScreenDesigner_new.tsx create mode 100644 frontend/components/template-builder/TemplateBuilder.tsx create mode 100644 frontend/components/template-builder/store/templateBuilderStore.ts create mode 100644 frontend/lib/registry/components/v2-table-list/TableListContainerWrapper.tsx create mode 100644 frontend/lib/registry/components/v2-table-search-widget/TableSearchContainerWrapper.tsx create mode 100644 frontend/lib/registry/components/v2-table-search-widget/table-search-widget-responsive.css create mode 100644 frontend/lib/registry/hoc/withContainerQuery.tsx create mode 100644 notes/gbpark/2026-04-10-card-engine-final-spec.md create mode 100644 notes/gbpark/2026-04-10-card-engine-phase1-log.md create mode 100644 notes/gbpark/2026-04-10-template-model-redesign.md create mode 100644 notes/gbpark/2026-04-10-template-redesign-phase1-log.md diff --git a/frontend/app/(main)/admin/builder/page.tsx b/frontend/app/(main)/admin/builder/page.tsx index c721df91..630567e7 100644 --- a/frontend/app/(main)/admin/builder/page.tsx +++ b/frontend/app/(main)/admin/builder/page.tsx @@ -1,7 +1,11 @@ "use client"; -import BuilderLayout from "@/components/builder/BuilderLayout"; +import TemplateBuilder from "@/components/template-builder/TemplateBuilder"; export default function BuilderPage() { - return ; + return ( +
+ +
+ ); } diff --git a/frontend/app/test-card-responsive/page.tsx b/frontend/app/test-card-responsive/page.tsx new file mode 100644 index 00000000..c9a7fc4b --- /dev/null +++ b/frontend/app/test-card-responsive/page.tsx @@ -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(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 ( +
+
+

Phase 1 PoC — 카드 폭 반응형 검증

+

+ 스펙: notes/gbpark/2026-04-10-card-engine-final-spec.md §2, §10 +

+
+ +
+ + setWidth(Number(e.target.value))} + className="flex-1" + /> + {width}px + + 감지된 모드: {detectedMode} + +
+ {[320, 520, 800, 1200].map((w) => ( + + ))} +
+
+ +
+
+ 카드 시뮬레이션 (width: {width}px) +
+ + {/* ── 1. v2-text-display (경량, 항상 동일) ── */} +
수주관리
+ + {/* ── 2. v2-aggregation-widget (경량, container-type 만 부착) ── */} +
+ {[ + { label: "전체", v: "128" }, + { label: "진행", v: "42" }, + { label: "완료", v: "74" }, + { label: "대기", v: "12" }, + ].map((k) => ( +
+
{k.label}
+
{k.v}
+
+ ))} +
+ + {/* ── 3. v2-table-search-widget (CSS @container 완전 마이그레이션) ── */} +
+
+
+ + + + +
+
+
128건
+ +
+
+
+ + {/* ── 4. v2-button-primary (경량) ── */} +
+ + + +
+ + {/* ── 5. v2-table-list (ResizeObserver 완전 마이그레이션) ── */} +
+
+ v2-table-list + + data-v2-table-list-mode={detectedMode} + +
+ {detectedMode === "wide" ? ( + + + + + + + + + + + + {[ + { 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) => ( + + + + + + + + ))} + +
#수주번호고객상태금액
{i + 1}{row.no}{row.cust}{row.status}{row.amt.toLocaleString()}
+ ) : ( +
+ {[ + { 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) => ( +
+
+ {row.no} + {row.status} +
+
{row.cust}
+
{row.amt.toLocaleString()} 원
+
+ ))} +
+ )} +
+
+ +
+
✅ 검증 포인트
+
    +
  • + 카드 폭을 800 → 400 으로 줄이면{" "} + v2-table-list 가 테이블 → 카드 리스트로 전환 (ResizeObserver 기반). +
  • +
  • + 같은 조건에서 v2-table-search-widget 의 필터/버튼이 가로 → 세로 스택으로 재배열 (CSS @container 기반). +
  • +
  • + 나머지 컴포넌트(text-display, aggregation-widget, button-primary)는 container-type: inline-size 만 부착된 상태. + 모드 분기는 Phase 2 에서 개별 재작성. +
  • +
+
+
+ ); +} diff --git a/frontend/components/builder/BuilderBlock.tsx b/frontend/components/builder/BuilderBlock.tsx deleted file mode 100644 index 8d629c14..00000000 --- a/frontend/components/builder/BuilderBlock.tsx +++ /dev/null @@ -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 ( -
{ - 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); }} - > -
{block.label}
-
- -
-
startResize(e, block.id, x, y, w, h)} - /> -
- ); -} - -/** 블록 내부 프리뷰 렌더 (타입별 분기) */ -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 ; - case "form": - return ; - case "search": - return f.searchable && !f.system)} />; - case "title": - return ; - case "button": - return ; - case "button-bar": - return ; - case "pagination": - return ; - case "divider": - return
; - case "stats": - return
통계 카드 프리뷰
; - default: - return
{block.type}
; - } -} - -function TablePreview({ fields }: { fields: FieldConfig[] }) { - const cols = fields.slice(0, 8); - if (!cols.length) return
테이블을 선택하세요
; - return ( - - - {cols.map((f) => )} - - - {[0, 1, 2].map((r) => ( - {cols.map((f) => )} - ))} - -
{f.label}
- ); -} - -function FormPreview({ fields, config }: { fields: FieldConfig[]; config: FormConfig }) { - const cols = config?.columns || 2; - const formFields = fields.filter((f) => !f.pk || f.type !== "code"); - return ( -
- {formFields.slice(0, 10).map((f) => ( -
-
- {f.label}{f.required && *} -
-
- {f.type === "select" - ? (typeof f.options?.[0] === "string" ? f.options[0] : typeof f.options?.[0] === "object" ? f.options[0].label : "—") - : "—"} -
-
- ))} -
- ); -} - -function SearchPreview({ fields }: { fields: FieldConfig[] }) { - if (!fields.length) return
검색 조건 없음
; - return ( -
- {fields.slice(0, 5).map((f) => ( -
-
{f.label}
-
-
- ))} -
- -
-
- ); -} - -function TitlePreview({ config }: { config: TitleConfig }) { - return ( -
- {config.text || "제목"} -
- ); -} - -function ButtonPreview({ config }: { config: ButtonConfig }) { - const cls = config.variant === "primary" ? "dev-pv-btn primary" : "dev-pv-btn"; - return
{config.text || "버튼"}
; -} - -function ButtonBarPreview({ config }: { config: ButtonBarConfig }) { - return ( -
- {(config.buttons || []).map((btn, i) => ( -
- {btn.text} -
- ))} -
- ); -} - -function PaginationPreview() { - return ( -
- 총 0건 -
- 1 - 2 - 3 -
- 20건/페이지 -
- ); -} diff --git a/frontend/components/builder/BuilderCanvas.tsx b/frontend/components/builder/BuilderCanvas.tsx deleted file mode 100644 index ab794dd9..00000000 --- a/frontend/components/builder/BuilderCanvas.tsx +++ /dev/null @@ -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 ( -
-
-
-
- {blocks.length === 0 && ( -
-
📝
-
- {currentView === "create" ? "등록" : "수정"} 팝업에 컴포넌트를 배치하세요 -
-
- )} - {blocks.map((block) => ( - - ))} -
-
-
-
- ); - } - - return ( -
-
- {blocks.length === 0 && ( -
-
🎨
-
- 팔레트에서 컴포넌트를 드래그하거나 클릭하여 추가하세요 -
-
- )} - {blocks.map((block) => ( - - ))} -
-
- ); -} diff --git a/frontend/components/builder/BuilderLayout.tsx b/frontend/components/builder/BuilderLayout.tsx deleted file mode 100644 index 811559e5..00000000 --- a/frontend/components/builder/BuilderLayout.tsx +++ /dev/null @@ -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 ( -
- - -
- - - -
- - {/* 상태바 */} -
- 블록 {blockCount}개 · {tableName || "테이블 미선택"} · 연결 {connections.length}개 - {isDirty ? "수정됨" : "저장됨"} -
-
- ); -} diff --git a/frontend/components/builder/BuilderPalette.tsx b/frontend/components/builder/BuilderPalette.tsx deleted file mode 100644 index 929b7645..00000000 --- a/frontend/components/builder/BuilderPalette.tsx +++ /dev/null @@ -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 ( -
-
컴포넌트
- {PALETTE_ITEMS.map((sec) => ( - -
{sec.section}
- {sec.items.map((item) => ( -
handleDragStart(e, item.type)} - onClick={() => handleClick(item.type)} - style={{ opacity: !tableName && ["table", "form", "search"].includes(item.type) ? 0.4 : 1 }} - > - {item.icon} - {item.label} -
- ))} -
- ))} -
- ); -} diff --git a/frontend/components/builder/BuilderProps.tsx b/frontend/components/builder/BuilderProps.tsx deleted file mode 100644 index 3ef73b4b..00000000 --- a/frontend/components/builder/BuilderProps.tsx +++ /dev/null @@ -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 = { - 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 ( -
-
속성
-
- 캔버스에서 컴포넌트를
선택하세요 -
-
- ); - } - - return ( -
-
{TYPE_LABELS[block.type] || block.type}
- - {/* 공통: 이름 */} -
컴포넌트 정보
-
- 이름 - updateBlock(block.id, { label: e.target.value })} - /> -
- - {/* 공통: 위치/크기 */} -
위치 · 크기
-
-
- - moveBlock(block.id, Number(e.target.value), block.position.y)} /> -
-
- - moveBlock(block.id, block.position.x, Number(e.target.value))} /> -
-
- - resizeBlock(block.id, Number(e.target.value), block.position.h)} /> -
-
- - resizeBlock(block.id, block.position.w, Number(e.target.value))} /> -
-
- - {/* 타입별 속성 패널 */} - {block.type === "table" && } - {block.type === "form" && } - {block.type === "search" && } - {block.type === "button" && } - {block.type === "button-bar" && } - {block.type === "title" && } - - {/* 삭제 버튼 */} -
- -
-
- ); -} diff --git a/frontend/components/builder/BuilderToolbar.tsx b/frontend/components/builder/BuilderToolbar.tsx deleted file mode 100644 index 8aaafde7..00000000 --- a/frontend/components/builder/BuilderToolbar.tsx +++ /dev/null @@ -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[]>([]); - 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) => { - 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 = { - 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 ( - <> - {/* 헤더 */} -
-
- INVYONE - DEV - setTemplateMeta({ templateName: e.target.value })} - placeholder="템플릿 이름" - /> -
-
- -
-
- - {/* 도구모음 */} -
-
- 테이블 - -
- -
- - {VIEW_TABS.map((tab) => ( - - ))} -
- -
- - {isDirty && ( - - ● 수정됨 - - )} -
- - ); -} diff --git a/frontend/components/builder/hooks/useBlockDrag.ts b/frontend/components/builder/hooks/useBlockDrag.ts deleted file mode 100644 index 31c2602d..00000000 --- a/frontend/components/builder/hooks/useBlockDrag.ts +++ /dev/null @@ -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(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 }; -} diff --git a/frontend/components/builder/hooks/useBuilderState.ts b/frontend/components/builder/hooks/useBuilderState.ts deleted file mode 100644 index b43b08a4..00000000 --- a/frontend/components/builder/hooks/useBuilderState.ts +++ /dev/null @@ -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; - - // 선택된 블록 - 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) => 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) => void; - updateField: (column: string, updates: Partial) => void; - setTemplateMeta: (meta: { templateName?: string; category?: string; description?: string }) => void; - addConnection: (conn: Connection) => void; - removeConnection: (connId: string) => void; - toTemplate: () => Template; - fromTemplate: (tpl: Record) => 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 = { - 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, - selectedBlockId: null as string | null, - connections: [] as Connection[], - templateId: null as string | null, - templateName: "", - category: "", - description: "", - isDirty: false, -}; - -// ─── 스토어 ─── -export const useBuilderState = create()( - 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; - }); -} diff --git a/frontend/components/builder/props/ButtonProps.tsx b/frontend/components/builder/props/ButtonProps.tsx deleted file mode 100644 index 8127a208..00000000 --- a/frontend/components/builder/props/ButtonProps.tsx +++ /dev/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 ( - <> -
버튼 설정
-
- 텍스트 - update("text", e.target.value)} /> -
-
- 액션 - -
-
- 스타일 - -
-
- 확인 메시지 - update("confirm", e.target.value || undefined)} /> -
- - ); -} - -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 ( - <> -
버튼 목록
- {config.buttons.map((btn, i) => ( -
-
- updateButton(i, "text", e.target.value)} /> - -
-
- - -
-
- ))} -
- -
- - ); -} diff --git a/frontend/components/builder/props/FieldListEditor.tsx b/frontend/components/builder/props/FieldListEditor.tsx deleted file mode 100644 index 9b704003..00000000 --- a/frontend/components/builder/props/FieldListEditor.tsx +++ /dev/null @@ -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(null); - - const filteredFields = filter ? fields.filter(filter) : fields; - const sorted = [...filteredFields].sort((a, b) => a.order - b.order); - - return ( -
- {sorted.map((f) => ( - -
setExpandedCol(expandedCol === f.column ? null : f.column)} - > -
{ - e.stopPropagation(); - updateField(f.column, { [toggleKey]: !f[toggleKey] }); - }} - > - ✓ -
- {f.label} -
- {f.pk && PK} - {f.required && 필수} - {f.searchable && 검색} - {f.system && SYS} - {f.computed && 계산} -
- {f.type} - -
- {expandedCol === f.column && } -
- ))} -
- ); -} - -/** 필드 상세 편집 패널 */ -function FieldDetail({ field }: { field: FieldConfig }) { - const updateField = useBuilderState((s) => s.updateField); - const col = field.column; - - return ( -
e.stopPropagation()}> -
- - -
-
- updateField(col, { required: v })} /> - updateField(col, { editable: v })} /> - updateField(col, { searchable: v })} /> - updateField(col, { sortable: v })} /> -
-
- ); -} - -function Toggle({ label, checked, onToggle }: { label: string; checked: boolean; onToggle: (v: boolean) => void }) { - return ( -
onToggle(!checked)}> -
- {label} -
- ); -} diff --git a/frontend/components/builder/props/FormProps.tsx b/frontend/components/builder/props/FormProps.tsx deleted file mode 100644 index 5b536a5d..00000000 --- a/frontend/components/builder/props/FormProps.tsx +++ /dev/null @@ -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 ( - <> -
폼 설정
-
- 컬럼 수 - -
-
- 저장 방식 - -
- -
입력 항목
-
체크: 폼에 표시 · 클릭: 상세 설정
- !f.system} toggleKey="visible" /> - - ); -} diff --git a/frontend/components/builder/props/SearchProps.tsx b/frontend/components/builder/props/SearchProps.tsx deleted file mode 100644 index 3bbc3504..00000000 --- a/frontend/components/builder/props/SearchProps.tsx +++ /dev/null @@ -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 ( - <> -
검색 설정
-
- 날짜 범위 검색 -
update("dateRangeEnabled", !config.dateRangeEnabled)} /> -
-
- 초기화 버튼 -
update("showResetButton", !config.showResetButton)} /> -
-
- 자동 검색 -
update("autoSearch", !config.autoSearch)} /> -
-
- 레이아웃 - -
- -
검색 조건
-
체크: 검색에 포함 · 클릭: 상세 설정
- !f.system} toggleKey="searchable" /> - - ); -} diff --git a/frontend/components/builder/props/TableProps.tsx b/frontend/components/builder/props/TableProps.tsx deleted file mode 100644 index dca823b3..00000000 --- a/frontend/components/builder/props/TableProps.tsx +++ /dev/null @@ -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 ( - <> -
테이블 설정
-
- 페이지 크기 - -
-
- 선택 방식 - -
-
- 자동 로드 -
update("autoLoad", !config.autoLoad)} /> -
-
- 인라인 편집 -
update("inlineEdit", !config.inlineEdit)} /> -
-
- 체크박스 -
update("showCheckbox", !config.showCheckbox)} /> -
-
- 스타일 - -
- -
표시할 컬럼
-
체크: 보이기 · 클릭: 상세 설정
- !f.system} toggleKey="visible" /> - - ); -} diff --git a/frontend/components/builder/props/TitleProps.tsx b/frontend/components/builder/props/TitleProps.tsx deleted file mode 100644 index 12a48a8d..00000000 --- a/frontend/components/builder/props/TitleProps.tsx +++ /dev/null @@ -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 ( - <> -
제목 설정
-
- 텍스트 - update("text", e.target.value)} /> -
-
- 크기 - -
-
- 굵기 - -
-
- 정렬 - -
- - ); -} diff --git a/frontend/components/dash/DashboardCanvas.tsx b/frontend/components/dash/DashboardCanvas.tsx index e07395b6..13e6ca78 100644 --- a/frontend/components/dash/DashboardCanvas.tsx +++ b/frontend/components/dash/DashboardCanvas.tsx @@ -51,31 +51,36 @@ export const DashboardCanvas = forwardRef( 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( 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( top: y + 'px', width: w + 'px', height: h + 'px', + zIndex: 10, }} > 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([]); + const [components, setComponents] = useState[]>([]); + const [connections, setConnections] = useState[]>([]); + const [templateKind, setTemplateKind] = useState(null); + const [templateLoaded, setTemplateLoaded] = useState(false); + const [loadError, setLoadError] = useState(null); + + // ─── 데이터 상태 ─── const [data, setData] = useState[]>([]); const [totalCount, setTotalCount] = useState(0); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); const [searchParams, setSearchParams] = useState>({}); const [loading, setLoading] = useState(false); - const [templateLoaded, setTemplateLoaded] = useState(false); + const [selectedRow, setSelectedRow] = useState | 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[] = Array.isArray(listView.components) + ? listView.components + : []; + const tplConnections: Record[] = 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) => { + // ─── DataPort 콜백 ─── + const handleSearch = useCallback((params: Record) => { 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) => { + 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 (
@@ -106,15 +196,16 @@ export function DashboardCard({
📋
{templateName}
- {templateCategory && ( -
{templateCategory}
- )} + {templateCategory &&
{templateCategory}
}
@@ -122,7 +213,10 @@ export function DashboardCard({ @@ -130,18 +224,27 @@ export function DashboardCard({ {editMode && ( @@ -149,53 +252,395 @@ export function DashboardCard({
- {/* 본문 — Template 컴포넌트 렌더 */} + {/* 본문 — container query 카드 */}
- {!primaryTable ? ( -
- 테이블이 지정되지 않은 템플릿입니다. -
+ {loadError ? ( +
⚠ {loadError}
) : !templateLoaded ? ( -
- 필드 로딩 중... +
템플릿 로딩 중...
+ ) : sortedComponents.length === 0 ? ( + // Template에 컴포넌트 배치 없음 → 기본 검색+테이블+페이지네이션 + + ) : effectiveKind === 'canvas' ? ( + // canvas kind — 자유배치, 반응형 없음 (control/flow 류) +
+
+ {sortedComponents.map((comp) => ( + + ))} +
) : ( -
- {searchableFields.length > 0 && ( - + {sortedComponents.map((comp) => ( + - )} -
- -
- {totalCount > 0 && ( - - )} + ))}
)}
{/* 접힌 상태: 미니 뷰 */} - + {/* 리사이즈 핸들 */}
); } + +// ───────────────────────────────────────────────────────── +// 컴포넌트별 렌더러 — business(grid) 와 canvas(absolute) +// ───────────────────────────────────────────────────────── + +interface ComponentRendererProps { + component: Record; + fields: FieldConfig[]; + data: Record[]; + loading: boolean; + totalCount: number; + page: number; + pageSize: number; + selectedRow: Record | null; + onSearch: (params: Record) => void; + onRowSelect: (row: Record) => 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 ( +
+ {renderByType(props)} +
+ ); +} + +/** + * 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 ( +
+ {renderByType(props)} +
+ ); +} + +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 ( + + ); + + case 'search': + return ; + + case 'form': + return ; + + case 'button': + return ; + + case 'button-bar': + return ; + + case 'pagination': + return ; + + case 'title': { + const titleCfg = config as any; + return ( +
+ {titleCfg.text ?? component.label} +
+ ); + } + + case 'divider': { + const dCfg = config as any; + return ( +
+
+
+ ); + } + + case 'stats': { + const sCfg = config as any; + const items = Array.isArray(sCfg.items) ? sCfg.items : []; + return ( +
0 ? `repeat(${items.length}, 1fr)` : '1fr', + gap: '.4rem', + padding: '.3rem', + }} + > + {items.length === 0 ? ( +
+ 통계 항목 없음 +
+ ) : ( + items.map((item: Record, i: number) => ( +
+
+ {item.label} +
+
+ {computeAggregation(data, item.column, item.aggregation)} +
+
+ )) + )} +
+ ); + } + + default: + return ( +
+ {type ?? 'unknown'} +
+ ); + } +} + +// ───────────────────────────────────────────────────────── +// Fallback — Template에 컴포넌트 배치가 없을 때 기본 카드 (검색+테이블+페이지네이션) +// ───────────────────────────────────────────────────────── + +interface DefaultCardContentProps { + fields: FieldConfig[]; + data: Record[]; + loading: boolean; + totalCount: number; + page: number; + pageSize: number; + onSearch: (params: Record) => void; + onRowSelect: (row: Record) => 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 ( +
+ {searchableFields.length > 0 && ( + + )} +
+ +
+ {totalCount > 0 && ( + + )} +
+ ); +} + +// ───────────────────────────────────────────────────────── +// 통계 집계 헬퍼 +// ───────────────────────────────────────────────────────── +function computeAggregation( + data: Record[], + 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'; +} diff --git a/frontend/components/dash/DashboardLayout.tsx b/frontend/components/dash/DashboardLayout.tsx index 590f64d6..3f904072 100644 --- a/frontend/components/dash/DashboardLayout.tsx +++ b/frontend/components/dash/DashboardLayout.tsx @@ -231,7 +231,8 @@ export function DashboardLayout() { onSaveLayout={handleSaveLayout} /> {/* 제어 모드 툴바 + 오버레이 */} -
+ {/* ★ flex container로 만들어야 안쪽 dash-canvas가 flex:1로 늘어남 */} +
{children}
+ // ★ flex 컨테이너로 만들어서 안쪽 dash-shell이 height:100% 잘 먹도록 +
+ {children} +
) : ( )} diff --git a/frontend/components/screen/ScreenDesigner_new.tsx b/frontend/components/screen/ScreenDesigner_new.tsx deleted file mode 100644 index 9117bd89..00000000 --- a/frontend/components/screen/ScreenDesigner_new.tsx +++ /dev/null @@ -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({ - 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(null); - - // 실행취소/다시실행을 위한 히스토리 상태 - const [history, setHistory] = useState([]); - const [historyIndex, setHistoryIndex] = useState(-1); - - // 그룹 상태 - const [groupState, setGroupState] = useState({ - 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([]); - const [searchTerm, setSearchTerm] = useState(""); - - // 클립보드 - const [clipboard, setClipboard] = useState<{ - type: "single" | "multiple" | "group"; - data: ComponentData[]; - } | null>(null); - - // 그룹 생성 다이얼로그 - const [showGroupCreateDialog, setShowGroupCreateDialog] = useState(false); - - const canvasRef = useRef(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 ( -
-
- -

화면을 선택하세요

-

설계할 화면을 먼저 선택해주세요.

-
-
- ); - } - - return ( -
- {/* 상단 툴바 */} - { - toast.info("미리보기 기능은 준비 중입니다."); - }} - onTogglePanel={togglePanel} - panelStates={panelStates} - canUndo={historyIndex > 0} - canRedo={historyIndex < history.length - 1} - isSaving={isSaving} - /> - - {/* 메인 캔버스 영역 (전체 화면) */} -
{ - if (e.target === e.currentTarget) { - setSelectedComponent(null); - setGroupState((prev) => ({ ...prev, selectedComponents: [] })); - } - }} - onDrop={handleDrop} - onDragOver={handleDragOver} - > - {/* 격자 라인 */} - {gridLines.map((line, index) => ( -
- ))} - - {/* 컴포넌트들 */} - {layout.components - .filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링 - .map((component) => { - const children = - component.type === "group" ? layout.components.filter((child) => child.parentId === component.id) : []; - - return ( - handleComponentClick(component, e)} - > - {children.map((child) => ( - handleComponentClick(child, e)} - /> - ))} - - ); - })} - - {/* 빈 캔버스 안내 */} - {layout.components.length === 0 && ( -
-
- -

캔버스가 비어있습니다

-

좌측 테이블 패널에서 테이블이나 컬럼을 드래그하여 화면을 설계하세요

-

단축키: T(테이블), P(속성), S(스타일), G(격자)

-
-
- )} -
- - {/* 플로팅 패널들 */} - closePanel("tables")} - position="left" - width={320} - height={600} - > - { - const dragData = { - type: column ? "column" : "table", - table, - column, - }; - e.dataTransfer.setData("application/json", JSON.stringify(dragData)); - }} - selectedTableName={selectedScreen.table_name!} - /> - - - closePanel("properties")} - position="right" - width={320} - height={500} - > - - - - closePanel("styles")} - position="right" - width={320} - height={400} - > - {selectedComponent ? ( -
- updateComponentProperty(selectedComponent.id, "style", newStyle)} - /> -
- ) : ( -
- 컴포넌트를 선택하여 스타일을 편집하세요 -
- )} -
- - closePanel("grid")} - position="right" - width={280} - height={450} - > - { - 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); - }} - /> - - - {/* 그룹 생성 툴바 (필요시) */} - {groupState.selectedComponents.length > 1 && ( -
- -
- )} -
- ); -} diff --git a/frontend/components/template-builder/TemplateBuilder.tsx b/frontend/components/template-builder/TemplateBuilder.tsx new file mode 100644 index 00000000..459ab64c --- /dev/null +++ b/frontend/components/template-builder/TemplateBuilder.tsx @@ -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 = { + 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; +} + +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(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(null); + const [drag, setDrag] = useState({ 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, 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) => { + if (e.dataTransfer.types.includes("application/x-template-component")) { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + } + }, []); + + const handleCanvasDrop = useCallback( + (e: React.DragEvent) => { + 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 ( +
+ ); + }, [store.gridSettings]); + + return ( +
+ +
+ +
+
{ + if (e.target === e.currentTarget) { + store.selectBlock(null); + } + }} + > + {gridLines} + {blocks.map((block) => ( + p.id === block.componentId)?.name ?? block.componentId} + paletteIcon={paletteItems.find((p) => p.id === block.componentId)?.icon ?? "◼"} + /> + ))} + {blocks.length === 0 && } +
+
+ +
+
+ ); +} + +function Toolbar({ onSave, onExit }: { onSave: () => void; onExit?: () => void }) { + const state = useTemplateBuilderStore(); + const undoEnabled = canUndo(state); + const redoEnabled = canRedo(state); + return ( +
+ {onExit && ( + + )} + state.setTemplateMeta({ templateName: e.target.value })} + placeholder="템플릿 이름" + className="w-48 rounded border border-slate-200 dark:border-slate-700 px-2 py-1" + /> + 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" + /> +
+ {(Object.keys(VIEW_LABELS) as BuilderView[]).map((view) => ( + + ))} +
+
+ + + + + +
+
+ ); +} + +function PalettePanel({ + items, + onDragStart, +}: { + items: PaletteItem[]; + onDragStart: (e: React.DragEvent, 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(); + 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 ( + + ); +} + +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 ( +
onMouseDown(e, block, "move")} + > +
+ {paletteIcon} + {paletteLabel} + + {Math.round(block.position.width)}×{Math.round(block.position.height)} + +
+
+ {block.componentId} +
+
onMouseDown(e, block, "resize")} + /> +
+ ); +} + +function EmptyState({ view }: { view: BuilderView }) { + return ( +
+
+
📋
+
{VIEW_LABELS[view]} 뷰가 비어있습니다
+
좌측 팔레트에서 컴포넌트를 드래그하여 배치하세요
+
+
+ ); +} + +function SidePanel() { + const state = useTemplateBuilderStore(); + const selected = useSelectedBlock(); + const [tab, setTab] = useState<"props" | "grid" | "meta">("props"); + + return ( + + ); +} + +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(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 ( +
+
+
component
+
{block.componentId}
+
+
+ updateBlockPosition(block.id, { left: v })} + /> + updateBlockPosition(block.id, { top: v })} + /> + updateBlockPosition(block.id, { width: Math.max(MIN_BLOCK_SIZE, v) })} + /> + updateBlockPosition(block.id, { height: Math.max(MIN_BLOCK_SIZE, v) })} + /> +
+
+
config (JSON)
+