"use client"; /** * pop-string-list 런타임 컴포넌트 * * 리스트 모드: 엑셀형 행/열 (CSS Grid) * 카드 모드: 셀 병합 가능한 카드 (CSS Grid + colSpan/rowSpan) * 오버플로우: visibleRows 제한 + "더보기" 점진 확장 */ import { useState, useEffect, useCallback, useMemo } from "react"; import { ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Loader2, AlertCircle, ChevronsUpDown } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; import { dataApi } from "@/lib/api/data"; import { executePopAction } from "@/hooks/pop/executePopAction"; import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { toast } from "sonner"; import type { PopStringListConfig, CardGridConfig, ListColumnConfig, CardCellDefinition, } from "./types"; // ===== 유틸리티 ===== /** * 컬럼명에서 실제 데이터 키를 추출 * 조인 컬럼은 "테이블명.컬럼명" 형식으로 저장됨 -> "컬럼명"만 추출 * 일반 컬럼은 그대로 반환 */ function resolveColumnName(name: string): string { if (!name) return name; const dotIdx = name.lastIndexOf("."); return dotIdx >= 0 ? name.substring(dotIdx + 1) : name; } // ===== Props ===== interface PopStringListComponentProps { config?: PopStringListConfig; className?: string; screenId?: string; componentId?: string; } // 테이블 행 데이터 타입 type RowData = Record; // ===== 메인 컴포넌트 ===== export function PopStringListComponent({ config, className, screenId, componentId, }: PopStringListComponentProps) { const displayMode = config?.displayMode || "list"; const header = config?.header; const overflow = config?.overflow; const dataSource = config?.dataSource; const listColumns = config?.listColumns || []; const cardGrid = config?.cardGrid; const rowClickAction = config?.rowClickAction || "none"; // 데이터 상태 const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // 더보기 모드: 현재 표시 중인 행 수 const [displayCount, setDisplayCount] = useState(0); // 페이지네이션 모드: 현재 페이지 (1부터 시작) const [currentPage, setCurrentPage] = useState(1); // 카드 버튼 행 단위 로딩 인덱스 (-1 = 로딩 없음) const [loadingRowIdx, setLoadingRowIdx] = useState(-1); // 이벤트 버스 const { publish, subscribe } = usePopEvent(screenId || ""); // 외부 필터 조건 (연결 시스템에서 수신, connectionId별 Map으로 복수 필터 AND 결합) const [externalFilters, setExternalFilters] = useState< Map >(new Map()); // 표준 입력 이벤트 구독 useEffect(() => { if (!componentId) return; const unsub = subscribe( `__comp_input__${componentId}__filter_condition`, (payload: unknown) => { const data = payload as { value?: { fieldName?: string; value?: unknown }; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string }; _connectionId?: string; }; const connId = data?._connectionId || "default"; setExternalFilters(prev => { const next = new Map(prev); if (data?.value?.value) { next.set(connId, { fieldName: data.value.fieldName || "", value: data.value.value, filterConfig: data.filterConfig, }); } else { next.delete(connId); } return next; }); } ); return unsub; }, [componentId, subscribe]); // 카드 버튼 클릭 핸들러 const handleCardButtonClick = useCallback( async (cell: CardCellDefinition, row: RowData) => { if (!cell.buttonAction) return; // 확인 다이얼로그 (간단 구현: window.confirm) if (cell.buttonConfirm?.enabled) { const msg = cell.buttonConfirm.message || "이 작업을 실행하시겠습니까?"; if (!window.confirm(msg)) return; } const rowIndex = rows.indexOf(row); setLoadingRowIdx(rowIndex); try { const result = await executePopAction(cell.buttonAction, row as Record, { publish, screenId, }); if (result.success) { toast.success("작업이 완료되었습니다."); } else { toast.error(result.error || "작업에 실패했습니다."); } } catch { toast.error("알 수 없는 오류가 발생했습니다."); } finally { setLoadingRowIdx(-1); } }, [rows, publish, screenId] ); // 행 클릭 핸들러 (selected_row 발행 + 모달 닫기 옵션) const handleRowClick = useCallback( (row: RowData) => { if (rowClickAction === "none") return; // selected_row 이벤트 발행 if (componentId) { publish(`__comp_output__${componentId}__selected_row`, row); } // 모달 내부에서 사용 시: 선택 후 모달 닫기 + 데이터 반환 if (rowClickAction === "select-and-close-modal") { publish("__pop_modal_close__", { selectedRow: row }); } }, [rowClickAction, componentId, publish] ); // 오버플로우 설정 (JSON 복원 시 string 유입 방어) const overflowMode = overflow?.mode || "loadMore"; const visibleRows = Number(overflow?.visibleRows) || 5; const loadMoreCount = Number(overflow?.loadMoreCount) || 5; const maxExpandRows = Number(overflow?.maxExpandRows) || 50; const showExpandButton = overflow?.showExpandButton ?? true; const pageSize = Number(overflow?.pageSize) || visibleRows; const paginationStyle = overflow?.paginationStyle || "bottom"; // --- 외부 필터 적용 (복수 필터 AND 결합) --- const filteredRows = useMemo(() => { if (externalFilters.size === 0) return rows; const matchSingleFilter = ( row: RowData, filter: { fieldName: string; value: unknown; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string } } ): boolean => { const searchValue = String(filter.value).toLowerCase(); if (!searchValue) return true; const fc = filter.filterConfig; const columns: string[] = fc?.targetColumns?.length ? fc.targetColumns : fc?.targetColumn ? [fc.targetColumn] : filter.fieldName ? [filter.fieldName] : []; if (columns.length === 0) return true; const mode = fc?.filterMode || "contains"; const matchCell = (cellValue: string) => { switch (mode) { case "equals": return cellValue === searchValue; case "starts_with": return cellValue.startsWith(searchValue); case "contains": default: return cellValue.includes(searchValue); } }; return columns.some((col) => matchCell(String(row[col] ?? "").toLowerCase())); }; const allFilters = [...externalFilters.values()]; return rows.filter((row) => allFilters.every((f) => matchSingleFilter(row, f))); }, [rows, externalFilters]); // --- 더보기 모드 --- useEffect(() => { setDisplayCount(visibleRows); }, [visibleRows]); const effectiveLimit = Math.min(displayCount || visibleRows, maxExpandRows, filteredRows.length); const hasMore = showExpandButton && filteredRows.length > effectiveLimit && effectiveLimit < maxExpandRows; const isExpanded = effectiveLimit > visibleRows; const handleLoadMore = useCallback(() => { setDisplayCount((prev) => { const current = prev || visibleRows; return Math.min(current + loadMoreCount, maxExpandRows, filteredRows.length); }); }, [visibleRows, loadMoreCount, maxExpandRows, filteredRows.length]); const handleCollapse = useCallback(() => { setDisplayCount(visibleRows); }, [visibleRows]); // --- 페이지네이션 모드 --- const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize)); useEffect(() => { setCurrentPage(1); }, [pageSize, filteredRows.length]); const handlePageChange = useCallback((page: number) => { setCurrentPage(Math.max(1, Math.min(page, totalPages))); }, [totalPages]); // --- 모드별 visibleData 결정 --- const visibleData = useMemo(() => { if (overflowMode === "pagination") { const start = (currentPage - 1) * pageSize; return filteredRows.slice(start, start + pageSize); } return filteredRows.slice(0, effectiveLimit); }, [overflowMode, filteredRows, currentPage, pageSize, effectiveLimit]); // dataSource 원시값 추출 (객체 참조 대신 안정적인 의존성 사용) const dsTableName = dataSource?.tableName; const dsSortColumn = dataSource?.sort?.column; const dsSortDirection = dataSource?.sort?.direction; const dsLimitMode = dataSource?.limit?.mode; const dsLimitCount = dataSource?.limit?.count; const dsFiltersKey = useMemo( () => JSON.stringify(dataSource?.filters || []), [dataSource?.filters] ); // 데이터 조회 useEffect(() => { if (!dsTableName) { setLoading(false); setRows([]); return; } const fetchData = async () => { setLoading(true); setError(null); try { // 필터 조건 구성 (설정 패널 고정 필터 + 외부 검색 필터) const filters: Record = {}; const parsedFilters = JSON.parse(dsFiltersKey) as Array<{ column?: string; value?: string }>; if (parsedFilters.length > 0) { parsedFilters.forEach((f) => { if (f.column && f.value) { filters[f.column] = f.value; } }); } // 정렬 조건 const sortBy = dsSortColumn; const sortOrder = dsSortDirection; // 개수 제한 (string 유입 방어: Number 캐스팅) const size = dsLimitMode === "limited" && dsLimitCount ? Number(dsLimitCount) : maxExpandRows; const result = await dataApi.getTableData(dsTableName, { page: 1, size, sortBy: sortOrder ? sortBy : undefined, sortOrder, filters: Object.keys(filters).length > 0 ? filters : undefined, }); setRows(result.data || []); } catch (err) { const message = err instanceof Error ? err.message : "데이터 조회 실패"; setError(message); setRows([]); } finally { setLoading(false); } }; fetchData(); }, [dsTableName, dsSortColumn, dsSortDirection, dsLimitMode, dsLimitCount, dsFiltersKey, maxExpandRows]); // 로딩 상태 if (loading) { return (
); } // 에러 상태 if (error) { return (
{error}
); } // 테이블 미선택 if (!dataSource?.tableName) { return (
테이블을 선택하세요
); } // 데이터 없음 if (rows.length === 0) { return (
데이터가 없습니다
); } const isPaginationSide = overflowMode === "pagination" && paginationStyle === "side" && totalPages > 1; return (
{/* 헤더 */} {header?.enabled && header.label && (
{header.label}
)} {/* 컨텐츠 */}
{displayMode === "list" ? ( ) : ( )} {isPaginationSide && ( <> )}
{/* side 모드 페이지 인디케이터 (컨텐츠 아래 별도 영역) */} {isPaginationSide && (
{currentPage} / {totalPages}
)} {/* 더보기 모드 컨트롤 */} {overflowMode === "loadMore" && showExpandButton && (hasMore || isExpanded) && (
{hasMore && ( )} {isExpanded && ( )}
)} {/* 페이지네이션 bottom 모드 컨트롤 */} {overflowMode === "pagination" && paginationStyle === "bottom" && totalPages > 1 && (
{rows.length}건 중 {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, rows.length)}
{currentPage} / {totalPages}
)}
); } // ===== 리스트 모드 ===== interface ListModeViewProps { columns: ListColumnConfig[]; data: RowData[]; onRowClick?: (row: RowData) => void; } function ListModeView({ columns, data, onRowClick }: ListModeViewProps) { // 런타임 컬럼 전환 상태 // key: 컬럼 인덱스, value: 현재 활성 컬럼명 (alternateColumns 중 하나 또는 원래 columnName) const [activeColumns, setActiveColumns] = useState>({}); if (columns.length === 0) { return (
컬럼을 설정하세요
); } const gridCols = columns.map((c) => c.width || "1fr").join(" "); return (
{/* 헤더 행 */}
{columns.map((col, colIdx) => { const hasAlternates = (col.alternateColumns || []).length > 0; const currentColName = activeColumns[colIdx] || col.columnName; // 원래 컬럼이면 기존 라벨, 전환된 컬럼이면 컬럼명 부분만 표시 const currentLabel = currentColName === col.columnName ? col.label : resolveColumnName(currentColName); if (hasAlternates) { // 전환 가능한 헤더: Popover 드롭다운 return (
{/* 원래 컬럼 */} {/* 대체 컬럼들 */} {(col.alternateColumns || []).map((altCol) => { const altLabel = resolveColumnName(altCol); return ( ); })}
); } // 전환 없는 일반 헤더 return (
{col.label}
); })}
{/* 데이터 행 */} {data.map((row, i) => (
onRowClick?.(row)} > {columns.map((col, colIdx) => { const currentColName = activeColumns[colIdx] || col.columnName; const resolvedKey = resolveColumnName(currentColName); return (
{String(row[resolvedKey] ?? "")}
); })}
))}
); } // ===== 카드 모드 ===== interface CardModeViewProps { cardGrid?: CardGridConfig; data: RowData[]; handleCardButtonClick?: (cell: CardCellDefinition, row: RowData) => void; loadingRowId?: number; } function CardModeView({ cardGrid, data, handleCardButtonClick, loadingRowId }: CardModeViewProps) { if (!cardGrid || (cardGrid.cells || []).length === 0) { return (
카드 레이아웃을 설정하세요
); } return (
{data.map((row, i) => (
0 ? cardGrid.colWidths.map((w) => `minmax(30px, ${w || "1fr"})`).join(" ") : "1fr", gridTemplateRows: cardGrid.rowHeights && cardGrid.rowHeights.length > 0 ? cardGrid.rowHeights .map((h) => { if (!h) return "minmax(32px, auto)"; // px 값 -> minmax(Npx, auto): 최소 높이 보장 + 컨텐츠에 맞게 확장 if (h.endsWith("px")) { return `minmax(${h}, auto)`; } // fr 값 -> 마이그레이션 호환: px 변환 후 minmax 적용 const px = Math.round(parseFloat(h) * 32) || 32; return `minmax(${px}px, auto)`; }) .join(" ") : `repeat(${Number(cardGrid.rows) || 1}, minmax(32px, auto))`, gap: `${Number(cardGrid.gap) || 0}px`, }} > {(cardGrid.cells || []).map((cell) => { // 가로 정렬 매핑 const justifyMap = { left: "flex-start", center: "center", right: "flex-end" } as const; const alignItemsMap = { top: "flex-start", middle: "center", bottom: "flex-end" } as const; return (
{renderCellContent(cell, row, handleCardButtonClick, loadingRowId === i)}
); })}
))}
); } // ===== 셀 컨텐츠 렌더링 ===== function renderCellContent( cell: CardCellDefinition, row: RowData, onButtonClick?: (cell: CardCellDefinition, row: RowData) => void, isButtonLoading?: boolean, ): React.ReactNode { const value = row[cell.columnName]; const displayValue = value != null ? String(value) : ""; switch (cell.type) { case "image": return displayValue ? ( {cell.label ) : (
No Image
); case "badge": return ( {displayValue} ); case "button": return ( ); case "text": default: { // 글자 크기 매핑 const fontSizeClass = cell.fontSize === "sm" ? "text-[10px]" : cell.fontSize === "lg" ? "text-sm" : "text-xs"; // md (기본) const isLabelLeft = cell.labelPosition === "left"; return (
{cell.label && ( {cell.label}{isLabelLeft ? ":" : ""} )} {displayValue}
); } } }