"use client"; /** * pop-card-list 런타임 컴포넌트 (V2 - 이미지 참조 기반 재설계) * * 테이블의 각 행이 하나의 카드로 표시됩니다. * 카드 구조: * - 헤더: 코드 + 제목 * - 본문: 이미지(왼쪽) + 라벨-값 목록(오른쪽) */ import React, { useEffect, useState, useRef } from "react"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; import type { PopCardListConfig, CardTemplateConfig, CardFieldBinding, } from "../types"; import { DEFAULT_CARD_IMAGE } from "../types"; import { dataApi } from "@/lib/api/data"; interface PopCardListComponentProps { config?: PopCardListConfig; className?: string; } // 테이블 행 데이터 타입 type RowData = Record; export function PopCardListComponent({ config, className, }: PopCardListComponentProps) { const layoutMode = config?.layoutMode || "grid"; const cardSize = config?.cardSize || "medium"; const cardsPerRow = config?.cardsPerRow || 3; const dataSource = config?.dataSource; const template = config?.cardTemplate; // 데이터 상태 const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // 이미지 URL 없는 항목 카운트 (toast 중복 방지용) const missingImageCountRef = useRef(0); const toastShownRef = useRef(false); // 데이터 조회 useEffect(() => { if (!dataSource?.tableName) { setLoading(false); setRows([]); return; } const fetchData = async () => { setLoading(true); setError(null); missingImageCountRef.current = 0; toastShownRef.current = false; 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; // 개수 제한 const size = dataSource.limit?.mode === "limited" && dataSource.limit?.count ? dataSource.limit.count : 100; // TODO: 조인 지원은 추후 구현 // 현재는 단일 테이블 조회만 지원 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]); // 이미지 URL 없는 항목 체크 및 toast 표시 useEffect(() => { if ( !loading && rows.length > 0 && template?.image?.enabled && template?.image?.imageColumn && !toastShownRef.current ) { const imageColumn = template.image.imageColumn; const missingCount = rows.filter((row) => !row[imageColumn]).length; if (missingCount > 0) { missingImageCountRef.current = missingCount; toastShownRef.current = true; toast.warning( `${missingCount}개 항목의 이미지 URL이 없어 기본 이미지로 표시됩니다` ); } } }, [loading, rows, template?.image]); // 레이아웃 클래스 (스크롤 지원) const layoutClass = layoutMode === "vertical" ? "flex flex-col gap-3 h-full overflow-y-auto" : layoutMode === "horizontal" ? "flex flex-row gap-3 h-full overflow-x-auto pb-2" : "grid gap-3 h-full overflow-y-auto"; // 그리드 스타일 const gridStyle = layoutMode === "grid" ? { gridTemplateColumns: `repeat(${cardsPerRow}, minmax(0, 1fr))` } : undefined; // 설정 미완료 상태 if (!dataSource?.tableName) { return (

데이터 소스를 설정해주세요.

); } // 로딩 중 if (loading) { return (
); } // 에러 상태 if (error) { return (

{error}

); } // 데이터 없음 if (rows.length === 0) { return (

데이터가 없습니다.

); } return (
{rows.map((row, index) => ( ))}
); } // ===== 카드 크기별 설정 ===== const CARD_SIZE_CONFIG = { small: { minHeight: "min-h-[120px]", minWidth: "min-w-[200px]", imageSize: "h-14 w-14", padding: "p-2", gap: "gap-2", headerPadding: "px-2 py-1.5", codeText: "text-[10px]", titleText: "text-xs", }, medium: { minHeight: "min-h-[140px]", minWidth: "min-w-[260px]", imageSize: "h-16 w-16", padding: "p-3", gap: "gap-3", headerPadding: "px-3 py-2", codeText: "text-xs", titleText: "text-sm", }, large: { minHeight: "min-h-[180px]", minWidth: "min-w-[320px]", imageSize: "h-20 w-20", padding: "p-4", gap: "gap-4", headerPadding: "px-4 py-2.5", codeText: "text-xs", titleText: "text-base", }, }; // ===== 카드 컴포넌트 ===== function Card({ row, template, cardSize, isHorizontal, }: { row: RowData; template?: CardTemplateConfig; cardSize: "small" | "medium" | "large"; isHorizontal: boolean; }) { const header = template?.header; const image = template?.image; const body = template?.body; // 크기별 설정 const sizeConfig = CARD_SIZE_CONFIG[cardSize]; // 헤더 값 추출 const codeValue = header?.codeField ? row[header.codeField] : null; const titleValue = header?.titleField ? row[header.titleField] : null; // 이미지 URL 결정 const imageUrl = image?.enabled && image?.imageColumn && row[image.imageColumn] ? String(row[image.imageColumn]) : image?.defaultImage || DEFAULT_CARD_IMAGE; return (
{/* 헤더 영역 */} {(codeValue !== null || titleValue !== null) && (
{codeValue !== null && ( {formatValue(codeValue)} )} {titleValue !== null && ( {formatValue(titleValue)} )}
)} {/* 본문 영역 */}
{/* 이미지 (왼쪽) */} {image?.enabled && (
{ // 이미지 로드 실패 시 기본 이미지로 대체 const target = e.target as HTMLImageElement; if (target.src !== DEFAULT_CARD_IMAGE) { target.src = DEFAULT_CARD_IMAGE; } }} />
)} {/* 필드 목록 (오른쪽) */}
{body?.fields && body.fields.length > 0 ? (
{body.fields.map((field) => ( ))}
) : (
본문 필드를 추가하세요
)}
); } // ===== 필드 행 컴포넌트 ===== function FieldRow({ field, row, cardSize, }: { field: CardFieldBinding; row: RowData; cardSize: "small" | "medium" | "large"; }) { const value = row[field.columnName]; // 크기별 텍스트 설정 const textSize = cardSize === "small" ? "text-[10px]" : "text-xs"; const labelMinWidth = cardSize === "small" ? "min-w-[50px]" : "min-w-[60px]"; return (
{/* 라벨 */} {field.label} {/* 값 */} {formatValue(value)}
); } // ===== 값 포맷팅 ===== function formatValue(value: unknown): string { if (value === null || value === undefined) { return "-"; } if (typeof value === "number") { return value.toLocaleString(); } if (typeof value === "boolean") { return value ? "예" : "아니오"; } if (value instanceof Date) { return value.toLocaleDateString(); } // ISO 날짜 문자열 감지 및 포맷 if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) { const date = new Date(value); if (!isNaN(date.getTime())) { // MM-DD 형식으로 표시 return `${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; } } return String(value); }