스타일 적용안되던 문제 수정

This commit is contained in:
kjs
2025-09-04 11:33:52 +09:00
parent bc23f64b42
commit feb26fa32a
11 changed files with 1767 additions and 248 deletions

View File

@@ -25,6 +25,7 @@ import {
FileTypeConfig,
CodeTypeConfig,
EntityTypeConfig,
ButtonTypeConfig,
} from "@/types/screen";
import { InteractiveDataTable } from "./InteractiveDataTable";
@@ -33,6 +34,7 @@ interface InteractiveScreenViewerProps {
allComponents: ComponentData[];
formData?: Record<string, any>;
onFormDataChange?: (fieldName: string, value: any) => void;
hideLabel?: boolean;
}
export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = ({
@@ -40,6 +42,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
allComponents,
formData: externalFormData,
onFormDataChange,
hideLabel = false,
}) => {
const [localFormData, setLocalFormData] = useState<Record<string, any>>({});
const [dateValues, setDateValues] = useState<Record<string, Date | undefined>>({});
@@ -96,10 +99,14 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
return React.cloneElement(element, {
style: {
...element.props.style, // 기존 스타일 유지
...comp.style,
// 크기는 부모 컨테이너에서 처리하므로 제거
width: undefined,
height: undefined,
// 크기는 부모 컨테이너에서 처리하므로 제거 (하지만 다른 스타일은 유지)
width: "100%",
height: "100%",
minHeight: "100%",
maxHeight: "100%",
boxSizing: "border-box",
},
});
};
@@ -183,7 +190,12 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
minLength={config?.minLength}
maxLength={config?.maxLength}
pattern={getPatternByFormat(config?.format || "none")}
className="h-full w-full"
className="w-full"
style={{
height: "100%",
minHeight: "100%",
maxHeight: "100%"
}}
/>,
);
}
@@ -223,7 +235,8 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
min={config?.min}
max={config?.max}
step={step}
className="h-full w-full"
className="w-full"
style={{ height: "100%" }}
/>,
);
}
@@ -454,7 +467,8 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
required={required}
min={config?.minDate}
max={config?.maxDate}
className="h-full w-full"
className="w-full"
style={{ height: "100%" }}
/>,
);
} else {
@@ -513,7 +527,8 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
required={required}
min={config?.minDate}
max={config?.maxDate}
className="h-full w-full"
className="w-full"
style={{ height: "100%" }}
/>,
);
}
@@ -561,7 +576,8 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
required={required}
multiple={config?.multiple}
accept={config?.accept}
className="h-full w-full"
className="w-full"
style={{ height: "100%" }}
/>,
);
}
@@ -632,14 +648,22 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
{ label: "카테고리", value: "category" },
];
return applyStyles(
return (
<Select
value={currentValue || config?.defaultValue || ""}
onValueChange={(value) => updateFormData(fieldName, value)}
disabled={readonly}
required={required}
>
<SelectTrigger className="h-full w-full">
<SelectTrigger
className="w-full"
style={{ height: "100%" }}
style={{
...comp.style,
width: "100%",
height: "100%",
}}
>
<SelectValue placeholder={finalPlaceholder} />
</SelectTrigger>
<SelectContent>
@@ -655,6 +679,60 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
);
}
case "button": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as ButtonTypeConfig | undefined;
const handleButtonClick = () => {
if (config?.actionType === "popup" && config.popupTitle) {
alert(`${config.popupTitle}\n\n${config.popupContent || "팝업 내용이 없습니다."}`);
} else if (config?.actionType === "navigate" && config.navigateUrl) {
if (config.navigateTarget === "_blank") {
window.open(config.navigateUrl, "_blank");
} else {
window.location.href = config.navigateUrl;
}
} else if (config?.actionType === "custom" && config.customAction) {
try {
// 간단한 JavaScript 실행 (보안상 제한적)
eval(config.customAction);
} catch (error) {
console.error("커스텀 액션 실행 오류:", error);
}
} else if (config?.actionType === "delete" && config.confirmMessage) {
if (confirm(config.confirmMessage)) {
console.log("삭제 확인됨");
}
} else {
console.log(`버튼 클릭: ${config?.actionType || "기본"} 액션`);
}
};
return (
<Button
onClick={handleButtonClick}
disabled={readonly}
size={config?.size || "sm"}
variant={config?.variant || "default"}
className="w-full"
style={{ height: "100%" }}
style={{
// 컴포넌트 스타일과 설정 스타일 모두 적용
...comp.style,
// 크기는 className으로 처리하므로 CSS 크기 속성 제거
width: "100%",
height: "100%",
// 설정값이 있으면 우선 적용, 없으면 컴포넌트 스타일 사용
backgroundColor: config?.backgroundColor || comp.style?.backgroundColor,
color: config?.textColor || comp.style?.color,
borderColor: config?.borderColor || comp.style?.borderColor,
}}
>
{label || "버튼"}
</Button>
);
}
default:
return applyStyles(
<Input
@@ -664,7 +742,8 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
onChange={(e) => updateFormData(fieldName, e.target.value)}
disabled={readonly}
required={required}
className="h-full w-full"
className="w-full"
style={{ height: "100%" }}
/>,
);
}
@@ -707,6 +786,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
// 라벨 표시 여부 계산
const shouldShowLabel =
!hideLabel && // hideLabel이 true면 라벨 숨김
component.style?.labelDisplay !== false &&
(component.label || component.style?.labelText) &&
!templateTypes.includes(component.type); // 템플릿 컴포넌트는 라벨 표시 안함
@@ -735,7 +815,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
)}
{/* 실제 위젯 */}
<div className={shouldShowLabel ? "flex-1" : "h-full w-full"}>{renderInteractiveWidget(component)}</div>
<div className="h-full w-full">{renderInteractiveWidget(component)}</div>
</div>
);
};

