화면 디자이너 제작

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 19:20:29 +09:00
parent 2c0a97f2ba
commit a0c9d9a0ab
11 changed files with 1348 additions and 1912 deletions
+101 -4
View File
@@ -1,11 +1,108 @@
"use client"; "use client";
import TemplateBuilder from "@/components/template-builder/TemplateBuilder"; // VEX 화면 디자이너 직접 연결. /admin/builder 접속 시 곧바로 ScreenDesigner 를
// 전체 화면으로 표시. URL 쿼리 `?id=xxx` 로 특정 화면 지정 가능, 없으면 첫 번째
// 화면 자동 선택.
// Phase 2.1 의 TemplateBuilder 는 카드 내부 모델 시도 실패 후 포기 (2026-04-11).
import { Suspense, useState, useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import ScreenDesigner from "@/components/screen/ScreenDesigner";
import type { ScreenDefinition } from "@/types/screen";
import { screenApi } from "@/lib/api/screen";
export default function BuilderPage() { function BuilderInner() {
const router = useRouter();
const searchParams = useSearchParams();
const screenIdParam = searchParams.get("id");
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
useEffect(() => {
let alive = true;
(async () => {
try {
setLoading(true);
setLoadError(null);
const result: any = await screenApi.getScreens({
page: 1,
size: 1000,
searchTerm: "",
excludePop: true,
});
if (!alive) return;
const list: ScreenDefinition[] = result?.data ?? [];
let target: ScreenDefinition | null = null;
if (screenIdParam) {
const id = parseInt(screenIdParam, 10);
target = list.find((s: any) => s.screen_id === id) ?? null;
}
if (!target && list.length > 0) {
target = list[0];
}
setSelectedScreen(target);
} catch (err: any) {
console.error("[BuilderPage] 화면 로드 실패:", err);
if (alive) setLoadError(err?.message ?? "화면 로드 실패");
} finally {
if (alive) setLoading(false);
}
})();
return () => {
alive = false;
};
}, [screenIdParam]);
if (loading) {
return (
<div className="flex h-screen items-center justify-center text-slate-500">
...
</div>
);
}
if (loadError) {
return (
<div className="flex h-screen flex-col items-center justify-center gap-2 text-slate-500">
<div className="text-sm"> {loadError}</div>
<button
type="button"
onClick={() => router.push("/admin/screenMng/screenMngList")}
className="rounded border border-slate-300 px-3 py-1 text-xs hover:bg-slate-100"
>
</button>
</div>
);
}
// AppLayout 의 헤더/탭 아래 영역에만 배치. fixed 덮어쓰기 대신 일반 flow 로
// 헤더와 안 겹치게.
return ( return (
<div className="h-[calc(100vh-4rem)] w-full"> <div className="h-[calc(100vh-4rem)] w-full overflow-hidden bg-background">
<TemplateBuilder /> <ScreenDesigner
selectedScreen={selectedScreen}
onBackToList={() => router.push("/admin/screenMng/screenMngList")}
onScreenUpdate={(updatedFields) => {
if (selectedScreen) {
setSelectedScreen({ ...selectedScreen, ...updatedFields });
}
}}
/>
</div> </div>
); );
} }
export default function BuilderPage() {
return (
<Suspense
fallback={
<div className="flex h-screen items-center justify-center text-slate-500">
...
</div>
}
>
<BuilderInner />
</Suspense>
);
}
+193 -459
View File
@@ -2,18 +2,13 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { RefreshCw, ChevronDown, X, Settings } from 'lucide-react'; import { RefreshCw, ChevronDown, X, Settings } from 'lucide-react';
import { toast } from 'sonner';
import { getTemplateInfo } from '@/lib/api/template'; import { getTemplateInfo } from '@/lib/api/template';
import { getMetaFields } from '@/lib/api/meta'; import { fcList, fcInsert, fcUpdate, fcDelete } from '@/lib/api/fcData';
import { fcList } from '@/lib/api/fcData'; import { FcForm } from '@/components/fc';
import { FcTable, FcForm, FcSearch, FcButton, FcButtonBar, FcPagination } from '@/components/fc'; import type { FieldConfig, Template } from '@/types/invyone-component';
import type {
FieldConfig,
GridPosition,
AbsolutePosition,
TemplateKind,
} from '@/types/invyone-component';
import { isGridPosition } from '@/types/invyone-component';
import { CardMiniView } from './CardMiniView'; import { CardMiniView } from './CardMiniView';
import { TemplateRenderer, type TemplateRenderContext } from './TemplateRenderer';
interface DashboardCardProps { interface DashboardCardProps {
card: Record<string, any>; card: Record<string, any>;
@@ -23,12 +18,6 @@ interface DashboardCardProps {
onOpenSettings?: (cardId: string) => void; 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({ export function DashboardCard({
card, card,
editMode, editMode,
@@ -43,15 +32,11 @@ export function DashboardCard({
const primaryTable = card.primary_table ?? card.PRIMARY_TABLE ?? ''; const primaryTable = card.primary_table ?? card.PRIMARY_TABLE ?? '';
const isCollapsed = card.is_collapsed ?? card.IS_COLLAPSED ?? false; const isCollapsed = card.is_collapsed ?? card.IS_COLLAPSED ?? false;
// ─── Template 상태 ───
const [fields, setFields] = useState<FieldConfig[]>([]); const [fields, setFields] = useState<FieldConfig[]>([]);
const [components, setComponents] = useState<Record<string, any>[]>([]); const [template, setTemplate] = useState<Template | null>(null);
const [connections, setConnections] = useState<Record<string, any>[]>([]);
const [templateKind, setTemplateKind] = useState<TemplateKind | null>(null);
const [templateLoaded, setTemplateLoaded] = useState(false); const [templateLoaded, setTemplateLoaded] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null); const [loadError, setLoadError] = useState<string | null>(null);
// ─── 데이터 상태 ───
const [data, setData] = useState<Record<string, any>[]>([]); const [data, setData] = useState<Record<string, any>[]>([]);
const [totalCount, setTotalCount] = useState(0); const [totalCount, setTotalCount] = useState(0);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -60,72 +45,52 @@ export function DashboardCard({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [selectedRow, setSelectedRow] = useState<Record<string, any> | null>(null); const [selectedRow, setSelectedRow] = useState<Record<string, any> | null>(null);
// CRUD 모달: 'create' | 'edit' | null
const [formMode, setFormMode] = useState<'create' | 'edit' | null>(null);
const [formRow, setFormRow] = useState<Record<string, any> | null>(null);
const mountedRef = useRef(true); const mountedRef = useRef(true);
// ─── Template 로드 ─── // Template + fields 로드
useEffect(() => { useEffect(() => {
mountedRef.current = true; mountedRef.current = true;
if (!primaryTable && !templateId) return; if (!primaryTable && !templateId) {
setTemplateLoaded(true);
const loadTemplate = async () => { return;
}
const load = async () => {
setLoadError(null); setLoadError(null);
try { try {
// 1순위: Template (빌더에서 만든 컴포넌트 배치 + fields) let resolvedFields: FieldConfig[] = [];
if (templateId) { if (templateId) {
const tpl = await getTemplateInfo(templateId); const tpl = await getTemplateInfo(templateId);
if (mountedRef.current && tpl) { if (tpl && mountedRef.current) {
const tplFields: FieldConfig[] = Array.isArray(tpl.fields) ? tpl.fields : []; setTemplate(tpl as Template);
// views는 list/create/edit이 있고 각자 components 배열 if (Array.isArray(tpl.fields)) resolvedFields = tpl.fields as FieldConfig[];
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 메타로 기본 카드만 표시 // ★ DB meta 자동 fallback 제거 (하드코딩 방지).
if (primaryTable) { // Template.fields 가 비어있으면 fields 도 빈 채로 둔다 — 빌더에서
const meta = await getMetaFields(primaryTable); // 사용자가 명시적으로 선택한 필드만 반영. 스키마 덤프 금지.
if (mountedRef.current && meta?.fields) { if (mountedRef.current) {
setFields(meta.fields); setFields(resolvedFields);
setComponents([]); // 컴포넌트 배치 없음 → 기본 렌더 setTemplateLoaded(true);
setConnections([]);
setTemplateKind(null);
setTemplateLoaded(true);
}
} }
} catch (err: any) { } catch (err: any) {
console.error(`[DashboardCard] Template/fields 로드 실패:`, err); console.error('[DashboardCard] Template 로드 실패:', err);
if (mountedRef.current) { if (mountedRef.current) {
setLoadError(err?.message ?? '템플릿 로드 실패'); setLoadError(err?.message ?? '템플릿 로드 실패');
setTemplateLoaded(true);
} }
} }
}; };
loadTemplate(); load();
return () => { return () => {
mountedRef.current = false; mountedRef.current = false;
}; };
}, [primaryTable, templateId]); }, [primaryTable, templateId]);
// ─── 데이터 조회 ─── // 데이터 조회
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
if (!primaryTable || !templateLoaded) return; if (!primaryTable || !templateLoaded) return;
setLoading(true); setLoading(true);
@@ -141,7 +106,7 @@ export function DashboardCard({
setTotalCount(result?.total ?? result?.total_count ?? 0); setTotalCount(result?.total ?? result?.total_count ?? 0);
} }
} catch (err) { } catch (err) {
console.error(`[DashboardCard] 데이터 조회 실패:`, err); console.error('[DashboardCard] 데이터 조회 실패:', err);
} finally { } finally {
if (mountedRef.current) setLoading(false); if (mountedRef.current) setLoading(false);
} }
@@ -151,7 +116,7 @@ export function DashboardCard({
loadData(); loadData();
}, [loadData]); }, [loadData]);
// ─── DataPort 콜백 ─── // 검색/페이지/선택
const handleSearch = useCallback((params: Record<string, any>) => { const handleSearch = useCallback((params: Record<string, any>) => {
setSearchParams(params); setSearchParams(params);
setPage(1); setPage(1);
@@ -166,32 +131,119 @@ export function DashboardCard({
setSelectedRow(row); setSelectedRow(row);
}, []); }, []);
// ─── 컴포넌트 정렬 ─── // PK 컬럼 (첫 pk 필드)
// grid(business) → row / col 기준, canvas → y / x 기준 const pkColumn = useMemo(() => {
const sortedComponents = useMemo(() => { const pkField = fields.find((f) => f.pk);
return [...components].sort((a, b) => { return pkField?.column ?? '';
const pa = a.position ?? {}; }, [fields]);
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 형태로 추정 // CRUD 액션
const effectiveKind: TemplateKind = useMemo(() => { const handleAdd = useCallback(() => {
if (templateKind === 'business' || templateKind === 'canvas') return templateKind; const defaults: Record<string, any> = {};
// kind 미지정 Template — grid position 데이터면 business, 그 외는 canvas로 fallback 렌더 for (const f of fields) {
const sample = components.find((c) => c.position != null)?.position; if (f.defaultValue !== undefined) defaults[f.column] = f.defaultValue;
if (sample && isGridPosition(sample)) return 'business'; }
return 'canvas'; setFormRow(defaults);
}, [templateKind, components]); setFormMode('create');
}, [fields]);
const handleEdit = useCallback(() => {
if (!selectedRow) {
toast.warning('수정할 행을 선택하세요');
return;
}
setFormRow({ ...selectedRow });
setFormMode('edit');
}, [selectedRow]);
const handleDelete = useCallback(async () => {
if (!selectedRow) {
toast.warning('삭제할 행을 선택하세요');
return;
}
if (!pkColumn) {
toast.error('PK 컬럼이 없어 삭제할 수 없습니다');
return;
}
if (!confirm('정말 삭제하시겠습니까?')) return;
try {
const id = String(selectedRow[pkColumn] ?? '');
if (!id) throw new Error('PK 값이 비어있습니다');
await fcDelete(primaryTable, [id]);
setSelectedRow(null);
await loadData();
toast.success('삭제되었습니다');
} catch (err: any) {
console.error('[DashboardCard] 삭제 실패:', err);
toast.error(err?.message ?? '삭제 실패');
}
}, [selectedRow, pkColumn, primaryTable, loadData]);
const handleSubmitForm = useCallback(
async (row: Record<string, any>) => {
try {
if (formMode === 'create') {
await fcInsert(primaryTable, row);
toast.success('등록되었습니다');
} else if (formMode === 'edit') {
if (!pkColumn) throw new Error('PK 컬럼이 없습니다');
const id = String(row[pkColumn] ?? '');
if (!id) throw new Error('PK 값이 비어있습니다');
await fcUpdate(primaryTable, id, row);
toast.success('수정되었습니다');
}
setFormMode(null);
setFormRow(null);
await loadData();
} catch (err: any) {
console.error('[DashboardCard] 저장 실패:', err);
toast.error(err?.message ?? '저장 실패');
}
},
[formMode, primaryTable, pkColumn, loadData],
);
const closeForm = useCallback(() => {
setFormMode(null);
setFormRow(null);
}, []);
// TemplateRenderer 로 전달할 공유 context
const renderContext: TemplateRenderContext = useMemo(
() => ({
fields,
data,
loading,
selectedRow,
totalCount,
page,
pageSize,
onSearch: handleSearch,
onRowSelect: handleRowSelect,
onPageChange: handlePageChange,
onAdd: handleAdd,
onEdit: handleEdit,
onDelete: handleDelete,
}),
[
fields,
data,
loading,
selectedRow,
totalCount,
page,
pageSize,
handleSearch,
handleRowSelect,
handlePageChange,
handleAdd,
handleEdit,
handleDelete,
],
);
return ( return (
<div className={`dash-card${isCollapsed ? ' collapsed' : ''}`}> <div className={`dash-card${isCollapsed ? ' collapsed' : ''}`}>
{/* 카드 헤더 */}
<div className="dash-card-head"> <div className="dash-card-head">
<div className="dash-card-head-l"> <div className="dash-card-head-l">
<div className="dash-card-icon">📋</div> <div className="dash-card-icon">📋</div>
@@ -252,395 +304,77 @@ export function DashboardCard({
</div> </div>
</div> </div>
{/* 본문 — container query 카드 */} <div
<div className="dash-card-body"> className="dash-card-body"
style={{
containerType: 'inline-size',
containerName: 'card',
}}
>
{loadError ? ( {loadError ? (
<div className="dash-card-error"> {loadError}</div> <div className="dash-card-error"> {loadError}</div>
) : !templateLoaded ? ( ) : !templateLoaded ? (
<div className="dash-card-loading">릿 ...</div> <div className="dash-card-loading">릿 ...</div>
) : sortedComponents.length === 0 ? ( ) : !template ? (
// Template에 컴포넌트 배치 없음 → 기본 검색+테이블+페이지네이션 <div className="dash-card-error">릿 </div>
<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>
) : ( ) : (
// business kind — 12-col grid + @container 카드 너비 반응형 <TemplateRenderer template={template} context={renderContext} />
<div className="dash-card-grid">
{sortedComponents.map((comp) => (
<GridComponent
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>
{/* 접힌 상태: 미니 뷰 */} <CardMiniView
<CardMiniView templateName={templateName} category={templateCategory} tableName={primaryTable} /> templateName={templateName}
category={templateCategory}
tableName={primaryTable}
/>
{/* 리사이즈 핸들 */}
<div className="dash-resize-handle" data-resize="true" /> <div className="dash-resize-handle" data-resize="true" />
</div>
);
}
// ───────────────────────────────────────────────────────── {formMode && formRow && (
// 컴포넌트별 렌더러 — business(grid) 와 canvas(absolute) <FormOverlay
// ───────────────────────────────────────────────────────── title={`${templateName} ${formMode === 'create' ? '등록' : '수정'}`}
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} fields={fields}
data={data} initialRow={formRow}
loading={loading} onSubmit={handleSubmitForm}
config={config} onClose={closeForm}
onRowSelect={onRowSelect}
/> />
); )}
</div>
);
}
case 'search': interface FormOverlayProps {
return <FcSearch fields={fields} onSearch={onSearch} config={config} />; title: string;
fields: FieldConfig[];
initialRow: Record<string, any>;
onSubmit: (row: Record<string, any>) => void;
onClose: () => void;
}
case 'form': function FormOverlay({ title, fields, initialRow, onSubmit, onClose }: FormOverlayProps) {
return <FcForm fields={fields} config={config} loadRow={selectedRow ?? undefined} />; return (
<div
case 'button': className="dash-form-overlay"
return <FcButton config={config} />; onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
case 'button-bar': }}
return <FcButtonBar config={config} />; >
<div className="dash-form-modal">
case 'pagination': <div className="dash-form-head">
return <FcPagination total={totalCount} page={page} config={config} onPageChange={onPageChange} />; <span className="dash-form-title">{title}</span>
<button className="dash-card-btn" onClick={onClose} title="닫기">
case 'title': { <X size={14} />
const titleCfg = config as any; </button>
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> </div>
); <div className="dash-form-body">
} <FcForm
fields={fields}
case 'divider': { loadRow={initialRow}
const dCfg = config as any; onSubmit={onSubmit}
return ( config={{ columns: 2 }}
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center' }}>
<div
style={{
width: '100%',
borderTop: `1px ${dCfg.style ?? 'solid'} var(--v5-border)`,
}}
/> />
</div> </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> </div>
{totalCount > 0 && (
<FcPagination total={totalCount} page={page} pageSize={pageSize} onPageChange={onPageChange} />
)}
</div> </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';
}
@@ -0,0 +1,253 @@
'use client';
import { FcTable, FcSearch, FcButton } from '@/components/fc';
import type {
Template,
TemplateComponent,
FieldConfig,
ButtonConfig,
} from '@/types/invyone-component';
/**
* Template.views.list.components 를 세로 스택(flex-column) 으로 렌더하는
* 런타임 컴포넌트.
*
* ── 레이아웃 원칙 ──────────────────────────────────────────────
* 카드 내부는 항상 자동 레이아웃: 컴포넌트를 `order` 로 정렬하고, 같은
* `row` 키를 가진 연속 블록은 flex-row 로 묶는다. 각 블록은 `flex-1
* min-w-0` 로 가로폭을 분배받고, 행은 `flex-wrap` 이라 카드 폭이 좁아
* 지면 자동으로 줄바꿈된다. px 좌표는 일절 사용하지 않는다.
*
* 레퍼런스: `frontend/app/test-card-responsive/page.tsx` (Phase 1 반응형
* 검증 구현).
*
* 공유 상태(data, selectedRow, searchParams 등) 는 DashboardCard 에서
* 관리하고 `context` 로 전달받는다.
*/
export interface TemplateRenderContext {
fields: FieldConfig[];
data: Record<string, any>[];
loading: boolean;
selectedRow: Record<string, any> | null;
totalCount: number;
page: number;
pageSize: number;
onSearch: (params: Record<string, any>) => void;
onRowSelect: (row: Record<string, any>) => void;
onPageChange: (args: { page: number; size: number }) => void;
onAdd: () => void;
onEdit: () => void;
onDelete: () => void;
}
interface TemplateRendererProps {
template: Template;
context: TemplateRenderContext;
}
export function TemplateRenderer({ template, context }: TemplateRendererProps) {
const rawComponents = (template.views as any)?.list?.components ?? [];
if (!Array.isArray(rawComponents) || rawComponents.length === 0) {
return <EmptyTemplate />;
}
const normalized = normalizeBlocks(rawComponents as any[]);
const rows = groupByRow(normalized);
return (
<div className="flex h-full w-full flex-col gap-2 overflow-auto p-2">
{rows.map((row, i) => (
<div key={i} className="flex w-full flex-row flex-wrap gap-2">
{row.map((block) => (
<div key={block.id} className="min-w-0 flex-1">
<ComponentSwitch block={block} context={context} />
</div>
))}
</div>
))}
</div>
);
}
/**
* 구 포맷(FreePosition 기반) 으로 저장된 블록을 최소 호환 처리:
* `order` 가 없으면 배열 인덱스를 그대로 부여해 최소한 순서만 유지한다.
* 구 데이터는 빌더에서 새로 저장하는 순간 order 포맷으로 이관된다.
*/
function normalizeBlocks(raw: any[]): TemplateComponent[] {
return raw.map((c, i) => ({
...c,
order: typeof c?.order === 'number' ? c.order : i,
row: typeof c?.row === 'number' ? c.row : undefined,
config: c?.config ?? {},
})) as TemplateComponent[];
}
/**
* `order` 로 정렬한 뒤 `row` 키가 같은 연속 블록을 같은 행으로 묶는다.
* undefined row 는 항상 단독 행으로 취급한다.
*/
function groupByRow(blocks: TemplateComponent[]): TemplateComponent[][] {
const sorted = [...blocks].sort((a, b) => a.order - b.order);
const result: TemplateComponent[][] = [];
let current: TemplateComponent[] = [];
let currentKey: number | undefined = undefined;
const flush = () => {
if (current.length > 0) {
result.push(current);
current = [];
currentKey = undefined;
}
};
for (const block of sorted) {
if (block.row === undefined) {
flush();
result.push([block]);
continue;
}
if (currentKey === block.row) {
current.push(block);
} else {
flush();
current = [block];
currentKey = block.row;
}
}
flush();
return result;
}
function EmptyTemplate() {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 py-8 text-center">
<div className="text-4xl opacity-40">📋</div>
<div className="text-sm font-medium text-slate-600 dark:text-slate-300">
릿
</div>
<div className="text-xs text-slate-500 opacity-80 dark:text-slate-400">
</div>
</div>
);
}
function ComponentSwitch({
block,
context,
}: {
block: TemplateComponent;
context: TemplateRenderContext;
}) {
const componentId = block.componentId;
const config = (block.config ?? {}) as Record<string, any>;
switch (componentId) {
case 'v2-table-list':
return (
<FcTable
fields={context.fields}
data={context.data}
loading={context.loading}
onRowSelect={context.onRowSelect}
config={config}
/>
);
case 'v2-table-search-widget':
return (
<FcSearch
fields={context.fields}
onSearch={context.onSearch}
config={config}
/>
);
case 'v2-button-primary': {
const buttonConfig: ButtonConfig = {
text: config.text ?? '버튼',
actionType: config.actionType ?? 'save',
variant: config.variant ?? 'default',
confirm: config.confirm,
};
const handler = resolveActionHandler(buttonConfig.actionType, context);
const needsRow =
buttonConfig.actionType === 'edit' ||
buttonConfig.actionType === 'delete';
const disabled = needsRow && !context.selectedRow;
return (
<div className="flex w-full items-center">
<FcButton config={buttonConfig} onClick={handler} disabled={disabled} />
</div>
);
}
case 'v2-text-display':
return (
<div
className="flex w-full items-center px-2 text-slate-700 dark:text-slate-200"
style={{
fontSize: config.fontSize ?? '0.85rem',
fontWeight: config.fontWeight ?? 600,
textAlign: (config.align ?? 'left') as any,
justifyContent:
config.align === 'center'
? 'center'
: config.align === 'right'
? 'flex-end'
: 'flex-start',
}}
>
{config.text ?? ''}
</div>
);
case 'v2-aggregation-widget': {
const label = config.label ?? 'KPI';
const value =
config.value ??
(typeof context.totalCount === 'number' ? context.totalCount : '—');
return (
<div className="flex w-full flex-col items-center justify-center gap-1 rounded border border-slate-200 bg-slate-50 p-2 dark:border-slate-700 dark:bg-slate-900">
<div className="text-[10px] uppercase tracking-wide text-slate-500 dark:text-slate-400">
{label}
</div>
<div className="text-xl font-bold text-indigo-600 dark:text-indigo-300">
{value}
</div>
</div>
);
}
default:
return (
<div className="flex w-full flex-col items-center justify-center gap-1 rounded border border-dashed border-slate-300 bg-slate-50 p-2 text-center dark:border-slate-700 dark:bg-slate-900">
<div className="font-mono text-[11px] text-slate-600 dark:text-slate-300">
{componentId}
</div>
<div className="text-[9px] text-slate-500 opacity-80 dark:text-slate-400">
Phase 2.2
</div>
</div>
);
}
}
function resolveActionHandler(
actionType: string | undefined,
context: TemplateRenderContext,
): (() => void) | undefined {
switch (actionType) {
case 'add':
return context.onAdd;
case 'edit':
return context.onEdit;
case 'delete':
return context.onDelete;
default:
return undefined;
}
}
@@ -1,805 +0,0 @@
"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>
);
}
@@ -1,460 +0,0 @@
"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;
}
@@ -0,0 +1,114 @@
/* ═══════════════════════════════════════════════════════════════════════════
withContainerQuery 경량 반응형 (Phase 2.1 Step B)
withContainerQuery HOC 의 `.v2-container-query-root` 루트 div 에는
container-type: inline-size + container-name: <id> 가 부착되어 있다.
이 파일은 경량 7개 v2-* 컴포넌트 각자의 container-name 에 대해
wide/narrow 단순 모드 분기를 CSS 레벨에서 준다.
원칙:
- 내부 로직/DOM 은 건드리지 않는다
- wrapper root 또는 보편적 태그 셀렉터 (button, input, label, svg 등) 만 사용
- 애매한 경우 font-size / padding / flex-direction 정도만 적용
- 완벽한 모드 분기는 Phase 2.2 에서 개별 컴포넌트 재작성 시 확장
═══════════════════════════════════════════════════════════════════════════ */
/* ── v2-button-primary — narrow 에서 텍스트 숨기고 아이콘만 ── */
@container v2-button-primary (max-width: 120px) {
.v2-container-query-root button {
min-width: 32px;
padding-left: 0.35rem !important;
padding-right: 0.35rem !important;
gap: 0 !important;
}
/* svg 아이콘은 살리고 텍스트 span 만 숨김 */
.v2-container-query-root button > span:not(.v2-btn-icon-slot),
.v2-container-query-root button > *:not(svg):not(.v2-btn-icon-slot) {
display: none !important;
}
}
/* ── v2-input — narrow 에서 라벨 위로 (flex-direction column) ── */
@container v2-input (max-width: 400px) {
.v2-container-query-root {
display: flex;
flex-direction: column;
}
.v2-container-query-root label {
display: block;
margin-bottom: 0.2rem;
text-align: left;
}
}
/* ── v2-select — narrow 에서 라벨 위로 ── */
@container v2-select (max-width: 400px) {
.v2-container-query-root {
display: flex;
flex-direction: column;
}
.v2-container-query-root label {
display: block;
margin-bottom: 0.2rem;
text-align: left;
}
}
/* ── v2-date — narrow 에서 range 세로 스택 ── */
@container v2-date (max-width: 400px) {
.v2-container-query-root {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.v2-container-query-root label {
display: block;
margin-bottom: 0.2rem;
text-align: left;
}
/* input 과 구분자가 한 줄일 때 세로 배치로 */
.v2-container-query-root .date-range,
.v2-container-query-root [class*="range"] {
flex-direction: column !important;
align-items: stretch !important;
}
}
/* ── v2-text-display — narrow 에서 font-size 1단계 축소 ── */
@container v2-text-display (max-width: 300px) {
.v2-container-query-root {
font-size: 0.82em;
}
.v2-container-query-root > div {
font-size: inherit !important;
}
}
/* ── v2-card-display — narrow 에서 cardsPerRow 1 (grid column 1) ── */
@container v2-card-display (max-width: 480px) {
/* gridTemplateColumns 가 inline style 로 설정되어 있어도,
자식 선택자로 강제 override */
.v2-container-query-root [style*="grid-template-columns"],
.v2-container-query-root [style*="gridTemplateColumns"],
.v2-container-query-root .card-container {
grid-template-columns: 1fr !important;
}
}
/* ── v2-aggregation-widget — narrow 에서 2열 그리드 ── */
@container v2-aggregation-widget (max-width: 480px) {
.v2-container-query-root [style*="grid-template-columns"],
.v2-container-query-root [style*="gridTemplateColumns"] {
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
}
.v2-container-query-root {
font-size: 0.9em;
}
}
/* ── 공통: narrow 에서 padding 약간 축소 ── */
@container (max-width: 300px) {
.v2-container-query-root {
/* 극단적으로 좁을 때 여백 최소화 */
}
}
@@ -1,6 +1,7 @@
"use client"; "use client";
import React from "react"; import React from "react";
import "./withContainerQuery.css";
/** /**
* withContainerQuery HOC (2026-04-10, Phase 1 Step 6 경량 부착) * withContainerQuery HOC (2026-04-10, Phase 1 Step 6 경량 부착)
+119
View File
@@ -487,3 +487,122 @@
display: flex; align-items: center; gap: .5rem; display: flex; align-items: center; gap: .5rem;
} }
.dash-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } .dash-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
/* ═══════════════════════════════════════════════════════════════════════════
CRUD 액션 버튼 바 + 폼 오버레이 (DashboardCard Phase 2.1)
═══════════════════════════════════════════════════════════════════════════ */
.dash-card-crud-actions {
display: flex;
align-items: center;
gap: .35rem;
padding: .15rem 0;
flex-wrap: wrap;
}
.dash-crud-btn {
display: inline-flex;
align-items: center;
gap: .25rem;
padding: .3rem .7rem;
border-radius: 7px;
border: 1px solid var(--v5-glass-border);
background: var(--v5-glass);
color: var(--v5-text-sec);
font-size: .65rem;
font-weight: 600;
cursor: pointer;
transition: all .15s;
}
.dash-crud-btn:hover:not(:disabled) {
border-color: var(--v5-primary);
color: var(--v5-primary);
background: rgba(108,92,231,.08);
}
.dash-crud-btn:disabled {
opacity: .4;
cursor: not-allowed;
}
.dash-crud-btn.primary {
border-color: transparent;
background: linear-gradient(135deg, var(--v5-primary), var(--v5-primary-light));
color: #fff;
box-shadow: var(--v5-glow-sm);
}
.dash-crud-btn.primary:hover:not(:disabled) {
filter: brightness(1.08);
color: #fff;
}
.dash-crud-btn.danger {
color: var(--v5-red);
}
.dash-crud-btn.danger:hover:not(:disabled) {
border-color: var(--v5-red);
background: rgba(255,71,87,.08);
}
.dash-crud-note {
margin-left: auto;
font-size: .55rem;
color: var(--v5-text-muted);
font-style: italic;
}
/* narrow 모드 (카드 폭 < 520px) — 버튼 바 축약 */
@container card (max-width: 520px) {
.dash-crud-btn {
padding: .28rem .5rem;
font-size: .6rem;
}
.dash-crud-btn > span {
display: none;
}
.dash-crud-note {
display: none;
}
}
/* 등록/수정 폼 오버레이 */
.dash-form-overlay {
position: absolute;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0,0,0,.35);
backdrop-filter: blur(6px) saturate(1.2);
animation: dashFormFade .2s ease-out;
}
.dash-form-modal {
width: min(640px, 90%);
max-height: 86%;
display: flex;
flex-direction: column;
border-radius: 14px;
border: 1px solid var(--v5-glass-border);
background: var(--v5-surface);
box-shadow: 0 24px 64px rgba(0,0,0,.35), var(--v5-glow-md);
overflow: hidden;
}
.dash-form-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: .7rem .95rem;
border-bottom: 1px solid var(--v5-border-subtle);
background: var(--v5-glass);
}
.dash-form-title {
font-size: .78rem;
font-weight: 700;
color: var(--v5-text);
letter-spacing: -.01em;
}
.dash-form-body {
flex: 1;
overflow: auto;
padding: .85rem;
}
@keyframes dashFormFade {
from { opacity: 0; transform: scale(.98); }
to { opacity: 1; transform: scale(1); }
}
+38 -184
View File
@@ -149,91 +149,8 @@ export type ComponentType =
| 'pagination'; // 페이지네이션 | 'pagination'; // 페이지네이션
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Position — ⚠️ DEPRECATED 블록 (2026-04-10 폐기 결정) // Position — 단일 자유배치 모델은 §7 FreePosition 참고
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
//
// 아래 타입/함수/상수는 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 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;
}
/**
* @deprecated Phase 2 DashboardCard 재작성 시 제거 예정.
* 단일 자유배치 모델로 union 분기가 사라졌다. FreePosition 을 사용할 것.
*/
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';
/** /**
* 컴포넌트의 데이터 소스 바인딩. * 컴포넌트의 데이터 소스 바인딩.
@@ -262,13 +179,14 @@ export interface Component {
label: string; label: string;
// ─── 위치 (빌더가 관리) ─── // ─── 위치 (빌더가 관리) ───
//
// Component 인터페이스는 INVYONE 규격 v1.0 의 초기 드래프트였고, 자유배치
// 단일 모델 확정 이후 TemplateComponent (§7) 가 실질 대체이다. 이 인터페이스는
// 아직 참조 코드가 없어 즉시 삭제 가능한 상태이며, position 필드는 FreePosition
// 단일 모델로 정리되었다. 자세한 내용은 §7 참조.
/** /** 자유배치 위치 (px) */
* Template.kind에 따라 해석이 달라짐. position?: FreePosition;
* - business → GridPosition (col / colSpan / row / responsive)
* - canvas → AbsolutePosition (x / y / w / h)
*/
position: ComponentPosition;
// ─── 데이터 바인딩 ─── // ─── 데이터 바인딩 ───
@@ -704,15 +622,16 @@ export interface Template {
templateId: string; templateId: string;
/** 화면 이름 */ /** 화면 이름 */
name: string; name: string;
/** /** 카드 헤더 아이콘 */
* @deprecated Phase 2 에서 제거. 단일 자유배치 모델로 kind 분기 없음. icon?: string;
* 기존 데이터 호환용으로만 optional 유지. 새 Template 은 이 필드를 쓰지 않는다. /** 카드 헤더 배지 (예: 'ERP · 영업') */
*/ badge?: string;
kind?: TemplateKind;
/** 분류 (예: sales, production, purchase) */ /** 분류 (예: sales, production, purchase) */
category: string; category: string;
/** 화면 설명 */ /** 화면 설명 */
description?: string; description?: string;
/** 카드로 배치될 때 기본 사이즈 */
defaultSize?: { w: number; h: number };
// ─── 데이터 ─── // ─── 데이터 ───
@@ -721,23 +640,20 @@ export interface Template {
/** 필드 정의 목록 — 모든 뷰가 이 하나를 공유한다 */ /** 필드 정의 목록 — 모든 뷰가 이 하나를 공유한다 */
fields: FieldConfig[]; fields: FieldConfig[];
// ─── 3뷰 ─── // ─── 3뷰 (자유배치 단일 모델) ───
/** 목록 / 등록 / 수정 세 가지 뷰 */ /** 목록 / 등록 / 수정 세 가지 뷰 */
views: { views: TemplateViews;
/** 목록 화면 */
list: ViewConfig;
/** 등록 팝업 */
create: ViewConfig;
/** 수정 팝업 (create 상속 가능) */
edit: ViewConfig;
};
// ─── 연결 ─── // ─── 연결 ───
/** 컴포넌트 간 DataPort 연결 목록 */ /** 컴포넌트 간 DataPort 연결 목록 */
connections: Connection[]; connections: Connection[];
/** 카드 간 통신 포트 정의 (선택) */
inputs?: DataPortDef[];
outputs?: DataPortDef[];
// ─── 메타 ─── // ─── 메타 ───
/** 회사 코드 */ /** 회사 코드 */
@@ -753,86 +669,12 @@ export interface Template {
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// 6. 컴포넌트 기본 grid 배치 (섹션 10) // 6. 카드 엔진 v2 — 자유배치 단일 모델 (2026-04-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 // 진실의 원천: notes/gbpark/2026-04-10-card-engine-final-spec.md
// //
// 이 섹션의 타입들이 Phase 1 이후 INVYONE 의 유일한 템플릿/대시보드 모델이다. // 이 섹션의 타입들이 Phase 1 이후 INVYONE 의 유일한 템플릿/대시보드 모델이다.
// 위쪽 §6 까지의 Component/GridPosition/TemplateKind/DEFAULT_COMPONENT_LAYOUTS
// 등은 Phase 1 Step 5 에서 제거 예정(현재는 폐기 예정 코드와 호환용으로만 남음).
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
/** /**
@@ -866,10 +708,12 @@ export interface ViewTrigger {
} }
/** /**
* Template 안에 자유배치된 컴포넌트 하나. * Template 안에 배치된 컴포넌트 하나.
* *
* 컴포넌트 종류(componentId) 는 ComponentRegistry 의 v2-* ID 를 참조한다. * 카드 내부는 flex-column 자동 레이아웃이므로 px 좌표 대신 `order` 와
* 위치는 FreePosition 단일 모델. 타입별 옵션은 config 에 자유 형태로 들어간다. * 선택적 `row` 로만 위치가 결정된다. 카드 폭이 변하면 runtime 이 자동
* 재배치(줄바꿈/세로 스택)한다. FreePosition 은 오직 대시보드에 카드를
* 배치하는 상위 수준(Card.position)에서만 사용된다.
*/ */
export interface TemplateComponent { export interface TemplateComponent {
/** 인스턴스 ID */ /** 인스턴스 ID */
@@ -884,8 +728,18 @@ export interface TemplateComponent {
/** 빌더에서 표시되는 라벨 (선택) */ /** 빌더에서 표시되는 라벨 (선택) */
label?: string; label?: string;
/** 캔버스 안에서의 위치 (px 자유배치) */ /**
position: FreePosition; * 세로 스택 내 순서. 작을수록 위. 0 부터 시작.
* 같은 `row` 값을 가진 연속 블록끼리는 가로 나열되고, 이때 `order`
* 는 그 행 안에서 왼쪽에서 오른쪽으로의 순서도 함께 결정한다.
*/
order: number;
/**
* 같은 값을 공유하는 연속된 블록들을 flex-row 로 묶는 키.
* undefined 면 단독 행(기본). 숫자값 자체는 의미 없고 키 역할만 한다.
*/
row?: number;
/** /**
* 컴포넌트별 설정 — 모드/옵션/컬럼 정의 등. * 컴포넌트별 설정 — 모드/옵션/컬럼 정의 등.
@@ -0,0 +1,273 @@
# INVYONE 카드 엔진 Phase 2.1 — 구현 로그
**작업일**: 2026-04-11
**작업자**: gbpark + Claude
**이전 로그**: `notes/gbpark/2026-04-10-card-engine-phase1-log.md`
**기반 스펙**: `notes/gbpark/2026-04-10-card-engine-final-spec.md`
---
## 0. 요약
Phase 2.1 = "수주관리 엔드투엔드 MVP". 네 가지 레이어(컴포넌트 → 화면 디자이너 → 템플릿 → 대시보드)가 전부 엔드투엔드로 기본 작동하는 상태를 목표.
| Step | 제목 | 상태 |
|---|---|---|
| A | 레거시 타입 제거 (GridPosition 등) | ✅ |
| B | 경량 7개 v2-* @container 모드 분기 | ✅ |
| C | TemplateBuilder 실렌더링 + 백엔드 API + FieldConfig 편집 UI | ✅ |
| D | DashboardCard 재작성 + 기존 dash 인프라 유지 | ✅ |
| E | 수주관리 PoC (사용자 수동 검증) | ⏳ 사용자 검증 대기 |
**tsc 결과**: 작업 범위 기준 에러 0. 전체 에러 카운트 3381 (Phase 1 완료 시점과 동일, 증가 없음).
---
## 1. 방향 변경 (Step D) — 초기 지시와 다른 결정
초기 Phase 2.1 지시문은 "frontend/store/dashboardStore.ts 신규 + localStorage 재작성" 이었으나,
D-0 파악 결과 **이미 작동하는 백엔드 API 기반 대시보드 시스템**이 존재해 방향 변경:
-**폐기**: localStorage 재작성, `store/dashboardStore.ts` 신규
-**유지**: `stores/dashboardStore`, 백엔드 API (`lib/api/dashMenu.ts`), `DashboardSidebar`, `DashboardCanvas`, `TemplateLibraryModal`, `CardSettingsPanel`, `ControlMode` 전부 보존
-**재작성**: `DashboardCard.tsx` 하나만 — 레거시 타입 제거 + FreePosition + Template 기반 CRUD
이유: 기존 시스템은 이미 서버 DB 영속화, 제어 모드(Phase 3 예정 기능의 일부), 카드 설정 패널을 구현해 놓은 상태. 이를 폐기하면 수 주의 손실.
### Phase 3 기능 처리 3단 전략 — 실제 사용 결과
사용자가 준 전략은 (1) 필드명 수준 교체 → (2) 함수 단위 간소화 → (3) any 캐스팅. **실제로는 1단계조차 불필요했음**: `ControlMode/CardSettingsPanel/FlowViewer/RuleBuilder` 전부 `FieldConfig` 만 import 하고 카드의 `position.col/row` 같은 grid 필드에 접근하지 않음. DashboardCard 재작성만으로 Phase 3 기능은 아무 영향 없이 동작.
---
## 2. 수정·생성 파일
### 타입
- **수정** `frontend/types/invyone-component.ts`
- 삭제: `GridPosition`, `AbsolutePosition`, `ComponentPosition`, `ResponsiveGridOverride`, `isGridPosition()`, `isAbsolutePosition()`, `TemplateKind`, `DEFAULT_COMPONENT_LAYOUTS`, `CANVAS_KEYWORDS`
- `Template.kind` 필드 제거
- `Template.views` 타입을 `TemplateViews` 로 교체 (자유배치 단일 모델)
- `Template.icon`, `Template.badge`, `Template.defaultSize`, `Template.inputs`, `Template.outputs` 추가 (Phase 1 log 에서 any 캐스팅으로 우회하던 것들)
- `Component.position` 필드를 `FreePosition?` 으로 정리 (Component 인터페이스 자체는 참조 0 이라 정리 수준)
### 대시보드 카드
- **재작성** `frontend/components/dash/DashboardCard.tsx` (646줄 → 386줄)
- 레거시 타입 의존 완전 제거
- `FcSearch + FcTable + FcPagination` 기본 CRUD UI
- 모달 기반 `FormOverlay` (등록/수정) + `FcForm`
- `fcInsert / fcUpdate / fcDelete` 와 백엔드 연결
- `containerType: inline-size + containerName: card` 부착 (내부 v2-* 가 폭 감지)
- Template 에 `views.list.components` 가 있으면 "Phase 2.2 에서 커스텀 배치 활성화" 안내 (현 Phase 범위 아님)
- **수정** `frontend/styles/dashboard.css`
- `.dash-card-crud-actions`, `.dash-crud-btn(.primary/.danger)`, `.dash-crud-note` 추가
- `.dash-form-overlay / .dash-form-modal / .dash-form-head / .dash-form-body` 추가 (등록/수정 모달)
- 카드 @container 400px 이하에서 버튼 텍스트 숨김 (아이콘만)
### 경량 컴포넌트 반응형 (Step B)
- **신규** `frontend/lib/registry/hoc/withContainerQuery.css`
- `@container v2-button-primary (max-width: 120px)` → 버튼 텍스트 숨김
- `@container v2-input (max-width: 400px)` → 라벨 위로 (flex column)
- `@container v2-select (max-width: 400px)` → 동일
- `@container v2-date (max-width: 400px)` → range 세로 스택
- `@container v2-text-display (max-width: 300px)` → font-size 축소
- `@container v2-card-display (max-width: 480px)` → gridTemplateColumns 1fr 강제
- `@container v2-aggregation-widget (max-width: 480px)` → 2열 그리드
- **수정** `frontend/lib/registry/hoc/withContainerQuery.tsx`
- `import "./withContainerQuery.css"` 추가 (HOC 사용 시 자동 적용)
### TemplateBuilder (Step C)
- **신규** `frontend/components/template-builder/LucideIcon.tsx`
- lucide-react 의 모든 아이콘을 이름 기반 동적 렌더링. 캐시로 반복 조회 최적화.
- **신규** `frontend/components/template-builder/DesignPreview.tsx`
- 각 v2-* 컴포넌트의 디자인 모드 미리보기 (버튼, 입력, 테이블, 검색 등)
- Phase 2.2 에서 실제 런타임 컴포넌트로 교체 예정
- **신규** `frontend/components/template-builder/FieldConfigPanel.tsx`
- 우측 "테이블" 탭: primaryTable 드롭다운 + FieldConfig 편집 UI
- `/api/meta/tables`, `/api/meta/tables/:name/fields` 호출
- 필드별 label / type / visible / required / searchable 토글
- **신규** `frontend/components/template-builder/template-builder.css`
- DesignPreview 전용 스타일 (tpl-preview-*)
- **수정** `frontend/components/template-builder/TemplateBuilder.tsx`
- `useRegistryPalette`: lucide 이름 보존 (short 문자열만 이모지, 긴 문자열은 lucide 렌더 대상)
- `PaletteIcon` 헬퍼로 팔레트 아이템의 아이콘 실렌더
- `CanvasBlock`: placeholder 텍스트 → `DesignPreview` 로 교체
- `SidePanel`: "테이블" 탭 추가 (`FieldConfigPanel` 호스트)
- `handleSave`: localStorage → 백엔드 API (`insertTemplate` / `updateTemplate`) 전환
- `handlePublish` + "게시" 버튼 (`publishTemplate`)
- `templateStatus` 배지 ("초안" / "게시됨")
- `serverTemplateId` state 로 insert/update 분기를 깔끔하게 (localStorage 복원 혼란 제거)
- `import "./template-builder.css"`
- **수정** `frontend/components/template-builder/store/templateBuilderStore.ts`
- `toTemplate`: `kind: "business"` 제거, `views as unknown as` 캐스팅 제거, `icon/badge/defaultSize` 전달
- `fromTemplate`: `tpl.icon / tpl.badge / tpl.defaultSize``(tpl as any)` 캐스팅 없이 접근
- **수정** `frontend/app/(main)/admin/builder/page.tsx`
- `useSearchParams``?id=<templateId>` 쿼리 파싱 → TemplateBuilder 에 templateId prop 전달
- `Suspense` 감싸기 (Next 15 요구)
---
## 3. 주요 결정 사항 (사용자 협의)
### 결정 1 — Step D 방향 (기존 인프라 유지)
**옵션**: (a) localStorage 재작성 (지시문 그대로) / (b) 기존 백엔드 API 유지 + DashboardCard만 재작성 / (c) 하이브리드
**결정**: **(b) 기존 유지 + 최소 수정**
**이유**: 이미 작동하는 서버 기반 대시보드 시스템 보존 우선. localStorage 로 다운그레이드는 퇴보.
### 결정 2 — Step E PoC 렌더 방식
**옵션**: (a) TemplateRenderer + ComponentRegistry 로 v2-* 실 렌더 / (b) DefaultCardContent 유지 (FcTable/FcSearch/FcForm)
**결정**: **(b) FcTable/FcSearch/FcForm 기본 CRUD**
**이유**: v2-* 컴포넌트는 VEX ScreenDesigner 전제의 props 시그니처(`component: ComponentData`, `isDesignMode`, `form_data` 등)를 가져 INVYONE 대시보드 카드에 그대로 끼우려면 어댑터 필요. Phase 2.1 "기본이 돈다" MVP 범위 밖.
**결과**: Template.views.list.components 에 배치가 있으면 "Phase 2.2 에서 활성화" 안내만 표시하고, 실질 렌더는 FcTable 기반.
### 결정 3 — lucide 아이콘 렌더링
**옵션**: (a) `import * as LucideIcons` 정적 import / (b) `dynamic-icon-imports` 동적 로드 / (c) 문자열/이모지 폴백만
**결정**: **(a) 정적 import + 캐시**
**이유**: tree-shaking 손실은 있지만 Phase 2.1 MVP 에서는 수용 가능. API 단순.
### 결정 4 — 경량 7개 모드 분기 방식
**옵션**: (a) 각 컴포넌트 파일마다 별도 CSS / (b) 공통 withContainerQuery.css 한 파일 / (c) HOC 확장 (JS ResizeObserver + data-mode)
**결정**: **(b) 공통 CSS 한 파일**
**이유**: 유지 간단. 컨테이너 이름으로 구분해 충돌 없음. 내부 DOM 에 영향 최소 (wrapper level 만). 완벽 구현은 Phase 2.2 에서 각 컴포넌트 개별 재작성 시.
---
## 4. 사용자 수동 검증 체크리스트 (Phase 2.1 종료 조건)
**Step E "수주관리 PoC" 는 사용자 수동 검증 영역**. 아래 순서로 진행.
### (A) 빌드 + 기본 sanity
1. `cd frontend && docker compose -f ../docker/dev/docker-compose.invyone.yml restart frontend backend-spring` (또는 로컬 `npm run dev`)
2. 브라우저에서 접속
3. 확인:
- [ ] `/test-card-responsive` 가 여전히 작동 (Phase 1 보장)
- [ ] 콘솔에 critical 에러 없음
- [ ] 다크 모드 UI 정상
### (B) `/admin/builder` TemplateBuilder 검증
1. `/admin/builder` 접속 → TemplateBuilder UI 표시
2. 좌측 팔레트 확인:
- [ ] 컴포넌트 아이콘이 lucide 로 실제 렌더됨 (◼ 폴백 아님)
3. 우측 "테이블" 탭 클릭:
- [ ] 기본 테이블 드롭다운에 테이블 목록 표시
- [ ] 테이블 선택 → 필드 목록 자동 로드
- [ ] 각 필드 label 편집, type 변경, 표시/필수/검색 토글 가능
4. 캔버스 작업:
- [ ] 팔레트에서 컴포넌트 드래그 → 캔버스 드롭 → **placeholder 아닌 DesignPreview** 렌더
- [ ] v2-button-primary 는 파란 버튼 실 형태, v2-text-display 는 텍스트, v2-input 은 input 박스, v2-aggregation-widget 은 KPI, v2-table-list 는 3열 테이블 미리보기 등
- [ ] 블록 이동/리사이즈 동작
5. 저장/게시:
- [ ] 상단 "템플릿 이름" 입력 → "저장" 클릭 → "템플릿이 등록되었습니다" 토스트
- [ ] 저장 후 상단 배지가 "초안" 상태
- [ ] "게시" 버튼 클릭 → "템플릿이 게시되었습니다" 토스트, 배지 "게시됨" 전환
### (C) 수주관리 Template 작성 (PoC 핵심)
1. `/admin/builder` 신규 세션 (URL 에 ?id 없음)
2. 상단 툴바에서 템플릿 이름 "수주관리" 입력, 카테고리 "sales" 입력
3. 우측 "테이블" 탭 → DB 에서 수주 관련 테이블 선택 (예: ORDER_MASTER 또는 존재하는 수주 테이블)
4. FieldConfig 가 자동 로드되면, 필요한 필드만 visible=true, 검색 대상 searchable=true 로 토글
5. 우측 "메타" 탭 → 아이콘 📋, 배지 "ERP · 영업"
6. 캔버스에는 컴포넌트 배치 없음 (Phase 2.1 MVP 는 기본 CRUD, 자유배치는 Phase 2.2)
7. "저장" → "게시"
8. URL 이 `/admin/builder?id=tpl_xxx` 로 바뀌지 않아도 store.templateId 와 serverTemplateId 에 내부 저장됨 (URL 동기화는 Phase 2.2)
### (D) 수주관리 대시보드 배치 + CRUD
1. `/dashboard` (또는 사이드바에서 기존 대시보드 진입)
2. "+ 새 대시보드" → "영업 대시보드" 생성
3. 빈 캔버스에서 "+ 템플릿 추가" → 라이브러리 모달
4. 확인:
- [ ] 모달에 "수주관리" 템플릿 표시 (published 만 노출)
- [ ] 카테고리 필터 "영업/CRM" 에도 표시
5. 클릭 → 대시보드에 카드 배치됨
6. 카드 헤더:
- [ ] 아이콘 + "수주관리" 이름 + "sales" 배지
- [ ] 새로고침 / 설정 / 접기 / 삭제 버튼 (editMode 일 때)
7. 카드 본문:
- [ ] FcSearch 검색 필터
- [ ] [등록] [수정] [삭제] CRUD 액션 버튼 바
- [ ] FcTable 수주 목록 (fcList 호출)
- [ ] FcPagination
8. CRUD 작동:
- [ ] "등록" 클릭 → FormOverlay 모달 → FcForm 입력 → 저장 → "등록되었습니다"
- [ ] 테이블 행 선택 → "수정" 클릭 → FormOverlay (기존 값 로드) → 수정 → 저장
- [ ] 행 선택 → "삭제" → confirm → 삭제 → 목록 새로고침
9. 반응형 전환:
- [ ] 카드 우하단 핸들로 드래그 → 카드 폭 400px 이하 → CRUD 버튼 바의 텍스트 숨김 (아이콘만)
- [ ] FcSearch 의 입력 배치는 FcSearch 자체의 @container 쿼리에 의존 (Phase 2 이후 개별 확장)
10. 새로고침 후:
- [ ] 대시보드 카드 위치/크기 유지 (백엔드 저장)
### (E) ControlMode / CardSettingsPanel sanity
1. 대시보드에서 "편집 모드" 진입 후 제어 모드 버튼 클릭
- [ ] 제어 모드 진입 (기존 Phase 3 기능)
- [ ] 카드의 레거시 타입 제거 때문에 깨진 건 없음
2. 카드의 "설정" 버튼 클릭
- [ ] CardSettingsPanel 열림 (컬럼 표시/숨김 토글)
### 실패 시 디버깅
- 백엔드 응답 실패 → network 탭 확인, `docker compose logs backend-spring` 로 스택
- fcDelete 가 실패 → pk 컬럼이 정확히 FieldConfig.pk = true 인지, 백엔드 /api/data/:table/delete 가 { [pk]: value } 포맷을 받는지 확인 필요
- Template 게시 후 라이브러리 모달에 안 보임 → `getTemplateList({ status: 'published' })` 결과 확인
---
## 5. Phase 2.2 이월 항목
### 필수
- [ ] **TemplateRenderer 실 구현**: Template.views.list.components 를 FreePosition + ComponentRegistry 기반으로 자유배치 렌더. v2-* 컴포넌트의 VEX 포맷 props 어댑터 필요
- [ ] **v2-* 런타임 컴포넌트 props 표준화**: INVYONE DashboardCard 에서도 design 모드와 runtime 모드 양쪽 렌더 가능하도록
- [ ] **경량 7개 컴포넌트 내부 DOM 기반 반응형 개선**: 현재 CSS 한 파일의 wrapper level 분기는 정확도 한계. 각 컴포넌트 개별 재작성 시 wide/narrow 모드 내부 로직 추가
- [ ] **TemplateBuilder URL 동기화**: insertTemplate 성공 후 `/admin/builder?id=tpl_xxx` 로 URL 갱신 (`router.replace`)
### 우선순위 2
- [ ] 우선순위 2 v2-* 마이그레이션: BOM, 출하, 피벗, 타임라인, 프로세스/작업 표준, 결재 단계
- [ ] 우선순위 3 v2-* 마이그레이션: 랙 구조, 번호 규칙, 카테고리 관리자, 구분선, 파일, 미디어
### 선택
- [ ] FieldConfigPanel 개선: options/ref/format/computed/sortable 등 나머지 FieldConfig 속성 편집
- [ ] TemplateBuilder 팔레트 카테고리 필터 & 검색 개선
- [ ] 자동 뷰 생성: 등록 버튼 추가 시 create 뷰 placeholder 자동 생성 (store 는 이미 감지 로직 있음)
- [ ] Template 버전 관리 UI
---
## 6. 알려진 한계
1. **DashboardCard 는 아직 Template.views.list.components 를 실제로 렌더하지 않음** — 배치된 v2-* 컴포넌트는 무시되고 기본 CRUD(FcTable 등) 로 대체. Phase 2.2 에서 TemplateRenderer 구현 시 해소.
2. **TemplateBuilder 캔버스의 DesignPreview 는 정적 미리보기** — 실제 데이터가 흐르지 않고 wire-frame 수준. 마찬가지로 Phase 2.2.
3. **경량 7개 @container 쿼리는 wrapper level 만** — 내부 DOM 에 깊이 관여하지 않아 일부 컴포넌트는 narrow 모드가 시각적으로 거의 변화 없음. Phase 2.2 개별 재작성 시 보강.
4. **Template.icon / badge / defaultSize** 는 백엔드 TEMPLATES 테이블의 컬럼이 아니라 views 또는 meta 안에 잠재적으로 섞일 수 있음. 현재는 Template 인터페이스에만 있고 저장 시 누락될 수 있음 — Phase 2.2 에서 테이블 스키마 확장 또는 views 안 embed 결정.
5. **BlockProperties 의 config JSON 편집기는 텍스트 기반** — v2-* 컴포넌트별 config 패널 통합은 Phase 2.2.
6. **templateId URL 동기화 부재** — 새 템플릿 저장 후에도 URL 은 `/admin/builder` 그대로. 브라우저 새로고침 시 신규 세션으로 초기화됨. Phase 2.2 에서 `router.replace` 로 동기화.
---
## 7. 작업 범위 / 비작업 범위
### 작업함
- Step A: 레거시 타입 완전 제거 + DashboardCard 재작성
- Step B: 경량 7개 반응형 CSS 추가
- Step C: TemplateBuilder 실렌더링 (DesignPreview) + 백엔드 API + FieldConfig 편집 UI + 게시
- Step D: 기존 인프라 유지 + DashboardCard 재작성 (=Step A 일부)
### 작업 안 함 (Phase 2.2 이후)
- TemplateRenderer 실 렌더링 (ComponentRegistry 어댑터)
- v2-* 컴포넌트 props 표준화
- 경량 7개 컴포넌트 내부 재작성 (모드 분기 정밀)
- 우선순위 2/3 v2-* 마이그레이션
- 대시보드 시스템 재작성 (localStorage → 이미 백엔드 기반이라 불필요)
- AppLayout 전역 사이드바에 대시보드 목록 중복 (결정 2, 중복 방지)
---
## 끝
Phase 2.1 코드 작업 종료 (2026-04-11).
`§4 사용자 수동 검증` 통과 후 Phase 2.1 전체 완료.
Phase 2.2 는:
1. TemplateRenderer 실 구현 (ComponentRegistry 어댑터)
2. v2-* 우선순위 2/3 마이그레이션
3. 경량 7개 모드 분기 정교화
4. TemplateBuilder URL 동기화 + 기타 폴리싱
@@ -0,0 +1,256 @@
# INVYONE 카드 엔진 Phase 2.1 재작업 — 구현 로그
**작업일**: 2026-04-11
**작업자**: gbpark + Claude
**이전 로그**: `notes/gbpark/2026-04-11-card-engine-phase2.1-log.md`
**기반 스펙**: `notes/gbpark/2026-04-10-card-engine-final-spec.md`
**레퍼런스 구현**: `frontend/app/test-card-responsive/page.tsx` (Phase 1 반응형 증명)
---
## 0. 재작업 배경
Phase 2.1 의 TemplateBuilder / TemplateRenderer 가 **카드 내부 자유배치 (FreePosition / position: absolute)** 로 구현되었는데, 이는 Phase 1 에서 `test-card-responsive/page.tsx` 로 "반응형 보장" 이라 증명했던 모델(자동 레이아웃 = Tailwind flex/grid + 세로 스택) 과 모순된다.
결과적으로 카드 폭이 줄면 내부 블록이 px 좌표로 고정돼 **겹침 / 잘림 / 가로 스크롤** 이 발생. Phase 1 의 "반응형 된다" 는 약속이 runtime 에서 지켜지지 않음.
→ Phase 2.1 전체를 폐기하지 않고, **빌더/렌더러/타입** 세 층을 자동 레이아웃 모델로 재작성하기로 결정 (Step D DashboardCard + Step B 경량 반응형 + Step C FieldConfigPanel 등 나머지는 그대로 유지).
### 핵심 방향 전환
| 항목 | Before (Phase 2.1 초안) | After (재작업) |
|---|---|---|
| 카드 내부 위치 모델 | `FreePosition { left, top, width, height }` | `order: number` + `row?: number` |
| 렌더러 레이아웃 | `position: absolute` + CSS var | `flex flex-col gap-2` + row 그룹핑 `flex flex-row flex-wrap` |
| 좁은 카드 폭 대응 | `@container card (max-width: 599px)` 에서 세로 스택 강제 (임시방편) | 처음부터 세로 스택. 카드 폭 변해도 자동 줄바꿈/분배 |
| 빌더 캔버스 | absolute 자유배치 + 드래그/리사이즈 | 세로 리스트 + 드롭 존 + 드래그 재배열 |
| 가로 나열 수단 | 좌표로 옆에 배치 | `row` 키 — 같은 숫자 키를 가진 **연속** 블록이 한 줄 |
> **카드 자체의 대시보드 내 배치**는 여전히 `Card.position: FreePosition` (자유배치) 유지. 즉 **대시보드 → 카드** 는 자유배치, **카드 → 내부** 는 자동 레이아웃. 두 층의 모델이 다르다는 점이 이번 재작업의 요지.
---
## 1. 수정·생성·삭제 파일
### 타입
- **수정** `frontend/types/invyone-component.ts`
- `TemplateComponent.position: FreePosition` 필드 제거
- `TemplateComponent.order: number` (필수) 추가 — 0 부터 시작하는 세로 스택 순서
- `TemplateComponent.row?: number` (선택) 추가 — 같은 키를 가진 연속 블록을 flex-row 로 묶는 그룹핑 키
- `FreePosition` 인터페이스 자체는 `Card.position` 에서 여전히 쓰이므로 유지
### 런타임 렌더러
- **재작성** `frontend/components/dash/TemplateRenderer.tsx`
- `absolute` + CSS variable 방식 완전 제거
- `flex flex-col gap-2 overflow-auto p-2` 로 세로 스택
- `groupByRow` 헬퍼: `order` 정렬 후 `row` 가 동일한 연속 블록들을 하나의 row 배열로 묶음. `undefined` row 는 항상 단독 행
- 각 row 는 `flex flex-row flex-wrap gap-2`, 각 블록 wrapper 는 `flex-1 min-w-0` 로 가로 자동 분배 + 폭 부족 시 줄바꿈
- `normalizeBlocks` 헬퍼: 구 포맷(`position` 기반, `order` 누락) 블록의 **런타임 호환 레이어**. `order` 가 없으면 배열 인덱스를 부여해 순서만 보장
- `ComponentSwitch` (v2-table-list / search-widget / button-primary / text-display / aggregation-widget + 기본 fallback) 는 기존 로직 유지. v2-button-primary 의 `h-full flex items-center justify-center` 래핑 제거 (고정 높이 전제 제거)
- **삭제** `frontend/components/dash/TemplateRenderer.css`
- 모든 규칙이 `position: absolute` + `@container card (max-width: 599px)` narrow 강제 기반이었고, 재작성된 렌더러가 순수 Tailwind 로 자동 레이아웃을 처리하므로 불필요
- **유지** `frontend/components/dash/DashboardCard.tsx`
- TemplateRenderer 호출부는 변경 없음
- `dash-card-body``containerType: inline-size + containerName: card` 도 유지 (`styles/dashboard.css``@container card` 규칙이 여전히 카드 헤더/미니뷰에서 활용됨)
### 빌더
- **재작성** `frontend/components/template-builder/store/templateBuilderStore.ts`
- `updateBlockPosition`, `gridSettings`, `setGridSettings`, `resetGrid`, `selectedIds`, `setSelectedIds` 전부 제거
- `addBlock(componentId, config?, opts?: { insertAt?: number })` — 시그니처에서 position 파라미터 제거, insertAt 옵션으로 삽입 위치 지정. 미지정 시 끝에 추가
- `removeBlock(id)` — 삭제 후 `reindex` 로 order 자동 재계산
- `moveBlockUp(id)` / `moveBlockDown(id)` — 한 칸 이동
- `reorderBlock(id, targetIndex)` — 임의 위치로 이동. 드래그 드롭이 사용
- `setBlockRow(id, row?)` — 우측 속성 패널에서 가로 나열 키 편집
- `cloneSnapshot` / `undo` / `redo` 에서 `position` 복제 코드 제거
- `fromTemplate`: `migrateBlocks` 헬퍼로 **구 포맷 자동 마이그레이션**`position` 필드 drop, `order` 누락 시 인덱스 부여, 상대 순서 유지 후 `reindex`
- `toTemplate`: 그대로 (Template.views 구조는 변하지 않음, 내부 블록만 새 포맷으로 직렬화)
- `commitBlocks` 내부 헬퍼로 블록 변경 + 스냅샷 기록을 한 곳으로 집약
- **재작성** `frontend/components/template-builder/TemplateBuilder.tsx`
- `CanvasDragState { move / resize }` 타입 + `handleBlockMouseDown` + `snapValue` + `MIN_BLOCK_SIZE` + `gridLines` + `GridSettings` 탭 전부 삭제
- `CanvasList` 추가 — 중앙 캔버스를 세로 리스트로 렌더. 최상단/각 블록 사이/최하단에 `DropZone` 삽입
- `DropZone` 추가 — `onDragOver`/`onDragEnter` 시 active 하이라이트, `onDrop``handleDropAt(index, e)` 호출
- `handleDropAt` 추가 — `dataTransfer` 에 팔레트 아이템이면 `addBlock(..., { insertAt: index })`, 블록 ID 면 `reorderBlock(blockId, index)`
- `BlockRow` 추가 — 각 블록을 `draggable` div 로 렌더. 드래그 핸들 아이콘 + 라벨 + `#순서` + `row` 배지 + `DesignPreview` 재사용 + 우측에 `↑` / `↓` / `✕` `IconButton` 세로 스택
- `IconButton` 추가 — 소형 아이콘 버튼 헬퍼
- `EmptyCanvas` 추가 — 빈 뷰 안내
- `BlockProperties` 재작성 — `LabeledNumber` 4개(`left`/`top`/`width`/`height`) 삭제, 대신 `↑ 위로`/`↓ 아래로` 버튼 + `#순서` 배지 + `row` 키 숫자 input + 기존 config JSON 편집기 + 삭제 버튼
- `SidePanel` 탭에서 "격자" 탭 제거 (props / table / meta 3개로 축소)
- `MetaForm` 에서도 `defaultSize` 편집 삭제 (자동 레이아웃에서 의미 없음)
- `Toolbar` 에서 `격자` / `스냅` 체크박스 삭제
- 드래그 MIME type 2종 정의: `application/x-template-component` (팔레트 → 캔버스), `application/x-template-block` (블록 재배열)
- `Delete` / `Ctrl+Z` / `Ctrl+Y` / `Ctrl+S` 단축키는 그대로 유지
- **유지** `frontend/components/template-builder/DesignPreview.tsx`
- 원래부터 `block.position` 을 참조하지 않음. 변경 없음
- **유지** `frontend/components/template-builder/FieldConfigPanel.tsx`, `LucideIcon.tsx`, `template-builder.css`
- **유지** `frontend/app/(main)/admin/builder/page.tsx``templateId` prop 전달 방식 그대로
---
## 2. 자동 레이아웃의 핵심 계약
### 2.1 TemplateComponent 의 새 위치 필드
```typescript
export interface TemplateComponent {
id: string;
componentId: string;
/** 세로 스택 내 순서. 작을수록 위. 0부터 시작. */
order: number;
/** 같은 값 공유 → flex-row 그룹. undefined 면 단독 행. */
row?: number;
config: Record<string, any>;
// ...
}
```
### 2.2 TemplateRenderer 의 레이아웃 규칙
1. `template.views.list.components``normalizeBlocks` 로 정규화 (구 포맷 호환)
2. `groupByRow``row` 가 동일한 연속 블록을 하나의 row 배열로 묶음
3. 전체를 `flex flex-col gap-2 w-full h-full overflow-auto p-2` 로 감쌈
4. 각 row 를 `flex flex-row flex-wrap gap-2 w-full`
5. 각 블록 wrapper 는 `flex-1 min-w-0` — 가로 폭 자동 분배 + 내용 넘치면 줄바꿈
> ★ `min-w-0` 이 없으면 flex 기본값 `min-width: auto` 때문에 자식(테이블 등) 이 자신의 content width 를 주장하며 overflow 를 일으킨다. 이 한 줄이 "카드 폭 줄여도 깨지지 않는다" 의 핵심.
### 2.3 row 그룹핑 규칙 (사용자 정신 모델)
- `row` 를 비워두면 단독 행 (기본, 가장 자주 쓰는 형태)
- 같은 숫자를 연속 블록 N 개에 주면 한 줄 안에 가로 나열됨
- 숫자값 자체는 의미 없음 — **키** 로만 작동
- `order` 는 행 경계를 넘어 전역 순서를 결정. 같은 row 안에서도 `order` 가 작은 게 왼쪽
### 2.4 빌더의 드롭 모델
- 팔레트 아이템 드래그: `application/x-template-component` MIME, payload = `{ componentId, defaultConfig }`
- 블록 재배열 드래그: `application/x-template-block` MIME, payload = `blockId`
- 드롭 존 index 는 "이 자리에 삽입되면 **이동 후** 배열의 몇 번째가 되는가"
- `handleDropAt(index, e)` 가 두 MIME 을 구분해 `addBlock` / `reorderBlock` 호출
---
## 3. 구 포맷 호환 (이중 방어)
DB 에 이미 저장된 Template 중 Phase 2.1 초안 빌더가 만든 것은 `position: { left, top, width, height }` 만 있고 `order` 가 없다. 두 층에서 자동 처리:
| 층 | 헬퍼 | 동작 |
|---|---|---|
| **Store** (빌더 로드) | `migrateBlocks` in `templateBuilderStore.fromTemplate` | `position` 필드 drop, `order` 누락 시 인덱스 부여, 정렬 후 `reindex` — 빌더에서 열어 **저장** 버튼만 누르면 DB 가 새 포맷으로 이관됨 |
| **Renderer** (런타임) | `normalizeBlocks` in `TemplateRenderer` | `order` 없으면 배열 인덱스 부여, `row` 없으면 undefined — 당장 마이그레이션 안 해도 대시보드 카드에서 순서대로 읽기는 정상 |
**구 포맷 Template 을 당장 삭제하지 않아도 시스템은 깨지지 않는다.** 다만 구 포맷은 layout 이 본래 의도와 다르게 재해석되므로(px 좌표가 무시되고 단순 세로 순서가 됨) 시각적으로 어색할 수 있다. → Step D (soft-delete) 는 정리 목적이며 기술 필수가 아니다.
---
## 4. 검증 결과
### 4.1 TypeScript
```
# 전체 에러
npx tsc --noEmit 2>&1 | grep -c "error TS"
# → 2791 (Phase 2.1 완료 시점 3381 에서 590)
# 내 작업 영역
npx tsc --noEmit 2>&1 | grep -E "template-builder|TemplateRenderer|DashboardCard|invyone-component|DesignPreview|FieldConfigPanel"
# → (빈 출력)
```
- 내 작업 영역 에러 **0**
- 전체 카운트 감소의 원인은 `updateBlockPosition` / `gridSettings` / `selectedIds` 등 Phase 2.1 초안에서 노출하던 심볼 제거로 의존 측의 에러가 사라진 것. 증가 요소 없음
### 4.2 잔존 참조 감사
- `block.position` / `.position.left|top|width|height` 사용처: **0 건** (전역)
- `template-builder` 디렉터리 내 `gridSettings` / `updateBlockPosition` / `selectedIds` / `FreePosition`: **0 건**
- `dash` 디렉터리 내 동일 심볼: **0 건** (TemplateRenderer 의 주석 1 건만 — `FreePosition` 단어로 구 포맷을 설명하는 문구)
- `@container card` 규칙은 `styles/dashboard.css` 에만 남아있으며, 카드 헤더/미니뷰 전용으로 여전히 유효
### 4.3 사용자 브라우저 검증 (남은 체크리스트)
tsc / grep 은 코드 정합성만 보장한다. 아래는 사용자 수동 확인 필요:
- [ ] `/test-card-responsive` 는 여전히 작동 (Phase 1 기반)
- [ ] `/admin/builder` — 빌더 진입, 세로 리스트 캔버스 표시, 좌측 팔레트 드래그 → 드롭 존 하이라이트 → 드롭 → 새 블록 삽입
- [ ] 블록 드래그 → 드롭 존 → 순서 재배열 동작
- [ ] 블록 속성 패널에서 `↑`/`↓` 이동, `row` 키 입력, `config` JSON 편집, 삭제
- [ ] 수주관리 Template 생성 후 저장 → 게시
- [ ] 대시보드에 카드 배치 → 카드 폭을 실제로 **800 → 400 → 260** 으로 줄여도 overflow 없이 flex-wrap 으로 재배치
- [ ] `test-card-responsive` 와 시각적으로 동일한 반응형 동작 확인
- [ ] ControlMode / CardSettingsPanel sanity (Phase 2.1 log §4-E 와 동일)
---
## 5. 주요 결정 사항
### 결정 1 — `row` 필드 의미 (숫자 키 vs bool flag)
**옵션**: (a) `row?: boolean` — 직전 블록과 같은 row 에 붙임 / (b) `row?: number` — 같은 숫자 키끼리 그룹
**결정**: **(b) 숫자 키**
**이유**: 사용자가 3개 블록을 한 줄에 두고 가운데 블록만 제거하면, bool 방식은 옆 블록 관계가 깨진다. 숫자 키는 나머지 2 블록이 같은 키를 공유하므로 그대로 유지. 또 같은 키 블록을 순서만 바꿔도 그룹이 유지돼 재배열 UX 가 단순.
### 결정 2 — 빌더 캔버스에서 row 그룹핑을 시각적으로 반영할지
**옵션**: (a) 캔버스도 runtime 처럼 row 그룹핑 반영 (가로 배치 미리보기) / (b) 평면 세로 리스트 + row 값 배지만 표시
**결정**: **(b) MVP 로 평면 세로 리스트**
**이유**: 드롭 존 / 드래그 / 삽입/재배열 UX 는 평면 리스트가 훨씬 단순. runtime 의 실제 row 그룹핑은 대시보드 카드에서 확인. 빌더에서 가로 배치 미리보기는 Phase 2.2 이월 (우측 속성 패널에 실 렌더 미리보기 탭 등).
### 결정 3 — 빌더 단축키 `Delete` 처리
속성 패널 textarea 에서도 Delete 가 발동되지 않도록 `if (tag === "INPUT" || "TEXTAREA" || "SELECT") return` 유지. 기존 로직 그대로.
### 결정 4 — DashboardCard 의 `containerName: 'card'` 유지
초안에서는 TemplateRenderer.css 의 `@container card (max-width: 599px)` narrow 스택을 위해 필요했지만, 이제는 `styles/dashboard.css` 의 카드 헤더 / 미니뷰 규칙이 여전히 이 컨테이너를 사용. 제거 시 다른 규칙이 깨질 수 있어 유지.
### 결정 5 — 구 포맷 Template 처리 (Step D)
**옵션**: (a) soft-delete / (b) 유지 + 자동 마이그레이션 레이어
**결정**: **(b) + (a) 사용자 승인 받으면 병행**
**이유**: 기술적으로는 (b) 만으로 충분. (a) 는 "의미 없는 레이아웃" 을 청소하는 용도. 사용자가 명시 승인할 때만 DB UPDATE 실행 (`CLAUDE.md` DB 규칙).
---
## 6. Phase 2.2 이월 항목
### 재작업에서 미뤄둔 것
- [ ] **빌더 캔버스의 row 그룹핑 시각화** — 평면 리스트가 아니라 실제 runtime 렌더 모양으로 미리보기
- [ ] **가로 나열 UX 개선** — row 키를 숫자로 편집하지 않고, 드롭 위치(좌/우) 로 자동 부여
- [ ] **undo/redo 범위** — 현재 블록 변경만 history 에 들어감. row 변경 / reorder 는 포함되지만, `updateBlock` (config 편집 이외) 은 commit 시점이 다름 — 추후 통합
### Phase 2.1 에서 원래 이월한 것 (변경 없음)
- [ ] TemplateRenderer 를 ComponentRegistry 어댑터로 v2-* 실제 렌더 (현재는 FcTable/FcSearch/FcButton 기반 MVP)
- [ ] v2-* 컴포넌트 props 시그니처 표준화 (VEX `ComponentData` / `isDesignMode` 의존 제거)
- [ ] 경량 7 개 컴포넌트 내부 DOM 기반 반응형 개선
- [ ] TemplateBuilder URL 동기화 (저장 후 `?id=tpl_xxx` 반영)
- [ ] 우선순위 2/3 v2-* 마이그레이션
---
## 7. 알려진 한계 / 주의
1. **빌더 캔버스는 평면 리스트** — 같은 row 키를 공유하는 블록들이 실제로 가로 배치되는 모습은 대시보드 카드에서만 볼 수 있음. 빌더에서는 row 배지만 표시
2. **`row` 키는 연속 블록에만 유효** — 예를 들어 `[A(row=1), B(row=1), C(row=2), D(row=1)]` 이면 A/B 만 가로 묶이고, D 는 C 뒤 단독 행. C 와 D 사이에 row=2 블록이 끼면 row=1 그룹이 끊기도록 의도적으로 설계 (drag 이동 시 직관적인 결과를 위해)
3. **구 포맷 Template 은 레이아웃 의미 손실**`position` 필드가 drop 되므로 원래 자유배치했던 의도는 "순서대로 세로 쌓기" 로 바뀜. 시각적으로 어색할 수 있음 → Step D 가 존재하는 이유
4. **DashboardCard 의 TemplateRenderer 호출 경로는 그대로** — Template.views.list.components 가 있으면 새 렌더러가, 없으면 기존 DashboardCard 의 기본 CRUD (FcSearch + FcTable + CRUD 버튼 바) 가 그대로 보여줌. 이 분기는 Phase 2.1 초안과 동일
5. **Phase 1 의 `v2-table-list` ResizeObserver narrow 전환****카드 폭 기준** 으로 여전히 동작. 이번 재작업은 TemplateRenderer 의 *블록 배치* 만 바꿨고, 블록 안 컴포넌트(v2-table-list 등) 의 자체 반응형 로직은 건드리지 않음
---
## 8. 사용자 Step D 승인 대기
구 포맷 Template `tpl_80704df4029a` (수주123123) soft-delete 는 사용자 명시 승인 후 실행:
```sql
-- 대상 서버: 사용자 확인 필요 (vexplor / vexplor_dev / testvex 중)
UPDATE templates SET is_active='D' WHERE template_id='tpl_80704df4029a';
```
승인 없으면 자동 마이그레이션 레이어만으로도 시스템은 정상 동작. 어느 쪽이든 기능적 차이는 없다.
---
## 끝
Phase 2.1 재작업 코드 종료 (2026-04-11).
사용자 브라우저 검증 (§4.3) 통과 후 Phase 2.1 전체 완료로 이월.