487 lines
15 KiB
TypeScript
487 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import React, { useMemo } from "react";
|
|
import { Eye, Pencil, Trash2 } from "lucide-react";
|
|
import type {
|
|
TableColumn,
|
|
TableConfig,
|
|
TableCardStyleConfig,
|
|
} from "../types";
|
|
import { getFullImageUrl } from "@/lib/api/client";
|
|
import { getFilePreviewUrl } from "@/lib/api/file";
|
|
import { renderTableCellValue } from "../cell-renderers";
|
|
|
|
export interface CardViewProps {
|
|
config: TableConfig;
|
|
data: any[];
|
|
isDesignMode?: boolean;
|
|
onCardClick?: (row: any) => void;
|
|
onView?: (row: any) => void;
|
|
onEdit?: (row: any) => void;
|
|
onDelete?: (row: any) => void;
|
|
/** Phase D.7 — canonical columns 메타. inference / D.5 cell formatting / display 라벨 위해. */
|
|
columns?: TableColumn[];
|
|
/** Phase D.7 — column 라벨 (langKey 번역 반영) */
|
|
getColumnLabel?: (col: TableColumn) => string;
|
|
}
|
|
|
|
const DEFAULT_STYLE: Required<TableCardStyleConfig> = {
|
|
showTitle: true,
|
|
showSubtitle: true,
|
|
showDescription: true,
|
|
showImage: true,
|
|
maxDescriptionLength: 120,
|
|
imagePosition: "top",
|
|
imageSize: "medium",
|
|
showActions: false,
|
|
showViewButton: false,
|
|
showEditButton: false,
|
|
showDeleteButton: false,
|
|
cardHeight: "auto",
|
|
};
|
|
|
|
const IMAGE_SIZE_PX: Record<NonNullable<TableCardStyleConfig["imageSize"]>, number> = {
|
|
small: 80,
|
|
medium: 140,
|
|
large: 200,
|
|
};
|
|
|
|
/**
|
|
* Phase D.7 — image 컬럼 inference: key 가 image/photo/thumbnail 포함 또는
|
|
* inputType/format 이 "image".
|
|
*/
|
|
function _isImageColumn(c: TableColumn): boolean {
|
|
const key = (c.key || "").toLowerCase();
|
|
if (c.inputType === "image" || c.format === "image") return true;
|
|
return /image|photo|thumbnail/.test(key);
|
|
}
|
|
|
|
/**
|
|
* Phase D.7 — file/attachment 컬럼 식별 (image 외): key 가 attachment/file 포함 또는
|
|
* inputType/format 이 "file"/"attachment".
|
|
*/
|
|
function _isFileColumn(c: TableColumn): boolean {
|
|
const key = (c.key || "").toLowerCase();
|
|
if (
|
|
c.inputType === "file" ||
|
|
c.inputType === "attachment" ||
|
|
c.format === "file" ||
|
|
c.format === "attachment"
|
|
)
|
|
return true;
|
|
return (
|
|
key.includes("attachment") ||
|
|
/(^|[_-])files?($|[_-])/.test(key)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Phase D.7 — 이미지 URL 정규화.
|
|
* - http(s):// → 그대로 (getFullImageUrl 가 그대로 반환)
|
|
* - 숫자 objid → getFilePreviewUrl
|
|
* - 그 외 path → getFullImageUrl
|
|
*/
|
|
function _normalizeImageUrl(raw: any): string | null {
|
|
if (raw === null || raw === undefined) return null;
|
|
const s = String(raw).trim();
|
|
if (!s) return null;
|
|
// 콤마 구분 시 첫 값만 사용
|
|
const first = s.includes(",") ? s.split(",")[0].trim() : s;
|
|
if (!first) return null;
|
|
if (/^\d+$/.test(first)) {
|
|
return getFilePreviewUrl(first);
|
|
}
|
|
return getFullImageUrl(first);
|
|
}
|
|
|
|
/**
|
|
* CardView — displayMode="card"
|
|
*
|
|
* 데이터 행을 카드 그리드로 렌더. `config.cardColumnMapping` 으로 데이터 컬럼을
|
|
* 카드 영역 (title/subtitle/description/image/displayColumns) 에 매핑.
|
|
*
|
|
* Phase D.7 (2026-05-20) — legacy 호환:
|
|
* - `cardColumnMapping` 비었으면 columns 기반 inference (title/subtitle/description/image)
|
|
* - legacy `cardConfig.{titleColumn, idColumn, cardHeight}` 도 fallback
|
|
* - 이미지 값 URL normalize (objid → getFilePreviewUrl, path → getFullImageUrl, http(s) 그대로)
|
|
* - displayColumns 의 셀 렌더는 D.5 `renderTableCellValue` 사용 (특수 셀 / 포맷)
|
|
* - idColumn 있으면 카드 우상단에 작은 ID 배지
|
|
* - cardHeight 가 number 면 카드 고정 높이
|
|
*/
|
|
export function CardView({
|
|
config,
|
|
data,
|
|
isDesignMode = false,
|
|
onCardClick,
|
|
onView,
|
|
onEdit,
|
|
onDelete,
|
|
columns,
|
|
getColumnLabel,
|
|
}: CardViewProps) {
|
|
const cardsPerRow = config.cardsPerRow ?? 3;
|
|
const cardSpacing = config.cardSpacing ?? 12;
|
|
const style: Required<TableCardStyleConfig> = {
|
|
...DEFAULT_STYLE,
|
|
...(config.cardStyle ?? {}),
|
|
};
|
|
|
|
// Phase D.7 — mapping fallback (canonical → legacy cardConfig)
|
|
const legacyCardConfig =
|
|
(config as any).cardConfig && typeof (config as any).cardConfig === "object"
|
|
? ((config as any).cardConfig as Record<string, any>)
|
|
: {};
|
|
const explicitMapping = config.cardColumnMapping ?? {};
|
|
|
|
// Phase D.7 — inference (mapping 비어있을 때 columns 사용)
|
|
const inferred = useMemo(() => {
|
|
const cols = Array.isArray(columns) ? columns : [];
|
|
const nonMedia = cols.filter(
|
|
(c) => !_isImageColumn(c) && !_isFileColumn(c),
|
|
);
|
|
const imageCol = cols.find(_isImageColumn);
|
|
return {
|
|
titleColumn: nonMedia[0]?.key,
|
|
subtitleColumn: nonMedia[1]?.key,
|
|
descriptionColumn: nonMedia[2]?.key,
|
|
imageColumn: imageCol?.key,
|
|
};
|
|
}, [columns]);
|
|
|
|
const mapping = useMemo(
|
|
() => ({
|
|
titleColumn:
|
|
explicitMapping.titleColumn ||
|
|
(legacyCardConfig.titleColumn as string | undefined) ||
|
|
inferred.titleColumn,
|
|
subtitleColumn:
|
|
explicitMapping.subtitleColumn ||
|
|
(legacyCardConfig.subtitleColumn as string | undefined) ||
|
|
inferred.subtitleColumn,
|
|
descriptionColumn:
|
|
explicitMapping.descriptionColumn ||
|
|
(legacyCardConfig.descriptionColumn as string | undefined) ||
|
|
inferred.descriptionColumn,
|
|
imageColumn:
|
|
explicitMapping.imageColumn ||
|
|
(legacyCardConfig.imageColumn as string | undefined) ||
|
|
inferred.imageColumn,
|
|
idColumn:
|
|
explicitMapping.idColumn ||
|
|
(legacyCardConfig.idColumn as string | undefined),
|
|
displayColumns: explicitMapping.displayColumns,
|
|
}),
|
|
[explicitMapping, legacyCardConfig, inferred],
|
|
);
|
|
|
|
// Phase D.7 — legacy cardHeight (cardConfig.cardHeight) fallback
|
|
const cardHeight =
|
|
style.cardHeight !== "auto" && style.cardHeight !== undefined
|
|
? style.cardHeight
|
|
: (legacyCardConfig.cardHeight as number | undefined);
|
|
|
|
// Phase D.7 — column 메타 lookup (displayColumns 의 cell renderer 용)
|
|
const columnByKey = useMemo(() => {
|
|
const m = new Map<string, TableColumn>();
|
|
if (Array.isArray(columns)) {
|
|
for (const c of columns) m.set(c.key, c);
|
|
}
|
|
return m;
|
|
}, [columns]);
|
|
|
|
if (data.length === 0) {
|
|
return <div style={emptyStyle}>{config.emptyMessage || "데이터 없음"}</div>;
|
|
}
|
|
|
|
const gridStyle: React.CSSProperties = {
|
|
display: "grid",
|
|
gridTemplateColumns: `repeat(${cardsPerRow}, minmax(0, 1fr))`,
|
|
gap: cardSpacing,
|
|
padding: 12,
|
|
overflow: "auto",
|
|
flex: 1,
|
|
};
|
|
|
|
return (
|
|
<div style={gridStyle}>
|
|
{data.map((row, idx) => (
|
|
<CardItem
|
|
key={idx}
|
|
row={row}
|
|
mapping={mapping}
|
|
style={style}
|
|
cardHeight={typeof cardHeight === "number" ? cardHeight : undefined}
|
|
columnByKey={columnByKey}
|
|
getColumnLabel={getColumnLabel}
|
|
onClick={onCardClick ? () => onCardClick(row) : undefined}
|
|
onView={style.showActions && style.showViewButton ? () => onView?.(row) : undefined}
|
|
onEdit={style.showActions && style.showEditButton ? () => onEdit?.(row) : undefined}
|
|
onDelete={style.showActions && style.showDeleteButton ? () => onDelete?.(row) : undefined}
|
|
isDesignMode={isDesignMode}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface CardItemProps {
|
|
row: any;
|
|
mapping: {
|
|
titleColumn?: string;
|
|
subtitleColumn?: string;
|
|
descriptionColumn?: string;
|
|
imageColumn?: string;
|
|
idColumn?: string;
|
|
displayColumns?: string[];
|
|
};
|
|
style: Required<TableCardStyleConfig>;
|
|
cardHeight?: number;
|
|
columnByKey: Map<string, TableColumn>;
|
|
getColumnLabel?: (col: TableColumn) => string;
|
|
isDesignMode: boolean;
|
|
onClick?: () => void;
|
|
onView?: () => void;
|
|
onEdit?: () => void;
|
|
onDelete?: () => void;
|
|
}
|
|
|
|
function CardItem({
|
|
row,
|
|
mapping,
|
|
style,
|
|
cardHeight,
|
|
columnByKey,
|
|
getColumnLabel,
|
|
isDesignMode,
|
|
onClick,
|
|
onView,
|
|
onEdit,
|
|
onDelete,
|
|
}: CardItemProps) {
|
|
const [imgError, setImgError] = React.useState(false);
|
|
|
|
const title = mapping.titleColumn ? row?.[mapping.titleColumn] : undefined;
|
|
const subtitle = mapping.subtitleColumn ? row?.[mapping.subtitleColumn] : undefined;
|
|
const descriptionRaw = mapping.descriptionColumn ? row?.[mapping.descriptionColumn] : undefined;
|
|
const imageRaw = mapping.imageColumn ? row?.[mapping.imageColumn] : undefined;
|
|
const idValue = mapping.idColumn ? row?.[mapping.idColumn] : undefined;
|
|
React.useEffect(() => {
|
|
setImgError(false);
|
|
}, [mapping.imageColumn, imageRaw]);
|
|
const description =
|
|
typeof descriptionRaw === "string" && descriptionRaw.length > style.maxDescriptionLength
|
|
? `${descriptionRaw.slice(0, style.maxDescriptionLength)}…`
|
|
: descriptionRaw;
|
|
const imageUrl = _normalizeImageUrl(imageRaw);
|
|
|
|
const imagePx = IMAGE_SIZE_PX[style.imageSize];
|
|
const isHorizontal = style.imagePosition === "left" || style.imagePosition === "right";
|
|
|
|
const cardStyle: React.CSSProperties = {
|
|
border: "1px solid hsl(var(--border))",
|
|
borderRadius: 8,
|
|
background: "hsl(var(--card))",
|
|
overflow: "hidden",
|
|
cursor: onClick ? "pointer" : "default",
|
|
display: isHorizontal ? "flex" : "block",
|
|
flexDirection: style.imagePosition === "right" ? "row-reverse" : "row",
|
|
...(typeof cardHeight === "number" && cardHeight > 0
|
|
? { height: `${cardHeight}px` }
|
|
: {}),
|
|
};
|
|
|
|
return (
|
|
<div style={cardStyle} onClick={onClick}>
|
|
{style.showImage && imageUrl && !imgError && (
|
|
<div
|
|
style={{
|
|
width: isHorizontal ? imagePx : "100%",
|
|
height: imagePx,
|
|
background: "hsl(var(--muted))",
|
|
flexShrink: 0,
|
|
position: "relative",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
<img
|
|
src={imageUrl}
|
|
alt=""
|
|
onError={() => setImgError(true)}
|
|
style={{
|
|
width: "100%",
|
|
height: "100%",
|
|
objectFit: "cover",
|
|
display: "block",
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
{style.showImage && imageRaw && imgError && (
|
|
<div
|
|
style={{
|
|
width: isHorizontal ? imagePx : "100%",
|
|
height: imagePx,
|
|
background: "hsl(var(--muted))",
|
|
flexShrink: 0,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
color: "hsl(var(--muted-foreground))",
|
|
fontSize: 11,
|
|
}}
|
|
>
|
|
이미지 없음
|
|
</div>
|
|
)}
|
|
<div
|
|
style={{
|
|
padding: 12,
|
|
flex: 1,
|
|
minWidth: 0,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 4,
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
{(idValue !== undefined && idValue !== null && idValue !== "") && (
|
|
// Phase D.7 — idColumn 배지 (헤더 영역 우측)
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "flex-end",
|
|
marginBottom: 2,
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
fontSize: 10,
|
|
color: "hsl(var(--muted-foreground))",
|
|
background: "hsl(var(--muted))",
|
|
borderRadius: 3,
|
|
padding: "1px 6px",
|
|
}}
|
|
title={mapping.idColumn}
|
|
>
|
|
{String(idValue)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{style.showTitle && title !== undefined && title !== null && title !== "" && (
|
|
<div style={{ fontSize: 13, fontWeight: 700 }}>{String(title)}</div>
|
|
)}
|
|
{style.showSubtitle && subtitle !== undefined && subtitle !== null && subtitle !== "" && (
|
|
<div style={{ fontSize: 11, color: "hsl(var(--muted-foreground))" }}>
|
|
{String(subtitle)}
|
|
</div>
|
|
)}
|
|
{style.showDescription && description !== undefined && description !== null && description !== "" && (
|
|
<div style={{ fontSize: 12, lineHeight: 1.5 }}>{String(description)}</div>
|
|
)}
|
|
{(mapping.displayColumns?.length ?? 0) > 0 && (
|
|
<div
|
|
style={{
|
|
marginTop: 6,
|
|
fontSize: 11,
|
|
color: "hsl(var(--muted-foreground))",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 2,
|
|
}}
|
|
>
|
|
{mapping.displayColumns!.map((colKey) => {
|
|
const col = columnByKey.get(colKey);
|
|
const label =
|
|
col && getColumnLabel ? getColumnLabel(col) : col?.label ?? colKey;
|
|
const cellValue = row?.[colKey];
|
|
// Phase D.7 — D.5 cell formatting 사용. col 메타 없으면 raw string.
|
|
const rendered = col
|
|
? renderTableCellValue({
|
|
value: cellValue,
|
|
column: col,
|
|
row,
|
|
isDesignMode,
|
|
})
|
|
: cellValue !== undefined && cellValue !== null
|
|
? String(cellValue)
|
|
: "-";
|
|
return (
|
|
<div key={colKey} style={{ display: "flex", gap: 6, minWidth: 0 }}>
|
|
<span style={{ fontWeight: 500, flexShrink: 0 }}>{label}:</span>
|
|
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
{rendered}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
{style.showActions && (onView || onEdit || onDelete) && (
|
|
<div style={{ display: "flex", gap: 6, marginTop: 8 }}>
|
|
{onView && (
|
|
<ActionButton onClick={onView} icon={<Eye size={12} />} label="보기" />
|
|
)}
|
|
{onEdit && (
|
|
<ActionButton onClick={onEdit} icon={<Pencil size={12} />} label="편집" />
|
|
)}
|
|
{onDelete && (
|
|
<ActionButton onClick={onDelete} icon={<Trash2 size={12} />} label="삭제" />
|
|
)}
|
|
</div>
|
|
)}
|
|
{isDesignMode &&
|
|
!mapping.titleColumn &&
|
|
!mapping.subtitleColumn &&
|
|
!mapping.descriptionColumn &&
|
|
!mapping.imageColumn && (
|
|
<div style={{ fontSize: 10.5, color: "hsl(var(--muted-foreground))" }}>
|
|
[디자인 모드] 컬럼 매핑이 비어있어 빈 카드만 표시됩니다.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ActionButton({
|
|
onClick,
|
|
icon,
|
|
label,
|
|
}: {
|
|
onClick: () => void;
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onClick();
|
|
}}
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 4,
|
|
padding: "3px 8px",
|
|
fontSize: 10.5,
|
|
background: "hsl(var(--muted))",
|
|
border: "1px solid hsl(var(--border))",
|
|
borderRadius: 4,
|
|
cursor: "pointer",
|
|
color: "hsl(var(--foreground))",
|
|
}}
|
|
>
|
|
{icon}
|
|
<span>{label}</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
const emptyStyle: React.CSSProperties = {
|
|
padding: 24,
|
|
textAlign: "center",
|
|
fontSize: 12,
|
|
color: "hsl(var(--muted-foreground))",
|
|
};
|