617 lines
19 KiB
TypeScript
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} />
|
|
);
|
|
}
|