ERP-node/frontend/lib/registry/components/repeat-container/RepeatContainerComponent.tsx

657 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: 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;