- Integrated `formatPgError` utility to provide user-friendly error messages based on PostgreSQL error codes during form data operations. - Updated error responses in `saveFormData`, `saveFormDataEnhanced`, `updateFormData`, and `updateFormDataPartial` to include specific messages based on the company context. - Improved error handling in the frontend components to display relevant error messages from the server response, ensuring users receive clear feedback on save operations. - Enhanced the required field validation by incorporating NOT NULL metadata checks across various components, improving the accuracy of form submissions. These changes improve the overall user experience by providing clearer error messages and ensuring that required fields are properly validated based on both manual settings and database constraints.
1446 lines
55 KiB
TypeScript
1446 lines
55 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useCallback, useEffect, useSyncExternalStore } from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { uploadFilesAndCreateData } from "@/lib/api/file";
|
|
import { toast } from "sonner";
|
|
import { ComponentData, WidgetComponent, DataTableComponent, FileComponent, ButtonTypeConfig } from "@/types/screen";
|
|
import { FileUploadComponent } from "@/lib/registry/components/file-upload/FileUploadComponent";
|
|
import { InteractiveDataTable } from "./InteractiveDataTable";
|
|
import { DynamicWebTypeRenderer } from "@/lib/registry";
|
|
import { DynamicComponentRenderer, isColumnRequiredByMeta } from "@/lib/registry/DynamicComponentRenderer";
|
|
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
|
import { isFileComponent, isDataTableComponent, isButtonComponent } from "@/lib/utils/componentTypeUtils";
|
|
import { FlowButtonGroup } from "./widgets/FlowButtonGroup";
|
|
import { FlowVisibilityConfig } from "@/types/control-management";
|
|
import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils";
|
|
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
|
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
|
import { evaluateConditional } from "@/lib/utils/conditionalEvaluator";
|
|
import {
|
|
subscribe as canvasSplitSubscribe,
|
|
getSnapshot as canvasSplitGetSnapshot,
|
|
getServerSnapshot as canvasSplitGetServerSnapshot,
|
|
subscribeDom as canvasSplitSubscribeDom,
|
|
} from "@/lib/registry/components/v2-split-line/canvasSplitStore";
|
|
|
|
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
|
|
import "@/lib/registry/components/ButtonRenderer";
|
|
import "@/lib/registry/components/CardRenderer";
|
|
import "@/lib/registry/components/DashboardRenderer";
|
|
import "@/lib/registry/components/WidgetRenderer";
|
|
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;
|
|
};
|
|
menuObjid?: number; // 메뉴 OBJID (코드 스코프용)
|
|
onSave?: () => Promise<void>;
|
|
onRefresh?: () => void;
|
|
onFlowRefresh?: () => void;
|
|
// 외부에서 전달받는 사용자 정보 (ScreenModal 등에서 사용)
|
|
userId?: string;
|
|
userName?: string;
|
|
companyCode?: string;
|
|
// 그룹 데이터 (EditModal에서 전달)
|
|
groupedData?: Record<string, any>[];
|
|
// 비활성화할 필드 목록 (EditModal에서 전달)
|
|
disabledFields?: string[];
|
|
// EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록)
|
|
isInModal?: boolean;
|
|
// 원본 데이터 (수정 모드에서 UPDATE 판단용)
|
|
originalData?: Record<string, any> | null;
|
|
// 탭 관련 정보 (탭 내부의 컴포넌트에서 사용)
|
|
parentTabId?: string; // 부모 탭 ID
|
|
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
|
|
}
|
|
|
|
export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerProps> = ({
|
|
component,
|
|
allComponents,
|
|
formData: externalFormData,
|
|
onFormDataChange,
|
|
hideLabel = false,
|
|
screenInfo,
|
|
menuObjid,
|
|
onSave,
|
|
onRefresh,
|
|
onFlowRefresh,
|
|
userId: externalUserId,
|
|
userName: externalUserName,
|
|
companyCode: externalCompanyCode,
|
|
groupedData,
|
|
disabledFields = [],
|
|
isInModal = false,
|
|
originalData,
|
|
parentTabId,
|
|
parentTabsComponentId,
|
|
}) => {
|
|
const { isPreviewMode } = useScreenPreview();
|
|
const { userName: authUserName, user: authUser } = useAuth();
|
|
const splitPanelContext = useSplitPanelContext();
|
|
|
|
// 캔버스 분할선 글로벌 스토어 구독
|
|
const canvasSplit = useSyncExternalStore(canvasSplitSubscribe, canvasSplitGetSnapshot, canvasSplitGetServerSnapshot);
|
|
const canvasSplitSideRef = React.useRef<"left" | "right" | null>(null);
|
|
const myScopeIdRef = React.useRef<string | null>(null);
|
|
|
|
// 외부에서 전달받은 사용자 정보가 있으면 우선 사용 (ScreenModal 등에서)
|
|
const userName = externalUserName || authUserName;
|
|
const user =
|
|
externalUserId && externalUserId !== authUser?.userId
|
|
? {
|
|
userId: externalUserId,
|
|
userName: externalUserName || authUserName || "",
|
|
companyCode: externalCompanyCode || authUser?.companyCode || "",
|
|
isAdmin: authUser?.isAdmin || false,
|
|
}
|
|
: authUser;
|
|
const [localFormData, setLocalFormData] = useState<Record<string, any>>({});
|
|
const [dateValues, setDateValues] = useState<Record<string, Date | undefined>>({});
|
|
|
|
// 테이블에서 선택된 행 데이터 (버튼 액션에 전달)
|
|
const [selectedRowsData, setSelectedRowsData] = useState<any[]>([]);
|
|
|
|
// 플로우에서 선택된 데이터 (버튼 액션에 전달)
|
|
const [flowSelectedData, setFlowSelectedData] = useState<any[]>([]);
|
|
const [flowSelectedStepId, setFlowSelectedStepId] = useState<number | null>(null);
|
|
|
|
// 팝업 화면 상태
|
|
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>>({});
|
|
|
|
// 🆕 분할 패널에서 매핑된 부모 데이터 가져오기
|
|
// disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달)
|
|
const splitPanelMappedData = React.useMemo(() => {
|
|
if (splitPanelContext && !splitPanelContext.disableAutoDataTransfer) {
|
|
return splitPanelContext.getMappedParentData();
|
|
}
|
|
return {};
|
|
}, [splitPanelContext, splitPanelContext?.selectedLeftData, splitPanelContext?.disableAutoDataTransfer]);
|
|
|
|
// formData 결정 (외부에서 전달받은 것이 있으면 우선 사용, 분할 패널 데이터도 병합)
|
|
const formData = React.useMemo(() => {
|
|
const baseData = externalFormData || localFormData;
|
|
// 분할 패널 매핑 데이터가 있으면 병합 (기존 값이 없는 경우에만)
|
|
// disableAutoDataTransfer가 true이면 자동 병합 안함
|
|
if (Object.keys(splitPanelMappedData).length > 0) {
|
|
const merged = { ...baseData };
|
|
for (const [key, value] of Object.entries(splitPanelMappedData)) {
|
|
// 기존 값이 없거나 빈 값인 경우에만 매핑 데이터 적용
|
|
if (merged[key] === undefined || merged[key] === null || merged[key] === "") {
|
|
merged[key] = value;
|
|
}
|
|
}
|
|
return merged;
|
|
}
|
|
return baseData;
|
|
}, [externalFormData, localFormData, splitPanelMappedData]);
|
|
|
|
// formData 업데이트 함수
|
|
const updateFormData = useCallback(
|
|
(fieldName: string, value: any) => {
|
|
if (onFormDataChange) {
|
|
onFormDataChange(fieldName, value);
|
|
} else {
|
|
setLocalFormData((prev) => ({
|
|
...prev,
|
|
[fieldName]: value,
|
|
}));
|
|
}
|
|
},
|
|
[onFormDataChange],
|
|
);
|
|
|
|
// 자동값 생성 함수
|
|
const generateAutoValue = useCallback(
|
|
(autoValueType: string): string => {
|
|
const now = new Date();
|
|
switch (autoValueType) {
|
|
case "current_datetime":
|
|
return now.toISOString().slice(0, 19).replace("T", " ");
|
|
case "current_date":
|
|
return now.toISOString().slice(0, 10);
|
|
case "current_time":
|
|
return now.toTimeString().slice(0, 8);
|
|
case "current_user":
|
|
return userName || "사용자";
|
|
case "uuid":
|
|
return crypto.randomUUID();
|
|
case "sequence":
|
|
return `SEQ_${Date.now()}`;
|
|
default:
|
|
return "";
|
|
}
|
|
},
|
|
[userName],
|
|
);
|
|
|
|
// 🆕 Enter 키로 다음 필드 이동
|
|
useEffect(() => {
|
|
const handleEnterKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
const target = e.target as HTMLElement;
|
|
|
|
// 한글 조합 중이면 무시 (한글 입력 문제 방지)
|
|
if ((e as any).isComposing || e.keyCode === 229) {
|
|
return;
|
|
}
|
|
|
|
// textarea는 제외 (여러 줄 입력)
|
|
if (target.tagName === "TEXTAREA") {
|
|
return;
|
|
}
|
|
|
|
// input, select 등의 폼 요소에서만 작동
|
|
if (target.tagName === "INPUT" || target.tagName === "SELECT" || target.getAttribute("role") === "combobox") {
|
|
e.preventDefault();
|
|
|
|
// 모든 포커스 가능한 요소 찾기
|
|
const focusableElements = document.querySelectorAll<HTMLElement>(
|
|
'input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [role="combobox"]:not([disabled])',
|
|
);
|
|
|
|
// 화면에 보이는 순서(Y 좌표 → X 좌표)대로 정렬
|
|
const focusableArray = Array.from(focusableElements).sort((a, b) => {
|
|
const rectA = a.getBoundingClientRect();
|
|
const rectB = b.getBoundingClientRect();
|
|
|
|
// Y 좌표 차이가 10px 이상이면 Y 좌표로 정렬 (위에서 아래로)
|
|
if (Math.abs(rectA.top - rectB.top) > 10) {
|
|
return rectA.top - rectB.top;
|
|
}
|
|
|
|
// 같은 줄이면 X 좌표로 정렬 (왼쪽에서 오른쪽으로)
|
|
return rectA.left - rectB.left;
|
|
});
|
|
|
|
const currentIndex = focusableArray.indexOf(target);
|
|
|
|
if (currentIndex !== -1 && currentIndex < focusableArray.length - 1) {
|
|
// 다음 요소로 포커스 이동
|
|
const nextElement = focusableArray[currentIndex + 1];
|
|
nextElement.focus();
|
|
|
|
// select() 제거: 한글 입력 시 이전 필드의 마지막 글자가 복사되는 버그 방지
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
document.addEventListener("keydown", handleEnterKey);
|
|
|
|
return () => {
|
|
document.removeEventListener("keydown", handleEnterKey);
|
|
};
|
|
}, []);
|
|
|
|
// 🆕 autoFill 자동 입력 초기화
|
|
React.useEffect(() => {
|
|
const initAutoInputFields = async () => {
|
|
for (const comp of allComponents) {
|
|
// type: "component" 또는 type: "widget" 모두 처리
|
|
if (comp.type === "widget" || comp.type === "component") {
|
|
const widget = comp as any;
|
|
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 { tableTypeApi } = await import("@/lib/api/screen");
|
|
const result = await tableTypeApi.getTableRecord(sourceTable, filterColumn, userValue, displayColumn);
|
|
|
|
updateFormData(fieldName, result.value);
|
|
} catch (error) {
|
|
console.error(`autoFill 조회 실패: ${fieldName}`, error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
initAutoInputFields();
|
|
}, [allComponents, user]);
|
|
|
|
// 팝업 화면 레이아웃 로드
|
|
React.useEffect(() => {
|
|
if (popupScreen?.screenId) {
|
|
loadPopupScreen(popupScreen.screenId);
|
|
}
|
|
}, [popupScreen?.screenId]);
|
|
|
|
const loadPopupScreen = async (screenId: number) => {
|
|
try {
|
|
setPopupLoading(true);
|
|
const response = await screenApi.getScreenLayout(screenId);
|
|
|
|
if (response.success && response.data) {
|
|
const screenData = response.data;
|
|
setPopupLayout(screenData.components || []);
|
|
setPopupScreenResolution({
|
|
width: screenData.screenResolution?.width || 1200,
|
|
height: screenData.screenResolution?.height || 800,
|
|
});
|
|
setPopupScreenInfo({
|
|
id: screenData.id,
|
|
tableName: screenData.tableName,
|
|
});
|
|
} else {
|
|
toast.error("팝업 화면을 불러올 수 없습니다.");
|
|
setPopupScreen(null);
|
|
}
|
|
} catch (error) {
|
|
// console.error("팝업 화면 로드 오류:", error);
|
|
toast.error("팝업 화면 로드 중 오류가 발생했습니다.");
|
|
setPopupScreen(null);
|
|
} finally {
|
|
setPopupLoading(false);
|
|
}
|
|
};
|
|
|
|
// 폼 데이터 변경 핸들러
|
|
const handleFormDataChange = (fieldName: string | any, value?: any) => {
|
|
// 일반 필드 변경
|
|
if (onFormDataChange) {
|
|
onFormDataChange(fieldName, value);
|
|
} else {
|
|
setLocalFormData((prev) => ({ ...prev, [fieldName]: value }));
|
|
}
|
|
};
|
|
|
|
// 동적 대화형 위젯 렌더링
|
|
const renderInteractiveWidget = (comp: ComponentData) => {
|
|
// 조건부 표시 평가 (기존 conditional 시스템)
|
|
const conditionalResult = evaluateConditional(comp.conditional, formData, allComponents);
|
|
|
|
// 조건에 따라 숨김 처리
|
|
if (!conditionalResult.visible) {
|
|
return null;
|
|
}
|
|
|
|
// 🆕 conditionalConfig 시스템 체크 (V2 레이아웃용)
|
|
const conditionalConfig = (comp as any).componentConfig?.conditionalConfig;
|
|
if (conditionalConfig?.enabled && formData) {
|
|
const { field, operator, value, action } = conditionalConfig;
|
|
const fieldValue = formData[field];
|
|
|
|
let conditionMet = false;
|
|
switch (operator) {
|
|
case "=":
|
|
case "==":
|
|
case "===":
|
|
conditionMet = fieldValue === value;
|
|
break;
|
|
case "!=":
|
|
case "!==":
|
|
conditionMet = fieldValue !== value;
|
|
break;
|
|
default:
|
|
conditionMet = fieldValue === value;
|
|
}
|
|
|
|
if (action === "show" && !conditionMet) {
|
|
return null;
|
|
}
|
|
if (action === "hide" && conditionMet) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// 데이터 테이블 컴포넌트 처리
|
|
if (isDataTableComponent(comp)) {
|
|
return (
|
|
<InteractiveDataTable
|
|
component={comp as DataTableComponent}
|
|
className="h-full w-full"
|
|
style={{
|
|
width: "100%",
|
|
height: "100%",
|
|
}}
|
|
onRefresh={() => {
|
|
// 테이블 자체에서 loadData를 호출하므로 여기서는 빈 함수
|
|
console.log("🔄 InteractiveDataTable 새로고침 트리거됨 (Dynamic)");
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// 파일 컴포넌트 처리
|
|
if (isFileComponent(comp)) {
|
|
return renderFileComponent(comp as FileComponent);
|
|
}
|
|
|
|
// 버튼 컴포넌트 또는 위젯이 아닌 경우 DynamicComponentRenderer 사용
|
|
if (comp.type !== "widget") {
|
|
return (
|
|
<DynamicComponentRenderer
|
|
component={comp}
|
|
isInteractive={true}
|
|
formData={formData}
|
|
originalData={originalData || undefined}
|
|
onFormDataChange={handleFormDataChange}
|
|
screenId={screenInfo?.id}
|
|
tableName={screenInfo?.tableName}
|
|
menuObjid={menuObjid}
|
|
userId={user?.userId}
|
|
userName={user?.userName}
|
|
companyCode={user?.companyCode}
|
|
onSave={onSave}
|
|
allComponents={allComponents}
|
|
selectedRowsData={selectedRowsData}
|
|
onSelectedRowsChange={(selectedRows, selectedData) => {
|
|
console.log("테이블에서 선택된 행 데이터:", selectedData);
|
|
setSelectedRowsData(selectedData);
|
|
}}
|
|
groupedData={groupedData}
|
|
disabledFields={disabledFields}
|
|
flowSelectedData={flowSelectedData}
|
|
flowSelectedStepId={flowSelectedStepId}
|
|
onFlowSelectedDataChange={(selectedData, stepId) => {
|
|
console.log("플로우에서 선택된 데이터:", { selectedData, stepId });
|
|
setFlowSelectedData(selectedData);
|
|
setFlowSelectedStepId(stepId);
|
|
}}
|
|
onRefresh={
|
|
onRefresh ||
|
|
(() => {
|
|
console.log("InteractiveScreenViewerDynamic onRefresh 호출");
|
|
})
|
|
}
|
|
onFlowRefresh={onFlowRefresh}
|
|
onClose={() => {
|
|
// buttonActions.ts가 이미 처리함
|
|
}}
|
|
// 탭 관련 정보 전달
|
|
parentTabId={parentTabId}
|
|
parentTabsComponentId={parentTabsComponentId}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const widget = comp as WidgetComponent;
|
|
const { widgetType, label, placeholder, required, readonly, columnName } = widget;
|
|
const fieldName = columnName || comp.id;
|
|
const currentValue = formData[fieldName] || "";
|
|
|
|
// 스타일 적용
|
|
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 제외한 스타일만 적용
|
|
width: "100%",
|
|
height: "100%",
|
|
minHeight: "100%",
|
|
maxHeight: "100%",
|
|
boxSizing: "border-box",
|
|
},
|
|
});
|
|
};
|
|
|
|
// 조건부 비활성화 적용
|
|
const isConditionallyDisabled = conditionalResult.disabled;
|
|
|
|
// 동적 웹타입 렌더링 사용
|
|
if (widgetType) {
|
|
try {
|
|
const dynamicElement = (
|
|
<DynamicWebTypeRenderer
|
|
webType={widgetType}
|
|
props={{
|
|
component: widget,
|
|
value: currentValue,
|
|
onChange: (value: any) => handleFormDataChange(fieldName, value),
|
|
onFormDataChange: handleFormDataChange,
|
|
formData: formData, // 🆕 전체 formData 전달
|
|
isInteractive: true,
|
|
readonly: readonly || isConditionallyDisabled, // 조건부 비활성화 적용
|
|
disabled: isConditionallyDisabled, // 조건부 비활성화 전달
|
|
required: required,
|
|
placeholder: placeholder,
|
|
className: "w-full h-full",
|
|
isInModal: isInModal, // 🆕 EditModal 내부 여부 전달
|
|
onSave: onSave, // 🆕 EditModal의 handleSave 콜백 전달
|
|
groupedData: groupedData, // 🆕 그룹 데이터 전달 (RepeatScreenModal용)
|
|
}}
|
|
config={widget.webTypeConfig}
|
|
onEvent={(event: string, data: any) => {
|
|
// 이벤트 처리
|
|
// console.log(`Widget event: ${event}`, data);
|
|
}}
|
|
/>
|
|
);
|
|
|
|
return applyStyles(dynamicElement);
|
|
} catch (error) {
|
|
// console.error(`웹타입 "${widgetType}" 대화형 렌더링 실패:`, error);
|
|
// 오류 발생 시 폴백으로 기본 input 렌더링
|
|
const fallbackElement = (
|
|
<Input
|
|
type="text"
|
|
value={currentValue}
|
|
onChange={(e) => handleFormDataChange(fieldName, e.target.value)}
|
|
placeholder={`${widgetType} (렌더링 오류)`}
|
|
disabled={readonly || isConditionallyDisabled}
|
|
required={required}
|
|
className="h-full w-full"
|
|
/>
|
|
);
|
|
return applyStyles(fallbackElement);
|
|
}
|
|
}
|
|
|
|
// 웹타입이 없는 경우 기본 input 렌더링 (하위 호환성)
|
|
const defaultElement = (
|
|
<Input
|
|
type="text"
|
|
value={currentValue}
|
|
onChange={(e) => handleFormDataChange(fieldName, e.target.value)}
|
|
placeholder={placeholder || "입력하세요"}
|
|
disabled={readonly || isConditionallyDisabled}
|
|
required={required}
|
|
className="h-full w-full"
|
|
/>
|
|
);
|
|
return applyStyles(defaultElement);
|
|
};
|
|
|
|
// 버튼 렌더링
|
|
const renderButton = (comp: ComponentData) => {
|
|
const config = (comp as any).webTypeConfig as ButtonTypeConfig | undefined;
|
|
const { label } = comp;
|
|
|
|
// 버튼 액션 핸들러들
|
|
const handleSaveAction = async () => {
|
|
// EditModal에서 전달된 onSave가 있으면 우선 사용 (수정 모달)
|
|
if (onSave) {
|
|
try {
|
|
await onSave();
|
|
} catch (error: any) {
|
|
console.error("저장 오류:", error);
|
|
const msg =
|
|
error?.response?.data?.message ||
|
|
error?.message ||
|
|
"저장에 실패했습니다.";
|
|
toast.error(msg);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 일반 저장 액션 (신규 생성)
|
|
if (!screenInfo?.tableName) {
|
|
toast.error("테이블명이 설정되지 않았습니다.");
|
|
return;
|
|
}
|
|
|
|
// 리피터가 화면과 동일 테이블을 사용하는지 감지 (useCustomTable 미설정 = 동일 테이블)
|
|
const hasRepeaterOnSameTable = allComponents.some((c: any) => {
|
|
const compType = c.componentType || c.overrides?.type;
|
|
if (compType !== "v2-repeater") return false;
|
|
const compConfig = c.componentConfig || c.overrides || {};
|
|
return !compConfig.useCustomTable;
|
|
});
|
|
|
|
if (hasRepeaterOnSameTable) {
|
|
// 동일 테이블 리피터: 마스터 저장 스킵, 리피터만 저장
|
|
// 리피터가 mainFormData를 각 행에 병합하여 N건 INSERT 처리
|
|
try {
|
|
window.dispatchEvent(
|
|
new CustomEvent("repeaterSave", {
|
|
detail: {
|
|
parentId: null,
|
|
masterRecordId: null,
|
|
mainFormData: formData,
|
|
tableName: screenInfo.tableName,
|
|
},
|
|
}),
|
|
);
|
|
|
|
toast.success("데이터가 성공적으로 저장되었습니다.");
|
|
} catch (error: any) {
|
|
const msg =
|
|
error?.response?.data?.message ||
|
|
error?.message ||
|
|
"저장에 실패했습니다.";
|
|
toast.error(msg);
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장)
|
|
// 단, 파일 업로드 컴포넌트의 파일 배열(objid 배열)은 포함
|
|
const masterFormData: Record<string, any> = {};
|
|
|
|
// 파일 업로드 컴포넌트의 columnName 목록 수집 (v2-media, file-upload 모두 포함)
|
|
const mediaColumnNames = new Set(
|
|
allComponents
|
|
.filter((c: any) =>
|
|
c.componentType === "v2-media" ||
|
|
c.componentType === "file-upload" ||
|
|
c.url?.includes("v2-media") ||
|
|
c.url?.includes("file-upload")
|
|
)
|
|
.map((c: any) => c.columnName || c.componentConfig?.columnName)
|
|
.filter(Boolean)
|
|
);
|
|
|
|
Object.entries(formData).forEach(([key, value]) => {
|
|
if (!Array.isArray(value)) {
|
|
masterFormData[key] = value;
|
|
} else if (mediaColumnNames.has(key)) {
|
|
masterFormData[key] = value.length > 0 ? value[0] : null;
|
|
console.log(`📷 미디어 데이터 저장: ${key}, objid: ${masterFormData[key]}`);
|
|
} else {
|
|
console.log(`🔄 리피터 데이터 제외 (별도 저장): ${key}, ${value.length}개 항목`);
|
|
}
|
|
});
|
|
|
|
const saveData: DynamicFormData = {
|
|
tableName: screenInfo.tableName,
|
|
data: masterFormData,
|
|
};
|
|
|
|
const response = await dynamicFormApi.saveData(saveData);
|
|
|
|
if (response.success) {
|
|
const masterRecordId = response.data?.id || formData.id;
|
|
|
|
// 리피터 데이터 저장 이벤트 발생 (V2Repeater 컴포넌트가 리스닝)
|
|
window.dispatchEvent(
|
|
new CustomEvent("repeaterSave", {
|
|
detail: {
|
|
parentId: masterRecordId,
|
|
masterRecordId,
|
|
mainFormData: formData,
|
|
tableName: screenInfo.tableName,
|
|
},
|
|
}),
|
|
);
|
|
|
|
toast.success("데이터가 성공적으로 저장되었습니다.");
|
|
} else {
|
|
toast.error(response.message || "저장에 실패했습니다.");
|
|
}
|
|
} catch (error: any) {
|
|
const msg =
|
|
error?.response?.data?.message ||
|
|
error?.message ||
|
|
"저장에 실패했습니다.";
|
|
toast.error(msg);
|
|
}
|
|
};
|
|
|
|
const handleDeleteAction = async () => {
|
|
if (!config?.confirmationEnabled || window.confirm(config.confirmationMessage || "정말 삭제하시겠습니까?")) {
|
|
// console.log("🗑️ 삭제 액션 실행");
|
|
toast.success("삭제가 완료되었습니다.");
|
|
}
|
|
};
|
|
|
|
const handlePopupAction = () => {
|
|
if (config?.popupScreenId) {
|
|
setPopupScreen({
|
|
screenId: config.popupScreenId,
|
|
title: config.popupTitle || "팝업 화면",
|
|
size: config.popupSize || "medium",
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleNavigateAction = () => {
|
|
const navigateType = config?.navigateType || "url";
|
|
|
|
if (navigateType === "screen" && config?.navigateScreenId) {
|
|
const screenPath = `/screens/${config.navigateScreenId}`;
|
|
|
|
if (config.navigateTarget === "_blank") {
|
|
window.open(screenPath, "_blank");
|
|
} else {
|
|
window.location.href = screenPath;
|
|
}
|
|
} else if (navigateType === "url" && config?.navigateUrl) {
|
|
if (config.navigateTarget === "_blank") {
|
|
window.open(config.navigateUrl, "_blank");
|
|
} else {
|
|
window.location.href = config.navigateUrl;
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleCustomAction = async () => {
|
|
if (config?.customAction) {
|
|
try {
|
|
const result = eval(config.customAction);
|
|
if (result instanceof Promise) {
|
|
await result;
|
|
}
|
|
// console.log("⚡ 커스텀 액션 실행 완료");
|
|
} catch (error) {
|
|
throw new Error(`커스텀 액션 실행 실패: ${error.message}`);
|
|
}
|
|
}
|
|
};
|
|
|
|
// 🆕 즉시 저장(quickInsert) 액션 핸들러
|
|
const handleQuickInsertAction = async () => {
|
|
// componentConfig에서 quickInsertConfig 가져오기
|
|
const quickInsertConfig = (comp as any).componentConfig?.action?.quickInsertConfig;
|
|
|
|
if (!quickInsertConfig?.targetTable) {
|
|
toast.error("대상 테이블이 설정되지 않았습니다.");
|
|
return;
|
|
}
|
|
|
|
// 1. 대상 테이블의 컬럼 목록 조회 (자동 매핑용)
|
|
let targetTableColumns: string[] = [];
|
|
try {
|
|
const { default: apiClient } = await import("@/lib/api/client");
|
|
const columnsResponse = await apiClient.get(
|
|
`/table-management/tables/${quickInsertConfig.targetTable}/columns`,
|
|
);
|
|
if (columnsResponse.data?.success && columnsResponse.data?.data) {
|
|
const columnsData = columnsResponse.data.data.columns || columnsResponse.data.data;
|
|
targetTableColumns = columnsData.map((col: any) => col.columnName || col.column_name || col.name);
|
|
console.log("📍 대상 테이블 컬럼 목록:", targetTableColumns);
|
|
}
|
|
} catch (error) {
|
|
console.error("대상 테이블 컬럼 조회 실패:", error);
|
|
}
|
|
|
|
// 2. 컬럼 매핑에서 값 수집
|
|
const insertData: Record<string, any> = {};
|
|
const columnMappings = quickInsertConfig.columnMappings || [];
|
|
|
|
for (const mapping of columnMappings) {
|
|
let value: any;
|
|
|
|
switch (mapping.sourceType) {
|
|
case "component":
|
|
// 같은 화면의 컴포넌트에서 값 가져오기
|
|
// 방법1: sourceColumnName 사용
|
|
if (mapping.sourceColumnName && formData[mapping.sourceColumnName] !== undefined) {
|
|
value = formData[mapping.sourceColumnName];
|
|
console.log(`📍 컴포넌트 값 (sourceColumnName): ${mapping.sourceColumnName} = ${value}`);
|
|
}
|
|
// 방법2: sourceComponentId로 컴포넌트 찾아서 columnName 사용
|
|
else if (mapping.sourceComponentId) {
|
|
const sourceComp = allComponents.find((c: any) => c.id === mapping.sourceComponentId);
|
|
if (sourceComp) {
|
|
const fieldName = (sourceComp as any).columnName || sourceComp.id;
|
|
value = formData[fieldName];
|
|
console.log(`📍 컴포넌트 값 (컴포넌트 조회): ${fieldName} = ${value}`);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case "leftPanel":
|
|
// 분할 패널 좌측 선택 데이터에서 값 가져오기
|
|
if (mapping.sourceColumn && splitPanelContext?.selectedLeftData) {
|
|
value = splitPanelContext.selectedLeftData[mapping.sourceColumn];
|
|
}
|
|
break;
|
|
|
|
case "fixed":
|
|
value = mapping.fixedValue;
|
|
break;
|
|
|
|
case "currentUser":
|
|
if (mapping.userField) {
|
|
switch (mapping.userField) {
|
|
case "userId":
|
|
value = user?.userId;
|
|
break;
|
|
case "userName":
|
|
value = userName;
|
|
break;
|
|
case "companyCode":
|
|
value = user?.companyCode;
|
|
break;
|
|
case "deptCode":
|
|
value = authUser?.deptCode;
|
|
break;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (value !== undefined && value !== null && value !== "") {
|
|
insertData[mapping.targetColumn] = value;
|
|
}
|
|
}
|
|
|
|
// 3. 좌측 패널 선택 데이터에서 자동 매핑 (컬럼명이 같고 대상 테이블에 있는 경우)
|
|
if (splitPanelContext?.selectedLeftData && targetTableColumns.length > 0) {
|
|
const leftData = splitPanelContext.selectedLeftData;
|
|
console.log("📍 좌측 패널 자동 매핑 시작:", leftData);
|
|
|
|
for (const [key, val] of Object.entries(leftData)) {
|
|
// 이미 매핑된 컬럼은 스킵
|
|
if (insertData[key] !== undefined) {
|
|
continue;
|
|
}
|
|
|
|
// 대상 테이블에 해당 컬럼이 없으면 스킵
|
|
if (!targetTableColumns.includes(key)) {
|
|
continue;
|
|
}
|
|
|
|
// 시스템 컬럼 제외
|
|
const systemColumns = ["id", "created_date", "updated_date", "writer", "writer_name"];
|
|
if (systemColumns.includes(key)) {
|
|
continue;
|
|
}
|
|
|
|
// _label, _name 으로 끝나는 표시용 컬럼 제외
|
|
if (key.endsWith("_label") || key.endsWith("_name")) {
|
|
continue;
|
|
}
|
|
|
|
// 값이 있으면 자동 추가
|
|
if (val !== undefined && val !== null && val !== "") {
|
|
insertData[key] = val;
|
|
console.log(`📍 자동 매핑 추가: ${key} = ${val}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log("🚀 quickInsert 최종 데이터:", insertData);
|
|
|
|
// 4. 필수값 검증
|
|
if (Object.keys(insertData).length === 0) {
|
|
toast.error("저장할 데이터가 없습니다. 값을 선택해주세요.");
|
|
return;
|
|
}
|
|
|
|
// 5. 중복 체크 (설정된 경우)
|
|
if (quickInsertConfig.duplicateCheck?.enabled && quickInsertConfig.duplicateCheck?.columns?.length > 0) {
|
|
try {
|
|
const { default: apiClient } = await import("@/lib/api/client");
|
|
|
|
// 중복 체크를 위한 검색 조건 구성
|
|
const searchConditions: Record<string, any> = {};
|
|
for (const col of quickInsertConfig.duplicateCheck.columns) {
|
|
if (insertData[col] !== undefined) {
|
|
searchConditions[col] = { value: insertData[col], operator: "equals" };
|
|
}
|
|
}
|
|
|
|
console.log("📍 중복 체크 조건:", searchConditions);
|
|
|
|
// 기존 데이터 조회
|
|
const checkResponse = await apiClient.post(`/table-management/tables/${quickInsertConfig.targetTable}/data`, {
|
|
page: 1,
|
|
pageSize: 1,
|
|
search: searchConditions,
|
|
});
|
|
|
|
console.log("📍 중복 체크 응답:", checkResponse.data);
|
|
|
|
// data 배열이 있고 길이가 0보다 크면 중복
|
|
const existingData = checkResponse.data?.data?.data || checkResponse.data?.data || [];
|
|
if (Array.isArray(existingData) && existingData.length > 0) {
|
|
toast.error(quickInsertConfig.duplicateCheck.errorMessage || "이미 존재하는 데이터입니다.");
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
console.error("중복 체크 오류:", error);
|
|
// 중복 체크 실패 시 계속 진행
|
|
}
|
|
}
|
|
|
|
// 6. API 호출
|
|
try {
|
|
const { default: apiClient } = await import("@/lib/api/client");
|
|
|
|
const response = await apiClient.post(
|
|
`/table-management/tables/${quickInsertConfig.targetTable}/add`,
|
|
insertData,
|
|
);
|
|
|
|
if (response.data?.success) {
|
|
// 7. 성공 후 동작
|
|
if (quickInsertConfig.afterInsert?.showSuccessMessage !== false) {
|
|
toast.success(quickInsertConfig.afterInsert?.successMessage || "저장되었습니다.");
|
|
}
|
|
|
|
// 데이터 새로고침 (테이블리스트, 카드 디스플레이)
|
|
if (quickInsertConfig.afterInsert?.refreshData !== false) {
|
|
console.log("📍 데이터 새로고침 이벤트 발송");
|
|
if (typeof window !== "undefined") {
|
|
window.dispatchEvent(new CustomEvent("refreshTable"));
|
|
window.dispatchEvent(new CustomEvent("refreshCardDisplay"));
|
|
}
|
|
}
|
|
|
|
// 지정된 컴포넌트 초기화
|
|
if (quickInsertConfig.afterInsert?.clearComponents?.length > 0) {
|
|
for (const componentId of quickInsertConfig.afterInsert.clearComponents) {
|
|
const targetComp = allComponents.find((c: any) => c.id === componentId);
|
|
if (targetComp) {
|
|
const fieldName = (targetComp as any).columnName || targetComp.id;
|
|
onFormDataChange?.(fieldName, "");
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
toast.error(response.data?.message || "저장에 실패했습니다.");
|
|
}
|
|
} catch (error: any) {
|
|
console.error("quickInsert 오류:", error);
|
|
toast.error(error.response?.data?.message || error.message || "저장 중 오류가 발생했습니다.");
|
|
}
|
|
};
|
|
|
|
const handleClick = async () => {
|
|
try {
|
|
const actionType = config?.actionType || "save";
|
|
|
|
switch (actionType) {
|
|
case "save":
|
|
await handleSaveAction();
|
|
break;
|
|
case "delete":
|
|
await handleDeleteAction();
|
|
break;
|
|
case "popup":
|
|
handlePopupAction();
|
|
break;
|
|
case "navigate":
|
|
handleNavigateAction();
|
|
break;
|
|
case "custom":
|
|
await handleCustomAction();
|
|
break;
|
|
case "quickInsert":
|
|
await handleQuickInsertAction();
|
|
break;
|
|
default:
|
|
// console.log("🔘 기본 버튼 클릭");
|
|
}
|
|
} catch (error) {
|
|
// console.error("버튼 액션 오류:", error);
|
|
toast.error(error.message || "액션 실행 중 오류가 발생했습니다.");
|
|
}
|
|
};
|
|
|
|
// 커스텀 색상이 있으면 Tailwind 클래스 대신 직접 스타일 적용
|
|
const hasCustomColors = config?.backgroundColor || config?.textColor || comp.style?.backgroundColor || comp.style?.color;
|
|
|
|
return (
|
|
<button
|
|
onClick={handleClick}
|
|
disabled={config?.disabled}
|
|
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={{
|
|
// 컴포넌트 스타일 적용
|
|
...comp.style,
|
|
// 설정값이 있으면 우선 적용
|
|
backgroundColor: config?.backgroundColor || comp.style?.backgroundColor,
|
|
color: config?.textColor || comp.style?.color,
|
|
// 부모 컨테이너 크기에 맞춤
|
|
width: "100%",
|
|
height: "100%",
|
|
}}
|
|
>
|
|
{label || "버튼"}
|
|
</button>
|
|
);
|
|
};
|
|
|
|
// 파일 컴포넌트 렌더링
|
|
const renderFileComponent = (comp: FileComponent) => {
|
|
const { label, readonly } = comp;
|
|
const fieldName = comp.columnName || comp.id;
|
|
|
|
// 화면 ID 추출 (URL에서)
|
|
const screenId =
|
|
screenInfo?.screenId ||
|
|
(typeof window !== "undefined" && window.location.pathname.includes("/screens/")
|
|
? parseInt(window.location.pathname.split("/screens/")[1])
|
|
: null);
|
|
|
|
return (
|
|
<div className="h-full w-full">
|
|
{/* 실제 FileUploadComponent 사용 */}
|
|
<FileUploadComponent
|
|
component={comp}
|
|
componentConfig={{
|
|
...comp.fileConfig,
|
|
multiple: comp.fileConfig?.multiple !== false,
|
|
accept: comp.fileConfig?.accept || "*/*",
|
|
maxSize: (comp.fileConfig?.maxSize || 10) * 1024 * 1024, // MB to bytes
|
|
disabled: readonly,
|
|
}}
|
|
componentStyle={{
|
|
width: "100%",
|
|
height: "100%",
|
|
}}
|
|
className="h-full w-full"
|
|
isInteractive={true}
|
|
isDesignMode={false}
|
|
formData={{
|
|
screenId, // 🎯 화면 ID 전달
|
|
// 🎯 백엔드 API가 기대하는 정확한 형식으로 설정
|
|
autoLink: true, // 자동 연결 활성화
|
|
linkedTable: "screen_files", // 연결 테이블
|
|
recordId: screenId, // 레코드 ID
|
|
columnName: fieldName, // 컬럼명 (중요!)
|
|
isVirtualFileColumn: true, // 가상 파일 컬럼
|
|
id: formData.id,
|
|
...formData,
|
|
}}
|
|
onFormDataChange={(data) => {
|
|
// console.log("📝 실제 화면 파일 업로드 완료:", data);
|
|
if (onFormDataChange) {
|
|
Object.entries(data).forEach(([key, value]) => {
|
|
onFormDataChange(key, value);
|
|
});
|
|
}
|
|
}}
|
|
onUpdate={(updates) => {
|
|
console.log("🔄🔄🔄 실제 화면 파일 컴포넌트 업데이트:", {
|
|
componentId: comp.id,
|
|
hasUploadedFiles: !!updates.uploadedFiles,
|
|
filesCount: updates.uploadedFiles?.length || 0,
|
|
hasLastFileUpdate: !!updates.lastFileUpdate,
|
|
updates,
|
|
});
|
|
|
|
// 파일 업로드/삭제 완료 시 formData 업데이터
|
|
if (updates.uploadedFiles && onFormDataChange) {
|
|
onFormDataChange(fieldName, updates.uploadedFiles);
|
|
}
|
|
|
|
// 🎯 화면설계 모드와 동기화를 위한 전역 이벤트 발생 (업로드/삭제 모두)
|
|
if (updates.uploadedFiles !== undefined && typeof window !== "undefined") {
|
|
// 업로드인지 삭제인지 판단 (lastFileUpdate가 있으면 변경사항 있음)
|
|
const action = updates.lastFileUpdate ? "update" : "sync";
|
|
|
|
const eventDetail = {
|
|
componentId: comp.id,
|
|
files: updates.uploadedFiles,
|
|
fileCount: updates.uploadedFiles.length,
|
|
action: action,
|
|
timestamp: updates.lastFileUpdate || Date.now(),
|
|
source: "realScreen", // 실제 화면에서 온 이벤트임을 표시
|
|
};
|
|
|
|
// console.log("🚀🚀🚀 실제 화면 파일 변경 이벤트 발생:", eventDetail);
|
|
|
|
const event = new CustomEvent("globalFileStateChanged", {
|
|
detail: eventDetail,
|
|
});
|
|
window.dispatchEvent(event);
|
|
|
|
// console.log("✅✅✅ 실제 화면 → 화면설계 모드 동기화 이벤트 발생 완료");
|
|
|
|
// 추가 지연 이벤트들 (화면설계 모드가 열려있을 때를 대비)
|
|
setTimeout(() => {
|
|
// console.log("🔄 실제 화면 추가 이벤트 발생 (지연 100ms)");
|
|
window.dispatchEvent(
|
|
new CustomEvent("globalFileStateChanged", {
|
|
detail: { ...eventDetail, delayed: true },
|
|
}),
|
|
);
|
|
}, 100);
|
|
|
|
setTimeout(() => {
|
|
// console.log("🔄 실제 화면 추가 이벤트 발생 (지연 500ms)");
|
|
window.dispatchEvent(
|
|
new CustomEvent("globalFileStateChanged", {
|
|
detail: { ...eventDetail, delayed: true, attempt: 2 },
|
|
}),
|
|
);
|
|
}, 500);
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 메인 렌더링
|
|
const { type, position, size, style = {} } = component;
|
|
|
|
// ✅ 격자 시스템 잔재 제거: style.width, style.height 무시
|
|
const { width: styleWidth, height: styleHeight, ...styleWithoutSize } = style;
|
|
|
|
// TableSearchWidget의 경우 높이를 자동으로 설정
|
|
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
|
|
|
|
// 라벨 표시 여부 확인 (V2 입력 컴포넌트)
|
|
const compType = (component as any).componentType || "";
|
|
const isV2InputComponent =
|
|
type === "v2-input" || type === "v2-select" || type === "v2-date" ||
|
|
compType === "v2-input" || compType === "v2-select" || compType === "v2-date";
|
|
const hasVisibleLabel = isV2InputComponent &&
|
|
style?.labelDisplay !== false && style?.labelDisplay !== "false" &&
|
|
(style?.labelText || (component as any).label);
|
|
|
|
// 라벨 위치에 따라 오프셋 계산 (좌/우 배치 시 세로 오프셋 불필요)
|
|
const labelPos = style?.labelPosition || "top";
|
|
const isVerticalLabel = labelPos === "top" || labelPos === "bottom";
|
|
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
|
|
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
|
const labelOffset = (hasVisibleLabel && isVerticalLabel) ? (labelFontSize + labelMarginBottom + 2) : 0;
|
|
|
|
// 수평 라벨 관련 (componentStyle 계산보다 먼저 선언)
|
|
const needsExternalLabel = hasVisibleLabel && labelPos !== "top";
|
|
const isHorizLabel = labelPos === "left" || labelPos === "right";
|
|
const labelText = style?.labelText || (component as any).label || "";
|
|
const labelGapValue = style?.labelGap || "8px";
|
|
|
|
const calculateCanvasSplitX = (): { x: number; w: number } => {
|
|
const compType = (component as any).componentType || "";
|
|
const isSplitLine = type === "component" && compType === "v2-split-line";
|
|
const origX = position?.x || 0;
|
|
const defaultW = size?.width || 200;
|
|
|
|
if (isSplitLine) return { x: origX, w: defaultW };
|
|
|
|
if (!canvasSplit.active || canvasSplit.canvasWidth <= 0 || !canvasSplit.scopeId) {
|
|
return { x: origX, w: defaultW };
|
|
}
|
|
|
|
if (myScopeIdRef.current === null) {
|
|
const el = document.getElementById(`interactive-${component.id}`);
|
|
const container = el?.closest("[data-screen-runtime]");
|
|
myScopeIdRef.current = container?.getAttribute("data-split-scope") || "__none__";
|
|
}
|
|
if (myScopeIdRef.current !== canvasSplit.scopeId) {
|
|
return { x: origX, w: defaultW };
|
|
}
|
|
|
|
const { initialDividerX, currentDividerX, canvasWidth } = canvasSplit;
|
|
const delta = currentDividerX - initialDividerX;
|
|
if (Math.abs(delta) < 1) return { x: origX, w: defaultW };
|
|
|
|
const origW = defaultW;
|
|
if (canvasSplitSideRef.current === null) {
|
|
const componentCenterX = origX + (origW / 2);
|
|
canvasSplitSideRef.current = componentCenterX < initialDividerX ? "left" : "right";
|
|
}
|
|
|
|
// 영역별 비례 스케일링: 스플릿선이 벽 역할 → 절대 넘어가지 않음
|
|
let newX: number;
|
|
let newW: number;
|
|
const GAP = 4; // 스플릿선과의 최소 간격
|
|
|
|
if (canvasSplitSideRef.current === "left") {
|
|
// 왼쪽 영역: [0, currentDividerX - GAP]
|
|
const initialZoneWidth = initialDividerX;
|
|
const currentZoneWidth = Math.max(20, currentDividerX - GAP);
|
|
const scale = initialZoneWidth > 0 ? currentZoneWidth / initialZoneWidth : 1;
|
|
newX = origX * scale;
|
|
newW = origW * scale;
|
|
// 안전 클램핑: 왼쪽 영역을 절대 넘지 않음
|
|
if (newX + newW > currentDividerX - GAP) {
|
|
newW = currentDividerX - GAP - newX;
|
|
}
|
|
} else {
|
|
// 오른쪽 영역: [currentDividerX + GAP, canvasWidth]
|
|
const initialRightWidth = canvasWidth - initialDividerX;
|
|
const currentRightWidth = Math.max(20, canvasWidth - currentDividerX - GAP);
|
|
const scale = initialRightWidth > 0 ? currentRightWidth / initialRightWidth : 1;
|
|
const rightOffset = origX - initialDividerX;
|
|
newX = currentDividerX + GAP + rightOffset * scale;
|
|
newW = origW * scale;
|
|
// 안전 클램핑: 오른쪽 영역을 절대 넘지 않음
|
|
if (newX < currentDividerX + GAP) newX = currentDividerX + GAP;
|
|
if (newX + newW > canvasWidth) newW = canvasWidth - newX;
|
|
}
|
|
|
|
newX = Math.max(0, newX);
|
|
newW = Math.max(20, newW);
|
|
|
|
return { x: newX, w: newW };
|
|
};
|
|
|
|
const splitResult = calculateCanvasSplitX();
|
|
const adjustedX = splitResult.x;
|
|
const adjustedW = splitResult.w;
|
|
const origW = size?.width || 200;
|
|
const isSplitActive = canvasSplit.active && canvasSplit.scopeId && myScopeIdRef.current === canvasSplit.scopeId;
|
|
|
|
// styleWithoutSize에서 left/top 제거 (캔버스 분할 조정값 덮어쓰기 방지)
|
|
const { left: _styleLeft, top: _styleTop, ...safeStyleWithoutSize } = styleWithoutSize as any;
|
|
|
|
// 수평 라벨 컴포넌트: position wrapper에서 border 제거 (내부 V2 컴포넌트가 기본 border 사용)
|
|
const cleanedStyle = (isHorizLabel && needsExternalLabel)
|
|
? (() => {
|
|
const { borderWidth: _bw, borderColor: _bc, borderStyle: _bs, border: _b, borderRadius: _br, ...rest } = safeStyleWithoutSize;
|
|
return rest;
|
|
})()
|
|
: safeStyleWithoutSize;
|
|
|
|
const componentStyle = {
|
|
position: "absolute" as const,
|
|
...cleanedStyle,
|
|
// left/top은 반드시 마지막에 (styleWithoutSize가 덮어쓰지 못하게)
|
|
left: adjustedX,
|
|
top: position?.y || 0,
|
|
zIndex: position?.z || 1,
|
|
width: isSplitActive ? adjustedW : (size?.width || 200),
|
|
height: isTableSearchWidget ? "auto" : size?.height || 10,
|
|
minHeight: isTableSearchWidget ? "48px" : undefined,
|
|
overflow: (isSplitActive && adjustedW < origW) ? "hidden" : (labelOffset > 0 ? "visible" : undefined),
|
|
willChange: canvasSplit.isDragging && isSplitActive ? "left, width" as const : undefined,
|
|
transition: isSplitActive
|
|
? (canvasSplit.isDragging ? "none" : "left 0.15s ease-out, width 0.15s ease-out")
|
|
: undefined,
|
|
};
|
|
|
|
// 스플릿 조정된 컴포넌트 객체 캐싱 (드래그 끝난 후 최종 렌더링용)
|
|
const splitAdjustedComponent = React.useMemo(() => {
|
|
if (isSplitActive && adjustedW !== origW) {
|
|
return { ...component, size: { ...(component as any).size, width: Math.round(adjustedW) } };
|
|
}
|
|
return component;
|
|
}, [component, isSplitActive, adjustedW, origW]);
|
|
|
|
// 드래그 중 DOM 직접 조작 (React 리렌더 없이 매 프레임 업데이트)
|
|
const elRef = React.useRef<HTMLDivElement>(null);
|
|
React.useEffect(() => {
|
|
const compType = (component as any).componentType || "";
|
|
if (type === "component" && compType === "v2-split-line") return;
|
|
|
|
const unsubscribe = canvasSplitSubscribeDom((snap) => {
|
|
if (!snap.isDragging || !snap.active || !snap.scopeId) return;
|
|
if (myScopeIdRef.current !== snap.scopeId) return;
|
|
const el = elRef.current;
|
|
if (!el) return;
|
|
|
|
const origX = position?.x || 0;
|
|
const oW = size?.width || 200;
|
|
const { initialDividerX, currentDividerX, canvasWidth } = snap;
|
|
const delta = currentDividerX - initialDividerX;
|
|
if (Math.abs(delta) < 1) return;
|
|
|
|
if (canvasSplitSideRef.current === null) {
|
|
canvasSplitSideRef.current = (origX + oW / 2) < initialDividerX ? "left" : "right";
|
|
}
|
|
|
|
const GAP = 4;
|
|
let nx: number, nw: number;
|
|
if (canvasSplitSideRef.current === "left") {
|
|
const scale = initialDividerX > 0 ? Math.max(20, currentDividerX - GAP) / initialDividerX : 1;
|
|
nx = origX * scale;
|
|
nw = oW * scale;
|
|
if (nx + nw > currentDividerX - GAP) nw = currentDividerX - GAP - nx;
|
|
} else {
|
|
const irw = canvasWidth - initialDividerX;
|
|
const crw = Math.max(20, canvasWidth - currentDividerX - GAP);
|
|
const scale = irw > 0 ? crw / irw : 1;
|
|
nx = currentDividerX + GAP + (origX - initialDividerX) * scale;
|
|
nw = oW * scale;
|
|
if (nx < currentDividerX + GAP) nx = currentDividerX + GAP;
|
|
if (nx + nw > canvasWidth) nw = canvasWidth - nx;
|
|
}
|
|
nx = Math.max(0, nx);
|
|
nw = Math.max(20, nw);
|
|
|
|
el.style.left = `${nx}px`;
|
|
el.style.width = `${Math.round(nw)}px`;
|
|
el.style.overflow = nw < oW ? "hidden" : "";
|
|
});
|
|
return unsubscribe;
|
|
}, [component.id, position?.x, size?.width, type]);
|
|
|
|
// needsExternalLabel, isHorizLabel, labelText, labelGapValue는 위에서 선언됨
|
|
|
|
const externalLabelComponent = needsExternalLabel ? (
|
|
<label
|
|
className="text-sm font-medium leading-none"
|
|
style={{
|
|
fontSize: style?.labelFontSize || "14px",
|
|
color: style?.labelColor || "#212121",
|
|
fontWeight: style?.labelFontWeight || "500",
|
|
...(isHorizLabel ? { whiteSpace: "nowrap" as const, display: "flex", alignItems: "center" } : {}),
|
|
...(labelPos === "bottom" ? { marginTop: style?.labelMarginBottom || "4px" } : {}),
|
|
}}
|
|
>
|
|
{labelText}{((component as any).required || (component as any).componentConfig?.required || isColumnRequiredByMeta((component as any).tableName, (component as any).columnName)) && (
|
|
<span className="text-orange-500">*</span>
|
|
)}
|
|
</label>
|
|
) : null;
|
|
|
|
const componentToRender = needsExternalLabel
|
|
? {
|
|
...splitAdjustedComponent,
|
|
style: {
|
|
...splitAdjustedComponent.style,
|
|
labelDisplay: false,
|
|
labelPosition: "top" as const,
|
|
...(isHorizLabel ? {
|
|
width: "100%",
|
|
height: "100%",
|
|
borderWidth: undefined,
|
|
borderColor: undefined,
|
|
borderStyle: undefined,
|
|
border: undefined,
|
|
borderRadius: undefined,
|
|
} : {}),
|
|
},
|
|
...(isHorizLabel ? {
|
|
size: {
|
|
...splitAdjustedComponent.size,
|
|
width: undefined as unknown as number,
|
|
height: undefined as unknown as number,
|
|
},
|
|
} : {}),
|
|
}
|
|
: splitAdjustedComponent;
|
|
|
|
return (
|
|
<>
|
|
<div ref={elRef} id={`interactive-${component.id}`} className="absolute" style={componentStyle}>
|
|
{needsExternalLabel ? (
|
|
isHorizLabel ? (
|
|
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
|
<label
|
|
className="text-sm font-medium leading-none"
|
|
style={{
|
|
position: "absolute",
|
|
top: "50%",
|
|
transform: "translateY(-50%)",
|
|
...(labelPos === "left"
|
|
? { right: "100%", marginRight: labelGapValue }
|
|
: { left: "100%", marginLeft: labelGapValue }),
|
|
fontSize: style?.labelFontSize || "14px",
|
|
color: style?.labelColor || "#212121",
|
|
fontWeight: style?.labelFontWeight || "500",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{labelText}{((component as any).required || (component as any).componentConfig?.required || isColumnRequiredByMeta((component as any).tableName, (component as any).columnName)) && (
|
|
<span className="text-orange-500">*</span>
|
|
)}
|
|
</label>
|
|
<div style={{ width: "100%", height: "100%" }}>
|
|
{renderInteractiveWidget(componentToRender)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column-reverse",
|
|
width: "100%",
|
|
height: "100%",
|
|
}}
|
|
>
|
|
{externalLabelComponent}
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
{renderInteractiveWidget(componentToRender)}
|
|
</div>
|
|
</div>
|
|
)
|
|
) : (
|
|
renderInteractiveWidget(componentToRender)
|
|
)}
|
|
</div>
|
|
|
|
{/* 팝업 화면 렌더링 */}
|
|
{popupScreen && (
|
|
<Dialog open={!!popupScreen} onOpenChange={() => setPopupScreen(null)}>
|
|
<DialogContent
|
|
className="max-w-none overflow-hidden p-0"
|
|
style={{
|
|
width: popupScreen.size === "small" ? "600px" : popupScreen.size === "large" ? "1400px" : "1000px",
|
|
height: "800px",
|
|
maxWidth: "95vw",
|
|
maxHeight: "90vh",
|
|
}}
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle>{popupScreen.title}</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
{popupLoading ? (
|
|
<div className="flex items-center justify-center p-8">
|
|
<div className="text-gray-500">로딩 중...</div>
|
|
</div>
|
|
) : (
|
|
<div
|
|
className="relative overflow-auto"
|
|
style={{
|
|
width: popupScreenResolution?.width || 1200,
|
|
height: popupScreenResolution?.height || 600,
|
|
maxWidth: "100%",
|
|
maxHeight: "70vh",
|
|
}}
|
|
>
|
|
{popupLayout.map((popupComponent) => (
|
|
<InteractiveScreenViewerDynamic
|
|
key={popupComponent.id}
|
|
component={popupComponent}
|
|
allComponents={popupLayout}
|
|
formData={popupFormData}
|
|
onFormDataChange={(fieldName, value) => {
|
|
setPopupFormData((prev) => ({ ...prev, [fieldName]: value }));
|
|
}}
|
|
screenInfo={popupScreenInfo}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
// 기존 InteractiveScreenViewer와의 호환성을 위한 export
|
|
export { InteractiveScreenViewerDynamic as InteractiveScreenViewer };
|
|
|
|
InteractiveScreenViewerDynamic.displayName = "InteractiveScreenViewerDynamic";
|