ERP-node/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx

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);
}