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

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

@ -147,16 +147,51 @@ export default function ScreenViewPage() {
);
}
// 라벨 표시 여부 계산
const templateTypes = ["datatable"];
const shouldShowLabel =
component.style?.labelDisplay !== false &&
(component.label || component.style?.labelText) &&
!templateTypes.includes(component.type);
const labelText = component.style?.labelText || component.label || "";
const labelStyle = {
fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151",
fontWeight: component.style?.labelFontWeight || "500",
backgroundColor: component.style?.labelBackgroundColor || "transparent",
padding: component.style?.labelPadding || "0",
borderRadius: component.style?.labelBorderRadius || "0",
marginBottom: component.style?.labelMarginBottom || "4px",
};
// 일반 컴포넌트 렌더링
return (
<div key={component.id}>
{/* 라벨을 외부에 별도로 렌더링 */}
{shouldShowLabel && (
<div
style={{
position: "absolute",
left: `${component.position.x}px`,
top: `${component.position.y - 25}px`, // 컴포넌트 위쪽에 라벨 배치
zIndex: (component.position.z || 1) + 1,
...labelStyle,
}}
>
{labelText}
{component.required && <span style={{ color: "#f97316", marginLeft: "2px" }}>*</span>}
</div>
)}
{/* 실제 컴포넌트 */}
<div
key={component.id}
style={{
position: "absolute",
left: `${component.position.x}px`,
top: `${component.position.y}px`,
width: `${component.size.width}px`,
height: `${component.size.height}px`,
width: component.style?.width || `${component.size.width}px`,
height: component.style?.height || `${component.size.height}px`,
zIndex: component.position.z || 1,
}}
>
@ -170,8 +205,10 @@ export default function ScreenViewPage() {
[fieldName]: value,
}));
}}
hideLabel={true} // 라벨 숨김 플래그 전달
/>
</div>
</div>
);
})}
</div>

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,6 +990,15 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
}}
>
{type === "container" && (
<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" />
@ -993,6 +1009,8 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
</div>
</div>
)}
</div>
)}
{false &&
(() => {

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,7 +1069,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
};
// 컬럼 위젯 생성
// 폼 컨테이너에 드롭한 경우
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",
@ -1039,12 +1087,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
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, // 기본 그리드 컬럼 수
readonly: false,
parentId: formContainerId, // 폼 컨테이너의 자식으로 설정
position: { x: relativeX, y: relativeY, z: 1 } as Position,
size: { width: 200, height: 40 },
style: {
labelDisplay: true,
labelFontSize: "12px",
@ -1054,6 +1101,33 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
},
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,8 +2347,18 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
return (
<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,
}}
>
<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)
@ -2283,11 +2367,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
onDragStart={(e) => startComponentDrag(component, e)}
onDragEnd={endDrag}
>
{children.map((child) => {
{/* 컨테이너 및 그룹의 자식 컴포넌트들 렌더링 */}
{(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);
dragState.isDragging &&
dragState.draggedComponents.some((dragComp) => dragComp.id === child.id);
let displayChild = child;
@ -2333,8 +2422,18 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
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
key={`${child.id}-${child.type === "widget" ? JSON.stringify((child as any).webTypeConfig) : ""}`}
component={displayChild}
isSelected={
selectedComponent?.id === child.id || groupState.selectedComponents.includes(child.id)
@ -2343,9 +2442,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
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 {

View File

@ -0,0 +1,801 @@
# 화면관리 시스템 설계문서
## 1. 개요
### 1.1 목적
ERP 시스템에서 사용자가 직관적인 드래그앤드롭 인터페이스를 통해 동적으로 화면을 설계하고 관리할 수 있는 시스템
### 1.2 주요 기능
- 드래그앤드롭 기반 화면 설계
- 실시간 미리보기 및 속성 편집
- 다양한 위젯 타입 지원
- 데이터베이스 테이블/컬럼과의 연동
- 템플릿 기반 빠른 화면 생성
- 스타일 및 레이아웃 커스터마이징
## 2. 시스템 아키텍처
### 2.1 전체 구조
```
화면 편집기 (ScreenDesigner)
├── 템플릿 패널 (TemplatesPanel)
├── 테이블 패널 (TablesPanel)
├── 속성 편집 패널 (PropertiesPanel)
├── 스타일 편집 패널 (StyleEditor)
├── 상세설정 패널 (DetailSettingsPanel)
├── 격자 설정 패널 (GridPanel)
└── 캔버스 영역 (RealtimePreview)
할당된 화면 (InteractiveScreenViewer)
├── 라벨 렌더링
├── 위젯 렌더링
└── 폼 데이터 관리
```
### 2.2 데이터 흐름
```
사용자 입력 → 컴포넌트 상태 → 레이아웃 데이터 → API 저장/불러오기 → 할당된 화면 렌더링
```
## 3. 컴포넌트 구조
### 3.1 컴포넌트 타입
```typescript
type ComponentType = "container" | "widget" | "group" | "datatable";
interface BaseComponent {
id: string;
type: ComponentType;
position: { x: number; y: number; z?: number };
size: { width: number; height: number };
parentId?: string;
label?: string;
required?: boolean;
readonly?: boolean;
style?: ComponentStyle;
}
interface WidgetComponent extends BaseComponent {
type: "widget";
widgetType: WebType;
placeholder?: string;
columnName?: string;
webTypeConfig?: WebTypeConfig;
}
```
### 3.2 지원하는 웹 타입
- **텍스트 입력**: text, email, tel
- **숫자 입력**: number, decimal
- **날짜/시간**: date, datetime
- **선택**: select, dropdown, radio
- **체크박스**: checkbox, boolean
- **텍스트 영역**: textarea
- **파일**: file
- **코드**: code
- **엔티티**: entity
- **버튼**: button
## 4. 주요 기능 상세
### 4.1 드래그앤드롭 시스템
#### 템플릿 드래그
- 사전 정의된 템플릿을 캔버스에 드롭
- 컨테이너와 자식 컴포넌트 관계 자동 설정
- 격자 스냅 및 자동 크기 조정
#### 컬럼 드래그
- 데이터베이스 테이블의 컬럼을 위젯으로 변환
- 컬럼 타입에 따른 자동 웹타입 매핑
- 폼 컨테이너에 드롭 시 자동 부모-자식 관계 설정
#### 다중 컴포넌트 드래그
- Ctrl/Cmd + 클릭으로 다중 선택
- 선택된 모든 컴포넌트 동시 이동
- 실시간 미리보기 제공
### 4.2 속성 편집 시스템
#### 실시간 속성 편집 패턴
```typescript
// 로컬 상태 기반 즉시 반영
const [localInputs, setLocalInputs] = useState({
title: component.title || "",
placeholder: component.placeholder || "",
});
// 입력과 동시에 업데이트
<Input
value={localInputs.title}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs(prev => ({ ...prev, title: newValue }));
onUpdateProperty("title", newValue);
}}
/>
```
#### 컴포넌트별 개별 상태 관리
```typescript
// 동적 ID 기반 상태 관리
const [localColumnInputs, setLocalColumnInputs] = useState<Record<string, string>>({});
// 컴포넌트 변경 시 기존 값 보존하면서 새 항목만 추가
useEffect(() => {
setLocalColumnInputs((prev) => {
const newInputs = { ...prev };
component.columns?.forEach((col) => {
if (!(col.id in newInputs)) {
newInputs[col.id] = col.label;
}
});
return newInputs;
});
}, [component.columns]);
```
### 4.3 스타일 시스템
#### 스타일 적용 계층
1. **컴포넌트 기본 스타일**: `component.style`
2. **웹타입 설정 스타일**: `webTypeConfig`에서 정의
3. **라벨 스타일**: 별도 관리 (`labelColor`, `labelFontSize` 등)
#### 스타일 패널 구성
- **여백**: margin, padding, gap
- **테두리**: borderWidth, borderStyle, borderColor, borderRadius
- **배경**: backgroundColor, backgroundImage
- **텍스트**: color, fontSize, fontWeight, textAlign
### 4.4 템플릿 시스템
#### 데이터 테이블 템플릿
```typescript
{
id: "data-table",
name: "데이터 테이블",
category: "table",
components: [
{
id: "table-container",
type: "datatable",
searchFilters: [],
columns: [
{ id: "col1", label: "컬럼 1", visible: true, sortable: true },
{ id: "col2", label: "컬럼 2", visible: true, sortable: false }
],
pagination: { enabled: true, pageSize: 10 },
actions: {
create: { enabled: true, label: "추가" },
edit: { enabled: true, label: "수정" },
delete: { enabled: true, label: "삭제" }
}
}
]
}
```
#### 입력 폼 템플릿
```typescript
{
id: "input-form",
name: "입력 폼",
category: "form",
components: [
{
id: "form-container",
type: "container",
style: { backgroundColor: "#f8f9fa", borderRadius: "8px" },
children: [
{
id: "save-button",
type: "widget",
widgetType: "button",
parentId: "form-container",
position: { x: 0, y: 0 },
style: { position: "absolute", bottom: "24px", right: "104px" }
},
{
id: "cancel-button",
type: "widget",
widgetType: "button",
parentId: "form-container",
position: { x: 0, y: 0 },
style: { position: "absolute", bottom: "24px", right: "24px" }
}
]
}
]
}
```
#### 범용 버튼 템플릿
```typescript
{
id: "universal-button",
name: "버튼",
category: "button",
components: [
{
id: "button",
type: "widget",
widgetType: "button",
webTypeConfig: {
actionType: "save",
variant: "default",
size: "sm"
}
}
]
}
```
### 4.5 웹타입별 상세 설정
#### 버튼 설정 (ButtonConfigPanel)
```typescript
interface ButtonTypeConfig {
actionType: ButtonActionType;
variant: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
size: "default" | "sm" | "lg" | "icon";
icon?: string;
confirmMessage?: string;
popupTitle?: string;
popupContent?: string;
popupSize?: "sm" | "md" | "lg";
navigateUrl?: string;
navigateTarget?: "_self" | "_blank";
customAction?: string;
backgroundColor?: string;
textColor?: string;
borderColor?: string;
}
type ButtonActionType =
| "save"
| "cancel"
| "delete"
| "edit"
| "add"
| "search"
| "reset"
| "submit"
| "close"
| "popup"
| "navigate"
| "custom";
```
#### 텍스트 설정 (TextTypeConfig)
```typescript
interface TextTypeConfig {
format: "none" | "korean" | "english" | "alphanumeric" | "numeric" | "email" | "phone" | "url";
minLength?: number;
maxLength?: number;
pattern?: string;
placeholder?: string;
multiline?: boolean;
}
```
#### 숫자 설정 (NumberTypeConfig)
```typescript
interface NumberTypeConfig {
min?: number;
max?: number;
step?: number;
format?: "integer" | "decimal" | "currency" | "percentage";
decimalPlaces?: number;
thousandSeparator?: boolean;
}
```
#### 날짜 설정 (DateTypeConfig)
```typescript
interface DateTypeConfig {
format: "YYYY-MM-DD" | "YYYY-MM-DD HH:mm" | "YYYY-MM-DD HH:mm:ss";
showTime: boolean;
minDate?: string;
maxDate?: string;
defaultValue?: string;
}
```
#### 선택박스 설정 (SelectTypeConfig)
```typescript
interface SelectTypeConfig {
options: Array<{ label: string; value: string }>;
multiple?: boolean;
searchable?: boolean;
placeholder?: string;
}
```
#### 엔티티 설정 (EntityTypeConfig)
```typescript
interface EntityTypeConfig {
entityName: string;
displayField: string;
valueField: string;
filters: Array<{ field: string; operator: string; value: any }>;
multiple: boolean;
searchable: boolean;
allowClear: boolean;
placeholder?: string;
displayFormat?: string;
defaultValue?: any;
}
```
### 4.6 격자 시스템
#### 격자 설정
```typescript
interface GridSettings {
columns: number; // 격자 컬럼 수
gap: number; // 격자 간격 (px)
padding: number; // 캔버스 패딩 (px)
snapToGrid: boolean; // 격자 스냅 활성화
showGrid: boolean; // 격자 표시 여부
gridColor: string; // 격자 선 색상
gridOpacity: number; // 격자 투명도 (0-1)
}
```
#### 격자 스냅 로직
```typescript
const snapToGrid = (value: number, gridSize: number): number => {
return Math.round(value / gridSize) * gridSize;
};
const snapSizeToGrid = (size: number, gridSize: number): number => {
return Math.max(gridSize, Math.round(size / gridSize) * gridSize);
};
```
## 5. 렌더링 시스템
### 5.1 편집기 렌더링 (ScreenDesigner)
#### 컴포넌트 위치 계산
```typescript
// 절대 위치 래퍼 div
<div
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,
}}
>
<RealtimePreview
component={displayComponent}
position={{ x: 0, y: 0 }} // 래퍼 div 내에서는 상대 위치
/>
</div>
```
#### 자식 컴포넌트 상대 위치
```typescript
// 자식 컴포넌트는 부모 기준 상대 위치로 계산
const relativePosition = {
x: child.position.x - parent.position.x,
y: child.position.y - parent.position.y,
};
```
### 5.2 할당된 화면 렌더링 (InteractiveScreenViewer)
#### 라벨 외부 분리 렌더링
```typescript
// 라벨을 컴포넌트 외부에 별도 렌더링 (높이에 영향 없음)
{shouldShowLabel && (
<div
style={{
position: "absolute",
left: `${component.position.x}px`,
top: `${component.position.y - 25}px`, // 컴포넌트 위쪽에 배치
zIndex: (component.position.z || 1) + 1,
...labelStyle,
}}
>
{labelText}
{component.required && <span style={{ color: "#f97316" }}>*</span>}
</div>
)}
// 실제 컴포넌트 (라벨 높이에 영향받지 않음)
<div style={{ height: component.style?.height || `${component.size.height}px` }}>
<InteractiveScreenViewer component={component} hideLabel={true} />
</div>
```
#### 스타일 적용 시스템
```typescript
const applyStyles = (element: React.ReactElement) => {
if (!comp.style) return element;
return React.cloneElement(element, {
style: {
...element.props.style, // 기존 스타일 유지
...comp.style, // 컴포넌트 스타일 적용
width: "100%", // 부모 컨테이너에 맞춤
height: "100%",
minHeight: "100%", // 강제 높이 적용
maxHeight: "100%",
boxSizing: "border-box",
},
});
};
```
#### 위젯별 렌더링
```typescript
switch (widgetType) {
case "text":
case "email":
case "tel":
return applyStyles(
<Input
type={inputType}
placeholder={finalPlaceholder}
value={currentValue}
onChange={handleInputChange}
className="w-full"
style={{ height: "100%" }}
/>
);
case "button":
const config = widget.webTypeConfig as ButtonTypeConfig;
return (
<Button
onClick={handleButtonClick}
size={config?.size || "sm"}
variant={config?.variant || "default"}
className="w-full"
style={{
...comp.style,
height: "100%",
backgroundColor: config?.backgroundColor,
color: config?.textColor,
borderColor: config?.borderColor,
}}
>
{label || "버튼"}
</Button>
);
case "entity":
return (
<Select>
<SelectTrigger
className="w-full"
style={{
...comp.style,
height: "100%",
}}
>
<SelectValue placeholder={finalPlaceholder} />
</SelectTrigger>
<SelectContent>
{options.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
```
## 6. 상태 관리
### 6.1 레이아웃 상태
```typescript
interface LayoutData {
components: ComponentData[];
gridSettings: GridSettings;
}
const [layout, setLayout] = useState<LayoutData>({
components: [],
gridSettings: defaultGridSettings,
});
```
### 6.2 선택 상태
```typescript
const [selectedComponent, setSelectedComponent] = useState<ComponentData | null>(null);
const [selectedComponents, setSelectedComponents] = useState<ComponentData[]>([]);
```
### 6.3 드래그 상태
```typescript
interface DragState {
isDragging: boolean;
draggedComponents: ComponentData[];
startPosition: { x: number; y: number };
currentPosition: { x: number; y: number };
}
```
### 6.4 패널 상태
```typescript
interface PanelState {
isOpen: boolean;
position: { x: number; y: number };
size: { width: number; height: number };
}
const [panelStates, setPanelStates] = useState<Record<string, PanelState>>({
templates: { isOpen: true, position: { x: 0, y: 0 }, size: { width: 300, height: 400 } },
properties: { isOpen: false, position: { x: 0, y: 0 }, size: { width: 360, height: 600 } },
styles: { isOpen: false, position: { x: 0, y: 0 }, size: { width: 360, height: 400 } },
// ...
});
```
## 7. API 연동
### 7.1 화면 정보 API
```typescript
// 화면 목록 조회
GET /api/screens
Response: {
screens: Array<{
id: number;
name: string;
description: string;
createdAt: string;
updatedAt: string;
}>
}
// 화면 상세 조회
GET /api/screens/:id
Response: {
id: number;
name: string;
description: string;
layout: LayoutData;
}
// 화면 저장
POST /api/screens/:id/layout
Request: {
layout: LayoutData
}
```
### 7.2 테이블 정보 API
```typescript
// 테이블 목록 조회
GET / api / tables;
Response: {
tables: Array<{
id: string;
name: string;
description: string;
columns: Array<{
id: string;
name: string;
type: string;
nullable: boolean;
primaryKey: boolean;
}>;
}>;
}
```
## 8. 성능 최적화
### 8.1 렌더링 최적화
- `useCallback`으로 이벤트 핸들러 메모이제이션
- `useMemo`로 계산 비용이 큰 값 캐싱
- 컴포넌트 분할을 통한 불필요한 리렌더링 방지
### 8.2 상태 최적화
- 로컬 상태 기반 즉시 반영으로 UI 응답성 향상
- 디바운싱을 통한 과도한 API 호출 방지
- 컴포넌트별 개별 상태 관리로 전역 상태 오염 방지
## 9. 개발 가이드
### 9.1 새로운 웹타입 추가
1. **타입 정의 추가**
```typescript
// types/screen.ts
type WebType = "text" | "number" | "date" | "새로운타입";
interface 새로운타입TypeConfig {
// 설정 속성들
}
type WebTypeConfig = TextTypeConfig | NumberTypeConfig | 새로운타입TypeConfig;
```
2. **설정 패널 생성**
```typescript
// panels/webtype-configs/새로운타입TypeConfigPanel.tsx
export const 새로운타입ConfigPanel: React.FC<Props> = ({ component, onUpdateComponent }) => {
// 설정 UI 구현
};
```
3. **렌더링 로직 추가**
```typescript
// RealtimePreview.tsx, InteractiveScreenViewer.tsx
case "새로운타입":
return renderNewWidget(component);
```
4. **DetailSettingsPanel에 연결**
```typescript
case "새로운타입":
return <새로운타입ConfigPanel component={widget} onUpdateComponent={handleUpdate} />;
```
### 9.2 새로운 템플릿 추가
1. **TemplatesPanel에 템플릿 정의 추가**
```typescript
const templates: TemplateComponent[] = [
{
id: "새로운-템플릿",
name: "새로운 템플릿",
category: "카테고리",
icon: <IconComponent />,
defaultSize: { width: 400, height: 300 },
components: [
// 템플릿 구성 컴포넌트들
]
}
];
```
2. **필요한 경우 특별한 렌더링 로직 추가**
### 9.3 코딩 컨벤션
#### 실시간 속성 편집 패턴 (필수)
```typescript
// 1. 로컬 상태 정의
const [localInputs, setLocalInputs] = useState({
title: component.title || "",
});
// 2. 컴포넌트 변경 시 동기화
useEffect(() => {
setLocalInputs({
title: component.title || "",
});
}, [component.title]);
// 3. 실시간 입력 처리
<Input
value={localInputs.title}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs(prev => ({ ...prev, title: newValue }));
onUpdateProperty("title", newValue);
}}
/>
```
## 10. 테스트 전략
### 10.1 단위 테스트
- 유틸리티 함수 (격자 계산, 스타일 적용 등)
- 컴포넌트 상태 관리 로직
- 데이터 변환 함수
### 10.2 통합 테스트
- 드래그앤드롭 시나리오
- 속성 편집 플로우
- API 연동 테스트
### 10.3 E2E 테스트
- 화면 생성부터 렌더링까지 전체 플로우
- 복잡한 사용자 시나리오
## 11. 향후 개선 계획
### 11.1 단기 계획
- 웹타입별 상세 설정 완성 (Date, Number, Select, Radio, File, Code, Entity)
- 조건부 표시 기능 (특정 조건에 따른 컴포넌트 표시/숨김)
- 계산 필드 기능 (다른 필드 값을 기반으로 한 자동 계산)
### 11.2 중장기 계획
- 컴포넌트 간 데이터 바인딩
- 워크플로우 연동
- 다국어 지원
- 반응형 디자인
- 컴포넌트 라이브러리 확장
## 12. 트러블슈팅
### 12.1 일반적인 문제
#### 높이가 적용되지 않는 문제
- **원인**: Tailwind CSS의 `h-full` 클래스가 인라인 스타일을 무시
- **해결**: `className="w-full"` + `style={{ height: "100%" }}` 사용
#### 라벨이 컴포넌트 높이에 포함되는 문제
- **원인**: 라벨과 위젯이 같은 컨테이너 내에 위치
- **해결**: 라벨을 외부에 별도 렌더링하여 높이에서 제외
#### 스타일이 할당된 화면에서 적용되지 않는 문제
- **원인**: `applyStyles` 함수 미사용 또는 잘못된 스타일 병합
- **해결**: 모든 위젯에서 일관된 스타일 적용 로직 사용
#### 다중 드래그 시 성능 문제
- **원인**: 과도한 리렌더링
- **해결**: `useCallback`, `useMemo` 적극 활용
### 12.2 디버깅 팁
- 브라우저 개발자 도구의 React DevTools 활용
- 콘솔 로그를 통한 상태 추적
- 컴포넌트 트리 구조 시각화
---
_본 문서는 지속적으로 업데이트되며, 새로운 기능 추가 시 해당 섹션을 업데이트해야 합니다._

View File

@ -21,7 +21,23 @@ export type WebType =
| "dropdown"
| "text_area"
| "boolean"
| "decimal";
| "decimal"
| "button";
// 버튼 기능 타입 정의
export type ButtonActionType =
| "save" // 저장
| "cancel" // 취소
| "delete" // 삭제
| "edit" // 수정
| "add" // 추가
| "search" // 검색
| "reset" // 초기화
| "submit" // 제출
| "close" // 닫기
| "popup" // 팝업 열기
| "navigate" // 페이지 이동
| "custom"; // 사용자 정의
// 위치 정보
export interface Position {
@ -602,6 +618,32 @@ export interface EntityTypeConfig {
filters?: Record<string, any>; // 추가 필터 조건
}
// 버튼 타입 설정
export interface ButtonTypeConfig {
actionType: ButtonActionType;
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
size?: "default" | "sm" | "lg" | "icon";
icon?: string; // Lucide 아이콘 이름
confirmMessage?: string; // 확인 메시지 (delete, submit 등에서 사용)
// 팝업 관련 설정
popupTitle?: string;
popupContent?: string;
popupSize?: "sm" | "md" | "lg" | "xl";
// 네비게이션 관련 설정
navigateUrl?: string;
navigateTarget?: "_self" | "_blank";
// 커스텀 액션 설정
customAction?: string; // JavaScript 코드 또는 함수명
// 스타일 설정
backgroundColor?: string;
textColor?: string;
borderColor?: string;
}
// 웹타입별 설정 유니온 타입
export type WebTypeConfig =
| DateTypeConfig
@ -613,4 +655,5 @@ export type WebTypeConfig =
| CheckboxTypeConfig
| RadioTypeConfig
| CodeTypeConfig
| EntityTypeConfig;
| EntityTypeConfig
| ButtonTypeConfig;