"use client"; import React, { useState, useEffect, useMemo, useCallback, useRef } 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; } // 섹션별 폼 데이터를 저장하는 타입 interface SectionFormData { index: number; originalData: Record; formData: Record; isDirty: boolean; } /** * 리피터 컨테이너 컴포넌트 * 데이터 수만큼 내부 컨텐츠를 반복 렌더링하는 컨테이너 */ 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 sectionFormDataRef = useRef>(new Map()); // 실제 사용할 테이블명 const effectiveTableName = useCustomTable ? customTableName : tableName; // 외부 데이터가 있으면 사용 useEffect(() => { if (externalData && Array.isArray(externalData)) { setData(externalData); // 데이터가 변경되면 섹션별 폼 데이터 초기화 sectionFormDataRef.current.clear(); } }, [externalData]); // 섹션별 폼 데이터 변경 핸들러 const handleSectionFormDataChange = useCallback( (sectionIndex: number, key: string, value: any, originalData: Record) => { const currentSection = sectionFormDataRef.current.get(sectionIndex) || { index: sectionIndex, originalData: originalData, formData: { ...originalData }, isDirty: false, }; // 폼 데이터 업데이트 currentSection.formData[key] = value; // 변경 여부 확인 (원본 데이터와 비교) currentSection.isDirty = Object.keys(currentSection.formData).some( (k) => currentSection.formData[k] !== currentSection.originalData[k] ); sectionFormDataRef.current.set(sectionIndex, currentSection); // 상위로 변경 알림 (기존 방식 호환) if (onFormDataChange) { onFormDataChange(`_repeat_${sectionIndex}_${key}`, value); } }, [onFormDataChange] ); // beforeFormSave 이벤트 리스너 - 외부 저장 버튼 클릭 시 섹션별 데이터 수집 useEffect(() => { if (isDesignMode) return; const handleBeforeFormSave = (event: Event) => { if (!(event instanceof CustomEvent) || !event.detail) return; const componentKey = component?.id || effectiveTableName || "repeat_container_data"; // 섹션별 데이터 수집 const sectionsData: any[] = []; const dirtySectionsData: any[] = []; // data 배열의 각 아이템에 대해 폼 데이터 수집 data.forEach((originalRow, index) => { const sectionData = sectionFormDataRef.current.get(index); if (sectionData) { // 섹션별 폼 데이터가 있는 경우 const mergedData = { ...originalRow, ...sectionData.formData, _sectionIndex: index, _isDirty: sectionData.isDirty, _targetTable: effectiveTableName, }; sectionsData.push(mergedData); // 변경된 섹션만 별도로 수집 if (sectionData.isDirty) { dirtySectionsData.push(mergedData); } } else { // 폼 데이터가 없으면 원본 데이터 사용 sectionsData.push({ ...originalRow, _sectionIndex: index, _isDirty: false, _targetTable: effectiveTableName, }); } }); // event.detail.formData에 수집된 데이터 추가 if (event.detail.formData) { // 전체 섹션 데이터 (배열) event.detail.formData[componentKey] = sectionsData; // 변경된 섹션만 (저장 최적화용) event.detail.formData[`${componentKey}_dirty`] = dirtySectionsData; // 테이블별 그룹화 (멀티테이블 저장용) if (effectiveTableName) { if (!event.detail.formData._repeatContainerTables) { event.detail.formData._repeatContainerTables = {}; } event.detail.formData._repeatContainerTables[effectiveTableName] = dirtySectionsData.length > 0 ? dirtySectionsData : sectionsData; } console.log("[RepeatContainer] beforeFormSave 데이터 수집 완료:", { componentKey, tableName: effectiveTableName, totalSections: sectionsData.length, dirtySections: dirtySectionsData.length, }); } }; window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener); return () => { window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener); }; }, [isDesignMode, component?.id, effectiveTableName, data]); // ============================================================ // 컴포넌트 데이터 변경 이벤트 리스닝 (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([]); // 데이터 변경 시 섹션별 폼 데이터 초기화 sectionFormDataRef.current.clear(); } return; } // 2. dataSourceComponentId가 없으면 테이블명으로 매칭 if (effectiveTableName && eventTableName === effectiveTableName && Array.isArray(eventData)) { console.log("✅ 리피터: 테이블명으로 데이터 수신 성공", { tableName: eventTableName, count: eventData.length }); setData(eventData); setCurrentPage(1); setSelectedIndices([]); // 데이터 변경 시 섹션별 폼 데이터 초기화 sectionFormDataRef.current.clear(); } }; // 테이블 리스트 데이터 변경 이벤트 (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 (
{ // 섹션별 폼 데이터 관리 handleSectionFormDataChange(context.index, key, value, context.data); }} />
); })}
); }, [ renderItem, slotChildren, formData, screenId, screenTableName, effectiveTableName, userId, userName, companyCode, handleSectionFormDataChange, ] ); // 드래그앤드롭 상태 (시각적 피드백용) 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;