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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 03:08:06 +09:00
parent 9c36191ebf
commit 2c0a97f2ba
44 changed files with 6513 additions and 2336 deletions
@@ -0,0 +1,946 @@
# INVYONE 카드 엔진 최종 스펙 (2026-04-10 확정)
> **이 문서가 진실의 원천이다.** 이전 12-grid / 48-col / 섹션 / business-canvas 분기 모델은 모두 폐기됨. 다른 클로드 세션은 이 문서만 따라가면 된다.
**작성일**: 2026-04-10
**상태**: 구현 착수 가능 (Phase 1 ~ 3)
**관련 문서**:
- mockup: `notes/gbpark/2026-04-08-invyone-mockup/` (시각 의도, 살아있음)
- 컴포넌트 규격: `notes/gbpark/2026-04-08-invyone-component-spec.md` (FieldConfig 부분만 살아있음)
- 폐기된 12-grid 노트: `notes/gbpark/2026-04-10-template-model-redesign.md` (참고용 obsolete)
---
## 0. 한 줄 요약
**VEX 자유배치 모델 + 컴포넌트 내부 @container 반응형 + 카드 단위 추상화.**
12-grid / 48-col / 섹션 / business-canvas 분기는 모두 우회 흔적이고 폐기됨. 단일 자유배치 카드 모델로 통일.
---
## 1. 핵심 개념 — 4가지 용어
| 용어 | 의미 | 예시 |
|---|---|---|
| **Component** | 빌더의 재료. VEX v2-* 컴포넌트 100+개. 자체 사이즈 + 자체 모드 가짐 | v2-table-list, v2-bom-tree, v2-button-primary, v2-search |
| **Template** | 한 화면 청사진. 컴포넌트들의 자유배치 묶음. 목록/등록/수정 3뷰 | 수주관리, 인사정보, BOM관리 |
| **Card** | Template 인스턴스. 대시보드에 배치된 한 개. 위치/크기 가짐 | 영업대시보드의 "수주관리 카드 #c1" |
| **Dashboard** | 카드 컬렉션 = 사이드바 메뉴 항목. 카드 자유 배치 | "영업 현황", "운영 모니터링" |
### 관계도
```
Component (v2-*) Template Card Dashboard
───────────── ──────── ───── ──────────
v2-table-list 수주관리 ───┐ ┌─ 카드#c1 (영업대시보드, left:50,top:50)
v2-search-widget ────┐ │ 1:N ├─ 카드#c5 (임원대시보드, left:200,top:80)
v2-button-primary ├──→ 목록 뷰 (자유) ──→인스턴스화├─ 카드#c9 (본부장, left:100,top:200)
v2-bom-tree │ 등록 뷰 (자유)
v2-aggregation ├──→ 수정 뷰 (자유)
... (100+개) │
└──→ 인사정보 ───→ 여러 인스턴스
BOM관리 ───→ 여러 인스턴스
...
```
### 두 단계의 자유배치
1. **개발자 (L1+)**: 빈 캔버스에 **컴포넌트** 자유배치 → Template 완성
2. **사용자 (L0)**: 빈 대시보드에 **Template (=Card)** 자유배치 → 나만의 대시보드
= **같은 자유배치 UX 를 두 레이어에서 사용**. 캔버스에 놓는 것의 종류만 다름.
---
## 2. ★ 반응형 메커니즘 (보장 + 한계 + 백업)
### 작동 원리
캔버스 자유배치 자체로는 반응형이 안 됨. **반응형은 컴포넌트 내부에서 처리**.
```
대시보드 캔버스 (자유배치)
└─ 카드 #1 (수주관리, 800x500)
└─ Template = 컴포넌트들의 자유배치
├─ v2-table-list (720x400)
│ │
│ └─ container-type: inline-size
│ └─ @container (min-width: 600px) → 테이블 모드
│ └─ @container (max-width: 599px) → 카드 리스트 모드
├─ v2-search-widget (400x60)
│ └─ @container (min-width: 350px) → 가로 필터
│ └─ @container (max-width: 349px) → 드롭다운 통합
└─ ...
```
### 카드 사이즈 변경 시 동작
사용자가 카드를 800x500 → 400x500 으로 드래그 리사이즈하면:
1. 카드 폭이 800px → 400px 로 변함
2. 카드 안의 v2-table-list 폭도 720 → 360 으로 변함 (% 또는 fit)
3. v2-table-list 의 `@container` 가 360px 감지 → 카드 리스트 모드로 자동 전환
4. v2-search-widget 도 마찬가지로 좁은 모드로 전환
5. **사용자는 아무것도 안 했는데 내부가 알아서 재배치됨**
### ✅ 잘 작동하는 케이스 (PC 위주 시나리오)
- 카드 폭 600 → 400 같은 적당한 변화
- 컴포넌트가 자체 모드 로직 가지고 있을 때
- 모니터 해상도 차이 (FHD/2K/4K)
- 대시보드에 카드 4개 배치로 카드가 작아지는 경우
### ❌ 안 되는 케이스 (한계)
1. **컴포넌트가 멍청할 때**@container 모드 없는 컴포넌트는 그냥 잘림
- **백업 플랜**: 모든 v2-* 컴포넌트에 최소 wide/narrow 2단계 모드 강제 (Phase 1 마이그레이션)
2. **카드 폭 600 → 100 같은 극심한 변화** — 컴포넌트 자체 최소 폭 미달
- **백업 플랜**: 카드 min-width: 280px 강제. 그 이하는 collapsed (mini) 모드로 자동 전환
3. **컴포넌트들이 "재배치" 되어야 할 때** — 가로 → 세로 재배치는 자유배치라 안 됨
- **백업 플랜**: 그런 케이스가 필요하면 컨테이너 컴포넌트 (탭/아코디언) 를 씀. 일반 자유배치는 재배치 안 함
4. **모바일/태블릿 (카드 폭 200px 이하)** — 자유배치는 모바일에 부적합
- **백업 플랜**: PC 위주 시나리오 (사용자 명시). 모바일은 별도 뷰 (Phase 4 이후)
### 보장 명시
> **이 메커니즘으로 PC 위주 시나리오 (FHD~4K, 카드 폭 280~1920px) 에서는 100% 반응형 작동.**
> 모든 v2-* 컴포넌트가 @container 기반 wide/narrow 모드를 가져야 한다는 조건 충족 시.
> 이 조건은 Phase 1 마이그레이션의 핵심 작업.
---
## 3. 데이터 모델 — 최종 타입
### 3.1 위치 (단일 모델)
```typescript
/** 자유배치 위치 (px) — 단일 위치 모델 */
export interface FreePosition {
left: number; // px
top: number; // px
width: number; // px
height: number; // px
}
```
### 3.2 컴포넌트 (Template 안의)
```typescript
/** Template 안에 배치된 컴포넌트 한 개 */
export interface TemplateComponent {
/** 인스턴스 ID */
id: string;
/** 컴포넌트 종류 — ComponentRegistry 의 ID 참조
* 예: 'v2-table-list', 'v2-bom-tree', 'v2-button-primary' */
componentId: string;
/** 캔버스 안에서의 위치 (px 자유배치) */
position: FreePosition;
/** 컴포넌트별 설정 — 모드/옵션 (그리드 모드, 버튼 액션, 컬럼 정의 등)
* ComponentRegistry 의 default_config 를 인스턴스 별로 오버라이드 */
config: Record<string, any>;
/** 컨테이너 컴포넌트의 자식 (탭, 아코디언 등) */
children?: TemplateComponent[];
/** 데이터 포트 — 컴포넌트 간 통신 */
inputs?: DataPort[];
outputs?: DataPort[];
/** 뷰 트리거 — 이 컴포넌트(예: 등록 버튼)가 다른 뷰를 여는지
* 자동 생성: 빌더가 등록 버튼을 감지하면 create 뷰 placeholder 생성 */
viewTrigger?: {
targetView: 'create' | 'edit' | 'detail';
action: 'open-modal' | 'navigate';
};
}
```
### 3.3 뷰 (Template 안의 한 화면)
```typescript
/** Template 의 한 뷰 (목록/등록/수정) */
export interface ViewConfig {
/** 이 뷰의 컴포넌트들 (자유배치) */
components: TemplateComponent[];
/** 뷰 표시 방식 */
layout?: 'card' | 'modal';
/** 모달 사이즈 (modal 일 때) */
modalSize?: { w: number; h: number };
/** 뷰 캔버스의 기본 사이즈 (디자인 시) */
designSize?: { w: number; h: number };
}
```
### 3.4 Template (한 화면 청사진)
```typescript
/** 재사용 가능한 화면 청사진 */
export interface Template {
/** 식별 */
templateId: string;
name: string; // '수주관리'
icon: string; // '📋'
badge: string; // 'ERP · 영업'
category: string; // 'sales'
description?: string;
/** 카드로 배치될 때 기본 사이즈 */
defaultSize: { w: number; h: number };
/** 데이터 (선택) */
primaryTable?: string; // 'ORDER_MASTER'
fields?: FieldConfig[]; // 컴포넌트들이 공유
/** ★ 3뷰 — 각 뷰는 독립 자유배치 캔버스 */
views: {
list: ViewConfig; // 카드 본체 (필수)
create?: ViewConfig; // 등록 모달 (자동 생성 가능)
edit?: ViewConfig; // 수정 모달 (자동 생성 가능)
};
/** 카드 간 통신 — 다른 카드와 주고받는 데이터 */
inputs?: DataPortDef[];
outputs?: DataPortDef[];
/** 메타 */
companyCode: string;
version: number;
status: 'draft' | 'published';
createdAt: string;
updatedAt: string;
}
```
### 3.5 Card (대시보드 인스턴스)
```typescript
/** 대시보드에 배치된 카드 한 개 */
export interface Card {
/** 인스턴스 ID */
id: string;
/** Template 참조 */
templateId: string;
/** 대시보드 안에서의 위치 (px 자유배치) */
position: FreePosition;
/** 접힘 (mini 모드) */
collapsed: boolean;
/** 인스턴스별 설정 오버라이드 */
configOverride?: Record<string, any>;
/** 인스턴스별 DataPort 연결 */
inputs?: DataPort[];
outputs?: DataPort[];
}
```
### 3.6 Dashboard
```typescript
/** 사이드바 메뉴 항목 = 카드 컬렉션 */
export interface Dashboard {
/** 식별 */
id: string;
name: string; // '영업 현황'
icon: string; // '💰'
/** 카드 자유배치 */
cards: Card[];
/** 카드 간 데이터 연결 */
connections: CardConnection[];
/** 메타 */
companyCode: string;
ownerId?: string; // 사용자별 대시보드일 때
isShared: boolean; // 공유 대시보드 여부
createdAt: string;
updatedAt: string;
}
/** 카드 간 데이터 연결 (DataPort 매칭) */
export interface CardConnection {
id: string;
from: { cardId: string; port: string };
to: { cardId: string; port: string };
}
```
### 3.7 폐기되는 타입 (types/invyone-component.ts 정리)
```typescript
// ❌ 폐기 — 더 이상 사용하지 않음
- GridPosition { col, colSpan, row, rowSpan }
- AbsolutePosition { x, y, w, h } (FreePosition )
- ComponentPosition (union )
- ResponsiveGridOverride { narrow, normal, wide }
- isGridPosition() / isAbsolutePosition()
- TemplateKind ('business' | 'canvas')
- DEFAULT_COMPONENT_LAYOUTS (12-col )
- CANVAS_KEYWORDS
// ✅ 유지
- FieldConfig, FieldType, FieldRef, FieldOption ()
- DataPort, DataPortType, Connection (, Card )
- ComponentTypeConfig (, v2-* )
```
---
## 4. 빌더 구조
### 4.1 두 빌더 통합
```
폐기:
frontend/components/builder/* (12-grid 빌더, 미완성)
승격:
frontend/components/screen/ScreenDesigner_new.tsx
→ 메인 빌더 (자유배치 + VEX v2-* + 격자 옵션)
→ 경로 변경 검토: components/template-builder/* 로 이동?
```
### 4.2 빌더 진입점
```
frontend/app/(main)/admin/builder/page.tsx
→ 현재: 12-grid 빌더 import
→ 변경: ScreenDesigner_new import
```
### 4.3 빌더 핵심 기능 (현재 ScreenDesigner_new 가 이미 가짐)
- ✅ 자유배치 + 컴포넌트 자체 사이즈
- ✅ 격자 옵션 (snapToGrid, showGrid 토글)
- ✅ 그룹화 / 실행취소 / 다시실행
- ✅ 단축키 (T/M/P/S/R/D/E)
- ✅ ComponentRegistry 기반 v2-* 100+ 컴포넌트
- ✅ 좌측 패널 (테이블, 컴포넌트), 우측 패널 (속성, 스타일, 격자)
- ✅ 중앙 캔버스 (자유배치 드롭)
### 4.4 빌더에 추가해야 하는 기능 (Phase 1)
-**3뷰 전환** (목록/등록/수정 토글) — useBuilderState.ts:25-27 의 BuilderView 패턴 참고
-**Template 저장/불러오기** (toTemplate / fromTemplate) — useBuilderState.ts:279-327 패턴 참고
-**자동 뷰 생성** (등록 버튼 추가 시 create 뷰 placeholder 생성)
-**DataPort 연결 UI** (컴포넌트 간 시각적 연결)
-**카드 미리보기** (실제 대시보드에서 어떻게 보일지)
### 4.5 대시보드 빌더 (Phase 2)
대시보드 빌더도 같은 자유배치 UX:
```
frontend/components/dash/
DashboardCanvas.tsx ← 대시보드 자유배치 캔버스 (재작성)
DashboardCard.tsx ← Card 컴포넌트 = Template 인스턴스
DashboardLayout.tsx ← 셸 (기존 유지)
TemplateLibraryModal.tsx ← 라이브러리 모달 (templateRenderers 패턴)
```
mockup 의 `js/04-templates.js` + `js/05-state.js` 패턴을 참고:
- `templateRenderers``lib/templates/registry.ts` (Template 정의들)
- `renderCanvas``DashboardCanvas.tsx`
- `addCardFromLib` → 라이브러리 모달 + 카드 인스턴스 생성
- `makeDraggable` / `makeResizable` / `applyClamp``useCardDrag.ts` / `useCardResize.ts`
---
## 5. VEX v2-* 컴포넌트 마이그레이션 가이드 (★ Phase 1 핵심)
### 5.1 마이그레이션 목표
모든 v2-* 컴포넌트가 다음 3가지를 갖추게:
1. **`container-type: inline-size`** — 부모 폭 감지 가능
2. **@container 모드 분기** — 최소 wide/narrow 2단계
3. **INVYONE FieldConfig 와 호환** — primaryTable + fields 를 받아서 동작
4. **DataPort 인터페이스** — inputs/outputs 명시
### 5.2 마이그레이션 패턴 (v2-table-list 예시)
#### Before
```tsx
// frontend/lib/registry/components/v2-table-list/TableListComponent.tsx
export const TableListWrapper: React.FC<Props> = (props) => {
return (
<div className="v2-table-list">
<table>...</table>
</div>
);
};
```
#### After
```tsx
export const TableListWrapper: React.FC<Props> = (props) => {
return (
<div className="v2-table-list-container">
{/* 내부에서 모드 분기 */}
<div className="v2-table-list-wide">
<table>...</table>
</div>
<div className="v2-table-list-narrow">
{/* 카드 리스트 모드 */}
{props.data.map(row => <Card key={row.id}>...</Card>)}
</div>
</div>
);
};
```
```css
/* TableListComponent.css */
.v2-table-list-container {
container-type: inline-size;
container-name: v2-table-list;
}
.v2-table-list-wide { display: none; }
.v2-table-list-narrow { display: block; }
@container v2-table-list (min-width: 600px) {
.v2-table-list-wide { display: block; }
.v2-table-list-narrow { display: none; }
}
```
### 5.3 컴포넌트별 마이그레이션 우선순위
#### 우선순위 1 — 가장 많이 쓰는 것 (Phase 1)
- v2-table-list (테이블)
- v2-search-widget (검색)
- v2-button-primary (버튼)
- v2-aggregation-widget (KPI)
- v2-card-display (카드 표시)
- v2-input, v2-select, v2-date (입력 필드)
- v2-text-display (텍스트)
#### 우선순위 2 — 도메인 특화 (Phase 2)
- v2-bom-tree, v2-bom-item-editor
- v2-shipping-plan-editor
- v2-pivot-grid
- v2-timeline-scheduler
- v2-process-work-standard
- v2-approval-step
#### 우선순위 3 — 보조 (Phase 3)
- v2-rack-structure
- v2-numbering-rule
- v2-category-manager
- v2-divider-line
- v2-file-upload, v2-media
### 5.4 ComponentRegistry 통합
```typescript
// 각 컴포넌트의 createComponentDefinition 호출에 추가
{
id: 'v2-table-list',
...
// ★ 추가
containerQuery: {
breakpoints: {
narrow: { maxWidth: 599 },
wide: { minWidth: 600 },
},
},
// ★ INVYONE 통합
fieldConfigSupported: true,
dataPorts: {
inputs: [
{ name: 'searchParams', type: 'params' },
{ name: 'refreshTrigger', type: 'value' },
],
outputs: [
{ name: 'selectedRow', type: 'row' },
{ name: 'selectedRows', type: 'rows' },
],
},
}
```
---
## 6. 작업 순서 (Phase 1 ~ 3)
### Phase 1 — 토대 정리 (1~2주)
**목표**: 폐기/유지 정리 + 핵심 컴포넌트 마이그레이션
1. **타입 정리** (1일)
- `types/invyone-component.ts` 에서 GridPosition, TemplateKind, DEFAULT_COMPONENT_LAYOUTS, CANVAS_KEYWORDS 폐기
- FreePosition, TemplateComponent, ViewConfig 정의 추가
- Template 인터페이스 수정 (kind 필드 제거)
- Card, Dashboard 인터페이스 추가
2. **components/builder 폐기** (1일)
- `frontend/components/builder/*` 전체 삭제 (또는 `_obsolete/` 로 이동)
- `frontend/app/(main)/admin/builder/page.tsx` 의 import 경로 변경
- 빌더 진입점이 ScreenDesigner_new 를 가리키게
3. **ScreenDesigner 정리 + 3뷰 추가** (3~4일)
- `ScreenDesigner_new.tsx``ScreenDesigner.tsx` 중 하나만 남기기 (new 권장)
- 3뷰 토글 UI 추가 (목록/등록/수정)
- useBuilderState 패턴 차용 (toTemplate / fromTemplate)
- 자동 뷰 생성 (등록 버튼 → create 뷰 placeholder)
4. **v2-* 우선순위 1 마이그레이션** (3~4일)
- v2-table-list, v2-search-widget, v2-button-primary, v2-aggregation-widget, v2-card-display, v2-input, v2-select, v2-date, v2-text-display
- 각 컴포넌트에 `container-type` + `@container` 추가
- wide/narrow 모드 분기
5. **Phase 1 검증**
- 수주관리 화면을 ScreenDesigner 로 만들기 (PoC)
- 카드 폭 변경 시 내부 컴포넌트가 모드 전환되는지 확인
### Phase 2 — 대시보드 + 카드 시스템 (2~3주)
**목표**: 자유배치 대시보드 + Card = Template 인스턴스 모델
> ### ★ Phase 2 시작 시 가장 먼저 할 일 — Phase 1 에서 미뤄둔 레거시 타입 정리
>
> Phase 1 Step 5 에서 `DashboardCard.tsx` 가 여전히 아래 타입들을 쓰고 있어
> `@deprecated` 주석만 달고 보존했다. Phase 2 의 `DashboardCard.tsx` 재작성
> 과 함께 이 항목들을 **일괄 제거**한다. 안 하면 영원히 남는다.
>
> **제거 대상** (`frontend/types/invyone-component.ts`):
> - [ ] `GridPosition` 인터페이스
> - [ ] `AbsolutePosition` 인터페이스
> - [ ] `ComponentPosition` 유니언
> - [ ] `ResponsiveGridOverride` 인터페이스
> - [ ] `isGridPosition()` / `isAbsolutePosition()` 타입 가드
> - [ ] `TemplateKind` 유니언
> - [ ] `DEFAULT_COMPONENT_LAYOUTS` 상수
> - [ ] `CANVAS_KEYWORDS` 상수
> - [ ] `Template.kind` 필드 (현재 `kind?: TemplateKind` 로 optional + deprecated)
>
> **대체**: 전부 `FreePosition { left, top, width, height }` + 단일 자유배치 모델
> (§3.1) 로 교체. `DashboardCard.tsx` 재작성 중 사용처를 FreePosition 으로
> 교체하면 이 타입들은 자연스럽게 의존처 0 이 되어 제거 가능.
>
> **검증**: Phase 2 종료 전에 `grep -rn "GridPosition\|AbsolutePosition\|TemplateKind"
> frontend/` 로 잔존 확인.
1. **대시보드 데이터 모델** (1일)
- Dashboard, Card, CardConnection 인터페이스 구현
- state 관리 (Zustand 또는 Context)
2. **DashboardCanvas 재작성** (3~4일)
- 자유배치 카드 캔버스
- 드래그/리사이즈/clamp (mockup `js/02-canvas.js` 패턴)
- 카드 추가/삭제/이동/접기
3. **TemplateLibraryModal** (2일)
- mockup 의 라이브러리 모달 (`index.html` 의 library 패턴)
- Template 목록 → 카드 추가
4. **카드 ↔ Template 인스턴스화 로직** (2일)
- templateId 로 Template 조회
- configOverride 적용
- 카드 헤더 (아이콘+이름+배지+버튼)
5. **사이드바 동적 대시보드 목록** (2일)
- mockup 의 `js/05-state.js` 의 sidebar 패턴
- 대시보드 추가/삭제/이름변경
6. **저장/복원** (1일)
- 백엔드 API + localStorage 폴백
- 대시보드/카드/연결 모두 영속화
7. **v2-* 우선순위 2 마이그레이션 시작** (병렬, 1주)
- 도메인 특화 컴포넌트들 (BOM, 출하, 피벗, 일정 등)
### Phase 3 — 데이터 흐름 + 도메인 확장 (3~4주)
**목표**: DataPort 연결 + 모든 v2-* 컴포넌트 마이그레이션 완료
1. **DataPort 연결 UI** (3일)
- 빌더에서 컴포넌트 간 시각적 연결
- Connection 데이터 모델 구현
- 런타임 이벤트 버스 (mockup 패턴)
2. **카드 간 연결 (대시보드 레벨)** (2일)
- 다른 Template 의 카드끼리 데이터 주고받기
- 예: 수주 카드의 selectedRow → BOM 카드의 inputs
3. **v2-* 우선순위 3 마이그레이션 완료** (1~2주, 병렬)
- 나머지 컴포넌트들
4. **자동 생성/프리셋** (1주)
- Template 자동 생성 (DB 메타 → 기본 Template)
- 도메인별 시작 템플릿 (수주관리, 인사정보, 재고관리)
---
## 7. 영향 범위 — 파일별 액션
### 폐기
| 파일/폴더 | 액션 |
|---|---|
| `frontend/components/builder/` (전체) | 삭제 (또는 _obsolete/) |
| `frontend/components/builder/BuilderCanvas.tsx` | 삭제 |
| `frontend/components/builder/BuilderToolbar.tsx` | 삭제 |
| `frontend/components/builder/BuilderBlock.tsx` | 삭제 |
| `frontend/components/builder/BuilderPalette.tsx` | 삭제 |
| `frontend/components/builder/BuilderProps.tsx` | 삭제 |
| `frontend/components/builder/BuilderLayout.tsx` | 삭제 |
| `frontend/components/builder/hooks/useBuilderState.ts` | **참고 후 삭제** (3뷰/toTemplate 패턴 차용) |
| `frontend/components/builder/hooks/gridMetrics.ts` | 삭제 (12-grid 헬퍼) |
| `frontend/components/builder/hooks/useBlockDrag.ts` | 삭제 |
| `frontend/components/builder/props/*` | 삭제 |
### 수정 (12-grid 흔적 제거)
| 파일 | 수정 내용 |
|---|---|
| `frontend/types/invyone-component.ts` | GridPosition, TemplateKind, DEFAULT_COMPONENT_LAYOUTS, CANVAS_KEYWORDS, ResponsiveGridOverride 폐기. FreePosition, TemplateComponent, ViewConfig, Card, Dashboard 추가. Template 에서 kind 필드 제거 |
| `frontend/types/screen.ts` | invyone-component.ts 와 통합 검토. ScreenDefinition → Template 매핑 |
| `frontend/app/(main)/admin/builder/page.tsx` | import 변경 (BuilderLayout → ScreenDesigner_new) |
| `frontend/components/dash/DashboardCanvas.tsx` | 자유배치 카드 캔버스로 재작성 |
| `frontend/components/dash/DashboardCard.tsx` | Card = Template 인스턴스 모델로 재작성 (transform: scale 제거 — 이미 결정) |
| `frontend/components/dash/DashboardLayout.tsx` | 셸 정리 |
| `frontend/styles/dashboard.css` | grid CSS 제거, free positioning CSS |
| `frontend/components/layout/AppLayout.tsx` | 사이드바 동적 대시보드 목록 |
### 승격 (메인 빌더로)
| 파일 | 액션 |
|---|---|
| `frontend/components/screen/ScreenDesigner_new.tsx` | 메인 빌더 승격. 3뷰 + Template 저장/로드 추가 |
| `frontend/components/screen/ScreenDesigner.tsx` | 검토 후 폐기 (구버전) |
| `frontend/components/screen/panels/ComponentsPanel.tsx` | 유지 + INVYONE Template 통합 |
| `frontend/components/screen/panels/PropertiesPanel.tsx` | 유지 |
| `frontend/components/screen/panels/GridPanel.tsx` | 유지 (격자 옵션) |
| `frontend/components/screen/panels/TablesPanel.tsx` | 유지 |
| `frontend/components/screen/DesignerToolbar.tsx` | 유지 + 3뷰 토글 추가 |
### 마이그레이션 (v2-* 100+ 컴포넌트)
| 폴더 | 액션 |
|---|---|
| `frontend/lib/registry/components/v2-*/` (모든 폴더) | @container 추가 + INVYONE Template 통합 + DataPort 인터페이스 추가 |
| `frontend/lib/registry/ComponentRegistry.ts` | INVYONE 모델 통합 (containerQuery, dataPorts 필드 추가) |
### 신규
| 파일 | 액션 |
|---|---|
| `frontend/lib/templates/registry.ts` | Template 정의들 (mockup `js/04-templates.js` 의 templateRenderers 패턴) |
| `frontend/components/templates/HrEmployeeList.tsx` | 인사정보 풀 카드 (mockup 매핑) |
| `frontend/components/templates/SalesOrderList.tsx` | 수주관리 풀 카드 |
| `frontend/components/templates/SalesKpi.tsx` | 매출 KPI 카드 |
| `frontend/components/templates/...` | 기타 도메인 카드들 |
| `frontend/components/dash/TemplateLibraryModal.tsx` | 라이브러리 모달 |
| `frontend/hooks/useCardDrag.ts` | 카드 드래그 (mockup `js/02-canvas.js` 패턴) |
| `frontend/hooks/useCardResize.ts` | 카드 리사이즈 |
| `frontend/store/dashboardStore.ts` | 대시보드 state (Zustand) |
---
## 8. 핵심 코드 스니펫 (구현 참고)
### 8.1 자유배치 캔버스 (DashboardCanvas)
```tsx
// frontend/components/dash/DashboardCanvas.tsx
"use client";
import { useDashboardStore } from "@/store/dashboardStore";
import { DashboardCard } from "./DashboardCard";
export function DashboardCanvas() {
const dashboard = useDashboardStore(s => s.activeDashboard);
const updateCardPosition = useDashboardStore(s => s.updateCardPosition);
if (!dashboard) return null;
return (
<div className="dash-canvas" style={{ position: 'relative', width: '100%', height: '100%' }}>
{dashboard.cards.length === 0 && (
<div className="dash-empty">
<button onClick={openLibrary}>+ 릿 </button>
</div>
)}
{dashboard.cards.map(card => (
<DashboardCard
key={card.id}
card={card}
onMove={(pos) => updateCardPosition(card.id, pos)}
/>
))}
</div>
);
}
```
### 8.2 카드 컴포넌트 (DashboardCard)
```tsx
// frontend/components/dash/DashboardCard.tsx
import { useTemplateRegistry } from "@/lib/templates/registry";
import { useCardDrag } from "@/hooks/useCardDrag";
import { useCardResize } from "@/hooks/useCardResize";
export function DashboardCard({ card, onMove }: { card: Card, onMove: (pos: FreePosition) => void }) {
const template = useTemplateRegistry(card.templateId);
const { onDragStart } = useCardDrag(card, onMove);
const { onResizeStart } = useCardResize(card, onMove);
if (!template) return null;
return (
<div
className="dash-card"
style={{
position: 'absolute',
left: card.position.left,
top: card.position.top,
width: card.position.width,
height: card.position.height,
// ★ 카드 자체가 container — 내부 컴포넌트들이 폭 감지
containerType: 'inline-size',
containerName: 'card',
}}
>
<div className="dash-card-head" onMouseDown={onDragStart}>
<span className="dash-card-icon">{template.icon}</span>
<span className="dash-card-title">{template.name}</span>
<span className="dash-card-badge">{template.badge}</span>
<button onClick={() => toggleCollapse(card.id)}></button>
<button onClick={() => removeCard(card.id)}>×</button>
</div>
<div className="dash-card-body">
{card.collapsed
? <TemplateRenderer template={template} view="mini" />
: <TemplateRenderer template={template} view="list" />
}
</div>
<div className="dash-card-resize-handle" onMouseDown={onResizeStart} />
</div>
);
}
```
### 8.3 Template 렌더러
```tsx
// frontend/components/templates/TemplateRenderer.tsx
export function TemplateRenderer({ template, view = 'list' }: Props) {
const viewConfig = template.views[view];
if (!viewConfig) return null;
return (
<div className="tpl-canvas" style={{ position: 'relative', width: '100%', height: '100%' }}>
{viewConfig.components.map(comp => (
<ComponentRenderer key={comp.id} component={comp} />
))}
</div>
);
}
function ComponentRenderer({ component }: { component: TemplateComponent }) {
const def = ComponentRegistry.getComponent(component.componentId);
if (!def) return null;
const Component = def.component;
return (
<div
style={{
position: 'absolute',
left: component.position.left,
top: component.position.top,
width: component.position.width,
height: component.position.height,
}}
>
<Component {...component.config} />
</div>
);
}
```
### 8.4 v2-* 컴포넌트 @container 패턴
```tsx
// frontend/lib/registry/components/v2-table-list/TableListComponent.tsx
export const TableListWrapper: React.FC<Props> = (props) => {
return (
<div className="v2-table-list-container">
{/* 두 모드를 모두 렌더하고 CSS 로 토글 */}
<div className="v2-table-list-wide">
<table>
<thead>...</thead>
<tbody>...</tbody>
</table>
</div>
<div className="v2-table-list-narrow">
{props.data.map(row => (
<div key={row.id} className="v2-table-list-card">
{props.fields.map(f => (
<div key={f.column}>
<span className="label">{f.label}</span>
<span className="value">{row[f.column]}</span>
</div>
))}
</div>
))}
</div>
</div>
);
};
```
```css
/* TableListComponent.css */
.v2-table-list-container {
container-type: inline-size;
container-name: v2-table-list;
width: 100%;
height: 100%;
}
.v2-table-list-wide { display: none; }
.v2-table-list-narrow { display: block; }
@container v2-table-list (min-width: 600px) {
.v2-table-list-wide { display: block; }
.v2-table-list-narrow { display: none; }
}
```
### 8.5 Template 저장/불러오기 (useBuilderState 패턴 차용)
```typescript
// frontend/store/builderStore.ts
export const useBuilderStore = create<BuilderState>((set, get) => ({
// ... 기존 state
toTemplate: (): Template => {
const s = get();
return {
templateId: s.templateId || generateId(),
name: s.templateName,
icon: s.icon,
badge: s.badge,
category: s.category,
defaultSize: s.defaultSize,
primaryTable: s.tableName,
fields: s.fields,
views: {
list: { components: s.views.list, layout: 'card' },
create: s.views.create.length > 0 ? { components: s.views.create, layout: 'modal' } : undefined,
edit: s.views.edit.length > 0 ? { components: s.views.edit, layout: 'modal' } : undefined,
},
companyCode: '*',
version: 1,
status: 'draft',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
},
fromTemplate: (tpl: Template) => {
set({
templateId: tpl.templateId,
templateName: tpl.name,
icon: tpl.icon,
badge: tpl.badge,
category: tpl.category,
defaultSize: tpl.defaultSize,
tableName: tpl.primaryTable,
fields: tpl.fields ?? [],
views: {
list: tpl.views.list.components,
create: tpl.views.create?.components ?? [],
edit: tpl.views.edit?.components ?? [],
},
currentView: 'list',
});
},
}));
```
---
## 9. 핵심 보장 사항 (사용자 약속)
> 사용자가 "반응형 된다" 는 말 믿고 진행하는 결정.
> 이 보장이 깨지면 백업 플랜으로 회피 가능.
### 보장 1: PC 시나리오 (FHD~4K) 100% 작동
- 모든 주요 v2-* 컴포넌트가 wide/narrow 2단계 모드 가지면
- 카드 폭 280~1920px 범위에서 자동 반응형 작동
### 보장 2: 카드 사이즈 변경 시 내부 자동 반응
- 사용자가 카드를 800x500 → 400x500 으로 드래그 리사이즈
- 카드 내부 v2-table-list 가 @container 로 폭 감지
- 자동으로 카드 리스트 모드 전환
- 사용자는 아무 작업 안 함
### 보장 3: 수십 컴포넌트 화면 가능
- PLM/MES 워크벤치 같은 복잡 화면
- 카드 1개 안에 v2-* 컴포넌트 10~20개 자유 배치 가능
- 또는 카드 5~10개 자유 배치
- 카드 간 데이터 연결 (CardConnection) 으로 통신
### 보장 4: VEX 사용자 학습 곡선 거의 없음
- ScreenDesigner 가 이미 자유배치 + 격자 옵션
- VEX 사용자가 "더 정렬 잘 되는 VEX" 로 인식
- 새로 배울 게 거의 없음
### 한계 (백업 플랜 적용 케이스)
- ❌ 모바일/태블릿 (200px 이하) — Phase 4 이후 별도 모바일 뷰
- ❌ 컴포넌트들이 "재배치" 되어야 할 때 — 컨테이너 컴포넌트 (탭/아코디언) 사용
-@container 미지원 브라우저 — 모던 브라우저 (Chrome 105+, Safari 16+, Firefox 110+) 만 타겟
---
## 10. 검증 체크리스트 (Phase 1 완료 시)
### 코드 정리
- [ ] `frontend/components/builder/*` 폐기 완료
- [ ] `frontend/types/invyone-component.ts` 정리 (GridPosition 등 제거)
- [ ] FreePosition, TemplateComponent, ViewConfig, Card, Dashboard 타입 추가
- [ ] `frontend/app/(main)/admin/builder/page.tsx` 에서 ScreenDesigner_new 사용
### 빌더
- [ ] ScreenDesigner_new 가 메인 빌더로 동작
- [ ] 3뷰 토글 (목록/등록/수정)
- [ ] Template 저장/불러오기
- [ ] 자동 뷰 생성 (등록 버튼 → create 뷰)
### v2-* 마이그레이션 (우선순위 1)
- [ ] v2-table-list @container 적용
- [ ] v2-search-widget @container 적용
- [ ] v2-button-primary @container 적용
- [ ] v2-aggregation-widget @container 적용
- [ ] v2-card-display @container 적용
- [ ] v2-input, v2-select, v2-date 검증
### PoC
- [ ] 수주관리 화면을 ScreenDesigner 로 만들기
- [ ] Template 저장 → 불러오기
- [ ] 카드 폭 800 → 400 변경 시 v2-table-list 가 카드 리스트로 전환
### 보장 검증
- [ ] 카드 폭 변화에 모든 우선순위 1 컴포넌트가 자동 반응
- [ ] 격자 옵션 (snapToGrid) ON/OFF 정상 동작
- [ ] 그룹화/실행취소 정상 동작
---
## 11. 다른 클로드 세션을 위한 작업 지시
이 문서를 보고 작업할 다른 클로드 세션은:
1. **반드시** 이 문서가 진실의 원천이라고 인지할 것
2. 이전 12-grid / 48-col / 섹션 / business-canvas 분기 모델은 모두 폐기됨
3. `notes/gbpark/2026-04-10-template-model-redesign.md` 는 obsolete (참고만)
4. `notes/gbpark/2026-04-08-invyone-mockup/` 의 mockup 의도를 따라갈 것
5. **반응형은 컴포넌트 내부 @container 로 처리** 가 핵심
6. Phase 1 부터 순서대로 진행
7. 각 Phase 완료 시 검증 체크리스트로 확인
8. 막히면 **반드시 사용자에게 질문**, 추측 구현 금지
### 작업 시작 전 필독
- `notes/gbpark/2026-04-08-invyone-mockup/README.md` (mockup 의도)
- `notes/gbpark/2026-04-08-invyone-mockup/js/04-templates.js` (templateRenderers 패턴)
- `notes/gbpark/2026-04-08-invyone-mockup/js/05-state.js` (state 패턴)
- `frontend/components/screen/ScreenDesigner_new.tsx` (메인 빌더 베이스)
- `frontend/lib/registry/ComponentRegistry.ts` (컴포넌트 레지스트리)
### 작업 시 반드시
- 폐기 결정 사항 다시 도입 금지 (12-grid, 48-col, 섹션 등)
- 컴포넌트가 멍청하면 안 됨 (반드시 @container 모드 가짐)
- 자유배치 px 가 기본 (그리드 셀 강제 금지)
- 카드 = Template 인스턴스 (1:N) 관계 명확히
---
## 끝
이 문서는 INVYONE 카드 엔진의 최종 진실. Phase 1 부터 시작하면 됨.
**작성자 메모**: 사용자가 "반응형 된다"는 약속을 믿고 진행하는 결정이므로, 반응형 메커니즘 (컴포넌트 @container) 의 작동을 Phase 1 PoC 에서 반드시 검증할 것. 실패하면 백업 플랜 (카드 min-width, 모드 강제 등) 적용.
@@ -0,0 +1,323 @@
# INVYONE 카드 엔진 Phase 1 — 구현 로그
**작업일**: 2026-04-10
**작업자**: gbpark + Claude
**기반 스펙**: `notes/gbpark/2026-04-10-card-engine-final-spec.md`
**이전 로그**: `notes/gbpark/2026-04-10-template-redesign-phase1-log.md` (OBSOLETE — 12-grid 모델)
---
## 0. 요약
스펙 §6 의 Phase 1 (토대 정리 + 핵심 컴포넌트 마이그레이션) 을 7단계로 나눠 진행.
| Step | 제목 | 상태 |
|---|---|---|
| 1 | 타입 추가 (FreePosition, TemplateComponent, TemplateViewConfig, Card, Dashboard…) | ✅ |
| 2 | template-builder 새 빌더 작성 (3뷰 + toTemplate/fromTemplate) | ✅ |
| 3 | admin/builder/page.tsx 진입점 전환 | ✅ |
| 4 | components/builder/* + ScreenDesigner_new.tsx 완전 삭제 | ✅ |
| 5 | 레거시 타입 @deprecated 표기 (완전 제거는 Phase 2) | ✅ |
| 6 | v2-* 우선순위 1 컴포넌트 마이그레이션 (완전 2 + 경량 7) | ✅ |
| 7 | PoC 시각 검증 페이지 | ✅ (코드 + 브라우저 검증 완료 2026-04-11) |
| 8 | 세션 후반 버그 픽스 (검증 중 발견) | ✅ (2026-04-11) |
**tsc 결과**: 내 변경 영역 기준 에러 0. 전체 에러 카운트는 3423 → 3381 로 감소(삭제된 폐기 파일의 기존 에러가 사라짐).
---
## 1. 수정·생성 파일
### 타입
- **수정** `frontend/types/invyone-component.ts`
- §7 추가: `FreePosition`, `ViewTrigger`, `TemplateComponent`, `TemplateViewConfig`, `TemplateViews`, `DataPortDef`, `Card`, `CardConnection`, `Dashboard`
- 레거시 타입 전부 `@deprecated` 표기: `GridPosition`, `AbsolutePosition`, `ComponentPosition`, `ResponsiveGridOverride`, `isGridPosition`, `isAbsolutePosition`, `TemplateKind`, `DEFAULT_COMPONENT_LAYOUTS`, `CANVAS_KEYWORDS`
- `Template.kind``kind?: TemplateKind` 로 optional + deprecated 변경
### 빌더 (신규)
- **신규** `frontend/components/template-builder/TemplateBuilder.tsx` (~550줄)
- 자유배치 캔버스 + 드래그/리사이즈 + 히스토리 + 격자 옵션 + 3뷰 토글 + 팔레트 + 속성/격자/메타 사이드 패널
- v2-* 렌더링은 placeholder (Phase 2 에서 실제 컴포넌트 연결)
- localStorage 기반 임시 저장/복원
- 단축키: Delete, Ctrl+Z/Y, Ctrl+S
- **신규** `frontend/components/template-builder/store/templateBuilderStore.ts`
- Zustand, `toTemplate` / `fromTemplate` / `undo` / `redo` / `commit` / viewTrigger 자동 감지
- `BuilderView = 'list' | 'create' | 'edit'`
### 빌더 진입점 전환
- **수정** `frontend/app/(main)/admin/builder/page.tsx`
- `BuilderLayout` (12-grid) → `TemplateBuilder` import 교체
### 폐기 삭제
- **삭제** `frontend/components/builder/` 전체
- BuilderLayout/Canvas/Block/Palette/Props/Toolbar + hooks(useBuilderState, useBlockDrag, gridMetrics) + props/*
- **삭제** `frontend/components/screen/ScreenDesigner_new.tsx` (dead code, 어디서도 import 되지 않았음)
### v2-* 마이그레이션 — 완전 (2개)
- **신규** `frontend/lib/registry/components/v2-table-list/TableListContainerWrapper.tsx`
- ResizeObserver 로 카드 폭 측정 → 600px 미만이면 `config.displayMode``"card"` 로 런타임 override
- 기존 `TableListComponent` 내부는 0줄 수정
- **수정** `frontend/lib/registry/components/v2-table-list/index.ts`
- `component: TableListWrapper``component: TableListContainerWrapper`
- **신규** `frontend/lib/registry/components/v2-table-search-widget/TableSearchContainerWrapper.tsx`
- 래퍼 div 에 `.v2-tsw-responsive-root` 클래스 부여
- **신규** `frontend/lib/registry/components/v2-table-search-widget/table-search-widget-responsive.css`
- `container-type: inline-size` + `@container v2-tsw (max-width: 599px)` 에서 flex-col 강제
- **수정** `frontend/lib/registry/components/v2-table-search-widget/index.tsx`
- `component: TableSearchWidget``component: TableSearchContainerWrapper`
### v2-* 마이그레이션 — 경량 (7개)
- **신규** `frontend/lib/registry/hoc/withContainerQuery.tsx`
- 간단한 HOC: `<div style={{ containerType: 'inline-size', containerName, width: '100%', height: '100%' }}>` 로 감싸기
- **수정** 각 컴포넌트 `index.ts` 에서 `component: Wrapper``component: withContainerQuery(Wrapper, "<id>")`:
- v2-button-primary
- v2-aggregation-widget
- v2-card-display
- v2-input
- v2-select
- v2-date
- v2-text-display
### PoC 검증
- **신규** `frontend/app/(main)/test-card-responsive/page.tsx`
- 카드 폭 슬라이더 (240 ~ 1400px)
- 수주관리 화면 구성 (v2-text-display + aggregation-widget + search-widget + button-primary + table-list 의 **레이아웃 시뮬레이션**)
- ResizeObserver 기반 `data-v2-table-list-mode` 전환 + CSS @container 기반 search-widget 세로 스택 동시 시각 확인
### 스펙 MD
- **수정** `notes/gbpark/2026-04-10-card-engine-final-spec.md`
- Phase 2 시작 섹션에 "Phase 1 에서 미뤄둔 레거시 타입 정리" 체크리스트 삽입
---
## 2. 주요 결정 사항 (사용자 협의)
### 결정 1 — ScreenDesigner_new 처리 방식
**옵션**: (a) 완전 재작성 / (b) 변환 레이어만 추가 / (c) 백지에서 새로 작성
**결정**: **(c) 와 (a) 혼합 — 새 `components/template-builder/` 폴더에 완전 재작성**
**이유**: ScreenDesigner_new 는 `ScreenDefinition/ComponentData/LayoutData` 기반이라 Template 모델로의 전환은 사실상 재작성. 새 경로가 의미상 더 명확(스펙 §4.1 검토 항목).
### 결정 2 — components/builder 폐기 방식
**결정**: **완전 삭제**
**이유**: 스펙 MD 가 폐기 명시, git history 로 추적 가능, 흔적 남기면 혼란.
### 결정 3 — Step 5 레거시 타입 처리
**옵션**: (a) @deprecated + 유지 / (b) DashboardCard 도 임시 수정해 완전 제거 / (c) Phase 2 로 이관
**결정**: **(a) @deprecated 주석 + 유지 + Phase 2 체크리스트에 제거 항목 명시**
**이유**: DashboardCard.tsx 는 Phase 2 재작성 대상(스펙 §6/§7). Phase 1 에서 임시 수정하면 Phase 2 재작성 때 두 번 일. 옵션 3 은 방향성 불명.
### 결정 4 — Step 6 v2-* 마이그레이션 범위
**결정**: **핵심 2개 완전 + 나머지 7개 경량**
- **완전**: v2-table-list (ResizeObserver), v2-table-search-widget (CSS @container)
- **경량**: button-primary, input, select, date, text-display, card-display, aggregation-widget → `container-type: inline-size` 만 부착
**이유**: PoC 검증력을 담보(검색+테이블 CRUD 기본형)하면서 Phase 1 스코프 유지. 나머지 7개의 모드 분기는 Phase 2 개별 재작성 시 추가.
---
## 3. Phase 2 이월 체크리스트
스펙 MD §6 Phase 2 섹션에 다음 항목이 추가됐다(`notes/gbpark/2026-04-10-card-engine-final-spec.md` 참조).
### 레거시 타입 최종 제거 (Phase 1 이월)
- [ ] `GridPosition`, `AbsolutePosition`, `ComponentPosition`, `ResponsiveGridOverride` 제거
- [ ] `isGridPosition()`, `isAbsolutePosition()` 가드 제거
- [ ] `TemplateKind`, `DEFAULT_COMPONENT_LAYOUTS`, `CANVAS_KEYWORDS` 제거
- [ ] `Template.kind` 필드 완전 제거
- [ ] `DashboardCard.tsx` 재작성하면서 위 타입 사용처를 전부 `FreePosition` 으로 교체
- [ ] 잔존 확인: `grep -rn "GridPosition\|AbsolutePosition\|TemplateKind" frontend/`
### 경량 7개 v2-* 컴포넌트 모드 분기 추가 (Phase 1 이월)
- [ ] v2-button-primary: narrow 에서 아이콘만 / wide 에서 아이콘+텍스트
- [ ] v2-input: 라벨 위치 (narrow=top / wide=left)
- [ ] v2-select: 동일
- [ ] v2-date: 동일 + narrow 에서 range 모드 자동 compact
- [ ] v2-text-display: font-size 반응
- [ ] v2-card-display: narrow 에서 cardsPerRow 자동 1
- [ ] v2-aggregation-widget: narrow 에서 2x2 그리드
### 우선순위 2/3 v2-* 마이그레이션 (스펙 §5.3)
- [ ] 우선순위 2: v2-bom-tree, v2-bom-item-editor, v2-shipping-plan-editor, v2-pivot-grid, v2-timeline-scheduler, v2-process-work-standard, v2-approval-step
- [ ] 우선순위 3: v2-rack-structure, v2-numbering-rule, v2-category-manager, v2-divider-line, v2-file-upload, v2-media
### 기타
- [ ] TemplateBuilder 의 placeholder 렌더링 → 실제 v2-* 컴포넌트 렌더링 (ComponentRegistry 연동)
- [ ] TemplateBuilder 저장/복원을 localStorage 에서 백엔드 API 로
- [ ] 3뷰 자동 생성 실제 동작 (등록 버튼 감지 → create 뷰 placeholder 자동 생성) — 현재 store 에는 탐지 로직 있으나 UI placeholder 생성 로직 없음
---
## 4. 사용자 수동 검증 체크리스트 (Phase 1 종료 조건)
다음 항목을 브라우저에서 직접 확인.
### (A) 테스트 페이지 — 반응형 메커니즘 시각 확인
1. 도커 or 로컬에서 frontend 실행
2. 브라우저에서 `/test-card-responsive` 접속
3. 카드 폭 슬라이더를 **800 → 400** 으로 드래그
4. 확인:
- [ ] 상단 "감지된 모드" 배지가 `wide``narrow` 로 전환 (버튼 색도 인디고 → 로즈)
- [ ] v2-table-list 영역이 테이블 → 카드 리스트로 재렌더 (`data-v2-table-list-mode` 속성값 wide/narrow 교차)
- [ ] v2-table-search-widget 영역의 필터/버튼이 가로 → 세로 스택으로 재배열
5. 슬라이더 프리셋 (320 / 520 / 800 / 1200) 로 경계값 확인
6. **실패 시**: 스펙 §9 백업 플랜 적용 검토 (카드 min-width 강제, 모드 강제 등)
### (B) 빌더 접근 확인
1. `/admin/builder` 접속
2. 확인:
- [ ] TemplateBuilder UI 가 표시 (상단 툴바 + 좌측 팔레트 + 중앙 캔버스 + 우측 속성 패널)
- [ ] 상단 탭에서 목록/등록/수정 3뷰 토글 가능
- [ ] 좌측 팔레트에서 v2-* 컴포넌트 드래그해 캔버스에 드롭 → 배치 성공
- [ ] 배치된 블록 드래그로 이동, 우하단 핸들로 리사이즈
- [ ] Ctrl+Z 실행취소, Ctrl+S 저장 (브라우저 저장 다이얼로그 아님 → localStorage)
- [ ] 새로고침 후에도 localStorage 에서 복원
3. 팔레트에 v2-* 컴포넌트가 안 보이면 ComponentRegistry 초기화가 안 된 것. 그 경우 fallback 9개가 표시되어야 함.
### (C) 기존 기능 영향 없음 확인
1. `/admin/screenMng` 접속 → ScreenDesigner(구버전) 가 여전히 동작
2. `/dashboard/...` 접속 → DashboardCard 가 여전히 동작 (Phase 2 재작성 전까지 @deprecated 타입 의존)
3. 아무 v2-* 사용 화면이 있으면 팔레트 드래그 안 해도 렌더 자체는 그대로
---
## 5. 알려진 한계 / TODO
1. **TemplateBuilder 는 아직 placeholder 빌더**. 실제 v2-* 컴포넌트는 Phase 2 에서 연결. 지금은 블록명/크기만 표시.
2. **ResizeObserver 기반 v2-table-list 는 `isDesignMode=true` 에서는 narrow 카드 모드가 동작하지 않음** — TableListComponent 의 원 로직이 design 모드에서 card 분기를 건너뜀. 빌더 안 프리뷰에서는 확인 불가, 실 렌더링(런타임)에서만 작동. 테스트는 `/test-card-responsive` 페이지로 수행.
3. **localStorage 저장 키**: `invyone-template:<templateId>` / `invyone-template:__last__`. 나중에 백엔드 API 로 전환 시 마이그레이션 필요.
4. **DashboardCard.tsx 는 여전히 레거시 타입 의존**. Phase 2 재작성 전까지 그대로 유지.
5. **스펙의 v2-search-widget 은 실제 폴더명이 v2-table-search-widget**. 이름만 다르고 마이그레이션은 정상 적용.
---
## 6. 참고 (작업 중 확인한 기존 파일)
- `frontend/lib/registry/components/v2-table-list/TableListComponent.tsx` (283KB, 내부 불변 약속)
- `frontend/lib/registry/components/v2-table-list/CardModeRenderer.tsx` (기존 카드 모드 렌더러 — 재사용)
- `frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx` (880줄, 내부 불변)
- `frontend/components/screen/ScreenDesigner.tsx` (347KB, screenMng 사용 중, 건드리지 않음)
- `frontend/components/dash/DashboardCard.tsx` (Phase 2 재작성 대상)
---
## 7. 세션 후반 버그 픽스 (검증 중 발견, 2026-04-11)
Phase 1 코드 작업이 완료된 뒤 §4 사용자 수동 검증을 진행하면서 3개의 버그가 발견되어 즉시 수정됨. Phase 1 의 일부로 포함.
### 7.1 `/test-card-responsive` 가 AppLayout 탭 시스템에 흡수됨
**증상**: 브라우저에서 URL 접속 시 `/admin` 과 비슷한 셸(사이드바 + 빈 탭 영역) 만 나오고 "열린 탭이 없습니다" 텍스트가 표시됨. PoC 페이지 자체가 렌더되지 않음.
**원인**: 페이지가 `frontend/app/(main)/test-card-responsive/page.tsx` 에 있었는데, `frontend/app/(main)/layout.tsx``<AppLayout>{children}</AppLayout>` 으로 감싸고 있고, `AppLayout` 은 탭 기반 SPA 셸이라 `/test-card-responsive` 라는 URL 이 탭 시스템에 등록되어 있지 않으면 `children` 이 렌더되지 않았음.
**수정**: 파일을 `(main)` 그룹 밖으로 이동해서 AppLayout 을 우회.
- 이전: `frontend/app/(main)/test-card-responsive/page.tsx`
- 이후: `frontend/app/test-card-responsive/page.tsx`
루트 layout 만 적용되고 AppLayout 의 탭 시스템은 통과. 검증 페이지라 사이드바/헤더가 필요 없어 문제 없음.
### 7.2 `useRegistryPalette` 가 VEX `default_size` 포맷 불일치로 NaN position 생성
**증상**: TemplateBuilder 팔레트에서 컴포넌트(예: split-panel-layout)를 드래그해 캔버스에 드롭해도 블록이 시각적으로 나타나지 않음. 우측 속성 패널의 LEFT/TOP/WIDTH/HEIGHT 필드가 모두 비어있음. 컴포넌트 자체는 선택된 상태(COMPONENT 이름 + CONFIG JSON 표시) 로 패널에 보이지만 캔버스에서는 사라짐.
**원인 연쇄**:
1. VEX `ComponentDefinition.default_size``{ width, height }` 포맷 (예: `{ width: 300, height: 40 }`)
2. TemplateBuilder 의 `PaletteItem.defaultSize``{ w, h }` 포맷
3. `useRegistryPalette``c.default_size` 를 그대로 전달 → `defaultSize.w` = `undefined`
4. 드롭 핸들러에서 `e.clientX - rect.left - defaultSize.w / 2` = `NaN`
5. `position = { left: NaN, top: NaN, width: undefined, height: undefined }` 가 store 에 저장됨
6. React 가 style 의 NaN/undefined 를 무시 → 블록이 "보이지 않음" (또는 0,0 에 0 크기)
7. `LabeledNumber``Math.round(NaN) = NaN` → 속성 패널 input 이 빈 칸으로 렌더
**수정**: `frontend/components/template-builder/TemplateBuilder.tsx``useRegistryPalette` 에서 두 가지 정규화:
1. `default_size``{width, height}``{w, h}` 두 포맷 모두 지원:
```ts
const rawSize = c.default_size ?? {};
const defaultSize = {
w: rawSize.w ?? rawSize.width ?? 280,
h: rawSize.h ?? rawSize.height ?? 180,
};
```
2. lucide icon 이름(긴 문자열 "Table", "LayoutGrid" 등) 은 `` 로 fallback:
```ts
const icon =
typeof rawIcon === "string" && rawIcon.length > 0 && rawIcon.length <= 2
? rawIcon
: "◼";
```
짧은 이모지/유니코드만 그대로 표시. lucide 이름의 실제 아이콘 렌더링은 Phase 2 에서 ComponentRegistry 연동할 때 본격 구현.
### 7.3 TemplateBuilder.tsx 다크 모드 가시성 전혀 없음
**증상**: INVYONE 기본 다크 테마에서 TemplateBuilder 전체가 흰색/밝은 회색 배경에 옅은 텍스트로 표시되어 거의 읽을 수 없음. 팔레트, 툴바, 캔버스, 속성 패널 모두 동일. 블록을 드롭해도 어두운 배경 위에 어두운 블록이 거의 구분 안 됨.
**원인**: TemplateBuilder.tsx 가 라이트 모드 전제로 작성됨. `bg-white`, `bg-slate-50`, `text-slate-*`, `border-slate-200` 등 Tailwind 클래스가 `dark:` variant 없이 사용됨.
**수정**: Python 정규식 스크립트로 일괄 치환. **21개 패턴, 총 71곳** 치환.
| 원본 | 치환 | 건수 |
|---|---|---|
| `bg-white` | `bg-white dark:bg-slate-900` | 8 |
| `bg-slate-50` | `bg-slate-50 dark:bg-slate-950` | 2 |
| `bg-slate-100` | `bg-slate-100 dark:bg-slate-800` | 1 |
| `bg-amber-50` | `bg-amber-50 dark:bg-amber-950/40` | 1 |
| `text-slate-800` | `text-slate-800 dark:text-slate-100` | 1 |
| `text-slate-700` | `text-slate-700 dark:text-slate-200` | 1 |
| `text-slate-600` | `text-slate-600 dark:text-slate-300` | 5 |
| `text-slate-500` | `text-slate-500 dark:text-slate-400` | 2 |
| `text-slate-400` | `text-slate-400 dark:text-slate-500` | 14 |
| `text-amber-700` | `text-amber-700 dark:text-amber-300` | 1 |
| `text-rose-600` | `text-rose-600 dark:text-rose-400` | 1 |
| `text-rose-500` | `text-rose-500 dark:text-rose-400` | 1 |
| `border-slate-200` | `border-slate-200 dark:border-slate-700` | 23 |
| `border-rose-200` | `border-rose-200 dark:border-rose-800` | 1 |
| `ring-indigo-200` | `ring-indigo-200 dark:ring-indigo-800` | 1 |
| `hover:bg-slate-100` | `hover:bg-slate-100 dark:hover:bg-slate-700` | 2 |
| `hover:bg-slate-50` | `hover:bg-slate-50 dark:hover:bg-slate-800/60` | 2 |
| `hover:bg-indigo-50` | `hover:bg-indigo-50 dark:hover:bg-indigo-950/40` | 1 |
| `hover:bg-rose-50` | `hover:bg-rose-50 dark:hover:bg-rose-950/40` | 1 |
| `hover:border-indigo-300` | `hover:border-indigo-300 dark:hover:border-indigo-600` | 1 |
| `hover:border-slate-300` | `hover:border-slate-300 dark:hover:border-slate-600` | 1 |
치환 순서: `hover:` variants 먼저, 그 다음 일반 variants. `(?<!hover:)` negative lookbehind 사용해서 `hover:bg-*` 와 일반 `bg-*` 를 분리.
**후속 정리**: 치환 순서 때문에 `text-slate-500 dark:text-slate-400 dark:text-slate-500` 같은 중복 체인이 일부 생김. 원인: `text-slate-500` → `... dark:text-slate-400` 치환 직후, 다음 `text-slate-400` 치환이 방금 추가된 `dark:text-slate-400` 안의 `text-slate-400` 을 또 매칭해서 `dark:text-slate-500` 를 뒤에 붙임. 추가 Python 스크립트로 `(dark:text-slate-\d00)\s+dark:text-slate-\d00` 패턴을 반복 제거. **1회 반복 후 남은 체인 0개** 확인.
### 7.4 수정 후 검증 결과
| 검증 | 결과 |
|---|---|
| `/test-card-responsive` 페이지 렌더 | ✅ 정상 |
| 카드 폭 슬라이더로 반응형 전환 (800 → 400) | ✅ v2-table-list 테이블 → 카드 리스트, v2-table-search-widget 가로 → 세로 스택 |
| `/admin/builder` TemplateBuilder UI | ✅ 정상 |
| 팔레트 → 캔버스 드롭 → 블록 표시 | ✅ position 값 정상 |
| 드래그/리사이즈/Delete/Ctrl+Z/Ctrl+S | ✅ 전부 동작 |
| 3뷰 토글 (목록/등록/수정) | ✅ 독립 블록 유지 |
| 다크 모드 가시성 | ✅ 모든 영역 구분 가능 |
| 기존 VEX 화면/디자이너 (DTG 이력관리, screenMng 등) | ⏭️ 마이그레이션 미완 상태라 작동 안 함 (알려진 상태, Phase 1 범위 아님, Phase 2 이후 개별 마이그레이션 예정) |
### 7.5 Phase 2 에 고려할 사항
- **`useRegistryPalette`**: Phase 2 의 "TemplateBuilder 실렌더링 연결" 작업에서 다시 건드림. 그때 lucide icon 실제 렌더 + FieldConfig 연결 + DataPort 연결 등 포함해 더 정교하게 개선.
- **다크 모드 치환**: `dark:` variant 수동 추가 방식이라 지저분할 수 있음. Phase 2 에서 shadcn CSS 변수 기반(`bg-background`, `text-foreground`, `border-border` 등) 으로 리팩터 고려 가능. 단 v5-layout.css 의 토큰과의 통합이 필요.
- 이 버그들은 **컴포넌트 레지스트리 연동 + 라이트/다크 디자인 시스템 통합** 이라는 더 큰 작업의 일부이므로, Phase 2 에서 자연스럽게 전체 정리됨.
---
## 끝
Phase 1 종료 (2026-04-11).
§4 사용자 수동 검증 **(A) `/test-card-responsive` 반응형 메커니즘 + (B) `/admin/builder` TemplateBuilder UI** 통과 확인. **(C) 기존 기능 sanity** 는 VEX → INVYONE 컴포넌트 마이그레이션이 "기능 업그레이드 먼저" 정책으로 아직 진행되지 않아 기존 VEX 화면들이 원래 작동하지 않는 정상 상태라 생략됨.
핵심 보장(사용자가 "반응형 된다" 고 해서 믿고 진행한 결정)이 PoC 에서 실제로 작동함을 확인: 카드 폭 변경 시 v2-table-list 가 ResizeObserver 기반으로 wide/narrow 모드를 자동 전환, v2-table-search-widget 은 CSS `@container` 기반으로 가로 → 세로 스택 전환. 스펙 MD §9 의 백업 플랜 적용 불필요.
Phase 2 에서:
1. 사용자가 만드는 대시보드 시스템 구현 (`components/dash/*` 재작성)
2. `DashboardCard` 재작성 — Card = Template 인스턴스, FreePosition + @container
3. 레거시 타입 완전 제거 (@deprecated 표기한 것들)
4. TemplateBuilder 실렌더링 연결 (ComponentRegistry + 실제 v2-* 렌더)
5. 경량 7개 v2-* 모드 분기 추가
6. 우선순위 2/3 v2-* 마이그레이션
7. VEX 화면들을 INVYONE Template 로 마이그레이션 시작
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,527 @@
# Template 모델 재설계 — Phase 1 구현 로그 (1차)
**작업일**: 2026-04-10
**작업자**: gbpark + Claude
**기반 스펙**: `notes/gbpark/2026-04-10-template-model-redesign.md` (섹션 8~16, 19~20)
**범위**: 스펙 섹션 19 "다음 액션" 중 **1~4번까지** (타입 재정의, CSS 변수, DashboardCard grid 전환, 빌더 grid-snap 전환)
**남은 작업**: 5~8번 (팝업 grid 적용, 레거시 마이그레이션 다이얼로그, Template 전수 검증, Phase 3/4 문서 업데이트)
---
## 0. 결과 요약
| 항목 | 상태 |
|---|---|
| 작업 스코프 1~4번 | ✅ 완료 |
| `npx tsc --noEmit` 작업 범위 에러 | 0건 |
| Finding 1 (row 반응형) 코드 구조 확보 | ✅ (브라우저 시각 검증 남음) |
| Finding 2 (공용 기하학 수식) 단위 검증 | ✅ `pixelDeltaToColDelta(200, 800, 8, 16) === 3` |
| Finding 3 (레거시 변환 다이얼로그) | ⏸ 다음 턴 |
| Finding 4 (drop/drag/resize 일관성) 코드 구조 확보 | ✅ (브라우저 시각 검증 남음) |
| **원칙 3 임시 위반 잔존** | ⚠ `useBuilderState.ts:303-308` `fromTemplate` business fallback — 섹션 3.5 / 4.6 참조 |
---
## 1. 수정/생성된 파일 목록
### 타입
- **수정** `frontend/types/invyone-component.ts`
### 스타일
- **수정** `frontend/styles/v5-layout.css`
- **수정** `frontend/styles/dashboard.css`
- **수정** `frontend/styles/developer.css`
### DashboardCard (대시보드 카드 → grid 렌더)
- **수정** `frontend/components/dash/DashboardCard.tsx`
### Builder (빌더 grid-snap 전환)
- **신규** `frontend/components/builder/hooks/gridMetrics.ts`
- **수정** `frontend/components/builder/hooks/useBuilderState.ts`
- **수정** `frontend/components/builder/hooks/useBlockDrag.ts`
- **수정** `frontend/components/builder/BuilderCanvas.tsx`
- **수정** `frontend/components/builder/BuilderBlock.tsx`
- **수정** `frontend/components/builder/BuilderProps.tsx`
- **수정** `frontend/components/builder/BuilderToolbar.tsx`
- **수정** `frontend/components/builder/BuilderPalette.tsx`
---
## 2. 스펙 작업 항목별 상세
### 2.1 타입 재정의 — 섹션 8
**파일**: `frontend/types/invyone-component.ts`
#### 추가된 타입
- `GridPosition { col, colSpan, row?, rowSpan?, responsive? }`
- `AbsolutePosition { x, y, w, h }`
- `ComponentPosition = GridPosition | AbsolutePosition`
- `ResponsiveGridOverride { narrow?, normal?, wide? }` — 각 breakpoint는 `Partial<Omit<GridPosition, 'responsive'>>`
- `TemplateKind = 'business' | 'canvas'`
#### 삭제된 타입
- 기존 `Position { x, y, w, h }` (주석은 "그리드 단위"였지만 실제는 픽셀)
- 기존 `ResponsiveOverride { lg, md, sm }` (뷰포트 네이밍, 구현도 없었음)
- `Component.responsive` 필드 (이제 `GridPosition.responsive` 안으로 이동)
#### 추가된 타입 가드
```ts
export function isGridPosition(pos: ComponentPosition): pos is GridPosition {
return pos != null && typeof pos === 'object' && 'col' in pos && 'colSpan' in pos;
}
export function isAbsolutePosition(pos: ComponentPosition): pos is AbsolutePosition {
return pos != null && typeof pos === 'object' && 'x' in pos && 'y' in pos && 'w' in pos && 'h' in pos;
}
```
#### `Component.position` → `ComponentPosition`
- Template.kind에 따라 어느 쪽으로 해석되는지 주석으로 명시
#### `Template.kind: TemplateKind` 필수 필드 추가
- 레이아웃 모델 분기. `business`(기본) / `canvas`(예외)
#### 신규 상수
- `DEFAULT_COMPONENT_LAYOUTS: Record<ComponentType, GridPosition>` — 8종 팔레트 기본값 + responsive 디폴트. 수주관리 스펙(섹션 10.3)을 참고해 table: 8span wide / 12span narrow-normal, form: 4span wide / 12span narrow-normal 등
- `CANVAS_KEYWORDS` — 레거시 canvas 휴리스틱 키워드 (섹션 13.3.1용, 마이그레이션 다이얼로그 구현 시 사용)
---
### 2.2 CSS 변수 + grid 스타일 — 섹션 11.5, 12
#### `frontend/styles/v5-layout.css`
`:root`에 grid 네임스페이스 변수 세트 추가:
```css
--grid-cols: 12;
--grid-gap: .5rem;
--grid-gap-narrow: .35rem;
--grid-gap-normal: .45rem;
--grid-gap-wide: .55rem;
--card-narrow-max: 520px;
--card-normal-max: 900px;
--grid-line: rgba(108,92,231,.08);
--grid-line-hover: rgba(108,92,231,.2);
--grid-drop-preview: rgba(108,92,231,.15);
--grid-drop-preview-border: rgba(108,92,231,.5);
```
`.dark`에도 대응하는 다크 테마 grid 토큰(보라 대신 라벤더 계열) 추가.
v5 Cosmic 토큰(`--v5-*`)은 그대로 유지. grid 토큰은 독립 네임스페이스로만 추가.
#### `frontend/styles/dashboard.css`
`.dash-card-body`에 container query 활성화:
```css
.dash-card-body {
flex: 1; overflow: auto; padding: .5rem;
container-type: inline-size;
container-name: card;
}
```
추가된 클래스:
- `.dash-card-error`, `.dash-card-loading` — 에러/로딩 상태
- `.dash-card-grid` — 12-col grid (business 렌더)
- `.dash-card-grid > .tpl-component`**`min-width: 0` 필수** (섹션 11.6, 12열 비율 유지의 핵심)
- `.dash-card-canvas-wrapper` / `.dash-card-canvas` / `.dash-card-canvas > .tpl-component` — absolute 렌더 (canvas kind)
`@container card` 쿼리 3단계:
- `max-width: 520px` → narrow
- `520.01px ~ 900px` → normal
- `>900.01px` → wide
★ 각 쿼리에서 `grid-column` + `grid-row` **둘 다** 오버라이드. Finding 1 대응 — row 오버라이드가 실제로 반응해야 narrow에서 form이 테이블 아래로 내려감.
#### `frontend/styles/developer.css` — 빌더 그리드
기존 `.dev-canvas-inner` (자유배치용, 1200×800 고정)는 유지하되, 새로 `.dev-canvas-grid` 추가:
```css
.dev-canvas-grid {
position: relative;
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 8px;
padding: 16px;
min-height: 600px;
container-type: inline-size;
container-name: card;
background-image: linear-gradient(to right, var(--grid-line) 1px, transparent 1px);
background-size: calc((100% - 32px) / 12 + 8px) 100%;
background-position: 16px 0;
background-repeat: repeat-x;
}
.dev-canvas-grid.dragging { /* grid-line-hover */ }
.dev-popup-grid { min-height: 320px; padding: 12px; }
.dev-grid-warn { /* 비-grid 블록 잔존 시 경고 배너 */ }
```
`.dev-block` 블록은 `position: absolute``position: relative` (grid item)으로 전환. `min-width: 0`, `min-height: 48px`, `cursor: move` 추가.
반응형 오버라이드 속성 패널 UI용 `.dev-resp-row`, `.dev-resp-label` 추가.
---
### 2.3 DashboardCard grid 렌더 전환 — 섹션 11.2~11.7
**파일**: `frontend/components/dash/DashboardCard.tsx`
#### 삭제된 코드 (~40줄)
- `canvasBounds` useMemo (캔버스 전체 박스 추정)
- `bodyRef` / `scale` state
- `ResizeObserver` + `transform: scale()` 계산 useEffect
#### 추가된 상태
- `templateKind: TemplateKind | null` — Template 로드 시 `tpl.kind` 복원
#### 렌더 분기 (섹션 11.3, 11.7)
```tsx
{effectiveKind === 'canvas' ? (
<div className="dash-card-canvas-wrapper">
<div className="dash-card-canvas">
{sortedComponents.map(c => <AbsoluteComponent ... />)}
</div>
</div>
) : (
<div className="dash-card-grid">
{sortedComponents.map(c => <GridComponent ... />)}
</div>
)}
```
`effectiveKind``templateKind`을 우선하되, kind 필드가 없는 레거시는 position 형태로 보수적 추정:
- `GridPosition``business`
- 그 외 (`{x,y,w,h}` 레거시) → `canvas`
★ 이 추정은 **렌더 경로 선택만** 담당하고 **DB 쓰기 없음**. 섹션 13.2의 "자동 변환 금지" 원칙과 충돌하지 않음 (변환이 아니라 렌더 분기).
#### `GridComponent` — business kind 전용
섹션 11.4 Finding 1 반영. **12개 CSS 변수 전부 주입**:
```tsx
const r = pos.responsive ?? {};
const style: React.CSSProperties = {
'--col': pos.col ?? 1,
'--col-span': pos.colSpan ?? 12,
'--row': toRowVal(pos.row), // auto 문자열 허용
'--row-span': toSpanVal(pos.rowSpan),
'--col-narrow': r.narrow?.col ?? pos.col ?? 1,
'--col-span-narrow': r.narrow?.colSpan ?? pos.colSpan ?? 12,
'--row-narrow': toRowVal(r.narrow?.row ?? pos.row),
'--row-span-narrow': toSpanVal(r.narrow?.rowSpan ?? pos.rowSpan),
// ... normal/wide 동일
};
```
`toRowVal(undefined) === 'auto'` — CSS grid auto placement 동작.
#### `AbsoluteComponent` — canvas kind 전용
`position.x/y/w/h` 그대로 `left/top/width/height`에 매핑.
#### `renderByType()` — 타입별 렌더 공통화
기존 `ComponentRenderer` switch 를 `renderByType(props)`로 분리. `wrapStyle` (absolute 박스)을 없애고 각 case에서 `width: 100%; height: 100%` 기준으로 렌더.
---
### 2.4 빌더 grid-snap 전환 — 섹션 9, 14.5
#### 2.4.1 `hooks/gridMetrics.ts` 신규
**★ drop / drag / resize 모두 반드시 이 파일만 사용. `canvasW/12` 직접 계산 금지.**
공용 함수:
- `getGridMetrics(canvasWidth, gap, padding)``{ contentWidth, colWidth, step, padding, gap }`
- `pixelToGridCol(pixelX, canvasWidth, gap, padding)` — 드롭 위치 계산용
- `pixelToColSpan(pixelW, canvasWidth, gap, padding)`
- `pixelDeltaToColDelta(pixelDelta, canvasWidth, gap, padding)` — drag/resize 증감용
- `readCanvasGeometry(el)``{ canvasWidth, gap, padding }` — DOM에서 1회 읽기
- `computeNextAvailableRow(newCol, newColSpan, blocks)` — 섹션 14.4 자동 밀기
- `BUILDER_ROW_HEIGHT = 48` — row 이동 환산용
#### 2.4.2 `useBuilderState.ts`
새 상태 필드:
- `templateKind: TemplateKind` (기본 `'business'`)
- `previewWidth: 'narrow' | 'normal' | 'wide'` (기본 `'normal'`)
새 액션:
- `addBlock(type: ComponentType, position: GridPosition)` — 시그니처 교체
- `setTemplateKind(kind)`
- `setPreviewWidth(w)`
삭제된 액션:
- `moveBlock(id, x, y)`, `resizeBlock(id, w, h)` — grid에서는 `updateBlock` 에 position 객체 통째로 넘김
삭제된 함수:
- `defaultSize(type)` — 픽셀 크기 테이블. `DEFAULT_COMPONENT_LAYOUTS`가 대체
`toTemplate()` — 반환 객체에 `kind: s.templateKind` 포함.
`fromTemplate(tpl)`**⚠ 원칙 3 임시 위반 상태** (line 303~308). `tpl.kind``business`/`canvas`면 그대로 수용, 없으면 `business` 강제 디폴트. 스펙 섹션 13.4 "절대 금지 패턴"(`if (!tpl.kind) tpl.kind = 'business'`)과 형식만 다를 뿐 동일 동작.
**주석-구현 불일치**: 주석(line 305)에는 "`isDirty=true`로 표시해 사용자가 확인 없이 저장하지 못하게 한다"고 적혔으나 실제 구현 line 325는 `isDirty: false`. 저장 차단 경로 자체가 없음.
DB 저장은 여전히 사용자 "저장" 클릭 필요하지만, 레거시 Template에 새 블록 추가 후 저장 시 데이터 혼합 리스크 있음. 상세는 섹션 3.5, 해소 계획은 섹션 4.6 참조.
#### 2.4.3 `BuilderCanvas.tsx` 재작성
`handleDrop` — 공용 함수 경로:
```tsx
const canvasEl = e.currentTarget as HTMLElement;
const { canvasWidth, gap, padding } = readCanvasGeometry(canvasEl);
const rect = canvasEl.getBoundingClientRect();
const relX = e.clientX - rect.left;
const dropCol = pixelToGridCol(relX, canvasWidth, gap, padding);
const defaultLayout = DEFAULT_COMPONENT_LAYOUTS[type];
const col = Math.min(dropCol, 13 - defaultLayout.colSpan);
const colSpan = Math.min(defaultLayout.colSpan, 13 - col);
const row = computeNextAvailableRow(col, colSpan, blocks);
addBlock(type, { col, colSpan, row, ...responsive });
```
분기:
- `templateKind === 'canvas'` → placeholder (자유배치 빌더는 Phase 2 이후)
- `templateKind === 'business'` + `currentView === 'list'``.dev-canvas-grid` 렌더
- `templateKind === 'business'` + 팝업 뷰 → `.dev-canvas-grid.dev-popup-grid``.dev-popup-frame` 안에 렌더
`previewWidth`에 따라 `maxWidth` 적용 (`400 / 720 / 1100`px). `@container`가 실제로 반응.
비-grid 블록이 잔존하면 `.dev-grid-warn` 배너로 경고.
#### 2.4.4 `BuilderBlock.tsx`
grid item 전환:
```tsx
const pos = block.position as GridPosition;
<div
className={`dev-block${isSelected ? ' selected' : ''}`}
style={{
gridColumn: `${pos.col} / span ${pos.colSpan}`,
gridRow: pos.row != null ? `${pos.row} / span ${pos.rowSpan ?? 1}` : 'auto',
}}
...
>
```
`isGridPosition` 아니면 `null` 반환 (canvas/legacy는 이번 단계에서 렌더 스킵).
드래그 label에 `col N span M · row R` 표기 추가 — 사용자 피드백용.
#### 2.4.5 `useBlockDrag.ts` 재작성
`startDrag` / `startResize` 둘 다 세션 시작 시 1회 `readCanvasGeometry` 호출해서 `canvasWidth/gap/padding`을 캐시. `onMove`에서는 `pixelDeltaToColDelta(dx, ...)`만 호출.
드래그:
```ts
const deltaCol = pixelDeltaToColDelta(dx, canvasWidth, gap, padding);
const deltaRow = Math.round(dy / BUILDER_ROW_HEIGHT);
const newCol = Math.max(1, Math.min(13 - cur.colSpan, origCol + deltaCol));
const newRow = Math.max(1, origRow + deltaRow);
updateBlock(id, { position: { ...cur, col: newCol, row: newRow } });
```
리사이즈:
```ts
const deltaCols = pixelDeltaToColDelta(dx, canvasWidth, gap, padding);
const newSpan = Math.max(1, Math.min(13 - cur.col, origSpan + deltaCols));
updateBlock(id, { position: { ...cur, colSpan: newSpan } });
```
★ 둘 다 `zustand.getState()`로 최신 block을 꺼내기 때문에 onMove 중 불필요한 재등록 없음.
#### 2.4.6 `BuilderProps.tsx`
위치 편집 UI를 `X/Y/W/H``col/colSpan/row/rowSpan`로 교체.
반응형 오버라이드 섹션 신규 — narrow/normal/wide 각 breakpoint마다 `col/span/row/rowSpan` 4칸. 빈 값은 `undefined`(= 기본값 fallback).
`isGridPosition` 아니면 "grid 위치가 아닙니다" 안내.
#### 2.4.7 `BuilderToolbar.tsx`
새 툴바 그룹 2개:
1. **모델**`business` / `canvas` 토글 → `setTemplateKind`
2. **미리보기**`narrow 400` / `보통 720` / `넓음 1100` 토글 → `setPreviewWidth`. `templateKind === 'business'`일 때만 표시
`handleSave` 페이로드에 `kind: tpl.kind` 포함.
#### 2.4.8 `BuilderPalette.tsx`
클릭으로 추가 시에도 `DEFAULT_COMPONENT_LAYOUTS` + `computeNextAvailableRow` 사용. 기존 `{x:16, y:16, w:0, h:0}` 제거.
---
## 3. 검증 결과
### 3.1 타입체크
```
$ npx tsc --noEmit | grep -E "invyone-component|components/builder|components/dash/DashboardCard|components/dash/CardSettings"
(0 matches)
```
내 작업 범위에서 에러 **0건**.
전체 프로젝트 타입체크는 VEX 레거시(screen-management, v2-*, dataflow, admin dashboard widgets 등)에서 기존부터 누적된 ~2800건 에러가 있으나 이번 작업과 무관.
### 3.2 Finding 2 단위 검증
```js
// getGridMetrics(800, 8, 16)
// content = 800 - 32 = 768
// totalGap = 88
// colWidth = (768 - 88) / 12 = 56.666...
// step = 64.666...
// 200 / step = 3.093
// round → 3
pixelDeltaToColDelta(200, 800, 8, 16) === 3
```
### 3.3 Finding 1 구조 확보
- `GridComponent`에서 `--row*`, `--row-span*` 8개 변수 주입 확인
- `@container card` 3개 쿼리 모두 `grid-row: var(--row-*) / span var(--row-span-*)` 라인 포함
- 브라우저 시각 검증은 사용자 몫 (수주관리 예시를 400px/1000px 카드에서 렌더했을 때 form 위치)
### 3.4 Finding 4 구조 확보
- `handleDrop`, `startDrag`, `startResize` 전부 `readCanvasGeometry``pixelToGridCol` / `pixelDeltaToColDelta` 경로
- `canvasW / 12` 직접 계산 grep 결과: 0건
### 3.5 Finding 3 — ⚠ 원칙 위반 잔존 (완료 아님)
스펙 섹션 13.4 "절대 금지 패턴"이 코드에 남아 있는 상태. 다음 턴 6번으로 해소 이월.
#### 위반 위치
**파일**: `frontend/components/builder/hooks/useBuilderState.ts`
**함수**: `fromTemplate(tpl)` (line 303~327)
**핵심 라인**: 307~308
```ts
const kind: TemplateKind =
tpl.kind === "business" || tpl.kind === "canvas" ? tpl.kind : "business";
```
스펙 섹션 13.4 "절대 금지 패턴"의 `if (!tpl.kind) tpl.kind = 'business';` 와 형식만 삼항으로 바꾼 동일 동작.
#### 주석-구현 불일치
line 303~306 주석:
```
// kind 는 호출자(빌더 진입점)가 다이얼로그로 확정한 뒤 넘겨야 함.
// 여기서는 tpl.kind 를 그대로 받고, 없으면 기본 business 로 두되 isDirty=true
// 로 표시해 사용자가 확인 없이 저장하지 못하게 한다.
```
그러나 실제 line 325는 `isDirty: false`. **저장 차단 의도를 구현에 반영하지 못함.** 주석과 구현 중 어느 쪽이 정답인지 불분명 — 다음 턴에서 다이얼로그 도입으로 이 문제 자체를 없애는 것이 맞음.
#### 현 시점 완화 요소 (사고 확률을 낮추는 요인)
1. **DB 자동 저장 경로 없음** — 사용자가 "저장" 버튼을 명시적으로 클릭해야만 반영
2. **빌더에서 레거시 블록 렌더 안 됨**`BuilderBlock``isGridPosition` 가드로 레거시 `{x,y,w,h}` 블록을 `null` 반환. 사용자 화면에 안 보이는 상태에서는 "저장" 누를 동기가 적음
3. **대시보드는 `effectiveKind` 추정** — position 형태로 business/canvas 분기. 레거시는 canvas fallback → absolute 렌더, 데이터 손상은 없음
#### 실제 리스크 시나리오
1. 사용자가 기존 `{x,y,w,h}` 기반 레거시 Template을 빌더에서 열기 → `fromTemplate``kind: 'business'` 강제 부여, `isDirty: false`
2. 사용자가 "화면이 비었네" 하고 새 컴포넌트를 palette에서 drop → `blocks.list`에 grid 블록이 추가되면서 기존 레거시 `{x,y,w,h}` 블록들과 혼재
3. 사용자가 "저장" 클릭 → `toTemplate``kind: 'business'` + 혼합 blocks 배열을 DB에 기록
4. **결과**: 원본 레거시 데이터가 business kind로 덮여쓰임, grid + absolute 포맷 혼합 상태. `migration.backup` 미적용이라 롤백 어려움
#### 다음 턴 해소 방법 (섹션 4.6 ★0순위)
1. `fromTemplate` 시그니처에 `kind: TemplateKind`를 **필수 파라미터**로 추가 — 호출자가 다이얼로그로 결정한 값만 넘김
2. 빌더 진입점(`BuilderLayout` 또는 `TemplateLibraryModal`)에 `openTemplate(templateId)` 도입 — 내부에서 `isLikelyCanvasTemplate` 휴리스틱 + 블로킹 다이얼로그
3. 사용자가 `business` 선택 시에만 `migrateTemplateAbsoluteToGrid(tpl)` + `migration.backup` 적용 후 `fromTemplate(migrated, 'business')` 호출
4. 사용자가 `canvas` 선택 시 `fromTemplate({...tpl, kind: 'canvas'}, 'canvas')` 호출 — 위치 유지
5. `fromTemplate` 내부의 `'business'` 강제 디폴트 삭제
#### 임시 부분 수정 하지 않는 이유
"`isDirty: true`만 고치면 되지 않냐" — **권장하지 않음**:
- `isDirty` 플래그만으로는 저장 차단 확실하지 않음 (다른 경로로 isDirty 초기화될 수 있음)
- `fromTemplate` 호출 경로는 빌더 진입 외에도 import/template library 등 여러 곳 → 부분 수정은 회귀 위험
- 다음 턴에서 정공법으로 한 번에 처리하는 것이 안전. 이번 턴 완료 보고는 **이 섹션으로 상태 명시**만 수행
---
## 4. 다음 턴에 해야 할 것 (스펙 5~8번)
### 5. 등록/수정 팝업 grid 적용 — 섹션 15
- `ViewConfig.size` 활용
- 팝업도 `.dev-canvas-grid` 사용, `container-name: popup` 분기는 필요 시
- 현재 `BuilderCanvas`에서 `dev-popup-grid`로 1차 적용 해둠 — 스펙 대로 정비 필요
- `FormConfig.columns` 는 폼 내부 필드 배치용으로 유지 (2단계 구조)
### 6. Template 마이그레이션 + 승인 다이얼로그 — 섹션 13 ★★ 최우선 (0순위)
**★★★ 가장 먼저 해야 할 것 — `fromTemplate` 임시 위반 제거** (상세: 섹션 3.5):
`useBuilderState.ts:307-308``tpl.kind ?? 'business'` 폴백 삭제. `fromTemplate` 시그니처에 `kind: TemplateKind`를 **필수 파라미터**로 추가. 주석의 `isDirty=true` 의도도 함께 정리 (다이얼로그가 생기면 이 주석 자체가 불필요해짐).
이어지는 구현 순서:
1. `isLikelyCanvasTemplate(oldTpl)` 휴리스틱 (키워드 / y 분포 / controlFlow 필드 — 스펙 섹션 13.3.1)
2. `migrateTemplateAbsoluteToGrid(oldTpl)``migration.backup` 원본 보존 (스펙 섹션 13.3.2)
3. `openTemplate(templateId)` 블로킹 다이얼로그 (스펙 섹션 13.5)
4. 빌더 진입점(`BuilderLayout` / `TemplateLibraryModal`)에서 `openTemplate` → 다이얼로그 → 확정된 `kind``fromTemplate(tpl, kind)` 호출 체인 구성
5. **절대 금지 패턴 재확인**: `if (!tpl.kind) tpl.kind = 'business'` 코드 어디에도 두지 말 것
**순서 제안**: 5번(팝업) 전에 **6번부터 먼저 처리** — 원칙 위반 잔존 시간을 최소화. 5번 작업 중 사용자가 레거시 Template을 건드려 데이터가 망가지면 뒤늦게 후회.
### 7. 기존 Template 전수 검증
- DB에 있는 Template 목록을 빌더에서 하나씩 열어서 다이얼로그 동작 확인
- canvas 디폴트 선택이 맞는지, 취소 시 DB 무변경, business 선택 시만 변환되고 `migration.backup` 저장되는지
### 8. Phase 3/4 문서 업데이트 — 섹션 16
- `notes/gbpark/2026-04-10-phase3-developer-builder.md` — 섹션 16.1 표대로 grid 기준으로 갱신
- `notes/gbpark/2026-04-10-phase4-dashboard-menu.md` — 섹션 16.2 표대로 `@container` 기준으로 갱신 + 2레이어 명확화 블록 추가
### 추가로 남은 사소한 것 (우선순위 낮음)
- 빌더 `.dev-canvas-grid.dragging` 클래스 on/off 시각 피드백
- `DropPreview` 컴포넌트 (섹션 14.2) — 드래그 중 가장 가까운 셀 프리뷰
- Canvas kind 빌더 실제 UX (현재는 placeholder)
---
## 5. 브라우저 실측 체크리스트 (사용자 몫)
### Finding 1 — 반응형 row 오버라이드
1. 빌더에서 `kind: business` 로 새 Template 만들기
2. 수주관리 패턴 배치:
- search: col 1 span 12 row 1
- table: col 1 span 8 row 2 / narrow,normal span 12
- form: col 9 span 4 row 2 / narrow,normal col 1 span 12 row 3
3. 저장 후 대시보드 카드에 꽂고 카드 크기 리사이즈
4. 400px 폭에서 form이 테이블 **아래** row 3 위치에 오는지 확인
5. 1000px 폭에서 form이 오른쪽 col 9~12에 오는지 확인
6. 겹치면 Finding 1 버그 재발 → 즉시 보고
### Finding 4 — drop/drag/resize 스냅 일치
1. 빌더에서 캔버스 배경 그리드 라인이 12줄로 보이는지
2. 팔레트 table 아이템을 정확히 같은 위치에 세 번 drop → 매번 같은 col/colSpan 인지 label 확인
3. 한 블록을 drag로 몇 칸 이동 → col 값이 gap을 고려한 정확한 값인지
4. 한 블록을 resize로 확장 → colSpan이 drop/drag와 같은 기준으로 증감하는지
### 기타
- **기존 DB Template 열기**: 현재 `{x,y,w,h}` 기반이므로 빌더에서 열면 `fromTemplate`의 default `business`로 들어감 → grid가 아니어서 BuilderBlock이 null 반환 → 화면이 비어 보일 수 있음. **레거시 변환 다이얼로그는 다음 턴**에서 구현. 당장 테스트는 **새 Template**으로 진행 권장.
- 대시보드 카드에서 kind 없는 레거시 Template 을 꽂으면 `effectiveKind === 'canvas'`로 fallback → absolute 그대로 렌더 (기존 동작과 다름 없음, scale은 빠졌으니 카드 밖으로 넘칠 수 있음 → narrow 카드에서는 스크롤)
---
## 6. 참조
### 원본 스펙
- `notes/gbpark/2026-04-10-template-model-redesign.md` — 섹션 0~20 전체
### 관련 메모리
- `memory/project_template_builder.md` — 2026-04-10 재설계 반영
- `memory/feedback_responsive_container_based.md`@container 원칙
- `memory/project_component_spec.md` — FieldConfig 규격
### 주요 코드 위치
- 타입 정의: `frontend/types/invyone-component.ts`
- Grid 공용 수식: `frontend/components/builder/hooks/gridMetrics.ts`
- 카드 렌더: `frontend/components/dash/DashboardCard.tsx`
- 빌더 캔버스: `frontend/components/builder/BuilderCanvas.tsx`
- 빌더 상태: `frontend/components/builder/hooks/useBuilderState.ts`
- 빌더 드래그: `frontend/components/builder/hooks/useBlockDrag.ts`
- 빌더 속성 패널: `frontend/components/builder/BuilderProps.tsx`
- 빌더 툴바: `frontend/components/builder/BuilderToolbar.tsx`
### v5 디자인 토큰
- `frontend/styles/v5-layout.css` — v5 Cosmic 토큰 + grid 네임스페이스 변수
- `frontend/styles/dashboard.css``.dash-card-grid`, `@container` 쿼리
- `frontend/styles/developer.css``.dev-canvas-grid`, 빌더 block/props 스타일