- Added routes for outsource purchase management, including CRUD operations and additional features such as auto-processes and release requests. - Created the `outsourcePurchaseController` to handle business logic for managing outsource purchase orders. - Introduced the `outsourcePurchaseService` for service layer operations related to outsource purchases. - Updated `app.ts` to include the new routes for outsource purchase management. (TASK:ERP-019)
355 lines
16 KiB
TypeScript
355 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useRef, useState } from "react";
|
|
import { 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 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 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>
|
|
</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>
|
|
);
|
|
}
|