리피터 컨테이너 기능 추가: ScreenDesigner 컴포넌트에 리피터 컨테이너 내부 드롭 처리 로직을 추가하여, 드롭 시 새로운 자식 컴포넌트를 생성하고 레이아웃을 업데이트합니다. 또한, TableListComponent에서 리피터 컨테이너와 집계 위젯 연동을 위한 커스텀 이벤트를 발생시켜 데이터 변경 사항을 처리할 수 있도록 개선하였습니다.
This commit is contained in:
parent
28f67cb0b6
commit
9d74baf60a
|
|
@ -2222,6 +2222,56 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 🎯 리피터 컨테이너 내부 드롭 처리
|
||||
const dropTarget = e.target as HTMLElement;
|
||||
const repeatContainer = dropTarget.closest('[data-repeat-container="true"]');
|
||||
if (repeatContainer) {
|
||||
const containerId = repeatContainer.getAttribute("data-component-id");
|
||||
if (containerId) {
|
||||
console.log("리피터 컨테이너 내부 드롭 감지:", { containerId, component });
|
||||
|
||||
// 해당 리피터 컨테이너 찾기
|
||||
const targetComponent = layout.components.find((c) => c.id === containerId);
|
||||
if (targetComponent && (targetComponent as any).componentType === "repeat-container") {
|
||||
const currentConfig = (targetComponent as any).componentConfig || {};
|
||||
const currentChildren = currentConfig.children || [];
|
||||
|
||||
// 새 자식 컴포넌트 생성
|
||||
const newChild = {
|
||||
id: `slot_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
componentType: component.id || component.componentType || "text-display",
|
||||
label: component.name || component.label || "새 컴포넌트",
|
||||
fieldName: "",
|
||||
position: { x: 0, y: currentChildren.length * 40 },
|
||||
size: component.defaultSize || { width: 200, height: 32 },
|
||||
componentConfig: component.defaultConfig || {},
|
||||
};
|
||||
|
||||
// 컴포넌트 업데이트
|
||||
const updatedComponent = {
|
||||
...targetComponent,
|
||||
componentConfig: {
|
||||
...currentConfig,
|
||||
children: [...currentChildren, newChild],
|
||||
},
|
||||
};
|
||||
|
||||
const newLayout = {
|
||||
...layout,
|
||||
components: layout.components.map((c) =>
|
||||
c.id === containerId ? updatedComponent : c
|
||||
),
|
||||
};
|
||||
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
console.log("리피터 컨테이너에 컴포넌트 추가 완료:", newChild);
|
||||
return; // 리피터 컨테이너 처리 완료
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
|
|
@ -2562,6 +2612,52 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
const dropTarget = e.target as HTMLElement;
|
||||
const formContainer = dropTarget.closest('[data-form-container="true"]');
|
||||
|
||||
// 🎯 리피터 컨테이너 내부에 컬럼 드롭 시 처리
|
||||
const repeatContainer = dropTarget.closest('[data-repeat-container="true"]');
|
||||
if (repeatContainer && type === "column" && column) {
|
||||
const containerId = repeatContainer.getAttribute("data-component-id");
|
||||
if (containerId) {
|
||||
console.log("리피터 컨테이너 내부에 컬럼 드롭:", { containerId, column });
|
||||
|
||||
const targetComponent = layout.components.find((c) => c.id === containerId);
|
||||
if (targetComponent && (targetComponent as any).componentType === "repeat-container") {
|
||||
const currentConfig = (targetComponent as any).componentConfig || {};
|
||||
const currentChildren = currentConfig.children || [];
|
||||
|
||||
// 새 자식 컴포넌트 생성 (컬럼 기반)
|
||||
const newChild = {
|
||||
id: `slot_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
componentType: column.widgetType || "text-display",
|
||||
label: column.columnLabel || column.columnName,
|
||||
fieldName: column.columnName,
|
||||
position: { x: 0, y: currentChildren.length * 40 },
|
||||
size: { width: 200, height: 32 },
|
||||
componentConfig: {},
|
||||
};
|
||||
|
||||
const updatedComponent = {
|
||||
...targetComponent,
|
||||
componentConfig: {
|
||||
...currentConfig,
|
||||
children: [...currentChildren, newChild],
|
||||
},
|
||||
};
|
||||
|
||||
const newLayout = {
|
||||
...layout,
|
||||
components: layout.components.map((c) =>
|
||||
c.id === containerId ? updatedComponent : c
|
||||
),
|
||||
};
|
||||
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
console.log("리피터 컨테이너에 컬럼 기반 컴포넌트 추가 완료:", newChild);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
|
|
@ -4687,9 +4783,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
onDropCapture={(e) => {
|
||||
// 캡처 단계에서 드롭 이벤트를 처리하여 자식 요소 드롭도 감지
|
||||
e.preventDefault();
|
||||
// console.log("🎯 캔버스 드롭 이벤트 발생");
|
||||
console.log("🎯 캔버스 드롭 이벤트 (캡처), target:", (e.target as HTMLElement).tagName, (e.target as HTMLElement).getAttribute("data-repeat-container"));
|
||||
handleDrop(e);
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -97,6 +97,9 @@ import "./pivot-grid/PivotGridRenderer"; // 피벗 테이블 (행/열 그룹화,
|
|||
// 🆕 집계 위젯 컴포넌트
|
||||
import "./aggregation-widget/AggregationWidgetRenderer"; // 데이터 집계 (합계, 평균, 개수 등)
|
||||
|
||||
// 🆕 리피터 컨테이너 컴포넌트
|
||||
import "./repeat-container/RepeatContainerRenderer"; // 데이터 수만큼 반복 렌더링
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,656 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { RepeatContainerConfig, RepeatItemContext, SlotComponentConfig } from "./types";
|
||||
import { Repeat, Package, ChevronLeft, ChevronRight, Plus } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import DynamicComponentRenderer from "@/lib/registry/DynamicComponentRenderer";
|
||||
|
||||
interface RepeatContainerComponentProps extends ComponentRendererProps {
|
||||
config?: RepeatContainerConfig;
|
||||
// 외부에서 데이터를 직접 전달받을 수 있음
|
||||
externalData?: any[];
|
||||
// 내부 컴포넌트를 렌더링하는 슬롯 (children 대용)
|
||||
renderItem?: (context: RepeatItemContext) => React.ReactNode;
|
||||
// formData 접근
|
||||
formData?: Record<string, any>;
|
||||
// formData 변경 콜백
|
||||
onFormDataChange?: (key: string, value: any) => void;
|
||||
// 선택 변경 콜백
|
||||
onSelectionChange?: (selectedData: any[]) => void;
|
||||
// 사용자 정보
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
companyCode?: string;
|
||||
// 화면 정보
|
||||
screenId?: number;
|
||||
screenTableName?: string;
|
||||
// 컴포넌트 업데이트 콜백 (디자인 모드에서 드래그앤드롭용)
|
||||
onUpdateComponent?: (updates: Partial<RepeatContainerConfig>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 리피터 컨테이너 컴포넌트
|
||||
* 데이터 수만큼 내부 컨텐츠를 반복 렌더링하는 컨테이너
|
||||
*/
|
||||
export function RepeatContainerComponent({
|
||||
component,
|
||||
isDesignMode = false,
|
||||
config: propsConfig,
|
||||
externalData,
|
||||
renderItem,
|
||||
formData = {},
|
||||
onFormDataChange,
|
||||
onSelectionChange,
|
||||
userId,
|
||||
userName,
|
||||
companyCode,
|
||||
screenId,
|
||||
screenTableName,
|
||||
onUpdateComponent,
|
||||
}: RepeatContainerComponentProps) {
|
||||
const componentConfig: RepeatContainerConfig = {
|
||||
dataSourceType: "manual",
|
||||
layout: "vertical",
|
||||
gridColumns: 2,
|
||||
gap: "16px",
|
||||
showBorder: true,
|
||||
showShadow: false,
|
||||
borderRadius: "8px",
|
||||
backgroundColor: "#ffffff",
|
||||
padding: "16px",
|
||||
showItemTitle: false,
|
||||
itemTitleTemplate: "",
|
||||
titleFontSize: "14px",
|
||||
titleColor: "#374151",
|
||||
titleFontWeight: "600",
|
||||
emptyMessage: "데이터가 없습니다",
|
||||
usePaging: false,
|
||||
pageSize: 10,
|
||||
clickable: false,
|
||||
showSelectedState: true,
|
||||
selectionMode: "single",
|
||||
...propsConfig,
|
||||
...component?.config,
|
||||
...component?.componentConfig,
|
||||
};
|
||||
|
||||
const {
|
||||
dataSourceType,
|
||||
dataSourceComponentId,
|
||||
tableName,
|
||||
customTableName,
|
||||
useCustomTable,
|
||||
layout,
|
||||
gridColumns,
|
||||
gap,
|
||||
itemMinWidth,
|
||||
itemMaxWidth,
|
||||
itemHeight,
|
||||
showBorder,
|
||||
showShadow,
|
||||
borderRadius,
|
||||
backgroundColor,
|
||||
padding,
|
||||
showItemTitle,
|
||||
itemTitleTemplate,
|
||||
titleFontSize,
|
||||
titleColor,
|
||||
titleFontWeight,
|
||||
filterField,
|
||||
filterColumn,
|
||||
useGrouping,
|
||||
groupByField,
|
||||
children: slotChildren,
|
||||
emptyMessage,
|
||||
usePaging,
|
||||
pageSize,
|
||||
clickable,
|
||||
showSelectedState,
|
||||
selectionMode,
|
||||
} = componentConfig;
|
||||
|
||||
// 데이터 상태
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [selectedIndices, setSelectedIndices] = useState<number[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 실제 사용할 테이블명
|
||||
const effectiveTableName = useCustomTable ? customTableName : tableName;
|
||||
|
||||
// 외부 데이터가 있으면 사용
|
||||
useEffect(() => {
|
||||
if (externalData && Array.isArray(externalData)) {
|
||||
setData(externalData);
|
||||
}
|
||||
}, [externalData]);
|
||||
|
||||
// 컴포넌트 데이터 변경 이벤트 리스닝 (componentId 또는 tableName으로 매칭)
|
||||
useEffect(() => {
|
||||
if (isDesignMode) return;
|
||||
|
||||
console.log("🔄 리피터 컨테이너 이벤트 리스너 등록:", {
|
||||
componentId: component?.id,
|
||||
dataSourceType,
|
||||
dataSourceComponentId,
|
||||
effectiveTableName,
|
||||
});
|
||||
|
||||
// dataSourceComponentId가 없어도 테이블명으로 매칭 가능
|
||||
const handleDataChange = (event: CustomEvent) => {
|
||||
const { componentId, tableName: eventTableName, data: eventData } = event.detail || {};
|
||||
|
||||
console.log("📩 리피터 컨테이너 이벤트 수신:", {
|
||||
eventType: event.type,
|
||||
fromComponentId: componentId,
|
||||
fromTableName: eventTableName,
|
||||
dataCount: Array.isArray(eventData) ? eventData.length : 0,
|
||||
myDataSourceComponentId: dataSourceComponentId,
|
||||
myEffectiveTableName: effectiveTableName,
|
||||
});
|
||||
|
||||
// 1. 명시적으로 dataSourceComponentId가 설정된 경우 해당 컴포넌트만 매칭
|
||||
if (dataSourceComponentId) {
|
||||
if (componentId === dataSourceComponentId && Array.isArray(eventData)) {
|
||||
console.log("✅ 리피터: 컴포넌트 ID로 데이터 수신 성공", { componentId, count: eventData.length });
|
||||
setData(eventData);
|
||||
setCurrentPage(1);
|
||||
setSelectedIndices([]);
|
||||
} else {
|
||||
console.log("⚠️ 리피터: 컴포넌트 ID 불일치로 무시", { expected: dataSourceComponentId, received: componentId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. dataSourceComponentId가 없으면 테이블명으로 매칭
|
||||
if (effectiveTableName && eventTableName === effectiveTableName && Array.isArray(eventData)) {
|
||||
console.log("✅ 리피터: 테이블명으로 데이터 수신 성공", { tableName: eventTableName, count: eventData.length });
|
||||
setData(eventData);
|
||||
setCurrentPage(1);
|
||||
setSelectedIndices([]);
|
||||
} else if (effectiveTableName) {
|
||||
console.log("⚠️ 리피터: 테이블명 불일치로 무시", { expected: effectiveTableName, received: eventTableName });
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("repeaterDataChange" as any, handleDataChange);
|
||||
window.addEventListener("tableListDataChange" as any, handleDataChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("repeaterDataChange" as any, handleDataChange);
|
||||
window.removeEventListener("tableListDataChange" as any, handleDataChange);
|
||||
};
|
||||
}, [component?.id, dataSourceType, dataSourceComponentId, effectiveTableName, isDesignMode]);
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredData = useMemo(() => {
|
||||
if (!filterField || !filterColumn) return data;
|
||||
|
||||
const filterValue = formData[filterField];
|
||||
if (filterValue === undefined || filterValue === null) return data;
|
||||
|
||||
if (Array.isArray(filterValue)) {
|
||||
return data.filter((row) => filterValue.includes(row[filterColumn]));
|
||||
}
|
||||
|
||||
return data.filter((row) => row[filterColumn] === filterValue);
|
||||
}, [data, filterField, filterColumn, formData]);
|
||||
|
||||
// 그룹핑된 데이터
|
||||
const groupedData = useMemo(() => {
|
||||
if (!useGrouping || !groupByField) return null;
|
||||
|
||||
const groups: Record<string, any[]> = {};
|
||||
filteredData.forEach((row) => {
|
||||
const key = String(row[groupByField] ?? "기타");
|
||||
if (!groups[key]) {
|
||||
groups[key] = [];
|
||||
}
|
||||
groups[key].push(row);
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [filteredData, useGrouping, groupByField]);
|
||||
|
||||
// 페이징된 데이터
|
||||
const paginatedData = useMemo(() => {
|
||||
if (!usePaging || !pageSize) return filteredData;
|
||||
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
return filteredData.slice(startIndex, startIndex + pageSize);
|
||||
}, [filteredData, usePaging, pageSize, currentPage]);
|
||||
|
||||
// 총 페이지 수
|
||||
const totalPages = useMemo(() => {
|
||||
if (!usePaging || !pageSize || filteredData.length === 0) return 1;
|
||||
return Math.ceil(filteredData.length / pageSize);
|
||||
}, [filteredData.length, usePaging, pageSize]);
|
||||
|
||||
// 아이템 제목 생성
|
||||
const generateTitle = useCallback(
|
||||
(rowData: Record<string, any>, index: number): string => {
|
||||
if (!showItemTitle) return "";
|
||||
|
||||
if (!itemTitleTemplate) {
|
||||
return `아이템 ${index + 1}`;
|
||||
}
|
||||
|
||||
return itemTitleTemplate.replace(/\{([^}]+)\}/g, (match, field) => {
|
||||
return String(rowData[field] ?? "");
|
||||
});
|
||||
},
|
||||
[showItemTitle, itemTitleTemplate]
|
||||
);
|
||||
|
||||
// 아이템 클릭 핸들러
|
||||
const handleItemClick = useCallback(
|
||||
(index: number, rowData: any) => {
|
||||
if (!clickable) return;
|
||||
|
||||
let newSelectedIndices: number[];
|
||||
|
||||
if (selectionMode === "multiple") {
|
||||
if (selectedIndices.includes(index)) {
|
||||
newSelectedIndices = selectedIndices.filter((i) => i !== index);
|
||||
} else {
|
||||
newSelectedIndices = [...selectedIndices, index];
|
||||
}
|
||||
} else {
|
||||
newSelectedIndices = selectedIndices.includes(index) ? [] : [index];
|
||||
}
|
||||
|
||||
setSelectedIndices(newSelectedIndices);
|
||||
|
||||
if (onSelectionChange) {
|
||||
const selectedData = newSelectedIndices.map((i) => paginatedData[i]);
|
||||
onSelectionChange(selectedData);
|
||||
}
|
||||
},
|
||||
[clickable, selectionMode, selectedIndices, paginatedData, onSelectionChange]
|
||||
);
|
||||
|
||||
// 레이아웃 스타일 계산
|
||||
const layoutStyle = useMemo(() => {
|
||||
const baseStyle: React.CSSProperties = {
|
||||
gap: gap || "16px",
|
||||
};
|
||||
|
||||
switch (layout) {
|
||||
case "horizontal":
|
||||
return {
|
||||
...baseStyle,
|
||||
display: "flex",
|
||||
flexDirection: "row" as const,
|
||||
flexWrap: "wrap" as const,
|
||||
};
|
||||
case "grid":
|
||||
return {
|
||||
...baseStyle,
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${gridColumns || 2}, 1fr)`,
|
||||
};
|
||||
case "vertical":
|
||||
default:
|
||||
return {
|
||||
...baseStyle,
|
||||
display: "flex",
|
||||
flexDirection: "column" as const,
|
||||
};
|
||||
}
|
||||
}, [layout, gap, gridColumns]);
|
||||
|
||||
// 아이템 스타일 계산
|
||||
const itemStyle = useMemo((): React.CSSProperties => {
|
||||
return {
|
||||
minWidth: itemMinWidth,
|
||||
maxWidth: itemMaxWidth,
|
||||
height: itemHeight,
|
||||
backgroundColor: backgroundColor || "#ffffff",
|
||||
borderRadius: borderRadius || "8px",
|
||||
padding: padding || "16px",
|
||||
border: showBorder ? "1px solid #e5e7eb" : "none",
|
||||
boxShadow: showShadow ? "0 1px 3px rgba(0,0,0,0.1)" : "none",
|
||||
};
|
||||
}, [itemMinWidth, itemMaxWidth, itemHeight, backgroundColor, borderRadius, padding, showBorder, showShadow]);
|
||||
|
||||
// 슬롯 자식 컴포넌트들을 렌더링
|
||||
const renderSlotChildren = useCallback(
|
||||
(context: RepeatItemContext) => {
|
||||
// renderItem prop이 있으면 우선 사용
|
||||
if (renderItem) {
|
||||
return renderItem(context);
|
||||
}
|
||||
|
||||
// 슬롯에 배치된 자식 컴포넌트가 없으면 기본 메시지
|
||||
if (!slotChildren || slotChildren.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
반복 아이템 #{context.index + 1}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 현재 아이템 데이터를 formData로 전달
|
||||
const itemFormData = {
|
||||
...formData,
|
||||
...context.data,
|
||||
_repeatIndex: context.index,
|
||||
_repeatTotal: context.totalCount,
|
||||
_isFirst: context.isFirst,
|
||||
_isLast: context.isLast,
|
||||
};
|
||||
|
||||
// 슬롯에 배치된 컴포넌트들을 렌더링
|
||||
return (
|
||||
<div className="relative" style={{ minHeight: "50px" }}>
|
||||
{slotChildren.map((childComp: SlotComponentConfig) => {
|
||||
const { position = { x: 0, y: 0 }, size = { width: 200, height: 40 } } = childComp;
|
||||
|
||||
// DynamicComponentRenderer가 기대하는 형식으로 변환
|
||||
const componentData = {
|
||||
id: `${childComp.id}_${context.index}`,
|
||||
componentType: childComp.componentType,
|
||||
label: childComp.label,
|
||||
columnName: childComp.fieldName,
|
||||
position: { ...position, z: 1 },
|
||||
size,
|
||||
componentConfig: childComp.componentConfig,
|
||||
style: childComp.style,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={componentData.id}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: position.x || 0,
|
||||
top: position.y || 0,
|
||||
width: size.width || 200,
|
||||
height: size.height || 40,
|
||||
}}
|
||||
>
|
||||
<DynamicComponentRenderer
|
||||
component={componentData}
|
||||
isInteractive={true}
|
||||
screenId={screenId}
|
||||
tableName={screenTableName || effectiveTableName}
|
||||
userId={userId}
|
||||
userName={userName}
|
||||
companyCode={companyCode}
|
||||
formData={itemFormData}
|
||||
onFormDataChange={(key, value) => {
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange(`_repeat_${context.index}_${key}`, value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[
|
||||
renderItem,
|
||||
slotChildren,
|
||||
formData,
|
||||
screenId,
|
||||
screenTableName,
|
||||
effectiveTableName,
|
||||
userId,
|
||||
userName,
|
||||
companyCode,
|
||||
onFormDataChange,
|
||||
]
|
||||
);
|
||||
|
||||
// 드래그앤드롭 상태 (시각적 피드백용)
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
// 드래그 오버 핸들러 (시각적 피드백만)
|
||||
// 중요: preventDefault()를 호출해야 드롭 가능 영역으로 인식됨
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}, []);
|
||||
|
||||
// 드래그 리브 핸들러
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
// 자식 요소로 이동할 때 false가 되지 않도록 체크
|
||||
const relatedTarget = e.relatedTarget as HTMLElement;
|
||||
if (relatedTarget && (e.currentTarget as HTMLElement).contains(relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
// 디자인 모드 미리보기
|
||||
if (isDesignMode) {
|
||||
const previewData = [
|
||||
{ id: 1, name: "아이템 1", value: 100 },
|
||||
{ id: 2, name: "아이템 2", value: 200 },
|
||||
{ id: 3, name: "아이템 3", value: 300 },
|
||||
];
|
||||
|
||||
const hasChildren = slotChildren && slotChildren.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-repeat-container="true"
|
||||
data-component-id={component?.id}
|
||||
className={cn(
|
||||
"rounded-md border border-dashed p-3 transition-colors",
|
||||
isDragOver
|
||||
? "border-green-500 bg-green-50/70"
|
||||
: "border-blue-300 bg-blue-50/50"
|
||||
)}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => {
|
||||
// 시각적 상태만 리셋, 드롭 로직은 ScreenDesigner에서 처리
|
||||
setIsDragOver(false);
|
||||
// 중요: preventDefault()를 호출하지 않아야 이벤트가 버블링됨
|
||||
// 하지만 필요하다면 호출해도 됨 - 버블링과 무관
|
||||
}}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-blue-700">
|
||||
<Repeat className="h-4 w-4" />
|
||||
<span className="font-medium">리피터 컨테이너</span>
|
||||
<span className="text-blue-500">({previewData.length}개 미리보기)</span>
|
||||
</div>
|
||||
{isDragOver ? (
|
||||
<div className="flex items-center gap-1 rounded bg-green-100 px-2 py-1 text-xs text-green-700">
|
||||
<Plus className="h-3 w-3" />
|
||||
여기에 놓으세요
|
||||
</div>
|
||||
) : !hasChildren ? (
|
||||
<div className="flex items-center gap-1 rounded bg-amber-100 px-2 py-1 text-xs text-amber-700">
|
||||
<Plus className="h-3 w-3" />
|
||||
컴포넌트를 드래그하세요
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={layoutStyle}>
|
||||
{previewData.map((row, index) => {
|
||||
const context: RepeatItemContext = {
|
||||
index,
|
||||
data: row,
|
||||
totalCount: previewData.length,
|
||||
isFirst: index === 0,
|
||||
isLast: index === previewData.length - 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={row.id || index}
|
||||
style={itemStyle}
|
||||
className={cn(
|
||||
"relative transition-all",
|
||||
clickable && "cursor-pointer hover:shadow-md",
|
||||
showSelectedState &&
|
||||
selectedIndices.includes(index) &&
|
||||
"ring-2 ring-blue-500"
|
||||
)}
|
||||
>
|
||||
{showItemTitle && (
|
||||
<div
|
||||
className="mb-2 border-b pb-2 font-medium"
|
||||
style={{
|
||||
fontSize: titleFontSize,
|
||||
color: titleColor,
|
||||
fontWeight: titleFontWeight,
|
||||
}}
|
||||
>
|
||||
{generateTitle(row, index)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasChildren ? (
|
||||
<div className="space-y-2">
|
||||
{/* 디자인 모드: 배치된 자식 컴포넌트들을 시각적으로 표시 */}
|
||||
{slotChildren!.map((child: SlotComponentConfig, childIdx: number) => (
|
||||
<div
|
||||
key={child.id}
|
||||
className="flex items-center gap-2 rounded border border-dashed border-green-300 bg-green-50/50 px-2 py-1.5"
|
||||
>
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded bg-green-100 text-xs font-medium text-green-700">
|
||||
{childIdx + 1}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium text-green-700">
|
||||
{child.label || child.componentType}
|
||||
</div>
|
||||
{child.fieldName && (
|
||||
<div className="truncate text-[10px] text-green-500">
|
||||
{child.fieldName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] text-green-400">
|
||||
{child.componentType}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="text-center text-[10px] text-slate-400">
|
||||
아이템 #{index + 1} - 실행 시 데이터 바인딩
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="text-xs text-slate-500">
|
||||
반복 아이템 #{index + 1}
|
||||
</div>
|
||||
<div className="mt-1 text-slate-400">
|
||||
컴포넌트를 드래그하여 배치하세요
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 빈 상태
|
||||
if (paginatedData.length === 0 && !isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-md border border-dashed bg-slate-50 py-8 text-center">
|
||||
<Package className="mb-2 h-8 w-8 text-slate-400" />
|
||||
<p className="text-sm text-muted-foreground">{emptyMessage}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-md border bg-slate-50 py-8">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">로딩 중...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 실제 렌더링
|
||||
return (
|
||||
<div className="repeat-container">
|
||||
<div style={layoutStyle}>
|
||||
{paginatedData.map((row, index) => {
|
||||
const context: RepeatItemContext = {
|
||||
index,
|
||||
data: row,
|
||||
totalCount: filteredData.length,
|
||||
isFirst: index === 0,
|
||||
isLast: index === paginatedData.length - 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={row.id || row._id || index}
|
||||
style={itemStyle}
|
||||
className={cn(
|
||||
"repeat-container-item relative transition-all",
|
||||
clickable && "cursor-pointer hover:shadow-md",
|
||||
showSelectedState &&
|
||||
selectedIndices.includes(index) &&
|
||||
"ring-2 ring-blue-500"
|
||||
)}
|
||||
onClick={() => handleItemClick(index, row)}
|
||||
>
|
||||
{showItemTitle && (
|
||||
<div
|
||||
className="mb-2 border-b pb-2"
|
||||
style={{
|
||||
fontSize: titleFontSize,
|
||||
color: titleColor,
|
||||
fontWeight: titleFontWeight,
|
||||
}}
|
||||
>
|
||||
{generateTitle(row, index)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderSlotChildren(context)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 페이징 */}
|
||||
{usePaging && totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<span className="px-3 text-sm text-muted-foreground">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const RepeatContainerWrapper = RepeatContainerComponent;
|
||||
|
|
@ -0,0 +1,803 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Database, Table2, ChevronsUpDown, Check, LayoutGrid, LayoutList, Rows3, Plus, X, GripVertical, Trash2, Type } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RepeatContainerConfig, SlotComponentConfig } from "./types";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
|
||||
interface RepeatContainerConfigPanelProps {
|
||||
config: RepeatContainerConfig;
|
||||
onChange: (config: Partial<RepeatContainerConfig>) => void;
|
||||
screenTableName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 리피터 컨테이너 설정 패널
|
||||
*/
|
||||
export function RepeatContainerConfigPanel({
|
||||
config,
|
||||
onChange,
|
||||
screenTableName,
|
||||
}: RepeatContainerConfigPanelProps) {
|
||||
const [availableTables, setAvailableTables] = useState<Array<{ tableName: string; displayName?: string }>>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
|
||||
|
||||
// 컬럼 관련 상태
|
||||
const [availableColumns, setAvailableColumns] = useState<Array<{ columnName: string; displayName?: string }>>([]);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
|
||||
// 실제 사용할 테이블 이름 계산
|
||||
const targetTableName = useMemo(() => {
|
||||
if (config.useCustomTable && config.customTableName) {
|
||||
return config.customTableName;
|
||||
}
|
||||
return config.tableName || screenTableName;
|
||||
}, [config.useCustomTable, config.customTableName, config.tableName, screenTableName]);
|
||||
|
||||
// 화면 테이블명 자동 설정 (초기 한 번만)
|
||||
useEffect(() => {
|
||||
if (screenTableName && !config.tableName && !config.customTableName) {
|
||||
onChange({ tableName: screenTableName });
|
||||
}
|
||||
}, [screenTableName, config.tableName, config.customTableName, onChange]);
|
||||
|
||||
// 전체 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const fetchTables = async () => {
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const response = await tableTypeApi.getTables();
|
||||
setAvailableTables(
|
||||
response.map((table: any) => ({
|
||||
tableName: table.tableName,
|
||||
displayName: table.displayName || table.tableName,
|
||||
}))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 가져오기 실패:", error);
|
||||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
};
|
||||
fetchTables();
|
||||
}, []);
|
||||
|
||||
// 테이블 컬럼 로드
|
||||
useEffect(() => {
|
||||
if (!targetTableName) {
|
||||
setAvailableColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchColumns = async () => {
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const response = await tableManagementApi.getColumnList(targetTableName);
|
||||
if (response.success && response.data && Array.isArray(response.data)) {
|
||||
setAvailableColumns(
|
||||
response.data.map((col: any) => ({
|
||||
columnName: col.columnName,
|
||||
displayName: col.displayName || col.columnLabel || col.columnName,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 가져오기 실패:", error);
|
||||
setAvailableColumns([]);
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
fetchColumns();
|
||||
}, [targetTableName]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">리피터 컨테이너 설정</div>
|
||||
|
||||
{/* 데이터 소스 테이블 설정 */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">데이터 소스 테이블</h3>
|
||||
<p className="text-muted-foreground text-[10px]">반복 렌더링할 데이터의 테이블을 선택합니다</p>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
|
||||
{/* 현재 선택된 테이블 표시 (카드 형태) */}
|
||||
<div className="flex items-center gap-2 rounded-md border bg-slate-50 p-2">
|
||||
<Database className="h-4 w-4 text-blue-500" />
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium">
|
||||
{config.customTableName || config.tableName || screenTableName || "테이블 미선택"}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{config.useCustomTable ? "커스텀 테이블" : "화면 기본 테이블"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 선택 Combobox */}
|
||||
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tableComboboxOpen}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
disabled={loadingTables}
|
||||
>
|
||||
테이블 변경...
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-xs">테이블을 찾을 수 없습니다</CommandEmpty>
|
||||
|
||||
{/* 그룹 1: 화면 기본 테이블 */}
|
||||
{screenTableName && (
|
||||
<CommandGroup heading="기본 (화면 테이블)">
|
||||
<CommandItem
|
||||
key={`default-${screenTableName}`}
|
||||
value={screenTableName}
|
||||
onSelect={() => {
|
||||
onChange({
|
||||
useCustomTable: false,
|
||||
customTableName: undefined,
|
||||
tableName: screenTableName,
|
||||
});
|
||||
setTableComboboxOpen(false);
|
||||
}}
|
||||
className="text-xs cursor-pointer"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
!config.useCustomTable ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<Database className="mr-2 h-3 w-3 text-blue-500" />
|
||||
{screenTableName}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* 그룹 2: 전체 테이블 */}
|
||||
<CommandGroup heading="전체 테이블">
|
||||
{availableTables
|
||||
.filter((table) => table.tableName !== screenTableName)
|
||||
.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.tableName} ${table.displayName || ""}`}
|
||||
onSelect={() => {
|
||||
onChange({
|
||||
useCustomTable: true,
|
||||
customTableName: table.tableName,
|
||||
tableName: table.tableName,
|
||||
});
|
||||
setTableComboboxOpen(false);
|
||||
}}
|
||||
className="text-xs cursor-pointer"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
config.customTableName === table.tableName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<Table2 className="mr-2 h-3 w-3 text-slate-400" />
|
||||
{table.displayName || table.tableName}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 데이터 소스 컴포넌트 연결 */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">데이터 소스 연결</h3>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
테이블 리스트에서 선택한 데이터를 받아올 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">데이터 수신 방식</Label>
|
||||
<Select
|
||||
value={config.dataSourceType || "manual"}
|
||||
onValueChange={(value) => onChange({ dataSourceType: value as any })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="manual">수동 (API에서 직접 조회)</SelectItem>
|
||||
<SelectItem value="table-list">테이블 리스트 선택 연동</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{config.dataSourceType === "table-list" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">연동할 테이블 리스트 컴포넌트 ID (선택)</Label>
|
||||
<Input
|
||||
value={config.dataSourceComponentId || ""}
|
||||
onChange={(e) => onChange({ dataSourceComponentId: e.target.value })}
|
||||
placeholder="비우면 테이블명으로 자동 매칭"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
비워두면 위에서 설정한 테이블명과 같은 테이블 리스트에서 데이터를 받습니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 슬롯 컴포넌트 설정 */}
|
||||
<SlotChildrenSection
|
||||
config={config}
|
||||
onChange={onChange}
|
||||
availableColumns={availableColumns}
|
||||
loadingColumns={loadingColumns}
|
||||
/>
|
||||
|
||||
{/* 레이아웃 설정 */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">레이아웃</h3>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
|
||||
{/* 레이아웃 타입 선택 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">배치 방식</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Button
|
||||
variant={config.layout === "vertical" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onChange({ layout: "vertical" })}
|
||||
className="h-9 text-xs flex flex-col gap-1"
|
||||
>
|
||||
<Rows3 className="h-4 w-4" />
|
||||
<span>세로</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={config.layout === "horizontal" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onChange({ layout: "horizontal" })}
|
||||
className="h-9 text-xs flex flex-col gap-1"
|
||||
>
|
||||
<LayoutList className="h-4 w-4 rotate-90" />
|
||||
<span>가로</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={config.layout === "grid" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onChange({ layout: "grid" })}
|
||||
className="h-9 text-xs flex flex-col gap-1"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
<span>그리드</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 그리드 컬럼 수 (grid 레이아웃일 때만) */}
|
||||
{config.layout === "grid" && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">그리드 컬럼 수</Label>
|
||||
<Select
|
||||
value={String(config.gridColumns || 2)}
|
||||
onValueChange={(value) => onChange({ gridColumns: Number(value) })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="2">2열</SelectItem>
|
||||
<SelectItem value="3">3열</SelectItem>
|
||||
<SelectItem value="4">4열</SelectItem>
|
||||
<SelectItem value="5">5열</SelectItem>
|
||||
<SelectItem value="6">6열</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 간격 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">아이템 간격</Label>
|
||||
<Input
|
||||
value={config.gap || "16px"}
|
||||
onChange={(e) => onChange({ gap: e.target.value })}
|
||||
placeholder="16px"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 아이템 카드 설정 */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">아이템 카드 스타일</h3>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">배경색</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={config.backgroundColor || "#ffffff"}
|
||||
onChange={(e) => onChange({ backgroundColor: e.target.value })}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">모서리 둥글기</Label>
|
||||
<Input
|
||||
value={config.borderRadius || "8px"}
|
||||
onChange={(e) => onChange({ borderRadius: e.target.value })}
|
||||
placeholder="8px"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">내부 패딩</Label>
|
||||
<Input
|
||||
value={config.padding || "16px"}
|
||||
onChange={(e) => onChange({ padding: e.target.value })}
|
||||
placeholder="16px"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">아이템 높이</Label>
|
||||
<Input
|
||||
value={config.itemHeight || "auto"}
|
||||
onChange={(e) => onChange({ itemHeight: e.target.value })}
|
||||
placeholder="auto"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="showBorder"
|
||||
checked={config.showBorder ?? true}
|
||||
onCheckedChange={(checked) => onChange({ showBorder: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor="showBorder" className="text-xs">
|
||||
테두리 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="showShadow"
|
||||
checked={config.showShadow ?? false}
|
||||
onCheckedChange={(checked) => onChange({ showShadow: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor="showShadow" className="text-xs">
|
||||
그림자 표시
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 아이템 제목 설정 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="showItemTitle"
|
||||
checked={config.showItemTitle ?? false}
|
||||
onCheckedChange={(checked) => onChange({ showItemTitle: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor="showItemTitle" className="text-sm font-semibold">
|
||||
아이템 제목 표시
|
||||
</Label>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
|
||||
{config.showItemTitle && (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">제목 템플릿</Label>
|
||||
<Input
|
||||
value={config.itemTitleTemplate || ""}
|
||||
onChange={(e) => onChange({ itemTitleTemplate: e.target.value })}
|
||||
placeholder="{order_no} - {item_code}"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{"{필드명}"} 형식으로 데이터 바인딩 가능
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">제목 크기</Label>
|
||||
<Input
|
||||
value={config.titleFontSize || "14px"}
|
||||
onChange={(e) => onChange({ titleFontSize: e.target.value })}
|
||||
placeholder="14px"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">제목 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={config.titleColor || "#374151"}
|
||||
onChange={(e) => onChange({ titleColor: e.target.value })}
|
||||
className="h-7"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">굵기</Label>
|
||||
<Select
|
||||
value={config.titleFontWeight || "600"}
|
||||
onValueChange={(value) => onChange({ titleFontWeight: value })}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="400">보통</SelectItem>
|
||||
<SelectItem value="500">중간</SelectItem>
|
||||
<SelectItem value="600">굵게</SelectItem>
|
||||
<SelectItem value="700">아주 굵게</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 페이징 설정 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="usePaging"
|
||||
checked={config.usePaging ?? false}
|
||||
onCheckedChange={(checked) => onChange({ usePaging: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor="usePaging" className="text-sm font-semibold">
|
||||
페이징 사용
|
||||
</Label>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
|
||||
{config.usePaging && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">페이지당 아이템 수</Label>
|
||||
<Select
|
||||
value={String(config.pageSize || 10)}
|
||||
onValueChange={(value) => onChange({ pageSize: Number(value) })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5개</SelectItem>
|
||||
<SelectItem value="10">10개</SelectItem>
|
||||
<SelectItem value="20">20개</SelectItem>
|
||||
<SelectItem value="50">50개</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 상호작용 설정 */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">상호작용</h3>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="clickable"
|
||||
checked={config.clickable ?? false}
|
||||
onCheckedChange={(checked) => onChange({ clickable: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor="clickable" className="text-xs">
|
||||
클릭 가능
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{config.clickable && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="showSelectedState"
|
||||
checked={config.showSelectedState ?? true}
|
||||
onCheckedChange={(checked) => onChange({ showSelectedState: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor="showSelectedState" className="text-xs">
|
||||
선택 상태 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">선택 모드</Label>
|
||||
<Select
|
||||
value={config.selectionMode || "single"}
|
||||
onValueChange={(value) =>
|
||||
onChange({ selectionMode: value as "single" | "multiple" })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="single">단일</SelectItem>
|
||||
<SelectItem value="multiple">다중</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 빈 상태 설정 */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">빈 상태 메시지</h3>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">메시지</Label>
|
||||
<Input
|
||||
value={config.emptyMessage || "데이터가 없습니다"}
|
||||
onChange={(e) => onChange({ emptyMessage: e.target.value })}
|
||||
placeholder="데이터가 없습니다"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 슬롯 자식 컴포넌트 관리 섹션
|
||||
// ============================================================
|
||||
interface SlotChildrenSectionProps {
|
||||
config: RepeatContainerConfig;
|
||||
onChange: (config: Partial<RepeatContainerConfig>) => void;
|
||||
availableColumns: Array<{ columnName: string; displayName?: string }>;
|
||||
loadingColumns: boolean;
|
||||
}
|
||||
|
||||
function SlotChildrenSection({
|
||||
config,
|
||||
onChange,
|
||||
availableColumns,
|
||||
loadingColumns,
|
||||
}: SlotChildrenSectionProps) {
|
||||
const [selectedColumn, setSelectedColumn] = useState<string>("");
|
||||
const [columnComboboxOpen, setColumnComboboxOpen] = useState(false);
|
||||
|
||||
const children = config.children || [];
|
||||
|
||||
// 컴포넌트 추가
|
||||
const addComponent = (columnName: string, displayName: string) => {
|
||||
const newChild: SlotComponentConfig = {
|
||||
id: `slot_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
componentType: "text-display",
|
||||
label: displayName,
|
||||
fieldName: columnName,
|
||||
position: { x: 0, y: children.length * 40 },
|
||||
size: { width: 200, height: 32 },
|
||||
componentConfig: {},
|
||||
};
|
||||
|
||||
onChange({
|
||||
children: [...children, newChild],
|
||||
});
|
||||
setSelectedColumn("");
|
||||
setColumnComboboxOpen(false);
|
||||
};
|
||||
|
||||
// 컴포넌트 삭제
|
||||
const removeComponent = (id: string) => {
|
||||
onChange({
|
||||
children: children.filter((c) => c.id !== id),
|
||||
});
|
||||
};
|
||||
|
||||
// 컴포넌트 라벨 변경
|
||||
const updateComponentLabel = (id: string, label: string) => {
|
||||
onChange({
|
||||
children: children.map((c) => (c.id === id ? { ...c, label } : c)),
|
||||
});
|
||||
};
|
||||
|
||||
// 컴포넌트 타입 변경
|
||||
const updateComponentType = (id: string, componentType: string) => {
|
||||
onChange({
|
||||
children: children.map((c) => (c.id === id ? { ...c, componentType } : c)),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">반복 표시 필드</h3>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
데이터 테이블의 컬럼을 선택하여 각 행에 표시할 필드를 추가합니다
|
||||
</p>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
|
||||
{/* 추가된 필드 목록 */}
|
||||
{children.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{children.map((child, index) => (
|
||||
<div
|
||||
key={child.id}
|
||||
className="flex items-center gap-2 rounded-md border border-green-200 bg-green-50 p-2"
|
||||
>
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded bg-green-200 text-xs font-medium text-green-700">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-green-700">
|
||||
{child.label || child.fieldName}
|
||||
</div>
|
||||
<div className="text-[10px] text-green-500">
|
||||
필드: {child.fieldName}
|
||||
</div>
|
||||
</div>
|
||||
<Select
|
||||
value={child.componentType}
|
||||
onValueChange={(value) => updateComponentType(child.id, value)}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-20 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="text-display">텍스트</SelectItem>
|
||||
<SelectItem value="text-input">입력</SelectItem>
|
||||
<SelectItem value="number-display">숫자</SelectItem>
|
||||
<SelectItem value="date-display">날짜</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0 text-red-400 hover:text-red-600"
|
||||
onClick={() => removeComponent(child.id)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed border-slate-300 bg-slate-50 p-4 text-center">
|
||||
<Type className="mx-auto h-6 w-6 text-slate-300" />
|
||||
<div className="mt-2 text-xs text-slate-500">표시할 필드가 없습니다</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
아래 컬럼 목록에서 선택하세요
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컬럼 선택 Combobox */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">컬럼 추가</Label>
|
||||
<Popover open={columnComboboxOpen} onOpenChange={setColumnComboboxOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={columnComboboxOpen}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
disabled={loadingColumns || availableColumns.length === 0}
|
||||
>
|
||||
{loadingColumns
|
||||
? "로딩 중..."
|
||||
: availableColumns.length === 0
|
||||
? "테이블을 먼저 선택하세요"
|
||||
: "컬럼 선택..."}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-xs">컬럼을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup heading="사용 가능한 컬럼">
|
||||
{availableColumns.map((col) => {
|
||||
const isAdded = children.some((c) => c.fieldName === col.columnName);
|
||||
return (
|
||||
<CommandItem
|
||||
key={col.columnName}
|
||||
value={`${col.columnName} ${col.displayName || ""}`}
|
||||
onSelect={() => {
|
||||
if (!isAdded) {
|
||||
addComponent(col.columnName, col.displayName || col.columnName);
|
||||
}
|
||||
}}
|
||||
disabled={isAdded}
|
||||
className={cn(
|
||||
"text-xs cursor-pointer",
|
||||
isAdded && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<Plus
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
isAdded ? "text-green-500" : "text-blue-500"
|
||||
)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{col.displayName || col.columnName}</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{col.columnName}
|
||||
</div>
|
||||
</div>
|
||||
{isAdded && (
|
||||
<Check className="h-3 w-3 text-green-500" />
|
||||
)}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-2">
|
||||
<p className="text-[10px] text-amber-700">
|
||||
<strong>안내:</strong> 여기는 필드 단위 컴포넌트만 추가하세요.<br />
|
||||
집계 위젯, 분할 패널 등은 리피터 외부에 별도로 배치해야 합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
||||
import { RepeatContainerDefinition } from "./index";
|
||||
|
||||
// 컴포넌트 자동 등록
|
||||
if (typeof window !== "undefined") {
|
||||
ComponentRegistry.registerComponent(RepeatContainerDefinition);
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
"use client";
|
||||
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { RepeatContainerWrapper } from "./RepeatContainerComponent";
|
||||
import { RepeatContainerConfigPanel } from "./RepeatContainerConfigPanel";
|
||||
import type { RepeatContainerConfig } from "./types";
|
||||
|
||||
/**
|
||||
* RepeatContainer 컴포넌트 정의
|
||||
* 데이터 수만큼 내부 컨텐츠를 반복 렌더링하는 컨테이너
|
||||
*/
|
||||
export const RepeatContainerDefinition = createComponentDefinition({
|
||||
id: "repeat-container",
|
||||
name: "리피터 컨테이너",
|
||||
nameEng: "Repeat Container",
|
||||
description: "데이터 수만큼 내부 컴포넌트를 반복 렌더링하는 컨테이너",
|
||||
category: ComponentCategory.LAYOUT,
|
||||
webType: "text",
|
||||
component: RepeatContainerWrapper,
|
||||
defaultConfig: {
|
||||
dataSourceType: "manual",
|
||||
layout: "vertical",
|
||||
gridColumns: 2,
|
||||
gap: "16px",
|
||||
showBorder: true,
|
||||
showShadow: false,
|
||||
borderRadius: "8px",
|
||||
backgroundColor: "#ffffff",
|
||||
padding: "16px",
|
||||
showItemTitle: false,
|
||||
itemTitleTemplate: "",
|
||||
titleFontSize: "14px",
|
||||
titleColor: "#374151",
|
||||
titleFontWeight: "600",
|
||||
emptyMessage: "데이터가 없습니다",
|
||||
usePaging: false,
|
||||
pageSize: 10,
|
||||
clickable: false,
|
||||
showSelectedState: true,
|
||||
selectionMode: "single",
|
||||
} as Partial<RepeatContainerConfig>,
|
||||
defaultSize: { width: 600, height: 300 },
|
||||
configPanel: RepeatContainerConfigPanel,
|
||||
icon: "Repeat",
|
||||
tags: ["리피터", "반복", "컨테이너", "데이터", "레이아웃", "그리드"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
});
|
||||
|
||||
// 타입 내보내기
|
||||
export type {
|
||||
RepeatContainerConfig,
|
||||
SlotComponentConfig,
|
||||
RepeatItemContext,
|
||||
RepeatContainerValue,
|
||||
DataSourceType,
|
||||
LayoutType,
|
||||
} from "./types";
|
||||
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
import { ComponentConfig } from "@/types/component";
|
||||
|
||||
/**
|
||||
* 리피터 컨테이너 데이터 소스 타입
|
||||
*/
|
||||
export type DataSourceType = "table-list" | "unified-repeater" | "externalData" | "manual";
|
||||
|
||||
/**
|
||||
* 리피터 컨테이너 레이아웃 타입
|
||||
*/
|
||||
export type LayoutType = "vertical" | "horizontal" | "grid";
|
||||
|
||||
/**
|
||||
* 슬롯에 배치된 컴포넌트 설정
|
||||
* 화면 디자이너에서 리피터 컨테이너 내부에 배치한 컴포넌트 정보
|
||||
*/
|
||||
export interface SlotComponentConfig {
|
||||
id: string;
|
||||
/** 컴포넌트 타입 (예: "text-input", "text-display") */
|
||||
componentType: string;
|
||||
/** 컴포넌트 라벨 */
|
||||
label?: string;
|
||||
/** 바인딩할 데이터 필드명 */
|
||||
fieldName?: string;
|
||||
/** 컴포넌트 위치 (슬롯 내부 상대 좌표) */
|
||||
position?: { x: number; y: number };
|
||||
/** 컴포넌트 크기 */
|
||||
size?: { width: number; height: number };
|
||||
/** 컴포넌트 상세 설정 */
|
||||
componentConfig?: Record<string, any>;
|
||||
/** 스타일 설정 */
|
||||
style?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 리피터 컨테이너 설정
|
||||
* 데이터 수만큼 내부 컴포넌트 또는 컨텐츠를 반복 렌더링하는 컨테이너
|
||||
*/
|
||||
export interface RepeatContainerConfig extends ComponentConfig {
|
||||
// ========================
|
||||
// 1. 데이터 소스 설정
|
||||
// ========================
|
||||
/** 데이터 소스 타입 */
|
||||
dataSourceType: DataSourceType;
|
||||
/** 연결할 테이블 리스트 또는 리피터 컴포넌트 ID */
|
||||
dataSourceComponentId?: string;
|
||||
|
||||
// 컴포넌트별 테이블 설정 (개발 가이드 준수)
|
||||
/** 사용할 테이블명 */
|
||||
tableName?: string;
|
||||
/** 커스텀 테이블명 */
|
||||
customTableName?: string;
|
||||
/** true: customTableName 사용 */
|
||||
useCustomTable?: boolean;
|
||||
/** true: 조회만, 저장 안 함 */
|
||||
isReadOnly?: boolean;
|
||||
|
||||
// ========================
|
||||
// 2. 레이아웃 설정
|
||||
// ========================
|
||||
/** 배치 방향 */
|
||||
layout: LayoutType;
|
||||
/** grid일 때 컬럼 수 */
|
||||
gridColumns?: number;
|
||||
/** 아이템 간 간격 */
|
||||
gap?: string;
|
||||
/** 아이템 최소 너비 */
|
||||
itemMinWidth?: string;
|
||||
/** 아이템 최대 너비 */
|
||||
itemMaxWidth?: string;
|
||||
/** 아이템 높이 */
|
||||
itemHeight?: string;
|
||||
|
||||
// ========================
|
||||
// 3. 아이템 카드 설정
|
||||
// ========================
|
||||
/** 카드 테두리 표시 */
|
||||
showBorder?: boolean;
|
||||
/** 카드 그림자 표시 */
|
||||
showShadow?: boolean;
|
||||
/** 카드 둥근 모서리 */
|
||||
borderRadius?: string;
|
||||
/** 카드 배경색 */
|
||||
backgroundColor?: string;
|
||||
/** 카드 내부 패딩 */
|
||||
padding?: string;
|
||||
|
||||
// ========================
|
||||
// 4. 제목 설정 (각 아이템)
|
||||
// ========================
|
||||
/** 아이템 제목 표시 */
|
||||
showItemTitle?: boolean;
|
||||
/** 아이템 제목 템플릿 (예: "{order_no} - {item_code}") */
|
||||
itemTitleTemplate?: string;
|
||||
/** 제목 폰트 크기 */
|
||||
titleFontSize?: string;
|
||||
/** 제목 색상 */
|
||||
titleColor?: string;
|
||||
/** 제목 폰트 굵기 */
|
||||
titleFontWeight?: string;
|
||||
|
||||
// ========================
|
||||
// 5. 데이터 필터링 (선택사항)
|
||||
// ========================
|
||||
/** 필터 필드 (formData에서 가져올 키) */
|
||||
filterField?: string;
|
||||
/** 필터 컬럼 (테이블에서 필터링할 컬럼) */
|
||||
filterColumn?: string;
|
||||
|
||||
// ========================
|
||||
// 6. 그룹핑 설정 (선택사항)
|
||||
// ========================
|
||||
/** 그룹핑 사용 여부 */
|
||||
useGrouping?: boolean;
|
||||
/** 그룹핑 기준 필드 */
|
||||
groupByField?: string;
|
||||
|
||||
// ========================
|
||||
// 7. 슬롯 컨텐츠 설정 (children 직접 배치)
|
||||
// ========================
|
||||
/**
|
||||
* 슬롯에 배치된 자식 컴포넌트들
|
||||
* 화면 디자이너에서 리피터 컨테이너 내부에 드래그앤드롭으로 배치된 컴포넌트들
|
||||
* 각 데이터 아이템마다 이 컴포넌트들이 반복 렌더링됨
|
||||
*/
|
||||
children?: SlotComponentConfig[];
|
||||
|
||||
// ========================
|
||||
// 8. 빈 상태 설정
|
||||
// ========================
|
||||
/** 데이터 없을 때 표시 메시지 */
|
||||
emptyMessage?: string;
|
||||
/** 빈 상태 아이콘 */
|
||||
emptyIcon?: string;
|
||||
|
||||
// ========================
|
||||
// 9. 페이징 설정 (선택사항)
|
||||
// ========================
|
||||
/** 페이징 사용 여부 */
|
||||
usePaging?: boolean;
|
||||
/** 페이지당 아이템 수 */
|
||||
pageSize?: number;
|
||||
|
||||
// ========================
|
||||
// 10. 이벤트 설정
|
||||
// ========================
|
||||
/** 아이템 클릭 이벤트 활성화 */
|
||||
clickable?: boolean;
|
||||
/** 클릭 시 선택 상태 표시 */
|
||||
showSelectedState?: boolean;
|
||||
/** 선택 모드 (단일/다중) */
|
||||
selectionMode?: "single" | "multiple";
|
||||
}
|
||||
|
||||
/**
|
||||
* 리피터 컨테이너 값 타입
|
||||
* 현재 렌더링 중인 데이터 배열
|
||||
*/
|
||||
export interface RepeatContainerValue {
|
||||
/** 원본 데이터 배열 */
|
||||
data: Record<string, any>[];
|
||||
/** 선택된 아이템 인덱스들 */
|
||||
selectedIndices?: number[];
|
||||
/** 현재 페이지 (페이징 사용 시) */
|
||||
currentPage?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 리피터 컨텍스트 (각 반복 아이템에서 사용)
|
||||
*/
|
||||
export interface RepeatItemContext {
|
||||
/** 현재 아이템 인덱스 */
|
||||
index: number;
|
||||
/** 현재 아이템 데이터 */
|
||||
data: Record<string, any>;
|
||||
/** 전체 데이터 수 */
|
||||
totalCount: number;
|
||||
/** 첫 번째 아이템인지 */
|
||||
isFirst: boolean;
|
||||
/** 마지막 아이템인지 */
|
||||
isLast: boolean;
|
||||
/** 그룹 키 (그룹핑 사용 시) */
|
||||
groupKey?: string;
|
||||
/** 그룹 내 인덱스 (그룹핑 사용 시) */
|
||||
groupIndex?: number;
|
||||
}
|
||||
|
||||
|
|
@ -2074,6 +2074,19 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
});
|
||||
}
|
||||
|
||||
// 🆕 리피터 컨테이너/집계 위젯 연동용 커스텀 이벤트 발생
|
||||
if (typeof window !== "undefined") {
|
||||
const event = new CustomEvent("tableListDataChange", {
|
||||
detail: {
|
||||
componentId: component.id,
|
||||
tableName: tableConfig.selectedTable,
|
||||
data: selectedRowsData,
|
||||
selectedRows: Array.from(newSelectedRows),
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
// 🆕 modalDataStore에 선택된 데이터 자동 저장 (테이블명 기반 dataSourceId)
|
||||
if (tableConfig.selectedTable && selectedRowsData.length > 0) {
|
||||
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
||||
|
|
@ -2112,6 +2125,19 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
});
|
||||
}
|
||||
|
||||
// 🆕 리피터 컨테이너/집계 위젯 연동용 커스텀 이벤트 발생
|
||||
if (typeof window !== "undefined") {
|
||||
const event = new CustomEvent("tableListDataChange", {
|
||||
detail: {
|
||||
componentId: component.id,
|
||||
tableName: tableConfig.selectedTable,
|
||||
data: filteredData,
|
||||
selectedRows: Array.from(newSelectedRows),
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
// 🆕 modalDataStore에 전체 데이터 저장
|
||||
if (tableConfig.selectedTable && filteredData.length > 0) {
|
||||
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
||||
|
|
@ -2135,6 +2161,19 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
onFormDataChange({ selectedRows: [], selectedRowsData: [] });
|
||||
}
|
||||
|
||||
// 🆕 리피터 컨테이너/집계 위젯 연동용 커스텀 이벤트 발생 (선택 해제)
|
||||
if (typeof window !== "undefined") {
|
||||
const event = new CustomEvent("tableListDataChange", {
|
||||
detail: {
|
||||
componentId: component.id,
|
||||
tableName: tableConfig.selectedTable,
|
||||
data: [],
|
||||
selectedRows: [],
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
// 🆕 modalDataStore 데이터 제거
|
||||
if (tableConfig.selectedTable) {
|
||||
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue