카드 레이아웃 구현

This commit is contained in:
kjs 2025-09-11 12:22:39 +09:00
parent 083f053851
commit 4da06b2a56
21 changed files with 2762 additions and 347 deletions

View File

@ -0,0 +1,4 @@
회사 코드: COMPANY_2
생성일: 2025-09-11T02:07:40.033Z
폴더 구조: YYYY/MM/DD/파일명
관리자: 시스템 자동 생성

View File

@ -0,0 +1,4 @@
회사 코드: COMPANY_3
생성일: 2025-09-11T02:08:06.303Z
폴더 구조: YYYY/MM/DD/파일명
관리자: 시스템 자동 생성

View File

@ -28,6 +28,7 @@ interface RealtimePreviewProps {
onDragEnd?: () => void;
onGroupToggle?: (groupId: string) => void; // 그룹 접기/펼치기
children?: React.ReactNode; // 그룹 내 자식 컴포넌트들
selectedScreen?: any; // 선택된 화면 정보
}
// 동적 위젯 타입 아이콘 (레지스트리에서 조회)
@ -65,6 +66,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
onDragEnd,
onGroupToggle,
children,
selectedScreen,
}) => {
const { id, type, position, size, style: componentStyle } = component;
@ -120,6 +122,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
onDragStart={onDragStart}
onDragEnd={onDragEnd}
children={children}
selectedScreen={selectedScreen}
/>
</div>

View File

@ -306,9 +306,62 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
timestamp: new Date().toISOString(),
});
const targetComponent = layout.components.find((comp) => comp.id === componentId);
const isLayoutComponent = targetComponent?.type === "layout";
// 레이아웃 컴포넌트의 위치가 변경되는 경우 존에 속한 컴포넌트들도 함께 이동
let positionDelta = { x: 0, y: 0 };
if (isLayoutComponent && (path === "position.x" || path === "position.y" || path === "position")) {
const oldPosition = targetComponent.position;
let newPosition = { ...oldPosition };
if (path === "position.x") {
newPosition.x = value;
positionDelta.x = value - oldPosition.x;
} else if (path === "position.y") {
newPosition.y = value;
positionDelta.y = value - oldPosition.y;
} else if (path === "position") {
newPosition = value;
positionDelta.x = value.x - oldPosition.x;
positionDelta.y = value.y - oldPosition.y;
}
console.log("📐 레이아웃 이동 감지:", {
layoutId: componentId,
oldPosition,
newPosition,
positionDelta,
});
}
const pathParts = path.split(".");
const updatedComponents = layout.components.map((comp) => {
if (comp.id !== componentId) return comp;
if (comp.id !== componentId) {
// 레이아웃 이동 시 존에 속한 컴포넌트들도 함께 이동
if (isLayoutComponent && (positionDelta.x !== 0 || positionDelta.y !== 0)) {
// 이 레이아웃의 존에 속한 컴포넌트인지 확인
const isInLayoutZone = comp.parentId === componentId && comp.zoneId;
if (isInLayoutZone) {
console.log("🔄 존 컴포넌트 함께 이동:", {
componentId: comp.id,
zoneId: comp.zoneId,
oldPosition: comp.position,
delta: positionDelta,
});
return {
...comp,
position: {
...comp.position,
x: comp.position.x + positionDelta.x,
y: comp.position.y + positionDelta.y,
},
};
}
}
return comp;
}
const newComp = { ...comp };
let current: any = newComp;
@ -1839,10 +1892,26 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 다중 선택된 컴포넌트들 확인
const isDraggedComponentSelected = groupState.selectedComponents.includes(component.id);
const componentsToMove = isDraggedComponentSelected
let componentsToMove = isDraggedComponentSelected
? layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id))
: [component];
// 레이아웃 컴포넌트인 경우 존에 속한 컴포넌트들도 함께 이동
if (component.type === "layout") {
const zoneComponents = layout.components.filter((comp) => comp.parentId === component.id && comp.zoneId);
console.log("🏗️ 레이아웃 드래그 - 존 컴포넌트들 포함:", {
layoutId: component.id,
zoneComponentsCount: zoneComponents.length,
zoneComponents: zoneComponents.map((c) => ({ id: c.id, zoneId: c.zoneId })),
});
// 중복 제거하여 추가
const allComponentIds = new Set(componentsToMove.map((c) => c.id));
const additionalComponents = zoneComponents.filter((c) => !allComponentIds.has(c.id));
componentsToMove = [...componentsToMove, ...additionalComponents];
}
console.log("드래그 시작:", component.id, "이동할 컴포넌트 수:", componentsToMove.length);
console.log("마우스 위치:", {
clientX: event.clientX,
@ -3035,6 +3104,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
onClick={(e) => handleComponentClick(component, e)}
onDragStart={(e) => startComponentDrag(component, e)}
onDragEnd={endDrag}
selectedScreen={selectedScreen}
>
{/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 */}
{(component.type === "group" || component.type === "container" || component.type === "area") &&
@ -3111,6 +3181,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
onClick={(e) => handleComponentClick(child, e)}
onDragStart={(e) => startComponentDrag(child, e)}
onDragEnd={endDrag}
selectedScreen={selectedScreen}
/>
);
})}

View File

