Files
invyone/frontend/lib/registry/components/table/cell-renderers.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

531 lines
15 KiB
TypeScript

"use client";
/**
* Phase D.5 (2026-05-20) — canonical TableComponent 의 셀 렌더링 helper.
*
* 기존 plain `String(row[col.key])` 자리에 사용.
*
* 책임:
* - image 셀 (TableCellImage)
* - file 셀 (TableCellFile)
* - entity 다중 컬럼 표시 (entityDisplayConfig)
* - number / date / boolean / currency 포맷팅
*
* 옛 본체에서 사용하던 `TableCellImage` / `TableCellFile` / `formatCellValue` 패턴을
* 흡수한 canonical 한 단순화 버전.
*/
import React, { useEffect, useState } from "react";
import { FileText } from "lucide-react";
import { getFullImageUrl } from "@/lib/api/client";
import { getFilePreviewUrl } from "@/lib/api/file";
import {
formatDate as centralFormatDate,
formatNumber as centralFormatNumber,
formatCurrency as centralFormatCurrency,
} from "@/lib/formatting";
import type { TableColumn } from "./types";
// ───────────────────────────────────────────────────────
// TableCellImage — 이미지 썸네일 셀
// ───────────────────────────────────────────────────────
export interface TableCellImageProps {
value: string;
isDesignMode?: boolean;
}
export const TableCellImage: React.FC<TableCellImageProps> = React.memo(
({ value, isDesignMode }) => {
const [imgSrc, setImgSrc] = useState<string | null>(null);
const [displayObjid, setDisplayObjid] = useState<string>("");
const [error, setError] = useState(false);
useEffect(() => {
let mounted = true;
setError(false);
const rawValue = String(value || "").trim();
if (!rawValue) {
setImgSrc(null);
setError(true);
return () => {
mounted = false;
};
}
const parts = rawValue.split(",").map((s) => s.trim()).filter(Boolean);
const first = parts[0] || rawValue;
setDisplayObjid(first);
const isObjid = /^\d+$/.test(first);
if (isDesignMode) {
// 디자인 모드: remote lookup 안 함. path 만 직접 src 로.
if (!isObjid) {
setImgSrc(getFullImageUrl(first));
} else {
setImgSrc(null);
}
return () => {
mounted = false;
};
}
if (isObjid) {
setImgSrc(getFilePreviewUrl(first));
} else {
setImgSrc(getFullImageUrl(first));
}
return () => {
mounted = false;
};
}, [value, isDesignMode]);
if (error || !imgSrc) {
return (
<span style={{ color: "hsl(var(--muted-foreground))", fontSize: 11 }}>
-
</span>
);
}
return (
<img
src={imgSrc}
alt=""
style={{
maxWidth: 36,
maxHeight: 36,
borderRadius: 3,
objectFit: "cover",
cursor: isDesignMode ? "default" : "pointer",
verticalAlign: "middle",
}}
onClick={(e) => {
if (isDesignMode) return;
e.stopPropagation();
const isObjid = /^\d+$/.test(displayObjid);
const openUrl = isObjid
? getFilePreviewUrl(displayObjid)
: getFullImageUrl(displayObjid);
if (typeof window !== "undefined") {
window.open(openUrl, "_blank");
}
}}
onError={() => setError(true)}
/>
);
},
);
TableCellImage.displayName = "TableCellImage";
// ───────────────────────────────────────────────────────
// TableCellFile — 파일 이름 + 다운로드 셀
// ───────────────────────────────────────────────────────
interface FileInfo {
objid: string;
name: string;
ext?: string;
}
function _parseFileValue(raw: string): FileInfo[] {
const trimmed = String(raw || "").trim();
if (!trimmed || trimmed === "-") return [];
// JSON 배열 시도
if (trimmed.startsWith("[")) {
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return parsed.map((f: any) => ({
objid: String(f?.objid ?? f?.id ?? ""),
name: String(
f?.realFileName ?? f?.real_file_name ?? f?.name ?? "파일",
),
ext: String(f?.fileExt ?? f?.file_ext ?? "") || undefined,
}));
}
} catch {
/* fall through */
}
}
// 콤마 구분
const parts = trimmed.split(",").map((s) => s.trim()).filter(Boolean);
return parts.map((p) => ({
objid: /^\d+$/.test(p) ? p : "",
name: /^\d+$/.test(p) ? p : p.split("/").pop() || p,
}));
}
export interface TableCellFileProps {
value: string;
isDesignMode?: boolean;
}
export const TableCellFile: React.FC<TableCellFileProps> = React.memo(
({ value, isDesignMode }) => {
const [files, setFiles] = useState<FileInfo[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let mounted = true;
setLoading(true);
const parsed = _parseFileValue(value);
if (parsed.length === 0) {
setFiles([]);
setLoading(false);
return () => {
mounted = false;
};
}
// JSON 으로 이미 이름이 있는 경우는 즉시 표시.
const hasNames = parsed.every((f) => f.name && f.name !== f.objid);
if (hasNames || isDesignMode) {
setFiles(parsed);
setLoading(false);
return () => {
mounted = false;
};
}
// 디자인 모드 X + objid 만 있음 → 비동기로 파일명 lookup (대표 1개만 — 보수적)
const objids = parsed.map((f) => f.objid).filter(Boolean);
if (objids.length === 0) {
setFiles(parsed);
setLoading(false);
return () => {
mounted = false;
};
}
(async () => {
try {
const { getFileInfoByObjid } = await import("@/lib/api/file");
const lookups = await Promise.all(
objids.map(async (oid) => {
try {
const info = await getFileInfoByObjid(oid);
if (info?.success && info.data) {
// backend snake_case + 임의 camelCase fallback 흡수
const d = info.data as any;
return {
objid: oid,
name:
d.real_file_name ||
d.realFileName ||
d.name ||
oid,
ext: d.file_ext || d.fileExt,
};
}
} catch {
/* noop */
}
return { objid: oid, name: oid };
}),
);
if (mounted) {
setFiles(lookups);
setLoading(false);
}
} catch {
if (mounted) {
setFiles(parsed);
setLoading(false);
}
}
})();
return () => {
mounted = false;
};
}, [value, isDesignMode]);
if (loading) {
return (
<span style={{ color: "hsl(var(--muted-foreground))", fontSize: 11 }}>
...
</span>
);
}
if (files.length === 0) {
return (
<span style={{ color: "hsl(var(--muted-foreground))", fontSize: 11 }}>
-
</span>
);
}
// 단일 파일 inline link
if (files.length === 1) {
const f = files[0];
const url = f.objid && /^\d+$/.test(f.objid)
? getFilePreviewUrl(f.objid)
: null;
const content = (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
fontSize: 12,
maxWidth: 200,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={f.name}
>
<FileText size={12} style={{ flexShrink: 0 }} />
<span style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
{f.name}
</span>
</span>
);
if (url && !isDesignMode) {
return (
<a
href={url}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
style={{
color: "hsl(var(--primary))",
textDecoration: "none",
}}
>
{content}
</a>
);
}
return content;
}
// 다중 — count 표시 + tooltip 으로 이름들
const allNames = files.map((f) => f.name).join(", ");
return (
<span
title={allNames}
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
fontSize: 12,
}}
>
<FileText size={12} />
<span>{files[0].name}</span>
<span
style={{
fontSize: 10,
color: "hsl(var(--muted-foreground))",
}}
>
{files.length - 1}
</span>
</span>
);
},
);
TableCellFile.displayName = "TableCellFile";
// ───────────────────────────────────────────────────────
// entityDisplayConfig 적용
// ───────────────────────────────────────────────────────
/**
* entity 다중 컬럼 표시 — `entityDisplayConfig.displayColumns` 의 각 컬럼을
* row 에서 찾아 separator (`" - "` 기본) 로 join.
*
* Key fallback 순서 (각 displayColumn 마다 시도):
* 1. `${column.key}_${displayColumn}`
* 2. `${entityJoinInfo?.sourceColumn}_${displayColumn}`
* 3. `${entityDisplayConfig?.joinTable}_${displayColumn}`
* 4. `${entityJoinInfo?.joinAlias}_${displayColumn}`
* 5. direct `displayColumn`
* 6. `displayColumn` 에 `.` 있으면 마지막 segment 도 시도
*
* 결과 join 이 빈 값이면 null 반환 (호출자가 fallback 으로 일반 포맷).
*/
function _applyEntityDisplayConfig(
column: TableColumn,
row: Record<string, any>,
): string | null {
const cfg = column.entityDisplayConfig;
if (!cfg) return null;
const displayColumns: string[] = Array.isArray(cfg.displayColumns)
? cfg.displayColumns
: Array.isArray((cfg as any).selectedColumns)
? ((cfg as any).selectedColumns as string[])
: [];
if (displayColumns.length === 0) return null;
const separator = cfg.separator || " - ";
const sourceColumn = column.entityJoinInfo?.sourceColumn;
const joinAlias = column.entityJoinInfo?.joinAlias;
const joinTable = cfg.joinTable;
const values: string[] = [];
for (const dc of displayColumns) {
const candidates: string[] = [
`${column.key}_${dc}`,
sourceColumn ? `${sourceColumn}_${dc}` : "",
joinTable ? `${joinTable}_${dc}` : "",
joinAlias ? `${joinAlias}_${dc}` : "",
dc,
].filter(Boolean);
if (dc.includes(".")) {
const last = dc.split(".").pop();
if (last) candidates.push(last);
}
let found: any = undefined;
for (const k of candidates) {
const v = row[k];
if (v !== undefined && v !== null && v !== "") {
found = v;
break;
}
}
if (found !== undefined && found !== null && found !== "") {
values.push(String(found));
}
}
const joined = values.join(separator).trim();
return joined.length > 0 ? joined : null;
}
// ───────────────────────────────────────────────────────
// renderTableCellValue — canonical 셀 디스플레이 진입점
// ───────────────────────────────────────────────────────
export interface RenderTableCellArgs {
value: any;
column: TableColumn;
row: Record<string, any>;
isDesignMode?: boolean;
}
export function renderTableCellValue(args: RenderTableCellArgs): React.ReactNode {
const { value, column, row, isDesignMode } = args;
// 1) entityDisplayConfig 우선 — value 가 비어도 다른 row 키에 표시값 있을 수 있음.
const entityDisplay = _applyEntityDisplayConfig(column, row);
if (entityDisplay) {
return entityDisplay;
}
// 2) image 셀
const isImage =
column.inputType === "image" || column.format === "image";
if (isImage) {
if (value === null || value === undefined || value === "") {
return (
<span style={{ color: "hsl(var(--muted-foreground))", fontSize: 11 }}>
-
</span>
);
}
return <TableCellImage value={String(value)} isDesignMode={isDesignMode} />;
}
// 3) file / attachment 셀
const keyLower = (column.key || "").toLowerCase();
const looksLikeFileKey =
keyLower.includes("attachment") ||
/(^|[_-])files?($|[_-])/.test(keyLower);
const isFile =
column.inputType === "file" ||
column.inputType === "attachment" ||
column.format === "file" ||
column.format === "attachment" ||
looksLikeFileKey;
if (isFile) {
if (value === null || value === undefined || value === "") {
return (
<span style={{ color: "hsl(var(--muted-foreground))", fontSize: 11 }}>
-
</span>
);
}
return <TableCellFile value={String(value)} isDesignMode={isDesignMode} />;
}
// 4) null/empty
if (value === null || value === undefined || value === "") {
return isDesignMode ? (
<span style={{ color: "hsl(var(--muted-foreground))" }}>...</span>
) : (
""
);
}
// 5) boolean
const isBoolean =
column.format === "boolean" || column.inputType === "checkbox";
if (isBoolean) {
const v = value;
const truthy =
v === true ||
v === 1 ||
v === "1" ||
v === "Y" ||
v === "y" ||
v === "true" ||
v === "TRUE";
return truthy ? "예" : "아니오";
}
// 6) date / datetime
const isDate =
column.inputType === "date" ||
column.inputType === "datetime" ||
column.format === "date" ||
column.format === "datetime";
if (isDate) {
try {
const formatted = centralFormatDate(value, "display");
if (formatted && formatted !== String(value)) return formatted;
} catch {
/* fall through to default */
}
// fallback: ISO date 의 앞 10 자리
const s = String(value);
if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10);
return s;
}
// 7) currency
if (column.format === "currency") {
try {
return centralFormatCurrency(value);
} catch {
const n = Number(value);
if (Number.isFinite(n)) return `${n.toLocaleString("ko-KR")}`;
}
return String(value);
}
// 8) number / decimal
const isNumber =
column.inputType === "number" ||
column.inputType === "decimal" ||
column.format === "number";
if (isNumber) {
const n = Number(value);
if (!Number.isFinite(n)) return String(value);
if (column.thousandSeparator === false) {
return String(n);
}
try {
return centralFormatNumber(value);
} catch {
return n.toLocaleString("ko-KR");
}
}
// 9) default — plain string
return String(value);
}