2025-09-09 14:29:04 +09:00
|
|
|
"use client";
|
|
|
|
|
|
2025-11-06 12:11:49 +09:00
|
|
|
import React, { useState, useCallback, useEffect } from "react";
|
2025-09-09 14:29:04 +09:00
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
2025-12-05 10:46:10 +09:00
|
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
2025-09-09 14:29:04 +09:00
|
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
|
|
|
import { uploadFilesAndCreateData } from "@/lib/api/file";
|
|
|
|
|
import { toast } from "sonner";
|
|
|
|
|
import { ComponentData, WidgetComponent, DataTableComponent, FileComponent, ButtonTypeConfig } from "@/types/screen";
|
2025-09-26 13:11:34 +09:00
|
|
|
import { FileUploadComponent } from "@/lib/registry/components/file-upload/FileUploadComponent";
|
2025-09-09 14:29:04 +09:00
|
|
|
import { InteractiveDataTable } from "./InteractiveDataTable";
|
|
|
|
|
import { DynamicWebTypeRenderer } from "@/lib/registry";
|
2025-09-10 14:09:32 +09:00
|
|
|
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
2025-09-19 02:15:21 +09:00
|
|
|
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
2025-09-29 13:29:03 +09:00
|
|
|
import { isFileComponent, isDataTableComponent, isButtonComponent } from "@/lib/utils/componentTypeUtils";
|
2025-10-24 10:37:02 +09:00
|
|
|
import { FlowButtonGroup } from "./widgets/FlowButtonGroup";
|
|
|
|
|
import { FlowVisibilityConfig } from "@/types/control-management";
|
|
|
|
|
import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils";
|
2025-10-28 15:39:22 +09:00
|
|
|
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
2025-12-02 18:03:52 +09:00
|
|
|
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
2025-09-10 14:09:32 +09:00
|
|
|
|
|
|
|
|
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
|
|
|
|
|
import "@/lib/registry/components/ButtonRenderer";
|
|
|
|
|
import "@/lib/registry/components/CardRenderer";
|
|
|
|
|
import "@/lib/registry/components/DashboardRenderer";
|
|
|
|
|
import "@/lib/registry/components/WidgetRenderer";
|
2025-09-09 14:29:04 +09:00
|
|
|
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;
|
|
|
|
|
};
|
2025-12-17 15:00:15 +09:00
|
|
|
menuObjid?: number; // 메뉴 OBJID (코드 스코프용)
|
2025-11-03 09:58:04 +09:00
|
|
|
onSave?: () => Promise<void>;
|
2025-11-13 17:42:20 +09:00
|
|
|
onRefresh?: () => void;
|
|
|
|
|
onFlowRefresh?: () => void;
|
2025-12-17 15:00:15 +09:00
|
|
|
// 외부에서 전달받는 사용자 정보 (ScreenModal 등에서 사용)
|
2025-11-20 15:30:00 +09:00
|
|
|
userId?: string;
|
|
|
|
|
userName?: string;
|
|
|
|
|
companyCode?: string;
|
2025-12-17 15:00:15 +09:00
|
|
|
// 그룹 데이터 (EditModal에서 전달)
|
2025-11-24 15:24:31 +09:00
|
|
|
groupedData?: Record<string, any>[];
|
2025-12-17 15:00:15 +09:00
|
|
|
// 비활성화할 필드 목록 (EditModal에서 전달)
|
2025-11-25 14:23:54 +09:00
|
|
|
disabledFields?: string[];
|
2025-12-17 15:00:15 +09:00
|
|
|
// EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록)
|
2025-11-25 12:07:14 +09:00
|
|
|
isInModal?: boolean;
|
2025-12-17 15:00:15 +09:00
|
|
|
// 원본 데이터 (수정 모드에서 UPDATE 판단용)
|
2025-12-01 15:21:03 +09:00
|
|
|
originalData?: Record<string, any> | null;
|
2025-12-17 15:00:15 +09:00
|
|
|
// 탭 관련 정보 (탭 내부의 컴포넌트에서 사용)
|
|
|
|
|
parentTabId?: string; // 부모 탭 ID
|
|
|
|
|
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
|
2025-09-09 14:29:04 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerProps> = ({
|
|
|
|
|
component,
|
|
|
|
|
allComponents,
|
|
|
|
|
formData: externalFormData,
|
|
|
|
|
onFormDataChange,
|
|
|
|
|
hideLabel = false,
|
|
|
|
|
screenInfo,
|
2025-11-25 15:55:05 +09:00
|
|
|
menuObjid,
|
2025-11-03 09:58:04 +09:00
|
|
|
onSave,
|
2025-11-13 17:42:20 +09:00
|
|
|
onRefresh,
|
|
|
|
|
onFlowRefresh,
|
2025-11-20 15:30:00 +09:00
|
|
|
userId: externalUserId,
|
|
|
|
|
userName: externalUserName,
|
|
|
|
|
companyCode: externalCompanyCode,
|
2025-11-24 15:24:31 +09:00
|
|
|
groupedData,
|
2025-11-25 14:23:54 +09:00
|
|
|
disabledFields = [],
|
2025-11-25 12:07:14 +09:00
|
|
|
isInModal = false,
|
2025-12-17 15:00:15 +09:00
|
|
|
originalData,
|
|
|
|
|
parentTabId,
|
|
|
|
|
parentTabsComponentId,
|
2025-09-09 14:29:04 +09:00
|
|
|
}) => {
|
2025-10-28 15:39:22 +09:00
|
|
|
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
2025-11-20 15:30:00 +09:00
|
|
|
const { userName: authUserName, user: authUser } = useAuth();
|
2025-12-02 18:03:52 +09:00
|
|
|
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
|
2025-11-20 15:30:00 +09:00
|
|
|
|
|
|
|
|
// 외부에서 전달받은 사용자 정보가 있으면 우선 사용 (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;
|
2025-09-09 14:29:04 +09:00
|
|
|
const [localFormData, setLocalFormData] = useState<Record<string, any>>({});
|
|
|
|
|
const [dateValues, setDateValues] = useState<Record<string, Date | undefined>>({});
|
|
|
|
|
|
2025-10-23 13:15:52 +09:00
|
|
|
// 테이블에서 선택된 행 데이터 (버튼 액션에 전달)
|
|
|
|
|
const [selectedRowsData, setSelectedRowsData] = useState<any[]>([]);
|
|
|
|
|
|
2025-10-23 17:26:14 +09:00
|
|
|
// 플로우에서 선택된 데이터 (버튼 액션에 전달)
|
|
|
|
|
const [flowSelectedData, setFlowSelectedData] = useState<any[]>([]);
|
|
|
|
|
const [flowSelectedStepId, setFlowSelectedStepId] = useState<number | null>(null);
|
|
|
|
|
|
2025-09-09 14:29:04 +09:00
|
|
|
// 팝업 화면 상태
|
|
|
|
|
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>>({});
|
|
|
|
|
|
2025-12-02 18:03:52 +09:00
|
|
|
// 🆕 분할 패널에서 매핑된 부모 데이터 가져오기
|
2025-12-05 14:08:07 +09:00
|
|
|
// disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달)
|
2025-12-02 18:03:52 +09:00
|
|
|
const splitPanelMappedData = React.useMemo(() => {
|
2025-12-05 14:08:07 +09:00
|
|
|
if (splitPanelContext && !splitPanelContext.disableAutoDataTransfer) {
|
2025-12-02 18:03:52 +09:00
|
|
|
return splitPanelContext.getMappedParentData();
|
|
|
|
|
}
|
|
|
|
|
return {};
|
2025-12-05 14:08:07 +09:00
|
|
|
}, [splitPanelContext, splitPanelContext?.selectedLeftData, splitPanelContext?.disableAutoDataTransfer]);
|
2025-12-02 18:03:52 +09:00
|
|
|
|
|
|
|
|
// formData 결정 (외부에서 전달받은 것이 있으면 우선 사용, 분할 패널 데이터도 병합)
|
|
|
|
|
const formData = React.useMemo(() => {
|
|
|
|
|
const baseData = externalFormData || localFormData;
|
|
|
|
|
// 분할 패널 매핑 데이터가 있으면 병합 (기존 값이 없는 경우에만)
|
2025-12-05 14:08:07 +09:00
|
|
|
// disableAutoDataTransfer가 true이면 자동 병합 안함
|
2025-12-02 18:03:52 +09:00
|
|
|
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]);
|
2025-09-09 14:29:04 +09:00
|
|
|
|
2025-11-04 14:33:39 +09:00
|
|
|
// formData 업데이트 함수
|
|
|
|
|
const updateFormData = useCallback(
|
|
|
|
|
(fieldName: string, value: any) => {
|
|
|
|
|
if (onFormDataChange) {
|
|
|
|
|
onFormDataChange(fieldName, value);
|
|
|
|
|
} else {
|
|
|
|
|
setLocalFormData((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[fieldName]: value,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[onFormDataChange],
|
|
|
|
|
);
|
|
|
|
|
|
2025-09-09 14:29:04 +09:00
|
|
|
// 자동값 생성 함수
|
|
|
|
|
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],
|
|
|
|
|
);
|
|
|
|
|
|
2025-11-06 12:11:49 +09:00
|
|
|
// 🆕 Enter 키로 다음 필드 이동
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handleEnterKey = (e: KeyboardEvent) => {
|
|
|
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
|
|
|
const target = e.target as HTMLElement;
|
2025-11-20 15:30:00 +09:00
|
|
|
|
2025-11-06 17:32:29 +09:00
|
|
|
// 한글 조합 중이면 무시 (한글 입력 문제 방지)
|
|
|
|
|
if ((e as any).isComposing || e.keyCode === 229) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-11-20 15:30:00 +09:00
|
|
|
|
2025-11-06 12:11:49 +09:00
|
|
|
// textarea는 제외 (여러 줄 입력)
|
|
|
|
|
if (target.tagName === "TEXTAREA") {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-11-20 15:30:00 +09:00
|
|
|
|
2025-11-06 12:11:49 +09:00
|
|
|
// input, select 등의 폼 요소에서만 작동
|
2025-11-20 15:30:00 +09:00
|
|
|
if (target.tagName === "INPUT" || target.tagName === "SELECT" || target.getAttribute("role") === "combobox") {
|
2025-11-06 12:11:49 +09:00
|
|
|
e.preventDefault();
|
2025-11-20 15:30:00 +09:00
|
|
|
|
2025-11-06 12:11:49 +09:00
|
|
|
// 모든 포커스 가능한 요소 찾기
|
|
|
|
|
const focusableElements = document.querySelectorAll<HTMLElement>(
|
2025-11-20 15:30:00 +09:00
|
|
|
'input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [role="combobox"]:not([disabled])',
|
2025-11-06 12:11:49 +09:00
|
|
|
);
|
2025-11-20 15:30:00 +09:00
|
|
|
|
2025-11-06 12:11:49 +09:00
|
|
|
// 화면에 보이는 순서(Y 좌표 → X 좌표)대로 정렬
|
|
|
|
|
const focusableArray = Array.from(focusableElements).sort((a, b) => {
|
|
|
|
|
const rectA = a.getBoundingClientRect();
|
|
|
|
|
const rectB = b.getBoundingClientRect();
|
2025-11-20 15:30:00 +09:00
|
|
|
|
2025-11-06 12:11:49 +09:00
|
|
|
// Y 좌표 차이가 10px 이상이면 Y 좌표로 정렬 (위에서 아래로)
|
|
|
|
|
if (Math.abs(rectA.top - rectB.top) > 10) {
|
|
|
|
|
return rectA.top - rectB.top;
|
|
|
|
|
}
|
2025-11-20 15:30:00 +09:00
|
|
|
|
2025-11-06 12:11:49 +09:00
|
|
|
// 같은 줄이면 X 좌표로 정렬 (왼쪽에서 오른쪽으로)
|
|
|
|
|
return rectA.left - rectB.left;
|
|
|
|
|
});
|
2025-11-20 15:30:00 +09:00
|
|
|
|
2025-11-06 12:11:49 +09:00
|
|
|
const currentIndex = focusableArray.indexOf(target);
|
2025-11-20 15:30:00 +09:00
|
|
|
|
2025-11-06 12:11:49 +09:00
|
|
|
if (currentIndex !== -1 && currentIndex < focusableArray.length - 1) {
|
|
|
|
|
// 다음 요소로 포커스 이동
|
|
|
|
|
const nextElement = focusableArray[currentIndex + 1];
|
|
|
|
|
nextElement.focus();
|
2025-11-20 15:30:00 +09:00
|
|
|
|
2025-11-06 17:32:29 +09:00
|
|
|
// select() 제거: 한글 입력 시 이전 필드의 마지막 글자가 복사되는 버그 방지
|
2025-11-06 12:11:49 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
2025-11-20 15:30:00 +09:00
|
|
|
|
2025-11-06 12:11:49 +09:00
|
|
|
document.addEventListener("keydown", handleEnterKey);
|
2025-11-20 15:30:00 +09:00
|
|
|
|
2025-11-06 12:11:49 +09:00
|
|
|
return () => {
|
|
|
|
|
document.removeEventListener("keydown", handleEnterKey);
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-11-04 14:33:39 +09:00
|
|
|
// 🆕 autoFill 자동 입력 초기화
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
const initAutoInputFields = async () => {
|
|
|
|
|
for (const comp of allComponents) {
|
|
|
|
|
// type: "component" 또는 type: "widget" 모두 처리
|
2025-11-20 15:30:00 +09:00
|
|
|
if (comp.type === "widget" || comp.type === "component") {
|
2025-11-04 14:33:39 +09:00
|
|
|
const widget = comp as any;
|
|
|
|
|
const fieldName = widget.columnName || widget.id;
|
2025-11-20 15:30:00 +09:00
|
|
|
|
2025-11-04 14:33:39 +09:00
|
|
|
// autoFill 처리 (테이블 조회 기반 자동 입력)
|
|
|
|
|
if (widget.autoFill?.enabled || (comp as any).autoFill?.enabled) {
|
|
|
|
|
const autoFillConfig = widget.autoFill || (comp as any).autoFill;
|
|
|
|
|
const currentValue = formData[fieldName];
|
2025-11-20 15:30:00 +09:00
|
|
|
|
|
|
|
|
if (currentValue === undefined || currentValue === "") {
|
2025-11-04 14:33:39 +09:00
|
|
|
const { sourceTable, filterColumn, userField, displayColumn } = autoFillConfig;
|
2025-11-20 15:30:00 +09:00
|
|
|
|
2025-11-04 14:33:39 +09:00
|
|
|
// 사용자 정보에서 필터 값 가져오기
|
|
|
|
|
const userValue = user?.[userField];
|
2025-11-20 15:30:00 +09:00
|
|
|
|
2025-11-04 14:33:39 +09:00
|
|
|
if (userValue && sourceTable && filterColumn && displayColumn) {
|
|
|
|
|
try {
|
|
|
|
|
const { tableTypeApi } = await import("@/lib/api/screen");
|
2025-11-20 15:30:00 +09:00
|
|
|
const result = await tableTypeApi.getTableRecord(sourceTable, filterColumn, userValue, displayColumn);
|
|
|
|
|
|
2025-11-04 14:33:39 +09:00
|
|
|
updateFormData(fieldName, result.value);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error(`autoFill 조회 실패: ${fieldName}`, error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
initAutoInputFields();
|
|
|
|
|
}, [allComponents, user]);
|
|
|
|
|
|
2025-09-09 14:29:04 +09:00
|
|
|
// 팝업 화면 레이아웃 로드
|
|
|
|
|
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) {
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.error("팝업 화면 로드 오류:", error);
|
2025-09-09 14:29:04 +09:00
|
|
|
toast.error("팝업 화면 로드 중 오류가 발생했습니다.");
|
|
|
|
|
setPopupScreen(null);
|
|
|
|
|
} finally {
|
|
|
|
|
setPopupLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 폼 데이터 변경 핸들러
|
2025-11-04 09:41:58 +09:00
|
|
|
const handleFormDataChange = (fieldName: string | any, value?: any) => {
|
|
|
|
|
// 일반 필드 변경
|
2025-09-09 14:29:04 +09:00
|
|
|
if (onFormDataChange) {
|
|
|
|
|
onFormDataChange(fieldName, value);
|
|
|
|
|
} else {
|
|
|
|
|
setLocalFormData((prev) => ({ ...prev, [fieldName]: value }));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 동적 대화형 위젯 렌더링
|
|
|
|
|
const renderInteractiveWidget = (comp: ComponentData) => {
|
|
|
|
|
// 데이터 테이블 컴포넌트 처리
|
2025-09-29 13:29:03 +09:00
|
|
|
if (isDataTableComponent(comp)) {
|
2025-09-09 14:29:04 +09:00
|
|
|
return (
|
|
|
|
|
<InteractiveDataTable
|
|
|
|
|
component={comp as DataTableComponent}
|
|
|
|
|
className="h-full w-full"
|
|
|
|
|
style={{
|
|
|
|
|
width: "100%",
|
|
|
|
|
height: "100%",
|
|
|
|
|
}}
|
2025-10-02 14:34:15 +09:00
|
|
|
onRefresh={() => {
|
|
|
|
|
// 테이블 자체에서 loadData를 호출하므로 여기서는 빈 함수
|
|
|
|
|
console.log("🔄 InteractiveDataTable 새로고침 트리거됨 (Dynamic)");
|
|
|
|
|
}}
|
2025-09-09 14:29:04 +09:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 파일 컴포넌트 처리
|
2025-09-29 13:29:03 +09:00
|
|
|
if (isFileComponent(comp)) {
|
2025-09-09 14:29:04 +09:00
|
|
|
return renderFileComponent(comp as FileComponent);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-02 14:34:15 +09:00
|
|
|
// 버튼 컴포넌트 또는 위젯이 아닌 경우 DynamicComponentRenderer 사용
|
2025-09-09 14:29:04 +09:00
|
|
|
if (comp.type !== "widget") {
|
2025-09-10 14:09:32 +09:00
|
|
|
return (
|
|
|
|
|
<DynamicComponentRenderer
|
|
|
|
|
component={comp}
|
|
|
|
|
isInteractive={true}
|
|
|
|
|
formData={formData}
|
2025-12-17 15:00:15 +09:00
|
|
|
originalData={originalData || undefined}
|
2025-09-10 14:09:32 +09:00
|
|
|
onFormDataChange={handleFormDataChange}
|
2025-09-12 14:24:25 +09:00
|
|
|
screenId={screenInfo?.id}
|
|
|
|
|
tableName={screenInfo?.tableName}
|
2025-12-17 15:00:15 +09:00
|
|
|
menuObjid={menuObjid}
|
|
|
|
|
userId={user?.userId}
|
|
|
|
|
userName={user?.userName}
|
|
|
|
|
companyCode={user?.companyCode}
|
|
|
|
|
onSave={onSave}
|
|
|
|
|
allComponents={allComponents}
|
2025-10-23 13:15:52 +09:00
|
|
|
selectedRowsData={selectedRowsData}
|
|
|
|
|
onSelectedRowsChange={(selectedRows, selectedData) => {
|
2025-12-17 15:00:15 +09:00
|
|
|
console.log("테이블에서 선택된 행 데이터:", selectedData);
|
2025-10-23 13:15:52 +09:00
|
|
|
setSelectedRowsData(selectedData);
|
|
|
|
|
}}
|
2025-11-24 15:24:31 +09:00
|
|
|
groupedData={groupedData}
|
2025-11-25 14:23:54 +09:00
|
|
|
disabledFields={disabledFields}
|
2025-10-23 17:26:14 +09:00
|
|
|
flowSelectedData={flowSelectedData}
|
|
|
|
|
flowSelectedStepId={flowSelectedStepId}
|
|
|
|
|
onFlowSelectedDataChange={(selectedData, stepId) => {
|
2025-12-17 15:00:15 +09:00
|
|
|
console.log("플로우에서 선택된 데이터:", { selectedData, stepId });
|
2025-10-23 17:26:14 +09:00
|
|
|
setFlowSelectedData(selectedData);
|
|
|
|
|
setFlowSelectedStepId(stepId);
|
|
|
|
|
}}
|
2025-11-20 15:30:00 +09:00
|
|
|
onRefresh={
|
|
|
|
|
onRefresh ||
|
|
|
|
|
(() => {
|
2025-12-17 15:00:15 +09:00
|
|
|
console.log("InteractiveScreenViewerDynamic onRefresh 호출");
|
2025-11-20 15:30:00 +09:00
|
|
|
})
|
|
|
|
|
}
|
2025-11-13 17:42:20 +09:00
|
|
|
onFlowRefresh={onFlowRefresh}
|
2025-09-12 14:24:25 +09:00
|
|
|
onClose={() => {
|
2025-11-03 09:58:04 +09:00
|
|
|
// buttonActions.ts가 이미 처리함
|
2025-09-12 14:24:25 +09:00
|
|
|
}}
|
2025-12-17 15:00:15 +09:00
|
|
|
// 탭 관련 정보 전달
|
|
|
|
|
parentTabId={parentTabId}
|
|
|
|
|
parentTabsComponentId={parentTabsComponentId}
|
2025-09-10 14:09:32 +09:00
|
|
|
/>
|
|
|
|
|
);
|
2025-09-09 14:29:04 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
2025-11-10 09:33:29 +09:00
|
|
|
// ✅ 격자 시스템 잔재 제거: style.width, style.height는 무시
|
|
|
|
|
// size.width, size.height가 부모 컨테이너에서 적용되므로
|
|
|
|
|
const { width, height, ...styleWithoutSize } = comp.style;
|
|
|
|
|
|
2025-09-09 14:29:04 +09:00
|
|
|
return React.cloneElement(element, {
|
|
|
|
|
style: {
|
|
|
|
|
...element.props.style,
|
2025-11-20 15:30:00 +09:00
|
|
|
...styleWithoutSize, // width/height 제외한 스타일만 적용
|
2025-09-09 14:29:04 +09:00
|
|
|
width: "100%",
|
|
|
|
|
height: "100%",
|
|
|
|
|
minHeight: "100%",
|
|
|
|
|
maxHeight: "100%",
|
|
|
|
|
boxSizing: "border-box",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 동적 웹타입 렌더링 사용
|
|
|
|
|
if (widgetType) {
|
|
|
|
|
try {
|
|
|
|
|
const dynamicElement = (
|
|
|
|
|
<DynamicWebTypeRenderer
|
|
|
|
|
webType={widgetType}
|
|
|
|
|
props={{
|
|
|
|
|
component: widget,
|
|
|
|
|
value: currentValue,
|
|
|
|
|
onChange: (value: any) => handleFormDataChange(fieldName, value),
|
2025-09-18 10:05:50 +09:00
|
|
|
onFormDataChange: handleFormDataChange,
|
2025-11-28 16:02:29 +09:00
|
|
|
formData: formData, // 🆕 전체 formData 전달
|
2025-09-18 10:05:50 +09:00
|
|
|
isInteractive: true,
|
2025-09-09 14:29:04 +09:00
|
|
|
readonly: readonly,
|
|
|
|
|
required: required,
|
|
|
|
|
placeholder: placeholder,
|
|
|
|
|
className: "w-full h-full",
|
2025-11-25 12:07:14 +09:00
|
|
|
isInModal: isInModal, // 🆕 EditModal 내부 여부 전달
|
|
|
|
|
onSave: onSave, // 🆕 EditModal의 handleSave 콜백 전달
|
2025-11-28 16:02:29 +09:00
|
|
|
groupedData: groupedData, // 🆕 그룹 데이터 전달 (RepeatScreenModal용)
|
2025-09-09 14:29:04 +09:00
|
|
|
}}
|
|
|
|
|
config={widget.webTypeConfig}
|
|
|
|
|
onEvent={(event: string, data: any) => {
|
|
|
|
|
// 이벤트 처리
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.log(`Widget event: ${event}`, data);
|
2025-09-09 14:29:04 +09:00
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return applyStyles(dynamicElement);
|
|
|
|
|
} catch (error) {
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.error(`웹타입 "${widgetType}" 대화형 렌더링 실패:`, error);
|
2025-09-09 14:29:04 +09:00
|
|
|
// 오류 발생 시 폴백으로 기본 input 렌더링
|
|
|
|
|
const fallbackElement = (
|
|
|
|
|
<Input
|
|
|
|
|
type="text"
|
|
|
|
|
value={currentValue}
|
|
|
|
|
onChange={(e) => handleFormDataChange(fieldName, e.target.value)}
|
|
|
|
|
placeholder={`${widgetType} (렌더링 오류)`}
|
|
|
|
|
disabled={readonly}
|
|
|
|
|
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}
|
|
|
|
|
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 () => {
|
2025-11-03 09:58:04 +09:00
|
|
|
// EditModal에서 전달된 onSave가 있으면 우선 사용 (수정 모달)
|
|
|
|
|
if (onSave) {
|
|
|
|
|
try {
|
|
|
|
|
await onSave();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("저장 오류:", error);
|
|
|
|
|
toast.error("저장 중 오류가 발생했습니다.");
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 일반 저장 액션 (신규 생성)
|
2025-09-09 14:29:04 +09:00
|
|
|
if (!screenInfo?.tableName) {
|
|
|
|
|
toast.error("테이블명이 설정되지 않았습니다.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const saveData: DynamicFormData = {
|
|
|
|
|
tableName: screenInfo.tableName,
|
|
|
|
|
data: formData,
|
|
|
|
|
};
|
|
|
|
|
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.log("💾 저장 액션 실행:", saveData);
|
2025-09-09 14:29:04 +09:00
|
|
|
const response = await dynamicFormApi.saveData(saveData);
|
|
|
|
|
|
|
|
|
|
if (response.success) {
|
|
|
|
|
toast.success("데이터가 성공적으로 저장되었습니다.");
|
|
|
|
|
} else {
|
|
|
|
|
toast.error(response.message || "저장에 실패했습니다.");
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.error("저장 오류:", error);
|
2025-09-09 14:29:04 +09:00
|
|
|
toast.error("저장 중 오류가 발생했습니다.");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleDeleteAction = async () => {
|
|
|
|
|
if (!config?.confirmationEnabled || window.confirm(config.confirmationMessage || "정말 삭제하시겠습니까?")) {
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.log("🗑️ 삭제 액션 실행");
|
2025-09-09 14:29:04 +09:00
|
|
|
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;
|
|
|
|
|
}
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.log("⚡ 커스텀 액션 실행 완료");
|
2025-09-09 14:29:04 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
throw new Error(`커스텀 액션 실행 실패: ${error.message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-16 14:38:03 +09:00
|
|
|
// 🆕 즉시 저장(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 || "저장 중 오류가 발생했습니다.");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-09 14:29:04 +09:00
|
|
|
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;
|
2025-12-16 14:38:03 +09:00
|
|
|
case "quickInsert":
|
|
|
|
|
await handleQuickInsertAction();
|
|
|
|
|
break;
|
2025-09-09 14:29:04 +09:00
|
|
|
default:
|
2025-10-28 15:39:22 +09:00
|
|
|
// console.log("🔘 기본 버튼 클릭");
|
2025-09-09 14:29:04 +09:00
|
|
|
}
|
|
|
|
|
} catch (error) {
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.error("버튼 액션 오류:", error);
|
2025-09-09 14:29:04 +09:00
|
|
|
toast.error(error.message || "액션 실행 중 오류가 발생했습니다.");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Button
|
|
|
|
|
onClick={handleClick}
|
|
|
|
|
variant={(config?.variant as any) || "default"}
|
|
|
|
|
size={(config?.size as any) || "default"}
|
|
|
|
|
disabled={config?.disabled}
|
|
|
|
|
style={{
|
2025-11-06 17:32:29 +09:00
|
|
|
// 컴포넌트 스타일 적용
|
2025-09-09 14:29:04 +09:00
|
|
|
...comp.style,
|
2025-11-04 11:41:20 +09:00
|
|
|
// 설정값이 있으면 우선 적용
|
|
|
|
|
backgroundColor: config?.backgroundColor || comp.style?.backgroundColor,
|
|
|
|
|
color: config?.textColor || comp.style?.color,
|
2025-11-06 17:32:29 +09:00
|
|
|
// 부모 컨테이너 크기에 맞춤
|
2025-11-20 15:30:00 +09:00
|
|
|
width: "100%",
|
|
|
|
|
height: "100%",
|
2025-09-09 14:29:04 +09:00
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{label || "버튼"}
|
|
|
|
|
</Button>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 파일 컴포넌트 렌더링
|
|
|
|
|
const renderFileComponent = (comp: FileComponent) => {
|
|
|
|
|
const { label, readonly } = comp;
|
|
|
|
|
const fieldName = comp.columnName || comp.id;
|
|
|
|
|
|
2025-09-29 13:29:03 +09:00
|
|
|
// 화면 ID 추출 (URL에서)
|
2025-10-28 15:39:22 +09:00
|
|
|
const screenId =
|
|
|
|
|
screenInfo?.screenId ||
|
|
|
|
|
(typeof window !== "undefined" && window.location.pathname.includes("/screens/")
|
|
|
|
|
? parseInt(window.location.pathname.split("/screens/")[1])
|
2025-09-29 13:29:03 +09:00
|
|
|
: null);
|
|
|
|
|
|
2025-09-09 14:29:04 +09:00
|
|
|
return (
|
|
|
|
|
<div className="h-full w-full">
|
2025-09-26 13:11:34 +09:00
|
|
|
{/* 실제 FileUploadComponent 사용 */}
|
|
|
|
|
<FileUploadComponent
|
2025-09-26 17:12:03 +09:00
|
|
|
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,
|
2025-09-26 13:11:34 +09:00
|
|
|
}}
|
2025-09-26 17:12:03 +09:00
|
|
|
componentStyle={{
|
2025-10-28 15:39:22 +09:00
|
|
|
width: "100%",
|
|
|
|
|
height: "100%",
|
2025-09-26 17:12:03 +09:00
|
|
|
}}
|
|
|
|
|
className="h-full w-full"
|
2025-09-26 13:11:34 +09:00
|
|
|
isInteractive={true}
|
2025-09-26 17:12:03 +09:00
|
|
|
isDesignMode={false}
|
2025-09-26 13:11:34 +09:00
|
|
|
formData={{
|
2025-09-29 17:21:47 +09:00
|
|
|
screenId, // 🎯 화면 ID 전달
|
|
|
|
|
// 🎯 백엔드 API가 기대하는 정확한 형식으로 설정
|
|
|
|
|
autoLink: true, // 자동 연결 활성화
|
2025-10-28 15:39:22 +09:00
|
|
|
linkedTable: "screen_files", // 연결 테이블
|
2025-09-29 17:21:47 +09:00
|
|
|
recordId: screenId, // 레코드 ID
|
|
|
|
|
columnName: fieldName, // 컬럼명 (중요!)
|
|
|
|
|
isVirtualFileColumn: true, // 가상 파일 컬럼
|
2025-09-26 13:11:34 +09:00
|
|
|
id: formData.id,
|
2025-10-28 15:39:22 +09:00
|
|
|
...formData,
|
2025-09-26 13:11:34 +09:00
|
|
|
}}
|
2025-09-26 17:12:03 +09:00
|
|
|
onFormDataChange={(data) => {
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.log("📝 실제 화면 파일 업로드 완료:", data);
|
2025-09-26 17:12:03 +09:00
|
|
|
if (onFormDataChange) {
|
|
|
|
|
Object.entries(data).forEach(([key, value]) => {
|
|
|
|
|
onFormDataChange(key, value);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
onUpdate={(updates) => {
|
2025-09-29 17:21:47 +09:00
|
|
|
console.log("🔄🔄🔄 실제 화면 파일 컴포넌트 업데이트:", {
|
|
|
|
|
componentId: comp.id,
|
|
|
|
|
hasUploadedFiles: !!updates.uploadedFiles,
|
|
|
|
|
filesCount: updates.uploadedFiles?.length || 0,
|
|
|
|
|
hasLastFileUpdate: !!updates.lastFileUpdate,
|
2025-10-28 15:39:22 +09:00
|
|
|
updates,
|
2025-09-29 17:21:47 +09:00
|
|
|
});
|
2025-10-28 15:39:22 +09:00
|
|
|
|
2025-09-29 17:21:47 +09:00
|
|
|
// 파일 업로드/삭제 완료 시 formData 업데이터
|
2025-09-26 17:12:03 +09:00
|
|
|
if (updates.uploadedFiles && onFormDataChange) {
|
|
|
|
|
onFormDataChange(fieldName, updates.uploadedFiles);
|
|
|
|
|
}
|
2025-10-28 15:39:22 +09:00
|
|
|
|
2025-09-29 17:21:47 +09:00
|
|
|
// 🎯 화면설계 모드와 동기화를 위한 전역 이벤트 발생 (업로드/삭제 모두)
|
2025-10-28 15:39:22 +09:00
|
|
|
if (updates.uploadedFiles !== undefined && typeof window !== "undefined") {
|
2025-09-29 17:21:47 +09:00
|
|
|
// 업로드인지 삭제인지 판단 (lastFileUpdate가 있으면 변경사항 있음)
|
2025-10-28 15:39:22 +09:00
|
|
|
const action = updates.lastFileUpdate ? "update" : "sync";
|
|
|
|
|
|
2025-09-29 17:21:47 +09:00
|
|
|
const eventDetail = {
|
|
|
|
|
componentId: comp.id,
|
|
|
|
|
files: updates.uploadedFiles,
|
|
|
|
|
fileCount: updates.uploadedFiles.length,
|
|
|
|
|
action: action,
|
|
|
|
|
timestamp: updates.lastFileUpdate || Date.now(),
|
2025-10-28 15:39:22 +09:00
|
|
|
source: "realScreen", // 실제 화면에서 온 이벤트임을 표시
|
2025-09-29 17:21:47 +09:00
|
|
|
};
|
2025-10-28 15:39:22 +09:00
|
|
|
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.log("🚀🚀🚀 실제 화면 파일 변경 이벤트 발생:", eventDetail);
|
2025-10-28 15:39:22 +09:00
|
|
|
|
|
|
|
|
const event = new CustomEvent("globalFileStateChanged", {
|
|
|
|
|
detail: eventDetail,
|
2025-09-29 17:21:47 +09:00
|
|
|
});
|
|
|
|
|
window.dispatchEvent(event);
|
2025-10-28 15:39:22 +09:00
|
|
|
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.log("✅✅✅ 실제 화면 → 화면설계 모드 동기화 이벤트 발생 완료");
|
2025-10-28 15:39:22 +09:00
|
|
|
|
2025-09-29 17:21:47 +09:00
|
|
|
// 추가 지연 이벤트들 (화면설계 모드가 열려있을 때를 대비)
|
|
|
|
|
setTimeout(() => {
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.log("🔄 실제 화면 추가 이벤트 발생 (지연 100ms)");
|
2025-10-28 15:39:22 +09:00
|
|
|
window.dispatchEvent(
|
|
|
|
|
new CustomEvent("globalFileStateChanged", {
|
|
|
|
|
detail: { ...eventDetail, delayed: true },
|
|
|
|
|
}),
|
|
|
|
|
);
|
2025-09-29 17:21:47 +09:00
|
|
|
}, 100);
|
2025-10-28 15:39:22 +09:00
|
|
|
|
2025-09-29 17:21:47 +09:00
|
|
|
setTimeout(() => {
|
2025-10-01 18:17:30 +09:00
|
|
|
// console.log("🔄 실제 화면 추가 이벤트 발생 (지연 500ms)");
|
2025-10-28 15:39:22 +09:00
|
|
|
window.dispatchEvent(
|
|
|
|
|
new CustomEvent("globalFileStateChanged", {
|
|
|
|
|
detail: { ...eventDetail, delayed: true, attempt: 2 },
|
|
|
|
|
}),
|
|
|
|
|
);
|
2025-09-29 17:21:47 +09:00
|
|
|
}, 500);
|
|
|
|
|
}
|
2025-09-26 13:11:34 +09:00
|
|
|
}}
|
|
|
|
|
/>
|
2025-09-09 14:29:04 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 메인 렌더링
|
|
|
|
|
const { type, position, size, style = {} } = component;
|
|
|
|
|
|
2025-11-10 09:33:29 +09:00
|
|
|
// ✅ 격자 시스템 잔재 제거: style.width, style.height 무시
|
|
|
|
|
const { width: styleWidth, height: styleHeight, ...styleWithoutSize } = style;
|
2025-11-20 15:30:00 +09:00
|
|
|
|
2025-11-12 14:50:06 +09:00
|
|
|
// TableSearchWidget의 경우 높이를 자동으로 설정
|
|
|
|
|
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
|
2025-11-20 15:30:00 +09:00
|
|
|
|
2025-09-09 14:29:04 +09:00
|
|
|
const componentStyle = {
|
|
|
|
|
position: "absolute" as const,
|
|
|
|
|
left: position?.x || 0,
|
|
|
|
|
top: position?.y || 0,
|
|
|
|
|
zIndex: position?.z || 1,
|
2025-11-20 15:30:00 +09:00
|
|
|
...styleWithoutSize, // width/height 제외한 스타일만 먼저 적용
|
|
|
|
|
width: size?.width || 200, // size의 픽셀 값이 최종 우선순위
|
|
|
|
|
height: isTableSearchWidget ? "auto" : size?.height || 10,
|
2025-11-12 14:50:06 +09:00
|
|
|
minHeight: isTableSearchWidget ? "48px" : undefined,
|
2025-09-09 14:29:04 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<div className="absolute" style={componentStyle}>
|
2025-09-30 10:30:05 +09:00
|
|
|
{/* 라벨 숨김 - 모달에서는 라벨을 표시하지 않음 */}
|
|
|
|
|
{/* 위젯 렌더링 */}
|
|
|
|
|
{renderInteractiveWidget(component)}
|
2025-09-09 14:29:04 +09:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 팝업 화면 렌더링 */}
|
|
|
|
|
{popupScreen && (
|
2025-12-05 10:46:10 +09:00
|
|
|
<Dialog open={!!popupScreen} onOpenChange={() => setPopupScreen(null)}>
|
|
|
|
|
<DialogContent
|
|
|
|
|
className="overflow-hidden p-0 max-w-none"
|
|
|
|
|
style={{
|
|
|
|
|
width: popupScreen.size === "small" ? "600px" : popupScreen.size === "large" ? "1400px" : "1000px",
|
|
|
|
|
height: "800px",
|
|
|
|
|
maxWidth: "95vw",
|
|
|
|
|
maxHeight: "90vh",
|
|
|
|
|
}}
|
2025-09-09 14:29:04 +09:00
|
|
|
>
|
|
|
|
|
<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>
|
|
|
|
|
)}
|
2025-12-05 10:46:10 +09:00
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
2025-09-09 14:29:04 +09:00
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 기존 InteractiveScreenViewer와의 호환성을 위한 export
|
|
|
|
|
export { InteractiveScreenViewerDynamic as InteractiveScreenViewer };
|
|
|
|
|
|
|
|
|
|
InteractiveScreenViewerDynamic.displayName = "InteractiveScreenViewerDynamic";
|