ERP-node/frontend/docs/component-development-guide.md

22 KiB

화면관리 시스템 컴포넌트 개발 가이드

화면관리 시스템에서 새로운 컴포넌트, 템플릿, 웹타입을 추가하는 완전한 가이드입니다.

🎯 목차

  1. 컴포넌트 추가하기
  2. 웹타입 추가하기
  3. 템플릿 추가하기
  4. 설정 패널 개발
  5. 데이터베이스 설정
  6. 테스트 및 검증

1. 컴포넌트 추가하기

1.1 컴포넌트 렌더러 생성

새로운 컴포넌트 렌더러를 생성합니다.

파일 위치: frontend/lib/registry/components/{ComponentName}Renderer.tsx

// 예시: AlertRenderer.tsx
import React from "react";
import { ComponentRenderer } from "../DynamicComponentRenderer";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { AlertTriangle, Info, CheckCircle, XCircle } from "lucide-react";

const AlertRenderer: ComponentRenderer = ({
  component,
  children,
  isInteractive,
  ...props
}) => {
  const config = component.componentConfig || {};
  const {
    title = "알림",
    message = "알림 메시지입니다.",
    type = "info", // info, warning, success, error
    showIcon = true,
    style = {}
  } = config;

  // 타입별 아이콘 매핑
  const iconMap = {
    info: Info,
    warning: AlertTriangle,
    success: CheckCircle,
    error: XCircle,
  };

  const Icon = iconMap[type as keyof typeof iconMap] || Info;

  return (
    <Alert
      className={`h-full w-full ${type === 'error' ? 'border-red-500' : ''}`}
      style={style}
    >
      {showIcon && <Icon className="h-4 w-4" />}
      <AlertTitle>{title}</AlertTitle>
      <AlertDescription>
        {isInteractive ? (
          // 실제 할당된 화면에서는 설정된 메시지 표시
          message
        ) : (
          // 디자이너에서는 플레이스홀더 + children 표시
          children && React.Children.count(children) > 0 ? (
            children
          ) : (
            <div className="text-sm">
              <div>{message}</div>
              <div className="mt-1 text-xs text-gray-400">
                알림 컴포넌트 - {type} 타입
              </div>
            </div>
          )
        )}
      </AlertDescription>
    </Alert>
  );
};

export default AlertRenderer;

1.2 컴포넌트 등록

파일: frontend/lib/registry/index.ts

// 컴포넌트 렌더러 import 추가
import AlertRenderer from "./components/AlertRenderer";

// 컴포넌트 레지스트리에 등록
export const registerComponents = () => {
  // 기존 컴포넌트들...
  ComponentRegistry.register("alert", AlertRenderer);
  ComponentRegistry.register("alert-info", AlertRenderer);
  ComponentRegistry.register("alert-warning", AlertRenderer);
};

1.3 InteractiveScreenViewer에 등록

파일: frontend/components/screen/InteractiveScreenViewerDynamic.tsx

// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
import "@/lib/registry/components/ButtonRenderer";
import "@/lib/registry/components/CardRenderer";
import "@/lib/registry/components/DashboardRenderer";
import "@/lib/registry/components/AlertRenderer"; // 추가
import "@/lib/registry/components/WidgetRenderer";

2. 웹타입 추가하기

2.1 웹타입 컴포넌트 생성

파일 위치: frontend/components/screen/widgets/types/{WebTypeName}Widget.tsx

// 예시: ColorPickerWidget.tsx
import React from "react";
import { WebTypeComponentProps } from "@/types/screen";
import { WidgetComponent } from "@/types/screen";

interface ColorPickerConfig {
  defaultColor?: string;
  showAlpha?: boolean;
  presetColors?: string[];
}

