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

796 lines
22 KiB
Markdown
Raw Normal View History

2025-09-10 14:09:32 +09:00
# 화면관리 시스템 컴포넌트 개발 가이드
화면관리 시스템에서 새로운 컴포넌트, 템플릿, 웹타입을 추가하는 완전한 가이드입니다.
## 🎯 목차
1. [컴포넌트 추가하기](#1-컴포넌트-추가하기)
2. [웹타입 추가하기](#2-웹타입-추가하기)
3. [템플릿 추가하기](#3-템플릿-추가하기)
4. [설정 패널 개발](#4-설정-패널-개발)
5. [데이터베이스 설정](#5-데이터베이스-설정)
6. [테스트 및 검증](#6-테스트-및-검증)
---
## 1. 컴포넌트 추가하기
### 1.1 컴포넌트 렌더러 생성
새로운 컴포넌트 렌더러를 생성합니다.
**파일 위치**: `frontend/lib/registry/components/{ComponentName}Renderer.tsx`
```typescript
// 예시: 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`
```typescript
// 컴포넌트 렌더러 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`
```typescript
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
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`
```typescript
// 예시: 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`
```typescript
// 웹타입 컴포넌트 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`
```typescript
// 웹타입별 설정 인터페이스에 추가
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`
```typescript
// 예시: 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`
```typescript
// 템플릿 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`
```typescript
// 예시: 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`
```typescript
// 설정 패널 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 테이블에 데이터 추가
```javascript
// 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 스크립트 실행
```bash
cd backend-node
node scripts/add-new-component.js
```
---
## 6. 테스트 및 검증
### 6.1 개발 서버 재시작
```bash
# 프론트엔드 재시작
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.tsx``hasNewConfigPanel` 배열 확인
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. 설정 구조
```typescript
// 일관된 설정 구조 사용
interface ComponentConfig {
// 기본 설정
title?: string;
description?: string;
// 표시 설정
showIcon?: boolean;
showBorder?: boolean;
// 스타일 설정
style?: {
backgroundColor?: string;
borderRadius?: string;
padding?: string;
};
// 컴포넌트별 전용 설정
[key: string]: any;
}
```
### 3. 반응형 지원
```typescript
// 컨테이너 크기에 따른 반응형 처리
const isSmall = component.size.width < 300;
const columns = isSmall ? 1 : 3;
```
### 4. 접근성 고려
```typescript
// 접근성 속성 추가
<button
aria-label={config.ariaLabel || config.text}
role="button"
tabIndex={0}
>
{config.text}
</button>
```
---
## 📚 참고 자료
- [Shadcn/ui 컴포넌트 문서](https://ui.shadcn.com)
- [Lucide 아이콘 문서](https://lucide.dev)
- [React Hook Form 문서](https://react-hook-form.com)
- [TypeScript 타입 정의 가이드](https://www.typescriptlang.org/docs)
---
이 가이드를 따라 새로운 컴포넌트, 웹타입, 템플릿을 성공적으로 추가할 수 있습니다. 추가 질문이나 문제가 발생하면 언제든지 문의해주세요! 🚀