View File

@@ -72,7 +72,7 @@ const renderWidget = (component: ComponentData) => {
const borderClass = hasCustomBorder ? "!border-0" : "";
const commonProps = {
placeholder: placeholder || `입력하세요...`,
placeholder: placeholder || "입력하세요...",
disabled: readonly,
required: required,
className: `w-full h-full ${borderClass}`,
@@ -621,6 +621,21 @@ const renderWidget = (component: ComponentData) => {
);
}
case "button":
return (
<Button
disabled={readonly}
size="sm"
variant={style?.backgroundColor === "transparent" ? "outline" : "default"}
className="gap-1 text-xs"
style={{
...style, // 모든 스타일 속성 적용
}}
>
{label}
</Button>
);
default:
return <Input type="text" {...commonProps} />;
}
@@ -723,13 +738,8 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
return (
<div
className="absolute cursor-move"
className="h-full w-full cursor-move"
style={{
left: `${component.position.x}px`,
top: `${component.position.y}px`,
width: `${size.width}px`,
height: shouldShowLabel ? `${size.height + 20 + labelMarginBottomValue}px` : `${size.height}px`,
zIndex: component.position.z || 1,
...(isSelected ? { boxShadow: "0 0 0 2px rgba(59, 130, 246, 0.5)" } : {}),
}}
onClick={(e) => {
@@ -940,13 +950,10 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
// 다른 컴포넌트들은 기존 구조 사용
return (
<div
className={`absolute cursor-move transition-all ${defaultRingClass}`}
className={`cursor-move transition-all ${defaultRingClass}`}
style={{
left: `${component.position.x}px`,
top: `${component.position.y}px`,
width: `${size.width}px`,
height: shouldShowLabel ? `${size.height + 20 + labelMarginBottomValue}px` : `${size.height}px`, // 라벨 공간 + 여백 추가
zIndex: component.position.z || 1,
height: `${size.height}px`, // 순수 컴포넌트 높이만 사용
...selectionStyle,
}}
onClick={(e) => {
@@ -961,7 +968,7 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
e.stopPropagation();
}}
>
{/* 라벨 표시 */}
{/* 라벨 표시 - 원래대로 컴포넌트 위쪽에 표시 */}
{shouldShowLabel && (
<div
className="pointer-events-none absolute left-0 w-full truncate"
@@ -983,14 +990,25 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
}}
>
{type === "container" && (
<div className="pointer-events-none flex h-full flex-col items-center justify-center p-2">
<div className="flex flex-col items-center space-y-1">
<Database className="h-6 w-6 text-blue-600" />
<div className="text-center">
<div className="text-xs font-medium">{label}</div>
<div className="text-xs text-gray-500">{tableName}</div>
<div
className="relative h-full w-full"
data-form-container={component.title === "새 데이터 입력" ? "true" : "false"}
data-component-id={component.id}
>
{/* 컨테이너 자식 컴포넌트들 렌더링 */}
{children && React.Children.count(children) > 0 ? (
<div className="absolute inset-0">{children}</div>
) : (
<div className="pointer-events-none flex h-full flex-col items-center justify-center p-2">
<div className="flex flex-col items-center space-y-1">
<Database className="h-6 w-6 text-blue-600" />
<div className="text-center">
<div className="text-xs font-medium">{label}</div>
<div className="text-xs text-gray-500">{tableName}</div>
</div>
</div>
</div>
</div>
)}
</div>
)}

View File

@@ -633,7 +633,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 격자 스냅 적용
const snappedPosition =
layout.gridSettings?.snapToGrid && gridInfo
? snapToGrid({ x: dropX, y: dropY, z: 1 }, gridInfo, layout.gridSettings)
? snapToGrid({ x: dropX, y: dropY, z: 1 }, gridInfo, layout.gridSettings as GridUtilSettings)
: { x: dropX, y: dropY, z: 1 };
console.log("🎨 템플릿 드롭:", {
@@ -644,8 +644,19 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
});
// 템플릿의 모든 컴포넌트들을 생성
// 먼저 ID 매핑을 생성 (parentId 참조를 위해)
const idMapping: Record<string, string> = {};
template.components.forEach((templateComp, index) => {
const newId = generateComponentId();
if (index === 0) {
// 첫 번째 컴포넌트(컨테이너)는 "form-container"로 매핑
idMapping["form-container"] = newId;
}
idMapping[templateComp.parentId || `temp_${index}`] = newId;
});
const newComponents: ComponentData[] = template.components.map((templateComp, index) => {
const componentId = generateComponentId();
const componentId = index === 0 ? idMapping["form-container"] : generateComponentId();
// 템플릿 컴포넌트의 상대 위치를 드롭 위치 기준으로 조정
const absoluteX = snappedPosition.x + templateComp.position.x;
@@ -654,17 +665,38 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 격자 스냅 적용
const finalPosition =
layout.gridSettings?.snapToGrid && gridInfo
? snapToGrid({ x: absoluteX, y: absoluteY, z: 1 }, gridInfo, layout.gridSettings)
? snapToGrid({ x: absoluteX, y: absoluteY, z: 1 }, gridInfo, layout.gridSettings as GridUtilSettings)
: { x: absoluteX, y: absoluteY, z: 1 };
if (templateComp.type === "container") {
// 그리드 컬럼 기반 크기 계산
const gridColumns =
typeof templateComp.size.width === "number" && templateComp.size.width <= 12 ? templateComp.size.width : 4; // 기본 4컬럼
const calculatedSize =
gridInfo && layout.gridSettings?.snapToGrid
? (() => {
const newWidth = calculateWidthFromColumns(
gridColumns,
gridInfo,
layout.gridSettings as GridUtilSettings,
);
return {
width: newWidth,
height: templateComp.size.height,
};
})()
: { width: 400, height: templateComp.size.height }; // 폴백 크기
return {
id: componentId,
type: "container",
label: templateComp.label,
tableName: selectedScreen?.tableName || "",
title: templateComp.title || templateComp.label,
position: finalPosition,
size: templateComp.size,
size: calculatedSize,
gridColumns,
style: {
labelDisplay: true,
labelFontSize: "14px",
@@ -817,6 +849,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
label: templateComp.label,
placeholder: templateComp.placeholder,
columnName: `field_${index + 1}`,
parentId: templateComp.parentId ? idMapping[templateComp.parentId] : undefined,
position: finalPosition,
size: templateComp.size,
required: templateComp.required || false,
@@ -878,6 +911,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 기존 테이블/컬럼 드래그 처리
const { type, table, column } = parsedData;
// 드롭 대상이 폼 컨테이너인지 확인
const dropTarget = e.target as HTMLElement;
const formContainer = dropTarget.closest('[data-form-container="true"]');
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
@@ -1031,29 +1069,65 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
};
// 컬럼 위젯 생성
newComponent = {
id: generateComponentId(),
type: "widget",
label: column.columnName,
tableName: table.tableName,
columnName: column.columnName,
widgetType: column.widgetType,
// dataType: column.dataType, // WidgetComponent에 dataType 속성이 없음
required: column.required,
readonly: false, // 누락된 속성 추가
position: { x, y, z: 1 } as Position,
size: { width: columnWidth, height: 40 },
gridColumns: 1, // 기본 그리드 컬럼 수
style: {
labelDisplay: true,
labelFontSize: "12px",
labelColor: "#374151",
labelFontWeight: "500",
labelMarginBottom: "6px",
},
webTypeConfig: getDefaultWebTypeConfig(column.widgetType),
};
// 폼 컨테이너에 드롭한 경우
if (formContainer) {
const formContainerId = formContainer.getAttribute("data-component-id");
const formContainerComponent = layout.components.find((c) => c.id === formContainerId);
if (formContainerComponent) {
// 폼 내부에서의 상대적 위치 계산
const containerRect = formContainer.getBoundingClientRect();
const relativeX = e.clientX - containerRect.left;
const relativeY = e.clientY - containerRect.top;
newComponent = {
id: generateComponentId(),
type: "widget",
label: 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: 200, height: 40 },
style: {
labelDisplay: true,
labelFontSize: "12px",
labelColor: "#374151",
labelFontWeight: "500",
labelMarginBottom: "6px",
},
webTypeConfig: getDefaultWebTypeConfig(column.widgetType),
};
} else {
return; // 폼 컨테이너를 찾을 수 없으면 드롭 취소
}
} else {
// 일반 캔버스에 드롭한 경우 (기존 로직)
newComponent = {
id: generateComponentId(),
type: "widget",
label: column.columnName,
tableName: table.tableName,
columnName: column.columnName,
widgetType: column.widgetType,
required: column.required,
readonly: false,
position: { x, y, z: 1 } as Position,
size: { width: columnWidth, height: 40 },
gridColumns: 1,
style: {
labelDisplay: true,
labelFontSize: "12px",
labelColor: "#374151",
labelFontWeight: "500",
labelMarginBottom: "6px",
},
webTypeConfig: getDefaultWebTypeConfig(column.widgetType),
};
}
} else {
return;
}
@@ -2273,79 +2347,106 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
return (
<RealtimePreview
key={`${component.id}-${component.type === "widget" ? JSON.stringify((component as any).webTypeConfig) : ""}`}
component={displayComponent}
isSelected={
selectedComponent?.id === component.id || groupState.selectedComponents.includes(component.id)
}
onClick={(e) => handleComponentClick(component, e)}
onDragStart={(e) => startComponentDrag(component, e)}
onDragEnd={endDrag}
<div
key={component.id}
className="absolute"
style={{
left: `${displayComponent.position.x}px`,
top: `${displayComponent.position.y}px`,
width: displayComponent.style?.width || `${displayComponent.size.width}px`,
height: displayComponent.style?.height || `${displayComponent.size.height}px`,
zIndex: displayComponent.position.z || 1,
}}
>
{children.map((child) => {
// 자식 컴포넌트에도 드래그 피드백 적용
const isChildDraggingThis = dragState.isDragging && dragState.draggedComponent?.id === child.id;
const isChildBeingDragged =
dragState.isDragging && dragState.draggedComponents.some((dragComp) => dragComp.id === child.id);
let displayChild = child;
if (isChildBeingDragged) {
if (isChildDraggingThis) {
// 주 드래그 자식 컴포넌트
displayChild = {
...child,
position: dragState.currentPosition,
style: {
...child.style,
opacity: 0.8,
transform: "scale(1.02)",
transition: "none",
zIndex: 9999,
},
};
} else {
// 다른 선택된 자식 컴포넌트들
const originalChildComponent = dragState.draggedComponents.find(
(dragComp) => dragComp.id === child.id,
);
if (originalChildComponent) {
const deltaX = dragState.currentPosition.x - dragState.originalPosition.x;
const deltaY = dragState.currentPosition.y - dragState.originalPosition.y;
displayChild = {
...child,
position: {
x: originalChildComponent.position.x + deltaX,
y: originalChildComponent.position.y + deltaY,
z: originalChildComponent.position.z || 1,
} as Position,
style: {
...child.style,
opacity: 0.8,
transition: "none",
zIndex: 8888,
},
};
}
}
<RealtimePreview
component={displayComponent}
isSelected={
selectedComponent?.id === component.id || groupState.selectedComponents.includes(component.id)
}
onClick={(e) => handleComponentClick(component, e)}
onDragStart={(e) => startComponentDrag(component, e)}
onDragEnd={endDrag}
>
{/* 컨테이너 및 그룹의 자식 컴포넌트들 렌더링 */}
{(component.type === "group" || component.type === "container") &&
layout.components
.filter((child) => child.parentId === component.id)
.map((child) => {
// 자식 컴포넌트에도 드래그 피드백 적용
const isChildDraggingThis = dragState.isDragging && dragState.draggedComponent?.id === child.id;
const isChildBeingDragged =
dragState.isDragging &&
dragState.draggedComponents.some((dragComp) => dragComp.id === child.id);
return (
<RealtimePreview
key={`${child.id}-${child.type === "widget" ? JSON.stringify((child as any).webTypeConfig) : ""}`}
component={displayChild}
isSelected={
selectedComponent?.id === child.id || groupState.selectedComponents.includes(child.id)
}
onClick={(e) => handleComponentClick(child, e)}
onDragStart={(e) => startComponentDrag(child, e)}
onDragEnd={endDrag}
/>
);
})}
</RealtimePreview>
let displayChild = child;
if (isChildBeingDragged) {
if (isChildDraggingThis) {
// 주 드래그 자식 컴포넌트
displayChild = {
...child,
position: dragState.currentPosition,
style: {
...child.style,
opacity: 0.8,
transform: "scale(1.02)",
transition: "none",
zIndex: 9999,
},
};
} else {
// 다른 선택된 자식 컴포넌트들
const originalChildComponent = dragState.draggedComponents.find(
(dragComp) => dragComp.id === child.id,
);
if (originalChildComponent) {
const deltaX = dragState.currentPosition.x - dragState.originalPosition.x;
const deltaY = dragState.currentPosition.y - dragState.originalPosition.y;
displayChild = {
...child,
position: {
x: originalChildComponent.position.x + deltaX,
y: originalChildComponent.position.y + deltaY,
z: originalChildComponent.position.z || 1,
} as Position,
style: {
...child.style,
opacity: 0.8,
transition: "none",
zIndex: 8888,
},
};
}
}
}
return (
<div
key={child.id}
className="absolute"
style={{
left: `${displayChild.position.x - component.position.x}px`,
top: `${displayChild.position.y - component.position.y}px`,
width: `${displayChild.size.width}px`,
height: `${displayChild.size.height}px`, // 순수 컴포넌트 높이만 사용
zIndex: displayChild.position.z || 1,
}}
>
<RealtimePreview
component={displayChild}
isSelected={
selectedComponent?.id === child.id || groupState.selectedComponents.includes(child.id)
}
onClick={(e) => handleComponentClick(child, e)}
onDragStart={(e) => startComponentDrag(child, e)}
onDragEnd={endDrag}
/>
</div>
);
})}
</RealtimePreview>
</div>
);
})}
@@ -2475,7 +2576,40 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
<div className="p-4">
<StyleEditor
style={selectedComponent.style || {}}
onStyleChange={(newStyle) => updateComponentProperty(selectedComponent.id, "style", newStyle)}
onStyleChange={(newStyle) => {
console.log("🔧 StyleEditor 크기 변경:", {
componentId: selectedComponent.id,
newStyle,
currentSize: selectedComponent.size,
hasWidth: !!newStyle.width,
hasHeight: !!newStyle.height,
});
// 스타일 업데이트
updateComponentProperty(selectedComponent.id, "style", newStyle);
// 크기가 변경된 경우 component.size도 업데이트
if (newStyle.width || newStyle.height) {
const width = newStyle.width
? parseInt(newStyle.width.replace("px", ""))
: selectedComponent.size.width;
const height = newStyle.height
? parseInt(newStyle.height.replace("px", ""))
: selectedComponent.size.height;
console.log("📏 크기 업데이트:", {
originalWidth: selectedComponent.size.width,
originalHeight: selectedComponent.size.height,
newWidth: width,
newHeight: height,
styleWidth: newStyle.width,
styleHeight: newStyle.height,
});
updateComponentProperty(selectedComponent.id, "size.width", width);
updateComponentProperty(selectedComponent.id, "size.height", height);
}
}}
/>
</div>
) : (

View File

@@ -8,7 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Separator } from "@/components/ui/separator";
import { Palette, Layout, Type, Square, Box, Eye, RotateCcw } from "lucide-react";
import { Palette, Type, Square, Box, Eye, RotateCcw } from "lucide-react";
import { ComponentStyle } from "@/types/screen";
interface StyleEditorProps {
@@ -62,12 +62,8 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
</div>
</CardHeader>
<CardContent>
<Tabs defaultValue="layout" className="w-full">
<TabsList className="grid w-full grid-cols-5">
<TabsTrigger value="layout">
<Layout className="mr-1 h-3 w-3" />
</TabsTrigger>
<Tabs defaultValue="spacing" className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="spacing">
<Box className="mr-1 h-3 w-3" />
@@ -86,93 +82,6 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
</TabsTrigger>
</TabsList>
{/* 레이아웃 탭 */}
<TabsContent value="layout" className="space-y-4">
{/* 너비/높이는 위젯 속성에서만 관리하도록 제거 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="display"> </Label>
<Select
value={localStyle.display || "block"}
onValueChange={(value) => handleStyleChange("display", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="block">Block</SelectItem>
<SelectItem value="inline">Inline</SelectItem>
<SelectItem value="inline-block">Inline-Block</SelectItem>
<SelectItem value="flex">Flex</SelectItem>
<SelectItem value="grid">Grid</SelectItem>
<SelectItem value="none">None</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="position"></Label>
<Select
value={localStyle.position || "static"}
onValueChange={(value) => handleStyleChange("position", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="static">Static</SelectItem>
<SelectItem value="relative">Relative</SelectItem>
<SelectItem value="absolute">Absolute</SelectItem>
<SelectItem value="fixed">Fixed</SelectItem>
<SelectItem value="sticky">Sticky</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{localStyle.display === "flex" && (
<>
<Separator />
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="flexDirection"></Label>
<Select
value={localStyle.flexDirection || "row"}
onValueChange={(value) => handleStyleChange("flexDirection", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="row"> (Row)</SelectItem>
<SelectItem value="row-reverse"> </SelectItem>
<SelectItem value="column"> (Column)</SelectItem>
<SelectItem value="column-reverse"> </SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="justifyContent"> </Label>
<Select
value={localStyle.justifyContent || "flex-start"}
onValueChange={(value) => handleStyleChange("justifyContent", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="flex-start"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="flex-end"></SelectItem>
<SelectItem value="space-between"></SelectItem>
<SelectItem value="space-around"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</>
)}
</TabsContent>
{/* 여백 탭 */}
<TabsContent value="spacing" className="space-y-4">
<div className="grid grid-cols-2 gap-4">

View File

@@ -0,0 +1,437 @@
"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 } from "@/types/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: "cancel", label: "취소", icon: <X className="h-4 w-4" />, color: "#6b7280" },
{ value: "delete", label: "삭제", icon: <Trash2 className="h-4 w-4" />, color: "#ef4444" },
{ value: "edit", label: "수정", icon: <Edit className="h-4 w-4" />, color: "#f59e0b" },
{ value: "add", label: "추가", icon: <Plus className="h-4 w-4" />, color: "#10b981" },
{ 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>({
actionType: "custom",
variant: "default",
size: "sm",
...config,
});
// 컴포넌트 변경 시 로컬 상태 동기화
useEffect(() => {
const newConfig = (component.webTypeConfig as ButtonTypeConfig) || {};
setLocalConfig({
actionType: "custom",
variant: "default",
size: "sm",
...newConfig,
});
}, [component.webTypeConfig]);
// 설정 업데이트 함수
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 "cancel":
case "close":
updates.variant = "outline";
updates.backgroundColor = "transparent";
updates.textColor = "#6b7280";
onUpdateComponent({
label: actionType === "cancel" ? "취소" : "닫기",
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.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;
}
updateConfig(updates);
};
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 className="space-y-1">
<Label className="text-xs"></Label>
<Select value={localConfig.size} onValueChange={(value) => updateConfig({ size: value as any })}>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"></SelectItem>
<SelectItem value="default"></SelectItem>
<SelectItem value="lg"></SelectItem>
<SelectItem value="icon"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 아이콘 설정 */}
<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>
<Input
value={localConfig.popupTitle || ""}
onChange={(e) => updateConfig({ popupTitle: e.target.value })}
placeholder="상세 정보"
className="h-8 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={localConfig.popupSize}
onValueChange={(value) => updateConfig({ popupSize: value as any })}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"></SelectItem>
<SelectItem value="md"></SelectItem>
<SelectItem value="lg"></SelectItem>
<SelectItem value="xl"> </SelectItem>
</SelectContent>
</Select>
</div>
<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"
/>
</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"> 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.navigateTarget}
onValueChange={(value) => updateConfig({ navigateTarget: value as any })}
>
<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

@@ -16,6 +16,7 @@ import {
FileTypeConfig,
CodeTypeConfig,
EntityTypeConfig,
ButtonTypeConfig,
} from "@/types/screen";
import { DateTypeConfigPanel } from "./webtype-configs/DateTypeConfigPanel";
import { NumberTypeConfigPanel } from "./webtype-configs/NumberTypeConfigPanel";
@@ -27,6 +28,7 @@ import { RadioTypeConfigPanel } from "./webtype-configs/RadioTypeConfigPanel";
import { FileTypeConfigPanel } from "./webtype-configs/FileTypeConfigPanel";
import { CodeTypeConfigPanel } from "./webtype-configs/CodeTypeConfigPanel";
import { EntityTypeConfigPanel } from "./webtype-configs/EntityTypeConfigPanel";
import { ButtonConfigPanel } from "./ButtonConfigPanel";
interface DetailSettingsPanelProps {
selectedComponent?: ComponentData;
@@ -162,6 +164,19 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({ select
/>
);
case "button":
return (
<ButtonConfigPanel
key={`${widget.id}-button`}
component={widget}
onUpdateComponent={(updates) => {
Object.entries(updates).forEach(([key, value]) => {
onUpdateProperty(widget.id, key, value);
});
}}
/>
);
default:
return <div className="text-sm text-gray-500 italic"> .</div>;
}

View File

@@ -41,6 +41,7 @@ const webTypeOptions: { value: WebType; label: string }[] = [
{ value: "code", label: "코드" },
{ value: "entity", label: "엔티티" },
{ value: "file", label: "파일" },
{ value: "button", label: "버튼" },
];
export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({

View File

@@ -5,7 +5,24 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Table, Search, FileText, Grid3x3, Info } from "lucide-react";
import {
Table,
Search,
FileText,
Grid3x3,
Info,
FormInput,
Save,
X,
Trash2,
Edit,
Plus,
RotateCcw,
Send,
ExternalLink,
MousePointer,
Settings,
} from "lucide-react";
// 템플릿 컴포넌트 타입 정의
export interface TemplateComponent {
@@ -54,6 +71,33 @@ const templateComponents: TemplateComponent[] = [
},
],
},
// 범용 버튼 템플릿
{
id: "universal-button",
name: "버튼",
description: "다양한 기능을 설정할 수 있는 범용 버튼. 상세설정에서 기능을 선택하세요.",
category: "button",
icon: <MousePointer className="h-4 w-4" />,
defaultSize: { width: 80, height: 36 },
components: [
{
type: "widget",
widgetType: "button",
label: "버튼",
position: { x: 0, y: 0 },
size: { width: 80, height: 36 },
style: {
backgroundColor: "#3b82f6",
color: "#ffffff",
border: "none",
borderRadius: "6px",
fontSize: "14px",
fontWeight: "500",
},
},
],
},
];
interface TemplatesPanelProps {