- Introduced a new `quote` module, including routes, controllers, and services for managing quotes. - Implemented API endpoints for listing, creating, updating, and deleting quotes, ensuring proper company code filtering for data access. - Developed a comprehensive UI for quote management, allowing users to create, edit, and view quotes seamlessly. - Enhanced the admin layout to include the new quote management page, improving navigation and accessibility for users. These additions significantly enhance the application's capabilities in managing quotes, providing users with essential tools for their sales processes.
376 lines
17 KiB
TypeScript
376 lines
17 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, 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;
|
|
}
|
|
|
|
export function ReportInlineViewer({
|
|
reportId,
|
|
contextParams,
|
|
className = "",
|
|
showToolbar = true,
|
|
onComponentClick,
|
|
}: 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}
|
|
/>
|
|
</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 }: {
|
|
page: ReportPage; pageIndex: number; totalPages: number; pages: ReportPage[];
|
|
watermark?: WatermarkConfig; getQueryResult: (queryId: string) => QueryResult | null;
|
|
editable?: boolean; onComponentClick?: (comp: ComponentConfig) => 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}
|
|
yOffset={offsets[comp.id] || 0}
|
|
measureRef={growableTypes.has(comp.type) ? setRef(comp.id) : undefined} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ComponentRenderer({ comp, pageIndex, totalPages, pages, getQueryResult, editable, onComponentClick, yOffset = 0, measureRef }: {
|
|
comp: ComponentConfig; pageIndex: number; totalPages: number; pages: ReportPage[];
|
|
getQueryResult: (queryId: string) => QueryResult | null;
|
|
editable?: boolean; onComponentClick?: (comp: ComponentConfig) => 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} />}
|
|
{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>
|
|
);
|
|
}
|