diff --git a/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-pause-override.spec.ts b/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-pause-override.spec.ts new file mode 100644 index 00000000..5bb8eb5e --- /dev/null +++ b/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-pause-override.spec.ts @@ -0,0 +1,152 @@ +import { test, expect, Page } from '@playwright/test'; +import { LoginPage } from '../../../Page objects/Login.page'; + +/** + * b shard (flag-off, 5-minute grid): P0 round-trip for the admin pause override + * (Approach C). This is the round-trip that was previously MISSING — earlier + * specs only verified the pause picker accepted a value, never that the edited + * pause (and the resulting netto) SURVIVED save + reopen through the new + * Pause{N}OverrideMinutes channel. + * + * Flow: open a last-week cell, set a known start/stop, set the pause to a known + * value, save, re-open, and assert the pause picker AND #nettoHours reflect the + * edited value. The override is non-destructive server-side, so the displayed + * pause must equal the override the admin typed. + */ + +const formatDate = (date: Date): string => { + const day = date.getDate(); + const month = date.getMonth() + 1; + const year = date.getFullYear(); + return `${day}.${month}.${year}`; +}; + +const getMonday = (baseDate: Date): Date => { + const dayOfWeek = baseDate.getDay(); + const diffToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; + const monday = new Date(baseDate); + monday.setDate(baseDate.getDate() + diffToMonday); + return monday; +}; + +const today = new Date(); +const lastWeekBase = new Date(today); +lastWeekBase.setDate(today.getDate() - 7); +const lastWeekMonday = getMonday(lastWeekBase); + +async function waitForSpinner(page: Page) { + if (await page.locator('.overlay-spinner').count() > 0) { + await page.locator('.overlay-spinner').waitFor({ state: 'hidden', timeout: 30000 }); + } +} + +// Position-based clock-face picker driver. The brittle +// `[style="transform: rotateZ(...deg) translateX(-50%);"] > span` selector is +// overlapped and times out; the repo standard (and the passing l1m flag-on +// counterpart) computes the hand angle and clicks `.clock-face` at the +// resulting coordinate. This drives the same way for both the start/stop time +// pickers and the pause picker. Math mirrors +// l1m/dashboard-edit-pause-override.spec.ts. +async function pickFiveMinute(page: Page, testid: string, timeStr: string) { + await page.locator(`[data-testid="${testid}"]`).click(); + const [hourStr, minuteStr] = timeStr.split(':'); + const h = parseInt(hourStr, 10); + const m = parseInt(minuteStr, 10); + const cx = 145, cy = 145; + + const hourFace = page.locator('.clock-face'); + await hourFace.first().waitFor({ state: 'visible', timeout: 5000 }); + const hourAngle = (h % 12) * 30; + const hourR = (h === 0 || h > 12) ? 60 : 100; + const hourRad = hourAngle * Math.PI / 180; + await hourFace.first().click({ + position: { + x: Math.round(cx + hourR * Math.sin(hourRad)), + y: Math.round(cy - hourR * Math.cos(hourRad)) + (Math.abs(Math.cos(hourRad)) < 0.01 ? 1 : 0), + }, + }); + + await page.waitForTimeout(500); + const minuteFace = page.locator('.clock-face'); + await minuteFace.first().waitFor({ state: 'visible', timeout: 5000 }); + const minuteAngle = m * 6; + const minuteR = 100; + const minuteRad = minuteAngle * Math.PI / 180; + await minuteFace.first().click({ + position: { + x: Math.round(cx + minuteR * Math.sin(minuteRad)), + y: Math.round(cy - minuteR * Math.cos(minuteRad)) + (Math.abs(Math.cos(minuteRad)) < 0.01 ? 1 : 0), + }, + }); + await page.waitForTimeout(500); + await page.locator('.timepicker-button span').filter({ hasText: 'Ok' }).click(); + await page.locator('.cdk-overlay-backdrop').waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {}); + await page.waitForTimeout(500); +} + +async function openCell(page: Page, cellId: string) { + await page.locator('mat-nested-tree-node').filter({ hasText: 'Timeregistrering' }).click(); + const indexUpdatePromise = page.waitForResponse( + r => r.url().includes('/api/time-planning-pn/plannings/index') && r.request().method() === 'POST' + ); + await page.locator('mat-tree-node').filter({ hasText: 'Dashboard' }).click(); + await page.locator('#backwards').click(); + await indexUpdatePromise; + await waitForSpinner(page); + await page.locator(cellId).waitFor({ state: 'visible', timeout: 15000 }); + await page.locator(cellId).scrollIntoViewIfNeeded(); + await page.locator(cellId).click(); + await page.locator('#planHours').waitFor({ state: 'visible', timeout: 15000 }); +} + +async function saveAndAwait(page: Page) { + const updatePromise = page.waitForResponse(r => + r.url().includes('/api/time-planning-pn/plannings/') && r.request().method() === 'PUT'); + const reindexPromise = page.waitForResponse(r => + r.url().includes('/api/time-planning-pn/plannings/index') && r.request().method() === 'POST'); + await page.locator('#saveButton').click(); + const updateResponse = await updatePromise; + await reindexPromise; + await waitForSpinner(page); + await page.waitForTimeout(1000); + return updateResponse; +} + +test.describe('Dashboard pause-override round-trip (b, flag-off)', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:4200'); + await new LoginPage(page).login(); + }); + + test('edited pause survives save + reopen via Pause{N}OverrideMinutes', async ({ page }) => { + const cellId = '#cell3_0'; + + await openCell(page, cellId); + + // Known shift-1 actual: 08:00 - 16:00 with a 00:30 pause. + await pickFiveMinute(page, 'start1StartedAt', '08:00'); + await expect(page.locator('[data-testid="start1StartedAt"]')).toHaveValue('08:00'); + await pickFiveMinute(page, 'stop1StoppedAt', '16:00'); + await expect(page.locator('[data-testid="stop1StoppedAt"]')).toHaveValue('16:00'); + await pickFiveMinute(page, 'pause1Id', '00:30'); + await expect(page.locator('[data-testid="pause1Id"]')).toHaveValue('00:30'); + + // Netto before save: 8h span - 0.5h pause = 7.50. + await expect(page.locator('#nettoHours')).toHaveValue('7.50'); + + const res = await saveAndAwait(page); + expect(res.status(), 'PUT must succeed').toBeLessThan(400); + + // Re-open and assert the override-derived pause and netto round-tripped. + await page.locator(cellId).scrollIntoViewIfNeeded(); + await page.locator(cellId).click(); + await expect(page.locator('#planHours')).toBeVisible(); + await expect( + page.locator('[data-testid="pause1Id"]'), + 'pause must round-trip 00:30 via the override channel', + ).toHaveValue('00:30'); + await expect(page.locator('#nettoHours')).toHaveValue('7.50'); + + await page.locator('#cancelButton').click(); + }); +}); diff --git a/eform-client/playwright/e2e/plugins/time-planning-pn/l1m/dashboard-edit-pause-override.spec.ts b/eform-client/playwright/e2e/plugins/time-planning-pn/l1m/dashboard-edit-pause-override.spec.ts new file mode 100644 index 00000000..9d5beb16 --- /dev/null +++ b/eform-client/playwright/e2e/plugins/time-planning-pn/l1m/dashboard-edit-pause-override.spec.ts @@ -0,0 +1,126 @@ +import { test, expect, Page } from '@playwright/test'; +import { LoginPage } from '../../../Page objects/Login.page'; + +/** + * l1m shard (flag-on, UseOneMinuteIntervals=true): P0 round-trip for the admin + * pause override (Approach C) on a one-minute site. + * + * Pre-Approach-C, the flag-on edit "worked" only because the save path + * DESTROYED the worker's recorded pause sub-slots (ApplyExactMinutePause / + * ClearPauseTimestamps). The override replaces that destructive collapse: the + * admin's typed pause total now rides on Pause{N}OverrideMinutes and the worker + * sub-slots are preserved server-side. This test asserts the admin-typed pause + * survives save + reopen exactly (1-minute precision), proving the override — + * not the legacy Pause{N}Id and not a slot rewrite — is the authoritative + * channel. + */ + +async function waitForSpinner(page: Page) { + if (await page.locator('.overlay-spinner').count() > 0) { + await page.locator('.overlay-spinner').waitFor({ state: 'hidden', timeout: 30000 }); + } +} + +// Position-based 1-minute clock-face picker (mirrors l1m/dashboard-edit-actual-exact). +async function pickTime(page: Page, timeStr: string) { + const [hourStr, minuteStr] = timeStr.split(':'); + const h = parseInt(hourStr, 10); + const m = parseInt(minuteStr, 10); + const cx = 145, cy = 145; + + const hourFace = page.locator('.clock-face'); + await hourFace.first().waitFor({ state: 'visible', timeout: 5000 }); + const hourAngle = (h % 12) * 30; + const hourR = (h === 0 || h > 12) ? 60 : 100; + const hourRad = hourAngle * Math.PI / 180; + await hourFace.first().click({ + position: { + x: Math.round(cx + hourR * Math.sin(hourRad)), + y: Math.round(cy - hourR * Math.cos(hourRad)) + (Math.abs(Math.cos(hourRad)) < 0.01 ? 1 : 0), + }, + }); + + await page.waitForTimeout(500); + const minuteFace = page.locator('.clock-face'); + await minuteFace.first().waitFor({ state: 'visible', timeout: 5000 }); + const minuteAngle = m * 6; + const minuteR = 100; + const minuteRad = minuteAngle * Math.PI / 180; + await minuteFace.first().click({ + position: { + x: Math.round(cx + minuteR * Math.sin(minuteRad)), + y: Math.round(cy - minuteR * Math.cos(minuteRad)) + (Math.abs(Math.cos(minuteRad)) < 0.01 ? 1 : 0), + }, + }); + await page.waitForTimeout(500); + await page.locator('.timepicker-button span').filter({ hasText: 'Ok' }).click(); + await page.locator('.cdk-overlay-backdrop').waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {}); + await page.waitForTimeout(500); +} + +async function setTimepickerValue(page: Page, selector: string, timeStr: string) { + await page.locator(`[data-testid="${selector}"]`).click(); + await pickTime(page, timeStr); +} + +async function openDialogForActiveCell(page: Page) { + await page.goto('http://localhost:4200'); + await new LoginPage(page).login(); + await page.locator('mat-nested-tree-node').filter({ hasText: 'Timeregistrering' }).click(); + const indexUpdatePromise = page.waitForResponse( + r => r.url().includes('/api/time-planning-pn/plannings/index') && r.request().method() === 'POST' + ); + await page.locator('mat-tree-node').filter({ hasText: 'Dashboard' }).click(); + await indexUpdatePromise; + await waitForSpinner(page); + await page.locator('#workingHoursSite').click(); + await page.locator('.ng-option').filter({ hasText: 'ac ad' }).click(); + await page.locator('#cell0_0').click(); + await page.locator('#planHours').waitFor({ state: 'visible', timeout: 15000 }); +} + +async function reopenCell(page: Page) { + await page.locator('#cell0_0').scrollIntoViewIfNeeded(); + await page.locator('#cell0_0').click(); + await expect(page.locator('#planHours')).toBeVisible(); +} + +async function clickSaveAndAwaitRoundtrip(page: Page) { + await expect(page.locator('#saveButton')).toBeEnabled({ timeout: 10000 }); + const updatePromise = page.waitForResponse(r => + r.url().includes('/api/time-planning-pn/plannings/') && r.request().method() === 'PUT'); + const reindexPromise = page.waitForResponse(r => + r.url().includes('/api/time-planning-pn/plannings/index') && r.request().method() === 'POST'); + await page.locator('#saveButton').click(); + const updateResponse = await updatePromise; + await reindexPromise; + await waitForSpinner(page); + await page.waitForTimeout(500); + return updateResponse; +} + +test.describe('Dashboard pause-override round-trip (l1m, flag-on)', () => { + test('admin-typed pause total round-trips at 1-minute precision', async ({ page }) => { + test.setTimeout(180000); + await openDialogForActiveCell(page); + + // Establish a known shift-1 span and set a deliberately off-grid pause + // total (00:23) so the assertion can only pass if the exact override + // round-tripped (not a 5-min-quantized value). + await setTimepickerValue(page, 'start1StartedAt', '08:01'); + await setTimepickerValue(page, 'stop1StoppedAt', '16:11'); + await setTimepickerValue(page, 'pause1Id', '00:23'); + await expect(page.locator('[data-testid="pause1Id"]')).toHaveValue('00:23'); + + const res = await clickSaveAndAwaitRoundtrip(page); + expect(res.status(), 'PUT must succeed under flag-on with an override pause').toBeLessThan(400); + + await reopenCell(page); + await expect( + page.locator('[data-testid="pause1Id"]'), + 'pause must round-trip 00:23 exactly via Pause1OverrideMinutes (not 00:20 / 00:25)', + ).toHaveValue('00:23'); + + await page.locator('#cancelButton').click(); + }); +}); diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.html b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.html index 38549d15..6c7bc911 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.html +++ b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.html @@ -226,6 +226,15 @@ + diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.spec.ts index a7e6b692..985febef 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.spec.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.spec.ts @@ -376,6 +376,84 @@ describe('WorkdayEntityDialogComponent', () => { }); }); + describe('Pause override (Approach C) save wiring', () => { + // `mockData` is a single module-level object shared (by reference) across + // every test via MAT_DIALOG_DATA. Tests in this block write the pause + // override fields on `planningPrDayModels`, and the outer `beforeEach` + // never deep-resets that object — so without this local reset a sibling + // test could leave the override/Specified fields dirty and poison the next + // one (the historical order-dependent CI failures). Reset every field this + // block touches to a known-clean baseline before each test so every test is + // self-contained and order-independent. + beforeEach(() => { + const m = component.data.planningPrDayModels as any; + for (let shift = 1; shift <= 5; shift++) { + m[`pause${shift}OverrideMinutes`] = null; + m[`pause${shift}OverrideMinutesSpecified`] = false; + } + m.clearPauseOverrides = false; + }); + + it('sets pause1OverrideMinutes + Specified when the pause field changes', () => { + // Drive the save-wiring unit directly with an explicit zero baseline so a + // 45-min pause is an unambiguous change. This bypasses the form-group → + // value plumbing in onUpdateWorkDayEntity (which can yield undefined pause + // values under jsdom) and tests applyPauseOverrideForShift's change + // detection deterministically, free of the shared fixture. + const m = component.data.planningPrDayModels as any; + component.ngOnInit(); + + // current (45) !== loaded (0) → genuine change. + (component as any).loadedPauseMinutes = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }; + (component as any).applyPauseOverrideForShift(1, '00:45'); + + expect(m.pause1OverrideMinutesSpecified).toBe(true); + expect(m.pause1OverrideMinutes).toBe(45); + }); + + it('leaves Specified=false when the pause field is unchanged', () => { + // current === loaded → no override written. Drive the unit directly with + // an explicit baseline so this never depends on the shared model's state + // or on jsdom form-value extraction. + const m = component.data.planningPrDayModels as any; + component.ngOnInit(); + + (component as any).loadedPauseMinutes = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }; + // pauseOverrideCleared[1] is false (reset by ngOnInit), current = 0 = loaded. + (component as any).applyPauseOverrideForShift(1, '00:00'); + + expect(m.pause1OverrideMinutesSpecified).toBe(false); + }); + + it('clear affordance signals revert-to-recorded (Specified=true, override=null)', () => { + // resetPauseToRecorded marks shift 1 cleared and resets its picker to the + // recorded sum (0 here → null hh:mm). As long as the picker still shows + // that value, applyPauseOverrideForShift must emit Specified=true with a + // null override. Drive it directly with the cleared value to avoid the + // form-extraction path in onUpdateWorkDayEntity. + const m = component.data.planningPrDayModels as any; + component.ngOnInit(); + + component.resetPauseToRecorded(1); + const clearedMinutes = (component as any).pauseOverrideClearedMinutes[1]; + // clearedMinutes is the raw minutes the picker was reset to (null when 0). + const clearedHhmm = component.convertMinutesToTime(clearedMinutes); + (component as any).applyPauseOverrideForShift(1, clearedHhmm); + + expect(m.pause1OverrideMinutesSpecified).toBe(true); + expect(m.pause1OverrideMinutes).toBeNull(); + }); + + it('prefers the served override for the displayed pause value', () => { + // Display precedence: a served override projects onto the pause picker. + (component.data.planningPrDayModels as any).pause1OverrideMinutes = 30; + + component.ngOnInit(); + + expect(component.workdayForm.get('actual.shift1.pause')?.value).toBe('00:30'); + }); + }); + describe('Flag Change Handling', () => { it('should turn off other flags when one is turned on', () => { component.ngOnInit(); diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.ts b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.ts index a7004b2c..bf8fef9b 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.ts @@ -79,6 +79,20 @@ export class WorkdayEntityDialogComponent implements OnInit, OnDestroy { isInTheFuture = false; maxPause1Id = 0; maxPause2Id = 0; + + // Pause-override (Approach C) change-detection state. Indexed by shift (1..5). + // loadedPauseMinutes[shift] = the per-shift total pause in MINUTES as displayed + // when the dialog opened (the value the pause picker was seeded with). On save + // we compare the picker's current minutes against this baseline; only a genuine + // change writes the override. pauseOverrideCleared[shift] is set by the + // "use recorded pauses" affordance to explicitly revert that shift to + // compute-from-slots (override = null, but Specified = true). + private loadedPauseMinutes: { [shift: number]: number | null } = {}; + pauseOverrideCleared: { [shift: number]: boolean } = {}; + // The recorded-sum minutes the clear affordance reset a shift's picker to. While + // pauseOverrideCleared[shift] is set, an edit that moves the picker away from + // this value is treated as a fresh override (the admin changed their mind). + private pauseOverrideClearedMinutes: { [shift: number]: number | null } = {}; todaysFlex = 0; nettoHoursOverrideActive = false; date: any; @@ -182,21 +196,40 @@ export class WorkdayEntityDialogComponent implements OnInit, OnDestroy { // sum of all Pause*StartedAt/Pause*StoppedAt timestamp pairs in seconds and // round to the nearest minute. When the flag is off, fall back to the legacy // 5-minute-slot value so flag-off behavior stays bit-identical. - const pause1Exact = this.useOneMinuteIntervals + // + // Approach C: when a pause override is present on the served model, prefer it + // directly for display. The server already projects the override onto the + // timestamp pair (so the sum-of-slots path would also reflect it), but using + // the raw override avoids any 5-minute-floor rounding loss on the projected + // pair and makes the displayed value bit-exact with what was saved. + const pauseDisplayHhmm = (shift: number, fallback: string | null): string | null => { + const ov = this.shiftOverrideMinutes(shift); + return ov !== null ? this.convertMinutesToTime(ov) : fallback; + }; + const pause1Exact = pauseDisplayHhmm(1, this.useOneMinuteIntervals ? this.convertMinutesToTime(this.computeExactPauseMinutes(1)) - : pause1Id; - const pause2Exact = this.useOneMinuteIntervals + : pause1Id); + const pause2Exact = pauseDisplayHhmm(2, this.useOneMinuteIntervals ? this.convertMinutesToTime(this.computeExactPauseMinutes(2)) - : pause2Id; - const pause3Exact = this.useOneMinuteIntervals + : pause2Id); + const pause3Exact = pauseDisplayHhmm(3, this.useOneMinuteIntervals ? this.convertMinutesToTime(this.computeExactPauseMinutes(3)) - : pause3Id; - const pause4Exact = this.useOneMinuteIntervals + : pause3Id); + const pause4Exact = pauseDisplayHhmm(4, this.useOneMinuteIntervals ? this.convertMinutesToTime(this.computeExactPauseMinutes(4)) - : pause4Id; - const pause5Exact = this.useOneMinuteIntervals + : pause4Id); + const pause5Exact = pauseDisplayHhmm(5, this.useOneMinuteIntervals ? this.convertMinutesToTime(this.computeExactPauseMinutes(5)) - : pause5Id; + : pause5Id); + + // Capture the displayed pause baseline (in minutes) per shift for save-time + // change detection, and reset the per-shift clear flags. + [pause1Exact, pause2Exact, pause3Exact, pause4Exact, pause5Exact] + .forEach((hhmm, idx) => { + const shift = idx + 1; + this.loadedPauseMinutes[shift] = this.toRawMinutes(hhmm); + this.pauseOverrideCleared[shift] = false; + }); // Er dato i fremtiden? this.isInTheFuture = Date.parse(this.data.planningPrDayModels.date) > Date.now(); @@ -1114,6 +1147,14 @@ export class WorkdayEntityDialogComponent implements OnInit, OnDestroy { return totalMs / 60000; } + // Read the pause override (in minutes) carried on the served model for a shift, + // or null when none is set (compute-from-slots). Used for display precedence. + private shiftOverrideMinutes(shift: number): number | null { + const m = this.data.planningPrDayModels; + const v = m[`pause${shift}OverrideMinutes`]; + return v === null || v === undefined ? null : v; + } + private getPauseTimestampPairs(shift: number): Array<[string | null, string | null]> { const m = this.data.planningPrDayModels; if (shift === 1) { @@ -1507,6 +1548,17 @@ export class WorkdayEntityDialogComponent implements OnInit, OnDestroy { this.data.planningPrDayModels.stop5Id = this.convertTimeToMinutes(a5?.stop, true, true); this.data.planningPrDayModels.stop5StoppedAt = this.convertTimeToDateTimeOfToday(a5?.stop === '00:00' ? '24:00' : a5?.stop); + // ===== Approach C: per-shift pause override (non-destructive) ===== + // The override is now the authoritative channel for the admin's total pause. + // For each shift, compare the picker's current minutes against the baseline + // captured when the dialog opened. The legacy Pause{N}Id / Pause{N}ExactMinutes + // are still sent above (harmless), but the override drives the effective total + // server-side and the worker's recorded sub-slots are preserved. + const actualGroups = [a1, a2, a3, a4, a5]; + actualGroups.forEach((group, idx) => { + this.applyPauseOverrideForShift(idx + 1, group?.pause); + }); + this.data.planningPrDayModels.planHours = this.workdayForm.get('planHours')?.value; this.data.planningPrDayModels.paidOutFlex = this.workdayForm.get('paidOutFlex')?.value; @@ -1523,6 +1575,62 @@ export class WorkdayEntityDialogComponent implements OnInit, OnDestroy { } } + // Approach C save wiring. For one shift, decide whether to emit the pause + // override on the model based on what the admin actually did: + // • "use recorded pauses" affordance used → Specified=true, override=null + // (revert that shift to compute-from-slots). + // • picker value changed from the loaded baseline → Specified=true, + // override= (authoritative total). Minutes are the exact HH:mm + // minutes for both flag-on and flag-off sites (the picker steps in 1-min + // or 5-min, but the field value is always real minutes). + // • unchanged → Specified=false (leave the server's override untouched; never + // locks an override just because start/stop were edited). + private applyPauseOverrideForShift(shift: number, pauseHhmm: string | null | undefined): void { + const m = this.data.planningPrDayModels; + const specifiedKey = `pause${shift}OverrideMinutesSpecified`; + const overrideKey = `pause${shift}OverrideMinutes`; + + const currentMinutes = this.toRawMinutes(pauseHhmm); + + if (this.pauseOverrideCleared[shift]) { + // Honor the clear only while the picker still shows the recorded value the + // affordance reset it to; a subsequent picker edit cancels the clear and + // becomes an explicit override below. + if (currentMinutes === (this.pauseOverrideClearedMinutes[shift] ?? null)) { + m[specifiedKey] = true; + m[overrideKey] = null; + return; + } + this.pauseOverrideCleared[shift] = false; + } + + const loadedMinutes = this.loadedPauseMinutes[shift] ?? null; + if (currentMinutes !== loadedMinutes) { + m[specifiedKey] = true; + m[overrideKey] = currentMinutes; + } else { + m[specifiedKey] = false; + } + } + + // Clear affordance: "reset pause to recorded" for one shift. Marks the shift so + // save sends Pause{N}OverrideMinutesSpecified=true with a null override (revert + // to compute-from-slots), and visually resets the picker to the recorded sum so + // the admin sees the value they are reverting to before saving. + resetPauseToRecorded(shift: number): void { + const recordedMinutes = this.useOneMinuteIntervals + ? this.computeExactPauseMinutes(shift) + : (this.computeFiveMinutePauseMinutes(shift) ?? 0); + const hhmm = this.convertMinutesToTime(recordedMinutes); + this.pauseOverrideCleared[shift] = true; + // Store the picker's resulting raw minutes (convertMinutesToTime(0) → null → + // toRawMinutes → null) so the "still showing the recorded value" guard in + // applyPauseOverrideForShift compares like-for-like. + this.pauseOverrideClearedMinutes[shift] = this.toRawMinutes(hhmm); + this.workdayForm.get(`actual.shift${shift}.pause`)?.setValue(hhmm); + this.calculatePlanHours(); + } + private getPlannedShiftMinutes( start: number | null, end: number | null, diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/planning-pr-day.model.ts b/eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/planning-pr-day.model.ts index d18992c7..8c8d8e03 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/planning-pr-day.model.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/planning-pr-day.model.ts @@ -146,4 +146,23 @@ export class PlanningPrDayModel { stop5ExactMinutes?: number | null; nettoHoursOverride: number; nettoHoursOverrideActive: boolean; + // Admin/manual pause override (Approach C). camelCase names match the C# DTO + // (Pause{N}OverrideMinutes / Pause{N}OverrideMinutesSpecified / ClearPauseOverrides) + // so JSON binds on the PUT body. null = compute pause from recorded slots; + // non-null = authoritative total pause MINUTES for that shift. The companion + // *Specified booleans distinguish "explicitly set/clear this shift" from + // "not sent" (int? cannot tell null from omitted on the wire). clearPauseOverrides + // reverts ALL five shifts to compute-from-slots in one shot. The worker's + // recorded Pause*StartedAt/StoppedAt are never destroyed by these. + pause1OverrideMinutes?: number | null; + pause2OverrideMinutes?: number | null; + pause3OverrideMinutes?: number | null; + pause4OverrideMinutes?: number | null; + pause5OverrideMinutes?: number | null; + pause1OverrideMinutesSpecified?: boolean; + pause2OverrideMinutesSpecified?: boolean; + pause3OverrideMinutesSpecified?: boolean; + pause4OverrideMinutesSpecified?: boolean; + pause5OverrideMinutesSpecified?: boolean; + clearPauseOverrides?: boolean; }