feat(pop-card-list): PopCardList 컴포넌트 추가

- PopCardListComponent: 카드 리스트 렌더링 컴포넌트 구현
- PopCardListConfig: 카드 리스트 설정 패널 구현
- types.ts: PopCardListProps 타입 정의 추가
- ComponentPalette: 카드 리스트 컴포넌트 팔레트에 등록
- pop-layout.ts: cardList 타입 추가

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
shin 2026-02-12 11:07:58 +09:00
parent 8c08e7f8e9
commit 6b8d437a22
8 changed files with 2211 additions and 2 deletions

View File

@ -3,7 +3,7 @@
import { useDrag } from "react-dnd"; import { useDrag } from "react-dnd";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PopComponentType } from "../types/pop-layout"; import { PopComponentType } from "../types/pop-layout";
import { Square, FileText, MousePointer, BarChart3 } from "lucide-react"; import { Square, FileText, MousePointer, BarChart3, LayoutGrid } from "lucide-react";
import { DND_ITEM_TYPES } from "../constants"; import { DND_ITEM_TYPES } from "../constants";
// 컴포넌트 정의 // 컴포넌트 정의
@ -39,6 +39,12 @@ const PALETTE_ITEMS: PaletteItem[] = [
icon: BarChart3, icon: BarChart3,
description: "KPI, 차트, 게이지, 통계 집계", description: "KPI, 차트, 게이지, 통계 집계",
}, },
{
type: "pop-card-list",
label: "카드 목록",
icon: LayoutGrid,
description: "테이블 데이터를 카드 형태로 표시",
},
]; ];
// 드래그 가능한 컴포넌트 아이템 // 드래그 가능한 컴포넌트 아이템

View File

@ -9,7 +9,7 @@
/** /**
* POP * POP
*/ */
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard"; export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list";
/** /**
* *
@ -344,6 +344,7 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: nu
"pop-text": { colSpan: 3, rowSpan: 1 }, "pop-text": { colSpan: 3, rowSpan: 1 },
"pop-icon": { colSpan: 1, rowSpan: 2 }, "pop-icon": { colSpan: 1, rowSpan: 2 },
"pop-dashboard": { colSpan: 6, rowSpan: 3 }, "pop-dashboard": { colSpan: 6, rowSpan: 3 },
"pop-card-list": { colSpan: 4, rowSpan: 3 },
}; };
/** /**

View File

@ -15,6 +15,7 @@ export * from "./types";
import "./pop-text"; import "./pop-text";
import "./pop-icon"; import "./pop-icon";
import "./pop-dashboard"; import "./pop-dashboard";
import "./pop-card-list";
// 향후 추가될 컴포넌트들: // 향후 추가될 컴포넌트들:
// import "./pop-field"; // import "./pop-field";

View File

@ -0,0 +1,395 @@
"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);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,200 @@
"use client";
/**
* pop-card-list (V2)
*
*
*
*/
import React from "react";
import { LayoutGrid, Package } from "lucide-react";
import type { PopCardListConfig } from "../types";
import {
CARD_LAYOUT_MODE_LABELS,
CARD_SIZE_LABELS,
DEFAULT_CARD_IMAGE,
} from "../types";
interface PopCardListPreviewProps {
config?: PopCardListConfig;
}
export function PopCardListPreviewComponent({
config,
}: PopCardListPreviewProps) {
const layoutMode = config?.layoutMode || "grid";
const cardSize = config?.cardSize || "medium";
const cardsPerRow = config?.cardsPerRow || 3;
const dataSource = config?.dataSource;
const template = config?.cardTemplate;
// 설정 상태 확인
const hasTable = !!dataSource?.tableName;
const hasHeader =
!!template?.header?.codeField || !!template?.header?.titleField;
const hasImage = template?.image?.enabled ?? true;
const fieldCount = template?.body?.fields?.length || 0;
// 샘플 카드 개수 (미리보기용)
const sampleCardCount =
layoutMode === "grid" ? Math.min(cardsPerRow, 3) : 2;
return (
<div className="flex h-full w-full flex-col bg-muted/30 p-3">
{/* 헤더 */}
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2 text-muted-foreground">
<LayoutGrid className="h-4 w-4" />
<span className="text-xs font-medium"> </span>
</div>
{/* 설정 배지 */}
<div className="flex gap-1">
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[9px] text-primary">
{CARD_LAYOUT_MODE_LABELS[layoutMode]}
</span>
<span className="rounded bg-secondary px-1.5 py-0.5 text-[9px] text-secondary-foreground">
{CARD_SIZE_LABELS[cardSize]}
</span>
</div>
</div>
{/* 테이블 미선택 시 안내 */}
{!hasTable ? (
<div className="flex flex-1 items-center justify-center">
<div className="text-center">
<Package className="mx-auto mb-2 h-8 w-8 text-muted-foreground/50" />
<p className="text-xs text-muted-foreground">
</p>
</div>
</div>
) : (
<>
{/* 테이블 정보 */}
<div className="mb-2 text-center">
<span className="rounded bg-muted px-2 py-0.5 text-[10px] text-muted-foreground">
{dataSource.tableName}
</span>
</div>
{/* 카드 미리보기 */}
<div
className={`flex-1 flex gap-2 ${
layoutMode === "vertical"
? "flex-col"
: layoutMode === "horizontal"
? "flex-row overflow-x-auto"
: "flex-wrap justify-center content-start"
}`}
>
{Array.from({ length: sampleCardCount }).map((_, idx) => (
<PreviewCard
key={idx}
index={idx}
hasHeader={hasHeader}
hasImage={hasImage}
fieldCount={fieldCount}
cardSize={cardSize}
layoutMode={layoutMode}
/>
))}
</div>
{/* 필드 정보 */}
{fieldCount > 0 && (
<div className="mt-2 text-center">
<span className="text-[10px] text-muted-foreground">
{fieldCount}
</span>
</div>
)}
</>
)}
</div>
);
}
// ===== 미리보기 카드 컴포넌트 =====
function PreviewCard({
index,
hasHeader,
hasImage,
fieldCount,
cardSize,
layoutMode,
}: {
index: number;
hasHeader: boolean;
hasImage: boolean;
fieldCount: number;
cardSize: string;
layoutMode: string;
}) {
// 카드 크기
const sizeClass =
cardSize === "small"
? "min-h-[60px]"
: cardSize === "large"
? "min-h-[100px]"
: "min-h-[80px]";
const widthClass =
layoutMode === "vertical"
? "w-full"
: layoutMode === "horizontal"
? "min-w-[140px] flex-shrink-0"
: "w-[140px]";
return (
<div
className={`rounded-md border bg-card overflow-hidden ${sizeClass} ${widthClass}`}
>
{/* 헤더 */}
{hasHeader && (
<div className="border-b bg-muted/30 px-2 py-1">
<div className="flex items-center gap-1">
<span className="h-2 w-8 rounded bg-muted-foreground/20" />
<span className="h-2 w-12 rounded bg-muted-foreground/30" />
</div>
</div>
)}
{/* 본문 */}
<div className="flex p-2 gap-2">
{/* 이미지 */}
{hasImage && (
<div className="flex-shrink-0">
<div className="h-10 w-10 rounded border bg-muted/30 flex items-center justify-center">
<img
src={DEFAULT_CARD_IMAGE}
alt=""
className="h-6 w-6 opacity-50"
/>
</div>
</div>
)}
{/* 필드 목록 */}
<div className="flex-1 space-y-1">
{fieldCount > 0 ? (
Array.from({ length: Math.min(fieldCount, 3) }).map((_, i) => (
<div key={i} className="flex items-center gap-1">
<span className="h-1.5 w-6 rounded bg-muted-foreground/20" />
<span className="h-1.5 w-10 rounded bg-muted-foreground/30" />
</div>
))
) : (
<div className="flex h-full items-center justify-center">
<span className="text-[8px] text-muted-foreground">
</span>
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,56 @@
"use client";
/**
* pop-card-list (V2)
*
* import하면 side-effect로 PopComponentRegistry에
*/
import { PopComponentRegistry } from "../../PopComponentRegistry";
import { PopCardListComponent } from "./PopCardListComponent";
import { PopCardListConfigPanel } from "./PopCardListConfig";
import { PopCardListPreviewComponent } from "./PopCardListPreview";
import type { PopCardListConfig } from "../types";
import { DEFAULT_CARD_IMAGE } from "../types";
// 기본 설정값 (V2 구조)
const defaultConfig: PopCardListConfig = {
// 데이터 소스 (테이블 단위)
dataSource: {
tableName: "",
},
// 카드 템플릿
cardTemplate: {
header: {
codeField: undefined,
titleField: undefined,
},
image: {
enabled: true,
imageColumn: undefined,
defaultImage: DEFAULT_CARD_IMAGE,
},
body: {
fields: [],
},
},
// 레이아웃 설정
layoutMode: "grid",
cardsPerRow: 3,
cardSize: "medium",
};
// 레지스트리 등록
PopComponentRegistry.registerComponent({
id: "pop-card-list",
name: "카드 목록",
description: "테이블 데이터를 카드 형태로 표시 (헤더 + 이미지 + 필드 목록)",
category: "display",
icon: "LayoutGrid",
component: PopCardListComponent,
configPanel: PopCardListConfigPanel,
preview: PopCardListPreviewComponent,
defaultProps: defaultConfig,
touchOptimized: true,
supportedDevices: ["mobile", "tablet"],
});

View File

@ -342,3 +342,114 @@ export interface PopDashboardConfig {
// 데이터 소스 (아이템 공통) // 데이터 소스 (아이템 공통)
dataSource?: DataSourceConfig; dataSource?: DataSourceConfig;
} }
// =============================================
// pop-card-list 전용 타입 (V2 - 이미지 참조 기반 재설계)
// =============================================
// ----- 조인 설정 -----
export interface CardColumnJoin {
targetTable: string;
joinType: "LEFT" | "INNER" | "RIGHT";
sourceColumn: string; // 메인 테이블 컬럼
targetColumn: string; // 조인 테이블 컬럼
}
// ----- 필터 설정 -----
export interface CardColumnFilter {
column: string;
operator: FilterOperator;
value: string;
}
// ----- 본문 필드 바인딩 (라벨-값 쌍) -----
export interface CardFieldBinding {
id: string;
columnName: string; // DB 컬럼명
label: string; // 표시 라벨 (예: "발주일")
textColor?: string; // 텍스트 색상 (예: "#ef4444" 빨간색)
}
// ----- 카드 헤더 설정 (코드 + 제목) -----
export interface CardHeaderConfig {
codeField?: string; // 코드로 표시할 컬럼 선택
titleField?: string; // 제목으로 표시할 컬럼 선택
}
// ----- 카드 이미지 설정 -----
// 기본 이미지 URL (박스 아이콘)
export const DEFAULT_CARD_IMAGE =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='1.5'%3E%3Cpath d='M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4'/%3E%3C/svg%3E";
export interface CardImageConfig {
enabled: boolean;
imageColumn?: string; // 이미지 URL 컬럼 (선택)
defaultImage: string; // 기본 이미지 (자동 설정, 필수)
}
// ----- 카드 본문 설정 -----
export interface CardBodyConfig {
fields: CardFieldBinding[]; // 라벨-값 쌍 목록
}
// ----- 카드 템플릿 (헤더 + 이미지 + 본문) -----
export interface CardTemplateConfig {
header: CardHeaderConfig;
image: CardImageConfig;
body: CardBodyConfig;
}
// ----- 데이터 소스 (테이블 단위) -----
export interface CardListDataSource {
tableName: string;
joins?: CardColumnJoin[];
filters?: CardColumnFilter[];
sort?: { column: string; direction: "asc" | "desc" };
limit?: { mode: "all" | "limited"; count?: number };
}
// ----- 카드 크기 -----
export type CardSize = "small" | "medium" | "large";
export const CARD_SIZE_LABELS: Record<CardSize, string> = {
small: "작게",
medium: "보통",
large: "크게",
};
// ----- 카드 배치 방식 (방향 기반) -----
export type CardLayoutMode = "grid" | "horizontal" | "vertical";
// grid: 격자 배치 (행/열로 정렬)
// horizontal: 가로 배치 (가로 스크롤)
// vertical: 세로 배치 (세로 스크롤)
export const CARD_LAYOUT_MODE_LABELS: Record<CardLayoutMode, string> = {
grid: "격자 배치",
horizontal: "가로 배치",
vertical: "세로 배치",
};
// ----- pop-card-list 전체 설정 -----
export interface PopCardListConfig {
// 데이터 소스 (테이블 단위)
dataSource: CardListDataSource;
// 카드 템플릿 (헤더 + 이미지 + 본문)
cardTemplate: CardTemplateConfig;
// 레이아웃 설정
layoutMode: CardLayoutMode;
cardsPerRow?: number; // 격자 배치일 때만 사용
cardSize: CardSize;
}