feature/v2-unified-renewal #379
|
|
@ -2222,6 +2222,56 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
return;
|
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();
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
if (!rect) return;
|
if (!rect) return;
|
||||||
|
|
||||||
|
|
@ -2562,6 +2612,52 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
const dropTarget = e.target as HTMLElement;
|
const dropTarget = e.target as HTMLElement;
|
||||||
const formContainer = dropTarget.closest('[data-form-container="true"]');
|
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();
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
if (!rect) return;
|
if (!rect) return;
|
||||||
|
|
||||||
|
|
@ -4687,9 +4783,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = "copy";
|
e.dataTransfer.dropEffect = "copy";
|
||||||
}}
|
}}
|
||||||
onDrop={(e) => {
|
onDropCapture={(e) => {
|
||||||
|
// 캡처 단계에서 드롭 이벤트를 처리하여 자식 요소 드롭도 감지
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// console.log("🎯 캔버스 드롭 이벤트 발생");
|
console.log("🎯 캔버스 드롭 이벤트 (캡처), target:", (e.target as HTMLElement).tagName, (e.target as HTMLElement).getAttribute("data-repeat-container"));
|
||||||
handleDrop(e);
|
handleDrop(e);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,9 @@ import "./pivot-grid/PivotGridRenderer"; // 피벗 테이블 (행/열 그룹화,
|
||||||
// 🆕 집계 위젯 컴포넌트
|
// 🆕 집계 위젯 컴포넌트
|
||||||
import "./aggregation-widget/AggregationWidgetRenderer"; // 데이터 집계 (합계, 평균, 개수 등)
|
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)
|
// 🆕 modalDataStore에 선택된 데이터 자동 저장 (테이블명 기반 dataSourceId)
|
||||||
if (tableConfig.selectedTable && selectedRowsData.length > 0) {
|
if (tableConfig.selectedTable && selectedRowsData.length > 0) {
|
||||||
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
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에 전체 데이터 저장
|
// 🆕 modalDataStore에 전체 데이터 저장
|
||||||
if (tableConfig.selectedTable && filteredData.length > 0) {
|
if (tableConfig.selectedTable && filteredData.length > 0) {
|
||||||
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
||||||
|
|
@ -2135,6 +2161,19 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
onFormDataChange({ selectedRows: [], selectedRowsData: [] });
|
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 데이터 제거
|
// 🆕 modalDataStore 데이터 제거
|
||||||
if (tableConfig.selectedTable) {
|
if (tableConfig.selectedTable) {
|
||||||
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue