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;
}