모달에서 저장 안되는 문제 수정

This commit is contained in:
kjs 2025-09-04 18:36:40 +09:00
parent 1c2249ee42
commit 95e68ca087
6 changed files with 572 additions and 236 deletions

View File

@ -129,9 +129,57 @@ export class DynamicFormService {
dataToInsert.updated_by = updated_by;
}
if (company_code && tableColumns.includes("company_code")) {
dataToInsert.company_code = company_code;
// company_code가 UUID 형태(36자)라면 하이픈 제거하여 32자로 만듦
let processedCompanyCode = company_code;
if (
typeof company_code === "string" &&
company_code.length === 36 &&
company_code.includes("-")
) {
processedCompanyCode = company_code.replace(/-/g, "");
console.log(
`🔧 company_code 길이 조정: "${company_code}" -> "${processedCompanyCode}" (${processedCompanyCode.length}자)`
);
}
// 여전히 32자를 초과하면 앞의 32자만 사용
if (
typeof processedCompanyCode === "string" &&
processedCompanyCode.length > 32
) {
processedCompanyCode = processedCompanyCode.substring(0, 32);
console.log(
`⚠️ company_code 길이 제한: 앞의 32자로 자름 -> "${processedCompanyCode}"`
);
}
dataToInsert.company_code = processedCompanyCode;
}
// 날짜/시간 문자열을 적절한 형태로 변환
Object.keys(dataToInsert).forEach((key) => {
const value = dataToInsert[key];
// 날짜/시간 관련 컬럼명 패턴 체크 (regdate, created_at, updated_at 등)
if (
typeof value === "string" &&
(key.toLowerCase().includes("date") ||
key.toLowerCase().includes("time") ||
key.toLowerCase().includes("created") ||
key.toLowerCase().includes("updated") ||
key.toLowerCase().includes("reg"))
) {
// YYYY-MM-DD HH:mm:ss 형태의 문자열을 Date 객체로 변환
if (value.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/)) {
console.log(`📅 날짜 변환: ${key} = "${value}" -> Date 객체`);
dataToInsert[key] = new Date(value);
}
// YYYY-MM-DD 형태의 문자열을 Date 객체로 변환
else if (value.match(/^\d{4}-\d{2}-\d{2}$/)) {
console.log(`📅 날짜 변환: ${key} = "${value}" -> Date 객체`);
dataToInsert[key] = new Date(value + "T00:00:00");
}
}
});
// 존재하지 않는 컬럼 제거
Object.keys(dataToInsert).forEach((key) => {
if (!tableColumns.includes(key)) {

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState } from "react";
import React, { useState, useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
@ -12,6 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
import { CalendarIcon } from "lucide-react";
import { format } from "date-fns";
import { ko } from "date-fns/locale";
import { useAuth } from "@/hooks/useAuth";
import {
ComponentData,
WidgetComponent,
@ -53,6 +54,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
hideLabel = false,
screenInfo,
}) => {
const { userName } = useAuth(); // 현재 로그인한 사용자명 가져오기
const [localFormData, setLocalFormData] = useState<Record<string, any>>({});
const [dateValues, setDateValues] = useState<Record<string, Date | undefined>>({});
@ -67,6 +69,32 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
const [popupLayout, setPopupLayout] = useState<ComponentData[]>([]);
const [popupLoading, setPopupLoading] = useState(false);
const [popupScreenResolution, setPopupScreenResolution] = useState<{ width: number; height: number } | null>(null);
const [popupScreenInfo, setPopupScreenInfo] = useState<{ id: number; tableName?: string } | null>(null);
// 팝업 전용 formData 상태
const [popupFormData, setPopupFormData] = useState<Record<string, any>>({});
// 자동값 생성 함수
const generateAutoValue = useCallback((autoValueType: string): string => {
const now = new Date();
switch (autoValueType) {
case "current_datetime":
return now.toISOString().slice(0, 19).replace("T", " "); // YYYY-MM-DD HH:mm:ss
case "current_date":
return now.toISOString().slice(0, 10); // YYYY-MM-DD
case "current_time":
return now.toTimeString().slice(0, 8); // HH:mm:ss
case "current_user":
// 실제 접속중인 사용자명 사용
return userName || "사용자"; // 사용자명이 없으면 기본값
case "uuid":
return crypto.randomUUID();
case "sequence":
return `SEQ_${Date.now()}`;
default:
return "";
}
}, [userName]); // userName 의존성 추가
// 팝업 화면 레이아웃 로드
React.useEffect(() => {
@ -74,29 +102,36 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
const loadPopupLayout = async () => {
try {
setPopupLoading(true);
console.log("🔍 팝업 화면 로드 시작:", {
screenId: popupScreen.screenId,
title: popupScreen.title,
size: popupScreen.size
});
console.log("🔍 팝업 화면 로드 시작:", popupScreen);
const layout = await screenApi.getLayout(popupScreen.screenId);
console.log("📊 팝업 화면 레이아웃 로드 완료:", {
// 화면 레이아웃과 화면 정보를 병렬로 가져오기
const [layout, screen] = await Promise.all([
screenApi.getLayout(popupScreen.screenId),
screenApi.getScreen(popupScreen.screenId)
]);
console.log("📊 팝업 화면 로드 완료:", {
componentsCount: layout.components?.length || 0,
gridSettings: layout.gridSettings,
screenResolution: layout.screenResolution,
components: layout.components?.map(c => ({
id: c.id,
type: c.type,
title: (c as any).title
}))
screenInfo: {
screenId: screen.screenId,
tableName: screen.tableName
},
popupFormData: {}
});
setPopupLayout(layout.components || []);
setPopupScreenResolution(layout.screenResolution || null);
setPopupScreenInfo({
id: popupScreen.screenId,
tableName: screen.tableName
});
// 팝업 formData 초기화
setPopupFormData({});
} catch (error) {
console.error("❌ 팝업 화면 레이아웃 로드 실패:", error);
console.error("❌ 팝업 화면 로드 실패:", error);
setPopupLayout([]);
setPopupScreenInfo(null);
} finally {
setPopupLoading(false);
}
@ -106,23 +141,86 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
}
}, [popupScreen]);
// 실제 사용할 폼 데이터 (외부에서 제공된 경우 우선 사용)
const formData = externalFormData || localFormData;
// 실제 사용할 폼 데이터 (외부와 로컬 데이터 병합)
const formData = { ...localFormData, ...externalFormData };
console.log("🔄 formData 구성:", {
external: externalFormData,
local: localFormData,
merged: formData,
hasExternalCallback: !!onFormDataChange
});
// 폼 데이터 업데이트
const updateFormData = (fieldName: string, value: any) => {
console.log(`🔄 updateFormData: ${fieldName} = "${value}" (외부콜백: ${!!onFormDataChange})`);
// 항상 로컬 상태도 업데이트
setLocalFormData((prev) => ({
...prev,
[fieldName]: value,
}));
console.log(`💾 로컬 상태 업데이트: ${fieldName} = "${value}"`);
// 외부 콜백이 있는 경우에도 전달
if (onFormDataChange) {
// 외부 콜백이 있는 경우 사용
onFormDataChange(fieldName, value);
} else {
// 로컬 상태 업데이트
setLocalFormData((prev) => ({
...prev,
[fieldName]: value,
}));
// 개별 필드를 객체로 변환해서 전달
const dataToSend = { [fieldName]: value };
onFormDataChange(dataToSend);
console.log(`📤 외부 콜백으로 전달: ${fieldName} = "${value}" (객체: ${JSON.stringify(dataToSend)})`);
}
};
// 자동입력 필드들의 값을 formData에 초기 설정
React.useEffect(() => {
console.log("🚀 자동입력 초기화 useEffect 실행 - allComponents 개수:", allComponents.length);
const initAutoInputFields = () => {
console.log("🔧 initAutoInputFields 실행 시작");
allComponents.forEach(comp => {
if (comp.type === 'widget') {
const widget = comp as WidgetComponent;
const fieldName = widget.columnName || widget.id;
// 텍스트 타입 위젯의 자동입력 처리
if ((widget.widgetType === 'text' || widget.widgetType === 'email' || widget.widgetType === 'tel') &&
widget.webTypeConfig) {
const config = widget.webTypeConfig as TextTypeConfig;
const isAutoInput = config?.autoInput || false;
if (isAutoInput && config?.autoValueType) {
// 이미 값이 있으면 덮어쓰지 않음
const currentValue = formData[fieldName];
console.log(`🔍 자동입력 필드 체크: ${fieldName}`, {
currentValue,
isEmpty: currentValue === undefined || currentValue === '',
isAutoInput,
autoValueType: config.autoValueType
});
if (currentValue === undefined || currentValue === '') {
const autoValue = config.autoValueType === "custom"
? config.customValue || ""
: generateAutoValue(config.autoValueType);
console.log("🔄 자동입력 필드 초기화:", {
fieldName,
autoValueType: config.autoValueType,
autoValue
});
updateFormData(fieldName, autoValue);
} else {
console.log(`⏭️ 자동입력 건너뜀 (값 있음): ${fieldName} = "${currentValue}"`);
}
}
}
}
});
};
// 초기 로드 시 자동입력 필드들 설정
initAutoInputFields();
}, [allComponents, generateAutoValue]); // formData는 의존성에서 제외 (무한 루프 방지)
// 날짜 값 업데이트
const updateDateValue = (fieldName: string, date: Date | undefined) => {
setDateValues((prev) => ({
@ -177,6 +275,17 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as TextTypeConfig | undefined;
// 자동입력 관련 처리
const isAutoInput = config?.autoInput || false;
const autoValue = isAutoInput && config?.autoValueType
? config.autoValueType === "custom"
? config.customValue || ""
: generateAutoValue(config.autoValueType)
: "";
// 기본값 또는 자동값 설정
const displayValue = isAutoInput ? autoValue : currentValue || config?.defaultValue || "";
console.log("📝 InteractiveScreenViewer - Text 위젯:", {
componentId: widget.id,
widgetType: widget.widgetType,
@ -187,6 +296,11 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
maxLength: config?.maxLength,
pattern: config?.pattern,
placeholder: config?.placeholder,
defaultValue: config?.defaultValue,
autoInput: isAutoInput,
autoValueType: config?.autoValueType,
autoValue,
displayValue,
},
});
@ -215,6 +329,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
// 입력 검증 함수
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
console.log(`📝 입력 변경: ${fieldName} = "${value}"`);
// 형식별 실시간 검증
if (config?.format && config.format !== "none") {
@ -222,6 +337,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
if (pattern) {
const regex = new RegExp(`^${pattern}$`);
if (value && !regex.test(value)) {
console.log(`❌ 형식 검증 실패: ${fieldName} = "${value}"`);
return; // 유효하지 않은 입력 차단
}
}
@ -229,9 +345,11 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
// 길이 제한 검증
if (config?.maxLength && value.length > config.maxLength) {
console.log(`❌ 길이 제한 초과: ${fieldName} = "${value}" (최대: ${config.maxLength})`);
return; // 최대 길이 초과 차단
}
console.log(`✅ updateFormData 호출: ${fieldName} = "${value}"`);
updateFormData(fieldName, value);
};
@ -241,15 +359,16 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
return applyStyles(
<Input
type={inputType}
placeholder={finalPlaceholder}
value={currentValue}
onChange={handleInputChange}
disabled={readonly}
placeholder={isAutoInput ? `자동입력: ${config?.autoValueType}` : finalPlaceholder}
value={displayValue}
onChange={isAutoInput ? undefined : handleInputChange}
disabled={readonly || isAutoInput}
readOnly={isAutoInput}
required={required}
minLength={config?.minLength}
maxLength={config?.maxLength}
pattern={getPatternByFormat(config?.format || "none")}
className="w-full"
className={`w-full ${isAutoInput ? "bg-gray-50 text-gray-700" : ""}`}
style={{
height: "100%",
minHeight: "100%",
@ -750,9 +869,6 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
case "save":
await handleSaveAction();
break;
case "cancel":
handleCancelAction();
break;
case "delete":
await handleDeleteAction();
break;
@ -794,8 +910,23 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
// 저장 액션
const handleSaveAction = async () => {
if (!formData || Object.keys(formData).length === 0) {
alert("저장할 데이터가 없습니다.");
// 저장 시점에서 최신 formData 구성
const currentFormData = { ...localFormData, ...externalFormData };
console.log("💾 저장 시작 - currentFormData:", currentFormData);
console.log("💾 저장 시점 formData 상세:", {
local: localFormData,
external: externalFormData,
merged: currentFormData
});
console.log("💾 currentFormData 키-값 상세:");
Object.entries(currentFormData).forEach(([key, value]) => {
console.log(` ${key}: "${value}" (타입: ${typeof value})`);
});
// formData 유효성 체크를 완화 (빈 객체라도 위젯이 있으면 저장 진행)
const hasWidgets = allComponents.some(comp => comp.type === 'widget');
if (!hasWidgets) {
alert("저장할 입력 컴포넌트가 없습니다.");
return;
}
@ -803,7 +934,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
const requiredFields = allComponents.filter(c => c.required && (c.columnName || c.id));
const missingFields = requiredFields.filter(field => {
const fieldName = field.columnName || field.id;
const value = formData[fieldName];
const value = currentFormData[fieldName];
return !value || value.toString().trim() === "";
});
@ -822,27 +953,93 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
// 컬럼명 기반으로 데이터 매핑
const mappedData: Record<string, any> = {};
// 컴포넌트에서 컬럼명이 있는 것들만 매핑
// 입력 가능한 컴포넌트에서 데이터 수집
allComponents.forEach(comp => {
if (comp.columnName) {
const fieldName = comp.columnName;
const componentId = comp.id;
// 위젯 컴포넌트이고 입력 가능한 타입인 경우
if (comp.type === 'widget') {
const widget = comp as WidgetComponent;
const fieldName = widget.columnName || widget.id;
let value = currentFormData[fieldName];
// formData에서 해당 값 찾기 (컬럼명 우선, 없으면 컴포넌트 ID)
const value = formData[fieldName] || formData[componentId];
console.log(`🔍 컴포넌트 처리: ${fieldName}`, {
widgetType: widget.widgetType,
formDataValue: value,
hasWebTypeConfig: !!widget.webTypeConfig,
config: widget.webTypeConfig
});
if (value !== undefined && value !== "") {
mappedData[fieldName] = value;
// 자동입력 필드인 경우에만 값이 없을 때 생성
if ((widget.widgetType === 'text' || widget.widgetType === 'email' || widget.widgetType === 'tel') &&
widget.webTypeConfig) {
const config = widget.webTypeConfig as TextTypeConfig;
const isAutoInput = config?.autoInput || false;
console.log(`📋 ${fieldName} 자동입력 체크:`, {
isAutoInput,
autoValueType: config?.autoValueType,
hasValue: !!value,
value
});
if (isAutoInput && config?.autoValueType && (!value || value === '')) {
// 자동입력이고 값이 없을 때만 생성
value = config.autoValueType === "custom"
? config.customValue || ""
: generateAutoValue(config.autoValueType);
console.log("💾 자동입력 값 저장 (값이 없어서 생성):", {
fieldName,
autoValueType: config.autoValueType,
generatedValue: value
});
} else if (isAutoInput && value) {
console.log("💾 자동입력 필드지만 기존 값 유지:", {
fieldName,
existingValue: value
});
} else if (!isAutoInput) {
console.log(`📝 일반 입력 필드: ${fieldName} = "${value}"`);
}
}
// 값이 있는 경우만 매핑 (빈 문자열도 포함하되, undefined는 제외)
if (value !== undefined && value !== null && value !== "undefined") {
// columnName이 있으면 columnName을 키로, 없으면 컴포넌트 ID를 키로 사용
const saveKey = widget.columnName || `comp_${widget.id}`;
mappedData[saveKey] = value;
} else if (widget.columnName) {
// 값이 없지만 columnName이 있는 경우, 빈 문자열로 저장
console.log(`⚠️ ${widget.columnName} 필드에 값이 없어 빈 문자열로 저장`);
mappedData[widget.columnName] = "";
}
}
});
console.log("💾 저장할 데이터 매핑:", {
원본데이터: formData,
원본데이터: currentFormData,
매핑된데이터: mappedData,
화면정보: screenInfo,
전체컴포넌트수: allComponents.length,
위젯컴포넌트수: allComponents.filter(c => c.type === 'widget').length,
});
// 각 컴포넌트의 상세 정보 로그
console.log("🔍 컴포넌트별 데이터 수집 상세:");
allComponents.forEach(comp => {
if (comp.type === 'widget') {
const widget = comp as WidgetComponent;
const fieldName = widget.columnName || widget.id;
const value = currentFormData[fieldName];
const hasValue = value !== undefined && value !== null && value !== '';
console.log(` - ${fieldName} (${widget.widgetType}): "${value}" (값있음: ${hasValue}, 컬럼명: ${widget.columnName})`);
}
});
// 매핑된 데이터가 비어있으면 경고
if (Object.keys(mappedData).length === 0) {
console.warn("⚠️ 매핑된 데이터가 없습니다. 빈 데이터로 저장됩니다.");
}
// 테이블명 결정 (화면 정보에서 가져오거나 첫 번째 컴포넌트의 테이블명 사용)
const tableName = screenInfo.tableName ||
allComponents.find(c => c.columnName)?.tableName ||
@ -864,9 +1061,11 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
// 저장 후 데이터 초기화 (선택사항)
if (onFormDataChange) {
const resetData: Record<string, any> = {};
Object.keys(formData).forEach(key => {
onFormDataChange(key, "");
resetData[key] = "";
});
onFormDataChange(resetData);
}
} else {
throw new Error(result.message || "저장에 실패했습니다.");
@ -877,19 +1076,6 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
}
};
// 취소 액션
const handleCancelAction = () => {
if (confirm("변경사항을 취소하시겠습니까?")) {
// 폼 초기화 또는 이전 페이지로 이동
if (onFormDataChange) {
// 모든 폼 데이터 초기화
Object.keys(formData).forEach(key => {
onFormDataChange(key, "");
});
}
console.log("❌ 작업이 취소되었습니다.");
}
};
// 삭제 액션
const handleDeleteAction = async () => {
@ -928,9 +1114,11 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
// 삭제 후 폼 초기화
if (onFormDataChange) {
const resetData: Record<string, any> = {};
Object.keys(formData).forEach(key => {
onFormDataChange(key, "");
resetData[key] = "";
});
onFormDataChange(resetData);
}
} else {
throw new Error(result.message || "삭제에 실패했습니다.");
@ -971,9 +1159,11 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
const handleResetAction = () => {
if (confirm("모든 입력을 초기화하시겠습니까?")) {
if (onFormDataChange) {
const resetData: Record<string, any> = {};
Object.keys(formData).forEach(key => {
onFormDataChange(key, "");
resetData[key] = "";
});
onFormDataChange(resetData);
}
console.log("🔄 폼 초기화 완료");
alert("입력이 초기화되었습니다.");
@ -989,42 +1179,92 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
// 닫기 액션
const handleCloseAction = () => {
console.log("❌ 창 닫기");
// 창 닫기 또는 모달 닫기
if (window.opener) {
console.log("❌ 닫기 액션 실행");
// 모달 내부에서 실행되는지 확인
const isInModal = document.querySelector('[role="dialog"]') !== null;
const isInPopup = window.opener !== null;
if (isInModal) {
// 모달 내부인 경우: 모달의 닫기 버튼 클릭하거나 모달 닫기 이벤트 발생
console.log("🔄 모달 내부에서 닫기 - 모달 닫기 시도");
// 모달의 닫기 버튼을 찾아서 클릭
const modalCloseButton = document.querySelector('[role="dialog"] button[aria-label*="Close"], [role="dialog"] button[data-dismiss="modal"], [role="dialog"] .dialog-close');
if (modalCloseButton) {
(modalCloseButton as HTMLElement).click();
} else {
// ESC 키 이벤트 발생시키기
const escEvent = new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, which: 27 });
document.dispatchEvent(escEvent);
}
} else if (isInPopup) {
// 팝업 창인 경우
console.log("🔄 팝업 창 닫기");
window.close();
} else {
history.back();
// 일반 페이지인 경우 - 이전 페이지로 이동하지 않고 아무것도 하지 않음
console.log("🔄 일반 페이지에서 닫기 - 아무 동작 하지 않음");
alert("닫기 버튼이 클릭되었습니다.");
}
};
// 팝업 액션
const handlePopupAction = () => {
console.log("🎯 팝업 액션 실행:", { popupScreenId: config?.popupScreenId });
if (config?.popupScreenId) {
// 화면 팝업 열기
// 화면 모달 열기
setPopupScreen({
screenId: config.popupScreenId,
title: config.popupTitle || "상세 정보",
size: config.popupSize || "md",
size: "lg",
});
} else if (config?.popupTitle && config?.popupContent) {
// 텍스트 팝업 표시
// 텍스트 모달 표시
alert(`${config.popupTitle}\n\n${config.popupContent}`);
} else {
alert("팝업을 표시합니다.");
alert("모달을 표시합니다.");
}
};
// 네비게이션 액션
const handleNavigateAction = () => {
if (config?.navigateUrl) {
const navigateType = config?.navigateType || "url";
if (navigateType === "screen" && config?.navigateScreenId) {
// 화면으로 이동
const screenPath = `/screens/${config.navigateScreenId}`;
console.log("🎯 화면으로 이동:", {
screenId: config.navigateScreenId,
target: config.navigateTarget || "_self",
path: screenPath
});
if (config.navigateTarget === "_blank") {
window.open(screenPath, "_blank");
} else {
window.location.href = screenPath;
}
} else if (navigateType === "url" && config?.navigateUrl) {
// URL로 이동
console.log("🔗 URL로 이동:", {
url: config.navigateUrl,
target: config.navigateTarget || "_self"
});
if (config.navigateTarget === "_blank") {
window.open(config.navigateUrl, "_blank");
} else {
window.location.href = config.navigateUrl;
}
} else {
console.log("🔗 네비게이션 URL이 설정되지 않았습니다.");
console.log("🔗 네비게이션 정보가 설정되지 않았습니다:", {
navigateType,
hasUrl: !!config?.navigateUrl,
hasScreenId: !!config?.navigateScreenId
});
}
};
@ -1050,7 +1290,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
<Button
onClick={handleButtonClick}
disabled={readonly}
size={config?.size || "sm"}
size="sm"
variant={config?.variant || "default"}
className="w-full"
style={{ height: "100%" }}
@ -1142,16 +1382,6 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
marginBottom: component.style?.labelMarginBottom || "4px",
};
// 팝업 크기 설정
const getPopupMaxWidth = (size: string) => {
switch (size) {
case "sm": return "max-w-md";
case "md": return "max-w-2xl";
case "lg": return "max-w-4xl";
case "xl": return "max-w-6xl";
default: return "max-w-2xl";
}
};
return (
<>
@ -1168,9 +1398,12 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
<div className="h-full w-full">{renderInteractiveWidget(component)}</div>
</div>
{/* 팝업 화면 모달 */}
<Dialog open={!!popupScreen} onOpenChange={() => setPopupScreen(null)}>
<DialogContent className={`${getPopupMaxWidth(popupScreen?.size || "md")} max-h-[80vh] overflow-hidden`}>
{/* 모달 화면 */}
<Dialog open={!!popupScreen} onOpenChange={() => {
setPopupScreen(null);
setPopupFormData({}); // 팝업 닫을 때 formData도 초기화
}}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden">
<DialogHeader>
<DialogTitle>{popupScreen?.title || "상세 정보"}</DialogTitle>
</DialogHeader>
@ -1201,10 +1434,31 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
zIndex: popupComponent.position.z || 1,
}}
>
{/* 🎯 핵심 수정: 팝업 전용 formData 사용 */}
<InteractiveScreenViewer
component={popupComponent}
allComponents={popupLayout}
hideLabel={false}
screenInfo={popupScreenInfo || undefined}
formData={popupFormData}
onFormDataChange={(newData) => {
console.log("💾 팝업 formData 업데이트:", {
newData,
newDataType: typeof newData,
newDataKeys: Object.keys(newData || {}),
prevFormData: popupFormData
});
// 잘못된 데이터 타입 체크
if (typeof newData === 'string') {
console.error("❌ 문자열이 formData로 전달됨:", newData);
return;
}
if (newData && typeof newData === 'object') {
setPopupFormData(prev => ({ ...prev, ...newData }));
}
}}
/>
</div>
))}

View File

@ -32,7 +32,6 @@ interface ButtonConfigPanelProps {
const actionTypeOptions: { value: ButtonActionType; label: string; icon: React.ReactNode; color: string }[] = [
{ value: "save", label: "저장", icon: <Save className="h-4 w-4" />, color: "#3b82f6" },
{ value: "cancel", label: "취소", icon: <X className="h-4 w-4" />, color: "#6b7280" },
{ value: "delete", label: "삭제", icon: <Trash2 className="h-4 w-4" />, color: "#ef4444" },
{ value: "edit", label: "수정", icon: <Edit className="h-4 w-4" />, color: "#f59e0b" },
{ value: "add", label: "추가", icon: <Plus className="h-4 w-4" />, color: "#10b981" },
@ -40,7 +39,7 @@ const actionTypeOptions: { value: ButtonActionType; label: string; icon: React.R
{ value: "reset", label: "초기화", icon: <RotateCcw className="h-4 w-4" />, color: "#6b7280" },
{ value: "submit", label: "제출", icon: <Send className="h-4 w-4" />, color: "#059669" },
{ value: "close", label: "닫기", icon: <X className="h-4 w-4" />, color: "#6b7280" },
{ value: "popup", label: "팝업 열기", icon: <ExternalLink className="h-4 w-4" />, color: "#8b5cf6" },
{ value: "popup", label: "모달 열기", icon: <ExternalLink className="h-4 w-4" />, color: "#8b5cf6" },
{ value: "navigate", label: "페이지 이동", icon: <ExternalLink className="h-4 w-4" />, color: "#0ea5e9" },
{ value: "custom", label: "사용자 정의", icon: <Settings className="h-4 w-4" />, color: "#64748b" },
];
@ -53,7 +52,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
const defaultConfig = {
actionType: "custom" as ButtonActionType,
variant: "default" as ButtonVariant,
size: "sm" as ButtonSize,
};
return {
@ -79,9 +77,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
}
};
// 팝업 액션 타입일 때 화면 목록 로드
// 모달 또는 네비게이션 액션 타입일 때 화면 목록 로드
useEffect(() => {
if (localConfig.actionType === "popup") {
if (localConfig.actionType === "popup" || localConfig.actionType === "navigate") {
loadScreens();
}
}, [localConfig.actionType]);
@ -94,7 +92,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
const defaultConfig = {
actionType: "custom" as ButtonActionType,
variant: "default" as ButtonVariant,
size: "sm" as ButtonSize,
};
// 실제 저장된 값이 우선순위를 가지도록 설정
@ -146,13 +143,12 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
style: { ...component.style, backgroundColor: "#3b82f6", color: "#ffffff" },
});
break;
case "cancel":
case "close":
updates.variant = "outline";
updates.backgroundColor = "transparent";
updates.textColor = "#6b7280";
onUpdateComponent({
label: actionType === "cancel" ? "취소" : "닫기",
label: "닫기",
style: { ...component.style, backgroundColor: "transparent", color: "#6b7280", border: "1px solid #d1d5db" },
});
break;
@ -211,7 +207,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
updates.backgroundColor = "#8b5cf6";
updates.textColor = "#ffffff";
updates.popupTitle = "상세 정보";
updates.popupContent = "여기에 팝업 내용을 입력하세요.";
updates.popupContent = "여기에 모달 내용을 입력하세요.";
updates.popupSize = "md";
onUpdateComponent({
label: "상세보기",
@ -221,6 +217,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
case "navigate":
updates.backgroundColor = "#0ea5e9";
updates.textColor = "#ffffff";
updates.navigateType = "url";
updates.navigateUrl = "/";
updates.navigateTarget = "_self";
onUpdateComponent({
@ -338,21 +335,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Select value={localConfig.size} onValueChange={(value) => updateConfig({ size: value as any })}>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"></SelectItem>
<SelectItem value="default"></SelectItem>
<SelectItem value="lg"></SelectItem>
<SelectItem value="icon"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 아이콘 설정 */}
@ -392,11 +374,11 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
<div className="space-y-3">
<Label className="flex items-center gap-1 text-xs font-medium">
<ExternalLink className="h-3 w-3 text-purple-500" />
</Label>
<div className="space-y-2">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Label className="text-xs"> </Label>
<Select
value={localConfig.popupScreenId?.toString() || "none"}
onValueChange={(value) =>
@ -418,12 +400,10 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
))}
</SelectContent>
</Select>
{localConfig.popupScreenId && (
<p className="text-xs text-gray-500"> </p>
)}
{localConfig.popupScreenId && <p className="text-xs text-gray-500"> </p>}
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Label className="text-xs"> </Label>
<Input
value={localConfig.popupTitle || ""}
onChange={(e) => updateConfig({ popupTitle: e.target.value })}
@ -431,33 +411,16 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
className="h-8 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={localConfig.popupSize}
onValueChange={(value) => updateConfig({ popupSize: value as any })}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"></SelectItem>
<SelectItem value="md"></SelectItem>
<SelectItem value="lg"></SelectItem>
<SelectItem value="xl"> </SelectItem>
</SelectContent>
</Select>
</div>
{!localConfig.popupScreenId && (
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Label className="text-xs"> </Label>
<Textarea
value={localConfig.popupContent || ""}
onChange={(e) => updateConfig({ popupContent: e.target.value })}
placeholder="여기에 팝업 내용을 입력하세요."
placeholder="여기에 모달 내용을 입력하세요."
className="h-16 resize-none text-xs"
/>
<p className="text-xs text-gray-500"> </p>
<p className="text-xs text-gray-500"> </p>
</div>
)}
</div>
@ -472,19 +435,67 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
</Label>
<div className="space-y-2">
<div className="space-y-1">
<Label className="text-xs"> URL</Label>
<Input
value={localConfig.navigateUrl || ""}
onChange={(e) => updateConfig({ navigateUrl: e.target.value })}
placeholder="/admin/users"
className="h-8 text-xs"
/>
<Label className="text-xs"> </Label>
<Select
value={localConfig.navigateType || "url"}
onValueChange={(value) => updateConfig({ navigateType: value as "url" | "screen" })}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="url">URL </SelectItem>
<SelectItem value="screen"> </SelectItem>
</SelectContent>
</Select>
</div>
{(localConfig.navigateType || "url") === "url" ? (
<div className="space-y-1">
<Label className="text-xs"> URL</Label>
<Input
value={localConfig.navigateUrl || ""}
onChange={(e) => updateConfig({ navigateUrl: e.target.value })}
placeholder="/admin/users"
className="h-8 text-xs"
/>
</div>
) : (
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={localConfig.navigateScreenId?.toString() || ""}
onValueChange={(value) => updateConfig({ navigateScreenId: value ? parseInt(value) : undefined })}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="화면을 선택하세요" />
</SelectTrigger>
<SelectContent>
{screensLoading ? (
<SelectItem value="" disabled>
...
</SelectItem>
) : screens.length === 0 ? (
<SelectItem value="" disabled>
</SelectItem>
) : (
screens.map((screen) => (
<SelectItem key={screen.screenId} value={screen.screenId.toString()}>
{screen.screenName} ({screen.screenCode})
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={localConfig.navigateTarget}
onValueChange={(value) => updateConfig({ navigateTarget: value as any })}
value={localConfig.navigateTarget || "_self"}
onValueChange={(value) => updateConfig({ navigateTarget: value as "_self" | "_blank" })}
>
<SelectTrigger className="h-8">
<SelectValue />

View File

@ -243,89 +243,6 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({ select
<span className="rounded bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800">{widget.widgetType}</span>
</div>
<div className="mt-1 text-xs text-gray-500">: {widget.columnName}</div>
{/* 입력 타입 설정 - 입력 가능한 웹타입에만 표시 */}
{inputableWebTypes.includes(widget.widgetType || "") && (
<div className="mt-3 space-y-2">
<label className="text-sm font-medium text-gray-700"> </label>
<Select
value={widget.inputType || "direct"}
onValueChange={(value: "direct" | "auto") => {
onUpdateProperty(widget.id, "inputType", value);
}}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="direct"></SelectItem>
<SelectItem value="auto"></SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-gray-500">
{widget.inputType === "auto"
? "시스템에서 자동으로 값을 생성합니다 (읽기 전용)"
: "사용자가 직접 값을 입력할 수 있습니다"}
</p>
{/* 자동 값 타입 설정 (자동입력일 때만 표시) */}
{widget.inputType === "auto" && (
<div className="mt-3 space-y-2">
<label className="text-sm font-medium text-gray-700"> </label>
<Select
value={widget.autoValueType || "current_datetime"}
onValueChange={(
value:
| "current_datetime"
| "current_date"
| "current_time"
| "current_user"
| "uuid"
| "sequence"
| "user_defined",
) => {
onUpdateProperty(widget.id, "autoValueType", value);
}}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="current_datetime"> </SelectItem>
<SelectItem value="current_date"> </SelectItem>
<SelectItem value="current_time"> </SelectItem>
<SelectItem value="current_user"> </SelectItem>
<SelectItem value="uuid">UUID</SelectItem>
<SelectItem value="sequence">퀀</SelectItem>
<SelectItem value="user_defined"> </SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-gray-500">
{(() => {
switch (widget.autoValueType || "current_datetime") {
case "current_datetime":
return "현재 날짜와 시간을 자동으로 입력합니다";
case "current_date":
return "현재 날짜를 자동으로 입력합니다";
case "current_time":
return "현재 시간을 자동으로 입력합니다";
case "current_user":
return "현재 로그인한 사용자 정보를 입력합니다";
case "uuid":
return "고유한 UUID를 생성합니다";
case "sequence":
return "순차적인 번호를 생성합니다";
case "user_defined":
return "사용자가 정의한 규칙에 따라 값을 생성합니다";
default:
return "";
}
})()}
</p>
</div>
)}
</div>
)}
</div>
{/* 상세 설정 영역 */}

View File

@ -21,7 +21,11 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config
pattern: "",
format: "none" as const,
placeholder: "",
defaultValue: "",
multiline: false,
autoInput: false,
autoValueType: "current_datetime" as const,
customValue: "",
...config,
};
@ -32,7 +36,11 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config
pattern: safeConfig.pattern,
format: safeConfig.format,
placeholder: safeConfig.placeholder,
defaultValue: safeConfig.defaultValue,
multiline: safeConfig.multiline,
autoInput: safeConfig.autoInput,
autoValueType: safeConfig.autoValueType,
customValue: safeConfig.customValue,
});
// config가 변경될 때 로컬 상태 동기화
@ -43,7 +51,11 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config
pattern: safeConfig.pattern,
format: safeConfig.format,
placeholder: safeConfig.placeholder,
defaultValue: safeConfig.defaultValue,
multiline: safeConfig.multiline,
autoInput: safeConfig.autoInput,
autoValueType: safeConfig.autoValueType,
customValue: safeConfig.customValue,
});
}, [
safeConfig.minLength,
@ -51,7 +63,11 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config
safeConfig.pattern,
safeConfig.format,
safeConfig.placeholder,
safeConfig.defaultValue,
safeConfig.multiline,
safeConfig.autoInput,
safeConfig.autoValueType,
safeConfig.customValue,
]);
const updateConfig = (key: keyof TextTypeConfig, value: any) => {
@ -69,7 +85,11 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config
pattern: key === "pattern" ? value : localValues.pattern,
format: key === "format" ? value : localValues.format,
placeholder: key === "placeholder" ? value : localValues.placeholder,
defaultValue: key === "defaultValue" ? value : localValues.defaultValue,
multiline: key === "multiline" ? value : localValues.multiline,
autoInput: key === "autoInput" ? value : localValues.autoInput,
autoValueType: key === "autoValueType" ? value : localValues.autoValueType,
customValue: key === "customValue" ? value : localValues.customValue,
};
const newConfig = JSON.parse(JSON.stringify(currentValues));
@ -172,6 +192,80 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config
/>
</div>
{/* 기본값 */}
<div>
<Label htmlFor="defaultValue" className="text-sm font-medium">
</Label>
<Input
id="defaultValue"
value={localValues.defaultValue}
onChange={(e) => updateConfig("defaultValue", e.target.value)}
placeholder="기본 입력값"
className="mt-1"
/>
</div>
{/* 자동입력 설정 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="autoInput" className="text-sm font-medium">
</Label>
<Checkbox
id="autoInput"
checked={localValues.autoInput}
onCheckedChange={(checked) => updateConfig("autoInput", !!checked)}
/>
</div>
{localValues.autoInput && (
<div className="space-y-3 border-l-2 border-blue-200 pl-4">
<div>
<Label htmlFor="autoValueType" className="text-sm font-medium">
</Label>
<Select value={localValues.autoValueType} onValueChange={(value) => updateConfig("autoValueType", value)}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="자동값 타입 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="current_datetime"> </SelectItem>
<SelectItem value="current_date"> </SelectItem>
<SelectItem value="current_time"> </SelectItem>
<SelectItem value="current_user"> </SelectItem>
<SelectItem value="uuid"> ID (UUID)</SelectItem>
<SelectItem value="sequence"></SelectItem>
<SelectItem value="custom"> </SelectItem>
</SelectContent>
</Select>
</div>
{localValues.autoValueType === "custom" && (
<div>
<Label htmlFor="customValue" className="text-sm font-medium">
</Label>
<Input
id="customValue"
value={localValues.customValue}
onChange={(e) => updateConfig("customValue", e.target.value)}
placeholder="사용자 정의 값을 입력하세요"
className="mt-1"
/>
</div>
)}
<div className="rounded-md bg-blue-50 p-3">
<div className="text-sm font-medium text-blue-900"> </div>
<div className="mt-1 text-xs text-blue-800">
, .
</div>
</div>
</div>
)}
</div>
{/* 여러 줄 입력 */}
<div className="flex items-center justify-between">
<Label htmlFor="multiline" className="text-sm font-medium">

View File

@ -27,7 +27,6 @@ export type WebType =
// 버튼 기능 타입 정의
export type ButtonActionType =
| "save" // 저장
| "cancel" // 취소
| "delete" // 삭제
| "edit" // 수정
| "add" // 추가
@ -35,7 +34,7 @@ export type ButtonActionType =
| "reset" // 초기화
| "submit" // 제출
| "close" // 닫기
| "popup" // 팝업 열기
| "popup" // 모달 열기
| "navigate" // 페이지 이동
| "custom"; // 사용자 정의
@ -570,10 +569,23 @@ export interface TextTypeConfig {
minLength?: number;
maxLength?: number;
pattern?: string; // 정규식 패턴
format?: "none" | "email" | "phone" | "url" | "korean" | "english";
format?: "none" | "email" | "phone" | "url" | "korean" | "english" | "alphanumeric" | "numeric";
placeholder?: string;
defaultValue?: string; // 기본값
autocomplete?: string;
spellcheck?: boolean;
multiline?: boolean; // 여러 줄 입력 여부
// 자동입력 관련 설정
autoInput?: boolean; // 자동입력 활성화
autoValueType?:
| "current_datetime"
| "current_date"
| "current_time"
| "current_user"
| "uuid"
| "sequence"
| "custom"; // 자동값 타입
customValue?: string; // 사용자 정의 값
}
// 파일 타입 설정
@ -633,18 +645,18 @@ export interface EntityTypeConfig {
export interface ButtonTypeConfig {
actionType: ButtonActionType;
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
size?: "default" | "sm" | "lg" | "icon";
icon?: string; // Lucide 아이콘 이름
confirmMessage?: string; // 확인 메시지 (delete, submit 등에서 사용)
// 팝업 관련 설정
// 모달 관련 설정
popupTitle?: string;
popupContent?: string;
popupSize?: "sm" | "md" | "lg" | "xl";
popupScreenId?: number; // 팝업으로 열 화면 ID
popupScreenId?: number; // 모달로 열 화면 ID
// 네비게이션 관련 설정
navigateType?: "url" | "screen"; // 네비게이션 방식: URL 직접 입력 또는 화면 선택
navigateUrl?: string;
navigateScreenId?: number; // 이동할 화면 ID
navigateTarget?: "_self" | "_blank";
// 커스텀 액션 설정