Files
invyone/frontend/lib/registry/components/table/views/CardView.tsx
T
DDD1542 7d204bfffd
Build & Deploy to K8s / build-and-deploy (push) Failing after 14m3s
refactor: complete canonical table cleanup
2026-05-21 11:55:08 +09:00

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