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

17 KiB
Raw Blame History

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 배열 오버라이드 (추가/삭제)

overrides: {
  mobile_portrait: {
    containers: {
      root: {
        children: ["comp1", "comp2", "mobile-only-button"]  // 컴포넌트 추가
      }
    }
  }
}

장점:

  • 모드별로 완전히 다른 컴포넌트 구성 가능
  • 유연성 극대화

단점:

  • 데이터 동기화 복잡
  • 삭제/추가 시 다른 모드에도 영향
  • 순서 변경 시 충돌 가능

방안 B: visibility 속성 (표시/숨김) 채택

interface PopComponentDefinitionV4 {
  visibility?: {
    tablet_landscape?: boolean;
    tablet_portrait?: boolean;
    mobile_landscape?: boolean;
    mobile_portrait?: boolean;
  };
}

장점:

  • 단순하고 명확
  • 컴포넌트는 항상 존재 (숨김만)
  • 데이터 일관성 유지

단점:

  • 완전히 다른 컴포넌트 추가는 불가능
  • 많은 모드 전용 컴포넌트는 비효율적

최종 결정: 하이브리드 접근

1. visibility: 기본 기능 (Phase 3)
   - 간단한 표시/숨김
   - 줄바꿈 컴포넌트 제어

2. components 오버라이드: 고급 기능 (Phase 3 기반)
   - 컴포넌트 설정 변경 (리스트 컬럼  )
   - 스타일 변경

3. children 오버라이드: 추후 고려
   - 모드별 완전히 다른 구성 필요 

🛠️ 구현 내용

1. 타입 정의 확장

pop-break 컴포넌트 추가

export type PopComponentType =
  | "pop-field"
  | "pop-button"
  | "pop-list"
  | "pop-indicator"
  | "pop-scanner"
  | "pop-numpad"
  | "pop-spacer"
  | "pop-break";  // 🆕 줄바꿈

visibility 속성 추가

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;
  
  // 기타...
}

기본 크기 설정

const defaultSizes: Record<PopComponentType, PopSizeConstraintV4> = {
  // ...
  "pop-break": {
    width: "fill",   // 100% 너비 (flex-basis: 100%)
    height: "fixed",
    fixedHeight: 0,  // 높이 0 (보이지 않음)
  },
};

2. 렌더러 로직 개선

visibility 체크 함수

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 → 모바일 세로에서 표시 (기본값)

컴포넌트 오버라이드 병합

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 전용 렌더링

// 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. 삭제 함수 개선

오버라이드 정리 로직

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,
  };
};

오버라이드 정리 상세

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

"표시" 탭 추가

<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 컴포넌트

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. 팔레트 업데이트

const COMPONENT_PALETTE = [
  // ... 기존 컴포넌트들
  {
    type: "pop-break",
    label: "줄바꿈",
    icon: WrapText,
    description: "강제 줄바꿈 (flex-basis: 100%)",
  },
];

🎯 사용 예시

예시 1: 모바일 전용 버튼

{
  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: 모드별 줄바꿈

레이아웃:
[필드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: 리스트 컬럼 수 변경 (확장 가능)

// 기본 (태블릿 가로)
{
  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. 성능 최적화

// useMemo로 병합 결과 캐싱
const effectiveRoot = useMemo(() => getMergedRoot(), [tempLayout, overrides, currentMode]);
const mergedComponent = useMemo(() => getMergedComponent(baseComponent), [baseComponent, overrides, currentMode]);

4. 타입 안전성

// 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 레이아웃 시스템의 핵심 기능이 완성되었으며, 실제 컴포넌트 구현 단계로 진행할 수 있습니다.