@ -5,7 +5,14 @@ import { Settings } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useWebTypes } from "@/hooks/admin/useWebTypes";
import { getConfigPanelComponent } from "@/lib/utils/getConfigPanelComponent";
import { ComponentData, WidgetComponent, FileComponent, WebTypeConfig, TableInfo } from "@/types/screen";
import {
ComponentData,
WidgetComponent,
FileComponent,
WebTypeConfig,
TableInfo,
LayoutComponent,
} from "@/types/screen";
import { ButtonConfigPanel } from "./ButtonConfigPanel";
import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
@ -41,6 +48,641 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
console.log(`🔍 DetailSettingsPanel selectedComponent.widgetType:`, selectedComponent?.widgetType);
const inputableWebTypes = webTypes.map((wt) => wt.web_type);
// 레이아웃 컴포넌트 설정 렌더링 함수
const renderLayoutConfig = (layoutComponent: LayoutComponent) => {
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="border-b border-gray-200 p-4">
<div className="flex items-center space-x-2">
<Settings className="h-4 w-4 text-gray-600" />
<h3 className="font-medium text-gray-900"> </h3>
</div>
<div className="mt-2 flex items-center space-x-2">
<span className="text-sm text-gray-600">:</span>
<span className="rounded bg-purple-100 px-2 py-1 text-xs font-medium text-purple-800">
{layoutComponent.layoutType}
</span>
</div>
<div className="mt-1 text-xs text-gray-500">ID: {layoutComponent.id}</div>
</div>
{/* 레이아웃 설정 영역 */}
<div className="flex-1 space-y-4 overflow-y-auto p-4">
{/* 기본 정보 */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700"> </label>
<input
type="text"
value={layoutComponent.label || ""}
onChange={(e) => onUpdateProperty(layoutComponent.id, "label", e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
placeholder="레이아웃 이름을 입력하세요"
/>
</div>
{/* 그리드 레이아웃 설정 */}
{layoutComponent.layoutType === "grid" && (
<div className="space-y-3">
<h4 className="text-sm font-medium text-gray-900"> </h4>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs font-medium text-gray-700"> </label>
<input
type="number"
min="1"
max="10"
value={layoutComponent.layoutConfig?.grid?.rows || 2}
onChange={(e) => {
const newRows = parseInt(e.target.value);
const newCols = layoutComponent.layoutConfig?.grid?.columns || 2;
// 그리드 설정 업데이트
onUpdateProperty(layoutComponent.id, "layoutConfig.grid.rows", newRows);
// 존 개수 자동 업데이트 (행 × 열)
const totalZones = newRows * newCols;
const currentZones = layoutComponent.zones || [];
if (totalZones !== currentZones.length) {
const newZones = [];
for (let row = 0; row < newRows; row++) {
for (let col = 0; col < newCols; col++) {
const zoneIndex = row * newCols + col;
newZones.push({
id: `zone${zoneIndex + 1}`,
name: `${zoneIndex + 1}`,
position: { row, column: col },
size: { width: "100%", height: "100%" },
});
}
}
onUpdateProperty(layoutComponent.id, "zones", newZones);
}
}}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-700"> </label>
<input
type="number"
min="1"
max="10"
value={layoutComponent.layoutConfig?.grid?.columns || 2}
onChange={(e) => {
const newCols = parseInt(e.target.value);
const newRows = layoutComponent.layoutConfig?.grid?.rows || 2;
// 그리드 설정 업데이트
onUpdateProperty(layoutComponent.id, "layoutConfig.grid.columns", newCols);
// 존 개수 자동 업데이트 (행 × 열)
const totalZones = newRows * newCols;
const currentZones = layoutComponent.zones || [];
if (totalZones !== currentZones.length) {
const newZones = [];
for (let row = 0; row < newRows; row++) {
for (let col = 0; col < newCols; col++) {
const zoneIndex = row * newCols + col;
newZones.push({
id: `zone${zoneIndex + 1}`,
name: `${zoneIndex + 1}`,
position: { row, column: col },
size: { width: "100%", height: "100%" },
});
}
}
onUpdateProperty(layoutComponent.id, "zones", newZones);
}
}}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
/>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-700"> (px)</label>
<input
type="number"
min="0"
max="50"
value={layoutComponent.layoutConfig?.grid?.gap || 16}
onChange={(e) =>
onUpdateProperty(layoutComponent.id, "layoutConfig.grid.gap", parseInt(e.target.value))
}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
/>
</div>
</div>
)}
{/* 플렉스박스 레이아웃 설정 */}
{layoutComponent.layoutType === "flexbox" && (
<div className="space-y-3">
<h4 className="text-sm font-medium text-gray-900"> </h4>
<div>
<label className="mb-1 block text-xs font-medium text-gray-700"></label>
<select
value={layoutComponent.layoutConfig?.flexbox?.direction || "row"}
onChange={(e) => {
const newDirection = e.target.value;
console.log("🔄 플렉스박스 방향 변경:", newDirection);
// 방향 설정 업데이트
onUpdateProperty(layoutComponent.id, "layoutConfig.flexbox.direction", newDirection);
// 방향 변경 시 존 크기 자동 조정
const currentZones = layoutComponent.zones || [];
const zoneCount = currentZones.length;
if (zoneCount > 0) {
const updatedZones = currentZones.map((zone, index) => ({
...zone,
size: {
...zone.size,
width: newDirection === "row" ? `${100 / zoneCount}%` : "100%",
height: newDirection === "column" ? `${100 / zoneCount}%` : "auto",
},
}));
console.log("🔄 존 크기 자동 조정:", {
direction: newDirection,
zoneCount,
updatedZones: updatedZones.map((z) => ({ id: z.id, size: z.size })),
});
onUpdateProperty(layoutComponent.id, "zones", updatedZones);
}
}}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
>
<option value="row"> (row)</option>
<option value="column"> (column)</option>
<option value="row-reverse"> </option>
<option value="column-reverse"> </option>
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-700"> </label>
<div className="flex items-center space-x-2">
<input
type="number"
min="1"
max="10"
value={layoutComponent.zones?.length || 2}
onChange={(e) => {
const newZoneCount = parseInt(e.target.value);
const currentZones = layoutComponent.zones || [];
const direction = layoutComponent.layoutConfig?.flexbox?.direction || "row";
if (newZoneCount > currentZones.length) {
// 존 추가
const newZones = [...currentZones];
for (let i = currentZones.length; i < newZoneCount; i++) {
newZones.push({
id: `zone${i + 1}`,
name: `${i + 1}`,
position: {},
size: {
width: direction === "row" ? `${100 / newZoneCount}%` : "100%",
height: direction === "column" ? `${100 / newZoneCount}%` : "100%",
},
});
}
// 기존 존들의 크기도 조정
newZones.forEach((zone, index) => {
if (direction === "row") {
zone.size.width = `${100 / newZoneCount}%`;
} else {
zone.size.height = `${100 / newZoneCount}%`;
}
});
onUpdateProperty(layoutComponent.id, "zones", newZones);
} else if (newZoneCount < currentZones.length) {
// 존 제거
const newZones = currentZones.slice(0, newZoneCount);
// 남은 존들의 크기 재조정
newZones.forEach((zone, index) => {
if (direction === "row") {
zone.size.width = `${100 / newZoneCount}%`;
} else {
zone.size.height = `${100 / newZoneCount}%`;
}
});
onUpdateProperty(layoutComponent.id, "zones", newZones);
}
}}
className="w-20 rounded border border-gray-300 px-2 py-1 text-sm"
/>
<span className="text-xs text-gray-500"></span>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-700"> (px)</label>
<input
type="number"
min="0"
max="50"
value={layoutComponent.layoutConfig?.flexbox?.gap || 16}
onChange={(e) =>
onUpdateProperty(layoutComponent.id, "layoutConfig.flexbox.gap", parseInt(e.target.value))
}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
/>
</div>
</div>
)}
{/* 분할 레이아웃 설정 */}
{layoutComponent.layoutType === "split" && (
<div className="space-y-3">
<h4 className="text-sm font-medium text-gray-900"> </h4>
<div>
<label className="mb-1 block text-xs font-medium text-gray-700"> </label>
<select
value={layoutComponent.layoutConfig?.split?.direction || "horizontal"}
onChange={(e) => onUpdateProperty(layoutComponent.id, "layoutConfig.split.direction", e.target.value)}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
>
<option value="horizontal"> </option>
<option value="vertical"> </option>
</select>
</div>
</div>
)}
{/* 카드 레이아웃 설정 */}
{layoutComponent.layoutType === "card-layout" && (
<div className="space-y-4">
<h4 className="text-sm font-medium text-gray-900"> </h4>
{/* 테이블 컬럼 매핑 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h5 className="text-xs font-medium text-gray-700"> </h5>
{currentTable && (
<span className="rounded bg-blue-50 px-2 py-1 text-xs text-blue-600">
: {currentTable.table_name}
</span>
)}
</div>
{/* 테이블이 선택되지 않은 경우 안내 */}
{!currentTable && (
<div className="rounded-lg bg-yellow-50 p-3 text-center">
<p className="text-sm text-yellow-700"> </p>
<p className="mt-1 text-xs text-yellow-600">
</p>
</div>
)}
{/* 테이블이 선택된 경우 컬럼 드롭다운 */}
{currentTable && (
<>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"> </label>
<select
value={layoutComponent.layoutConfig?.card?.columnMapping?.titleColumn || ""}
onChange={(e) =>
onUpdateProperty(
layoutComponent.id,
"layoutConfig.card.columnMapping.titleColumn",
e.target.value,
)
}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
>
<option value=""> </option>
{currentTable.columns?.map((column) => (
<option key={column.columnName} value={column.columnName}>
{column.columnLabel || column.columnName} ({column.dataType})
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"> </label>
<select
value={layoutComponent.layoutConfig?.card?.columnMapping?.subtitleColumn || ""}
onChange={(e) =>
onUpdateProperty(
layoutComponent.id,
"layoutConfig.card.columnMapping.subtitleColumn",
e.target.value,
)
}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
>
<option value=""> </option>
{currentTable.columns?.map((column) => (
<option key={column.columnName} value={column.columnName}>
{column.columnLabel || column.columnName} ({column.dataType})
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"> </label>
<select
value={layoutComponent.layoutConfig?.card?.columnMapping?.descriptionColumn || ""}
onChange={(e) =>
onUpdateProperty(
layoutComponent.id,
"layoutConfig.card.columnMapping.descriptionColumn",
e.target.value,
)
}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
>
<option value=""> </option>
{currentTable.columns?.map((column) => (
<option key={column.columnName} value={column.columnName}>
{column.columnLabel || column.columnName} ({column.dataType})
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"> </label>
<select
value={layoutComponent.layoutConfig?.card?.columnMapping?.imageColumn || ""}
onChange={(e) =>
onUpdateProperty(
layoutComponent.id,
"layoutConfig.card.columnMapping.imageColumn",
e.target.value,
)
}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
>
<option value=""> </option>
{currentTable.columns?.map((column) => (
<option key={column.columnName} value={column.columnName}>
{column.columnLabel || column.columnName} ({column.dataType})
</option>
))}
</select>
</div>
{/* 동적 표시 컬럼 추가 */}
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-xs font-medium text-gray-600"> </label>
<button
type="button"
onClick={() => {
const currentColumns =
layoutComponent.layoutConfig?.card?.columnMapping?.displayColumns || [];
const newColumns = [...currentColumns, ""];
onUpdateProperty(
layoutComponent.id,
"layoutConfig.card.columnMapping.displayColumns",
newColumns,
);
}}
className="rounded bg-blue-500 px-2 py-1 text-xs text-white hover:bg-blue-600"
>
+
</button>
</div>
<div className="space-y-2">
{(layoutComponent.layoutConfig?.card?.columnMapping?.displayColumns || []).map(
(column, index) => (
<div key={index} className="flex items-center space-x-2">
<select
value={column}
onChange={(e) => {
const currentColumns = [
...(layoutComponent.layoutConfig?.card?.columnMapping?.displayColumns || []),
];
currentColumns[index] = e.target.value;
onUpdateProperty(
layoutComponent.id,
"layoutConfig.card.columnMapping.displayColumns",
currentColumns,
);
}}
className="flex-1 rounded border border-gray-300 px-2 py-1 text-sm"
>
<option value=""> </option>
{currentTable.columns?.map((col) => (
<option key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName} ({col.dataType})
</option>
))}
</select>
<button
type="button"
onClick={() => {
const currentColumns = [
...(layoutComponent.layoutConfig?.card?.columnMapping?.displayColumns || []),
];
currentColumns.splice(index, 1);
onUpdateProperty(
layoutComponent.id,
"layoutConfig.card.columnMapping.displayColumns",
currentColumns,
);
}}
className="rounded bg-red-500 px-2 py-1 text-xs text-white hover:bg-red-600"
>
</button>
</div>
),
)}
{(!layoutComponent.layoutConfig?.card?.columnMapping?.displayColumns ||
layoutComponent.layoutConfig.card.columnMapping.displayColumns.length === 0) && (
<div className="rounded border border-dashed border-gray-300 py-2 text-center text-xs text-gray-500">
"컬럼 추가"
</div>
)}
</div>
</div>
</>
)}
</div>
{/* 카드 스타일 설정 */}
<div className="space-y-3">
<h5 className="text-xs font-medium text-gray-700"> </h5>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"> </label>
<input
type="number"
min="1"
max="6"
value={layoutComponent.layoutConfig?.card?.cardsPerRow || 3}
onChange={(e) =>
onUpdateProperty(layoutComponent.id, "layoutConfig.card.cardsPerRow", parseInt(e.target.value))
}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"> (px)</label>
<input
type="number"
min="0"
max="50"
value={layoutComponent.layoutConfig?.card?.cardSpacing || 16}
onChange={(e) =>
onUpdateProperty(layoutComponent.id, "layoutConfig.card.cardSpacing", parseInt(e.target.value))
}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="showTitle"
checked={layoutComponent.layoutConfig?.card?.cardStyle?.showTitle ?? true}
onChange={(e) =>
onUpdateProperty(layoutComponent.id, "layoutConfig.card.cardStyle.showTitle", e.target.checked)
}
className="rounded border-gray-300"
/>
<label htmlFor="showTitle" className="text-xs text-gray-600">
</label>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="showSubtitle"
checked={layoutComponent.layoutConfig?.card?.cardStyle?.showSubtitle ?? true}
onChange={(e) =>
onUpdateProperty(
layoutComponent.id,
"layoutConfig.card.cardStyle.showSubtitle",
e.target.checked,
)
}
className="rounded border-gray-300"
/>
<label htmlFor="showSubtitle" className="text-xs text-gray-600">
</label>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="showDescription"
checked={layoutComponent.layoutConfig?.card?.cardStyle?.showDescription ?? true}
onChange={(e) =>
onUpdateProperty(
layoutComponent.id,
"layoutConfig.card.cardStyle.showDescription",
e.target.checked,
)
}
className="rounded border-gray-300"
/>
<label htmlFor="showDescription" className="text-xs text-gray-600">
</label>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="showImage"
checked={layoutComponent.layoutConfig?.card?.cardStyle?.showImage ?? false}
onChange={(e) =>
onUpdateProperty(layoutComponent.id, "layoutConfig.card.cardStyle.showImage", e.target.checked)
}
className="rounded border-gray-300"
/>
<label htmlFor="showImage" className="text-xs text-gray-600">
</label>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"> </label>
<input
type="number"
min="10"
max="500"
value={layoutComponent.layoutConfig?.card?.cardStyle?.maxDescriptionLength || 100}
onChange={(e) =>
onUpdateProperty(
layoutComponent.id,
"layoutConfig.card.cardStyle.maxDescriptionLength",
parseInt(e.target.value),
)
}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
/>
</div>
</div>
</div>
)}
{/* 존 목록 - 카드 레이아웃은 데이터 기반이므로 존 관리 불필요 */}
{layoutComponent.layoutType !== "card-layout" && (
<div className="space-y-3">
<h4 className="text-sm font-medium text-gray-900"> </h4>
<div className="space-y-2">
{layoutComponent.zones?.map((zone, index) => (
<div key={zone.id} className="rounded-lg bg-gray-50 p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">{zone.name}</span>
<span className="text-xs text-gray-500">ID: {zone.id}</span>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="mb-1 block text-xs text-gray-600"></label>
<input
type="text"
value={zone.size?.width || "100%"}
onChange={(e) =>
onUpdateProperty(layoutComponent.id, `zones.${index}.size.width`, e.target.value)
}
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
placeholder="100%"
/>
</div>
<div>
<label className="mb-1 block text-xs text-gray-600"></label>
<input
type="text"
value={zone.size?.height || "auto"}
onChange={(e) =>
onUpdateProperty(layoutComponent.id, `zones.${index}.size.height`, e.target.value)
}
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
placeholder="auto"
/>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
};
// 웹타입별 상세 설정 렌더링 함수 - useCallback 제거하여 항상 최신 widget 사용
const renderWebTypeConfig = (widget: WidgetComponent) => {
const currentConfig = widget.webTypeConfig || {};
@ -231,13 +873,18 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
);
}
// 레이아웃 컴포넌트 처리
if (selectedComponent.type === "layout") {
return renderLayoutConfig(selectedComponent as LayoutComponent);
}
if (selectedComponent.type !== "widget" && selectedComponent.type !== "file" && selectedComponent.type !== "button") {
return (
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
<Settings className="mb-4 h-12 w-12 text-gray-400" />
<h3 className="mb-2 text-lg font-medium text-gray-900"> </h3>
<p className="text-sm text-gray-500">
, , .
, , , .
<br />
: {selectedComponent.type}
</p>

View File

@ -570,39 +570,49 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
/>
</div>
<div>
<Label htmlFor="width" className="text-sm font-medium">
</Label>
<Input
id="width"
type="number"
value={localInputs.width}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, width: newValue }));
onUpdateProperty("size", { ...selectedComponent.size, width: Number(newValue) });
}}
className="mt-1"
/>
</div>
{/* 카드 레이아웃은 자동 크기 계산으로 너비/높이 설정 숨김 */}
{selectedComponent?.type !== "layout" || (selectedComponent as any)?.layoutType !== "card" ? (
<>
<div>
<Label htmlFor="width" className="text-sm font-medium">
</Label>
<Input
id="width"
type="number"
value={localInputs.width}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, width: newValue }));
onUpdateProperty("size", { ...selectedComponent.size, width: Number(newValue) });
}}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="height" className="text-sm font-medium">
</Label>
<Input
id="height"
type="number"
value={localInputs.height}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, height: newValue }));
onUpdateProperty("size", { ...selectedComponent.size, height: Number(newValue) });
}}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="height" className="text-sm font-medium">
</Label>
<Input
id="height"
type="number"
value={localInputs.height}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, height: newValue }));
onUpdateProperty("size", { ...selectedComponent.size, height: Number(newValue) });
}}
className="mt-1"
/>
</div>
</>
) : (
<div className="col-span-2 rounded-lg bg-blue-50 p-3 text-center">
<p className="text-sm text-blue-600"> </p>
<p className="mt-1 text-xs text-blue-500"> </p>
</div>
)}
<div>
<Label htmlFor="zIndex" className="text-sm font-medium">

View File

