- EditModal, InteractiveScreenViewer, SaveModal 컴포넌트에서 리피터 데이터(배열)를 마스터 저장에서 제외하고, 별도로 저장하는 로직을 추가하였습니다. - 리피터 데이터 저장 이벤트를 발생시켜 UnifiedRepeater 컴포넌트가 이를 리스닝하도록 개선하였습니다. - 각 컴포넌트에서 최종 저장 데이터 로그를 업데이트하여, 저장 과정에서의 데이터 흐름을 명확히 하였습니다. 이로 인해 데이터 저장의 효율성과 리피터 관리의 일관성이 향상되었습니다.
1095 lines
40 KiB
TypeScript
1095 lines
40 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useCallback, useEffect } 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 "@/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(); // 분할 패널 컨텍스트
|
|
|
|
// 외부에서 전달받은 사용자 정보가 있으면 우선 사용 (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) => {
|
|
// 조건부 표시 평가
|
|
const conditionalResult = evaluateConditional(comp.conditional, formData, allComponents);
|
|
|
|
// 조건에 따라 숨김 처리
|
|
if (!conditionalResult.visible) {
|
|
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;
|
|
}
|
|
|
|
try {
|
|
// 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (UnifiedRepeater가 별도로 저장)
|
|
const masterFormData: Record<string, any> = {};
|
|
Object.entries(formData).forEach(([key, value]) => {
|
|
// 배열 데이터는 리피터 데이터이므로 제외
|
|
if (!Array.isArray(value)) {
|
|
masterFormData[key] = value;
|
|
} else {
|
|
console.log(`🔄 리피터 데이터 제외 (별도 저장): ${key}, ${value.length}개 항목`);
|
|
}
|
|
});
|
|
|
|
const saveData: DynamicFormData = {
|
|
tableName: screenInfo.tableName,
|
|
data: masterFormData,
|
|
};
|
|
|
|
// console.log("💾 저장 액션 실행:", saveData);
|
|
const response = await dynamicFormApi.saveData(saveData);
|
|
|
|
if (response.success) {
|
|
const masterRecordId = response.data?.id || formData.id;
|
|
|
|
// 리피터 데이터 저장 이벤트 발생 (UnifiedRepeater 컴포넌트가 리스닝)
|
|
window.dispatchEvent(
|
|
new CustomEvent("repeaterSave", {
|
|
detail: {
|
|
parentId: masterRecordId,
|
|
masterRecordId, // 🆕 마스터 레코드 ID (FK 자동 연결용)
|
|
mainFormData: formData,
|
|
tableName: screenInfo.tableName,
|
|
},
|
|
}),
|
|
);
|
|
|
|
toast.success("데이터가 성공적으로 저장되었습니다.");
|
|
} else {
|
|
toast.error(response.message || "저장에 실패했습니다.");
|
|
}
|
|
} catch (error) {
|
|
// console.error("저장 오류:", 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";
|
|
|
|
const componentStyle = {
|
|
position: "absolute" as const,
|
|
left: position?.x || 0,
|
|
top: position?.y || 0,
|
|
zIndex: position?.z || 1,
|
|
...styleWithoutSize, // width/height 제외한 스타일만 먼저 적용
|
|
width: size?.width || 200, // size의 픽셀 값이 최종 우선순위
|
|
height: isTableSearchWidget ? "auto" : size?.height || 10,
|
|
minHeight: isTableSearchWidget ? "48px" : undefined,
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="absolute" style={componentStyle}>
|
|
{/* 라벨 숨김 - 모달에서는 라벨을 표시하지 않음 */}
|
|
{/* 위젯 렌더링 */}
|
|
{renderInteractiveWidget(component)}
|
|
</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";
|