화면 디자이너 제작
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,108 @@
|
||||
"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 (
|
||||
<div className="h-[calc(100vh-4rem)] w-full">
|
||||
<TemplateBuilder />
|
||||
<div className="h-[calc(100vh-4rem)] w-full overflow-hidden bg-background">
|
||||
<ScreenDesigner
|
||||
selectedScreen={selectedScreen}
|
||||
onBackToList={() => router.push("/admin/screenMng/screenMngList")}
|
||||
onScreenUpdate={(updatedFields) => {
|
||||
if (selectedScreen) {
|
||||
setSelectedScreen({ ...selectedScreen, ...updatedFields });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BuilderPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-screen items-center justify-center text-slate-500">
|
||||
빌더 로딩 중...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<BuilderInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,18 +2,13 @@
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { RefreshCw, ChevronDown, X, Settings } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { getTemplateInfo } from '@/lib/api/template';
|
||||
import { getMetaFields } from '@/lib/api/meta';
|
||||
import { fcList } from '@/lib/api/fcData';
|
||||
import { FcTable, FcForm, FcSearch, FcButton, FcButtonBar, FcPagination } from '@/components/fc';
|
||||
import type {
|
||||
FieldConfig,
|
||||
GridPosition,
|
||||
AbsolutePosition,
|
||||
TemplateKind,
|
||||
} from '@/types/invyone-component';
|
||||
import { isGridPosition } from '@/types/invyone-component';
|
||||
import { fcList, fcInsert, fcUpdate, fcDelete } from '@/lib/api/fcData';
|
||||
import { FcForm } from '@/components/fc';
|
||||
import type { FieldConfig, Template } from '@/types/invyone-component';
|
||||
import { CardMiniView } from './CardMiniView';
|
||||
import { TemplateRenderer, type TemplateRenderContext } from './TemplateRenderer';
|
||||
|
||||
interface DashboardCardProps {
|
||||
card: Record<string, any>;
|
||||
@@ -23,12 +18,6 @@ interface DashboardCardProps {
|
||||
onOpenSettings?: (cardId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* DashboardCard — Template 기반 렌더러 (2026-04-10 재설계)
|
||||
* - kind: 'business' → 12-col grid + @container 카드 너비 반응형
|
||||
* - kind: 'canvas' → absolute 자유배치 (control/flow 등 예외)
|
||||
* - 반응형 분기는 GridComponent가 CSS 변수로 주입, @container 쿼리가 처리
|
||||
*/
|
||||
export function DashboardCard({
|
||||
card,
|
||||
editMode,
|
||||
@@ -43,15 +32,11 @@ export function DashboardCard({
|
||||
const primaryTable = card.primary_table ?? card.PRIMARY_TABLE ?? '';
|
||||
const isCollapsed = card.is_collapsed ?? card.IS_COLLAPSED ?? false;
|
||||
|
||||
// ─── Template 상태 ───
|
||||
const [fields, setFields] = useState<FieldConfig[]>([]);
|
||||
const [components, setComponents] = useState<Record<string, any>[]>([]);
|
||||
const [connections, setConnections] = useState<Record<string, any>[]>([]);
|
||||
const [templateKind, setTemplateKind] = useState<TemplateKind | null>(null);
|
||||
const [template, setTemplate] = useState<Template | null>(null);
|
||||
const [templateLoaded, setTemplateLoaded] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
// ─── 데이터 상태 ───
|
||||
const [data, setData] = useState<Record<string, any>[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -60,72 +45,52 @@ export function DashboardCard({
|
||||
const [loading, setLoading] = useState(false);
|
||||
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);
|
||||
|
||||
// ─── Template 로드 ───
|
||||
// Template + fields 로드
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
if (!primaryTable && !templateId) return;
|
||||
|
||||
const loadTemplate = async () => {
|
||||
if (!primaryTable && !templateId) {
|
||||
setTemplateLoaded(true);
|
||||
return;
|
||||
}
|
||||
const load = async () => {
|
||||
setLoadError(null);
|
||||
try {
|
||||
// 1순위: Template (빌더에서 만든 컴포넌트 배치 + fields)
|
||||
let resolvedFields: FieldConfig[] = [];
|
||||
if (templateId) {
|
||||
const tpl = await getTemplateInfo(templateId);
|
||||
if (mountedRef.current && tpl) {
|
||||
const tplFields: FieldConfig[] = Array.isArray(tpl.fields) ? tpl.fields : [];
|
||||
// views는 list/create/edit이 있고 각자 components 배열
|
||||
const listView = tpl.views?.list ?? {};
|
||||
const tplComponents: Record<string, any>[] = Array.isArray(listView.components)
|
||||
? listView.components
|
||||
: [];
|
||||
const tplConnections: Record<string, any>[] = Array.isArray(tpl.connections)
|
||||
? tpl.connections
|
||||
: [];
|
||||
|
||||
// Template에 fields가 있으면 그대로, 없으면 DB 메타 fallback
|
||||
if (tplFields.length > 0) {
|
||||
setFields(tplFields);
|
||||
} else if (primaryTable) {
|
||||
const meta = await getMetaFields(primaryTable);
|
||||
if (mountedRef.current) setFields(meta?.fields ?? []);
|
||||
}
|
||||
|
||||
setComponents(tplComponents);
|
||||
setConnections(tplConnections);
|
||||
// kind 가 없으면 null로 두고 fallback 렌더 — 레거시 변환은 빌더에서 처리
|
||||
setTemplateKind((tpl.kind as TemplateKind) ?? null);
|
||||
setTemplateLoaded(true);
|
||||
return;
|
||||
if (tpl && mountedRef.current) {
|
||||
setTemplate(tpl as Template);
|
||||
if (Array.isArray(tpl.fields)) resolvedFields = tpl.fields as FieldConfig[];
|
||||
}
|
||||
}
|
||||
// 2순위 (fallback): Template 없으면 DB 메타로 기본 카드만 표시
|
||||
if (primaryTable) {
|
||||
const meta = await getMetaFields(primaryTable);
|
||||
if (mountedRef.current && meta?.fields) {
|
||||
setFields(meta.fields);
|
||||
setComponents([]); // 컴포넌트 배치 없음 → 기본 렌더
|
||||
setConnections([]);
|
||||
setTemplateKind(null);
|
||||
setTemplateLoaded(true);
|
||||
}
|
||||
// ★ DB meta 자동 fallback 제거 (하드코딩 방지).
|
||||
// Template.fields 가 비어있으면 fields 도 빈 채로 둔다 — 빌더에서
|
||||
// 사용자가 명시적으로 선택한 필드만 반영. 스키마 덤프 금지.
|
||||
if (mountedRef.current) {
|
||||
setFields(resolvedFields);
|
||||
setTemplateLoaded(true);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`[DashboardCard] Template/fields 로드 실패:`, err);
|
||||
console.error('[DashboardCard] Template 로드 실패:', err);
|
||||
if (mountedRef.current) {
|
||||
setLoadError(err?.message ?? '템플릿 로드 실패');
|
||||
setTemplateLoaded(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
loadTemplate();
|
||||
|
||||
load();
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, [primaryTable, templateId]);
|
||||
|
||||
// ─── 데이터 조회 ───
|
||||
// 데이터 조회
|
||||
const loadData = useCallback(async () => {
|
||||
if (!primaryTable || !templateLoaded) return;
|
||||
setLoading(true);
|
||||
@@ -141,7 +106,7 @@ export function DashboardCard({
|
||||
setTotalCount(result?.total ?? result?.total_count ?? 0);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[DashboardCard] 데이터 조회 실패:`, err);
|
||||
console.error('[DashboardCard] 데이터 조회 실패:', err);
|
||||
} finally {
|
||||
if (mountedRef.current) setLoading(false);
|
||||
}
|
||||
@@ -151,7 +116,7 @@ export function DashboardCard({
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// ─── DataPort 콜백 ───
|
||||
// 검색/페이지/선택
|
||||
const handleSearch = useCallback((params: Record<string, any>) => {
|
||||
setSearchParams(params);
|
||||
setPage(1);
|
||||
@@ -166,32 +131,119 @@ export function DashboardCard({
|
||||
setSelectedRow(row);
|
||||
}, []);
|
||||
|
||||
// ─── 컴포넌트 정렬 ───
|
||||
// grid(business) → row / col 기준, canvas → y / x 기준
|
||||
const sortedComponents = useMemo(() => {
|
||||
return [...components].sort((a, b) => {
|
||||
const pa = a.position ?? {};
|
||||
const pb = b.position ?? {};
|
||||
if (isGridPosition(pa) && isGridPosition(pb)) {
|
||||
return (pa.row ?? 0) - (pb.row ?? 0) || (pa.col ?? 0) - (pb.col ?? 0);
|
||||
}
|
||||
// absolute fallback (기존 {x,y,w,h} 데이터 호환)
|
||||
return ((pa as any).y ?? 0) - ((pb as any).y ?? 0);
|
||||
});
|
||||
}, [components]);
|
||||
// PK 컬럼 (첫 pk 필드)
|
||||
const pkColumn = useMemo(() => {
|
||||
const pkField = fields.find((f) => f.pk);
|
||||
return pkField?.column ?? '';
|
||||
}, [fields]);
|
||||
|
||||
// business kind 여부 — 명시된 kind를 우선하고, 없으면 position 형태로 추정
|
||||
const effectiveKind: TemplateKind = useMemo(() => {
|
||||
if (templateKind === 'business' || templateKind === 'canvas') return templateKind;
|
||||
// kind 미지정 Template — grid position 데이터면 business, 그 외는 canvas로 fallback 렌더
|
||||
const sample = components.find((c) => c.position != null)?.position;
|
||||
if (sample && isGridPosition(sample)) return 'business';
|
||||
return 'canvas';
|
||||
}, [templateKind, components]);
|
||||
// CRUD 액션
|
||||
const handleAdd = useCallback(() => {
|
||||
const defaults: Record<string, any> = {};
|
||||
for (const f of fields) {
|
||||
if (f.defaultValue !== undefined) defaults[f.column] = f.defaultValue;
|
||||
}
|
||||
setFormRow(defaults);
|
||||
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 (
|
||||
<div className={`dash-card${isCollapsed ? ' collapsed' : ''}`}>
|
||||
{/* 카드 헤더 */}
|
||||
<div className="dash-card-head">
|
||||
<div className="dash-card-head-l">
|
||||
<div className="dash-card-icon">📋</div>
|
||||
@@ -252,395 +304,77 @@ export function DashboardCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 — container query 카드 */}
|
||||
<div className="dash-card-body">
|
||||
<div
|
||||
className="dash-card-body"
|
||||
style={{
|
||||
containerType: 'inline-size',
|
||||
containerName: 'card',
|
||||
}}
|
||||
>
|
||||
{loadError ? (
|
||||
<div className="dash-card-error">⚠ {loadError}</div>
|
||||
) : !templateLoaded ? (
|
||||
<div className="dash-card-loading">템플릿 로딩 중...</div>
|
||||
) : sortedComponents.length === 0 ? (
|
||||
// Template에 컴포넌트 배치 없음 → 기본 검색+테이블+페이지네이션
|
||||
<DefaultCardContent
|
||||
fields={fields}
|
||||
data={data}
|
||||
loading={loading}
|
||||
totalCount={totalCount}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
onSearch={handleSearch}
|
||||
onRowSelect={handleRowSelect}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
) : effectiveKind === 'canvas' ? (
|
||||
// canvas kind — 자유배치, 반응형 없음 (control/flow 류)
|
||||
<div className="dash-card-canvas-wrapper">
|
||||
<div className="dash-card-canvas">
|
||||
{sortedComponents.map((comp) => (
|
||||
<AbsoluteComponent
|
||||
key={comp.id}
|
||||
component={comp}
|
||||
fields={fields}
|
||||
data={data}
|
||||
loading={loading}
|
||||
totalCount={totalCount}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
selectedRow={selectedRow}
|
||||
onSearch={handleSearch}
|
||||
onRowSelect={handleRowSelect}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : !template ? (
|
||||
<div className="dash-card-error">템플릿을 찾을 수 없습니다</div>
|
||||
) : (
|
||||
// business kind — 12-col grid + @container 카드 너비 반응형
|
||||
<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>
|
||||
<TemplateRenderer template={template} context={renderContext} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 접힌 상태: 미니 뷰 */}
|
||||
<CardMiniView templateName={templateName} category={templateCategory} tableName={primaryTable} />
|
||||
<CardMiniView
|
||||
templateName={templateName}
|
||||
category={templateCategory}
|
||||
tableName={primaryTable}
|
||||
/>
|
||||
|
||||
{/* 리사이즈 핸들 */}
|
||||
<div className="dash-resize-handle" data-resize="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 컴포넌트별 렌더러 — business(grid) 와 canvas(absolute)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
interface ComponentRendererProps {
|
||||
component: Record<string, any>;
|
||||
fields: FieldConfig[];
|
||||
data: Record<string, any>[];
|
||||
loading: boolean;
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
selectedRow: Record<string, any> | null;
|
||||
onSearch: (params: Record<string, any>) => void;
|
||||
onRowSelect: (row: Record<string, any>) => void;
|
||||
onPageChange: (p: { page: number; size: number }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* GridComponent — business kind 전용.
|
||||
* col/row 및 responsive(narrow/normal/wide) 전부를 CSS 변수로 주입.
|
||||
* @container 쿼리에서 col/row를 동시에 오버라이드해야 responsive.row가 실제로 적용됨.
|
||||
*/
|
||||
function GridComponent(props: ComponentRendererProps) {
|
||||
const { component } = props;
|
||||
const pos = (component.position ?? {}) as GridPosition;
|
||||
|
||||
const toRowVal = (r?: number): string | number => (r != null ? r : 'auto');
|
||||
const toSpanVal = (s?: number): number => s ?? 1;
|
||||
|
||||
const r = pos.responsive ?? {};
|
||||
const style: React.CSSProperties = {
|
||||
'--col': pos.col ?? 1,
|
||||
'--col-span': pos.colSpan ?? 12,
|
||||
'--row': toRowVal(pos.row),
|
||||
'--row-span': toSpanVal(pos.rowSpan),
|
||||
|
||||
'--col-narrow': r.narrow?.col ?? pos.col ?? 1,
|
||||
'--col-span-narrow': r.narrow?.colSpan ?? pos.colSpan ?? 12,
|
||||
'--row-narrow': toRowVal(r.narrow?.row ?? pos.row),
|
||||
'--row-span-narrow': toSpanVal(r.narrow?.rowSpan ?? pos.rowSpan),
|
||||
|
||||
'--col-normal': r.normal?.col ?? pos.col ?? 1,
|
||||
'--col-span-normal': r.normal?.colSpan ?? pos.colSpan ?? 12,
|
||||
'--row-normal': toRowVal(r.normal?.row ?? pos.row),
|
||||
'--row-span-normal': toSpanVal(r.normal?.rowSpan ?? pos.rowSpan),
|
||||
|
||||
'--col-wide': r.wide?.col ?? pos.col ?? 1,
|
||||
'--col-span-wide': r.wide?.colSpan ?? pos.colSpan ?? 12,
|
||||
'--row-wide': toRowVal(r.wide?.row ?? pos.row),
|
||||
'--row-span-wide': toSpanVal(r.wide?.rowSpan ?? pos.rowSpan),
|
||||
} as React.CSSProperties;
|
||||
|
||||
return (
|
||||
<div className="tpl-component" data-type={component.type} style={style}>
|
||||
{renderByType(props)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AbsoluteComponent — canvas kind 전용. position.x/y/w/h 직접 렌더.
|
||||
*/
|
||||
function AbsoluteComponent(props: ComponentRendererProps) {
|
||||
const { component } = props;
|
||||
const pos = (component.position ?? { x: 0, y: 0, w: 200, h: 100 }) as AbsolutePosition;
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
left: pos.x,
|
||||
top: pos.y,
|
||||
width: pos.w,
|
||||
height: pos.h,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tpl-component" data-type={component.type} style={style}>
|
||||
{renderByType(props)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderByType(props: ComponentRendererProps) {
|
||||
const {
|
||||
component,
|
||||
fields,
|
||||
data,
|
||||
loading,
|
||||
totalCount,
|
||||
page,
|
||||
selectedRow,
|
||||
onSearch,
|
||||
onRowSelect,
|
||||
onPageChange,
|
||||
} = props;
|
||||
const config = component.config ?? {};
|
||||
const type = component.type;
|
||||
|
||||
switch (type) {
|
||||
case 'table':
|
||||
return (
|
||||
<FcTable
|
||||
{formMode && formRow && (
|
||||
<FormOverlay
|
||||
title={`${templateName} ${formMode === 'create' ? '등록' : '수정'}`}
|
||||
fields={fields}
|
||||
data={data}
|
||||
loading={loading}
|
||||
config={config}
|
||||
onRowSelect={onRowSelect}
|
||||
initialRow={formRow}
|
||||
onSubmit={handleSubmitForm}
|
||||
onClose={closeForm}
|
||||
/>
|
||||
);
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'search':
|
||||
return <FcSearch fields={fields} onSearch={onSearch} config={config} />;
|
||||
interface FormOverlayProps {
|
||||
title: string;
|
||||
fields: FieldConfig[];
|
||||
initialRow: Record<string, any>;
|
||||
onSubmit: (row: Record<string, any>) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
case 'form':
|
||||
return <FcForm fields={fields} config={config} loadRow={selectedRow ?? undefined} />;
|
||||
|
||||
case 'button':
|
||||
return <FcButton config={config} />;
|
||||
|
||||
case 'button-bar':
|
||||
return <FcButtonBar config={config} />;
|
||||
|
||||
case 'pagination':
|
||||
return <FcPagination total={totalCount} page={page} config={config} onPageChange={onPageChange} />;
|
||||
|
||||
case 'title': {
|
||||
const titleCfg = config as any;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent:
|
||||
titleCfg.align === 'center' ? 'center' : titleCfg.align === 'right' ? 'flex-end' : 'flex-start',
|
||||
color: 'var(--v5-text)',
|
||||
fontSize: titleCfg.fontSize ?? '0.85rem',
|
||||
fontWeight: titleCfg.fontWeight ?? '700',
|
||||
padding: '0 0.4rem',
|
||||
}}
|
||||
>
|
||||
{titleCfg.text ?? component.label}
|
||||
function FormOverlay({ title, fields, initialRow, onSubmit, onClose }: FormOverlayProps) {
|
||||
return (
|
||||
<div
|
||||
className="dash-form-overlay"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="dash-form-modal">
|
||||
<div className="dash-form-head">
|
||||
<span className="dash-form-title">{title}</span>
|
||||
<button className="dash-card-btn" onClick={onClose} title="닫기">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'divider': {
|
||||
const dCfg = config as any;
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
borderTop: `1px ${dCfg.style ?? 'solid'} var(--v5-border)`,
|
||||
}}
|
||||
<div className="dash-form-body">
|
||||
<FcForm
|
||||
fields={fields}
|
||||
loadRow={initialRow}
|
||||
onSubmit={onSubmit}
|
||||
config={{ columns: 2 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'stats': {
|
||||
const sCfg = config as any;
|
||||
const items = Array.isArray(sCfg.items) ? sCfg.items : [];
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: items.length > 0 ? `repeat(${items.length}, 1fr)` : '1fr',
|
||||
gap: '.4rem',
|
||||
padding: '.3rem',
|
||||
}}
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '.55rem',
|
||||
color: 'var(--v5-text-muted)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
통계 항목 없음
|
||||
</div>
|
||||
) : (
|
||||
items.map((item: Record<string, any>, i: number) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
border: '1px solid var(--v5-glass-border)',
|
||||
borderRadius: 8,
|
||||
padding: '.4rem .55rem',
|
||||
background: 'var(--v5-glass)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '.5rem',
|
||||
fontWeight: 700,
|
||||
color: 'var(--v5-text-muted)',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 800,
|
||||
color: 'var(--v5-text)',
|
||||
marginTop: '.15rem',
|
||||
}}
|
||||
>
|
||||
{computeAggregation(data, item.column, item.aggregation)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: '1px dashed var(--v5-border)',
|
||||
borderRadius: 6,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--v5-text-muted)',
|
||||
fontSize: '.55rem',
|
||||
}}
|
||||
>
|
||||
{type ?? 'unknown'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Fallback — Template에 컴포넌트 배치가 없을 때 기본 카드 (검색+테이블+페이지네이션)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
interface DefaultCardContentProps {
|
||||
fields: FieldConfig[];
|
||||
data: Record<string, any>[];
|
||||
loading: boolean;
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
onSearch: (params: Record<string, any>) => void;
|
||||
onRowSelect: (row: Record<string, any>) => void;
|
||||
onPageChange: (p: { page: number; size: number }) => void;
|
||||
}
|
||||
|
||||
function DefaultCardContent({
|
||||
fields,
|
||||
data,
|
||||
loading,
|
||||
totalCount,
|
||||
page,
|
||||
pageSize,
|
||||
onSearch,
|
||||
onRowSelect,
|
||||
onPageChange,
|
||||
}: DefaultCardContentProps) {
|
||||
const visibleFields = fields.filter((f) => f.visible && !f.system);
|
||||
const searchableFields = fields.filter((f) => f.searchable && !f.system);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '.35rem', height: '100%' }}>
|
||||
{searchableFields.length > 0 && (
|
||||
<FcSearch
|
||||
fields={fields}
|
||||
onSearch={onSearch}
|
||||
config={{ layout: 'inline', autoSearch: false, dateRangeEnabled: true, showResetButton: true }}
|
||||
/>
|
||||
)}
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
|
||||
<FcTable fields={visibleFields} data={data} loading={loading} onRowSelect={onRowSelect} />
|
||||
</div>
|
||||
{totalCount > 0 && (
|
||||
<FcPagination total={totalCount} page={page} pageSize={pageSize} onPageChange={onPageChange} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 통계 집계 헬퍼
|
||||
// ─────────────────────────────────────────────────────────
|
||||
function computeAggregation(
|
||||
data: Record<string, any>[],
|
||||
column: string,
|
||||
aggregation: 'count' | 'sum' | 'avg'
|
||||
): string {
|
||||
if (!data || data.length === 0) return '0';
|
||||
if (aggregation === 'count') return String(data.length);
|
||||
const nums = data
|
||||
.map((row) => Number(row[column]))
|
||||
.filter((n) => !isNaN(n));
|
||||
if (nums.length === 0) return '0';
|
||||
if (aggregation === 'sum') {
|
||||
return nums.reduce((a, b) => a + b, 0).toLocaleString('ko-KR');
|
||||
}
|
||||
if (aggregation === 'avg') {
|
||||
return Math.round(nums.reduce((a, b) => a + b, 0) / nums.length).toLocaleString('ko-KR');
|
||||
}
|
||||
return '0';
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
import React from "react";
|
||||
import "./withContainerQuery.css";
|
||||
|
||||
/**
|
||||
* withContainerQuery HOC (2026-04-10, Phase 1 Step 6 경량 부착)
|
||||
|
||||
@@ -487,3 +487,122 @@
|
||||
display: flex; align-items: center; gap: .5rem;
|
||||
}
|
||||
.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); }
|
||||
}
|
||||
|
||||
@@ -149,91 +149,8 @@ export type ComponentType =
|
||||
| '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;
|
||||
|
||||
// ─── 위치 (빌더가 관리) ───
|
||||
//
|
||||
// Component 인터페이스는 INVYONE 규격 v1.0 의 초기 드래프트였고, 자유배치
|
||||
// 단일 모델 확정 이후 TemplateComponent (§7) 가 실질 대체이다. 이 인터페이스는
|
||||
// 아직 참조 코드가 없어 즉시 삭제 가능한 상태이며, position 필드는 FreePosition
|
||||
// 단일 모델로 정리되었다. 자세한 내용은 §7 참조.
|
||||
|
||||
/**
|
||||
* Template.kind에 따라 해석이 달라짐.
|
||||
* - business → GridPosition (col / colSpan / row / responsive)
|
||||
* - canvas → AbsolutePosition (x / y / w / h)
|
||||
*/
|
||||
position: ComponentPosition;
|
||||
/** 자유배치 위치 (px) */
|
||||
position?: FreePosition;
|
||||
|
||||
// ─── 데이터 바인딩 ───
|
||||
|
||||
@@ -704,15 +622,16 @@ export interface Template {
|
||||
templateId: string;
|
||||
/** 화면 이름 */
|
||||
name: string;
|
||||
/**
|
||||
* @deprecated Phase 2 에서 제거. 단일 자유배치 모델로 kind 분기 없음.
|
||||
* 기존 데이터 호환용으로만 optional 유지. 새 Template 은 이 필드를 쓰지 않는다.
|
||||
*/
|
||||
kind?: TemplateKind;
|
||||
/** 카드 헤더 아이콘 */
|
||||
icon?: string;
|
||||
/** 카드 헤더 배지 (예: 'ERP · 영업') */
|
||||
badge?: string;
|
||||
/** 분류 (예: sales, production, purchase) */
|
||||
category: string;
|
||||
/** 화면 설명 */
|
||||
description?: string;
|
||||
/** 카드로 배치될 때 기본 사이즈 */
|
||||
defaultSize?: { w: number; h: number };
|
||||
|
||||
// ─── 데이터 ───
|
||||
|
||||
@@ -721,23 +640,20 @@ export interface Template {
|
||||
/** 필드 정의 목록 — 모든 뷰가 이 하나를 공유한다 */
|
||||
fields: FieldConfig[];
|
||||
|
||||
// ─── 3뷰 ───
|
||||
// ─── 3뷰 (자유배치 단일 모델) ───
|
||||
|
||||
/** 목록 / 등록 / 수정 세 가지 뷰 */
|
||||
views: {
|
||||
/** 목록 화면 */
|
||||
list: ViewConfig;
|
||||
/** 등록 팝업 */
|
||||
create: ViewConfig;
|
||||
/** 수정 팝업 (create 상속 가능) */
|
||||
edit: ViewConfig;
|
||||
};
|
||||
views: TemplateViews;
|
||||
|
||||
// ─── 연결 ───
|
||||
|
||||
/** 컴포넌트 간 DataPort 연결 목록 */
|
||||
connections: Connection[];
|
||||
|
||||
/** 카드 간 통신 포트 정의 (선택) */
|
||||
inputs?: DataPortDef[];
|
||||
outputs?: DataPortDef[];
|
||||
|
||||
// ─── 메타 ───
|
||||
|
||||
/** 회사 코드 */
|
||||
@@ -753,86 +669,12 @@ export interface Template {
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 6. 컴포넌트 기본 grid 배치 (섹션 10)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @deprecated Phase 2 DashboardCard 재작성 시 제거 예정.
|
||||
* 12-grid 기본 배치는 폐기됐다. 팔레트 드롭 시 기본 크기는
|
||||
* ComponentRegistry 의 default_size 를 사용하고, 위치는 FreePosition 으로
|
||||
* 마우스 좌표에서 계산한다(components/template-builder/TemplateBuilder.tsx).
|
||||
*/
|
||||
export const DEFAULT_COMPONENT_LAYOUTS: Record<ComponentType, GridPosition> = {
|
||||
search: { col: 1, colSpan: 12 },
|
||||
table: {
|
||||
col: 1,
|
||||
colSpan: 8,
|
||||
responsive: {
|
||||
narrow: { colSpan: 12 },
|
||||
normal: { colSpan: 12 },
|
||||
wide: { colSpan: 8 },
|
||||
},
|
||||
},
|
||||
form: {
|
||||
col: 1,
|
||||
colSpan: 4,
|
||||
responsive: {
|
||||
narrow: { col: 1, colSpan: 12 },
|
||||
normal: { col: 1, colSpan: 12 },
|
||||
wide: { col: 9, colSpan: 4 },
|
||||
},
|
||||
},
|
||||
'button-bar': { col: 1, colSpan: 12 },
|
||||
button: {
|
||||
col: 1,
|
||||
colSpan: 2,
|
||||
responsive: {
|
||||
narrow: { colSpan: 12 },
|
||||
normal: { colSpan: 3 },
|
||||
wide: { colSpan: 2 },
|
||||
},
|
||||
},
|
||||
stats: {
|
||||
col: 1,
|
||||
colSpan: 4,
|
||||
responsive: {
|
||||
narrow: { colSpan: 12 },
|
||||
normal: { colSpan: 6 },
|
||||
wide: { colSpan: 4 },
|
||||
},
|
||||
},
|
||||
title: { col: 1, colSpan: 12 },
|
||||
divider: { col: 1, colSpan: 12 },
|
||||
pagination: { col: 1, colSpan: 12 },
|
||||
tabs: { col: 1, colSpan: 12 },
|
||||
'split-panel': { col: 1, colSpan: 12 },
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated Phase 2 DashboardCard 재작성 시 제거 예정.
|
||||
* 모든 Template 이 단일 자유배치 모델이므로 kind 휴리스틱이 필요 없다.
|
||||
* 현재 DashboardCard.tsx 만 이 상수를 사용 중.
|
||||
*/
|
||||
export const CANVAS_KEYWORDS = [
|
||||
'control',
|
||||
'flow',
|
||||
'workflow',
|
||||
'bpm',
|
||||
'canvas',
|
||||
'node',
|
||||
'diagram',
|
||||
'graph',
|
||||
] as const;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 7. 카드 엔진 v2 — 자유배치 단일 모델 (2026-04-10 확정)
|
||||
// 6. 카드 엔진 v2 — 자유배치 단일 모델 (2026-04-10 확정)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// 진실의 원천: notes/gbpark/2026-04-10-card-engine-final-spec.md
|
||||
//
|
||||
// 이 섹션의 타입들이 Phase 1 이후 INVYONE 의 유일한 템플릿/대시보드 모델이다.
|
||||
// 위쪽 §6 까지의 Component/GridPosition/TemplateKind/DEFAULT_COMPONENT_LAYOUTS
|
||||
// 등은 Phase 1 Step 5 에서 제거 예정(현재는 폐기 예정 코드와 호환용으로만 남음).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -866,10 +708,12 @@ export interface ViewTrigger {
|
||||
}
|
||||
|
||||
/**
|
||||
* Template 안에 자유배치된 컴포넌트 하나.
|
||||
* Template 안에 배치된 컴포넌트 하나.
|
||||
*
|
||||
* 컴포넌트 종류(componentId) 는 ComponentRegistry 의 v2-* ID 를 참조한다.
|
||||
* 위치는 FreePosition 단일 모델. 타입별 옵션은 config 에 자유 형태로 들어간다.
|
||||
* 카드 내부는 flex-column 자동 레이아웃이므로 px 좌표 대신 `order` 와
|
||||
* 선택적 `row` 로만 위치가 결정된다. 카드 폭이 변하면 runtime 이 자동
|
||||
* 재배치(줄바꿈/세로 스택)한다. FreePosition 은 오직 대시보드에 카드를
|
||||
* 배치하는 상위 수준(Card.position)에서만 사용된다.
|
||||
*/
|
||||
export interface TemplateComponent {
|
||||
/** 인스턴스 ID */
|
||||
@@ -884,8 +728,18 @@ export interface TemplateComponent {
|
||||
/** 빌더에서 표시되는 라벨 (선택) */
|
||||
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 전체 완료로 이월.
|
||||
Reference in New Issue
Block a user