661 lines
22 KiB
TypeScript
661 lines
22 KiB
TypeScript
"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 대신 minHeight 사용 - 내부 컨텐츠가 커지면 자동으로 높이 확장
|
|
minHeight: itemHeight || "auto",
|
|
height: "auto", // 고정 높이 대신 auto로 변경
|
|
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",
|
|
overflow: "visible", // 내부 컨텐츠가 튀어나가지 않도록
|
|
};
|
|
}, [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,
|
|
};
|
|
|
|
// 슬롯에 배치된 컴포넌트들을 렌더링 (Flow 레이아웃으로 변경)
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
{slotChildren.map((childComp: SlotComponentConfig) => {
|
|
const { size = { width: "100%", height: "auto" } } = childComp;
|
|
|
|
// DynamicComponentRenderer가 기대하는 형식으로 변환
|
|
const componentData = {
|
|
id: `${childComp.id}_${context.index}`,
|
|
componentType: childComp.componentType,
|
|
label: childComp.label,
|
|
columnName: childComp.fieldName,
|
|
position: { x: 0, y: 0, z: 1 },
|
|
size: {
|
|
width: typeof size.width === "number" ? size.width : undefined,
|
|
height: typeof size.height === "number" ? size.height : undefined,
|
|
},
|
|
componentConfig: childComp.componentConfig,
|
|
style: childComp.style,
|
|
};
|
|
|
|
return (
|
|
<div
|
|
key={componentData.id}
|
|
className="w-full"
|
|
style={{
|
|
// 너비는 100%로, 높이는 자동으로
|
|
minHeight: typeof size.height === "number" ? size.height : "auto",
|
|
}}
|
|
>
|
|
<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;
|