export const ColorPickerWidget: React.FC<WebTypeComponentProps> = ({
  component,
  value,
  onChange,
  readonly = false
}) => {
  const widget = component as WidgetComponent;
  const { placeholder, required, style } = widget || {};
  const config = widget?.webTypeConfig as ColorPickerConfig | undefined;

  const handleColorChange = (color: string) => {
    if (!readonly && onChange) {
      onChange(color);
    }
  };

  return (
    <div className="h-full w-full" style={style}>
      {/* 라벨 표시 */}
      {widget?.label && (
        <label className="mb-1 block text-sm font-medium">
          {widget.label}
          {required && <span className="text-orange-500">*</span>}
        </label>
      )}

      <div className="flex gap-2 items-center">
        {/* 색상 입력 */}
        <input
          type="color"
          value={value || config?.defaultColor || "#000000"}
          onChange={(e) => handleColorChange(e.target.value)}
          disabled={readonly}
          className="h-10 w-16 rounded border border-gray-300 cursor-pointer disabled:cursor-not-allowed"
        />

        {/* 색상 값 표시 */}
        <input
          type="text"
          value={value || config?.defaultColor || "#000000"}
          onChange={(e) => handleColorChange(e.target.value)}
          placeholder={placeholder || "색상을 선택하세요"}
          disabled={readonly}
          className="flex-1 h-10 px-3 rounded border border-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100"
        />
      </div>

      {/* 미리 설정된 색상들 */}
      {config?.presetColors && (
        <div className="mt-2 flex gap-1 flex-wrap">
          {config.presetColors.map((color, idx) => (
            <button
              key={idx}
              type="button"
              onClick={() => handleColorChange(color)}
              disabled={readonly}
              className="w-6 h-6 rounded border border-gray-300 cursor-pointer hover:scale-110 transition-transform disabled:cursor-not-allowed"
              style={{ backgroundColor: color }}
              title={color}
            />
          ))}
        </div>
      )}
    </div>
  );
};

2.2 웹타입 등록

파일: frontend/lib/registry/index.ts

// 웹타입 컴포넌트 import 추가
import { ColorPickerWidget } from "@/components/screen/widgets/types/ColorPickerWidget";

// 웹타입 레지스트리에 등록
export const registerWebTypes = () => {
  // 기존 웹타입들...
  WebTypeRegistry.register("color", ColorPickerWidget);
  WebTypeRegistry.register("colorpicker", ColorPickerWidget);
};

2.3 웹타입 설정 인터페이스 추가

파일: frontend/types/screen.ts

// 웹타입별 설정 인터페이스에 추가
export interface ColorPickerTypeConfig {
  defaultColor?: string;
  showAlpha?: boolean;
  presetColors?: string[];
}

// 전체 웹타입 설정 유니온에 추가
export type WebTypeConfig =
  | TextTypeConfig
  | NumberTypeConfig
  | DateTypeConfig
  | SelectTypeConfig
  | FileTypeConfig
  | ColorPickerTypeConfig; // 추가

3. 템플릿 추가하기

3.1 템플릿 컴포넌트 생성

파일 위치: frontend/components/screen/templates/{TemplateName}Template.tsx

// 예시: ContactFormTemplate.tsx
import React from "react";
import { ComponentData } from "@/types/screen";
import { generateComponentId } from "@/lib/utils/componentUtils";

interface ContactFormTemplateProps {
  onAddComponents: (components: ComponentData[]) => void;
  position: { x: number; y: number };
}

