Compare commits
2 Commits
134976ff9e
...
49e8e40521
| Author | SHA1 | Date |
|---|---|---|
|
|
49e8e40521 | |
|
|
b071d8090b |
|
|
@ -11,6 +11,7 @@ import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRendere
|
|||
import { DynamicWebTypeRenderer } from "@/lib/registry";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { initializeComponents } from "@/lib/registry/components";
|
||||
|
||||
export default function ScreenViewPage() {
|
||||
const params = useParams();
|
||||
|
|
@ -23,6 +24,20 @@ export default function ScreenViewPage() {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const initComponents = async () => {
|
||||
try {
|
||||
console.log("🚀 할당된 화면에서 컴포넌트 시스템 초기화 시작...");
|
||||
await initializeComponents();
|
||||
console.log("✅ 할당된 화면에서 컴포넌트 시스템 초기화 완료");
|
||||
} catch (error) {
|
||||
console.error("❌ 할당된 화면에서 컴포넌트 시스템 초기화 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
initComponents();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const loadScreen = async () => {
|
||||
try {
|
||||
|
|
@ -150,10 +165,19 @@ export default function ScreenViewPage() {
|
|||
allComponents={layout.components}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
console.log("📝 폼 데이터 변경:", { fieldName, value });
|
||||
setFormData((prev) => {
|
||||
const newFormData = {
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
};
|
||||
console.log("📊 전체 폼 데이터:", newFormData);
|
||||
return newFormData;
|
||||
});
|
||||
}}
|
||||
screenInfo={{
|
||||
id: screenId,
|
||||
tableName: screen?.tableName,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -234,6 +258,14 @@ export default function ScreenViewPage() {
|
|||
[fieldName]: value,
|
||||
}));
|
||||
}}
|
||||
screenId={screenId}
|
||||
tableName={screen?.tableName}
|
||||
onRefresh={() => {
|
||||
console.log("화면 새로고침 요청");
|
||||
}}
|
||||
onClose={() => {
|
||||
console.log("화면 닫기 요청");
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<DynamicWebTypeRenderer
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { Inter, JetBrains_Mono } from "next/font/google";
|
|||
import "./globals.css";
|
||||
import { QueryProvider } from "@/providers/QueryProvider";
|
||||
import { RegistryProvider } from "./registry-provider";
|
||||
import { Toaster } from "sonner";
|
||||
import ScreenModal from "@/components/common/ScreenModal";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
|
|
@ -44,6 +46,8 @@ export default function RootLayout({
|
|||
<QueryProvider>
|
||||
<RegistryProvider>{children}</RegistryProvider>
|
||||
</QueryProvider>
|
||||
<Toaster position="top-right" richColors />
|
||||
<ScreenModal />
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,224 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { ComponentData } from "@/types/screen";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ScreenModalState {
|
||||
isOpen: boolean;
|
||||
screenId: number | null;
|
||||
title: string;
|
||||
size: "sm" | "md" | "lg" | "xl";
|
||||
}
|
||||
|
||||
interface ScreenModalProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||
const [modalState, setModalState] = useState<ScreenModalState>({
|
||||
isOpen: false,
|
||||
screenId: null,
|
||||
title: "",
|
||||
size: "md",
|
||||
});
|
||||
|
||||
const [screenData, setScreenData] = useState<{
|
||||
components: ComponentData[];
|
||||
screenInfo: any;
|
||||
} | null>(null);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [screenDimensions, setScreenDimensions] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
|
||||
// 화면의 실제 크기 계산 함수
|
||||
const calculateScreenDimensions = (components: ComponentData[]) => {
|
||||
let maxWidth = 800; // 최소 너비
|
||||
let maxHeight = 600; // 최소 높이
|
||||
|
||||
components.forEach((component) => {
|
||||
const x = parseFloat(component.style?.positionX || "0");
|
||||
const y = parseFloat(component.style?.positionY || "0");
|
||||
const width = parseFloat(component.style?.width || "100");
|
||||
const height = parseFloat(component.style?.height || "40");
|
||||
|
||||
// 컴포넌트의 오른쪽 끝과 아래쪽 끝 계산
|
||||
const rightEdge = x + width;
|
||||
const bottomEdge = y + height;
|
||||
|
||||
maxWidth = Math.max(maxWidth, rightEdge + 50); // 여백 추가
|
||||
maxHeight = Math.max(maxHeight, bottomEdge + 50); // 여백 추가
|
||||
});
|
||||
|
||||
return {
|
||||
width: Math.min(maxWidth, window.innerWidth * 0.9), // 화면의 90%를 넘지 않도록
|
||||
height: Math.min(maxHeight, window.innerHeight * 0.8), // 화면의 80%를 넘지 않도록
|
||||
};
|
||||
};
|
||||
|
||||
// 전역 모달 이벤트 리스너
|
||||
useEffect(() => {
|
||||
const handleOpenModal = (event: CustomEvent) => {
|
||||
const { screenId, title, size } = event.detail;
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
screenId,
|
||||
title,
|
||||
size,
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("openScreenModal", handleOpenModal as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("openScreenModal", handleOpenModal as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 화면 데이터 로딩
|
||||
useEffect(() => {
|
||||
if (modalState.isOpen && modalState.screenId) {
|
||||
loadScreenData(modalState.screenId);
|
||||
}
|
||||
}, [modalState.isOpen, modalState.screenId]);
|
||||
|
||||
const loadScreenData = async (screenId: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
console.log("화면 데이터 로딩 시작:", screenId);
|
||||
|
||||
// 화면 정보와 레이아웃 데이터 로딩
|
||||
const [screenInfo, layoutData] = await Promise.all([
|
||||
screenApi.getScreen(screenId),
|
||||
screenApi.getLayout(screenId),
|
||||
]);
|
||||
|
||||
console.log("API 응답:", { screenInfo, layoutData });
|
||||
|
||||
// screenApi는 직접 데이터를 반환하므로 .success 체크 불필요
|
||||
if (screenInfo && layoutData) {
|
||||
const components = layoutData.components || [];
|
||||
|
||||
// 화면의 실제 크기 계산
|
||||
const dimensions = calculateScreenDimensions(components);
|
||||
setScreenDimensions(dimensions);
|
||||
|
||||
setScreenData({
|
||||
components,
|
||||
screenInfo: screenInfo,
|
||||
});
|
||||
console.log("화면 데이터 설정 완료:", {
|
||||
componentsCount: components.length,
|
||||
dimensions,
|
||||
screenInfo,
|
||||
});
|
||||
} else {
|
||||
throw new Error("화면 데이터가 없습니다");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("화면 데이터 로딩 오류:", error);
|
||||
toast.error("화면을 불러오는 중 오류가 발생했습니다.");
|
||||
handleClose();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setModalState({
|
||||
isOpen: false,
|
||||
screenId: null,
|
||||
title: "",
|
||||
size: "md",
|
||||
});
|
||||
setScreenData(null);
|
||||
};
|
||||
|
||||
// 모달 크기 설정 - 화면 내용에 맞게 동적 조정
|
||||
const getModalStyle = () => {
|
||||
if (!screenDimensions) {
|
||||
return {
|
||||
className: "w-fit min-w-[400px] max-w-4xl max-h-[80vh] overflow-hidden",
|
||||
style: {}
|
||||
};
|
||||
}
|
||||
|
||||
// 헤더 높이와 패딩을 고려한 전체 높이 계산
|
||||
const headerHeight = 60; // DialogHeader + 패딩
|
||||
const totalHeight = screenDimensions.height + headerHeight;
|
||||
|
||||
return {
|
||||
className: "overflow-hidden p-0",
|
||||
style: {
|
||||
width: `${screenDimensions.width + 48}px`, // 헤더 패딩과 여백 고려
|
||||
height: `${Math.min(totalHeight, window.innerHeight * 0.8)}px`,
|
||||
maxWidth: '90vw',
|
||||
maxHeight: '80vh'
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const modalStyle = getModalStyle();
|
||||
|
||||
return (
|
||||
<Dialog open={modalState.isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent
|
||||
className={`${modalStyle.className} ${className || ""}`}
|
||||
style={modalStyle.style}
|
||||
>
|
||||
<DialogHeader className="px-6 py-4 border-b">
|
||||
<DialogTitle>{modalState.title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-hidden p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">화면을 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : screenData ? (
|
||||
<div
|
||||
className="relative bg-white overflow-hidden"
|
||||
style={{
|
||||
width: (screenDimensions?.width || 800),
|
||||
height: (screenDimensions?.height || 600),
|
||||
}}
|
||||
>
|
||||
{screenData.components.map((component) => (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={component.id}
|
||||
component={component}
|
||||
allComponents={screenData.components}
|
||||
screenInfo={{
|
||||
id: modalState.screenId!,
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-gray-600">화면 데이터가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScreenModal;
|
||||
|
|
@ -165,6 +165,9 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
componentId: comp.id,
|
||||
componentType: comp.type,
|
||||
componentConfig: comp.componentConfig,
|
||||
style: comp.style,
|
||||
size: comp.size,
|
||||
position: comp.position,
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
@ -173,6 +176,21 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
isInteractive={true}
|
||||
formData={formData}
|
||||
onFormDataChange={handleFormDataChange}
|
||||
screenId={screenInfo?.id}
|
||||
tableName={screenInfo?.tableName}
|
||||
onRefresh={() => {
|
||||
// 화면 새로고침 로직 (필요시 구현)
|
||||
console.log("화면 새로고침 요청");
|
||||
}}
|
||||
onClose={() => {
|
||||
// 화면 닫기 로직 (필요시 구현)
|
||||
console.log("화면 닫기 요청");
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
...comp.style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import "@/lib/registry/components";
|
|||
interface RealtimePreviewProps {
|
||||
component: ComponentData;
|
||||
isSelected?: boolean;
|
||||
isDesignMode?: boolean; // 편집 모드 여부
|
||||
onClick?: (e?: React.MouseEvent) => void;
|
||||
onDragStart?: (e: React.DragEvent) => void;
|
||||
onDragEnd?: () => void;
|
||||
|
|
@ -63,6 +64,7 @@ const getWidgetIcon = (widgetType: WebType | undefined): React.ReactNode => {
|
|||
export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||
component,
|
||||
isSelected = false,
|
||||
isDesignMode = true, // 기본값은 편집 모드
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
|
|
@ -122,6 +124,8 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
<DynamicComponentRenderer
|
||||
component={component}
|
||||
isSelected={isSelected}
|
||||
isDesignMode={isDesignMode}
|
||||
isInteractive={!isDesignMode} // 편집 모드가 아닐 때만 인터랙티브
|
||||
onClick={onClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
SCREEN_RESOLUTIONS,
|
||||
} from "@/types/screen";
|
||||
import { generateComponentId } from "@/lib/utils/generateId";
|
||||
import { getComponentIdFromWebType } from "@/lib/utils/webTypeMapping";
|
||||
import {
|
||||
createGroupComponent,
|
||||
calculateBoundingBox,
|
||||
|
|
@ -38,6 +39,7 @@ import { GroupingToolbar } from "./GroupingToolbar";
|
|||
import { screenApi, tableTypeApi } from "@/lib/api/screen";
|
||||
import { toast } from "sonner";
|
||||
import { MenuAssignmentModal } from "./MenuAssignmentModal";
|
||||
import { initializeComponents } from "@/lib/registry/components";
|
||||
|
||||
import StyleEditor from "./StyleEditor";
|
||||
import { RealtimePreview } from "./RealtimePreviewDynamic";
|
||||
|
|
@ -629,6 +631,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
[layout, gridInfo, saveToHistory],
|
||||
);
|
||||
|
||||
// 컴포넌트 시스템 초기화
|
||||
useEffect(() => {
|
||||
const initComponents = async () => {
|
||||
try {
|
||||
console.log("🚀 컴포넌트 시스템 초기화 시작...");
|
||||
await initializeComponents();
|
||||
console.log("✅ 컴포넌트 시스템 초기화 완료");
|
||||
} catch (error) {
|
||||
console.error("❌ 컴포넌트 시스템 초기화 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
initComponents();
|
||||
}, []);
|
||||
|
||||
// 테이블 데이터 로드 (성능 최적화: 선택된 테이블만 조회)
|
||||
useEffect(() => {
|
||||
if (selectedScreen?.tableName && selectedScreen.tableName.trim()) {
|
||||
|
|
@ -1499,7 +1516,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
|
||||
const newComponent: ComponentData = {
|
||||
id: generateComponentId(),
|
||||
type: "widget", // 새 컴포넌트는 모두 widget 타입
|
||||
type: "component", // ✅ 새 컴포넌트 시스템 사용
|
||||
label: component.name,
|
||||
widgetType: component.webType,
|
||||
componentType: component.id, // 새 컴포넌트 시스템의 ID (DynamicComponentRenderer용)
|
||||
|
|
@ -1507,11 +1524,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
size: component.defaultSize,
|
||||
componentConfig: {
|
||||
type: component.id, // 새 컴포넌트 시스템의 ID 사용
|
||||
webType: component.webType, // 웹타입 정보 추가
|
||||
...component.defaultConfig,
|
||||
},
|
||||
webTypeConfig: getDefaultWebTypeConfig(component.webType),
|
||||
style: {
|
||||
labelDisplay: true,
|
||||
labelDisplay: component.id === "text-display" ? false : true, // 텍스트 표시 컴포넌트는 기본적으로 라벨 숨김
|
||||
labelFontSize: "14px",
|
||||
labelColor: "#374151",
|
||||
labelFontWeight: "500",
|
||||
|
|
@ -1547,10 +1565,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
e.preventDefault();
|
||||
|
||||
const dragData = e.dataTransfer.getData("application/json");
|
||||
if (!dragData) return;
|
||||
console.log("🎯 드롭 이벤트:", { dragData });
|
||||
if (!dragData) {
|
||||
console.log("❌ 드래그 데이터가 없습니다");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedData = JSON.parse(dragData);
|
||||
console.log("📋 파싱된 데이터:", parsedData);
|
||||
|
||||
// 템플릿 드래그인 경우
|
||||
if (parsedData.type === "template") {
|
||||
|
|
@ -1603,6 +1626,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
},
|
||||
};
|
||||
} else if (type === "column") {
|
||||
console.log("🔄 컬럼 드롭 처리:", { webType: column.widgetType, columnName: column.columnName });
|
||||
// 현재 해상도에 맞는 격자 정보로 기본 크기 계산
|
||||
const currentGridInfo = layout.gridSettings
|
||||
? calculateGridInfo(screenResolution.width, screenResolution.height, {
|
||||
|
|
@ -1767,18 +1791,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
const relativeX = e.clientX - containerRect.left;
|
||||
const relativeY = e.clientY - containerRect.top;
|
||||
|
||||
// 웹타입을 새로운 컴포넌트 ID로 매핑
|
||||
const componentId = getComponentIdFromWebType(column.widgetType);
|
||||
console.log(`🔄 폼 컨테이너 드롭: ${column.widgetType} → ${componentId}`);
|
||||
|
||||
newComponent = {
|
||||
id: generateComponentId(),
|
||||
type: "widget",
|
||||
label: column.columnName,
|
||||
type: "component", // ✅ 새로운 컴포넌트 시스템 사용
|
||||
label: column.columnLabel || column.columnName,
|
||||
tableName: table.tableName,
|
||||
columnName: column.columnName,
|
||||
widgetType: column.widgetType,
|
||||
required: column.required,
|
||||
readonly: false,
|
||||
parentId: formContainerId, // 폼 컨테이너의 자식으로 설정
|
||||
position: { x: relativeX, y: relativeY, z: 1 } as Position,
|
||||
size: { width: defaultWidth, height: 40 },
|
||||
gridColumns: 1,
|
||||
style: {
|
||||
labelDisplay: true,
|
||||
labelFontSize: "12px",
|
||||
|
|
@ -1786,20 +1814,26 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
labelFontWeight: "500",
|
||||
labelMarginBottom: "6px",
|
||||
},
|
||||
webTypeConfig: getDefaultWebTypeConfig(column.widgetType),
|
||||
componentConfig: {
|
||||
type: componentId, // text-input, number-input 등
|
||||
webType: column.widgetType, // 원본 웹타입 보존
|
||||
...getDefaultWebTypeConfig(column.widgetType),
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return; // 폼 컨테이너를 찾을 수 없으면 드롭 취소
|
||||
}
|
||||
} else {
|
||||
// 일반 캔버스에 드롭한 경우 (기존 로직)
|
||||
// 일반 캔버스에 드롭한 경우 - 새로운 컴포넌트 시스템 사용
|
||||
const componentId = getComponentIdFromWebType(column.widgetType);
|
||||
console.log(`🔄 캔버스 드롭: ${column.widgetType} → ${componentId}`);
|
||||
|
||||
newComponent = {
|
||||
id: generateComponentId(),
|
||||
type: "widget",
|
||||
type: "component", // ✅ 새로운 컴포넌트 시스템 사용
|
||||
label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명
|
||||
tableName: table.tableName,
|
||||
columnName: column.columnName,
|
||||
widgetType: column.widgetType,
|
||||
required: column.required,
|
||||
readonly: false,
|
||||
position: { x, y, z: 1 } as Position,
|
||||
|
|
@ -1812,7 +1846,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
labelFontWeight: "500",
|
||||
labelMarginBottom: "6px",
|
||||
},
|
||||
webTypeConfig: getDefaultWebTypeConfig(column.widgetType),
|
||||
componentConfig: {
|
||||
type: componentId, // text-input, number-input 등
|
||||
webType: column.widgetType, // 원본 웹타입 보존
|
||||
...getDefaultWebTypeConfig(column.widgetType),
|
||||
},
|
||||
};
|
||||
}
|
||||
} else {
|
||||
|
|
@ -3093,7 +3131,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
console.log("🎯 캔버스 드롭 이벤트 발생");
|
||||
handleComponentDrop(e);
|
||||
handleDrop(e);
|
||||
}}
|
||||
>
|
||||
{/* 격자 라인 */}
|
||||
|
|
@ -3176,6 +3214,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
isSelected={
|
||||
selectedComponent?.id === component.id || groupState.selectedComponents.includes(component.id)
|
||||
}
|
||||
isDesignMode={true} // 편집 모드로 설정
|
||||
onClick={(e) => handleComponentClick(component, e)}
|
||||
onDragStart={(e) => startComponentDrag(component, e)}
|
||||
onDragEnd={endDrag}
|
||||
|
|
@ -3255,6 +3294,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
isSelected={
|
||||
selectedComponent?.id === child.id || groupState.selectedComponents.includes(child.id)
|
||||
}
|
||||
isDesignMode={true} // 편집 모드로 설정
|
||||
onClick={(e) => handleComponentClick(child, e)}
|
||||
onDragStart={(e) => startComponentDrag(child, e)}
|
||||
onDragEnd={endDrag}
|
||||
|
|
@ -3323,11 +3363,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
onDragStart={(e, table, column) => {
|
||||
console.log("🚀 드래그 시작:", { table: table.tableName, column: column?.columnName });
|
||||
const dragData = {
|
||||
type: column ? "column" : "table",
|
||||
table,
|
||||
column,
|
||||
};
|
||||
console.log("📦 드래그 데이터:", dragData);
|
||||
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
|
||||
}}
|
||||
selectedTableName={selectedScreen.tableName}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,82 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Check, ChevronsUpDown, Search } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ComponentData } from "@/types/screen";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface ButtonConfigPanelProps {
|
||||
component: ComponentData;
|
||||
onUpdateProperty: (path: string, value: any) => void;
|
||||
}
|
||||
|
||||
interface ScreenOption {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component, onUpdateProperty }) => {
|
||||
const config = component.componentConfig || {};
|
||||
const [screens, setScreens] = useState<ScreenOption[]>([]);
|
||||
const [screensLoading, setScreensLoading] = useState(false);
|
||||
const [modalScreenOpen, setModalScreenOpen] = useState(false);
|
||||
const [navScreenOpen, setNavScreenOpen] = useState(false);
|
||||
const [modalSearchTerm, setModalSearchTerm] = useState("");
|
||||
const [navSearchTerm, setNavSearchTerm] = useState("");
|
||||
|
||||
// 화면 목록 가져오기
|
||||
useEffect(() => {
|
||||
const fetchScreens = async () => {
|
||||
try {
|
||||
setScreensLoading(true);
|
||||
console.log("🔍 화면 목록 API 호출 시작");
|
||||
const response = await apiClient.get("/screen-management/screens");
|
||||
console.log("✅ 화면 목록 API 응답:", response.data);
|
||||
|
||||
if (response.data.success && Array.isArray(response.data.data)) {
|
||||
const screenList = response.data.data.map((screen: any) => ({
|
||||
id: screen.screenId,
|
||||
name: screen.screenName,
|
||||
description: screen.description,
|
||||
}));
|
||||
setScreens(screenList);
|
||||
console.log("✅ 화면 목록 설정 완료:", screenList.length, "개");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 화면 목록 로딩 실패:", error);
|
||||
} finally {
|
||||
setScreensLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchScreens();
|
||||
}, []);
|
||||
|
||||
// 검색 필터링 함수
|
||||
const filterScreens = (searchTerm: string) => {
|
||||
if (!searchTerm.trim()) return screens;
|
||||
return screens.filter(
|
||||
(screen) =>
|
||||
screen.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(screen.description && screen.description.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
);
|
||||
};
|
||||
|
||||
console.log("🔧 config-panels/ButtonConfigPanel 렌더링:", {
|
||||
component,
|
||||
config,
|
||||
action: config.action,
|
||||
actionType: config.action?.type,
|
||||
screensCount: screens.length,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -68,8 +131,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
|||
<div>
|
||||
<Label htmlFor="button-action">버튼 액션</Label>
|
||||
<Select
|
||||
value={config.action || "custom"}
|
||||
onValueChange={(value) => onUpdateProperty("componentConfig.action", value)}
|
||||
value={config.action?.type || "save"}
|
||||
defaultValue="save"
|
||||
onValueChange={(value) => onUpdateProperty("componentConfig.action", { type: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="버튼 액션 선택" />
|
||||
|
|
@ -84,10 +148,278 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
|||
<SelectItem value="reset">초기화</SelectItem>
|
||||
<SelectItem value="submit">제출</SelectItem>
|
||||
<SelectItem value="close">닫기</SelectItem>
|
||||
<SelectItem value="custom">사용자 정의</SelectItem>
|
||||
<SelectItem value="modal">모달 열기</SelectItem>
|
||||
<SelectItem value="navigate">페이지 이동</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 모달 열기 액션 설정 */}
|
||||
{config.action?.type === "modal" && (
|
||||
<div className="mt-4 space-y-4 rounded-lg border bg-gray-50 p-4">
|
||||
<h4 className="text-sm font-medium text-gray-700">모달 설정</h4>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="modal-title">모달 제목</Label>
|
||||
<Input
|
||||
id="modal-title"
|
||||
placeholder="모달 제목을 입력하세요"
|
||||
value={config.action?.modalTitle || ""}
|
||||
onChange={(e) =>
|
||||
onUpdateProperty("componentConfig.action", {
|
||||
...config.action,
|
||||
modalTitle: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="modal-size">모달 크기</Label>
|
||||
<Select
|
||||
value={config.action?.modalSize || "md"}
|
||||
onValueChange={(value) =>
|
||||
onUpdateProperty("componentConfig.action", {
|
||||
...config.action,
|
||||
modalSize: value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="모달 크기 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sm">작음 (Small)</SelectItem>
|
||||
<SelectItem value="md">보통 (Medium)</SelectItem>
|
||||
<SelectItem value="lg">큼 (Large)</SelectItem>
|
||||
<SelectItem value="xl">매우 큼 (Extra Large)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="target-screen-modal">대상 화면 선택</Label>
|
||||
<Popover open={modalScreenOpen} onOpenChange={setModalScreenOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={modalScreenOpen}
|
||||
className="w-full justify-between h-10"
|
||||
disabled={screensLoading}
|
||||
>
|
||||
{config.action?.targetScreenId
|
||||
? screens.find((screen) => screen.id === config.action?.targetScreenId)?.name ||
|
||||
"화면을 선택하세요..."
|
||||
: "화면을 선택하세요..."}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start" style={{ width: 'var(--radix-popover-trigger-width)' }}>
|
||||
<div className="flex flex-col">
|
||||
{/* 검색 입력 */}
|
||||
<div className="flex items-center border-b px-3 py-2">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<Input
|
||||
placeholder="화면 검색..."
|
||||
value={modalSearchTerm}
|
||||
onChange={(e) => setModalSearchTerm(e.target.value)}
|
||||
className="border-0 p-0 focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
{/* 검색 결과 */}
|
||||
<div className="max-h-[200px] overflow-auto">
|
||||
{(() => {
|
||||
const filteredScreens = filterScreens(modalSearchTerm);
|
||||
if (screensLoading) {
|
||||
return <div className="p-3 text-sm text-gray-500">화면 목록을 불러오는 중...</div>;
|
||||
}
|
||||
if (filteredScreens.length === 0) {
|
||||
return <div className="p-3 text-sm text-gray-500">검색 결과가 없습니다.</div>;
|
||||
}
|
||||
return filteredScreens.map((screen, index) => (
|
||||
<div
|
||||
key={`modal-screen-${screen.id}-${index}`}
|
||||
className="flex cursor-pointer items-center px-3 py-2 hover:bg-gray-100"
|
||||
onClick={() => {
|
||||
onUpdateProperty("componentConfig.action", {
|
||||
...config.action,
|
||||
targetScreenId: screen.id,
|
||||
});
|
||||
setModalScreenOpen(false);
|
||||
setModalSearchTerm("");
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
config.action?.targetScreenId === screen.id ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{screen.name}</span>
|
||||
{screen.description && <span className="text-xs text-gray-500">{screen.description}</span>}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 페이지 이동 액션 설정 */}
|
||||
{config.action?.type === "navigate" && (
|
||||
<div className="mt-4 space-y-4 rounded-lg border bg-gray-50 p-4">
|
||||
<h4 className="text-sm font-medium text-gray-700">페이지 이동 설정</h4>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="target-screen-nav">이동할 화면 선택</Label>
|
||||
<Popover open={navScreenOpen} onOpenChange={setNavScreenOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={navScreenOpen}
|
||||
className="w-full justify-between h-10"
|
||||
disabled={screensLoading}
|
||||
>
|
||||
{config.action?.targetScreenId
|
||||
? screens.find((screen) => screen.id === config.action?.targetScreenId)?.name ||
|
||||
"화면을 선택하세요..."
|
||||
: "화면을 선택하세요..."}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start" style={{ width: 'var(--radix-popover-trigger-width)' }}>
|
||||
<div className="flex flex-col">
|
||||
{/* 검색 입력 */}
|
||||
<div className="flex items-center border-b px-3 py-2">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<Input
|
||||
placeholder="화면 검색..."
|
||||
value={navSearchTerm}
|
||||
onChange={(e) => setNavSearchTerm(e.target.value)}
|
||||
className="border-0 p-0 focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
{/* 검색 결과 */}
|
||||
<div className="max-h-[200px] overflow-auto">
|
||||
{(() => {
|
||||
const filteredScreens = filterScreens(navSearchTerm);
|
||||
if (screensLoading) {
|
||||
return <div className="p-3 text-sm text-gray-500">화면 목록을 불러오는 중...</div>;
|
||||
}
|
||||
if (filteredScreens.length === 0) {
|
||||
return <div className="p-3 text-sm text-gray-500">검색 결과가 없습니다.</div>;
|
||||
}
|
||||
return filteredScreens.map((screen, index) => (
|
||||
<div
|
||||
key={`navigate-screen-${screen.id}-${index}`}
|
||||
className="flex cursor-pointer items-center px-3 py-2 hover:bg-gray-100"
|
||||
onClick={() => {
|
||||
onUpdateProperty("componentConfig.action", {
|
||||
...config.action,
|
||||
targetScreenId: screen.id,
|
||||
});
|
||||
setNavScreenOpen(false);
|
||||
setNavSearchTerm("");
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
config.action?.targetScreenId === screen.id ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{screen.name}</span>
|
||||
{screen.description && <span className="text-xs text-gray-500">{screen.description}</span>}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
선택한 화면으로 /screens/{"{"}화면ID{"}"} 형태로 이동합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="target-url">또는 직접 URL 입력 (고급)</Label>
|
||||
<Input
|
||||
id="target-url"
|
||||
placeholder="예: /admin/users 또는 https://example.com"
|
||||
value={config.action?.targetUrl || ""}
|
||||
onChange={(e) =>
|
||||
onUpdateProperty("componentConfig.action", {
|
||||
...config.action,
|
||||
targetUrl: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">URL을 입력하면 화면 선택보다 우선 적용됩니다</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 확인 메시지 설정 (모든 액션 공통) */}
|
||||
{config.action?.type && config.action.type !== "cancel" && config.action.type !== "close" && (
|
||||
<div className="mt-4 space-y-4 rounded-lg border bg-blue-50 p-4">
|
||||
<h4 className="text-sm font-medium text-gray-700">확인 메시지 설정</h4>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="confirm-message">실행 전 확인 메시지</Label>
|
||||
<Input
|
||||
id="confirm-message"
|
||||
placeholder="예: 정말 저장하시겠습니까?"
|
||||
value={config.action?.confirmMessage || ""}
|
||||
onChange={(e) =>
|
||||
onUpdateProperty("componentConfig.action", {
|
||||
...config.action,
|
||||
confirmMessage: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="success-message">성공 메시지</Label>
|
||||
<Input
|
||||
id="success-message"
|
||||
placeholder="예: 저장되었습니다."
|
||||
value={config.action?.successMessage || ""}
|
||||
onChange={(e) =>
|
||||
onUpdateProperty("componentConfig.action", {
|
||||
...config.action,
|
||||
successMessage: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="error-message">오류 메시지</Label>
|
||||
<Input
|
||||
id="error-message"
|
||||
placeholder="예: 저장 중 오류가 발생했습니다."
|
||||
value={config.action?.errorMessage || ""}
|
||||
onChange={(e) =>
|
||||
onUpdateProperty("componentConfig.action", {
|
||||
...config.action,
|
||||
errorMessage: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,537 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Save,
|
||||
X,
|
||||
Trash2,
|
||||
Edit,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
Send,
|
||||
ExternalLink,
|
||||
MousePointer,
|
||||
Settings,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { ButtonActionType, ButtonTypeConfig, WidgetComponent, ScreenDefinition } from "@/types/screen";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
|
||||
interface ButtonConfigPanelProps {
|
||||
component: WidgetComponent;
|
||||
onUpdateComponent: (updates: Partial<WidgetComponent>) => void;
|
||||
}
|
||||
|
||||
const actionTypeOptions: { value: ButtonActionType; label: string; icon: React.ReactNode; color: string }[] = [
|
||||
{ value: "save", label: "저장", icon: <Save className="h-4 w-4" />, color: "#3b82f6" },
|
||||
{ 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" },
|
||||
{ value: "search", label: "검색", icon: <MousePointer className="h-4 w-4" />, color: "#8b5cf6" },
|
||||
{ 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: "navigate", label: "페이지 이동", icon: <ExternalLink className="h-4 w-4" />, color: "#0ea5e9" },
|
||||
{ value: "custom", label: "사용자 정의", icon: <Settings className="h-4 w-4" />, color: "#64748b" },
|
||||
];
|
||||
|
||||
export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component, onUpdateComponent }) => {
|
||||
const config = (component.webTypeConfig as ButtonTypeConfig) || {};
|
||||
|
||||
// 로컬 상태 관리
|
||||
const [localConfig, setLocalConfig] = useState<ButtonTypeConfig>(() => {
|
||||
const defaultConfig = {
|
||||
actionType: "custom" as ButtonActionType,
|
||||
variant: "default" as ButtonVariant,
|
||||
};
|
||||
|
||||
return {
|
||||
...defaultConfig,
|
||||
...config, // 저장된 값이 기본값을 덮어씀
|
||||
};
|
||||
});
|
||||
|
||||
// 화면 목록 상태
|
||||
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
|
||||
const [screensLoading, setScreensLoading] = useState(false);
|
||||
|
||||
// 화면 목록 로드 함수
|
||||
const loadScreens = async () => {
|
||||
try {
|
||||
setScreensLoading(true);
|
||||
const response = await screenApi.getScreens({ size: 1000 }); // 모든 화면 가져오기
|
||||
setScreens(response.data);
|
||||
} catch (error) {
|
||||
console.error("화면 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setScreensLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 모달 또는 네비게이션 액션 타입일 때 화면 목록 로드
|
||||
useEffect(() => {
|
||||
if (localConfig.actionType === "popup" || localConfig.actionType === "navigate") {
|
||||
loadScreens();
|
||||
}
|
||||
}, [localConfig.actionType]);
|
||||
|
||||
// 컴포넌트 변경 시 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
const newConfig = (component.webTypeConfig as ButtonTypeConfig) || {};
|
||||
|
||||
// 기본값 설정 (실제 값이 있으면 덮어쓰지 않음)
|
||||
const defaultConfig = {
|
||||
actionType: "custom" as ButtonActionType,
|
||||
variant: "default" as ButtonVariant,
|
||||
};
|
||||
|
||||
// 실제 저장된 값이 우선순위를 가지도록 설정
|
||||
setLocalConfig({
|
||||
...defaultConfig,
|
||||
...newConfig, // 저장된 값이 기본값을 덮어씀
|
||||
});
|
||||
|
||||
console.log("🔄 ButtonConfigPanel 로컬 상태 동기화:", {
|
||||
componentId: component.id,
|
||||
savedConfig: newConfig,
|
||||
finalConfig: { ...defaultConfig, ...newConfig },
|
||||
});
|
||||
}, [component.webTypeConfig, component.id]);
|
||||
|
||||
// 설정 업데이트 함수
|
||||
const updateConfig = (updates: Partial<ButtonTypeConfig>) => {
|
||||
const newConfig = { ...localConfig, ...updates };
|
||||
setLocalConfig(newConfig);
|
||||
|
||||
// 스타일 업데이트도 함께 적용
|
||||
const styleUpdates: any = {};
|
||||
if (updates.backgroundColor) styleUpdates.backgroundColor = updates.backgroundColor;
|
||||
if (updates.textColor) styleUpdates.color = updates.textColor;
|
||||
if (updates.borderColor) styleUpdates.borderColor = updates.borderColor;
|
||||
|
||||
onUpdateComponent({
|
||||
webTypeConfig: newConfig,
|
||||
...(Object.keys(styleUpdates).length > 0 && {
|
||||
style: { ...component.style, ...styleUpdates },
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
// 액션 타입 변경 시 기본값 설정
|
||||
const handleActionTypeChange = (actionType: ButtonActionType) => {
|
||||
const actionOption = actionTypeOptions.find((opt) => opt.value === actionType);
|
||||
const updates: Partial<ButtonTypeConfig> = { actionType };
|
||||
|
||||
// 액션 타입에 따른 기본 설정
|
||||
switch (actionType) {
|
||||
case "save":
|
||||
updates.variant = "default";
|
||||
updates.backgroundColor = "#3b82f6";
|
||||
updates.textColor = "#ffffff";
|
||||
// 버튼 라벨과 스타일도 업데이트
|
||||
onUpdateComponent({
|
||||
label: "저장",
|
||||
style: { ...component.style, backgroundColor: "#3b82f6", color: "#ffffff" },
|
||||
});
|
||||
break;
|
||||
case "close":
|
||||
updates.variant = "outline";
|
||||
updates.backgroundColor = "transparent";
|
||||
updates.textColor = "#6b7280";
|
||||
onUpdateComponent({
|
||||
label: "닫기",
|
||||
style: { ...component.style, backgroundColor: "transparent", color: "#6b7280", border: "1px solid #d1d5db" },
|
||||
});
|
||||
break;
|
||||
case "delete":
|
||||
updates.variant = "destructive";
|
||||
updates.backgroundColor = "#ef4444";
|
||||
updates.textColor = "#ffffff";
|
||||
updates.confirmMessage = "정말로 삭제하시겠습니까?";
|
||||
onUpdateComponent({
|
||||
label: "삭제",
|
||||
style: { ...component.style, backgroundColor: "#ef4444", color: "#ffffff" },
|
||||
});
|
||||
break;
|
||||
case "edit":
|
||||
updates.backgroundColor = "#f59e0b";
|
||||
updates.textColor = "#ffffff";
|
||||
onUpdateComponent({
|
||||
label: "수정",
|
||||
style: { ...component.style, backgroundColor: "#f59e0b", color: "#ffffff" },
|
||||
});
|
||||
break;
|
||||
case "add":
|
||||
updates.backgroundColor = "#10b981";
|
||||
updates.textColor = "#ffffff";
|
||||
onUpdateComponent({
|
||||
label: "추가",
|
||||
style: { ...component.style, backgroundColor: "#10b981", color: "#ffffff" },
|
||||
});
|
||||
break;
|
||||
case "search":
|
||||
updates.backgroundColor = "#8b5cf6";
|
||||
updates.textColor = "#ffffff";
|
||||
onUpdateComponent({
|
||||
label: "검색",
|
||||
style: { ...component.style, backgroundColor: "#8b5cf6", color: "#ffffff" },
|
||||
});
|
||||
break;
|
||||
case "reset":
|
||||
updates.variant = "outline";
|
||||
updates.backgroundColor = "transparent";
|
||||
updates.textColor = "#6b7280";
|
||||
onUpdateComponent({
|
||||
label: "초기화",
|
||||
style: { ...component.style, backgroundColor: "transparent", color: "#6b7280", border: "1px solid #d1d5db" },
|
||||
});
|
||||
break;
|
||||
case "submit":
|
||||
updates.backgroundColor = "#059669";
|
||||
updates.textColor = "#ffffff";
|
||||
onUpdateComponent({
|
||||
label: "제출",
|
||||
style: { ...component.style, backgroundColor: "#059669", color: "#ffffff" },
|
||||
});
|
||||
break;
|
||||
case "popup":
|
||||
updates.backgroundColor = "#8b5cf6";
|
||||
updates.textColor = "#ffffff";
|
||||
updates.popupTitle = "상세 정보";
|
||||
updates.popupContent = "여기에 모달 내용을 입력하세요.";
|
||||
updates.popupSize = "md";
|
||||
onUpdateComponent({
|
||||
label: "상세보기",
|
||||
style: { ...component.style, backgroundColor: "#8b5cf6", color: "#ffffff" },
|
||||
});
|
||||
break;
|
||||
case "navigate":
|
||||
updates.backgroundColor = "#0ea5e9";
|
||||
updates.textColor = "#ffffff";
|
||||
updates.navigateType = "url";
|
||||
updates.navigateUrl = "/";
|
||||
updates.navigateTarget = "_self";
|
||||
onUpdateComponent({
|
||||
label: "이동",
|
||||
style: { ...component.style, backgroundColor: "#0ea5e9", color: "#ffffff" },
|
||||
});
|
||||
break;
|
||||
case "custom":
|
||||
updates.backgroundColor = "#64748b";
|
||||
updates.textColor = "#ffffff";
|
||||
onUpdateComponent({
|
||||
label: "버튼",
|
||||
style: { ...component.style, backgroundColor: "#64748b", color: "#ffffff" },
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// 로컬 상태 업데이트 후 webTypeConfig도 함께 업데이트
|
||||
const newConfig = { ...localConfig, ...updates };
|
||||
setLocalConfig(newConfig);
|
||||
|
||||
// webTypeConfig를 마지막에 다시 업데이트하여 확실히 저장되도록 함
|
||||
setTimeout(() => {
|
||||
onUpdateComponent({
|
||||
webTypeConfig: newConfig,
|
||||
});
|
||||
|
||||
console.log("🎯 ButtonActionType webTypeConfig 최종 업데이트:", {
|
||||
actionType,
|
||||
newConfig,
|
||||
componentId: component.id,
|
||||
});
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const selectedActionOption = actionTypeOptions.find((opt) => opt.value === localConfig.actionType);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-medium">
|
||||
<Settings className="h-4 w-4" />
|
||||
버튼 기능 설정
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 액션 타입 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">버튼 기능</Label>
|
||||
<Select value={localConfig.actionType} onValueChange={handleActionTypeChange}>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{actionTypeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
{option.icon}
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedActionOption && (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
{selectedActionOption.icon}
|
||||
<span>{selectedActionOption.label}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
style={{ backgroundColor: selectedActionOption.color + "20", color: selectedActionOption.color }}
|
||||
>
|
||||
{selectedActionOption.value}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 기본 설정 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium">기본 설정</Label>
|
||||
|
||||
{/* 버튼 텍스트 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">버튼 텍스트</Label>
|
||||
<Input
|
||||
value={component.label || ""}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
onUpdateComponent({ label: newValue });
|
||||
}}
|
||||
placeholder="버튼에 표시될 텍스트"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 버튼 스타일 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">스타일</Label>
|
||||
<Select value={localConfig.variant} onValueChange={(value) => updateConfig({ variant: value as any })}>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">기본</SelectItem>
|
||||
<SelectItem value="destructive">위험</SelectItem>
|
||||
<SelectItem value="outline">외곽선</SelectItem>
|
||||
<SelectItem value="secondary">보조</SelectItem>
|
||||
<SelectItem value="ghost">투명</SelectItem>
|
||||
<SelectItem value="link">링크</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 아이콘 설정 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">아이콘 (Lucide 아이콘 이름)</Label>
|
||||
<Input
|
||||
value={localConfig.icon || ""}
|
||||
onChange={(e) => updateConfig({ icon: e.target.value })}
|
||||
placeholder="예: Save, Edit, Trash2"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 액션별 세부 설정 */}
|
||||
{localConfig.actionType === "delete" && (
|
||||
<div className="space-y-3">
|
||||
<Label className="flex items-center gap-1 text-xs font-medium">
|
||||
<AlertTriangle className="h-3 w-3 text-red-500" />
|
||||
삭제 확인 설정
|
||||
</Label>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">확인 메시지</Label>
|
||||
<Input
|
||||
value={localConfig.confirmMessage || ""}
|
||||
onChange={(e) => updateConfig({ confirmMessage: e.target.value })}
|
||||
placeholder="정말로 삭제하시겠습니까?"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{localConfig.actionType === "popup" && (
|
||||
<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>
|
||||
<Select
|
||||
value={localConfig.popupScreenId?.toString() || "none"}
|
||||
onValueChange={(value) =>
|
||||
updateConfig({
|
||||
popupScreenId: value === "none" ? undefined : parseInt(value),
|
||||
})
|
||||
}
|
||||
disabled={screensLoading}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder={screensLoading ? "로딩 중..." : "화면을 선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안함</SelectItem>
|
||||
{screens.map((screen) => (
|
||||
<SelectItem key={screen.screenId} value={screen.screenId.toString()}>
|
||||
{screen.screenName} ({screen.screenCode})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{localConfig.popupScreenId && <p className="text-xs text-gray-500">선택된 화면이 모달로 열립니다</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">모달 제목</Label>
|
||||
<Input
|
||||
value={localConfig.popupTitle || ""}
|
||||
onChange={(e) => updateConfig({ popupTitle: e.target.value })}
|
||||
placeholder="상세 정보"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
{!localConfig.popupScreenId && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">모달 내용</Label>
|
||||
<Textarea
|
||||
value={localConfig.popupContent || ""}
|
||||
onChange={(e) => updateConfig({ popupContent: e.target.value })}
|
||||
placeholder="여기에 모달 내용을 입력하세요."
|
||||
className="h-16 resize-none text-xs"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">화면을 선택하지 않으면 이 내용이 모달에 표시됩니다</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{localConfig.actionType === "navigate" && (
|
||||
<div className="space-y-3">
|
||||
<Label className="flex items-center gap-1 text-xs font-medium">
|
||||
<ExternalLink className="h-3 w-3 text-blue-500" />
|
||||
페이지 이동 설정
|
||||
</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<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 || "_self"}
|
||||
onValueChange={(value) => updateConfig({ navigateTarget: value as "_self" | "_blank" })}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_self">현재 창</SelectItem>
|
||||
<SelectItem value="_blank">새 창</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{localConfig.actionType === "custom" && (
|
||||
<div className="space-y-3">
|
||||
<Label className="flex items-center gap-1 text-xs font-medium">
|
||||
<Settings className="h-3 w-3 text-gray-500" />
|
||||
사용자 정의 액션
|
||||
</Label>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">JavaScript 코드</Label>
|
||||
<Textarea
|
||||
value={localConfig.customAction || ""}
|
||||
onChange={(e) => updateConfig({ customAction: e.target.value })}
|
||||
placeholder="alert('버튼이 클릭되었습니다!');"
|
||||
className="h-16 resize-none font-mono text-xs"
|
||||
/>
|
||||
<div className="text-xs text-gray-500">
|
||||
JavaScript 코드를 입력하세요. 예: alert(), console.log(), 함수 호출 등
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -13,7 +13,7 @@ import {
|
|||
TableInfo,
|
||||
LayoutComponent,
|
||||
} from "@/types/screen";
|
||||
import { ButtonConfigPanel } from "./ButtonConfigPanel";
|
||||
// 레거시 ButtonConfigPanel 제거됨
|
||||
import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
|
||||
|
||||
// 새로운 컴포넌트 설정 패널들 import
|
||||
|
|
@ -26,6 +26,9 @@ import { ChartConfigPanel } from "../config-panels/ChartConfigPanel";
|
|||
import { AlertConfigPanel } from "../config-panels/AlertConfigPanel";
|
||||
import { BadgeConfigPanel } from "../config-panels/BadgeConfigPanel";
|
||||
|
||||
// 동적 컴포넌트 설정 패널
|
||||
import { DynamicComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
|
||||
|
||||
interface DetailSettingsPanelProps {
|
||||
selectedComponent?: ComponentData;
|
||||
onUpdateProperty: (componentId: string, path: string, value: any) => void;
|
||||
|
|
@ -878,13 +881,18 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
return renderLayoutConfig(selectedComponent as LayoutComponent);
|
||||
}
|
||||
|
||||
if (selectedComponent.type !== "widget" && selectedComponent.type !== "file" && selectedComponent.type !== "button") {
|
||||
if (
|
||||
selectedComponent.type !== "widget" &&
|
||||
selectedComponent.type !== "file" &&
|
||||
selectedComponent.type !== "button" &&
|
||||
selectedComponent.type !== "component"
|
||||
) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
|
||||
<Settings className="mb-4 h-12 w-12 text-gray-400" />
|
||||
<h3 className="mb-2 text-lg font-medium text-gray-900">설정할 수 없는 컴포넌트입니다</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
상세 설정은 위젯, 파일, 버튼, 레이아웃 컴포넌트에서만 사용할 수 있습니다.
|
||||
상세 설정은 위젯, 파일, 버튼, 컴포넌트, 레이아웃에서만 사용할 수 있습니다.
|
||||
<br />
|
||||
현재 선택된 컴포넌트: {selectedComponent.type}
|
||||
</p>
|
||||
|
|
@ -924,9 +932,45 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
// 버튼 컴포넌트인 경우 ButtonConfigPanel 렌더링
|
||||
// 레거시 버튼을 새로운 컴포넌트 시스템으로 강제 변환
|
||||
if (selectedComponent.type === "button") {
|
||||
const buttonWidget = selectedComponent as WidgetComponent;
|
||||
console.log("🔄 레거시 버튼을 새로운 컴포넌트 시스템으로 변환:", selectedComponent);
|
||||
|
||||
// 레거시 버튼을 새로운 시스템으로 변환
|
||||
const convertedComponent = {
|
||||
...selectedComponent,
|
||||
type: "component" as const,
|
||||
componentConfig: {
|
||||
type: "button-primary",
|
||||
webType: "button",
|
||||
...selectedComponent.componentConfig,
|
||||
},
|
||||
};
|
||||
|
||||
// 변환된 컴포넌트로 DB 업데이트
|
||||
onUpdateProperty(selectedComponent.id, "type", "component");
|
||||
onUpdateProperty(selectedComponent.id, "componentConfig", convertedComponent.componentConfig);
|
||||
|
||||
// 변환된 컴포넌트로 처리 계속
|
||||
selectedComponent = convertedComponent;
|
||||
}
|
||||
|
||||
// 새로운 컴포넌트 시스템 처리 (type: "component")
|
||||
if (selectedComponent.type === "component") {
|
||||
const componentId = selectedComponent.componentConfig?.type;
|
||||
const webType = selectedComponent.componentConfig?.webType;
|
||||
|
||||
console.log("🔧 새로운 컴포넌트 시스템 설정 패널:", { componentId, webType });
|
||||
|
||||
if (!componentId) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
|
||||
<Settings className="mb-4 h-12 w-12 text-gray-400" />
|
||||
<h3 className="mb-2 text-lg font-medium text-gray-900">컴포넌트 ID가 없습니다</h3>
|
||||
<p className="text-sm text-gray-500">componentConfig.type이 설정되지 않았습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
|
|
@ -934,22 +978,33 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
<div className="border-b border-gray-200 p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Settings className="h-4 w-4 text-gray-600" />
|
||||
<h3 className="font-medium text-gray-900">버튼 상세 설정</h3>
|
||||
<h3 className="font-medium text-gray-900">컴포넌트 설정</h3>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-600">타입:</span>
|
||||
<span className="rounded bg-green-100 px-2 py-1 text-xs font-medium text-green-800">버튼</span>
|
||||
<span className="text-sm text-gray-600">컴포넌트:</span>
|
||||
<span className="rounded bg-green-100 px-2 py-1 text-xs font-medium text-green-800">{componentId}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500">라벨: {buttonWidget.label || "버튼"}</div>
|
||||
{webType && (
|
||||
<div className="mt-1 flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-600">웹타입:</span>
|
||||
<span className="rounded bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800">{webType}</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedComponent.columnName && (
|
||||
<div className="mt-1 text-xs text-gray-500">컬럼: {selectedComponent.columnName}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 버튼 설정 영역 */}
|
||||
{/* 컴포넌트 설정 패널 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<ButtonConfigPanel
|
||||
component={buttonWidget}
|
||||
onUpdateComponent={(updates) => {
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
onUpdateProperty(buttonWidget.id, key, value);
|
||||
<DynamicComponentConfigPanel
|
||||
componentId={componentId}
|
||||
config={selectedComponent.componentConfig || {}}
|
||||
onChange={(newConfig) => {
|
||||
console.log("🔧 컴포넌트 설정 변경:", newConfig);
|
||||
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
|
||||
Object.entries(newConfig).forEach(([key, value]) => {
|
||||
onUpdateProperty(selectedComponent.id, `componentConfig.${key}`, value);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
|
@ -958,6 +1013,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
// 기존 위젯 시스템 처리 (type: "widget")
|
||||
const widget = selectedComponent as WidgetComponent;
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -535,7 +535,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalInputs((prev) => ({ ...prev, positionX: newValue }));
|
||||
onUpdateProperty("position", { ...selectedComponent.position, x: Number(newValue) });
|
||||
onUpdateProperty("position.x", Number(newValue));
|
||||
}}
|
||||
className={`mt-1 ${
|
||||
dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id
|
||||
|
|
@ -565,7 +565,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalInputs((prev) => ({ ...prev, positionY: newValue }));
|
||||
onUpdateProperty("position", { ...selectedComponent.position, y: Number(newValue) });
|
||||
onUpdateProperty("position.y", Number(newValue));
|
||||
}}
|
||||
className={`mt-1 ${
|
||||
dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id
|
||||
|
|
@ -590,7 +590,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalInputs((prev) => ({ ...prev, width: newValue }));
|
||||
onUpdateProperty("size", { ...(selectedComponent.size || {}), width: Number(newValue) });
|
||||
onUpdateProperty("size.width", Number(newValue));
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
|
|
@ -607,7 +607,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalInputs((prev) => ({ ...prev, height: newValue }));
|
||||
onUpdateProperty("size", { ...(selectedComponent.size || {}), height: Number(newValue) });
|
||||
onUpdateProperty("size.height", Number(newValue));
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
|
|
@ -633,7 +633,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalInputs((prev) => ({ ...prev, positionZ: newValue }));
|
||||
onUpdateProperty("position", { ...selectedComponent.position, z: Number(newValue) });
|
||||
onUpdateProperty("position.z", Number(newValue));
|
||||
}}
|
||||
className="mt-1"
|
||||
placeholder="1"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,184 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn("overflow-hidden p-0", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
|
|
@ -406,8 +406,19 @@ export class AutoRegisteringComponentRenderer {
|
|||
};
|
||||
}
|
||||
|
||||
// ComponentRegistry의 검증 로직 재사용
|
||||
return ComponentRegistry["validateComponentDefinition"](definition);
|
||||
// 기본적인 검증만 수행
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (!definition.id) errors.push("id가 필요합니다");
|
||||
if (!definition.name) errors.push("name이 필요합니다");
|
||||
if (!definition.category) errors.push("category가 필요합니다");
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ export interface ComponentRenderer {
|
|||
children?: React.ReactNode;
|
||||
onZoneComponentDrop?: (e: React.DragEvent, zoneId: string, layoutId: string) => void;
|
||||
onZoneClick?: (zoneId: string) => void;
|
||||
// 버튼 액션을 위한 추가 props
|
||||
screenId?: number;
|
||||
tableName?: string;
|
||||
onRefresh?: () => void;
|
||||
onClose?: () => void;
|
||||
[key: string]: any;
|
||||
}): React.ReactElement;
|
||||
}
|
||||
|
|
@ -69,6 +74,11 @@ export interface DynamicComponentRendererProps {
|
|||
onDragStart?: (e: React.DragEvent) => void;
|
||||
onDragEnd?: () => void;
|
||||
children?: React.ReactNode;
|
||||
// 버튼 액션을 위한 추가 props
|
||||
screenId?: number;
|
||||
tableName?: string;
|
||||
onRefresh?: () => void;
|
||||
onClose?: () => void;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
|
@ -84,6 +94,16 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
// component_config에서 실제 컴포넌트 타입 추출
|
||||
const componentType = component.componentConfig?.type || component.type;
|
||||
|
||||
console.log("🔍 컴포넌트 타입 추출:", {
|
||||
componentId: component.id,
|
||||
componentConfigType: component.componentConfig?.type,
|
||||
componentType: component.type,
|
||||
finalComponentType: componentType,
|
||||
componentConfig: component.componentConfig,
|
||||
propsScreenId: props.screenId,
|
||||
propsTableName: props.tableName,
|
||||
});
|
||||
|
||||
// 레이아웃 컴포넌트 처리
|
||||
if (componentType === "layout") {
|
||||
return (
|
||||
|
|
@ -106,14 +126,22 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
componentId: component.id,
|
||||
componentType,
|
||||
componentConfig: component.componentConfig,
|
||||
registeredTypes: legacyComponentRegistry.getRegisteredTypes(),
|
||||
hasRenderer: legacyComponentRegistry.has(componentType),
|
||||
actualRenderer: legacyComponentRegistry.get(componentType),
|
||||
mapSize: legacyComponentRegistry.getRegisteredTypes().length,
|
||||
newSystemRegistered: ComponentRegistry.getAllComponents().map((c) => c.id),
|
||||
legacySystemRegistered: legacyComponentRegistry.getRegisteredTypes(),
|
||||
hasLegacyRenderer: legacyComponentRegistry.has(componentType),
|
||||
actualLegacyRenderer: legacyComponentRegistry.get(componentType),
|
||||
legacyMapSize: legacyComponentRegistry.getRegisteredTypes().length,
|
||||
});
|
||||
|
||||
// 1. 새 컴포넌트 시스템에서 먼저 조회
|
||||
const newComponent = ComponentRegistry.getComponent(componentType);
|
||||
console.log("🔍 새 컴포넌트 시스템 조회:", {
|
||||
componentType,
|
||||
found: !!newComponent,
|
||||
component: newComponent,
|
||||
registeredTypes: ComponentRegistry.getAllComponents().map((c) => c.id),
|
||||
});
|
||||
|
||||
if (newComponent) {
|
||||
console.log("✨ 새 컴포넌트 시스템에서 발견:", componentType);
|
||||
|
||||
|
|
@ -121,18 +149,46 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
try {
|
||||
const NewComponentRenderer = newComponent.component;
|
||||
if (NewComponentRenderer) {
|
||||
console.log("🔧 컴포넌트 렌더링 props:", {
|
||||
componentType,
|
||||
componentId: component.id,
|
||||
screenId: props.screenId,
|
||||
tableName: props.tableName,
|
||||
onRefresh: !!props.onRefresh,
|
||||
onClose: !!props.onClose,
|
||||
});
|
||||
// React 전용 props 필터링
|
||||
const {
|
||||
isInteractive,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
selectedScreen,
|
||||
onZoneComponentDrop,
|
||||
onZoneClick,
|
||||
componentConfig,
|
||||
...safeProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<NewComponentRenderer
|
||||
{...props}
|
||||
{...safeProps}
|
||||
component={component}
|
||||
isSelected={isSelected}
|
||||
onClick={onClick}
|
||||
isInteractive={isInteractive}
|
||||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
size={component.size || newComponent.defaultSize}
|
||||
position={component.position}
|
||||
style={component.style}
|
||||
config={component.componentConfig}
|
||||
componentConfig={component.componentConfig}
|
||||
screenId={props.screenId}
|
||||
tableName={props.tableName}
|
||||
onRefresh={props.onRefresh}
|
||||
onClose={props.onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -145,7 +201,13 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
const renderer = legacyComponentRegistry.get(componentType);
|
||||
|
||||
if (!renderer) {
|
||||
console.warn(`⚠️ 등록되지 않은 컴포넌트 타입: ${componentType}`);
|
||||
console.error(`⚠️ 등록되지 않은 컴포넌트 타입: ${componentType}`, {
|
||||
component: component,
|
||||
componentType: componentType,
|
||||
componentConfig: component.componentConfig,
|
||||
availableNewComponents: ComponentRegistry.getAllComponents().map((c) => c.id),
|
||||
availableLegacyComponents: legacyComponentRegistry.getRegisteredTypes(),
|
||||
});
|
||||
|
||||
// 폴백 렌더링 - 기본 플레이스홀더
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -40,9 +40,8 @@ const ButtonRenderer: ComponentRenderer = ({ component, ...props }) => {
|
|||
);
|
||||
};
|
||||
|
||||
// 레지스트리에 등록 - 모든 버튼 타입들
|
||||
// 레지스트리에 등록 - 기본 버튼 타입만 (button-primary는 새 컴포넌트 시스템 사용)
|
||||
componentRegistry.register("button", ButtonRenderer);
|
||||
componentRegistry.register("button-primary", ButtonRenderer);
|
||||
componentRegistry.register("button-secondary", ButtonRenderer);
|
||||
|
||||
export { ButtonRenderer };
|
||||
|
|
|
|||
|
|
@ -1,11 +1,33 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { ButtonPrimaryConfig } from "./types";
|
||||
import {
|
||||
ButtonActionExecutor,
|
||||
ButtonActionContext,
|
||||
ButtonActionType,
|
||||
DEFAULT_BUTTON_ACTIONS,
|
||||
} from "@/lib/utils/buttonActions";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
||||
config?: ButtonPrimaryConfig;
|
||||
// 추가 props
|
||||
screenId?: number;
|
||||
tableName?: string;
|
||||
onRefresh?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -16,20 +38,54 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
isInteractive = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
config,
|
||||
className,
|
||||
style,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
screenId,
|
||||
tableName,
|
||||
onRefresh,
|
||||
onClose,
|
||||
...props
|
||||
}) => {
|
||||
// 확인 다이얼로그 상태
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState<{
|
||||
type: ButtonActionType;
|
||||
config: any;
|
||||
context: ButtonActionContext;
|
||||
} | null>(null);
|
||||
// 컴포넌트 설정
|
||||
const componentConfig = {
|
||||
...config,
|
||||
...component.config,
|
||||
} as ButtonPrimaryConfig;
|
||||
|
||||
// 액션 설정 처리 - DB에서 문자열로 저장된 액션을 객체로 변환
|
||||
const processedConfig = { ...componentConfig };
|
||||
if (componentConfig.action && typeof componentConfig.action === "string") {
|
||||
const actionType = componentConfig.action as ButtonActionType;
|
||||
processedConfig.action = {
|
||||
...DEFAULT_BUTTON_ACTIONS[actionType],
|
||||
type: actionType,
|
||||
};
|
||||
}
|
||||
|
||||
console.log("🔧 버튼 컴포넌트 설정:", {
|
||||
originalConfig: componentConfig,
|
||||
processedConfig,
|
||||
component: component,
|
||||
screenId,
|
||||
tableName,
|
||||
onRefresh,
|
||||
onClose,
|
||||
});
|
||||
|
||||
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
|
||||
const componentStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
|
|
@ -44,10 +100,131 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
|
||||
}
|
||||
|
||||
// 확인 다이얼로그가 필요한 액션 타입들
|
||||
const confirmationRequiredActions: ButtonActionType[] = ["save", "submit", "delete"];
|
||||
|
||||
// 실제 액션 실행 함수
|
||||
const executeAction = async (actionConfig: any, context: ButtonActionContext) => {
|
||||
console.log("🚀 executeAction 시작:", { actionConfig, context });
|
||||
let loadingToast: string | number | undefined;
|
||||
|
||||
try {
|
||||
console.log("📱 로딩 토스트 표시 시작");
|
||||
// 로딩 토스트 표시
|
||||
loadingToast = toast.loading(
|
||||
actionConfig.type === "save"
|
||||
? "저장 중..."
|
||||
: actionConfig.type === "delete"
|
||||
? "삭제 중..."
|
||||
: actionConfig.type === "submit"
|
||||
? "제출 중..."
|
||||
: "처리 중...",
|
||||
);
|
||||
console.log("📱 로딩 토스트 ID:", loadingToast);
|
||||
|
||||
console.log("⚡ ButtonActionExecutor.executeAction 호출 시작");
|
||||
const success = await ButtonActionExecutor.executeAction(actionConfig, context);
|
||||
console.log("⚡ ButtonActionExecutor.executeAction 완료, success:", success);
|
||||
|
||||
// 로딩 토스트 제거
|
||||
console.log("📱 로딩 토스트 제거");
|
||||
toast.dismiss(loadingToast);
|
||||
|
||||
// 성공 시 토스트 표시
|
||||
const successMessage =
|
||||
actionConfig.successMessage ||
|
||||
(actionConfig.type === "save"
|
||||
? "저장되었습니다."
|
||||
: actionConfig.type === "delete"
|
||||
? "삭제되었습니다."
|
||||
: actionConfig.type === "submit"
|
||||
? "제출되었습니다."
|
||||
: "완료되었습니다.");
|
||||
|
||||
console.log("🎉 성공 토스트 표시:", successMessage);
|
||||
toast.success(successMessage);
|
||||
|
||||
console.log("✅ 버튼 액션 실행 성공:", actionConfig.type);
|
||||
} catch (error) {
|
||||
console.log("❌ executeAction catch 블록 진입:", error);
|
||||
|
||||
// 로딩 토스트 제거
|
||||
if (loadingToast) {
|
||||
console.log("📱 오류 시 로딩 토스트 제거");
|
||||
toast.dismiss(loadingToast);
|
||||
}
|
||||
|
||||
console.error("❌ 버튼 액션 실행 오류:", error);
|
||||
|
||||
// 오류 토스트 표시
|
||||
const errorMessage =
|
||||
actionConfig.errorMessage ||
|
||||
(actionConfig.type === "save"
|
||||
? "저장 중 오류가 발생했습니다."
|
||||
: actionConfig.type === "delete"
|
||||
? "삭제 중 오류가 발생했습니다."
|
||||
: actionConfig.type === "submit"
|
||||
? "제출 중 오류가 발생했습니다."
|
||||
: "처리 중 오류가 발생했습니다.");
|
||||
|
||||
console.log("💥 오류 토스트 표시:", errorMessage);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
// 이벤트 핸들러
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
const handleClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
|
||||
// 디자인 모드에서는 기본 onClick만 실행
|
||||
if (isDesignMode) {
|
||||
onClick?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// 인터랙티브 모드에서 액션 실행
|
||||
if (isInteractive && processedConfig.action) {
|
||||
const context: ButtonActionContext = {
|
||||
formData: formData || {},
|
||||
screenId,
|
||||
tableName,
|
||||
onFormDataChange,
|
||||
onRefresh,
|
||||
onClose,
|
||||
};
|
||||
|
||||
// 확인이 필요한 액션인지 확인
|
||||
if (confirmationRequiredActions.includes(processedConfig.action.type)) {
|
||||
// 확인 다이얼로그 표시
|
||||
setPendingAction({
|
||||
type: processedConfig.action.type,
|
||||
config: processedConfig.action,
|
||||
context,
|
||||
});
|
||||
setShowConfirmDialog(true);
|
||||
} else {
|
||||
// 확인이 필요하지 않은 액션은 바로 실행
|
||||
await executeAction(processedConfig.action, context);
|
||||
}
|
||||
} else {
|
||||
// 액션이 설정되지 않은 경우 기본 onClick 실행
|
||||
onClick?.();
|
||||
}
|
||||
};
|
||||
|
||||
// 확인 다이얼로그에서 확인 버튼 클릭 시
|
||||
const handleConfirmAction = async () => {
|
||||
if (pendingAction) {
|
||||
await executeAction(pendingAction.config, pendingAction.context);
|
||||
}
|
||||
setShowConfirmDialog(false);
|
||||
setPendingAction(null);
|
||||
};
|
||||
|
||||
// 확인 다이얼로그에서 취소 버튼 클릭 시
|
||||
const handleCancelAction = () => {
|
||||
setShowConfirmDialog(false);
|
||||
setPendingAction(null);
|
||||
};
|
||||
|
||||
// DOM에 전달하면 안 되는 React-specific props 필터링
|
||||
|
|
@ -64,33 +241,97 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
size: _size,
|
||||
position: _position,
|
||||
style: _style,
|
||||
screenId: _screenId,
|
||||
tableName: _tableName,
|
||||
onRefresh: _onRefresh,
|
||||
onClose: _onClose,
|
||||
...domProps
|
||||
} = props;
|
||||
|
||||
// 다이얼로그 메시지 생성
|
||||
const getConfirmMessage = () => {
|
||||
if (!pendingAction) return "";
|
||||
|
||||
const customMessage = pendingAction.config.confirmMessage;
|
||||
if (customMessage) return customMessage;
|
||||
|
||||
switch (pendingAction.type) {
|
||||
case "save":
|
||||
return "변경사항을 저장하시겠습니까?";
|
||||
case "delete":
|
||||
return "정말로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.";
|
||||
case "submit":
|
||||
return "제출하시겠습니까?";
|
||||
default:
|
||||
return "이 작업을 실행하시겠습니까?";
|
||||
}
|
||||
};
|
||||
|
||||
const getConfirmTitle = () => {
|
||||
if (!pendingAction) return "";
|
||||
|
||||
switch (pendingAction.type) {
|
||||
case "save":
|
||||
return "저장 확인";
|
||||
case "delete":
|
||||
return "삭제 확인";
|
||||
case "submit":
|
||||
return "제출 확인";
|
||||
default:
|
||||
return "작업 확인";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={componentStyle} className={className} {...domProps}>
|
||||
<button
|
||||
type={componentConfig.actionType || "button"}
|
||||
disabled={componentConfig.disabled || false}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: "1px solid #3b82f6",
|
||||
borderRadius: "4px",
|
||||
backgroundColor: "#3b82f6",
|
||||
color: "white",
|
||||
fontSize: "14px",
|
||||
fontWeight: "500",
|
||||
cursor: componentConfig.disabled ? "not-allowed" : "pointer",
|
||||
outline: "none",
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
{componentConfig.text || component.label || "버튼"}
|
||||
</button>
|
||||
</div>
|
||||
<>
|
||||
<div style={componentStyle} className={className} {...domProps}>
|
||||
<button
|
||||
type={componentConfig.actionType || "button"}
|
||||
disabled={componentConfig.disabled || false}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: "1px solid #3b82f6",
|
||||
borderRadius: "4px",
|
||||
backgroundColor: "#3b82f6",
|
||||
color: "white",
|
||||
fontSize: "14px",
|
||||
fontWeight: "500",
|
||||
cursor: componentConfig.disabled ? "not-allowed" : "pointer",
|
||||
outline: "none",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
{processedConfig.text || component.label || "버튼"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 확인 다이얼로그 */}
|
||||
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{getConfirmTitle()}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{getConfirmMessage()}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={handleCancelAction}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmAction}>
|
||||
{pendingAction?.type === "save"
|
||||
? "저장"
|
||||
: pendingAction?.type === "delete"
|
||||
? "삭제"
|
||||
: pendingAction?.type === "submit"
|
||||
? "제출"
|
||||
: "확인"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -16,36 +16,24 @@ export interface ButtonPrimaryConfigPanelProps {
|
|||
* ButtonPrimary 설정 패널
|
||||
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
||||
*/
|
||||
export const ButtonPrimaryConfigPanel: React.FC<ButtonPrimaryConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
export const ButtonPrimaryConfigPanel: React.FC<ButtonPrimaryConfigPanelProps> = ({ config, onChange }) => {
|
||||
const handleChange = (key: keyof ButtonPrimaryConfig, value: any) => {
|
||||
onChange({ [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">
|
||||
button-primary 설정
|
||||
</div>
|
||||
<div className="text-sm font-medium">button-primary 설정</div>
|
||||
|
||||
{/* 버튼 관련 설정 */}
|
||||
{/* 버튼 관련 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="text">버튼 텍스트</Label>
|
||||
<Input
|
||||
id="text"
|
||||
value={config.text || ""}
|
||||
onChange={(e) => handleChange("text", e.target.value)}
|
||||
/>
|
||||
<Input id="text" value={config.text || ""} onChange={(e) => handleChange("text", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="actionType">액션 타입</Label>
|
||||
<Select
|
||||
value={config.actionType || "button"}
|
||||
onValueChange={(value) => handleChange("actionType", value)}
|
||||
>
|
||||
<Select value={config.actionType || "button"} onValueChange={(value) => handleChange("actionType", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
|
|
|||
|
|
@ -21,9 +21,14 @@ export const ButtonPrimaryDefinition = createComponentDefinition({
|
|||
webType: "button",
|
||||
component: ButtonPrimaryWrapper,
|
||||
defaultConfig: {
|
||||
text: "버튼",
|
||||
text: "저장",
|
||||
actionType: "button",
|
||||
variant: "primary",
|
||||
action: {
|
||||
type: "save",
|
||||
successMessage: "저장되었습니다.",
|
||||
errorMessage: "저장 중 오류가 발생했습니다.",
|
||||
},
|
||||
},
|
||||
defaultSize: { width: 120, height: 36 },
|
||||
configPanel: ButtonPrimaryConfigPanel,
|
||||
|
|
@ -34,11 +39,7 @@ export const ButtonPrimaryDefinition = createComponentDefinition({
|
|||
documentation: "https://docs.example.com/components/button-primary",
|
||||
});
|
||||
|
||||
// ComponentRegistry에 등록
|
||||
import { ComponentRegistry } from "../../ComponentRegistry";
|
||||
ComponentRegistry.registerComponent(ButtonPrimaryDefinition);
|
||||
|
||||
console.log("🚀 ButtonPrimary 컴포넌트 등록 완료");
|
||||
// 컴포넌트는 ButtonPrimaryRenderer에서 자동 등록됩니다
|
||||
|
||||
// 타입 내보내기
|
||||
export type { ButtonPrimaryConfig } from "./types";
|
||||
|
|
|
|||
|
|
@ -1,27 +1,30 @@
|
|||
"use client";
|
||||
|
||||
import { ComponentConfig } from "@/types/component";
|
||||
import { ButtonActionConfig } from "@/lib/utils/buttonActions";
|
||||
|
||||
/**
|
||||
* ButtonPrimary 컴포넌트 설정 타입
|
||||
*/
|
||||
export interface ButtonPrimaryConfig extends ComponentConfig {
|
||||
// 버튼 관련 설정
|
||||
// 버튼 관련 설정
|
||||
text?: string;
|
||||
actionType?: "button" | "submit" | "reset";
|
||||
variant?: "primary" | "secondary" | "danger";
|
||||
|
||||
|
||||
// 버튼 액션 설정
|
||||
action?: ButtonActionConfig;
|
||||
|
||||
// 공통 설정
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
placeholder?: string;
|
||||
helperText?: string;
|
||||
|
||||
|
||||
// 스타일 관련
|
||||
variant?: "default" | "outlined" | "filled";
|
||||
size?: "sm" | "md" | "lg";
|
||||
|
||||
|
||||
// 이벤트 관련
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
|
|
@ -39,7 +42,7 @@ export interface ButtonPrimaryProps {
|
|||
config?: ButtonPrimaryConfig;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
|
||||
|
||||
// 이벤트 핸들러
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
|
|
|
|||
|
|
@ -16,12 +16,15 @@ export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
|
|||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
isInteractive = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
config,
|
||||
className,
|
||||
style,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
...props
|
||||
}) => {
|
||||
// 컴포넌트 설정
|
||||
|
|
@ -64,6 +67,10 @@ export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
|
|||
size: _size,
|
||||
position: _position,
|
||||
style: _style,
|
||||
screenId: _screenId,
|
||||
tableName: _tableName,
|
||||
onRefresh: _onRefresh,
|
||||
onClose: _onClose,
|
||||
...domProps
|
||||
} = props;
|
||||
|
||||
|
|
@ -79,23 +86,33 @@ export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
|
|||
fontSize: component.style?.labelFontSize || "14px",
|
||||
color: component.style?.labelColor || "#374151",
|
||||
fontWeight: "500",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
{component.label}
|
||||
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
|
||||
{component.required && (
|
||||
<span style={{
|
||||
color: "#ef4444",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
style={{display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
cursor: "pointer",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
|
|
@ -105,18 +122,20 @@ export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
|
|||
checked={component.value === true || component.value === "true"}
|
||||
disabled={componentConfig.disabled || false}
|
||||
required={componentConfig.required || false}
|
||||
style={{
|
||||
width: "16px",
|
||||
style={{width: "16px",
|
||||
height: "16px",
|
||||
accentColor: "#3b82f6",
|
||||
}}
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),}}
|
||||
onChange={(e) => {
|
||||
if (component.onChange) {
|
||||
component.onChange(e.target.checked);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: "#374151" }}>{componentConfig.checkboxLabel || component.text || "체크박스"}</span>
|
||||
<span style={{color: "#374151",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),}}>{componentConfig.checkboxLabel || component.text || "체크박스"}</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -16,12 +16,15 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
isInteractive = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
config,
|
||||
className,
|
||||
style,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
...props
|
||||
}) => {
|
||||
// 컴포넌트 설정
|
||||
|
|
@ -64,6 +67,10 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
size: _size,
|
||||
position: _position,
|
||||
style: _style,
|
||||
screenId: _screenId,
|
||||
tableName: _tableName,
|
||||
onRefresh: _onRefresh,
|
||||
onClose: _onClose,
|
||||
...domProps
|
||||
} = props;
|
||||
|
||||
|
|
@ -79,10 +86,20 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
fontSize: component.style?.labelFontSize || "14px",
|
||||
color: component.style?.labelColor || "#374151",
|
||||
fontWeight: "500",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
{component.label}
|
||||
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
|
||||
{component.required && (
|
||||
<span style={{
|
||||
color: "#ef4444",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
|
||||
|
|
@ -93,15 +110,15 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
disabled={componentConfig.disabled || false}
|
||||
required={componentConfig.required || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
style={{
|
||||
width: "100%",
|
||||
style={{width: "100%",
|
||||
height: "100%",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: "4px",
|
||||
padding: "8px 12px",
|
||||
fontSize: "14px",
|
||||
outline: "none",
|
||||
}}
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,10 @@ export const DividerLineComponent: React.FC<DividerLineComponentProps> = ({
|
|||
size: _size,
|
||||
position: _position,
|
||||
style: _style,
|
||||
screenId: _screenId,
|
||||
tableName: _tableName,
|
||||
onRefresh: _onRefresh,
|
||||
onClose: _onClose,
|
||||
...domProps
|
||||
} = props;
|
||||
|
||||
|
|
|
|||
|
|
@ -16,12 +16,15 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
isInteractive = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
config,
|
||||
className,
|
||||
style,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
...props
|
||||
}) => {
|
||||
// 컴포넌트 설정
|
||||
|
|
@ -64,6 +67,10 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
size: _size,
|
||||
position: _position,
|
||||
style: _style,
|
||||
screenId: _screenId,
|
||||
tableName: _tableName,
|
||||
onRefresh: _onRefresh,
|
||||
onClose: _onClose,
|
||||
...domProps
|
||||
} = props;
|
||||
|
||||
|
|
@ -79,16 +86,20 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
fontSize: component.style?.labelFontSize || "14px",
|
||||
color: component.style?.labelColor || "#374151",
|
||||
fontWeight: "500",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
{component.label}
|
||||
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
|
||||
{component.required && <span style={{color: "#ef4444",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}>*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
style={{width: "100%",
|
||||
height: "100%",
|
||||
border: "2px dashed #d1d5db",
|
||||
borderRadius: "8px",
|
||||
|
|
@ -99,7 +110,9 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
cursor: "pointer",
|
||||
backgroundColor: "#f9fafb",
|
||||
position: "relative",
|
||||
}}
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
|
|
@ -116,6 +129,8 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
height: "100%",
|
||||
opacity: 0,
|
||||
cursor: "pointer",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onChange={(e) => {
|
||||
if (component.onChange) {
|
||||
|
|
@ -124,10 +139,22 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
}
|
||||
}}
|
||||
/>
|
||||
<div style={{ textAlign: "center", color: "#6b7280", fontSize: "14px" }}>
|
||||
<div style={{ fontSize: "24px", marginBottom: "8px" }}>📁</div>
|
||||
<div style={{ fontWeight: "500" }}>파일을 선택하거나 드래그하세요</div>
|
||||
<div style={{ fontSize: "12px", marginTop: "4px" }}>
|
||||
<div style={{textAlign: "center", color: "#6b7280", fontSize: "14px",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}>
|
||||
<div style={{fontSize: "24px", marginBottom: "8px",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}>📁</div>
|
||||
<div style={{fontWeight: "500",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}>파일을 선택하거나 드래그하세요</div>
|
||||
<div style={{fontSize: "12px", marginTop: "4px",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}>
|
||||
{componentConfig.accept && `지원 형식: ${componentConfig.accept}`}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -64,6 +64,10 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
|
|||
size: _size,
|
||||
position: _position,
|
||||
style: _style,
|
||||
screenId: _screenId,
|
||||
tableName: _tableName,
|
||||
onRefresh: _onRefresh,
|
||||
onClose: _onClose,
|
||||
...domProps
|
||||
} = props;
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ import "./select-basic/SelectBasicRenderer";
|
|||
import "./checkbox-basic/CheckboxBasicRenderer";
|
||||
import "./radio-basic/RadioBasicRenderer";
|
||||
import "./date-input/DateInputRenderer";
|
||||
import "./label-basic/LabelBasicRenderer";
|
||||
// import "./label-basic/LabelBasicRenderer"; // 제거됨 - text-display로 교체
|
||||
import "./text-display/TextDisplayRenderer";
|
||||
import "./file-upload/FileUploadRenderer";
|
||||
import "./slider-basic/SliderBasicRenderer";
|
||||
import "./toggle-switch/ToggleSwitchRenderer";
|
||||
|
|
|
|||
|
|
@ -1,124 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { LabelBasicConfig } from "./types";
|
||||
|
||||
export interface LabelBasicComponentProps extends ComponentRendererProps {
|
||||
config?: LabelBasicConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* LabelBasic 컴포넌트
|
||||
* label-basic 컴포넌트입니다
|
||||
*/
|
||||
export const LabelBasicComponent: React.FC<LabelBasicComponentProps> = ({
|
||||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
config,
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}) => {
|
||||
// 컴포넌트 설정
|
||||
const componentConfig = {
|
||||
...config,
|
||||
...component.config,
|
||||
} as LabelBasicConfig;
|
||||
|
||||
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
|
||||
const componentStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
...component.style,
|
||||
...style,
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일
|
||||
if (isDesignMode) {
|
||||
componentStyle.border = "1px dashed #cbd5e1";
|
||||
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
|
||||
}
|
||||
|
||||
// 이벤트 핸들러
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
// DOM에 전달하면 안 되는 React-specific props 필터링
|
||||
const {
|
||||
selectedScreen,
|
||||
onZoneComponentDrop,
|
||||
onZoneClick,
|
||||
componentConfig: _componentConfig,
|
||||
component: _component,
|
||||
isSelected: _isSelected,
|
||||
onClick: _onClick,
|
||||
onDragStart: _onDragStart,
|
||||
onDragEnd: _onDragEnd,
|
||||
size: _size,
|
||||
position: _position,
|
||||
style: _style,
|
||||
...domProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div style={componentStyle} className={className} {...domProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && (
|
||||
<label
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-25px",
|
||||
left: "0px",
|
||||
fontSize: component.style?.labelFontSize || "14px",
|
||||
color: component.style?.labelColor || "#374151",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
{component.label}
|
||||
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={component.value || ""}
|
||||
placeholder={componentConfig.placeholder || ""}
|
||||
disabled={componentConfig.disabled || false}
|
||||
required={componentConfig.required || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: "4px",
|
||||
padding: "8px 12px",
|
||||
fontSize: "14px",
|
||||
outline: "none",
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onChange={(e) => {
|
||||
if (props.onChange) {
|
||||
props.onChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* LabelBasic 래퍼 컴포넌트
|
||||
* 추가적인 로직이나 상태 관리가 필요한 경우 사용
|
||||
*/
|
||||
export const LabelBasicWrapper: React.FC<LabelBasicComponentProps> = (props) => {
|
||||
return <LabelBasicComponent {...props} />;
|
||||
};
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { LabelBasicConfig } from "./types";
|
||||
|
||||
export interface LabelBasicConfigPanelProps {
|
||||
config: LabelBasicConfig;
|
||||
onChange: (config: Partial<LabelBasicConfig>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* LabelBasic 설정 패널
|
||||
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
||||
*/
|
||||
export const LabelBasicConfigPanel: React.FC<LabelBasicConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const handleChange = (key: keyof LabelBasicConfig, value: any) => {
|
||||
onChange({ [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">
|
||||
label-basic 설정
|
||||
</div>
|
||||
|
||||
{/* 텍스트 관련 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="placeholder">플레이스홀더</Label>
|
||||
<Input
|
||||
id="placeholder"
|
||||
value={config.placeholder || ""}
|
||||
onChange={(e) => handleChange("placeholder", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxLength">최대 길이</Label>
|
||||
<Input
|
||||
id="maxLength"
|
||||
type="number"
|
||||
value={config.maxLength || ""}
|
||||
onChange={(e) => handleChange("maxLength", parseInt(e.target.value) || undefined)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 공통 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disabled">비활성화</Label>
|
||||
<Checkbox
|
||||
id="disabled"
|
||||
checked={config.disabled || false}
|
||||
onCheckedChange={(checked) => handleChange("disabled", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="required">필수 입력</Label>
|
||||
<Checkbox
|
||||
id="required"
|
||||
checked={config.required || false}
|
||||
onCheckedChange={(checked) => handleChange("required", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="readonly">읽기 전용</Label>
|
||||
<Checkbox
|
||||
id="readonly"
|
||||
checked={config.readonly || false}
|
||||
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { LabelBasicDefinition } from "./index";
|
||||
import { LabelBasicComponent } from "./LabelBasicComponent";
|
||||
|
||||
/**
|
||||
* LabelBasic 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class LabelBasicRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = LabelBasicDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <LabelBasicComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트별 특화 메서드들
|
||||
*/
|
||||
|
||||
// text 타입 특화 속성 처리
|
||||
protected getLabelBasicProps() {
|
||||
const baseProps = this.getWebTypeProps();
|
||||
|
||||
// text 타입에 특화된 추가 속성들
|
||||
return {
|
||||
...baseProps,
|
||||
// 여기에 text 타입 특화 속성들 추가
|
||||
};
|
||||
}
|
||||
|
||||
// 값 변경 처리
|
||||
protected handleValueChange = (value: any) => {
|
||||
this.updateComponent({ value });
|
||||
};
|
||||
|
||||
// 포커스 처리
|
||||
protected handleFocus = () => {
|
||||
// 포커스 로직
|
||||
};
|
||||
|
||||
// 블러 처리
|
||||
protected handleBlur = () => {
|
||||
// 블러 로직
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
LabelBasicRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
LabelBasicRenderer.enableHotReload();
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
# LabelBasic 컴포넌트
|
||||
|
||||
label-basic 컴포넌트입니다
|
||||
|
||||
## 개요
|
||||
|
||||
- **ID**: `label-basic`
|
||||
- **카테고리**: display
|
||||
- **웹타입**: text
|
||||
- **작성자**: 개발팀
|
||||
- **버전**: 1.0.0
|
||||
|
||||
## 특징
|
||||
|
||||
- ✅ 자동 등록 시스템
|
||||
- ✅ 타입 안전성
|
||||
- ✅ Hot Reload 지원
|
||||
- ✅ 설정 패널 제공
|
||||
- ✅ 반응형 디자인
|
||||
|
||||
## 사용법
|
||||
|
||||
### 기본 사용법
|
||||
|
||||
```tsx
|
||||
import { LabelBasicComponent } from "@/lib/registry/components/label-basic";
|
||||
|
||||
<LabelBasicComponent
|
||||
component={{
|
||||
id: "my-label-basic",
|
||||
type: "widget",
|
||||
webType: "text",
|
||||
position: { x: 100, y: 100, z: 1 },
|
||||
size: { width: 150, height: 24 },
|
||||
config: {
|
||||
// 설정값들
|
||||
}
|
||||
}}
|
||||
isDesignMode={false}
|
||||
/>
|
||||
```
|
||||
|
||||
### 설정 옵션
|
||||
|
||||
| 속성 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| placeholder | string | "" | 플레이스홀더 텍스트 |
|
||||
| maxLength | number | 255 | 최대 입력 길이 |
|
||||
| minLength | number | 0 | 최소 입력 길이 |
|
||||
| disabled | boolean | false | 비활성화 여부 |
|
||||
| required | boolean | false | 필수 입력 여부 |
|
||||
| readonly | boolean | false | 읽기 전용 여부 |
|
||||
|
||||
## 이벤트
|
||||
|
||||
- `onChange`: 값 변경 시
|
||||
- `onFocus`: 포커스 시
|
||||
- `onBlur`: 포커스 해제 시
|
||||
- `onClick`: 클릭 시
|
||||
|
||||
## 스타일링
|
||||
|
||||
컴포넌트는 다음과 같은 스타일 옵션을 제공합니다:
|
||||
|
||||
- `variant`: "default" | "outlined" | "filled"
|
||||
- `size`: "sm" | "md" | "lg"
|
||||
|
||||
## 예시
|
||||
|
||||
```tsx
|
||||
// 기본 예시
|
||||
<LabelBasicComponent
|
||||
component={{
|
||||
id: "sample-label-basic",
|
||||
config: {
|
||||
placeholder: "입력하세요",
|
||||
required: true,
|
||||
variant: "outlined"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## 개발자 정보
|
||||
|
||||
- **생성일**: 2025-09-11
|
||||
- **CLI 명령어**: `node scripts/create-component.js label-basic --category=display --webType=text`
|
||||
- **경로**: `lib/registry/components/label-basic/`
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md)
|
||||
- [개발자 문서](https://docs.example.com/components/label-basic)
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { LabelBasicConfig } from "./types";
|
||||
|
||||
/**
|
||||
* LabelBasic 컴포넌트 기본 설정
|
||||
*/
|
||||
export const LabelBasicDefaultConfig: LabelBasicConfig = {
|
||||
placeholder: "텍스트를 입력하세요",
|
||||
maxLength: 255,
|
||||
|
||||
// 공통 기본값
|
||||
disabled: false,
|
||||
required: false,
|
||||
readonly: false,
|
||||
variant: "default",
|
||||
size: "md",
|
||||
};
|
||||
|
||||
/**
|
||||
* LabelBasic 컴포넌트 설정 스키마
|
||||
* 유효성 검사 및 타입 체크에 사용
|
||||
*/
|
||||
export const LabelBasicConfigSchema = {
|
||||
placeholder: { type: "string", default: "" },
|
||||
maxLength: { type: "number", min: 1 },
|
||||
minLength: { type: "number", min: 0 },
|
||||
|
||||
// 공통 스키마
|
||||
disabled: { type: "boolean", default: false },
|
||||
required: { type: "boolean", default: false },
|
||||
readonly: { type: "boolean", default: false },
|
||||
variant: {
|
||||
type: "enum",
|
||||
values: ["default", "outlined", "filled"],
|
||||
default: "default"
|
||||
},
|
||||
size: {
|
||||
type: "enum",
|
||||
values: ["sm", "md", "lg"],
|
||||
default: "md"
|
||||
},
|
||||
};
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import type { WebType } from "@/types/screen";
|
||||
import { LabelBasicWrapper } from "./LabelBasicComponent";
|
||||
import { LabelBasicConfigPanel } from "./LabelBasicConfigPanel";
|
||||
import { LabelBasicConfig } from "./types";
|
||||
|
||||
/**
|
||||
* LabelBasic 컴포넌트 정의
|
||||
* label-basic 컴포넌트입니다
|
||||
*/
|
||||
export const LabelBasicDefinition = createComponentDefinition({
|
||||
id: "label-basic",
|
||||
name: "라벨 텍스트",
|
||||
nameEng: "LabelBasic Component",
|
||||
description: "텍스트 표시를 위한 라벨 텍스트 컴포넌트",
|
||||
category: ComponentCategory.DISPLAY,
|
||||
webType: "text",
|
||||
component: LabelBasicWrapper,
|
||||
defaultConfig: {
|
||||
placeholder: "텍스트를 입력하세요",
|
||||
maxLength: 255,
|
||||
},
|
||||
defaultSize: { width: 150, height: 24 },
|
||||
configPanel: LabelBasicConfigPanel,
|
||||
icon: "Eye",
|
||||
tags: [],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation: "https://docs.example.com/components/label-basic",
|
||||
});
|
||||
|
||||
// 타입 내보내기
|
||||
export type { LabelBasicConfig } from "./types";
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { LabelBasicComponent } from "./LabelBasicComponent";
|
||||
export { LabelBasicRenderer } from "./LabelBasicRenderer";
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { ComponentConfig } from "@/types/component";
|
||||
|
||||
/**
|
||||
* LabelBasic 컴포넌트 설정 타입
|
||||
*/
|
||||
export interface LabelBasicConfig extends ComponentConfig {
|
||||
// 텍스트 관련 설정
|
||||
placeholder?: string;
|
||||
maxLength?: number;
|
||||
minLength?: number;
|
||||
|
||||
// 공통 설정
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
placeholder?: string;
|
||||
helperText?: string;
|
||||
|
||||
// 스타일 관련
|
||||
variant?: "default" | "outlined" | "filled";
|
||||
size?: "sm" | "md" | "lg";
|
||||
|
||||
// 이벤트 관련
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* LabelBasic 컴포넌트 Props 타입
|
||||
*/
|
||||
export interface LabelBasicProps {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: any;
|
||||
config?: LabelBasicConfig;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
|
||||
// 이벤트 핸들러
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
|
@ -16,12 +16,15 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
|
|||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
isInteractive = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
config,
|
||||
className,
|
||||
style,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
...props
|
||||
}) => {
|
||||
// 컴포넌트 설정
|
||||
|
|
@ -64,6 +67,10 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
|
|||
size: _size,
|
||||
position: _position,
|
||||
style: _style,
|
||||
screenId: _screenId,
|
||||
tableName: _tableName,
|
||||
onRefresh: _onRefresh,
|
||||
onClose: _onClose,
|
||||
...domProps
|
||||
} = props;
|
||||
|
||||
|
|
@ -79,10 +86,20 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
|
|||
fontSize: component.style?.labelFontSize || "14px",
|
||||
color: component.style?.labelColor || "#374151",
|
||||
fontWeight: "500",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
{component.label}
|
||||
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
|
||||
{component.required && (
|
||||
<span style={{
|
||||
color: "#ef4444",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
|
||||
|
|
@ -96,15 +113,15 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
|
|||
min={componentConfig.min}
|
||||
max={componentConfig.max}
|
||||
step={componentConfig.step || 1}
|
||||
style={{
|
||||
width: "100%",
|
||||
style={{width: "100%",
|
||||
height: "100%",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: "4px",
|
||||
padding: "8px 12px",
|
||||
fontSize: "14px",
|
||||
outline: "none",
|
||||
}}
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
|
|
|
|||
|
|
@ -16,12 +16,15 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
|
|||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
isInteractive = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
config,
|
||||
className,
|
||||
style,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
...props
|
||||
}) => {
|
||||
// 컴포넌트 설정
|
||||
|
|
@ -64,6 +67,10 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
|
|||
size: _size,
|
||||
position: _position,
|
||||
style: _style,
|
||||
screenId: _screenId,
|
||||
tableName: _tableName,
|
||||
onRefresh: _onRefresh,
|
||||
onClose: _onClose,
|
||||
...domProps
|
||||
} = props;
|
||||
|
||||
|
|
@ -79,22 +86,28 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
|
|||
fontSize: component.style?.labelFontSize || "14px",
|
||||
color: component.style?.labelColor || "#374151",
|
||||
fontWeight: "500",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
{component.label}
|
||||
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
|
||||
{component.required && <span style={{color: "#ef4444",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}>*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
style={{width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: componentConfig.direction === "horizontal" ? "row" : "column",
|
||||
gap: "8px",
|
||||
padding: "8px",
|
||||
}}
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
|
|
@ -102,13 +115,14 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
|
|||
{(componentConfig.options || []).map((option, index) => (
|
||||
<label
|
||||
key={index}
|
||||
style={{
|
||||
display: "flex",
|
||||
style={{display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
|
|
@ -117,37 +131,53 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
|
|||
checked={component.value === option.value}
|
||||
disabled={componentConfig.disabled || false}
|
||||
required={componentConfig.required || false}
|
||||
style={{
|
||||
width: "16px",
|
||||
style={{width: "16px",
|
||||
height: "16px",
|
||||
accentColor: "#3b82f6",
|
||||
}}
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onChange={(e) => {
|
||||
if (component.onChange) {
|
||||
component.onChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: "#374151" }}>{option.label}</span>
|
||||
<span style={{color: "#374151",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}>{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
{(!componentConfig.options || componentConfig.options.length === 0) && (
|
||||
<>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "6px", cursor: "pointer", fontSize: "14px" }}>
|
||||
<label style={{display: "flex", alignItems: "center", gap: "6px", cursor: "pointer", fontSize: "14px",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}>
|
||||
<input
|
||||
type="radio"
|
||||
name={component.id || "radio-group"}
|
||||
value="option1"
|
||||
style={{ width: "16px", height: "16px", accentColor: "#3b82f6" }}
|
||||
style={{width: "16px", height: "16px", accentColor: "#3b82f6",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
/>
|
||||
<span>옵션 1</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "6px", cursor: "pointer", fontSize: "14px" }}>
|
||||
<label style={{display: "flex", alignItems: "center", gap: "6px", cursor: "pointer", fontSize: "14px",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}>
|
||||
<input
|
||||
type="radio"
|
||||
name={component.id || "radio-group"}
|
||||
value="option2"
|
||||
style={{ width: "16px", height: "16px", accentColor: "#3b82f6" }}
|
||||
style={{width: "16px", height: "16px", accentColor: "#3b82f6",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
/>
|
||||
<span>옵션 2</span>
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -16,12 +16,15 @@ export const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
isInteractive = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
config,
|
||||
className,
|
||||
style,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
...props
|
||||
}) => {
|
||||
// 컴포넌트 설정
|
||||
|
|
@ -64,6 +67,10 @@ export const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
size: _size,
|
||||
position: _position,
|
||||
style: _style,
|
||||
screenId: _screenId,
|
||||
tableName: _tableName,
|
||||
onRefresh: _onRefresh,
|
||||
onClose: _onClose,
|
||||
...domProps
|
||||
} = props;
|
||||
|
||||
|
|
@ -79,10 +86,15 @@ export const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
fontSize: component.style?.labelFontSize || "14px",
|
||||
color: component.style?.labelColor || "#374151",
|
||||
fontWeight: "500",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
{component.label}
|
||||
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
|
||||
{component.required && <span style={{color: "#ef4444",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}>*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
|
|
@ -91,8 +103,7 @@ export const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
disabled={componentConfig.disabled || false}
|
||||
required={componentConfig.required || false}
|
||||
multiple={componentConfig.multiple || false}
|
||||
style={{
|
||||
width: "100%",
|
||||
style={{width: "100%",
|
||||
height: "100%",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: "4px",
|
||||
|
|
@ -100,7 +111,9 @@ export const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
fontSize: "14px",
|
||||
outline: "none",
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
|
|
|
|||
|
|
@ -16,12 +16,15 @@ export const SliderBasicComponent: React.FC<SliderBasicComponentProps> = ({
|
|||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
isInteractive = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
config,
|
||||
className,
|
||||
style,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
...props
|
||||
}) => {
|
||||
// 컴포넌트 설정
|
||||
|
|
@ -64,6 +67,10 @@ export const SliderBasicComponent: React.FC<SliderBasicComponentProps> = ({
|
|||
size: _size,
|
||||
position: _position,
|
||||
style: _style,
|
||||
screenId: _screenId,
|
||||
tableName: _tableName,
|
||||
onRefresh: _onRefresh,
|
||||
onClose: _onClose,
|
||||
...domProps
|
||||
} = props;
|
||||
|
||||
|
|
@ -79,22 +86,28 @@ export const SliderBasicComponent: React.FC<SliderBasicComponentProps> = ({
|
|||
fontSize: component.style?.labelFontSize || "14px",
|
||||
color: component.style?.labelColor || "#374151",
|
||||
fontWeight: "500",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
{component.label}
|
||||
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
|
||||
{component.required && <span style={{color: "#ef4444",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}>*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
style={{width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "12px",
|
||||
padding: "8px",
|
||||
}}
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
|
|
@ -107,13 +120,14 @@ export const SliderBasicComponent: React.FC<SliderBasicComponentProps> = ({
|
|||
value={component.value || componentConfig.min || 0}
|
||||
disabled={componentConfig.disabled || false}
|
||||
required={componentConfig.required || false}
|
||||
style={{
|
||||
width: "70%",
|
||||
style={{width: "70%",
|
||||
height: "6px",
|
||||
outline: "none",
|
||||
borderRadius: "3px",
|
||||
background: "#e5e7eb",
|
||||
accentColor: "#3b82f6",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onChange={(e) => {
|
||||
if (component.onChange) {
|
||||
|
|
@ -122,12 +136,13 @@ export const SliderBasicComponent: React.FC<SliderBasicComponentProps> = ({
|
|||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
width: "30%",
|
||||
style={{width: "30%",
|
||||
textAlign: "center",
|
||||
fontSize: "14px",
|
||||
color: "#374151",
|
||||
fontWeight: "500",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
{component.value || componentConfig.min || 0}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
# TextDisplay 컴포넌트
|
||||
|
||||
텍스트 표시 전용 컴포넌트입니다
|
||||
|
||||
## 개요
|
||||
|
||||
- **ID**: `text-display`
|
||||
- **카테고리**: display
|
||||
- **웹타입**: text
|
||||
- **작성자**: 개발팀
|
||||
- **버전**: 1.0.0
|
||||
|
||||
## 특징
|
||||
|
||||
- ✅ 자동 등록 시스템
|
||||
- ✅ 타입 안전성
|
||||
- ✅ Hot Reload 지원
|
||||
- ✅ 설정 패널 제공
|
||||
- ✅ 반응형 디자인
|
||||
- ✅ 다양한 텍스트 스타일 옵션
|
||||
|
||||
## 사용법
|
||||
|
||||
### 기본 사용법
|
||||
|
||||
```tsx
|
||||
import { TextDisplayComponent } from "@/lib/registry/components/text-display";
|
||||
|
||||
<TextDisplayComponent
|
||||
component={{
|
||||
id: "my-text-display",
|
||||
type: "widget",
|
||||
webType: "text",
|
||||
position: { x: 100, y: 100, z: 1 },
|
||||
size: { width: 150, height: 24 },
|
||||
componentConfig: {
|
||||
text: "표시할 텍스트",
|
||||
fontSize: "16px",
|
||||
color: "#333333"
|
||||
}
|
||||
}}
|
||||
isDesignMode={false}
|
||||
/>
|
||||
```
|
||||
|
||||
### 설정 옵션
|
||||
|
||||
| 속성 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| text | string | "텍스트를 입력하세요" | 표시할 텍스트 |
|
||||
| fontSize | string | "14px" | 폰트 크기 |
|
||||
| fontWeight | string | "normal" | 폰트 굵기 |
|
||||
| color | string | "#374151" | 텍스트 색상 |
|
||||
| textAlign | "left" \| "center" \| "right" | "left" | 텍스트 정렬 |
|
||||
| backgroundColor | string | "transparent" | 배경색 |
|
||||
| padding | string | "0" | 패딩 |
|
||||
| borderRadius | string | "0" | 모서리 둥글기 |
|
||||
| border | string | "none" | 테두리 |
|
||||
| disabled | boolean | false | 비활성화 여부 |
|
||||
|
||||
## 이벤트
|
||||
|
||||
- `onClick`: 클릭 시 (실제 모드에서만)
|
||||
|
||||
## 스타일링
|
||||
|
||||
컴포넌트는 다음과 같은 스타일 옵션을 제공합니다:
|
||||
|
||||
- **폰트 크기**: px, em, rem 단위 지원
|
||||
- **폰트 굵기**: normal, bold, 100-900 숫자값
|
||||
- **텍스트 정렬**: 왼쪽, 가운데, 오른쪽
|
||||
- **색상**: HEX, RGB, 색상명 지원
|
||||
- **배경 및 테두리**: CSS 표준 속성 지원
|
||||
|
||||
## 예시
|
||||
|
||||
```tsx
|
||||
// 제목 텍스트
|
||||
<TextDisplayComponent
|
||||
component={{
|
||||
id: "title-text",
|
||||
componentConfig: {
|
||||
text: "제품 관리 시스템",
|
||||
fontSize: "24px",
|
||||
fontWeight: "bold",
|
||||
color: "#1f2937",
|
||||
textAlign: "center"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
// 설명 텍스트
|
||||
<TextDisplayComponent
|
||||
component={{
|
||||
id: "description-text",
|
||||
componentConfig: {
|
||||
text: "제품 정보를 관리할 수 있습니다.",
|
||||
fontSize: "14px",
|
||||
color: "#6b7280",
|
||||
backgroundColor: "#f9fafb",
|
||||
padding: "8px",
|
||||
borderRadius: "4px"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## 라벨 텍스트 컴포넌트와의 차이점
|
||||
|
||||
기존 `label-basic` 컴포넌트를 대체하는 새로운 컴포넌트입니다:
|
||||
|
||||
### 개선사항
|
||||
- ✅ 더 직관적인 설정 패널
|
||||
- ✅ 다양한 텍스트 스타일 옵션
|
||||
- ✅ 실시간 텍스트 편집
|
||||
- ✅ 더 나은 사용자 경험
|
||||
|
||||
### 마이그레이션
|
||||
기존 `label-basic` 컴포넌트는 자동으로 `text-display`로 교체됩니다.
|
||||
|
||||
## 개발자 정보
|
||||
|
||||
- **생성일**: 2025-09-12
|
||||
- **교체 대상**: label-basic 컴포넌트
|
||||
- **경로**: `lib/registry/components/text-display/`
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md)
|
||||
- [개발자 문서](https://docs.example.com/components/text-display)
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ComponentRendererProps } from "../../types";
|
||||
import { TextDisplayConfig } from "./types";
|
||||
|
||||
export interface TextDisplayComponentProps extends ComponentRendererProps {
|
||||
// 추가 props가 필요한 경우 여기에 정의
|
||||
}
|
||||
|
||||
/**
|
||||
* TextDisplay 컴포넌트
|
||||
* text-display 컴포넌트입니다
|
||||
*/
|
||||
export const TextDisplayComponent: React.FC<TextDisplayComponentProps> = ({
|
||||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
...props
|
||||
}) => {
|
||||
const componentConfig = (component.componentConfig || {}) as TextDisplayConfig;
|
||||
|
||||
// 컴포넌트 스타일 계산
|
||||
const componentStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
left: `${component.style?.positionX || 0}px`,
|
||||
top: `${component.style?.positionY || 0}px`,
|
||||
width: `${component.style?.width || 150}px`,
|
||||
height: `${component.style?.height || 24}px`,
|
||||
zIndex: component.style?.positionZ || 1,
|
||||
cursor: isDesignMode ? "pointer" : "default",
|
||||
border: isSelected ? "2px solid #3b82f6" : "none",
|
||||
outline: isSelected ? "none" : undefined,
|
||||
};
|
||||
|
||||
// 클릭 핸들러
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (isDesignMode) {
|
||||
e.stopPropagation();
|
||||
onClick?.(e);
|
||||
} else {
|
||||
// 실제 모드에서의 클릭 처리
|
||||
componentConfig.onClick?.();
|
||||
}
|
||||
};
|
||||
|
||||
// className 생성
|
||||
const className = ["text-display-component", isSelected ? "selected" : "", componentConfig.disabled ? "disabled" : ""]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
// DOM props 필터링 (React 관련 props 제거)
|
||||
const {
|
||||
component: _component,
|
||||
isDesignMode: _isDesignMode,
|
||||
isSelected: _isSelected,
|
||||
isInteractive: _isInteractive,
|
||||
screenId: _screenId,
|
||||
tableName: _tableName,
|
||||
onRefresh: _onRefresh,
|
||||
onClose: _onClose,
|
||||
formData: _formData,
|
||||
onFormDataChange: _onFormDataChange,
|
||||
componentConfig: _componentConfig,
|
||||
...domProps
|
||||
} = props;
|
||||
|
||||
// 텍스트 스타일 계산
|
||||
const textStyle: React.CSSProperties = {
|
||||
fontSize: componentConfig.fontSize || "14px",
|
||||
fontWeight: componentConfig.fontWeight || "normal",
|
||||
color: componentConfig.color || "#374151",
|
||||
textAlign: componentConfig.textAlign || "left",
|
||||
backgroundColor: componentConfig.backgroundColor || "transparent",
|
||||
padding: componentConfig.padding || "0",
|
||||
borderRadius: componentConfig.borderRadius || "0",
|
||||
border: componentConfig.border || "none",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent:
|
||||
componentConfig.textAlign === "center"
|
||||
? "center"
|
||||
: componentConfig.textAlign === "right"
|
||||
? "flex-end"
|
||||
: "flex-start",
|
||||
wordBreak: "break-word",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={componentStyle} className={className} {...domProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-25px",
|
||||
left: "0px",
|
||||
fontSize: component.style?.labelFontSize || "14px",
|
||||
color: component.style?.labelColor || "#374151",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
{component.label}
|
||||
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div style={textStyle} onClick={handleClick} onDragStart={onDragStart} onDragEnd={onDragEnd}>
|
||||
{componentConfig.text || "텍스트를 입력하세요"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* TextDisplay 래퍼 컴포넌트
|
||||
* 추가적인 로직이나 상태 관리가 필요한 경우 사용
|
||||
*/
|
||||
export const TextDisplayWrapper: React.FC<TextDisplayComponentProps> = (props) => {
|
||||
return <TextDisplayComponent {...props} />;
|
||||
};
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { TextDisplayConfig } from "./types";
|
||||
|
||||
export interface TextDisplayConfigPanelProps {
|
||||
config: TextDisplayConfig;
|
||||
onChange: (config: Partial<TextDisplayConfig>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* TextDisplay 설정 패널
|
||||
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
||||
*/
|
||||
export const TextDisplayConfigPanel: React.FC<TextDisplayConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const handleChange = (key: keyof TextDisplayConfig, value: any) => {
|
||||
onChange({ [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">
|
||||
텍스트 표시 설정
|
||||
</div>
|
||||
|
||||
{/* 표시할 텍스트 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="text">표시할 텍스트</Label>
|
||||
<Input
|
||||
id="text"
|
||||
value={config.text || ""}
|
||||
onChange={(e) => handleChange("text", e.target.value)}
|
||||
placeholder="표시할 텍스트를 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 폰트 크기 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fontSize">폰트 크기</Label>
|
||||
<Input
|
||||
id="fontSize"
|
||||
value={config.fontSize || ""}
|
||||
onChange={(e) => handleChange("fontSize", e.target.value)}
|
||||
placeholder="14px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 폰트 굵기 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fontWeight">폰트 굵기</Label>
|
||||
<Select
|
||||
value={config.fontWeight || "normal"}
|
||||
onValueChange={(value) => handleChange("fontWeight", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="폰트 굵기 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="normal">보통</SelectItem>
|
||||
<SelectItem value="bold">굵게</SelectItem>
|
||||
<SelectItem value="lighter">얇게</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
<SelectItem value="200">200</SelectItem>
|
||||
<SelectItem value="300">300</SelectItem>
|
||||
<SelectItem value="400">400</SelectItem>
|
||||
<SelectItem value="500">500</SelectItem>
|
||||
<SelectItem value="600">600</SelectItem>
|
||||
<SelectItem value="700">700</SelectItem>
|
||||
<SelectItem value="800">800</SelectItem>
|
||||
<SelectItem value="900">900</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 텍스트 색상 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="color">텍스트 색상</Label>
|
||||
<Input
|
||||
id="color"
|
||||
type="color"
|
||||
value={config.color || "#374151"}
|
||||
onChange={(e) => handleChange("color", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 텍스트 정렬 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="textAlign">텍스트 정렬</Label>
|
||||
<Select
|
||||
value={config.textAlign || "left"}
|
||||
onValueChange={(value) => handleChange("textAlign", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="정렬 방식 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽 정렬</SelectItem>
|
||||
<SelectItem value="center">가운데 정렬</SelectItem>
|
||||
<SelectItem value="right">오른쪽 정렬</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 배경색 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backgroundColor">배경색</Label>
|
||||
<Input
|
||||
id="backgroundColor"
|
||||
type="color"
|
||||
value={config.backgroundColor || "#ffffff"}
|
||||
onChange={(e) => handleChange("backgroundColor", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 패딩 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="padding">패딩</Label>
|
||||
<Input
|
||||
id="padding"
|
||||
value={config.padding || ""}
|
||||
onChange={(e) => handleChange("padding", e.target.value)}
|
||||
placeholder="8px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 모서리 둥글기 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="borderRadius">모서리 둥글기</Label>
|
||||
<Input
|
||||
id="borderRadius"
|
||||
value={config.borderRadius || ""}
|
||||
onChange={(e) => handleChange("borderRadius", e.target.value)}
|
||||
placeholder="4px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 테두리 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="border">테두리</Label>
|
||||
<Input
|
||||
id="border"
|
||||
value={config.border || ""}
|
||||
onChange={(e) => handleChange("border", e.target.value)}
|
||||
placeholder="1px solid #d1d5db"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 비활성화 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disabled">비활성화</Label>
|
||||
<Checkbox
|
||||
id="disabled"
|
||||
checked={config.disabled || false}
|
||||
onCheckedChange={(checked) => handleChange("disabled", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { TextDisplayDefinition } from "./index";
|
||||
import { TextDisplayComponent } from "./TextDisplayComponent";
|
||||
|
||||
/**
|
||||
* TextDisplay 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class TextDisplayRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = TextDisplayDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <TextDisplayComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트별 특화 메서드들
|
||||
*/
|
||||
|
||||
// text 타입 특화 속성 처리
|
||||
protected getTextDisplayProps() {
|
||||
const baseProps = this.getWebTypeProps();
|
||||
|
||||
// text 타입에 특화된 추가 속성들
|
||||
return {
|
||||
...baseProps,
|
||||
// 여기에 text 타입 특화 속성들 추가
|
||||
};
|
||||
}
|
||||
|
||||
// 클릭 처리
|
||||
protected handleClick = () => {
|
||||
// 클릭 로직
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
TextDisplayRenderer.registerSelf();
|
||||
|
||||
// Hot Reload는 Next.js에서 자동으로 처리됨
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import type { WebType } from "@/types/screen";
|
||||
import { TextDisplayWrapper } from "./TextDisplayComponent";
|
||||
import { TextDisplayConfigPanel } from "./TextDisplayConfigPanel";
|
||||
import { TextDisplayConfig } from "./types";
|
||||
|
||||
/**
|
||||
* TextDisplay 컴포넌트 정의
|
||||
* text-display 컴포넌트입니다
|
||||
*/
|
||||
export const TextDisplayDefinition = createComponentDefinition({
|
||||
id: "text-display",
|
||||
name: "텍스트 표시",
|
||||
nameEng: "TextDisplay Component",
|
||||
description: "텍스트를 표시하기 위한 컴포넌트",
|
||||
category: ComponentCategory.DISPLAY,
|
||||
webType: "text",
|
||||
component: TextDisplayWrapper,
|
||||
defaultConfig: {
|
||||
text: "텍스트를 입력하세요",
|
||||
fontSize: "14px",
|
||||
fontWeight: "normal",
|
||||
color: "#374151",
|
||||
textAlign: "left",
|
||||
},
|
||||
defaultSize: { width: 150, height: 24 },
|
||||
configPanel: TextDisplayConfigPanel,
|
||||
icon: "Type",
|
||||
tags: ["텍스트", "표시", "라벨"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation: "https://docs.example.com/components/text-display",
|
||||
});
|
||||
|
||||
// 타입 내보내기
|
||||
export type { TextDisplayConfig } from "./types";
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
|
||||
import { ComponentConfig } from "@/types/component";
|
||||
|
||||
/**
|
||||
* TextDisplay 컴포넌트 설정 타입
|
||||
*/
|
||||
export interface TextDisplayConfig extends ComponentConfig {
|
||||
// 텍스트 관련 설정
|
||||
text?: string; // 표시할 텍스트
|
||||
fontSize?: string; // 폰트 크기
|
||||
fontWeight?: string; // 폰트 굵기
|
||||
color?: string; // 텍스트 색상
|
||||
textAlign?: "left" | "center" | "right"; // 텍스트 정렬
|
||||
|
||||
// 스타일 관련
|
||||
backgroundColor?: string; // 배경색
|
||||
padding?: string; // 패딩
|
||||
borderRadius?: string; // 모서리 둥글기
|
||||
border?: string; // 테두리
|
||||
|
||||
// 공통 설정
|
||||
disabled?: boolean;
|
||||
|
||||
// 이벤트 관련
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* TextDisplay 컴포넌트 Props 타입
|
||||
*/
|
||||
export interface TextDisplayProps {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: any;
|
||||
config?: TextDisplayConfig;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
|
||||
// 이벤트 핸들러
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
|
@ -16,12 +16,15 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
isInteractive = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
config,
|
||||
className,
|
||||
style,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
...props
|
||||
}) => {
|
||||
// 컴포넌트 설정
|
||||
|
|
@ -30,6 +33,12 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
...component.config,
|
||||
} as TextInputConfig;
|
||||
|
||||
console.log("🔧 텍스트 입력 컴포넌트 설정:", {
|
||||
config,
|
||||
componentConfig,
|
||||
component: component,
|
||||
});
|
||||
|
||||
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
|
||||
const componentStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
|
|
@ -64,6 +73,10 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
size: _size,
|
||||
position: _position,
|
||||
style: _style,
|
||||
screenId: _screenId,
|
||||
tableName: _tableName,
|
||||
onRefresh: _onRefresh,
|
||||
onClose: _onClose,
|
||||
...domProps
|
||||
} = props;
|
||||
|
||||
|
|
@ -88,7 +101,11 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
|
||||
<input
|
||||
type="text"
|
||||
value={component.value || ""}
|
||||
value={
|
||||
isInteractive && formData && component.columnName
|
||||
? formData[component.columnName] || ""
|
||||
: component.value || ""
|
||||
}
|
||||
placeholder={componentConfig.placeholder || ""}
|
||||
disabled={componentConfig.disabled || false}
|
||||
required={componentConfig.required || false}
|
||||
|
|
@ -101,13 +118,23 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
padding: "8px 12px",
|
||||
fontSize: "14px",
|
||||
outline: "none",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
|
||||
// isInteractive 모드에서는 formData 업데이트
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, newValue);
|
||||
}
|
||||
|
||||
// 기존 onChange 핸들러도 호출
|
||||
if (props.onChange) {
|
||||
props.onChange(e.target.value);
|
||||
props.onChange(newValue);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -33,11 +33,7 @@ export const TextInputDefinition = createComponentDefinition({
|
|||
documentation: "https://docs.example.com/components/text-input",
|
||||
});
|
||||
|
||||
// ComponentRegistry에 등록
|
||||
import { ComponentRegistry } from "../../ComponentRegistry";
|
||||
ComponentRegistry.registerComponent(TextInputDefinition);
|
||||
|
||||
console.log("🚀 TextInput 컴포넌트 등록 완료");
|
||||
// 컴포넌트는 TextInputRenderer에서 자동 등록됩니다
|
||||
|
||||
// 타입 내보내기
|
||||
export type { TextInputConfig } from "./types";
|
||||
|
|
|
|||
|
|
@ -16,12 +16,15 @@ export const TextareaBasicComponent: React.FC<TextareaBasicComponentProps> = ({
|
|||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
isInteractive = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
config,
|
||||
className,
|
||||
style,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
...props
|
||||
}) => {
|
||||
// 컴포넌트 설정
|
||||
|
|
@ -64,6 +67,10 @@ export const TextareaBasicComponent: React.FC<TextareaBasicComponentProps> = ({
|
|||
size: _size,
|
||||
position: _position,
|
||||
style: _style,
|
||||
screenId: _screenId,
|
||||
tableName: _tableName,
|
||||
onRefresh: _onRefresh,
|
||||
onClose: _onClose,
|
||||
...domProps
|
||||
} = props;
|
||||
|
||||
|
|
@ -79,10 +86,15 @@ export const TextareaBasicComponent: React.FC<TextareaBasicComponentProps> = ({
|
|||
fontSize: component.style?.labelFontSize || "14px",
|
||||
color: component.style?.labelColor || "#374151",
|
||||
fontWeight: "500",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
{component.label}
|
||||
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
|
||||
{component.required && <span style={{color: "#ef4444",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}>*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
|
|
@ -93,8 +105,7 @@ export const TextareaBasicComponent: React.FC<TextareaBasicComponentProps> = ({
|
|||
required={componentConfig.required || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
rows={componentConfig.rows || 3}
|
||||
style={{
|
||||
width: "100%",
|
||||
style={{width: "100%",
|
||||
height: "100%",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: "4px",
|
||||
|
|
@ -102,7 +113,9 @@ export const TextareaBasicComponent: React.FC<TextareaBasicComponentProps> = ({
|
|||
fontSize: "14px",
|
||||
outline: "none",
|
||||
resize: "none",
|
||||
}}
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
|
|
|
|||
|
|
@ -16,12 +16,15 @@ export const ToggleSwitchComponent: React.FC<ToggleSwitchComponentProps> = ({
|
|||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
isInteractive = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
config,
|
||||
className,
|
||||
style,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
...props
|
||||
}) => {
|
||||
// 컴포넌트 설정
|
||||
|
|
@ -64,6 +67,10 @@ export const ToggleSwitchComponent: React.FC<ToggleSwitchComponentProps> = ({
|
|||
size: _size,
|
||||
position: _position,
|
||||
style: _style,
|
||||
screenId: _screenId,
|
||||
tableName: _tableName,
|
||||
onRefresh: _onRefresh,
|
||||
onClose: _onClose,
|
||||
...domProps
|
||||
} = props;
|
||||
|
||||
|
|
@ -79,30 +86,35 @@ export const ToggleSwitchComponent: React.FC<ToggleSwitchComponentProps> = ({
|
|||
fontSize: component.style?.labelFontSize || "14px",
|
||||
color: component.style?.labelColor || "#374151",
|
||||
fontWeight: "500",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
{component.label}
|
||||
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
|
||||
{component.required && <span style={{color: "#ef4444",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}>*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
style={{display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "12px",
|
||||
cursor: "pointer",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
style={{position: "relative",
|
||||
width: "48px",
|
||||
height: "24px",
|
||||
backgroundColor: component.value === true ? "#3b82f6" : "#d1d5db",
|
||||
|
|
@ -110,6 +122,8 @@ export const ToggleSwitchComponent: React.FC<ToggleSwitchComponentProps> = ({
|
|||
transition: "background-color 0.2s",
|
||||
cursor: componentConfig.disabled ? "not-allowed" : "pointer",
|
||||
opacity: componentConfig.disabled ? 0.5 : 1,
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
<input
|
||||
|
|
@ -118,12 +132,14 @@ export const ToggleSwitchComponent: React.FC<ToggleSwitchComponentProps> = ({
|
|||
disabled={componentConfig.disabled || false}
|
||||
required={componentConfig.required || false}
|
||||
style={{
|
||||
position: "absolute",
|
||||
position: "absolute",
|
||||
opacity: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onChange={(e) => {
|
||||
if (component.onChange) {
|
||||
component.onChange(e.target.checked);
|
||||
|
|
@ -132,7 +148,7 @@ export const ToggleSwitchComponent: React.FC<ToggleSwitchComponentProps> = ({
|
|||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
position: "absolute",
|
||||
top: "2px",
|
||||
left: component.value === true ? "26px" : "2px",
|
||||
width: "20px",
|
||||
|
|
@ -141,10 +157,15 @@ export const ToggleSwitchComponent: React.FC<ToggleSwitchComponentProps> = ({
|
|||
borderRadius: "50%",
|
||||
transition: "left 0.2s",
|
||||
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
|
||||
}}
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span style={{ color: "#374151" }}>{componentConfig.toggleLabel || (component.value ? "켜짐" : "꺼짐")}</span>
|
||||
<span style={{color: "#374151",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}>{componentConfig.toggleLabel || (component.value ? "켜짐" : "꺼짐")}</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,498 @@
|
|||
"use client";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { DynamicFormApi } from "@/lib/api/dynamicForm";
|
||||
|
||||
/**
|
||||
* 버튼 액션 타입 정의
|
||||
*/
|
||||
export type ButtonActionType =
|
||||
| "save" // 저장
|
||||
| "cancel" // 취소
|
||||
| "delete" // 삭제
|
||||
| "edit" // 편집
|
||||
| "add" // 추가
|
||||
| "search" // 검색
|
||||
| "reset" // 초기화
|
||||
| "submit" // 제출
|
||||
| "close" // 닫기
|
||||
| "popup" // 팝업 열기
|
||||
| "navigate" // 페이지 이동
|
||||
| "modal" // 모달 열기
|
||||
| "newWindow"; // 새 창 열기
|
||||
|
||||
/**
|
||||
* 버튼 액션 설정
|
||||
*/
|
||||
export interface ButtonActionConfig {
|
||||
type: ButtonActionType;
|
||||
|
||||
// 저장/제출 관련
|
||||
saveEndpoint?: string;
|
||||
validateForm?: boolean;
|
||||
|
||||
// 네비게이션 관련
|
||||
targetUrl?: string;
|
||||
targetScreenId?: number;
|
||||
|
||||
// 모달/팝업 관련
|
||||
modalTitle?: string;
|
||||
modalSize?: "sm" | "md" | "lg" | "xl";
|
||||
popupWidth?: number;
|
||||
popupHeight?: number;
|
||||
|
||||
// 확인 메시지
|
||||
confirmMessage?: string;
|
||||
successMessage?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 버튼 액션 실행 컨텍스트
|
||||
*/
|
||||
export interface ButtonActionContext {
|
||||
formData: Record<string, any>;
|
||||
screenId?: number;
|
||||
tableName?: string;
|
||||
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||
onClose?: () => void;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 버튼 액션 실행기
|
||||
*/
|
||||
export class ButtonActionExecutor {
|
||||
/**
|
||||
* 액션 실행
|
||||
*/
|
||||
static async executeAction(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||
try {
|
||||
// 확인 로직은 컴포넌트에서 처리하므로 여기서는 제거
|
||||
|
||||
switch (config.type) {
|
||||
case "save":
|
||||
return await this.handleSave(config, context);
|
||||
|
||||
case "submit":
|
||||
return await this.handleSubmit(config, context);
|
||||
|
||||
case "delete":
|
||||
return await this.handleDelete(config, context);
|
||||
|
||||
case "reset":
|
||||
return this.handleReset(config, context);
|
||||
|
||||
case "cancel":
|
||||
return this.handleCancel(config, context);
|
||||
|
||||
case "navigate":
|
||||
return this.handleNavigate(config, context);
|
||||
|
||||
case "modal":
|
||||
return this.handleModal(config, context);
|
||||
|
||||
case "newWindow":
|
||||
return this.handleNewWindow(config, context);
|
||||
|
||||
case "popup":
|
||||
return this.handlePopup(config, context);
|
||||
|
||||
case "search":
|
||||
return this.handleSearch(config, context);
|
||||
|
||||
case "add":
|
||||
return this.handleAdd(config, context);
|
||||
|
||||
case "edit":
|
||||
return this.handleEdit(config, context);
|
||||
|
||||
case "close":
|
||||
return this.handleClose(config, context);
|
||||
|
||||
default:
|
||||
console.warn(`지원되지 않는 액션 타입: ${config.type}`);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("버튼 액션 실행 오류:", error);
|
||||
toast.error(config.errorMessage || "작업 중 오류가 발생했습니다.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장 액션 처리
|
||||
*/
|
||||
private static async handleSave(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||
const { formData, tableName, screenId } = context;
|
||||
|
||||
// 폼 유효성 검사
|
||||
if (config.validateForm) {
|
||||
const validation = this.validateFormData(formData);
|
||||
if (!validation.isValid) {
|
||||
toast.error(`입력값을 확인해주세요: ${validation.errors.join(", ")}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// API 엔드포인트가 지정된 경우
|
||||
if (config.saveEndpoint) {
|
||||
const response = await fetch(config.saveEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`저장 실패: ${response.statusText}`);
|
||||
}
|
||||
} else if (tableName && screenId) {
|
||||
// 기본 테이블 저장 로직
|
||||
console.log("테이블 저장:", { tableName, formData, screenId });
|
||||
|
||||
// 실제 저장 API 호출
|
||||
const saveResult = await DynamicFormApi.saveFormData({
|
||||
screenId,
|
||||
tableName,
|
||||
data: formData,
|
||||
});
|
||||
|
||||
if (!saveResult.success) {
|
||||
throw new Error(saveResult.message || "저장에 실패했습니다.");
|
||||
}
|
||||
|
||||
console.log("✅ 저장 성공:", saveResult);
|
||||
} else {
|
||||
throw new Error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)");
|
||||
}
|
||||
|
||||
context.onRefresh?.();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("저장 오류:", error);
|
||||
throw error; // 에러를 다시 던져서 컴포넌트에서 처리하도록 함
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 제출 액션 처리
|
||||
*/
|
||||
private static async handleSubmit(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||
// 제출은 저장과 유사하지만 추가적인 처리가 있을 수 있음
|
||||
return await this.handleSave(config, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제 액션 처리
|
||||
*/
|
||||
private static async handleDelete(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||
const { formData, tableName, screenId } = context;
|
||||
|
||||
try {
|
||||
if (tableName && screenId && formData.id) {
|
||||
console.log("데이터 삭제:", { tableName, screenId, id: formData.id });
|
||||
|
||||
// 실제 삭제 API 호출
|
||||
const deleteResult = await DynamicFormApi.deleteFormData(formData.id);
|
||||
|
||||
if (!deleteResult.success) {
|
||||
throw new Error(deleteResult.message || "삭제에 실패했습니다.");
|
||||
}
|
||||
|
||||
console.log("✅ 삭제 성공:", deleteResult);
|
||||
} else {
|
||||
throw new Error("삭제에 필요한 정보가 부족합니다. (ID, 테이블명 또는 화면ID 누락)");
|
||||
}
|
||||
|
||||
context.onRefresh?.();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("삭제 오류:", error);
|
||||
throw error; // 에러를 다시 던져서 컴포넌트에서 처리하도록 함
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 초기화 액션 처리
|
||||
*/
|
||||
private static handleReset(config: ButtonActionConfig, context: ButtonActionContext): boolean {
|
||||
const { formData, onFormDataChange } = context;
|
||||
|
||||
// 폼 데이터 초기화 - 각 필드를 개별적으로 초기화
|
||||
if (onFormDataChange && formData) {
|
||||
Object.keys(formData).forEach((key) => {
|
||||
onFormDataChange(key, "");
|
||||
});
|
||||
}
|
||||
|
||||
toast.success(config.successMessage || "초기화되었습니다.");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 취소 액션 처리
|
||||
*/
|
||||
private static handleCancel(config: ButtonActionConfig, context: ButtonActionContext): boolean {
|
||||
const { onClose } = context;
|
||||
|
||||
onClose?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 네비게이션 액션 처리
|
||||
*/
|
||||
private static handleNavigate(config: ButtonActionConfig, context: ButtonActionContext): boolean {
|
||||
let targetUrl = config.targetUrl;
|
||||
|
||||
// 화면 ID가 지정된 경우 URL 생성
|
||||
if (config.targetScreenId) {
|
||||
targetUrl = `/screens/${config.targetScreenId}`;
|
||||
}
|
||||
|
||||
if (targetUrl) {
|
||||
window.location.href = targetUrl;
|
||||
return true;
|
||||
}
|
||||
|
||||
toast.error("이동할 페이지가 지정되지 않았습니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모달 액션 처리
|
||||
*/
|
||||
private static handleModal(config: ButtonActionConfig, context: ButtonActionContext): boolean {
|
||||
// 모달 열기 로직
|
||||
console.log("모달 열기:", {
|
||||
title: config.modalTitle,
|
||||
size: config.modalSize,
|
||||
targetScreenId: config.targetScreenId,
|
||||
});
|
||||
|
||||
if (config.targetScreenId) {
|
||||
// 전역 모달 상태 업데이트를 위한 이벤트 발생
|
||||
const modalEvent = new CustomEvent("openScreenModal", {
|
||||
detail: {
|
||||
screenId: config.targetScreenId,
|
||||
title: config.modalTitle || "화면",
|
||||
size: config.modalSize || "md",
|
||||
},
|
||||
});
|
||||
|
||||
window.dispatchEvent(modalEvent);
|
||||
toast.success("모달 화면이 열렸습니다.");
|
||||
} else {
|
||||
toast.error("모달로 열 화면이 지정되지 않았습니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 창 액션 처리
|
||||
*/
|
||||
private static handleNewWindow(config: ButtonActionConfig, context: ButtonActionContext): boolean {
|
||||
let targetUrl = config.targetUrl;
|
||||
|
||||
// 화면 ID가 지정된 경우 URL 생성
|
||||
if (config.targetScreenId) {
|
||||
targetUrl = `/screens/${config.targetScreenId}`;
|
||||
}
|
||||
|
||||
if (targetUrl) {
|
||||
const windowFeatures = `width=${config.popupWidth || 800},height=${config.popupHeight || 600},scrollbars=yes,resizable=yes`;
|
||||
window.open(targetUrl, "_blank", windowFeatures);
|
||||
return true;
|
||||
}
|
||||
|
||||
toast.error("열 페이지가 지정되지 않았습니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 팝업 액션 처리
|
||||
*/
|
||||
private static handlePopup(config: ButtonActionConfig, context: ButtonActionContext): boolean {
|
||||
// 팝업은 새 창과 유사하지만 더 작은 크기
|
||||
return this.handleNewWindow(
|
||||
{
|
||||
...config,
|
||||
popupWidth: config.popupWidth || 600,
|
||||
popupHeight: config.popupHeight || 400,
|
||||
},
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 액션 처리
|
||||
*/
|
||||
private static handleSearch(config: ButtonActionConfig, context: ButtonActionContext): boolean {
|
||||
const { formData, onRefresh } = context;
|
||||
|
||||
console.log("검색 실행:", formData);
|
||||
|
||||
// 검색 조건 검증
|
||||
const hasSearchCriteria = Object.values(formData).some(
|
||||
(value) => value !== null && value !== undefined && value !== "",
|
||||
);
|
||||
|
||||
if (!hasSearchCriteria) {
|
||||
toast.warning("검색 조건을 입력해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 검색 실행 (데이터 새로고침)
|
||||
onRefresh?.();
|
||||
|
||||
// 검색 조건을 URL 파라미터로 추가 (선택사항)
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(formData).forEach(([key, value]) => {
|
||||
if (value !== null && value !== undefined && value !== "") {
|
||||
searchParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
// URL 업데이트 (히스토리에 추가하지 않음)
|
||||
if (searchParams.toString()) {
|
||||
const newUrl = `${window.location.pathname}?${searchParams.toString()}`;
|
||||
window.history.replaceState({}, "", newUrl);
|
||||
}
|
||||
|
||||
toast.success(config.successMessage || "검색을 실행했습니다.");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 추가 액션 처리
|
||||
*/
|
||||
private static handleAdd(config: ButtonActionConfig, context: ButtonActionContext): boolean {
|
||||
console.log("추가 액션 실행:", context);
|
||||
// 추가 로직 구현 (예: 새 레코드 생성 폼 열기)
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 편집 액션 처리
|
||||
*/
|
||||
private static handleEdit(config: ButtonActionConfig, context: ButtonActionContext): boolean {
|
||||
console.log("편집 액션 실행:", context);
|
||||
// 편집 로직 구현 (예: 편집 모드로 전환)
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 닫기 액션 처리
|
||||
*/
|
||||
private static handleClose(config: ButtonActionConfig, context: ButtonActionContext): boolean {
|
||||
console.log("닫기 액션 실행:", context);
|
||||
context.onClose?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼 데이터 유효성 검사
|
||||
*/
|
||||
private static validateFormData(formData: Record<string, any>): {
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
|
||||
// 기본적인 유효성 검사 로직
|
||||
Object.entries(formData).forEach(([key, value]) => {
|
||||
// 빈 값 체크 (null, undefined, 빈 문자열)
|
||||
if (value === null || value === undefined || value === "") {
|
||||
// 필수 필드는 향후 컴포넌트 설정에서 확인 가능
|
||||
console.warn(`필드 '${key}'가 비어있습니다.`);
|
||||
}
|
||||
|
||||
// 기본 타입 검증
|
||||
if (typeof value === "string" && value.trim() === "") {
|
||||
console.warn(`필드 '${key}'가 공백만 포함되어 있습니다.`);
|
||||
}
|
||||
});
|
||||
|
||||
// 최소한 하나의 필드는 있어야 함
|
||||
if (Object.keys(formData).length === 0) {
|
||||
errors.push("저장할 데이터가 없습니다.");
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 버튼 액션 설정들
|
||||
*/
|
||||
export const DEFAULT_BUTTON_ACTIONS: Record<ButtonActionType, Partial<ButtonActionConfig>> = {
|
||||
save: {
|
||||
type: "save",
|
||||
validateForm: true,
|
||||
confirmMessage: "저장하시겠습니까?",
|
||||
successMessage: "저장되었습니다.",
|
||||
errorMessage: "저장 중 오류가 발생했습니다.",
|
||||
},
|
||||
submit: {
|
||||
type: "submit",
|
||||
validateForm: true,
|
||||
successMessage: "제출되었습니다.",
|
||||
errorMessage: "제출 중 오류가 발생했습니다.",
|
||||
},
|
||||
delete: {
|
||||
type: "delete",
|
||||
confirmMessage: "정말 삭제하시겠습니까?",
|
||||
successMessage: "삭제되었습니다.",
|
||||
errorMessage: "삭제 중 오류가 발생했습니다.",
|
||||
},
|
||||
reset: {
|
||||
type: "reset",
|
||||
confirmMessage: "초기화하시겠습니까?",
|
||||
successMessage: "초기화되었습니다.",
|
||||
},
|
||||
cancel: {
|
||||
type: "cancel",
|
||||
},
|
||||
navigate: {
|
||||
type: "navigate",
|
||||
},
|
||||
modal: {
|
||||
type: "modal",
|
||||
modalSize: "md",
|
||||
},
|
||||
newWindow: {
|
||||
type: "newWindow",
|
||||
popupWidth: 800,
|
||||
popupHeight: 600,
|
||||
},
|
||||
popup: {
|
||||
type: "popup",
|
||||
popupWidth: 600,
|
||||
popupHeight: 400,
|
||||
},
|
||||
search: {
|
||||
type: "search",
|
||||
successMessage: "검색을 실행했습니다.",
|
||||
},
|
||||
add: {
|
||||
type: "add",
|
||||
successMessage: "추가되었습니다.",
|
||||
},
|
||||
edit: {
|
||||
type: "edit",
|
||||
successMessage: "편집되었습니다.",
|
||||
},
|
||||
close: {
|
||||
type: "close",
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
/**
|
||||
* 컴포넌트 ID로 해당 컴포넌트의 ConfigPanel을 동적으로 로드하는 유틸리티
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
// 컴포넌트별 ConfigPanel 동적 import 맵
|
||||
const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
|
||||
"text-input": () => import("@/lib/registry/components/text-input/TextInputConfigPanel"),
|
||||
"number-input": () => import("@/lib/registry/components/number-input/NumberInputConfigPanel"),
|
||||
"date-input": () => import("@/lib/registry/components/date-input/DateInputConfigPanel"),
|
||||
"textarea-basic": () => import("@/lib/registry/components/textarea-basic/TextareaBasicConfigPanel"),
|
||||
"select-basic": () => import("@/lib/registry/components/select-basic/SelectBasicConfigPanel"),
|
||||
"checkbox-basic": () => import("@/lib/registry/components/checkbox-basic/CheckboxBasicConfigPanel"),
|
||||
"radio-basic": () => import("@/lib/registry/components/radio-basic/RadioBasicConfigPanel"),
|
||||
"toggle-switch": () => import("@/lib/registry/components/toggle-switch/ToggleSwitchConfigPanel"),
|
||||
"file-upload": () => import("@/lib/registry/components/file-upload/FileUploadConfigPanel"),
|
||||
"button-primary": () => import("@/lib/registry/components/button-primary/ButtonPrimaryConfigPanel"),
|
||||
"text-display": () => import("@/lib/registry/components/text-display/TextDisplayConfigPanel"),
|
||||
"slider-basic": () => import("@/lib/registry/components/slider-basic/SliderBasicConfigPanel"),
|
||||
"image-display": () => import("@/lib/registry/components/image-display/ImageDisplayConfigPanel"),
|
||||
"divider-line": () => import("@/lib/registry/components/divider-line/DividerLineConfigPanel"),
|
||||
};
|
||||
|
||||
// ConfigPanel 컴포넌트 캐시
|
||||
const configPanelCache = new Map<string, React.ComponentType<any>>();
|
||||
|
||||
/**
|
||||
* 컴포넌트 ID로 ConfigPanel 컴포넌트를 동적으로 로드
|
||||
*/
|
||||
export async function getComponentConfigPanel(componentId: string): Promise<React.ComponentType<any> | null> {
|
||||
// 캐시에서 먼저 확인
|
||||
if (configPanelCache.has(componentId)) {
|
||||
return configPanelCache.get(componentId)!;
|
||||
}
|
||||
|
||||
// 매핑에서 import 함수 찾기
|
||||
const importFn = CONFIG_PANEL_MAP[componentId];
|
||||
if (!importFn) {
|
||||
console.warn(`컴포넌트 "${componentId}"에 대한 ConfigPanel을 찾을 수 없습니다.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`🔧 ConfigPanel 로드 중: ${componentId}`);
|
||||
const module = await importFn();
|
||||
|
||||
// 모듈에서 ConfigPanel 컴포넌트 추출
|
||||
const ConfigPanelComponent = module[`${toPascalCase(componentId)}ConfigPanel`] || module.default;
|
||||
|
||||
if (!ConfigPanelComponent) {
|
||||
console.error(`컴포넌트 "${componentId}"의 ConfigPanel을 모듈에서 찾을 수 없습니다.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 캐시에 저장
|
||||
configPanelCache.set(componentId, ConfigPanelComponent);
|
||||
console.log(`✅ ConfigPanel 로드 완료: ${componentId}`);
|
||||
|
||||
return ConfigPanelComponent;
|
||||
} catch (error) {
|
||||
console.error(`컴포넌트 "${componentId}"의 ConfigPanel 로드 실패:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트 ID가 ConfigPanel을 지원하는지 확인
|
||||
*/
|
||||
export function hasComponentConfigPanel(componentId: string): boolean {
|
||||
return componentId in CONFIG_PANEL_MAP;
|
||||
}
|
||||
|
||||
/**
|
||||
* 지원되는 모든 컴포넌트 ID 목록 조회
|
||||
*/
|
||||
export function getSupportedConfigPanelComponents(): string[] {
|
||||
return Object.keys(CONFIG_PANEL_MAP);
|
||||
}
|
||||
|
||||
/**
|
||||
* kebab-case를 PascalCase로 변환
|
||||
* text-input → TextInput
|
||||
*/
|
||||
function toPascalCase(str: string): string {
|
||||
return str
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트 설정 패널을 렌더링하는 React 컴포넌트
|
||||
*/
|
||||
export interface ComponentConfigPanelProps {
|
||||
componentId: string;
|
||||
config: Record<string, any>;
|
||||
onChange: (config: Record<string, any>) => void;
|
||||
}
|
||||
|
||||
export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> = ({
|
||||
componentId,
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const [ConfigPanelComponent, setConfigPanelComponent] = React.useState<React.ComponentType<any> | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
async function loadConfigPanel() {
|
||||
try {
|
||||
console.log(`🔧 DynamicComponentConfigPanel: ${componentId} 로드 시작`);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const component = await getComponentConfigPanel(componentId);
|
||||
console.log(`🔧 DynamicComponentConfigPanel: ${componentId} 로드 결과:`, component);
|
||||
|
||||
if (mounted) {
|
||||
setConfigPanelComponent(() => component);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`❌ DynamicComponentConfigPanel: ${componentId} 로드 실패:`, err);
|
||||
if (mounted) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadConfigPanel();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [componentId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-gray-300 bg-gray-50 p-4">
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
<span className="text-sm font-medium">⏳ 로딩 중...</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">설정 패널을 불러오는 중입니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-red-300 bg-red-50 p-4">
|
||||
<div className="flex items-center gap-2 text-red-600">
|
||||
<span className="text-sm font-medium">⚠️ 로드 실패</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-red-500">설정 패널을 불러올 수 없습니다: {error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!ConfigPanelComponent) {
|
||||
console.warn(`⚠️ DynamicComponentConfigPanel: ${componentId} ConfigPanelComponent가 null`);
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-yellow-300 bg-yellow-50 p-4">
|
||||
<div className="flex items-center gap-2 text-yellow-600">
|
||||
<span className="text-sm font-medium">⚠️ 설정 패널 없음</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-yellow-500">컴포넌트 "{componentId}"에 대한 설정 패널이 없습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ConfigPanelComponent config={config} onChange={onChange} />;
|
||||
};
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* 웹타입을 새로운 컴포넌트 시스템의 컴포넌트 ID로 매핑하는 유틸리티
|
||||
*/
|
||||
|
||||
export interface WebTypeMapping {
|
||||
webType: string;
|
||||
componentId: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹타입 → 컴포넌트 ID 매핑 테이블
|
||||
*/
|
||||
export const WEB_TYPE_COMPONENT_MAPPING: Record<string, string> = {
|
||||
// 텍스트 입력
|
||||
text: "text-input",
|
||||
email: "text-input",
|
||||
password: "text-input",
|
||||
tel: "text-input",
|
||||
|
||||
// 숫자 입력
|
||||
number: "number-input",
|
||||
decimal: "number-input",
|
||||
|
||||
// 날짜/시간
|
||||
date: "date-input",
|
||||
datetime: "date-input",
|
||||
time: "date-input",
|
||||
|
||||
// 텍스트 영역
|
||||
textarea: "textarea-basic",
|
||||
|
||||
// 선택
|
||||
select: "select-basic",
|
||||
dropdown: "select-basic",
|
||||
|
||||
// 체크박스/라디오
|
||||
checkbox: "checkbox-basic",
|
||||
radio: "radio-basic",
|
||||
boolean: "toggle-switch",
|
||||
|
||||
// 파일
|
||||
file: "file-upload",
|
||||
|
||||
// 버튼
|
||||
button: "button-primary",
|
||||
|
||||
// 기타
|
||||
label: "text-display",
|
||||
code: "text-input", // 임시로 텍스트 입력 사용
|
||||
entity: "select-basic", // 임시로 선택상자 사용
|
||||
};
|
||||
|
||||
/**
|
||||
* 웹타입을 컴포넌트 ID로 변환
|
||||
*/
|
||||
export function getComponentIdFromWebType(webType: string): string {
|
||||
const componentId = WEB_TYPE_COMPONENT_MAPPING[webType];
|
||||
|
||||
if (!componentId) {
|
||||
console.warn(`웹타입 "${webType}"에 대한 컴포넌트 매핑을 찾을 수 없습니다. 기본값 'text-input' 사용`);
|
||||
return "text-input"; // 기본값
|
||||
}
|
||||
|
||||
console.log(`웹타입 "${webType}" → 컴포넌트 "${componentId}" 매핑`);
|
||||
return componentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트 ID를 웹타입으로 역변환 (필요한 경우)
|
||||
*/
|
||||
export function getWebTypeFromComponentId(componentId: string): string {
|
||||
const entry = Object.entries(WEB_TYPE_COMPONENT_MAPPING).find(([_, id]) => id === componentId);
|
||||
return entry ? entry[0] : "text";
|
||||
}
|
||||
|
||||
/**
|
||||
* 지원되는 모든 웹타입 목록 조회
|
||||
*/
|
||||
export function getSupportedWebTypes(): string[] {
|
||||
return Object.keys(WEB_TYPE_COMPONENT_MAPPING);
|
||||
}
|
||||
|
||||
/**
|
||||
* 지원되는 모든 컴포넌트 ID 목록 조회
|
||||
*/
|
||||
export function getSupportedComponentIds(): string[] {
|
||||
return [...new Set(Object.values(WEB_TYPE_COMPONENT_MAPPING))];
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 정보 조회
|
||||
*/
|
||||
export function getWebTypeMappings(): WebTypeMapping[] {
|
||||
return Object.entries(WEB_TYPE_COMPONENT_MAPPING).map(([webType, componentId]) => ({
|
||||
webType,
|
||||
componentId,
|
||||
description: `${webType} → ${componentId}`,
|
||||
}));
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
|
|
@ -34,6 +34,7 @@
|
|||
"axios": "^1.11.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.525.0",
|
||||
"next": "15.4.4",
|
||||
|
|
@ -884,9 +885,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.30",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz",
|
||||
"integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==",
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -1113,9 +1114,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@prisma/config": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.15.0.tgz",
|
||||
"integrity": "sha512-KMEoec9b2u6zX0EbSEx/dRpx1oNLjqJEBZYyK0S3TTIbZ7GEGoVyGyFRk4C72+A38cuPLbfQGQvgOD+gBErKlA==",
|
||||
"version": "6.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.16.1.tgz",
|
||||
"integrity": "sha512-sz3uxRPNL62QrJ0EYiujCFkIGZ3hg+9hgC1Ae1HjoYuj0BxCqHua4JNijYvYCrh9LlofZDZcRBX3tHBfLvAngA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
|
|
@ -1126,53 +1127,53 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@prisma/debug": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.15.0.tgz",
|
||||
"integrity": "sha512-y7cSeLuQmyt+A3hstAs6tsuAiVXSnw9T55ra77z0nbNkA8Lcq9rNcQg6PI00by/+WnE/aMRJ/W7sZWn2cgIy1g==",
|
||||
"version": "6.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.16.1.tgz",
|
||||
"integrity": "sha512-RWv/VisW5vJE4cDRTuAHeVedtGoItXTnhuLHsSlJ9202QKz60uiXWywBlVcqXVq8bFeIZoCoWH+R1duZJPwqLw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/engines": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.15.0.tgz",
|
||||
"integrity": "sha512-opITiR5ddFJ1N2iqa7mkRlohCZqVSsHhRcc29QXeldMljOf4FSellLT0J5goVb64EzRTKcIDeIsJBgmilNcKxA==",
|
||||
"version": "6.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.16.1.tgz",
|
||||
"integrity": "sha512-EOnEM5HlosPudBqbI+jipmaW/vQEaF0bKBo4gVkGabasINHR6RpC6h44fKZEqx4GD8CvH+einD2+b49DQrwrAg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.15.0",
|
||||
"@prisma/engines-version": "6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb",
|
||||
"@prisma/fetch-engine": "6.15.0",
|
||||
"@prisma/get-platform": "6.15.0"
|
||||
"@prisma/debug": "6.16.1",
|
||||
"@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
|
||||
"@prisma/fetch-engine": "6.16.1",
|
||||
"@prisma/get-platform": "6.16.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/engines-version": {
|
||||
"version": "6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb.tgz",
|
||||
"integrity": "sha512-a/46aK5j6L3ePwilZYEgYDPrhBQ/n4gYjLxT5YncUTJJNRnTCVjPF86QdzUOLRdYjCLfhtZp9aum90W0J+trrg==",
|
||||
"version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43.tgz",
|
||||
"integrity": "sha512-ThvlDaKIVrnrv97ujNFDYiQbeMQpLa0O86HFA2mNoip4mtFqM7U5GSz2ie1i2xByZtvPztJlNRgPsXGeM/kqAA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/fetch-engine": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.15.0.tgz",
|
||||
"integrity": "sha512-xcT5f6b+OWBq6vTUnRCc7qL+Im570CtwvgSj+0MTSGA1o9UDSKZ/WANvwtiRXdbYWECpyC3CukoG3A04VTAPHw==",
|
||||
"version": "6.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.16.1.tgz",
|
||||
"integrity": "sha512-fl/PKQ8da5YTayw86WD3O9OmKJEM43gD3vANy2hS5S1CnfW2oPXk+Q03+gUWqcKK306QqhjjIHRFuTZ31WaosQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.15.0",
|
||||
"@prisma/engines-version": "6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb",
|
||||
"@prisma/get-platform": "6.15.0"
|
||||
"@prisma/debug": "6.16.1",
|
||||
"@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
|
||||
"@prisma/get-platform": "6.16.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/get-platform": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.15.0.tgz",
|
||||
"integrity": "sha512-Jbb+Xbxyp05NSR1x2epabetHiXvpO8tdN2YNoWoA/ZsbYyxxu/CO/ROBauIFuMXs3Ti+W7N7SJtWsHGaWte9Rg==",
|
||||
"version": "6.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.16.1.tgz",
|
||||
"integrity": "sha512-kUfg4vagBG7dnaGRcGd1c0ytQFcDj2SUABiuveIpL3bthFdTLI6PJeLEia6Q8Dgh+WhPdo0N2q0Fzjk63XTyaA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.15.0"
|
||||
"@prisma/debug": "6.16.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/number": {
|
||||
|
|
@ -2511,9 +2512,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.87.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.87.1.tgz",
|
||||
"integrity": "sha512-HOFHVvhOCprrWvtccSzc7+RNqpnLlZ5R6lTmngb8aq7b4rc2/jDT0w+vLdQ4lD9bNtQ+/A4GsFXy030Gk4ollA==",
|
||||
"version": "5.87.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.87.4.tgz",
|
||||
"integrity": "sha512-uNsg6zMxraEPDVO2Bn+F3/ctHi+Zsk+MMpcN8h6P7ozqD088F6mFY5TfGM7zuyIrL7HKpDyu6QHfLWiDxh3cuw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
|
|
@ -2521,9 +2522,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-devtools": {
|
||||
"version": "5.86.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.86.0.tgz",
|
||||
"integrity": "sha512-/JDw9BP80eambEK/EsDMGAcsL2VFT+8F5KCOwierjPU7QP8Wt1GT32yJpn3qOinBM8/zS3Jy36+F0GiyJp411A==",
|
||||
"version": "5.87.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.87.3.tgz",
|
||||
"integrity": "sha512-LkzxzSr2HS1ALHTgDmJH5eGAVsSQiuwz//VhFW5OqNk0OQ+Fsqba0Tsf+NzWRtXYvpgUqwQr4b2zdFZwxHcGvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
|
|
@ -2532,12 +2533,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.87.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.87.1.tgz",
|
||||
"integrity": "sha512-YKauf8jfMowgAqcxj96AHs+Ux3m3bWT1oSVKamaRPXSnW2HqSznnTCEkAVqctF1e/W9R/mPcyzzINIgpOH94qg==",
|
||||
"version": "5.87.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.87.4.tgz",
|
||||
"integrity": "sha512-T5GT/1ZaNsUXf5I3RhcYuT17I4CPlbZgyLxc/ZGv7ciS6esytlbjb3DgUFO6c8JWYMDpdjSWInyGZUErgzqhcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.87.1"
|
||||
"@tanstack/query-core": "5.87.4"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
|
|
@ -2548,20 +2549,20 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query-devtools": {
|
||||
"version": "5.87.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.87.1.tgz",
|
||||
"integrity": "sha512-YPuEub8RQrrsXOxoiMJn33VcGPIeuVINWBgLu9RLSQB8ueXaKlGLZ3NJkahGpbt2AbWf749FQ6R+1jBFk3kdCA==",
|
||||
"version": "5.87.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.87.4.tgz",
|
||||
"integrity": "sha512-JYcnVJBBW1DCPyNGM0S2CyrLpe6KFiL2gpYd/k9tAp62Du7+Y27zkzd+dKFyxpFadYaTxsx4kUA7YvnkMLVUoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-devtools": "5.86.0"
|
||||
"@tanstack/query-devtools": "5.87.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tanstack/react-query": "^5.87.1",
|
||||
"@tanstack/react-query": "^5.87.4",
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
|
|
@ -2599,9 +2600,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz",
|
||||
"integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==",
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
|
|
@ -2669,17 +2670,17 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.42.0.tgz",
|
||||
"integrity": "sha512-Aq2dPqsQkxHOLfb2OPv43RnIvfj05nw8v/6n3B2NABIPpHnjQnaLo9QGMTvml+tv4korl/Cjfrb/BYhoL8UUTQ==",
|
||||
"version": "8.43.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.43.0.tgz",
|
||||
"integrity": "sha512-8tg+gt7ENL7KewsKMKDHXR1vm8tt9eMxjJBYINf6swonlWgkYn5NwyIgXpbbDxTNU5DgpDFfj95prcTq2clIQQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.10.0",
|
||||
"@typescript-eslint/scope-manager": "8.42.0",
|
||||
"@typescript-eslint/type-utils": "8.42.0",
|
||||
"@typescript-eslint/utils": "8.42.0",
|
||||
"@typescript-eslint/visitor-keys": "8.42.0",
|
||||
"@typescript-eslint/scope-manager": "8.43.0",
|
||||
"@typescript-eslint/type-utils": "8.43.0",
|
||||
"@typescript-eslint/utils": "8.43.0",
|
||||
"@typescript-eslint/visitor-keys": "8.43.0",
|
||||
"graphemer": "^1.4.0",
|
||||
"ignore": "^7.0.0",
|
||||
"natural-compare": "^1.4.0",
|
||||
|
|
@ -2693,7 +2694,7 @@
|
|||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.42.0",
|
||||
"@typescript-eslint/parser": "^8.43.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
|
|
@ -2709,16 +2710,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.42.0.tgz",
|
||||
"integrity": "sha512-r1XG74QgShUgXph1BYseJ+KZd17bKQib/yF3SR+demvytiRXrwd12Blnz5eYGm8tXaeRdd4x88MlfwldHoudGg==",
|
||||
"version": "8.43.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.43.0.tgz",
|
||||
"integrity": "sha512-B7RIQiTsCBBmY+yW4+ILd6mF5h1FUwJsVvpqkrgpszYifetQ2Ke+Z4u6aZh0CblkUGIdR59iYVyXqqZGkZ3aBw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.42.0",
|
||||
"@typescript-eslint/types": "8.42.0",
|
||||
"@typescript-eslint/typescript-estree": "8.42.0",
|
||||
"@typescript-eslint/visitor-keys": "8.42.0",
|
||||
"@typescript-eslint/scope-manager": "8.43.0",
|
||||
"@typescript-eslint/types": "8.43.0",
|
||||
"@typescript-eslint/typescript-estree": "8.43.0",
|
||||
"@typescript-eslint/visitor-keys": "8.43.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -2734,14 +2735,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.42.0.tgz",
|
||||
"integrity": "sha512-vfVpLHAhbPjilrabtOSNcUDmBboQNrJUiNAGoImkZKnMjs2TIcWG33s4Ds0wY3/50aZmTMqJa6PiwkwezaAklg==",
|
||||
"version": "8.43.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.43.0.tgz",
|
||||
"integrity": "sha512-htB/+D/BIGoNTQYffZw4uM4NzzuolCoaA/BusuSIcC8YjmBYQioew5VUZAYdAETPjeed0hqCaW7EHg+Robq8uw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.42.0",
|
||||
"@typescript-eslint/types": "^8.42.0",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.43.0",
|
||||
"@typescript-eslint/types": "^8.43.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -2756,14 +2757,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.42.0.tgz",
|
||||
"integrity": "sha512-51+x9o78NBAVgQzOPd17DkNTnIzJ8T/O2dmMBLoK9qbY0Gm52XJcdJcCl18ExBMiHo6jPMErUQWUv5RLE51zJw==",
|
||||
"version": "8.43.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.43.0.tgz",
|
||||
"integrity": "sha512-daSWlQ87ZhsjrbMLvpuuMAt3y4ba57AuvadcR7f3nl8eS3BjRc8L9VLxFLk92RL5xdXOg6IQ+qKjjqNEimGuAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.42.0",
|
||||
"@typescript-eslint/visitor-keys": "8.42.0"
|
||||
"@typescript-eslint/types": "8.43.0",
|
||||
"@typescript-eslint/visitor-keys": "8.43.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
|
|
@ -2774,9 +2775,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.42.0.tgz",
|
||||
"integrity": "sha512-kHeFUOdwAJfUmYKjR3CLgZSglGHjbNTi1H8sTYRYV2xX6eNz4RyJ2LIgsDLKf8Yi0/GL1WZAC/DgZBeBft8QAQ==",
|
||||
"version": "8.43.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.43.0.tgz",
|
||||
"integrity": "sha512-ALC2prjZcj2YqqL5X/bwWQmHA2em6/94GcbB/KKu5SX3EBDOsqztmmX1kMkvAJHzxk7TazKzJfFiEIagNV3qEA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -2791,15 +2792,15 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.42.0.tgz",
|
||||
"integrity": "sha512-9KChw92sbPTYVFw3JLRH1ockhyR3zqqn9lQXol3/YbI6jVxzWoGcT3AsAW0mu1MY0gYtsXnUGV/AKpkAj5tVlQ==",
|
||||
"version": "8.43.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.43.0.tgz",
|
||||
"integrity": "sha512-qaH1uLBpBuBBuRf8c1mLJ6swOfzCXryhKND04Igr4pckzSEW9JX5Aw9AgW00kwfjWJF0kk0ps9ExKTfvXfw4Qg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.42.0",
|
||||
"@typescript-eslint/typescript-estree": "8.42.0",
|
||||
"@typescript-eslint/utils": "8.42.0",
|
||||
"@typescript-eslint/types": "8.43.0",
|
||||
"@typescript-eslint/typescript-estree": "8.43.0",
|
||||
"@typescript-eslint/utils": "8.43.0",
|
||||
"debug": "^4.3.4",
|
||||
"ts-api-utils": "^2.1.0"
|
||||
},
|
||||
|
|
@ -2816,9 +2817,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.42.0.tgz",
|
||||
"integrity": "sha512-LdtAWMiFmbRLNP7JNeY0SqEtJvGMYSzfiWBSmx+VSZ1CH+1zyl8Mmw1TT39OrtsRvIYShjJWzTDMPWZJCpwBlw==",
|
||||
"version": "8.43.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.43.0.tgz",
|
||||
"integrity": "sha512-vQ2FZaxJpydjSZJKiSW/LJsabFFvV7KgLC5DiLhkBcykhQj8iK9BOaDmQt74nnKdLvceM5xmhaTF+pLekrxEkw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -2830,16 +2831,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.42.0.tgz",
|
||||
"integrity": "sha512-ku/uYtT4QXY8sl9EDJETD27o3Ewdi72hcXg1ah/kkUgBvAYHLwj2ofswFFNXS+FL5G+AGkxBtvGt8pFBHKlHsQ==",
|
||||
"version": "8.43.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.43.0.tgz",
|
||||
"integrity": "sha512-7Vv6zlAhPb+cvEpP06WXXy/ZByph9iL6BQRBDj4kmBsW98AqEeQHlj/13X+sZOrKSo9/rNKH4Ul4f6EICREFdw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.42.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.42.0",
|
||||
"@typescript-eslint/types": "8.42.0",
|
||||
"@typescript-eslint/visitor-keys": "8.42.0",
|
||||
"@typescript-eslint/project-service": "8.43.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.43.0",
|
||||
"@typescript-eslint/types": "8.43.0",
|
||||
"@typescript-eslint/visitor-keys": "8.43.0",
|
||||
"debug": "^4.3.4",
|
||||
"fast-glob": "^3.3.2",
|
||||
"is-glob": "^4.0.3",
|
||||
|
|
@ -2915,16 +2916,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.42.0.tgz",
|
||||
"integrity": "sha512-JnIzu7H3RH5BrKC4NoZqRfmjqCIS1u3hGZltDYJgkVdqAezl4L9d1ZLw+36huCujtSBSAirGINF/S4UxOcR+/g==",
|
||||
"version": "8.43.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.43.0.tgz",
|
||||
"integrity": "sha512-S1/tEmkUeeswxd0GGcnwuVQPFWo8NzZTOMxCvw8BX7OMxnNae+i8Tm7REQen/SwUIPoPqfKn7EaZ+YLpiB3k9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.7.0",
|
||||
"@typescript-eslint/scope-manager": "8.42.0",
|
||||
"@typescript-eslint/types": "8.42.0",
|
||||
"@typescript-eslint/typescript-estree": "8.42.0"
|
||||
"@typescript-eslint/scope-manager": "8.43.0",
|
||||
"@typescript-eslint/types": "8.43.0",
|
||||
"@typescript-eslint/typescript-estree": "8.43.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
|
|
@ -2939,13 +2940,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.42.0.tgz",
|
||||
"integrity": "sha512-3WbiuzoEowaEn8RSnhJBrxSwX8ULYE9CXaPepS2C2W3NSA5NNIvBaslpBSBElPq0UGr0xVJlXFWOAKIkyylydQ==",
|
||||
"version": "8.43.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.43.0.tgz",
|
||||
"integrity": "sha512-T+S1KqRD4sg/bHfLwrpF/K3gQLBM1n7Rp7OjjikjTEssI2YJzQpi5WXoynOaQ93ERIuq3O8RBTOUYDKszUCEHw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.42.0",
|
||||
"@typescript-eslint/types": "8.43.0",
|
||||
"eslint-visitor-keys": "^4.2.1"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -3520,9 +3521,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
|
||||
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz",
|
||||
"integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
|
|
@ -3759,6 +3760,22 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cmdk": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
|
||||
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-id": "^1.1.0",
|
||||
"@radix-ui/react-primitive": "^2.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/color": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||
|
|
@ -6199,9 +6216,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.18",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.18.tgz",
|
||||
"integrity": "sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==",
|
||||
"version": "0.30.19",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
|
||||
"integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -6928,15 +6945,15 @@
|
|||
}
|
||||
},
|
||||
"node_modules/prisma": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.15.0.tgz",
|
||||
"integrity": "sha512-E6RCgOt+kUVtjtZgLQDBJ6md2tDItLJNExwI0XJeBc1FKL+Vwb+ovxXxuok9r8oBgsOXBA33fGDuE/0qDdCWqQ==",
|
||||
"version": "6.16.1",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.1.tgz",
|
||||
"integrity": "sha512-MFkMU0eaDDKAT4R/By2IA9oQmwLTxokqv2wegAErr9Rf+oIe7W2sYpE/Uxq0H2DliIR7vnV63PkC1bEwUtl98w==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/config": "6.15.0",
|
||||
"@prisma/engines": "6.15.0"
|
||||
"@prisma/config": "6.16.1",
|
||||
"@prisma/engines": "6.16.1"
|
||||
},
|
||||
"bin": {
|
||||
"prisma": "build/index.js"
|
||||
|
|
@ -8350,9 +8367,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.5.tgz",
|
||||
"integrity": "sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz",
|
||||
"integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
|
|
@ -40,6 +40,7 @@
|
|||
"axios": "^1.11.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.525.0",
|
||||
"next": "15.4.4",
|
||||
|
|
|
|||
|
|
@ -52,12 +52,15 @@ export interface ComponentRendererProps {
|
|||
component: any; // ComponentData from screen.ts
|
||||
isDesignMode?: boolean;
|
||||
isSelected?: boolean;
|
||||
isInteractive?: boolean;
|
||||
onClick?: () => void;
|
||||
onDragStart?: (e: React.DragEvent) => void;
|
||||
onDragEnd?: (e: React.DragEvent) => void;
|
||||
onUpdate?: (updates: Partial<any>) => void;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
formData?: Record<string, any>;
|
||||
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,9 +43,10 @@ export type ButtonActionType =
|
|||
| "reset" // 초기화
|
||||
| "submit" // 제출
|
||||
| "close" // 닫기
|
||||
| "popup" // 모달 열기
|
||||
| "navigate" // 페이지 이동
|
||||
| "custom"; // 사용자 정의
|
||||
| "popup" // 팝업 열기
|
||||
| "modal" // 모달 열기
|
||||
| "newWindow" // 새 창 열기
|
||||
| "navigate"; // 페이지 이동
|
||||
|
||||
// 위치 정보
|
||||
export interface Position {
|
||||
|
|
|
|||
Loading…
Reference in New Issue