ERP-node/frontend/docs/screen-management-design.md

21 KiB

화면관리 시스템 설계문서

1. 개요

1.1 목적

ERP 시스템에서 사용자가 직관적인 드래그앤드롭 인터페이스를 통해 동적으로 화면을 설계하고 관리할 수 있는 시스템

1.2 주요 기능

  • 드래그앤드롭 기반 화면 설계
  • 실시간 미리보기 및 속성 편집
  • 다양한 위젯 타입 지원
  • 데이터베이스 테이블/컬럼과의 연동
  • 템플릿 기반 빠른 화면 생성
  • 스타일 및 레이아웃 커스터마이징

2. 시스템 아키텍처

2.1 전체 구조

화면 편집기 (ScreenDesigner)
├── 템플릿 패널 (TemplatesPanel)
├── 테이블 패널 (TablesPanel)
├── 속성 편집 패널 (PropertiesPanel)
├── 스타일 편집 패널 (StyleEditor)
├── 상세설정 패널 (DetailSettingsPanel)
├── 격자 설정 패널 (GridPanel)
└── 캔버스 영역 (RealtimePreview)

할당된 화면 (InteractiveScreenViewer)
├── 라벨 렌더링
├── 위젯 렌더링
└── 폼 데이터 관리

2.2 데이터 흐름

사용자 입력 → 컴포넌트 상태 → 레이아웃 데이터 → API 저장/불러오기 → 할당된 화면 렌더링

3. 컴포넌트 구조

3.1 컴포넌트 타입

type ComponentType = "container" | "widget" | "group" | "datatable";

interface BaseComponent {
  id: string;
  type: ComponentType;
  position: { x: number; y: number; z?: number };
  size: { width: number; height: number };
  parentId?: string;
  label?: string;
  required?: boolean;
  readonly?: boolean;
  style?: ComponentStyle;
}

interface WidgetComponent extends BaseComponent {
  type: "widget";
  widgetType: WebType;
  placeholder?: string;
  columnName?: string;
  webTypeConfig?: WebTypeConfig;
}

3.2 지원하는 웹 타입

  • 텍스트 입력: text, email, tel
  • 숫자 입력: number, decimal
  • 날짜/시간: date, datetime
  • 선택: select, dropdown, radio
  • 체크박스: checkbox, boolean
  • 텍스트 영역: textarea
  • 파일: file
  • 코드: code
  • 엔티티: entity
  • 버튼: button

4. 주요 기능 상세

4.1 드래그앤드롭 시스템

템플릿 드래그

  • 사전 정의된 템플릿을 캔버스에 드롭
  • 컨테이너와 자식 컴포넌트 관계 자동 설정
  • 격자 스냅 및 자동 크기 조정

컬럼 드래그

  • 데이터베이스 테이블의 컬럼을 위젯으로 변환
  • 컬럼 타입에 따른 자동 웹타입 매핑
  • 폼 컨테이너에 드롭 시 자동 부모-자식 관계 설정

다중 컴포넌트 드래그

  • Ctrl/Cmd + 클릭으로 다중 선택
  • 선택된 모든 컴포넌트 동시 이동
  • 실시간 미리보기 제공

4.2 속성 편집 시스템

실시간 속성 편집 패턴

// 로컬 상태 기반 즉시 반영
const [localInputs, setLocalInputs] = useState({
  title: component.title || "",
  placeholder: component.placeholder || "",
});

// 입력과 동시에 업데이트
<Input
  value={localInputs.title}
  onChange={(e) => {
    const newValue = e.target.value;
    setLocalInputs(prev => ({ ...prev, title: newValue }));
    onUpdateProperty("title", newValue);
  }}
/>

컴포넌트별 개별 상태 관리

// 동적 ID 기반 상태 관리
const [localColumnInputs, setLocalColumnInputs] = useState<Record<string, string>>({});

// 컴포넌트 변경 시 기존 값 보존하면서 새 항목만 추가
useEffect(() => {
  setLocalColumnInputs((prev) => {
    const newInputs = { ...prev };
    component.columns?.forEach((col) => {
      if (!(col.id in newInputs)) {
        newInputs[col.id] = col.label;
      }
    });
    return newInputs;
  });
}, [component.columns]);

4.3 스타일 시스템

스타일 적용 계층

  1. 컴포넌트 기본 스타일: component.style
  2. 웹타입 설정 스타일: webTypeConfig에서 정의
  3. 라벨 스타일: 별도 관리 (labelColor, labelFontSize 등)

