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

1223 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useState } from "react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { CalendarIcon } from "lucide-react";
import { format } from "date-fns";
import { ko } from "date-fns/locale";
import {
ComponentData,
WidgetComponent,
DataTableComponent,
TextTypeConfig,
NumberTypeConfig,
DateTypeConfig,
SelectTypeConfig,
RadioTypeConfig,
CheckboxTypeConfig,
TextareaTypeConfig,
FileTypeConfig,
CodeTypeConfig,
EntityTypeConfig,
ButtonTypeConfig,
} from "@/types/screen";
import { InteractiveDataTable } from "./InteractiveDataTable";
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
import { useParams } from "next/navigation";
import { screenApi } from "@/lib/api/screen";
interface InteractiveScreenViewerProps {
component: ComponentData;
allComponents: ComponentData[];
formData?: Record<string, any>;
onFormDataChange?: (fieldName: string, value: any) => void;
hideLabel?: boolean;
screenInfo?: {
id: number;
tableName?: string;
};
}
export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = ({
component,
allComponents,
formData: externalFormData,
onFormDataChange,
hideLabel = false,
screenInfo,
}) => {
const [localFormData, setLocalFormData] = useState<Record<string, any>>({});
const [dateValues, setDateValues] = useState<Record<string, Date | undefined>>({});
// 팝업 화면 상태
const [popupScreen, setPopupScreen] = useState<{
screenId: number;
title: string;
size: string;
} | null>(null);
// 팝업 화면 레이아웃 상태
const [popupLayout, setPopupLayout] = useState<ComponentData[]>([]);
const [popupLoading, setPopupLoading] = useState(false);
const [popupScreenResolution, setPopupScreenResolution] = useState<{ width: number; height: number } | null>(null);
// 팝업 화면 레이아웃 로드
React.useEffect(() => {
if (popupScreen) {
const loadPopupLayout = async () => {
try {
setPopupLoading(true);
console.log("🔍 팝업 화면 로드 시작:", {
screenId: popupScreen.screenId,
title: popupScreen.title,
size: popupScreen.size
});
const layout = await screenApi.getLayout(popupScreen.screenId);
console.log("📊 팝업 화면 레이아웃 로드 완료:", {
componentsCount: layout.components?.length || 0,
gridSettings: layout.gridSettings,
screenResolution: layout.screenResolution,
components: layout.components?.map(c => ({
id: c.id,
type: c.type,
title: (c as any).title
}))
});
setPopupLayout(layout.components || []);
setPopupScreenResolution(layout.screenResolution || null);
} catch (error) {
console.error("❌ 팝업 화면 레이아웃 로드 실패:", error);
setPopupLayout([]);
} finally {
setPopupLoading(false);
}
};
loadPopupLayout();
}
}, [popupScreen]);
// 실제 사용할 폼 데이터 (외부에서 제공된 경우 우선 사용)
const formData = externalFormData || localFormData;
// 폼 데이터 업데이트
const updateFormData = (fieldName: string, value: any) => {
if (onFormDataChange) {
// 외부 콜백이 있는 경우 사용
onFormDataChange(fieldName, value);
} else {
// 로컬 상태 업데이트
setLocalFormData((prev) => ({
...prev,
[fieldName]: value,
}));
}
};
// 날짜 값 업데이트
const updateDateValue = (fieldName: string, date: Date | undefined) => {
setDateValues((prev) => ({
...prev,
[fieldName]: date,
}));
updateFormData(fieldName, date ? format(date, "yyyy-MM-dd") : "");
};
// 실제 사용 가능한 위젯 렌더링
const renderInteractiveWidget = (comp: ComponentData) => {
// 데이터 테이블 컴포넌트 처리
if (comp.type === "datatable") {
return (
<InteractiveDataTable
component={comp as DataTableComponent}
className="h-full w-full"
style={{
width: "100%",
height: "100%",
}}
/>
);
}
const { widgetType, label, placeholder, required, readonly, columnName } = comp;
const fieldName = columnName || comp.id;
const currentValue = formData[fieldName] || "";
// 스타일 적용
const applyStyles = (element: React.ReactElement) => {
if (!comp.style) return element;
return React.cloneElement(element, {
style: {
...element.props.style, // 기존 스타일 유지
...comp.style,
// 크기는 부모 컨테이너에서 처리하므로 제거 (하지만 다른 스타일은 유지)
width: "100%",
height: "100%",
minHeight: "100%",
maxHeight: "100%",
boxSizing: "border-box",
},
});
};
switch (widgetType) {
case "text":
case "email":
case "tel": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as TextTypeConfig | undefined;
console.log("📝 InteractiveScreenViewer - Text 위젯:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
appliedSettings: {
format: config?.format,
minLength: config?.minLength,
maxLength: config?.maxLength,
pattern: config?.pattern,
placeholder: config?.placeholder,
},
});
// 형식별 패턴 생성
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>) => {
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)) {
return; // 유효하지 않은 입력 차단
}
}
}
// 길이 제한 검증
if (config?.maxLength && value.length > config.maxLength) {
return; // 최대 길이 초과 차단
}
updateFormData(fieldName, value);
};
const finalPlaceholder = config?.placeholder || placeholder || "입력하세요...";
const inputType = widgetType === "email" ? "email" : widgetType === "tel" ? "tel" : "text";
return applyStyles(
<Input
type={inputType}
placeholder={finalPlaceholder}
value={currentValue}
onChange={handleInputChange}
disabled={readonly}
required={required}
minLength={config?.minLength}
maxLength={config?.maxLength}
pattern={getPatternByFormat(config?.format || "none")}
className="w-full"
style={{
height: "100%",
minHeight: "100%",
maxHeight: "100%"
}}
/>,
);
}
case "number":
case "decimal": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as NumberTypeConfig | undefined;
console.log("🔢 InteractiveScreenViewer - Number 위젯:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
appliedSettings: {
format: config?.format,
min: config?.min,
max: config?.max,
step: config?.step,
decimalPlaces: config?.decimalPlaces,
thousandSeparator: config?.thousandSeparator,
prefix: config?.prefix,
suffix: config?.suffix,
},
});
const step = config?.step || (widgetType === "decimal" ? 0.01 : 1);
const finalPlaceholder = config?.placeholder || placeholder || "숫자를 입력하세요...";
return applyStyles(
<Input
type="number"
placeholder={finalPlaceholder}
value={currentValue}
onChange={(e) => updateFormData(fieldName, e.target.valueAsNumber || 0)}
disabled={readonly}
required={required}
min={config?.min}
max={config?.max}
step={step}
className="w-full"
style={{ height: "100%" }}
/>,
);
}
case "textarea":
case "text_area": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as TextareaTypeConfig | undefined;
console.log("📄 InteractiveScreenViewer - Textarea 위젯:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
appliedSettings: {
rows: config?.rows,
maxLength: config?.maxLength,
minLength: config?.minLength,
placeholder: config?.placeholder,
defaultValue: config?.defaultValue,
resizable: config?.resizable,
wordWrap: config?.wordWrap,
},
});
const finalPlaceholder = config?.placeholder || placeholder || "내용을 입력하세요...";
const rows = config?.rows || 3;
return applyStyles(
<Textarea
placeholder={finalPlaceholder}
value={currentValue || config?.defaultValue || ""}
onChange={(e) => updateFormData(fieldName, e.target.value)}
disabled={readonly}
required={required}
minLength={config?.minLength}
maxLength={config?.maxLength}
rows={rows}
className={`h-full w-full ${config?.resizable === false ? "resize-none" : ""}`}
style={{
whiteSpace: config?.wordWrap === false ? "nowrap" : "normal",
}}
/>,
);
}
case "select":
case "dropdown": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as SelectTypeConfig | undefined;
console.log("📋 InteractiveScreenViewer - Select 위젯:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
appliedSettings: {
options: config?.options,
multiple: config?.multiple,
searchable: config?.searchable,
placeholder: config?.placeholder,
defaultValue: config?.defaultValue,
},
});
const finalPlaceholder = config?.placeholder || placeholder || "선택하세요...";
const options = config?.options || [
{ label: "옵션 1", value: "option1" },
{ label: "옵션 2", value: "option2" },
{ label: "옵션 3", value: "option3" },
];
return applyStyles(
<Select
value={currentValue || config?.defaultValue || ""}
onValueChange={(value) => updateFormData(fieldName, value)}
disabled={readonly}
required={required}
>
<SelectTrigger className="h-full w-full">
<SelectValue placeholder={finalPlaceholder} />
</SelectTrigger>
<SelectContent>
{options.map((option, index) => (
<SelectItem key={index} value={option.value} disabled={option.disabled}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>,
);
}
case "checkbox":
case "boolean": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as CheckboxTypeConfig | undefined;
console.log("☑️ InteractiveScreenViewer - Checkbox 위젯:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
appliedSettings: {
defaultChecked: config?.defaultChecked,
labelPosition: config?.labelPosition,
checkboxText: config?.checkboxText,
trueValue: config?.trueValue,
falseValue: config?.falseValue,
},
});
const isChecked = currentValue === true || currentValue === "true" || config?.defaultChecked;
const checkboxText = config?.checkboxText || label || "확인";
const labelPosition = config?.labelPosition || "right";
return applyStyles(
<div
className={`flex h-full w-full items-center space-x-2 ${labelPosition === "left" ? "flex-row-reverse" : ""}`}
>
<Checkbox
id={fieldName}
checked={isChecked}
onCheckedChange={(checked) => updateFormData(fieldName, checked)}
disabled={readonly}
required={required}
/>
<label htmlFor={fieldName} className="text-sm">
{checkboxText}
</label>
</div>,
);
}
case "radio": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as RadioTypeConfig | undefined;
console.log("🔘 InteractiveScreenViewer - Radio 위젯:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
appliedSettings: {
options: config?.options,
defaultValue: config?.defaultValue,
layout: config?.layout,
allowNone: config?.allowNone,
},
});
const options = config?.options || [
{ label: "옵션 1", value: "option1" },
{ label: "옵션 2", value: "option2" },
{ label: "옵션 3", value: "option3" },
];
const layout = config?.layout || "vertical";
const selectedValue = currentValue || config?.defaultValue || "";
return applyStyles(
<div className={`h-full w-full ${layout === "horizontal" ? "flex flex-wrap gap-4" : "space-y-2"}`}>
{config?.allowNone && (
<div className="flex items-center space-x-2">
<input
type="radio"
id={`${fieldName}_none`}
name={fieldName}
value=""
checked={selectedValue === ""}
onChange={(e) => updateFormData(fieldName, e.target.value)}
disabled={readonly}
required={required}
className="h-4 w-4"
/>
<label htmlFor={`${fieldName}_none`} className="text-sm">
</label>
</div>
)}
{options.map((option, index) => (
<div key={index} className="flex items-center space-x-2">
<input
type="radio"
id={`${fieldName}_${index}`}
name={fieldName}
value={option.value}
checked={selectedValue === option.value}
onChange={(e) => updateFormData(fieldName, e.target.value)}
disabled={readonly || option.disabled}
required={required}
className="h-4 w-4"
/>
<label htmlFor={`${fieldName}_${index}`} className="text-sm">
{option.label}
</label>
</div>
))}
</div>,
);
}
case "date": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as DateTypeConfig | undefined;
console.log("📅 InteractiveScreenViewer - Date 위젯:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
appliedSettings: {
format: config?.format,
showTime: config?.showTime,
defaultValue: config?.defaultValue,
minDate: config?.minDate,
maxDate: config?.maxDate,
},
});
const shouldShowTime = config?.showTime || config?.format?.includes("HH:mm");
const finalPlaceholder = config?.placeholder || placeholder || "날짜를 선택하세요";
if (shouldShowTime) {
// 시간 포함 날짜 입력
return applyStyles(
<Input
type="datetime-local"
placeholder={finalPlaceholder}
value={currentValue || config?.defaultValue || ""}
onChange={(e) => updateFormData(fieldName, e.target.value)}
disabled={readonly}
required={required}
min={config?.minDate}
max={config?.maxDate}
className="w-full"
style={{ height: "100%" }}
/>,
);
} else {
// 날짜만 입력
const dateValue = dateValues[fieldName];
return applyStyles(
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="h-full w-full justify-start text-left font-normal"
disabled={readonly}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{dateValue ? format(dateValue, "PPP", { locale: ko }) : config?.defaultValue || finalPlaceholder}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={dateValue}
onSelect={(date) => updateDateValue(fieldName, date)}
initialFocus
/>
</PopoverContent>
</Popover>,
);
}
}
case "datetime": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as DateTypeConfig | undefined;
console.log("🕐 InteractiveScreenViewer - DateTime 위젯:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
appliedSettings: {
format: config?.format,
defaultValue: config?.defaultValue,
minDate: config?.minDate,
maxDate: config?.maxDate,
},
});
const finalPlaceholder = config?.placeholder || placeholder || "날짜와 시간을 입력하세요...";
return applyStyles(
<Input
type="datetime-local"
placeholder={finalPlaceholder}
value={currentValue || config?.defaultValue || ""}
onChange={(e) => updateFormData(fieldName, e.target.value)}
disabled={readonly}
required={required}
min={config?.minDate}
max={config?.maxDate}
className="w-full"
style={{ height: "100%" }}
/>,
);
}
case "file": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as FileTypeConfig | undefined;
console.log("📁 InteractiveScreenViewer - File 위젯:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
appliedSettings: {
accept: config?.accept,
multiple: config?.multiple,
maxSize: config?.maxSize,
},
});
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
// 파일 크기 검증
if (config?.maxSize) {
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.size > config.maxSize * 1024 * 1024) {
alert(`파일 크기가 ${config.maxSize}MB를 초과합니다: ${file.name}`);
e.target.value = "";
return;
}
}
}
const file = config?.multiple ? files : files[0];
updateFormData(fieldName, file);
};
return applyStyles(
<Input
type="file"
onChange={handleFileChange}
disabled={readonly}
required={required}
multiple={config?.multiple}
accept={config?.accept}
className="w-full"
style={{ height: "100%" }}
/>,
);
}
case "code": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as CodeTypeConfig | undefined;
console.log("💻 InteractiveScreenViewer - Code 위젯:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
appliedSettings: {
language: config?.language,
theme: config?.theme,
fontSize: config?.fontSize,
defaultValue: config?.defaultValue,
wordWrap: config?.wordWrap,
tabSize: config?.tabSize,
},
});
const finalPlaceholder = config?.placeholder || "코드를 입력하세요...";
const rows = config?.rows || 4;
return applyStyles(
<Textarea
placeholder={finalPlaceholder}
value={currentValue || config?.defaultValue || ""}
onChange={(e) => updateFormData(fieldName, e.target.value)}
disabled={readonly}
required={required}
rows={rows}
className="h-full w-full resize-none font-mono text-sm"
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 = comp as WidgetComponent;
const config = widget.webTypeConfig as EntityTypeConfig | undefined;
console.log("🏢 InteractiveScreenViewer - Entity 위젯:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
appliedSettings: {
entityName: config?.entityName,
displayField: config?.displayField,
valueField: config?.valueField,
multiple: config?.multiple,
defaultValue: config?.defaultValue,
},
});
const finalPlaceholder = config?.placeholder || "엔티티를 선택하세요...";
const defaultOptions = [
{ label: "사용자", value: "user" },
{ label: "제품", value: "product" },
{ label: "주문", value: "order" },
{ label: "카테고리", value: "category" },
];
return (
<Select
value={currentValue || config?.defaultValue || ""}
onValueChange={(value) => updateFormData(fieldName, value)}
disabled={readonly}
required={required}
>
<SelectTrigger
className="w-full"
style={{ height: "100%" }}
style={{
...comp.style,
width: "100%",
height: "100%",
}}
>
<SelectValue placeholder={finalPlaceholder} />
</SelectTrigger>
<SelectContent>
{defaultOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{config?.displayFormat
? config.displayFormat.replace("{label}", option.label).replace("{value}", option.value)
: option.label}
</SelectItem>
))}
</SelectContent>
</Select>,
);
}
case "button": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as ButtonTypeConfig | undefined;
const handleButtonClick = async () => {
const actionType = config?.actionType || "save";
try {
switch (actionType) {
case "save":
await handleSaveAction();
break;
case "cancel":
handleCancelAction();
break;
case "delete":
await handleDeleteAction();
break;
case "edit":
handleEditAction();
break;
case "add":
handleAddAction();
break;
case "search":
handleSearchAction();
break;
case "reset":
handleResetAction();
break;
case "submit":
await handleSubmitAction();
break;
case "close":
handleCloseAction();
break;
case "popup":
handlePopupAction();
break;
case "navigate":
handleNavigateAction();
break;
case "custom":
await handleCustomAction();
break;
default:
console.log(`알 수 없는 액션 타입: ${actionType}`);
}
} catch (error) {
console.error(`버튼 액션 실행 오류 (${actionType}):`, error);
alert(`작업 중 오류가 발생했습니다: ${error.message}`);
}
};
// 저장 액션
const handleSaveAction = async () => {
if (!formData || Object.keys(formData).length === 0) {
alert("저장할 데이터가 없습니다.");
return;
}
// 필수 항목 검증
const requiredFields = allComponents.filter(c => c.required && (c.columnName || c.id));
const missingFields = requiredFields.filter(field => {
const fieldName = field.columnName || field.id;
const value = formData[fieldName];
return !value || value.toString().trim() === "";
});
if (missingFields.length > 0) {
const fieldNames = missingFields.map(f => f.label || f.columnName || f.id).join(", ");
alert(`다음 필수 항목을 입력해주세요: ${fieldNames}`);
return;
}
if (!screenInfo?.id) {
alert("화면 정보가 없어 저장할 수 없습니다.");
return;
}
try {
// 컬럼명 기반으로 데이터 매핑
const mappedData: Record<string, any> = {};
// 컴포넌트에서 컬럼명이 있는 것들만 매핑
allComponents.forEach(comp => {
if (comp.columnName) {
const fieldName = comp.columnName;
const componentId = comp.id;
// formData에서 해당 값 찾기 (컬럼명 우선, 없으면 컴포넌트 ID)
const value = formData[fieldName] || formData[componentId];
if (value !== undefined && value !== "") {
mappedData[fieldName] = value;
}
}
});
console.log("💾 저장할 데이터 매핑:", {
원본데이터: formData,
매핑된데이터: mappedData,
화면정보: screenInfo,
});
// 테이블명 결정 (화면 정보에서 가져오거나 첫 번째 컴포넌트의 테이블명 사용)
const tableName = screenInfo.tableName ||
allComponents.find(c => c.columnName)?.tableName ||
"dynamic_form_data"; // 기본값
const saveData: DynamicFormData = {
screenId: screenInfo.id,
tableName: tableName,
data: mappedData,
};
console.log("🚀 API 저장 요청:", saveData);
const result = await dynamicFormApi.saveFormData(saveData);
if (result.success) {
alert("저장되었습니다.");
console.log("✅ 저장 성공:", result.data);
// 저장 후 데이터 초기화 (선택사항)
if (onFormDataChange) {
Object.keys(formData).forEach(key => {
onFormDataChange(key, "");
});
}
} else {
throw new Error(result.message || "저장에 실패했습니다.");
}
} catch (error: any) {
console.error("❌ 저장 실패:", error);
alert(`저장 중 오류가 발생했습니다: ${error.message}`);
}
};
// 취소 액션
const handleCancelAction = () => {
if (confirm("변경사항을 취소하시겠습니까?")) {
// 폼 초기화 또는 이전 페이지로 이동
if (onFormDataChange) {
// 모든 폼 데이터 초기화
Object.keys(formData).forEach(key => {
onFormDataChange(key, "");
});
}
console.log("❌ 작업이 취소되었습니다.");
}
};
// 삭제 액션
const handleDeleteAction = async () => {
const confirmMessage = config?.confirmMessage || "정말로 삭제하시겠습니까?";
if (!confirm(confirmMessage)) {
return;
}
// 삭제할 레코드 ID가 필요 (폼 데이터에서 id 필드 찾기)
const recordId = formData["id"] || formData["ID"] || formData["objid"];
if (!recordId) {
alert("삭제할 데이터를 찾을 수 없습니다. (ID가 없음)");
return;
}
// 테이블명 결정
const tableName = screenInfo?.tableName ||
allComponents.find(c => c.columnName)?.tableName ||
"unknown_table";
if (!tableName || tableName === "unknown_table") {
alert("테이블 정보가 없어 삭제할 수 없습니다.");
return;
}
try {
console.log("🗑️ 삭제 실행:", { recordId, tableName, formData });
const result = await dynamicFormApi.deleteFormDataFromTable(recordId, tableName);
if (result.success) {
alert("삭제되었습니다.");
console.log("✅ 삭제 성공");
// 삭제 후 폼 초기화
if (onFormDataChange) {
Object.keys(formData).forEach(key => {
onFormDataChange(key, "");
});
}
} else {
throw new Error(result.message || "삭제에 실패했습니다.");
}
} catch (error: any) {
console.error("❌ 삭제 실패:", error);
alert(`삭제 중 오류가 발생했습니다: ${error.message}`);
}
};
// 편집 액션
const handleEditAction = () => {
console.log("✏️ 편집 모드 활성화");
// 읽기 전용 모드를 편집 모드로 전환
alert("편집 모드로 전환되었습니다.");
};
// 추가 액션
const handleAddAction = () => {
console.log(" 새 항목 추가");
// 새 항목 추가 로직
alert("새 항목을 추가할 수 있습니다.");
};
// 검색 액션
const handleSearchAction = () => {
console.log("🔍 검색 실행:", formData);
// 검색 로직
const searchTerms = Object.values(formData).filter(v => v && v.toString().trim());
if (searchTerms.length === 0) {
alert("검색할 내용을 입력해주세요.");
} else {
alert(`검색 실행: ${searchTerms.join(", ")}`);
}
};
// 초기화 액션
const handleResetAction = () => {
if (confirm("모든 입력을 초기화하시겠습니까?")) {
if (onFormDataChange) {
Object.keys(formData).forEach(key => {
onFormDataChange(key, "");
});
}
console.log("🔄 폼 초기화 완료");
alert("입력이 초기화되었습니다.");
}
};
// 제출 액션
const handleSubmitAction = async () => {
console.log("📤 폼 제출:", formData);
// 제출 로직
alert("제출되었습니다.");
};
// 닫기 액션
const handleCloseAction = () => {
console.log("❌ 창 닫기");
// 창 닫기 또는 모달 닫기
if (window.opener) {
window.close();
} else {
history.back();
}
};
// 팝업 액션
const handlePopupAction = () => {
if (config?.popupScreenId) {
// 화면 팝업 열기
setPopupScreen({
screenId: config.popupScreenId,
title: config.popupTitle || "상세 정보",
size: config.popupSize || "md",
});
} else if (config?.popupTitle && config?.popupContent) {
// 텍스트 팝업 표시
alert(`${config.popupTitle}\n\n${config.popupContent}`);
} else {
alert("팝업을 표시합니다.");
}
};
// 네비게이션 액션
const handleNavigateAction = () => {
if (config?.navigateUrl) {
if (config.navigateTarget === "_blank") {
window.open(config.navigateUrl, "_blank");
} else {
window.location.href = config.navigateUrl;
}
} else {
console.log("🔗 네비게이션 URL이 설정되지 않았습니다.");
}
};
// 커스텀 액션
const handleCustomAction = async () => {
if (config?.customAction) {
try {
// 보안상 제한적인 eval 사용
const result = eval(config.customAction);
if (result instanceof Promise) {
await result;
}
console.log("⚡ 커스텀 액션 실행 완료");
} catch (error) {
throw new Error(`커스텀 액션 실행 실패: ${error.message}`);
}
} else {
console.log("⚡ 커스텀 액션이 설정되지 않았습니다.");
}
};
return (
<Button
onClick={handleButtonClick}
disabled={readonly}
size={config?.size || "sm"}
variant={config?.variant || "default"}
className="w-full"
style={{ height: "100%" }}
style={{
// 컴포넌트 스타일과 설정 스타일 모두 적용
...comp.style,
// 크기는 className으로 처리하므로 CSS 크기 속성 제거
width: "100%",
height: "100%",
// 설정값이 있으면 우선 적용, 없으면 컴포넌트 스타일 사용
backgroundColor: config?.backgroundColor || comp.style?.backgroundColor,
color: config?.textColor || comp.style?.color,
borderColor: config?.borderColor || comp.style?.borderColor,
}}
>
{label || "버튼"}
</Button>
);
}
default:
return applyStyles(
<Input
type="text"
placeholder={placeholder || "입력하세요..."}
value={currentValue}
onChange={(e) => updateFormData(fieldName, e.target.value)}
disabled={readonly}
required={required}
className="w-full"
style={{ height: "100%" }}
/>,
);
}
};
// 그룹 컴포넌트 처리
if (component.type === "group") {
const children = allComponents.filter((comp) => comp.parentId === component.id);
return (
<div className="relative h-full w-full">
{/* 그룹 내의 자식 컴포넌트들 렌더링 */}
{children.map((child) => (
<div
key={child.id}
style={{
position: "absolute",
left: `${child.position.x - component.position.x}px`,
top: `${child.position.y - component.position.y}px`,
width: `${child.size.width}px`,
height: `${child.size.height}px`,
zIndex: child.position.z || 1,
}}
>
<InteractiveScreenViewer
component={child}
allComponents={allComponents}
formData={externalFormData}
onFormDataChange={onFormDataChange}
/>
</div>
))}
</div>
);
}
// 일반 위젯 컴포넌트
// 템플릿 컴포넌트 목록 (자체적으로 제목을 가지므로 라벨 불필요)
const templateTypes = ["datatable"];
// 라벨 표시 여부 계산
const shouldShowLabel =
!hideLabel && // hideLabel이 true면 라벨 숨김
component.style?.labelDisplay !== false &&
(component.label || component.style?.labelText) &&
!templateTypes.includes(component.type); // 템플릿 컴포넌트는 라벨 표시 안함
const labelText = component.style?.labelText || component.label || "";
// 라벨 스타일 적용
const labelStyle = {
fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151",
fontWeight: component.style?.labelFontWeight || "500",
backgroundColor: component.style?.labelBackgroundColor || "transparent",
padding: component.style?.labelPadding || "0",
borderRadius: component.style?.labelBorderRadius || "0",
marginBottom: component.style?.labelMarginBottom || "4px",
};
// 팝업 크기 설정
const getPopupMaxWidth = (size: string) => {
switch (size) {
case "sm": return "max-w-md";
case "md": return "max-w-2xl";
case "lg": return "max-w-4xl";
case "xl": return "max-w-6xl";
default: return "max-w-2xl";
}
};
return (
<>
<div className="h-full w-full">
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
{shouldShowLabel && (
<div className="block" style={labelStyle}>
{labelText}
{component.required && <span style={{ color: "#f97316", marginLeft: "2px" }}>*</span>}
</div>
)}
{/* 실제 위젯 */}
<div className="h-full w-full">{renderInteractiveWidget(component)}</div>
</div>
{/* 팝업 화면 모달 */}
<Dialog open={!!popupScreen} onOpenChange={() => setPopupScreen(null)}>
<DialogContent className={`${getPopupMaxWidth(popupScreen?.size || "md")} max-h-[80vh] overflow-hidden`}>
<DialogHeader>
<DialogTitle>{popupScreen?.title || "상세 정보"}</DialogTitle>
</DialogHeader>
<div className="overflow-y-auto max-h-[60vh] p-2">
{popupLoading ? (
<div className="flex items-center justify-center py-8">
<div className="text-gray-500"> ...</div>
</div>
) : popupLayout.length > 0 ? (
<div className="relative bg-white border rounded" style={{
width: popupScreenResolution ? `${popupScreenResolution.width}px` : "100%",
height: popupScreenResolution ? `${popupScreenResolution.height}px` : "400px",
minHeight: "400px",
position: "relative",
overflow: "hidden"
}}>
{/* 팝업에서도 실제 위치와 크기로 렌더링 */}
{popupLayout.map((popupComponent) => (
<div
key={popupComponent.id}
className="absolute"
style={{
left: `${popupComponent.position.x}px`,
top: `${popupComponent.position.y}px`,
width: `${popupComponent.size.width}px`,
height: `${popupComponent.size.height}px`,
zIndex: popupComponent.position.z || 1,
}}
>
<InteractiveScreenViewer
component={popupComponent}
allComponents={popupLayout}
hideLabel={false}
/>
</div>
))}
</div>
) : (
<div className="flex items-center justify-center py-8">
<div className="text-gray-500"> .</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
</>
);
};