Files
vexplor/frontend/components/screen/InteractiveScreenViewerDynamic.tsx
DDD1542 a8ad26cf30 refactor: Enhance horizontal label handling in dynamic components
- Updated the InteractiveScreenViewerDynamic and RealtimePreviewDynamic components to improve horizontal label rendering and style management.
- Refactored the DynamicComponentRenderer to support external horizontal labels, ensuring proper display and positioning based on component styles.
- Cleaned up style handling by removing unnecessary border properties for horizontal labels, enhancing visual consistency.
- Improved the logic for determining label display requirements, streamlining the rendering process for dynamic components.
2026-02-27 15:24:55 +09:00

1436 lines
54 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 } 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) {
console.error("저장 오류:", error);
toast.error("저장 중 오류가 발생했습니다.");
}
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) {
toast.error("저장 중 오류가 발생했습니다.");
}
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) {
toast.error("저장 중 오류가 발생했습니다.");
}
};
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) && (
<span className="ml-1 text-destructive">*</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) && (
<span className="ml-1 text-destructive">*</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";