스타일 패널 구성

  • 여백: margin, padding, gap
  • 테두리: borderWidth, borderStyle, borderColor, borderRadius
  • 배경: backgroundColor, backgroundImage
  • 텍스트: color, fontSize, fontWeight, textAlign

4.4 템플릿 시스템

데이터 테이블 템플릿

{
  id: "data-table",
  name: "데이터 테이블",
  category: "table",
  components: [
    {
      id: "table-container",
      type: "datatable",
      searchFilters: [],
      columns: [
        { id: "col1", label: "컬럼 1", visible: true, sortable: true },
        { id: "col2", label: "컬럼 2", visible: true, sortable: false }
      ],
      pagination: { enabled: true, pageSize: 10 },
      actions: {
        create: { enabled: true, label: "추가" },
        edit: { enabled: true, label: "수정" },
        delete: { enabled: true, label: "삭제" }
      }
    }
  ]
}

입력 폼 템플릿

{
  id: "input-form",
  name: "입력 폼",
  category: "form",
  components: [
    {
      id: "form-container",
      type: "container",
      style: { backgroundColor: "#f8f9fa", borderRadius: "8px" },
      children: [
        {
          id: "save-button",
          type: "widget",
          widgetType: "button",
          parentId: "form-container",
          position: { x: 0, y: 0 },
          style: { position: "absolute", bottom: "24px", right: "104px" }
        },
        {
          id: "cancel-button",
          type: "widget",
          widgetType: "button",
          parentId: "form-container",
          position: { x: 0, y: 0 },
          style: { position: "absolute", bottom: "24px", right: "24px" }
        }
      ]
    }
  ]
}

범용 버튼 템플릿

{
  id: "universal-button",
  name: "버튼",
  category: "button",
  components: [
    {
      id: "button",
      type: "widget",
      widgetType: "button",
      webTypeConfig: {
        actionType: "save",
        variant: "default",
        size: "sm"
      }
    }
  ]
}

4.5 웹타입별 상세 설정

버튼 설정 (ButtonConfigPanel)

interface ButtonTypeConfig {
  actionType: ButtonActionType;
  variant: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
  size: "default" | "sm" | "lg" | "icon";
  icon?: string;
  confirmMessage?: string;
  popupTitle?: string;
  popupContent?: string;
  popupSize?: "sm" | "md" | "lg";
  navigateUrl?: string;
  navigateTarget?: "_self" | "_blank";
  customAction?: string;
  backgroundColor?: string;
  textColor?: string;
  borderColor?: string;
}

type ButtonActionType =
  | "save"
  | "cancel"
  | "delete"
  | "edit"
  | "add"
  | "search"
  | "reset"
  | "submit"
  | "close"
  | "popup"
  | "navigate"
  | "custom";

텍스트 설정 (TextTypeConfig)

interface TextTypeConfig {
  format: "none" | "korean" | "english" | "alphanumeric" | "numeric" | "email" | "phone" | "url";
  minLength?: number;
  maxLength?: number;
  pattern?: string;
  placeholder?: string;
  multiline?: boolean;
}

숫자 설정 (NumberTypeConfig)

interface NumberTypeConfig {
  min?: number;
  max?: number;
  step?: number;
  format?: "integer" | "decimal" | "currency" | "percentage";
  decimalPlaces?: number;
  thousandSeparator?: boolean;
}

날짜 설정 (DateTypeConfig)

interface DateTypeConfig {
  format: "YYYY-MM-DD" | "YYYY-MM-DD HH:mm" | "YYYY-MM-DD HH:mm:ss";
  showTime: boolean;
  minDate?: string;
  maxDate?: string;
  defaultValue?: string;
}

선택박스 설정 (SelectTypeConfig)

interface SelectTypeConfig {
  options: Array<{ label: string; value: string }>;
  multiple?: boolean;
  searchable?: boolean;
  placeholder?: string;
}

엔티티 설정 (EntityTypeConfig)

interface EntityTypeConfig {
  entityName: string;
  displayField: string;
  valueField: string;
  filters: Array<{ field: string; operator: string; value: any }>;
  multiple: boolean;
  searchable: boolean;
  allowClear: boolean;
  placeholder?: string;
  displayFormat?: string;
  defaultValue?: any;
}

4.6 격자 시스템

격자 설정

interface GridSettings {
  columns: number; // 격자 컬럼 수
  gap: number; // 격자 간격 (px)
  padding: number; // 캔버스 패딩 (px)
  snapToGrid: boolean; // 격자 스냅 활성화
  showGrid: boolean; // 격자 표시 여부
  gridColor: string; // 격자 선 색상
  gridOpacity: number; // 격자 투명도 (0-1)
}

