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

2262 lines
86 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, useCallback, useEffect, useMemo } 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, File, Upload, X, Loader2 } from "lucide-react";
import { format } from "date-fns";
import { ko } from "date-fns/locale";
import { useAuth } from "@/hooks/useAuth";
import { uploadFilesAndCreateData } from "@/lib/api/file";
import { toast } from "sonner";
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
import { CascadingDropdownConfig } from "@/types/screen-management";
import {
ComponentData,
WidgetComponent,
DataTableComponent,
FileComponent,
TextTypeConfig,
NumberTypeConfig,
DateTypeConfig,
SelectTypeConfig,
RadioTypeConfig,
CheckboxTypeConfig,
TextareaTypeConfig,
FileTypeConfig,
CodeTypeConfig,
EntityTypeConfig,
ButtonTypeConfig,
} from "@/types";
import { InteractiveDataTable } from "./InteractiveDataTable";
import { FileUpload } from "./widgets/FileUpload";
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
import { useParams } from "next/navigation";
import { screenApi, tableTypeApi } from "@/lib/api/screen";
import { DynamicWebTypeRenderer } from "@/lib/registry/DynamicWebTypeRenderer";
import { enhancedFormService } from "@/lib/services/enhancedFormService";
import { FormValidationIndicator } from "@/components/common/FormValidationIndicator";
import { useFormValidation } from "@/hooks/useFormValidation";
import { UnifiedColumnInfo as ColumnInfo } from "@/types";
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
import { buildGridClasses } from "@/lib/constants/columnSpans";
import { cn } from "@/lib/utils";
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { useMultiLang } from "@/hooks/useMultiLang";
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar";
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
/**
* 🔗 연쇄 드롭다운 래퍼 컴포넌트
* InteractiveScreenViewer 내에서 사용
*/
interface CascadingDropdownWrapperProps {
/** 직접 설정 방식 */
config?: CascadingDropdownConfig;
/** 공통 관리 방식 (관계 코드) */
relationCode?: string;
/** 부모 필드명 (relationCode 사용 시 필요) */
parentFieldName?: string;
parentValue?: string | number | null;
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
disabled?: boolean;
required?: boolean;
}
const CascadingDropdownWrapper: React.FC<CascadingDropdownWrapperProps> = ({
config,
relationCode,
parentFieldName,
parentValue,
value,
onChange,
placeholder,
disabled,
required,
}) => {
const { options, loading, error, relationConfig } = useCascadingDropdown({
config,
relationCode,
parentValue,
});
// 실제 사용할 설정 (직접 설정 또는 API에서 가져온 설정)
const effectiveConfig = config || relationConfig;
// 부모 값이 없을 때 메시지
const getPlaceholder = () => {
if (!parentValue) {
return effectiveConfig?.emptyParentMessage || "상위 항목을 먼저 선택하세요";
}
if (loading) {
return effectiveConfig?.loadingMessage || "로딩 중...";
}
if (options.length === 0) {
return effectiveConfig?.noOptionsMessage || "선택 가능한 항목이 없습니다";
}
return placeholder || "선택하세요";
};
const isDisabled = disabled || !parentValue || loading;
return (
<Select
value={value || ""}
onValueChange={(newValue) => onChange?.(newValue)}
disabled={isDisabled}
>
<SelectTrigger className="h-full w-full">
{loading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-muted-foreground text-sm"> ...</span>
</div>
) : (
<SelectValue placeholder={getPlaceholder()} />
)}
</SelectTrigger>
<SelectContent>
{options.length === 0 ? (
<div className="text-muted-foreground px-2 py-4 text-center text-sm">
{!parentValue
? config.emptyParentMessage || "상위 항목을 먼저 선택하세요"
: config.noOptionsMessage || "선택 가능한 항목이 없습니다"}
</div>
) : (
options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
);
};
interface InteractiveScreenViewerProps {
component: ComponentData;
allComponents: ComponentData[];
formData?: Record<string, any>;
onFormDataChange?: (fieldName: string, value: any) => void;
hideLabel?: boolean;
screenInfo?: {
id: number;
tableName?: string;
};
menuObjid?: number; // 🆕 메뉴 OBJID (코드 스코프용)
// 새로운 검증 관련 옵션들
enableEnhancedValidation?: boolean;
tableColumns?: ColumnInfo[];
showValidationPanel?: boolean;
validationOptions?: {
enableRealTimeValidation?: boolean;
validationDelay?: number;
enableAutoSave?: boolean;
showToastMessages?: boolean;
};
}
export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = ({
component,
allComponents,
formData: externalFormData,
onFormDataChange,
hideLabel = false,
screenInfo,
menuObjid, // 🆕 메뉴 OBJID
enableEnhancedValidation = false,
tableColumns = [],
showValidationPanel = false,
validationOptions = {},
}) => {
// component가 없으면 빈 div 반환
if (!component) {
console.warn("⚠️ InteractiveScreenViewer: component가 undefined입니다.");
return <div className="h-full w-full" />;
}
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
const { userName, user } = useAuth(); // 현재 로그인한 사용자명과 사용자 정보 가져오기
const { userLang } = useMultiLang(); // 다국어 훅
const [localFormData, setLocalFormData] = useState<Record<string, any>>({});
const [dateValues, setDateValues] = useState<Record<string, Date | undefined>>({});
// 다국어 번역 상태 (langKeyId가 있는 컴포넌트들의 번역 텍스트)
const [translations, setTranslations] = useState<Record<string, string>>({});
// 다국어 키 수집 및 번역 로드
useEffect(() => {
const loadTranslations = async () => {
// 모든 컴포넌트에서 langKey 수집
const langKeysToFetch: string[] = [];
const collectLangKeys = (comps: ComponentData[]) => {
comps.forEach((comp) => {
// 컴포넌트 라벨의 langKey
if ((comp as any).langKey) {
langKeysToFetch.push((comp as any).langKey);
}
// componentConfig 내의 langKey (버튼 텍스트 등)
if ((comp as any).componentConfig?.langKey) {
langKeysToFetch.push((comp as any).componentConfig.langKey);
}
// 자식 컴포넌트 재귀 처리
if ((comp as any).children) {
collectLangKeys((comp as any).children);
}
});
};
collectLangKeys(allComponents);
// langKey가 있으면 배치 조회
if (langKeysToFetch.length > 0 && userLang) {
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.post("/multilang/batch", {
langKeys: [...new Set(langKeysToFetch)], // 중복 제거
}, {
params: {
userLang,
companyCode: user?.companyCode || "*",
},
});
if (response.data?.success && response.data?.data) {
setTranslations(response.data.data);
}
} catch (error) {
console.error("다국어 번역 로드 실패:", error);
}
}
};
loadTranslations();
}, [allComponents, userLang, user?.companyCode]);
// 팝업 화면 상태
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);
const [popupScreenInfo, setPopupScreenInfo] = useState<{ id: number; tableName?: string } | null>(null);
// 팝업 전용 formData 상태
const [popupFormData, setPopupFormData] = useState<Record<string, any>>({});
// 통합된 폼 데이터
const finalFormData = { ...localFormData, ...externalFormData };
// 개선된 검증 시스템 (선택적 활성화)
const enhancedValidation = enableEnhancedValidation && screenInfo && tableColumns.length > 0
? useFormValidation(
finalFormData,
allComponents.filter(c => c.type === 'widget') as WidgetComponent[],
tableColumns,
{
id: screenInfo.id,
screenName: screenInfo.tableName || "unknown",
tableName: screenInfo.tableName,
screenResolution: { width: 800, height: 600 },
gridSettings: { size: 20, color: "#e0e0e0", opacity: 0.5 },
description: "동적 화면"
},
{
enableRealTimeValidation: true,
validationDelay: 300,
enableAutoSave: false,
showToastMessages: true,
...validationOptions,
}
)
: null;
// 자동값 생성 함수
const generateAutoValue = useCallback(async (autoValueType: string, ruleId?: string): Promise<string> => {
const now = new Date();
switch (autoValueType) {
case "current_datetime":
return now.toISOString().slice(0, 19).replace("T", " "); // YYYY-MM-DD HH:mm:ss
case "current_date":
return now.toISOString().slice(0, 10); // YYYY-MM-DD
case "current_time":
return now.toTimeString().slice(0, 8); // HH:mm:ss
case "current_user":
// 실제 접속중인 사용자명 사용
return userName || "사용자"; // 사용자명이 없으면 기본값
case "uuid":
return crypto.randomUUID();
case "sequence":
return `SEQ_${Date.now()}`;
case "numbering_rule":
// 채번 규칙 사용
if (ruleId) {
try {
const { generateNumberingCode } = await import("@/lib/api/numberingRule");
const response = await generateNumberingCode(ruleId);
if (response.success && response.data) {
return response.data.generatedCode;
}
} catch (error) {
console.error("채번 규칙 코드 생성 실패:", error);
}
}
return "";
default:
return "";
}
}, [userName]); // userName 의존성 추가
// 팝업 화면 레이아웃 로드
React.useEffect(() => {
if (popupScreen) {
const loadPopupLayout = async () => {
try {
setPopupLoading(true);
// console.log("🔍 팝업 화면 로드 시작:", popupScreen);
// 화면 레이아웃과 화면 정보를 병렬로 가져오기
const [layout, screen] = await Promise.all([
screenApi.getLayout(popupScreen.screenId),
screenApi.getScreen(popupScreen.screenId)
]);
console.log("📊 팝업 화면 로드 완료:", {
componentsCount: layout.components?.length || 0,
screenInfo: {
screenId: screen.screenId,
tableName: screen.tableName
},
popupFormData: {}
});
setPopupLayout(layout.components || []);
setPopupScreenResolution(layout.screenResolution || null);
setPopupScreenInfo({
id: popupScreen.screenId,
tableName: screen.tableName
});
// 팝업 formData 초기화
setPopupFormData({});
} catch (error) {
// console.error("❌ 팝업 화면 로드 실패:", error);
setPopupLayout([]);
setPopupScreenInfo(null);
} finally {
setPopupLoading(false);
}
};
loadPopupLayout();
}
}, [popupScreen]);
// 실제 사용할 폼 데이터 (외부와 로컬 데이터 병합)
const formData = { ...localFormData, ...externalFormData };
console.log("🔄 formData 구성:", {
external: externalFormData,
local: localFormData,
merged: formData,
hasExternalCallback: !!onFormDataChange
});
// 폼 데이터 업데이트
const updateFormData = (fieldName: string, value: any) => {
// 프리뷰 모드에서는 데이터 업데이트 하지 않음
if (isPreviewMode) {
return;
}
// console.log(`🔄 updateFormData: ${fieldName} = "${value}" (외부콜백: ${!!onFormDataChange})`);
// 항상 로컬 상태도 업데이트
setLocalFormData((prev) => ({
...prev,
[fieldName]: value,
}));
// console.log(`💾 로컬 상태 업데이트: ${fieldName} = "${value}"`);
// 외부 콜백이 있는 경우에도 전달 (개별 필드 단위로)
if (onFormDataChange) {
onFormDataChange(fieldName, value);
// console.log(`📤 외부 콜백으로 전달: ${fieldName} = "${value}"`);
}
};
// 자동입력 필드들의 값을 formData에 초기 설정
React.useEffect(() => {
// console.log("🚀 자동입력 초기화 useEffect 실행 - allComponents 개수:", allComponents.length);
const initAutoInputFields = async () => {
// console.log("🔧 initAutoInputFields 실행 시작");
for (const comp of allComponents) {
// 🆕 type: "component" 또는 type: "widget" 모두 처리
if (comp.type === 'widget' || comp.type === 'component') {
const widget = comp as WidgetComponent;
const fieldName = widget.columnName || widget.id;
// 🆕 autoFill 처리 (테이블 조회 기반 자동 입력)
if (widget.autoFill?.enabled || (comp as any).autoFill?.enabled) {
const autoFillConfig = widget.autoFill || (comp as any).autoFill;
const currentValue = formData[fieldName];
if (currentValue === undefined || currentValue === '') {
const { sourceTable, filterColumn, userField, displayColumn } = autoFillConfig;
// 사용자 정보에서 필터 값 가져오기
const userValue = user?.[userField];
if (userValue && sourceTable && filterColumn && displayColumn) {
try {
const result = await tableTypeApi.getTableRecord(
sourceTable,
filterColumn,
userValue,
displayColumn
);
updateFormData(fieldName, result.value);
} catch (error) {
console.error(`autoFill 조회 실패: ${fieldName}`, error);
}
}
}
continue; // autoFill이 활성화되면 일반 자동입력은 건너뜀
}
// 기존 widget 타입 전용 로직은 widget인 경우만
if (comp.type !== 'widget') continue;
// 텍스트 타입 위젯의 자동입력 처리 (기존 로직)
if ((widget.widgetType === 'text' || widget.widgetType === 'email' || widget.widgetType === 'tel') &&
widget.webTypeConfig) {
const config = widget.webTypeConfig as TextTypeConfig;
const isAutoInput = config?.autoInput || false;
if (isAutoInput && config?.autoValueType) {
// 이미 값이 있으면 덮어쓰지 않음
const currentValue = formData[fieldName];
console.log(`🔍 자동입력 필드 체크: ${fieldName}`, {
currentValue,
isEmpty: currentValue === undefined || currentValue === '',
isAutoInput,
autoValueType: config.autoValueType
});
if (currentValue === undefined || currentValue === '') {
const autoValue = config.autoValueType === "custom"
? config.customValue || ""
: generateAutoValue(config.autoValueType);
console.log("🔄 자동입력 필드 초기화:", {
fieldName,
autoValueType: config.autoValueType,
autoValue
});
updateFormData(fieldName, autoValue);
} else {
// console.log(`⏭️ 자동입력 건너뜀 (값 있음): ${fieldName} = "${currentValue}"`);
}
}
}
}
}
};
// 초기 로드 시 자동입력 필드들 설정
initAutoInputFields();
}, [allComponents, generateAutoValue, user]); // formData는 의존성에서 제외 (무한 루프 방지)
// 날짜 값 업데이트
const updateDateValue = (fieldName: string, date: Date | undefined) => {
setDateValues((prev) => ({
...prev,
[fieldName]: date,
}));
updateFormData(fieldName, date ? format(date, "yyyy-MM-dd") : "");
};
// 실제 사용 가능한 위젯 렌더링
const renderInteractiveWidget = (comp: ComponentData) => {
console.log("🎯 renderInteractiveWidget 호출:", {
type: comp.type,
id: comp.id,
componentId: (comp as any).componentId,
hasComponentConfig: !!(comp as any).componentConfig,
componentConfig: (comp as any).componentConfig,
});
// 데이터 테이블 컴포넌트 처리
if (comp.type === "datatable") {
return (
<InteractiveDataTable
component={comp as DataTableComponent}
className="h-full w-full"
style={{
width: "100%",
height: "100%",
}}
onRefresh={() => {
// 테이블 내부에서 loadData 호출하므로 여기서는 빈 함수
console.log("🔄 InteractiveDataTable 새로고침 트리거됨");
}}
/>
);
}
// 플로우 위젯 컴포넌트 처리
if (comp.type === "flow" || (comp.type === "component" && (comp as any).componentConfig?.type === "flow-widget")) {
const FlowWidget = require("@/components/screen/widgets/FlowWidget").FlowWidget;
// componentConfig에서 flowId 추출
const flowConfig = (comp as any).componentConfig || {};
console.log("🔍 InteractiveScreenViewer 플로우 위젯 변환:", {
compType: comp.type,
hasComponentConfig: !!(comp as any).componentConfig,
flowConfig,
flowConfigFlowId: flowConfig.flowId,
finalFlowId: flowConfig.flowId,
});
const flowComponent = {
...comp,
type: "flow" as const,
flowId: flowConfig.flowId,
flowName: flowConfig.flowName,
showStepCount: flowConfig.showStepCount !== false,
allowDataMove: flowConfig.allowDataMove || false,
displayMode: flowConfig.displayMode || "horizontal",
};
console.log("🔍 InteractiveScreenViewer 최종 flowComponent:", flowComponent);
return (
<div className="w-full">
<FlowWidget component={flowComponent as any} />
</div>
);
}
// 탭 컴포넌트 처리
const componentType = (comp as any).componentType || (comp as any).componentId;
if (comp.type === "tabs" || (comp.type === "component" && componentType === "tabs-widget")) {
const TabsWidget = require("@/components/screen/widgets/TabsWidget").TabsWidget;
// componentConfig에서 탭 정보 추출
const tabsConfig = comp.componentConfig || {};
const tabsComponent = {
...comp,
type: "tabs" as const,
tabs: tabsConfig.tabs || [],
defaultTab: tabsConfig.defaultTab,
orientation: tabsConfig.orientation || "horizontal",
variant: tabsConfig.variant || "default",
allowCloseable: tabsConfig.allowCloseable || false,
persistSelection: tabsConfig.persistSelection || false,
};
console.log("🔍 탭 컴포넌트 렌더링:", {
originalType: comp.type,
componentType,
componentId: (comp as any).componentId,
tabs: tabsComponent.tabs,
tabsConfig,
});
return (
<div className="h-full w-full">
<TabsWidget
component={tabsComponent as any}
menuObjid={menuObjid} // 🆕 부모의 menuObjid 전달
/>
</div>
);
}
// 🆕 렉 구조 컴포넌트 처리
if (comp.type === "component" && componentType === "rack-structure") {
const { RackStructureComponent } = require("@/lib/registry/components/rack-structure/RackStructureComponent");
const componentConfig = (comp as any).componentConfig || {};
// config가 중첩되어 있을 수 있음: componentConfig.config 또는 componentConfig 직접
const rackConfig = componentConfig.config || componentConfig;
console.log("🏗️ 렉 구조 컴포넌트 렌더링:", {
componentType,
componentConfig,
rackConfig,
fieldMapping: rackConfig.fieldMapping,
formData,
});
return (
<div className="h-full w-full overflow-auto">
<RackStructureComponent
config={rackConfig}
formData={formData}
tableName={tableName}
onChange={(locations: any[]) => {
console.log("📦 렉 구조 위치 데이터 변경:", locations.length, "개");
// 컴포넌트의 columnName을 키로 사용
const fieldKey = (comp as any).columnName || "_rackStructureLocations";
updateFormData(fieldKey, locations);
}}
isPreview={false}
/>
</div>
);
}
const { widgetType, label: originalLabel, placeholder, required, readonly, columnName } = comp;
const fieldName = columnName || comp.id;
const currentValue = formData[fieldName] || "";
// 다국어 라벨 적용 (langKey가 있으면 번역 텍스트 사용)
const compLangKey = (comp as any).langKey;
const label = compLangKey && translations[compLangKey] ? translations[compLangKey] : originalLabel;
// 스타일 적용
const applyStyles = (element: React.ReactElement) => {
if (!comp.style) return element;
// ✅ 격자 시스템 잔재 제거: style.width, style.height는 무시
// size.width, size.height가 부모 컨테이너에서 적용되므로
const { width, height, ...styleWithoutSize } = comp.style;
return React.cloneElement(element, {
style: {
...element.props.style, // 기존 스타일 유지
...styleWithoutSize, // width/height 제외한 스타일만 적용
boxSizing: "border-box",
},
});
};
switch (widgetType) {
case "text":
case "email":
case "tel": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as TextTypeConfig | undefined;
// 자동입력 관련 처리
const isAutoInput = config?.autoInput || false;
const autoValue = isAutoInput && config?.autoValueType
? config.autoValueType === "custom"
? config.customValue || ""
: generateAutoValue(config.autoValueType)
: "";
// 기본값 또는 자동값 설정
const displayValue = isAutoInput ? autoValue : currentValue || config?.defaultValue || "";
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,
defaultValue: config?.defaultValue,
autoInput: isAutoInput,
autoValueType: config?.autoValueType,
autoValue,
displayValue,
},
});
// 형식별 패턴 생성
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;
// console.log(`📝 입력 변경: ${fieldName} = "${value}"`);
// 형식별 실시간 검증
if (config?.format && config.format !== "none") {
const pattern = getPatternByFormat(config.format);
if (pattern) {
const regex = new RegExp(`^${pattern}$`);
if (value && !regex.test(value)) {
// console.log(`❌ 형식 검증 실패: ${fieldName} = "${value}"`);
return; // 유효하지 않은 입력 차단
}
}
}
// 길이 제한 검증
if (config?.maxLength && value.length > config.maxLength) {
// console.log(`❌ 길이 제한 초과: ${fieldName} = "${value}" (최대: ${config.maxLength})`);
return; // 최대 길이 초과 차단
}
// console.log(`✅ updateFormData 호출: ${fieldName} = "${value}"`);
updateFormData(fieldName, value);
};
const finalPlaceholder = config?.placeholder || placeholder || "입력하세요...";
const inputType = widgetType === "email" ? "email" : widgetType === "tel" ? "tel" : "text";
return applyStyles(
<Input
type={inputType}
placeholder={isAutoInput ? `자동입력: ${config?.autoValueType}` : finalPlaceholder}
value={displayValue}
onChange={isAutoInput ? undefined : handleInputChange}
disabled={readonly || isAutoInput}
readOnly={isAutoInput}
required={required}
minLength={config?.minLength}
maxLength={config?.maxLength}
pattern={getPatternByFormat(config?.format || "none")}
className={`w-full ${isAutoInput ? "bg-muted text-muted-foreground" : ""}`}
/>,
);
}
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,
cascading: config?.cascading,
},
});
const finalPlaceholder = config?.placeholder || placeholder || "선택하세요...";
// 🆕 연쇄 드롭다운 처리 (방법 1: 관계 코드 방식 - 권장)
if (config?.cascadingRelationCode && config?.cascadingParentField) {
const parentFieldValue = formData[config.cascadingParentField];
console.log("🔗 연쇄 드롭다운 (관계코드 방식):", {
relationCode: config.cascadingRelationCode,
parentField: config.cascadingParentField,
parentValue: parentFieldValue,
});
return applyStyles(
<CascadingDropdownWrapper
relationCode={config.cascadingRelationCode}
parentFieldName={config.cascadingParentField}
parentValue={parentFieldValue}
value={currentValue}
onChange={(value) => updateFormData(fieldName, value)}
placeholder={finalPlaceholder}
disabled={readonly}
required={required}
/>,
);
}
// 🔄 연쇄 드롭다운 처리 (방법 2: 직접 설정 방식 - 레거시)
if (config?.cascading?.enabled) {
const cascadingConfig = config.cascading;
const parentValue = formData[cascadingConfig.parentField];
return applyStyles(
<CascadingDropdownWrapper
config={cascadingConfig}
parentValue={parentValue}
value={currentValue}
onChange={(value) => updateFormData(fieldName, value)}
placeholder={finalPlaceholder}
disabled={readonly}
required={required}
/>,
);
}
// 일반 Select
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;
// 현재 파일 값 가져오기
const getCurrentValue = () => {
const fieldName = widget.columnName || widget.id;
return (externalFormData?.[fieldName] || localFormData[fieldName]) as any;
};
const currentValue = getCurrentValue();
// 화면 ID 추출 (URL에서)
const screenId = typeof window !== 'undefined' && window.location.pathname.includes('/screens/')
? parseInt(window.location.pathname.split('/screens/')[1])
: null;
console.log("📁 InteractiveScreenViewer - File 위젯:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
currentValue,
screenId,
appliedSettings: {
accept: config?.accept,
multiple: config?.multiple,
maxSize: config?.maxSize,
preview: config?.preview,
},
});
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
// 프리뷰 모드에서는 파일 업로드 차단
if (isPreviewMode) {
e.target.value = ""; // 파일 선택 취소
return;
}
const files = e.target.files;
const fieldName = widget.columnName || widget.id;
// 파일 선택을 취소한 경우 (files가 null이거나 길이가 0)
if (!files || files.length === 0) {
// console.log("📁 파일 선택 취소됨 - 기존 파일 유지");
// 현재 저장된 파일이 있는지 확인
const currentStoredValue = externalFormData?.[fieldName] || localFormData[fieldName];
if (currentStoredValue) {
// console.log("📁 기존 파일 있음 - 유지:", currentStoredValue);
// 기존 파일이 있으면 그대로 유지 (아무것도 하지 않음)
return;
} else {
// console.log("📁 기존 파일 없음 - 빈 상태 유지");
// 기존 파일이 없으면 빈 상태 유지
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;
}
}
}
// 실제 서버로 파일 업로드
try {
toast.loading(`${files.length}개 파일 업로드 중...`);
const uploadResult = await uploadFilesAndCreateData(files);
if (uploadResult.success) {
// console.log("📁 업로드 완료된 파일 데이터:", uploadResult.data);
setLocalFormData(prev => ({ ...prev, [fieldName]: uploadResult.data }));
// 외부 폼 데이터 변경 콜백 호출
if (onFormDataChange) {
onFormDataChange(fieldName, uploadResult.data);
}
toast.success(uploadResult.message);
} else {
throw new Error("파일 업로드에 실패했습니다.");
}
} catch (error) {
// console.error("파일 업로드 오류:", error);
toast.error("파일 업로드에 실패했습니다.");
// 파일 입력 초기화
e.target.value = "";
return;
}
};
const clearFile = () => {
const fieldName = widget.columnName || widget.id;
setLocalFormData(prev => ({ ...prev, [fieldName]: null }));
// 외부 폼 데이터 변경 콜백 호출
if (onFormDataChange) {
onFormDataChange(fieldName, null);
}
// 파일 input 초기화
const fileInput = document.querySelector(`input[type="file"][data-field="${fieldName}"]`) as HTMLInputElement;
if (fileInput) {
fileInput.value = "";
}
};
const renderFilePreview = () => {
if (!currentValue || !config?.preview) return null;
// 새로운 JSON 구조에서 파일 정보 추출
const fileData = currentValue.files || [];
if (fileData.length === 0) return null;
return (
<div className="mt-2 space-y-2">
<div className="text-sm font-medium text-foreground">
({fileData.length})
</div>
{fileData.map((fileInfo: any, index: number) => {
const isImage = fileInfo.type?.startsWith('image/');
return (
<div key={index} className="flex items-center gap-2 rounded border bg-muted p-2">
<div className="flex h-16 w-16 items-center justify-center rounded bg-muted/50">
{isImage ? (
<div className="text-success text-xs font-medium">IMG</div>
) : (
<File className="h-8 w-8 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">{fileInfo.name}</p>
<p className="text-xs text-muted-foreground">
{(fileInfo.size / 1024 / 1024).toFixed(2)} MB
</p>
<p className="text-xs text-muted-foreground">{fileInfo.type || '알 수 없는 형식'}</p>
<p className="text-xs text-muted-foreground/70">: {new Date(fileInfo.uploadedAt).toLocaleString('ko-KR')}</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={clearFile}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
);
})}
</div>
);
};
const fieldName = widget.columnName || widget.id;
return applyStyles(
<div className="w-full space-y-2">
{/* 파일 선택 영역 */}
<div className="relative">
<input
type="file"
data-field={fieldName}
onChange={handleFileChange}
disabled={readonly}
required={required}
multiple={config?.multiple}
accept={config?.accept}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
style={{ zIndex: 1 }}
/>
<div className={cn(
"flex items-center justify-center rounded-lg border-2 border-dashed p-4 text-center transition-colors",
currentValue && currentValue.files && currentValue.files.length > 0
? 'border-success/30 bg-success/10'
: 'border-input bg-muted hover:border-input/80 hover:bg-muted/80',
readonly && 'cursor-not-allowed opacity-50',
!readonly && 'cursor-pointer'
)}>
<div className="space-y-2">
{currentValue && currentValue.files && currentValue.files.length > 0 ? (
<>
<div className="flex items-center justify-center">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-success/20">
<svg className="h-5 w-5 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
<p className="text-sm font-medium text-success">
{currentValue.totalCount === 1
? '파일 선택됨'
: `${currentValue.totalCount}개 파일 선택됨`}
</p>
<p className="text-xs text-success/80">
{(currentValue.totalSize / 1024 / 1024).toFixed(2)}MB
</p>
<p className="text-xs text-success/80"> </p>
</>
) : (
<>
<Upload className="mx-auto h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
{config?.dragDrop ? '파일을 드래그하여 놓거나 클릭하여 선택' : '클릭하여 파일 선택'}
</p>
{(config?.accept || config?.maxSize) && (
<div className="text-xs text-muted-foreground space-y-1">
{config.accept && <div> : {config.accept}</div>}
{config.maxSize && <div> : {config.maxSize}MB</div>}
{config.multiple && <div> </div>}
</div>
)}
</>
)}
</div>
</div>
</div>
{/* 파일 미리보기 */}
{renderFilePreview()}
</div>
);
}
case "code": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as CodeTypeConfig | undefined;
console.log(`🔍 [InteractiveScreenViewer] Code 위젯 렌더링:`, {
componentId: widget.id,
columnName: widget.columnName,
codeCategory: config?.codeCategory,
menuObjid,
hasMenuObjid: !!menuObjid,
});
// code 타입은 공통코드 선택박스로 처리
// DynamicWebTypeRenderer를 사용하여 SelectBasicComponent 렌더링
try {
return applyStyles(
<DynamicWebTypeRenderer
webType="select"
props={{
component: widget,
value: currentValue,
onChange: (value: any) => updateFormData(fieldName, value),
onFormDataChange: updateFormData,
isInteractive: true,
readonly: readonly,
required: required,
placeholder: config?.placeholder || "코드를 선택하세요...",
className: "w-full h-full",
menuObjid: menuObjid, // 🆕 메뉴 OBJID 전달
}}
config={{
...config,
codeCategory: config?.codeCategory,
isCodeType: true, // 코드 타입임을 명시
}}
onEvent={(event: string, data: any) => {
// console.log(`Code widget event: ${event}`, data);
}}
/>
);
} catch (error) {
// console.error("DynamicWebTypeRenderer 로딩 실패, 기본 Select 사용:", error);
// 폴백: 기본 Select 컴포넌트 사용
return applyStyles(
<Select
value={currentValue || ""}
onValueChange={(value) => updateFormData(fieldName, value)}
disabled={readonly}
required={required}
>
<SelectTrigger className="h-full w-full">
<SelectValue placeholder={config?.placeholder || "코드를 선택하세요..."} />
</SelectTrigger>
<SelectContent>
<SelectItem value="loading"> ...</SelectItem>
</SelectContent>
</Select>
);
}
}
case "entity": {
// DynamicWebTypeRenderer로 위임하여 EntitySearchInputWrapper 사용
const widget = comp as WidgetComponent;
return applyStyles(
<DynamicWebTypeRenderer
webType="entity"
config={widget.webTypeConfig}
props={{
component: widget,
value: currentValue,
onChange: (value: any) => updateFormData(fieldName, value),
onFormDataChange: updateFormData,
formData: formData,
readonly: readonly,
required: required,
placeholder: widget.placeholder || "엔티티를 선택하세요",
isInteractive: true,
className: "w-full h-full",
}}
/>,
);
}
case "button": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as ButtonTypeConfig | undefined;
const handleButtonClick = async () => {
// 프리뷰 모드에서는 버튼 동작 차단
if (isPreviewMode) {
return;
}
const actionType = config?.actionType || "save";
try {
switch (actionType) {
case "save":
await handleSaveAction();
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 () => {
// console.log("💾 저장 시작");
// ✅ 사용자 정보가 로드되지 않았으면 저장 불가
if (!user?.userId) {
alert("사용자 정보를 불러오는 중입니다. 잠시 후 다시 시도해주세요.");
return;
}
// 개선된 검증 시스템이 활성화된 경우
if (enhancedValidation) {
// console.log("🔍 개선된 검증 시스템 사용");
const success = await enhancedValidation.saveForm();
if (success) {
toast.success("데이터가 성공적으로 저장되었습니다!");
}
return;
}
// 기존 방식 (레거시 지원)
const currentFormData = { ...localFormData, ...externalFormData };
// console.log("💾 기존 방식으로 저장 - currentFormData:", currentFormData);
// formData 유효성 체크를 완화 (빈 객체라도 위젯이 있으면 저장 진행)
const hasWidgets = allComponents.some(comp => comp.type === 'widget');
if (!hasWidgets) {
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 = currentFormData[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.type === 'widget') {
const widget = comp as WidgetComponent;
const fieldName = widget.columnName || widget.id;
let value = currentFormData[fieldName];
console.log(`🔍 컴포넌트 처리: ${fieldName}`, {
widgetType: widget.widgetType,
formDataValue: value,
hasWebTypeConfig: !!widget.webTypeConfig,
config: widget.webTypeConfig
});
// 자동입력 필드인 경우에만 값이 없을 때 생성
if ((widget.widgetType === 'text' || widget.widgetType === 'email' || widget.widgetType === 'tel') &&
widget.webTypeConfig) {
const config = widget.webTypeConfig as TextTypeConfig;
const isAutoInput = config?.autoInput || false;
console.log(`📋 ${fieldName} 자동입력 체크:`, {
isAutoInput,
autoValueType: config?.autoValueType,
hasValue: !!value,
value
});
if (isAutoInput && config?.autoValueType && (!value || value === '')) {
// 자동입력이고 값이 없을 때만 생성
value = config.autoValueType === "custom"
? config.customValue || ""
: generateAutoValue(config.autoValueType);
console.log("💾 자동입력 값 저장 (값이 없어서 생성):", {
fieldName,
autoValueType: config.autoValueType,
generatedValue: value
});
} else if (isAutoInput && value) {
console.log("💾 자동입력 필드지만 기존 값 유지:", {
fieldName,
existingValue: value
});
} else if (!isAutoInput) {
// console.log(`📝 일반 입력 필드: ${fieldName} = "${value}"`);
}
}
// 값이 있는 경우만 매핑 (빈 문자열도 포함하되, undefined는 제외)
if (value !== undefined && value !== null && value !== "undefined") {
// columnName이 있으면 columnName을 키로, 없으면 컴포넌트 ID를 키로 사용
const saveKey = widget.columnName || `comp_${widget.id}`;
mappedData[saveKey] = value;
} else if (widget.columnName) {
// 값이 없지만 columnName이 있는 경우, 빈 문자열로 저장
// console.log(`⚠️ ${widget.columnName} 필드에 값이 없어 빈 문자열로 저장`);
mappedData[widget.columnName] = "";
}
}
});
console.log("💾 저장할 데이터 매핑:", {
원본데이터: currentFormData,
매핑된데이터: mappedData,
화면정보: screenInfo,
전체컴포넌트수: allComponents.length,
위젯컴포넌트수: allComponents.filter(c => c.type === 'widget').length,
});
// 각 컴포넌트의 상세 정보 로그
// console.log("🔍 컴포넌트별 데이터 수집 상세:");
allComponents.forEach(comp => {
if (comp.type === 'widget') {
const widget = comp as WidgetComponent;
const fieldName = widget.columnName || widget.id;
const value = currentFormData[fieldName];
const hasValue = value !== undefined && value !== null && value !== '';
// console.log(` - ${fieldName} (${widget.widgetType}): "${value}" (값있음: ${hasValue}, 컬럼명: ${widget.columnName})`);
}
});
// 매핑된 데이터가 비어있으면 경고
if (Object.keys(mappedData).length === 0) {
// console.warn("⚠️ 매핑된 데이터가 없습니다. 빈 데이터로 저장됩니다.");
}
// 테이블명 결정 (화면 정보에서 가져오거나 첫 번째 컴포넌트의 테이블명 사용)
const tableName = screenInfo.tableName ||
allComponents.find(c => c.columnName)?.tableName ||
"dynamic_form_data"; // 기본값
// 🆕 자동으로 작성자 정보 추가 (user.userId가 확실히 있음)
const writerValue = user.userId;
const companyCodeValue = user.companyCode || "";
console.log("👤 현재 사용자 정보:", {
userId: user.userId,
userName: userName,
companyCode: user.companyCode, // ✅ 회사 코드
formDataWriter: mappedData.writer, // ✅ 폼에서 입력한 writer 값
formDataCompanyCode: mappedData.company_code, // ✅ 폼에서 입력한 company_code 값
defaultWriterValue: writerValue,
companyCodeValue, // ✅ 최종 회사 코드 값
});
const dataWithUserInfo = {
...mappedData,
writer: mappedData.writer || writerValue, // ✅ 입력값 우선, 없으면 userId
created_by: writerValue, // created_by는 항상 로그인한 사람
updated_by: writerValue, // updated_by는 항상 로그인한 사람
company_code: mappedData.company_code || companyCodeValue, // ✅ 입력값 우선, 없으면 user.companyCode
};
const saveData: DynamicFormData = {
screenId: screenInfo.id,
tableName: tableName,
data: dataWithUserInfo,
};
console.log("🚀 API 저장 요청:", saveData);
const result = await dynamicFormApi.saveFormData(saveData);
if (result.success) {
alert("저장되었습니다.");
// console.log("✅ 저장 성공:", result.data);
// 저장 후 데이터 초기화 (선택사항)
if (onFormDataChange) {
const resetData: Record<string, any> = {};
Object.keys(formData).forEach(key => {
resetData[key] = "";
});
onFormDataChange(resetData);
}
} else {
throw new Error(result.message || "저장에 실패했습니다.");
}
} catch (error: any) {
// console.error("❌ 저장 실패:", error);
alert(`저장 중 오류가 발생했습니다: ${error.message}`);
}
};
// 삭제 액션
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 });
// screenId 전달하여 제어관리 실행 가능하도록 함
const result = await dynamicFormApi.deleteFormDataFromTable(recordId, tableName, screenInfo?.id);
if (result.success) {
alert("삭제되었습니다.");
// console.log("✅ 삭제 성공");
// 삭제 후 폼 초기화
if (onFormDataChange) {
const resetData: Record<string, any> = {};
Object.keys(formData).forEach(key => {
resetData[key] = "";
});
onFormDataChange(resetData);
}
} else {
throw new Error(result.message || "삭제에 실패했습니다.");
}
} catch (error: any) {
// console.error("❌ 삭제 실패:", error);
alert(`삭제 중 오류가 발생했습니다: ${error.message}`);
}
};
// 편집 액션
const handleEditAction = () => {
console.log("✏️ 수정 액션 실행");
// 버튼 컴포넌트의 수정 모달 설정 가져오기
const editModalTitle = config?.editModalTitle || "";
const editModalDescription = config?.editModalDescription || "";
console.log("📝 버튼 수정 모달 설정:", { editModalTitle, editModalDescription });
// EditModal 열기 이벤트 발생
const event = new CustomEvent("openEditModal", {
detail: {
screenId: screenInfo?.id,
modalSize: "lg",
editData: formData,
modalTitle: editModalTitle,
modalDescription: editModalDescription,
onSave: () => {
console.log("✅ 수정 완료");
// 필요시 폼 새로고침 또는 콜백 실행
},
},
});
window.dispatchEvent(event);
};
// 추가 액션
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) {
const resetData: Record<string, any> = {};
Object.keys(formData).forEach(key => {
resetData[key] = "";
});
onFormDataChange(resetData);
}
// console.log("🔄 폼 초기화 완료");
alert("입력이 초기화되었습니다.");
}
};
// 제출 액션
const handleSubmitAction = async () => {
// console.log("📤 폼 제출:", formData);
// 제출 로직
alert("제출되었습니다.");
};
// 닫기 액션
const handleCloseAction = () => {
// console.log("❌ 닫기 액션 실행");
// 모달 내부에서 실행되는지 확인
const isInModal = document.querySelector('[role="dialog"]') !== null;
const isInPopup = window.opener !== null;
if (isInModal) {
// 모달 내부인 경우: 모달의 닫기 버튼 클릭하거나 모달 닫기 이벤트 발생
// console.log("🔄 모달 내부에서 닫기 - 모달 닫기 시도");
// 모달의 닫기 버튼을 찾아서 클릭
const modalCloseButton = document.querySelector('[role="dialog"] button[aria-label*="Close"], [role="dialog"] button[data-dismiss="modal"], [role="dialog"] .dialog-close');
if (modalCloseButton) {
(modalCloseButton as HTMLElement).click();
} else {
// ESC 키 이벤트 발생시키기
const escEvent = new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, which: 27 });
document.dispatchEvent(escEvent);
}
} else if (isInPopup) {
// 팝업 창인 경우
// console.log("🔄 팝업 창 닫기");
window.close();
} else {
// 일반 페이지인 경우 - 이전 페이지로 이동하지 않고 아무것도 하지 않음
// console.log("🔄 일반 페이지에서 닫기 - 아무 동작 하지 않음");
alert("닫기 버튼이 클릭되었습니다.");
}
};
// 팝업 액션
const handlePopupAction = () => {
// console.log("🎯 팝업 액션 실행:", { popupScreenId: config?.popupScreenId });
if (config?.popupScreenId) {
// 화면 모달 열기
setPopupScreen({
screenId: config.popupScreenId,
title: config.popupTitle || "상세 정보",
size: "lg",
});
} else if (config?.popupTitle && config?.popupContent) {
// 텍스트 모달 표시
alert(`${config.popupTitle}\n\n${config.popupContent}`);
} else {
alert("모달을 표시합니다.");
}
};
// 네비게이션 액션
const handleNavigateAction = () => {
const navigateType = config?.navigateType || "url";
if (navigateType === "screen" && config?.navigateScreenId) {
// 화면으로 이동
const screenPath = `/screens/${config.navigateScreenId}`;
console.log("🎯 화면으로 이동:", {
screenId: config.navigateScreenId,
target: config.navigateTarget || "_self",
path: screenPath
});
if (config.navigateTarget === "_blank") {
window.open(screenPath, "_blank");
} else {
window.location.href = screenPath;
}
} else if (navigateType === "url" && config?.navigateUrl) {
// URL로 이동
console.log("🔗 URL로 이동:", {
url: config.navigateUrl,
target: config.navigateTarget || "_self"
});
if (config.navigateTarget === "_blank") {
window.open(config.navigateUrl, "_blank");
} else {
window.location.href = config.navigateUrl;
}
} else {
console.log("🔗 네비게이션 정보가 설정되지 않았습니다:", {
navigateType,
hasUrl: !!config?.navigateUrl,
hasScreenId: !!config?.navigateScreenId
});
}
};
// 커스텀 액션
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("⚡ 커스텀 액션이 설정되지 않았습니다.");
}
};
// 버튼 텍스트 다국어 적용 (componentConfig.langKey 확인)
const buttonLangKey = (widget as any).componentConfig?.langKey;
const buttonText = buttonLangKey && translations[buttonLangKey]
? translations[buttonLangKey]
: (widget as any).componentConfig?.text || label || "버튼";
// 커스텀 색상이 있으면 Tailwind 클래스 대신 직접 스타일 적용
const hasCustomColors = config?.backgroundColor || config?.textColor;
return applyStyles(
<button
onClick={handleButtonClick}
disabled={readonly}
className={`w-full rounded-md px-3 py-2 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ${
hasCustomColors
? ''
: 'bg-background border border-foreground text-foreground shadow-xs hover:bg-muted/50'
}`}
style={{
height: "100%",
backgroundColor: config?.backgroundColor,
color: config?.textColor,
borderColor: config?.borderColor,
}}
>
{buttonText}
</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 (isFileComponent(component)) {
const fileComponent = component as FileComponent;
console.log("🎯 File 컴포넌트 렌더링:", {
componentId: fileComponent.id,
currentUploadedFiles: fileComponent.uploadedFiles?.length || 0,
hasOnFormDataChange: !!onFormDataChange,
userInfo: user ? { userId: user.userId, companyCode: user.companyCode } : "no user"
});
const handleFileUpdate = useCallback(async (updates: Partial<FileComponent>) => {
// 실제 화면에서는 파일 업데이트를 처리
console.log("📎 InteractiveScreenViewer - 파일 컴포넌트 업데이트:", {
updates,
hasUploadedFiles: !!updates.uploadedFiles,
uploadedFilesCount: updates.uploadedFiles?.length || 0,
hasOnFormDataChange: !!onFormDataChange
});
if (updates.uploadedFiles && onFormDataChange) {
const fieldName = fileComponent.columnName || fileComponent.id;
// attach_file_info 테이블 구조에 맞는 데이터 생성
const fileInfoForDB = updates.uploadedFiles.map(file => ({
objid: file.objid.replace('temp_', ''), // temp_ 제거
target_objid: "",
saved_file_name: file.savedFileName,
real_file_name: file.realFileName,
doc_type: file.docType,
doc_type_name: file.docTypeName,
file_size: file.fileSize,
file_ext: file.fileExt,
file_path: file.filePath,
writer: file.writer,
regdate: file.regdate,
status: file.status,
parent_target_objid: "",
company_code: file.companyCode
}));
// console.log("💾 attach_file_info 형태로 변환된 데이터:", fileInfoForDB);
// FormData에는 파일 연결 정보만 저장 (간단한 형태)
const formDataValue = {
fileCount: updates.uploadedFiles.length,
docType: fileComponent.fileConfig.docType,
files: updates.uploadedFiles.map(file => ({
objid: file.objid,
realFileName: file.realFileName,
fileSize: file.fileSize,
status: file.status
}))
};
// console.log("📝 FormData 저장값:", { fieldName, formDataValue });
onFormDataChange(fieldName, formDataValue);
// TODO: 실제 API 연동 시 attach_file_info 테이블에 저장
// await saveFilesToDatabase(fileInfoForDB);
} else {
console.warn("⚠️ 파일 업데이트 실패:", {
hasUploadedFiles: !!updates.uploadedFiles,
hasOnFormDataChange: !!onFormDataChange
});
}
}, [fileComponent, onFormDataChange]);
return (
<div className="h-full w-full">
<FileUpload
component={fileComponent}
onUpdateComponent={handleFileUpdate}
userInfo={user} // 사용자 정보를 프롭으로 전달
/>
</div>
);
}
// 그룹 컴포넌트 처리
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.style?.width || `${child.size.width}px`,
height: child.style?.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 ?? true) &&
(component.label || component.style?.labelText) &&
!templateTypes.includes(component.type); // 템플릿 컴포넌트는 라벨 표시 안함
// 다국어 라벨 텍스트 결정 (langKey가 있으면 번역 텍스트 사용)
const langKey = (component as any).langKey;
const originalLabelText = component.style?.labelText || component.label || "";
const labelText = langKey && translations[langKey] ? translations[langKey] : originalLabelText;
// 라벨 표시 여부 로그 (디버깅용)
if (component.type === "widget") {
console.log("🏷️ 라벨 표시 체크:", {
componentId: component.id,
hideLabel,
shouldShowLabel,
labelText,
langKey,
hasTranslation: !!translations[langKey],
});
}
// 라벨 스타일 적용
const labelStyle = {
fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#212121",
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 componentForRendering = shouldShowLabel
? {
...component,
style: {
...component.style,
labelDisplay: false, // 상위에서 라벨을 표시했으므로 컴포넌트 내부에서는 숨김
},
}
: component;
return (
<SplitPanelProvider>
<ActiveTabProvider>
<TableOptionsProvider>
<div className="flex h-full flex-col">
{/* 테이블 옵션 툴바 */}
<TableOptionsToolbar />
{/* 메인 컨텐츠 */}
<div className="h-full flex-1" style={{ width: '100%' }}>
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
{shouldShowLabel && (
<label className="mb-2 block text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{labelText}
{(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
</label>
)}
{/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */}
<div className="h-full" style={{ width: '100%', height: '100%' }}>{renderInteractiveWidget(componentForRendering)}</div>
</div>
</div>
{/* 개선된 검증 패널 (선택적 표시) */}
{showValidationPanel && enhancedValidation && (
<div className="absolute bottom-4 right-4 z-50">
<FormValidationIndicator
validationState={enhancedValidation.validationState}
saveState={enhancedValidation.saveState}
onSave={async () => {
const success = await enhancedValidation.saveForm();
if (success) {
toast.success("데이터가 성공적으로 저장되었습니다!");
}
}}
canSave={enhancedValidation.canSave}
compact={true}
showDetails={false}
/>
</div>
)}
{/* 모달 화면 */}
<Dialog open={!!popupScreen} onOpenChange={() => {
setPopupScreen(null);
setPopupFormData({}); // 팝업 닫을 때 formData도 초기화
}}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden p-0">
<DialogHeader className="px-6 pt-4 pb-2">
<DialogTitle>{popupScreen?.title || "상세 정보"}</DialogTitle>
</DialogHeader>
<div className="overflow-y-auto px-6 pb-6" style={{ maxHeight: "calc(90vh - 80px)" }}>
{popupLoading ? (
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground"> ...</div>
</div>
) : popupLayout.length > 0 ? (
<div className="relative bg-background 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.style?.width || `${popupComponent.size.width}px`,
height: popupComponent.style?.height || `${popupComponent.size.height}px`,
zIndex: Math.min(popupComponent.position.z || 1, 20), // 최대 z-index 20으로 제한
}}
>
{/* 🎯 핵심 수정: 팝업 전용 formData 사용 */}
<InteractiveScreenViewer
component={popupComponent}
allComponents={popupLayout}
hideLabel={false}
screenInfo={popupScreenInfo || undefined}
formData={popupFormData}
onFormDataChange={(fieldName, value) => {
console.log("💾 팝업 formData 업데이트:", {
fieldName,
value,
valueType: typeof value,
prevFormData: popupFormData
});
setPopupFormData(prev => ({
...prev,
[fieldName]: value
}));
}}
/>
</div>
))}
</div>
) : (
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground"> .</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
</TableOptionsProvider>
</ActiveTabProvider>
</SplitPanelProvider>
);
};