export const ContactFormTemplate: React.FC<ContactFormTemplateProps> = ({
  onAddComponents,
  position,
}) => {
  const createContactForm = () => {
    const components: ComponentData[] = [
      // 컨테이너
      {
        id: generateComponentId(),
        type: "container",
        position: position,
        size: { width: 500, height: 600 },
        style: {
          backgroundColor: "#ffffff",
          border: "1px solid #e5e7eb",
          borderRadius: "8px",
          padding: "24px",
        },
        children: [],
      },

      // 제목
      {
        id: generateComponentId(),
        type: "widget",
        webType: "text",
        position: { x: position.x + 24, y: position.y + 24 },
        size: { width: 452, height: 40 },
        label: "연락처 양식",
        placeholder: "연락처를 입력해주세요",
        style: {
          fontSize: "24px",
          fontWeight: "bold",
          color: "#1f2937",
        },
      },

      // 이름 입력
      {
        id: generateComponentId(),
        type: "widget",
        webType: "text",
        position: { x: position.x + 24, y: position.y + 84 },
        size: { width: 452, height: 40 },
        label: "이름",
        placeholder: "이름을 입력하세요",
        required: true,
      },

      // 이메일 입력
      {
        id: generateComponentId(),
        type: "widget",
        webType: "email",
        position: { x: position.x + 24, y: position.y + 144 },
        size: { width: 452, height: 40 },
        label: "이메일",
        placeholder: "이메일을 입력하세요",
        required: true,
      },

      // 전화번호 입력
      {
        id: generateComponentId(),
        type: "widget",
        webType: "tel",
        position: { x: position.x + 24, y: position.y + 204 },
        size: { width: 452, height: 40 },
        label: "전화번호",
        placeholder: "전화번호를 입력하세요",
      },

      // 메시지 입력
      {
        id: generateComponentId(),
        type: "widget",
        webType: "textarea",
        position: { x: position.x + 24, y: position.y + 264 },
        size: { width: 452, height: 120 },
        label: "메시지",
        placeholder: "메시지를 입력하세요",
        required: true,
      },

      // 제출 버튼
      {
        id: generateComponentId(),
        type: "button",
        componentType: "button-primary",
        position: { x: position.x + 24, y: position.y + 404 },
        size: { width: 120, height: 40 },
        componentConfig: {
          text: "제출",
          actionType: "submit",
          style: "primary",
        },
      },

      // 취소 버튼
      {
        id: generateComponentId(),
        type: "button",
        componentType: "button-secondary",
        position: { x: position.x + 164, y: position.y + 404 },
        size: { width: 120, height: 40 },
        componentConfig: {
          text: "취소",
          actionType: "cancel",
          style: "secondary",
        },
      },
    ];

    onAddComponents(components);
  };

  return (
    <div
      className="cursor-pointer rounded border-2 border-dashed border-gray-300 bg-gray-50 p-4 hover:border-blue-400 hover:bg-blue-50"
      onClick={createContactForm}
    >
      <div className="text-center">
        <div className="text-lg font-medium">연락처 양식</div>
        <div className="mt-1 text-sm text-gray-600">
          이름, 이메일, 전화번호, 메시지 입력이 포함된 연락처 양식
        </div>
      </div>
    </div>
  );
};

3.2 템플릿 패널에 등록

파일: frontend/components/screen/panels/TemplatesPanel.tsx

// 템플릿 import 추가
import { ContactFormTemplate } from "@/components/screen/templates/ContactFormTemplate";

// 템플릿 목록에 추가
const templates = [
  // 기존 템플릿들...
  {
    id: "contact-form",
    name: "연락처 양식",
    description: "이름, 이메일, 전화번호, 메시지가 포함된 연락처 양식",
    component: ContactFormTemplate,
  },
];

4. 설정 패널 개발

4.1 설정 패널 컴포넌트 생성

파일 위치: frontend/components/screen/config-panels/{ComponentName}ConfigPanel.tsx

// 예시: AlertConfigPanel.tsx
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { ComponentData } from "@/types/screen";

interface AlertConfigPanelProps {
  component: ComponentData;
  onUpdateProperty: (path: string, value: any) => void;
}