격자 스냅 로직

const snapToGrid = (value: number, gridSize: number): number => {
  return Math.round(value / gridSize) * gridSize;
};

const snapSizeToGrid = (size: number, gridSize: number): number => {
  return Math.max(gridSize, Math.round(size / gridSize) * gridSize);
};

5. 렌더링 시스템

5.1 편집기 렌더링 (ScreenDesigner)

컴포넌트 위치 계산

// 절대 위치 래퍼 div
<div
  className="absolute"
  style={{
    left: `${displayComponent.position.x}px`,
    top: `${displayComponent.position.y}px`,
    width: displayComponent.style?.width || `${displayComponent.size.width}px`,
    height: displayComponent.style?.height || `${displayComponent.size.height}px`,
    zIndex: displayComponent.position.z || 1,
  }}
>
  <RealtimePreview
    component={displayComponent}
    position={{ x: 0, y: 0 }} // 래퍼 div 내에서는 상대 위치
  />
</div>

자식 컴포넌트 상대 위치

// 자식 컴포넌트는 부모 기준 상대 위치로 계산
const relativePosition = {
  x: child.position.x - parent.position.x,
  y: child.position.y - parent.position.y,
};

5.2 할당된 화면 렌더링 (InteractiveScreenViewer)

라벨 외부 분리 렌더링

// 라벨을 컴포넌트 외부에 별도 렌더링 (높이에 영향 없음)
{shouldShowLabel && (
  <div
    style={{
      position: "absolute",
      left: `${component.position.x}px`,
      top: `${component.position.y - 25}px`, // 컴포넌트 위쪽에 배치
      zIndex: (component.position.z || 1) + 1,
      ...labelStyle,
    }}
  >
    {labelText}
    {component.required && <span style={{ color: "#f97316" }}>*</span>}
  </div>
)}

// 실제 컴포넌트 (라벨 높이에 영향받지 않음)
<div style={{ height: component.style?.height || `${component.size.height}px` }}>
  <InteractiveScreenViewer component={component} hideLabel={true} />
</div>

스타일 적용 시스템

const applyStyles = (element: React.ReactElement) => {
  if (!comp.style) return element;

  return React.cloneElement(element, {
    style: {
      ...element.props.style, // 기존 스타일 유지
      ...comp.style, // 컴포넌트 스타일 적용
      width: "100%", // 부모 컨테이너에 맞춤
      height: "100%",
      minHeight: "100%", // 강제 높이 적용
      maxHeight: "100%",
      boxSizing: "border-box",
    },
  });
};

위젯별 렌더링

