ERP-node/popdocs/decisions/002-phase3-visibility-break.md

691 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Phase 3: Visibility + 줄바꿈 컴포넌트 구현
**날짜**: 2026-02-04
**상태**: 구현 완료 ✅
**관련 이슈**: 모드별 컴포넌트 표시/숨김, 강제 줄바꿈
---
## 📋 목표
Phase 2의 배치 고정 기능에 이어, 다음 기능들을 추가:
1. **모드별 컴포넌트 표시/숨김** (visibility)
2. **강제 줄바꿈 컴포넌트** (pop-break)
3. **컴포넌트 오버라이드 병합** (모드별 설정 변경)
---
## 🔍 문제 정의
### 문제 1: 모드별 컴포넌트 추가/삭제 불가
```
현재 상황:
- 모든 모드에서 같은 컴포넌트만 표시 가능
- 모바일 전용 버튼(예: "전화 걸기")을 추가할 수 없음
요구사항:
- 특정 모드에서만 컴포넌트 표시
- 다른 모드에서는 자동 숨김
```
### 문제 2: Flexbox에서 강제 줄바꿈 불가
```
현재 상황:
- wrap: true여도 컴포넌트가 공간을 채워야 줄바꿈
- [A] [B] [C] → 강제로 [A] [B] / [C] 불가능
요구사항:
- 사용자가 원하는 위치에서 강제 줄바꿈
- 디자인 모드에서 시각적으로 표시
```
### 문제 3: 컴포넌트 설정을 모드별로 변경 불가
```
현재 상황:
- 컨테이너 배치만 오버라이드 가능
- 리스트 컬럼 수, 버튼 스타일 등은 모든 모드 동일
요구사항 (확장성):
- 태블릿: 리스트 7개 컬럼
- 모바일: 리스트 3개 컬럼
```
---
## 💡 해결 방안
### 방안 A: children 배열 오버라이드 (추가/삭제)
```typescript
overrides: {
mobile_portrait: {
containers: {
root: {
children: ["comp1", "comp2", "mobile-only-button"] // 컴포넌트 추가
}
}
}
}
```
**장점**:
- 모드별로 완전히 다른 컴포넌트 구성 가능
- 유연성 극대화
**단점**:
- 데이터 동기화 복잡
- 삭제/추가 시 다른 모드에도 영향
- 순서 변경 시 충돌 가능
---
### 방안 B: visibility 속성 (표시/숨김) ✅ 채택
```typescript
interface PopComponentDefinitionV4 {
visibility?: {
tablet_landscape?: boolean;
tablet_portrait?: boolean;
mobile_landscape?: boolean;
mobile_portrait?: boolean;
};
}
```
**장점**:
- 단순하고 명확
- 컴포넌트는 항상 존재 (숨김만)
- 데이터 일관성 유지
**단점**:
- 완전히 다른 컴포넌트 추가는 불가능
- 많은 모드 전용 컴포넌트는 비효율적
---
### 최종 결정: 하이브리드 접근 ⭐
```typescript
1. visibility: 기본 기능 (Phase 3)
- 간단한 표시/숨김
- 줄바꿈 컴포넌트 제어
2. components 오버라이드: 고급 기능 (Phase 3 기반)
- 컴포넌트 설정 변경 (리스트 컬럼 )
- 스타일 변경
3. children 오버라이드: 추후 고려
- 모드별 완전히 다른 구성 필요
```
---
## 🛠️ 구현 내용
### 1. 타입 정의 확장
#### pop-break 컴포넌트 추가
```typescript
export type PopComponentType =
| "pop-field"
| "pop-button"
| "pop-list"
| "pop-indicator"
| "pop-scanner"
| "pop-numpad"
| "pop-spacer"
| "pop-break"; // 🆕 줄바꿈
```
#### visibility 속성 추가
```typescript
export interface PopComponentDefinitionV4 {
id: string;
type: PopComponentType;
size: PopSizeConstraintV4;
// 🆕 모드별 표시/숨김
visibility?: {
tablet_landscape?: boolean; // undefined = true (기본 표시)
tablet_portrait?: boolean;
mobile_landscape?: boolean;
mobile_portrait?: boolean;
};
// 기존: 픽셀 기반 반응형
hideBelow?: number;
// 기타...
}
```
#### 기본 크기 설정
```typescript
const defaultSizes: Record<PopComponentType, PopSizeConstraintV4> = {
// ...
"pop-break": {
width: "fill", // 100% 너비 (flex-basis: 100%)
height: "fixed",
fixedHeight: 0, // 높이 0 (보이지 않음)
},
};
```
---
### 2. 렌더러 로직 개선
#### visibility 체크 함수
```typescript
const isComponentVisible = (component: PopComponentDefinitionV4): boolean => {
if (!component.visibility) return true; // 기본값: 표시
const modeVisibility = component.visibility[currentMode];
return modeVisibility !== false; // undefined도 true로 취급
};
```
**로직 설명**:
- `visibility` 속성이 없으면 → 모든 모드에서 표시
- `visibility.mobile_portrait === false` → 모바일 세로에서 숨김
- `visibility.mobile_portrait === undefined` → 모바일 세로에서 표시 (기본값)
---
#### 컴포넌트 오버라이드 병합
```typescript
const getMergedComponent = (
baseComponent: PopComponentDefinitionV4
): PopComponentDefinitionV4 => {
if (currentMode === "tablet_landscape") return baseComponent;
const componentOverride = overrides?.[currentMode]?.components?.[baseComponent.id];
if (!componentOverride) return baseComponent;
// 깊은 병합 (config, size)
return {
...baseComponent,
...componentOverride,
size: { ...baseComponent.size, ...componentOverride.size },
config: { ...baseComponent.config, ...componentOverride.config },
};
};
```
**병합 우선순위**:
1. `baseComponent` (기본값)
2. `overrides[currentMode].components[id]` (모드별 오버라이드)
3. 중첩 객체는 깊은 병합 (`size`, `config`)
**확장 가능성**:
- 리스트 컬럼 수 변경
- 버튼 스타일 변경
- 필드 표시 형식 변경
---
#### pop-break 전용 렌더링
```typescript
// pop-break 특수 처리
if (mergedComponent.type === "pop-break") {
return (
<div
key={componentId}
className={cn(
"w-full",
isDesignMode
? "h-4 border-2 border-dashed border-gray-300 rounded flex items-center justify-center cursor-pointer hover:border-gray-400"
: "h-0"
)}
style={{ flexBasis: "100%" }} // 핵심: 100% 너비로 줄바꿈 강제
onClick={() => onComponentClick?.(componentId)}
>
{isDesignMode && (
<span className="text-xs text-gray-400">줄바꿈</span>
)}
</div>
);
}
```
**동작 방식**:
- `flex-basis: 100%` → 컨테이너 전체 너비 차지
- 다음 컴포넌트는 자동으로 새 줄로 이동
- 디자인 모드: 점선 표시 (높이 16px)
- 실제 화면: 높이 0 (안 보임)
---
### 3. 삭제 함수 개선
#### 오버라이드 정리 로직
```typescript
export const removeComponentFromV4Layout = (
layout: PopLayoutDataV4,
componentId: string
): PopLayoutDataV4 => {
// 1. 컴포넌트 정의 삭제
const { [componentId]: _, ...remainingComponents } = layout.components;
// 2. root.children에서 제거
const newRoot = removeChildFromContainer(layout.root, componentId);
// 3. 🆕 모든 오버라이드에서 제거
const newOverrides = cleanupOverridesAfterDelete(layout.overrides, componentId);
return {
...layout,
root: newRoot,
components: remainingComponents,
overrides: newOverrides,
};
};
```
#### 오버라이드 정리 상세
```typescript
function cleanupOverridesAfterDelete(
overrides: PopLayoutDataV4["overrides"],
componentId: string
): PopLayoutDataV4["overrides"] {
if (!overrides) return undefined;
const newOverrides = { ...overrides };
for (const mode of Object.keys(newOverrides)) {
const override = newOverrides[mode];
if (!override) continue;
const updated = { ...override };
// containers.root.children에서 제거
if (updated.containers?.root?.children) {
updated.containers = {
...updated.containers,
root: {
...updated.containers.root,
children: updated.containers.root.children.filter(id => id !== componentId),
},
};
}
// components에서 제거
if (updated.components?.[componentId]) {
const { [componentId]: _, ...rest } = updated.components;
updated.components = Object.keys(rest).length > 0 ? rest : undefined;
}
// 빈 오버라이드 정리
if (!updated.containers && !updated.components) {
delete newOverrides[mode];
} else {
newOverrides[mode] = updated;
}
}
// 모든 오버라이드가 비었으면 undefined 반환
return Object.keys(newOverrides).length > 0 ? newOverrides : undefined;
}
```
**정리 항목**:
1. `overrides[mode].containers.root.children` - 컴포넌트 ID 제거
2. `overrides[mode].components[componentId]` - 컴포넌트 설정 제거
3. 빈 오버라이드 객체 삭제 (메모리 절약)
---
### 4. 속성 패널 UI
#### "표시" 탭 추가
```typescript
<TabsList>
<TabsTrigger value="size">크기</TabsTrigger>
<TabsTrigger value="settings">설정</TabsTrigger>
<TabsTrigger value="visibility">
<Eye className="h-3 w-3" />
표시
</TabsTrigger>
<TabsTrigger value="data">데이터</TabsTrigger>
</TabsList>
```
#### VisibilityForm 컴포넌트
```typescript
function VisibilityForm({ component, onUpdate }: VisibilityFormProps) {
const modes = [
{ key: "tablet_landscape", label: "태블릿 가로 (1024×768)" },
{ key: "tablet_portrait", label: "태블릿 세로 (768×1024)" },
{ key: "mobile_landscape", label: "모바일 가로 (667×375)" },
{ key: "mobile_portrait", label: "모바일 세로 (375×667)" },
];
return (
<div className="space-y-4">
<Label>모드별 표시 설정</Label>
<p className="text-xs text-muted-foreground">
체크 해제하면 해당 모드에서 컴포넌트가 숨겨집니다
</p>
<div className="space-y-2 rounded-lg border p-3">
{modes.map(({ key, label }) => {
const isChecked = component.visibility?.[key] !== false;
return (
<div key={key} className="flex items-center gap-2">
<input
type="checkbox"
checked={isChecked}
onChange={(e) => {
onUpdate?.({
visibility: {
...component.visibility,
[key]: e.target.checked,
},
});
}}
/>
<label>{label}</label>
{!isChecked && <span>(숨김)</span>}
</div>
);
})}
</div>
{/* 기존: 반응형 숨김 (픽셀 기반) */}
<div className="space-y-3">
<Label>반응형 숨김 (픽셀 기반)</Label>
<Input
type="number"
value={component.hideBelow || ""}
onChange={(e) =>
onUpdate?.({
hideBelow: e.target.value ? Number(e.target.value) : undefined,
})
}
placeholder="없음"
/>
<p className="text-xs text-muted-foreground">
: 500 입력 화면 너비가 500px 이하면 자동 숨김
</p>
</div>
</div>
);
}
```
**UI 특징**:
- 체크박스로 직관적인 표시/숨김 제어
- 기본값은 모든 모드 체크 (표시)
- `hideBelow` (픽셀 기반)와 별도 유지
---
### 5. 팔레트 업데이트
```typescript
const COMPONENT_PALETTE = [
// ... 기존 컴포넌트들
{
type: "pop-break",
label: "줄바꿈",
icon: WrapText,
description: "강제 줄바꿈 (flex-basis: 100%)",
},
];
```
---
## 🎯 사용 예시
### 예시 1: 모바일 전용 버튼
```typescript
{
id: "call-button",
type: "pop-button",
label: "전화 걸기",
size: { width: "fixed", height: "fixed", fixedWidth: 120, fixedHeight: 48 },
visibility: {
tablet_landscape: false, // 태블릿 가로: 숨김
tablet_portrait: false, // 태블릿 세로: 숨김
mobile_landscape: true, // 모바일 가로: 표시
mobile_portrait: true, // 모바일 세로: 표시
},
}
```
**결과**:
- 태블릿: "전화 걸기" 버튼 안 보임
- 모바일: "전화 걸기" 버튼 보임
---
### 예시 2: 모드별 줄바꿈
```typescript
레이아웃:
[필드A] [필드B] [줄바꿈] [필드C] [필드D]
줄바꿈 컴포넌트 설정:
{
id: "break-1",
type: "pop-break",
visibility: {
tablet_landscape: false, // 태블릿: 줄바꿈 숨김 (한 줄)
mobile_portrait: true, // 모바일: 줄바꿈 표시 (두 줄)
},
}
```
**결과**:
```
태블릿 가로 (1024px):
┌─────────────────────────────────┐
│ [필드A] [필드B] [필드C] [필드D] │ ← 한 줄
└─────────────────────────────────┘
모바일 세로 (375px):
┌─────────────────┐
│ [필드A] [필드B] │ ← 첫 줄
│ [필드C] [필드D] │ ← 둘째 줄 (줄바꿈 적용)
└─────────────────┘
```
---
### 예시 3: 리스트 컬럼 수 변경 (확장 가능)
```typescript
// 기본 (태블릿 가로)
{
id: "product-list",
type: "pop-list",
config: {
columns: 7, // 7개 컬럼
}
}
// 오버라이드 (모바일 세로)
overrides: {
mobile_portrait: {
components: {
"product-list": {
config: {
columns: 3, // 3개 컬럼
}
}
}
}
}
```
**결과**:
- 태블릿: 7개 컬럼 표시
- 모바일: 3개 컬럼 표시 (병합됨)
---
## ✅ 테스트 시나리오
### 테스트 1: 줄바꿈 기본 동작
```
1. 팔레트에서 "줄바꿈" 드래그
2. [A] [B] [C] 사이에 드롭
3. 예상 결과: [A] [B] / [C]
4. 디자인 모드에서 점선 "줄바꿈" 표시 확인
5. 미리보기에서 줄바꿈이 안 보이는지 확인
```
### 테스트 2: 모드별 줄바꿈 표시
```
1. 줄바꿈 컴포넌트 추가
2. "표시" 탭 → 태블릿 모드 체크 해제
3. 태블릿 가로 모드: [A] [B] [C] (한 줄)
4. 모바일 세로 모드: [A] [B] / [C] (두 줄)
```
### 테스트 3: 컴포넌트 삭제 시 오버라이드 정리
```
1. 모바일 세로 모드에서 배치 고정
2. 컴포넌트 삭제
3. 저장 후 로드
4. DB 확인: overrides에서도 제거되었는지
```
### 테스트 4: 모드별 컴포넌트 숨김
```
1. "전화 걸기" 버튼 추가
2. "표시" 탭 → 태블릿 모드 체크 해제
3. 태블릿 가로: 버튼 안 보임
4. 모바일 세로: 버튼 보임
```
### 테스트 5: 속성 패널 UI
```
1. 컴포넌트 선택
2. "표시" 탭 클릭
3. 4개 체크박스 확인 (모두 체크됨)
4. 체크 해제 시 "(숨김)" 표시 확인
5. 저장 후 로드 → 체크 상태 유지
```
---
## 🔍 기술적 고려사항
### 1. 데이터 일관성
```
문제: 컴포넌트 삭제 시 오버라이드 잔여물
해결:
- cleanupOverridesAfterDelete() 함수
- containers.root.children 정리
- components 오버라이드 정리
- 빈 오버라이드 자동 삭제
```
### 2. 병합 우선순위
```
우선순위 (높음 → 낮음):
1. tempLayout (고정 전 미리보기)
2. overrides[currentMode].containers.root
3. overrides[currentMode].components[id]
4. layout.root (기본값)
5. layout.components[id] (기본값)
```
### 3. 성능 최적화
```typescript
// useMemo로 병합 결과 캐싱
const effectiveRoot = useMemo(() => getMergedRoot(), [tempLayout, overrides, currentMode]);
const mergedComponent = useMemo(() => getMergedComponent(baseComponent), [baseComponent, overrides, currentMode]);
```
### 4. 타입 안전성
```typescript
// visibility 키는 ViewportPreset에서만 허용
visibility?: {
[K in ViewportPreset]?: boolean;
};
// 컴파일 타임에 오타 방지
visibility.tablet_landspace = false; // ❌ 오타 감지!
visibility.tablet_landscape = false; // ✅ 정상
```
---
## 📊 영향 받는 파일
### 코드 파일
```
✅ frontend/components/pop/designer/types/pop-layout.ts
- PopComponentType 확장 (pop-break)
- PopComponentDefinitionV4.visibility 추가
- cleanupOverridesAfterDelete() 추가
✅ frontend/components/pop/designer/renderers/PopFlexRenderer.tsx
- isComponentVisible() 추가
- getMergedComponent() 추가
- pop-break 렌더링 추가
- ContainerRenderer props 확장
✅ frontend/components/pop/designer/panels/ComponentEditorPanelV4.tsx
- "표시" 탭 추가
- VisibilityForm 컴포넌트 추가
- COMPONENT_TYPE_LABELS 업데이트
✅ frontend/components/pop/designer/panels/ComponentPaletteV4.tsx
- "줄바꿈" 컴포넌트 추가
```
### 문서 파일
```
✅ popdocs/CHANGELOG.md
- Phase 3 완료 기록
✅ popdocs/PLAN.md
- Phase 3 체크 완료
- Phase 4 계획 추가
✅ popdocs/decisions/002-phase3-visibility-break.md (이 문서)
- 설계 결정 및 구현 상세
```
---
## 🚀 다음 단계
### Phase 4: 실제 컴포넌트 구현
```
우선순위:
1. pop-field (입력/표시 필드)
2. pop-button (액션 버튼)
3. pop-list (데이터 리스트)
4. pop-indicator (KPI 표시)
5. pop-scanner (바코드/QR)
6. pop-numpad (숫자 입력)
```
### 추가 개선 사항
```
1. 컴포넌트 오버라이드 UI
- 리스트 컬럼 수 조정
- 버튼 스타일 변경
- 필드 표시 형식 변경
2. "모든 모드에 적용" 기능
- 한 번에 모든 모드 체크/해제
3. 오버라이드 비교 뷰
- 기본값 vs 오버라이드 차이 표시
```
---
## 📝 결론
Phase 3를 통해 다음을 달성:
1. ✅ 모드별 컴포넌트 표시/숨김 제어
2. ✅ 강제 줄바꿈 컴포넌트 (Flexbox 한계 극복)
3. ✅ 컴포넌트 오버라이드 병합 (확장성 확보)
4. ✅ 데이터 일관성 유지 (삭제 시 정리)
이제 v4 레이아웃 시스템의 핵심 기능이 완성되었으며, 실제 컴포넌트 구현 단계로 진행할 수 있습니다.