export const AlertConfigPanel: React.FC<AlertConfigPanelProps> = ({
  component,
  onUpdateProperty,
}) => {
  const config = component.componentConfig || {};

  return (
    <Card>
      <CardHeader>
        <CardTitle className="text-sm">알림 설정</CardTitle>
      </CardHeader>
      <CardContent className="space-y-4">
        {/* 제목 설정 */}
        <div>
          <Label htmlFor="alert-title" className="text-xs">제목</Label>
          <Input
            id="alert-title"
            value={config.title || ""}
            onChange={(e) => onUpdateProperty("componentConfig.title", e.target.value)}
            placeholder="알림 제목"
            className="h-8"
          />
        </div>

        {/* 메시지 설정 */}
        <div>
          <Label htmlFor="alert-message" className="text-xs">메시지</Label>
          <Textarea
            id="alert-message"
            value={config.message || ""}
            onChange={(e) => onUpdateProperty("componentConfig.message", e.target.value)}
            placeholder="알림 메시지를 입력하세요"
            className="min-h-[60px]"
          />
        </div>

        {/* 타입 설정 */}
        <div>
          <Label className="text-xs">알림 타입</Label>
          <Select
            value={config.type || "info"}
            onValueChange={(value) => onUpdateProperty("componentConfig.type", value)}
          >
            <SelectTrigger className="h-8">
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="info">정보</SelectItem>
              <SelectItem value="warning">경고</SelectItem>
              <SelectItem value="success">성공</SelectItem>
              <SelectItem value="error">오류</SelectItem>
            </SelectContent>
          </Select>
        </div>

        {/* 아이콘 표시 설정 */}
        <div className="flex items-center justify-between">
          <Label htmlFor="show-icon" className="text-xs">아이콘 표시</Label>
          <Switch
            id="show-icon"
            checked={config.showIcon ?? true}
            onCheckedChange={(checked) => onUpdateProperty("componentConfig.showIcon", checked)}
          />
        </div>

        {/* 스타일 설정 */}
        <div>
          <Label htmlFor="alert-bg-color" className="text-xs">배경 색상</Label>
          <Input
            id="alert-bg-color"
            type="color"
            value={config.style?.backgroundColor || "#ffffff"}
            onChange={(e) => onUpdateProperty("componentConfig.style.backgroundColor", e.target.value)}
            className="h-8 w-full"
          />
        </div>

        {/* 테두리 반경 설정 */}
        <div>
          <Label htmlFor="border-radius" className="text-xs">테두리 반경 (px)</Label>
          <Input
            id="border-radius"
            type="number"
            value={parseInt(config.style?.borderRadius || "6") || 6}
            onChange={(e) => onUpdateProperty("componentConfig.style.borderRadius", `${e.target.value}px`)}
            min="0"
            max="50"
            className="h-8"
          />
        </div>
      </CardContent>
    </Card>
  );
};

4.2 설정 패널 등록

파일: frontend/components/screen/panels/DetailSettingsPanel.tsx

// 설정 패널 import 추가
import { AlertConfigPanel } from "@/components/screen/config-panels/AlertConfigPanel";

// hasNewConfigPanel 배열에 추가
const hasNewConfigPanel =
  componentType &&
  [
    "button",
    "button-primary",
    "button-secondary",
    "card",
    "dashboard",
    "alert", // 추가
    "alert-info", // 추가
    // 기타 컴포넌트들...
  ].includes(componentType);

// switch 문에 케이스 추가
switch (componentType) {
  case "button":
  case "button-primary":
  case "button-secondary":
    return <NewButtonConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;

  case "card":
    return <CardConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;

  case "dashboard":
    return <DashboardConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;

  case "alert": // 추가
  case "alert-info":
    return <AlertConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;

  // 기타 케이스들...
}

5. 데이터베이스 설정

5.1 component_standards 테이블에 데이터 추가

// backend-node/scripts/add-new-component.js
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();

async function addNewComponents() {
  // 알림 컴포넌트들 추가
  const alertComponents = [
    {
      component_code: "alert-info",
      component_name: "정보 알림",
      category: "display",
      description: "정보성 내용을 표시하는 알림 컴포넌트",
      component_config: {
        type: "alert",
        config_panel: "AlertConfigPanel",
      },
      default_size: {
        width: 400,
        height: 80,
      },
      icon_name: "info",
    },
    {
      component_code: "alert-warning",
      component_name: "경고 알림",
      category: "display",
      description: "경고 내용을 표시하는 알림 컴포넌트",
      component_config: {
        type: "alert",
        config_panel: "AlertConfigPanel",
      },
      default_size: {
        width: 400,
        height: 80,
      },
      icon_name: "alert-triangle",
    },
  ];

  // 웹타입 추가
  const webTypes = [
    {
      component_code: "color-picker",
      component_name: "색상 선택기",
      category: "input",
      description: "색상을 선택할 수 있는 입력 컴포넌트",
      component_config: {
        type: "widget",
        webType: "color",
      },
      default_size: {
        width: 200,
        height: 40,
      },
      icon_name: "palette",
    },
  ];

  // 데이터베이스에 삽입
  for (const component of [...alertComponents, ...webTypes]) {
    await prisma.component_standards.create({
      data: component,
    });
    console.log(`✅ ${component.component_name} 추가 완료`);
  }

  console.log("🎉 모든 컴포넌트 추가 완료!");
  await prisma.$disconnect();
}