switch (widgetType) {
  case "text":
  case "email":
  case "tel":
    return applyStyles(
      <Input
        type={inputType}
        placeholder={finalPlaceholder}
        value={currentValue}
        onChange={handleInputChange}
        className="w-full"
        style={{ height: "100%" }}
      />
    );

  case "button":
    const config = widget.webTypeConfig as ButtonTypeConfig;
    return (
      <Button
        onClick={handleButtonClick}
        size={config?.size || "sm"}
        variant={config?.variant || "default"}
        className="w-full"
        style={{
          ...comp.style,
          height: "100%",
          backgroundColor: config?.backgroundColor,
          color: config?.textColor,
          borderColor: config?.borderColor,
        }}
      >
        {label || "버튼"}
      </Button>
    );

  case "entity":
    return (
      <Select>
        <SelectTrigger
          className="w-full"
          style={{
            ...comp.style,
            height: "100%",
          }}
        >
          <SelectValue placeholder={finalPlaceholder} />
        </SelectTrigger>
        <SelectContent>
          {options.map(option => (
            <SelectItem key={option.value} value={option.value}>
              {option.label}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
    );
}

6. 상태 관리

6.1 레이아웃 상태

interface LayoutData {
  components: ComponentData[];
  gridSettings: GridSettings;
}

const [layout, setLayout] = useState<LayoutData>({
  components: [],
  gridSettings: defaultGridSettings,
});

6.2 선택 상태

const [selectedComponent, setSelectedComponent] = useState<ComponentData | null>(null);
const [selectedComponents, setSelectedComponents] = useState<ComponentData[]>([]);

6.3 드래그 상태

interface DragState {
  isDragging: boolean;
  draggedComponents: ComponentData[];
  startPosition: { x: number; y: number };
  currentPosition: { x: number; y: number };
}

6.4 패널 상태

interface PanelState {
  isOpen: boolean;
  position: { x: number; y: number };
  size: { width: number; height: number };
}

const [panelStates, setPanelStates] = useState<Record<string, PanelState>>({
  templates: { isOpen: true, position: { x: 0, y: 0 }, size: { width: 300, height: 400 } },
  properties: { isOpen: false, position: { x: 0, y: 0 }, size: { width: 360, height: 600 } },
  styles: { isOpen: false, position: { x: 0, y: 0 }, size: { width: 360, height: 400 } },
  // ...
});

7. API 연동

7.1 화면 정보 API

// 화면 목록 조회
GET /api/screens
Response: {
  screens: Array<{
    id: number;
    name: string;
    description: string;
    createdAt: string;
    updatedAt: string;
  }>
}

// 화면 상세 조회
GET /api/screens/:id
Response: {
  id: number;
  name: string;
  description: string;
  layout: LayoutData;
}

// 화면 저장
POST /api/screens/:id/layout
Request: {
  layout: LayoutData
}

7.2 테이블 정보 API

// 테이블 목록 조회
GET / api / tables;
Response: {
  tables: Array<{
    id: string;
    name: string;
    description: string;
    columns: Array<{
      id: string;
      name: string;
      type: string;
      nullable: boolean;
      primaryKey: boolean;
    }>;
  }>;
}

8. 성능 최적화

8.1 렌더링 최적화

  • useCallback으로 이벤트 핸들러 메모이제이션
  • useMemo로 계산 비용이 큰 값 캐싱
  • 컴포넌트 분할을 통한 불필요한 리렌더링 방지

8.2 상태 최적화

  • 로컬 상태 기반 즉시 반영으로 UI 응답성 향상
  • 디바운싱을 통한 과도한 API 호출 방지
  • 컴포넌트별 개별 상태 관리로 전역 상태 오염 방지

9. 개발 가이드

9.1 새로운 웹타입 추가 (간편한 3단계)

새로운 웹타입 추가가 대폭 간편해졌습니다! 이제 데이터베이스 기반 동적 웹타입 시스템을 사용합니다.

1단계: 데이터베이스에 웹타입 등록

-- web_type_standard 테이블에 새 웹타입 추가
INSERT INTO web_type_standard (
  web_type,
  type_name,
  config_panel,
  active
) VALUES (
  'my_new_type',           -- 웹타입 코드 (영문)
  '새로운 입력 타입',       -- 한글 표시명
  'MyNewTypeConfigPanel',  -- 설정 패널 컴포넌트명 (선택사항)
  'Y'                      -- 활성화 여부
);

2단계: 설정 패널 컴포넌트 생성 (선택사항)

웹타입에 특별한 설정이 필요한 경우만 생성:

// frontend/components/screen/config-panels/MyNewTypeConfigPanel.tsx
export const MyNewTypeConfigPanel = ({ config, onConfigChange }) => {
  return (
    <div className="space-y-4">
      <h3 className="text-sm font-medium">새로운 타입 설정</h3>

      {/* 설정 UI */}
      <div>
        <label className="text-sm">옵션 1</label>
        <input
          value={config.option1 || ""}
          onChange={(e) => onConfigChange({ ...config, option1: e.target.value })}
        />
      </div>

      <div>
        <label className="text-sm">옵션 2</label>
        <select
          value={config.option2 || "default"}
          onChange={(e) => onConfigChange({ ...config, option2: e.target.value })}
        >
          <option value="default">기본값</option>
          <option value="custom">사용자 정의</option>
        </select>
      </div>
    </div>
  );
};

3단계: 레지스트리에 등록

// frontend/lib/utils/availableConfigPanels.ts
import { MyNewTypeConfigPanel } from "@/components/screen/config-panels/MyNewTypeConfigPanel";

export const availableConfigPanels = {
  // 기존 패널들...
  ButtonConfigPanel,
  TextTypeConfigPanel,
  NumberTypeConfigPanel,

  // 새 패널 추가
  MyNewTypeConfigPanel, // ← 이 한 줄만 추가!
};

완료! 🎉

  • PropertiesPanel: 자동으로 드롭다운에 "새로운 입력 타입" 표시
  • DetailSettingsPanel: 위젯 타입 변경 시 자동으로 설정 패널 표시
  • 실시간 업데이트: React key props로 즉시 반영

설정 패널이 없는 경우

config_panel을 NULL로 설정하거나 레지스트리에 등록하지 않으면 "기본 설정" 메시지가 표시됩니다.

-- 설정 패널 없는 간단한 웹타입
INSERT INTO web_type_standard (web_type, type_name, active)
VALUES ('simple_type', '간단한 타입', 'Y');

렌더링 로직 추가 (필요한 경우)

새 웹타입이 특별한 렌더링이 필요한 경우에만 추가:

// RealtimePreviewDynamic.tsx 또는 InteractiveScreenViewer.tsx
case "my_new_type":
  return (
    <MyNewInputComponent
      value={currentValue}
      onChange={handleInputChange}
      placeholder={finalPlaceholder}
      config={widget.webTypeConfig}
      style={{ height: "100%" }}
    />
  );

타입 정의 추가 (TypeScript 지원)

// types/screen.ts
export type WebType =
  | "text"
  | "number"
  | "date"
  | "my_new_type"  // ← 새 타입 추가
  | /* 기타 타입들 */;

export interface MyNewTypeConfig {
  option1?: string;
  option2?: "default" | "custom";
  // 기타 설정 옵션들
}

export type WebTypeConfig =
  | TextTypeConfig
  | NumberTypeConfig
  | MyNewTypeConfig  // ← 새 설정 타입 추가
  | /* 기타 설정 타입들 */;

🎯 핵심 장점

  • 플러그 앤 플레이: 코드 수정 최소화
  • 데이터베이스 기반: 개발자 도구나 어드민에서 웹타입 관리 가능
  • 자동 감지: 별도 등록 로직 없이 자동으로 시스템에 반영
  • 실시간 업데이트: React key props로 즉시 설정 변경 반영

9.2 새로운 템플릿 추가

  1. TemplatesPanel에 템플릿 정의 추가
const templates: TemplateComponent[] = [
  {
    id: "새로운-템플릿",
    name: "새로운 템플릿",
    category: "카테고리",
    icon: <IconComponent />,
    defaultSize: { width: 400, height: 300 },
    components: [
      // 템플릿 구성 컴포넌트들
    ]
  }
];
  1. 필요한 경우 특별한 렌더링 로직 추가

9.3 코딩 컨벤션

실시간 속성 편집 패턴 (필수)

// 1. 로컬 상태 정의
const [localInputs, setLocalInputs] = useState({
  title: component.title || "",
});

// 2. 컴포넌트 변경 시 동기화
useEffect(() => {
  setLocalInputs({
    title: component.title || "",
  });
}, [component.title]);

// 3. 실시간 입력 처리
<Input
  value={localInputs.title}
  onChange={(e) => {
    const newValue = e.target.value;
    setLocalInputs(prev => ({ ...prev, title: newValue }));
    onUpdateProperty("title", newValue);
  }}
/>

10. 테스트 전략

10.1 단위 테스트

  • 유틸리티 함수 (격자 계산, 스타일 적용 등)
  • 컴포넌트 상태 관리 로직
  • 데이터 변환 함수

10.2 통합 테스트

  • 드래그앤드롭 시나리오
  • 속성 편집 플로우
  • API 연동 테스트

10.3 E2E 테스트

  • 화면 생성부터 렌더링까지 전체 플로우
  • 복잡한 사용자 시나리오

11. 향후 개선 계획

11.1 단기 계획

  • 웹타입별 상세 설정 완성 (Date, Number, Select, Radio, File, Code, Entity)
  • 조건부 표시 기능 (특정 조건에 따른 컴포넌트 표시/숨김)
  • 계산 필드 기능 (다른 필드 값을 기반으로 한 자동 계산)

11.2 중장기 계획

  • 컴포넌트 간 데이터 바인딩
  • 워크플로우 연동
  • 다국어 지원
  • 반응형 디자인
  • 컴포넌트 라이브러리 확장

12. 트러블슈팅

12.1 일반적인 문제

높이가 적용되지 않는 문제

  • 원인: Tailwind CSS의 h-full 클래스가 인라인 스타일을 무시
  • 해결: className="w-full" + style={{ height: "100%" }} 사용

라벨이 컴포넌트 높이에 포함되는 문제

  • 원인: 라벨과 위젯이 같은 컨테이너 내에 위치
  • 해결: 라벨을 외부에 별도 렌더링하여 높이에서 제외

스타일이 할당된 화면에서 적용되지 않는 문제

  • 원인: applyStyles 함수 미사용 또는 잘못된 스타일 병합
  • 해결: 모든 위젯에서 일관된 스타일 적용 로직 사용

다중 드래그 시 성능 문제

  • 원인: 과도한 리렌더링
  • 해결: useCallback, useMemo 적극 활용

12.2 디버깅 팁

  • 브라우저 개발자 도구의 React DevTools 활용
  • 콘솔 로그를 통한 상태 추적
  • 컴포넌트 트리 구조 시각화

본 문서는 지속적으로 업데이트되며, 새로운 기능 추가 시 해당 섹션을 업데이트해야 합니다.