Add dashboard report snapshots feature with CRUD operations. Implemented new database migrations for dashboard_report_snapshots, including report type handling. Updated backend routes and models to support snapshot retrieval and upsert functionality. Enhanced frontend components to integrate report snapshot management, including UI for daily and end-cycle reports. Added tests for new functionality to ensure reliability.
CI / lint-and-test (push) Canceled after 0s
CI / lint-and-test (push) Canceled after 0s
This commit is contained in:
1 parent
059087be1d
commit
34336b9bd1
41 files changed
+4824
-178
No files matched your search
+60
-25
@@ -2,6 +2,52 @@ import { jsPDF } from 'jspdf';
|
||||
import html2canvas from 'html2canvas';
|
||||
import { revealPdfHeaders } from '../components/shared/PdfReportHeader.tsx';
|
||||
|
||||
const PAGE_WIDTH_MM = 210;
|
||||
const PAGE_HEIGHT_MM = 297;
|
||||
const MARGIN_MM = 10;
|
||||
const CONTENT_WIDTH_MM = PAGE_WIDTH_MM - MARGIN_MM * 2;
|
||||
const USABLE_HEIGHT_MM = PAGE_HEIGHT_MM - MARGIN_MM * 2;
|
||||
|
||||
export const usableHeightCssPx = (widthPx: number): number =>
|
||||
(USABLE_HEIGHT_MM / CONTENT_WIDTH_MM) * widthPx;
|
||||
|
||||
/**
|
||||
* Safe page-break offsets for `data-pdf-block` nodes.
|
||||
*
|
||||
* A block's start is only a break point when the whole block fits on one page.
|
||||
* Oversized tables (e.g. 49-day Hitung Ayam) must start under the header
|
||||
* instead of being deferred to page 2.
|
||||
*/
|
||||
export function pdfBlockBreakOffsets(
|
||||
blocks: Array<{ top: number; bottom: number }>,
|
||||
usableHeightPx: number
|
||||
): number[] {
|
||||
const breaks: number[] = [];
|
||||
for (const block of blocks) {
|
||||
const height = block.bottom - block.top;
|
||||
if (height <= usableHeightPx && block.top > 0) {
|
||||
breaks.push(block.top);
|
||||
}
|
||||
if (block.bottom > 0) {
|
||||
breaks.push(block.bottom);
|
||||
}
|
||||
}
|
||||
return [...new Set(breaks.map((value) => Math.round(value)))].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function choosePdfSliceHeight(
|
||||
cursor: number,
|
||||
canvasHeight: number,
|
||||
usableHeightPx: number,
|
||||
breakPoints: number[]
|
||||
): number {
|
||||
const remaining = canvasHeight - cursor;
|
||||
if (remaining <= usableHeightPx) return remaining;
|
||||
const limit = cursor + usableHeightPx;
|
||||
const candidate = [...breakPoints].reverse().find((point) => point > cursor && point <= limit);
|
||||
return candidate ? candidate - cursor : usableHeightPx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a card to a multi-page A4 PDF without slicing through its contents.
|
||||
*
|
||||
@@ -21,9 +67,10 @@ export const exportCardToPdf = async (
|
||||
options?: { widthPx?: number }
|
||||
): Promise<void> => {
|
||||
const widthPx = options?.widthPx ?? 800;
|
||||
const scale = 2;
|
||||
const scale = 1.5;
|
||||
const jpegQuality = 0.82;
|
||||
/** Offsets, in cloned-CSS pixels, where a page may safely break. */
|
||||
const safeBreaks: number[] = [];
|
||||
const blockRects: Array<{ top: number; bottom: number }> = [];
|
||||
|
||||
const canvas = await html2canvas(element, {
|
||||
scale,
|
||||
@@ -45,20 +92,20 @@ export const exportCardToPdf = async (
|
||||
const top = clonedElement.getBoundingClientRect().top;
|
||||
clonedElement.querySelectorAll<HTMLElement>('[data-pdf-block]').forEach((node) => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
safeBreaks.push(rect.top - top, rect.bottom - top);
|
||||
blockRects.push({ top: rect.top - top, bottom: rect.bottom - top });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
|
||||
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4', compress: true });
|
||||
const pageWidthMm = doc.internal.pageSize.getWidth();
|
||||
const pageHeightMm = doc.internal.pageSize.getHeight();
|
||||
const marginMm = 10;
|
||||
const contentWidthMm = pageWidthMm - marginMm * 2;
|
||||
const usableHeightMm = pageHeightMm - marginMm * 2;
|
||||
const contentWidthMm = pageWidthMm - MARGIN_MM * 2;
|
||||
const usableHeightMm = pageHeightMm - MARGIN_MM * 2;
|
||||
|
||||
const mmPerPx = contentWidthMm / canvas.width;
|
||||
const usableHeightPx = usableHeightMm / mmPerPx;
|
||||
const safeBreaks = pdfBlockBreakOffsets(blockRects, usableHeightCssPx(widthPx));
|
||||
|
||||
// Clone-CSS px -> canvas px, de-duplicated and ordered.
|
||||
const breakPoints = [...new Set(safeBreaks.map((value) => Math.round(value * scale)))]
|
||||
@@ -69,20 +116,8 @@ export const exportCardToPdf = async (
|
||||
let firstPage = true;
|
||||
|
||||
while (cursor < canvas.height) {
|
||||
const remaining = canvas.height - cursor;
|
||||
let sliceHeight: number;
|
||||
|
||||
if (remaining <= usableHeightPx) {
|
||||
sliceHeight = remaining;
|
||||
} else {
|
||||
const limit = cursor + usableHeightPx;
|
||||
const candidate = [...breakPoints]
|
||||
.reverse()
|
||||
.find((point) => point > cursor && point <= limit);
|
||||
// No declared boundary fits — the block is taller than a page, so fall
|
||||
// back to a hard cut rather than looping forever.
|
||||
sliceHeight = candidate ? candidate - cursor : usableHeightPx;
|
||||
}
|
||||
const sliceHeight = choosePdfSliceHeight(cursor, canvas.height, usableHeightPx, breakPoints);
|
||||
if (sliceHeight <= 0) break;
|
||||
|
||||
const slice = document.createElement('canvas');
|
||||
slice.width = canvas.width;
|
||||
@@ -95,10 +130,10 @@ export const exportCardToPdf = async (
|
||||
|
||||
if (!firstPage) doc.addPage();
|
||||
doc.addImage(
|
||||
slice.toDataURL('image/png'),
|
||||
'PNG',
|
||||
marginMm,
|
||||
marginMm,
|
||||
slice.toDataURL('image/jpeg', jpegQuality),
|
||||
'JPEG',
|
||||
MARGIN_MM,
|
||||
MARGIN_MM,
|
||||
contentWidthMm,
|
||||
slice.height * mmPerPx
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user