@ -4,11 +4,74 @@
## 📋 목차
1. [CLI를 이용한 자동 생성](#cli를-이용한-자동-생성)
2. [생성된 파일 구조](#생성된-파일-구조)
3. [레이아웃 커스터마이징](#레이아웃-커스터마이징)
4. [고급 설정](#고급-설정)
5. [문제 해결](#문제-해결)
1. [현재 지원하는 레이아웃](#현재-지원하는-레이아웃)
2. [CLI를 이용한 자동 생성](#cli를-이용한-자동-생성)
3. [생성된 파일 구조](#생성된-파일-구조)
4. [레이아웃 커스터마이징](#레이아웃-커스터마이징)
5. [카드 레이아웃 상세 가이드](#카드-레이아웃-상세-가이드)웃
6. [고급 설정](#고급-설정)
7. [문제 해결](#문제-해결)
---
## 🎨 현재 지원하는 레이아웃
### 기본 레이아웃 (Basic)
#### 1. 그리드 레이아웃 (Grid Layout)
- **ID**: `grid`
- **존 개수**: 4개 (동적 설정 가능)
- **특징**: 격자 형태의 균등 분할 레이아웃
- **용도**: 카드, 대시보드 위젯 배치
#### 2. 플렉스박스 레이아웃 (Flexbox Layout)
- **ID**: `flexbox`
- **존 개수**: 2개 (동적 설정 가능)
- **특징**: 가로/세로 방향 설정 가능, 자동 크기 조정
- **방향 설정**: 수평(`row`) / 수직(`column`)
- **용도**: 반응형 레이아웃, 사이드바 구조
#### 3. 분할 레이아웃 (Split Layout)
- **ID**: `split`
- **존 개수**: 2개
- **특징**: 좌우 또는 상하 분할
- **용도**: 마스터-디테일 화면, 비교 화면
### 네비게이션 레이아웃 (Navigation)
#### 4. 아코디언 레이아웃 (Accordion Layout)
- **ID**: `accordion`
- **존 개수**: 3개
- **특징**: 접을 수 있는 섹션들
- **용도**: FAQ, 설정 패널, 단계별 폼
### 대시보드 레이아웃 (Dashboard)
#### 5. 카드 레이아웃 (Card Layout) ⭐ **데이터 기반**
- **ID**: `card-layout`
- **존 개수**: 6개 (디자인 모드), 데이터 기반 (실행 모드)
- **특징**: 실제 테이블 데이터를 카드 형태로 표시
- **핵심 기능**:
- 테이블 컬럼 매핑 (타이틀, 서브타이틀, 설명, 이미지)
- 동적 표시 컬럼 관리
- 컬럼 라벨 우선 표시
- 실시간 카드 스타일 설정
- 한 행당 카드 수, 간격 조정
- **용도**: 상품 목록, 직원 카드, 프로젝트 카드
### 콘텐츠 레이아웃 (Content)
#### 6. 히어로 섹션 레이아웃 (Hero Section Layout)
- **ID**: `hero-section`
- **존 개수**: 3개
- **특징**: 대형 헤더 섹션과 하위 영역들
- **용도**: 랜딩 페이지, 제품 소개
---
@ -211,8 +274,307 @@ export interface YourLayoutProps extends LayoutRendererProps {
---
## 🎴 카드 레이아웃 상세 가이드
### 개요
카드 레이아웃은 **데이터 기반 레이아웃**으로, 실제 데이터베이스 테이블의 데이터를 카드 형태로 표시합니다. 두 가지 모드로 작동합니다:
- **디자인 모드**: 6개의 존이 있는 일반 레이아웃 (편집기에서)
- **실행 모드**: 테이블 데이터를 기반으로 한 동적 카드 생성
### 🔧 설정 방법
#### 1. 테이블 컬럼 매핑
카드의 각 부분에 어떤 테이블 컬럼을 표시할지 설정합니다:
```typescript
interface CardColumnMapping {
titleColumn?: string; // 카드 제목에 표시할 컬럼
subtitleColumn?: string; // 카드 서브타이틀에 표시할 컬럼
descriptionColumn?: string; // 카드 설명에 표시할 컬럼
imageColumn?: string; // 카드 이미지에 표시할 컬럼
displayColumns?: string[]; // 추가로 표시할 컬럼들
}
```
#### 2. 카드 스타일 설정
카드의 외관을 세부적으로 조정할 수 있습니다:
```typescript
interface CardStyle {
showTitle?: boolean; // 타이틀 표시 여부
showSubtitle?: boolean; // 서브타이틀 표시 여부
showDescription?: boolean; // 설명 표시 여부
showImage?: boolean; // 이미지 표시 여부
maxDescriptionLength?: number; // 설명 최대 길이
cardsPerRow?: number; // 한 행당 카드 수
cardSpacing?: number; // 카드 간격 (px)
}
```
### 📋 사용 가이드
#### 1. 카드 레이아웃 생성
1. **레이아웃 추가**: 템플릿에서 "카드 레이아웃" 선택
2. **테이블 선택**: 화면에서 사용할 테이블 선택
3. **컬럼 매핑**: 상세설정 패널에서 컬럼 매핑 설정
#### 2. 컬럼 매핑 설정
**상세설정 패널**에서 다음을 설정합니다:
1. **타이틀 컬럼**: 카드 제목으로 사용할 컬럼 (예: 상품명, 직원명)
2. **서브타이틀 컬럼**: 카드 부제목으로 사용할 컬럼 (예: 카테고리, 부서)
3. **설명 컬럼**: 카드 설명으로 사용할 컬럼 (예: 상세설명, 상태)
4. **이미지 컬럼**: 카드 이미지로 사용할 컬럼 (이미지 URL)
#### 3. 표시 컬럼 추가
추가로 표시하고 싶은 컬럼들을 동적으로 추가할 수 있습니다:
- **"+ 컬럼 추가"** 버튼 클릭
- 드롭다운에서 원하는 컬럼 선택
- 각 컬럼마다 **"삭제"** 버튼으로 제거 가능
#### 4. 카드 스타일 조정
- **한 행당 카드 수**: 1-6개 설정 가능
- **카드 간격**: 픽셀 단위로 조정
- **표시 옵션**: 타이틀, 서브타이틀, 설명, 이미지 개별 제어
- **설명 최대 길이**: 긴 설명 텍스트 자동 축약
### 🎨 카드 렌더링 로직
#### 라벨 우선 표시
컬럼명 대신 **라벨값을 우선 표시**합니다:
```typescript
// 컬럼 라벨 변환 함수
const getColumnLabel = (columnName: string) => {
const column = tableColumns.find((col) => col.columnName === columnName);
return column?.columnLabel || columnName; // 라벨이 있으면 라벨, 없으면 컬럼명
};
// 사용 예시
// 컬럼명: "reg_date" → 라벨: "등록일"
// 컬럼명: "user_name" → 라벨: "사용자명"
```
#### 데이터 가져오기
카드 레이아웃은 다음과 같이 데이터를 가져옵니다:
```typescript
// 테이블 데이터와 컬럼 정보를 병렬로 로드
const [dataResponse, columnsResponse] = await Promise.all([
tableTypeApi.getTableData(tableName, { page: 1, size: 50 }),
tableTypeApi.getColumns(tableName),
]);
// 카드 렌더링에 사용
setTableData(dataResponse.data);
setTableColumns(columnsResponse);
```
#### 카드 구조
각 카드는 다음과 같은 구조로 렌더링됩니다:
```typescript
// 카드 내용 구성
<div className="card">
{/* 이미지 (설정된 경우) */}
{showImage && imageColumn && (
<img src={getColumnValue(item, imageColumn)} />
)}
{/* 타이틀 */}
{showTitle && titleColumn && (
<h3>{getColumnValue(item, titleColumn)}</h3>
)}
{/* 서브타이틀 */}
{showSubtitle && subtitleColumn && (
<p>{getColumnValue(item, subtitleColumn)}</p>
)}
{/* 설명 */}
{showDescription && descriptionColumn && (
<p>{truncateText(getColumnValue(item, descriptionColumn), maxLength)}</p>
)}
{/* 추가 표시 컬럼들 */}
{displayColumns?.map(columnName => (
<div key={columnName}>
<span>{getColumnLabel(columnName)}:</span>
<span>{getColumnValue(item, columnName)}</span>
</div>
))}
</div>
```
### 💡 활용 예시
#### 1. 직원 카드
```json
{
"columnMapping": {
"titleColumn": "emp_name", // 직원명
"subtitleColumn": "dept_name", // 부서명
"descriptionColumn": "position", // 직책
"imageColumn": "profile_image", // 프로필 사진
"displayColumns": ["email", "phone", "hire_date"] // 이메일, 전화번호, 입사일
},
"cardStyle": {
"cardsPerRow": 3,
"cardSpacing": 16,
"showTitle": true,
"showSubtitle": true,
"showDescription": true,
"showImage": true
}
}
```
#### 2. 상품 카드
```json
{
"columnMapping": {
"titleColumn": "product_name", // 상품명
"subtitleColumn": "category_name", // 카테고리
"descriptionColumn": "description", // 상품설명
"imageColumn": "product_image", // 상품 이미지
"displayColumns": ["price", "stock", "rating"] // 가격, 재고, 평점
},
"cardStyle": {
"cardsPerRow": 4,
"cardSpacing": 20,
"maxDescriptionLength": 80
}
}
```
#### 3. 프로젝트 카드
```json
{
"columnMapping": {
"titleColumn": "project_name", // 프로젝트명
"subtitleColumn": "status", // 상태
"descriptionColumn": "description", // 프로젝트 설명
"displayColumns": ["start_date", "end_date", "manager", "progress"] // 시작일, 종료일, 담당자, 진행률
},
"cardStyle": {
"cardsPerRow": 2,
"cardSpacing": 24,
"showImage": false
}
}
```
### 🔍 주요 특징
#### 1. **실시간 설정 반영**
- 상세설정 패널에서 설정 변경 시 즉시 카드에 반영
- 체크박스로 표시 요소 실시간 제어
- 컬럼 매핑 변경 시 즉시 업데이트
#### 2. **라벨 우선 표시 시스템**
- 데이터베이스 컬럼명 대신 사용자 친화적인 라벨 표시
- 라벨이 없는 경우 컬럼명으로 대체
- 드롭다운에서도 "라벨명 (데이터타입)" 형식으로 표시
#### 3. **동적 컬럼 관리**
- 표시할 컬럼을 자유롭게 추가/제거
- 각 컬럼별 개별 라벨 표시
- 컬럼 순서에 따른 표시 순서 보장
#### 4. **반응형 카드 레이아웃**
- 한 행당 카드 수 조정으로 반응형 구현
- 카드 간격 픽셀 단위 조정
- 자동 높이 조정 및 최소 높이 보장
#### 5. **데이터 안전성**
- 빈 데이터에 대한 폴백 처리
- 잘못된 컬럼 참조 시 오류 방지
- 고유한 key prop으로 React 렌더링 최적화
### 🚨 주의사항
1. **성능 고려**: 대량 데이터 시 페이징 처리 (현재 최대 50개)
2. **이미지 처리**: 이미지 URL이 유효하지 않을 경우 대체 이미지 표시
3. **텍스트 길이**: 긴 텍스트는 자동으로 축약 처리
4. **데이터 타입**: 날짜/시간 데이터의 경우 적절한 포맷팅 필요
---
## 🔧 고급 설정
### 영역(Zone) 관리 시스템
#### 존과 컴포넌트의 관계
레이아웃의 존(Zone)에 컴포넌트를 배치하면 다음과 같은 관계가 형성됩니다:
```typescript
// 존에 배치된 컴포넌트의 구조
interface ComponentInZone {
id: string;
parentId: string; // 레이아웃 컴포넌트 ID
zoneId: string; // 존 ID (새로 추가된 필드)
position: { x: number; y: number }; // 존 내부 상대 좌표
// ... 기타 속성들
}
```
#### 존에 컴포넌트 배치하기
1. **드래그앤드롭으로 배치**
- 테이블이나 컬럼을 레이아웃의 존 영역에 드롭
- 자동으로 `parentId``zoneId`가 설정됨
- 존 내부 상대 좌표로 위치 계산
2. **레이아웃 이동 시 함께 이동**
- 레이아웃 컴포넌트를 이동하면 존에 속한 모든 컴포넌트가 함께 이동
- 상대적 위치 관계 유지
- 드래그 중에도 실시간으로 함께 이동
#### 존 드롭 이벤트 처리
각 존은 독립적인 드롭존으로 작동합니다:
```typescript
// 존별 드롭 처리 예시
const handleZoneDrop = (e: React.DragEvent, zoneId: string) => {
const { type, table, column } = JSON.parse(e.dataTransfer.getData("application/json"));
const newComponent = {
id: generateId(),
type: "widget",
parentId: layoutId, // 레이아웃을 부모로 설정
zoneId: zoneId, // 존 ID 설정
position: {
// 존 내부 상대 좌표
x: e.clientX - dropZone.getBoundingClientRect().left,
y: e.clientY - dropZone.getBoundingClientRect().top,
},
// ... 기타 설정
};
};
```
### 영역(Zone) 커스터마이징
영역별로 다른 스타일을 적용하려면:
@ -268,6 +630,101 @@ const animatedStyle: React.CSSProperties = {
};
```
### 높이 설정 및 styled-jsx 사용
모든 레이아웃에서 부모 높이를 올바르게 따르도록 하는 패턴:
```typescript
export const YourLayoutLayout: React.FC<YourLayoutProps> = ({ layout, isDesignMode, ...props }) => {
return (
<div className="layout-container">
{/* 레이아웃 컨텐츠 */}
<style jsx>{`
.layout-container {
height: 100% !important;
min-height: 200px !important;
width: 100% !important;
display: flex; /* 또는 grid */
flex-direction: column; /* 필요에 따라 */
}
.zone-area {
flex: 1; /* 존이 사용 가능한 공간을 채우도록 */
height: 100%; /* 그리드 레이아웃의 경우 */
}
`}</style>
</div>
);
};
```
#### 레이아웃별 높이 적용 패턴
**1. Flexbox 레이아웃**
```typescript
// FlexboxLayout.tsx
<style jsx>{`
.flexbox-container {
height: 100% !important;
min-height: 200px !important;
width: 100% !important;
display: flex !important;
flex-direction: ${flexDirection === "row" ? "row" : "column"} !important;
}
.flexbox-zone {
flex: 1;
min-height: 0; /* flexbox 오버플로우 방지 */
}
`}</style>
```
**2. Grid 레이아웃**
```typescript
// GridLayout.tsx
<style jsx>{`
.grid-container {
height: 100% !important;
min-height: 200px !important;
width: 100% !important;
display: grid !important;
grid-template-columns: repeat(${columns}, 1fr);
grid-template-rows: repeat(${rows}, 1fr);
}
.grid-zone {
height: 100%;
width: 100%;
}
`}</style>
```
**3. Card 레이아웃**
```typescript
// CardLayoutLayout.tsx
<style jsx>{`
.card-container {
height: 100% !important;
min-height: 200px !important;
width: 100% !important;
display: grid;
grid-template-columns: repeat(${cardsPerRow}, 1fr);
gap: ${cardSpacing}px;
}
`}</style>
```
#### 중요한 CSS 규칙
1. **`!important` 사용**: Tailwind나 다른 CSS 프레임워크의 스타일을 오버라이드
2. **`height: 100%`**: 부모의 높이를 완전히 따르도록 설정
3. **`min-height`**: 최소 높이 보장으로 너무 작아지지 않도록 방지
4. **`flex: 1`**: 플렉스 아이템이 사용 가능한 공간을 채우도록 설정
---
## 🔄 자동 등록 시스템
@ -323,6 +780,72 @@ const { layout, isDesignMode, renderer, ...domProps } = props;
3. **브라우저 새로고침**: 캐시 문제일 수 있음
4. **개발자 도구**: `window.__LAYOUT_REGISTRY__.list()` 로 등록 상태 확인
#### 4. 레이아웃 높이가 적용되지 않음
```typescript
// ❌ 문제: 인라인 스타일만 사용
<div style={{ height: "100%" }}>
// ✅ 해결: styled-jsx로 강제 적용
<div className="layout-container">
<style jsx>{`
.layout-container {
height: 100% !important;
min-height: 200px !important;
}
`}</style>
</div>
```
#### 5. Flexbox 방향 설정이 적용되지 않음
```typescript
// ❌ 문제: display: block이 flex를 오버라이드
<div style={{ display: "flex", flexDirection: "row" }}>
// ✅ 해결: !important로 강제 적용
<style jsx>{`
.flexbox-container {
display: flex !important;
flex-direction: ${direction === "row" ? "row" : "column"} !important;
}
`}</style>
```
#### 6. 카드 레이아웃에서 데이터가 표시되지 않음
1. **테이블 선택 확인**: 화면에서 테이블이 선택되었는지 확인
2. **컬럼 매핑 설정**: 상세설정에서 최소한 타이틀 컬럼은 설정
3. **체크박스 상태**: "타이틀 표시", "서브타이틀 표시" 등이 체크되었는지 확인
4. **API 호출 확인**: 브라우저 개발자 도구에서 API 호출 성공 여부 확인
#### 7. React Key Prop 경고
```typescript
// ❌ 문제: 고유하지 않은 key
{items.map((item, index) => (
<div key={item.id}> // item.id가 undefined일 수 있음
// ✅ 해결: 안전한 key 생성
{items.map((item, index) => (
<div key={item.objid || item.id || item.company_code || `item-${index}`}>
```
#### 8. 라벨이 컬럼명으로 표시되는 문제
```typescript
// ❌ 문제: 컬럼명 직접 사용
<span>{columnName}:</span>
// ✅ 해결: 라벨 우선 표시
const getColumnLabel = (columnName: string) => {
const column = tableColumns.find(col => col.columnName === columnName);
return column?.columnLabel || columnName;
};
<span>{getColumnLabel(columnName)}:</span>
```
### 디버깅 도구
#### 브라우저 개발자 도구
@ -404,13 +927,124 @@ export const PricingTableLayout: React.FC<PricingTableLayoutProps> = ({
---
## 🎯 존 관리 시스템의 장점
### 1. 구조적 레이아웃 관리
- **논리적 그룹화**: 관련된 컴포넌트들을 존별로 그룹화
- **일관된 이동**: 레이아웃 이동 시 모든 하위 컴포넌트가 함께 이동
- **상대적 위치**: 존 내부에서의 상대적 위치 관계 유지
### 2. 직관적인 사용자 경험
- **드래그앤드롭**: 존 영역에 직접 드롭하여 배치
- **시각적 피드백**: 존 경계와 라벨로 명확한 구분
- **실시간 미리보기**: 드래그 중에도 함께 이동하는 모습 확인
### 3. 개발자 친화적
- **자동 관계 설정**: `parentId``zoneId` 자동 할당
- **타입 안전성**: TypeScript로 완전한 타입 지원
- **확장 가능성**: 새로운 존 타입 쉽게 추가 가능
## 🔍 사용 예시
### 대시보드 레이아웃 예시
```bash
# 대시보드 레이아웃 생성
node scripts/create-layout.js dashboard-layout --category=dashboard --zones=4 --description="대시보드 레이아웃"
```
생성된 레이아웃에서:
1. **헤더 존**: 제목, 네비게이션 컴포넌트 배치
2. **사이드바 존**: 메뉴, 필터 컴포넌트 배치
3. **메인 존**: 차트, 테이블 컴포넌트 배치
4. **푸터 존**: 상태 정보, 액션 버튼 배치
### 폼 레이아웃 예시
```bash
# 폼 레이아웃 생성
node scripts/create-layout.js form-layout --category=form --zones=3 --description="폼 레이아웃"
```
생성된 레이아웃에서:
1. **입력 존**: 텍스트 필드, 선택박스 배치
2. **첨부 존**: 파일 업로드 컴포넌트 배치
3. **액션 존**: 저장, 취소 버튼 배치
## 🎯 마무리
새로운 CLI 방식으로 레이아웃 추가가 매우 간단해졌습니다:
새로운 CLI 방식과 존 관리 시스템, 그리고 데이터 기반 레이아웃으로 화면관리 시스템이 혁신적으로 개선되었습니다:
### 🚀 핵심 기능
1. **한 줄 명령어**로 모든 파일 자동 생성
2. **타입 안전성** 보장
2. **타입 안전성** 보장 (TypeScript 완전 지원)
3. **자동 등록**으로 즉시 사용 가능
4. **Hot Reload** 지원으로 빠른 개발
5. **존 관리 시스템**으로 구조적 레이아웃 관리
6. **데이터 기반 렌더링** (카드 레이아웃)
7. **동적 존 개수 설정** (Flexbox, Grid)
8. **높이 자동 적용** (styled-jsx + !important)
더 자세한 정보가 필요하면 각 레이아웃의 `README.md` 파일을 참고하세요! 🚀
### 🎨 사용자 경험
- **직관적 드래그앤드롭**: 존에 직접 컴포넌트 배치
- **일관된 이동**: 레이아웃과 하위 컴포넌트가 함께 이동
- **시각적 피드백**: 명확한 존 경계와 라벨 표시
- **실시간 설정**: 체크박스로 즉시 표시 제어
- **라벨 우선 표시**: 컬럼명 대신 사용자 친화적 라벨
### 🔧 개발자 경험
- **자동화된 설정**: 복잡한 관계 설정이 자동으로 처리
- **확장 가능한 구조**: 새로운 레이아웃 타입 쉽게 추가
- **완전한 타입 지원**: TypeScript로 안전한 개발
- **문제 해결 가이드**: 자주 발생하는 문제들의 해결책 제공
- **성능 최적화**: React key, 메모이제이션 등 적용
### 🆕 최신 업데이트 (2025.09.11)
#### 카드 레이아웃 고도화
- ✅ **실제 데이터 연동**: 테이블 데이터를 실시간으로 카드에 표시
- ✅ **컬럼 매핑 시스템**: 타이틀, 서브타이틀, 설명 등 자유로운 매핑
- ✅ **동적 표시 컬럼**: 필요한 컬럼을 추가/제거하며 동적 관리
- ✅ **라벨 우선 표시**: 데이터베이스 컬럼명 대신 사용자 친화적 라벨
- ✅ **실시간 설정 반영**: 체크박스 토글로 즉시 카드 업데이트
#### 레이아웃 시스템 개선
- ✅ **높이 적용 문제 해결**: 모든 레이아웃에서 부모 높이 완벽 적용
- ✅ **Flexbox 방향 설정**: 수평/수직 방향 정상 작동
- ✅ **동적 존 개수**: Grid, Flexbox에서 존 개수 실시간 조정
- ✅ **시각적 피드백**: 모든 레이아웃에서 존 경계 명확히 표시
- ✅ **React 경고 해결**: Key prop, DOM prop 전달 등 모든 경고 해결
#### 개발자 도구 강화
- ✅ **디버깅 로그**: 카드 설정 로드, 데이터 가져오기 등 상세 로깅
- ✅ **에러 핸들링**: API 함수명 오류, 데이터 타입 불일치 등 완벽 처리
- ✅ **타입 안전성**: 모든 컴포넌트와 설정에 완전한 TypeScript 지원
### 📈 지원하는 레이아웃 현황
1. **Grid Layout** - 격자 형태 기본 레이아웃
2. **Flexbox Layout** - 유연한 방향 설정 가능
3. **Split Layout** - 좌우/상하 분할
4. **Accordion Layout** - 접기/펼치기 네비게이션
5. **Card Layout** - 데이터 기반 카드 표시 ⭐
6. **Hero Section Layout** - 대형 헤더 섹션
### 🔮 향후 계획
- **Table Layout**: 데이터 테이블 전용 레이아웃
- **Form Layout**: 폼 입력에 최적화된 레이아웃
- **Dashboard Layout**: 위젯 배치에 특화된 레이아웃
- **Mobile Responsive**: 모바일 대응 반응형 레이아웃
더 자세한 정보가 필요하면 각 레이아웃의 `README.md` 파일을 참고하거나, 브라우저 개발자 도구에서 `window.__LAYOUT_REGISTRY__.help()`를 실행해보세요! 🚀

View File

@ -88,6 +88,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
onClick={onClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onUpdateLayout={props.onUpdateLayout}
{...props}
/>
);

View File

@ -14,6 +14,7 @@ export interface DynamicLayoutRendererProps {
onComponentDrop?: (zoneId: string, component: ComponentData, e: React.DragEvent) => void;
onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: (e: React.DragEvent) => void;
onUpdateLayout?: (updatedLayout: LayoutComponent) => void; // 레이아웃 업데이트 콜백
className?: string;
style?: React.CSSProperties;
}
@ -28,8 +29,10 @@ export const DynamicLayoutRenderer: React.FC<DynamicLayoutRendererProps> = ({
onComponentDrop,
onDragStart,
onDragEnd,
onUpdateLayout,
className,
style,
...restProps
}) => {
console.log("🎯 DynamicLayoutRenderer:", {
layoutId: layout.id,
@ -77,8 +80,10 @@ export const DynamicLayoutRenderer: React.FC<DynamicLayoutRendererProps> = ({
onComponentDrop={onComponentDrop}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onUpdateLayout={onUpdateLayout}
className={className}
style={style}
{...restProps}
/>
);
} catch (error) {
@ -103,4 +108,3 @@ export const DynamicLayoutRenderer: React.FC<DynamicLayoutRendererProps> = ({
};
export default DynamicLayoutRenderer;

View File

@ -86,7 +86,25 @@ export class AutoRegisteringLayoutRenderer {
* .
*/
getZoneChildren(zoneId: string): ComponentData[] {
return this.props.allComponents.filter((comp) => comp.parentId === this.props.layout.id && comp.zoneId === zoneId);
if (!this.props.allComponents) {
return [];
}
// 1. 레이아웃 내의 존에 직접 배치된 컴포넌트들 (zoneId 기준)
const zoneComponents = this.props.allComponents.filter(
(comp) => comp.parentId === this.props.layout.id && comp.zoneId === zoneId,
);
// 2. 기존 방식 호환성 (parentId가 존 ID인 경우)
const legacyComponents = this.props.allComponents.filter((comp) => comp.parentId === zoneId);
// 중복 제거하여 반환
const allComponents = [...zoneComponents, ...legacyComponents];
const uniqueComponents = allComponents.filter(
(component, index, array) => array.findIndex((c) => c.id === component.id) === index,
);
return uniqueComponents;
}
/**
@ -192,7 +210,23 @@ export class AutoRegisteringLayoutRenderer {
</div>
{/* 드롭존 */}
<div className="drop-zone" style={dropZoneStyle}>
<div
className="drop-zone"
style={dropZoneStyle}
onDragOver={(e) => {
if (isDesignMode) {
e.preventDefault();
e.stopPropagation();
}
}}
onDrop={(e) => {
if (isDesignMode) {
e.preventDefault();
e.stopPropagation();
this.handleZoneDrop(e, zone.id);
}
}}
>
{zoneChildren.length > 0 ? (
zoneChildren.map((child) => (
<DynamicComponentRenderer
@ -212,6 +246,104 @@ export class AutoRegisteringLayoutRenderer {
);
}
/**
* .
*/
private handleZoneDrop = (e: React.DragEvent, zoneId: string) => {
try {
const dragData = e.dataTransfer.getData("application/json");
if (!dragData) return;
const { type, table, column, component } = JSON.parse(dragData);
// 드롭 위치 계산 (존 내부 상대 좌표)
const dropZone = e.currentTarget as HTMLElement;
const rect = dropZone.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
let newComponent: any;
if (type === "table") {
// 테이블 컨테이너 생성
newComponent = {
id: `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
type: "container",
label: table.tableLabel || table.tableName,
tableName: table.tableName,
position: { x, y, z: 1 },
size: { width: 300, height: 200 },
parentId: this.props.layout.id, // 레이아웃을 부모로 설정
zoneId: zoneId, // 존 ID 설정
};
} else if (type === "column") {
// 컬럼 위젯 생성
newComponent = {
id: `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
type: "widget",
label: column.columnLabel || column.columnName,
tableName: table.tableName,
columnName: column.columnName,
widgetType: column.widgetType,
dataType: column.dataType,
required: column.required,
position: { x, y, z: 1 },
size: { width: 200, height: 40 },
parentId: this.props.layout.id, // 레이아웃을 부모로 설정
zoneId: zoneId, // 존 ID 설정
};
} else if (type === "component" && component) {
// 컴포넌트 생성
newComponent = {
id: `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
type: component.componentType || "widget",
label: component.name,
widgetType: component.webType,
position: { x, y, z: 1 },
size: component.defaultSize || { width: 200, height: 40 },
parentId: this.props.layout.id, // 레이아웃을 부모로 설정
zoneId: zoneId, // 존 ID 설정
componentConfig: component.componentConfig || {},
webTypeConfig: this.getDefaultWebTypeConfig(component.webType),
style: {
labelDisplay: true,
labelFontSize: "14px",
labelColor: "#374151",
labelFontWeight: "500",
labelMarginBottom: "4px",
},
};
}
if (newComponent && this.props.onUpdateLayout) {
// 레이아웃에 컴포넌트 추가
const updatedComponents = [...(this.props.layout.components || []), newComponent];
this.props.onUpdateLayout({
...this.props.layout,
components: updatedComponents,
});
console.log(`컴포넌트가 존 ${zoneId}에 추가되었습니다:`, newComponent);
}
} catch (error) {
console.error("존 드롭 처리 중 오류:", error);
}
};
/**
* .
*/
private getDefaultWebTypeConfig = (webType: string) => {
const configs: Record<string, any> = {
text: { maxLength: 255 },
number: { min: 0, max: 999999 },
date: { format: "YYYY-MM-DD" },
select: { options: [] },
// 추가 웹타입 설정...
};
return configs[webType] || {};
};
/**
* .
*/

View File

@ -15,6 +15,7 @@ export interface LayoutRendererProps {
onComponentDrop?: (zoneId: string, component: ComponentData, e: React.DragEvent) => void;
onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: (e: React.DragEvent) => void;
onUpdateLayout?: (updatedLayout: LayoutComponent) => void; // 레이아웃 업데이트 콜백
children?: React.ReactNode;
className?: string;
style?: React.CSSProperties;

View File

@ -50,55 +50,83 @@ export const AccordionLayout: React.FC<AccordionLayoutProps> = ({
accordionStyle.padding = "4px";
}
// DOM props만 추출 (React DOM에서 인식하는 props만)
const {
children: propsChildren,
onUpdateLayout,
onSelectComponent,
isDesignMode: _isDesignMode,
allComponents,
onZoneClick,
onComponentDrop,
onDragStart,
onDragEnd,
selectedScreen, // DOM에 전달하지 않도록 제외
...domProps
} = props;
return (
<div
className={`accordion-layout ${isDesignMode ? "design-mode" : ""} ${className}`}
style={accordionStyle}
onClick={onClick}
draggable={isDesignMode}
onDragStart={props.onDragStart}
onDragEnd={props.onDragEnd}
{...props}
>
{layout.zones.map((zone: any, index: number) => {
const zoneChildren = renderer.getZoneChildren(zone.id);
const isExpanded = accordionConfig.defaultExpanded?.includes(zone.id) || index === 0;
<>
<style jsx>{`
.force-accordion-layout {
display: flex !important;
flex-direction: column !important;
height: ${layout.size?.height ? `${layout.size.height}px` : "100%"} !important;
min-height: ${layout.size?.height ? `${layout.size.height}px` : "200px"} !important;
width: 100% !important;
}
`}</style>
<div
className={`accordion-layout force-accordion-layout ${isDesignMode ? "design-mode" : ""} ${className}`}
style={accordionStyle}
onClick={onClick}
draggable={isDesignMode}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
{...domProps}
>
{layout.zones.map((zone: any, index: number) => {
const zoneChildren = renderer.getZoneChildren(zone.id);
const isExpanded = accordionConfig.defaultExpanded?.includes(zone.id) || index === 0;
return (
<AccordionSection
key={zone.id}
zone={zone}
isExpanded={isExpanded}
isDesignMode={isDesignMode}
renderer={renderer}
zoneChildren={zoneChildren}
/>
);
})}
return (
<AccordionSection
key={zone.id}
zone={zone}
isExpanded={isExpanded}
isDesignMode={isDesignMode}
renderer={renderer}
zoneChildren={zoneChildren}
onComponentDrop={onComponentDrop}
onZoneClick={onZoneClick}
/>
);
})}
{/* 디자인 모드에서 빈 영역 표시 */}
{layout.zones.length === 0 && (
<div
className="empty-accordion-container"
style={{
flex: 1,
border: "2px dashed #cbd5e1",
borderRadius: "8px",
backgroundColor: "rgba(148, 163, 184, 0.05)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
color: "#64748b",
minHeight: "100px",
padding: "20px",
textAlign: "center",
}}
>
{isDesignMode ? "아코디언에 존을 추가하세요" : "빈 아코디언"}
</div>
)}
</div>
{/* 디자인 모드에서 빈 영역 표시 */}
{layout.zones.length === 0 && (
<div
className="empty-accordion-container"
style={{
flex: 1,
border: "2px dashed #cbd5e1",
borderRadius: "8px",
backgroundColor: "rgba(148, 163, 184, 0.05)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
color: "#64748b",
minHeight: "100px",
padding: "20px",
textAlign: "center",
}}
>
{isDesignMode ? "아코디언에 존을 추가하세요" : "빈 아코디언"}
</div>
)}
</div>
</>
);
};
@ -111,13 +139,17 @@ const AccordionSection: React.FC<{
isDesignMode: boolean;
renderer: any;
zoneChildren: any;
}> = ({ zone, isExpanded: initialExpanded, isDesignMode, renderer, zoneChildren }) => {
onComponentDrop?: (e: React.DragEvent, zoneId: string) => void;
onZoneClick?: (zoneId: string) => void;
}> = ({ zone, isExpanded: initialExpanded, isDesignMode, renderer, zoneChildren, onComponentDrop, onZoneClick }) => {
const [isExpanded, setIsExpanded] = React.useState(initialExpanded);
const headerStyle: React.CSSProperties = {
padding: "12px 16px",
backgroundColor: "#f8fafc",
borderBottom: "1px solid #e2e8f0",
backgroundColor: isDesignMode ? "#3b82f6" : "#f8fafc",
color: isDesignMode ? "white" : "#374151",
border: "1px solid #e2e8f0",
borderBottom: isExpanded ? "none" : "1px solid #e2e8f0",
cursor: "pointer",
userSelect: "none",
display: "flex",
@ -125,18 +157,24 @@ const AccordionSection: React.FC<{
justifyContent: "space-between",
fontSize: "14px",
fontWeight: 500,
borderRadius: isDesignMode ? "4px" : "0",
margin: isDesignMode ? "2px" : "0",
borderRadius: "8px 8px 0 0",
margin: "2px",
transition: "all 0.2s ease",
boxShadow: "0 1px 3px 0 rgba(0, 0, 0, 0.1)",
};
const contentStyle: React.CSSProperties = {
padding: isExpanded ? "12px 16px" : "0 16px",
maxHeight: isExpanded ? "500px" : "0",
maxHeight: isExpanded ? zone.size?.height || "500px" : "0",
overflow: "hidden",
transition: "all 0.3s ease",
backgroundColor: "#ffffff",
borderRadius: isDesignMode ? "4px" : "0",
margin: isDesignMode ? "2px" : "0",
backgroundColor: "white",
border: "1px solid #e2e8f0",
borderTop: "none",
borderRadius: "0 0 8px 8px",
margin: "2px",
minHeight: isExpanded ? zone.size?.minHeight || "100px" : "0",
boxShadow: "0 1px 3px 0 rgba(0, 0, 0, 0.1)",
};
return (
@ -153,11 +191,47 @@ const AccordionSection: React.FC<{
</span>
</div>
<div className="accordion-content" style={contentStyle}>
{renderer.renderZone(zone, zoneChildren, {
style: { minHeight: isExpanded ? "50px" : "0" },
className: "accordion-zone-content",
})}
<div
className="accordion-content"
style={{
...contentStyle,
position: "relative",
}}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
if (onComponentDrop) {
onComponentDrop(e, zone.id); // 존 ID와 함께 드롭 이벤트 전달
}
}}
onClick={(e) => {
e.stopPropagation();
if (onZoneClick) {
onZoneClick(zone.id);
}
}}
>
{/* 존 안의 컴포넌트들을 절대 위치로 렌더링 */}
{isExpanded &&
zoneChildren.map((child: any) => (
<div
key={child.id}
style={{
position: "absolute",
left: child.position?.x || 0,
top: child.position?.y || 0,
width: child.size?.width || "auto",
height: child.size?.height || "auto",
zIndex: child.position?.z || 1,
}}
>
{renderer.renderChild(child)}
</div>
))}
</div>
</div>
);

View File

@ -7,29 +7,84 @@ import { LayoutRendererProps } from "../BaseLayoutRenderer";
*
* 3x2
*/
export const CardLayoutLayout: React.FC<LayoutRendererProps> = ({
interface CardLayoutProps extends LayoutRendererProps {
tableData?: any[]; // 테이블 데이터
tableColumns?: any[]; // 테이블 컬럼 정보 (라벨 포함)
}
export const CardLayoutLayout: React.FC<CardLayoutProps> = ({
layout,
children,
onUpdateLayout,
onSelectComponent,
isDesignMode = false,
className = "",
onClick,
allComponents,
tableData = [],
tableColumns = [],
}) => {
const cardConfig = layout.layoutConfig?.cardLayout || {
columns: 3,
gap: 16,
aspectRatio: "4:3",
// 카드 설정 가져오기 (기본값 보장)
const cardConfig = {
cardsPerRow: layout.layoutConfig?.card?.cardsPerRow ?? 3,
cardSpacing: layout.layoutConfig?.card?.cardSpacing ?? 16,
columnMapping: layout.layoutConfig?.card?.columnMapping || {},
cardStyle: {
showTitle: layout.layoutConfig?.card?.cardStyle?.showTitle ?? true,
showSubtitle: layout.layoutConfig?.card?.cardStyle?.showSubtitle ?? true,
showDescription: layout.layoutConfig?.card?.cardStyle?.showDescription ?? true,
showImage: layout.layoutConfig?.card?.cardStyle?.showImage ?? false,
maxDescriptionLength: layout.layoutConfig?.card?.cardStyle?.maxDescriptionLength ?? 100,
},
};
// 카드 레이아웃 스타일
// 실제 테이블 데이터 사용 (없으면 샘플 데이터)
const displayData =
tableData.length > 0
? tableData
: [
{
id: 1,
name: "김철수",
email: "kim@example.com",
phone: "010-1234-5678",
department: "개발팀",
position: "시니어 개발자",
description: "풀스택 개발자로 React, Node.js 전문가입니다. 5년 이상의 경험을 보유하고 있습니다.",
avatar: "/images/avatar1.jpg",
},
{
id: 2,
name: "이영희",
email: "lee@example.com",
phone: "010-2345-6789",
department: "디자인팀",
position: "UI/UX 디자이너",
description: "사용자 경험을 중시하는 디자이너로 Figma, Adobe XD를 능숙하게 다룹니다.",
avatar: "/images/avatar2.jpg",
},
{
id: 3,
name: "박민수",
email: "park@example.com",
phone: "010-3456-7890",
department: "기획팀",
position: "프로덕트 매니저",
description: "데이터 기반 의사결정을 통해 제품을 성장시키는 PM입니다.",
avatar: "/images/avatar3.jpg",
},
];
// 컨테이너 스타일
const containerStyle: React.CSSProperties = {
display: "grid",
gridTemplateColumns: `repeat(${cardConfig.columns}, 1fr)`,
gridTemplateRows: "repeat(2, 300px)", // 2행 고정
gap: `${cardConfig.gap}px`,
gridTemplateColumns: `repeat(${cardConfig.cardsPerRow || 3}, 1fr)`,
gap: `${cardConfig.cardSpacing || 16}px`,
padding: "16px",
width: "100%",
height: "100%",
background: "transparent",
overflow: "auto",
};
// 카드 스타일
@ -45,58 +100,179 @@ export const CardLayoutLayout: React.FC<LayoutRendererProps> = ({
flexDirection: "column",
position: "relative",
minHeight: "200px",
cursor: isDesignMode ? "pointer" : "default",
};
// 디자인 모드에서 호버 효과
const designModeCardStyle: React.CSSProperties = isDesignMode
? {
...cardStyle,
cursor: "pointer",
borderColor: "#d1d5db",
"&:hover": {
borderColor: "#3b82f6",
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1)",
},
}
: cardStyle;
// 텍스트 자르기 함수
const truncateText = (text: string, maxLength: number) => {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + "...";
};
// 컬럼 매핑에서 값 가져오기
const getColumnValue = (data: any, columnName?: string) => {
if (!columnName) return "";
return data[columnName] || "";
};
// 컬럼명을 라벨로 변환하는 헬퍼 함수
const getColumnLabel = (columnName: string) => {
if (!tableColumns || tableColumns.length === 0) return columnName;
const column = tableColumns.find((col) => col.columnName === columnName);
return column?.columnLabel || columnName;
};
// 자동 폴백 로직 - 컬럼이 설정되지 않은 경우 적절한 기본값 찾기
const getAutoFallbackValue = (data: any, type: "title" | "subtitle" | "description") => {
const keys = Object.keys(data);
switch (type) {
case "title":
// 이름 관련 필드 우선 검색
return data.name || data.title || data.label || data[keys[0]] || "제목 없음";
case "subtitle":
// 직책, 부서, 카테고리 관련 필드 검색
return data.position || data.role || data.department || data.category || data.type || "";
case "description":
// 설명, 내용 관련 필드 검색
return data.description || data.content || data.summary || data.memo || "";
default:
return "";
}
};
return (
<div style={containerStyle}>
{layout.zones?.map((zone, index) => {
const zoneChildren = children?.filter((child) => child.props.parentId === zone.id) || [];
<>
<style jsx>{`
.force-card-layout {
height: ${layout.size?.height ? `${layout.size.height}px` : "100%"} !important;
min-height: ${layout.size?.height ? `${layout.size.height}px` : "200px"} !important;
width: 100% !important;
}
.card-hover:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.15) !important;
border-color: #3b82f6 !important;
}
`}</style>
<div style={containerStyle} className={`card-layout force-card-layout ${className || ""}`} onClick={onClick}>
{isDesignMode
? // 디자인 모드: 존 기반 렌더링
layout.zones?.map((zone, index) => {
const zoneChildren = children?.filter((child) => child.props.parentId === zone.id) || [];
return (
<div
key={zone.id}
style={designModeCardStyle}
onClick={() => isDesignMode && onSelectComponent?.(zone.id)}
className={isDesignMode ? "hover:border-blue-500 hover:shadow-md" : ""}
>
{/* 카드 헤더 */}
{isDesignMode && (
<div className="absolute top-2 left-2 z-10">
<div className="rounded bg-blue-500 px-2 py-1 text-xs text-white">{zone.name}</div>
</div>
)}
return (
<div
key={zone.id}
style={cardStyle}
onClick={() => onSelectComponent?.(zone.id)}
className="hover:border-blue-500 hover:shadow-md"
>
{/* 존 라벨 */}
<div className="absolute left-2 top-2 z-10">
<div className="rounded bg-blue-500 px-2 py-1 text-xs text-white">{zone.name}</div>
</div>
{/* 카드 내용 */}
<div className="flex flex-1 flex-col">
{zoneChildren.length > 0 ? (
<div className="flex-1">{zoneChildren}</div>
) : (
isDesignMode && (
<div className="flex flex-1 items-center justify-center rounded border-2 border-dashed border-gray-200 text-gray-400">
<div className="text-center">
<div className="text-sm font-medium">{zone.name}</div>
<div className="mt-1 text-xs"> </div>
{/* 존 내용 */}
<div className="flex flex-1 flex-col pt-6">
{zoneChildren.length > 0 ? (
<div className="flex-1">{zoneChildren}</div>
) : (
<div className="flex flex-1 items-center justify-center rounded border-2 border-dashed border-gray-200 text-gray-400">
<div className="text-center">
<div className="text-sm font-medium"> </div>
<div className="mt-1 text-xs"> </div>
</div>
</div>
)}
</div>
</div>
);
})
: // 실행 모드: 데이터 기반 카드 렌더링
displayData.map((item, index) => (
<div
key={item.objid || item.id || item.company_code || `card-${index}`}
style={cardStyle}
className="card-hover"
>
{/* 카드 이미지 */}
{cardConfig.cardStyle?.showImage && cardConfig.columnMapping?.imageColumn && (
<div className="mb-3 flex justify-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-gray-200">
<span className="text-xl text-gray-500">👤</span>
</div>
</div>
)
)}
)}
{/* 카드 타이틀 */}
{cardConfig.cardStyle?.showTitle && (
<div className="mb-2">
<h3 className="text-lg font-semibold text-gray-900">
{getColumnValue(item, cardConfig.columnMapping?.titleColumn) ||
getAutoFallbackValue(item, "title")}
</h3>
</div>
)}
{/* 카드 서브타이틀 */}
{cardConfig.cardStyle?.showSubtitle && (
<div className="mb-2">
<p className="text-sm font-medium text-blue-600">
{getColumnValue(item, cardConfig.columnMapping?.subtitleColumn) ||
getAutoFallbackValue(item, "subtitle")}
</p>
</div>
)}
{/* 카드 설명 */}
{cardConfig.cardStyle?.showDescription && (
<div className="mb-3 flex-1">
<p className="text-sm leading-relaxed text-gray-600">
{truncateText(
getColumnValue(item, cardConfig.columnMapping?.descriptionColumn) ||
getAutoFallbackValue(item, "description"),
cardConfig.cardStyle?.maxDescriptionLength || 100,
)}
</p>
</div>
)}
{/* 추가 표시 컬럼들 */}
{cardConfig.columnMapping?.displayColumns && cardConfig.columnMapping.displayColumns.length > 0 && (
<div className="space-y-1 border-t border-gray-100 pt-3">
{cardConfig.columnMapping.displayColumns.map((columnName, idx) => {
const value = getColumnValue(item, columnName);
if (!value) return null;
return (
<div key={idx} className="flex justify-between text-xs">
<span className="capitalize text-gray-500">{getColumnLabel(columnName)}:</span>
<span className="font-medium text-gray-700">{value}</span>
</div>
);
})}
</div>
)}
{/* 카드 액션 (선택사항) */}
<div className="mt-3 flex justify-end space-x-2">
<button className="text-xs font-medium text-blue-600 hover:text-blue-800"></button>
<button className="text-xs font-medium text-gray-500 hover:text-gray-700"></button>
</div>
</div>
))}
{/* 빈 상태 표시 */}
{!isDesignMode && displayData.length === 0 && (
<div className="col-span-full flex items-center justify-center p-8">
<div className="text-center text-gray-500">
<div className="mb-2 text-lg">📋</div>
<div className="text-sm"> </div>
</div>
</div>
);
})}
</div>
)}
</div>
</>
);
};

View File

@ -3,7 +3,90 @@
import { AutoRegisteringLayoutRenderer } from "../AutoRegisteringLayoutRenderer";
import { CardLayoutDefinition } from "./index";
import { CardLayoutLayout } from "./CardLayoutLayout";
import React from "react";
import React, { useState, useEffect } from "react";
import { tableTypeApi } from "@/lib/api/screen";
/**
*
*/
const CardLayoutRendererComponent: React.FC<any> = (props) => {
const { layout, children, onUpdateLayout, onSelectComponent, isDesignMode } = props;
const [tableData, setTableData] = useState<any[]>([]);
const [tableColumns, setTableColumns] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
// 테이블 데이터 로딩
useEffect(() => {
const loadTableData = async () => {
// 디자인 모드에서는 테이블 데이터를 로드하지 않음
if (isDesignMode) {
return;
}
// 카드 설정에서 테이블명 확인 (여러 소스에서 시도)
const tableName =
layout?.tableName ||
layout?.screenTableName ||
props?.tableName ||
props?.selectedScreen?.tableName ||
"company_mng"; // 임시 하드코딩 (테스트용)
if (!tableName) {
console.log("📋 카드 레이아웃: 테이블명이 설정되지 않음", {
layoutTableName: layout?.tableName,
screenTableName: layout?.screenTableName,
propsTableName: props?.tableName,
selectedScreenTableName: props?.selectedScreen?.tableName,
});
return;
}
try {
setLoading(true);
console.log(`📋 카드 레이아웃: ${tableName} 테이블 데이터 로딩 시작`);
// 테이블 데이터와 컬럼 정보를 병렬로 로드
const [dataResponse, columnsResponse] = await Promise.all([
tableTypeApi.getTableData(tableName, {
page: 1,
size: 50, // 카드 레이아웃용으로 적당한 개수
}),
tableTypeApi.getColumns(tableName),
]);
console.log(`📋 카드 레이아웃: ${tableName} 데이터 로딩 완료`, {
total: dataResponse.total,
dataLength: dataResponse.data.length,
columnsLength: columnsResponse.length,
sampleData: dataResponse.data.slice(0, 2),
sampleColumns: columnsResponse.slice(0, 3),
});
setTableData(dataResponse.data);
setTableColumns(columnsResponse);
} catch (error) {
console.error(`❌ 카드 레이아웃: ${tableName} 데이터 로딩 실패`, error);
setTableData([]);
} finally {
setLoading(false);
}
};
loadTableData();
}, [layout?.tableName, isDesignMode]);
return (
<CardLayoutLayout
layout={layout}
children={children}
onUpdateLayout={onUpdateLayout}
onSelectComponent={onSelectComponent}
isDesignMode={isDesignMode}
tableData={tableData}
tableColumns={tableColumns}
/>
);
};
/**
*
@ -19,17 +102,7 @@ export class CardLayoutRenderer extends AutoRegisteringLayoutRenderer {
*
*/
render(): React.ReactElement {
const { layout, children, onUpdateLayout, onSelectComponent, isDesignMode } = this.props;
return (
<CardLayoutLayout
layout={layout}
children={children}
onUpdateLayout={onUpdateLayout}
onSelectComponent={onSelectComponent}
isDesignMode={isDesignMode}
/>
);
return <CardLayoutRendererComponent {...this.props} />;
}
/**

View File

@ -33,19 +33,57 @@ export const FlexboxLayout: React.FC<FlexboxLayoutProps> = ({
const flexConfig = layout.layoutConfig.flexbox;
const containerStyle = renderer.getLayoutContainerStyle();
// 플렉스박스 스타일 설정
console.log("🔍 FlexboxLayout 컨테이너 설정:", {
layoutId: layout.id,
flexConfig,
direction: flexConfig.direction,
layoutConfig: layout.layoutConfig,
});
// 플렉스박스 스타일 설정 (containerStyle보다 flex 속성을 우선 적용)
const flexStyle: React.CSSProperties = {
...containerStyle,
display: "flex",
display: "flex !important" as any,
flexDirection: flexConfig.direction,
justifyContent: flexConfig.justify,
alignItems: flexConfig.align,
flexWrap: flexConfig.wrap,
gap: `${flexConfig.gap}px`,
height: "100%",
width: "100%",
// 레이아웃 컴포넌트의 높이 적용 - 부모 높이를 100% 따라가도록
height: layout.size?.height ? `${layout.size.height}px` : "100%",
width: layout.size?.width ? `${layout.size.width}px` : "100%",
minHeight: layout.size?.height ? `${layout.size.height}px` : "200px", // 최소 높이 보장
// containerStyle을 나중에 적용하되, flex 관련 속성은 덮어쓰지 않도록
...containerStyle,
// flex 속성들을 다시 강제 적용 (!important 사용)
display: "flex !important" as any,
flexDirection: flexConfig.direction,
// 높이 관련 속성도 강제 적용
height: layout.size?.height ? `${layout.size.height}px` : "100%",
minHeight: layout.size?.height ? `${layout.size.height}px` : "200px",
};
console.log("🎨 FlexboxLayout 최종 스타일:", {
display: flexStyle.display,
flexDirection: flexStyle.flexDirection,
height: flexStyle.height,
width: flexStyle.width,
});
// 각 존의 크기 정보 상세 로깅
console.log(
"🔍 각 존의 크기 정보:",
layout.zones?.map((zone) => ({
id: zone.id,
size: zone.size,
calculatedStyle: {
width: zone.size?.width || "auto",
height: zone.size?.height || defaultZoneHeight,
minHeight: zone.size?.minHeight || "auto",
maxHeight: zone.size?.maxHeight || "none",
},
})),
);
// 디자인 모드 스타일
if (isDesignMode) {
flexStyle.border = isSelected ? "2px solid #3b82f6" : "1px solid #e2e8f0";
@ -53,63 +91,219 @@ export const FlexboxLayout: React.FC<FlexboxLayoutProps> = ({
flexStyle.padding = "8px";
}
// DOM props만 추출 (React DOM에서 인식하는 props만)
const {
children: propsChildren,
onUpdateLayout,
onSelectComponent,
isDesignMode: _isDesignMode,
allComponents,
onZoneClick,
onComponentDrop,
onDragStart,
onDragEnd,
selectedScreen, // DOM에 전달하지 않도록 제외
...domProps
} = props;
return (
<div
className={`flexbox-layout ${isDesignMode ? "design-mode" : ""} ${className}`}
style={flexStyle}
onClick={onClick}
draggable={isDesignMode}
onDragStart={props.onDragStart}
onDragEnd={props.onDragEnd}
{...props}
>
{layout.zones.map((zone: any) => {
const zoneChildren = renderer.getZoneChildren(zone.id);
<>
<style jsx>{`
.force-flex-layout {
display: flex !important;
flex-direction: ${flexConfig.direction} !important;
height: ${layout.size?.height ? `${layout.size.height}px` : "100%"} !important;
min-height: ${layout.size?.height ? `${layout.size.height}px` : "200px"} !important;
width: 100% !important;
}
`}</style>
<div
className={`flexbox-layout force-flex-layout ${isDesignMode ? "design-mode" : ""} ${className}`}
style={flexStyle}
onClick={onClick}
draggable={isDesignMode}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
{...domProps}
>
{layout.zones.map((zone: any) => {
const zoneChildren = renderer.getZoneChildren(zone.id);
// 플렉스 아이템 스타일 설정
const zoneStyle: React.CSSProperties = {
flex: renderer.calculateFlexValue(zone, flexConfig.direction),
};
// 레이아웃 전체 높이 기준으로 존 높이 계산
const layoutHeight = layout.size?.height || 400; // 기본값 400px
const defaultZoneHeight =
flexConfig.direction === "column"
? Math.floor(layoutHeight / layout.zones.length) - 32 // 세로 배치시 존 개수로 나눔
: layoutHeight - 32; // 가로 배치시 전체 높이 사용
return renderer.renderZone(zone, zoneChildren, {
style: zoneStyle,
className: "flex-zone",
});
})}
console.log("🔍 FlexboxLayout 높이 정보:", {
layoutId: layout.id,
layoutSize: layout.size,
layoutHeight,
defaultZoneHeight,
flexDirection: flexConfig.direction,
zoneId: zone.id,
zoneSize: zone.size,
});
{/* 존이 없을 때 안내 메시지 */}
{layout.zones.length === 0 && (
<div
className="empty-flex-container"
style={{
flex: 1,
border: isDesignMode ? "2px dashed #cbd5e1" : "1px solid #e2e8f0",
// 플렉스 아이템 스타일 설정
const zoneStyle: React.CSSProperties = {
flex: renderer.calculateFlexValue(zone, flexConfig.direction),
// 카드 레이아웃처럼 항상 명확한 경계 표시
backgroundColor: "white",
border: "1px solid #e5e7eb",
borderRadius: "8px",
backgroundColor: isDesignMode ? "rgba(148, 163, 184, 0.05)" : "rgba(248, 250, 252, 0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: isDesignMode ? "14px" : "12px",
color: "#64748b",
minHeight: "100px",
padding: "20px",
textAlign: "center",
padding: "16px",
boxShadow: "0 1px 3px 0 rgba(0, 0, 0, 0.1)",
// 존의 크기: 개별 설정이 있으면 우선, 없으면 계산된 기본값
width: zone.size?.width
? typeof zone.size.width === "number"
? `${zone.size.width}px`
: zone.size.width
: "auto",
height: zone.size?.height
? typeof zone.size.height === "number"
? `${zone.size.height}px`
: zone.size.height
: flexConfig.direction === "row"
? "100%" // 가로 배치일 때는 부모 높이를 100% 따라감
: `${defaultZoneHeight}px`,
minHeight: zone.size?.minHeight
? typeof zone.size.minHeight === "number"
? `${zone.size.minHeight}px`
: zone.size.minHeight
: "100px",
maxHeight: zone.size?.maxHeight
? typeof zone.size.maxHeight === "number"
? `${zone.size.maxHeight}px`
: zone.size.maxHeight
: "none",
position: "relative",
transition: "all 0.2s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "#3b82f6";
e.currentTarget.style.backgroundColor = "rgba(59, 130, 246, 0.05)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = isDesignMode ? "#cbd5e1" : "#e2e8f0";
e.currentTarget.style.backgroundColor = isDesignMode
? "rgba(148, 163, 184, 0.05)"
: "rgba(248, 250, 252, 0.5)";
}}
>
{isDesignMode ? "플렉스박스 레이아웃에 존을 추가하세요" : "빈 레이아웃"}
</div>
)}
</div>
margin: "4px",
overflow: "hidden",
display: "flex",
flexDirection: "column",
};
return (
<div
key={zone.id}
style={zoneStyle}
className="flex-zone"
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "#3b82f6";
e.currentTarget.style.boxShadow =
"0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 0 0 2px rgba(59, 130, 246, 0.1)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "#e5e7eb";
e.currentTarget.style.boxShadow = "0 1px 3px 0 rgba(0, 0, 0, 0.1)";
}}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
if (onComponentDrop) {
onComponentDrop(e, zone.id); // 존 ID와 함께 드롭 이벤트 전달
}
}}
onClick={(e) => {
e.stopPropagation();
if (onZoneClick) {
onZoneClick(zone.id);
}
}}
>
{/* 존 라벨 */}
{isDesignMode && (
<div
className="zone-label"
style={{
position: "absolute",
top: "-2px",
left: "8px",
backgroundColor: "#3b82f6",
color: "white",
fontSize: "10px",
padding: "2px 6px",
borderRadius: "0 0 4px 4px",
fontWeight: "500",
zIndex: 10,
}}
>
{zone.name || zone.id}
</div>
)}
{/* 존 내용 */}
<div
style={{
paddingTop: isDesignMode ? "16px" : "0",
flex: 1,
position: "relative",
minHeight: "100px",
}}
className="flex-zone-content"
>
{/* 존 안의 컴포넌트들을 절대 위치로 렌더링 */}
{zoneChildren.map((child: any) => (
<div
key={child.id}
style={{
position: "absolute",
left: child.position?.x || 0,
top: child.position?.y || 0,
width: child.size?.width || "auto",
height: child.size?.height || "auto",
zIndex: child.position?.z || 1,
}}
>
{renderer.renderChild(child)}
</div>
))}
</div>
</div>
);
})}
{/* 존이 없을 때 안내 메시지 */}
{layout.zones.length === 0 && (
<div
className="empty-flex-container"
style={{
flex: 1,
border: isDesignMode ? "2px dashed #cbd5e1" : "1px solid #e2e8f0",
borderRadius: "8px",
backgroundColor: isDesignMode ? "rgba(148, 163, 184, 0.05)" : "rgba(248, 250, 252, 0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: isDesignMode ? "14px" : "12px",
color: "#64748b",
minHeight: "100px",
padding: "20px",
textAlign: "center",
transition: "all 0.2s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "#3b82f6";
e.currentTarget.style.backgroundColor = "rgba(59, 130, 246, 0.05)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = isDesignMode ? "#cbd5e1" : "#e2e8f0";
e.currentTarget.style.backgroundColor = isDesignMode
? "rgba(148, 163, 184, 0.05)"
: "rgba(248, 250, 252, 0.5)";
}}
>
{isDesignMode ? "플렉스박스 레이아웃에 존을 추가하세요" : "빈 레이아웃"}
</div>
)}
</div>
</>
);
};

View File

@ -35,13 +35,22 @@ export const GridLayout: React.FC<GridLayoutProps> = ({
// 그리드 컨테이너 스타일
const gridStyle: React.CSSProperties = {
...containerStyle,
display: "grid",
gridTemplateRows: `repeat(${gridConfig.rows}, 1fr)`,
gridTemplateColumns: `repeat(${gridConfig.columns}, 1fr)`,
gap: `${gridConfig.gap || 16}px`,
height: "100%",
width: "100%",
// 레이아웃 컴포넌트의 높이 적용 - 부모 높이를 100% 따라가도록
height: layout.size?.height ? `${layout.size.height}px` : "100%",
width: layout.size?.width ? `${layout.size.width}px` : "100%",
minHeight: layout.size?.height ? `${layout.size.height}px` : "200px", // 최소 높이 보장
// containerStyle을 나중에 적용하되, grid 관련 속성은 덮어쓰지 않도록
...containerStyle,
// grid 속성들을 다시 강제 적용
display: "grid",
gridTemplateRows: `repeat(${gridConfig.rows}, 1fr)`,
gridTemplateColumns: `repeat(${gridConfig.columns}, 1fr)`,
height: layout.size?.height ? `${layout.size.height}px` : "100%",
minHeight: layout.size?.height ? `${layout.size.height}px` : "200px",
};
// 디자인 모드 스타일
@ -61,37 +70,168 @@ export const GridLayout: React.FC<GridLayoutProps> = ({
onComponentDrop,
onDragStart,
onDragEnd,
selectedScreen, // DOM에 전달하지 않도록 제외
...domProps
} = props;
return (
<div
className={`grid-layout ${isDesignMode ? "design-mode" : ""} ${className}`}
style={gridStyle}
onClick={onClick}
draggable={isDesignMode}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
{...domProps}
>
{layout.zones.map((zone: any) => {
const zoneChildren = renderer.getZoneChildren(zone.id);
<>
<style jsx>{`
.force-grid-layout {
display: grid !important;
height: ${layout.size?.height ? `${layout.size.height}px` : "100%"} !important;
min-height: ${layout.size?.height ? `${layout.size.height}px` : "200px"} !important;
width: 100% !important;
}
`}</style>
<div
className={`grid-layout force-grid-layout ${isDesignMode ? "design-mode" : ""} ${className}`}
style={gridStyle}
onClick={onClick}
draggable={isDesignMode}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
{...domProps}
>
{layout.zones.map((zone: any) => {
const zoneChildren = renderer.getZoneChildren(zone.id);
// 그리드 위치 설정
const zoneStyle: React.CSSProperties = {
gridRow: zone.position.row !== undefined ? zone.position.row + 1 : undefined,
gridColumn: zone.position.column !== undefined ? zone.position.column + 1 : undefined,
};
// 레이아웃 전체 높이를 행 수로 나누어 각 존의 기본 높이 계산
// layout은 LayoutComponent이고, 실제 높이는 layout.size.height에 있음
const layoutHeight = layout.size?.height || 400; // 기본값 400px
const rowHeight = Math.floor(layoutHeight / gridConfig.rows);
return renderer.renderZone(zone, zoneChildren, {
style: zoneStyle,
className: "grid-zone",
});
})}
console.log("🔍 GridLayout 높이 정보:", {
layoutId: layout.id,
layoutSize: layout.size,
layoutHeight,
rowHeight,
gridRows: gridConfig.rows,
zoneId: zone.id,
zoneSize: zone.size,
});
{/* 디자인 모드에서 빈 그리드 셀 표시 */}
{isDesignMode && <GridEmptyCells gridConfig={gridConfig} layout={layout} isDesignMode={isDesignMode} />}
</div>
// 그리드 위치 설정
const zoneStyle: React.CSSProperties = {
gridRow: zone.position.row !== undefined ? zone.position.row + 1 : undefined,
gridColumn: zone.position.column !== undefined ? zone.position.column + 1 : undefined,
// 카드 레이아웃처럼 항상 명확한 경계 표시
backgroundColor: "white",
border: "1px solid #e5e7eb",
borderRadius: "8px",
padding: "16px",
boxShadow: "0 1px 3px 0 rgba(0, 0, 0, 0.1)",
// 존의 높이: 개별 설정이 있으면 우선, 없으면 부모 높이를 100% 따라가도록
height: zone.size?.height
? typeof zone.size.height === "number"
? `${zone.size.height}px`
: zone.size.height
: "100%", // 그리드 셀의 높이를 100% 따라감
minHeight: zone.size?.minHeight
? typeof zone.size.minHeight === "number"
? `${zone.size.minHeight}px`
: zone.size.minHeight
: "100px",
maxHeight: zone.size?.maxHeight
? typeof zone.size.maxHeight === "number"
? `${zone.size.maxHeight}px`
: zone.size.maxHeight
: "none",
position: "relative",
transition: "all 0.2s ease",
overflow: "hidden",
display: "flex",
flexDirection: "column",
};
return (
<div
key={zone.id}
style={zoneStyle}
className="grid-zone"
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "#3b82f6";
e.currentTarget.style.boxShadow =
"0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 0 0 2px rgba(59, 130, 246, 0.1)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "#e5e7eb";
e.currentTarget.style.boxShadow = "0 1px 3px 0 rgba(0, 0, 0, 0.1)";
}}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
if (onComponentDrop) {
onComponentDrop(e, zone.id); // 존 ID와 함께 드롭 이벤트 전달
}
}}
onClick={(e) => {
e.stopPropagation();
if (onZoneClick) {
onZoneClick(zone.id);
}
}}
>
{/* 존 라벨 */}
{isDesignMode && (
<div
className="zone-label"
style={{
position: "absolute",
top: "-2px",
left: "8px",
backgroundColor: "#3b82f6",
color: "white",
fontSize: "10px",
padding: "2px 6px",
borderRadius: "0 0 4px 4px",
fontWeight: "500",
zIndex: 10,
}}
>
{zone.name || zone.id}
</div>
)}
{/* 존 내용 */}
<div
style={{
paddingTop: isDesignMode ? "16px" : "0",
flex: 1,
position: "relative",
minHeight: "100px",
}}
className="grid-zone-content"
>
{/* 존 안의 컴포넌트들을 절대 위치로 렌더링 */}
{zoneChildren.map((child: any) => (
<div
key={child.id}
style={{
position: "absolute",
left: child.position?.x || 0,
top: child.position?.y || 0,
width: child.size?.width || "auto",
height: child.size?.height || "auto",
zIndex: child.position?.z || 1,
}}
>
{renderer.renderChild(child)}
</div>
))}
</div>
</div>
);
})}
{/* 디자인 모드에서 빈 그리드 셀 표시 */}
{isDesignMode && <GridEmptyCells gridConfig={gridConfig} layout={layout} isDesignMode={isDesignMode} />}
</div>
</>
);
};

View File

@ -21,10 +21,10 @@ export const HeroSectionLayout: React.FC<HeroSectionLayoutProps> = ({
}) => {
if (!layout.layoutConfig.heroSection) {
return (
<div className="error-layout flex items-center justify-center p-4 border-2 border-red-300 bg-red-50 rounded">
<div className="error-layout flex items-center justify-center rounded border-2 border-red-300 bg-red-50 p-4">
<div className="text-center text-red-600">
<div className="font-medium">heroSection .</div>
<div className="text-sm mt-1">layoutConfig.heroSection가 .</div>
<div className="mt-1 text-sm">layoutConfig.heroSection가 .</div>
</div>
</div>
);
@ -36,9 +36,11 @@ export const HeroSectionLayout: React.FC<HeroSectionLayoutProps> = ({
// heroSection 컨테이너 스타일
const heroSectionStyle: React.CSSProperties = {
...containerStyle,
// TODO: 레이아웃 전용 스타일 정의
display: "flex",
flexDirection: "column",
height: "100%",
width: "100%",
padding: "16px",
};
// 디자인 모드 스타일
@ -47,52 +49,159 @@ export const HeroSectionLayout: React.FC<HeroSectionLayoutProps> = ({
heroSectionStyle.borderRadius = "8px";
}
// DOM props만 추출 (React DOM에서 인식하는 props만)
const {
children: propsChildren,
onUpdateLayout,
onSelectComponent,
isDesignMode: _isDesignMode,
allComponents,
onZoneClick,
onComponentDrop,
onDragStart,
onDragEnd,
selectedScreen, // DOM에 전달하지 않도록 제외
...domProps
} = props;
return (
<div
className={`hero-section-layout ${isDesignMode ? "design-mode" : ""} ${className}`}
style={heroSectionStyle}
onClick={onClick}
draggable={isDesignMode}
onDragStart={props.onDragStart}
onDragEnd={props.onDragEnd}
{...props}
>
{layout.zones.map((zone: any) => {
const zoneChildren = renderer.getZoneChildren(zone.id);
<>
<style jsx>{`
.force-hero-layout {
display: flex !important;
flex-direction: column !important;
height: ${layout.size?.height ? `${layout.size.height}px` : "100%"} !important;
min-height: ${layout.size?.height ? `${layout.size.height}px` : "200px"} !important;
width: 100% !important;
}
`}</style>
<div
className={`hero-section-layout force-hero-layout ${isDesignMode ? "design-mode" : ""} ${className}`}
style={heroSectionStyle}
onClick={onClick}
draggable={isDesignMode}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
{...domProps}
>
{layout.zones.map((zone: any, index: number) => {
const zoneChildren = renderer.getZoneChildren(zone.id);
// TODO: 존별 스타일 정의
const zoneStyle: React.CSSProperties = {
// 레이아웃별 존 스타일 구현
};
// 레이아웃 전체 높이 기준으로 존 높이 계산
const layoutHeight = layout.size?.height || 600; // 영웅 섹션은 기본값 600px
const defaultZoneHeight = Math.floor(layoutHeight / layout.zones.length) - 32; // 존 개수로 나눔
return renderer.renderZone(zone, zoneChildren, {
style: zoneStyle,
className: "hero-section-zone",
});
})}
{/* 디자인 모드에서 빈 영역 표시 */}
{isDesignMode && layout.zones.length === 0 && (
<div
className="empty-hero-section-container"
style={{
flex: 1,
border: "2px dashed #cbd5e1",
// 영웅 섹션 존 스타일 (일반적으로 세로로 배치)
const zoneStyle: React.CSSProperties = {
width: "100%",
marginBottom: index < layout.zones.length - 1 ? "16px" : "0",
// 카드 레이아웃처럼 항상 명확한 경계 표시
backgroundColor: "white",
border: "1px solid #e5e7eb",
borderRadius: "8px",
backgroundColor: "rgba(148, 163, 184, 0.05)",
padding: "16px",
boxShadow: "0 1px 3px 0 rgba(0, 0, 0, 0.1)",
// 존의 높이: 개별 설정이 있으면 우선, 없으면 flex로 자동 분배
height: zone.size?.height
? typeof zone.size.height === "number"
? `${zone.size.height}px`
: zone.size.height
: "auto", // flex 컨테이너에서 자동으로 높이 분배
flex: zone.size?.height ? "none" : "1", // 개별 높이가 없으면 flex로 균등 분배
minHeight: zone.size?.minHeight
? typeof zone.size.minHeight === "number"
? `${zone.size.minHeight}px`
: zone.size.minHeight
: "120px",
maxHeight: zone.size?.maxHeight
? typeof zone.size.maxHeight === "number"
? `${zone.size.maxHeight}px`
: zone.size.maxHeight
: "none",
position: "relative",
transition: "all 0.2s ease",
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
color: "#64748b",
minHeight: "100px",
padding: "20px",
textAlign: "center",
}}
>
heroSection에
</div>
)}
</div>
flexDirection: "column",
};
return (
<div
key={zone.id}
style={zoneStyle}
className="hero-section-zone"
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "#3b82f6";
e.currentTarget.style.boxShadow =
"0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 0 0 2px rgba(59, 130, 246, 0.1)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "#e5e7eb";
e.currentTarget.style.boxShadow = "0 1px 3px 0 rgba(0, 0, 0, 0.1)";
}}
>
{/* 존 라벨 */}
{isDesignMode && (
<div
className="zone-label"
style={{
position: "absolute",
top: "-2px",
left: "8px",
backgroundColor: "#3b82f6",
color: "white",
fontSize: "10px",
padding: "2px 6px",
borderRadius: "0 0 4px 4px",
fontWeight: "500",
zIndex: 10,
}}
>
{zone.name || zone.id}
</div>
)}
{/* 존 내용 */}
<div
style={{ paddingTop: isDesignMode ? "16px" : "0", flex: 1, display: "flex", flexDirection: "column" }}
>
{renderer.renderZone(zone, zoneChildren, {
style: {
border: "none",
backgroundColor: "transparent",
flex: 1,
minHeight: "0",
},
className: "hero-section-zone-content",
})}
</div>
</div>
);
})}
{/* 디자인 모드에서 빈 영역 표시 */}
{isDesignMode && layout.zones.length === 0 && (
<div
className="empty-hero-section-container"
style={{
flex: 1,
border: "2px dashed #cbd5e1",
borderRadius: "8px",
backgroundColor: "rgba(148, 163, 184, 0.05)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
color: "#64748b",
minHeight: "100px",
padding: "20px",
textAlign: "center",
}}
>
heroSection에
</div>
)}
</div>
</>
);
};

View File

@ -4,7 +4,6 @@ import { LayoutRegistry } from "../LayoutRegistry";
import { discoverLayouts } from "../utils/autoDiscovery";
// 기존 레이아웃들 (호환성을 위해 유지)
import { SplitLayout } from "./SplitLayoutRenderer";
import { TabsLayout } from "./TabsLayoutRenderer";
// 새 구조 레이아웃들 (자동 등록)
@ -68,7 +67,7 @@ async function initializeLegacyLayouts() {
tabs: {
position: "top",
variant: "default",
size: "default",
size: "md",
closable: false,
defaultTab: "tab1",
},

View File

@ -21,10 +21,10 @@ export const SplitLayout: React.FC<SplitLayoutProps> = ({
}) => {
if (!layout.layoutConfig.split) {
return (
<div className="error-layout flex items-center justify-center p-4 border-2 border-red-300 bg-red-50 rounded">
<div className="error-layout flex items-center justify-center rounded border-2 border-red-300 bg-red-50 p-4">
<div className="text-center text-red-600">
<div className="font-medium">split .</div>
<div className="text-sm mt-1">layoutConfig.split가 .</div>
<div className="mt-1 text-sm">layoutConfig.split가 .</div>
</div>
</div>
);
@ -36,9 +36,12 @@ export const SplitLayout: React.FC<SplitLayoutProps> = ({
// split 컨테이너 스타일
const splitStyle: React.CSSProperties = {
...containerStyle,
// TODO: 레이아웃 전용 스타일 정의
display: "flex",
flexDirection: splitConfig.direction || "row", // 기본값: 가로 분할
height: "100%",
width: "100%",
gap: "8px",
padding: "8px",
};
// 디자인 모드 스타일
@ -47,52 +50,160 @@ export const SplitLayout: React.FC<SplitLayoutProps> = ({
splitStyle.borderRadius = "8px";
}
// DOM props만 추출 (React DOM에서 인식하는 props만)
const {
children: propsChildren,
onUpdateLayout,
onSelectComponent,
isDesignMode: _isDesignMode,
allComponents,
onZoneClick,
onComponentDrop,
onDragStart,
onDragEnd,
selectedScreen, // DOM에 전달하지 않도록 제외
...domProps
} = props;
return (
<div
className={`split-layout ${isDesignMode ? "design-mode" : ""} ${className}`}
style={splitStyle}
onClick={onClick}
draggable={isDesignMode}
onDragStart={props.onDragStart}
onDragEnd={props.onDragEnd}
{...props}
>
{layout.zones.map((zone: any) => {
const zoneChildren = renderer.getZoneChildren(zone.id);
<>
<style jsx>{`
.force-split-layout {
display: flex !important;
height: ${layout.size?.height ? `${layout.size.height}px` : "100%"} !important;
min-height: ${layout.size?.height ? `${layout.size.height}px` : "200px"} !important;
width: 100% !important;
}
`}</style>
<div
className={`split-layout force-split-layout ${isDesignMode ? "design-mode" : ""} ${className}`}
style={splitStyle}
onClick={onClick}
draggable={isDesignMode}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
{...domProps}
>
{layout.zones.map((zone: any, index: number) => {
const zoneChildren = renderer.getZoneChildren(zone.id);
// TODO: 존별 스타일 정의
const zoneStyle: React.CSSProperties = {
// 레이아웃별 존 스타일 구현
};
// 레이아웃 전체 높이 기준으로 존 높이 계산
const layoutHeight = layout.size?.height || 400; // 기본값 400px
const defaultZoneHeight =
splitConfig.direction === "vertical"
? Math.floor(layoutHeight / layout.zones.length) - 32 // 세로 분할시 존 개수로 나눔
: layoutHeight - 32; // 가로 분할시 전체 높이 사용
return renderer.renderZone(zone, zoneChildren, {
style: zoneStyle,
className: "split-zone",
});
})}
{/* 디자인 모드에서 빈 영역 표시 */}
{isDesignMode && layout.zones.length === 0 && (
<div
className="empty-split-container"
style={{
flex: 1,
border: "2px dashed #cbd5e1",
// 분할 레이아웃 존 스타일
const zoneStyle: React.CSSProperties = {
flex: 1, // 동일한 크기로 분할
// 카드 레이아웃처럼 항상 명확한 경계 표시
backgroundColor: "white",
border: "1px solid #e5e7eb",
borderRadius: "8px",
backgroundColor: "rgba(148, 163, 184, 0.05)",
padding: "16px",
boxShadow: "0 1px 3px 0 rgba(0, 0, 0, 0.1)",
// 존의 높이: 개별 설정이 있으면 우선, 없으면 부모 높이를 100% 따라가도록
height: zone.size?.height
? typeof zone.size.height === "number"
? `${zone.size.height}px`
: zone.size.height
: "100%", // 분할 영역의 높이를 100% 따라감
minHeight: zone.size?.minHeight
? typeof zone.size.minHeight === "number"
? `${zone.size.minHeight}px`
: zone.size.minHeight
: "100px",
maxHeight: zone.size?.maxHeight
? typeof zone.size.maxHeight === "number"
? `${zone.size.maxHeight}px`
: zone.size.maxHeight
: "none",
position: "relative",
transition: "all 0.2s ease",
margin: "4px",
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
color: "#64748b",
minHeight: "100px",
padding: "20px",
textAlign: "center",
}}
>
split에
</div>
)}
</div>
flexDirection: "column",
};
return (
<div
key={zone.id}
style={zoneStyle}
className="split-zone"
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "#3b82f6";
e.currentTarget.style.boxShadow =
"0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 0 0 2px rgba(59, 130, 246, 0.1)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "#e5e7eb";
e.currentTarget.style.boxShadow = "0 1px 3px 0 rgba(0, 0, 0, 0.1)";
}}
>
{/* 존 라벨 */}
{isDesignMode && (
<div
className="zone-label"
style={{
position: "absolute",
top: "-2px",
left: "8px",
backgroundColor: "#3b82f6",
color: "white",
fontSize: "10px",
padding: "2px 6px",
borderRadius: "0 0 4px 4px",
fontWeight: "500",
zIndex: 10,
}}
>
{zone.name || zone.id}
</div>
)}
{/* 존 내용 */}
<div
style={{ paddingTop: isDesignMode ? "16px" : "0", flex: 1, display: "flex", flexDirection: "column" }}
>
{renderer.renderZone(zone, zoneChildren, {
style: {
border: "none",
backgroundColor: "transparent",
flex: 1,
minHeight: "0",
},
className: "split-zone-content",
})}
</div>
</div>
);
})}
{/* 디자인 모드에서 빈 영역 표시 */}
{isDesignMode && layout.zones.length === 0 && (
<div
className="empty-split-container"
style={{
flex: 1,
border: "2px dashed #cbd5e1",
borderRadius: "8px",
backgroundColor: "rgba(148, 163, 184, 0.05)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
color: "#64748b",
minHeight: "100px",
padding: "20px",
textAlign: "center",
}}
>
split에
</div>
)}
</div>
</>
);
};

View File

@ -135,6 +135,33 @@ export interface LayoutConfig {
className: string;
template: string; // HTML 템플릿
};
// 카드 레이아웃 설정
card?: {
// 테이블 컬럼 매핑 설정
columnMapping?: {
titleColumn?: string; // 카드 타이틀로 사용할 컬럼
subtitleColumn?: string; // 카드 서브타이틀로 사용할 컬럼
imageColumn?: string; // 카드 이미지로 사용할 컬럼
descriptionColumn?: string; // 카드 설명으로 사용할 컬럼
displayColumns?: string[]; // 카드에 표시할 추가 컬럼들
actionColumns?: string[]; // 액션 버튼으로 표시할 컬럼들
};
// 카드 스타일 설정
cardStyle?: {
showImage?: boolean;
showTitle?: boolean;
showSubtitle?: boolean;
showDescription?: boolean;
maxDescriptionLength?: number;
imagePosition?: "top" | "left" | "right";
imageSize?: "small" | "medium" | "large";
};
// 그리드 설정
cardsPerRow?: number; // 한 행에 표시할 카드 수
cardSpacing?: number; // 카드 간격
autoHeight?: boolean; // 자동 높이 조정
};
}
// 드롭존 설정

View File

@ -160,6 +160,7 @@ export interface BaseComponent {
position: Position;
size: { width: number; height: number };
parentId?: string;
zoneId?: string; // 레이아웃 존 ID (레이아웃 내 배치용)
style?: ComponentStyle; // 스타일 속성 추가
tableName?: string; // 테이블명 추가
label?: string; // 라벨 추가