diff --git a/CLAUDE.md b/CLAUDE.md index 3f331533..4198c83b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,7 +155,14 @@ start; relaties staan bij dit type uit omdat een taak onder meerdere banden kan relatielijnen en legendaregel, de balken volgen `barColorSelection` via `criticalFill`); optie "blad per resource" = `PrintOptions.pageBreakBeforeGroups` → `RenderReportResult.forcedBreakOffsets` → `forcedBreakOffsetsPx` in `tileLayout`, waar een gedwongen -positie zonder vulgraaddrempel wint; `isGanttReportType()` bundelt beide Gantt-achtige typen; de voet +positie zonder vulgraaddrempel wint — een band direct onder een band (de optionele typelaag +`groupByType`: eerst een band per resourcetype in de vaste volgorde `RESOURCE_TYPE_BAND_ORDER`) krijgt +geen eigen gedwongen positie; optie *Rapportageperiode* = de gedeelde `ReportingPeriod` als +`PrintOptions.timeWindow` (tijdas exact op het venster, geometrie geklemd op het chartgebied want +`Draw2D` kent geen clip; de rijenbron filtert op overlap en telt `counts.outsidePeriod`); optie +*Eenheden/dag en curve tonen* = `PrintOptions.assignmentColumns` + `rowAssignments` (per `rowKey` +uit `assignmentByRowKey`: eenheden opgeteld, curve alleen bij eensluidende records) als twee +tabelkolommen achter de naam; `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 diff --git a/artifacts/resourcediagram/04-typelaag-en-toewijzingskolommen.png b/artifacts/resourcediagram/04-typelaag-en-toewijzingskolommen.png new file mode 100644 index 00000000..76cb2d36 Binary files /dev/null and b/artifacts/resourcediagram/04-typelaag-en-toewijzingskolommen.png differ diff --git a/artifacts/resourcediagram/05-rapportageperiode-14-sep-tot-11-okt.png b/artifacts/resourcediagram/05-rapportageperiode-14-sep-tot-11-okt.png new file mode 100644 index 00000000..aab8ffe2 Binary files /dev/null and b/artifacts/resourcediagram/05-rapportageperiode-14-sep-tot-11-okt.png differ diff --git a/public/docs/en/gids-rapporten-printen.md b/public/docs/en/gids-rapporten-printen.md index 781dda60..b797740e 100644 --- a/public/docs/en/gids-rapporten-printen.md +++ b/public/docs/en/gids-rapporten-printen.md @@ -101,7 +101,28 @@ with the tasks assigned to it underneath, in order of start. That is the "who do overview for the site meeting, or — with the option **Each resource on a new page** — a separate sheet per person to hand out. A task with two resources appears under both bands; summary tasks are left out — an assignment on a summary task (which an import can produce) is not shown here. **Include tasks without a resource** adds a *(none)* band -at the bottom, so the meeting can see at a glance what nobody owns yet. +at the bottom, so the meeting can see at a glance what nobody owns yet. **Group by resource type** +adds a layer above: first a band per type — labour, crew, subcontractor, equipment, material, in +that fixed order — with the resources inside it; useful when a meeting discusses the people first +and the equipment after, or when you only want to print the equipment block; the number after a type +band counts the task rows below it, so a task under two resources of the same type counts twice. +With *Each resource on a new page* a type band starts a new sheet together with its first resource. **Reporting period** — +the same control as on the table reports, see further down — limits the diagram to a time window: +only tasks that touch the window take part, the time axis runs exactly from the start to the end +date, and a bar that continues beyond it is cut off at the edge. That lets a meeting put the +look-ahead report and the resource diagram side by side over exactly the same weeks. *Project +duration* (the default) is the usual behaviour; with a window the summary block also counts how +many tasks fall outside it. After the task name two columns **Units/d** and **Curve** are shown by +default: how many units per day the band's resource is assigned to the task, and with which +distribution curve — "you are on Foundations half days, front loaded" says more than "you are on +Foundations". A task under two bands shows each band's own load; two assignments of the same +resource on one task are added up, and a dash in the curve column means those assignments carry +different curves. If the assignment has its own hour distribution the column says *Contour*; if +the curve was imported from MS Project or P6 and is none of the eight built-in shapes it says +*Imported curve* — the same as in the properties panel. The two columns make the table a good 130 px +wider and the time axis narrower; with long task names and *Truncate task names* off the time axis +can all but disappear. Turn **Show units/day and curve** off for the narrow table of the Gantt +print. The settings of the Gantt print apply here too — critical path, float, bar colours, status line, paper, header repeat — with three exceptions: *Follow view* (this report doesn't take its rows from @@ -160,7 +181,7 @@ a few conventions: Four reports work on a time window: look-ahead, progress, resource loading and resource assignments. They share one *Reporting period* control in the report options, with its own -remembered setting per report: +remembered setting per report (the resource diagram above offers the same control): - **Next / last week, 2, 4, 6, 8 or 12 weeks** and **next / last month** — counted from the project's status date (or today if none is set). A preset is inclusive on both ends: *next 4 diff --git a/public/docs/nl/gids-rapporten-printen.md b/public/docs/nl/gids-rapporten-printen.md index 35b4bebe..ae6040f1 100644 --- a/public/docs/nl/gids-rapporten-printen.md +++ b/public/docs/nl/gids-rapporten-printen.md @@ -104,7 +104,27 @@ een nieuwe pagina** — een los vel per persoon om uit te delen. Een taak met tw onder beide banden; verzameltaken doen niet mee — een toewijzing op een verzameltaak (die uit een import kan komen) wordt hier niet getoond. Met **Taken zonder resource meenemen** komt er onderaan een band *(geen)* bij, zodat je in het overleg meteen ziet wat -nog niemand heeft. +nog niemand heeft. Met **Groeperen op resourcetype** komt er een laag boven: eerst een band per type +— arbeid, ploeg, onderaannemer, materieel, materiaal, in die vaste volgorde — en daarbinnen de +resources; handig als je in het overleg eerst de mensen en dan het materieel bespreekt, of alleen het +materieelblok wilt afdrukken; het getal achter een typeband telt de taakrijen eronder, dus een taak +die onder twee resources van hetzelfde type staat telt twee keer. Bij *Elke resource op een nieuwe +pagina* begint een typeband op een nieuw vel samen met zijn eerste resource. Met **Rapportageperiode** — dezelfde keuze als bij de +tabelrapporten, zie verderop — beperk je het diagram tot een tijdvenster: alleen taken die het +venster raken doen mee, de tijdas loopt precies van de begin- tot de einddatum, en een balk die +verder doorloopt wordt aan de rand afgekapt. Zo leg je in dezelfde vergadering het look-ahead-rapport +en het resourcediagram over exact dezelfde weken naast elkaar. *Hele project* (standaard) is het +gewone gedrag; bij een venster telt het overzichtsblok ook hoeveel taken erbuiten vallen. +Achter de taaknaam staan standaard twee kolommen **Eenh./d** en **Curve**: hoeveel eenheden per +dag de resource van die band op de taak staat en met welke verdeelcurve — "je staat halve dagen +op Fundering, vooraan belast" zegt meer dan "je staat op Fundering". Een taak onder twee banden +toont per band de eigen inzet; twee toewijzingen van dezelfde resource op één taak worden +opgeteld, en een streepje bij de curve betekent dat die toewijzingen verschillende curves hebben. +Heeft de toewijzing een eigen urenverdeling, dan staat er *Contour*; is de curve uit MS Project of +P6 geïmporteerd en is het geen van de acht eigen vormen, dan staat er *Geïmporteerde curve* — +hetzelfde als in het eigenschappenpaneel. De twee kolommen maken de tabel ruim 130 px breder en de +tijdas dus smaller; bij lange taaknamen met *Taaknamen afkappen* uit kan de tijdas daardoor bijna +verdwijnen. Zet **Eenheden/dag en curve tonen** uit voor de smalle tabel van de Gantt-afdruk. De instellingen van de Gantt-afdruk gelden hier ook — kritiek pad, speling, balkkleuren, statuslijn, papier, kopherhaling — op drie na: *Volg weergave* (de rijen komen bij dit rapport niet van het @@ -162,7 +182,7 @@ delen een paar afspraken: Vier rapporten werken op een tijdvenster: look-ahead, voortgang, resourcebelasting en resourcetoewijzingen. Ze delen één *Rapportageperiode*-keuze in de rapportopties, met een eigen -onthouden instelling per rapport: +onthouden instelling per rapport (het resourcediagram hierboven kent dezelfde keuze): - **Volgende / afgelopen week, 2, 4, 6, 8 of 12 weken** en **volgende / afgelopen maand** — gerekend vanaf de statusdatum van het project (of vandaag als die ontbreekt). Een preset is inclusief aan diff --git a/src/components/panels/ReportPanel.tsx b/src/components/panels/ReportPanel.tsx index d7689fd6..39a0cbc0 100644 --- a/src/components/panels/ReportPanel.tsx +++ b/src/components/panels/ReportPanel.tsx @@ -27,6 +27,7 @@ import { import { computeResourceGanttRows } from '@/engine/reports'; import type { ViewRow } from '@/engine/view/visibleRows'; import { TableReportView } from './reports/TableReportView'; +import { ReportingPeriodField, useResolvedPeriod } from './reports/ReportingPeriodField'; import { TableReportOptionsBlock } from './reports/TableReportOptionsBlock'; import { useTableReportSpec } from './reports/useTableReportSpec'; import { toPdfSpec } from './reports/tableReportSpec'; @@ -447,12 +448,36 @@ export function ReportPanel() { const isGanttLike = isGanttReportType(reportType); const noneLabel = tTask('structure.none'); // De bandvolgorde volgt de app-taal (nooit de OS-taal van de afdrukker: zelfde vel, zelfde nummering). + // Typelabels voor de optionele typelaag (punt 2): dezelfde sleutels als het resourcepaneel. + const resourceTypeLabels = useMemo(() => ({ + LABOR: tCommon('resource.type.labor'), CREW: tCommon('resource.type.crew'), + SUBCONTRACTOR: tCommon('resource.type.subcontractor'), EQUIPMENT: tCommon('resource.type.equipment'), + MATERIAL: tCommon('resource.type.material'), + }), [tCommon]); + // Vertaalde curvenamen voor de toewijzingskolommen (punt 1): dezelfde sleutels als het taakraster. + const curveLabels = useMemo(() => ({ + UNIFORM: tCommon('resource.curve.uniform'), FRONT_LOADED: tCommon('resource.curve.frontLoaded'), + BACK_LOADED: tCommon('resource.curve.backLoaded'), BELL: tCommon('resource.curve.bell'), + EARLY_PEAK: tCommon('resource.curve.earlyPeak'), LATE_PEAK: tCommon('resource.curve.latePeak'), + DOUBLE_PEAK: tCommon('resource.curve.doublePeak'), TURTLE: tCommon('resource.curve.turtle'), + // Dezelfde twee toestanden als het eigenschappenpaneel: contour op de taak, geïmporteerde curve. + contoured: tTask('properties.assignments.contoured'), imported: tTask('properties.assignments.importedCurve'), + }), [tCommon, tTask]); + // Rapportageperiode als tijdvenster (punt 3): dezelfde oplossing als het control toont; bij + // *Hele project* geen venster, zodat het rapport byte-identiek blijft aan vóór deze optie. + const resourceGanttPeriod = useResolvedPeriod(resourceGanttOptions.period); + const resourceGanttWindow = reportType === 'resourceGantt' && resourceGanttOptions.period.preset !== 'project' + ? resourceGanttPeriod + : undefined; const resourceGantt = useMemo(() => (reportType === 'resourceGantt' ? computeResourceGanttRows({ tasks, resources, assignments }, { includeUnassigned: resourceGanttOptions.includeUnassigned, noneLabel, locale: i18n.language, + groupByType: resourceGanttOptions.groupByType, typeLabels: resourceTypeLabels, + window: resourceGanttWindow, }) : null), - [reportType, tasks, resources, assignments, noneLabel, resourceGanttOptions.includeUnassigned, i18n.language]); + [reportType, tasks, resources, assignments, noneLabel, resourceGanttOptions.includeUnassigned, + resourceGanttOptions.groupByType, resourceTypeLabels, resourceGanttWindow, i18n.language]); // Rijenbron van de Gantt-render: resourcediagram ⇒ de resourcebanden; Gantt-afdruk ⇒ de schermrijen // bij Volg weergave (#54), anders `undefined` = de volledige takenboom (oud gedrag, geen verrassingen). const reportRows = resourceGantt ? resourceGantt.rows : followView ? viewRows : undefined; @@ -536,7 +561,13 @@ export function ReportPanel() { labels: { // Resourcediagram zonder één toewijzing: zeg wat er ontbreekt, niet "geen taken" — tenzij er // écht geen taken zijn, dan is "wijs resources toe" het verkeerde advies. - noTasks: reportType === 'resourceGantt' && tasks.length > 0 ? t('resourceGantt.empty') : t('noTasks'), + // Leeg door het venster (géén bladtaak meer in de periode) ⇒ wijs naar de periode; leeg terwijl + // er wél taken in de periode staan ⇒ die zijn niet toegewezen, en een andere periode helpt niet. + noTasks: reportType === 'resourceGantt' && tasks.length > 0 + ? (resourceGantt && resourceGantt.counts.inPeriod === 0 && resourceGantt.counts.outsidePeriod > 0 + ? t('resourceGantt.emptyPeriod') + : t('resourceGantt.empty')) + : t('noTasks'), printed: t('printed'), legend: { criticalPath: t('legend.criticalPath'), @@ -552,6 +583,8 @@ export function ReportPanel() { tableHeaders: { wbs: t('tableHeaders.wbs'), taskName: t('tableHeaders.taskName'), + unitsPerDay: t('tableHeaders.unitsPerDay'), + curve: t('tableHeaders.curve'), start: t('tableHeaders.start'), end: t('tableHeaders.end'), duration: t('tableHeaders.duration'), @@ -594,6 +627,12 @@ export function ReportPanel() { rows: reportRows, // Issue #113 "een blad per persoon": gedwongen paginaovergang vóór elke resourceband. pageBreakBeforeGroups: reportType === 'resourceGantt' && resourceGanttOptions.pageBreakPerResource, + // Punt 3: de tijdas op de rapportageperiode (alleen resourcediagram, alleen buiten *Hele project*). + timeWindow: resourceGanttWindow, + // Punt 1: eenheden/dag en curve van de band op de taak als tabelkolommen (alleen resourcediagram). + assignmentColumns: reportType === 'resourceGantt' && resourceGanttOptions.showAssignmentColumns, + rowAssignments: resourceGantt?.assignmentByRowKey, + curveLabels, barColorsLegendLabels: { criticalOutline: t('legend.criticalOutline', { defaultValue: 'Kritiek pad (rand)' }), categoriesMore: (n: number) => t('legend.categoriesMore', { count: n }), @@ -603,7 +642,8 @@ export function ReportPanel() { project.endDate, project.author, dateNotation, weekStartDay, reportCompressNonWorkdays, timelineColumns, reportFontScale, cpmResult, barColorSelection, fieldCtx.activityCodeTypes, fieldCtx.customFieldDefs, reportTaskTypeLabels, tTask, statusLine, statusDate, resources, - assignments, baselineOverlay, reportRows, reportType, resourceGanttOptions.pageBreakPerResource, tasks.length]); + assignments, baselineOverlay, reportRows, reportType, resourceGanttOptions.pageBreakPerResource, tasks.length, + resourceGantt, resourceGanttWindow, resourceGanttOptions.showAssignmentColumns, curveLabels]); // `options` bevat afgeleide catalogus-/vertaalobjecten die bij een lokale preview-state-update // opnieuw kunnen worden aangemaakt zonder dat hun inhoud wijzigde. De rastertaak gebruikt deze // inhoudssignatuur als effectgrens: anders start `setPreviewPages` zelf opnieuw pagina 0 en 1. @@ -615,7 +655,10 @@ export function ReportPanel() { ? (value as ViewRow[]).map(r => (r.kind === 'group' ? `g:${r.key}:${r.label}:${r.count}:${r.depth}` : `t:${r.rowKey}:${r.depth}:${r.dimmed ? 1 : 0}`)) - : value + // Een Map serialiseert als `{}`; de toewijzingskolommen (punt 1) moeten wél een herrender geven. + : key === 'rowAssignments' && value instanceof Map + ? [...(value as Map).entries()] + : value )), [options]); // Eén generatie beheert één layout + één begrensde renderqueue. Een optiewijziging annuleert het @@ -1187,6 +1230,12 @@ export function ReportPanel() { {resourceGantt.counts.assignments} {t('resourceGantt.unassigned')} {resourceGantt.counts.unassignedTasks} + {resourceGanttWindow && ( + <> + {t('resourceGantt.outsidePeriod')} + {resourceGantt.counts.outsidePeriod} + + )} ) : reportType === 'gantt' ? ( <> @@ -1400,6 +1449,33 @@ export function ReportPanel() { /> {t('resourceGantt.includeUnassigned')} + + + {/* Punt 3: de gedeelde rapportageperiode (issue #120) als tijdvenster van dit rapport. */} + patchResourceGanttOptions({ period: next })} + dataKey="resourceGanttPeriod" + /> )} diff --git a/src/components/panels/reports/ReportingPeriodField.tsx b/src/components/panels/reports/ReportingPeriodField.tsx index 161ee6bb..ec86550a 100644 --- a/src/components/panels/reports/ReportingPeriodField.tsx +++ b/src/components/panels/reports/ReportingPeriodField.tsx @@ -39,8 +39,10 @@ interface Props { dataKey: string; } -/** De referentiedag en projectspanne uit de live store — dezelfde bron als `useReportContext`. */ -function useResolvedPeriod(period: ReportingPeriod): ResolvedPeriod { +/** De referentiedag en projectspanne uit de live store — dezelfde bron als `useReportContext`. + * Ook gebruikt door `ReportPanel` voor het tijdvenster van het resourcediagram, zodat de rijen en + * de tijdas exact het venster nemen dat dit control toont. */ +export function useResolvedPeriod(period: ReportingPeriod): ResolvedPeriod { const tasks = useAppStore(s => s.tasks); const statusDate = useAppStore(s => s.project.statusDate); const today = formatDate(new Date()); diff --git a/src/engine/reports/resourceGantt.ts b/src/engine/reports/resourceGantt.ts index 3ab0814b..e936eec1 100644 --- a/src/engine/reports/resourceGantt.ts +++ b/src/engine/reports/resourceGantt.ts @@ -1,7 +1,9 @@ import type { Task } from '@/types/task'; -import type { Resource, ResourceAssignment } from '@/types/resource'; +import type { Resource, ResourceAssignment, ResourceCurve, ResourceType } from '@/types/resource'; import { encodeBandKey, encodeGroupedTaskRowKey, NONE_RAWKEY, type ViewRow } from '@/engine/view/visibleRows'; -import { taskStart } from './reportCommon'; +import { matchContoursToAssignments } from '@/engine/contour/contourEngine'; +import { dayOf, taskFinish, taskStart } from './reportCommon'; +import type { ResolvedPeriod } from './reportingPeriod'; /** * Resourcediagram (issue #113, gfayat): "wie doet wat, en wanneer" als GRAFISCH rapport — de @@ -30,6 +32,35 @@ import { taskStart } from './reportCommon'; * eenduidig anker en zou op een blad per persoon de bladrand af lopen — het paneel zet `showDeps` * voor dit type uit (bevinding 2). * + * Optioneel in TWEE LAGEN (manuvarkey op #113, punt 2): eerst een band per resourceTYPE (mensen + * eerst — arbeid, ploeg, onderaannemer — dan materieel, dan materiaal; een vaste volgorde, niet + * de vertaalde labelvolgorde, zodat een uitgedeeld vel in elke taal dezelfde blokvolgorde heeft), + * daarbinnen de resourcebanden zoals hierboven, en de taken op diepte 2. De "(geen)"-band blijft + * op diepte 0 als laatste: taken zonder resource hebben geen type. + * + * Optioneel binnen een TIJDVENSTER (punt 3): met `window` (de opgeloste rapportageperiode, issue + * #120) doen alleen bladtaken mee die het venster raken — start ≤ tot én einde ≥ van, op dagniveau, + * dezelfde overlapregel als de tabelrapporten; een taak zonder datums valt erbuiten. Alle + * tellingen volgen die gefilterde set; `counts.outsidePeriod` telt wat er is weggelaten, zodat de + * UI een lege uitkomst kan verklaren. De render krijgt hetzelfde venster als `timeWindow`. + * + * Per taakrij onder een resourceband levert `assignmentByRowKey` de TOEWIJZING van die band op + * die taak (punt 1): eenheden per dag en de verdeelcurve — de rij is een taak, maar wat de lezer + * wil weten is "hoe zwaar staat déze resource erop". De curveTOESTAND is exact de weergaveregel + * van het eigenschappenpaneel (`TaskAssignmentsSection`), zodat rapport en paneel nooit twee + * antwoorden geven: een aan de toewijzing gekoppelde CONTOUR op de taak ⇒ `'contoured'`, anders + * `curveValues` zonder OPS-vorm (`curve` afwezig) ⇒ `'imported'`, anders `curve` (afwezig = + * UNIFORM). Let op: dat is een WEERGAVEregel, niet de verdeelregel van `ResourceLoad.ts`'s + * `assignmentDayUnits` — die verdeelt met `curveValues` zodra die er zijn, óók naast een `curve` + * (het gewone P6-pad: naamterugval + exacte waarden), en negeert een contour zonder periodes. Het + * rapport toont dus wat het paneel toont ("Vooraan belast"), terwijl het histogram P6's exacte + * waarden gebruikt; een echt gedeelde toestandshelper is een vervolgstap (review ronde 2). Een + * P6-/MSP-import met een curve zonder OPS-vorm heet hier in elk geval nooit "Uniform" + * (ronde 1, bevinding 1). Twee records van dezelfde resource op één + * taak (één rij) worden opgeteld; de curve is alleen bekend als alle records dezelfde hebben + * (anders `null`, de render toont een streepje). De "(geen)"-band heeft geen toewijzing en dus + * geen entry. De render tekent dit als twee tabelkolommen (`PrintOptions.assignmentColumns`). + * * Invoer is structureel een `ReportContext`-subset, zoals de rest van `src/engine/reports/`. Puur: * geen React-/store-imports, headless getest in `tests/planning/check-reports.ts`. */ @@ -46,11 +77,20 @@ export interface ResourceGanttRowsOptions { noneLabel: string; /** BCP-47-taal voor de bandvolgorde en de gelijknaamdetectie (de app-taal, `i18n.language`). */ locale: string; + /** Twee lagen: een band per resourcetype, daarbinnen per resource (default uit). */ + groupByType?: boolean; + /** Labels per resourcetype — `t('common:resource.type.')`; een ontbrekend label valt + * terug op de enum-naam. Alleen gelezen bij `groupByType`. */ + typeLabels?: Partial>; + /** Tijdvenster (opgeloste rapportageperiode, ISO-dagen inclusief). Afwezig = hele project. */ + window?: ResolvedPeriod; } export interface ResourceGanttRowsResult { /** De rijen voor `PrintOptions.rows`: bandrij per resource, taakrijen eronder (diepte 1). */ rows: ViewRow[]; + /** Per taakrij-sleutel (`rowKey`) onder een resourceband: de toewijzing van die band op die taak. */ + assignmentByRowKey: Map; counts: { /** Resources (op identiteit) met minstens één bladtaak. */ resources: number; @@ -60,12 +100,39 @@ export interface ResourceGanttRowsResult { /** Bladtaken zonder resource — ook geteld wanneer ze niet in de rijen staan. Telt, anders dan * het tabelrapport Resourcetoewijzingen, óók mijlpalen en hammocks: dit rapport tekent ze. */ unassignedTasks: number; + /** Bladtaken die door het tijdvenster zijn weggelaten (0 zonder venster). */ + outsidePeriod: number; + /** Bladtaken ín het venster (zonder venster: alle bladtaken) — ook als er geen toewijzing op zit. */ + inPeriod: number; }; } +/** De curvetoestand van een toewijzing: contour op de taak, geïmporteerde exacte curve, of de OPS-vorm. */ +export type RowCurve = ResourceCurve | 'contoured' | 'imported'; + +/** De toewijzing achter één taakrij van een resourceband (zie de moduledoc). */ +export interface RowAssignment { + /** Som van `unitsPerDay` over de records van deze resource op deze taak. */ + unitsPerDay: number; + /** De curvetoestand als alle records dezelfde hebben (`curve` afwezig = UNIFORM), anders null. */ + curve: RowCurve | null; +} + /** Bandsleutel van de "(geen)"-band — dezelfde codering als de schermgroepering. */ const NONE_BAND_KEY = encodeBandKey([NONE_RAWKEY]); +/** + * Bandvolgorde van de typen bij `groupByType`: wie het werk doet eerst, dan waarmee, dan waarvan. + * Bewust vast en niet op vertaald label gesorteerd — zie de moduledoc. Een type dat hier zou + * ontbreken (kan niet met het huidige enum) komt achteraan. + */ +export const RESOURCE_TYPE_BAND_ORDER: readonly ResourceType[] = ['LABOR', 'CREW', 'SUBCONTRACTOR', 'EQUIPMENT', 'MATERIAL']; + +/** Ruwe bandsleutel van een typeband — met prefix, zodat hij nooit botst met een resource-id. */ +function typeRawKey(type: ResourceType): string { + return `type:${type}`; +} + /** Op start (`taskStart`, dezelfde definitie als de tabelrapporten), lege datums achteraan, * gelijke starts in invoervolgorde (stabiel). */ function sortByStart(tasks: Task[]): Task[] { @@ -115,7 +182,14 @@ export function computeResourceGanttRows( ctx: ResourceGanttInput, opts: ResourceGanttRowsOptions, ): ResourceGanttRowsResult { - const leaves = ctx.tasks.filter(t => t.childIds.length === 0); + const allLeaves = ctx.tasks.filter(t => t.childIds.length === 0); + const inWindow = (t: Task): boolean => { + if (!opts.window) return true; + const s = dayOf(taskStart(t)); + const f = dayOf(taskFinish(t)); + return s !== '' && f !== '' && s <= opts.window.to && f >= opts.window.from; + }; + const leaves = allLeaves.filter(inWindow); const leafById = new Map(leaves.map(t => [t.id, t])); const resourceIds = new Set(ctx.resources.map(r => r.id)); @@ -123,7 +197,30 @@ export function computeResourceGanttRows( // een toewijzing aan een onbekende resource telt niet (zelfde regel als `resourceNames` op het // scherm: die taak is dan "zonder resource"). const tasksByResource = new Map>(); + // Per resource-id × taak-id de opgetelde eenheden en de verzameling curvetoestanden (punt 1). + const loadByResourceTask = new Map }>(); const assignedTaskIds = new Set(); + // Contourkoppeling op de VOLLEDIGE recordlijst per taak — ook records naar een onbekende + // resource (die filtert de rij-opbouw hieronder pas weg) — precies zoals `ResourceLoad.ts`'s + // `contourLookup` en het eigenschappenpaneel de lijst aanbieden; anders kan de legacy-terugval + // in `matchContoursToAssignments` (`assignments.length === 1`) hier anders uitvallen dan daar + // (review ronde 2, bevinding 3). + const recordsByTask = new Map(); + for (const a of ctx.assignments) { + if (!leafById.has(a.taskId)) continue; + const list = recordsByTask.get(a.taskId) ?? []; + list.push(a); + recordsByTask.set(a.taskId, list); + } + const contouredIds = new Set(); + for (const [taskId, list] of recordsByTask) { + const task = leafById.get(taskId)!; + if (task.timephasedContours && task.timephasedContours.length > 0) { + for (const id of matchContoursToAssignments(task.timephasedContours, list).keys()) contouredIds.add(id); + } + } + const curveOf = (a: ResourceAssignment): RowCurve => + contouredIds.has(a.id) ? 'contoured' : (!a.curve && a.curveValues ? 'imported' : (a.curve ?? 'UNIFORM')); let assignments = 0; for (const a of ctx.assignments) { const task = leafById.get(a.taskId); @@ -133,6 +230,11 @@ export function computeResourceGanttRows( if (!bucket) { bucket = new Map(); tasksByResource.set(a.resourceId, bucket); } bucket.set(task.id, task); assignedTaskIds.add(task.id); + const loadKey = `${a.resourceId}\u0000${task.id}`; + const load = loadByResourceTask.get(loadKey) ?? { unitsPerDay: 0, curves: new Set() }; + load.unitsPerDay += a.unitsPerDay; + load.curves.add(curveOf(a)); + loadByResourceTask.set(loadKey, load); } const labels = resourceBandLabels(ctx.resources, opts.locale); @@ -144,15 +246,47 @@ export function computeResourceGanttRows( .sort((a, b) => collator.compare(a.label, b.label) || a.index - b.index); const rows: ViewRow[] = []; - for (const band of bands) { - const key = encodeBandKey([band.id]); + const assignmentByRowKey = new Map(); + // Eén resourceband met zijn taakrijen, onder een optioneel typepad (diepte +1). + const pushBand = (band: (typeof bands)[number], path: string[]) => { + const groupPath = [...path, band.id]; + const key = encodeBandKey(groupPath); rows.push({ kind: 'group', rowKey: key, key, label: band.label, count: band.tasks.size, - depth: 0, levelIndex: 0, collapsed: false, + depth: path.length, levelIndex: path.length, collapsed: false, }); for (const task of sortByStart([...band.tasks.values()])) { - rows.push({ kind: 'task', rowKey: encodeGroupedTaskRowKey([band.id], task.id), task, depth: 1, dimmed: false }); + const rowKey = encodeGroupedTaskRowKey(groupPath, task.id); + rows.push({ kind: 'task', rowKey, task, depth: path.length + 1, dimmed: false }); + const load = loadByResourceTask.get(`${band.id}\u0000${task.id}`); + if (load) { + assignmentByRowKey.set(rowKey, { + unitsPerDay: load.unitsPerDay, + curve: load.curves.size === 1 ? [...load.curves][0] : null, + }); + } + } + }; + if (opts.groupByType) { + const typeOf = new Map(ctx.resources.map(r => [r.id, r.type])); + const rank = (type: ResourceType) => { + const i = RESOURCE_TYPE_BAND_ORDER.indexOf(type); + return i < 0 ? RESOURCE_TYPE_BAND_ORDER.length : i; + }; + // Typen in de vaste volgorde; binnen een type blijft de collator-volgorde van `bands`. + const types = [...new Set(bands.map(b => typeOf.get(b.id) as ResourceType))].sort((a, b) => rank(a) - rank(b)); + for (const type of types) { + const members = bands.filter(b => typeOf.get(b.id) === type); + const raw = typeRawKey(type); + const key = encodeBandKey([raw]); + rows.push({ + kind: 'group', rowKey: key, key, label: opts.typeLabels?.[type] ?? type, + count: members.reduce((n, b) => n + b.tasks.size, 0), depth: 0, levelIndex: 0, collapsed: false, + }); + for (const band of members) pushBand(band, [raw]); } + } else { + for (const band of bands) pushBand(band, []); } const unassigned = sortByStart(leaves.filter(t => !assignedTaskIds.has(t.id))); @@ -166,5 +300,12 @@ export function computeResourceGanttRows( } } - return { rows, counts: { resources: bands.length, assignments, unassignedTasks: unassigned.length } }; + return { + rows, + assignmentByRowKey, + counts: { + resources: bands.length, assignments, unassignedTasks: unassigned.length, + outsidePeriod: allLeaves.length - leaves.length, inPeriod: leaves.length, + }, + }; } diff --git a/src/i18n/locales/ar/report.json b/src/i18n/locales/ar/report.json index f092c4dc..70f66eac 100644 --- a/src/i18n/locales/ar/report.json +++ b/src/i18n/locales/ar/report.json @@ -67,6 +67,8 @@ }, "tableHeaders": { "wbs": "WBS", + "unitsPerDay": "وحدة/يوم", + "curve": "المنحنى", "taskName": "اسم المهمة", "start": "البداية", "end": "النهاية", @@ -146,6 +148,10 @@ "resourceGantt": { "pageBreakPerResource": "كل مورد في صفحة جديدة", "includeUnassigned": "تضمين المهام بدون مورد", + "groupByType": "تجميع حسب نوع المورد", + "showAssignmentColumns": "إظهار الوحدات/اليوم والمنحنى", + "emptyPeriod": "لا توجد مهام في فترة التقرير — اختر فترة أخرى أو المشروع بأكمله.", + "outsidePeriod": "خارج الفترة:", "empty": "لا توجد تعيينات موارد — عيّن الموارد للمهام أولاً.", "resources": "الموارد:", "assignments": "التعيينات:", diff --git a/src/i18n/locales/de/report.json b/src/i18n/locales/de/report.json index 8915a09e..462542cb 100644 --- a/src/i18n/locales/de/report.json +++ b/src/i18n/locales/de/report.json @@ -55,6 +55,8 @@ }, "tableHeaders": { "wbs": "WBS", + "unitsPerDay": "Einh./T", + "curve": "Kurve", "taskName": "Aufgabenname", "start": "Anfang", "end": "Ende", @@ -134,6 +136,10 @@ "resourceGantt": { "pageBreakPerResource": "Jede Ressource auf einer neuen Seite", "includeUnassigned": "Aufgaben ohne Ressource einbeziehen", + "groupByType": "Nach Ressourcentyp gruppieren", + "showAssignmentColumns": "Einheiten/Tag und Kurve anzeigen", + "emptyPeriod": "Keine Vorgänge im Berichtszeitraum — wählen Sie einen anderen Zeitraum oder Gesamtes Projekt.", + "outsidePeriod": "Außerhalb des Zeitraums:", "empty": "Keine Ressourcenzuweisungen — weisen Sie zuerst Ressourcen zu Aufgaben zu.", "resources": "Ressourcen:", "assignments": "Zuweisungen:", diff --git a/src/i18n/locales/en/report.json b/src/i18n/locales/en/report.json index b7623ac1..415157c9 100644 --- a/src/i18n/locales/en/report.json +++ b/src/i18n/locales/en/report.json @@ -55,6 +55,8 @@ }, "tableHeaders": { "wbs": "WBS", + "unitsPerDay": "Units/d", + "curve": "Curve", "taskName": "Task Name", "start": "Start", "end": "End", @@ -134,6 +136,10 @@ "resourceGantt": { "pageBreakPerResource": "Each resource on a new page", "includeUnassigned": "Include tasks without a resource", + "groupByType": "Group by resource type", + "showAssignmentColumns": "Show units/day and curve", + "emptyPeriod": "No tasks in the reporting period — choose another period or Whole project.", + "outsidePeriod": "Outside the period:", "empty": "No resource assignments — assign resources to tasks first.", "resources": "Resources:", "assignments": "Assignments:", diff --git a/src/i18n/locales/es/report.json b/src/i18n/locales/es/report.json index 348a71c2..ee350964 100644 --- a/src/i18n/locales/es/report.json +++ b/src/i18n/locales/es/report.json @@ -58,6 +58,8 @@ }, "tableHeaders": { "wbs": "WBS", + "unitsPerDay": "Unid./d", + "curve": "Curva", "taskName": "Nombre de tarea", "start": "Inicio", "end": "Fin", @@ -137,6 +139,10 @@ "resourceGantt": { "pageBreakPerResource": "Cada recurso en una página nueva", "includeUnassigned": "Incluir tareas sin recurso", + "groupByType": "Agrupar por tipo de recurso", + "showAssignmentColumns": "Mostrar unidades/día y curva", + "emptyPeriod": "No hay tareas en el período del informe — elija otro período o Proyecto completo.", + "outsidePeriod": "Fuera del período:", "empty": "No hay asignaciones de recursos — asigne primero recursos a las tareas.", "resources": "Recursos:", "assignments": "Asignaciones:", diff --git a/src/i18n/locales/fa/report.json b/src/i18n/locales/fa/report.json index 1859b27c..353c25ae 100644 --- a/src/i18n/locales/fa/report.json +++ b/src/i18n/locales/fa/report.json @@ -56,6 +56,8 @@ }, "tableHeaders": { "wbs": "WBS", + "unitsPerDay": "واحد/روز", + "curve": "منحنی", "taskName": "نام وظیفه", "start": "شروع", "end": "پایان", @@ -135,6 +137,10 @@ "resourceGantt": { "pageBreakPerResource": "هر منبع در صفحه‌ای جدید", "includeUnassigned": "شامل کردن کارهای بدون منبع", + "groupByType": "گروه‌بندی بر اساس نوع منبع", + "showAssignmentColumns": "نمایش واحد/روز و منحنی", + "emptyPeriod": "هیچ کاری در دوره گزارش وجود ندارد — دوره دیگری یا کل پروژه را انتخاب کنید.", + "outsidePeriod": "خارج از دوره:", "empty": "هیچ تخصیص منبعی وجود ندارد — ابتدا منابع را به کارها تخصیص دهید.", "resources": "منابع:", "assignments": "تخصیص‌ها:", diff --git a/src/i18n/locales/fr/report.json b/src/i18n/locales/fr/report.json index bb6f6a62..aad5aa38 100644 --- a/src/i18n/locales/fr/report.json +++ b/src/i18n/locales/fr/report.json @@ -58,6 +58,8 @@ }, "tableHeaders": { "wbs": "WBS", + "unitsPerDay": "Unités/j", + "curve": "Courbe", "taskName": "Nom de la tâche", "start": "Début", "end": "Fin", @@ -137,6 +139,10 @@ "resourceGantt": { "pageBreakPerResource": "Chaque ressource sur une nouvelle page", "includeUnassigned": "Inclure les tâches sans ressource", + "groupByType": "Grouper par type de ressource", + "showAssignmentColumns": "Afficher unités/jour et courbe", + "emptyPeriod": "Aucune tâche dans la période de rapport — choisissez une autre période ou Projet entier.", + "outsidePeriod": "Hors période :", "empty": "Aucune affectation de ressource — affectez d'abord des ressources aux tâches.", "resources": "Ressources :", "assignments": "Affectations :", diff --git a/src/i18n/locales/it/report.json b/src/i18n/locales/it/report.json index c0283846..b5beec51 100644 --- a/src/i18n/locales/it/report.json +++ b/src/i18n/locales/it/report.json @@ -58,6 +58,8 @@ }, "tableHeaders": { "wbs": "WBS", + "unitsPerDay": "Unità/g", + "curve": "Curva", "taskName": "Nome attività", "start": "Inizio", "end": "Fine", @@ -137,6 +139,10 @@ "resourceGantt": { "pageBreakPerResource": "Ogni risorsa su una nuova pagina", "includeUnassigned": "Includi le attività senza risorsa", + "groupByType": "Raggruppa per tipo di risorsa", + "showAssignmentColumns": "Mostra unità/giorno e curva", + "emptyPeriod": "Nessuna attività nel periodo del report — scegli un altro periodo o Intero progetto.", + "outsidePeriod": "Fuori dal periodo:", "empty": "Nessuna assegnazione di risorse — assegna prima le risorse alle attività.", "resources": "Risorse:", "assignments": "Assegnazioni:", diff --git a/src/i18n/locales/ja/report.json b/src/i18n/locales/ja/report.json index 9fac187b..1813d42e 100644 --- a/src/i18n/locales/ja/report.json +++ b/src/i18n/locales/ja/report.json @@ -52,6 +52,8 @@ }, "tableHeaders": { "wbs": "WBS", + "unitsPerDay": "単位/日", + "curve": "カーブ", "taskName": "タスク名", "start": "開始", "end": "終了", @@ -131,6 +133,10 @@ "resourceGantt": { "pageBreakPerResource": "リソースごとに改ページ", "includeUnassigned": "リソース未割当のタスクを含める", + "groupByType": "リソース種別でグループ化", + "showAssignmentColumns": "単位/日とカーブを表示", + "emptyPeriod": "レポート期間内にタスクがありません — 別の期間またはプロジェクト全体を選択してください。", + "outsidePeriod": "期間外:", "empty": "リソースの割当がありません — まずタスクにリソースを割り当ててください。", "resources": "リソース:", "assignments": "割当:", diff --git a/src/i18n/locales/ko/report.json b/src/i18n/locales/ko/report.json index 45118c02..5d0036f1 100644 --- a/src/i18n/locales/ko/report.json +++ b/src/i18n/locales/ko/report.json @@ -52,6 +52,8 @@ }, "tableHeaders": { "wbs": "WBS", + "unitsPerDay": "단위/일", + "curve": "곡선", "taskName": "작업 이름", "start": "시작", "end": "종료", @@ -131,6 +133,10 @@ "resourceGantt": { "pageBreakPerResource": "자원마다 새 페이지", "includeUnassigned": "자원이 없는 작업 포함", + "groupByType": "자원 유형별로 그룹화", + "showAssignmentColumns": "단위/일 및 곡선 표시", + "emptyPeriod": "보고 기간에 작업이 없습니다 — 다른 기간 또는 전체 프로젝트를 선택하세요.", + "outsidePeriod": "기간 외:", "empty": "자원 배정이 없습니다 — 먼저 작업에 자원을 배정하세요.", "resources": "자원:", "assignments": "배정:", diff --git a/src/i18n/locales/nl/report.json b/src/i18n/locales/nl/report.json index d4d2c231..5d1f739f 100644 --- a/src/i18n/locales/nl/report.json +++ b/src/i18n/locales/nl/report.json @@ -55,6 +55,8 @@ }, "tableHeaders": { "wbs": "WBS", + "unitsPerDay": "Eenh./d", + "curve": "Curve", "taskName": "Taaknaam", "start": "Start", "end": "Einde", @@ -134,6 +136,10 @@ "resourceGantt": { "pageBreakPerResource": "Elke resource op een nieuwe pagina", "includeUnassigned": "Taken zonder resource meenemen", + "groupByType": "Groeperen op resourcetype", + "showAssignmentColumns": "Eenheden/dag en curve tonen", + "emptyPeriod": "Geen taken in de rapportageperiode — kies een andere periode of Hele project.", + "outsidePeriod": "Buiten de periode:", "empty": "Geen resourcetoewijzingen — wijs eerst resources toe aan taken.", "resources": "Resources:", "assignments": "Toewijzingen:", diff --git a/src/i18n/locales/pl/report.json b/src/i18n/locales/pl/report.json index 44f07b15..1f642953 100644 --- a/src/i18n/locales/pl/report.json +++ b/src/i18n/locales/pl/report.json @@ -61,6 +61,8 @@ }, "tableHeaders": { "wbs": "WBS", + "unitsPerDay": "Jedn./d", + "curve": "Krzywa", "taskName": "Nazwa zadania", "start": "Początek", "end": "Koniec", @@ -140,6 +142,10 @@ "resourceGantt": { "pageBreakPerResource": "Każdy zasób na nowej stronie", "includeUnassigned": "Uwzględnij zadania bez zasobu", + "groupByType": "Grupuj według typu zasobu", + "showAssignmentColumns": "Pokaż jednostki/dzień i krzywą", + "emptyPeriod": "Brak zadań w okresie raportu — wybierz inny okres lub Cały projekt.", + "outsidePeriod": "Poza okresem:", "empty": "Brak przypisań zasobów — najpierw przypisz zasoby do zadań.", "resources": "Zasoby:", "assignments": "Przypisania:", diff --git a/src/i18n/locales/pt/report.json b/src/i18n/locales/pt/report.json index b7bd207f..616c5811 100644 --- a/src/i18n/locales/pt/report.json +++ b/src/i18n/locales/pt/report.json @@ -58,6 +58,8 @@ }, "tableHeaders": { "wbs": "WBS", + "unitsPerDay": "Unid./d", + "curve": "Curva", "taskName": "Nome da tarefa", "start": "Início", "end": "Fim", @@ -137,6 +139,10 @@ "resourceGantt": { "pageBreakPerResource": "Cada recurso numa nova página", "includeUnassigned": "Incluir tarefas sem recurso", + "groupByType": "Agrupar por tipo de recurso", + "showAssignmentColumns": "Mostrar unidades/dia e curva", + "emptyPeriod": "Nenhuma tarefa no período do relatório — escolha outro período ou Projeto inteiro.", + "outsidePeriod": "Fora do período:", "empty": "Sem atribuições de recursos — atribua primeiro recursos às tarefas.", "resources": "Recursos:", "assignments": "Atribuições:", diff --git a/src/i18n/locales/tr/report.json b/src/i18n/locales/tr/report.json index cf25531a..b76699da 100644 --- a/src/i18n/locales/tr/report.json +++ b/src/i18n/locales/tr/report.json @@ -55,6 +55,8 @@ }, "tableHeaders": { "wbs": "WBS", + "unitsPerDay": "Birim/g", + "curve": "Eğri", "taskName": "Görev adı", "start": "Başlangıç", "end": "Bitiş", @@ -134,6 +136,10 @@ "resourceGantt": { "pageBreakPerResource": "Her kaynak yeni bir sayfada", "includeUnassigned": "Kaynaksız görevleri dahil et", + "groupByType": "Kaynak türüne göre grupla", + "showAssignmentColumns": "Birim/gün ve eğriyi göster", + "emptyPeriod": "Rapor döneminde görev yok — başka bir dönem veya Tüm proje seçin.", + "outsidePeriod": "Dönem dışında:", "empty": "Kaynak ataması yok — önce görevlere kaynak atayın.", "resources": "Kaynaklar:", "assignments": "Atamalar:", diff --git a/src/i18n/locales/zh/report.json b/src/i18n/locales/zh/report.json index 7e8c7eae..6bf76303 100644 --- a/src/i18n/locales/zh/report.json +++ b/src/i18n/locales/zh/report.json @@ -52,6 +52,8 @@ }, "tableHeaders": { "wbs": "WBS", + "unitsPerDay": "单位/天", + "curve": "曲线", "taskName": "任务名称", "start": "开始", "end": "结束", @@ -131,6 +133,10 @@ "resourceGantt": { "pageBreakPerResource": "每个资源另起一页", "includeUnassigned": "包含未分配资源的任务", + "groupByType": "按资源类型分组", + "showAssignmentColumns": "显示单位/天和曲线", + "emptyPeriod": "报告期间内没有任务 — 请选择其他期间或整个项目。", + "outsidePeriod": "期间之外:", "empty": "没有资源分配——请先为任务分配资源。", "resources": "资源:", "assignments": "分配:", diff --git a/src/services/print/printPreview.ts b/src/services/print/printPreview.ts index 7c9026e7..6f96d303 100644 --- a/src/services/print/printPreview.ts +++ b/src/services/print/printPreview.ts @@ -30,6 +30,7 @@ import type { Resource, ResourceAssignment } from '@/types/resource'; import type { ActivityCodeType, CustomFieldDef } from '@/types/structure'; import type { BarColorSelection } from '@/types/barColor'; import type { ViewRow } from '@/engine/view/visibleRows'; +import type { RowAssignment, RowCurve } from '@/engine/reports/resourceGantt'; import type { BaselineOverlay } from '@/types/baseline'; // BASISmaten bij rapport-lettergrootte 100%. Niets tekent hier nog rechtstreeks mee: alle @@ -89,6 +90,10 @@ const COL = { start: { w: 55 }, end: { w: 55 }, complete: { w: 45 }, + // Toewijzingskolommen van het resourcediagram (manuvarkey punt 1): eenheden per dag en de + // verdeelcurve van de resource van de band op die taak. Alleen bij `assignmentColumns`. + units: { w: 45 }, + curve: { w: 98 }, }; /** @@ -120,20 +125,24 @@ function resolveNameColumnWidth(raw: number | undefined): number { * **Voltooiing tonen** uit verdwijnt de hele Volt.-kolom uit de tabel (issue #93) — niet alleen * de waarden — dus krimpt de tabel met precies die kolombreedte en krijgt de tijdlijn die ruimte. */ -function tableWidthFor(showCompletion: boolean, nameW: number): number { - return COL.wbs.w + nameW + COL.duration.w + COL.start.w + COL.end.w + (showCompletion ? COL.complete.w : 0); +function tableWidthFor(showCompletion: boolean, nameW: number, assignmentColumns = false): number { + return COL.wbs.w + nameW + COL.duration.w + COL.start.w + COL.end.w + (showCompletion ? COL.complete.w : 0) + + (assignmentColumns ? COL.units.w + COL.curve.w : 0); } // Kolomposities van links naar rechts. `k` is de rapport-lettergrootteschaal (zie // {@link ReportMetrics}); álle kolommaten schalen mee, want een grotere letter heeft een bredere // kolom nodig. Bij k = 1 is dit rekenkundig exact de ongeschaalde uitkomst. `complete` is // `undefined` wanneer de kolom verborgen is; alle tekenpaden lezen dat als "niet tekenen". -function getColPositions(k: number, showCompletion: boolean, nameW: number) { +function getColPositions(k: number, showCompletion: boolean, nameW: number, assignmentColumns = false) { let x = 0; const next = (w: number) => { const col = { x, w: w * k }; x += w * k; return col; }; return { wbs: next(COL.wbs.w), name: next(nameW), + // Direct achter de naam: ze horen bij "wie staat hierop en hoe", niet bij de datums. + units: assignmentColumns ? next(COL.units.w) : undefined, + curve: assignmentColumns ? next(COL.curve.w) : undefined, duration: next(COL.duration.w), start: next(COL.start.w), end: next(COL.end.w), @@ -153,6 +162,8 @@ export interface PrintRow { depth: number; label?: string; // groepsband-label count?: number; // groepsband-aantal bladrijen + /** Toewijzing van de band op deze taak (resourcediagram, `PrintOptions.rowAssignments`). */ + assignment?: RowAssignment; } /** @@ -161,11 +172,15 @@ export interface PrintRow { * de volledige takenboom (oud gedrag, self-flatten), met wezen zonder gevonden ouder achteraan. * Geëxporteerd omdat het rapportpaneel dezelfde rijen nodig heeft om de naamkolom te meten. */ -export function buildPrintRows(tasks: Task[], rows: ViewRow[] | undefined): PrintRow[] { +export function buildPrintRows( + tasks: Task[], + rows: ViewRow[] | undefined, + rowAssignments?: ReadonlyMap, +): PrintRow[] { const printRows: PrintRow[] = []; if (rows) { for (const row of rows) { - if (row.kind === 'task') printRows.push({ kind: 'task', task: row.task, depth: row.depth }); + if (row.kind === 'task') printRows.push({ kind: 'task', task: row.task, depth: row.depth, assignment: rowAssignments?.get(row.rowKey) }); else printRows.push({ kind: 'group', depth: row.depth, label: row.label, count: row.count }); } return printRows; @@ -270,7 +285,12 @@ export const REPORT_FONT_SCALES = [90, 100, 110, 125] as const; * geen enkele Select kan tonen en die na een herstart dus niet reproduceerbaar is. Zelfde semantiek * als in de settings- en rapport-loaders, allemaal via {@link snapToChoice}. */ -function makeMetrics(reportFontScale: number | undefined, showCompletion: boolean, taskNameColumnWidth: number | undefined): ReportMetrics { +function makeMetrics( + reportFontScale: number | undefined, + showCompletion: boolean, + taskNameColumnWidth: number | undefined, + assignmentColumns = false, +): ReportMetrics { const pct = snapToChoice(REPORT_FONT_SCALES, reportFontScale ?? 100) ?? 100; const k = pct / 100; const nameW = resolveNameColumnWidth(taskNameColumnWidth); @@ -286,9 +306,9 @@ function makeMetrics(reportFontScale: number | undefined, showCompletion: boolea // Bewust de SOM van de twee geschaalde hoogtes, niet `(PROJECT + TIMELINE) * k`: alleen zo valt // de kopstrook-grens gegarandeerd tot op de bit samen met waar de tijdschaal-kop eindigt. totalHeaderHeight: projectHeaderHeight + timelineHeaderHeight, - tableWidth: tableWidthFor(showCompletion, nameW) * k, + tableWidth: tableWidthFor(showCompletion, nameW, assignmentColumns) * k, footerHeight: FOOTER_HEIGHT * k, - cols: getColPositions(k, showCompletion, nameW), + cols: getColPositions(k, showCompletion, nameW, assignmentColumns), }; } @@ -327,7 +347,11 @@ export interface PrintOptions { * bindend-informatie beschikbaar is (zie {@link PrintOptions.drivingSequenceIds}). */ relationStyle: string; }; - tableHeaders: { wbs: string; taskName: string; start: string; end: string; duration: string; completion: string }; + tableHeaders: { + wbs: string; taskName: string; start: string; end: string; duration: string; completion: string; + /** Koppen van de toewijzingskolommen (resourcediagram); ontbreken ⇒ Nederlandse terugval. */ + unitsPerDay?: string; curve?: string; + }; /** Label boven de gestippelde "vandaag"-lijn in het Gantt-gebied. */ today: string; /** Label boven de statusdatum-/voortgangslijn in de exportkop (#54). */ @@ -361,6 +385,31 @@ export interface PrintOptions { * `timelineColumns` moet krijgen. Default 1 = oud gedrag (alles op één paginabreedte). */ timelineColumns?: number; + /** + * OPTIONEEL — rapportageperiode als TIJDVENSTER (manuvarkey op #113, punt 3; resourcediagram): de + * tijdas loopt exact van `from` t/m `to` (ISO-dagen, inclusief) zonder de gebruikelijke marge van + * 7/14 dagen, en balken, mijlpalen, speling, voortgang en baseline worden op de chartrand + * afgekapt — `Draw2D` kent geen clip, dus de geometrie zelf wordt geklemd (ook de 3 px- + * minimumbreedte van een balk en het middelpunt van een ruit; relatiepijlen worden bij een + * venster helemaal niet getekend). Welke rijen in het + * venster horen beslist de rijenbron (`computeResourceGanttRows`), niet de render; een rij die + * er toch buiten valt tekent gewoon geen balk. Relatiepijlen worden niet geklemd: het venster + * wordt alleen aangeboden op het resourcediagram, dat er geen tekent. Afwezig ⇒ byte-identiek. + */ + timeWindow?: { from: string; to: string }; + /** + * OPTIONEEL — TOEWIJZINGSKOLOMMEN (manuvarkey op #113, punt 1; resourcediagram): twee extra + * tabelkolommen direct achter de naam — eenheden per dag en verdeelcurve van de resource van de + * band op die taak — gevuld uit `rowAssignments` (per `ViewRow.rowKey`, uit + * `computeResourceGanttRows().assignmentByRowKey`). Een rij zonder entry (bandrij, "(geen)") + * laat de cellen leeg. De tabel wordt precies de twee kolombreedtes breder; afwezig ⇒ + * byte-identiek. + */ + assignmentColumns?: boolean; + rowAssignments?: ReadonlyMap; + /** Vertaalde curvenamen (`common:resource.curve.*`, plus `contoured`/`imported` uit + * `task:properties.assignments.*`); een ontbrekend label valt terug op de enum-/toestandsnaam. */ + curveLabels?: Partial>; /** * Lettergrootte van het GEGENEREERDE RAPPORT als percentage (issue #25 punt 4). 100 (of * ontbrekend) = het oude gedrag, byte-identiek. Werkt bewust RELATIEF: tekst, rijhoogtes, @@ -450,6 +499,28 @@ function formatDuration(days: number): string { return `${days}d`; } +/** + * Breek `text` op woordgrenzen in regels die binnen `maxWidth` passen (dezelfde px-eenheid als + * `d2d.measureText`). Eén woord dat alleen al te breed is wordt met een ellipsis afgekort. + */ +function wrapWords(d2d: Draw2D, text: string, maxWidth: number): string[] { + const lines: string[] = []; + let line = ''; + for (const word of text.split(/\s+/).filter(Boolean)) { + const candidate = line ? `${line} ${word}` : word; + if (d2d.measureText(candidate).width <= maxWidth) { line = candidate; continue; } + if (line) lines.push(line); + line = d2d.measureText(word).width <= maxWidth ? word : fitText(d2d, word, maxWidth); + } + if (line) lines.push(line); + return lines.length > 0 ? lines : ['']; +} + +/** Eenheden per dag: geheel als "2", anders tot twee decimalen zonder nullen ("0.5", "1.25"). */ +function formatUnitsPerDay(units: number): string { + return String(Math.round(units * 100) / 100); +} + /** Format completion as "75%" */ function formatCompletion(completion: number): string { return `${Math.round(completion * 100)}%`; @@ -625,11 +696,11 @@ export function renderReport( ): RenderReportResult { // Alle maatvoering loopt via dit object — de tekenhelpers lezen de module-constanten niet meer // rechtstreeks (zie {@link ReportMetrics} voor het waarom van relatief-schalen). - const m = makeMetrics(options.reportFontScale, options.showCompletion, options.taskNameColumnWidth); + const m = makeMetrics(options.reportFontScale, options.showCompletion, options.taskNameColumnWidth, !!options.assignmentColumns); // Rijen-bron: zie {@link buildPrintRows} — taakrijen mét diepte plus groepsband-rijen (#54) die // als samenvattings-strook tekenen. - const printRows = buildPrintRows(tasks, options.rows); + const printRows = buildPrintRows(tasks, options.rows, options.rowAssignments); const flatTasks: PrintTask[] = printRows .filter((r): r is PrintRow & { kind: 'task'; task: Task } => r.kind === 'task') .map(r => ({ ...r.task, _depth: r.depth })); @@ -641,7 +712,12 @@ export function renderReport( d2d.fillStyle = PRINT_COLORS.textSecondary; d2d.font = m.font(14); d2d.textAlign = 'center'; - d2d.fillText(options.labels?.noTasks ?? 'No tasks to display', 300, 100); + // Woord-wrap binnen de 600 px brede doos: een instructie ("kies een andere periode …") mag + // niet halverwege afkappen (review ronde 2, bevinding 4). Verticaal is er ruimte zat. + const emptyLines = wrapWords(d2d, options.labels?.noTasks ?? 'No tasks to display', 560); + const emptyLineH = m.s(18); + const emptyTop = 100 - ((emptyLines.length - 1) * emptyLineH) / 2; + emptyLines.forEach((line, i) => d2d.fillText(line, 300, emptyTop + i * emptyLineH)); // Geen kop-/tijdschaalstrook in de lege-staat (alleen een centrale melding) ⇒ niets te herhalen. // Het meldingsvak zelf houdt z'n vaste 600×200; alleen de tekst erin volgt de schaal. return { width: 600, height: 200, tableWidth: m.tableWidth, headerHeight: 0, footerHeight: 0 }; @@ -663,9 +739,15 @@ export function renderReport( } } - // Add padding days - minDate = addCalendarDays(minDate, -7); - maxDate = addCalendarDays(maxDate, 14); + if (options.timeWindow) { + // Tijdvenster: de as is precies het venster (einde exclusief, dus `to` + 1), zonder marge. + minDate = parseDate(options.timeWindow.from); + maxDate = addCalendarDays(parseDate(options.timeWindow.to), 1); + } else { + // Add padding days + minDate = addCalendarDays(minDate, -7); + maxDate = addCalendarDays(maxDate, 14); + } const calendarDays = diffCalendarDays(minDate, maxDate); @@ -673,11 +755,20 @@ export function renderReport( // scherm-Gantt beslist of de kalender werkelijk gecomprimeerd kan worden (een kalender zonder // werkdag valt gecontroleerd terug op de gewone kalender-as). const calEngine = new CalendarEngine(calendar); - const compressed = isCompressedEffective(calEngine, !!options.compressNonWorkdays); - const measureAxis = resolveGanttAxis({ + let compressed = isCompressedEffective(calEngine, !!options.compressNonWorkdays); + let measureAxis = resolveGanttAxis({ calendar: calEngine, compressNonWorkdays: compressed, origin: minDate, chartOriginX: 0, zoom: 1, scrollX: 0, }); + // Tijdvenster zonder één werkdag (b.v. een weekend) op de gecomprimeerde as: de as zou dan naar + // de eerstvolgende werkdag búiten het venster kleven. Val voor dít venster terug op de kalender-as. + if (options.timeWindow && compressed && measureAxis.daySpan(minDate, maxDate) < 1) { + compressed = false; + measureAxis = resolveGanttAxis({ + calendar: calEngine, compressNonWorkdays: false, + origin: minDate, chartOriginX: 0, zoom: 1, scrollX: 0, + }); + } const timelineDays = compressed ? Math.max(1, Math.ceil(measureAxis.daySpan(minDate, maxDate))) : calendarDays; @@ -711,6 +802,10 @@ export function renderReport( const chartWidth = timelineDays * zoom; const canvasWidth = m.tableWidth + chartWidth; + // Tijdvenster: chart-x klemmen op het chartgebied (zie `PrintOptions.timeWindow`). Zonder venster + // is dit de identiteit, zodat de oude render byte-identiek blijft. + const windowed = !!options.timeWindow; + const clampX = (x: number) => (windowed ? Math.min(canvasWidth, Math.max(m.tableWidth, x)) : x); // Rij-aantal voor de hoogte: ALLE printrijen (taken + groepsbanden) — de banden zijn volle rijen. const canvasHeight = m.totalHeaderHeight + printRows.length * m.rowHeight + m.footerHeight; @@ -890,7 +985,7 @@ export function renderReport( const statusUtc = Date.UTC(statusDay.getUTCFullYear(), statusDay.getUTCMonth(), statusDay.getUTCDate()); const fullyDone = c >= 1 && finishDay <= statusUtc; const notStarted = c === 0 && startDay >= statusUtc; - if (!fullyDone && !notStarted) px = bx1 + (bx2 - bx1) * c; + if (!fullyDone && !notStarted) px = clampX(bx1 + (bx2 - bx1) * c); } d2d.lineTo(statusLineX!, rowTop); d2d.lineTo(px, rowMid); @@ -977,33 +1072,47 @@ export function renderReport( const x = dateToX(date) + zoom / 2; const cy = y + barHeight / 2; const size = barHeight * 0.45; + // Tijdvenster: een ruit die het chartgebied helemaal mist wordt niet getekend; een ruit op de + // rand wordt met zijn middelpunt naar binnen geklemd, zodat hij nooit half over de tabel of + // over de rechterrand hangt (hyperkritische review, bevinding 2). + // Middelpunt buiten het chartgebied ⇒ niet tekenen (een naar binnen geklemde ruit zou + // een dag suggereren waarop de mijlpaal niet valt — review ronde 2, bevinding 6). + const inWindow = !windowed || (x >= m.tableWidth && x <= canvasWidth); + const cx = windowed ? Math.min(canvasWidth - size, Math.max(m.tableWidth + size, x)) : x; + + if (inWindow) { + const advies = colorAdvice(task); + d2d.fillStyle = advies.kind === 'solid' ? advies.fill : advies.segments[0].color; + if (advies.outline) { + // Rode rand om een kritieke mijlpaal in de niet-critical-modi: de ruit omtrekken. + d2d.strokeStyle = advies.outline; + d2d.lineWidth = 1; + } + d2d.beginPath(); + d2d.moveTo(cx, cy - size); + d2d.lineTo(cx + size, cy); + d2d.lineTo(cx, cy + size); + d2d.lineTo(cx - size, cy); + d2d.closePath(); + d2d.fill(); + if (advies.outline) d2d.stroke(); - const advies = colorAdvice(task); - d2d.fillStyle = advies.kind === 'solid' ? advies.fill : advies.segments[0].color; - if (advies.outline) { - // Rode rand om een kritieke mijlpaal in de niet-critical-modi: de ruit omtrekken. - d2d.strokeStyle = advies.outline; - d2d.lineWidth = 1; - } - d2d.beginPath(); - d2d.moveTo(x, cy - size); - d2d.lineTo(x + size, cy); - d2d.lineTo(x, cy + size); - d2d.lineTo(x - size, cy); - d2d.closePath(); - d2d.fill(); - if (advies.outline) d2d.stroke(); - - // Task name label (rechts van de ruit, valt terug naar links/ellipsis bij de rand) - if (options.showTaskNames) { - barLabelJobs.push({ name: task.name, barRightX: x + size, barLeftX: x - size, y: cy + m.s(3), bold: false }); + // Task name label (rechts van de ruit, valt terug naar links/ellipsis bij de rand) + if (options.showTaskNames) { + barLabelJobs.push({ name: task.name, barRightX: cx + size, barLeftX: cx - size, y: cy + m.s(3), bold: false }); + } } } else if (task.childIds.length > 0) { // Summary bracket bar const start = parseDate(task.time.earlyStart || task.time.scheduleStart); const end = parseDate(task.time.earlyFinish || task.time.scheduleFinish); - const x1 = dateToX(start); - const x2 = dateToX(end) + zoom; + const rawX1 = dateToX(start); + const rawX2 = dateToX(end) + zoom; + // Tijdvenster: geklemd op het chartgebied; een afgekapt uiteinde krijgt geen haakje (dat zou + // een echt begin/einde suggereren). Valt de hele haak buiten het venster, dan niets. + const x1 = clampX(rawX1); + const x2 = clampX(rawX2); + if (windowed && x2 <= x1) continue; const width = Math.max(x2 - x1, 3); const barY = y + barHeight * 0.3; const barH = barHeight * 0.3; @@ -1012,20 +1121,24 @@ export function renderReport( d2d.fillRect(x1, barY, width, barH); // Left triangle - d2d.beginPath(); - d2d.moveTo(x1, barY); - d2d.lineTo(x1, barY + barH + 5); - d2d.lineTo(x1 + 6, barY + barH); - d2d.closePath(); - d2d.fill(); + if (x1 === rawX1) { + d2d.beginPath(); + d2d.moveTo(x1, barY); + d2d.lineTo(x1, barY + barH + 5); + d2d.lineTo(x1 + 6, barY + barH); + d2d.closePath(); + d2d.fill(); + } // Right triangle - d2d.beginPath(); - d2d.moveTo(x1 + width, barY); - d2d.lineTo(x1 + width, barY + barH + 5); - d2d.lineTo(x1 + width - 6, barY + barH); - d2d.closePath(); - d2d.fill(); + if (x2 === rawX2) { + d2d.beginPath(); + d2d.moveTo(x1 + width, barY); + d2d.lineTo(x1 + width, barY + barH + 5); + d2d.lineTo(x1 + width - 6, barY + barH); + d2d.closePath(); + d2d.fill(); + } // Task name label (rechts van de balk, valt terug naar links/ellipsis bij de rand) if (options.showTaskNames) { @@ -1035,9 +1148,15 @@ export function renderReport( // Normal task bar const start = parseDate(task.time.earlyStart || task.time.scheduleStart); const end = parseDate(task.time.earlyFinish || task.time.scheduleFinish); - const x1 = dateToX(start); - const x2 = dateToX(end) + zoom; - const width = Math.max(x2 - x1, 3); + const rawX1 = dateToX(start); + const rawX2 = dateToX(end) + zoom; + const width = Math.max(rawX2 - rawX1, 3); + // Tijdvenster: de balkuiteinden geklemd op het chartgebied; de voortgangsgrens en de + // speling hieronder klemmen op dezelfde manier. Een balk die helemaal buiten het venster + // valt tekent niets (ook geen label). + const x1 = clampX(rawX1); + const x2 = clampX(rawX2); + if (windowed && x2 <= x1) continue; // Kleurmodi (#21) en onderbroken balken (Z15) zijn onafhankelijke dimensies: dezelfde // kleurverhouding komt terug in elk werkblok van één taak. @@ -1046,10 +1165,11 @@ export function renderReport( const segments = task.splitGaps && task.splitGaps.length > 0 ? computeSplitSegments(task.splitGaps, start, end, false, calEngine) : [{ start, end }]; - const segs = segments.map((s, i) => ({ - x1: i === 0 ? x1 : dateToX(s.start), - x2: i === segments.length - 1 ? x2 : dateToX(s.end), - })); + const segs = segments.map((s, i) => { + const rx1 = i === 0 ? rawX1 : dateToX(s.start); + const rx2 = i === segments.length - 1 ? rawX2 : dateToX(s.end); + return { rx1, rx2, x1: clampX(rx1), x2: clampX(rx2) }; + }).filter(s => !windowed || s.x2 > s.x1); const split = segs.length > 1; if (split) { @@ -1062,33 +1182,52 @@ export function renderReport( } for (const s of segs) { - const sw = Math.max(s.x2 - s.x1, split ? 2 : 3); + // De minimumbreedte (3 px, 2 bij splits) op de RUWE maat, en het einde bij een venster op de + // chartrand geklemd zodat dat minimum er niet overheen steekt. + const minW = split ? 2 : 3; + const rawSw = Math.max(s.rx2 - s.rx1, minW); + // Zichtbare breedte: zonder venster de oude `max(breedte, minimum)` op x1; bij een venster + // de geklemde breedte, en is die smaller dan het minimum, dan schuift het minimum naar + // binnen (zoals de ruit) in plaats van te worden afgeknepen, zodat een eendagstaak op de + // laatste vensterdag zichtbaar blijft (review ronde 2, bevinding 7). + let sx1 = s.x1; + let sw = windowed ? s.x2 - s.x1 : rawSw; + if (windowed && sw < minW) { + sw = Math.min(minW, canvasWidth - m.tableWidth); + sx1 = Math.max(m.tableWidth, Math.min(s.x1, canvasWidth - sw)); + } if (advies.kind === 'segments') { - let sx = s.x1; + // Kleurvakken op de ruwe tijdas verdeeld en daarna per vak op het chartgebied geknipt: een + // afgekapte balk toont zo de kleuren die bij het zichtbare stuk horen (review, bevinding 8). + let sx = s.rx1; advies.segments.forEach((seg, si) => { const isLast = si === advies.segments.length - 1; - const w = isLast ? s.x1 + sw - sx : Math.round(sw * seg.weight); - d2d.fillStyle = seg.color; - d2d.roundRect(sx, y, w, barHeight, si === 0 ? 3 : 0); - d2d.fill(); + const w = isLast ? s.rx1 + rawSw - sx : Math.round(rawSw * seg.weight); + const vx1 = windowed ? Math.max(sx, m.tableWidth) : sx; + const vx2 = windowed ? Math.min(sx + w, canvasWidth) : sx + w; + if (!windowed || vx2 > vx1) { + d2d.fillStyle = seg.color; + d2d.roundRect(vx1, y, vx2 - vx1, barHeight, si === 0 ? 3 : 0); + d2d.fill(); + } sx += w; }); } else { d2d.fillStyle = advies.fill; - d2d.roundRect(s.x1, y, sw, barHeight, 3); + d2d.roundRect(sx1, y, sw, barHeight, 3); d2d.fill(); } if (advies.outline) { d2d.strokeStyle = advies.outline; d2d.lineWidth = 1; - d2d.roundRect(s.x1, y, sw, barHeight, 3); + d2d.roundRect(sx1, y, sw, barHeight, 3); d2d.stroke(); } } // Eén globale voortgangsgrens over de volle taakduur, maar nooit kleur over de tijdgaten. if (options.showCompletion && task.time.completion > 0) { - const progressEnd = x1 + width * task.time.completion; + const progressEnd = clampX(rawX1 + width * task.time.completion); d2d.fillStyle = 'rgba(0, 0, 0, 0.25)'; for (const s of segs) { const sw = Math.max(s.x2 - s.x1, split ? 2 : 3); @@ -1104,17 +1243,17 @@ export function renderReport( } // Float indicator - if (options.showFloat && task.time.totalFloat > 0 && !task.time.isCritical) { - const floatWidth = task.time.totalFloat * zoom; + const floatEndX = clampX(rawX2 + task.time.totalFloat * zoom); + if (options.showFloat && task.time.totalFloat > 0 && !task.time.isCritical && floatEndX > x2) { d2d.fillStyle = PRINT_COLORS.float + '40'; - d2d.roundRect(x2, y + barHeight * 0.2, floatWidth, barHeight * 0.6, 2); + d2d.roundRect(x2, y + barHeight * 0.2, floatEndX - x2, barHeight * 0.6, 2); d2d.fill(); } // Task name label (rechts van de balk + eventuele speling; valt terug naar links/ellipsis bij de rand) if (options.showTaskNames) { const hasFloat = options.showFloat && task.time.totalFloat > 0 && !task.time.isCritical; - const barRightX = x2 + (hasFloat ? task.time.totalFloat * zoom : 0); + const barRightX = hasFloat ? Math.max(x2, floatEndX) : x2; barLabelJobs.push({ name: task.name, barRightX, barLeftX: x1, y: y + barHeight / 2 + m.s(3), bold: false }); } } @@ -1131,19 +1270,24 @@ export function renderReport( if (baseline.isMilestone) { const x = dateToX(parseDate(baseline.start)) + zoom / 2; const cy = baseY + baseHeight / 2; - d2d.beginPath(); - d2d.moveTo(x, cy - baseHeight); - d2d.lineTo(x + baseHeight, cy); - d2d.lineTo(x, cy + baseHeight); - d2d.lineTo(x - baseHeight, cy); - d2d.closePath(); - d2d.fill(); + if (!windowed || (x >= m.tableWidth && x <= canvasWidth)) { + const bcx = windowed ? Math.min(canvasWidth - baseHeight, Math.max(m.tableWidth + baseHeight, x)) : x; + d2d.beginPath(); + d2d.moveTo(bcx, cy - baseHeight); + d2d.lineTo(bcx + baseHeight, cy); + d2d.lineTo(bcx, cy + baseHeight); + d2d.lineTo(bcx - baseHeight, cy); + d2d.closePath(); + d2d.fill(); + } } else { - const x1 = dateToX(parseDate(baseline.start)); - const x2 = dateToX(parseDate(baseline.finish)) + zoom; - d2d.beginPath(); - d2d.roundRect(x1, baseY, Math.max(x2 - x1, 2), baseHeight, 1); - d2d.fill(); + const x1 = clampX(dateToX(parseDate(baseline.start))); + const x2 = clampX(dateToX(parseDate(baseline.finish)) + zoom); + if (!windowed || x2 > x1) { + d2d.beginPath(); + d2d.roundRect(x1, baseY, Math.max(x2 - x1, 2), baseHeight, 1); + d2d.fill(); + } } } } @@ -1165,7 +1309,9 @@ export function renderReport( // tegel geëmit), dus daar was dit nooit stuk. De omkering repareert dus feitelijk de RASTER-preview // en brengt die in lijn met wat de export altijd al deed — wat precies de bedoeling is, want die // twee horen WYSIWYG te zijn. - if (options.showDeps) { + // Bij een tijdvenster worden relaties nooit getekend: `drawDependencies` klemt niet, en het + // venster wordt alleen op het resourcediagram aangeboden, dat sowieso geen relaties tekent. + if (options.showDeps && !windowed) { // #54 volg-weergave: alleen relaties waarvan béide endpoints een zichtbare rij zijn (zelfde // regel als het scherm). rowIndexOf indexeert printRows (groepsbanden meegerekend) en is // daarmee tegelijk het zichtbaarheids- én het y-positie-bron; in boom-modus (= alle taken @@ -1206,7 +1352,9 @@ export function renderReport( // Resourcediagram (issue #113): een gedwongen overgang vóór elke bandrij ná de eerste — de // bovenrand van rij i is de onderrand van rij i-1, dus exact een bestaande breekpositie. const forcedBreakOffsets = options.pageBreakBeforeGroups - ? printRows.flatMap((row, i) => (row.kind === 'group' && i > 0 ? [m.totalHeaderHeight + i * m.rowHeight] : [])) + // Een band direct ónder een band (typelaag, punt 2) blijft bij zijn ouder: anders zou de typekop + // alleen op een verder leeg vel staan. + ? printRows.flatMap((row, i) => (row.kind === 'group' && i > 0 && printRows[i - 1].kind !== 'group' ? [m.totalHeaderHeight + i * m.rowHeight] : [])) : undefined; return { width: canvasWidth, height: canvasHeight, tableWidth: m.tableWidth, headerHeight: m.totalHeaderHeight, @@ -1787,8 +1935,10 @@ function drawTimelineHeader( d2d.textAlign = 'left'; d2d.fillText(th?.taskName ?? 'Taaknaam', cols.name.x + m.s(4), headerY); + if (cols.curve) d2d.fillText(th?.curve ?? 'Curve', cols.curve.x + m.s(4), headerY); d2d.textAlign = 'center'; + if (cols.units) d2d.fillText(th?.unitsPerDay ?? 'Eenh./d', cols.units.x + cols.units.w / 2, headerY); d2d.fillText(th?.duration ?? 'Duur', cols.duration.x + cols.duration.w / 2, headerY); d2d.fillText(th?.start ?? 'Start', cols.start.x + cols.start.w / 2, headerY); d2d.fillText(th?.end ?? 'Einde', cols.end.x + cols.end.w / 2, headerY); @@ -1801,6 +1951,7 @@ function drawTimelineHeader( d2d.lineWidth = 0.5; const colBorders = [cols.name.x, cols.duration.x, cols.start.x, cols.end.x, m.tableWidth]; if (cols.complete) colBorders.push(cols.complete.x); + if (cols.units && cols.curve) colBorders.push(cols.units.x, cols.curve.x); for (const cx of colBorders) { d2d.beginPath(); d2d.moveTo(cx, top); @@ -1825,7 +1976,7 @@ function drawTimelineHeader( function drawTaskTable( d2d: Draw2D, m: ReportMetrics, - printRows: { kind: 'task' | 'group'; task?: Task; depth: number; label?: string; count?: number }[], + printRows: PrintRow[], canvasHeight: number, cols: ColPositions, options: PrintOptions, @@ -1898,6 +2049,17 @@ function drawTaskTable( const nameAvail = cols.name.x + cols.name.w - m.s(NAME_RIGHT_PAD) - nameX; d2d.fillText(fitText(d2d, task.name, nameAvail), nameX, textY); + // Toewijzingskolommen (resourcediagram): eenheden rechts uitgelijnd, de curve links en afgekort. + if (cols.units && cols.curve && row.assignment) { + d2d.fillStyle = PRINT_COLORS.textSecondary; + d2d.font = m.font(8); + d2d.textAlign = 'right'; + d2d.fillText(formatUnitsPerDay(row.assignment.unitsPerDay), cols.units.x + cols.units.w - cellPad, textY); + d2d.textAlign = 'left'; + const curveText = row.assignment.curve === null ? '—' : (options.curveLabels?.[row.assignment.curve] ?? row.assignment.curve); + d2d.fillText(fitText(d2d, curveText, cols.curve.w - 2 * cellPad), cols.curve.x + cellPad, textY); + } + // Duration d2d.fillStyle = PRINT_COLORS.textSecondary; d2d.font = m.font(8); @@ -1933,6 +2095,7 @@ function drawTaskTable( d2d.lineWidth = 0.5; const colBorders = [cols.name.x, cols.duration.x, cols.start.x, cols.end.x]; if (cols.complete) colBorders.push(cols.complete.x); + if (cols.units && cols.curve) colBorders.push(cols.units.x, cols.curve.x); for (const cx of colBorders) { d2d.beginPath(); d2d.moveTo(cx, m.totalHeaderHeight); diff --git a/src/utils/reportSettings.ts b/src/utils/reportSettings.ts index 3df813e6..51df4e2c 100644 --- a/src/utils/reportSettings.ts +++ b/src/utils/reportSettings.ts @@ -137,15 +137,27 @@ export type TableReportPeriodKey = (typeof TABLE_REPORT_PERIOD_KEYS)[number]; * elke resource begint op een nieuwe pagina, zodat je per ploeg of medewerker één vel kunt * uitdelen; uit = één doorlopend overlegdocument. `includeUnassigned` neemt de taken zonder * resource als laatste band mee — handig om in een overleg te zien wat nog niemand heeft. + * `groupByType` (manuvarkey, punt 2) zet er een laag boven: eerst een band per resourcetype + * (arbeid, ploeg, onderaannemer, materieel, materiaal), daarbinnen per resource. `period` (punt 3) + * is de gedeelde rapportageperiode (issue #120): alleen taken die het venster raken, en de tijdas + * exact op het venster; default `project` = het oude gedrag. `showAssignmentColumns` (punt 1) + * zet achter de taaknaam twee kolommen met eenheden per dag en verdeelcurve van de resource van + * de band — standaard aan: dat is de informatie waarvoor je dit rapport uitdeelt. */ export interface ResourceGanttReportOptions { pageBreakPerResource: boolean; includeUnassigned: boolean; + groupByType: boolean; + period: ReportingPeriod; + showAssignmentColumns: boolean; } export const DEFAULT_RESOURCE_GANTT_OPTIONS: ResourceGanttReportOptions = { pageBreakPerResource: false, includeUnassigned: false, + groupByType: false, + period: { preset: 'project' }, + showAssignmentColumns: true, }; /** Grenzen van de numerieke opties (de UI en de loader delen ze). */ @@ -330,6 +342,9 @@ function parseResourceGanttOptions(raw: unknown): ResourceGanttReportOptions { return { pageBreakPerResource: parseBoolean(s.pageBreakPerResource) ?? d.pageBreakPerResource, includeUnassigned: parseBoolean(s.includeUnassigned) ?? d.includeUnassigned, + groupByType: parseBoolean(s.groupByType) ?? d.groupByType, + period: parseReportingPeriod(s.period, d.period), + showAssignmentColumns: parseBoolean(s.showAssignmentColumns) ?? d.showAssignmentColumns, }; } diff --git a/tests/browser/just-updated-dialog.spec.ts b/tests/browser/just-updated-dialog.spec.ts index c0724734..d1d22c85 100644 --- a/tests/browser/just-updated-dialog.spec.ts +++ b/tests/browser/just-updated-dialog.spec.ts @@ -7,6 +7,18 @@ declare global { } } +/** + * De dialoog bevraagt de GitHub Releases-API voor de releasevergelijking (grootteverschil, tijd + * tussen releases). Onbevoegd is die API op een gedeelde CI-runner rate-limited (403) en achter + * een sandboxproxy niet vertrouwd (ERR_CERT_AUTHORITY_INVALID); beide geven een console.error die + * de harness als fout telt, terwijl deze tests niets uit die vergelijking asserteren. Antwoord + * daarom deterministisch met een lege lijst — de pure functies erachter zijn headless getest in + * tests/planning/check-just-updated.ts. + */ +async function stubReleasesApi(page: Page): Promise { + await page.route('https://api.github.com/repos/**', route => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })); +} + async function selectLocale(page: Page, option: string, expectedLocale: string): Promise { await page.evaluate(() => window.__OPS__!.store.getState().setUI({ showSettingsDialog: true })); const settings = page.locator('.settings-dialog'); @@ -21,6 +33,7 @@ async function selectLocale(page: Page, option: string, expectedLocale: string): } test('update-highlights volgen de app-ready route en houden externe link open', async ({ page, ops: _ops }) => { + await stubReleasesApi(page); await page.evaluate(() => { window.openedReleaseUrls = []; window.open = ((url?: string | URL) => { @@ -50,6 +63,7 @@ test('update-highlights volgen de app-ready route en houden externe link open', }); test('update-highlights werken smal, licht/donker en RTL', async ({ page, ops: _ops }) => { + await stubReleasesApi(page); await page.setViewportSize({ width: 390, height: 420 }); await selectLocale(page, 'NL — Nederlands', 'nl'); await page.evaluate(() => window.__OPS__!.store.getState().setUI({ uiTheme: 'light', justUpdated: { from: null, to: '2026.8.1' } })); diff --git a/tests/browser/resource-gantt-report.spec.ts b/tests/browser/resource-gantt-report.spec.ts index 0a97a555..2e691411 100644 --- a/tests/browser/resource-gantt-report.spec.ts +++ b/tests/browser/resource-gantt-report.spec.ts @@ -57,6 +57,41 @@ test('resourcediagram: rapporttype rendert per resource, opties sturen samenvatt await expect(pages).toHaveCount(3); await expect(count('unassigned')).toHaveText('1'); + // Typelaag (manuvarkey punt 2): Ploeg A (ploeg) en Kraan (materieel) krijgen elk een typekop, en die + // blijft bij zijn eerste resource op hetzelfde vel — nog steeds drie pagina's, geen leeg vel per type. + await page.locator('[data-ops-report-option="groupByType"]').check(); + await expect(pages).toHaveCount(3); + + // Toewijzingskolommen (manuvarkey punt 1): standaard aan; uitzetten maakt de tabel smaller en + // rastert de preview dus opnieuw. + const colsToggle = page.locator('[data-ops-report-option="showAssignmentColumns"]'); + await expect(colsToggle).toBeChecked(); + const firstImg = pages.first().locator('img'); + const beforeCols = await firstImg.getAttribute('src'); + await colsToggle.uncheck(); + await expect.poll(() => firstImg.getAttribute('src'), { timeout: 20_000 }).not.toBe(beforeCols); + await colsToggle.check(); + + // Rapportageperiode (manuvarkey punt 3): een eigen bereik dat alleen Fundering raakt ⇒ alleen Ploeg A + // blijft over; Casco (met Kraan) en Gevel vallen buiten de periode en de telling zegt dat. Terug naar + // Hele project herstelt alles — de export hieronder telt dan weer drie pagina's. + const periodField = page.locator('[data-ops-report-period="resourceGanttPeriod"]'); + await periodField.getByLabel(/^(Reporting period:|Rapportageperiode:)$/).click(); + await page.getByRole('option', { name: /^(Custom|Aangepast)$/ }).click(); + const fromInput = page.locator('[data-ops-report-option="resourceGanttPeriod.from"]'); + const toInput = page.locator('[data-ops-report-option="resourceGanttPeriod.to"]'); + await toInput.fill('2026-09-15'); + await fromInput.fill('2026-09-01'); + await expect(count('resources')).toHaveText('1'); + await expect(count('assignments')).toHaveText('1'); + await expect(count('unassigned')).toHaveText('0'); + await expect(count('outsidePeriod')).toHaveText('2'); + await expect(pages).toHaveCount(1); + await periodField.getByLabel(/^(Reporting period:|Rapportageperiode:)$/).click(); + await page.getByRole('option', { name: /^(Project duration|Hele project)$/ }).click(); + await expect(count('outsidePeriod')).toHaveCount(0); + await expect(pages).toHaveCount(3); + // De échte export (vector-tak, zie paginateVector) moet dezelfde drie pagina's opleveren als de // preview: dat is het pad dat de gebruiker in handen krijgt, en de enige plek waar de gedwongen // breekposities de PDF in gaan. diff --git a/tests/planning/check-print-report.ts b/tests/planning/check-print-report.ts index 70334fba..8f1de737 100644 --- a/tests/planning/check-print-report.ts +++ b/tests/planning/check-print-report.ts @@ -47,6 +47,9 @@ function record(tasks: Task[], sequences: Sequence[], calendar: WorkCalendar, op const rects: RectEv[] = []; const paths: PathEv[] = []; const roundRects: RoundRectEv[] = []; + // Gevulde paden (mijlpaal- en baselineruiten, samenvattingshaakjes) — `fill()` legde ze eerder + // stil weg, waardoor een ruit die over de tabel steekt onzichtbaar bleef voor de tests. + const fills: PathEv[] = []; let seq = 0; let curPath: { x: number; y: number }[] | null = null; const st = { font: '10px x', fillStyle: '', strokeStyle: '', lineWidth: 0, textAlign: 'left' as TextAlign, textBaseline: 'alphabetic' as TextBaseline, dash: [] as number[] }; @@ -62,14 +65,14 @@ function record(tasks: Task[], sequences: Sequence[], calendar: WorkCalendar, op fillRect(x, y, w, h) { rects.push({ x, y, w, h, color: st.fillStyle, seq: seq++ }); }, strokeRect() {}, beginPath() { curPath = []; }, moveTo(x, y) { if (!curPath) curPath = []; curPath.push({ x, y }); }, lineTo(x, y) { if (!curPath) curPath = []; curPath.push({ x, y }); }, - closePath() {}, fill() { curPath = null; }, + closePath() {}, fill() { if (curPath) fills.push({ pts: curPath, color: st.fillStyle, dash: [], seq: seq++ }); curPath = null; }, stroke() { if (curPath) paths.push({ pts: curPath, color: st.strokeStyle, dash: [...st.dash], seq: seq++ }); curPath = null; }, roundRect(x, y, w, h) { roundRects.push({ x, y, w, h, color: st.fillStyle, strokeColor: st.strokeStyle, mode: 'fill', seq: seq++ }); }, fillText(text, x, y) { texts.push({ text, x, y, color: st.fillStyle, font: st.font, seq: seq++ }); }, measureText(t) { return measure(t); }, }; const dims = renderReport(() => d2d, tasks, sequences, calendar, 'P', options); - return { texts, rects, paths, roundRects, dims }; + return { texts, rects, paths, roundRects, fills, dims }; } // ── Fixtures ─────────────────────────────────────────────────────────────────────────────────── @@ -486,6 +489,104 @@ const baseOptions = (over: Partial = {}): PrintOptions => ({ // Eén band ⇒ niets te breken: geen gedwongen posities, dus ook geen lege eerste pagina. const single = measurePrintReport(bandTasks.slice(0, 2), [], cal, 'Eén resource', baseOptions({ rows: bands.slice(0, 3), pageBreakBeforeGroups: true })); ok(single.forcedBreakOffsets === undefined, 'één band ⇒ geen gedwongen posities'); + // Typelaag (manuvarkey punt 2): een band direct ónder een band krijgt géén eigen gedwongen + // positie — de typekop blijft bij zijn eerste resource; de tweede resource onder hetzelfde type + // en de volgende typekop breken wél. + const typed: ViewRow[] = [ + { kind: 'group', rowKey: 'type0', key: 'type0', label: 'Ploeg', count: 4, depth: 0, levelIndex: 0, collapsed: false }, + ...bands.slice(0, 6).map(r => ({ ...r, depth: r.depth + 1 })), + { kind: 'group', rowKey: 'type1', key: 'type1', label: 'Materieel', count: 2, depth: 0, levelIndex: 0, collapsed: false }, + ...bands.slice(6).map(r => ({ ...r, depth: r.depth + 1 })), + ]; + const typedForced = measurePrintReport(bandTasks, [], cal, 'Resourcediagram', baseOptions({ rows: typed, pageBreakBeforeGroups: true })); + ok(JSON.stringify(typedForced.forcedBreakOffsets) === JSON.stringify([typedForced.headerHeight + 4 * rowH, typedForced.headerHeight + 7 * rowH]), + `typelaag: gedwongen posities vóór resource 2 (rij 4) en typeband 2 (rij 7), niet vóór een band direct onder een typekop (got ${JSON.stringify(typedForced.forcedBreakOffsets)})`); + + // Tijdvenster (manuvarkey punt 3): de tijdas loopt exact over het venster (einde inclusief, geen + // marge van 7/14 dagen), balken worden op de chartrand geklemd, en een taak buiten het venster + // tekent geen balk en geen balklabel (zijn tabelrij blijft: welke rijen meedoen beslist de + // rijenbron). Zonder venster byte-identiek. Venster 10–14 jan bij 60 px/dag: de kritieke taak + // (12–16 jan) begint 120 px in de chart — ruimte voor het label links van de balk — en wordt + // rechts afgekapt; de zichtbare (5–9 jan) en de gefilterde (19–23 jan) taak vallen erbuiten. + { + const win = { from: '2026-01-10', to: '2026-01-14' }; + const fixed = baseOptions({ autoFit: false, customZoom: 60, showFloat: false }); + const rec = record(FIX_TASKS, [], cal, { ...fixed, timeWindow: win }); + const d = rec.dims; + ok(Math.abs(d.width - (d.tableWidth + 5 * 60)) < 1e-6, `venster van 5 dagen ⇒ chart 5 × zoom breed (got ${d.width - d.tableWidth})`); + const inBody = (y: number) => y >= d.headerHeight && y < d.height - d.footerHeight; + const bars = rec.roundRects.filter(r => (r.color === CRITICAL || r.color === NORMAL) && inBody(r.y)); + ok(bars.length === 1 && bars[0].color === CRITICAL, `alleen de kritieke taak tekent een balk (got ${bars.length})`); + ok(bars.every(b => b.x >= d.tableWidth - 1e-6 && b.x + b.w <= d.width + 1e-6), 'de balk ligt binnen het chartgebied'); + ok(Math.abs(bars[0].x - (d.tableWidth + 2 * 60)) < 1e-6 && Math.abs(bars[0].x + bars[0].w - d.width) < 1e-6, + 'de balk begint op 12 jan en eindigt op de rechter chartrand, niet erbuiten'); + const chartTexts = rec.texts.filter(t => t.x >= d.tableWidth && inBody(t.y)).map(t => t.text); + ok(chartTexts.some(t => t.startsWith('Kritieke')), `balklabel van de kritieke taak in de chart (got ${JSON.stringify(chartTexts)})`); + ok(!chartTexts.some(t => t.startsWith('Gefilterde') || t.startsWith('Zichtbare')), 'taken buiten het venster: geen balklabel'); + ok(rec.texts.filter(t => t.text === T_HIDDEN.name && t.x < d.tableWidth).length === 1, 'taak buiten het venster houdt zijn tabelrij'); + ok(JSON.stringify(record(FIX_TASKS, [], cal, fixed)) === JSON.stringify(record(FIX_TASKS, [], cal, { ...fixed, timeWindow: undefined })), + 'zonder venster byte-identiek'); + + // Review-bevinding 2: een mijlpaal op de eerste vensterdag bij lage zoom (4 px/dag, ruit ± 6 px) + // hangt niet half over de tabel — het middelpunt wordt naar binnen geklemd. Niets in het + // chartgebied (gevulde paden, balken, lijnen) ligt links van de tabelrand of rechts van de chart. + const ms = mkTask('t-ms', 'Mijlpaal', { isMilestone: true, time: mkTime({ earlyStart: '2026-01-10', earlyFinish: '2026-01-10', scheduleStart: '2026-01-10', scheduleFinish: '2026-01-10' }) }); + const low = record([ms, T_CRIT], [], cal, { ...fixed, customZoom: 4, timeWindow: win }); + const ld = low.dims; + const inBodyPt = (y: number) => y >= ld.headerHeight && y < ld.height - ld.footerHeight; + // `pts.length > 0`: een lege `beginPath(); roundRect(); fill()` (voortgangsoverlay) mag de + // assertie niet vacuüm vervullen (review ronde 2, bevinding 9). + const bodyFills = low.fills.filter(f => f.pts.length > 0 && f.pts.every(p => inBodyPt(p.y))); + ok(bodyFills.length >= 1, `de ruit wordt getekend (got ${bodyFills.length} gevulde paden)`); + ok(bodyFills.every(f => f.pts.every(p => p.x >= ld.tableWidth - 1e-6 && p.x <= ld.width + 1e-6)), + `ruit binnen het chartgebied (got ${JSON.stringify(bodyFills.map(f => f.pts.map(p => Math.round(p.x * 100) / 100)))}, tabel ${ld.tableWidth}, chart tot ${ld.width})`); + const lowBars = low.roundRects.filter(r => (r.color === CRITICAL || r.color === NORMAL) && inBodyPt(r.y)); + ok(lowBars.every(b => b.x >= ld.tableWidth - 1e-6 && b.x + b.w <= ld.width + 1e-6), 'ook bij 4 px/dag blijft de balk binnen de chart'); + // Review-bevinding 7: de 3 px-minimumbreedte steekt niet meer over de rechter chartrand. + const tiny = record([T_CRIT], [], cal, { ...fixed, customZoom: 1, timeWindow: { from: '2026-01-10', to: '2026-01-12' } }); + const tinyBar = tiny.roundRects.find(r => r.color === CRITICAL && r.y >= tiny.dims.headerHeight && r.y < tiny.dims.height - tiny.dims.footerHeight); + ok(!!tinyBar && tinyBar.x + tinyBar.w <= tiny.dims.width + 1e-6 && tinyBar.w >= 3 - 1e-6, `minimumbreedte naar binnen geschoven en op de chartrand geklemd (got ${JSON.stringify(tinyBar)}, chart tot ${tiny.dims.width})`); + // Lege staat: een lange instructie wordt op woordgrenzen gewrapt, nooit halverwege afgekapt. + // De testmeter rekent 6 px per teken; deze tekst is 143 tekens = 858 px, dus minstens twee regels. + const lang = 'Keine Vorgänge im Berichtszeitraum — wählen Sie einen anderen Zeitraum oder Gesamtes Projekt, um alle Vorgänge des Projekts wieder zu sehen.'; + const leeg = record([], [], cal, baseOptions({ labels: { ...baseOptions().labels!, noTasks: lang } })); + const leegTexts = leeg.texts.map(t => t.text); + ok(leegTexts.length >= 2 && leegTexts.join(' ') === lang && leegTexts.every(t => !t.includes('…')), + `lege staat gewrapt zonder afkappen (got ${JSON.stringify(leegTexts)})`); + } + + // Toewijzingskolommen (manuvarkey punt 1): twee kolommen direct achter de naam (x 180–225 en + // 225–300 bij de standaardnaamkolom van 130), gevuld per rijsleutel; de tabel wordt precies de + // twee kolombreedtes breder; zonder de optie (ook mét `rowAssignments`) byte-identiek. + { + const aBands: ViewRow[] = [ + { kind: 'group', rowKey: 'b', key: 'b', label: 'Metselaar', count: 2, depth: 0, levelIndex: 0, collapsed: false }, + { kind: 'task', rowKey: 'b/norm', task: T_NORM, depth: 1, dimmed: false }, + { kind: 'task', rowKey: 'b/crit', task: T_CRIT, depth: 1, dimmed: false }, + ]; + const rowAssignments = new Map([ + ['b/norm', { unitsPerDay: 1.5, curve: 'FRONT_LOADED' as const }], + ['b/crit', { unitsPerDay: 2, curve: null }], + ]); + const plainA = record([T_NORM, T_CRIT], [], cal, baseOptions({ rows: aBands })); + const withCols = record([T_NORM, T_CRIT], [], cal, baseOptions({ + rows: aBands, assignmentColumns: true, rowAssignments, curveLabels: { FRONT_LOADED: 'Vooraan belast' }, + })); + ok(withCols.dims.tableWidth === plainA.dims.tableWidth + 45 + 98, `tabel precies twee kolommen breder (got +${withCols.dims.tableWidth - plainA.dims.tableWidth})`); + const inCol = (t: { x: number; y: number }, x0: number, x1: number) => t.x >= x0 && t.x <= x1 && t.y > withCols.dims.headerHeight && t.y < withCols.dims.height - withCols.dims.footerHeight; + const unitsTexts = withCols.texts.filter(t => inCol(t, 180, 225)).map(t => t.text).sort(); + const curveTexts = withCols.texts.filter(t => inCol(t, 225, 300)).map(t => t.text).sort(); + ok(JSON.stringify(unitsTexts) === JSON.stringify(['1.5', '2']), `eenheden per rij in de kolom (got ${JSON.stringify(unitsTexts)})`); + // De testmeter rekent 6 px per teken, dus "Vooraan belast" (84 px) kapt in de 75 px-kolom af op + // een beletselteken — dat is het bedoelde `fitText`-gedrag; met het echte 8 px-font past het. + ok(curveTexts.length === 2 && curveTexts.some(t => t.startsWith('Vooraan be')) && curveTexts.includes('—'), + `vertaalde curve (desnoods afgekort), streepje bij verschillende curves (got ${JSON.stringify(curveTexts)})`); + const heads = withCols.texts.filter(t => t.y <= withCols.dims.headerHeight).map(t => t.text); + ok(heads.includes('Eenh./d') && heads.includes('Curve'), 'kolomkoppen aanwezig (Nederlandse terugval zonder label)'); + ok(!plainA.texts.some(t => t.text === 'Eenh./d' || t.text === 'Vooraan belast'), 'zonder optie geen kolommen'); + ok(JSON.stringify(record([T_NORM, T_CRIT], [], cal, baseOptions({ rows: aBands, rowAssignments }))) === JSON.stringify(plainA), + 'rowAssignments zonder assignmentColumns ⇒ byte-identiek'); + } // Dezelfde drie banden in de overige pagineermodi (review op #132, bevinding 9): zonder // kopherhaling, in 'actual' (1 pt = 1 px, horizontaal getegeld) en met de tijdlijn over twee // paginabreedtes — steeds drie body-rijen die exact op de bandgrenzen eindigen. diff --git a/tests/planning/check-report-resource-gantt-setting.ts b/tests/planning/check-report-resource-gantt-setting.ts index c81f1c03..a3567c0b 100644 --- a/tests/planning/check-report-resource-gantt-setting.ts +++ b/tests/planning/check-report-resource-gantt-setting.ts @@ -14,7 +14,7 @@ const expect = (label: string, got: unknown, want: unknown) => { if (JSON.stringify(got) !== JSON.stringify(want)) failures.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); }; -expect('defaults: blad per resource uit, taken zonder resource uit', DEFAULT_REPORT_SETTINGS.resourceGantt, { pageBreakPerResource: false, includeUnassigned: false }); +expect('defaults: blad per resource uit, taken zonder resource uit, typelaag uit', DEFAULT_REPORT_SETTINGS.resourceGantt, { pageBreakPerResource: false, includeUnassigned: false, groupByType: false, period: { preset: 'project' }, showAssignmentColumns: true }); expect('defaults: één bron', DEFAULT_REPORT_SETTINGS.resourceGantt, DEFAULT_RESOURCE_GANTT_OPTIONS); expect('isGanttReportType: gantt én resourceGantt', [isGanttReportType('gantt'), isGanttReportType('resourceGantt'), isGanttReportType('milestones'), isGanttReportType('resourceAssignments')], [true, true, false, false]); // Review N6: één predicaat voor "dit type tekent geen relaties" — de forcering van showDeps én het @@ -25,10 +25,10 @@ expect('showDeps staat standaard aan (de forcering doet dus echt iets)', DEFAULT // barColorSelection), dus zonder lijnen is het misleidend — verborgen op hetzelfde predicaat als de relaties. expect('reportTypeShowsCriticalToggle: verborgen waar geen relaties getekend worden', [reportTypeShowsCriticalToggle('gantt'), reportTypeShowsCriticalToggle('resourceGantt'), reportTypeShowsCriticalToggle('milestones')], [true, false, true]); -localStorage.setItem('ops-reportSettings', JSON.stringify({ reportType: 'resourceGantt', resourceGantt: { pageBreakPerResource: true, includeUnassigned: true } })); +localStorage.setItem('ops-reportSettings', JSON.stringify({ reportType: 'resourceGantt', resourceGantt: { pageBreakPerResource: true, includeUnassigned: true, groupByType: true, period: { preset: 'next4Weeks' }, showAssignmentColumns: false } })); const loaded = await loadReportSettings(); expect('opgeslagen rapporttype resourceGantt wordt geladen', loaded.reportType, 'resourceGantt'); -expect('opgeslagen opties worden geladen', loaded.resourceGantt, { pageBreakPerResource: true, includeUnassigned: true }); +expect('opgeslagen opties worden geladen', loaded.resourceGantt, { pageBreakPerResource: true, includeUnassigned: true, groupByType: true, period: { preset: 'next4Weeks' }, showAssignmentColumns: false }); localStorage.setItem('ops-reportSettings', JSON.stringify({ reportType: 'gantt', showLegend: false })); const zonderBlok = await loadReportSettings(); @@ -37,16 +37,21 @@ expect('… en de overige voorkeuren blijven staan', zonderBlok.showLegend, fals localStorage.setItem('ops-reportSettings', JSON.stringify({ resourceGantt: { pageBreakPerResource: 'ja', includeUnassigned: true } })); const rommel = await loadReportSettings(); -expect('rommel in één veld ⇒ alleen dat veld terug op de default', rommel.resourceGantt, { pageBreakPerResource: false, includeUnassigned: true }); +expect('rommel in één veld ⇒ alleen dat veld terug op de default', rommel.resourceGantt, { pageBreakPerResource: false, includeUnassigned: true, groupByType: false, period: { preset: 'project' }, showAssignmentColumns: true }); +// Punt 3: de rapportageperiode gaat door dezelfde tolerante parser als de tabelrapporten. +localStorage.setItem('ops-reportSettings', JSON.stringify({ resourceGantt: { period: { preset: 'nonsense' } } })); +expect('onbekende periode-preset ⇒ Hele project', (await loadReportSettings()).resourceGantt.period, { preset: 'project' }); +localStorage.setItem('ops-reportSettings', JSON.stringify({ resourceGantt: { period: { preset: 'custom', from: '2026-09-30', to: '2026-09-01' } } })); +expect('omgekeerd eigen bereik ⇒ Hele project', (await loadReportSettings()).resourceGantt.period, { preset: 'project' }); localStorage.setItem('ops-reportSettings', JSON.stringify({ resourceGantt: 'aan' })); expect('rommel in het hele blok ⇒ defaults', (await loadReportSettings()).resourceGantt, DEFAULT_RESOURCE_GANTT_OPTIONS); localStorage.setItem('ops-reportSettings', JSON.stringify({ reportType: 'resourceDiagram' })); expect('onbekend rapporttype ⇒ default rapporttype', (await loadReportSettings()).reportType, DEFAULT_REPORT_SETTINGS.reportType); -await saveReportSettings({ ...DEFAULT_REPORT_SETTINGS, reportType: 'resourceGantt', resourceGantt: { pageBreakPerResource: true, includeUnassigned: false } }); +await saveReportSettings({ ...DEFAULT_REPORT_SETTINGS, reportType: 'resourceGantt', resourceGantt: { pageBreakPerResource: true, includeUnassigned: false, groupByType: true, period: { preset: 'custom', from: '2026-09-01', to: '2026-09-30' }, showAssignmentColumns: false } }); const saved = await loadReportSettings(); expect('rapporttype overleeft een round-trip', saved.reportType, 'resourceGantt'); -expect('opties overleven een round-trip', saved.resourceGantt, { pageBreakPerResource: true, includeUnassigned: false }); +expect('opties overleven een round-trip', saved.resourceGantt, { pageBreakPerResource: true, includeUnassigned: false, groupByType: true, period: { preset: 'custom', from: '2026-09-01', to: '2026-09-30' }, showAssignmentColumns: false }); if (failures.length > 0) { console.log(`XX report-resource-gantt-setting: ${failures.length} afwijking(en)`); diff --git a/tests/planning/check-reports.ts b/tests/planning/check-reports.ts index 04c1a5fd..6f2089fd 100644 --- a/tests/planning/check-reports.ts +++ b/tests/planning/check-reports.ts @@ -13,14 +13,14 @@ import { type ReportContext, type ReportingPeriod, computeLookAhead, computeCriticalReport, computeProgressReport, computeScheduleHealth, computeResourceLoading, computeResourceAssignments, computeResourceGanttRows, computeWbsSummary, progressState, - remainingDays, resourceBandLabels, taskDepths, + remainingDays, resourceBandLabels, taskDepths, taskFinish, taskStart, isValidReportingPeriod, periodDays, projectSpan, resolveReportingPeriod, weeksToPreset, } from '@/engine/reports'; import { addCalendarMonths, formatDate, parseDate } from '@/utils/dateUtils'; import { makeMonthLabeler } from '@/utils/monthLabel'; import { DEFAULT_TABLE_REPORT_OPTIONS, parseReportingPeriod, parseTableReportOptions } from '@/utils/reportSettings'; import type { Task } from '@/types/task'; -import type { Resource, ResourceAssignment } from '@/types/resource'; +import type { Resource, ResourceAssignment, ResourceCurve } from '@/types/resource'; const S = () => useAppStore.getState(); const diffs: string[] = []; @@ -491,7 +491,7 @@ eq('scenario: B rest = 6 wd (10 × 60%)', remainingDays(ctx, byId(B)), 6); const r = computeResourceGanttRows(base, opts); eq('resourceGantt: één band per resource, op naam', r.rows.filter(x => x.kind === 'group').map(label), ['Kraan', 'Ploeg 1']); // Ploeg 1 → A, B, D; Kraan → B; zonder resource: C, E (mijlpaal is óók een bladtaak), F, G. - eq('resourceGantt: tellingen', r.counts, { resources: 2, assignments: 4, unassignedTasks: 4 }); + eq('resourceGantt: tellingen', r.counts, { resources: 2, assignments: 4, unassignedTasks: 4, outsidePeriod: 0, inPeriod: 7 }); eq('resourceGantt: Ploeg 1 heeft A, B en D', [...bandTasks(r.rows, 'Ploeg 1')].sort(), [A, B, D].sort()); eq('resourceGantt: Kraan heeft alleen B', bandTasks(r.rows, 'Kraan'), [B]); ok('resourceGantt: B staat onder beide banden', r.rows.filter(x => x.kind === 'task' && x.task.id === B).length === 2); @@ -516,7 +516,7 @@ eq('scenario: B rest = 6 wd (10 × 60%)', remainingDays(ctx, byId(B)), 6); assignments: [asg('a1', A, jan1.id), asg('a2', A, jan2.id), asg('a3', B, jan2.id)], }, opts); eq('resourceGantt: gelijknamige resources ⇒ twee banden met volgnummer', twins.rows.filter(x => x.kind === 'group').map(label), ['Jan #1', 'Jan #2']); - eq('resourceGantt: gelijknamig — tellingen op identiteit', twins.counts, { resources: 2, assignments: 3, unassignedTasks: 5 }); + eq('resourceGantt: gelijknamig — tellingen op identiteit', twins.counts, { resources: 2, assignments: 3, unassignedTasks: 5, outsidePeriod: 0, inPeriod: 7 }); eq('resourceGantt: gelijknamig — Jan #1 heeft A', bandTasks(twins.rows, 'Jan #1'), [A]); eq('resourceGantt: gelijknamig — Jan #2 heeft A en B', [...bandTasks(twins.rows, 'Jan #2')].sort(), [A, B].sort()); ok('resourceGantt: gelijknamig — rijsleutels uniek', new Set(twins.rows.map(x => x.rowKey)).size === twins.rows.length); @@ -527,7 +527,106 @@ eq('scenario: B rest = 6 wd (10 × 60%)', remainingDays(ctx, byId(B)), 6); assignments: [asg('a1', A, 'res-leeg')], }, opts); eq('resourceGantt: naamloze resource ⇒ band "#2" (positie in de projectlijst)', naamloos.rows.filter(x => x.kind === 'group').map(label), ['#2']); - eq('resourceGantt: naamloze resource — A telt als toegewezen', naamloos.counts, { resources: 1, assignments: 1, unassignedTasks: 6 }); + eq('resourceGantt: naamloze resource — A telt als toegewezen', naamloos.counts, { resources: 1, assignments: 1, unassignedTasks: 6, outsidePeriod: 0, inPeriod: 7 }); + + // manuvarkey op #113, punt 2: twee lagen — typeband (vaste volgorde: mensen, dan materieel, dan + // materiaal), daarin de resourcebanden op diepte 1, de taken op diepte 2; "(geen)" blijft achteraan + // op diepte 0 (taken zonder resource hebben geen type). Zonder de optie byte-identiek. + const typeLabels = { LABOR: 'Arbeid', CREW: 'Ploeg', SUBCONTRACTOR: 'Onderaannemer', EQUIPMENT: 'Materieel', MATERIAL: 'Materiaal' }; + const typed = computeResourceGanttRows(base, { ...opts, groupByType: true, typeLabels }); + const typedGroups = typed.rows.filter(x => x.kind === 'group'); + eq('resourceGantt/type: typebanden vóór hun resources, mensen eerst', typedGroups.map(x => `${x.depth}:${label(x)}`), ['0:Ploeg', '1:Ploeg 1', '0:Materieel', '1:Kraan']); + ok('resourceGantt/type: taakrijen op diepte 2', typed.rows.every(x => x.kind !== 'task' || x.depth === 2)); + ok('resourceGantt/type: rijsleutels uniek', new Set(typed.rows.map(x => x.rowKey)).size === typed.rows.length); + eq('resourceGantt/type: typeband telt de taakrijen eronder', typedGroups.filter(x => x.depth === 0).map(x => x.count), [3, 1]); + eq('resourceGantt/type: tellingen ongewijzigd', typed.counts, r.counts); + eq('resourceGantt/type: dezelfde taken onder Ploeg 1', [...bandTasks(typed.rows, 'Ploeg 1')].sort(), [A, B, D].sort()); + const typedNone = computeResourceGanttRows(base, { ...opts, groupByType: true, typeLabels, includeUnassigned: true }); + const typedNoneGroups = typedNone.rows.filter(x => x.kind === 'group'); + const lastGroup = typedNoneGroups[typedNoneGroups.length - 1]; + ok('resourceGantt/type: "(geen)" blijft als laatste band op diepte 0', label(lastGroup) === '(geen)' && lastGroup.depth === 0); + ok('resourceGantt/type: zonder de optie byte-identiek', JSON.stringify(computeResourceGanttRows(base, { ...opts, typeLabels }).rows) === JSON.stringify(r.rows)); + eq('resourceGantt/type: ontbrekend label ⇒ enum-naam', computeResourceGanttRows(base, { ...opts, groupByType: true }).rows.filter(x => x.kind === 'group' && x.depth === 0).map(label), ['CREW', 'EQUIPMENT']); + // Gelijknamigen binnen één type houden hun volgnummer; de nummering loopt over de hele lijst. + const typedTwins = computeResourceGanttRows({ + tasks: ctx.tasks, resources: [jan1, { ...jan2, type: 'LABOR' }], + assignments: [asg('a1', A, jan1.id), asg('a2', B, jan2.id)], + }, { ...opts, groupByType: true, typeLabels }); + eq('resourceGantt/type: gelijknamigen over twee typen houden hun volgnummer', typedTwins.rows.filter(x => x.kind === 'group').map(x => `${x.depth}:${label(x)}`), ['0:Arbeid', '1:Jan #2', '0:Materieel', '1:Jan #1']); + + // manuvarkey op #113, punt 3: tijdvenster — alleen bladtaken die het venster raken (start ≤ tot én + // einde ≥ van, op dagniveau); de tellingen volgen de gefilterde set en `outsidePeriod` telt wat er + // is weggelaten. Zonder venster (Hele project) is outsidePeriod 0 en verandert er niets. + const dayOfTask = (id: string) => ({ s: taskStart(byId(id)).slice(0, 10), f: taskFinish(byId(id)).slice(0, 10) }); + const dayB = dayOfTask(B).s; + const overlapsB = (id: string) => { const { s, f } = dayOfTask(id); return s !== '' && f !== '' && s <= dayB && f >= dayB; }; + const leavesAll = ctx.tasks.filter(t => t.childIds.length === 0); + const windowed = computeResourceGanttRows(base, { ...opts, includeUnassigned: true, window: { from: dayB, to: dayB } }); + ok('resourceGantt/venster: B (start op de vensterdag) staat onder Ploeg 1', bandTasks(windowed.rows, 'Ploeg 1').includes(B)); + ok('resourceGantt/venster: A (klaar vóór het venster) staat er niet', !windowed.rows.some(x => x.kind === 'task' && x.task.id === A)); + ok('resourceGantt/venster: elke getekende taak raakt het venster', windowed.rows.every(x => x.kind !== 'task' || overlapsB(x.task.id))); + ok('resourceGantt/venster: elke bladtaak die het venster raakt staat er ook', leavesAll.filter(t => overlapsB(t.id)).every(t => windowed.rows.some(x => x.kind === 'task' && x.task.id === t.id))); + eq('resourceGantt/venster: outsidePeriod telt de weggelaten bladtaken', windowed.counts.outsidePeriod, leavesAll.filter(t => !overlapsB(t.id)).length); + eq('resourceGantt/venster: inPeriod + outsidePeriod = alle bladtaken', windowed.counts.inPeriod + windowed.counts.outsidePeriod, leavesAll.length); + ok('resourceGantt/venster: tellingen volgen de gefilterde set', windowed.counts.assignments < r.counts.assignments && windowed.counts.unassignedTasks < r.counts.unassignedTasks); + const buiten = computeResourceGanttRows(base, { ...opts, window: { from: '2030-01-01', to: '2030-01-31' } }); + eq('resourceGantt/venster: venster zonder taken ⇒ geen rijen, alles buiten de periode', [buiten.rows.length, buiten.counts.outsidePeriod, buiten.counts.resources], [0, leavesAll.length, 0]); + const ruim = computeResourceGanttRows(base, { ...opts, window: { from: '2020-01-01', to: '2035-12-31' } }); + ok('resourceGantt/venster: een venster dat alles omvat ⇒ dezelfde rijen als zonder venster', JSON.stringify(ruim.rows) === JSON.stringify(r.rows) && ruim.counts.outsidePeriod === 0); + + // manuvarkey op #113, punt 1: per taakrij de toewijzing van de band op die taak — eenheden opgeteld + // over records van dezelfde resource, curve alleen als alle records dezelfde hebben (afwezig = + // UNIFORM), anders null; taakrijen onder "(geen)" hebben geen entry. + const asgFull = (id: string, taskId: string, resourceId: string, unitsPerDay: number, curve?: ResourceCurve, extra: Partial = {}): ResourceAssignment => ({ id, taskId, resourceId, unitsPerDay, curve, ...extra }); + const loaded = computeResourceGanttRows({ + tasks: ctx.tasks, resources: [kraan, jan1], + assignments: [ + asgFull('l1', A, kraan.id, 0.5, 'FRONT_LOADED'), asgFull('l2', A, kraan.id, 1, 'FRONT_LOADED'), + asgFull('l3', B, kraan.id, 2), asgFull('l4', B, jan1.id, 1, 'BELL'), asgFull('l5', B, jan1.id, 1, 'BACK_LOADED'), + ], + }, { ...opts, includeUnassigned: true }); + const rowOf = (bandName: string, taskId: string) => { + const start = loaded.rows.findIndex(x => label(x) === bandName); + return loaded.rows.slice(start + 1).find(x => x.kind === 'task' && x.task.id === taskId)!; + }; + eq('resourceGantt/toewijzing: Kraan op A — twee records opgeteld, zelfde curve', loaded.assignmentByRowKey.get(rowOf('Kraan', A).rowKey), { unitsPerDay: 1.5, curve: 'FRONT_LOADED' }); + eq('resourceGantt/toewijzing: Kraan op B — record zonder curve telt als UNIFORM', loaded.assignmentByRowKey.get(rowOf('Kraan', B).rowKey), { unitsPerDay: 2, curve: 'UNIFORM' }); + eq('resourceGantt/toewijzing: Jan op B — verschillende curves ⇒ null', loaded.assignmentByRowKey.get(rowOf('Jan', B).rowKey), { unitsPerDay: 2, curve: null }); + const noneStart = loaded.rows.findIndex(x => label(x) === '(geen)'); + ok('resourceGantt/toewijzing: elke taakrij onder een resourceband heeft een entry', loaded.rows.slice(0, noneStart).every(x => x.kind !== 'task' || loaded.assignmentByRowKey.has(x.rowKey))); + ok('resourceGantt/toewijzing: "(geen)"-rijen hebben geen entry', noneStart > 0 && loaded.rows.slice(noneStart).every(x => x.kind !== 'task' || !loaded.assignmentByRowKey.has(x.rowKey))); + eq('resourceGantt/toewijzing: aantal entries = aantal taakrijen onder resourcebanden', loaded.assignmentByRowKey.size, loaded.rows.slice(0, noneStart).filter(x => x.kind === 'task').length); + // Review-bevinding 1: dezelfde drie lagen als de lastverdeling — een contour op de taak wint, dan + // een geïmporteerde exacte curve zonder OPS-vorm, dan pas `curve`. Nooit "Uniform" bij een + // P6-/MSP-import met een echte curve. + const curveValues = [0, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + const dMetContour = { ...byId(D), timephasedContours: [{ resourceUid: null, resourceId: jan1.id, periods: [] }] }; + const layered = computeResourceGanttRows({ + tasks: ctx.tasks.map(t => (t.id === D ? dMetContour : t)), resources: [kraan, jan1], + assignments: [ + asgFull('c1', A, kraan.id, 1, undefined, { curveValues }), // geïmporteerd, geen OPS-vorm + asgFull('c2', B, kraan.id, 1, 'FRONT_LOADED', { curveValues }), // OPS-vorm aanwezig ⇒ die wint + asgFull('c3', D, jan1.id, 1, 'BELL'), // contour op de taak ⇒ contoured + asgFull('c4', D, kraan.id, 1, undefined, { curveValues }), // geen contour voor Kraan ⇒ imported + ], + }, opts); + const rowOf2 = (bandName: string, taskId: string) => { + const start = layered.rows.findIndex(x => label(x) === bandName); + return layered.rows.slice(start + 1).find(x => x.kind === 'task' && x.task.id === taskId)!; + }; + eq('resourceGantt/curve: curveValues zonder curve ⇒ imported', layered.assignmentByRowKey.get(rowOf2('Kraan', A).rowKey)?.curve, 'imported'); + eq('resourceGantt/curve: curveValues mét OPS-vorm ⇒ de vorm', layered.assignmentByRowKey.get(rowOf2('Kraan', B).rowKey)?.curve, 'FRONT_LOADED'); + eq('resourceGantt/curve: contour op de taak voor deze resource ⇒ contoured', layered.assignmentByRowKey.get(rowOf2('Jan', D).rowKey)?.curve, 'contoured'); + eq('resourceGantt/curve: contour geldt alleen voor de gekoppelde resource', layered.assignmentByRowKey.get(rowOf2('Kraan', D).rowKey)?.curve, 'imported'); + // Ronde 2, bevinding 3: de contourkoppeling loopt op de VOLLEDIGE recordlijst van de taak, ook + // met een record naar een onbekende resource erbij — dan slaat de legacy-terugval (één contour + // zonder resourceId bij precies één record) níét toe, net als in het paneel en de lastverdeling. + const dLegacy = { ...byId(D), timephasedContours: [{ resourceUid: null, periods: [] }] }; + const legacy = computeResourceGanttRows({ + tasks: ctx.tasks.map(t => (t.id === D ? dLegacy : t)), resources: [kraan], + assignments: [asgFull('g1', D, kraan.id, 1), asgFull('g2', D, 'res-onbekend', 1)], + }, opts); + eq('resourceGantt/curve: legacy-contour telt niet bij twee records (ook als er één naar een onbekende resource wijst)', legacy.assignmentByRowKey.get(legacy.rows.find(x => x.kind === 'task' && x.task.id === D)!.rowKey)?.curve, 'UNIFORM'); // Twee toewijzingen van dezelfde resource op één taak zijn één rij; een toewijzing aan een // onbekende resource telt niet (die taak is dan "zonder resource", zoals op het scherm). @@ -537,7 +636,7 @@ eq('scenario: B rest = 6 wd (10 × 60%)', remainingDays(ctx, byId(B)), 6); }, opts); eq('resourceGantt: dubbele toewijzing ⇒ één rij', bandTasks(dubbel.rows, 'Kraan'), [A]); // …maar de telling volgt de records, zoals het tabelrapport Resourcetoewijzingen (review N8). - eq('resourceGantt: onbekende resource ⇒ taak zonder resource; toewijzingen tellen records', dubbel.counts, { resources: 1, assignments: 2, unassignedTasks: 6 }); + eq('resourceGantt: onbekende resource ⇒ taak zonder resource; toewijzingen tellen records', dubbel.counts, { resources: 1, assignments: 2, unassignedTasks: 6, outsidePeriod: 0, inPeriod: 7 }); // Bandvolgorde: taal-/cijferbewust ("Ploeg 2" vóór "Ploeg 10"), hoofdletterongevoelig. const p10 = res('p10', 'Ploeg 10'); const p2 = res('p2', 'ploeg 2'); const aa = res('aa', 'Aannemer'); @@ -568,7 +667,7 @@ eq('scenario: B rest = 6 wd (10 × 60%)', remainingDays(ctx, byId(B)), 6); // Leeg: geen toewijzingen ⇒ geen rijen; geen taken ⇒ ook geen "(geen)"-band en nultellingen. ok('resourceGantt: geen toewijzingen ⇒ leeg', computeResourceGanttRows({ ...base, assignments: [] }, opts).rows.length === 0); const leeg = computeResourceGanttRows({ tasks: [], resources: base.resources, assignments: base.assignments }, { ...opts, includeUnassigned: true }); - eq('resourceGantt: leeg project ⇒ geen rijen, nultellingen', [leeg.rows.length, leeg.counts], [0, { resources: 0, assignments: 0, unassignedTasks: 0 }]); + eq('resourceGantt: leeg project ⇒ geen rijen, nultellingen', [leeg.rows.length, leeg.counts], [0, { resources: 0, assignments: 0, unassignedTasks: 0, outsidePeriod: 0, inPeriod: 0 }]); } // ── Uitslag ──────────────────────────────────────────────────────────────────────────────────────