feat(analytics): AVM v2 batch valuation, comparison, history + frontend upgrade
Add batch valuation (POST /analytics/valuation/batch, max 50 properties), valuation comparison (POST /analytics/valuation/compare, 2-5 properties), and history endpoint (GET /analytics/valuation/history/:propertyId) with confidence explanation helper. Frontend: enhanced valuation form with project autocomplete and deep analysis toggle, results with confidence badges and price range visualization, comparables table, history chart, market context card, and PDF export. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
176
apps/web/components/valuation/export-pdf-button.tsx
Normal file
176
apps/web/components/valuation/export-pdf-button.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
'use client';
|
||||
|
||||
import { Download, Loader2 } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface ExportPdfButtonProps {
|
||||
/** CSS selector for the DOM element to capture */
|
||||
targetSelector: string;
|
||||
/** Filename without extension */
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export function ExportPdfButton({
|
||||
targetSelector,
|
||||
filename = 'dinh-gia-bat-dong-san',
|
||||
}: ExportPdfButtonProps) {
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const handleExport = useCallback(async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const element = document.querySelector(targetSelector);
|
||||
if (!element) {
|
||||
console.error('Export target not found:', targetSelector);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dynamic imports for client-only PDF libraries
|
||||
const [html2canvasModule, jsPDFModule] = await Promise.all([
|
||||
import('html2canvas'),
|
||||
import('jspdf'),
|
||||
]);
|
||||
const html2canvas = html2canvasModule.default;
|
||||
const { jsPDF } = jsPDFModule;
|
||||
|
||||
const canvas = await html2canvas(element as HTMLElement, {
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
logging: false,
|
||||
backgroundColor: '#ffffff',
|
||||
});
|
||||
|
||||
const imgData = canvas.toDataURL('image/png');
|
||||
const imgWidth = canvas.width;
|
||||
const imgHeight = canvas.height;
|
||||
|
||||
// A4 dimensions in mm
|
||||
const pdfWidth = 210;
|
||||
const pdfMargin = 10;
|
||||
const contentWidth = pdfWidth - 2 * pdfMargin;
|
||||
const contentHeight = (imgHeight / imgWidth) * contentWidth;
|
||||
|
||||
const pdf = new jsPDF({
|
||||
orientation: contentHeight > 297 - 2 * pdfMargin ? 'portrait' : 'portrait',
|
||||
unit: 'mm',
|
||||
format: 'a4',
|
||||
});
|
||||
|
||||
// Add header
|
||||
pdf.setFontSize(16);
|
||||
pdf.setTextColor(34, 139, 34); // Green
|
||||
pdf.text('GoodGo — Báo cáo định giá', pdfMargin, pdfMargin + 5);
|
||||
pdf.setFontSize(10);
|
||||
pdf.setTextColor(128, 128, 128);
|
||||
pdf.text(
|
||||
`Ngày: ${new Date().toLocaleDateString('vi-VN')}`,
|
||||
pdfMargin,
|
||||
pdfMargin + 12,
|
||||
);
|
||||
|
||||
const headerHeight = 20;
|
||||
const availableHeight = 297 - 2 * pdfMargin - headerHeight;
|
||||
|
||||
if (contentHeight <= availableHeight) {
|
||||
// Fits on one page
|
||||
pdf.addImage(
|
||||
imgData,
|
||||
'PNG',
|
||||
pdfMargin,
|
||||
pdfMargin + headerHeight,
|
||||
contentWidth,
|
||||
contentHeight,
|
||||
);
|
||||
} else {
|
||||
// Multi-page: split the image
|
||||
let yOffset = 0;
|
||||
let isFirstPage = true;
|
||||
|
||||
while (yOffset < contentHeight) {
|
||||
if (!isFirstPage) {
|
||||
pdf.addPage();
|
||||
}
|
||||
|
||||
const pageContentHeight = isFirstPage
|
||||
? availableHeight
|
||||
: 297 - 2 * pdfMargin;
|
||||
const pageTopMargin = isFirstPage
|
||||
? pdfMargin + headerHeight
|
||||
: pdfMargin;
|
||||
|
||||
// Calculate source rectangle in image coordinates
|
||||
const srcY = (yOffset / contentHeight) * imgHeight;
|
||||
const srcHeight = (pageContentHeight / contentHeight) * imgHeight;
|
||||
|
||||
// Create a temporary canvas for this page slice
|
||||
const pageCanvas = document.createElement('canvas');
|
||||
pageCanvas.width = imgWidth;
|
||||
pageCanvas.height = Math.min(srcHeight, imgHeight - srcY);
|
||||
|
||||
const ctx = pageCanvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.drawImage(
|
||||
canvas,
|
||||
0,
|
||||
srcY,
|
||||
imgWidth,
|
||||
pageCanvas.height,
|
||||
0,
|
||||
0,
|
||||
imgWidth,
|
||||
pageCanvas.height,
|
||||
);
|
||||
}
|
||||
|
||||
const pageImgData = pageCanvas.toDataURL('image/png');
|
||||
const sliceHeight =
|
||||
(pageCanvas.height / imgWidth) * contentWidth;
|
||||
|
||||
pdf.addImage(
|
||||
pageImgData,
|
||||
'PNG',
|
||||
pdfMargin,
|
||||
pageTopMargin,
|
||||
contentWidth,
|
||||
sliceHeight,
|
||||
);
|
||||
|
||||
yOffset += pageContentHeight;
|
||||
isFirstPage = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Footer on last page
|
||||
pdf.setFontSize(8);
|
||||
pdf.setTextColor(180, 180, 180);
|
||||
pdf.text(
|
||||
'Được tạo bởi GoodGo AI Valuation — goodgo.vn',
|
||||
pdfMargin,
|
||||
297 - pdfMargin,
|
||||
);
|
||||
|
||||
pdf.save(`${filename}.pdf`);
|
||||
} catch (error) {
|
||||
console.error('PDF export failed:', error);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}, [targetSelector, filename]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
>
|
||||
{isExporting ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{isExporting ? 'Đang xuất...' : 'Xuất PDF'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user