ERP-node/frontend/components/screen/RealtimePreview.tsx

1353 lines
50 KiB
TypeScript

"use client";
import React from "react";
import {
ComponentData,
WebType,
WidgetComponent,
DateTypeConfig,
NumberTypeConfig,
SelectTypeConfig,
TextTypeConfig,
TextareaTypeConfig,
CheckboxTypeConfig,
RadioTypeConfig,
FileTypeConfig,
CodeTypeConfig,
EntityTypeConfig,
} from "@/types/screen";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
// import { Checkbox } from "@/components/ui/checkbox";
// import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Database,
Type,
Hash,
List,
AlignLeft,
CheckSquare,
Radio,
Calendar,
Code,
Building,
File,
Group,
ChevronDown,
ChevronRight,
Search,
RotateCcw,
Plus,
Edit,
Trash2,
} from "lucide-react";
interface RealtimePreviewProps {
component: ComponentData;
isSelected?: boolean;
onClick?: (e?: React.MouseEvent) => void;
onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: () => void;
onGroupToggle?: (groupId: string) => void; // 그룹 접기/펼치기
children?: React.ReactNode; // 그룹 내 자식 컴포넌트들
}
// 웹 타입에 따른 위젯 렌더링
const renderWidget = (component: ComponentData) => {
const { widgetType, label, placeholder, required, readonly, columnName, style } = component;
// 디버깅: 실제 widgetType 값 확인
console.log("RealtimePreview - widgetType:", widgetType, "columnName:", columnName);
// 사용자가 테두리를 설정했는지 확인
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
// 기본 테두리 제거 여부 결정 - Shadcn UI 기본 border 클래스를 덮어쓰기
const borderClass = hasCustomBorder ? "!border-0" : "";
const commonProps = {
placeholder: placeholder || "입력하세요...",
disabled: readonly,
required: required,
className: `w-full h-full ${borderClass}`,
};
switch (widgetType) {
case "text":
case "email":
case "tel": {
const widget = component as WidgetComponent;
const config = widget.webTypeConfig as TextTypeConfig | undefined;
// 입력 타입에 따른 처리
const isAutoInput = widget.inputType === "auto";
// 자동 값 생성 함수
const getAutoValue = (autoValueType: string) => {
switch (autoValueType) {
case "current_datetime":
return new Date().toLocaleString("ko-KR");
case "current_date":
return new Date().toLocaleDateString("ko-KR");
case "current_time":
return new Date().toLocaleTimeString("ko-KR");
case "current_user":
return "현재사용자";
case "uuid":
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
case "sequence":
return "SEQ_001";
case "user_defined":
return "사용자정의값";
default:
return "자동생성값";
}
};
// 자동 값 플레이스홀더 생성 함수
const getAutoPlaceholder = (autoValueType: string) => {
switch (autoValueType) {
case "current_datetime":
return "현재 날짜시간";
case "current_date":
return "현재 날짜";
case "current_time":
return "현재 시간";
case "current_user":
return "현재 사용자";
case "uuid":
return "UUID";
case "sequence":
return "시퀀스";
case "user_defined":
return "사용자 정의";
default:
return "자동 생성됨";
}
};
// 플레이스홀더 처리
const finalPlaceholder = isAutoInput
? getAutoPlaceholder(widget.autoValueType || "current_datetime")
: config?.placeholder || placeholder || "텍스트를 입력하세요";
// 자동 값 처리
const autoValue = isAutoInput ? getAutoValue(widget.autoValueType || "current_datetime") : "";
const inputType = widgetType === "email" ? "email" : widgetType === "tel" ? "tel" : "text";
// 형식별 패턴 생성
const getPatternByFormat = (format: string) => {
switch (format) {
case "korean":
return "[가-힣\\s]*";
case "english":
return "[a-zA-Z\\s]*";
case "alphanumeric":
return "[a-zA-Z0-9]*";
case "numeric":
return "[0-9]*";
case "email":
return "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}";
case "phone":
return "\\d{3}-\\d{4}-\\d{4}";
case "url":
return "https?://[\\w\\-]+(\\.[\\w\\-]+)+([\\w\\-\\.,@?^=%&:/~\\+#]*[\\w\\-\\@?^=%&/~\\+#])?";
default:
return config?.pattern || undefined;
}
};
// 입력 검증 함수
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const value = e.target.value;
// 형식별 실시간 검증
if (config?.format && config.format !== "none") {
const pattern = getPatternByFormat(config.format);
if (pattern) {
const regex = new RegExp(`^${pattern}$`);
if (value && !regex.test(value)) {
// 유효하지 않은 입력은 무시
e.preventDefault();
return;
}
}
}
// 길이 제한 검증
if (config?.maxLength && value.length > config.maxLength) {
e.preventDefault();
return;
}
};
const inputProps = {
...commonProps,
placeholder: finalPlaceholder,
value: isAutoInput ? autoValue : undefined, // 자동입력인 경우 자동 값 표시
minLength: config?.minLength,
maxLength: config?.maxLength,
pattern: getPatternByFormat(config?.format || "none"),
onInput: handleInputChange,
onChange: () => {}, // 읽기 전용으로 처리
readOnly: readonly || isAutoInput, // 자동입력인 경우 읽기 전용
className: `w-full h-full ${borderClass} ${isAutoInput ? "bg-gray-50 text-gray-600" : ""}`,
};
// multiline이면 Textarea로 렌더링
if (config?.multiline) {
return <Textarea {...inputProps} />;
}
return <Input type={inputType} {...inputProps} />;
}
case "number":
case "decimal": {
const widget = component as WidgetComponent;
const config = widget.webTypeConfig as NumberTypeConfig | undefined;
// 입력 타입에 따른 처리
const isAutoInput = widget.inputType === "auto";
// 자동 값 생성 함수 (숫자용)
const getAutoNumberValue = (autoValueType: string) => {
switch (autoValueType) {
case "current_datetime":
return Date.now().toString();
case "current_date":
return new Date().getDate().toString();
case "current_time":
return new Date().getHours().toString();
case "sequence":
return "1001";
case "uuid":
return Math.floor(Math.random() * 1000000).toString();
case "user_defined":
return "999";
default:
return "0";
}
};
// 자동 값 플레이스홀더 생성 함수 (숫자용)
const getAutoNumberPlaceholder = (autoValueType: string) => {
switch (autoValueType) {
case "current_datetime":
return "타임스탬프";
case "current_date":
return "현재 일";
case "current_time":
return "현재 시";
case "sequence":
return "시퀀스";
case "uuid":
return "랜덤 숫자";
case "user_defined":
return "사용자 정의";
default:
return "자동 생성";
}
};
// 자동 값 처리
const autoValue = isAutoInput ? getAutoNumberValue(widget.autoValueType || "sequence") : "";
// 디버깅: 현재 설정값 확인
console.log("🔢 숫자 위젯 렌더링:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
placeholder: widget.placeholder,
inputType: widget.inputType,
isAutoInput,
});
// 단계값 결정: webTypeConfig > 기본값 (소수는 0.01, 정수는 1)
const step = config?.step || (widgetType === "decimal" ? 0.01 : 1);
// 플레이스홀더 처리
const finalPlaceholder = isAutoInput
? getAutoNumberPlaceholder(widget.autoValueType || "sequence")
: config?.placeholder || placeholder || "숫자를 입력하세요";
// 형식에 따른 표시값 처리
const formatValue = (value: string) => {
if (!value || !config) return value;
const num = parseFloat(value);
if (isNaN(num)) return value;
switch (config.format) {
case "currency":
return new Intl.NumberFormat("ko-KR", {
style: "currency",
currency: "KRW",
}).format(num);
case "percentage":
return `${num}%`;
case "decimal":
return num.toFixed(config.decimalPlaces || 2);
default:
if (config.thousandSeparator) {
return new Intl.NumberFormat("ko-KR").format(num);
}
return value;
}
};
// 접두사/접미사가 있는 경우 표시용 컨테이너 사용
if (config?.prefix || config?.suffix) {
return (
<div className="flex w-full items-center">
{config.prefix && (
<span className="rounded-l border border-r-0 bg-gray-50 px-2 py-2 text-sm text-gray-600">
{config.prefix}
</span>
)}
<Input
type="number"
step={step}
min={config?.min}
max={config?.max}
{...commonProps}
placeholder={finalPlaceholder}
value={isAutoInput ? autoValue : undefined} // 자동입력인 경우 자동 표시
className={`${config?.prefix ? "rounded-l-none" : ""} ${config?.suffix ? "rounded-r-none" : ""} ${borderClass} ${isAutoInput ? "bg-gray-50 text-gray-600" : ""}`}
onChange={() => {}} // 읽기 전용으로 처리
readOnly={readonly || isAutoInput}
/>
{config.suffix && (
<span className="rounded-r border border-l-0 bg-gray-50 px-2 py-2 text-sm text-gray-600">
{config.suffix}
</span>
)}
</div>
);
}
return (
<Input
type="number"
step={step}
min={config?.min}
max={config?.max}
{...commonProps}
placeholder={finalPlaceholder}
value={isAutoInput ? autoValue : undefined} // 자동입력인 경우 자동 표시
className={`h-full w-full ${borderClass} ${isAutoInput ? "bg-gray-50 text-gray-600" : ""}`}
onChange={() => {}} // 읽기 전용으로 처리
readOnly={readonly || isAutoInput}
/>
);
}
case "date":
case "datetime": {
const widget = component as WidgetComponent;
const config = widget.webTypeConfig as DateTypeConfig | undefined;
// 입력 타입에 따른 처리
const isAutoInput = widget.inputType === "auto";
// 자동 값 생성 함수 (날짜용)
const getAutoDateValue = (autoValueType: string, inputType: string) => {
const now = new Date();
switch (autoValueType) {
case "current_datetime":
return inputType === "datetime-local"
? now.toISOString().slice(0, 16) // YYYY-MM-DDTHH:mm
: now.toISOString().slice(0, 10); // YYYY-MM-DD
case "current_date":
return now.toISOString().slice(0, 10); // YYYY-MM-DD
case "current_time":
return inputType === "datetime-local"
? now.toISOString().slice(0, 16) // YYYY-MM-DDTHH:mm
: now.toTimeString().slice(0, 5); // HH:mm
case "user_defined":
return inputType === "datetime-local" ? "2024-01-01T09:00" : "2024-01-01";
default:
return inputType === "datetime-local" ? now.toISOString().slice(0, 16) : now.toISOString().slice(0, 10);
}
};
// 자동 값 플레이스홀더 생성 함수 (날짜용)
const getAutoDatePlaceholder = (autoValueType: string) => {
switch (autoValueType) {
case "current_datetime":
return "현재 날짜시간";
case "current_date":
return "현재 날짜";
case "current_time":
return "현재 시간";
case "user_defined":
return "사용자 정의";
default:
return "자동 생성";
}
};
// 웹타입 설정에 따른 input type 결정
let inputType = "date";
if (config?.showTime || config?.format?.includes("HH:mm")) {
inputType = "datetime-local";
}
// defaultValue를 inputType에 맞게 변환
let processedDefaultValue = config?.defaultValue || "";
if (processedDefaultValue) {
if (inputType === "datetime-local") {
// datetime-local은 "YYYY-MM-DDTHH:mm" 형식이 필요
if (!processedDefaultValue.includes("T") && processedDefaultValue.includes(" ")) {
processedDefaultValue = processedDefaultValue.replace(" ", "T");
}
// 초가 없으면 제거 (datetime-local은 분까지만)
if (processedDefaultValue.includes(":") && processedDefaultValue.split(":").length > 2) {
processedDefaultValue = processedDefaultValue.substring(0, processedDefaultValue.lastIndexOf(":"));
}
} else if (inputType === "date") {
// date는 "YYYY-MM-DD" 형식만 필요
if (processedDefaultValue.includes(" ") || processedDefaultValue.includes("T")) {
processedDefaultValue = processedDefaultValue.split(/[T ]/)[0];
}
}
}
// 자동 값 처리
const autoValue = isAutoInput ? getAutoDateValue(widget.autoValueType || "current_date", inputType) : "";
// 플레이스홀더 우선순위: webTypeConfig > placeholder > 기본값
const finalPlaceholder = isAutoInput
? getAutoDatePlaceholder(widget.autoValueType || "current_date")
: config?.placeholder || placeholder || "날짜를 선택하세요";
// 디버깅: 현재 설정값 확인
console.log("📅 날짜 위젯 렌더링:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
configExists: !!config,
configKeys: config ? Object.keys(config) : [],
configStringified: JSON.stringify(config),
placeholder: widget.placeholder,
finalInputType: inputType,
finalPlaceholder,
inputTypeDecision: {
showTimeCheck: config?.showTime,
formatCheck: config?.format?.includes("HH:mm"),
formatValue: config?.format,
resultInputType: inputType,
},
appliedSettings: {
minDate: config?.minDate,
maxDate: config?.maxDate,
defaultValue: config?.defaultValue,
processedDefaultValue,
showTime: config?.showTime,
format: config?.format,
},
inputProps: {
type: inputType,
placeholder: finalPlaceholder,
min: config?.minDate,
max: config?.maxDate,
value: processedDefaultValue,
},
valueConversion: {
originalValue: config?.defaultValue,
processedValue: processedDefaultValue,
inputType,
conversionApplied: config?.defaultValue !== processedDefaultValue,
},
widgetFullData: {
id: widget.id,
type: widget.type,
widgetType: widget.widgetType,
webTypeConfig: widget.webTypeConfig,
webTypeConfigStringified: JSON.stringify(widget.webTypeConfig),
},
timestamp: new Date().toISOString(),
});
return (
<Input
type={inputType}
{...commonProps}
placeholder={finalPlaceholder}
min={config?.minDate}
max={config?.maxDate}
value={isAutoInput ? autoValue : processedDefaultValue}
className={`h-full w-full ${borderClass} ${isAutoInput ? "bg-gray-50 text-gray-600" : ""}`}
onChange={() => {}} // 읽기 전용으로 처리
readOnly={readonly || isAutoInput}
/>
);
}
case "select":
case "dropdown": {
const widget = component as WidgetComponent;
const config = widget.webTypeConfig as SelectTypeConfig | undefined;
// 디버깅: 현재 설정값 확인
console.log("📋 선택박스 위젯 렌더링:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
options: config?.options,
placeholder: widget.placeholder,
});
// 플레이스홀더 처리
const finalPlaceholder = config?.placeholder || placeholder || "선택하세요...";
// 옵션 목록 (webTypeConfig에서 가져오거나 기본 옵션 사용)
const options = config?.options || [
{ label: "옵션 1", value: "option1" },
{ label: "옵션 2", value: "option2" },
{ label: "옵션 3", value: "option3" },
];
return (
<select
disabled={readonly}
required={required}
multiple={config?.multiple}
value={config?.defaultValue || ""}
onChange={() => {}} // 읽기 전용으로 처리
className={`w-full rounded-md px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-100 ${
hasCustomBorder ? "!border-0" : "border border-gray-300"
}`}
>
<option value="">{finalPlaceholder}</option>
{options.map((option, index) => (
<option key={index} value={option.value} disabled={option.disabled}>
{option.label}
</option>
))}
</select>
);
}
case "textarea":
case "text_area": {
const widget = component as WidgetComponent;
const config = widget.webTypeConfig as TextareaTypeConfig | undefined;
return (
<Textarea
{...commonProps}
rows={config?.rows || 3}
placeholder={config?.placeholder || placeholder || "텍스트를 입력하세요"}
minLength={config?.minLength}
maxLength={config?.maxLength}
value={config?.defaultValue || ""}
onChange={() => {}} // 읽기 전용으로 처리
readOnly
style={{
resize: config?.resizable === false ? "none" : "vertical",
whiteSpace: config?.wordWrap === false ? "nowrap" : "normal",
}}
/>
);
}
case "boolean":
case "checkbox": {
const widget = component as WidgetComponent;
const config = widget.webTypeConfig as CheckboxTypeConfig | undefined;
const checkboxText = config?.checkboxText || label || columnName || "체크박스";
const isLeftLabel = config?.labelPosition === "left";
return (
<div className="flex items-center space-x-2">
{isLeftLabel && (
<Label htmlFor={`checkbox-${component.id}`} className="text-sm">
{checkboxText}
</Label>
)}
<input
type="checkbox"
id={`checkbox-${component.id}`}
disabled={readonly}
required={required}
checked={config?.defaultChecked || false}
onChange={() => {}} // 읽기 전용으로 처리
readOnly
className="h-4 w-4"
/>
{!isLeftLabel && (
<Label htmlFor={`checkbox-${component.id}`} className="text-sm">
{checkboxText}
</Label>
)}
</div>
);
}
case "radio": {
const widget = component as WidgetComponent;
const config = widget.webTypeConfig as RadioTypeConfig | undefined;
const options = config?.options || [
{ label: "옵션 1", value: "option1" },
{ label: "옵션 2", value: "option2" },
];
const layoutClass =
config?.layout === "horizontal"
? "flex flex-row space-x-4"
: config?.layout === "grid"
? "grid grid-cols-2 gap-2"
: "space-y-2";
return (
<div className={layoutClass}>
{options.map((option, index) => (
<div key={option.value} className="flex items-center space-x-2">
<input
type="radio"
id={`radio-${component.id}-${index}`}
name={`radio-group-${component.id}`}
value={option.value}
disabled={readonly}
required={required}
checked={config?.defaultValue === option.value}
onChange={() => {}} // 읽기 전용으로 처리
readOnly
className="h-4 w-4"
/>
<Label htmlFor={`radio-${component.id}-${index}`} className="text-sm">
{option.label}
</Label>
</div>
))}
</div>
);
}
case "code": {
const widget = component as WidgetComponent;
const config = widget.webTypeConfig as CodeTypeConfig | undefined;
console.log("💻 코드 위젯 렌더링:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
appliedSettings: {
language: config?.language,
theme: config?.theme,
fontSize: config?.fontSize,
defaultValue: config?.defaultValue,
readOnly: config?.readOnly,
wordWrap: config?.wordWrap,
},
});
return (
<Textarea
{...commonProps}
rows={config?.rows || 4}
className={`w-full font-mono text-sm ${borderClass}`}
placeholder={config?.placeholder || "코드를 입력하세요..."}
value={config?.defaultValue || ""}
onChange={() => {}} // 읽기 전용으로 처리
readOnly
style={{
fontSize: `${config?.fontSize || 14}px`,
backgroundColor: config?.theme === "dark" ? "#1e1e1e" : "#ffffff",
color: config?.theme === "dark" ? "#ffffff" : "#000000",
whiteSpace: config?.wordWrap === false ? "nowrap" : "normal",
tabSize: config?.tabSize || 2,
}}
/>
);
}
case "entity": {
const widget = component as WidgetComponent;
const config = widget.webTypeConfig as EntityTypeConfig | undefined;
console.log("🏢 엔티티 위젯 렌더링:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
appliedSettings: {
entityName: config?.entityName,
displayField: config?.displayField,
valueField: config?.valueField,
multiple: config?.multiple,
searchable: config?.searchable,
allowClear: config?.allowClear,
maxSelections: config?.maxSelections,
},
});
// 기본 옵션들 (실제로는 API에서 가져와야 함)
const defaultOptions = [
{ label: "사용자", value: "user" },
{ label: "제품", value: "product" },
{ label: "주문", value: "order" },
{ label: "카테고리", value: "category" },
];
return (
<select
disabled={readonly}
required={required}
multiple={config?.multiple}
value={config?.defaultValue || ""}
onChange={() => {}} // 읽기 전용으로 처리
className={`w-full rounded-md px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-100 ${
hasCustomBorder ? "!border-0" : "border border-gray-300"
}`}
>
<option value="">{config?.placeholder || "엔티티를 선택하세요..."}</option>
{defaultOptions.map((option) => (
<option key={option.value} value={option.value}>
{config?.displayFormat
? config.displayFormat.replace("{label}", option.label).replace("{value}", option.value)
: option.label}
</option>
))}
</select>
);
}
case "file": {
const widget = component as WidgetComponent;
const config = widget.webTypeConfig as FileTypeConfig | undefined;
console.log("📁 파일 위젯 렌더링:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
appliedSettings: {
accept: config?.accept,
multiple: config?.multiple,
maxSize: config?.maxSize,
preview: config?.preview,
allowedTypes: config?.allowedTypes,
},
});
// 파일 크기 제한 검증
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || !config?.maxSize) return;
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.size > config.maxSize * 1024 * 1024) {
// MB to bytes
alert(`파일 크기가 ${config.maxSize}MB를 초과합니다: ${file.name}`);
e.target.value = ""; // 파일 선택 초기화
return;
}
}
};
return (
<div className="w-full">
<input
type="file"
disabled={readonly}
required={required}
multiple={config?.multiple}
accept={config?.accept}
onChange={handleFileChange}
className={`w-full rounded-md px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-100 ${
hasCustomBorder ? "!border-0" : "border border-gray-300"
}`}
/>
{config?.maxSize && <div className="mt-1 text-xs text-gray-500"> : {config.maxSize}MB</div>}
{config?.accept && <div className="mt-1 text-xs text-gray-500"> : {config.accept}</div>}
</div>
);
}
case "button":
return (
<Button
disabled={readonly}
size="sm"
variant={style?.backgroundColor === "transparent" ? "outline" : "default"}
className="gap-1 text-xs"
style={{
...style, // 모든 스타일 속성 적용
}}
>
{label}
</Button>
);
default:
return <Input type="text" {...commonProps} />;
}
};
// 위젯 타입 아이콘
const getWidgetIcon = (widgetType: WebType | undefined) => {
switch (widgetType) {
case "text":
case "email":
case "tel":
return <Type className="h-4 w-4 text-blue-600" />;
case "number":
case "decimal":
return <Hash className="h-4 w-4 text-green-600" />;
case "date":
case "datetime":
return <Calendar className="h-4 w-4 text-purple-600" />;
case "select":
case "dropdown":
return <List className="h-4 w-4 text-orange-600" />;
case "textarea":
case "text_area":
return <AlignLeft className="h-4 w-4 text-indigo-600" />;
case "boolean":
case "checkbox":
return <CheckSquare className="h-4 w-4 text-blue-600" />;
case "radio":
return <Radio className="h-4 w-4 text-blue-600" />;
case "code":
return <Code className="h-4 w-4 text-gray-600" />;
case "entity":
return <Building className="h-4 w-4 text-cyan-600" />;
case "file":
return <File className="h-4 w-4 text-yellow-600" />;
default:
return <Type className="h-4 w-4 text-gray-500" />;
}
};
export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
component,
isSelected = false,
onClick,
onDragStart,
onDragEnd,
children,
onGroupToggle,
}) => {
const { type, label, tableName, columnName, widgetType, size, style } = component;
// 사용자가 테두리를 설정했는지 확인
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
// 기본 선택 테두리는 사용자 테두리가 없을 때만 적용 (데이터 테이블 제외)
const defaultRingClass =
hasCustomBorder || type === "datatable"
? ""
: isSelected
? "ring-opacity-50 ring-2 ring-blue-500"
: "hover:ring-opacity-50 hover:ring-1 hover:ring-gray-300";
// 사용자 테두리가 있을 때 또는 데이터 테이블일 때 선택 상태 표시를 위한 스타일
const selectionStyle =
(hasCustomBorder || type === "datatable") && isSelected
? {
boxShadow: "0 0 0 2px rgba(59, 130, 246, 0.5)", // 외부 그림자로 선택 표시
...style,
}
: style;
// 라벨 스타일 계산
const shouldShowLabel = component.style?.labelDisplay !== false && (component.label || component.style?.labelText);
const labelText = component.style?.labelText || component.label || "";
// 라벨 하단 여백 값 추출 (px 단위 숫자로 변환)
const labelMarginBottomValue = parseInt(component.style?.labelMarginBottom || "4px", 10);
const labelStyle: React.CSSProperties = {
fontSize: component.style?.labelFontSize || "12px",
color: component.style?.labelColor || "#374151",
fontWeight: component.style?.labelFontWeight || "500",
fontFamily: component.style?.labelFontFamily || "inherit",
textAlign: component.style?.labelTextAlign || "left",
backgroundColor: component.style?.labelBackgroundColor || "transparent",
padding: component.style?.labelPadding || "0",
borderRadius: component.style?.labelBorderRadius || "0",
};
// 데이터 테이블은 특별한 구조로 렌더링
if (type === "datatable") {
const dataTableComponent = component as any; // DataTableComponent 타입
// 메모이제이션을 위한 계산 최적화
const visibleColumns = React.useMemo(
() => dataTableComponent.columns?.filter((col: any) => col.visible) || [],
[dataTableComponent.columns],
);
const filters = React.useMemo(() => dataTableComponent.filters || [], [dataTableComponent.filters]);
return (
<div
className="h-full w-full cursor-move"
style={{
...(isSelected ? { boxShadow: "0 0 0 2px rgba(59, 130, 246, 0.5)" } : {}),
}}
onClick={(e) => {
e.stopPropagation();
onClick?.(e);
}}
draggable
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onMouseDown={(e) => {
e.stopPropagation();
}}
>
{/* 라벨 표시 */}
{shouldShowLabel && (
<div
className="pointer-events-none absolute left-0 w-full truncate"
style={{
...labelStyle,
top: `${-20 - labelMarginBottomValue}px`,
}}
>
{labelText}
{component.required && <span style={{ color: "#f97316", marginLeft: "2px" }}>*</span>}
</div>
)}
{/* Shadcn UI 기반 데이터 테이블 */}
<Card
className="flex h-full w-full flex-col overflow-hidden"
style={{
width: `${size.width}px`,
height: `${size.height}px`,
}}
>
{/* 카드 헤더 */}
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Database className="text-muted-foreground h-4 w-4" />
<CardTitle className="text-sm">{dataTableComponent.title || label}</CardTitle>
</div>
<div className="flex items-center space-x-2">
{filters.length > 0 && (
<Badge variant="outline" className="text-xs">
<Search className="mr-1 h-3 w-3" />
{filters.length}
</Badge>
)}
{/* CRUD 버튼들 (미리보기) */}
{dataTableComponent.enableAdd && (
<Button size="sm" className="gap-1 text-xs">
<Plus className="h-3 w-3" />
{dataTableComponent.addButtonText || "추가"}
</Button>
)}
{dataTableComponent.enableEdit && (
<Button size="sm" variant="outline" className="gap-1 text-xs">
<Edit className="h-3 w-3" />
{dataTableComponent.editButtonText || "수정"}
</Button>
)}
{dataTableComponent.enableDelete && (
<Button size="sm" variant="destructive" className="gap-1 text-xs">
<Trash2 className="h-3 w-3" />
{dataTableComponent.deleteButtonText || "삭제"}
</Button>
)}
{dataTableComponent.showSearchButton && (
<Button size="sm" className="gap-1 text-xs">
<Search className="h-3 w-3" />
{dataTableComponent.searchButtonText || "검색"}
</Button>
)}
<Button size="sm" variant="outline" className="gap-1 text-xs">
<RotateCcw className="h-3 w-3" />
</Button>
</div>
</div>
{/* 필터 영역 미리보기 */}
{filters.length > 0 && (
<>
<Separator className="my-2" />
<div className="space-y-3">
<div className="text-muted-foreground flex items-center gap-2 text-xs">
<Search className="h-3 w-3" />
</div>
<div
className="grid gap-3"
style={{
gridTemplateColumns: filters.map((filter: any) => `${filter.gridColumns || 3}fr`).join(" "),
}}
>
{filters.map((filter: any, index: number) => (
<div key={`filter-${index}`} className="space-y-1">
<label className="text-muted-foreground text-xs font-medium">{filter.label}</label>
<div className="bg-background text-muted-foreground rounded border px-2 py-1 text-xs">
...
</div>
</div>
))}
</div>
</div>
</>
)}
</CardHeader>
{/* 테이블 내용 */}
<CardContent className="flex-1 p-0">
<div className="flex h-full flex-col">
{visibleColumns.length > 0 ? (
<>
<Table>
<TableHeader>
<TableRow>
{visibleColumns.map((column: any) => (
<TableHead key={column.id} className="px-4 text-xs font-semibold">
{column.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{/* 샘플 데이터 3행 */}
<TableRow className="hover:bg-muted/50">
{visibleColumns.map((column: any, colIndex: number) => (
<TableCell key={`sample1-${colIndex}`} className="px-4 font-mono text-xs">
1-{colIndex + 1}
</TableCell>
))}
</TableRow>
<TableRow className="hover:bg-muted/50">
{visibleColumns.map((column: any, colIndex: number) => (
<TableCell key={`sample2-${colIndex}`} className="px-4 font-mono text-xs">
2-{colIndex + 1}
</TableCell>
))}
</TableRow>
<TableRow className="hover:bg-muted/50">
{visibleColumns.map((column: any, colIndex: number) => (
<TableCell key={`sample3-${colIndex}`} className="px-4 font-mono text-xs">
3-{colIndex + 1}
</TableCell>
))}
</TableRow>
</TableBody>
</Table>
{/* 페이지네이션 미리보기 */}
{dataTableComponent.pagination?.enabled && (
<div className="bg-muted/20 mt-auto border-t">
<div className="flex items-center justify-between px-4 py-2">
{dataTableComponent.pagination.showPageInfo && (
<div className="text-muted-foreground text-xs">
<span className="font-medium">100</span> <span className="font-medium">1</span>-
<span className="font-medium">10</span>
</div>
)}
<div className="flex items-center space-x-2">
{dataTableComponent.pagination.showFirstLast && (
<Button size="sm" variant="outline" className="gap-1 text-xs">
</Button>
)}
<Button size="sm" variant="outline" className="gap-1 text-xs">
</Button>
<div className="flex items-center gap-1 text-xs font-medium">
<span>1</span>
<span className="text-muted-foreground">/</span>
<span>10</span>
</div>
<Button size="sm" variant="outline" className="gap-1 text-xs">
</Button>
{dataTableComponent.pagination.showFirstLast && (
<Button size="sm" variant="outline" className="gap-1 text-xs">
</Button>
)}
</div>
</div>
</div>
)}
</>
) : (
<div className="flex flex-1 items-center justify-center">
<div className="text-muted-foreground flex flex-col items-center gap-2">
<Database className="h-6 w-6" />
<p className="text-xs"> </p>
</div>
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}
// 다른 컴포넌트들은 기존 구조 사용
return (
<div
className={`cursor-move transition-all ${defaultRingClass}`}
style={{
width: `${size.width}px`,
height: `${size.height}px`, // 순수 컴포넌트 높이만 사용
...selectionStyle,
}}
onClick={(e) => {
e.stopPropagation();
onClick?.(e);
}}
draggable
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onMouseDown={(e) => {
// 드래그 시작을 위한 마우스 다운 이벤트
e.stopPropagation();
}}
>
{/* 라벨 표시 - 원래대로 컴포넌트 위쪽에 표시 */}
{shouldShowLabel && (
<div
className="pointer-events-none absolute left-0 w-full truncate"
style={{
...labelStyle,
top: `${-20 - labelMarginBottomValue}px`, // 라벨 높이(약 20px) + 여백값만큼 위로 이동
}}
>
{labelText}
{component.required && <span style={{ color: "#f97316", marginLeft: "2px" }}>*</span>}
</div>
)}
{/* 컴포넌트 내용 */}
<div
style={{
width: `${size.width}px`,
height: `${size.height}px`,
}}
>
{type === "container" && (
<div
className="relative h-full w-full"
data-form-container={component.title === "새 데이터 입력" ? "true" : "false"}
data-component-id={component.id}
>
{/* 컨테이너 자식 컴포넌트들 렌더링 */}
{children && React.Children.count(children) > 0 ? (
<div className="absolute inset-0">{children}</div>
) : (
<div className="pointer-events-none flex h-full flex-col items-center justify-center p-2">
<div className="flex flex-col items-center space-y-1">
<Database className="h-6 w-6 text-blue-600" />
<div className="text-center">
<div className="text-xs font-medium">{label}</div>
<div className="text-xs text-gray-500">{tableName}</div>
</div>
</div>
</div>
)}
</div>
)}
{false &&
(() => {
const dataTableComponent = component as any; // DataTableComponent 타입
const visibleColumns = dataTableComponent.columns?.filter((col: any) => col.visible) || [];
const filters = dataTableComponent.filters || [];
return (
<>
{/* 데이터 테이블 헤더 */}
<div className="border-b bg-gray-50 px-4 py-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-900">{dataTableComponent.title || label}</h3>
<div className="flex items-center space-x-2">
{filters.length > 0 && <div className="text-xs text-gray-500"> {filters.length}</div>}
{dataTableComponent.showSearchButton && (
<button className="rounded bg-blue-600 px-3 py-1 text-xs text-white">
{dataTableComponent.searchButtonText || "검색"}
</button>
)}
</div>
</div>
{/* 필터 영역 미리보기 */}
{filters.length > 0 && (
<div className="mt-3 space-y-2">
<div className="text-xs font-medium text-gray-700"> </div>
<div
className="grid gap-2"
style={{
gridTemplateColumns: filters.map((filter: any) => `${filter.gridColumns || 3}fr`).join(" "),
}}
>
{filters.map((filter: any, index: number) => {
const getFilterIcon = (webType: string) => {
switch (webType) {
case "text":
case "email":
case "tel":
return "📝";
case "number":
case "decimal":
return "🔢";
case "date":
case "datetime":
return "📅";
case "select":
return "📋";
default:
return "🔍";
}
};
const getFilterPlaceholder = (webType: string) => {
switch (webType) {
case "text":
return "텍스트 검색...";
case "email":
return "이메일 검색...";
case "tel":
return "전화번호 검색...";
case "number":
return "숫자 입력...";
case "decimal":
return "소수 입력...";
case "date":
return "날짜 선택...";
case "datetime":
return "날짜시간 선택...";
case "select":
return "옵션 선택...";
default:
return "검색...";
}
};
return (
<div key={index} className="text-xs">
<div className="mb-1 flex items-center space-x-1 text-gray-600">
<span>{getFilterIcon(filter.widgetType)}</span>
<span>{filter.label}</span>
</div>
<div className="rounded border bg-white px-2 py-1 text-gray-400">
{getFilterPlaceholder(filter.widgetType)}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
{/* 테이블 내용 미리보기 */}
<div className="flex-1 p-4">
<div className="space-y-2">
{/* 테이블 헤더 행 */}
{visibleColumns.length > 0 ? (
<div
className="gap-2 border-b pb-2 text-xs font-medium text-gray-700"
style={{
display: "grid",
gridTemplateColumns: visibleColumns.map((col: any) => `${col.gridColumns || 2}fr`).join(" "),
}}
>
{visibleColumns.map((column: any) => (
<div key={column.id} className="truncate">
{column.label}
</div>
))}
</div>
) : (
<div className="py-4 text-center text-xs text-gray-500"> </div>
)}
{/* 샘플 데이터 행들 */}
{visibleColumns.length > 0 &&
[1, 2, 3].map((row) => (
<div
key={row}
className="gap-2 py-1 text-xs text-gray-600"
style={{
display: "grid",
gridTemplateColumns: visibleColumns
.map((col: any) => `${col.gridColumns || 2}fr`)
.join(" "),
}}
>
{visibleColumns.map((column: any, colIndex: number) => (
<div key={colIndex} className="truncate">
{row}-{colIndex + 1}
</div>
))}
</div>
))}
</div>
</div>
{/* 페이지네이션 미리보기 */}
{dataTableComponent.pagination?.enabled && (
<div className="border-t bg-gray-50 px-4 py-2">
<div className="flex items-center justify-between text-xs text-gray-600">
{dataTableComponent.pagination.showPageInfo && (
<div> 100 1-{dataTableComponent.pagination.pageSize || 10}</div>
)}
<div className="flex items-center space-x-1">
{dataTableComponent.pagination.showFirstLast && (
<button className="rounded border px-2 py-1"></button>
)}
<button className="rounded border px-2 py-1"></button>
<span className="px-2">1</span>
<button className="rounded border px-2 py-1"></button>
{dataTableComponent.pagination.showFirstLast && (
<button className="rounded border px-2 py-1"></button>
)}
</div>
</div>
</div>
)}
</>
);
})()}
{type === "group" && (
<div className="relative h-full w-full">
{/* 그룹 내용 */}
<div className="absolute inset-0">{children}</div>
</div>
)}
{type === "widget" && (
<div className="flex h-full flex-col">
{/* 위젯 본체 - 실제 웹 위젯처럼 보이도록 */}
<div className="pointer-events-none flex-1">{renderWidget(component)}</div>
</div>
)}
</div>
</div>
);
};
export default RealtimePreview;