> GenerateExcelDashboard(
var dataRow = new Row() { RowIndex = (uint)rowIndex };
try
{
- FillDataRow(dataRow, worker, site, culture, planning, plr, language, isThirdShiftEnabled, isFourthShiftEnabled, isFifthShiftEnabled);
+ FillDataRow(dataRow, worker, site, culture, planning, plr, language, isThirdShiftEnabled, isFourthShiftEnabled, isFifthShiftEnabled, cache?.AssignedSite?.UseOneMinuteIntervals ?? false);
// Append pay code values for this day
var dayPayLines = (cache != null && cache.PayLinesByDate.ContainsKey(planning.Date))
diff --git a/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts b/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts
index 245ec1ee6..5ae0cbaa2 100644
--- a/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts
+++ b/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts
@@ -307,4 +307,45 @@ test.describe('Dashboard — multi-shift (3-5) round-trip regression guard', ()
await page.locator('#cancelButton').click();
});
+
+ /**
+ * Phase 4 — second-precision DISPLAY: when a row's site has
+ * `useOneMinuteIntervals = true` AND the planning has a precise
+ * `start1StartedAt` stamp (e.g. 07:03:53), the plannings-table cell must
+ * render the stamp at HH:mm:ss instead of the legacy HH:mm.
+ *
+ * Server-side seeding `AssignedSite.UseOneMinuteIntervals = true` plus
+ * a planning with `Start1StartedAt = 2026-05-15 07:03:53` requires DB
+ * fixture work the CI playwright shard doesn't yet wire up (the tests
+ * here log in as admin and rely on the default seed). Captured here as
+ * a TODO so the assertion shape survives any future fixture work; the
+ * Phase 4 jest unit test on `formatStamp(...)` covers the contract for
+ * the merge-blocking path.
+ */
+ test.skip('plannings-table renders HH:mm:ss for actual stamp when site flag is on', async ({ page }) => {
+ // TODO(phase 4 fixture): seed AssignedSite.UseOneMinuteIntervals = true
+ // for the worker referenced by #cell3_0 AND a PlanRegistration row with
+ // Start1StartedAt = '2026-05-15T07:03:53Z' on a date that lands inside
+ // the dashboard's default visible range.
+ //
+ // Then the assertion shape is:
+ //
+ // await page.locator('mat-nested-tree-node').filter({ hasText: 'Timeregistrering' }).click();
+ // await page.locator('mat-tree-node').filter({ hasText: 'Dashboard' }).click();
+ // await waitForSpinner(page);
+ //
+ // const cellId = '#cell3_0';
+ // await page.locator(cellId).scrollIntoViewIfNeeded();
+ //
+ // // The first-shift actual line is rendered with id firstShiftActual{rowIdx}_{colField}.
+ // const firstShiftActual = page.locator('[id^="firstShiftActual"]').first();
+ // await expect(firstShiftActual).toContainText('07:03:53');
+ // // Negative guard — the legacy 5-min path would render '07:00' / '07:05' instead.
+ // await expect(firstShiftActual).not.toContainText(/07:0[05]\s/);
+ //
+ // Until the fixture lands the unit test
+ // `formatStamp (Phase 4) — uses HH:mm:ss format when row.useOneMinuteIntervals is true`
+ // covers the format-helper contract (eform-client/src/app/plugins/modules/time-planning-pn/
+ // components/plannings/time-plannings-table/time-plannings-table.component.spec.ts).
+ });
});
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 bc3e5d42a..dbd8f38f8 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
@@ -47,7 +47,7 @@
#plannedStartPicker
[format]="24"
[defaultTime]="workdayForm.get('planned.shift' + shiftId + '.start')?.value"
- [minutesGap]="5"
+ [minutesGap]="useOneMinuteIntervals ? 1 : 5"
(closed)="calculatePlanHours()">
@@ -80,7 +80,7 @@
#plannedPausePicker
[format]="24"
[defaultTime]="workdayForm.get('planned.shift' + shiftId + '.break')?.value"
- [minutesGap]="5"
+ [minutesGap]="useOneMinuteIntervals ? 1 : 5"
(closed)="calculatePlanHours()">
@@ -108,7 +108,7 @@
#plannedStopPicker
[format]="24"
[defaultTime]="workdayForm.get('planned.shift' + shiftId + '.stop')?.value"
- [minutesGap]="5"
+ [minutesGap]="useOneMinuteIntervals ? 1 : 5"
(closed)="calculatePlanHours()">
@@ -176,7 +176,7 @@
@@ -213,7 +213,7 @@
@@ -245,7 +245,7 @@
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 073df03f7..f6e5ebfc7 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
@@ -64,6 +64,17 @@ export class WorkdayEntityDialogComponent implements OnInit, OnDestroy {
// Reactive form
workdayForm!: FormGroup;
+ /**
+ * Phase 4: pulls UseOneMinuteIntervals off the assigned-site model passed
+ * into the dialog. Drives the six ngx-material-timepicker `[minutesGap]`
+ * bindings — when on, the picker steps in 1-minute increments instead of
+ * 5-minute snap (sub-minute INPUT is deferred per Q2(c); only DISPLAY
+ * shows seconds today).
+ */
+ get useOneMinuteIntervals(): boolean {
+ return this.data?.assignedSiteModel?.useOneMinuteIntervals ?? false;
+ }
+
// UI / beregningsfelter
isInTheFuture = false;
maxPause1Id = 0;
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.html b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.html
index 552fe9911..f6cf3d9d7 100644
--- a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.html
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.html
@@ -174,9 +174,9 @@
- login {{ datePipe.transform(row.planningPrDayModels[col.field]?.start1StartedAt, 'HH:mm', 'UTC') }}
+ login {{ formatStamp(row, row.planningPrDayModels[col.field]?.start1StartedAt) }}
-
- {{ getStopTimeDisplay(row.planningPrDayModels[col.field]?.start1StartedAt, row.planningPrDayModels[col.field]?.stop1StoppedAt) }} logout
+ {{ getStopTimeDisplayWithSeconds(row, row.planningPrDayModels[col.field]?.start1StartedAt, row.planningPrDayModels[col.field]?.stop1StoppedAt) }} logout
-
warning
@@ -192,9 +192,9 @@
- login {{ datePipe.transform(row.planningPrDayModels[col.field]?.start2StartedAt, 'HH:mm', 'UTC') }}
+ login {{ formatStamp(row, row.planningPrDayModels[col.field]?.start2StartedAt) }}
-
- {{ getStopTimeDisplay(row.planningPrDayModels[col.field]?.start2StartedAt, row.planningPrDayModels[col.field]?.stop2StoppedAt) }} logout
+ {{ getStopTimeDisplayWithSeconds(row, row.planningPrDayModels[col.field]?.start2StartedAt, row.planningPrDayModels[col.field]?.stop2StoppedAt) }} logout
-
warning
@@ -211,9 +211,9 @@
- login {{ datePipe.transform(row.planningPrDayModels[col.field]?.start3StartedAt, 'HH:mm', 'UTC') }}
+ login {{ formatStamp(row, row.planningPrDayModels[col.field]?.start3StartedAt) }}
-
- {{ getStopTimeDisplay(row.planningPrDayModels[col.field]?.start3StartedAt, row.planningPrDayModels[col.field]?.stop3StoppedAt) }} logout
+ {{ getStopTimeDisplayWithSeconds(row, row.planningPrDayModels[col.field]?.start3StartedAt, row.planningPrDayModels[col.field]?.stop3StoppedAt) }} logout
-
warning
@@ -230,9 +230,9 @@
- login {{ datePipe.transform(row.planningPrDayModels[col.field]?.start4StartedAt, 'HH:mm', 'UTC') }}
+ login {{ formatStamp(row, row.planningPrDayModels[col.field]?.start4StartedAt) }}
-
- {{ getStopTimeDisplay(row.planningPrDayModels[col.field]?.start4StartedAt, row.planningPrDayModels[col.field]?.stop4StoppedAt) }} logout
+ {{ getStopTimeDisplayWithSeconds(row, row.planningPrDayModels[col.field]?.start4StartedAt, row.planningPrDayModels[col.field]?.stop4StoppedAt) }} logout
-
warning
@@ -249,9 +249,9 @@
- login {{ datePipe.transform(row.planningPrDayModels[col.field]?.start5StartedAt, 'HH:mm', 'UTC') }}
+ login {{ formatStamp(row, row.planningPrDayModels[col.field]?.start5StartedAt) }}
-
- {{ getStopTimeDisplay(row.planningPrDayModels[col.field]?.start5StartedAt, row.planningPrDayModels[col.field]?.stop5StoppedAt) }} logout
+ {{ getStopTimeDisplayWithSeconds(row, row.planningPrDayModels[col.field]?.start5StartedAt, row.planningPrDayModels[col.field]?.stop5StoppedAt) }} logout
-
warning
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.spec.ts
index 63943dd73..b946aa858 100644
--- a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.spec.ts
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.spec.ts
@@ -374,4 +374,111 @@ describe('TimePlanningsTableComponent', () => {
expect(result).toBe('10:30');
});
});
+
+ // ------------------------------------------------------------------
+ // Phase 4 — second-precision display when UseOneMinuteIntervals is on
+ // ------------------------------------------------------------------
+ describe('formatStamp (Phase 4)', () => {
+ it('returns empty string when value is falsy', () => {
+ expect(component.formatStamp({ useOneMinuteIntervals: true }, null)).toBe('');
+ expect(component.formatStamp({ useOneMinuteIntervals: true }, undefined as any)).toBe('');
+ expect(component.formatStamp({ useOneMinuteIntervals: true }, '')).toBe('');
+ });
+
+ it("uses HH:mm format when row.useOneMinuteIntervals is false", () => {
+ const transformSpy = jest
+ .spyOn(component['datePipe'], 'transform')
+ .mockReturnValue('07:03');
+ const result = component.formatStamp(
+ { useOneMinuteIntervals: false },
+ '2026-05-15T07:03:53Z',
+ );
+ expect(transformSpy).toHaveBeenCalledWith('2026-05-15T07:03:53Z', 'HH:mm', 'UTC');
+ expect(result).toBe('07:03');
+ });
+
+ it("uses HH:mm:ss format when row.useOneMinuteIntervals is true", () => {
+ const transformSpy = jest
+ .spyOn(component['datePipe'], 'transform')
+ .mockReturnValue('07:03:53');
+ const result = component.formatStamp(
+ { useOneMinuteIntervals: true },
+ '2026-05-15T07:03:53Z',
+ );
+ expect(transformSpy).toHaveBeenCalledWith('2026-05-15T07:03:53Z', 'HH:mm:ss', 'UTC');
+ expect(result).toBe('07:03:53');
+ });
+
+ it("falls back to HH:mm when row is null/undefined (defensive)", () => {
+ const transformSpy = jest
+ .spyOn(component['datePipe'], 'transform')
+ .mockReturnValue('07:03');
+ const result = component.formatStamp(null as any, '2026-05-15T07:03:53Z');
+ expect(transformSpy).toHaveBeenCalledWith('2026-05-15T07:03:53Z', 'HH:mm', 'UTC');
+ expect(result).toBe('07:03');
+ });
+ });
+
+ describe('getStopTimeDisplayWithSeconds (Phase 4)', () => {
+ it('returns empty string when either timestamp is falsy', () => {
+ expect(
+ component.getStopTimeDisplayWithSeconds({ useOneMinuteIntervals: true }, null, '2026-05-15T10:00:00Z'),
+ ).toBe('');
+ expect(
+ component.getStopTimeDisplayWithSeconds({ useOneMinuteIntervals: true }, '2026-05-15T08:00:00Z', null),
+ ).toBe('');
+ });
+
+ it('returns 24:00 when stop is on a later day regardless of flag', () => {
+ expect(
+ component.getStopTimeDisplayWithSeconds(
+ { useOneMinuteIntervals: true },
+ '2026-05-15T23:00:00Z',
+ '2026-05-16T01:00:00Z',
+ ),
+ ).toBe('24:00');
+ });
+
+ it("uses HH:mm format when flag off (legacy parity)", () => {
+ const transformSpy = jest
+ .spyOn(component['datePipe'], 'transform')
+ .mockReturnValue('15:30');
+ const result = component.getStopTimeDisplayWithSeconds(
+ { useOneMinuteIntervals: false },
+ '2026-05-15T07:00:00Z',
+ '2026-05-15T15:30:11Z',
+ );
+ expect(transformSpy).toHaveBeenCalledWith('2026-05-15T15:30:11Z', 'HH:mm', 'UTC');
+ expect(result).toBe('15:30');
+ });
+
+ it("uses HH:mm:ss format when flag on", () => {
+ const transformSpy = jest
+ .spyOn(component['datePipe'], 'transform')
+ .mockReturnValue('15:30:11');
+ const result = component.getStopTimeDisplayWithSeconds(
+ { useOneMinuteIntervals: true },
+ '2026-05-15T07:00:00Z',
+ '2026-05-15T15:30:11Z',
+ );
+ expect(transformSpy).toHaveBeenCalledWith('2026-05-15T15:30:11Z', 'HH:mm:ss', 'UTC');
+ expect(result).toBe('15:30:11');
+ });
+ });
+
+ describe('convertHoursToTimeWithSeconds (Phase 4)', () => {
+ it('formats whole-hour values with seconds suffix', () => {
+ expect(component.convertHoursToTimeWithSeconds(8)).toBe('08:00:00');
+ });
+
+ it('handles fractional hours that include sub-minute precision', () => {
+ // 7h 3m 53s → 7 + 3/60 + 53/3600 ≈ 7.06472222...
+ const sevenThreeFiftyThree = 7 + 3 / 60 + 53 / 3600;
+ expect(component.convertHoursToTimeWithSeconds(sevenThreeFiftyThree)).toBe('07:03:53');
+ });
+
+ it('emits negative sign for negative values', () => {
+ expect(component.convertHoursToTimeWithSeconds(-1.5)).toBe('-1:30:00');
+ });
+ });
});
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.ts b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.ts
index 6b6a82638..df7d582f8 100644
--- a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.ts
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.ts
@@ -290,6 +290,43 @@ export class TimePlanningsTableComponent implements OnInit, OnChanges, AfterView
return this.datePipe.transform(stoppedAt, 'HH:mm', 'UTC') ?? '';
}
+ /**
+ * Phase 4 second-precision sibling of the inline `datePipe.transform(..., 'HH:mm', 'UTC')`
+ * calls used in the plannings table. When the row's site has
+ * useOneMinuteIntervals on, the actual stamp cells render at
+ * HH:mm:ss; otherwise falls through to the legacy HH:mm
+ * format so flag-off rows are byte-identical to before.
+ */
+ formatStamp(row: any, value: string | null | undefined): string {
+ if (!value) {
+ return '';
+ }
+ const fmt = row?.useOneMinuteIntervals ? 'HH:mm:ss' : 'HH:mm';
+ return this.datePipe.transform(value, fmt, 'UTC') ?? '';
+ }
+
+ /**
+ * Phase 4 second-precision sibling of {@link getStopTimeDisplay}. Same
+ * cross-day "24:00" guard as the legacy method, but emits seconds in the
+ * normal case when the row's site has the flag on.
+ */
+ getStopTimeDisplayWithSeconds(row: any, startedAt: string | null, stoppedAt: string | null): string {
+ if (!startedAt || !stoppedAt) {
+ return '';
+ }
+ const startDate = new Date(startedAt);
+ const stopDate = new Date(stoppedAt);
+ if (
+ startDate.getUTCFullYear() !== stopDate.getUTCFullYear() ||
+ startDate.getUTCMonth() !== stopDate.getUTCMonth() ||
+ startDate.getUTCDate() !== stopDate.getUTCDate()
+ ) {
+ return '24:00';
+ }
+ const fmt = row?.useOneMinuteIntervals ? 'HH:mm:ss' : 'HH:mm';
+ return this.datePipe.transform(stoppedAt, fmt, 'UTC') ?? '';
+ }
+
onFirstColumnClick(row: any): void {
// only do something if the selectAuthIsAdmin$ is true
this.selectAuthIsAdmin$.subscribe(value => {
@@ -411,6 +448,28 @@ export class TimePlanningsTableComponent implements OnInit, OnChanges, AfterView
return `${this.padZero(hrs)}:${this.padZero(mins)}`;
}
+ /**
+ * Phase 4 second-precision sibling of {@link convertHoursToTime}. Adds a
+ * :SS tail for callers that want seconds-level display when the
+ * row's site has UseOneMinuteIntervals on. The legacy 2-component
+ * helper is intentionally left untouched so flag-off display paths remain
+ * byte-identical.
+ */
+ convertHoursToTimeWithSeconds(hours: number): string {
+ const isNegative = hours < 0;
+ if (hours < 0) {
+ hours = Math.abs(hours);
+ }
+ const totalSeconds = Math.round(hours * 3600);
+ const hrs = Math.floor(totalSeconds / 3600);
+ const mins = Math.floor((totalSeconds % 3600) / 60);
+ const secs = totalSeconds % 60;
+ if (isNegative) {
+ return `-${hrs}:${this.padZero(mins)}:${this.padZero(secs)}`;
+ }
+ return `${this.padZero(hrs)}:${this.padZero(mins)}:${this.padZero(secs)}`;
+ }
+
convertHoursToTimeWithTranslations(hours: number): string {
const totalMinutes = Math.floor(hours * 60)
const hrs = Math.floor(totalMinutes / 60);
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/time-planning.model.ts b/eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/time-planning.model.ts
index bf1e65a45..b99d0b57c 100644
--- a/eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/time-planning.model.ts
+++ b/eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/time-planning.model.ts
@@ -15,4 +15,11 @@ export class TimePlanningModel {
deviceModel: string;
deviceManufacturer: string;
softwareVersionIsValid: boolean;
+ /**
+ * Phase 4: mirror of the row's assigned-site UseOneMinuteIntervals flag.
+ * When true, the plannings table renders actual stamps at HH:mm:ss instead
+ * of HH:mm. Default false preserves byte-identical legacy behavior for
+ * rows whose site has the flag off.
+ */
+ useOneMinuteIntervals: boolean;
}