diff --git a/CLAUDE.md b/CLAUDE.md index 95ad9642..742a604f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -153,7 +153,10 @@ schermgroepering: gelijknamigen krijgen `#n`, naamlozen een surrogaat), daaronde start; relaties staan bij dit type uit omdat een taak onder meerdere banden kan staan; optie "blad per resource" = `PrintOptions.pageBreakBeforeGroups` → `RenderReportResult.forcedBreakOffsets` → `forcedBreakOffsetsPx` in `tileLayout`, waar een gedwongen -positie zonder vulgraaddrempel wint; `isGanttReportType()` bundelt beide Gantt-achtige typen), het +positie zonder vulgraaddrempel wint; `isGanttReportType()` bundelt beide Gantt-achtige typen; de voet +met legenda is sinds #113 net als de kop een herhaalbaar blok — `RenderReportResult.footerHeight` → +`repeatFooterHeightPx`/`repeatFooter`, instelling `repeatFooter` standaard aan; let op: de preview +rendert per pagina één volledige `renderReport`-pass extra voor die strook, net als voor de kop), het mijlpalen- en variance-rapport (eigen DOM-component + `build*Columns` voor de PDF) en zeven **tabelrapporten** uit discussie #31 — look-ahead, kritiek/near-critical, voortgang, planningsgezondheid, resourcebelasting per week, resourcetoewijzingen en WBS-samenvatting. Die zeven hebben een pure diff --git a/artifacts/resourcediagram/03-voet-op-elke-pagina-pagina-2.png b/artifacts/resourcediagram/03-voet-op-elke-pagina-pagina-2.png new file mode 100644 index 00000000..127cda26 Binary files /dev/null and b/artifacts/resourcediagram/03-voet-op-elke-pagina-pagina-2.png differ diff --git a/public/docs/en/gids-rapporten-printen.md b/public/docs/en/gids-rapporten-printen.md index 46795d3a..e3471561 100644 --- a/public/docs/en/gids-rapporten-printen.md +++ b/public/docs/en/gids-rapporten-printen.md @@ -40,6 +40,14 @@ A full, formatted printout of the Gantt bars — this is the only report type wi independently of the zoom level above. - **Repeat header on each page** — on by default; keeps the report header visible on every printed page instead of only the first. +- **Repeat footer on each page** — on by default; puts the footer with project name, print date and + legend at the bottom of every page instead of only the last, so a sheet handed out on its own + carries its own legend — also when the timeline is spread over several pages side by side. If + the whole print fits on one page, the footer simply stays below the last row. The strip costs + space on every page: roughly two rows fewer per sheet, so occasionally one page more, and a + resource that just fitted on one sheet with *Each resource on a new page* can spill onto two. + Off = the footer on the last page only. The page number ("3 / 7") always sits in the bottom + margin, separate from the footer; the footer itself no longer carries a page number. - **Timeline over** — spreads the Gantt timeline across 1 to 8 pages side by side; only available with auto-fit on. Choose more pages when you want a less compressed timeline without reducing the table text. @@ -102,8 +110,8 @@ resource yourself first. Bands are per resource, not per name: two resources tha name each get their own band (*Jan #1*, *Jan #2*), and a resource without a name gets a sequence number. The summary block counts the resources, the assignments and the tasks without a resource — that last count includes milestones and hammocks, because they are drawn here (the *Resource -assignments* table report counts real activities only). The footer with the legend and print date -sits, as in every multi-page print, on the last sheet. If there are no assignments yet, the preview +assignments* table report counts real activities only). With *Repeat footer on each page* (on by +default) every sheet you hand out carries its own legend. If there are no assignments yet, the preview says so instead of showing an empty page; assigning happens on the **Resources** tab (see [Resources & histogram](docs://gids-resources-histogram)). diff --git a/public/docs/nl/gids-rapporten-printen.md b/public/docs/nl/gids-rapporten-printen.md index 7fb2bc7a..14a8cef7 100644 --- a/public/docs/nl/gids-rapporten-printen.md +++ b/public/docs/nl/gids-rapporten-printen.md @@ -41,6 +41,14 @@ instellingenblok: van het zoomniveau hierboven. - **Kop op elke pagina herhalen** — standaard aan; houdt de rapportkop zichtbaar op elke afgedrukte pagina in plaats van alleen de eerste. +- **Voet op elke pagina herhalen** — standaard aan; zet de voet met projectnaam, afdrukdatum en + legenda onderaan elke pagina in plaats van alleen de laatste, zodat een los uitgedeeld vel zijn + eigen legenda heeft — ook wanneer de tijdlijn over meerdere pagina's naast elkaar staat. Past de + hele afdruk op één pagina, dan blijft de voet gewoon onder de laatste rij staan. De strook kost + per pagina ruimte: ongeveer twee rijen minder per vel, dus soms een pagina meer, en een resource + die met *Elke resource op een nieuwe pagina* nét op één vel paste kan daardoor over twee vellen + gaan. Uit = de voet alleen op de laatste pagina. Het paginanummer ("3 / 7") staat altijd in de + ondermarge, los van de voet; de voet zelf draagt geen paginanummer meer. - **Tijdlijn over** — verdeelt de Gantt-tijdlijn over 1 tot 8 pagina's naast elkaar; alleen beschikbaar met auto-fit aan. Kies meer pagina's wanneer je de tijdas minder wilt comprimeren zonder de tabeltekst kleiner te maken. @@ -105,8 +113,8 @@ niet eerst zelf op resource te groeperen. Banden zijn per resource, niet per naa toevallig hetzelfde heten krijgen elk een eigen band (*Jan #1*, *Jan #2*), en een resource zonder naam een volgnummer. Het overzichtsblok telt de resources, de toewijzingen en de taken zonder resource — die laatste telling omvat ook mijlpalen en hammocks, want die worden hier getekend (het -tabelrapport *Resourcetoewijzingen* telt alleen echte activiteiten). De voet met legenda en -afdrukdatum staat, zoals bij elke meerpagina-afdruk, op het laatste vel. Zijn er nog geen +tabelrapport *Resourcetoewijzingen* telt alleen echte activiteiten). Met *Voet op elke pagina +herhalen* (standaard aan) heeft elk uitgedeeld vel zijn eigen legenda. Zijn er nog geen toewijzingen, dan zegt het voorbeeld dat in plaats van een lege pagina te tonen; toewijzen gebeurt op het tabblad **Resources** (zie [Resources & histogram](docs://gids-resources-histogram)). diff --git a/src/components/panels/ReportPanel.tsx b/src/components/panels/ReportPanel.tsx index f4d9e970..54901789 100644 --- a/src/components/panels/ReportPanel.tsx +++ b/src/components/panels/ReportPanel.tsx @@ -6,8 +6,8 @@ import { computePreviewRasterLimits } from '@/services/print/previewSafety'; import { getLocalizedMonths, getLocalizedMonthsShort } from '@/i18n/dateFormat'; import { projectFileBase } from '@/utils/documents'; import { computeHighResScale } from '@/utils/miniPdf'; -import { paginateCanvasToPdfBytes } from '@/services/print/paginate'; -import { computeTileLayout } from '@/services/print/tileLayout'; +import { paginateCanvasToPdfBytes, type PaginateOptions } from '@/services/print/paginate'; +import { computeTileLayout, footerLayoutWidthFor } from '@/services/print/tileLayout'; import { ensureInterLoaded, getInterFontBytes, getArabicFontBytes } from '@/services/pdf/fontLoader'; import { RTL_LOCALES, type Locale } from '@/i18n/config'; import { Select } from '@/components/common/Select'; @@ -305,6 +305,9 @@ export function ReportPanel() { // Bewust géén veld in `PrintOptions`: de kopherhaling is puur een pagineerder-zaak (raster: // hoogte in px; vector: boolean), niet iets dat de render-zoom raakt. const [repeatHeader, setRepeatHeader] = useState(DEFAULT_REPORT_SETTINGS.repeatHeader); + // Voet (projectnaam, afdrukdatum, legenda) op elke pagina — issue #113: een blad per persoon + // zonder legenda is onleesbaar. Zelfde pagineerder-zaak als de kop, dus óók geen PrintOptions-veld. + const [repeatFooter, setRepeatFooter] = useState(DEFAULT_REPORT_SETTINGS.repeatFooter); // Issue #25 punt 5 — smeert de tijdlijn uit over N paginabreedtes (1 = oud gedrag, geen // verrassing voor bestaande gebruikers). Alleen zinvol in fit-width-modus; daarom `disabled` // wanneer `autoFit` uit staat (dan tegelt de export in 'actual'-modus toch al horizontaal). @@ -387,6 +390,7 @@ export function ReportPanel() { setPaperSize(s.paperSize); setOrientation(s.orientation); setRepeatHeader(s.repeatHeader); + setRepeatFooter(s.repeatFooter); setTimelineColumns(s.timelineColumns); setReportFontScale(s.reportFontScale); setStatusLine(s.statusLine); @@ -427,14 +431,14 @@ export function ReportPanel() { void saveReportSettings({ reportType, showCritical, showFloat, showDeps, showWeekends, compressNonWorkdays: reportCompressNonWorkdays, showLegend, showTaskNames, showCompletion, truncateTaskNames, taskNameColumnWidth, showBaselineOverlay, autoFit, customZoom, - paperSize, orientation, repeatHeader, timelineColumns, reportFontScale, statusLine, followView, previewQuality, + paperSize, orientation, repeatHeader, repeatFooter, timelineColumns, reportFontScale, statusLine, followView, previewQuality, tableReports: tableOptions, resourceGantt: resourceGanttOptions, }).catch(() => {}); }, [reportType, showCritical, showFloat, showDeps, showWeekends, reportCompressNonWorkdays, showLegend, showTaskNames, showCompletion, truncateTaskNames, taskNameColumnWidth, showBaselineOverlay, autoFit, customZoom, paperSize, - orientation, repeatHeader, timelineColumns, reportFontScale, statusLine, followView, previewQuality, tableOptions, - resourceGanttOptions]); + orientation, repeatHeader, repeatFooter, timelineColumns, reportFontScale, statusLine, followView, previewQuality, + tableOptions, resourceGanttOptions]); // Resourcediagram (issue #113): dezelfde Gantt-render, maar de rijen komen uit de pure rekenmodule // (per resource-identiteit een band, daaronder zijn taken) en niet van het scherm. @@ -548,8 +552,6 @@ export function ReportPanel() { duration: t('tableHeaders.duration'), completion: t('tableHeaders.completion', { defaultValue: 'Volt.' }), }, - page: t('page', { defaultValue: 'Pagina' }), - of: t('of', { defaultValue: 'van' }), today: t('today', { defaultValue: 'Vandaag' }), statusDate: t('statusDateLabel', { defaultValue: 'Statusdatum' }), progressDate: t('progressDateLabel', { defaultValue: 'Voortgangsdatum' }), @@ -647,7 +649,7 @@ export function ReportPanel() { const renderPreview = () => { if (cancelled) return; const { - width: logicalWidth, height: logicalHeight, tableWidth, headerHeight, breakOffsets, forcedBreakOffsets, + width: logicalWidth, height: logicalHeight, tableWidth, headerHeight, footerHeight, breakOffsets, forcedBreakOffsets, } = measurePrintReport(tasks, sequences, calendar, projectName, options); const lowerPaper = options.paperSize.toLowerCase() as 'a4' | 'a3' | 'a2' | 'a1'; const cssPageWidth = previewCssWidth; @@ -665,6 +667,7 @@ export function ReportPanel() { // Kop herhalen per pagina (issue #25 punt 1): de hoogte komt uit de render zelf; 0 = niet // herhalen (oud gedrag). De raster-tak wil px, de vector-tak een boolean. repeatHeaderHeightPx: repeatHeader ? headerHeight : 0, + repeatFooterHeightPx: repeatFooter ? footerHeight : 0, timelineColumns: options.timelineColumns, // Rij-bewuste paginering (issue #110): preview en export delen dezelfde breekposities; // het resourcediagram (issue #113) ook zijn gedwongen overgangen per resource. @@ -673,6 +676,9 @@ export function ReportPanel() { supersample: previewLimits.pageSupersample, }; const layout = computeTileLayout(tileOptions); + // De herhaalde voet wordt binnen één paginabreedte gelegd (meerdere kolommen ⇒ compleet op elk + // vel); zonder herhaling blijft de render exact de oude (voet over de volle canvasbreedte). + const pageOptions: PrintOptions = { ...options, footerLayoutWidth: footerLayoutWidthFor(layout) }; const total = layout.rows * layout.cols; const root = previewViewportRef.current; const anchor = root ? capturePreviewScrollAnchor(root) : { index: 0, offset: 0, scrollTop: 0 }; @@ -752,7 +758,7 @@ export function ReportPanel() { const canvas = document.createElement('canvas'); let pendingObjectUrl: string | undefined; try { - renderPrintPreviewPage(canvas, tasks, sequences, calendar, projectName, options, { + renderPrintPreviewPage(canvas, tasks, sequences, calendar, projectName, pageOptions, { layout, pageIndex: index, rasterWidth: previewLimits.pageRasterWidth, @@ -828,7 +834,7 @@ export function ReportPanel() { // toevoegen zou iedere preview-state-update opnieuw laten rasteren. // eslint-disable-next-line react-hooks/exhaustive-deps }, [reportSettingsHydrated, isGanttLike, tasks, sequences, calendar, projectName, previewOptionsSignature, - repeatHeader, previewCssWidth, previewQuality, replacePreviewPages]); + repeatHeader, repeatFooter, previewCssWidth, previewQuality, replacePreviewPages]); // Eén stabiele observer per layout. Een nieuwe afbeelding verandert zijn dependencies niet en kan // dus geen observer-rebuild/ping-pong veroorzaken. De queue dedupliceert callbacks. @@ -919,20 +925,25 @@ export function ReportPanel() { const exportRaster = (): Uint8Array => { const exportCanvas = document.createElement('canvas'); const { - width: logicalWidth, height: logicalHeight, tableWidth, headerHeight, breakOffsets, forcedBreakOffsets, + width: logicalWidth, height: logicalHeight, tableWidth, headerHeight, footerHeight, breakOffsets, forcedBreakOffsets, } = renderPrintCanvas(exportCanvas, tasks, sequences, calendar, projectName, options, 1); - const exportScale = computeHighResScale(logicalWidth, logicalHeight); - renderPrintCanvas(exportCanvas, tasks, sequences, calendar, projectName, options, exportScale); - return paginateCanvasToPdfBytes(exportCanvas, { + const rasterTile: PaginateOptions = { paperSize: lowerPaper, orientation, mode, logicalWidth, logicalHeight, frozenColumnWidthPx: tableWidth, - // Zelfde kopherhaling (px) en tijdlijn-spreiding als de preview en de vector-tak, zodat de - // raster-terugval WYSIWYG gelijk is aan beide (issue #25 punt 1 + 5). + // Zelfde kop-/voetherhaling (px) en tijdlijn-spreiding als de preview en de vector-tak, + // zodat de raster-terugval WYSIWYG gelijk is aan beide (issue #25 punt 1 + 5, #113). repeatHeaderHeightPx: repeatHeader ? headerHeight : 0, + repeatFooterHeightPx: repeatFooter ? footerHeight : 0, timelineColumns, breakOffsetsPx: breakOffsets, forcedBreakOffsetsPx: forcedBreakOffsets, - }); + }; + // De high-res render legt de voet binnen één paginabreedte (zie `footerLayoutWidth`) — alleen + // wanneer hij herhaald wordt; anders is dit letterlijk de oude render. + const exportScale = computeHighResScale(logicalWidth, logicalHeight); + renderPrintCanvas(exportCanvas, tasks, sequences, calendar, projectName, + { ...options, footerLayoutWidth: footerLayoutWidthFor(computeTileLayout(rasterTile)) }, exportScale); + return paginateCanvasToPdfBytes(exportCanvas, rasterTile); }; // Vector-tak (fase 2): échte vector-PDF met selecteerbare tekst + ingebedde Inter. Bij een fout @@ -948,14 +959,16 @@ export function ReportPanel() { getArabicFontBytes(700), ]); pdfBytes = await paginateVectorToPdfBytes( - (make) => renderReport(make, tasks, sequences, calendar, projectName, options), + (make, footerLayoutWidth) => renderReport(make, tasks, sequences, calendar, projectName, { ...options, footerLayoutWidth }), { paperSize: lowerPaper, orientation, mode, baseDir: exportBaseDir, - // Kop per pagina herhalen (issue #25 punt 1) + tijdlijn over N pagina's (punt 5). + // Kop per pagina herhalen (issue #25 punt 1) + tijdlijn over N pagina's (punt 5); voet + // per pagina (issue #113). repeatHeader, + repeatFooter, timelineColumns, }, { regular, bold }, @@ -1061,7 +1074,7 @@ export function ReportPanel() { await writePdf(tablePdfBytes, `${fileBase}-${suffix}.pdf`); }, [reportType, isGanttLike, projectName, fileBase, tasks, sequences, calendar, options, paperSize, orientation, - autoFit, repeatHeader, timelineColumns, writePdf, t, dd, milestoneRows, varianceResult, tableSpec]); + autoFit, repeatHeader, repeatFooter, timelineColumns, writePdf, t, dd, milestoneRows, varianceResult, tableSpec]); // K7-guard: een stale planning eerst doorrekenen. NIET meteen daarna exporteren — `runExport` // leest `tasks`/`options`/`tableSpec` uit de closure van de HUIDIGE render, en die kent de @@ -1433,6 +1446,10 @@ export function ReportPanel() { setRepeatHeader(e.target.checked)} className="accent-accent flex-shrink-0" /> {t('repeatHeader')} +