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

619 lines
20 KiB
TypeScript
Raw Normal View History

"use client";
import React from "react";
2025-09-03 11:32:09 +09:00
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 { Checkbox } from "@/components/ui/checkbox";
// import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
2025-09-01 15:22:47 +09:00
import {
Database,
Type,
Hash,
List,
AlignLeft,
CheckSquare,
Radio,
Calendar,
Code,
Building,
File,
Group,
ChevronDown,
ChevronRight,
} from "lucide-react";
interface RealtimePreviewProps {
component: ComponentData;
isSelected?: boolean;
2025-09-01 16:40:24 +09:00
onClick?: (e?: React.MouseEvent) => void;
onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: () => void;
2025-09-01 15:22:47 +09:00
onGroupToggle?: (groupId: string) => void; // 그룹 접기/펼치기
children?: React.ReactNode; // 그룹 내 자식 컴포넌트들
}
// 웹 타입에 따른 위젯 렌더링
const renderWidget = (component: ComponentData) => {
2025-09-01 17:05:36 +09:00
const { widgetType, label, placeholder, required, readonly, columnName, style } = component;
2025-09-01 15:22:47 +09:00
// 디버깅: 실제 widgetType 값 확인
2025-09-01 15:22:47 +09:00
console.log("RealtimePreview - widgetType:", widgetType, "columnName:", columnName);
2025-09-01 17:05:36 +09:00
// 사용자가 테두리를 설정했는지 확인
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,
2025-09-01 17:05:36 +09:00
className: `w-full h-full ${borderClass}`,
};
switch (widgetType) {
case "text":
case "email":
2025-09-03 11:32:09 +09:00
case "tel": {
const widget = component as WidgetComponent;
const config = widget.webTypeConfig as TextTypeConfig | undefined;
// 플레이스홀더 처리
const finalPlaceholder = config?.placeholder || placeholder || "텍스트를 입력하세요";
const inputType = widgetType === "email" ? "email" : widgetType === "tel" ? "tel" : "text";
// multiline이면 Textarea로 렌더링
if (config?.multiline) {
return (
<Textarea
{...commonProps}
placeholder={finalPlaceholder}
minLength={config?.minLength}
maxLength={config?.maxLength}
pattern={config?.pattern}
/>
);
}
return (
<Input
type={inputType}
{...commonProps}
placeholder={finalPlaceholder}
minLength={config?.minLength}
maxLength={config?.maxLength}
pattern={config?.pattern}
/>
);
}
case "number":
2025-09-03 11:32:09 +09:00
case "decimal": {
const widget = component as WidgetComponent;
const config = widget.webTypeConfig as NumberTypeConfig | undefined;
// 디버깅: 현재 설정값 확인
console.log("🔢 숫자 위젯 렌더링:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
placeholder: widget.placeholder,
});
// 단계값 결정: webTypeConfig > 기본값 (소수는 0.01, 정수는 1)
const step = config?.step || (widgetType === "decimal" ? 0.01 : 1);
// 플레이스홀더 처리
const finalPlaceholder = config?.placeholder || placeholder || "숫자를 입력하세요";
// 접두사/접미사가 있는 경우 표시용 컨테이너 사용
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}
className={`${config?.prefix ? "rounded-l-none" : ""} ${config?.suffix ? "rounded-r-none" : ""} ${borderClass}`}
/>
{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}
/>
);
}
case "date":
2025-09-03 11:32:09 +09:00
case "datetime": {
const widget = component as WidgetComponent;
const config = widget.webTypeConfig as DateTypeConfig | undefined;
// 웹타입 설정에 따른 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];
}
}
}
// 플레이스홀더 우선순위: webTypeConfig > placeholder > 기본값
const finalPlaceholder = 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={processedDefaultValue}
onChange={() => {}} // 읽기 전용으로 처리
readOnly
/>
);
}
case "select":
2025-09-03 11:32:09 +09:00
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}
2025-09-03 11:32:09 +09:00
multiple={config?.multiple}
2025-09-01 17:05:36 +09:00
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"
}`}
>
2025-09-03 11:32:09 +09:00
<option value="">{finalPlaceholder}</option>
{options.map((option, index) => (
<option key={index} value={option.value} disabled={option.disabled}>
{option.label}
</option>
))}
</select>
);
2025-09-03 11:32:09 +09:00
}
case "textarea":
2025-09-03 11:32:09 +09:00
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}
style={{
resize: config?.resizable === false ? "none" : "vertical",
whiteSpace: config?.wordWrap === false ? "nowrap" : "normal",
}}
/>
);
}
case "boolean":
2025-09-03 11:32:09 +09:00
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">
2025-09-03 11:32:09 +09:00
{isLeftLabel && (
<Label htmlFor={`checkbox-${component.id}`} className="text-sm">
{checkboxText}
</Label>
)}
<input
type="checkbox"
id={`checkbox-${component.id}`}
disabled={readonly}
required={required}
2025-09-03 11:32:09 +09:00
defaultChecked={config?.defaultChecked}
className="h-4 w-4"
/>
2025-09-03 11:32:09 +09:00
{!isLeftLabel && (
<Label htmlFor={`checkbox-${component.id}`} className="text-sm">
{checkboxText}
</Label>
)}
</div>
);
2025-09-03 11:32:09 +09:00
}
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 (
2025-09-03 11:32:09 +09:00
<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}
defaultChecked={config?.defaultValue === option.value}
className="h-4 w-4"
/>
<Label htmlFor={`radio-${component.id}-${index}`} className="text-sm">
{option.label}
</Label>
</div>
))}
</div>
);
2025-09-03 11:32:09 +09:00
}
case "code": {
const widget = component as WidgetComponent;
const config = widget.webTypeConfig as CodeTypeConfig | undefined;
return (
2025-09-01 17:05:36 +09:00
<Textarea
{...commonProps}
rows={4}
className={`w-full font-mono text-sm ${borderClass}`}
2025-09-03 11:32:09 +09:00
placeholder={config?.placeholder || "코드를 입력하세요..."}
readOnly={config?.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",
}}
2025-09-01 17:05:36 +09:00
/>
);
2025-09-03 11:32:09 +09:00
}
case "entity": {
const widget = component as WidgetComponent;
const config = widget.webTypeConfig as EntityTypeConfig | undefined;
return (
<select
disabled={readonly}
required={required}
2025-09-03 11:32:09 +09:00
multiple={config?.multiple}
2025-09-01 17:05:36 +09:00
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"
}`}
>
2025-09-03 11:32:09 +09:00
<option value="">{config?.placeholder || "엔티티를 선택하세요..."}</option>
<option value="user"></option>
<option value="product"></option>
<option value="order"></option>
</select>
);
2025-09-03 11:32:09 +09:00
}
case "file": {
const widget = component as WidgetComponent;
const config = widget.webTypeConfig as FileTypeConfig | undefined;
return (
<input
type="file"
disabled={readonly}
required={required}
2025-09-03 11:32:09 +09:00
multiple={config?.multiple}
accept={config?.accept}
2025-09-01 17:05:36 +09:00
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"
}`}
/>
);
2025-09-03 11:32:09 +09:00
}
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,
2025-09-01 15:22:47 +09:00
children,
onGroupToggle,
}) => {
const { type, label, tableName, columnName, widgetType, size, style } = component;
2025-09-01 17:05:36 +09:00
// 사용자가 테두리를 설정했는지 확인
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
// 기본 선택 테두리는 사용자 테두리가 없을 때만 적용
const defaultRingClass = hasCustomBorder
? ""
: isSelected
? "ring-opacity-50 ring-2 ring-blue-500"
: "hover:ring-opacity-50 hover:ring-1 hover:ring-gray-300";
// 사용자 테두리가 있을 때 선택 상태 표시를 위한 스타일
const selectionStyle =
hasCustomBorder && isSelected
? {
boxShadow: "0 0 0 2px rgba(59, 130, 246, 0.5)", // 외부 그림자로 선택 표시
...style,
}
: style;
2025-09-02 16:46:54 +09:00
// 라벨 스타일 계산
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",
};
return (
<div
2025-09-01 17:05:36 +09:00
className={`absolute cursor-move transition-all ${defaultRingClass}`}
style={{
left: `${component.position.x}px`,
top: `${component.position.y}px`,
2025-09-02 16:46:54 +09:00
width: `${size.width}px`,
height: shouldShowLabel ? `${size.height + 20 + labelMarginBottomValue}px` : `${size.height}px`, // 라벨 공간 + 여백 추가
zIndex: component.position.z || 1,
2025-09-01 17:05:36 +09:00
...selectionStyle,
}}
2025-09-01 16:40:24 +09:00
onClick={(e) => {
e.stopPropagation();
onClick?.(e);
}}
draggable
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onMouseDown={(e) => {
// 드래그 시작을 위한 마우스 다운 이벤트
e.stopPropagation();
}}
>
2025-09-02 16:46:54 +09:00
{/* 라벨 표시 */}
{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>
)}
2025-09-02 16:46:54 +09:00
{/* 컴포넌트 내용 */}
<div
style={{
width: `${size.width}px`,
height: `${size.height}px`,
}}
>
{type === "container" && (
<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>
)}
2025-09-01 15:22:47 +09:00
2025-09-02 16:46:54 +09:00
{type === "group" && (
<div className="relative h-full w-full">
2025-09-03 11:32:09 +09:00
{/* 그룹 내용 */}
2025-09-02 16:46:54 +09:00
<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;