Compare commits

...

2 Commits

Author SHA1 Message Date
kjs 49e8e40521 버튼 버그 수정 2025-09-12 14:24:42 +09:00
kjs b071d8090b 버튼 기능구현 2025-09-12 14:24:25 +09:00
52 changed files with 3049 additions and 1323 deletions

View File

@ -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

View File

@ -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>

View File

@ -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;

View File

@ -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,
}}
/>
);
}

View File

@ -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}

View File

@ -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}

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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 (

View File

@ -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"

View File

@ -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,
}

View File

@ -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,
};
}
/**

View File

@ -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 (

View File

@ -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 };

View File

@ -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>
</>
);
};

View File

@ -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>

View File

@ -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";

View File

@ -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;

View File

@ -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>
);

View File

@ -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}

View File

@ -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;

View File

@ -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>

View File

@ -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;

View File

@ -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";

View File

@ -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} />;
};

View File

@ -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>
);
};

View File

@ -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();
}

View File

@ -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)

View File

@ -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"
},
};

View File

@ -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";

View File

@ -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;
}

View File

@ -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}

View File

@ -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>

View File

@ -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}

View File

@ -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}

View File

@ -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)

View File

@ -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} />;
};

View File

@ -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>
);
};

View File

@ -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에서 자동으로 처리됨

View File

@ -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";

View File

@ -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;
}

View File

@ -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);
}
}}
/>

View File

@ -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";

View File

@ -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}

View File

@ -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>
);

View File

@ -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",
},
};

View File

@ -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} />;
};

View File

@ -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}`,
}));
}

View File

@ -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"

View File

@ -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",

View File

@ -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;
}

View File

@ -43,9 +43,10 @@ export type ButtonActionType =
| "reset" // 초기화
| "submit" // 제출
| "close" // 닫기
| "popup" // 모달 열기
| "navigate" // 페이지 이동
| "custom"; // 사용자 정의
| "popup" // 팝업 열기
| "modal" // 모달 열기
| "newWindow" // 새 창 열기
| "navigate"; // 페이지 이동
// 위치 정보
export interface Position {