addNewComponents().catch(console.error);

5.2 스크립트 실행

cd backend-node
node scripts/add-new-component.js

6. 테스트 및 검증

6.1 개발 서버 재시작

# 프론트엔드 재시작
cd frontend
npm run dev

# 백엔드 재시작 (필요시)
cd ../backend-node
npm run dev

6.2 테스트 체크리스트

컴포넌트 패널 확인

  • 새 컴포넌트가 컴포넌트 패널에 표시됨
  • 카테고리별로 올바르게 분류됨
  • 아이콘이 올바르게 표시됨

드래그앤드롭 확인

  • 컴포넌트를 캔버스에 드래그 가능
  • 기본 크기로 올바르게 배치됨
  • 컴포넌트가 텍스트박스가 아닌 실제 형태로 렌더링됨

속성 편집 확인

  • 컴포넌트 선택 시 속성 패널에 기본 속성 표시
  • 상세 설정 패널이 올바르게 표시됨
  • 설정값 변경이 실시간으로 반영됨

할당된 화면 확인

  • 화면 저장 후 메뉴에 할당
  • 할당된 화면에서 컴포넌트가 올바르게 표시됨
  • 위치와 크기가 편집기와 동일함
  • 인터랙티브 모드로 올바르게 동작함

6.3 문제 해결

컴포넌트가 텍스트박스로 표시되는 경우

  1. DynamicComponentRenderer.tsx에서 컴포넌트가 등록되었는지 확인
  2. InteractiveScreenViewerDynamic.tsx에서 import 되었는지 확인
  3. 브라우저 콘솔에서 레지스트리 등록 로그 확인

설정 패널이 표시되지 않는 경우

  1. DetailSettingsPanel.tsxhasNewConfigPanel 배열 확인
  2. switch 문에 케이스가 추가되었는지 확인
  3. 데이터베이스의 config_panel 값 확인

할당된 화면에서 렌더링 안 되는 경우

  1. InteractiveScreenViewerDynamic.tsx에서 import 확인
  2. 컴포넌트 렌더러에서 isInteractive prop 처리 확인
  3. 브라우저 콘솔에서 오류 메시지 확인

🎯 모범 사례

1. 컴포넌트 네이밍

  • 컴포넌트 코드: kebab-case (예: alert-info, contact-form)
  • 파일명: PascalCase (예: AlertRenderer.tsx, ContactFormTemplate.tsx)
  • 클래스명: camelCase (예: alertContainer, formInput)

2. 설정 구조

// 일관된 설정 구조 사용
interface ComponentConfig {
  // 기본 설정
  title?: string;
  description?: string;

  // 표시 설정
  showIcon?: boolean;
  showBorder?: boolean;

  // 스타일 설정
  style?: {
    backgroundColor?: string;
    borderRadius?: string;
    padding?: string;
  };

  // 컴포넌트별 전용 설정
  [key: string]: any;
}

3. 반응형 지원

// 컨테이너 크기에 따른 반응형 처리
const isSmall = component.size.width < 300;
const columns = isSmall ? 1 : 3;

4. 접근성 고려

// 접근성 속성 추가
<button
  aria-label={config.ariaLabel || config.text}
  role="button"
  tabIndex={0}
>
  {config.text}
</button>

📚 참고 자료


이 가이드를 따라 새로운 컴포넌트, 웹타입, 템플릿을 성공적으로 추가할 수 있습니다. 추가 질문이나 문제가 발생하면 언제든지 문의해주세요! 🚀