ERP-node/frontend/components/report/designer/renderers/CardRenderer.tsx

617 lines
19 KiB
TypeScript

"use client";
import type { CardRendererProps, QueryResult } from "./types";
import type {
CardLayoutConfig,
CardLayoutRow,
CardElement,
CardHeaderElement,
CardDataCellElement,
CardDividerElement,
CardBadgeElement,
CardImageElement,
CardNumberElement,
CardDateElement,
CardLinkElement,
CardStatusElement,
CardSpacerElement,
CardStaticTextElement,
} from "@/types/report";
import * as LucideIcons from "lucide-react";
// ─────────────────────────────────────────────────────────────────────────────
// 기존 cardItems 방식 렌더러 (하위 호환)
// ─────────────────────────────────────────────────────────────────────────────
function CardListRenderer({ component, getQueryResult }: CardRendererProps) {
const cardTitle = component.cardTitle || "정보 카드";
const cardItems = component.cardItems || [];
const labelWidth = component.labelWidth || 80;
const showCardTitle = component.showCardTitle !== false;
const titleFontSize = component.titleFontSize || 14;
const labelFontSize = component.labelFontSize || 13;
const valueFontSize = component.valueFontSize || 13;
const titleColor = component.titleColor || "#1e40af";
const labelColor = component.labelColor || "#374151";
const valueColor = component.valueColor || "#000000";
const getCardItemValue = (item: {
label: string;
value: string;
fieldName?: string;
}) => {
if (item.fieldName && component.queryId) {
const queryResult = getQueryResult(component.queryId);
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
const row = queryResult.rows[0];
return row[item.fieldName] !== undefined
? String(row[item.fieldName])
: item.value;
}
}
return item.value;
};
return (
<div className="flex h-full w-full flex-col overflow-hidden">
{showCardTitle && (
<>
<div
className="flex-shrink-0 px-2 py-1 font-semibold"
style={{ fontSize: `${titleFontSize}px`, color: titleColor }}
>
{cardTitle}
</div>
<div
className="mx-1 flex-shrink-0 border-b"
style={{ borderColor: component.borderColor || "#e5e7eb" }}
/>
</>
)}
<div className="flex-1 overflow-auto px-2 py-1">
{cardItems.map(
(
item: { label: string; value: string; fieldName?: string },
index: number,
) => (
<div key={index} className="flex py-0.5">
<span
className="flex-shrink-0 font-medium"
style={{
width: `${labelWidth}px`,
fontSize: `${labelFontSize}px`,
color: labelColor,
}}
>
{item.label}
</span>
<span
className="flex-1"
style={{ fontSize: `${valueFontSize}px`, color: valueColor }}
>
{getCardItemValue(item)}
</span>
</div>
),
)}
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// v3 요소별 렌더러
// ─────────────────────────────────────────────────────────────────────────────
interface ElementRendererProps {
element: CardElement;
getQueryResult: (queryId: string) => QueryResult | null;
queryId?: string;
config: CardLayoutConfig;
}
function CardHeaderRenderer({
element,
config,
}: {
element: CardHeaderElement;
config: CardLayoutConfig;
}) {
const titleFontSize =
element.titleFontSize || config.headerTitleFontSize || 14;
const titleColor = element.titleColor || config.headerTitleColor || "#1e40af";
const iconColor = element.iconColor || titleColor;
const IconComponent = element.icon
? (LucideIcons as Record<string, React.ComponentType<{ className?: string; style?: React.CSSProperties }>>)[element.icon]
: null;
return (
<div
className="flex items-center gap-2 py-1"
style={{ gridColumn: `span ${element.colspan || 1}` }}
>
{IconComponent && (
<IconComponent
className="w-4 h-4 flex-shrink-0"
style={{ color: iconColor }}
/>
)}
<span
className="font-semibold"
style={{ fontSize: `${titleFontSize}px`, color: titleColor }}
>
{element.title}
</span>
</div>
);
}
function CardDataCellRenderer({
element,
getQueryResult,
queryId,
config,
}: {
element: CardDataCellElement;
getQueryResult: (queryId: string) => QueryResult | null;
queryId?: string;
config: CardLayoutConfig;
}) {
const labelFontSize =
element.labelFontSize || config.labelFontSize || 13;
const labelColor = element.labelColor || config.labelColor || "#374151";
const valueFontSize =
element.valueFontSize || config.valueFontSize || 13;
const valueColor = element.valueColor || config.valueColor || "#000000";
const getValue = (): string => {
if (element.columnName && queryId) {
const queryResult = getQueryResult(queryId);
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
const row = queryResult.rows[0];
const value = row[element.columnName];
return value !== undefined && value !== null ? String(value) : "";
}
}
return "";
};
const value = getValue() || "-";
if (element.direction === "vertical") {
return (
<div
className="flex flex-col py-1"
style={{ gridColumn: `span ${element.colspan || 1}` }}
>
<span
className="font-medium mb-0.5"
style={{ fontSize: `${labelFontSize}px`, color: labelColor }}
>
{element.label}
</span>
<span style={{ fontSize: `${valueFontSize}px`, color: valueColor }}>
{value}
</span>
</div>
);
}
return (
<div
className="flex items-center py-1"
style={{ gridColumn: `span ${element.colspan || 1}` }}
>
<span
className="font-medium flex-shrink-0"
style={{
width: element.labelWidth ? `${element.labelWidth}px` : "auto",
minWidth: "60px",
fontSize: `${labelFontSize}px`,
color: labelColor,
}}
>
{element.label}
</span>
<span
className="flex-1"
style={{ fontSize: `${valueFontSize}px`, color: valueColor }}
>
{value}
</span>
</div>
);
}
function CardDividerRenderer({
element,
config,
}: {
element: CardDividerElement;
config: CardLayoutConfig;
}) {
const thickness = element.thickness || config.dividerThickness || 1;
const color = element.color || config.dividerColor || "#e5e7eb";
const style = element.style || "solid";
return (
<div
className="py-1"
style={{ gridColumn: `span ${element.colspan || 1}` }}
>
<div
style={{
borderTopWidth: `${thickness}px`,
borderTopStyle: style,
borderTopColor: color,
}}
/>
</div>
);
}
function CardBadgeRenderer({
element,
getQueryResult,
queryId,
}: {
element: CardBadgeElement;
getQueryResult: (queryId: string) => QueryResult | null;
queryId?: string;
config: CardLayoutConfig;
}) {
const getValue = (): string => {
if (element.columnName && queryId) {
const queryResult = getQueryResult(queryId);
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
const row = queryResult.rows[0];
const value = row[element.columnName];
return value !== undefined && value !== null ? String(value) : "";
}
}
return "";
};
const value = getValue();
const bgColor = element.colorMap?.[value] || "#e5e7eb";
return (
<div
className="flex items-center gap-2 py-1"
style={{ gridColumn: `span ${element.colspan || 1}` }}
>
{element.label && (
<span className="text-sm text-gray-600">{element.label}</span>
)}
<span
className="px-2 py-0.5 rounded text-xs font-medium"
style={{ backgroundColor: bgColor }}
>
{value || "-"}
</span>
</div>
);
}
function getFieldValue(
columnName: string | undefined,
queryId: string | undefined,
getQueryResult: (id: string) => QueryResult | null,
): string {
if (!columnName || !queryId) return "";
const result = getQueryResult(queryId);
if (result?.rows?.length) {
const val = result.rows[0][columnName];
return val !== undefined && val !== null ? String(val) : "";
}
return "";
}
function formatNumber(raw: string, format?: string, suffix?: string): string {
const num = parseFloat(raw);
if (isNaN(num)) return raw || "-";
if (format === "comma" || format === "currency") {
const formatted = num.toLocaleString("ko-KR");
return format === "currency" ? `${formatted}${suffix || "원"}` : formatted;
}
return raw;
}
function CardImageRenderer({
element,
getQueryResult,
queryId,
}: {
element: CardImageElement;
getQueryResult: (id: string) => QueryResult | null;
queryId?: string;
}) {
const url = getFieldValue(element.columnName, queryId, getQueryResult);
return (
<div style={{ gridColumn: `span ${element.colspan || 1}`, height: element.height || 80 }}>
{url ? (
<img
src={url}
alt={element.altText || ""}
style={{ width: "100%", height: "100%", objectFit: element.objectFit || "contain" }}
/>
) : (
<div className="flex items-center justify-center h-full bg-gray-100 text-xs text-gray-400 rounded">
</div>
)}
</div>
);
}
function CardNumberRenderer({
element,
getQueryResult,
queryId,
config,
}: {
element: CardNumberElement;
getQueryResult: (id: string) => QueryResult | null;
queryId?: string;
config: CardLayoutConfig;
}) {
const raw = getFieldValue(element.columnName, queryId, getQueryResult);
const value = formatNumber(raw, element.numberFormat, element.currencySuffix);
const labelFontSize = element.labelFontSize || config.labelFontSize || 13;
const labelColor = element.labelColor || config.labelColor || "#374151";
const valueFontSize = element.valueFontSize || config.valueFontSize || 13;
const valueColor = element.valueColor || config.valueColor || "#000000";
return (
<div className="flex flex-col py-1" style={{ gridColumn: `span ${element.colspan || 1}` }}>
<span className="font-medium mb-0.5" style={{ fontSize: `${labelFontSize}px`, color: labelColor }}>
{element.label}
</span>
<span style={{ fontSize: `${valueFontSize}px`, color: valueColor }}>{value}</span>
</div>
);
}
function CardDateRenderer({
element,
getQueryResult,
queryId,
config,
}: {
element: CardDateElement;
getQueryResult: (id: string) => QueryResult | null;
queryId?: string;
config: CardLayoutConfig;
}) {
const raw = getFieldValue(element.columnName, queryId, getQueryResult);
const labelFontSize = element.labelFontSize || config.labelFontSize || 13;
const labelColor = element.labelColor || config.labelColor || "#374151";
const valueFontSize = element.valueFontSize || config.valueFontSize || 13;
const valueColor = element.valueColor || config.valueColor || "#000000";
let displayValue = raw || "-";
if (raw && element.dateFormat) {
try {
const d = new Date(raw);
if (!isNaN(d.getTime())) {
displayValue = element.dateFormat
.replace("YYYY", String(d.getFullYear()))
.replace("MM", String(d.getMonth() + 1).padStart(2, "0"))
.replace("DD", String(d.getDate()).padStart(2, "0"));
}
} catch {
displayValue = raw;
}
}
return (
<div className="flex flex-col py-1" style={{ gridColumn: `span ${element.colspan || 1}` }}>
<span className="font-medium mb-0.5" style={{ fontSize: `${labelFontSize}px`, color: labelColor }}>
{element.label}
</span>
<span style={{ fontSize: `${valueFontSize}px`, color: valueColor }}>{displayValue}</span>
</div>
);
}
function CardLinkRenderer({
element,
getQueryResult,
queryId,
}: {
element: CardLinkElement;
getQueryResult: (id: string) => QueryResult | null;
queryId?: string;
}) {
const url = getFieldValue(element.columnName, queryId, getQueryResult);
const text = element.linkText || url || element.label;
return (
<div className="flex items-center py-1" style={{ gridColumn: `span ${element.colspan || 1}` }}>
<span className="text-sm text-gray-600 mr-2">{element.label}</span>
{url ? (
<a
href={url}
target={element.openInNewTab ? "_blank" : undefined}
rel={element.openInNewTab ? "noopener noreferrer" : undefined}
className="text-sm text-blue-600 hover:underline truncate"
>
{text}
</a>
) : (
<span className="text-sm text-gray-400">-</span>
)}
</div>
);
}
function CardStatusRenderer({
element,
getQueryResult,
queryId,
}: {
element: CardStatusElement;
getQueryResult: (id: string) => QueryResult | null;
queryId?: string;
}) {
const value = getFieldValue(element.columnName, queryId, getQueryResult);
const mapping = element.statusMappings?.find((m) => m.value === value);
const dotColor = mapping?.color || "#9ca3af";
const label = mapping?.label || value || "-";
return (
<div className="flex items-center gap-2 py-1" style={{ gridColumn: `span ${element.colspan || 1}` }}>
<span
className="inline-block w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: dotColor }}
/>
<span className="text-sm">{label}</span>
</div>
);
}
function CardStaticTextRenderer({ element }: { element: CardStaticTextElement }) {
return (
<div style={{ gridColumn: `span ${element.colspan || 1}` }}>
<span
style={{
fontSize: `${element.fontSize || 13}px`,
color: element.color || "#000000",
fontWeight: element.fontWeight || "normal",
textAlign: element.textAlign || "left",
display: "block",
}}
>
{element.text}
</span>
</div>
);
}
function CardElementRenderer({
element,
getQueryResult,
queryId,
config,
}: ElementRendererProps) {
switch (element.type) {
case "header":
return <CardHeaderRenderer element={element} config={config} />;
case "dataCell":
return (
<CardDataCellRenderer
element={element}
getQueryResult={getQueryResult}
queryId={queryId}
config={config}
/>
);
case "divider":
return <CardDividerRenderer element={element} config={config} />;
case "badge":
return (
<CardBadgeRenderer
element={element}
getQueryResult={getQueryResult}
queryId={queryId}
config={config}
/>
);
case "image":
return <CardImageRenderer element={element as CardImageElement} getQueryResult={getQueryResult} queryId={queryId} />;
case "number":
return <CardNumberRenderer element={element as CardNumberElement} getQueryResult={getQueryResult} queryId={queryId} config={config} />;
case "date":
return <CardDateRenderer element={element as CardDateElement} getQueryResult={getQueryResult} queryId={queryId} config={config} />;
case "link":
return <CardLinkRenderer element={element as CardLinkElement} getQueryResult={getQueryResult} queryId={queryId} />;
case "status":
return <CardStatusRenderer element={element as CardStatusElement} getQueryResult={getQueryResult} queryId={queryId} />;
case "spacer":
return <div key={element.id} style={{ height: (element as CardSpacerElement).height || 16, gridColumn: `span ${element.colspan || 1}` }} />;
case "staticText":
return <CardStaticTextRenderer element={element as CardStaticTextElement} />;
default:
return null;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// v3 그리드 렌더러
// ─────────────────────────────────────────────────────────────────────────────
function CardGridRenderer({
config,
getQueryResult,
queryId,
}: {
config: CardLayoutConfig;
getQueryResult: (queryId: string) => QueryResult | null;
queryId?: string;
}) {
return (
<div
className="flex h-full w-full flex-col overflow-hidden"
style={{
padding: config.padding || "8px",
backgroundColor: config.backgroundColor || "#ffffff",
borderRadius: config.borderRadius || "0",
border: config.borderStyle !== "none"
? `${config.borderWidth || 1}px ${config.borderStyle || "solid"} ${config.borderColor || "#e5e7eb"}`
: "none",
...(config.accentBorderWidth && config.accentBorderWidth > 0
? { borderLeft: `${config.accentBorderWidth}px solid ${config.accentBorderColor || "#3b82f6"}` }
: {}),
}}
>
<div
className="flex-1 overflow-auto"
style={{ display: "flex", flexDirection: "column", gap: config.gap || "0px" }}
>
{config.rows.map((row: CardLayoutRow) => (
<div
key={row.id}
className="grid"
style={{
gridTemplateColumns: `repeat(${row.gridColumns}, 1fr)`,
height: row.height || "auto",
gap: config.gap || "0px",
marginBottom: row.marginBottom || undefined,
}}
>
{row.elements.map((element: CardElement) => (
<CardElementRenderer
key={element.id}
element={element}
getQueryResult={getQueryResult}
queryId={queryId}
config={config}
/>
))}
</div>
))}
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 메인 컴포넌트
// ─────────────────────────────────────────────────────────────────────────────
export function CardRenderer({ component, getQueryResult }: CardRendererProps) {
const effectiveQueryId = component.queryId || `card_${component.id}`;
if (component.cardLayoutConfig) {
return (
<CardGridRenderer
config={component.cardLayoutConfig}
getQueryResult={getQueryResult}
queryId={effectiveQueryId}
/>
);
}
return (
<CardListRenderer component={component} getQueryResult={getQueryResult} />
);
}