- Introduced a new controller for managing custom input values in report cells, allowing users to retrieve and upsert values associated with specific reports and targets. - Implemented API routes for fetching and saving report cell values, ensuring proper authentication and data handling. - Enhanced the frontend components to support the new report cell input functionality, including the ability to edit and save input values in a modal. - Updated inventory and equipment management pages to include new features for handling missing items and managing warehouse locations effectively.
339 lines
12 KiB
TypeScript
339 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import type { TableRendererProps } from "./types";
|
|
import type { ComponentConfig, GridCell } from "@/types/report";
|
|
|
|
type TableColumn = NonNullable<ComponentConfig["tableColumns"]>[number];
|
|
|
|
// ─── 헬퍼 함수 ────────────────────────────────────────────────────────────────
|
|
|
|
function applyNumberFormat(value: string, format?: string, suffix?: string): string {
|
|
if (!format || format === "none") return value;
|
|
const num = parseFloat(value.replace(/,/g, ""));
|
|
if (isNaN(num)) return value;
|
|
const formatted = num.toLocaleString("ko-KR");
|
|
return format === "currency" ? `${formatted}${suffix ?? "원"}` : formatted;
|
|
}
|
|
|
|
function getCellValue(col: TableColumn, row: Record<string, unknown>): string {
|
|
const raw = String(row[col.field] ?? "");
|
|
return applyNumberFormat(raw, col.numberFormat, col.currencySuffix);
|
|
}
|
|
|
|
function calcSummary(col: TableColumn, rows: Record<string, unknown>[]): string {
|
|
if (!col.summaryType || col.summaryType === "NONE") return "";
|
|
|
|
if (col.summaryType === "COUNT") {
|
|
return applyNumberFormat(String(rows.length), col.numberFormat, col.currencySuffix);
|
|
}
|
|
|
|
const values = rows
|
|
.map((row) => {
|
|
const raw = String(row[col.field] ?? "");
|
|
return parseFloat(raw.replace(/,/g, ""));
|
|
})
|
|
.filter((v) => !isNaN(v));
|
|
|
|
if (values.length === 0) return "";
|
|
|
|
const sum = values.reduce((a, b) => a + b, 0);
|
|
const result = col.summaryType === "AVG" ? sum / values.length : sum;
|
|
|
|
return applyNumberFormat(
|
|
parseFloat(result.toFixed(4)).toString(),
|
|
col.numberFormat,
|
|
col.currencySuffix,
|
|
);
|
|
}
|
|
|
|
// ─── 그리드 셀 값 계산 ──────────────────────────────────────────────────────
|
|
|
|
function getGridCellValue(
|
|
cell: GridCell,
|
|
row?: Record<string, unknown>,
|
|
override?: string,
|
|
): string {
|
|
if (cell.cellType === "input") {
|
|
return override ?? "";
|
|
}
|
|
|
|
if (cell.cellType === "static") return cell.value ?? "";
|
|
|
|
if (cell.cellType === "field") {
|
|
if (cell.field && row) {
|
|
const raw = String(row[cell.field] ?? "");
|
|
return applyNumberFormat(raw, cell.numberFormat, cell.currencySuffix);
|
|
}
|
|
return ""; // 데이터 없으면 플레이스홀더 숨김
|
|
}
|
|
|
|
return cell.value ?? "";
|
|
}
|
|
|
|
// ─── 그리드 테이블 렌더러 ────────────────────────────────────────────────────
|
|
|
|
function GridTableRenderer({ component, getQueryResult, cellOverrides, onInputCellClick }: TableRendererProps) {
|
|
const cells = component.gridCells ?? [];
|
|
const rowCount = component.gridRowCount ?? 0;
|
|
const colCount = component.gridColCount ?? 0;
|
|
const colWidths = component.gridColWidths ?? [];
|
|
const rowHeights = component.gridRowHeights ?? [];
|
|
const headerRows = component.gridHeaderRows ?? 1;
|
|
const headerCols = component.gridHeaderCols ?? 1;
|
|
|
|
if (cells.length === 0 || rowCount === 0 || colCount === 0) {
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center border-2 border-dashed border-gray-300 bg-gray-50 text-xs text-gray-400">
|
|
격자 양식을 구성하세요
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const resultKey = component.visualQuery?.tableName
|
|
? `visual_${component.id}`
|
|
: component.queryId;
|
|
const queryResult = resultKey ? getQueryResult(resultKey) : null;
|
|
const dataRow = queryResult?.rows?.[0] as Record<string, unknown> | undefined;
|
|
|
|
const totalConfigWidth = colWidths.reduce((a, b) => a + b, 0) || 1;
|
|
const totalConfigHeight = rowHeights.reduce((a, b) => a + b, 0) || 1;
|
|
|
|
const hdrBg = component.headerBackgroundColor || "#f3f4f6";
|
|
const hdrColor = component.headerTextColor || "#111827";
|
|
|
|
const findCell = (row: number, col: number) =>
|
|
cells.find((c) => c.row === row && c.col === col);
|
|
|
|
const tableRows: React.ReactNode[] = [];
|
|
|
|
for (let r = 0; r < rowCount; r++) {
|
|
const tds: React.ReactNode[] = [];
|
|
const rowHPct = ((rowHeights[r] ?? 32) / totalConfigHeight) * 100;
|
|
|
|
for (let c = 0; c < colCount; c++) {
|
|
const cell = findCell(r, c);
|
|
if (!cell || cell.merged) continue;
|
|
|
|
const rSpan = cell.rowSpan ?? 1;
|
|
const cSpan = cell.colSpan ?? 1;
|
|
const borderW =
|
|
cell.borderStyle === "medium" ? 2 : cell.borderStyle === "thick" ? 3 : cell.borderStyle === "none" ? 0 : 1;
|
|
|
|
const isHeader = r < headerRows || c < headerCols;
|
|
const cellBg = cell.backgroundColor || (isHeader ? hdrBg : "white");
|
|
const cellColor = cell.textColor || (isHeader ? hdrColor : "#111827");
|
|
|
|
const overrideValue = cellOverrides?.[component.id]?.[cell.id];
|
|
const displayValue = getGridCellValue(cell, dataRow, overrideValue);
|
|
const isInputCell = cell.cellType === "input";
|
|
const showPlaceholder = isInputCell && !overrideValue;
|
|
|
|
tds.push(
|
|
<td
|
|
key={cell.id}
|
|
rowSpan={rSpan > 1 ? rSpan : undefined}
|
|
colSpan={cSpan > 1 ? cSpan : undefined}
|
|
onClick={
|
|
isInputCell && onInputCellClick
|
|
? (e) => { e.stopPropagation(); onInputCellClick(component, cell); }
|
|
: undefined
|
|
}
|
|
style={{
|
|
backgroundColor: isInputCell && onInputCellClick && !overrideValue ? "#fffbe6" : cellBg,
|
|
border: `${borderW}px solid #d1d5db`,
|
|
padding: "2px 4px",
|
|
fontSize: cell.fontSize ?? 12,
|
|
fontWeight: cell.fontWeight === "bold" ? 700 : (isHeader ? 600 : 400),
|
|
color: showPlaceholder ? "#9ca3af" : cellColor,
|
|
textAlign: cell.align || "center",
|
|
verticalAlign: cell.verticalAlign || "middle",
|
|
overflow: "hidden",
|
|
whiteSpace: "pre-line",
|
|
wordBreak: "break-word",
|
|
fontStyle: showPlaceholder ? "italic" : undefined,
|
|
cursor: isInputCell && onInputCellClick ? "pointer" : undefined,
|
|
}}
|
|
>
|
|
{showPlaceholder ? (cell.inputPlaceholder || "입력") : displayValue}
|
|
</td>,
|
|
);
|
|
}
|
|
|
|
tableRows.push(
|
|
<tr key={`grid-row-${r}`} style={{ height: `${rowHPct.toFixed(2)}%` }}>
|
|
{tds}
|
|
</tr>,
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="h-full w-full overflow-hidden">
|
|
<table
|
|
className="border-collapse"
|
|
style={{ width: "100%", height: "100%", tableLayout: "fixed" }}
|
|
>
|
|
<colgroup>
|
|
{colWidths.map((w, i) => {
|
|
const pct = (w / totalConfigWidth) * 100;
|
|
return <col key={i} style={{ width: `${pct.toFixed(2)}%` }} />;
|
|
})}
|
|
</colgroup>
|
|
<tbody>{tableRows}</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 기존 테이블 렌더러 ─────────────────────────────────────────────────────
|
|
|
|
function ClassicTableRenderer({ component, getQueryResult }: TableRendererProps) {
|
|
const resultKey = component.visualQuery?.tableName
|
|
? `visual_${component.id}`
|
|
: component.queryId;
|
|
|
|
const queryResult = resultKey ? getQueryResult(resultKey) : null;
|
|
const hasData = queryResult && queryResult.rows.length > 0;
|
|
const hasColumns = component.tableColumns && component.tableColumns.length > 0;
|
|
|
|
if (!hasData && !hasColumns) {
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center border-2 border-dashed border-gray-300 bg-gray-50 text-xs text-gray-400">
|
|
테이블을 구성하세요
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const allColumns = hasColumns
|
|
? component.tableColumns!
|
|
: queryResult!.fields.map((field) => ({
|
|
field,
|
|
header: field,
|
|
width: undefined as number | undefined,
|
|
align: "left" as const,
|
|
visible: true,
|
|
summaryType: undefined as "SUM" | "AVG" | "COUNT" | "NONE" | undefined,
|
|
}));
|
|
|
|
const visibleColumns = allColumns.filter((col) => col.visible !== false);
|
|
const dataRows = hasData ? queryResult!.rows : [];
|
|
const previewRowCount = component.tableRowCount ?? 3;
|
|
|
|
const hasSummaryRow =
|
|
component.showFooter &&
|
|
visibleColumns.some((col) => col.summaryType && col.summaryType !== "NONE");
|
|
|
|
const borderClass = component.showBorder !== false ? "border border-gray-300" : "";
|
|
const rowH = component.rowHeight ?? 28;
|
|
|
|
// 열 너비: 설정된 비율을 유지하면서 컴포넌트 전체 너비에 맞게 스케일
|
|
const totalConfigWidth = visibleColumns.reduce((s, c) => s + (c.width || 120), 0);
|
|
|
|
return (
|
|
<div className="h-full w-full overflow-hidden">
|
|
<table className="w-full border-collapse text-xs" style={{ tableLayout: "fixed" }}>
|
|
<colgroup>
|
|
{visibleColumns.map((col, idx) => {
|
|
const ratio = (col.width || 120) / totalConfigWidth;
|
|
return <col key={idx} style={{ width: `${(ratio * 100).toFixed(2)}%` }} />;
|
|
})}
|
|
</colgroup>
|
|
<thead>
|
|
<tr
|
|
style={{
|
|
backgroundColor: component.headerBackgroundColor || "#f3f4f6",
|
|
color: component.headerTextColor || "#111827",
|
|
}}
|
|
>
|
|
{visibleColumns.map((col, idx) => (
|
|
<th
|
|
key={`h_${col.field || col.header}_${idx}`}
|
|
className={borderClass}
|
|
style={{
|
|
padding: "4px 6px",
|
|
textAlign: col.align || "left",
|
|
fontWeight: "600",
|
|
wordBreak: "break-word",
|
|
whiteSpace: "pre-line",
|
|
}}
|
|
>
|
|
{col.header}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{dataRows.length > 0
|
|
? dataRows.map((row, rowIdx) => (
|
|
<tr key={rowIdx}>
|
|
{visibleColumns.map((col, colIdx) => (
|
|
<td
|
|
key={`r${rowIdx}_c${col.field || col.header}_${colIdx}`}
|
|
className={borderClass}
|
|
style={{
|
|
padding: "4px 6px",
|
|
textAlign: col.align || "left",
|
|
minHeight: `${rowH}px`,
|
|
wordBreak: "break-word",
|
|
whiteSpace: "pre-line",
|
|
}}
|
|
>
|
|
{getCellValue(col, row)}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))
|
|
: Array.from({ length: previewRowCount }).map((_, rowIdx) => (
|
|
<tr key={`empty-${rowIdx}`}>
|
|
{visibleColumns.map((col, colIdx) => (
|
|
<td
|
|
key={`e${rowIdx}_${colIdx}`}
|
|
className={borderClass}
|
|
style={{
|
|
padding: "4px 6px",
|
|
textAlign: col.align || "left",
|
|
height: `${rowH}px`,
|
|
color: "#d1d5db",
|
|
}}
|
|
>
|
|
{""}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
|
|
{hasSummaryRow && dataRows.length > 0 && (
|
|
<tfoot>
|
|
<tr
|
|
style={{
|
|
backgroundColor: "#f3f4f6",
|
|
fontWeight: 600,
|
|
borderTop: "2px solid #d1d5db",
|
|
}}
|
|
>
|
|
{visibleColumns.map((col, idx) => (
|
|
<td
|
|
key={`f_${col.field || col.header}_${idx}`}
|
|
className={borderClass}
|
|
style={{ padding: "4px 6px", textAlign: col.align || "right" }}
|
|
>
|
|
{calcSummary(col, dataRows)}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
</tfoot>
|
|
)}
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 메인 export ─────────────────────────────────────────────────────────────
|
|
|
|
export function TableRenderer(props: TableRendererProps) {
|
|
if (props.component.gridMode) {
|
|
return <GridTableRenderer {...props} />;
|
|
}
|
|
return <ClassicTableRenderer {...props} />;
|
|
}
|