리피터 컨테이너 기능 추가: ScreenDesigner 컴포넌트에 리피터 컨테이너 내부 드롭 처리 로직을 추가하여, 드롭 시 새로운 자식 컴포넌트를 생성하고 레이아웃을 업데이트합니다. 또한, TableListComponent에서 리피터 컨테이너와 집계 위젯 연동을 위한 커스텀 이벤트를 발생시켜 데이터 변경 사항을 처리할 수 있도록 개선하였습니다.
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user