531 lines
15 KiB
TypeScript
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);
|
|
}
|