"use client"; /** * pop-string-list 런타임 컴포넌트 * * 리스트 모드: 엑셀형 행/열 (CSS Grid) * 카드 모드: 셀 병합 가능한 카드 (CSS Grid + colSpan/rowSpan) * 오버플로우: visibleRows 제한 + "전체보기" 확장 */ import { useState, useEffect, useCallback, useMemo } from "react"; import { ChevronDown, ChevronUp, 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; } // 테이블 행 데이터 타입 type RowData = Record; // ===== 메인 컴포넌트 ===== export function PopStringListComponent({ config, className, screenId, }: 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); // 카드 버튼 행 단위 로딩 인덱스 (-1 = 로딩 없음) const [loadingRowIdx, setLoadingRowIdx] = useState(-1); // 이벤트 발행 (카드 버튼 액션에서 사용) const { publish } = usePopEvent(screenId || ""); // 카드 버튼 클릭 핸들러 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] ); // 오버플로우 계산 (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); }, []); // 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 (
데이터가 없습니다
); } return (
{/* 헤더 */} {header?.enabled && header.label && (
{header.label}
)} {/* 컨텐츠 */}
{displayMode === "list" ? ( ) : ( )}
{/* 전체보기 버튼 */} {showExpandButton && hasMore && (
)}
); } // ===== 리스트 모드 ===== interface ListModeViewProps { columns: ListColumnConfig[]; data: RowData[]; } function ListModeView({ columns, data }: 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) => (
{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}
); } } }