리피터 컨테이너 기능 추가: ScreenDesigner 컴포넌트에 리피터 컨테이너 내부 드롭 처리 로직을 추가하여, 드롭 시 새로운 자식 컴포넌트를 생성하고 레이아웃을 업데이트합니다. 또한, TableListComponent에서 리피터 컨테이너와 집계 위젯 연동을 위한 커스텀 이벤트를 발생시켜 데이터 변경 사항을 처리할 수 있도록 개선하였습니다.

This commit is contained in:
kjs 2026-01-16 15:12:22 +09:00
parent 28f67cb0b6
commit 9d74baf60a
8 changed files with 1859 additions and 2 deletions

View File

@ -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);
}}
>

View File

@ -97,6 +97,9 @@ import "./pivot-grid/PivotGridRenderer"; // 피벗 테이블 (행/열 그룹화,
// 🆕 집계 위젯 컴포넌트
import "./aggregation-widget/AggregationWidgetRenderer"; // 데이터 집계 (합계, 평균, 개수 등)
// 🆕 리피터 컨테이너 컴포넌트
import "./repeat-container/RepeatContainerRenderer"; // 데이터 수만큼 반복 렌더링
/**
*
*/

View File

@ -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;

View File

@ -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>
);
}

View File

@ -0,0 +1,12 @@
"use client";
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import { RepeatContainerDefinition } from "./index";
// 컴포넌트 자동 등록
if (typeof window !== "undefined") {
ComponentRegistry.registerComponent(RepeatContainerDefinition);
}
export {};

View File

@ -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";

View File

@ -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;
}

View File

@ -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 }) => {