396 lines
11 KiB
TypeScript
396 lines
11 KiB
TypeScript
"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<string, unknown>;
|
|
|
|
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<RowData[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(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<string, unknown> = {};
|
|
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 (
|
|
<div
|
|
className={`flex h-full w-full items-center justify-center rounded-md border border-dashed bg-muted/30 p-4 ${className || ""}`}
|
|
>
|
|
<p className="text-sm text-muted-foreground">
|
|
데이터 소스를 설정해주세요.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 로딩 중
|
|
if (loading) {
|
|
return (
|
|
<div
|
|
className={`flex h-full w-full items-center justify-center rounded-md border bg-muted/30 p-4 ${className || ""}`}
|
|
>
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 에러 상태
|
|
if (error) {
|
|
return (
|
|
<div
|
|
className={`flex h-full w-full items-center justify-center rounded-md border border-destructive/50 bg-destructive/10 p-4 ${className || ""}`}
|
|
>
|
|
<p className="text-sm text-destructive">{error}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 데이터 없음
|
|
if (rows.length === 0) {
|
|
return (
|
|
<div
|
|
className={`flex h-full w-full items-center justify-center rounded-md border border-dashed bg-muted/30 p-4 ${className || ""}`}
|
|
>
|
|
<p className="text-sm text-muted-foreground">데이터가 없습니다.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={`h-full w-full ${layoutClass} ${className || ""}`} style={gridStyle}>
|
|
{rows.map((row, index) => (
|
|
<Card
|
|
key={index}
|
|
row={row}
|
|
template={template}
|
|
cardSize={cardSize}
|
|
isHorizontal={layoutMode === "horizontal"}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== 카드 크기별 설정 =====
|
|
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 (
|
|
<div
|
|
className={`rounded-lg border bg-card shadow-sm overflow-hidden ${sizeConfig.minHeight} ${
|
|
isHorizontal ? `flex-shrink-0 ${sizeConfig.minWidth}` : ""
|
|
}`}
|
|
>
|
|
{/* 헤더 영역 */}
|
|
{(codeValue !== null || titleValue !== null) && (
|
|
<div className={`border-b bg-muted/30 ${sizeConfig.headerPadding}`}>
|
|
<div className="flex items-center gap-2">
|
|
{codeValue !== null && (
|
|
<span className={`font-semibold text-muted-foreground ${sizeConfig.codeText}`}>
|
|
{formatValue(codeValue)}
|
|
</span>
|
|
)}
|
|
{titleValue !== null && (
|
|
<span className={`font-medium truncate ${sizeConfig.titleText}`}>
|
|
{formatValue(titleValue)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 본문 영역 */}
|
|
<div className={`flex ${sizeConfig.padding} ${sizeConfig.gap}`}>
|
|
{/* 이미지 (왼쪽) */}
|
|
{image?.enabled && (
|
|
<div className="flex-shrink-0">
|
|
<div className={`${sizeConfig.imageSize} rounded-md border bg-muted/30 flex items-center justify-center overflow-hidden`}>
|
|
<img
|
|
src={imageUrl}
|
|
alt=""
|
|
className="h-full w-full object-contain p-1"
|
|
onError={(e) => {
|
|
// 이미지 로드 실패 시 기본 이미지로 대체
|
|
const target = e.target as HTMLImageElement;
|
|
if (target.src !== DEFAULT_CARD_IMAGE) {
|
|
target.src = DEFAULT_CARD_IMAGE;
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 필드 목록 (오른쪽) */}
|
|
<div className="flex-1 min-w-0">
|
|
{body?.fields && body.fields.length > 0 ? (
|
|
<div className={cardSize === "small" ? "space-y-1" : "space-y-1.5"}>
|
|
{body.fields.map((field) => (
|
|
<FieldRow key={field.id} field={field} row={row} cardSize={cardSize} />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">
|
|
본문 필드를 추가하세요
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== 필드 행 컴포넌트 =====
|
|
|
|
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 (
|
|
<div className={`flex items-baseline gap-1.5 ${textSize}`}>
|
|
{/* 라벨 */}
|
|
<span className={`flex-shrink-0 text-muted-foreground ${labelMinWidth}`}>
|
|
{field.label}
|
|
</span>
|
|
{/* 값 */}
|
|
<span
|
|
className="font-medium truncate"
|
|
style={field.textColor ? { color: field.textColor } : undefined}
|
|
>
|
|
{formatValue(value)}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== 값 포맷팅 =====
|
|
|
|
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);
|
|
}
|