스타일 적용안되던 문제 수정
This commit is contained in:
parent
bc23f64b42
commit
feb26fa32a
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
(() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> = ({
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 활용
|
||||
- 콘솔 로그를 통한 상태 추적
|
||||
- 컴포넌트 트리 구조 시각화
|
||||
|
||||
---
|
||||
|
||||
_본 문서는 지속적으로 업데이트되며, 새로운 기능 추가 시 해당 섹션을 업데이트해야 합니다._
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue