Merge branch 'ksh-v2-work' into ksh-button
This commit is contained in:
commit
ae3261d9bc
|
|
@ -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: "테이블 데이터를 카드 형태로 표시",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// 드래그 가능한 컴포넌트 아이템
|
// 드래그 가능한 컴포넌트 아이템
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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"],
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue