1353 lines
50 KiB
TypeScript
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;
|