"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"; // V2 이벤트 시스템 import { V2_EVENTS, subscribeV2Event, type TableListDataChangeDetail, type RepeaterDataChangeDetail } from "@/types/component-events"; interface RepeatContainerComponentProps extends ComponentRendererProps { config?: RepeatContainerConfig; // 외부에서 데이터를 직접 전달받을 수 있음 externalData?: any[]; // 내부 컴포넌트를 렌더링하는 슬롯 (children 대용) renderItem?: (context: RepeatItemContext) => React.ReactNode; // formData 접근 formData?: Record; // formData 변경 콜백 onFormDataChange?: (key: string, value: any) => void; // 선택 변경 콜백 onSelectionChange?: (selectedData: any[]) => void; // 사용자 정보 userId?: string; userName?: string; companyCode?: string; // 화면 정보 screenId?: number; screenTableName?: string; // 컴포넌트 업데이트 콜백 (디자인 모드에서 드래그앤드롭용) onUpdateComponent?: (updates: Partial) => 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: "", titleColumn: "", descriptionColumn: "", titleFontSize: "14px", titleColor: "#374151", titleFontWeight: "600", descriptionFontSize: "12px", descriptionColor: "#6b7280", 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, titleColumn, descriptionColumn, titleFontSize, titleColor, titleFontWeight, descriptionFontSize, descriptionColor, filterField, filterColumn, useGrouping, groupByField, children: slotChildren, emptyMessage, usePaging, pageSize, clickable, showSelectedState, selectionMode, } = componentConfig; // 데이터 상태 const [data, setData] = useState([]); const [selectedIndices, setSelectedIndices] = useState([]); const [currentPage, setCurrentPage] = useState(1); const [isLoading, setIsLoading] = useState(false); // 실제 사용할 테이블명 const effectiveTableName = useCustomTable ? customTableName : tableName; // 외부 데이터가 있으면 사용 useEffect(() => { if (externalData && Array.isArray(externalData)) { setData(externalData); } }, [externalData]); // ============================================================ // 컴포넌트 데이터 변경 이벤트 리스닝 (V2 표준 이벤트) // componentId 또는 tableName으로 매칭 // ============================================================ useEffect(() => { if (isDesignMode) return; console.log("🔄 리피터 컨테이너 이벤트 리스너 등록:", { componentId: component?.id, dataSourceType, dataSourceComponentId, effectiveTableName, }); // 공통 데이터 처리 함수 const processIncomingData = ( componentId: string | undefined, eventTableName: string | undefined, eventData: any[] ) => { // 1. 명시적으로 dataSourceComponentId가 설정된 경우 해당 컴포넌트만 매칭 if (dataSourceComponentId) { if (componentId === dataSourceComponentId && Array.isArray(eventData)) { console.log("✅ 리피터: 컴포넌트 ID로 데이터 수신 성공", { componentId, count: eventData.length }); setData(eventData); setCurrentPage(1); setSelectedIndices([]); } return; } // 2. dataSourceComponentId가 없으면 테이블명으로 매칭 if (effectiveTableName && eventTableName === effectiveTableName && Array.isArray(eventData)) { console.log("✅ 리피터: 테이블명으로 데이터 수신 성공", { tableName: eventTableName, count: eventData.length }); setData(eventData); setCurrentPage(1); setSelectedIndices([]); } }; // 테이블 리스트 데이터 변경 이벤트 (V2 표준) const handleTableListDataChange = (event: CustomEvent) => { const { componentId, tableName: eventTableName, data: eventData } = event.detail || {}; processIncomingData(componentId, eventTableName, eventData); }; // 리피터 데이터 변경 이벤트 (V2 표준) const handleRepeaterDataChange = (event: CustomEvent) => { const { componentId, tableName: eventTableName, data: eventData } = event.detail || {}; processIncomingData(componentId, eventTableName, eventData); }; // V2 표준 이벤트 구독 const unsubscribeTableList = subscribeV2Event(V2_EVENTS.TABLE_LIST_DATA_CHANGE, handleTableListDataChange); const unsubscribeRepeater = subscribeV2Event(V2_EVENTS.REPEATER_DATA_CHANGE, handleRepeaterDataChange); return () => { unsubscribeTableList(); unsubscribeRepeater(); }; }, [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 = {}; 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]); // 아이템 제목 생성 (titleColumn 우선, 없으면 itemTitleTemplate 사용) const generateTitle = useCallback( (rowData: Record, index: number): string => { if (!showItemTitle) return ""; // titleColumn이 설정된 경우 해당 컬럼 값 사용 if (titleColumn) { return String(rowData[titleColumn] ?? ""); } // 레거시: itemTitleTemplate 사용 if (itemTitleTemplate) { return itemTitleTemplate.replace(/\{([^}]+)\}/g, (match, field) => { return String(rowData[field] ?? ""); }); } return `아이템 ${index + 1}`; }, [showItemTitle, titleColumn, itemTitleTemplate] ); // 아이템 설명 생성 const generateDescription = useCallback( (rowData: Record): string => { if (!showItemTitle || !descriptionColumn) return ""; return String(rowData[descriptionColumn] ?? ""); }, [showItemTitle, descriptionColumn] ); // 아이템 클릭 핸들러 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 (
반복 아이템 #{context.index + 1}
); } // 현재 아이템 데이터를 formData로 전달 const itemFormData = { ...formData, ...context.data, _repeatIndex: context.index, _repeatTotal: context.totalCount, _isFirst: context.isFirst, _isLast: context.isLast, }; // 슬롯에 배치된 컴포넌트들을 렌더링 (Flow 레이아웃으로 변경) return (
{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 (
{ if (onFormDataChange) { onFormDataChange(`_repeat_${context.index}_${key}`, value); } }} />
); })}
); }, [ 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 (
{ // 시각적 상태만 리셋, 드롭 로직은 ScreenDesigner에서 처리 setIsDragOver(false); // 중요: preventDefault()를 호출하지 않아야 이벤트가 버블링됨 // 하지만 필요하다면 호출해도 됨 - 버블링과 무관 }} >
리피터 컨테이너 ({previewData.length}개 미리보기)
{isDragOver ? (
여기에 놓으세요
) : !hasChildren ? (
컴포넌트를 드래그하세요
) : null}
{previewData.map((row, index) => { const context: RepeatItemContext = { index, data: row, totalCount: previewData.length, isFirst: index === 0, isLast: index === previewData.length - 1, }; return (
{showItemTitle && (titleColumn || itemTitleTemplate) && (
{generateTitle(row, index)}
{descriptionColumn && generateDescription(row) && (
{generateDescription(row)}
)}
)} {hasChildren ? (
{/* 디자인 모드: 배치된 자식 컴포넌트들을 시각적으로 표시 */} {slotChildren!.map((child: SlotComponentConfig, childIdx: number) => (
{childIdx + 1}
{child.label || child.componentType}
{child.fieldName && (
{child.fieldName}
)}
{child.componentType}
))}
아이템 #{index + 1} - 실행 시 데이터 바인딩
) : (
반복 아이템 #{index + 1}
컴포넌트를 드래그하여 배치하세요
)}
); })}
); } // 빈 상태 if (paginatedData.length === 0 && !isLoading) { return (

{emptyMessage}

); } // 로딩 상태 if (isLoading) { return (
로딩 중...
); } // 실제 렌더링 return (
{paginatedData.map((row, index) => { const context: RepeatItemContext = { index, data: row, totalCount: filteredData.length, isFirst: index === 0, isLast: index === paginatedData.length - 1, }; return (
handleItemClick(index, row)} > {showItemTitle && (titleColumn || itemTitleTemplate) && (
{generateTitle(row, index)}
{descriptionColumn && generateDescription(row) && (
{generateDescription(row)}
)}
)} {renderSlotChildren(context)}
); })}
{/* 페이징 */} {usePaging && totalPages > 1 && (
{currentPage} / {totalPages}
)}
); } export const RepeatContainerWrapper = RepeatContainerComponent;