Files
vexplor_dev/frontend/components/report/ReportInlineViewer.tsx
kjs 51c4fddde0 feat: Add report cell value management functionality
- 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.
2026-04-20 17:59:28 +09:00

388 lines
18 KiB
TypeScript

"use client";
import React, { useEffect, useRef, useState } from "react";
import { FileDown, FileText, Loader2, Printer } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ComponentConfig, GridCell, ReportPage, WatermarkConfig } from "@/types/report";
import { useReportRenderer, QueryResult } from "@/hooks/useReportRenderer";
import { getFullImageUrl } from "@/lib/api/client";
import {
TextRenderer,
TableRenderer,
ImageRenderer,
DividerRenderer,
SignatureRenderer,
StampRenderer,
PageNumberRenderer,
CardRenderer,
CalculationRenderer,
BarcodeCanvasRenderer,
CheckboxRenderer,
} from "./designer/renderers";
import { MM_TO_PX } from "@/lib/report/constants";
interface ReportInlineViewerProps {
reportId: string | null;
contextParams?: Record<string, unknown>;
className?: string;
showToolbar?: boolean;
/** 컴포넌트 클릭 콜백 — 편집 모드에서 사용 */
onComponentClick?: (component: ComponentConfig) => void;
/** cellType="input" 셀의 커스텀 값 오버라이드: { [componentId]: { [cellId]: value } } */
cellOverrides?: Record<string, Record<string, string>>;
/** input 셀 클릭 콜백 */
onInputCellClick?: (component: ComponentConfig, cell: GridCell) => void;
}
export function ReportInlineViewer({
reportId,
contextParams,
className = "",
showToolbar = true,
onComponentClick,
cellOverrides,
onInputCellClick,
}: ReportInlineViewerProps) {
const { detail, pages, watermark, getQueryResult, isLoading } = useReportRenderer(reportId, contextParams);
const containerRef = useRef<HTMLDivElement>(null);
const previewRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
const [isExporting, setIsExporting] = useState(false);
const editable = !!onComponentClick;
useEffect(() => {
if (!containerRef.current || pages.length === 0) return;
const calculateScale = () => {
const container = containerRef.current;
if (!container) return;
const pageWidthPx = pages[0].width * MM_TO_PX;
const availableWidth = container.clientWidth - 48;
setScale(Math.min(availableWidth / pageWidthPx, 1));
};
const observer = new ResizeObserver(calculateScale);
observer.observe(containerRef.current);
calculateScale();
return () => observer.disconnect();
}, [pages]);
const handleDownloadPDF = async () => {
if (!previewRef.current || pages.length === 0) return;
setIsExporting(true);
try {
const [{ jsPDF }, html2canvas] = await Promise.all([
import("jspdf"),
import("html2canvas").then((m) => m.default),
]);
const pageEls = previewRef.current.querySelectorAll<HTMLElement>("[data-list-preview-page]");
if (pageEls.length === 0) return;
const firstPage = pages[0];
const doc = new jsPDF({
orientation: firstPage.orientation === "landscape" ? "l" : "p",
unit: "mm",
format: [firstPage.width, firstPage.height],
});
for (let i = 0; i < pageEls.length; i++) {
const canvas = await html2canvas(pageEls[i], { scale: 2, useCORS: true, allowTaint: true, backgroundColor: "#ffffff" });
if (i > 0) {
const p = pages[i] ?? firstPage;
doc.addPage([p.width, p.height], p.orientation === "landscape" ? "l" : "p");
}
const p = pages[i] ?? firstPage;
doc.addImage(canvas.toDataURL("image/jpeg", 0.92), "JPEG", 0, 0, p.width, p.height);
}
doc.save(`${detail?.report?.report_name_kor ?? "report"}.pdf`);
} catch { /* 무시 */ } finally { setIsExporting(false); }
};
const handlePrint = () => {
if (!previewRef.current || pages.length === 0) return;
// 1) body의 기존 자식들 숨기기
const bodyChildren = Array.from(document.body.children) as HTMLElement[];
bodyChildren.forEach((el) => { el.setAttribute("data-print-hidden", "true"); });
// 2) 프리뷰 내용을 body 직속에 복제 (스케일 제거, 원본 크기)
const printDiv = document.createElement("div");
printDiv.id = "report-print-root";
// 각 페이지를 원본 크기로 복제
const pageEls = previewRef.current.querySelectorAll<HTMLElement>("[data-list-preview-page]");
pageEls.forEach((el) => {
const clone = el.cloneNode(true) as HTMLElement;
clone.style.boxShadow = "none";
clone.style.position = "relative";
clone.style.pageBreakAfter = "always";
clone.style.margin = "0 auto";
printDiv.appendChild(clone);
});
document.body.appendChild(printDiv);
// 3) 스타일
const style = document.createElement("style");
style.id = "report-print-style";
style.textContent = `
@media print {
@page { margin: 0; }
* { print-color-adjust: exact !important; -webkit-print-color-adjust: exact !important; }
[data-print-hidden] { display: none !important; }
#report-print-root { display: block !important; }
#report-print-root [data-list-preview-page] { page-break-after: always; }
#report-print-root [data-list-preview-page]:last-child { page-break-after: auto; }
}
#report-print-root { display: none; }
`;
document.head.appendChild(style);
// 4) 인쇄 후 정리
const cleanup = () => {
printDiv.remove();
style.remove();
bodyChildren.forEach((el) => { el.removeAttribute("data-print-hidden"); });
window.removeEventListener("afterprint", cleanup);
};
window.addEventListener("afterprint", cleanup);
setTimeout(() => window.print(), 100);
};
if (!reportId) {
return (
<div className={`flex h-full items-center justify-center ${className}`}>
<div className="text-center text-gray-400">
<FileText className="mx-auto mb-3 h-14 w-14 opacity-30" />
<p className="text-sm"> </p>
</div>
</div>
);
}
return (
<div className={`flex h-full flex-col ${className}`}>
{showToolbar && pages.length > 0 && !isLoading && (
<div className="flex shrink-0 items-center justify-end gap-2 border-b bg-white px-3 py-2">
<Button variant="outline" size="sm" onClick={handlePrint} className="gap-1.5 text-xs">
<Printer className="h-3.5 w-3.5" />
</Button>
<Button variant="outline" size="sm" onClick={handleDownloadPDF} disabled={isExporting} className="gap-1.5 text-xs">
{isExporting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileDown className="h-3.5 w-3.5" />} PDF
</Button>
</div>
)}
<div ref={containerRef} className="min-h-0 flex-1 overflow-auto bg-gray-100">
{isLoading ? (
<div className="flex h-full items-center justify-center"><Loader2 className="h-10 w-10 animate-spin text-gray-400" /></div>
) : pages.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-3 text-gray-400">
<FileText className="h-14 w-14 opacity-30" />
<p className="text-sm">{detail ? "저장된 레이아웃이 없습니다." : "데이터를 불러오는 중..."}</p>
</div>
) : (
<div ref={previewRef} className="flex flex-col items-center p-6" style={{ gap: `${24 * scale}px` }}>
{[...pages].sort((a, b) => a.page_order - b.page_order).map((page, pageIndex) => (
<div key={page.page_id} style={{
width: `${Math.ceil(page.width * MM_TO_PX * scale) + 1}px`,
minHeight: `${Math.ceil(page.height * MM_TO_PX * scale) + 1}px`,
flexShrink: 0,
}}>
<div style={{
transform: `scale(${scale})`, transformOrigin: "top left",
width: `${page.width * MM_TO_PX}px`, minHeight: `${page.height * MM_TO_PX}px`,
}}>
<PagePreview
page={page} pageIndex={pageIndex} totalPages={pages.length}
pages={pages} watermark={watermark} getQueryResult={getQueryResult}
editable={editable} onComponentClick={onComponentClick}
cellOverrides={cellOverrides} onInputCellClick={onInputCellClick}
/>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
/* ── 내부 컴포넌트 ── */
function WatermarkLayer({ watermark, pageWidth, pageHeight }: { watermark: WatermarkConfig; pageWidth: number; pageHeight: number }) {
const baseStyle: React.CSSProperties = { position: "absolute", top: 0, left: 0, width: "100%", height: "100%", pointerEvents: "none", overflow: "hidden", zIndex: 0 };
const rotation = watermark.rotation ?? -45;
const textOrImage = watermark.type === "text" ? (
<span style={{ fontSize: `${watermark.fontSize || 48}px`, color: watermark.fontColor || "#cccccc", fontWeight: "bold", userSelect: "none", whiteSpace: "nowrap" }}>{watermark.text || "WATERMARK"}</span>
) : watermark.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={watermark.imageUrl.startsWith("data:") ? watermark.imageUrl : getFullImageUrl(watermark.imageUrl)} alt="" style={{ maxWidth: "50%", maxHeight: "50%", objectFit: "contain" }} />
) : null;
if (watermark.style === "diagonal") return <div style={baseStyle}><div style={{ position: "absolute", top: "50%", left: "50%", transform: `translate(-50%, -50%) rotate(${rotation}deg)`, opacity: watermark.opacity, display: "flex", alignItems: "center", justifyContent: "center" }}>{textOrImage}</div></div>;
if (watermark.style === "center") return <div style={baseStyle}><div style={{ position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)", opacity: watermark.opacity, display: "flex", alignItems: "center", justifyContent: "center" }}>{textOrImage}</div></div>;
if (watermark.style === "tile") {
const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150;
const cols = Math.ceil((pageWidth * MM_TO_PX) / tileSize) + 2;
const rows = Math.ceil((pageHeight * MM_TO_PX) / tileSize) + 2;
return <div style={baseStyle}><div style={{ position: "absolute", top: "-50%", left: "-50%", width: "200%", height: "200%", display: "flex", flexWrap: "wrap", alignContent: "flex-start", transform: `rotate(${rotation}deg)`, opacity: watermark.opacity }}>
{Array.from({ length: rows * cols }).map((_, i) => <div key={i} style={{ width: `${tileSize}px`, height: `${tileSize}px`, display: "flex", alignItems: "center", justifyContent: "center" }}>{textOrImage}</div>)}
</div></div>;
}
return null;
}
function PagePreview({ page, pageIndex, totalPages, pages, watermark, getQueryResult, editable, onComponentClick, cellOverrides, onInputCellClick }: {
page: ReportPage; pageIndex: number; totalPages: number; pages: ReportPage[];
watermark?: WatermarkConfig; getQueryResult: (queryId: string) => QueryResult | null;
editable?: boolean; onComponentClick?: (comp: ComponentConfig) => void;
cellOverrides?: Record<string, Record<string, string>>;
onInputCellClick?: (component: ComponentConfig, cell: GridCell) => void;
}) {
const comps = page.components ?? [];
const sortedByY = [...comps].sort((a, b) => a.y - b.y);
const growableTypes = new Set(["table", "card", "calculation"]);
// 실제 렌더링 높이를 측정하여 yOffset 계산
const [measuredHeights, setMeasuredHeights] = useState<Record<string, number>>({});
const compRefs = useRef<Record<string, HTMLDivElement | null>>({});
useEffect(() => {
// 렌더 후 growable 컴포넌트의 실제 scrollHeight 측정
const newHeights: Record<string, number> = {};
let changed = false;
for (const c of sortedByY) {
if (growableTypes.has(c.type)) {
const el = compRefs.current[c.id];
if (el) {
const actual = el.scrollHeight;
if (actual > c.height && actual !== measuredHeights[c.id]) {
newHeights[c.id] = actual;
changed = true;
}
}
}
}
if (changed) setMeasuredHeights((prev) => ({ ...prev, ...newHeights }));
});
// yOffset 계산
const offsets: Record<string, number> = {};
let cumulativeShift = 0;
for (const c of sortedByY) {
offsets[c.id] = cumulativeShift;
if (growableTypes.has(c.type)) {
const measured = measuredHeights[c.id];
if (measured && measured > c.height) {
cumulativeShift += measured - c.height;
}
}
}
const totalPageHeight = page.height * MM_TO_PX + cumulativeShift;
const setRef = (id: string) => (el: HTMLDivElement | null) => {
compRefs.current[id] = el;
};
return (
<div data-list-preview-page={page.page_id} className="relative shadow-md" style={{
width: `${page.width * MM_TO_PX}px`, minHeight: `${totalPageHeight}px`,
backgroundColor: page.background_color || "#ffffff", flexShrink: 0, overflow: "visible",
}}>
{watermark?.enabled && <WatermarkLayer watermark={watermark} pageWidth={page.width} pageHeight={page.height} />}
{sortedByY.sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0)).map((comp) => (
<ComponentRenderer key={comp.id} comp={comp} pageIndex={pageIndex} totalPages={totalPages}
pages={pages} getQueryResult={getQueryResult} editable={editable} onComponentClick={onComponentClick}
cellOverrides={cellOverrides} onInputCellClick={onInputCellClick}
yOffset={offsets[comp.id] || 0}
measureRef={growableTypes.has(comp.type) ? setRef(comp.id) : undefined} />
))}
</div>
);
}
function ComponentRenderer({ comp, pageIndex, totalPages, pages, getQueryResult, editable, onComponentClick, cellOverrides, onInputCellClick, yOffset = 0, measureRef }: {
comp: ComponentConfig; pageIndex: number; totalPages: number; pages: ReportPage[];
getQueryResult: (queryId: string) => QueryResult | null;
editable?: boolean; onComponentClick?: (comp: ComponentConfig) => void;
cellOverrides?: Record<string, Record<string, string>>;
onInputCellClick?: (component: ComponentConfig, cell: GridCell) => void;
yOffset?: number; measureRef?: (el: HTMLDivElement | null) => void;
}) {
const [hovered, setHovered] = useState(false);
const isDivider = comp.type === "divider";
const isClickable = editable && !isDivider && comp.type !== "pageNumber";
// 데이터 양에 따라 늘어나야 하는 컴포넌트
const growable = comp.type === "table" || comp.type === "card" || comp.type === "calculation";
const baseStyle: React.CSSProperties = {
position: "absolute",
left: `${comp.x}px`, top: `${comp.y + yOffset}px`,
width: `${comp.width}px`,
...(growable ? { minHeight: `${comp.height}px` } : { height: `${comp.height}px` }),
boxSizing: "border-box",
overflow: growable ? "visible" : "hidden",
zIndex: comp.zIndex ?? 0,
backgroundColor: comp.backgroundColor || "transparent",
...(comp.borderWidth ? { borderWidth: `${comp.borderWidth}px`, borderColor: comp.borderColor || "#000", borderStyle: "solid" } : {}),
...(comp.borderRadius ? { borderRadius: `${comp.borderRadius}px` } : {}),
padding: isDivider ? 0 : comp.padding != null ? typeof comp.padding === "number" ? `${comp.padding}px` : comp.padding : "8px",
// 클릭 가능 시 호버 효과
...(isClickable ? { cursor: "pointer", transition: "outline 0.15s, box-shadow 0.15s" } : {}),
...(isClickable && hovered ? { outline: "2px solid #3b82f6", outlineOffset: "-1px", boxShadow: "0 0 0 4px rgba(59,130,246,0.15)" } : {}),
};
const STATUS_LABELS: Record<string, string> = { draft: "작성중", pending: "검토중", approved: "승인", rejected: "반려", converted: "수주전환" };
const getComponentValue = (c: ComponentConfig): string => {
if (c.queryId && c.fieldName) {
const qr = getQueryResult(c.queryId);
let val = "-";
if (qr?.rows?.length) {
const raw = qr.rows[0][c.fieldName];
if (raw != null && raw !== "") {
val = String(raw);
if (c.fieldName === "status" && STATUS_LABELS[val]) val = STATUS_LABELS[val];
}
}
// defaultValue가 있으면 라벨로 표시: "라벨\n값"
if (c.defaultValue) return `${c.defaultValue}\n${val}`;
return val;
}
return c.defaultValue || "";
};
const displayValue = getComponentValue(comp);
const sortedPages = [...pages].sort((a, b) => a.page_order - b.page_order);
const currentPageId = sortedPages[pageIndex]?.page_id ?? null;
const layoutConfig = { pages: sortedPages.map((p) => ({ page_id: p.page_id, page_order: p.page_order })) };
const handleClick = (e: React.MouseEvent) => {
if (!isClickable) return;
e.stopPropagation();
onComponentClick?.(comp);
};
return (
<div ref={measureRef} style={baseStyle} onClick={handleClick}
onMouseEnter={() => isClickable && setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{(comp.type === "text" || comp.type === "label") && <TextRenderer component={comp} displayValue={displayValue} getQueryResult={getQueryResult} />}
{comp.type === "table" && <TableRenderer component={comp} getQueryResult={getQueryResult} cellOverrides={cellOverrides} onInputCellClick={onInputCellClick} />}
{comp.type === "image" && <ImageRenderer component={comp} />}
{comp.type === "divider" && <DividerRenderer component={comp} />}
{comp.type === "signature" && <SignatureRenderer component={comp} />}
{comp.type === "stamp" && <StampRenderer component={comp} />}
{comp.type === "pageNumber" && <PageNumberRenderer component={comp} currentPageId={currentPageId} layoutConfig={layoutConfig} />}
{comp.type === "card" && <CardRenderer component={comp} getQueryResult={getQueryResult} />}
{comp.type === "calculation" && <CalculationRenderer component={comp} getQueryResult={getQueryResult} />}
{comp.type === "barcode" && <BarcodeCanvasRenderer component={comp} getQueryResult={getQueryResult} />}
{comp.type === "checkbox" && <CheckboxRenderer component={comp} getQueryResult={getQueryResult} />}
</div>
);
}