"use client"; /** * pop-string-list 런타임 컴포넌트 * * 리스트 모드: 엑셀형 행/열 (CSS Grid) * 카드 모드: 셀 병합 가능한 카드 (CSS Grid + colSpan/rowSpan) * 오버플로우: visibleRows 제한 + "전체보기" 확장 */ import { useState, useEffect, useCallback } from "react"; import { ChevronDown, ChevronUp, Loader2, AlertCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { dataApi } from "@/lib/api/data"; import type { PopStringListConfig, CardGridConfig, ListColumnConfig, CardCellDefinition, } from "./types"; // ===== Props ===== interface PopStringListComponentProps { config?: PopStringListConfig; className?: string; } // 테이블 행 데이터 타입 type RowData = Record; // ===== 메인 컴포넌트 ===== export function PopStringListComponent({ config, className, }: 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 [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [expanded, setExpanded] = useState(false); // 오버플로우 계산 (JSON 복원 시 string 유입 방어) const visibleRows = Number(overflow?.visibleRows) || 5; const maxExpandRows = Number(overflow?.maxExpandRows) || 20; const showExpandButton = overflow?.showExpandButton ?? true; // 표시할 데이터 슬라이스 const visibleData = expanded ? rows.slice(0, maxExpandRows) : rows.slice(0, visibleRows); const hasMore = rows.length > visibleRows; // 확장/축소 토글 const toggleExpanded = useCallback(() => { setExpanded((prev) => !prev); }, []); // 데이터 조회 useEffect(() => { if (!dataSource?.tableName) { setLoading(false); setRows([]); return; } const fetchData = async () => { setLoading(true); setError(null); try { // 필터 조건 구성 const filters: Record = {}; if (dataSource.filters && dataSource.filters.length > 0) { dataSource.filters.forEach((f) => { if (f.column && f.value) { filters[f.column] = f.value; } }); } // 정렬 조건 const sortBy = dataSource.sort?.column; const sortOrder = dataSource.sort?.direction; // 개수 제한 (string 유입 방어: Number 캐스팅) const size = dataSource.limit?.mode === "limited" && dataSource.limit?.count ? Number(dataSource.limit.count) : maxExpandRows; const result = await dataApi.getTableData(dataSource.tableName, { 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(); }, [dataSource, maxExpandRows]); // 로딩 상태 if (loading) { return (
); } // 에러 상태 if (error) { return (
{error}
); } // 테이블 미선택 if (!dataSource?.tableName) { return (
테이블을 선택하세요
); } // 데이터 없음 if (rows.length === 0) { return (
데이터가 없습니다
); } return (
{/* 헤더 */} {header?.enabled && header.label && (
{header.label}
)} {/* 컨텐츠 */}
{displayMode === "list" ? ( ) : ( )}
{/* 전체보기 버튼 */} {showExpandButton && hasMore && (
)}
); } // ===== 리스트 모드 ===== interface ListModeViewProps { columns: ListColumnConfig[]; data: RowData[]; } function ListModeView({ columns, data }: ListModeViewProps) { if (columns.length === 0) { return (
컬럼을 설정하세요
); } const gridCols = columns.map((c) => c.width || "1fr").join(" "); return (
{/* 헤더 행 */}
{columns.map((col) => (
{col.label}
))}
{/* 데이터 행 */} {data.map((row, i) => (
{columns.map((col) => (
{String(row[col.columnName] ?? "")}
))}
))}
); } // ===== 카드 모드 ===== interface CardModeViewProps { cardGrid?: CardGridConfig; data: RowData[]; } function CardModeView({ cardGrid, data }: 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 "32px"; // px 값은 직접 사용, fr 값은 마이그레이션 호환 return h.endsWith("px") ? h : `${Math.round(parseFloat(h) * 32) || 32}px`; }) .join(" ") : `repeat(${Number(cardGrid.rows) || 1}, 32px)`, 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)}
); })}
))}
); } // ===== 셀 컨텐츠 렌더링 ===== function renderCellContent(cell: CardCellDefinition, row: RowData): 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}
); } } }