From 1a7561f99249331076f201e8ce740be862a0dc72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Sat, 12 Sep 2026 08:42:00 +0200 Subject: [PATCH 01/28] docs: design for the Reconciled ("Afstemt") day lock Marking a day Afstemt freezes it and every earlier day for that worker, against web edits, mobile registrations and background recalculation. Earlier days lock without being marked Reconciled themselves, and a day can only be unlocked once every day after it has been unlocked -- the boundary moves, it never gets a hole punched in it. Three findings shaped the design: Reconciled/ReconciledAt already exist on PlanRegistrations and PlanRegistrationVersions (migration 20260127060748) and ship in the pinned Microting.TimePlanningBase 10.0.62. Nothing writes them. So this needs no base-repo change, no migration and no NuGet bump. The lock is derived from MAX(Date) WHERE Reconciled, not stored. That makes "earlier days are locked but not marked" and "unlock only in reverse order" properties of the model rather than rules something has to enforce and keep in sync. There is no choke point: 32 write sites across 11 files in 2 repos, and PnBase.Create/Update/Delete live in the base package. But every write goes through EF change tracking, so a SaveChanges interceptor sees all of them. Call-site guards alone would repeat the MaxDaysEditable mistake, which is enforced only on the read path and in the browser and is bypassable by a crafted POST today. Barring reconcile at or after today is load-bearing beyond the obvious: it is what makes the forward flex cascades -- one running 180 days ahead, one unbounded -- provably unable to reach a locked day. The spec records that so nobody relaxes it without revisiting them. Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-09-12-reconciled-day-lock-design.md | 434 ++++++++++++++++++ 1 file changed, 434 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md diff --git a/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md b/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md new file mode 100644 index 00000000..4a397e3c --- /dev/null +++ b/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md @@ -0,0 +1,434 @@ +# Reconciled ("Afstemt") day lock — design + +**Date:** 2026-09-12 +**Status:** Design, awaiting review +**Scope:** `eform-angular-timeplanning-plugin` (web UI + `TimePlanning.Pn` API), +`eform-service-timeplanning-plugin` (background jobs). **No change to +`eform-timeplanning-base`.** + +--- + +## 1. Problem + +Payroll periods need to be closed. Once a period's hours are agreed, nothing +should be able to change them — not a web edit, not a mobile registration, and +not a background recalculation that quietly rewrites a flex balance three months +after the fact. + +Today the plugin has no such mechanism. It has four partial approximations, none +of which closes a day: + +| Mechanism | Scope | Enforced where | +|---|---|---| +| `MaxDaysEditable` (default 45) | tenant-wide | **read path + browser only** — a crafted POST bypasses it | +| `AllowEditOfRegistrations` / `DaysBackInTimeAllowedEditingEnabled` | per site | server-side, **mobile write path only**, boolean-only (the numeric day count is never compared) | +| `DayOfPayment` | tenant-wide | **commented out** in `PlanRegistrationHelper` (two places) | +| `TransferredToPayroll` | per registration | informational in the export preview; **blocks nothing** | + +Two per-site date watermarks exist (`FlexChainComputedThrough`, +`UseOneMinuteIntervalsFrom`) but both gate *calculation semantics*, not editing. +Notably `UseOneMinuteIntervalsFrom`'s own doc comment already refers to +"already-closed periods" — a concept that has never existed in the schema. + +## 2. Goals + +1. A web user can mark a worker's day as **Afstemt** (reconciled), recording + when it happened. +2. A reconciled day, **and every earlier day for that worker**, becomes + immutable: no edit from web or mobile, no modification by any recalculation. +3. Earlier days are locked **without** being marked `Reconciled` themselves. +4. Unlocking is possible only in strict reverse order — to free a day, every + day after it must be freed first. +5. The three resulting day states are legible in the grid without relying on + colour alone. + +## 3. Non-goals + +- Fixing the `MaxDaysEditable` server-side enforcement hole. Noted as a known + gap; it is the reason this design does not rely on call-site guards alone. +- Reconciling at any granularity other than per worker per day. +- A `ReconciledBy` column. Who performed the action is recoverable from + `PlanRegistrationVersion.UpdatedByUserId`; adding a column would require a + base-repo migration, which this design otherwise avoids entirely. +- Changing `TransferredToPayroll` semantics, or coupling reconcile to payroll + export. + +--- + +## 4. Data model — no schema change required + +`Reconciled` and `ReconciledAt` **already exist** on both `PlanRegistrations` +and `PlanRegistrationVersions`, added by migration +`20260127060748_AddReconciledAndTransferredToPayrollToPlanRegistration`: + +```csharp +public bool Reconciled { get; set; } // tinyint(1) NOT NULL DEFAULT 0 +public DateTime? ReconciledAt { get; set; } // datetime(6) NULL +``` + +They are verified present in the currently pinned package +(`Microting.TimePlanningBase` **10.0.62**). Nothing writes them today; exactly +one place reads them — the admin-only version diff in +`TimePlanningPlanningService.CompareVersions`. + +An unused translation key already ships in all 24 locale files: +`'Reconciled in accounting': 'Afstemt i regnskabet'`. + +**Consequence: no base-repo change, no migration, no NuGet bump.** + +### 4.1 The lock predicate — derived, never stored + +``` +lockedThrough(siteId) = MAX(Date) + WHERE SdkSitId = siteId + AND Reconciled = 1 + AND WorkflowState != 'Removed' + -- null when the worker has no reconciled day + +isLocked(siteId, date) ⟺ lockedThrough(siteId) != null + AND date <= lockedThrough(siteId) +``` + +One row carries the mark. Every earlier day is locked by derivation and keeps +`Reconciled = false`, which is requirement (3) satisfied by construction rather +than by a background job that must keep flags in sync. + +Unlocking is therefore *only* meaningful at `lockedThrough` itself: clearing +that day's flag moves the boundary back to the next-newest reconciled day, or +to null. Requirement (4) is a property of the model, not a rule that has to be +separately enforced — a day below the boundary cannot be unlocked because +unlocking it would not change `MAX(Date)`. + +### 4.2 Invariants + +- **I1.** `ReconciledAt` is non-null exactly when `Reconciled` is true. Both are + written in the same operation; unlock clears both. +- **I2.** `Reconciled` may only be set for `date < today`. Reconciling today or + a future day is rejected **server-side**, not merely hidden in the UI. +- **I3.** No write of any kind may modify, create or delete a `PlanRegistration` + whose `Date <= lockedThrough(SdkSitId)`. + +**I2 is load-bearing beyond its obvious purpose.** See §6.2. + +### 4.3 Query cost + +`PlanRegistrations` has a unique index on `(SdkSitId, Date, WorkflowState)`. + +- `isLocked` for a known `(site, date)` is an exact seek — optimal. +- `lockedThrough(site)` is a prefix scan on `SdkSitId` plus a range on `Date`; + `Reconciled` is not in the index, so candidate rows are table lookups. For a + single site over a viewed window this is negligible. The dashboard resolves + it **once per site per request**, not per day, and passes the value down. + +No new index is proposed. If profiling later shows it matters, the narrow fix +is a filtered index on `(SdkSitId, Reconciled, Date)` — but note this repo has +already reverted speculative indexes once (base commit `f086394`), so it should +be added only against a measured query. + +--- + +## 5. Write-path inventory + +There are **32 `PlanRegistration.Create/Update/Delete` call sites across 11 +files in 2 repositories**, and **no choke point**. `PnBase.Create/Update/Delete` +live in the base NuGet package; most services call them directly rather than +through any plugin-owned helper. + +Categories: + +- **Web/admin:** `PlanningService.Update`, `UpdateByCurrentUserNam`, both + `Index` gap-fills, `WorkingHoursService.CreateUpdate` (bulk, multi-day), + `FlexService.UpdateCreate` (+ an estate-wide re-save loop), + `TimeSettingService.UpdateAssignedSite`, `GoogleSheetHelper`, `Import`, + `AbsenceRequestService`, `ContentHandoverService`, `PayrollExportService`. +- **Mobile/gRPC:** thin adapters delegating to the same services, plus a + **kiosk** `UpdateWorkingHour` overload with *no date guard at all*. +- **Recalculation:** `UpdatePlanRegistrationsInPeriod` (4 write sites; runs on + **every dashboard load and every mobile fetch**), `UpdatePlanRegistration`, + and the forward flex cascades. +- **Background (service repo):** `SearchListJob` (8×/day sheet pull; nightly + per-site walk that can **delete** rows), `FlexChainCatchUpJob` (off by + default), `eFormCompletedHandler` (device submissions, can resurrect a + `Removed` row). +- **Startup:** `CorruptedPauseIdRepair` — notable as the **only** path that + already implements a date lock of its own. + +Two behaviours matter more than the raw count: + +**Reading writes.** `POST plannings/index` — the dashboard load — creates a +`PlanRegistration` for every missing date in the requested range, then +`UpdatePlanRegistrationsInPeriod` writes to all of them. Opening a report over +a closed month would, today, create and write rows inside it. + +**Cascades write days nobody edited.** Forward flex cascades run from an edited +day through later days: one bounded at today, one **180 days into the future** +(`WorkingHoursService.CreateUpdate`), and one in the service repo with **no +upper bound at all** (`eFormCompletedHandler`). + +**Every write goes through EF change tracking.** No production path uses +`ExecuteSqlRaw`, `ExecuteUpdate`, `ExecuteDelete` or a bulk insert against +`PlanRegistration`. (A grep finds two `ExecuteSqlRaw` hits in +`TimePlanning.Pn.Test/TestBaseSetup.cs`; both replay a SQL dump into the **SDK** +database during test setup and touch neither the plugin DB nor +`PlanRegistration`.) A `SaveChanges` interceptor can therefore see all 32 sites. + +--- + +## 6. Enforcement + +### 6.1 Three layers + +**Layer 1 — `SaveChangesInterceptor` (the guarantee).** +Rejects any tracked `PlanRegistration` entry that violates **I3**: `Modified` or +`Deleted` with `Date <= lockedThrough`, or `Added` in that range. This is the +only layer that is *complete*: it covers all 32 sites and any site added later. + +It must be registered **in both repositories** wherever a +`TimePlanningPnDbContext` is constructed. Registering it in only one leaves the +background jobs unguarded — a lock with a hole, which is worse than no lock +because it invites trust. + +The interceptor resolves `lockedThrough` per distinct `SdkSitId` in the change +set, once per `SaveChanges`, not per row. + +**Layer 2 — guards on user-facing write paths (the message).** +`PlanningService.Update`, `UpdateByCurrentUserNam`, +`WorkingHoursService.CreateUpdate`, and both `UpdateWorkingHour` overloads +check the predicate first and return a localized failure. Without this the user +sees a 500 from the interceptor instead of "this day is reconciled". + +**Layer 3 — recalculation paths skip, they do not throw.** +`UpdatePlanRegistrationsInPeriod` legitimately spans the boundary on every +dashboard load. It filters locked days out of its working set rather than +failing. Same for `UpdatePlanRegistration` and the service-repo jobs. + +Rationale for all three: guards alone repeat the `MaxDaysEditable` mistake +(bypassable); the interceptor alone turns an ordinary dashboard load into an +exception. + +### 6.2 Why the cascades need no special handling + +Given **I2** (nothing at or after today may be reconciled): + +- every locked day satisfies `date <= lockedThrough < today`; +- every editable day satisfies `date > lockedThrough`; +- a forward cascade starts from an edited day and walks *forward*. + +Therefore every day a cascade touches is `> lockedThrough` and unlocked. **The +cascades provably cannot reach a locked day.** Reading the boundary day to seed +the chain is unaffected — that is a read. + +This is the single reason the 180-day and unbounded cascades do not need to be +re-plumbed. **If I2 is ever relaxed, they become live hazards immediately.** +Any future change permitting a future-dated reconcile must revisit §5's cascade +list first. Layer 1 would catch the violation, but as a 500 rather than a +designed behaviour. + +### 6.3 Gap-fill inside a locked range + +Gap-fill **stops at the boundary**: no row creation, no recomputation for +`date <= lockedThrough`. A locked period is frozen exactly as it stands. + +Consequence, accepted deliberately: a day inside a locked range that never had +a registration stays empty in the grid rather than materialising a blank row. +That is the correct reading — nothing was registered that day — and it is what +makes "reconciled" mean byte-stable rather than merely read-only. + +### 6.4 Timezone + +The predicate in **I2** depends on "today", and the codebase mixes +`DateTime.Now` and `DateTime.UtcNow`. `PlanRegistration.Date` is a midnight +local date. + +**Decision: compare against `DateTime.Now.Date`** (server local), matching how +`PlanRegistrationHelper` and the existing mobile guard already compute "today". +Using UTC would make the boundary shift by a day for part of each evening in +Danish time. This is stated explicitly because it is exactly the kind of +off-by-one that is invisible in tests written at midday. + +--- + +## 7. API surface + +Three operations on `TimePlanningPlanningController`: + +| Verb | Route | Body | Returns | +|---|---|---|---| +| `PUT` | `plannings/{id}/reconcile` | — | `OperationResult` | +| `PUT` | `plannings/{id}/unreconcile` | — | `OperationResult` | +| `PUT` | `plannings/reconcile-through` | `{ date, siteIds[] }` | `OperationResult` with per-site outcome | + +Rules enforced server-side: + +- Reconcile rejects `date >= DateTime.Now.Date` (**I2**). +- Reconcile is idempotent: re-reconciling an already-reconciled day is a no-op + success, not an error. +- Unreconcile rejects any day that is not exactly `lockedThrough` for that + worker, with a message naming the day that must be freed first. +- `reconcile-through` sets `Reconciled` on **one** day per listed worker: the + latest day at or before the given date that has a registration. Everything + earlier locks by derivation. If a worker has no registration on or before the + date, that worker is skipped and reported. + +The read model gains two fields on the existing per-day DTO: + +```csharp +public bool Reconciled { get; set; } +public DateTime? ReconciledAt { get; set; } +``` + +and one per-row field, so the client can render the staircase without computing +it per cell: + +```csharp +public DateTime? LockedThrough { get; set; } +``` + +`isLocked` is then a pure client-side comparison, consistent with how the grid +already derives cell classes. + +--- + +## 8. UI + +### 8.1 Three states + +Because the mark is per worker per day, the boundary is a **staircase** down +the grid, not one vertical line. Each worker has their own reconciled-through +date. + +Four independent channels, so colour is never load-bearing: + +| State | Texture | Glyph | Cursor | Tooltip (da) | +|---|---|---|---|---| +| Open | none | — | `pointer` | — | +| Locked (cascade) | diagonal hatch | outline `lock`, bottom-left | `not-allowed` | `Låst · ligger før en afstemt dag` | +| Afstemt (boundary) | flat tint, 3px right border | filled `verified`, top-right | `default` | `Afstemt kl. ` | + +Constraints from the existing code: + +- `getCellClass(row, field)` returns a **single** string today and has no + composition mechanism. It must be extended to return multiple classes. +- Styles must be **theme-agnostic**: `body.theme-eform` rules do not apply under + `theme-workspace`. Use `--tp-td-bg` / `--tp-border` / `--tp-text`, and define + new `--tp-locked-*` tokens at `:root` with dark overrides. +- `outline` + yellow is already taken by `.highlight-cell`; blue by + `.setting-ico.active`. The lock palette must avoid both. +- Day-cell icons use `fontSet="material-symbols-outlined"` + `class="neutral-icon"`. + `lock` is already in use elsewhere (pay-rule-set banner) and is the right + precedent. + +A legend sits under the grid whenever any locked day is in range — otherwise +nobody learns what the hatch means. + +### 8.2 Single day + +A third action in the workday dialog footer, with a two-step inline confirm +(the footer morphs; no second modal). After saving, the dialog reopens +read-only with a provenance line where the actions were. + +The dialog is chosen over a cell hover affordance deliberately: it is the only +place the user already sees *whose day, which date, which hours* before +committing, and the day cell is a dense click target where a misfire would +freeze a period. + +### 8.3 Multi-day + +Because one date plus the cascade already locks everything before it, +"multi-day" means **multi-worker**. A toolbar control **"Afstem til og med +\"** sets the boundary for many workers in one action, with a live +preview of the affected count before committing. + +Worker selection uses mtx-grid's `[rowSelectable]` / `[multiSelectable]`, which +this plugin has never used. The host's backend-configuration task-list is the +precedent and documents two gotchas that apply verbatim: + +1. mtx-grid binds `(click)="_selectRow()"` on the ``; with `[rowSelectable]` + this **clears the batch selection** when a day cell is clicked. The day cell + must call `stopRowClick($event)`. +2. mtx-grid rebuilds its internal `SelectionModel` empty in `ngOnChanges` + **without emitting** `rowSelectedChange`, so the component must re-emit an + empty selection itself. + +Both are load-bearing: the planning grid's day cells are clickable, so (1) is +guaranteed to bite. + +### 8.4 Unlock + +Only the boundary day offers unlock; it moves the line back one notch. A day +below the boundary shows *which* day must be freed first rather than a disabled +control with no explanation. + +Confirmation is a typed word, not a checkbox — deliberately asymmetric: sealing +takes a click, unsealing takes a word. + +### 8.5 Blocked feedback + +A locked day's dialog **opens read-only** rather than refusing to open. People +read closed days constantly. Every field renders at full opacity, disabled, with +a banner at the top. This reuses the existing `tp-help-hint tone="warn"` pattern +already used for `dayCell.futureDisabled`. + +**Copy rule:** user-facing text states what the day *is*. It never explains a +restriction by referring to what an administrator may do. ("admin = Microting" +is a standing constraint in this product.) + +### 8.6 Permissions + +Per explicit decision: **any web user may reconcile.** This is a deliberate +departure from the rest of the toolbar — payroll export, for instance, is +admin-gated — and it means an ordinary user can freeze a period. The +reverse-order unlock rule is the only safeguard, and it is the reason unlock +carries the heavier confirmation. + +--- + +## 9. Testing + +Tests in this repo run **only in CI**. + +**C# (`TimePlanning.Pn.Test`):** +- `lockedThrough` with: no reconciled day, one, several, one soft-deleted. +- `isLocked` at the boundary, either side of it, and for a worker with none. +- **I2**: reconcile rejected for today and for a future date. +- Unreconcile rejected below the boundary, accepted at it, and the boundary + moving back to the next-newest. +- Interceptor: `Modified`, `Added` and `Deleted` in a locked range each + rejected; each allowed above the boundary. +- Gap-fill creates nothing inside a locked range. +- `UpdatePlanRegistrationsInPeriod` leaves locked rows byte-identical — + asserted on `Version`/`UpdatedAt`, so a no-op re-save still fails the test. +- `reconcile-through` marks exactly one day per worker and skips workers with + no registration. + +New test classes must be added to the CI shard filters in **both** +`dotnet-core-pr.yml` and `dotnet-core-master.yml`, or they silently never run. + +**Playwright:** reconcile a day → cell shows the boundary treatment → earlier +cell shows the locked treatment → opening a locked day gives a read-only dialog +→ unlock below the boundary is refused → unlock at the boundary moves it back. +Anchor rows by worker identity, not by grid index. + +--- + +## 10. Risks + +| Risk | Mitigation | +|---|---| +| Interceptor registered in only one repo → silent hole | Explicit test in each repo asserting a locked write is rejected through that repo's own context construction | +| **I2** relaxed later → cascades reach locked days | Stated as an invariant here and in code comments at the cascade sites; interceptor catches it as a 500 rather than corruption | +| Any user can freeze a period | Accepted by decision; reverse-order unlock + typed confirmation | +| Row selection breaks day-cell clicks | Known mtx-grid gotchas documented in §8.3, both with existing fixes in the host repo | +| `MaxDaysEditable` remains bypassable | Out of scope, explicitly; the interceptor makes the *new* lock not share the flaw | +| Timezone off-by-one near midnight | `DateTime.Now.Date` fixed in §6.4; tests must include a late-evening case | + +## 11. Open questions + +1. Should `reconcile-through` apply to **selected** workers only, or to every + worker currently visible under the active filters? Selected is safer; + all-visible is faster for the common month-end case. +2. Should a locked day still be exportable to payroll, and should + `TransferredToPayroll` and `Reconciled` be related at all? They are + independent in this design. +3. Does the mobile app need to *display* the locked state, or is rejecting the + write sufficient for now? From bda60f21e7cc21a98bed1b8210ba8d3616d7268f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Sat, 12 Sep 2026 09:07:47 +0200 Subject: [PATCH 02/28] docs: settle bulk scope, payroll independence and mobile handling Bulk reconcile needs three selection modes -- picked rows, a picked day column, or everything visible in the chosen period. They collapse into one operation with two axes, a target date and a set of workers, rather than three separate features: the date comes from a column header or the picker, and the worker set comes from row selection, defaulting to every visible row. Recorded as a table mapping each requested mode onto that one mechanism. Two consequences worth writing down. The affected cells are previewed in place before committing, so the cascade is something you see rather than infer. And a row already reconciled past the target date is skipped, not moved backwards -- that would be an unlock, which is deliberately the heavier action; skipped rows are counted and reported instead of quietly dropped. Reconciled and TransferredToPayroll stay independent. They are adjacent columns from the same migration, which makes it easy to assume they are related; they are not, in either direction. Mobile rejects the write with the same localized failure as the web paths and renders no lock state. No mobile UI work is in scope. Open questions section now holds nothing blocking -- just the skipped-row reporting surface and whether ReconciledBy is wanted on the record, which would need the base migration this design otherwise avoids. Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-09-12-reconciled-day-lock-design.md | 65 +++++++++++++++---- 1 file changed, 53 insertions(+), 12 deletions(-) diff --git a/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md b/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md index 4a397e3c..c69d5856 100644 --- a/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md +++ b/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md @@ -335,9 +335,36 @@ freeze a period. ### 8.3 Multi-day Because one date plus the cascade already locks everything before it, -"multi-day" means **multi-worker**. A toolbar control **"Afstem til og med -\"** sets the boundary for many workers in one action, with a live -preview of the affected count before committing. +"multi-day" means **multi-worker**. Every bulk action is therefore the same +operation with two axes: + +``` +scope = target date × set of workers +``` + +- **The date** comes from clicking a day-column header, or from the toolbar's + date field. One date, never a range — the cascade supplies the range. +- **The worker set** comes from row selection; with nothing selected it + defaults to **every worker currently visible** under the active filters. + +That single mechanism covers all three requested modes without three separate +features: + +| Requested mode | How it is expressed | +|---|---| +| Selected rows | tick rows, then pick a date | +| Selected column | click a day header; no row selection ⇒ all visible workers | +| All visible in the period | pick a date with nothing selected | + +**The affected region is previewed before it is committed** — the cells that +will be locked are highlighted in place, so the cascade is visible rather than +inferred. The confirm step states the two counts that matter: how many workers, +and the date the boundary lands on. + +**Rows already reconciled past the target date are skipped, not moved +backwards.** Applying an earlier date to a worker whose boundary is already +later would be an *unlock*, and unlocking is deliberately a separate, heavier +action (§8.4). Those rows are reported as skipped rather than silently ignored. Worker selection uses mtx-grid's `[rowSelectable]` / `[multiSelectable]`, which this plugin has never used. The host's backend-configuration task-list is the @@ -422,13 +449,27 @@ Anchor rows by worker identity, not by grid index. | `MaxDaysEditable` remains bypassable | Out of scope, explicitly; the interceptor makes the *new* lock not share the flaw | | Timezone off-by-one near midnight | `DateTime.Now.Date` fixed in §6.4; tests must include a late-evening case | -## 11. Open questions +## 11. Resolved decisions + +These were open during design and are now settled: + +1. **Bulk scope** — all three: selected rows, a selected day column, or every + worker visible in the chosen period. Expressed as one mechanism (date × + worker set) rather than three features; see §8.3. +2. **No link to payroll.** `Reconciled` and `TransferredToPayroll` stay + independent. Reconciling does not affect export eligibility, and exporting + does not reconcile. They are adjacent columns from the same migration and it + would be easy to assume otherwise — they are not related. +3. **Mobile rejects the write and shows no lock state.** The gRPC paths return + the same localized failure as the web paths. No mobile UI work is in scope; + the app does not need to render the three states. + +## 12. Open questions + +None blocking. Two worth revisiting after the first release: -1. Should `reconcile-through` apply to **selected** workers only, or to every - worker currently visible under the active filters? Selected is safer; - all-visible is faster for the common month-end case. -2. Should a locked day still be exportable to payroll, and should - `TransferredToPayroll` and `Reconciled` be related at all? They are - independent in this design. -3. Does the mobile app need to *display* the locked state, or is rejecting the - write sufficient for now? +- Whether the skipped-rows report in §8.3 needs a persistent surface, or + whether naming the count in the result toast is enough. +- Whether `ReconciledBy` is wanted on the face of the record. It is currently + recoverable from `PlanRegistrationVersion.UpdatedByUserId` (§3), and adding + it would require the base-repo migration this design otherwise avoids. From f17cfdc500895ad30b4dd2ef732231bc4c9eb812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Sat, 12 Sep 2026 09:43:09 +0200 Subject: [PATCH 03/28] docs: implementation plan for the Reconciled day lock 15 tasks across three repos, phased so the backend is complete and enforceable at task 6. Revised after review, which found the first draft would not have survived execution. The defects worth recording: The interceptor opened a second DbContext to read the boundary, and built it with ServerVersion.AutoDetect -- which opens a connection and runs a version query. That guard fires on every save, including the four Update calls inside the per-day loop that runs on every dashboard load; 50 workers over a month would have meant thousands of extra connections per page view. It now queries the context it is already inside, which is safe because the boundary query projects into an anonymous type and tracks nothing. The same mistake in miniature: the rewritten context helper used AutoDetect where the factory it replaced hardcodes the version, adding a round-trip per worker per load. Hardcoded to match. Task 5 said to iterate a filtered collection while still projecting the unfiltered one. UpdatePlanRegistrationsInPeriod has a single 715-line loop that fuses writing and projection, so following that instruction would have dropped locked days out of the grid -- silently wrong rather than blocked, which is the worst failure mode for a plan. Now: guard the four Update calls, leave the loop alone. LockedThrough was set inside that loop, so a worker with no rows in the window never got one and a fully locked month would have rendered editable. A `using Pomelo...` line that does not compile -- this repo uses the Microting fork. Three tests that fail in arrange because the seed order put the row inside the range its own Create would be blocked by. A test that seeded WorkflowState = Removed, which PnBase.Create overwrites unconditionally. Three test helpers that are private or do not exist. Seven wrong line numbers. Also corrected: the rationale for guarding setDisabled claimed the cascade re-enables unconditionally. It does not -- every enable is value-guarded. The true reason is that none is guarded by isInTheFuture, which is a pre-existing bug the lock would inherit. An agent that checked the false premise would have skipped a load-bearing step. Spec UI requirements 8.1-8.4 (glyph, tooltip, legend, two-step confirm, bulk preview, typed unlock confirmation) have no tasks and are listed as an open decision rather than quietly dropped. Co-Authored-By: Claude Opus 5 (1M context) --- .../plans/2026-09-12-reconciled-day-lock.md | 2530 +++++++++++++++++ 1 file changed, 2530 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-12-reconciled-day-lock.md diff --git a/docs/superpowers/plans/2026-09-12-reconciled-day-lock.md b/docs/superpowers/plans/2026-09-12-reconciled-day-lock.md new file mode 100644 index 00000000..c0fd0446 --- /dev/null +++ b/docs/superpowers/plans/2026-09-12-reconciled-day-lock.md @@ -0,0 +1,2530 @@ +# Reconciled ("Afstemt") Day Lock Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a web user mark a worker's day "Afstemt", which freezes that day and every earlier day for that worker against edits and recalculation, unlockable only in reverse order. + +**Architecture:** The lock is *derived*, never stored: a day is locked when that worker has any `Reconciled` day at or after it. `Reconciled`/`ReconciledAt` already exist in the DB. Enforcement is three layers — a `SaveChangesInterceptor` that makes the rule unbypassable, friendly guards on user-facing write paths so blocked edits get a message instead of a 500, and recalculation paths that skip locked days silently. + +**Tech Stack:** C# / .NET 10 / EF Core 10 / MySQL via the **Microting.EntityFrameworkCore.MySql** fork (NOT Pomelo — see Task 2); Angular 20.3 NgModule (`standalone: false`), Angular Material + CDK 20.2.14, `mtx-grid` 20.4.2, ngx-translate 17; NUnit + NSubstitute + Testcontainers; Playwright. + +**Spec:** `docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md` + +## Global Constraints + +- **Dev mode: NONE — edit the source repos directly.** Every path in this plan is a + source-repo path and every task ends in a commit there. Do **not** run + `devgetchanges.sh`, and do not edit the host-app mirror under + `eform-angular-frontend/eFormAPI/Plugins/` — that mirror is stale (dated Aug 14) + and syncing from it would overwrite this work with old code. The one exception is + Task 10, which edits `eform-angular-frontend`'s own SCSS because CLAUDE.md puts all + SCSS there. + +- **No base-repo change.** `Reconciled` (`tinyint(1) NOT NULL DEFAULT 0`) and `ReconciledAt` (`datetime(6) NULL`) already exist on `PlanRegistrations` **and** `PlanRegistrationVersions` (migration `20260127060748`), and ship in the pinned `Microting.TimePlanningBase` **10.0.62**. Do not add a migration. Do not bump the package. +- **Invariant I1:** `ReconciledAt` is non-null exactly when `Reconciled` is true. Both written together; unlock clears both. +- **Invariant I2:** `Reconciled` may only be set for `date < DateTime.Now.Date`. Server-enforced, not merely hidden in the UI. +- **Invariant I3:** No write may modify, create or delete a `PlanRegistration` whose `Date <= lockedThrough(SdkSitId)`. +- **I2 is load-bearing beyond its own purpose.** Because the boundary is always in the past and edits are only allowed above it, forward flex cascades (one runs 180 days ahead, one is unbounded) provably cannot reach a locked day. If I2 is ever relaxed, those cascades must be revisited first. +- **Timezone:** compare against `DateTime.Now.Date` (server local), matching `PlanRegistrationHelper` and the existing mobile guard. Never `UtcNow`. +- **Copy rule:** user-facing text states what the day *is*. It never explains a restriction by referring to what an administrator may do. ("admin = Microting".) +- **Permissions:** any web user may reconcile. No admin gate on reconcile or unlock. +- **Payroll:** `Reconciled` and `TransferredToPayroll` are independent in both directions. Do not couple them. +- **Mobile:** rejects the write with the same localized failure as web. No mobile UI work. +- **Tests run only in CI.** Never run `dotnet test`, `playwright test`, `jest` or `npm test` locally — a hook blocks them. `dotnet build` is allowed and expected. Push and watch `gh pr checks `. +- **Never commit to `stable` directly.** Branch, PR into `stable`. +- **SCSS lives in `eform-angular-frontend`**, never per-plugin (CLAUDE.md). Task 10 is a separate repo and a separate PR. +- **New C# test classes must be added to the shard filters in BOTH** `.github/workflows/dotnet-core-pr.yml` and `dotnet-core-master.yml`, or they silently never run. + +--- + +## File Structure + +**New files** + +| Path | Responsibility | +|---|---| +| `eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs` | The lock predicate. Sole source of truth for `lockedThrough` and `IsLocked`. | +| `eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Interceptors/ReconciledDayLockInterceptor.cs` | Stateless `SaveChangesInterceptor` enforcing I3 across every write path. | +| `eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/ReconcileThroughRequestModel.cs` | Bulk request body. | +| `eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/ReconcileThroughResultModel.cs` | Bulk result (applied / skipped / landed-on). | +| `eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs` | Predicate unit tests. | +| `eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockInterceptorTests.cs` | Interceptor enforcement tests. | +| `eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs` | Endpoint behaviour tests. | +| `eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-day-lock.spec.ts` | E2E. | + +**Modified — backend** + +| Path | Change | +|---|---| +| `.../Infrastructure/Helpers/TimePlanningDbContextHelper.cs` | Build options locally; attach interceptor. | +| `.../EformTimePlanningPlugin.cs:183-189` | `.AddInterceptors(...)` on the pooled context. | +| `.../Services/TimePlanningPlanningService/TimePlanningPlanningService.cs` | Guards; three new service methods; `LockedThrough` on the row model. | +| `.../Services/TimePlanningPlanningService/ITimePlanningPlanningService.cs` | Three new signatures. | +| `.../Controllers/TimePlanningPlanningController.cs` | Three new routes. | +| `.../Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs` | Guards on `CreateUpdate` and both `UpdateWorkingHour` overloads. | +| `.../Infrastructure/Helpers/PlanRegistrationHelper.cs` | Skip locked days; project the two DTO fields. | +| `.../Infrastructure/Models/Planning/TimePlanningPlanningPrDayModel.cs` | `+Reconciled`, `+ReconciledAt`. | +| `.../Infrastructure/Models/Planning/TimePlanningPlanningModel.cs` | `+LockedThrough`. | +| `.../Resources/Translations.resx` + `Translations.da.resx` | Six new keys. | +| `.../TimePlanning.Pn.Test/TestBaseSetup.cs` | Attach the interceptor so tests exercise it. | + +**Modified — frontend (plugin repo)** + +`models/plannings/planning-pr-day.model.ts`, `models/plannings/time-planning.model.ts`, `services/time-planning-pn-plannings.service.ts`, `components/plannings/time-plannings-table/time-plannings-table.component.{ts,html}`, `components/plannings/time-plannings-container/time-plannings-container.component.{ts,html}`, `components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.{ts,html}`, `help/help.model.ts`, `help/planning-help.registry.ts`, `help/i18n/{da,enUS}.ts`, `i18n/{da,enUS}.ts`. + +**Modified — host repo (separate PR):** `eform-client/src/scss/styles.scss`. + +--- + +## Task 1: The lock predicate + +**Files:** +- Create: `eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs` +- Test: `eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs` +- Modify: `.github/workflows/dotnet-core-pr.yml:251`, `.github/workflows/dotnet-core-master.yml:262` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `DayLockHelper.LockedThroughAsync(TimePlanningPnDbContext db, int sdkSitId) -> Task` + - `DayLockHelper.LockedThroughForSitesAsync(TimePlanningPnDbContext db, IReadOnlyCollection sdkSitIds) -> Task>` + - `DayLockHelper.IsLocked(DateTime? lockedThrough, DateTime date) -> bool` + - `DayLockHelper.CanReconcile(DateTime date) -> bool` + +- [ ] **Step 1: Write the failing test** + +Create `TimePlanning.Pn.Test/DayLockHelperTests.cs`: + +```csharp +using System; +using System.Threading.Tasks; +using Microting.eForm.Infrastructure.Constants; +using NUnit.Framework; +using TimePlanning.Pn.Infrastructure.Helpers; +using PlanRegistrationEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.PlanRegistration; + +namespace TimePlanning.Pn.Test; + +/// +/// The lock is derived, never stored: a day is locked when the worker has any +/// Reconciled day at or after it. These tests pin that derivation, because +/// every enforcement layer downstream trusts it. +/// +[TestFixture] +public class DayLockHelperTests : TestBaseSetup +{ + [SetUp] + public async Task SetUpTest() => await base.Setup(); + + // No workflowState parameter: PnBase.Create overwrites it with "created" + // regardless of what is set here. + private async Task Seed(int site, DateTime date, bool reconciled) + { + await new PlanRegistrationEntity + { + SdkSitId = site, + Date = date, + Reconciled = reconciled, + ReconciledAt = reconciled ? new DateTime(2026, 1, 20, 9, 12, 0) : (DateTime?)null, + PlanText = "", + CommentOffice = "", + CommentOfficeAll = "", + WorkflowState = Constants.WorkflowStates.Created, + CreatedByUserId = 1, + UpdatedByUserId = 1, + }.Create(TimePlanningPnDbContext!); + } + + [Test] + public async Task LockedThrough_NoReconciledDay_IsNull() + { + await Seed(700, new DateTime(2026, 1, 12), reconciled: false); + + var result = await DayLockHelper.LockedThroughAsync(TimePlanningPnDbContext!, 700); + + Assert.That(result, Is.Null, "a worker with nothing reconciled has no boundary"); + } + + [Test] + public async Task LockedThrough_SeveralReconciled_IsTheLatest() + { + await Seed(701, new DateTime(2026, 1, 12), reconciled: true); + await Seed(701, new DateTime(2026, 1, 16), reconciled: true); + await Seed(701, new DateTime(2026, 1, 14), reconciled: false); + + var result = await DayLockHelper.LockedThroughAsync(TimePlanningPnDbContext!, 701); + + Assert.That(result, Is.EqualTo(new DateTime(2026, 1, 16))); + } + + [Test] + public async Task LockedThrough_IgnoresRemovedRows() + { + // PnBase.Create OVERWRITES WorkflowState with "created" unconditionally + // (PnBase.cs:16), so a row cannot be seeded as Removed -- it has to be + // created and then soft-deleted. Note this test must run BEFORE the + // interceptor exists (Task 2) or the Delete below lands on the boundary + // day and is rejected; from Task 2 onward, delete the LATER row first + // while the earlier one still holds no boundary. + await Seed(702, new DateTime(2026, 1, 12), reconciled: true); + await Seed(702, new DateTime(2026, 1, 18), reconciled: true); + + var later = await TimePlanningPnDbContext!.PlanRegistrations + .FirstAsync(x => x.SdkSitId == 702 && x.Date == new DateTime(2026, 1, 18)); + await later.Delete(TimePlanningPnDbContext!); + + var result = await DayLockHelper.LockedThroughAsync(TimePlanningPnDbContext!, 702); + + Assert.That(result, Is.EqualTo(new DateTime(2026, 1, 12)), + "a soft-deleted reconciled row must not hold the boundary"); + } + + [Test] + public async Task LockedThrough_IsPerWorker() + { + await Seed(703, new DateTime(2026, 1, 16), reconciled: true); + await Seed(704, new DateTime(2026, 1, 12), reconciled: false); + + // Await FIRST, then assert synchronously. Assert.Multiple(async () => ...) + // binds to the Action overload, making the lambda async void: its + // assertions can run after the block exits, and a failure is then lost + // or blamed on the wrong test. NUnit.Analyzers flags this. + var seven03 = await DayLockHelper.LockedThroughAsync(TimePlanningPnDbContext!, 703); + var seven04 = await DayLockHelper.LockedThroughAsync(TimePlanningPnDbContext!, 704); + + Assert.Multiple(() => + { + Assert.That(seven03, Is.EqualTo(new DateTime(2026, 1, 16))); + Assert.That(seven04, Is.Null, "one worker's boundary must not leak onto another"); + }); + } + + [Test] + public async Task LockedThroughForSites_ResolvesManyInOneQuery() + { + await Seed(705, new DateTime(2026, 1, 16), reconciled: true); + await Seed(706, new DateTime(2026, 1, 13), reconciled: true); + await Seed(707, new DateTime(2026, 1, 13), reconciled: false); + + var map = await DayLockHelper.LockedThroughForSitesAsync( + TimePlanningPnDbContext!, new[] { 705, 706, 707 }); + + Assert.Multiple(() => + { + Assert.That(map[705], Is.EqualTo(new DateTime(2026, 1, 16))); + Assert.That(map[706], Is.EqualTo(new DateTime(2026, 1, 13))); + Assert.That(map[707], Is.Null); + Assert.That(map.Count, Is.EqualTo(3), "every requested site gets an entry"); + }); + } + + [TestCase("2026-01-10", false, TestName = "IsLocked_BeforeBoundary_True")] + [TestCase("2026-01-16", false, TestName = "IsLocked_AtBoundary_True")] + [TestCase("2026-01-17", true, TestName = "IsLocked_AfterBoundary_False")] + public void IsLocked_RelativeToBoundary(string date, bool expectedOpen) + { + var boundary = new DateTime(2026, 1, 16); + + var locked = DayLockHelper.IsLocked(boundary, DateTime.Parse(date)); + + Assert.That(locked, Is.EqualTo(!expectedOpen)); + } + + [Test] + public void IsLocked_NoBoundary_NothingIsLocked() + { + Assert.That(DayLockHelper.IsLocked(null, new DateTime(2020, 1, 1)), Is.False); + } + + [Test] + public void IsLocked_IgnoresTimeOfDay() + { + var boundary = new DateTime(2026, 1, 16); + + Assert.That(DayLockHelper.IsLocked(boundary, new DateTime(2026, 1, 16, 23, 59, 59)), + Is.True, "the boundary day is locked for its whole length"); + } + + [Test] + public void CanReconcile_TodayAndFuture_False_Past_True() + { + Assert.Multiple(() => + { + Assert.That(DayLockHelper.CanReconcile(DateTime.Now.Date), Is.False, + "today must stay open so time can still be registered"); + Assert.That(DayLockHelper.CanReconcile(DateTime.Now.Date.AddDays(1)), Is.False); + Assert.That(DayLockHelper.CanReconcile(DateTime.Now.Date.AddDays(-1)), Is.True); + Assert.That(DayLockHelper.CanReconcile(DateTime.Now.Date.AddHours(23)), Is.False, + "a time-of-day on today is still today"); + }); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +This repo runs tests only in CI. Verify the failure shape locally with a build instead: + +Run: `cd eFormAPI/Plugins/TimePlanning.Pn && dotnet build TimePlanning.Pn.sln -v q` +Expected: FAIL — `error CS0103: The name 'DayLockHelper' does not exist in the current context` + +- [ ] **Step 3: Write minimal implementation** + +Create `TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs`: + +```csharp +#nullable enable +namespace TimePlanning.Pn.Infrastructure.Helpers; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microting.eForm.Infrastructure.Constants; +using Microting.TimePlanningBase.Infrastructure.Data; + +/// +/// The single source of truth for "is this day locked". +/// +/// The lock is DERIVED, never stored. A worker's boundary is the latest date +/// they have a Reconciled registration on; every day at or before it is locked. +/// Earlier days are therefore locked WITHOUT being marked Reconciled, and a day +/// below the boundary cannot be unlocked because unlocking it would not move +/// MAX(Date) -- both requirements fall out of the model instead of needing a +/// job to keep flags in sync. +/// +public static class DayLockHelper +{ + /// + /// The latest reconciled date for one worker, or null when they have none. + /// Soft-deleted rows never hold the boundary. + /// + public static async Task LockedThroughAsync(TimePlanningPnDbContext db, int sdkSitId) + { + return await db.PlanRegistrations + .Where(x => x.SdkSitId == sdkSitId) + .Where(x => x.Reconciled) + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .MaxAsync(x => (DateTime?)x.Date) + .ConfigureAwait(false); + } + + /// + /// Boundaries for many workers in ONE query. Callers that render a grid + /// resolve this once per request rather than once per day cell. + /// Every requested site gets an entry; sites with no reconciled day map to null. + /// + public static async Task> LockedThroughForSitesAsync( + TimePlanningPnDbContext db, IReadOnlyCollection sdkSitIds) + { + var found = await db.PlanRegistrations + .Where(x => sdkSitIds.Contains(x.SdkSitId)) + .Where(x => x.Reconciled) + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .GroupBy(x => x.SdkSitId) + .Select(g => new { SdkSitId = g.Key, Max = g.Max(x => x.Date) }) + .ToListAsync() + .ConfigureAwait(false); + + var map = found.ToDictionary(x => x.SdkSitId, x => (DateTime?)x.Max); + foreach (var id in sdkSitIds) + { + map.TryAdd(id, null); + } + return map; + } + + /// + /// Pure predicate, so callers can resolve the boundary once and test many + /// dates against it without touching the database again. + /// + public static bool IsLocked(DateTime? lockedThrough, DateTime date) + => lockedThrough.HasValue && date.Date <= lockedThrough.Value.Date; + + /// + /// Invariant I2: today and future days must stay open so time can still be + /// registered. This is also what makes the forward flex cascades unable to + /// reach a locked day -- see the design doc before relaxing it. + /// + /// DateTime.Now, not UtcNow: PlanRegistration.Date is a local midnight, and + /// the existing mobile guard compares the same way. + /// + public static bool CanReconcile(DateTime date) => date.Date < DateTime.Now.Date; +} +``` + +- [ ] **Step 4: Register the test class in BOTH CI shard filters** + +Without this the tests silently never run. In `.github/workflows/dotnet-core-pr.yml` line 251 and `.github/workflows/dotnet-core-master.yml` line 262, append to the shard `c` filter string: + +``` +|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests +``` + +- [ ] **Step 5: Build** + +Run: `cd eFormAPI/Plugins/TimePlanning.Pn && dotnet build TimePlanning.Pn.sln -v q` +Expected: `0 Error(s)` + +- [ ] **Step 6: Commit** + +```bash +git add eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs \ + .github/workflows/dotnet-core-pr.yml .github/workflows/dotnet-core-master.yml +git commit -m "feat(lock): derive the reconciled-day boundary from the data" +``` + +--- + +## Task 2: The enforcement interceptor + +**Files:** +- Create: `.../TimePlanning.Pn/Infrastructure/Interceptors/ReconciledDayLockInterceptor.cs` +- Modify: `.../TimePlanning.Pn/Infrastructure/Helpers/TimePlanningDbContextHelper.cs` (whole file) +- Modify: `.../TimePlanning.Pn/EformTimePlanningPlugin.cs:183-189` +- Modify: `.../TimePlanning.Pn.Test/TestBaseSetup.cs:32-42` and `:113-122` +- Test: `.../TimePlanning.Pn.Test/DayLockInterceptorTests.cs` +- Modify: both workflow files + +**Interfaces:** +- Consumes: `DayLockHelper.LockedThroughForSitesAsync`, `DayLockHelper.IsLocked` (Task 1). +- Produces: `ReconciledDayLockInterceptor` (stateless, parameterless ctor); `DayLockedException : InvalidOperationException`. + +**Why an interceptor at all:** there are 32 `PlanRegistration` write sites across 11 files in 2 repos and no choke point — `PnBase.Create/Update/Delete` live in the base NuGet. Guarding call sites alone would repeat the `MaxDaysEditable` mistake, which is enforced only on the read path and in the browser and is bypassable by a crafted POST today. Every write does go through EF change tracking (no raw SQL, no `ExecuteUpdate`), so one interceptor sees all of them. + +- [ ] **Step 1: Write the failing test** + +Create `TimePlanning.Pn.Test/DayLockInterceptorTests.cs`: + +```csharp +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microting.eForm.Infrastructure.Constants; +using NUnit.Framework; +using TimePlanning.Pn.Infrastructure.Interceptors; +using PlanRegistrationEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.PlanRegistration; + +namespace TimePlanning.Pn.Test; + +/// +/// The interceptor is the only layer that is COMPLETE -- it covers all 32 +/// PlanRegistration write sites and anything added later. These tests go +/// through a context built exactly as production builds it (interceptor +/// attached), so they prove the wiring, not just the class. +/// +[TestFixture] +public class DayLockInterceptorTests : TestBaseSetup +{ + [SetUp] + public async Task SetUpTest() => await base.Setup(); + + private async Task SeedReconciled(int site, DateTime date) + { + var row = new PlanRegistrationEntity + { + SdkSitId = site, Date = date, Reconciled = true, + ReconciledAt = new DateTime(2026, 1, 20, 9, 12, 0), + PlanText = "", CommentOffice = "", CommentOfficeAll = "", + WorkflowState = Constants.WorkflowStates.Created, + CreatedByUserId = 1, UpdatedByUserId = 1, + }; + await row.Create(TimePlanningPnDbContext!); + return row; + } + + private async Task SeedPlain(int site, DateTime date) + { + var row = new PlanRegistrationEntity + { + SdkSitId = site, Date = date, + PlanText = "", CommentOffice = "", CommentOfficeAll = "", + WorkflowState = Constants.WorkflowStates.Created, + CreatedByUserId = 1, UpdatedByUserId = 1, + }; + await row.Create(TimePlanningPnDbContext!); + return row; + } + + [Test] + public async Task Modifying_ADayBelowTheBoundary_IsRejected() + { + // Order matters: create the earlier row FIRST. Seeding the boundary + // first would put this Create inside the locked range, and the arrange + // step would throw before the assertion was ever reached. + var earlier = await SeedPlain(800, new DateTime(2026, 1, 13)); + await SeedReconciled(800, new DateTime(2026, 1, 16)); + + earlier.PlanHours = 9; + + Assert.ThrowsAsync(async () => + await earlier.Update(TimePlanningPnDbContext!)); + } + + [Test] + public async Task Modifying_TheBoundaryDayItself_IsRejected() + { + var boundary = await SeedReconciled(801, new DateTime(2026, 1, 16)); + + boundary.PlanHours = 9; + + Assert.ThrowsAsync(async () => + await boundary.Update(TimePlanningPnDbContext!)); + } + + [Test] + public async Task Creating_ARowInsideALockedRange_IsRejected() + { + await SeedReconciled(802, new DateTime(2026, 1, 16)); + + Assert.ThrowsAsync(async () => + await SeedPlain(802, new DateTime(2026, 1, 14))); + } + + [Test] + public async Task SoftDeleting_ALockedRow_IsRejected() + { + // PnBase.Delete is a soft delete -- it sets WorkflowState = Removed via + // UpdateInternal, so this arrives at the interceptor as Modified, not + // Deleted. The name says SoftDeleting so nobody reads a pass here as + // proof that the EntityState.Deleted arm works. + var earlier = await SeedPlain(803, new DateTime(2026, 1, 13)); + await SeedReconciled(803, new DateTime(2026, 1, 16)); + + Assert.ThrowsAsync(async () => + await earlier.Delete(TimePlanningPnDbContext!)); + } + + [Test] + public async Task Modifying_ADayAboveTheBoundary_IsAllowed() + { + var later = await SeedPlain(804, new DateTime(2026, 1, 18)); + await SeedReconciled(804, new DateTime(2026, 1, 16)); + + later.PlanHours = 9; + await later.Update(TimePlanningPnDbContext!); + + var reloaded = await TimePlanningPnDbContext!.PlanRegistrations + .FirstAsync(x => x.Id == later.Id); + Assert.That(reloaded.PlanHours, Is.EqualTo(9), + "days after the boundary must stay fully editable"); + } + + [Test] + public async Task ANotherWorkersBoundary_DoesNotLockThisWorker() + { + var other = await SeedPlain(806, new DateTime(2026, 1, 13)); + await SeedReconciled(805, new DateTime(2026, 1, 16)); + + other.PlanHours = 9; + await other.Update(TimePlanningPnDbContext!); + + var reloaded = await TimePlanningPnDbContext!.PlanRegistrations + .FirstAsync(x => x.Id == other.Id); + Assert.That(reloaded.PlanHours, Is.EqualTo(9), + "the boundary is per worker; it must not leak across sites"); + } + + [Test] + public async Task SettingReconciled_OnTheBoundaryDay_IsAllowed() + { + // Unlocking must not be blocked by the very lock it is clearing. + var boundary = await SeedReconciled(807, new DateTime(2026, 1, 16)); + + boundary.Reconciled = false; + boundary.ReconciledAt = null; + await boundary.Update(TimePlanningPnDbContext!); + + var reloaded = await TimePlanningPnDbContext!.PlanRegistrations + .FirstAsync(x => x.Id == boundary.Id); + Assert.That(reloaded.Reconciled, Is.False, + "clearing the flag on the boundary day is how unlocking works"); + } +} +``` + +- [ ] **Step 2: Run build to verify it fails** + +Run: `cd eFormAPI/Plugins/TimePlanning.Pn && dotnet build TimePlanning.Pn.sln -v q` +Expected: FAIL — `error CS0246: The type or namespace name 'DayLockedException' could not be found` + +- [ ] **Step 3: Write the interceptor** + +Create `TimePlanning.Pn/Infrastructure/Interceptors/ReconciledDayLockInterceptor.cs`: + +```csharp +#nullable enable +namespace TimePlanning.Pn.Infrastructure.Interceptors; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Helpers; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microting.TimePlanningBase.Infrastructure.Data; +using PlanRegistrationEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.PlanRegistration; + +/// +/// Thrown when a write would touch a day at or before the worker's reconciled +/// boundary. Distinct from a generic InvalidOperationException so the friendly +/// guards in the services can tell "the lock stopped this" from "something else +/// broke" -- and so a stray one is legible in Sentry. +/// +public class DayLockedException(int sdkSitId, DateTime date, DateTime lockedThrough) + : InvalidOperationException( + $"Day {date:yyyy-MM-dd} for site {sdkSitId} is locked: reconciled through {lockedThrough:yyyy-MM-dd}.") +{ + public int SdkSitId { get; } = sdkSitId; + public DateTime Date { get; } = date; + public DateTime LockedThrough { get; } = lockedThrough; +} + +/// +/// Enforces invariant I3 across every PlanRegistration write path. +/// +/// STATELESS BY DESIGN. The pooled context registration +/// (EformTimePlanningPlugin.AddDbContextPool) reuses context instances, so an +/// interceptor holding per-request state would leak it between requests. +/// Everything this needs is read from the change tracker on each call. +/// +/// The boundary is resolved ONCE per SaveChanges for the distinct sites in the +/// change set, not once per row. +/// +public class ReconciledDayLockInterceptor : SaveChangesInterceptor +{ + public override InterceptionResult SavingChanges( + DbContextEventData eventData, InterceptionResult result) + { + GuardAsync(eventData.Context, CancellationToken.None).GetAwaiter().GetResult(); + return base.SavingChanges(eventData, result); + } + + public override async ValueTask> SavingChangesAsync( + DbContextEventData eventData, InterceptionResult result, + CancellationToken cancellationToken = default) + { + await GuardAsync(eventData.Context, cancellationToken).ConfigureAwait(false); + return await base.SavingChangesAsync(eventData, result, cancellationToken) + .ConfigureAwait(false); + } + + private static async Task GuardAsync(DbContext? context, CancellationToken ct) + { + if (context is not TimePlanningPnDbContext db) + { + return; + } + + // PnBase.Delete is a SOFT delete: it sets WorkflowState = Removed and + // routes through UpdateInternal, so a delete reaches here as Modified, + // never Deleted. The Deleted arm is kept as insurance against a future + // hard delete; do not "simplify" it away on the grounds that it never + // fires today. + var touched = db.ChangeTracker.Entries() + .Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted) + .ToList(); + + if (touched.Count == 0) + { + return; + } + + var siteIds = touched.Select(e => e.Entity.SdkSitId).Distinct().ToList(); + + // Query `db` itself. An earlier draft opened a second context here; + // that is a production outage, because ServerVersion.AutoDetect opens a + // connection and runs a version query, and this guard runs on EVERY + // save -- including the four Update calls inside the per-day loop of + // UpdatePlanRegistrationsInPeriod, which runs on every dashboard load. + // 50 workers x 31 days would mean thousands of extra connections per + // page view. + // + // Querying `db` is safe: LockedThroughForSitesAsync projects into an + // anonymous type so it tracks nothing and cannot pollute the change + // tracker; a query never re-enters SaveChanges so there is no + // recursion; and it reuses the open connection and any ambient + // transaction, which a separate context could not see. + var boundaries = await DayLockHelper + .LockedThroughForSitesAsync(db, siteIds) + .ConfigureAwait(false); + + foreach (var entry in touched) + { + var siteId = entry.Entity.SdkSitId; + if (!boundaries.TryGetValue(siteId, out var boundary) || boundary is null) + { + continue; + } + + if (!DayLockHelper.IsLocked(boundary, entry.Entity.Date)) + { + continue; + } + + // The one permitted write inside the locked range: clearing the flag + // on the boundary day itself. That is what unlocking IS, and it must + // not be blocked by the lock it is removing. + if (IsUnlockOfBoundaryDay(entry, boundary.Value)) + { + continue; + } + + throw new DayLockedException(siteId, entry.Entity.Date, boundary.Value); + } + } + + private static bool IsUnlockOfBoundaryDay( + EntityEntry entry, DateTime boundary) + { + if (entry.State != EntityState.Modified) + { + return false; + } + if (entry.Entity.Date.Date != boundary.Date) + { + return false; + } + // Reconciled must be going true -> false, and nothing else about the row + // may be changing in the same save. + var reconciled = entry.Property(x => x.Reconciled); + if (!reconciled.IsModified || (bool)reconciled.CurrentValue! ) + { + return false; + } + var changedOthers = entry.Properties + .Where(p => p.IsModified) + .Select(p => p.Metadata.Name) + .Where(n => n is not (nameof(PlanRegistrationEntity.Reconciled) + or nameof(PlanRegistrationEntity.ReconciledAt) + or nameof(PlanRegistrationEntity.UpdatedAt) + or nameof(PlanRegistrationEntity.Version) + or nameof(PlanRegistrationEntity.UpdatedByUserId))) + .ToList(); + + return changedOthers.Count == 0; + } +} +``` + +- [ ] **Step 4: Wire it into the web helper** + +Replace the body of `TimePlanning.Pn/Infrastructure/Helpers/TimePlanningDbContextHelper.cs` entirely: + +```csharp +using System; +using Microsoft.EntityFrameworkCore; +using Microting.TimePlanningBase.Infrastructure.Data; +using TimePlanning.Pn.Infrastructure.Interceptors; +// NB: no Pomelo using. This repo uses the Microting.EntityFrameworkCore.MySql +// fork, and MariaDbServerVersion/ServerVersion come from +// Microsoft.EntityFrameworkCore -- which is why EformTimePlanningPlugin.cs +// needs no provider-specific using either. Adding a Pomelo PackageReference +// would introduce a second, conflicting provider. + +namespace TimePlanning.Pn.Infrastructure.Helpers; + +/// +/// Builds plugin DbContexts with the day-lock interceptor attached. +/// +/// This no longer delegates to TimePlanningPnContextFactory: that factory +/// builds its DbContextOptionsBuilder in a method-local and exposes no hook, so +/// there is no way to attach an interceptor through it. The context's public +/// options constructor is the supported seam, and it needs no base-package change. +/// +public class TimePlanningDbContextHelper(string connectionString) : ITimePlanningDbContextHelper +{ + private string ConnectionString { get; } = connectionString; + + public TimePlanningPnDbContext GetDbContext() + { + var optionsBuilder = new DbContextOptionsBuilder(); + + // Hardcoded version, exactly as TimePlanningPnContextFactory does + // (factory line 39). ServerVersion.AutoDetect OPENS A CONNECTION and + // runs a version query; this method is called once per assigned site on + // every dashboard load, so AutoDetect here would add a round-trip per + // worker per page view that the current code does not pay. + optionsBuilder.UseMySql( + ConnectionString, + new MariaDbServerVersion(new Version(10, 5, 0)), + mySqlOptionsAction: builder => { builder.EnableRetryOnFailure(); }); + + optionsBuilder.AddInterceptors(new ReconciledDayLockInterceptor()); + + return new TimePlanningPnDbContext(optionsBuilder.Options); + } +} + +public interface ITimePlanningDbContextHelper +{ + TimePlanningPnDbContext GetDbContext(); +} +``` + +- [ ] **Step 5: Wire it into the pooled registration** + +In `TimePlanning.Pn/EformTimePlanningPlugin.cs`, change lines 183-189 to add the interceptor. The pool reuses instances, which is safe here only because the interceptor is stateless: + +```csharp + services.AddDbContextPool(o => + o.UseMySql(connectionString, new MariaDbServerVersion( + ServerVersion.AutoDetect(connectionString)), mySqlOptionsAction: builder => + { + builder.EnableRetryOnFailure(); + builder.MigrationsAssembly(PluginAssembly().FullName); + }) + .AddInterceptors(new ReconciledDayLockInterceptor())); +``` + +Add at the top of the file: `using TimePlanning.Pn.Infrastructure.Interceptors;` + +- [ ] **Step 6: Make the tests exercise the real wiring** + +`TestBaseSetup` builds its own contexts, so without this the tests would bypass the interceptor entirely and the lock would look tested while being unenforced. + +In `TimePlanning.Pn.Test/TestBaseSetup.cs`, add `using TimePlanning.Pn.Infrastructure.Interceptors;` and append `.AddInterceptors(new ReconciledDayLockInterceptor())` to the options in **both** `GetTimePlanningPnDbContext` (after line 40) and `CreateTimePlanningPnDbContext` (after line 120): + +```csharp + optionsBuilder.AddInterceptors(new ReconciledDayLockInterceptor()); +``` + +- [ ] **Step 7: Register the test class in BOTH shard filters** + +Append to the shard `c` filter in `dotnet-core-pr.yml:251` and `dotnet-core-master.yml:262`: + +``` +|FullyQualifiedName=TimePlanning.Pn.Test.DayLockInterceptorTests +``` + +- [ ] **Step 8: Build** + +Run: `cd eFormAPI/Plugins/TimePlanning.Pn && dotnet build TimePlanning.Pn.sln -v q` +Expected: `0 Error(s)` + +- [ ] **Step 9: Commit** + +```bash +git add eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Interceptors/ReconciledDayLockInterceptor.cs \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/TimePlanningDbContextHelper.cs \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/EformTimePlanningPlugin.cs \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockInterceptorTests.cs \ + .github/workflows/dotnet-core-pr.yml .github/workflows/dotnet-core-master.yml +git commit -m "feat(lock): enforce the day lock at the SaveChanges boundary" +``` + +--- + +## Task 3: Localized messages + +**Files:** +- Modify: `.../TimePlanning.Pn/Resources/Translations.resx` (append before `` at :204) +- Modify: `.../TimePlanning.Pn/Resources/Translations.da.resx` + +**Interfaces:** +- Produces: six resx keys consumed by Tasks 4 and 5. + +Note: several existing keys (`PlanningNotFound`, `ErrorWhileUpdatingPlanning`) are **not** in any resx — `IStringLocalizer` returns the key itself when unresolved. Do not follow that precedent; add these properly. + +- [ ] **Step 1: Add the English entries** + +In `Translations.resx`, before ``: + +```xml + + This day is reconciled. The figures are final. + + + This day is locked because it is before a reconciled day. + + + Only days before today can be reconciled. + + + Unlock the most recent reconciled day first. + + + Day reconciled + + + Day unlocked + +``` + +- [ ] **Step 2: Add the Danish entries** + +In `Translations.da.resx`, before ``: + +```xml + + Dagen er afstemt. Tallene er endelige. + + + Dagen er låst, fordi den ligger før en afstemt dag. + + + Kun dage før i dag kan afstemmes. + + + Lås den seneste afstemte dag op først. + + + Dagen er afstemt + + + Dagen er låst op + +``` + +- [ ] **Step 3: Build and commit** + +Run: `cd eFormAPI/Plugins/TimePlanning.Pn && dotnet build TimePlanning.Pn.sln -v q` +Expected: `0 Error(s)` + +```bash +git add eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx +git commit -m "feat(lock): add the day-lock messages in English and Danish" +``` + +--- + +## Task 4: Reconcile / unlock / bulk endpoints + +**Files:** +- Create: `.../Infrastructure/Models/Planning/ReconcileThroughRequestModel.cs` +- Create: `.../Infrastructure/Models/Planning/ReconcileThroughResultModel.cs` +- Modify: `.../Services/TimePlanningPlanningService/ITimePlanningPlanningService.cs` +- Modify: `.../Services/TimePlanningPlanningService/TimePlanningPlanningService.cs` (append three methods) +- Modify: `.../Controllers/TimePlanningPlanningController.cs` +- Test: `.../TimePlanning.Pn.Test/ReconcileServiceTests.cs` +- Modify: both workflow files + +**Interfaces:** +- Consumes: `DayLockHelper.*` (Task 1); resx keys (Task 3). +- Produces: + - `ITimePlanningPlanningService.Reconcile(int id) -> Task` + - `ITimePlanningPlanningService.Unreconcile(int id) -> Task` + - `ITimePlanningPlanningService.ReconcileThrough(ReconcileThroughRequestModel model) -> Task>` + - `ReconcileThroughRequestModel { DateTime Date; List SiteIds; }` + - `ReconcileThroughResultModel { Dictionary LandedOnBySiteId; int Applied; List SkippedAlreadyFurtherForward; List SkippedNoRegistration; List AlreadyReconciledSiteIds; }` + +- [ ] **Step 1: Write the failing test** + +Create `TimePlanning.Pn.Test/ReconcileServiceTests.cs`. + +**Three of the four symbols these tests use are not reachable from a new fixture — you must provide them:** + +| symbol | status | what to do | +|---|---|---| +| `GetBaseDbContext()` | `protected` on `TestBaseSetup:85` | inherited, use as-is | +| `_service` | `private` field of `PlanningServiceMultiShiftTests:31` | declare your own | +| `BuildAdminIndexServiceAsync` | `private`, `PlanningServiceMultiShiftTests:925-957` | copy it into this fixture | +| `OneDayRequest` | `private`, `:959-963` | copy it | +| `SeedPlain` | **does not exist anywhere** | write it (below) | + +Mirror the `[SetUp]` from `PlanningServiceMultiShiftTests.cs:38-81` verbatim (same substitutes, same service construction, same `_service`/`_userService`/`_dbContextHelper`/`_options` fields), copy `BuildAdminIndexServiceAsync` and `OneDayRequest` verbatim, and add this seed helper: + +```csharp + /// + /// One plain PlanRegistration. Returns the tracked entity so tests can + /// reconcile it by Id. Note PnBase.Create forces WorkflowState to "created". + /// + private async Task SeedPlain(int siteId, DateTime date) + { + var row = new PlanRegistrationEntity + { + SdkSitId = siteId, + Date = date, + PlanText = "", + CommentOffice = "", + CommentOfficeAll = "", + WorkflowState = Constants.WorkflowStates.Created, + CreatedByUserId = 1, + UpdatedByUserId = 1, + }; + await row.Create(TimePlanningPnDbContext!); + return row; + } +``` + +Then the tests: + +```csharp + [Test] + public async Task Reconcile_APastDay_SetsFlagAndTimestamp() + { + var row = await SeedPlain(900, DateTime.Now.Date.AddDays(-5)); + + var result = await _service.Reconcile(row.Id); + + Assert.That(result.Success, Is.True, result.Message); + var reloaded = await TimePlanningPnDbContext!.PlanRegistrations.FirstAsync(x => x.Id == row.Id); + Assert.Multiple(() => + { + Assert.That(reloaded.Reconciled, Is.True); + Assert.That(reloaded.ReconciledAt, Is.Not.Null, "I1: the timestamp is written with the flag"); + }); + } + + [Test] + public async Task Reconcile_Today_IsRejected() + { + var row = await SeedPlain(901, DateTime.Now.Date); + + var result = await _service.Reconcile(row.Id); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("CannotReconcileTodayOrFuture"), + "I2: today must stay open so time can still be registered"); + } + + [Test] + public async Task Reconcile_AFutureDay_IsRejected() + { + var row = await SeedPlain(902, DateTime.Now.Date.AddDays(3)); + + var result = await _service.Reconcile(row.Id); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("CannotReconcileTodayOrFuture")); + } + + [Test] + public async Task Reconcile_AnAlreadyReconciledDay_IsAnIdempotentSuccess() + { + var row = await SeedPlain(903, DateTime.Now.Date.AddDays(-5)); + await _service.Reconcile(row.Id); + + var again = await _service.Reconcile(row.Id); + + Assert.That(again.Success, Is.True, "re-reconciling is a no-op, not an error"); + } + + [Test] + public async Task Unreconcile_BelowTheBoundary_IsRejected_AndNamesTheBlockingDay() + { + var earlier = await SeedPlain(904, DateTime.Now.Date.AddDays(-8)); + var later = await SeedPlain(904, DateTime.Now.Date.AddDays(-3)); + await _service.Reconcile(earlier.Id); + await _service.Reconcile(later.Id); + + var result = await _service.Unreconcile(earlier.Id); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("OnlyLatestReconciledDayCanBeUnlocked"), + "the puzzle rule: free the outermost piece first"); + } + + [Test] + public async Task Unreconcile_AtTheBoundary_MovesItBackToTheNextNewest() + { + var earlier = await SeedPlain(905, DateTime.Now.Date.AddDays(-8)); + var later = await SeedPlain(905, DateTime.Now.Date.AddDays(-3)); + await _service.Reconcile(earlier.Id); + await _service.Reconcile(later.Id); + + var result = await _service.Unreconcile(later.Id); + + Assert.That(result.Success, Is.True, result.Message); + var boundary = await DayLockHelper.LockedThroughAsync(TimePlanningPnDbContext!, 905); + Assert.That(boundary, Is.EqualTo(earlier.Date), + "the boundary moves back one notch, it does not vanish"); + } + + [Test] + public async Task ReconcileThrough_MarksOneDayPerWorker_AndSkipsThoseAlreadyFurtherForward() + { + var aEarly = await SeedPlain(906, DateTime.Now.Date.AddDays(-6)); + var bEarly = await SeedPlain(907, DateTime.Now.Date.AddDays(-6)); + var bLate = await SeedPlain(907, DateTime.Now.Date.AddDays(-2)); + await _service.Reconcile(bLate.Id); // 907's boundary is already newer + + var result = await _service.ReconcileThrough(new ReconcileThroughRequestModel + { + Date = DateTime.Now.Date.AddDays(-6), + SiteIds = new List { 906, 907 } + }); + + Assert.That(result.Success, Is.True, result.Message); + Assert.Multiple(() => + { + Assert.That(result.Model.Applied, Is.EqualTo(1)); + Assert.That(result.Model.SkippedAlreadyFurtherForward, Is.EquivalentTo(new[] { 907 }), + "moving 907 backwards would be an unlock, which is a separate action"); + Assert.That(result.Model.SkippedNoRegistration, Is.Empty, + "907 was skipped for the other reason -- the two must not be conflated"); + Assert.That(result.Model.LandedOnBySiteId[906], Is.EqualTo(aEarly.Date)); + Assert.That(result.Model.LandedOnBySiteId.ContainsKey(907), Is.False); + }); + } + + [Test] + public async Task ReconcileThrough_LandsOnTheLatestDayWithARegistration() + { + await SeedPlain(908, DateTime.Now.Date.AddDays(-9)); + // nothing on -8 or -7 + var target = DateTime.Now.Date.AddDays(-7); + + var result = await _service.ReconcileThrough(new ReconcileThroughRequestModel + { + Date = target, SiteIds = new List { 908 } + }); + + Assert.That(result.Model.LandedOnBySiteId[908], Is.EqualTo(DateTime.Now.Date.AddDays(-9)), + "a seal on a day with no registration would mean nothing"); + } +``` + +- [ ] **Step 2: Run build to verify it fails** + +Run: `cd eFormAPI/Plugins/TimePlanning.Pn && dotnet build TimePlanning.Pn.sln -v q` +Expected: FAIL — `'ITimePlanningPlanningService' does not contain a definition for 'Reconcile'` + +- [ ] **Step 3: Add the request/result models** + +Create `Infrastructure/Models/Planning/ReconcileThroughRequestModel.cs`: + +```csharp +#nullable enable +namespace TimePlanning.Pn.Infrastructure.Models.Planning; + +using System; +using System.Collections.Generic; + +/// +/// One date, many workers. The cascade supplies the range, so this never +/// carries a range of its own. +/// +public class ReconcileThroughRequestModel +{ + public DateTime Date { get; set; } + public List SiteIds { get; set; } = new(); +} +``` + +Create `Infrastructure/Models/Planning/ReconcileThroughResultModel.cs`: + +```csharp +#nullable enable +namespace TimePlanning.Pn.Infrastructure.Models.Planning; + +using System; +using System.Collections.Generic; + +public class ReconcileThroughResultModel +{ + /// Where each worker's boundary landed. Per worker, not shared: + /// the mark falls on that worker's latest day with a registration at or + /// before the requested date, so a staircase has no single landing date. + public Dictionary LandedOnBySiteId { get; set; } = new(); + + /// Workers whose boundary actually moved. Excludes no-ops. + public int Applied { get; set; } + + /// Already reconciled at or past the target. Moving them back would + /// be an unlock, which is deliberately a separate, heavier action. + public List SkippedAlreadyFurtherForward { get; set; } = new(); + + /// No registration at or before the target, so there was nothing + /// to mark. A distinct case from the above — the spec distinguishes them. + public List SkippedNoRegistration { get; set; } = new(); + + /// Already marked on exactly the landing day; nothing changed. + public List AlreadyReconciledSiteIds { get; set; } = new(); +} +``` + +- [ ] **Step 4: Add the three interface members** + +In `ITimePlanningPlanningService.cs`, inside the interface body: + +```csharp + Task Reconcile(int id); + Task Unreconcile(int id); + Task> ReconcileThrough(ReconcileThroughRequestModel model); +``` + +- [ ] **Step 5: Implement the three methods** + +Append to `TimePlanningPlanningService.cs`, inside the class: + +```csharp + public async Task Reconcile(int id) + { + try + { + await using var db = dbContextHelper.GetDbContext(); + var planning = await db.PlanRegistrations + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .FirstOrDefaultAsync(x => x.Id == id); + + if (planning == null) + { + return new OperationResult(false, localizationService.GetString("PlanningNotFound")); + } + + // Idempotent: re-reconciling an already reconciled day is a no-op + // success. Clients retry; that should not read as a failure. + if (planning.Reconciled) + { + return new OperationResult(true, localizationService.GetString("SuccessfullyReconciledDay")); + } + + if (!DayLockHelper.CanReconcile(planning.Date)) + { + return new OperationResult(false, + localizationService.GetString("CannotReconcileTodayOrFuture")); + } + + // Without this, reconciling a day BELOW an existing boundary passes + // CanReconcile, reaches Update, and the interceptor throws into the + // generic catch -- a 500-shaped "ErrorWhileUpdatingPlanning" instead + // of a message. That is exactly what Layer 2 exists to prevent. + var existingBoundary = await DayLockHelper.LockedThroughAsync(db, planning.SdkSitId); + if (DayLockHelper.IsLocked(existingBoundary, planning.Date)) + { + return new OperationResult(false, + localizationService.GetString("DayIsLockedByReconciledDay")); + } + + planning.Reconciled = true; + // DateTime.Now, not UtcNow: the tooltip renders this verbatim as + // "Afstemt kl. ", and UTC would read 1-2 hours off in + // Danish time. Consistent with the CanReconcile comparison. + planning.ReconciledAt = DateTime.Now; // I1: written together + planning.UpdatedByUserId = userService.UserId; + await planning.Update(db); + + return new OperationResult(true, localizationService.GetString("SuccessfullyReconciledDay")); + } + catch (DayLockedException) + { + // Expected and routine: a blocked edit is a normal outcome, not an + // incident. Do not report it to Sentry. + return new OperationResult(false, + localizationService.GetString("DayIsLockedByReconciledDay")); + } + catch (Exception e) + { + SentrySdk.CaptureException(e); + logger.LogError(e, "TimePlanningPlanningService.Reconcile failed"); + return new OperationResult(false, localizationService.GetString("ErrorWhileUpdatingPlanning")); + } + } + + public async Task Unreconcile(int id) + { + try + { + await using var db = dbContextHelper.GetDbContext(); + var planning = await db.PlanRegistrations + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .FirstOrDefaultAsync(x => x.Id == id); + + if (planning == null) + { + return new OperationResult(false, localizationService.GetString("PlanningNotFound")); + } + // Boundary check FIRST. If the idempotency check came first, a user + // clicking unlock on a cascade-locked day (Reconciled = false, deep + // inside the range) would be told "Dagen er låst op" while nothing + // happened. + var boundary = await DayLockHelper.LockedThroughAsync(db, planning.SdkSitId); + if (boundary is null || planning.Date.Date != boundary.Value.Date) + { + return new OperationResult(false, + localizationService.GetString("OnlyLatestReconciledDayCanBeUnlocked")); + } + + if (!planning.Reconciled) + { + return new OperationResult(true, localizationService.GetString("SuccessfullyUnlockedDay")); + } + + planning.Reconciled = false; + planning.ReconciledAt = null; // I1: cleared together + planning.UpdatedByUserId = userService.UserId; + await planning.Update(db); + + return new OperationResult(true, localizationService.GetString("SuccessfullyUnlockedDay")); + } + catch (Exception e) + { + SentrySdk.CaptureException(e); + logger.LogError(e, "TimePlanningPlanningService.Unreconcile failed"); + return new OperationResult(false, localizationService.GetString("ErrorWhileUpdatingPlanning")); + } + } + + public async Task> ReconcileThrough( + ReconcileThroughRequestModel model) + { + try + { + if (model == null || model.SiteIds.Count == 0) + { + return new OperationDataResult(false, + localizationService.GetString("ErrorWhileUpdatingPlanning")); + } + if (!DayLockHelper.CanReconcile(model.Date)) + { + return new OperationDataResult(false, + localizationService.GetString("CannotReconcileTodayOrFuture")); + } + + await using var db = dbContextHelper.GetDbContext(); + // Distinct: a duplicated site id in the request would otherwise be + // counted twice. + var siteIds = model.SiteIds.Distinct().ToList(); + var boundaries = await DayLockHelper.LockedThroughForSitesAsync(db, siteIds); + var result = new ReconcileThroughResultModel(); + var target = model.Date.Date; + + foreach (var siteId in siteIds) + { + // Already at or past the target: moving the boundary BACK would + // be an unlock, which is deliberately a separate, heavier action. + if (boundaries.TryGetValue(siteId, out var existing) + && existing.HasValue && existing.Value.Date >= target) + { + result.SkippedAlreadyFurtherForward.Add(siteId); + continue; + } + + // The mark must land on a day that actually has a registration — + // a seal on an empty day means nothing. + var landing = await db.PlanRegistrations + .Where(x => x.SdkSitId == siteId) + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .Where(x => x.Date <= target) + .OrderByDescending(x => x.Date) + .FirstOrDefaultAsync(); + + if (landing == null) + { + // A different reason from "already further forward", and the + // spec distinguishes them -- do not merge the two lists. + result.SkippedNoRegistration.Add(siteId); + continue; + } + + if (landing.Reconciled) + { + // Already marked on exactly this day: nothing to do, and it + // must not inflate Applied. + result.AlreadyReconciledSiteIds.Add(siteId); + continue; + } + + landing.Reconciled = true; + landing.ReconciledAt = DateTime.Now; // see Reconcile() + landing.UpdatedByUserId = userService.UserId; + await landing.Update(db); + + result.Applied++; + // Per-worker, because the boundary is a staircase: one shared + // LandedOn would name the wrong date for most workers. + result.LandedOnBySiteId[siteId] = landing.Date; + } + + return new OperationDataResult(true, result); + } + catch (Exception e) + { + SentrySdk.CaptureException(e); + logger.LogError(e, "TimePlanningPlanningService.ReconcileThrough failed"); + return new OperationDataResult(false, + localizationService.GetString("ErrorWhileUpdatingPlanning")); + } + } +``` + +Add `using TimePlanning.Pn.Infrastructure.Helpers;` if not already present. + +- [ ] **Step 6: Add the three routes** + +In `Controllers/TimePlanningPlanningController.cs`, matching the file's existing separate-attribute style: + +```csharp + [HttpPut] + [Route("{id}/reconcile")] + public async Task Reconcile(int id) + { + return await _planningService.Reconcile(id); + } + + [HttpPut] + [Route("{id}/unreconcile")] + public async Task Unreconcile(int id) + { + return await _planningService.Unreconcile(id); + } + + [HttpPut] + [Route("reconcile-through")] + public async Task> ReconcileThrough( + [FromBody] ReconcileThroughRequestModel model) + { + return await _planningService.ReconcileThrough(model); + } +``` + +- [ ] **Step 7: Register the test class in BOTH shard filters** + +Append `|FullyQualifiedName=TimePlanning.Pn.Test.ReconcileServiceTests` to shard `c` in both workflow files. + +- [ ] **Step 8: Build and commit** + +Run: `cd eFormAPI/Plugins/TimePlanning.Pn && dotnet build TimePlanning.Pn.sln -v q` +Expected: `0 Error(s)` + +```bash +git add eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/ReconcileThroughRequestModel.cs \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/ReconcileThroughResultModel.cs \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/ITimePlanningPlanningService.cs \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/TimePlanningPlanningController.cs \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs \ + .github/workflows/dotnet-core-pr.yml .github/workflows/dotnet-core-master.yml +git commit -m "feat(lock): add reconcile, unlock and reconcile-through endpoints" +``` + +--- + +## Task 5: Friendly guards and recalculation skipping + +**Files:** +- Modify: `.../Services/TimePlanningPlanningService/TimePlanningPlanningService.cs` (`Update` :671 (the null check ends :700), `UpdateByCurrentUserNam` ~:1164, gap-fill loops :370-402 and :593-623) +- Modify: `.../Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs` (`CreateUpdate` :454, `UpdateWorkingHour` :1339 and :2012) +- Modify: `.../Infrastructure/Helpers/PlanRegistrationHelper.cs` (`UpdatePlanRegistrationsInPeriod` :394) + +**Interfaces:** +- Consumes: `DayLockHelper.*`, resx keys. +- Produces: no new public API. + +**Why both layers:** the interceptor guarantees correctness but surfaces as an exception. These guards turn the common, user-triggered cases into a clean message. The recalculation paths get the opposite treatment — they *skip* rather than throw, because a dashboard load legitimately spans the boundary on every single request. + +- [ ] **Step 1: Guard `TimePlanningPlanningService.Update`** + +Immediately after the `planning == null` check (currently ends :700), insert: + +```csharp + var lockedThrough = await DayLockHelper.LockedThroughAsync(dbContext, planning.SdkSitId); + if (DayLockHelper.IsLocked(lockedThrough, planning.Date)) + { + return new OperationResult(false, localizationService.GetString( + planning.Reconciled ? "DayIsReconciled" : "DayIsLockedByReconciledDay")); + } +``` + +- [ ] **Step 2: Guard `UpdateByCurrentUserNam`** + +Same block, immediately after that method's own null/lookup check for the planning row. + +- [ ] **Step 3: Guard the three working-hours write paths** + +In `TimePlanningWorkingHoursService.CreateUpdate` (:454), after the assigned-site lookup and before the per-day loop writes, reject the whole request if any posted day is locked: + +```csharp + var lockedThrough = await DayLockHelper.LockedThroughAsync(dbContext, model.SiteId); + if (lockedThrough.HasValue + && model.Plannings.Any(p => DayLockHelper.IsLocked(lockedThrough, p.Date))) + { + return new OperationResult(false, + localizationService.GetString("DayIsLockedByReconciledDay")); + } +``` + +The two `UpdateWorkingHour` overloads need **different** code — their signatures differ, and neither matches a naive copy: + +**Personal overload (`:1339`)** — there is no `sdkSiteId` variable in this method. The site comes from the SDK site looked up at `:1381-1383`, so place the guard after that lookup: + +```csharp + var lockedThrough = await DayLockHelper.LockedThroughAsync( + dbContext, (int)sdkSite.MicrotingUid!); + if (DayLockHelper.IsLocked(lockedThrough, model.Date)) + { + return new OperationResult(false, + localizationService.GetString("DayIsLockedByReconciledDay")); + } +``` + +**Kiosk overload (`:2012`)** — `sdkSiteId` is a parameter and is **`int?`**. It has no assigned-site lookup near the top; the lookup is duplicated deep inside two branches (`:2308-2310` and `:2591-2593`), so guarding "after the lookup" would protect one branch and leave the other open. Guard at the **top of the method**, from the parameter, before either branch: + +```csharp + if (sdkSiteId.HasValue) + { + var lockedThrough = await DayLockHelper.LockedThroughAsync(dbContext, sdkSiteId.Value); + if (DayLockHelper.IsLocked(lockedThrough, model.Date)) + { + return new OperationResult(false, + localizationService.GetString("DayIsLockedByReconciledDay")); + } + } +``` + +The kiosk overload currently has **no date guard of any kind** — this is its first. Note neither overload wraps its body in try/catch (only `CreateUpdate` does, at `:456`), so a guard that throws would surface raw. + +- [ ] **Step 4: Skip locked days in the bulk recompute** + +`UpdatePlanRegistrationsInPeriod` has exactly **one** loop — `foreach (var plan in planningsInPeriod)` at `PlanRegistrationHelper.cs:423`, whose body runs from `:424` to `:1138`. That single body both **writes** (four `Update` calls) and **projects** (`:862` builds the DTO, `:1137` adds it to the row). + +**Do NOT change what the loop iterates.** Swapping the source to a filtered list would drop locked days out of the grid entirely, which contradicts spec §6.3 — locked days must still be *displayed*, just never rewritten. That is a silent wrong result, not a build error, so it would ship. + +Instead: resolve the boundary **once before** the loop, and guard **only the four writes**. + +Before `foreach (var plan in planningsInPeriod)` at `:423`: + +```csharp + // A dashboard load legitimately spans the boundary, so locked days are + // skipped rather than raising. Without this, the interceptor turns + // every visit to a closed month into a 500. + var lockedThrough = await DayLockHelper.LockedThroughAsync(dbContext, dbAssignedSite.SiteId); +``` + +Immediately after `planRegistration` is materialised at `:425`: + +```csharp + var dayIsLocked = DayLockHelper.IsLocked(lockedThrough, planRegistration.Date); +``` + +Then wrap **each** of the four `await planRegistration.Update(dbContext)` calls — at `:471`, `:542`, `:810` and `:834` — as: + +```csharp + if (!dayIsLocked) + { + await planRegistration.Update(dbContext).ConfigureAwait(false); + } +``` + +Everything from `:862` down is untouched, so the projection is preserved by construction. + +**Also note `:1109`:** `planningsInPeriod` is *reassigned inside the loop* (a re-query feeding the totals at `:1127-1135`). The already-taken enumerator is unaffected, but do not add any other logic that depends on that collection's identity. + +- [ ] **Step 5: Stop gap-fill inside a locked range** + +In `TimePlanningPlanningService.Index` (loop at **:370-402**) and `IndexByCurrentUserName` (loop at **:593-623**), skip missing dates that fall inside the lock. + +**Use the right context and the right site.** `Index`'s gap-fill writes through `innerDbContext`, created per site at `:284-285` inside the `foreach (var assignedSite in assignedSites)` at `:216` — resolve `lockedThrough` inside that per-site block from `innerDbContext` and `dbAssignedSite.SiteId`, never before the outer loop (that would resolve it for the wrong worker). `IndexByCurrentUserName`'s gap-fill uses `dbContext` and has a single site, so it is simpler. + +Then in each loop: + +```csharp + if (DayLockHelper.IsLocked(lockedThrough, missingDate)) + { + // Frozen means frozen: a locked period does not grow new rows. + continue; + } +``` + +with `lockedThrough` resolved once before the loop. + +- [ ] **Step 6: Add tests for the skipping behaviour** + +Append to `ReconcileServiceTests.cs`: + +```csharp + [Test] + public async Task Index_OverALockedRange_LeavesLockedRowsByteIdentical() + { + var row = await SeedPlain(910, DateTime.Now.Date.AddDays(-5)); + await _service.Reconcile(row.Id); + var before = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == row.Id); + + await using var baseDbContext = GetBaseDbContext(); + var svc = await BuildAdminIndexServiceAsync(baseDbContext); + await svc.Index(new TimePlanningPlanningRequestModel + { + DateFrom = DateTime.Now.Date.AddDays(-10), + DateTo = DateTime.Now.Date + }); + + var after = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == row.Id); + Assert.Multiple(() => + { + Assert.That(after.Version, Is.EqualTo(before.Version), + "a no-op re-save still bumps Version — this catches silent rewrites"); + Assert.That(after.UpdatedAt, Is.EqualTo(before.UpdatedAt)); + }); + } + + [Test] + public async Task Index_OverALockedRange_CreatesNoNewRows() + { + var row = await SeedPlain(911, DateTime.Now.Date.AddDays(-5)); + await _service.Reconcile(row.Id); + var countBefore = await TimePlanningPnDbContext!.PlanRegistrations + .CountAsync(x => x.SdkSitId == 911); + + await using var baseDbContext = GetBaseDbContext(); + var svc = await BuildAdminIndexServiceAsync(baseDbContext); + await svc.Index(new TimePlanningPlanningRequestModel + { + DateFrom = DateTime.Now.Date.AddDays(-10), + DateTo = DateTime.Now.Date + }); + + var countAfter = await TimePlanningPnDbContext!.PlanRegistrations + .CountAsync(x => x.SdkSitId == 911); + Assert.That(countAfter, Is.EqualTo(countBefore), + "gap-fill must not materialise rows inside a frozen period"); + } +``` + +- [ ] **Step 7: Build and commit** + +Run: `cd eFormAPI/Plugins/TimePlanning.Pn && dotnet build TimePlanning.Pn.sln -v q` +Expected: `0 Error(s)` + +```bash +git add eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs +git commit -m "fix(lock): return a message on blocked writes and skip locked days when recalculating" +``` + +--- + +## Task 6: Expose the lock state on the read model + +**Files:** +- Modify: `.../Infrastructure/Models/Planning/TimePlanningPlanningPrDayModel.cs:235-237` +- Modify: `.../Infrastructure/Models/Planning/TimePlanningPlanningModel.cs:78` +- Modify: `.../Infrastructure/Helpers/PlanRegistrationHelper.cs:862` (projection) and where `siteModel` is built +- Test: append to `ReconcileServiceTests.cs` + +**Interfaces:** +- Produces (consumed by Tasks 7-11): + - `TimePlanningPlanningPrDayModel.Reconciled : bool` + - `TimePlanningPlanningPrDayModel.ReconciledAt : DateTime?` + - `TimePlanningPlanningModel.LockedThrough : DateTime?` + +- [ ] **Step 1: Add the per-day fields** + +In `TimePlanningPlanningPrDayModel.cs`, after `NettoHoursOverrideActive` (:236): + +```csharp + /// True only on the boundary day itself. Earlier days are locked + /// by derivation and keep this false — see LockedThrough on the row. + public bool Reconciled { get; set; } + + public DateTime? ReconciledAt { get; set; } +``` + +- [ ] **Step 2: Add the row field** + +In `TimePlanningPlanningModel.cs`, before `PlanningPrDayModels` (:78): + +```csharp + /// The worker's reconciled boundary: every day at or before this is + /// locked. Null when nothing is reconciled. Sent once per row so the client + /// compares dates instead of scanning cells. + public DateTime? LockedThrough { get; set; } +``` + +- [ ] **Step 3: Populate them** + +In `PlanRegistrationHelper.cs` at the `planningModel` initializer (:862), add: + +```csharp + Reconciled = planRegistration.Reconciled, + ReconciledAt = planRegistration.ReconciledAt, +``` + +And set the row-level value **once, BEFORE the loop at `:423`** — reuse the `lockedThrough` local that Task 5 Step 4 already resolves there: + +```csharp + siteModel.LockedThrough = lockedThrough; +``` + +**Not** alongside the totals at `:1131-1135`: those are inside the loop, so that would re-query once per day per site, and — worse — a worker with **no rows in the window never enters the loop at all**, leaving `LockedThrough` null. A worker whose entire visible month is locked would then render as fully editable. + +- [ ] **Step 4: Test the projection** + +```csharp + [Test] + public async Task Index_ProjectsReconciledStateOntoTheReadModel() + { + var row = await SeedPlain(912, DateTime.Now.Date.AddDays(-4)); + await _service.Reconcile(row.Id); + + await using var baseDbContext = GetBaseDbContext(); + var svc = await BuildAdminIndexServiceAsync(baseDbContext); + var result = await svc.Index(new TimePlanningPlanningRequestModel + { + DateFrom = DateTime.Now.Date.AddDays(-6), + DateTo = DateTime.Now.Date + }); + + var siteRow = result.Model.Single(x => x.SiteId == 912); + var day = siteRow.PlanningPrDayModels.Single(d => d.Date.Date == row.Date.Date); + Assert.Multiple(() => + { + Assert.That(siteRow.LockedThrough, Is.EqualTo(row.Date), + "the client needs the boundary once per row, not per cell"); + Assert.That(day.Reconciled, Is.True); + Assert.That(day.ReconciledAt, Is.Not.Null); + }); + } +``` + +- [ ] **Step 5: Build and commit** + +```bash +git add eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/ \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs \ + eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs +git commit -m "feat(lock): send the reconciled state and boundary to the client" +``` + +**→ Natural phase boundary. The backend is complete and enforceable here. Open a PR, watch CI green, and merge before starting Task 7 if you want to land this incrementally.** + +--- + +## Task 7: The interceptor in the service repo + +**Files (different repository):** `/home/rene/Documents/workspace/microting/eform-service-timeplanning-plugin` +- Create: `ServiceTimePlanningPlugin/Infrastructure/Interceptors/ReconciledDayLockInterceptor.cs` (copy of Task 2's, namespace changed) +- Create: `ServiceTimePlanningPlugin/Infrastructure/Helpers/DayLockHelper.cs` (copy of Task 1's, namespace changed) +- Modify: `ServiceTimePlanningPlugin/Infrastructure/Helpers/DbContextHelper.cs:39-44` + +**Why duplicated rather than shared:** the two repos share no code except the base NuGet, and this design adds nothing to that package. Duplicating ~90 lines is the smaller cost. **A divergence between the copies is a lock with a hole**, so both files carry a header comment naming their twin. + +- [ ] **Step 1: Copy the helper and interceptor** + +Copy both files, change `namespace TimePlanning.Pn.Infrastructure.*` to `namespace ServiceTimePlanningPlugin.Infrastructure.*`, and add at the top of each: + +```csharp +// NOTE: this is a deliberate copy of the same file in +// eform-angular-timeplanning-plugin (TimePlanning.Pn/Infrastructure/...). +// The two repos share only the base NuGet package, which this design does not +// modify. If you change one, change the other: a divergence means background +// jobs can write days the web refuses to, which is worse than no lock at all. +``` + +- [ ] **Step 2: Wire it into the service helper** + +Replace `DbContextHelper.GetDbContext()`: + +```csharp + public TimePlanningPnDbContext GetDbContext() + { + var optionsBuilder = new DbContextOptionsBuilder(); + + // Hardcoded version, exactly as TimePlanningPnContextFactory does + // (factory line 39). ServerVersion.AutoDetect OPENS A CONNECTION and + // runs a version query; this method is called once per assigned site on + // every dashboard load, so AutoDetect here would add a round-trip per + // worker per page view that the current code does not pay. + optionsBuilder.UseMySql( + ConnectionString, + new MariaDbServerVersion(new Version(10, 5, 0)), + mySqlOptionsAction: builder => { builder.EnableRetryOnFailure(); }); + + optionsBuilder.AddInterceptors(new ReconciledDayLockInterceptor()); + + return new TimePlanningPnDbContext(optionsBuilder.Options); + } +``` + +This covers `eFormCompletedHandler`, `SearchListJob` (including its nightly soft-**delete** path), `FlexChainCatchUpJob` and `Core.cs:257`. + +**One documented exception.** `Core.cs:146-148` builds a context straight from +`TimePlanningPnContextFactory`, bypassing this helper, so it is **not** guarded. That +context is read-only in practice — migrations plus reading `PluginConfigurationValues` +at `:170`/`:174` — and writes no `PlanRegistration`. Leave it, but add a comment at +`:146` saying so, because the spec's own risk table calls an unguarded construction +site "a lock with a hole … worse than no lock", and the next reader deserves to know +this one was considered rather than missed. + +- [ ] **Step 3: Add the enforcement test this repo requires** + +Spec §10 requires *"an explicit test in each repo asserting a locked write is rejected +through that repo's own context construction"* — otherwise a divergence between the two +copies is invisible. The service repo has `ServiceTimePlanningPlugin.Integration.Test` +(NUnit 4.6.1 + Testcontainers MariaDb) and, unlike the plugin repo, **no shard +allowlist**, so a new class runs automatically. + +Create `ServiceTimePlanningPlugin.Integration.Test/DayLockInterceptorTests.cs` with one +test proving the wiring — build the context through `DbContextHelper.GetDbContext()`, +not by hand, or it proves nothing: + +```csharp + [Test] + public async Task AWriteToALockedDay_IsRejected_ThroughThisReposOwnHelper() + { + var helper = new DbContextHelper(ConnectionString); + await using var db = helper.GetDbContext(); + + var earlier = new PlanRegistration + { + SdkSitId = 950, Date = new DateTime(2026, 1, 13), + PlanText = "", CommentOffice = "", CommentOfficeAll = "", + WorkflowState = Constants.WorkflowStates.Created, + CreatedByUserId = 1, UpdatedByUserId = 1, + }; + await earlier.Create(db); + + await new PlanRegistration + { + SdkSitId = 950, Date = new DateTime(2026, 1, 16), + Reconciled = true, ReconciledAt = DateTime.Now, + PlanText = "", CommentOffice = "", CommentOfficeAll = "", + WorkflowState = Constants.WorkflowStates.Created, + CreatedByUserId = 1, UpdatedByUserId = 1, + }.Create(db); + + earlier.PlanHours = 9; + + Assert.ThrowsAsync(async () => await earlier.Update(db), + "background jobs must be as locked out as the web is -- if this fails, the " + + "two copies of the interceptor have diverged"); + } +``` + +- [ ] **Step 4: Build and commit (in the service repo)** + +```bash +cd /home/rene/Documents/workspace/microting/eform-service-timeplanning-plugin +dotnet build -v q +git add ServiceTimePlanningPlugin/Infrastructure/ +git commit -m "feat(lock): enforce the reconciled-day lock in background jobs" +``` + +--- + +## Task 8: Frontend models and service methods + +**Files:** +- Modify: `eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/planning-pr-day.model.ts` +- Modify: `.../models/plannings/time-planning.model.ts` +- Modify: `.../services/time-planning-pn-plannings.service.ts` + +**Interfaces:** +- Consumes: the three DTO fields from Task 6. +- Produces: + - `PlanningPrDayModel.reconciled: boolean`, `.reconciledAt: string | null` + - `TimePlanningModel.lockedThrough: string | null` + - `TimePlanningPnPlanningsService.reconcileDay(id: number)`, `.unreconcileDay(id: number)`, `.reconcileThrough(date: string, siteIds: number[])` + +- [ ] **Step 1: Add the model fields** + +In `planning-pr-day.model.ts`, before the closing brace: + +```ts + /** True only on the boundary day. Earlier days are locked by derivation. */ + reconciled: boolean; + reconciledAt: string | null; +``` + +In `time-planning.model.ts`, before the closing brace: + +```ts + /** Every day at or before this is locked for this worker. Null = nothing locked. */ + lockedThrough: string | null; +``` + +- [ ] **Step 2: Add the service methods** + +In `time-planning-pn-plannings.service.ts`, inside the class: + +```ts + reconcileDay(id: number): Observable { + return this.apiBaseService.put( + TimePlanningPnPlanningsMethods.Plannings + '/' + id + '/reconcile', {} + ); + } + + unreconcileDay(id: number): Observable { + return this.apiBaseService.put( + TimePlanningPnPlanningsMethods.Plannings + '/' + id + '/unreconcile', {} + ); + } + + reconcileThrough( + date: string, siteIds: number[] + ): Observable> { + return this.apiBaseService.put( + TimePlanningPnPlanningsMethods.Plannings + '/reconcile-through', + {date, siteIds} + ); + } +``` + +Create `models/plannings/reconcile-through-result.model.ts`: + +```ts +export class ReconcileThroughResultModel { + landedOn: string | null; + applied: number; + skippedSiteIds: number[]; +} +``` + +and export it from `models/plannings/index.ts`. + +- [ ] **Step 3: Commit** + +```bash +git add eform-client/src/app/plugins/modules/time-planning-pn/models/ \ + eform-client/src/app/plugins/modules/time-planning-pn/services/time-planning-pn-plannings.service.ts +git commit -m "feat(lock): add the lock fields and reconcile calls to the client" +``` + +--- + +## Task 9: Three states in the grid + +**Files:** +- Modify: `.../time-plannings-table/time-plannings-table.component.ts` (`getCellClass` :201-245, `onDayColumnClick` :458-485) + +**Interfaces:** +- Consumes: `TimePlanningModel.lockedThrough`, `PlanningPrDayModel.reconciled` (Task 8); CSS classes from Task 10. +- Produces: `isDayLocked(row, field) -> boolean`, `isDayReconciled(row, field) -> boolean`. + +**The signature problem:** `getCellClass` returns a **single** string today and mtx-grid stamps it onto the ``. Three lock states layer *on top of* the existing four backgrounds, so it must return composed classes. + +- [ ] **Step 1: Add the predicates and compose the classes** + +Add to the class: + +```ts + /** The worker's boundary, parsed once per call. Null when nothing is locked. */ + private lockedThrough(row: any): number | null { + return row?.lockedThrough ? new Date(row.lockedThrough).setHours(0, 0, 0, 0) : null; + } + + isDayLocked(row: any, field: string): boolean { + const boundary = this.lockedThrough(row); + const date = row?.planningPrDayModels?.[field]?.date; + if (boundary === null || !date) { + return false; + } + return new Date(date).setHours(0, 0, 0, 0) <= boundary; + } + + isDayReconciled(row: any, field: string): boolean { + return row?.planningPrDayModels?.[field]?.reconciled === true; + } +``` + +Then change `getCellClass` to append a lock class to whatever it already returns. Replace the method's `return` statements with a single composed exit by wrapping the existing body: + +```ts + getCellClass(row: any, field: string): string { + const base = this.getCellStateClass(row, field); + if (this.isDayReconciled(row, field)) { + return `${base} reconciled-background`; + } + if (this.isDayLocked(row, field)) { + return `${base} locked-background`; + } + return base; + } +``` + +and rename the existing `getCellClass` body (lines 201-245) to `private getCellStateClass(row: any, field: string): string` with no other change. The four existing background classes keep working untouched. + +- [ ] **Step 2: Open locked days read-only** + +In `onDayColumnClick` (:458), pass the lock state into the dialog so it can render read-only — the dialog still **opens**, because people read closed days constantly: + +```ts + this.dialog.open(WorkdayEntityDialogComponent, { + data: { + planningPrDayModels: cellData, + assignedSiteModel: result.model, + tags: row.tags ?? [], + isLocked: this.isDayLocked(row, field), + isReconciled: this.isDayReconciled(row, field), + lockedThrough: row.lockedThrough ?? null, + }, + ... +``` + +- [ ] **Step 3: Commit** + +```bash +git add eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.ts +git commit -m "feat(lock): mark locked and reconciled days in the grid" +``` + +--- + +## Task 10: The cell styles (HOST REPO — separate PR) + +**Files (different repository):** `/home/rene/Documents/workspace/microting/eform-angular-frontend` +- Modify: `eform-client/src/scss/styles.scss` (append after the `.red-background .plan-container` block ending :358) + +Per CLAUDE.md, **all SCSS lives in `eform-angular-frontend`** — this is a second repo, a second branch and a second PR. The four existing `*-background` classes live here (`:196`, `:239`, `:280`, `:320`) and the new ones must sit beside them. + +- [ ] **Step 1: Add the two state classes** + +Mirroring the exact selector shape of the existing four (bare class, then a `.plan-container` descendant, `!important` throughout, `var(--token, #fallback)`): + +```scss +/* Reconciled / locked day cells. + Four independent channels carry the state — texture, glyph, cursor and the + tooltip text — so colour is never load-bearing. The 3px right border on a + reconciled cell is what draws the boundary line down the grid. + Theme-agnostic on purpose: body.theme-eform rules do not apply under + theme-workspace, and these must read on both. */ +:root { + --tp-locked-bg: #E4E7E4; + --tp-locked-hatch: rgba(22, 33, 30, 0.055); + --tp-seal-ink: #2F5D50; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --tp-locked-bg: #262B29; + --tp-locked-hatch: rgba(230, 234, 232, 0.06); + --tp-seal-ink: #7FD1B9; + } +} + +.locked-background .plan-container { + background: repeating-linear-gradient(135deg, + var(--tp-locked-hatch) 0 2px, transparent 2px 6px), + var(--tp-locked-bg) !important; + /* beats the shared rule `.plan-container, .progress-container` (selector at + styles.scss:360, declaration :361). Scoping by .locked-background means the + avatar/progress circle is unaffected. */ + cursor: not-allowed !important; +} + +.locked-background .plan-content { + opacity: 0.72; +} + +.reconciled-background .plan-container { + background: var(--tp-locked-bg) !important; + border-right: 3px solid var(--tp-seal-ink) !important; + cursor: default !important; +} +``` + +- [ ] **Step 2: Commit in the host repo** + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-frontend +git checkout -b feat/reconciled-day-lock-styles +git add eform-client/src/scss/styles.scss +git commit -m "feat(timeplanning): add locked and reconciled day-cell styles" +``` + +Open a PR against that repo's target branch. Note from project memory: three FOSSA checks fail on every `eform-angular-frontend` PR and are non-gating. + +--- + +## Task 11: Read-only dialog, reconcile and unlock + +**Files:** +- Modify: `.../workday-entity/workday-entity-dialog.component.ts` (data type :50-54, `isInTheFuture` assignment :249, the `updateDisabledStates()` cascade :865-1075) +- Modify: `.../workday-entity/workday-entity-dialog.component.html` (hint ~:461, actions :494-514) + +**Interfaces:** +- Consumes: the dialog data fields added in Task 9; service methods from Task 8. +- Produces: nothing downstream. + +**The trap — and check the premise, because a wrong one invites skipping the step.** `updateDisabledStates()` (`:865-1075`) makes 74 `setDisabled` calls, 38 of them enabling. Each enable *is* value-guarded (`if (isSet(p1Start))`, `if (thirdShiftActive)`…). What none of them is guarded by is **`isInTheFuture`** — so the cascade already silently re-enables controls the constructor disabled for future dates. That is a pre-existing bug, and a day-lock implemented only at construction inherits it: one keystroke reopens a locked form. Guarding inside `setDisabled` fixes both at once. + +- [ ] **Step 1: Accept the new data fields** + +Extend the injected data type at `:50-54`: + +```ts + public data = inject<{ + planningPrDayModels: PlanningPrDayModel, + assignedSiteModel: AssignedSiteModel, + tags?: SharedTagModel[], + isLocked?: boolean, + isReconciled?: boolean, + lockedThrough?: string | null + }>(MAT_DIALOG_DATA); +``` + +Add a field and set it beside the `isInTheFuture` assignment at `:249`: + +```ts + /** A reconciled or cascade-locked day opens read-only. */ + isLocked = false; +``` + +```ts + this.isLocked = this.data.isLocked === true; +``` + +- [ ] **Step 2: Make the cascade honour the lock** + +This is the load-bearing change. Modify `setDisabled` itself (`:481-492`; note `getCtrl` already exists at `:477-479` and `setDisabled` already calls it — you are changing one line inside an existing method, not introducing either) so no caller can re-enable a control on a locked day: + +```ts + private setDisabled(path: string, disabled: boolean) { + const c = this.getCtrl(path); + if (!c) { + return; + } + // A locked day can never re-enable a control. The progressive-enable + // cascade calls setDisabled(path, false) unconditionally in many branches; + // without this guard, touching any field would reopen the form. + const effective = disabled || this.isLocked; + if (effective && c.enabled) { + c.disable({emitEvent: false}); + } + if (!effective && c.disabled) { + c.enable({emitEvent: false}); + } + } +``` + +Then disable the whole form once, after the form is built: + +```ts + if (this.isLocked) { + this.workdayForm.disable({emitEvent: false}); + } +``` + +- [ ] **Step 3: Add the banner** + +In the .html, beside the existing future hint (`:461-465`): + +```html + +``` + +- [ ] **Step 4: Replace the actions on a locked day** + +In `mat-dialog-actions` (:494-514), hide Save and offer the reverse action. Note `[mat-dialog-close]="data"` fires regardless of `(click)`, so Save must be removed from the DOM, not merely disabled: + +```html + + + + + +``` + +- [ ] **Step 5: Add the handlers** + +```ts + /** I2: today and future days stay open so time can still be registered. */ + get canReconcile(): boolean { + const d = new Date(this.data.planningPrDayModels.date); + const today = new Date(); + today.setHours(0, 0, 0, 0); + d.setHours(0, 0, 0, 0); + return d < today; + } + + onReconcile(): void { + this.planningsService.reconcileDay(this.data.planningPrDayModels.id) + .subscribe(result => { + if (result && result.success) { + this.dialogRef.close(this.data); + } + }); + } + + onUnlock(): void { + this.planningsService.unreconcileDay(this.data.planningPrDayModels.id) + .subscribe(result => { + if (result && result.success) { + this.dialogRef.close(this.data); + } + }); + } +``` + +- [ ] **Step 6: Commit** + +```bash +git add eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/ +git commit -m "feat(lock): open locked days read-only, with reconcile and unlock" +``` + +--- + +## Task 12: Bulk reconcile in the toolbar + +**Files:** +- Modify: `.../time-plannings-table/time-plannings-table.component.{ts,html}` (grid selection) +- Modify: `.../time-plannings-container/time-plannings-container.component.{ts,html}` (toolbar + scope bar) + +**Interfaces:** +- Consumes: `reconcileThrough` (Task 8). +- Produces: nothing downstream. + +**Two mtx-grid gotchas, both documented in the host's backend-configuration task-list and both guaranteed to bite here:** +1. mtx-grid binds `(click)="_selectRow()"` on the ``. With `[rowSelectable]`, clicking a **day cell** clears the batch selection. The cell must call `stopRowClick($event)`. +2. mtx-grid rebuilds its internal `SelectionModel` empty in `ngOnChanges` **without emitting** `rowSelectedChange`, so the component must re-emit an empty selection itself. + +- [ ] **Step 1: Enable row selection** + +In the table .html, on `` (:19-30): + +```html + [rowSelectable]="true" + [multiSelectable]="true" + (rowSelectedChange)="onRowSelected($event)" +``` + +In the table .ts: + +```ts + @Output() selectionChanged: EventEmitter = new EventEmitter(); + + onRowSelected(rows: any[]): void { + this.selectionChanged.emit((rows ?? []).map(r => r.siteId)); + } + + /** mtx-grid selects the row on any click inside it; the day cell must not. */ + stopRowClick(event: Event): void { + event.stopPropagation(); + } +``` + +In the day-cell template (:238), add `(click)="stopRowClick($event)"` to the `.plan-container` **before** the existing handler so the row-select is suppressed but the dialog still opens: + +```html +
+``` + +And re-emit an empty selection in `ngOnChanges` when `timePlannings` changes, because the grid will not: + +```ts + if (changes.timePlannings) { + this.selectionChanged.emit([]); + } +``` + +- [ ] **Step 2: Add the toolbar control** + +In the container .html, after the reload button (`:100-108`): + +```html + + +``` + +- [ ] **Step 3: Add the container logic** + +```ts + selectedSiteIds: number[] = []; + reconcileThroughDate: string | null = null; + + /** I2: nothing at or after today may be reconciled. */ + get maxReconcileDate(): string { + const d = new Date(); + d.setDate(d.getDate() - 1); + return d.toISOString().slice(0, 10); + } + + onSelectionChanged(siteIds: number[]): void { + this.selectedSiteIds = siteIds; + } + + onReconcileThrough(): void { + if (!this.reconcileThroughDate) { + return; + } + // No selection means every worker currently visible under the active + // filters — the month-end case. + const siteIds = this.selectedSiteIds.length + ? this.selectedSiteIds + : this.timePlannings.map(x => x.siteId); + + this.planningsService.reconcileThrough(this.reconcileThroughDate, siteIds) + .subscribe(result => { + if (result && result.success) { + this.reconcileThroughDate = null; + this.selectedSiteIds = []; + this.getPlannings(); + } + }); + } +``` + +Bind the table's output in the container template: `(selectionChanged)="onSelectionChanged($event)"`. + +- [ ] **Step 4: Commit** + +```bash +git add eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/ +git commit -m "feat(lock): reconcile many workers through a date from the toolbar" +``` + +--- + +## Task 13: Help entries and translations + +**Files:** +- Modify: `.../help/help.model.ts` (the `HELP_IDS` array) +- Modify: `.../help/planning-help.registry.ts` +- Modify: `.../help/i18n/da.ts` and `.../help/i18n/enUS.ts` +- Modify: `.../i18n/da.ts` and `.../i18n/enUS.ts` + +`HelpEntryId` is a closed union off `HELP_IDS`, and prose is `Record` — adding an id **forces** both help i18n files to gain the entry or the build fails. + +- [ ] **Step 1: Add the ids** + +In `help/help.model.ts`, add to `HELP_IDS` in the day-cell block: `'dayCell.reconciled'`, `'dayCell.lockedByReconciled'`, and in the toolbar block: `'toolbar.reconcileThrough'`. + +- [ ] **Step 2: Register the entries** + +In `help/planning-help.registry.ts`, in the toolbar block (after `:44`): + +```ts + { id: 'toolbar.reconcileThrough', kind: 'control', section: 'toolbar', anchor: 'toolbar.reconcileThrough' }, +``` + +in the day-cell block: + +```ts + { id: 'dayCell.reconciled', kind: 'control', section: 'dayCell', anchor: 'dayCell.reconciled' }, + { id: 'dayCell.lockedByReconciled', kind: 'control', section: 'dayCell', anchor: 'dayCell.lockedByReconciled' }, +``` + +Do **not** assign a `tour` step — page tour steps 1-8 are taken and the tour order is deliberate. + +- [ ] **Step 3: Add the prose (Danish)** + +In `help/i18n/da.ts`: + +```ts + 'dayCell.reconciled': { + title: 'Afstemt', + short: 'Dagens tal er endelige.', + detail: 'Dagen er afstemt, og tallene ændres ikke længere — heller ikke af en efterberegning. Alle dage før denne er samtidig låst.', + keywords: ['afstemt', 'låst', 'endelig', 'afslutning'], + }, + 'dayCell.lockedByReconciled': { + title: 'Låst', + short: 'Dagen ligger før en afstemt dag.', + detail: 'Dagen kan ikke ændres, fordi en senere dag er afstemt. Lås den seneste afstemte dag op først.', + keywords: ['låst', 'afstemt', 'tidligere'], + }, + 'toolbar.reconcileThrough': { + title: 'Afstem til og med', + short: 'Sæt grænsen for flere medarbejdere på én gang.', + detail: 'Vælg en dato. Er ingen rækker markeret, gælder den alle synlige medarbejdere. Kun grænsedagen markeres som afstemt — alt før låses automatisk.', + keywords: ['afstem', 'flere', 'månedsafslutning', 'lås'], + }, +``` + +- [ ] **Step 4: Add the prose (English)** + +Same three keys in `help/i18n/enUS.ts` with English values matching the copy rule — describe what the day *is*, never who may change it. + +- [ ] **Step 5: Add the UI strings** + +In `i18n/da.ts`, before the closing `};`: + +```ts + 'Reconcile day': 'Afstem dag', + 'Reconcile through': 'Afstem til og med', + Unlock: 'Lås op', + Reconciled: 'Afstemt', + Locked: 'Låst', +``` + +In `i18n/enUS.ts`, the same keys with identity values. + +- [ ] **Step 6: Commit** + +```bash +git add eform-client/src/app/plugins/modules/time-planning-pn/help/ \ + eform-client/src/app/plugins/modules/time-planning-pn/i18n/ +git commit -m "feat(lock): add help entries and translations for the day lock" +``` + +--- + +## Task 14: End-to-end test + +**Files:** +- Create: `eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-day-lock.spec.ts` +- Create: `eform-client/playwright/e2e/plugins/time-planning-pn/s/` seed files (copy `a/`'s) +- Modify: `.github/workflows/dotnet-core-pr.yml:95` and `dotnet-core-master.yml:102` matrix — add `s` + +**Anchor rows by worker identity, never by grid index.** The `#cellN_M` ids are positional; a row shift silently addresses a different worker. Read the worker from the dialog title and assert it stays constant, as `e1m/dashboard-edit-multishift.spec.ts` now does. + +- [ ] **Step 1: Write the spec** + +```ts +import { test, expect, Page } from '@playwright/test'; +import { LoginPage } from '../../../Page objects/Login.page'; + +async function waitForSpinner(page: Page) { + if (await page.locator('.overlay-spinner').count() > 0) { + await page.locator('.overlay-spinner').waitFor({ state: 'hidden', timeout: 30000 }); + } +} + +async function dialogWorker(page: Page): Promise { + const title = page.locator('mat-dialog-container [mat-dialog-title]'); + await expect(title).toBeVisible({ timeout: 10000 }); + const raw = (await title.innerText()).replace(/\s+/g, ' ').trim(); + return raw.split(/\s+-\s+/)[0].trim(); +} + +test.describe('Reconciled day lock', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:4200'); + await new LoginPage(page).login(); + }); + + test('reconciling a day locks it and every earlier day for that worker', async ({ page }) => { + await page.locator('mat-nested-tree-node').filter({ hasText: 'Timeregistrering' }).click(); + const indexPromise = 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 indexPromise; + await waitForSpinner(page); + + // Open a past day and reconcile it. + await page.locator('#cell3_1').click(); + const worker = await dialogWorker(page); + const reconcilePromise = page.waitForResponse(r => + r.url().includes('/reconcile') && r.request().method() === 'PUT'); + await page.locator('#reconcileButton').click(); + await reconcilePromise; + await waitForSpinner(page); + + // The cell now carries the boundary treatment. + await expect(page.locator('#cell3_1').locator('..')) + .toHaveClass(/reconciled-background/); + + // The day before it is locked, but NOT reconciled. + await expect(page.locator('#cell3_0').locator('..')) + .toHaveClass(/locked-background/); + + // Opening the locked day gives a read-only dialog for the same worker. + await page.locator('#cell3_0').click(); + expect(await dialogWorker(page)).toBe(worker); + await expect(page.locator('#saveButton')).toHaveCount(0); + await expect(page.locator('#unlockButton')).toHaveCount(0); + await page.locator('#cancelButton').click(); + + // The boundary day offers unlock; using it frees both days. + await page.locator('#cell3_1').click(); + expect(await dialogWorker(page)).toBe(worker); + const unlockPromise = page.waitForResponse(r => + r.url().includes('/unreconcile') && r.request().method() === 'PUT'); + await page.locator('#unlockButton').click(); + await unlockPromise; + await waitForSpinner(page); + + await expect(page.locator('#cell3_0').locator('..')) + .not.toHaveClass(/locked-background/); + }); +}); +``` + +- [ ] **Step 2: Add the shard** + +Add `s` to the `matrix.test` array in `.github/workflows/dotnet-core-pr.yml:95` and `dotnet-core-master.yml:102`. + +**Do not copy seed SQL.** The seed step (`dotnet-core-pr.yml:156-171`) falls back to `a/`'s dumps when a shard has none — `b`, `c`, `p`, `q` and `r` already rely on that, and copying the 19 MB `420_SDK.sql` plus the 3.2 MB plugin dump would be 22 MB for nothing. What *is* required: the `s/` directory must contain at least one spec, because the test step runs `npx playwright test .../${{ matrix.test }}/` and exits non-zero when it finds none. + +- [ ] **Step 3: Commit** + +```bash +git add eform-client/playwright/e2e/plugins/time-planning-pn/s/ \ + .github/workflows/dotnet-core-pr.yml .github/workflows/dotnet-core-master.yml +git commit -m "test(lock): end-to-end reconcile, cascade and unlock" +``` + +--- + +## Task 15: Open the PR and watch CI + +- [ ] **Step 1: Dual review gate (MANDATORY before the PR)** + +Dispatch `superpowers:requesting-code-review` **and** a `code-simplifier` subagent in parallel. Act on the findings; do not merge around them. + +- [ ] **Step 2: Push and open the PR** + +```bash +git push -u origin feat/reconciled-day-lock +gh pr create --base stable --title "feat(lock): reconciled ('Afstemt') day lock" --body "" +``` + +- [ ] **Step 3: Watch CI to a verdict** + +`gh pr checks ` until every check has finished. For each red check, find the failed step and classify it as infrastructure or a real failure. Compare against `stable`'s latest run — a shard also red there is not yours. + +Two known flake sources in this repo, neither caused by this change: +- `b/activate-plugin.spec.ts:30` has a hardcoded `waitForTimeout(100000)` inside a 180s budget; it runs in setup for every `1m` shard. +- MariaDB container start fails intermittently. If a job dies at "Start MariaDB" with every test step skipped, that shard ran nothing — re-run it. + +Re-running a failed job **overwrites** its conclusion, so a green run can hide a first-attempt failure. To audit honestly: `gh api repos///actions/runs//attempts/1/jobs`. + +- [ ] **Step 4: Report the verdict, do not merge without approval** + +--- + +## Self-Review + +**Spec coverage.** §4 data model → Task 1. §4.2 I1 → Tasks 4 (write) and 1 (test). I2 → Tasks 1, 4, 12. I3 → Task 2. §4.3 query cost → Task 1 (`LockedThroughForSitesAsync`). §5 write inventory → Tasks 2, 5, 7. §6.1 three layers → Tasks 2 (L1), 5 (L2, L3). §6.2 cascades → Global Constraints + Task 1 doc comment. §6.3 gap-fill → Task 5 Step 5. §6.4 timezone → Task 1 `CanReconcile`. §7 API → Task 4; read model → Task 6. §8.1 three states → Tasks 9, 10. §8.2 single day → Task 11. §8.3 bulk → Tasks 4 (`ReconcileThrough`), 12. §8.4 unlock → Tasks 4, 11. §8.5 blocked feedback → Task 11. §8.6 permissions → no admin gate anywhere (verified: no `[Authorize]` added in Task 4). §9 testing → Tasks 1, 2, 4, 5, 6, 14. + +**Gaps found and closed while reviewing:** +- The interceptor must permit the unlock write on the boundary day, or unlocking would be blocked by the lock it removes. Added `IsUnlockOfBoundaryDay` (Task 2 Step 3) and a test for it. +- `TestBaseSetup` builds its own contexts and would bypass the interceptor — the lock would appear tested while being unenforced. Added Task 2 Step 6. +- `[mat-dialog-close]="data"` fires regardless of `(click)`, so a disabled Save still closes with data. Save is removed from the DOM on a locked day, not disabled (Task 11 Step 4). + +**Type consistency.** `lockedThrough` is `DateTime?` in C# and `string | null` in TS (JSON). `LockedThroughAsync` / `LockedThroughForSitesAsync` / `IsLocked` / `CanReconcile` are used with those exact names in Tasks 2, 4, 5, 6. `reconciled` / `reconciledAt` match the C# `Reconciled` / `ReconciledAt` under default camelCase JSON. CSS classes `locked-background` / `reconciled-background` match between Task 9 (emitted) and Tasks 10, 14 (styled, asserted). + +**Known risk carried forward:** Task 7 duplicates ~90 lines into the service repo. A divergence is a lock with a hole. Both copies carry a header comment naming the twin; there is no shared package to put it in without modifying the base. From 56ca9d401bedb64444fde17e3aa9c5d2ffa52c29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Sun, 13 Sep 2026 15:33:54 +0200 Subject: [PATCH 04/28] docs: park the Reconciled day lock with a pick-up-here status Design and plan are complete and reviewed; no implementation code exists. Records where the branch is, the one decision still open (spec 8.1-8.4 UI requirements have no tasks -- particularly the typed unlock confirmation, which is the only safeguard on a feature any web user can trigger), what the design settled so it is not re-litigated, and the traps the plan review already caught so they are not reintroduced. Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-09-13-STATUS-reconciled-day-lock.md | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-13-STATUS-reconciled-day-lock.md diff --git a/docs/superpowers/plans/2026-09-13-STATUS-reconciled-day-lock.md b/docs/superpowers/plans/2026-09-13-STATUS-reconciled-day-lock.md new file mode 100644 index 00000000..b0d3a8a3 --- /dev/null +++ b/docs/superpowers/plans/2026-09-13-STATUS-reconciled-day-lock.md @@ -0,0 +1,106 @@ +# STATUS — Reconciled ("Afstemt") day lock — PARKED 2026-09-13 + +**State:** design and plan complete and reviewed. **No implementation code written.** + +**Branch:** `docs/reconciled-day-lock-spec` — 3 commits, **never pushed, no PR**, working tree clean. + +``` +da5cd018 docs: implementation plan for the Reconciled day lock +145cd2cb docs: settle bulk scope, payroll independence and mobile handling +8f2f9fdf docs: design for the Reconciled ("Afstemt") day lock +``` + +**Artefacts** +- Spec: `docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md` +- Plan: `docs/superpowers/plans/2026-09-12-reconciled-day-lock.md` (15 tasks, 3 repos) +- Interactive mockups: https://claude.ai/code/artifact/30122702-dd2e-4826-b7f3-ee03c6ae218d + +--- + +## To pick this up + +1. `git checkout docs/reconciled-day-lock-spec` +2. Read the spec, then the plan. The plan's Global Constraints section carries every + project-wide rule; each task is self-contained from there. +3. Decide the one open question below. +4. Push the docs branch and PR it, or fold the docs into the implementation branch. +5. Execute with `superpowers:subagent-driven-development` — one fresh subagent per task, + review between tasks. + +**Natural stopping point:** after Task 6 the backend is complete and enforceable. Tasks 7-14 +(service repo, frontend, styles, e2e) can land separately. + +--- + +## THE OPEN DECISION — needed before Task 9 + +Spec §8.1–8.4 requirements with **no task in the plan**. Either write tasks for them or +move them to non-goals explicitly; do not let them lapse silently. + +| Spec ref | Requirement | Why it matters | +|---|---|---| +| §8.1 | `lock` / `verified` glyphs, tooltips, the legend under the grid | Three of four visual channels survive without them, so "never colour alone" still holds — but nobody learns what the hatch means without the legend | +| §8.2 | Two-step inline confirm in the dialog footer; provenance line after | Reconciling is near-irreversible and any web user can do it | +| §8.3 | Preview of the affected region before commit; confirm naming worker count and landing date | The cascade is the part people get wrong in their heads | +| §8.4 | **Typed-word confirmation to unlock** | This is the *only* safeguard on a feature any web user can trigger. Dropping it is a real weakening, not a trim | + +Recommendation: keep §8.4 at minimum. + +--- + +## What the design settled (so it is not re-litigated) + +- **No base-repo change.** `Reconciled` / `ReconciledAt` already exist on + `PlanRegistrations` and `PlanRegistrationVersions` (migration `20260127060748`) and ship + in the pinned `Microting.TimePlanningBase` **10.0.62**. Nothing writes them today. +- **The lock is derived, never stored:** `lockedThrough(site) = MAX(Date) WHERE Reconciled`. + "Earlier days locked but not marked" and "unlock only in reverse order" fall out of the + model rather than needing enforcement. +- **Scope:** per worker per day. Bulk = one date × a set of workers (selected rows, a + selected column, or all visible). +- **Permissions:** any web user may reconcile. No admin gate. +- **I2 — nothing at or after today may be reconciled** — is load-bearing beyond the + obvious: it is what makes the forward flex cascades (one 180 days ahead, one unbounded) + provably unable to reach a locked day. **Do not relax it without revisiting them.** +- **No payroll link.** `Reconciled` and `TransferredToPayroll` are independent both ways. +- **Mobile** rejects the write; no mobile UI. +- Enforcement is a `SaveChangesInterceptor` (complete, unbypassable) + friendly guards on + user-facing paths + silent skipping on recalculation paths. Guards alone would repeat the + `MaxDaysEditable` mistake, which is enforced only on the read path and in the browser and + is bypassable by a crafted POST today. + +## Traps the plan review already caught — do not reintroduce + +- **Never call `ServerVersion.AutoDetect` in the interceptor or in `GetDbContext()`.** It + opens a connection and runs a version query; both run per-save / per-site on every + dashboard load. The factory hardcodes `new MariaDbServerVersion(new Version(10, 5, 0))` — + match it. +- **Do not filter the collection in `UpdatePlanRegistrationsInPeriod`.** Its single + 715-line loop fuses writing and projection; filtering it drops locked days out of the + grid. Guard the four `Update` calls instead. +- **Set `siteModel.LockedThrough` before the loop**, or a worker with no rows in the + window renders as fully editable. +- **`TestBaseSetup` builds its own contexts** — attach the interceptor there or every + enforcement test passes through an unguarded context. +- **`PnBase.Create` overwrites `WorkflowState`**; `PnBase.Delete` is a *soft* delete + arriving as `Modified`, never `Deleted`. +- **No Pomelo.** This repo uses the `Microting.EntityFrameworkCore.MySql` fork. +- The interceptor must **permit** clearing the flag on the boundary day, or unlocking is + blocked by the lock it removes. + +--- + +## Unrelated items parked alongside + +- **Column M (Saturday hours)** in the Excel export still reads bare `NettoHours`, ignoring + the override — the same bug fixed for column J in PR #1704. On `stable` now. +- **Overridden Grundlovsdag** ignores the override, because an override carries no + information about which hours fell after noon. A payroll policy decision, documented at + the call site. +- **`b/activate-plugin.spec.ts:30`** has a hardcoded `waitForTimeout(100000)` inside a 180s + budget, in the setup path for all nine `1m` shards. Highest-leverage flakiness fix + available — one change covers nine shards. +- **Flakiest Playwright shards** measured over 40 runs (recovering failures that re-runs had + masked): `k` 5, `b` 3, `r` 3. `e1m` was 1. +- **The host-app mirror is stale** (Aug 14). `devgetchanges.sh` against it would overwrite + recent work with old code. From 5536a8f44e6944770cac7d29d0b253dc8e9f9350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 15 Sep 2026 07:03:18 +0200 Subject: [PATCH 05/28] docs: make the reconcile status doc a full handoff entry point Renamed STATUS -> HANDOFF and folded in what a cold session needs that was previously only in chat: the dev-mode gate, the normal development cycle including the dual review gate, subagent-driven execution, the CI-only testing rule and the shard-filter trap, and a note that stable has moved and that the dependency-alignment map shares this branch by accident. One entry point instead of a status note plus a separate brief. Co-Authored-By: Claude Opus 5 (1M context) --- ...2026-09-13-HANDOFF-reconciled-day-lock.md} | 61 +++++++++++++++---- 1 file changed, 48 insertions(+), 13 deletions(-) rename docs/superpowers/plans/{2026-09-13-STATUS-reconciled-day-lock.md => 2026-09-13-HANDOFF-reconciled-day-lock.md} (65%) diff --git a/docs/superpowers/plans/2026-09-13-STATUS-reconciled-day-lock.md b/docs/superpowers/plans/2026-09-13-HANDOFF-reconciled-day-lock.md similarity index 65% rename from docs/superpowers/plans/2026-09-13-STATUS-reconciled-day-lock.md rename to docs/superpowers/plans/2026-09-13-HANDOFF-reconciled-day-lock.md index b0d3a8a3..e7898404 100644 --- a/docs/superpowers/plans/2026-09-13-STATUS-reconciled-day-lock.md +++ b/docs/superpowers/plans/2026-09-13-HANDOFF-reconciled-day-lock.md @@ -1,4 +1,6 @@ -# STATUS — Reconciled ("Afstemt") day lock — PARKED 2026-09-13 +# HANDOFF — Reconciled ("Afstemt") day lock + +**Parked 2026-09-13. This file is the entry point — read it before the spec or the plan.** **State:** design and plan complete and reviewed. **No implementation code written.** @@ -19,18 +21,51 @@ da5cd018 docs: implementation plan for the Reconciled day lock ## To pick this up -1. `git checkout docs/reconciled-day-lock-spec` -2. Read the spec, then the plan. The plan's Global Constraints section carries every - project-wide rule; each task is self-contained from there. -3. Decide the one open question below. -4. Push the docs branch and PR it, or fold the docs into the implementation branch. -5. Execute with `superpowers:subagent-driven-development` — one fresh subagent per task, - review between tasks. - -**Natural stopping point:** after Task 6 the backend is complete and enforceable. Tasks 7-14 -(service repo, frontend, styles, e2e) can land separately. - ---- +**Announce the dev-mode gate first** (CLAUDE.md requires it), then: + +1. Read this file, then the spec, then the plan. The plan's Global Constraints section + carries every project-wide rule; each task is self-contained from there. +2. Settle the open decision below with the user. +3. Decide whether to push this docs branch and PR it, or fold the docs into the + implementation branch. Note the dependency-alignment map (`3f33a08e`) rode along on the + same branch and is unrelated work — split it out if this becomes a PR. +4. `stable` has moved since parking (`9e2ae84f`, `82348e71`, `1ef699be` — none touch the + lock). Rebase or merge before starting. +5. Execute with **`superpowers:subagent-driven-development`** — one fresh subagent per + task, review between tasks. + +**Follow the normal development cycle for every task** (CLAUDE.md): branch off `stable`, +write or update tests, verify what can be verified locally (`dotnet build` only — see +below), run the **dual review gate** (`superpowers:requesting-code-review` AND a +`code-simplifier` subagent, dispatched in parallel) before committing, stage files by +name, PR into `stable`, then watch CI to a verdict. + +**Natural stopping point:** after Task 6 the backend is complete and enforceable. Tasks +7-14 (service repo, frontend, styles, e2e) can land separately. + +### What this feature does + +Marking a worker's day **Afstemt** freezes that day and every earlier day for that worker +against web edits, mobile registrations and background recalculation. Earlier days lock +**without** being marked `Reconciled`. A day can only be unlocked once every day after it +is unlocked — like a Chinese ring puzzle. + +### House rules that bite here + +- **Dev mode: NONE — edit the source repos directly.** Do **not** run `devgetchanges.sh`; + the host-app mirror is stale (Aug 14) and syncing from it would overwrite recent work. +- **Tests run ONLY in CI.** Never run `dotnet test`, `playwright test`, `jest` or + `npm test` locally — a PreToolUse hook blocks them. `dotnet build` is allowed and + expected. Push and watch `gh pr checks `. +- **New C# test classes must be added to the shard filters in BOTH** + `.github/workflows/dotnet-core-pr.yml` **and** `dotnet-core-master.yml`, or they + silently never run. +- **Never commit to `stable`.** Branch, PR in. +- **SCSS lives in `eform-angular-frontend`**, never per-plugin — Task 10 is a separate + repo and a separate PR. Expect three FOSSA checks to fail on that repo's PRs; they are + non-gating. +- **User-facing copy must never explain a restriction by referring to what an + administrator may do** ("admin = Microting"). State what the day *is*. ## THE OPEN DECISION — needed before Task 9 From 09bca5420022d0a16d2e10aa36ccc0920ca0c614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 15 Sep 2026 07:28:06 +0200 Subject: [PATCH 06/28] feat(lock): derive the reconciled-day boundary from the data The lock is derived, never stored: a worker's boundary is MAX(Date) over their non-removed Reconciled PlanRegistration rows. Every day at or before that boundary is locked. Co-Authored-By: Claude Opus 5 --- .github/workflows/dotnet-core-master.yml | 2 +- .github/workflows/dotnet-core-pr.yml | 2 +- .../DayLockHelperTests.cs | 177 ++++++++++++++++++ .../Infrastructure/Helpers/DayLockHelper.cs | 86 +++++++++ 4 files changed, 265 insertions(+), 2 deletions(-) create mode 100644 eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs create mode 100644 eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs diff --git a/.github/workflows/dotnet-core-master.yml b/.github/workflows/dotnet-core-master.yml index fc183975..6214cab1 100644 --- a/.github/workflows/dotnet-core-master.yml +++ b/.github/workflows/dotnet-core-master.yml @@ -259,7 +259,7 @@ jobs: - name: b filter: "FullyQualifiedName=TimePlanning.Pn.Test.PictureSnapshotServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.DanLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.DataLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.ConfigurationSeedDataTests" - name: c - filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests" + filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests" - name: d filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationVersionHistoryTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetControllerTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTierRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PraktikantPayLineRoutingTests|FullyQualifiedName=TimePlanning.Pn.Test.MobileFlexRecomputeAndCascadeTests|FullyQualifiedName=TimePlanning.Pn.Test.SiteWorkerResolverTests" - name: e diff --git a/.github/workflows/dotnet-core-pr.yml b/.github/workflows/dotnet-core-pr.yml index 8975ac27..07858004 100644 --- a/.github/workflows/dotnet-core-pr.yml +++ b/.github/workflows/dotnet-core-pr.yml @@ -248,7 +248,7 @@ jobs: - name: b filter: "FullyQualifiedName=TimePlanning.Pn.Test.PictureSnapshotServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.DanLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.DataLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.ConfigurationSeedDataTests" - name: c - filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests" + filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests" - name: d filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationVersionHistoryTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetControllerTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTierRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PraktikantPayLineRoutingTests|FullyQualifiedName=TimePlanning.Pn.Test.MobileFlexRecomputeAndCascadeTests|FullyQualifiedName=TimePlanning.Pn.Test.SiteWorkerResolverTests" - name: e diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs new file mode 100644 index 00000000..961f7c7b --- /dev/null +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs @@ -0,0 +1,177 @@ +using System; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microting.eForm.Infrastructure.Constants; +using NUnit.Framework; +using TimePlanning.Pn.Infrastructure.Helpers; +using PlanRegistrationEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.PlanRegistration; + +namespace TimePlanning.Pn.Test; + +/// +/// The lock is derived, never stored: a day is locked when the worker has any +/// Reconciled day at or after it. These tests pin that derivation, because +/// every enforcement layer downstream trusts it. +/// +[TestFixture] +public class DayLockHelperTests : TestBaseSetup +{ + [SetUp] + public async Task SetUpTest() => await base.Setup(); + + // No workflowState parameter: PnBase.Create overwrites it with "created" + // regardless of what is set here. + private async Task Seed(int site, DateTime date, bool reconciled) + { + await new PlanRegistrationEntity + { + SdkSitId = site, + Date = date, + Reconciled = reconciled, + ReconciledAt = reconciled ? new DateTime(2026, 1, 20, 9, 12, 0) : (DateTime?)null, + PlanText = "", + CommentOffice = "", + CommentOfficeAll = "", + WorkflowState = Constants.WorkflowStates.Created, + CreatedByUserId = 1, + UpdatedByUserId = 1, + }.Create(TimePlanningPnDbContext!); + } + + [Test] + public async Task LockedThrough_NoReconciledDay_IsNull() + { + await Seed(700, new DateTime(2026, 1, 12), reconciled: false); + + var result = await DayLockHelper.LockedThroughAsync(TimePlanningPnDbContext!, 700); + + Assert.That(result, Is.Null, "a worker with nothing reconciled has no boundary"); + } + + [Test] + public async Task LockedThrough_SeveralReconciled_IsTheLatest() + { + await Seed(701, new DateTime(2026, 1, 12), reconciled: true); + await Seed(701, new DateTime(2026, 1, 16), reconciled: true); + await Seed(701, new DateTime(2026, 1, 14), reconciled: false); + + var result = await DayLockHelper.LockedThroughAsync(TimePlanningPnDbContext!, 701); + + Assert.That(result, Is.EqualTo(new DateTime(2026, 1, 16))); + } + + [Test] + public async Task LockedThrough_IgnoresRemovedRows() + { + // Once Task 2's interceptor exists, any write that touches a + // PlanRegistration at or before the current boundary (I3) is + // rejected. That rules out the obvious seeding -- reconcile a row, + // then soft-delete it in a LATER, separate save -- because by the + // time of that second save the boundary would already be the row + // being deleted, and the delete would land ON the boundary day. + // + // Instead: seed 702/12 reconciled (this is and stays the boundary), + // and seed 702/18 PLAIN (not reconciled, so it holds no boundary and + // simply sits above the existing one). Then, in a SINGLE save on the + // tracked 18th entity, set Reconciled + ReconciledAt and soft-delete + // it via Delete() (PnBase.Delete only ever issues one SaveChanges + // when there are pending changes). At the moment that save runs, the + // DB boundary is still the 12th, so the write to the 18th is above + // the boundary and permitted -- both before Task 2's interceptor + // exists and after. The row ends up Removed, so it must never + // surface as the new boundary. + await Seed(702, new DateTime(2026, 1, 12), reconciled: true); + await Seed(702, new DateTime(2026, 1, 18), reconciled: false); + + var later = await TimePlanningPnDbContext!.PlanRegistrations + .FirstAsync(x => x.SdkSitId == 702 && x.Date == new DateTime(2026, 1, 18)); + later.Reconciled = true; + later.ReconciledAt = new DateTime(2026, 1, 20, 9, 12, 0); + await later.Delete(TimePlanningPnDbContext!); + + var result = await DayLockHelper.LockedThroughAsync(TimePlanningPnDbContext!, 702); + + Assert.That(result, Is.EqualTo(new DateTime(2026, 1, 12)), + "a soft-deleted reconciled row must not hold the boundary"); + } + + [Test] + public async Task LockedThrough_IsPerWorker() + { + await Seed(703, new DateTime(2026, 1, 16), reconciled: true); + await Seed(704, new DateTime(2026, 1, 12), reconciled: false); + + // Await FIRST, then assert synchronously. Assert.Multiple(async () => ...) + // binds to the Action overload, making the lambda async void: its + // assertions can run after the block exits, and a failure is then lost + // or blamed on the wrong test. NUnit.Analyzers flags this. + var seven03 = await DayLockHelper.LockedThroughAsync(TimePlanningPnDbContext!, 703); + var seven04 = await DayLockHelper.LockedThroughAsync(TimePlanningPnDbContext!, 704); + + Assert.Multiple(() => + { + Assert.That(seven03, Is.EqualTo(new DateTime(2026, 1, 16))); + Assert.That(seven04, Is.Null, "one worker's boundary must not leak onto another"); + }); + } + + [Test] + public async Task LockedThroughForSites_ResolvesManyInOneQuery() + { + await Seed(705, new DateTime(2026, 1, 16), reconciled: true); + await Seed(706, new DateTime(2026, 1, 13), reconciled: true); + await Seed(707, new DateTime(2026, 1, 13), reconciled: false); + + var map = await DayLockHelper.LockedThroughForSitesAsync( + TimePlanningPnDbContext!, new[] { 705, 706, 707 }); + + Assert.Multiple(() => + { + Assert.That(map[705], Is.EqualTo(new DateTime(2026, 1, 16))); + Assert.That(map[706], Is.EqualTo(new DateTime(2026, 1, 13))); + Assert.That(map[707], Is.Null); + Assert.That(map.Count, Is.EqualTo(3), "every requested site gets an entry"); + }); + } + + [TestCase("2026-01-10", true, TestName = "IsLocked_BeforeBoundary_True")] + [TestCase("2026-01-16", true, TestName = "IsLocked_AtBoundary_True")] + [TestCase("2026-01-17", false, TestName = "IsLocked_AfterBoundary_False")] + public void IsLocked_RelativeToBoundary(string date, bool expectedLocked) + { + var boundary = new DateTime(2026, 1, 16); + + var locked = DayLockHelper.IsLocked(boundary, DateTime.Parse(date)); + + Assert.That(locked, Is.EqualTo(expectedLocked)); + } + + [Test] + public void IsLocked_NoBoundary_NothingIsLocked() + { + Assert.That(DayLockHelper.IsLocked(null, new DateTime(2020, 1, 1)), Is.False); + } + + [Test] + public void IsLocked_IgnoresTimeOfDay() + { + var boundary = new DateTime(2026, 1, 16); + + Assert.That(DayLockHelper.IsLocked(boundary, new DateTime(2026, 1, 16, 23, 59, 59)), + Is.True, "the boundary day is locked for its whole length"); + } + + [Test] + public void CanReconcile_TodayAndFuture_False_Past_True() + { + Assert.Multiple(() => + { + Assert.That(DayLockHelper.CanReconcile(DateTime.Now.Date), Is.False, + "today must stay open so time can still be registered"); + Assert.That(DayLockHelper.CanReconcile(DateTime.Now.Date.AddDays(1)), Is.False); + Assert.That(DayLockHelper.CanReconcile(DateTime.Now.Date.AddDays(-1)), Is.True); + Assert.That(DayLockHelper.CanReconcile(DateTime.Now.Date.AddHours(23)), Is.False, + "a time-of-day on today is still today"); + }); + } +} diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs new file mode 100644 index 00000000..cb934187 --- /dev/null +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs @@ -0,0 +1,86 @@ +#nullable enable +namespace TimePlanning.Pn.Infrastructure.Helpers; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microting.eForm.Infrastructure.Constants; +using Microting.TimePlanningBase.Infrastructure.Data; +using Microting.TimePlanningBase.Infrastructure.Data.Entities; + +/// +/// The single source of truth for "is this day locked". +/// +/// The lock is DERIVED, never stored. A worker's boundary is the latest date +/// they have a Reconciled registration on; every day at or before it is locked. +/// Earlier days are therefore locked WITHOUT being marked Reconciled, and a day +/// below the boundary cannot be unlocked because unlocking it would not move +/// MAX(Date) -- both requirements fall out of the model instead of needing a +/// job to keep flags in sync. +/// +public static class DayLockHelper +{ + /// + /// The latest reconciled date for one worker, or null when they have none. + /// Soft-deleted rows never hold the boundary. + /// + public static async Task LockedThroughAsync(TimePlanningPnDbContext db, int sdkSitId) + { + return await BoundaryRows(db) + .Where(x => x.SdkSitId == sdkSitId) + .MaxAsync(x => (DateTime?)x.Date) + .ConfigureAwait(false); + } + + /// + /// Boundaries for many workers in ONE query. Callers that render a grid + /// resolve this once per request rather than once per day cell. + /// Every requested site gets an entry; sites with no reconciled day map to null. + /// + public static async Task> LockedThroughForSitesAsync( + TimePlanningPnDbContext db, IReadOnlyCollection sdkSitIds) + { + var found = await BoundaryRows(db) + .Where(x => sdkSitIds.Contains(x.SdkSitId)) + .GroupBy(x => x.SdkSitId) + .Select(g => new { SdkSitId = g.Key, Max = g.Max(x => x.Date) }) + .ToListAsync() + .ConfigureAwait(false); + + var map = found.ToDictionary(x => x.SdkSitId, x => (DateTime?)x.Max); + foreach (var id in sdkSitIds) + { + map.TryAdd(id, null); + } + return map; + } + + /// + /// Pure predicate, so callers can resolve the boundary once and test many + /// dates against it without touching the database again. + /// + public static bool IsLocked(DateTime? lockedThrough, DateTime date) + => lockedThrough.HasValue && date.Date <= lockedThrough.Value.Date; + + /// + /// Invariant I2: today and future days must stay open so time can still be + /// registered. This is also what makes the forward flex cascades unable to + /// reach a locked day -- see the design doc before relaxing it. + /// + /// DateTime.Now, not UtcNow: PlanRegistration.Date is a local midnight, and + /// the existing mobile guard compares the same way. + /// + public static bool CanReconcile(DateTime date) => date.Date < DateTime.Now.Date; + + /// + /// What counts as a boundary row, in one place: Reconciled and not + /// soft-deleted. Both public queries compose their own site predicate + /// over this so the two never drift apart. + /// + private static IQueryable BoundaryRows(TimePlanningPnDbContext db) + => db.PlanRegistrations + .Where(x => x.Reconciled) + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed); +} From 17bf5f2cb8edb88f809f5e7abb9b54519a65cdee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 15 Sep 2026 10:18:30 +0200 Subject: [PATCH 07/28] feat(lock): enforce the day lock at the SaveChanges boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every PlanRegistration write is now checked against the worker's reconciled boundary via a SaveChangesInterceptor, on both its original and its current (site, date) slot -- so a write cannot escape the lock by moving a locked row's date past the boundary or reassigning it to another worker. Two writes remain permitted inside the locked range: clearing the flag on the boundary day itself (unlock, which also requires ReconciledAt to be cleared), and a payroll-flag-only write, which spec §11.2 requires so exporting a reconciled period keeps working. Wired into the pooled DbContext registration, TimePlanningDbContextHelper, and both test context builders in TestBaseSetup. Co-Authored-By: Claude Opus 5 --- .github/workflows/dotnet-core-master.yml | 2 +- .github/workflows/dotnet-core-pr.yml | 2 +- .../DayLockHelperTests.cs | 9 +- .../DayLockInterceptorTests.cs | 267 ++++++++++++++++ .../TimePlanning.Pn.Test/TestBaseSetup.cs | 5 + .../EformTimePlanningPlugin.cs | 4 +- .../Infrastructure/Helpers/DayLockHelper.cs | 6 +- .../Helpers/TimePlanningDbContextHelper.cs | 37 ++- .../ReconciledDayLockInterceptor.cs | 287 ++++++++++++++++++ 9 files changed, 608 insertions(+), 11 deletions(-) create mode 100644 eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockInterceptorTests.cs create mode 100644 eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Interceptors/ReconciledDayLockInterceptor.cs diff --git a/.github/workflows/dotnet-core-master.yml b/.github/workflows/dotnet-core-master.yml index 6214cab1..44af8fe9 100644 --- a/.github/workflows/dotnet-core-master.yml +++ b/.github/workflows/dotnet-core-master.yml @@ -259,7 +259,7 @@ jobs: - name: b filter: "FullyQualifiedName=TimePlanning.Pn.Test.PictureSnapshotServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.DanLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.DataLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.ConfigurationSeedDataTests" - name: c - filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests" + filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockInterceptorTests" - name: d filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationVersionHistoryTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetControllerTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTierRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PraktikantPayLineRoutingTests|FullyQualifiedName=TimePlanning.Pn.Test.MobileFlexRecomputeAndCascadeTests|FullyQualifiedName=TimePlanning.Pn.Test.SiteWorkerResolverTests" - name: e diff --git a/.github/workflows/dotnet-core-pr.yml b/.github/workflows/dotnet-core-pr.yml index 07858004..8997c9e2 100644 --- a/.github/workflows/dotnet-core-pr.yml +++ b/.github/workflows/dotnet-core-pr.yml @@ -248,7 +248,7 @@ jobs: - name: b filter: "FullyQualifiedName=TimePlanning.Pn.Test.PictureSnapshotServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.DanLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.DataLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.ConfigurationSeedDataTests" - name: c - filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests" + filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockInterceptorTests" - name: d filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationVersionHistoryTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetControllerTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTierRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PraktikantPayLineRoutingTests|FullyQualifiedName=TimePlanning.Pn.Test.MobileFlexRecomputeAndCascadeTests|FullyQualifiedName=TimePlanning.Pn.Test.SiteWorkerResolverTests" - name: e diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs index 961f7c7b..67b21b18 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs @@ -51,9 +51,16 @@ public async Task LockedThrough_NoReconciledDay_IsNull() [Test] public async Task LockedThrough_SeveralReconciled_IsTheLatest() { + // The plain row sits ABOVE the boundary (2026-01-18, not the + // 14th): with the interceptor attached, creating an unreconciled row + // at or below an already-seeded boundary would throw during arrange, + // before this test ever got to its assertion. Seeding it above also + // strengthens the test: a plain row past the reconciled max proves + // MAX ignores non-reconciled rows, not just that it ignores earlier + // reconciled ones. await Seed(701, new DateTime(2026, 1, 12), reconciled: true); await Seed(701, new DateTime(2026, 1, 16), reconciled: true); - await Seed(701, new DateTime(2026, 1, 14), reconciled: false); + await Seed(701, new DateTime(2026, 1, 18), reconciled: false); var result = await DayLockHelper.LockedThroughAsync(TimePlanningPnDbContext!, 701); diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockInterceptorTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockInterceptorTests.cs new file mode 100644 index 00000000..08510ebf --- /dev/null +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockInterceptorTests.cs @@ -0,0 +1,267 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microting.eForm.Infrastructure.Constants; +using NUnit.Framework; +using TimePlanning.Pn.Infrastructure.Interceptors; +using PlanRegistrationEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.PlanRegistration; + +namespace TimePlanning.Pn.Test; + +/// +/// The interceptor is the only layer that is COMPLETE -- it covers all 32 +/// PlanRegistration write sites and anything added later. These tests go +/// through a context built exactly as production builds it (interceptor +/// attached), so they prove the wiring, not just the class. +/// +[TestFixture] +public class DayLockInterceptorTests : TestBaseSetup +{ + [SetUp] + public async Task SetUpTest() => await base.Setup(); + + private async Task SeedReconciled(int site, DateTime date) + { + var row = new PlanRegistrationEntity + { + SdkSitId = site, Date = date, Reconciled = true, + ReconciledAt = new DateTime(2026, 1, 20, 9, 12, 0), + PlanText = "", CommentOffice = "", CommentOfficeAll = "", + WorkflowState = Constants.WorkflowStates.Created, + CreatedByUserId = 1, UpdatedByUserId = 1, + }; + await row.Create(TimePlanningPnDbContext!); + return row; + } + + private async Task SeedPlain(int site, DateTime date) + { + var row = new PlanRegistrationEntity + { + SdkSitId = site, Date = date, + PlanText = "", CommentOffice = "", CommentOfficeAll = "", + WorkflowState = Constants.WorkflowStates.Created, + CreatedByUserId = 1, UpdatedByUserId = 1, + }; + await row.Create(TimePlanningPnDbContext!); + return row; + } + + [Test] + public async Task Modifying_ADayBelowTheBoundary_IsRejected() + { + // Order matters: create the earlier row FIRST. Seeding the boundary + // first would put this Create inside the locked range, and the arrange + // step would throw before the assertion was ever reached. + var earlier = await SeedPlain(800, new DateTime(2026, 1, 13)); + await SeedReconciled(800, new DateTime(2026, 1, 16)); + + earlier.PlanHours = 9; + + Assert.ThrowsAsync(async () => + await earlier.Update(TimePlanningPnDbContext!)); + } + + [Test] + public async Task Modifying_TheBoundaryDayItself_IsRejected() + { + var boundary = await SeedReconciled(801, new DateTime(2026, 1, 16)); + + boundary.PlanHours = 9; + + Assert.ThrowsAsync(async () => + await boundary.Update(TimePlanningPnDbContext!)); + } + + [Test] + public async Task Creating_ARowInsideALockedRange_IsRejected() + { + await SeedReconciled(802, new DateTime(2026, 1, 16)); + + Assert.ThrowsAsync(async () => + await SeedPlain(802, new DateTime(2026, 1, 14))); + } + + [Test] + public async Task SoftDeleting_ALockedRow_IsRejected() + { + // PnBase.Delete is a soft delete -- it sets WorkflowState = Removed via + // UpdateInternal, so this arrives at the interceptor as Modified, not + // Deleted. The name says SoftDeleting so nobody reads a pass here as + // proof that the EntityState.Deleted arm works. + var earlier = await SeedPlain(803, new DateTime(2026, 1, 13)); + await SeedReconciled(803, new DateTime(2026, 1, 16)); + + Assert.ThrowsAsync(async () => + await earlier.Delete(TimePlanningPnDbContext!)); + } + + [Test] + public async Task Modifying_ADayAboveTheBoundary_IsAllowed() + { + var later = await SeedPlain(804, new DateTime(2026, 1, 18)); + await SeedReconciled(804, new DateTime(2026, 1, 16)); + + later.PlanHours = 9; + await later.Update(TimePlanningPnDbContext!); + + // AsNoTracking: reading the tracked instance back off the same context + // would prove nothing -- it already holds the in-memory value whether + // or not the write ever reached the database. + var reloaded = await TimePlanningPnDbContext!.PlanRegistrations + .AsNoTracking() + .FirstAsync(x => x.Id == later.Id); + Assert.That(reloaded.PlanHours, Is.EqualTo(9), + "days after the boundary must stay fully editable"); + } + + [Test] + public async Task ANotherWorkersBoundary_DoesNotLockThisWorker() + { + var other = await SeedPlain(806, new DateTime(2026, 1, 13)); + await SeedReconciled(805, new DateTime(2026, 1, 16)); + + other.PlanHours = 9; + await other.Update(TimePlanningPnDbContext!); + + var reloaded = await TimePlanningPnDbContext!.PlanRegistrations + .AsNoTracking() + .FirstAsync(x => x.Id == other.Id); + Assert.That(reloaded.PlanHours, Is.EqualTo(9), + "the boundary is per worker; it must not leak across sites"); + } + + [Test] + public async Task SettingReconciled_OnTheBoundaryDay_IsAllowed() + { + // Unlocking must not be blocked by the very lock it is clearing. + var boundary = await SeedReconciled(807, new DateTime(2026, 1, 16)); + + boundary.Reconciled = false; + boundary.ReconciledAt = null; + await boundary.Update(TimePlanningPnDbContext!); + + var reloaded = await TimePlanningPnDbContext!.PlanRegistrations + .AsNoTracking() + .FirstAsync(x => x.Id == boundary.Id); + Assert.That(reloaded.Reconciled, Is.False, + "clearing the flag on the boundary day is how unlocking works"); + } + + [Test] + public async Task SettingTheTransferredToPayrollFlag_OnALockedDay_IsAllowed() + { + // Mirrors PayrollExportService.ExportPayroll exactly (ruling F10): + // Reconciled and TransferredToPayroll are independent, so exporting a + // reconciled period must not be blocked by the very lock reconciling + // it created. + var earlier = await SeedPlain(808, new DateTime(2026, 1, 13)); + await SeedReconciled(808, new DateTime(2026, 1, 16)); + + earlier.TransferredToPayroll = true; + earlier.TransferredToPayrollAt = DateTime.UtcNow; + await earlier.Update(TimePlanningPnDbContext!); + + var reloaded = await TimePlanningPnDbContext!.PlanRegistrations + .AsNoTracking() + .FirstAsync(x => x.Id == earlier.Id); + Assert.That(reloaded.TransferredToPayroll, Is.True, + "the payroll flag must persist on a locked day"); + } + + [Test] + public async Task ChangingHours_AlongsideThePayrollFlag_IsRejected() + { + // Proves the payroll exemption is not a loophole: as soon as anything + // else about the row changes in the same save, the lock still applies. + var earlier = await SeedPlain(809, new DateTime(2026, 1, 13)); + await SeedReconciled(809, new DateTime(2026, 1, 16)); + + earlier.TransferredToPayroll = true; + earlier.PlanHours = 9; + + Assert.ThrowsAsync(async () => + await earlier.Update(TimePlanningPnDbContext!)); + } + + [Test] + public async Task MovingALockedRowOutOfTheLockedRange_IsRejected() + { + // I3 is keyed on (SdkSitId, Date) at the time of the write -- it must + // also be checked against the ORIGINAL slot, or moving a locked row's + // Date to a day above the boundary would let it escape the lock it + // started inside. + var earlier = await SeedPlain(810, new DateTime(2026, 1, 13)); + await SeedReconciled(810, new DateTime(2026, 1, 16)); + + earlier.Date = new DateTime(2026, 1, 20); + + Assert.ThrowsAsync(async () => + await earlier.Update(TimePlanningPnDbContext!)); + } + + [Test] + public async Task UnlockingAndChangingHours_OnTheBoundaryDay_IsRejected() + { + // Proves the unlock exemption is not a loophole either: as soon as + // anything besides Reconciled/ReconciledAt changes in the same save, + // the lock still applies -- even on the boundary day itself. + var boundary = await SeedReconciled(811, new DateTime(2026, 1, 16)); + + boundary.Reconciled = false; + boundary.ReconciledAt = null; + boundary.PlanHours = 9; + + Assert.ThrowsAsync(async () => + await boundary.Update(TimePlanningPnDbContext!)); + } + + [Test] + public async Task ClearingReconciled_BelowTheBoundary_IsRejected() + { + // Unlocking only ever works ON the boundary day itself -- a day + // below it cannot be unlocked, because clearing its flag would not + // move MAX(Date) (see DayLockHelper's doc comment): the row would + // still sit below the (unchanged) boundary set by the later + // reconciled day. + var earlier = await SeedReconciled(812, new DateTime(2026, 1, 12)); + await SeedReconciled(812, new DateTime(2026, 1, 16)); + + earlier.Reconciled = false; + earlier.ReconciledAt = null; + + Assert.ThrowsAsync(async () => + await earlier.Update(TimePlanningPnDbContext!)); + } + + [Test] + public async Task MovingALockedRowToAnotherWorker_IsRejected() + { + // Same escape as MovingALockedRowOutOfTheLockedRange_IsRejected, but + // via SdkSitId instead of Date: without checking the ORIGINAL site, + // reassigning a locked row to a worker with no boundary of their own + // would let it through. + var earlier = await SeedPlain(814, new DateTime(2026, 1, 13)); + await SeedReconciled(814, new DateTime(2026, 1, 16)); + + earlier.SdkSitId = 815; + + Assert.ThrowsAsync(async () => + await earlier.Update(TimePlanningPnDbContext!)); + } + + [Test] + public async Task ClearingReconciledButKeepingReconciledAt_OnTheBoundaryDay_IsRejected() + { + // Guards invariant I1 at the choke point: Reconciled=false with + // ReconciledAt still set is not a valid unlock, even on the boundary + // day itself -- the row must never end up in a state I1 forbids. + var boundary = await SeedReconciled(816, new DateTime(2026, 1, 16)); + + boundary.Reconciled = false; + + Assert.ThrowsAsync(async () => + await boundary.Update(TimePlanningPnDbContext!)); + } +} diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs index 022e9288..365089de 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs @@ -10,6 +10,7 @@ using NUnit.Framework; using Testcontainers.MariaDb; using TimePlanning.Pn.Infrastructure.Data.Seed; +using TimePlanning.Pn.Infrastructure.Interceptors; #nullable enable namespace TimePlanning.Pn.Test; @@ -39,6 +40,8 @@ private TimePlanningPnDbContext GetTimePlanningPnDbContext(string connectionStr) builder.EnableRetryOnFailure(); }); + optionsBuilder.AddInterceptors(ReconciledDayLockInterceptor.Instance); + var backendConfigurationPnDbContext = new TimePlanningPnDbContext(optionsBuilder.Options); // Drop and recreate the database fresh for each test to avoid state pollution @@ -119,6 +122,8 @@ protected TimePlanningPnDbContext CreateTimePlanningPnDbContext() builder.EnableRetryOnFailure(); }); + optionsBuilder.AddInterceptors(ReconciledDayLockInterceptor.Instance); + return new TimePlanningPnDbContext(optionsBuilder.Options); } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/EformTimePlanningPlugin.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/EformTimePlanningPlugin.cs index cefa56a8..697eacf6 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/EformTimePlanningPlugin.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/EformTimePlanningPlugin.cs @@ -27,6 +27,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE using Microting.EformAngularFrontendBase.Infrastructure.Data; using Sentry; using TimePlanning.Pn.Infrastructure.Helpers; +using TimePlanning.Pn.Infrastructure.Interceptors; using TimePlanning.Pn.Services.TimePlanningRegistrationDeviceService; using TimePlanning.Pn.Services.TimePlanningGpsCoordinateService; using TimePlanning.Pn.Services.TimePlanningPictureSnapshotService; @@ -186,7 +187,8 @@ public void ConfigureDbContext(IServiceCollection services, string connectionStr { builder.EnableRetryOnFailure(); builder.MigrationsAssembly(PluginAssembly().FullName); - })); + }) + .AddInterceptors(ReconciledDayLockInterceptor.Instance)); var contextFactory = new TimePlanningPnContextFactory(); var context = contextFactory.CreateDbContext(new[] { connectionString }); diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs index cb934187..33ed43f5 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs @@ -4,6 +4,7 @@ namespace TimePlanning.Pn.Infrastructure.Helpers; using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Microting.eForm.Infrastructure.Constants; @@ -40,13 +41,14 @@ public static class DayLockHelper /// Every requested site gets an entry; sites with no reconciled day map to null. /// public static async Task> LockedThroughForSitesAsync( - TimePlanningPnDbContext db, IReadOnlyCollection sdkSitIds) + TimePlanningPnDbContext db, IReadOnlyCollection sdkSitIds, + CancellationToken cancellationToken = default) { var found = await BoundaryRows(db) .Where(x => sdkSitIds.Contains(x.SdkSitId)) .GroupBy(x => x.SdkSitId) .Select(g => new { SdkSitId = g.Key, Max = g.Max(x => x.Date) }) - .ToListAsync() + .ToListAsync(cancellationToken) .ConfigureAwait(false); var map = found.ToDictionary(x => x.SdkSitId, x => (DateTime?)x.Max); diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/TimePlanningDbContextHelper.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/TimePlanningDbContextHelper.cs index b73b8f4f..1d25e435 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/TimePlanningDbContextHelper.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/TimePlanningDbContextHelper.cs @@ -1,21 +1,48 @@ +using System; +using Microsoft.EntityFrameworkCore; using Microting.TimePlanningBase.Infrastructure.Data; -using Microting.TimePlanningBase.Infrastructure.Data.Factories; +using TimePlanning.Pn.Infrastructure.Interceptors; +// NB: no Pomelo using. This repo uses the Microting.EntityFrameworkCore.MySql +// fork, and MariaDbServerVersion/ServerVersion come from +// Microsoft.EntityFrameworkCore -- which is why EformTimePlanningPlugin.cs +// needs no provider-specific using either. Adding a Pomelo PackageReference +// would introduce a second, conflicting provider. namespace TimePlanning.Pn.Infrastructure.Helpers; +/// +/// Builds plugin DbContexts with the day-lock interceptor attached. +/// +/// This no longer delegates to TimePlanningPnContextFactory: that factory +/// builds its DbContextOptionsBuilder in a method-local and exposes no hook, so +/// there is no way to attach an interceptor through it. The context's public +/// options constructor is the supported seam, and it needs no base-package change. +/// public class TimePlanningDbContextHelper(string connectionString) : ITimePlanningDbContextHelper { - private string ConnectionString { get;} = connectionString; + private string ConnectionString { get; } = connectionString; public TimePlanningPnDbContext GetDbContext() { - TimePlanningPnContextFactory contextFactory = new TimePlanningPnContextFactory(); + var optionsBuilder = new DbContextOptionsBuilder(); - return contextFactory.CreateDbContext([ConnectionString]); + // Hardcoded version, exactly as TimePlanningPnContextFactory does. + // ServerVersion.AutoDetect OPENS A CONNECTION and runs a version + // query; this method is called once per assigned site on every + // dashboard load, so AutoDetect here would add a round-trip per + // worker per page view that the current code does not pay. + optionsBuilder.UseMySql( + ConnectionString, + new MariaDbServerVersion(new Version(10, 5, 0)), + mySqlOptionsAction: builder => { builder.EnableRetryOnFailure(); }); + + optionsBuilder.AddInterceptors(ReconciledDayLockInterceptor.Instance); + + return new TimePlanningPnDbContext(optionsBuilder.Options); } } public interface ITimePlanningDbContextHelper { TimePlanningPnDbContext GetDbContext(); -} \ No newline at end of file +} diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Interceptors/ReconciledDayLockInterceptor.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Interceptors/ReconciledDayLockInterceptor.cs new file mode 100644 index 00000000..591eaf99 --- /dev/null +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Interceptors/ReconciledDayLockInterceptor.cs @@ -0,0 +1,287 @@ +#nullable enable +namespace TimePlanning.Pn.Infrastructure.Interceptors; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Helpers; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microting.TimePlanningBase.Infrastructure.Data; +using PlanRegistrationEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.PlanRegistration; + +/// +/// Thrown when a write would touch a day at or before the worker's reconciled +/// boundary. Distinct from a generic InvalidOperationException so the friendly +/// guards in the services can tell "the lock stopped this" from "something else +/// broke" -- and so a stray one is legible in Sentry. +/// +/// After this is thrown, the rejected entry stays tracked on the DbContext (EF +/// never rolls back change-tracker state just because SaveChanges threw). A +/// later SaveChanges call on the SAME context will see that entry again and +/// rethrow. Callers that catch this and want to keep using the context must +/// either stop trying to save through it, or explicitly detach/reload the +/// rejected entry first. +/// +public class DayLockedException(int sdkSitId, DateTime date, DateTime lockedThrough) + : InvalidOperationException( + $"Day {date:yyyy-MM-dd} for site {sdkSitId} is locked: reconciled through {lockedThrough:yyyy-MM-dd}.") +{ + public int SdkSitId { get; } = sdkSitId; + public DateTime Date { get; } = date; + public DateTime LockedThrough { get; } = lockedThrough; +} + +/// +/// Enforces invariant I3 across every PlanRegistration write path. +/// +/// STATELESS BY DESIGN. The pooled context registration +/// (EformTimePlanningPlugin.AddDbContextPool) reuses context instances, so an +/// interceptor holding per-request state would leak it between requests. +/// Everything this needs is read from the change tracker on each call. Since +/// there is no state, one shared is used everywhere +/// instead of a `new` per registration. +/// +/// The boundary is resolved ONCE per SaveChanges for the distinct sites in the +/// change set, not once per row. +/// +public class ReconciledDayLockInterceptor : SaveChangesInterceptor +{ + public static readonly ReconciledDayLockInterceptor Instance = new(); + + /// + /// Bookkeeping columns PnBase.Update/Delete touch on every single save + /// (UpdateInternal bumps Version and UpdatedAt unconditionally, and + /// callers routinely stamp UpdatedByUserId), regardless of what the + /// caller actually meant to change. Both permitted-write checks below + /// need "what did the caller actually change", so both exclude these. + /// + private static readonly HashSet BookkeepingProperties = + [ + nameof(PlanRegistrationEntity.UpdatedAt), + nameof(PlanRegistrationEntity.Version), + nameof(PlanRegistrationEntity.UpdatedByUserId), + ]; + + private static readonly HashSet UnlockProperties = + [ + nameof(PlanRegistrationEntity.Reconciled), + nameof(PlanRegistrationEntity.ReconciledAt), + ]; + + /// + /// Spec §11.2: Reconciled and TransferredToPayroll are independent in both + /// directions -- reconciling a period must not affect export eligibility, + /// and exporting it must not be blocked by the lock reconciling it + /// created. PayrollExportService.ExportPayroll sets exactly these two + /// properties, one Update per row, after the export file already exists; + /// without this exemption exporting any reconciled period throws midway + /// and leaves some rows flagged and some not. Do not "tighten" this away. + /// + private static readonly HashSet PayrollFlagProperties = + [ + nameof(PlanRegistrationEntity.TransferredToPayroll), + nameof(PlanRegistrationEntity.TransferredToPayrollAt), + ]; + + /// One (SdkSitId, Date) pair a write touches -- see . + private readonly record struct Slot(int SdkSitId, DateTime Date); + + public override InterceptionResult SavingChanges( + DbContextEventData eventData, InterceptionResult result) + { + // Sync-over-async is safe here: ASP.NET Core requests run with no + // SynchronizationContext, and GuardAsync/LockedThroughForSitesAsync use + // ConfigureAwait(false) throughout, so there is no continuation to + // deadlock on. This path exists only because the plugin's own seed + // code calls the synchronous SaveChanges; those seeds never touch + // PlanRegistration, so GuardAsync returns before awaiting anything. + GuardAsync(eventData.Context, CancellationToken.None).GetAwaiter().GetResult(); + return base.SavingChanges(eventData, result); + } + + public override async ValueTask> SavingChangesAsync( + DbContextEventData eventData, InterceptionResult result, + CancellationToken cancellationToken = default) + { + await GuardAsync(eventData.Context, cancellationToken).ConfigureAwait(false); + return await base.SavingChangesAsync(eventData, result, cancellationToken) + .ConfigureAwait(false); + } + + private static async Task GuardAsync(DbContext? context, CancellationToken cancellationToken) + { + if (context is not TimePlanningPnDbContext db) + { + return; + } + + // PnBase.Delete is a SOFT delete: it sets WorkflowState = Removed and + // routes through UpdateInternal, so a delete reaches here as Modified, + // never Deleted. The Deleted arm is kept as insurance against a future + // hard delete; do not "simplify" it away on the grounds that it never + // fires today. + // + // Each entry contributes one or two slots: I3 is keyed on + // (SdkSitId, Date), and a Modified/Deleted row can change either or + // both -- moving a row's Date past the boundary, or its SdkSitId to + // another worker, must not let it escape a lock it started inside. + var entrySlots = db.ChangeTracker.Entries() + .Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted) + .ToDictionary(e => e, SlotsToCheck); + + if (entrySlots.Count == 0) + { + return; + } + + var siteIds = entrySlots.Values + .SelectMany(slots => slots) + .Select(s => s.SdkSitId) + .Distinct() + .ToList(); + + // Query `db` itself. An earlier draft opened a second context here; + // that is a production outage, because ServerVersion.AutoDetect opens a + // connection and runs a version query, and this guard runs on EVERY + // save -- including the four Update calls inside the per-day loop of + // UpdatePlanRegistrationsInPeriod, which runs on every dashboard load. + // 50 workers x 31 days would mean thousands of extra connections per + // page view. + // + // Querying `db` is safe: LockedThroughForSitesAsync projects into an + // anonymous type so it tracks nothing and cannot pollute the change + // tracker; a query never re-enters SaveChanges so there is no + // recursion; and it reuses the open connection and any ambient + // transaction, which a separate context could not see. + var boundaries = await DayLockHelper + .LockedThroughForSitesAsync(db, siteIds, cancellationToken) + .ConfigureAwait(false); + + foreach (var (entry, slots) in entrySlots) + { + Slot? lockedSlot = null; + DateTime lockedBoundary = default; + + foreach (var slot in slots) + { + if (boundaries.TryGetValue(slot.SdkSitId, out var boundary) + && DayLockHelper.IsLocked(boundary, slot.Date)) + { + lockedSlot = slot; + lockedBoundary = boundary!.Value; + break; + } + } + + if (lockedSlot is null) + { + continue; + } + + // The two permitted writes inside the locked range: clearing the + // flag on the boundary day itself (that is what unlocking IS), and + // a payroll-flag-only write on any locked day (ruling F10). Both + // are evaluated against the entry's CURRENT site's boundary -- + // that is the only boundary either exemption is ever about -- and + // both already fail whenever Date or SdkSitId is among the + // modified properties (they are not in either exemption's allowed + // property set), which is exactly what stops a locked ORIGINAL + // slot from being exempted just because the write is moving the + // row away from it. + var currentSiteBoundary = boundaries.TryGetValue(entry.Entity.SdkSitId, out var csb) ? csb : null; + if ((currentSiteBoundary is not null && IsUnlockOfBoundaryDay(entry, currentSiteBoundary.Value)) + || IsPayrollFlagOnlyWrite(entry)) + { + continue; + } + + throw new DayLockedException(lockedSlot.Value.SdkSitId, lockedSlot.Value.Date, lockedBoundary); + } + } + + /// + /// The slot(s) I3 must check for one entry: always the CURRENT + /// (SdkSitId, Date), and -- for Modified/Deleted only, since Added has no + /// "before" -- the ORIGINAL one too, when it differs. Never reads + /// OriginalValue on an Added entry; EF has no original value to give one. + /// + private static List SlotsToCheck(EntityEntry entry) + { + var current = new Slot(entry.Entity.SdkSitId, entry.Entity.Date); + + if (entry.State is not (EntityState.Modified or EntityState.Deleted)) + { + return [current]; + } + + var original = new Slot( + entry.Property(x => x.SdkSitId).OriginalValue, + entry.Property(x => x.Date).OriginalValue); + + return original.Equals(current) ? [current] : [current, original]; + } + + /// + /// What the caller actually changed, ignoring the bookkeeping columns + /// every PnBase.Update/Delete call touches regardless of intent. Both + /// permitted-write predicates below are just a subset check over this. + /// + private static HashSet NonBookkeepingModifiedProperties( + EntityEntry entry) + => entry.Properties + .Where(p => p.IsModified) + .Select(p => p.Metadata.Name) + .Where(name => !BookkeepingProperties.Contains(name)) + .ToHashSet(); + + private static bool IsUnlockOfBoundaryDay( + EntityEntry entry, DateTime boundary) + { + if (entry.State != EntityState.Modified) + { + return false; + } + if (entry.Entity.Date.Date != boundary.Date) + { + return false; + } + // Reconciled must be going true -> false, and nothing else about the + // row -- besides Reconciled/ReconciledAt themselves -- may be + // changing in the same save. + var reconciled = entry.Property(x => x.Reconciled); + if (!reconciled.IsModified || (bool)reconciled.CurrentValue!) + { + return false; + } + // Invariant I1: ReconciledAt non-null exactly when Reconciled. A + // "clear Reconciled but leave ReconciledAt set" write is not a valid + // unlock -- it would leave the row in a state I1 forbids -- so this + // choke point refuses it rather than trusting every caller to clear + // both together. + if (entry.Property(x => x.ReconciledAt).CurrentValue is not null) + { + return false; + } + + return NonBookkeepingModifiedProperties(entry).IsSubsetOf(UnlockProperties); + } + + private static bool IsPayrollFlagOnlyWrite(EntityEntry entry) + { + if (entry.State != EntityState.Modified) + { + return false; + } + + var changed = NonBookkeepingModifiedProperties(entry); + // changed.Count == 0 would mean nothing but bookkeeping moved -- not a + // payroll write, and Count > 0 here already guarantees at least one of + // TransferredToPayroll/TransferredToPayrollAt is the modified property + // once the subset check below passes. + return changed.Count > 0 && changed.IsSubsetOf(PayrollFlagProperties); + } +} From 4a845325f23521b52e4fb391e81ac99ef5e916b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 15 Sep 2026 10:22:40 +0200 Subject: [PATCH 08/28] feat(lock): add the day-lock messages in English and Danish Co-Authored-By: Claude Opus 5 --- .../Resources/Translations.Designer.cs | 36 +++++++++++++++++++ .../Resources/Translations.da.resx | 18 ++++++++++ .../Resources/Translations.resx | 18 ++++++++++ 3 files changed, 72 insertions(+) diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs index 8d2fea37..a9854710 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs @@ -308,5 +308,41 @@ internal static string DayOverview { return ResourceManager.GetString("DayOverview", resourceCulture); } } + + internal static string DayIsReconciled { + get { + return ResourceManager.GetString("DayIsReconciled", resourceCulture); + } + } + + internal static string DayIsLockedByReconciledDay { + get { + return ResourceManager.GetString("DayIsLockedByReconciledDay", resourceCulture); + } + } + + internal static string CannotReconcileTodayOrFuture { + get { + return ResourceManager.GetString("CannotReconcileTodayOrFuture", resourceCulture); + } + } + + internal static string OnlyLatestReconciledDayCanBeUnlocked { + get { + return ResourceManager.GetString("OnlyLatestReconciledDayCanBeUnlocked", resourceCulture); + } + } + + internal static string SuccessfullyReconciledDay { + get { + return ResourceManager.GetString("SuccessfullyReconciledDay", resourceCulture); + } + } + + internal static string SuccessfullyUnlockedDay { + get { + return ResourceManager.GetString("SuccessfullyUnlockedDay", resourceCulture); + } + } } } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx index f2ec3ac5..d4887ddb 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx @@ -201,4 +201,22 @@ Dagsoversigt + + Dagen er afstemt. Tallene er endelige. + + + Dagen er låst, fordi den ligger før en afstemt dag. + + + Kun dage før i dag kan afstemmes. + + + Lås den seneste afstemte dag op først. + + + Dagen er afstemt + + + Dagen er låst op + \ No newline at end of file diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx index 1b1bf136..eaae564f 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx @@ -201,4 +201,22 @@ Day overview + + This day is reconciled. The figures are final. + + + This day is locked because it is before a reconciled day. + + + Only days before today can be reconciled. + + + Unlock the most recent reconciled day first. + + + Day reconciled + + + Day unlocked + \ No newline at end of file From 2d0e30df1fc16c85bf59148bbf242927f01fd625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 15 Sep 2026 10:48:43 +0200 Subject: [PATCH 09/28] feat(lock): add reconcile, unlock and reconcile-through endpoints Reconcile rejects today and later days (I2) and is idempotent; unlock is allowed only on the worker's boundary day; reconcile-through marks one landing day per worker and reports skips by reason; a concurrent boundary move degrades to a per-site skip. Co-Authored-By: Claude Opus 5 --- .github/workflows/dotnet-core-master.yml | 2 +- .github/workflows/dotnet-core-pr.yml | 2 +- .../ReconcileServiceTests.cs | 368 ++++++++++++++++++ .../TimePlanningPlanningController.cs | 22 ++ .../Planning/ReconcileThroughRequestModel.cs | 15 + .../Planning/ReconcileThroughResultModel.cs | 27 ++ .../ITimePlanningPlanningService.cs | 4 + .../TimePlanningPlanningService.cs | 233 +++++++++++ 8 files changed, 671 insertions(+), 2 deletions(-) create mode 100644 eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs create mode 100644 eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/ReconcileThroughRequestModel.cs create mode 100644 eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/ReconcileThroughResultModel.cs diff --git a/.github/workflows/dotnet-core-master.yml b/.github/workflows/dotnet-core-master.yml index 44af8fe9..60789b59 100644 --- a/.github/workflows/dotnet-core-master.yml +++ b/.github/workflows/dotnet-core-master.yml @@ -259,7 +259,7 @@ jobs: - name: b filter: "FullyQualifiedName=TimePlanning.Pn.Test.PictureSnapshotServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.DanLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.DataLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.ConfigurationSeedDataTests" - name: c - filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockInterceptorTests" + filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockInterceptorTests|FullyQualifiedName=TimePlanning.Pn.Test.ReconcileServiceTests" - name: d filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationVersionHistoryTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetControllerTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTierRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PraktikantPayLineRoutingTests|FullyQualifiedName=TimePlanning.Pn.Test.MobileFlexRecomputeAndCascadeTests|FullyQualifiedName=TimePlanning.Pn.Test.SiteWorkerResolverTests" - name: e diff --git a/.github/workflows/dotnet-core-pr.yml b/.github/workflows/dotnet-core-pr.yml index 8997c9e2..ca6dc05f 100644 --- a/.github/workflows/dotnet-core-pr.yml +++ b/.github/workflows/dotnet-core-pr.yml @@ -248,7 +248,7 @@ jobs: - name: b filter: "FullyQualifiedName=TimePlanning.Pn.Test.PictureSnapshotServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.DanLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.DataLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.ConfigurationSeedDataTests" - name: c - filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockInterceptorTests" + filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockInterceptorTests|FullyQualifiedName=TimePlanning.Pn.Test.ReconcileServiceTests" - name: d filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationVersionHistoryTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetControllerTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTierRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PraktikantPayLineRoutingTests|FullyQualifiedName=TimePlanning.Pn.Test.MobileFlexRecomputeAndCascadeTests|FullyQualifiedName=TimePlanning.Pn.Test.SiteWorkerResolverTests" - name: e diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs new file mode 100644 index 00000000..fb4b63ad --- /dev/null +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs @@ -0,0 +1,368 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microting.eForm.Infrastructure.Constants; +using Microting.eFormApi.BasePn.Abstractions; +using Microting.eFormApi.BasePn.Infrastructure.Helpers.PluginDbOptions; +using Microting.eFormApi.BasePn.Infrastructure.Database.Entities; +using Microting.EformAngularFrontendBase.Infrastructure.Data; +using Microting.TimePlanningBase.Infrastructure.Data.Entities; +using NSubstitute; +using NUnit.Framework; +using TimePlanning.Pn.Infrastructure.Helpers; +using TimePlanning.Pn.Infrastructure.Models.Planning; +using TimePlanning.Pn.Infrastructure.Models.Settings; +using TimePlanning.Pn.Services.TimePlanningLocalizationService; +using TimePlanning.Pn.Services.TimePlanningPlanningService; + +namespace TimePlanning.Pn.Test; + +[TestFixture] +public class ReconcileServiceTests : TestBaseSetup +{ + private ITimePlanningPlanningService _service; + private IUserService _userService; + private ITimePlanningLocalizationService _localizationService; + private IEFormCoreService _coreService; + private ITimePlanningDbContextHelper _dbContextHelper; + private IPluginDbOptions _options; + + [SetUp] + public async Task SetUpTest() + { + await base.Setup(); + + _userService = Substitute.For(); + _userService.UserId.Returns(1); + _userService.GetCurrentUserAsync().Returns(new EformUser { Id = 1 }); + + _localizationService = Substitute.For(); + _localizationService.GetString(Arg.Any()).Returns(x => x[0]?.ToString()); + + _coreService = Substitute.For(); + var core = await GetCore(); + _coreService.GetCore().Returns(core); + + _dbContextHelper = Substitute.For(); + // NB: this hands out the SHARED fixture context. TimePlanningPlanningService + // .Index() disposes every context it takes from the helper, so a test that + // calls Index() through this stub would dispose the fixture out from under + // itself. The Index() tests below go through BuildAdminIndexServiceAsync, + // which overrides this with a fresh context per call -- do the same for any + // new one. + _dbContextHelper.GetDbContext().Returns(TimePlanningPnDbContext); + + _options = Substitute.For>(); + _options.Value.Returns(new TimePlanningBaseSettings + { + AutoBreakCalculationActive = "0", + DayOfPayment = 20, + GpsEnabled = "0", + SnapshotEnabled = "0" + }); + + _service = new TimePlanningPlanningService( + Substitute.For>(), + _options, + TimePlanningPnDbContext, + _dbContextHelper, + _userService, + _localizationService, + null, + _coreService); + } + + /// + /// One plain PlanRegistration. Returns the tracked entity so tests can + /// reconcile it by Id. Note PnBase.Create forces WorkflowState to "created". + /// + private async Task SeedPlain(int siteId, DateTime date) + { + var row = new PlanRegistration + { + SdkSitId = siteId, + Date = date, + PlanText = "", + CommentOffice = "", + CommentOfficeAll = "", + WorkflowState = Constants.WorkflowStates.Created, + CreatedByUserId = 1, + UpdatedByUserId = 1, + }; + await row.Create(TimePlanningPnDbContext!); + return row; + } + + /// + /// Builds a TimePlanningPlanningService wired for the full Index() path: + /// real BaseDbContext seeded with an admin user (role "admin"), and an + /// ITimePlanningDbContextHelper that hands out a FRESH plugin context per + /// call — Index() fans out per-site work concurrently, and production's + /// helper also returns a new context per call. + /// + /// Not used by this task's own tests, but copied verbatim so Tasks 5 and 6 + /// can append Index() tests to this fixture without duplicating it. + /// + private async Task BuildAdminIndexServiceAsync(BaseDbContext baseDbContext) + { + var role = new EformRole { Name = "admin", NormalizedName = "ADMIN" }; + baseDbContext.Roles.Add(role); + await baseDbContext.SaveChangesAsync(); + + var user = new EformUser + { + UserName = "admin@planning-index.test", + Email = "admin@planning-index.test", + FirstName = "Admin", + LastName = "PlanningIndex" + }; + baseDbContext.Users.Add(user); + await baseDbContext.SaveChangesAsync(); + + baseDbContext.UserRoles.Add(new EformUserRole { UserId = user.Id, RoleId = role.Id }); + await baseDbContext.SaveChangesAsync(); + + _userService.UserId.Returns(user.Id); + _userService.GetCurrentUserAsync().Returns(new EformUser { Id = user.Id }); + _dbContextHelper.GetDbContext().Returns(_ => CreateTimePlanningPnDbContext()); + + return new TimePlanningPlanningService( + Substitute.For>(), + _options, + TimePlanningPnDbContext, + _dbContextHelper, + _userService, + _localizationService, + baseDbContext, + _coreService); + } + + [Test] + public async Task Reconcile_APastDay_SetsFlagAndTimestamp() + { + var row = await SeedPlain(900, DateTime.Now.Date.AddDays(-5)); + + var result = await _service.Reconcile(row.Id); + + Assert.That(result.Success, Is.True, result.Message); + var reloaded = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == row.Id); + Assert.Multiple(() => + { + Assert.That(reloaded.Reconciled, Is.True); + Assert.That(reloaded.ReconciledAt, Is.Not.Null, "I1: the timestamp is written with the flag"); + }); + } + + [Test] + public async Task Reconcile_Today_IsRejected() + { + var row = await SeedPlain(901, DateTime.Now.Date); + + var result = await _service.Reconcile(row.Id); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("CannotReconcileTodayOrFuture"), + "I2: today must stay open so time can still be registered"); + } + + [Test] + public async Task Reconcile_AFutureDay_IsRejected() + { + var row = await SeedPlain(902, DateTime.Now.Date.AddDays(3)); + + var result = await _service.Reconcile(row.Id); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("CannotReconcileTodayOrFuture")); + } + + [Test] + public async Task Reconcile_AnAlreadyReconciledDay_IsAnIdempotentSuccess() + { + var row = await SeedPlain(903, DateTime.Now.Date.AddDays(-5)); + await _service.Reconcile(row.Id); + + var again = await _service.Reconcile(row.Id); + + Assert.That(again.Success, Is.True, "re-reconciling is a no-op, not an error"); + } + + [Test] + public async Task Unreconcile_BelowTheBoundary_IsRejected_AndNamesTheBlockingDay() + { + var earlier = await SeedPlain(904, DateTime.Now.Date.AddDays(-8)); + var later = await SeedPlain(904, DateTime.Now.Date.AddDays(-3)); + await _service.Reconcile(earlier.Id); + await _service.Reconcile(later.Id); + + var result = await _service.Unreconcile(earlier.Id); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("OnlyLatestReconciledDayCanBeUnlocked"), + "the puzzle rule: free the outermost piece first"); + } + + [Test] + public async Task Unreconcile_AtTheBoundary_MovesItBackToTheNextNewest() + { + var earlier = await SeedPlain(905, DateTime.Now.Date.AddDays(-8)); + var later = await SeedPlain(905, DateTime.Now.Date.AddDays(-3)); + await _service.Reconcile(earlier.Id); + await _service.Reconcile(later.Id); + + var result = await _service.Unreconcile(later.Id); + + Assert.That(result.Success, Is.True, result.Message); + var boundary = await DayLockHelper.LockedThroughAsync(TimePlanningPnDbContext!, 905); + Assert.That(boundary, Is.EqualTo(earlier.Date), + "the boundary moves back one notch, it does not vanish"); + } + + [Test] + public async Task Unreconcile_ClearsBothFlagAndTimestamp() + { + var row = await SeedPlain(909, DateTime.Now.Date.AddDays(-5)); + await _service.Reconcile(row.Id); + + var result = await _service.Unreconcile(row.Id); + + Assert.That(result.Success, Is.True, result.Message); + var reloaded = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == row.Id); + Assert.Multiple(() => + { + Assert.That(reloaded.Reconciled, Is.False); + Assert.That(reloaded.ReconciledAt, Is.Null, "I1: unlock clears both together"); + }); + } + + [Test] + public async Task Reconcile_ADayBelowAnExistingBoundary_IsRejectedWithAMessage() + { + var later = await SeedPlain(910, DateTime.Now.Date.AddDays(-3)); + var earlier = await SeedPlain(910, DateTime.Now.Date.AddDays(-8)); + await _service.Reconcile(later.Id); + + var result = await _service.Reconcile(earlier.Id); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("DayIsLockedByReconciledDay"), + "the explicit Layer-2 IsLocked guard rejects this with a message before any write is attempted"); + } + + [Test] + public async Task Reconcile_DoesNotTouchTransferredToPayroll() + { + // false is TransferredToPayroll's default, so seeding it false would + // pass even if Reconcile blindly reset the flag. Seed it TRUE (and + // stamp TransferredToPayrollAt) so the assertion actually catches a + // reconcile that resets an already-exported flag -- the real §11.2 risk. + var row = await SeedPlain(911, DateTime.Now.Date.AddDays(-5)); + row.TransferredToPayroll = true; + row.TransferredToPayrollAt = DateTime.Now; + await row.Update(TimePlanningPnDbContext!); + + var result = await _service.Reconcile(row.Id); + + Assert.That(result.Success, Is.True, result.Message); + var reloaded = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == row.Id); + Assert.Multiple(() => + { + Assert.That(reloaded.TransferredToPayroll, Is.True, + "spec 11.2: Reconciled and TransferredToPayroll are independent"); + Assert.That(reloaded.TransferredToPayrollAt, Is.Not.Null, + "spec 11.2: reconciling must not clear the export timestamp either"); + }); + } + + [Test] + public async Task ReconcileThrough_MarksOneDayPerWorker_AndSkipsThoseAlreadyFurtherForward() + { + var aEarly = await SeedPlain(906, DateTime.Now.Date.AddDays(-6)); + var bEarly = await SeedPlain(907, DateTime.Now.Date.AddDays(-6)); + var bLate = await SeedPlain(907, DateTime.Now.Date.AddDays(-2)); + await _service.Reconcile(bLate.Id); // 907's boundary is already newer + + var result = await _service.ReconcileThrough(new ReconcileThroughRequestModel + { + Date = DateTime.Now.Date.AddDays(-6), + SiteIds = new List { 906, 907 } + }); + + Assert.That(result.Success, Is.True, result.Message); + Assert.Multiple(() => + { + Assert.That(result.Model.Applied, Is.EqualTo(1)); + Assert.That(result.Model.SkippedAlreadyFurtherForward, Is.EquivalentTo(new[] { 907 }), + "moving 907 backwards would be an unlock, which is a separate action"); + Assert.That(result.Model.SkippedNoRegistration, Is.Empty, + "907 was skipped for the other reason -- the two must not be conflated"); + Assert.That(result.Model.LandedOnBySiteId[906], Is.EqualTo(aEarly.Date)); + Assert.That(result.Model.LandedOnBySiteId.ContainsKey(907), Is.False); + }); + } + + [Test] + public async Task ReconcileThrough_LandsOnTheLatestDayWithARegistration() + { + await SeedPlain(908, DateTime.Now.Date.AddDays(-9)); + // nothing on -8 or -7 + var target = DateTime.Now.Date.AddDays(-7); + + var result = await _service.ReconcileThrough(new ReconcileThroughRequestModel + { + Date = target, SiteIds = new List { 908 } + }); + + Assert.That(result.Model.LandedOnBySiteId[908], Is.EqualTo(DateTime.Now.Date.AddDays(-9)), + "a seal on a day with no registration would mean nothing"); + } + + [Test] + public async Task ReconcileThrough_SkipsWorkersWithNoRegistration() + { + // Site 912 has no PlanRegistration rows at all. + var result = await _service.ReconcileThrough(new ReconcileThroughRequestModel + { + Date = DateTime.Now.Date.AddDays(-6), + SiteIds = new List { 912 } + }); + + Assert.That(result.Success, Is.True, result.Message); + Assert.Multiple(() => + { + Assert.That(result.Model.SkippedNoRegistration, Is.EquivalentTo(new[] { 912 })); + Assert.That(result.Model.SkippedAlreadyFurtherForward, Is.Empty, + "no registration is a distinct reason from already-further-forward"); + Assert.That(result.Model.LandedOnBySiteId.ContainsKey(912), Is.False); + }); + } + + [Test] + public async Task ReconcileThrough_ADayAlreadyReconciledOnExactlyTheLandingDay_IsANoOp() + { + var row = await SeedPlain(913, DateTime.Now.Date.AddDays(-6)); + await _service.Reconcile(row.Id); + + var result = await _service.ReconcileThrough(new ReconcileThroughRequestModel + { + Date = DateTime.Now.Date.AddDays(-4), + SiteIds = new List { 913 } + }); + + Assert.That(result.Success, Is.True, result.Message); + Assert.Multiple(() => + { + Assert.That(result.Model.AlreadyReconciledSiteIds, Is.EquivalentTo(new[] { 913 })); + Assert.That(result.Model.Applied, Is.EqualTo(0)); + Assert.That(result.Model.SkippedAlreadyFurtherForward, Is.Empty, + "913's only registration IS the landing day -- it must not also appear here"); + Assert.That(result.Model.SkippedNoRegistration, Is.Empty); + }); + } +} diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/TimePlanningPlanningController.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/TimePlanningPlanningController.cs index ef4b11fd..e1a55912 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/TimePlanningPlanningController.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/TimePlanningPlanningController.cs @@ -78,4 +78,26 @@ public async Task> GetV { return await _planningService.GetVersionHistory(planRegistrationId); } + + [HttpPut] + [Route("{id}/reconcile")] + public async Task Reconcile(int id) + { + return await _planningService.Reconcile(id); + } + + [HttpPut] + [Route("{id}/unreconcile")] + public async Task Unreconcile(int id) + { + return await _planningService.Unreconcile(id); + } + + [HttpPut] + [Route("reconcile-through")] + public async Task> ReconcileThrough( + [FromBody] ReconcileThroughRequestModel model) + { + return await _planningService.ReconcileThrough(model); + } } \ No newline at end of file diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/ReconcileThroughRequestModel.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/ReconcileThroughRequestModel.cs new file mode 100644 index 00000000..5ede3b1f --- /dev/null +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/ReconcileThroughRequestModel.cs @@ -0,0 +1,15 @@ +#nullable enable +namespace TimePlanning.Pn.Infrastructure.Models.Planning; + +using System; +using System.Collections.Generic; + +/// +/// One date, many workers. The cascade supplies the range, so this never +/// carries a range of its own. +/// +public class ReconcileThroughRequestModel +{ + public DateTime Date { get; set; } + public List SiteIds { get; set; } = new(); +} diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/ReconcileThroughResultModel.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/ReconcileThroughResultModel.cs new file mode 100644 index 00000000..3c7af63d --- /dev/null +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/ReconcileThroughResultModel.cs @@ -0,0 +1,27 @@ +#nullable enable +namespace TimePlanning.Pn.Infrastructure.Models.Planning; + +using System; +using System.Collections.Generic; + +public class ReconcileThroughResultModel +{ + /// Where each worker's boundary landed. Per worker, not shared: + /// the mark falls on that worker's latest day with a registration at or + /// before the requested date, so a staircase has no single landing date. + public Dictionary LandedOnBySiteId { get; set; } = new(); + + /// Workers whose boundary actually moved. Excludes no-ops. + public int Applied { get; set; } + + /// Already reconciled at or past the target. Moving them back would + /// be an unlock, which is deliberately a separate, heavier action. + public List SkippedAlreadyFurtherForward { get; set; } = new(); + + /// No registration at or before the target, so there was nothing + /// to mark. A distinct case from the above — the spec distinguishes them. + public List SkippedNoRegistration { get; set; } = new(); + + /// Already marked on exactly the landing day; nothing changed. + public List AlreadyReconciledSiteIds { get; set; } = new(); +} diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/ITimePlanningPlanningService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/ITimePlanningPlanningService.cs index d91c5ba1..f902015f 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/ITimePlanningPlanningService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/ITimePlanningPlanningService.cs @@ -40,4 +40,8 @@ public interface ITimePlanningPlanningService Task UpdateByCurrentUserNam(TimePlanningPlanningPrDayModel model); Task> GetVersionHistory(int planRegistrationId); + + Task Reconcile(int id); + Task Unreconcile(int id); + Task> ReconcileThrough(ReconcileThroughRequestModel model); } \ No newline at end of file diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs index 710b6d3c..2a199f53 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs @@ -29,6 +29,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE using Microting.TimePlanningBase.Infrastructure.Helpers; using Sentry; using TimePlanning.Pn.Infrastructure.Helpers; +using TimePlanning.Pn.Infrastructure.Interceptors; using TimePlanning.Pn.Infrastructure.Models.Settings; namespace TimePlanning.Pn.Services.TimePlanningPlanningService; @@ -2270,4 +2271,236 @@ private void CompareBoolField(List changes, string fieldName, bool? }); } } + + /// + /// Loads a live (non-removed) PlanRegistration by id. Shared by every + /// single-row reconcile mutation below; each call site still owns its own + /// null check and PlanningNotFound response. + /// + private async Task FindActivePlanningAsync(int id) + { + return await dbContext.PlanRegistrations + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .FirstOrDefaultAsync(x => x.Id == id); + } + + /// + /// Sets Reconciled and ReconciledAt together and saves. I1: flag and + /// timestamp always change together. DateTime.Now, not UtcNow: the + /// tooltip renders this verbatim as "Afstemt kl. ", and UTC + /// would read 1-2 hours off in Danish time. Consistent with the + /// CanReconcile comparison. Can throw DayLockedException; callers decide + /// how to handle that race. + /// + private async Task SetReconciledAsync(PlanRegistration planning, bool reconciled) + { + planning.Reconciled = reconciled; + planning.ReconciledAt = reconciled ? DateTime.Now : null; + planning.UpdatedByUserId = userService.UserId; + await planning.Update(dbContext); + } + + public async Task Reconcile(int id) + { + try + { + var planning = await FindActivePlanningAsync(id); + + if (planning == null) + { + return new OperationResult(false, localizationService.GetString("PlanningNotFound")); + } + + // Idempotent: re-reconciling an already reconciled day is a no-op + // success. Clients retry; that should not read as a failure. + if (planning.Reconciled) + { + return new OperationResult(true, localizationService.GetString("SuccessfullyReconciledDay")); + } + + if (!DayLockHelper.CanReconcile(planning.Date)) + { + return new OperationResult(false, + localizationService.GetString("CannotReconcileTodayOrFuture")); + } + + // Without this, reconciling a day BELOW an existing boundary passes + // CanReconcile, reaches Update, and the interceptor throws into the + // generic catch -- a 500-shaped "ErrorWhileUpdatingPlanning" instead + // of a message. That is exactly what Layer 2 exists to prevent. + var existingBoundary = await DayLockHelper.LockedThroughAsync(dbContext, planning.SdkSitId); + if (DayLockHelper.IsLocked(existingBoundary, planning.Date)) + { + return new OperationResult(false, + localizationService.GetString("DayIsLockedByReconciledDay")); + } + + await SetReconciledAsync(planning, true); + + return new OperationResult(true, localizationService.GetString("SuccessfullyReconciledDay")); + } + catch (DayLockedException) + { + // Expected and routine: a blocked edit is a normal outcome, not an + // incident. Do not report it to Sentry. + // + // The rejected entry stays tracked on `dbContext` (a request-scoped, + // injected context — see Task 4 amendment A1) after this throws. + // That is acceptable because we return immediately; no further + // save is attempted on this context for the rest of the request. + return new OperationResult(false, + localizationService.GetString("DayIsLockedByReconciledDay")); + } + catch (Exception e) + { + SentrySdk.CaptureException(e); + logger.LogError(e, "TimePlanningPlanningService.Reconcile failed"); + return new OperationResult(false, localizationService.GetString("ErrorWhileUpdatingPlanning")); + } + } + + public async Task Unreconcile(int id) + { + try + { + var planning = await FindActivePlanningAsync(id); + + if (planning == null) + { + return new OperationResult(false, localizationService.GetString("PlanningNotFound")); + } + // Boundary check FIRST. If the idempotency check came first, a user + // clicking unlock on a cascade-locked day (Reconciled = false, deep + // inside the range) would be told "Dagen er låst op" while nothing + // happened. + var boundary = await DayLockHelper.LockedThroughAsync(dbContext, planning.SdkSitId); + if (boundary is null || planning.Date.Date != boundary.Value.Date) + { + return new OperationResult(false, + localizationService.GetString("OnlyLatestReconciledDayCanBeUnlocked")); + } + + if (!planning.Reconciled) + { + return new OperationResult(true, localizationService.GetString("SuccessfullyUnlockedDay")); + } + + await SetReconciledAsync(planning, false); + + return new OperationResult(true, localizationService.GetString("SuccessfullyUnlockedDay")); + } + catch (DayLockedException) + { + // Race: another request reconciled a newer day for this site + // between the boundary read above and this save, so `planning`'s + // day is no longer the boundary the interceptor will permit an + // unlock on. Routine, not an incident -- no Sentry, same as + // Reconcile's DayLockedException catch. + return new OperationResult(false, + localizationService.GetString("OnlyLatestReconciledDayCanBeUnlocked")); + } + catch (Exception e) + { + SentrySdk.CaptureException(e); + logger.LogError(e, "TimePlanningPlanningService.Unreconcile failed"); + return new OperationResult(false, localizationService.GetString("ErrorWhileUpdatingPlanning")); + } + } + + public async Task> ReconcileThrough( + ReconcileThroughRequestModel model) + { + try + { + if (model == null || model.SiteIds.Count == 0) + { + return new OperationDataResult(false, + localizationService.GetString("ErrorWhileUpdatingPlanning")); + } + if (!DayLockHelper.CanReconcile(model.Date)) + { + return new OperationDataResult(false, + localizationService.GetString("CannotReconcileTodayOrFuture")); + } + + // Distinct: a duplicated site id in the request would otherwise be + // counted twice. + var siteIds = model.SiteIds.Distinct().ToList(); + var boundaries = await DayLockHelper.LockedThroughForSitesAsync(dbContext, siteIds); + var result = new ReconcileThroughResultModel(); + var target = model.Date.Date; + + foreach (var siteId in siteIds) + { + // Already at or past the target: moving the boundary BACK would + // be an unlock, which is deliberately a separate, heavier action. + if (DayLockHelper.IsLocked(boundaries.GetValueOrDefault(siteId), target)) + { + result.SkippedAlreadyFurtherForward.Add(siteId); + continue; + } + + // The mark must land on a day that actually has a registration — + // a seal on an empty day means nothing. + var landing = await dbContext.PlanRegistrations + .Where(x => x.SdkSitId == siteId) + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .Where(x => x.Date <= target) + .OrderByDescending(x => x.Date) + .FirstOrDefaultAsync(); + + if (landing == null) + { + // A different reason from "already further forward", and the + // spec distinguishes them -- do not merge the two lists. + result.SkippedNoRegistration.Add(siteId); + continue; + } + + if (landing.Reconciled) + { + // Already marked on exactly this day: nothing to do, and it + // must not inflate Applied. + result.AlreadyReconciledSiteIds.Add(siteId); + continue; + } + + try + { + await SetReconciledAsync(landing, true); + } + catch (DayLockedException) + { + // Race: another request moved this site's boundary between + // the snapshot taken above and this write, so `landing` is + // now at or before the NEW boundary -- the same meaning as + // "already further forward". Routine, not an incident: no + // Sentry, and the other sites in this request must not be + // aborted because of it. + // + // Detach the rejected entry: it stays tracked on this + // request-scoped `dbContext` after the throw, and every + // later site's SaveChanges on the same context would + // otherwise see it again and rethrow. + dbContext.Entry(landing).State = EntityState.Detached; + result.SkippedAlreadyFurtherForward.Add(siteId); + continue; + } + + result.Applied++; + // Per-worker, because the boundary is a staircase: one shared + // LandedOn would name the wrong date for most workers. + result.LandedOnBySiteId[siteId] = landing.Date; + } + + return new OperationDataResult(true, result); + } + catch (Exception e) + { + SentrySdk.CaptureException(e); + logger.LogError(e, "TimePlanningPlanningService.ReconcileThrough failed"); + return new OperationDataResult(false, + localizationService.GetString("ErrorWhileUpdatingPlanning")); + } + } } \ No newline at end of file From befdf4b4c9f54100a5b699dd661384d079738613 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 15 Sep 2026 15:07:05 +0200 Subject: [PATCH 10/28] fix(lock): return a message on blocked writes and skip locked days when recalculating - web and mobile edits of a locked day return DayIsReconciled or DayIsLockedByReconciledDay; - the dashboard recompute reverts locked days before any save, so a closed month renders its stored values and never trips the lock; - gap-fill never creates rows inside the lock, and sends a non-persisted placeholder day instead, so the positional grid keeps one column per date; - the working-hours save skips locked rows (the page posts every row) and marks them read-only. Co-Authored-By: Claude Opus 5 --- .../DayLockHelperTests.cs | 29 + .../ReconcileServiceTests.cs | 618 +++++++++++++++++- .../Infrastructure/Helpers/DayLockHelper.cs | 21 + .../Helpers/PlanRegistrationHelper.cs | 43 +- .../TimePlanningPlanningService.cs | 86 ++- .../TimePlanningWorkingHoursService.cs | 79 ++- 6 files changed, 857 insertions(+), 19 deletions(-) diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs index 67b21b18..a64e9847 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Microting.eForm.Infrastructure.Constants; @@ -181,4 +182,32 @@ public void CanReconcile_TodayAndFuture_False_Past_True() "a time-of-day on today is still today"); }); } + + /// + /// WhereOpen is the SQL-side twin of IsLocked, used by bulk writers that + /// must never load a locked row. Pins the equivalence, time of day + /// included, so the two cannot drift apart. + /// + [Test] + public void WhereOpen_KeepsExactlyTheDaysIsLockedLeavesOpen() + { + var boundary = new DateTime(2026, 1, 18); + var dates = new[] + { + boundary.AddDays(-1), + boundary, + boundary.AddHours(23).AddMinutes(59), + boundary.AddDays(1), + boundary.AddDays(1).AddHours(6) + }; + var rows = dates.Select(d => new PlanRegistrationEntity { Date = d }).AsQueryable(); + + Assert.Multiple(() => + { + Assert.That(rows.WhereOpen(boundary).Select(x => x.Date), + Is.EqualTo(dates.Where(d => !DayLockHelper.IsLocked(boundary, d)))); + Assert.That(rows.WhereOpen(null).Count(), Is.EqualTo(dates.Length), + "no boundary, nothing is locked"); + }); + } } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs index fb4b63ad..c293dc16 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs @@ -15,20 +15,46 @@ using TimePlanning.Pn.Infrastructure.Helpers; using TimePlanning.Pn.Infrastructure.Models.Planning; using TimePlanning.Pn.Infrastructure.Models.Settings; +using TimePlanning.Pn.Infrastructure.Models.WorkingHours.Index; +using TimePlanning.Pn.Infrastructure.Models.WorkingHours.UpdateCreate; using TimePlanning.Pn.Services.TimePlanningLocalizationService; using TimePlanning.Pn.Services.TimePlanningPlanningService; +using TimePlanning.Pn.Services.TimePlanningWorkingHoursService; +using AssignedSiteEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.AssignedSite; +using SdkLanguage = Microting.eForm.Infrastructure.Data.Entities.Language; +using SdkSite = Microting.eForm.Infrastructure.Data.Entities.Site; +using SdkSiteWorker = Microting.eForm.Infrastructure.Data.Entities.SiteWorker; +using SdkWorker = Microting.eForm.Infrastructure.Data.Entities.Worker; namespace TimePlanning.Pn.Test; [TestFixture] public class ReconcileServiceTests : TestBaseSetup { + /// The user BuildAdminIndexServiceAsync creates; the current-user paths find "my site" by it. + private const string AdminEmail = "admin@planning-index.test"; + + /// + /// Stored values on a locked day that UpdatePlanRegistrationsInPeriod + /// would overwrite if the day were open (SeedAssignedSiteAsync plans 0 + /// hours on every weekday, and no earlier row carries a flex balance in): + /// - PlanHours: the plan recompute (which would write 0) is skipped for + /// locked days outright, so this pins that skip. + /// - SumFlexEnd: the flex chain still runs on the tracked entity and + /// writes 0 + 0 - 7.5 = -7.5 in memory, so this pins the revert before + /// the projection. + /// + private const double StoredPlanHours = 7.5; + private const double StoredSumFlexEnd = 3.5; + private ITimePlanningPlanningService _service; private IUserService _userService; private ITimePlanningLocalizationService _localizationService; private IEFormCoreService _coreService; private ITimePlanningDbContextHelper _dbContextHelper; private IPluginDbOptions _options; + /// The logger BuildAdminIndexServiceAsync's service writes to; see AssertNoErrorLogged. + private ILogger _indexLogger; [SetUp] public async Task SetUpTest() @@ -103,8 +129,9 @@ private async Task SeedPlain(int siteId, DateTime date) /// call — Index() fans out per-site work concurrently, and production's /// helper also returns a new context per call. /// - /// Not used by this task's own tests, but copied verbatim so Tasks 5 and 6 - /// can append Index() tests to this fixture without duplicating it. + /// Also wires _userService to that user, which is what the current-user + /// paths (IndexByCurrentUserName, UpdateByCurrentUserNam, the personal + /// UpdateWorkingHour) resolve "me" from. /// private async Task BuildAdminIndexServiceAsync(BaseDbContext baseDbContext) { @@ -114,8 +141,8 @@ private async Task BuildAdminIndexServiceAsync(Bas var user = new EformUser { - UserName = "admin@planning-index.test", - Email = "admin@planning-index.test", + UserName = AdminEmail, + Email = AdminEmail, FirstName = "Admin", LastName = "PlanningIndex" }; @@ -129,8 +156,9 @@ private async Task BuildAdminIndexServiceAsync(Bas _userService.GetCurrentUserAsync().Returns(new EformUser { Id = user.Id }); _dbContextHelper.GetDbContext().Returns(_ => CreateTimePlanningPnDbContext()); + _indexLogger = Substitute.For>(); return new TimePlanningPlanningService( - Substitute.For>(), + _indexLogger, _options, TimePlanningPnDbContext, _dbContextHelper, @@ -140,6 +168,185 @@ private async Task BuildAdminIndexServiceAsync(Bas _coreService); } + /// + /// UpdatePlanRegistrationsInPeriod's catch-all logs and SWALLOWS whatever + /// its try throws, so a lock rejection there (a missing guard on an Update + /// inside the try) would not fail Index. It does log at Error level, which + /// this catches. Inspects ReceivedCalls, because LogError is an extension + /// method over the generic Log<TState> and cannot be matched directly. + /// + private void AssertNoErrorLogged() + { + var errors = _indexLogger.ReceivedCalls() + .Count(c => c.GetMethodInfo().Name == nameof(ILogger.Log) && c.GetArguments()[0] is LogLevel.Error); + Assert.That(errors, Is.Zero, "nothing on the Index path may be rejected and swallowed"); + } + + /// + /// The dashboard grid reads days BY POSITION, so the list must hold + /// exactly one entry per date of the window, in order (ruling F17). + /// + private static void AssertOneDayPerDate( + IReadOnlyList days, TimePlanningPlanningRequestModel window) + { + var from = window.DateFrom!.Value.Date; + var count = (window.DateTo!.Value.Date - from).Days + 1; + Assert.That(days, Has.Count.EqualTo(count), "one entry per date, locked gaps included"); + for (var i = 0; i < count; i++) + { + Assert.That(days[i].Date, Is.EqualTo(from.AddDays(i)), $"column {i} must be {from.AddDays(i):yyyy-MM-dd}"); + } + } + + /// + /// A registration device, so the kiosk UpdateWorkingHour overload accepts + /// its token. Returns the generated token for the call under test. + /// + private async Task SeedKioskDeviceAsync() + { + var token = Guid.NewGuid().ToString("N"); + await new RegistrationDevice + { + Token = token, + Name = "Kiosk Device", + OtpCode = "10001", + SoftwareVersion = "1.0.0", + Manufacturer = "Test", + Model = "Test", + OsVersion = "1.0", + CreatedByUserId = 1, + UpdatedByUserId = 1, + }.Create(TimePlanningPnDbContext!); + return token; + } + + /// A plain day, reconciled, so it becomes the boundary. + private async Task SeedReconciledBoundaryAsync(int siteUid, DateTime date) + { + var row = await SeedPlain(siteUid, date); + var reconciled = await _service.Reconcile(row.Id); + Assert.That(reconciled.Success, Is.True, reconciled.Message); + return row; + } + + /// + /// Everything Index() needs to actually process a site: the SDK Site the + /// row takes its name from and the plugin AssignedSite it iterates. Without + /// both, Index() skips the site and a lock test passes vacuously (ruling F7). + /// + private async Task SeedAssignedSiteAsync(int siteUid, bool useGoogleSheetAsDefault = false) + { + var sdkDbContext = (await _coreService.GetCore()).DbContextHelper.GetDbContext(); + var sdkSite = new SdkSite { Name = $"Reconcile site {siteUid}", MicrotingUid = siteUid }; + await sdkSite.Create(sdkDbContext); + + await new AssignedSiteEntity + { + SiteId = siteUid, + // The recompute has two branches, each with its own guarded Update + // inside the catch-all. Default to the weekday-plan branch (the + // entity itself defaults to the Google-sheet one), whose all-zero + // plan is what makes StoredPlanHours stale. + UseGoogleSheetAsDefault = useGoogleSheetAsDefault, + // The personal mobile write path refuses past days without this, + // before its lock guard is ever reached. + AllowEditOfRegistrations = true, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }.Create(TimePlanningPnDbContext!); + + return sdkSite; + } + + /// + /// Makes the current user's site: a worker with + /// the admin's e-mail, attached to it. That is how the current-user paths + /// resolve "my site". + /// + private async Task SeedCurrentUserWorkerAsync(SdkSite sdkSite) + { + var sdkDbContext = (await _coreService.GetCore()).DbContextHelper.GetDbContext(); + var worker = new SdkWorker + { + FirstName = "Admin", + LastName = "PlanningIndex", + Email = AdminEmail, + MicrotingUid = 1000 + sdkSite.MicrotingUid!.Value + }; + await worker.Create(sdkDbContext); + await new SdkSiteWorker + { + SiteId = sdkSite.Id, + WorkerId = worker.Id, + MicrotingUid = 2000 + sdkSite.MicrotingUid!.Value + }.Create(sdkDbContext); + } + + /// + /// A reconciled day (so the boundary) storing values a dashboard load + /// rewrites in memory. That makes the loaded, TRACKED entity dirty -- the + /// precondition for the flush trap in ruling F15. Without it, merely + /// skipping the Update calls would look sufficient. + /// + /// IsSaturday is stored WRONG for the date, so the unconditional weekday + /// assignment before the first Update (the one outside the try, whose + /// throw nothing swallows) always dirties the row, whatever day of the + /// week the test runs on. + /// + private async Task SeedReconciledDayWithStaleStoredValuesAsync(int siteUid, DateTime date) + { + var row = await SeedPlain(siteUid, date); + row.PlanHours = StoredPlanHours; + row.SumFlexEnd = StoredSumFlexEnd; + row.IsSaturday = date.DayOfWeek != DayOfWeek.Saturday; + await row.Update(TimePlanningPnDbContext!); + + var reconciled = await _service.Reconcile(row.Id); + Assert.That(reconciled.Success, Is.True, reconciled.Message); + return row; + } + + private TimePlanningWorkingHoursService BuildWorkingHoursService(BaseDbContext baseDbContext = null) => + new(Substitute.For>(), + TimePlanningPnDbContext!, + _userService, + _localizationService, + baseDbContext, + _options, + _coreService); + + /// + /// One row as the working-hours page posts it (the page posts every row it + /// shows). Tests assert MessageId because the recompute inside + /// UpdatePlanning never touches it, unlike PlanHours. + /// + private static TimePlanningWorkingHoursModel Posted(DateTime date) => new() + { + Date = date, + Message = 3, + PlanText = "", + PaidOutFlex = "0", + CommentOffice = "", + CommentOfficeAll = "" + }; + + private static TimePlanningPlanningPrDayModel EditOf(PlanRegistration row) => new() + { + Id = row.Id, + Date = row.Date, + CommentOffice = "" + }; + + /// + /// With the boundary seeded on -5, this window holds the locked day AND + /// open days after it, which gap-fill creates and the loop then saves. + /// + private static TimePlanningPlanningRequestModel LastTenDaysThroughToday() => new() + { + DateFrom = DateTime.Now.Date.AddDays(-10), + DateTo = DateTime.Now.Date + }; + [Test] public async Task Reconcile_APastDay_SetsFlagAndTimestamp() { @@ -365,4 +572,405 @@ public async Task ReconcileThrough_ADayAlreadyReconciledOnExactlyTheLandingDay_I Assert.That(result.Model.SkippedNoRegistration, Is.Empty); }); } + + // --------------------------------------------------------------------- + // Layer 2: user-facing write paths answer with a message. Without the + // guards, each of these reaches the interceptor and comes back as a + // generic error (or, where the method has no try/catch, a raw exception). + // --------------------------------------------------------------------- + + [Test] + public async Task Update_ADayBelowTheBoundary_ReturnsDayIsLockedByReconciledDay() + { + // Plain rows below a boundary must exist before it: afterwards the + // interceptor refuses to create them. + var earlier = await SeedPlain(920, DateTime.Now.Date.AddDays(-8)); + await SeedReconciledBoundaryAsync(920, DateTime.Now.Date.AddDays(-3)); + + var result = await _service.Update(earlier.Id, EditOf(earlier)); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("DayIsLockedByReconciledDay")); + } + + [Test] + public async Task Update_TheBoundaryDay_ReturnsDayIsReconciled() + { + var boundary = await SeedReconciledBoundaryAsync(921, DateTime.Now.Date.AddDays(-3)); + + var result = await _service.Update(boundary.Id, EditOf(boundary)); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("DayIsReconciled"), + "the reconciled day itself says what it is, not that something else locks it"); + } + + [Test] + public async Task Update_AnOpenDayAboveTheBoundary_StillSucceeds() + { + await SeedAssignedSiteAsync(922); + await SeedReconciledBoundaryAsync(922, DateTime.Now.Date.AddDays(-3)); + var open = await SeedPlain(922, DateTime.Now.Date.AddDays(-1)); + + var result = await _service.Update(open.Id, EditOf(open)); + + Assert.That(result.Success, Is.True, result.Message); + } + + [Test] + public async Task UpdateByCurrentUserNam_TheBoundaryDay_ReturnsDayIsReconciled() + { + await using var baseDbContext = GetBaseDbContext(); + var svc = await BuildAdminIndexServiceAsync(baseDbContext); + await SeedCurrentUserWorkerAsync(await SeedAssignedSiteAsync(923)); + var boundary = await SeedReconciledBoundaryAsync(923, DateTime.Now.Date.AddDays(-3)); + + var result = await svc.UpdateByCurrentUserNam(EditOf(boundary)); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("DayIsReconciled"), + "spec 11.3: the mobile path answers with the same message as web"); + } + + [Test] + public async Task CreateUpdate_AcrossTheBoundary_SkipsLockedRowsAndSavesOpenOnes() + { + // UpdatePlanning dereferences the AssignedSite. + await SeedAssignedSiteAsync(924); + var boundary = await SeedReconciledBoundaryAsync(924, DateTime.Now.Date.AddDays(-3)); + var existingOpen = await SeedPlain(924, DateTime.Now.Date.AddDays(-1)); + var before = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == boundary.Id); + var missingOpenDate = DateTime.Now.Date.AddDays(-2); + + var result = await BuildWorkingHoursService().CreateUpdate(new TimePlanningWorkingHoursUpdateCreateModel + { + SiteId = 924, + Plannings = new List + { + // First, like the page's carried-over row: a locked first row + // must not stop the next row from being created. + Posted(boundary.Date), + Posted(missingOpenDate), + Posted(existingOpen.Date) + } + }); + + Assert.That(result.Success, Is.True, result.Message); + var lockedAfter = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == boundary.Id); + var created = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .SingleOrDefaultAsync(x => x.SdkSitId == 924 && x.Date == missingOpenDate); + var updated = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == existingOpen.Id); + Assert.Multiple(() => + { + Assert.That(lockedAfter.Version, Is.EqualTo(before.Version), + "the locked row is skipped, not re-saved"); + Assert.That(lockedAfter.UpdatedAt, Is.EqualTo(before.UpdatedAt)); + Assert.That(lockedAfter.MessageId, Is.Null, "the posted change to the locked row is ignored"); + Assert.That(created, Is.Not.Null, "an open day missing from the DB is still created"); + Assert.That(created?.MessageId, Is.EqualTo(3)); + Assert.That(updated.MessageId, Is.EqualTo(3), "an open day in the same request still saves"); + }); + } + + /// + /// Pins CreateUpdate's loop skip, which WhereOpen does not make redundant: + /// WhereOpen keeps locked ROWS out of the load, but a locked date with NO + /// row (a gap row the working-hours Index emits, and the page posts every + /// row) is not in the load either way. Without the skip, CreatePlanning + /// creates -5 inside the lock; its own catch swallows the interceptor's + /// throw, the Added entity stays tracked, the next save (-2's create, then + /// -1's update, which has no catch) flushes it again, and the request fails. + /// + [Test] + public async Task CreateUpdate_ALockedGapRowInTheMiddle_IsNotCreatedAndTheRestSaves() + { + await SeedAssignedSiteAsync(932); + var below = await SeedPlain(932, DateTime.Now.Date.AddDays(-8)); + await SeedReconciledBoundaryAsync(932, DateTime.Now.Date.AddDays(-3)); + var existingOpen = await SeedPlain(932, DateTime.Now.Date.AddDays(-1)); + var lockedGapDate = DateTime.Now.Date.AddDays(-5); + var openGapDate = DateTime.Now.Date.AddDays(-2); + + var result = await BuildWorkingHoursService().CreateUpdate(new TimePlanningWorkingHoursUpdateCreateModel + { + SiteId = 932, + Plannings = new List + { + Posted(below.Date), + // Not first, so without the skip CreatePlanning would create it. + Posted(lockedGapDate), + Posted(openGapDate), + Posted(existingOpen.Date) + } + }); + + Assert.That(result.Success, Is.True, result.Message); + var lockedGap = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .AnyAsync(x => x.SdkSitId == 932 && x.Date == lockedGapDate); + var created = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .SingleOrDefaultAsync(x => x.SdkSitId == 932 && x.Date == openGapDate); + var updated = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == existingOpen.Id); + Assert.Multiple(() => + { + Assert.That(lockedGap, Is.False, "a locked period does not grow new rows"); + Assert.That(created?.MessageId, Is.EqualTo(3), "the open gap row is created"); + Assert.That(updated.MessageId, Is.EqualTo(3), "the open stored row is updated"); + }); + } + + /// + /// Pins CreateUpdate's DB-side WhereOpen filter, which looks redundant next + /// to the loop's skip but is the only thing keeping the forward cascade + /// out of the lock. Posting only the locked -8 skips it in the loop; the + /// cascade then runs over every row after -8. Without the filter it would + /// re-chain -6 (stored balance 5, recomputed 0) and save it inside the + /// lock, the interceptor would reject that, and the request would fail. + /// + [Test] + public async Task CreateUpdate_PostingALockedDay_CascadesPastTheLockWithoutTouchingIt() + { + await SeedAssignedSiteAsync(931); + var below = await SeedPlain(931, DateTime.Now.Date.AddDays(-8)); + var insideLock = await SeedPlain(931, DateTime.Now.Date.AddDays(-6)); + insideLock.SumFlexStart = 5; + insideLock.SumFlexEnd = 5; + await insideLock.Update(TimePlanningPnDbContext!); + var boundary = await SeedReconciledBoundaryAsync(931, DateTime.Now.Date.AddDays(-3)); + // Stale balance, so the cascade's re-chain off the boundary visibly changes it. + var open = await SeedPlain(931, DateTime.Now.Date.AddDays(-1)); + open.SumFlexStart = 9; + open.SumFlexEnd = 9; + await open.Update(TimePlanningPnDbContext!); + async Task Stored(PlanRegistration row) => + await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking().FirstAsync(x => x.Id == row.Id); + var insideBefore = await Stored(insideLock); + var boundaryBefore = await Stored(boundary); + var openBefore = await Stored(open); + + var result = await BuildWorkingHoursService().CreateUpdate(new TimePlanningWorkingHoursUpdateCreateModel + { + SiteId = 931, + Plannings = new List { new() { Date = below.Date } } + }); + + Assert.That(result.Success, Is.True, result.Message); + var insideAfter = await Stored(insideLock); + var boundaryAfter = await Stored(boundary); + var openAfter = await Stored(open); + Assert.Multiple(() => + { + Assert.That(insideAfter.Version, Is.EqualTo(insideBefore.Version), "a locked row the cascade passes"); + Assert.That(boundaryAfter.Version, Is.EqualTo(boundaryBefore.Version), "the boundary"); + Assert.That(openAfter.Version, Is.GreaterThan(openBefore.Version), + "the open day after the lock is still recomputed by the cascade"); + Assert.That(openAfter.SumFlexStart, Is.EqualTo(boundaryAfter.SumFlexEnd), + "re-chained off the stored boundary balance"); + }); + } + + [Test] + public async Task WorkingHoursIndex_MarksReconciledLockedDaysAsIsLocked() + { + await SeedAssignedSiteAsync(930); + // Keep the MaxDaysEditable window out of the way, so only the + // reconciled lock can set IsLocked on these past days. + _options.Value.MaxDaysEditable = 365; + _userService.GetCurrentUserLanguage().Returns(new SdkLanguage { LanguageCode = "da" }); + await SeedPlain(930, DateTime.Now.Date.AddDays(-8)); + await SeedReconciledBoundaryAsync(930, DateTime.Now.Date.AddDays(-3)); + await SeedPlain(930, DateTime.Now.Date.AddDays(-1)); + + var result = await BuildWorkingHoursService().Index(new TimePlanningWorkingHoursRequestModel + { + SiteId = 930, + DateFrom = DateTime.Now.Date.AddDays(-10), + DateTo = DateTime.Now.Date.AddDays(-1) + }); + + Assert.That(result.Success, Is.True, result.Message); + bool IsLockedOn(int daysAgo) => + result.Model.Single(x => x.Date == DateTime.Now.Date.AddDays(-daysAgo)).IsLocked; + Assert.Multiple(() => + { + Assert.That(IsLockedOn(8), Is.True, "a stored day below the boundary"); + Assert.That(IsLockedOn(3), Is.True, "the reconciled day itself"); + Assert.That(IsLockedOn(5), Is.True, "an empty day inside the lock (the gap-row path)"); + Assert.That(IsLockedOn(1), Is.False, "a stored day above the boundary stays editable"); + Assert.That(IsLockedOn(2), Is.False, "an empty day above the boundary stays editable"); + }); + } + + [Test] + public async Task UpdateWorkingHour_Kiosk_ADayBelowTheBoundary_IsRejectedAndCreatesNothing() + { + var token = await SeedKioskDeviceAsync(); + await SeedReconciledBoundaryAsync(925, DateTime.Now.Date.AddDays(-3)); + // No row on -4: without the guard the kiosk would CREATE one inside the + // lock, and this method has no try/catch to turn the rejection into a message. + var lockedDate = DateTime.Now.Date.AddDays(-4); + + var result = await BuildWorkingHoursService().UpdateWorkingHour(925, + new TimePlanningWorkingHoursUpdateModel { Date = lockedDate }, token); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("DayIsLockedByReconciledDay")); + Assert.That(await TimePlanningPnDbContext!.PlanRegistrations + .AnyAsync(x => x.SdkSitId == 925 && x.Date == lockedDate), Is.False); + } + + /// + /// Separates the message rule (the row's own Reconciled flag, as on web) + /// from the earlier "is it the boundary date" rule: -8 was reconciled + /// first, then -3, so -8 is reconciled but lies below the boundary. The + /// boundary-date rule answered DayIsLockedByReconciledDay here; web says + /// DayIsReconciled for the same day, and mobile must too (spec 11.3). + /// + [Test] + public async Task UpdateWorkingHour_Kiosk_AnOlderReconciledDay_ReturnsDayIsReconciled() + { + var token = await SeedKioskDeviceAsync(); + var older = await SeedReconciledBoundaryAsync(933, DateTime.Now.Date.AddDays(-8)); + await SeedReconciledBoundaryAsync(933, DateTime.Now.Date.AddDays(-3)); + + var result = await BuildWorkingHoursService().UpdateWorkingHour(933, + new TimePlanningWorkingHoursUpdateModel { Date = older.Date }, token); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("DayIsReconciled")); + } + + [Test] + public async Task UpdateWorkingHour_Personal_TheBoundaryDay_ReturnsDayIsReconciled() + { + await using var baseDbContext = GetBaseDbContext(); + // Seeds the admin user and points _userService at it ("me"). + await BuildAdminIndexServiceAsync(baseDbContext); + await SeedCurrentUserWorkerAsync(await SeedAssignedSiteAsync(926)); + var boundary = await SeedReconciledBoundaryAsync(926, DateTime.Now.Date.AddDays(-3)); + + var result = await BuildWorkingHoursService(baseDbContext).UpdateWorkingHour( + new TimePlanningWorkingHoursUpdateModel { Date = boundary.Date }); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("DayIsReconciled"), + "spec 11.3: the mobile path answers with the same message as web"); + } + + // --------------------------------------------------------------------- + // Layer 3: recalculation skips locked days, and a dashboard load over a + // closed period still succeeds (ruling F15: a dirty locked entity left + // behind would be flushed by the next open day's save and fail the load). + // Each asserts the positional one-entry-per-date contract (ruling F17) and + // that nothing was rejected and swallowed inside the recompute's catch. + // --------------------------------------------------------------------- + + /// + /// Run once per recompute branch: each branch has its own guarded Update + /// inside the catch-all, and AssertNoErrorLogged is what pins it. + /// + [TestCase(false)] + [TestCase(true)] + public async Task Index_OverALockedRange_LeavesLockedRowsByteIdentical(bool useGoogleSheetAsDefault) + { + await using var baseDbContext = GetBaseDbContext(); + var svc = await BuildAdminIndexServiceAsync(baseDbContext); + await SeedAssignedSiteAsync(927, useGoogleSheetAsDefault); + var row = await SeedReconciledDayWithStaleStoredValuesAsync(927, DateTime.Now.Date.AddDays(-5)); + var before = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == row.Id); + var window = LastTenDaysThroughToday(); + + var result = await svc.Index(window); + + Assert.That(result.Success, Is.True, result.Message); + AssertNoErrorLogged(); + var days = result.Model.Single(x => x.SiteId == 927).PlanningPrDayModels; + AssertOneDayPerDate(days, window); + var lockedDay = days.Single(x => x.Date == row.Date); + var after = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == row.Id); + Assert.Multiple(() => + { + Assert.That(after.Version, Is.EqualTo(before.Version), + "a no-op re-save still bumps Version — this catches silent rewrites"); + Assert.That(after.UpdatedAt, Is.EqualTo(before.UpdatedAt)); + Assert.That(lockedDay.Id, Is.EqualTo(row.Id)); + Assert.That(lockedDay.PlanHours, Is.EqualTo(StoredPlanHours), + "the grid shows the STORED, reconciled plan, not a recomputation"); + Assert.That(lockedDay.SumFlexEnd, Is.EqualTo(StoredSumFlexEnd), + "the grid shows the STORED, reconciled balance, not a recomputation"); + }); + } + + [Test] + public async Task Index_OverALockedRange_CreatesNoNewRows() + { + await using var baseDbContext = GetBaseDbContext(); + var svc = await BuildAdminIndexServiceAsync(baseDbContext); + await SeedAssignedSiteAsync(928); + var boundary = DateTime.Now.Date.AddDays(-5); + await SeedReconciledDayWithStaleStoredValuesAsync(928, boundary); + var window = LastTenDaysThroughToday(); + + var result = await svc.Index(window); + + Assert.That(result.Success, Is.True, result.Message); + AssertNoErrorLogged(); + var days = result.Model.Single(x => x.SiteId == 928).PlanningPrDayModels; + AssertOneDayPerDate(days, window); + var lockedRows = await TimePlanningPnDbContext!.PlanRegistrations + .CountAsync(x => x.SdkSitId == 928 && x.Date <= boundary); + var openRows = await TimePlanningPnDbContext!.PlanRegistrations + .CountAsync(x => x.SdkSitId == 928 && x.Date > boundary); + Assert.Multiple(() => + { + Assert.That(lockedRows, Is.EqualTo(1), + "gap-fill must not materialise rows inside a frozen period"); + Assert.That(openRows, Is.EqualTo(5), + "gap-fill still fills the open days after the boundary (-4 .. today)"); + Assert.That(days.Where(x => x.Date < boundary).Select(x => x.Id), Is.All.EqualTo(0), + "the missing locked dates (-10 .. -6) are placeholders, with no row behind them"); + }); + } + + [Test] + public async Task IndexByCurrentUserName_OverALockedRange_Succeeds() + { + await using var baseDbContext = GetBaseDbContext(); + var svc = await BuildAdminIndexServiceAsync(baseDbContext); + await SeedCurrentUserWorkerAsync(await SeedAssignedSiteAsync(929)); + var row = await SeedReconciledDayWithStaleStoredValuesAsync(929, DateTime.Now.Date.AddDays(-5)); + var before = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == row.Id); + var window = LastTenDaysThroughToday(); + + var result = await svc.IndexByCurrentUserName(window, null, null, null, null); + + Assert.That(result.Success, Is.True, result.Message); + AssertNoErrorLogged(); + Assert.That(result.Model.SiteId, Is.EqualTo(929)); + var days = result.Model.PlanningPrDayModels; + AssertOneDayPerDate(days, window); + var lockedDay = days.Single(x => x.Date == row.Date); + var after = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == row.Id); + var lockedRows = await TimePlanningPnDbContext!.PlanRegistrations + .CountAsync(x => x.SdkSitId == 929 && x.Date <= row.Date); + Assert.Multiple(() => + { + Assert.That(after.Version, Is.EqualTo(before.Version)); + Assert.That(after.UpdatedAt, Is.EqualTo(before.UpdatedAt)); + Assert.That(lockedDay.PlanHours, Is.EqualTo(StoredPlanHours), + "the mobile fetch shows the STORED, reconciled plan, not a recomputation"); + Assert.That(lockedDay.SumFlexEnd, Is.EqualTo(StoredSumFlexEnd)); + Assert.That(lockedRows, Is.EqualTo(1), + "the mobile path's gap-fill must not grow the frozen period either"); + Assert.That(days.Where(x => x.Date < row.Date).Select(x => x.Id), Is.All.EqualTo(0), + "the missing locked dates are placeholders on the mobile path too"); + }); + } } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs index 33ed43f5..c1a5abdb 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs @@ -66,6 +66,27 @@ public static class DayLockHelper public static bool IsLocked(DateTime? lockedThrough, DateTime date) => lockedThrough.HasValue && date.Date <= lockedThrough.Value.Date; + /// + /// The rows NOT locked by , as a filter the + /// database runs. Exactly equivalent to !IsLocked(lockedThrough, x.Date), + /// time of day included: date.Date <= lockedThrough.Date holds exactly + /// when date < lockedThrough.Date + 1 day. + /// + /// For bulk writers: a locked row that is never loaded is never tracked, + /// so no later SaveChanges on the context can flush a change into it. + /// + public static IQueryable WhereOpen( + this IQueryable query, DateTime? lockedThrough) + { + if (lockedThrough is not { } boundary) + { + return query; + } + + var firstOpenDay = boundary.Date.AddDays(1); + return query.Where(x => x.Date >= firstOpenDay); + } + /// /// Invariant I2: today and future days must stay open so time can still be /// registered. This is also what makes the forward flex cascades unable to diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs index aefc50e8..c22d7bdf 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs @@ -420,9 +420,14 @@ public static async Task UpdatePlanRegistrationsInPer // ? new DateTime(DateTime.Now.Year, DateTime.Now.Month, settingsDayOfPayment, 0, 0, 0) // : new DateTime(DateTime.Now.Year, DateTime.Now.Month - 1, settingsDayOfPayment, 0, 0, 0); var dayOfPayment = toDay.AddMonths(-1); + // A dashboard load legitimately spans the boundary, so locked days are + // skipped rather than raising. Without this, the interceptor turns + // every visit to a closed month into a 500. + var lockedThrough = await DayLockHelper.LockedThroughAsync(dbContext, dbAssignedSite.SiteId); foreach (var plan in planningsInPeriod) { var planRegistration = await dbContext.PlanRegistrations.AsTracking().FirstAsync(x => x.Id == plan.Id); + var dayIsLocked = DayLockHelper.IsLocked(lockedThrough, planRegistration.Date); var midnight = new DateTime(planRegistration.Date.Year, planRegistration.Date.Month, planRegistration.Date.Day, 0, 0, 0); // Mode at registration: the write-time marker when the row has one, @@ -468,7 +473,10 @@ public static async Task UpdatePlanRegistrationsInPer } planRegistration.IsSaturday = midnight.DayOfWeek == DayOfWeek.Saturday; planRegistration.IsSunday = midnight.DayOfWeek == DayOfWeek.Sunday; - await planRegistration.Update(dbContext).ConfigureAwait(false); + if (!dayIsLocked) + { + await planRegistration.Update(dbContext).ConfigureAwait(false); + } if (!dbAssignedSite.Resigned) { @@ -476,7 +484,11 @@ public static async Task UpdatePlanRegistrationsInPer { if (dbAssignedSite.UseGoogleSheetAsDefault) { - if (planRegistration.Date > dayOfPayment && !planRegistration.PlanChangedByAdmin) + // Locked days skip the plan recompute outright: it would be + // reverted anyway, and its "Plan hours changed" event (plus + // `tainted`, which repeats it for every later day) would + // fire falsely on every load of a closed period. + if (!dayIsLocked && planRegistration.Date > dayOfPayment && !planRegistration.PlanChangedByAdmin) { if (!string.IsNullOrEmpty(planRegistration.PlanText)) { @@ -539,11 +551,16 @@ await dbContext.PlanRegistrations.AsNoTracking() FlexChain.ApplyNettoFlexChainDecimal(planRegistration, preTimePlanning); } - await planRegistration.Update(dbContext).ConfigureAwait(false); + if (!dayIsLocked) + { + await planRegistration.Update(dbContext).ConfigureAwait(false); + } } else { - if (planRegistration.Date > dayOfPayment && !planRegistration.PlanChangedByAdmin) + // Same as the Google-sheet branch: no plan recompute, and so + // no false "Plan hours changed" event, for a locked day. + if (!dayIsLocked && planRegistration.Date > dayOfPayment && !planRegistration.PlanChangedByAdmin) { var dayOfWeek = planRegistration.Date.DayOfWeek; var originalPlanHours = planRegistration.PlanHours; @@ -807,6 +824,7 @@ await dbContext.PlanRegistrations.AsNoTracking() // Console.WriteLine($"The plannedHours are now: {planRegistration.PlanHours}"); + // Unguarded on purpose: this block only runs for open days. await planRegistration.Update(dbContext).ConfigureAwait(false); } @@ -831,7 +849,10 @@ await dbContext.PlanRegistrations.AsNoTracking() { FlexChain.ApplyNettoFlexChainDecimal(planRegistration, preTimePlanning); } - await planRegistration.Update(dbContext).ConfigureAwait(false); + if (!dayIsLocked) + { + await planRegistration.Update(dbContext).ConfigureAwait(false); + } } } catch (Exception e) @@ -846,6 +867,18 @@ await dbContext.PlanRegistrations.AsNoTracking() } } + if (dayIsLocked) + { + // Frozen means frozen. The recomputation above mutated this TRACKED + // entity in memory. Reverting it (no query) means (a) the grid shows + // the STORED, reconciled values, not a recomputation, and (b) the next + // open day's SaveChanges cannot flush these changes into a locked row, + // which the interceptor would reject and fail the whole dashboard load. + var lockedEntry = dbContext.Entry(planRegistration); + lockedEntry.CurrentValues.SetValues(lockedEntry.OriginalValues); + lockedEntry.State = EntityState.Unchanged; + } + string? messageLabel = null; if (planRegistration.MessageId.HasValue && messagesById.TryGetValue(planRegistration.MessageId.Value, out var msg)) diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs index 2a199f53..f002e6d4 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs @@ -368,7 +368,11 @@ await dbContext.AssignedSites } } - foreach (var missingDate in missingDates) + // Per site and from this site's own context: every worker has + // their own boundary, and sites run concurrently. + var lockedMissingDates = await LockedMissingDatesAsync( + innerDbContext, dbAssignedSite.SiteId, missingDates); + foreach (var missingDate in missingDates.Except(lockedMissingDates)) { var newPlanRegistration = new PlanRegistration { @@ -429,6 +433,7 @@ await innerDbContext.PlanRegistrations.AsNoTracking() midnightOfDateFrom, midnightOfDateTo, options); + AddLockedPlaceholderDays(siteModel, lockedMissingDates); return siteModel; }).ToList(); @@ -591,7 +596,9 @@ public async Task> IndexByCurrent } } - foreach (var missingDate in missingDates) + var lockedMissingDates = await LockedMissingDatesAsync( + dbContext, dbAssignedSite.SiteId, missingDates); + foreach (var missingDate in missingDates.Except(lockedMissingDates)) { var newPlanRegistration = new PlanRegistration { @@ -658,6 +665,7 @@ await dbContext.PlanRegistrations.AsNoTracking() midnightOfDateTo, options, messageLanguage); + AddLockedPlaceholderDays(siteModel, lockedMissingDates); siteModel.PlanningPrDayModels = model.IsSortDsc ? siteModel.PlanningPrDayModels.OrderByDescending(x => x.Date).ToList() @@ -700,6 +708,11 @@ public async Task Update(int id, TimePlanningPlanningPrDayModel localizationService.GetString("PlanningNotFound")); } + if (await CheckDayLockAsync(planning) is { } dayLocked) + { + return dayLocked; + } + var assignedSite = await dbContext.AssignedSites .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) .FirstAsync(x => x.SiteId == planning.SdkSitId); @@ -1238,6 +1251,11 @@ public async Task UpdateByCurrentUserNam( localizationService.GetString("PlanningNotFound")); } + if (await CheckDayLockAsync(planning) is { } dayLocked) + { + return dayLocked; + } + // Snapshot each shift's PRE-EDIT EFFECTIVE SHOWN coarse tick (override → // (override/5)+1, else Pause{N}Id) BEFORE the model is applied, so the // pause-override inference can change-detect a manual pause edit @@ -2284,6 +2302,70 @@ private void CompareBoolField(List changes, string fieldName, bool? .FirstOrDefaultAsync(x => x.Id == id); } + /// + /// The day-lock guard for the web and mobile edit paths: a failure to + /// return when the day is locked, else null. Without it the interceptor + /// still refuses the write, but as a generic error instead of a message + /// saying what the day is. + /// + private async Task CheckDayLockAsync(PlanRegistration planning) + { + var lockedThrough = await DayLockHelper.LockedThroughAsync(dbContext, planning.SdkSitId); + return DayLockHelper.IsLocked(lockedThrough, planning.Date) + ? new OperationResult(false, localizationService.GetString( + planning.Reconciled ? "DayIsReconciled" : "DayIsLockedByReconciledDay")) + : null; + } + + /// + /// The missing dates gap-fill must NOT create, because they fall inside the + /// site's lock: frozen means frozen, so a locked period does not grow new + /// rows (AddLockedPlaceholderDays stands in for them instead). The boundary + /// costs a query, so it is only resolved when there is anything to fill. + /// + private static async Task> LockedMissingDatesAsync( + TimePlanningPnDbContext ctx, int siteId, List missingDates) + { + if (missingDates.Count == 0) + { + return missingDates; + } + + var lockedThrough = await DayLockHelper.LockedThroughAsync(ctx, siteId); + return missingDates.Where(x => DayLockHelper.IsLocked(lockedThrough, x)).ToList(); + } + + /// + /// Gives each missing day inside the lock (which gap-fill may not create) + /// a NON-PERSISTED stand-in, then restores date order. The dashboard grid + /// reads days BY POSITION (column i = planningPrDayModels[i]), so a + /// missing entry would shift every later day one column left, under the + /// wrong header, and a click would open a different day. Id 0 marks a day + /// with no row; the values mirror what the projection yields for an empty + /// row. Nothing is written, so frozen still means frozen. + /// + private static void AddLockedPlaceholderDays( + TimePlanningPlanningModel siteModel, List lockedMissingDates) + { + if (lockedMissingDates.Count == 0) + { + return; + } + + siteModel.PlanningPrDayModels.AddRange(lockedMissingDates.Select(date => + new TimePlanningPlanningPrDayModel + { + Id = 0, + Date = date, + SiteId = siteModel.SiteId, + SiteName = siteModel.SiteName, + WeekDay = date.DayOfWeek == DayOfWeek.Sunday ? 7 : (int)date.DayOfWeek, + // |NettoHours - PlanHours| <= 0 on an empty row. + PlanHoursMatched = true + })); + siteModel.PlanningPrDayModels = siteModel.PlanningPrDayModels.OrderBy(x => x.Date).ToList(); + } + /// /// Sets Reconciled and ReconciledAt together and saves. I1: flag and /// timestamp always change together. DateTime.Now, not UtcNow: the diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs index 94d4c63d..11ad6f9a 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs @@ -101,6 +101,9 @@ public async Task>> Inde // Stage 3 tick-exact parity: per-row mode-at-registration from the // AssignedSiteVersions audit trail (one query; in-memory lookups). var oneMinuteTimeline = await OneMinuteModeTimeline.BuildAsync(dbContext, assignedSite); + // Reconciled days ride the existing IsLocked flag, which the page + // already renders read-only. One lookup; every row is checked in memory. + var lockedThrough = await DayLockHelper.LockedThroughAsync(dbContext, model.SiteId); var timePlanningRequest = dbContext.PlanRegistrations .AsNoTracking() @@ -177,7 +180,8 @@ public async Task>> Inde CommentWorker = x.WorkerComment.Replace("\r", "
"), CommentOffice = x.CommentOffice.Replace("\r", "
"), // CommentOfficeAll = x.CommentOfficeAll, - IsLocked = (x.Date < DateTime.Now.AddDays(-(int)(maxDaysEditable ?? 0)) || x.Date == midnight), + IsLocked = (x.Date < DateTime.Now.AddDays(-(int)(maxDaysEditable ?? 0)) || x.Date == midnight) + || DayLockHelper.IsLocked(lockedThrough, x.Date), IsWeekend = x.Date.DayOfWeek == DayOfWeek.Saturday || x.Date.DayOfWeek == DayOfWeek.Sunday, NettoHoursOverride = x.NettoHoursOverride, NettoHoursOverrideActive = x.NettoHoursOverrideActive, @@ -393,7 +397,8 @@ public async Task>> Inde Date = model.DateFrom.AddDays(i), WeekDay = (int)model.DateFrom.AddDays(i).DayOfWeek, IsLocked = model.DateFrom.AddDays(i) < DateTime.Now.AddDays(-(int)(maxDaysEditable ?? 0)) || - model.DateFrom.AddDays(i) == midnight, + model.DateFrom.AddDays(i) == midnight || + DayLockHelper.IsLocked(lockedThrough, model.DateFrom.AddDays(i)), IsWeekend = model.DateFrom.AddDays(i).DayOfWeek == DayOfWeek.Saturday || model.DateFrom.AddDays(i).DayOfWeek == DayOfWeek.Sunday //WorkerId = model.WorkerId, @@ -455,9 +460,20 @@ public async Task CreateUpdate(TimePlanningWorkingHoursUpdateCr { try { + // Locked rows arrive here because the page posts every row it shows. + // They are displayed read-only (Index sets IsLocked), so they are + // skipped, not rejected: rejecting would make any range touching a + // reconciled day unsaveable. The interceptor still guarantees that a + // crafted POST cannot write one. + var lockedThrough = await DayLockHelper.LockedThroughAsync(dbContext, model.SiteId); + // Locked rows are never even loaded, so nothing below can mutate + // one and have a later save flush it (ruling F15). The forward + // cascade walks this same list, so it skips them too. Not redundant + // with the loop's skip: only this keeps the cascade out of the lock. var planRegistrations = await dbContext.PlanRegistrations .Where(x => x.SdkSitId == model.SiteId) .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .WhereOpen(lockedThrough) .ToListAsync(); // Site-level one-minute flag drives the forward-cascade recompute below // so the double AND *InSeconds SumFlex columns are written consistently. @@ -470,18 +486,24 @@ public async Task CreateUpdate(TimePlanningWorkingHoursUpdateCr foreach (var planning in model.Plannings) { planning.Date = new DateTime(planning.Date.Year, planning.Date.Month, planning.Date.Day, 0, 0, 0); + if (DayLockHelper.IsLocked(lockedThrough, planning.Date)) + { + // Neither updated nor created. `first` is still cleared: the + // carried-over first row may be the locked one, and the next + // row must then be creatable as usual. + first = false; + continue; + } + var planRegistration = planRegistrations.FirstOrDefault(x => x.Date == planning.Date); if (planRegistration != null) { await UpdatePlanning( first, planRegistration, planning, model.SiteId, cascadeTimeline); } - else + else if (!first) { - if (!first) - { - await CreatePlanning(first, planning, model.SiteId, model.SiteId, planning.CommentWorker); - } + await CreatePlanning(first, planning, model.SiteId, model.SiteId, planning.CommentWorker); } first = false; @@ -1336,6 +1358,36 @@ private static void ApplyPunchClockFlexChainDecimal( FlexChain.ClearSumFlexSeconds(planRegistration); } + /// + /// The day-lock guard for both UpdateWorkingHour overloads: a failure to + /// return when the day is locked, else null. Neither overload has a + /// try/catch, so this answers with a message instead of letting the + /// interceptor throw. The message follows the planning service's rule, the + /// row's own Reconciled flag, so mobile says what web says for the same day + /// (spec §11.3). The row may not be loaded yet (or may not exist), so the + /// flag costs one cheap query, and only when the day is locked. + /// + private async Task CheckDayLockAsync(int? sdkSitId, DateTime date) + { + if (sdkSitId is not { } siteId) + { + return null; + } + + var lockedThrough = await DayLockHelper.LockedThroughAsync(dbContext, siteId); + if (!DayLockHelper.IsLocked(lockedThrough, date)) + { + return null; + } + + var day = date.Date; + var reconciled = await dbContext.PlanRegistrations + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .AnyAsync(x => x.SdkSitId == siteId && x.Date == day && x.Reconciled); + return new OperationResult(false, localizationService.GetString( + reconciled ? "DayIsReconciled" : "DayIsLockedByReconciledDay")); + } + public async Task UpdateWorkingHour(TimePlanningWorkingHoursUpdateModel model) { Console.WriteLine($"[DEBUG-GRPC-UPDATE] === UpdateWorkingHour (PERSONAL mode, 1-param) entered ==="); @@ -1397,6 +1449,11 @@ public async Task UpdateWorkingHour(TimePlanningWorkingHoursUpd localizationService.GetString("EditingNotAllowedForWorker")); } + if (await CheckDayLockAsync(sdkSite.MicrotingUid, model.Date) is { } dayLocked) + { + return dayLocked; + } + var todayAtMidnight = model.Date; Console.WriteLine($"[DEBUG-GRPC-UPDATE] Querying PlanRegistrations: Date={todayAtMidnight:yyyy-MM-dd}, SdkSitId={sdkSite.MicrotingUid}"); @@ -2024,6 +2081,14 @@ public async Task UpdateWorkingHour(int? sdkSiteId, TimePlannin } Console.WriteLine($"[DEBUG-GRPC-UPDATE] KIOSK: registrationDevice found, Id={registrationDevice.Id}"); + // Before both branches below (each repeats its own assigned-site lookup), + // and after the token check so an unknown device learns nothing about + // the lock. + if (await CheckDayLockAsync(sdkSiteId, model.Date) is { } dayLocked) + { + return dayLocked; + } + registrationDevice.OsVersion = model.OsVersion; registrationDevice.Model = model.Model; registrationDevice.Manufacturer = model.Manufacturer; From 658e824c9fe79183c06ac10b389121b21b9eaddd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 15 Sep 2026 15:13:18 +0200 Subject: [PATCH 11/28] docs(lock): record implementation rulings and add the UI and write-path tasks The spec gains section 13, which lists what changed during execution: the payroll-flag exemption, the two-slot check, reverting locked days before any save, placeholder days for the positional grid, and the working-hours skip. Section 6.3 is corrected, and the section 8.3 grid gotcha now uses disableRowClickSelection. The plan gains Task 5B (remaining plugin write paths) and the reviewed UI tasks for section 8.1-8.4 (8A-14A), which settles the handoff's open decision. Co-Authored-By: Claude Opus 5 --- .../plans/2026-09-12-reconciled-day-lock.md | 3476 +++++++++++++++++ .../2026-09-13-HANDOFF-reconciled-day-lock.md | 6 +- .../2026-09-12-reconciled-day-lock-design.md | 52 +- 3 files changed, 3528 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-09-12-reconciled-day-lock.md b/docs/superpowers/plans/2026-09-12-reconciled-day-lock.md index c0fd0446..56625d34 100644 --- a/docs/superpowers/plans/2026-09-12-reconciled-day-lock.md +++ b/docs/superpowers/plans/2026-09-12-reconciled-day-lock.md @@ -2528,3 +2528,3479 @@ Re-running a failed job **overwrites** its conclusion, so a green run can hide a **Type consistency.** `lockedThrough` is `DateTime?` in C# and `string | null` in TS (JSON). `LockedThroughAsync` / `LockedThroughForSitesAsync` / `IsLocked` / `CanReconcile` are used with those exact names in Tasks 2, 4, 5, 6. `reconciled` / `reconciledAt` match the C# `Reconciled` / `ReconciledAt` under default camelCase JSON. CSS classes `locked-background` / `reconciled-background` match between Task 9 (emitted) and Tasks 10, 14 (styled, asserted). **Known risk carried forward:** Task 7 duplicates ~90 lines into the service repo. A divergence is a lock with a hole. Both copies carry a header comment naming the twin; there is no shared package to put it in without modifying the base. + +--- + +# Addendum A (2026-09-15): tasks added during execution + +These tasks were written and reviewed after the pre-flight scan and the write-path audits found gaps in the plan. Where they conflict with an earlier task, they supersede it; each says what it supersedes. + +## Task 5B: Guard the remaining plugin write paths (controller-authored; not in the original plan) + +**Why this task exists.** A write-path audit (`write-path-audit.md` in this directory, read it first) found PlanRegistration writes outside Task 5's scope that reach locked (past) days. With the interceptor live, each one either crashes something or leaves partial state. Rulings F10, F11 and F13 in `progress.md` settle the treatment. The rule is: **bulk or background re-syncs SKIP locked rows (frozen means frozen); a user acting on specific days gets a MESSAGE.** + +**Files (verify line numbers; they are from the audit):** +- `TimePlanning.Pn/Infrastructure/Helpers/CorruptedPauseIdRepair.cs` (`Run`) +- `TimePlanning.Pn/EformTimePlanningPlugin.cs` (`RepairCorruptedPauseIds` ~917 and its call in ConfigureServices ~208) +- `TimePlanning.Pn/Infrastructure/Helpers/GoogleSheetHelper.cs` (`PullEverythingFromGoogleSheet` ~161-473) +- `TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs` (`Import` ~3927-4126) +- `TimePlanning.Pn/Services/TimePlanningFlexService/TimePlanningFlexService.cs` (`UpdateCreate` ~146-228, plus its follow-up loop ~168-211) +- `TimePlanning.Pn/Services/AbsenceRequestService/AbsenceRequestService.cs` (`ApproveAsync` ~223-291) +- `TimePlanning.Pn/Services/ContentHandoverService/ContentHandoverService.cs` (`AcceptAsync` ~584-921) +- Tests: extend the existing fixtures `CorruptedPauseIdRepairTests.cs`, `TimePlanningFlexServiceRemovedRowTests.cs` (or a sibling in the same shard), `AbsenceRequestServiceTests.cs`, `ContentHandoverServiceTests.cs` and `WorkingHoursImportRemovedRowTests.cs`; mirror each one's existing seeding. Put new cases in the EXISTING classes where possible, so no shard edit is needed. If you must add a class, add it to a shard filter in BOTH workflow files. + +**Interfaces consumed:** `DayLockHelper.LockedThroughAsync / LockedThroughForSitesAsync / IsLocked`, `DayLockedException`, `ReconciledDayLockInterceptor.Instance`, resx key `DayIsLockedByReconciledDay`. + +### THE TRAP (it applies to every SKIP below) +`PnBase.Create/Update/Delete` each call SaveChanges, which flushes EVERY dirty tracked entity on the context. "Skipping" a locked row therefore means: **do not mutate a tracked locked entity at all** (decide before mutating), or revert it (`entry.CurrentValues.SetValues(entry.OriginalValues); entry.State = Unchanged`) before the next save on that context. Skipping only the `.Update()` call while leaving the mutated entity tracked makes the NEXT save throw. For each skip, state in your report which of the two you used and why it is safe. + +Resolve boundaries ONCE per call or run (for many sites, `LockedThroughForSitesAsync` once), never per row. + +### Steps + +1. **CorruptedPauseIdRepair (startup, CRITICAL, ruling F13: defense in depth).** + a. In `Run`, resolve boundaries for the sites in the scan set once. Exclude or skip every row with `IsLocked(boundary, row.Date)` before mutating it. + b. `RepairCorruptedPauseIds` builds its own context via `TimePlanningPnContextFactory` (unguarded). Build it with the interceptor attached instead: reuse `TimePlanningDbContextHelper(connectionString).GetDbContext()` if that fits, otherwise the same options it uses (MariaDbServerVersion 10.5.0, EnableRetryOnFailure, `ReconciledDayLockInterceptor.Instance`). **Never ServerVersion.AutoDetect.** + c. Around the repair call in ConfigureServices, catch ONLY `DayLockedException`: log it + `SentrySdk.CaptureException`, and continue startup. Other exceptions keep today's behaviour. Comment why: a skip bug must never take the whole host down. + Test (CorruptedPauseIdRepairTests): a worker with a reconciled boundary and a corrupted-pause row inside the repair window AT OR BELOW the boundary, plus one above it. `Run` completes without throwing, the locked row is byte-identical (Version/UpdatedAt via AsNoTracking), and the unlocked row IS repaired. + +2. **Payroll export**: nothing to do. Ruling F10 exempts payroll-flag-only writes in the interceptor. Add ONE test proving it end to end if the PayrollExport fixture allows it cheaply (export a period containing a reconciled day, then assert the flag is set and there is no error). Otherwise say so. + +3. **GoogleSheetHelper.PullEverythingFromGoogleSheet**: SKIP rows whose date is locked for that worker. Resolve boundaries once for the mapped sites. No test is expected (it needs the Sheets API); say so. + +4. **WorkingHoursService.Import**: SKIP locked rows (dates are >= yesterday, so only a reconciled yesterday can hit this). Test in WorkingHoursImportRemovedRowTests style if its fixture drives Import; otherwise say so. + +5. **FlexService.UpdateCreate**: + a. Before ANY write, if any entry in the posted batch is locked for its site, return `OperationResult(false, localizationService.GetString("DayIsLockedByReconciledDay"))` (a message: a user is editing specific days). + b. The follow-up loop over rows with `Date > Now.AddDays(-2)` touches YESTERDAY, which may be reconciled, and it is NOT a forward cascade from an edited day, so spec §6.2 does not cover it. SKIP locked rows there. + Tests: the batch reject returns the message and writes nothing (a row above the boundary in the same batch stays unchanged); the follow-up loop with a reconciled yesterday does not throw. + +6. **AbsenceRequestService.ApproveAsync**: BEFORE persisting `Status = Approved`, check every requested day against the worker's boundary. If any is locked, return the localized failure (DayIsLockedByReconciledDay) and persist nothing. Test: approving a request covering a locked day leaves the request NOT approved and no day flagged. + +7. **ContentHandoverService.AcceptAsync**: BEFORE any write, check BOTH the sender's and the receiver's row for that date against each worker's own boundary. If either is locked, return the localized failure and write nothing. Test: accepting a handover where the sender's day is locked leaves both rows and the request status unchanged. + +8. `PlanRegistrationHelper.UpdatePlanRegistration` (swallowing catch): no code change. CreateUpdate's Task 5 entry guard rejects any request containing a locked day, and cascades stay above the boundary (I2). Confirm by reading, and record it in the report. + +9. Build: `cd eFormAPI/Plugins/TimePlanning.Pn && dotnet build TimePlanning.Pn.sln -v q`, 0 errors, no new warnings in touched files. + +Commit message (when told to): `fix(lock): keep startup repair, sheet pull, import, flex, absence and handover inside the lock` + +--- + +# Addendum B (2026-09-15): UI tasks for spec §8.1–8.4 + +Written after the user put §8.1–8.4 in scope, then reviewed twice against the real code (the second verdict: ready). Task *N*A/*N*B extends Task *N*, and the brief extraction for Task *N* includes them. + + +These tasks slot into `docs/superpowers/plans/2026-09-12-reconciled-day-lock.md` beside +the frontend tasks they extend. The plan's Global Constraints apply unchanged: dev mode +NONE, tests run in CI only, staging by file name, SCSS only in `eform-angular-frontend`, +and the copy rule (a string says what the day *is* and never mentions administrators). + +*Revision 2 applies the plan review: 0 critical, 3 important, 12 minor.* + +| Task | Follows | Spec | Repo | +|---|---|---|---| +| 8A | Task 8 | §7, correction | plugin | +| 9A | Task 9 | §8.1 glyphs, tooltips, legend; shared helpers | plugin | +| 10A | Task 10 | §8.1-8.4 styles | **eform-angular-frontend** (Task 10's branch and PR) | +| 11A | Task 11 | §8.2 inline confirm, read-only in place, provenance | plugin | +| 11B | 11A | §8.4 typed-word unlock, "free this day first" | plugin | +| 12A | Task 12 | §8.3 preview, header click, scope bar, per-site toast | plugin | +| 13A | Task 13 | strings for all of the above, help-wiring spec | plugin | +| 14A | Task 14 | shard `s` bootstrap, Task 14's spec rebuilt on the shared helpers | plugin | + +**Execution order.** 8 → 8A → 9 → 9A → 10 → 10A → 11 → 11A → 11B → 12 → 12A → 13 → 13A → 14 → 14A. +Where a lettered task supersedes a step of its parent, an implementer who has not run +the parent yet should skip that step and apply the lettered version. Every supersession +is listed at the top of the task. + +## Conflicts with plan Tasks 8-14 + +The plan review checked these against the code. #4 and #6 are corrected here. + +1. **Task 8:** the TS `ReconcileThroughResultModel` has the wrong shape, and the + service uses the type without importing it. Fixed in 8A. +2. **Task 10, dark mode:** the dark overrides key off + `@media (prefers-color-scheme: dark) :root:not([data-theme="light"])`. This app + switches dark mode with `body.theme-dark`, so the tokens would follow the OS setting, + not the app setting. Fixed in 10A. +3. **Task 10, legibility:** in dark mode the lock ground `#262B29` sits under the state + classes' hard-coded dark text. That covers `.plan-text` and also `.comment`, which + inherits `#0F1316 !important` from `.X-background .plan-container`. Task 10 also dims + the lock glyph along with the cell. Fixed in 10A. +4. **§8.1 filled seal (corrected):** a filled `verified` cannot come from the Outlined + face, for two reasons. + - `index.html:15` loads Outlined with FILL fixed at 0. + - Under `body.theme-workspace`, `_workspace-mat-overrides.scss:27,36-44` forces + *every* `.mat-icon` onto Outlined with `!important`. + + `fontSet="material-symbols-rounded"` with the class `.filled` is therefore not + enough on its own. 9A/10A add a `tp-seal` class and a rule that out-specifies the + workspace override. +5. **Task 11 Step 4:** `btn-secondary` inside `mat-dialog-actions` fails the plugin + CI's "Button conventions" step. That step runs in the `build` job, which gates the + PR. Fixed in 11A and 11B. +6. **Task 11 Step 3 (rationale corrected):** the bound `[helpId]` and + `[attr.data-tp-help]` attributes do **not** fail `help-wiring.spec.ts`. They slip + past it: its regexes read only literal `helpId=` and `data-tp-help=` attributes. The + two banner ids would then sit outside the registry checks and outside the exhaustive + hint list, so a typo in either id would never be caught. The fix stays: two hints + with fixed ids, plus the hint-list update in 13A. +7. **Task 11 Step 3 / §8.5:** `tp-help-hint` renders only for admins + (`HelpVisibilityService`). Non-admin users can reconcile (§8.6), so they would see + no locked banner. The copy that matters goes in the footer as plain translated + text. +8. **Task 11 Step 5:** reconcile and unlock close with `this.data`. The table's + `afterClosed` treats that as a save and calls `updatePlanning` on a day that is now + locked, which is refused. Fixed with the `lockStateChanged` close contract (11A). +9. **Tasks 11-12:** every new literal template key fails `help-wiring.spec.ts`'s frozen + `TEMPLATE_TRANSLATE_KEYS`. Task 13 adds keys to `da` and `enUS` only. Fixed in 13A. +10. **Task 12:** `maxReconcileDate` uses `toISOString()`, which is UTC, so the max is + one day early between 00:00 and 02:00 Danish time. +11. **Task 12:** the toolbar commits directly, with no preview, and ignores the + per-site outcome. +12. **Task 12, gotcha 1:** the fix covers only the day cell. A click on the Name + column still clears the batch selection. `[disableRowClickSelection]` fixes both + at the source. +13. **Task 12, gotcha 2:** the re-emit happens only when `timePlannings` changes. The + grid also drops the selection when `[columns]` or `[headerTemplate]` change. +14. **Task 12, housekeeping:** its commit uses a directory `git add`. The container + spec needs a `ToastrService` provider once the container injects it. +15. **Task 13:** it registers the anchor `toolbar.reconcileThrough`, but Task 12's + markup never carries it. +16. **Task 14, bootstrap:** shard `s` has no `activate-plugin.spec.ts` or + `assert-true.spec.ts` bootstrap, which shards `q` and `r` have. The plugin never + loads, and every `s` spec fails. Fixed in 14A. +17. **Task 14, the spec itself:** + - It uses the current week. On a Monday or a Tuesday, `#cell3_1` is in the future + or is today, so the reconcile button is not there. + - It addresses rows by the positional `#cellN_M` ids. + - It clicks reconcile and unlock once each, which no longer matches the two-step + and typed-word flows. + + Fixed in 14A. +18. **Directory `git add`s:** Task 8 Step 3, Task 11 Step 6, Task 13 Step 6 and Task 14 + Step 3 stage directories. 8A, 11A, 13A and 14A replace each with an explicit list + of files. +19. **Spec tension, left as written:** §8.1 gives a locked cell the cursor + `not-allowed`, but §8.5 says the cell still opens a read-only dialog when clicked. + +## Spec edits for the controller + +Apply these to `docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md`: + +1. **§8.3, gotcha 1.** Replace "The day cell must call `stopRowClick($event)`" with: + "Set `[disableRowClickSelection]="true"` on the grid. `_selectRow` then leaves the + selection alone for any click on the row (the day cell *and* the Name column), + while still emitting `rowClick`. Rows are selected by their checkbox only." +2. **§8.1 constraint list.** Add: "The filled `verified` seal uses + `fontSet="material-symbols-rounded"` with the class `filled tp-seal`. Outlined is + loaded with FILL fixed at 0, and theme-workspace forces every `.mat-icon` onto + Outlined, so the host styles carry a `tp-seal` rule that restores Rounded with + FILL 1." + +## Facts these tasks rely on + +Each fact was checked against the code on 2026-09-15. + +1. **mtx-grid 20.4.2 stamps the `` class through a *pure* pipe:** + `[class]="col | colClass: row: rowChangeRecord: ..."`. The pipe re-runs only when + the row object changes, so `getCellClass` cannot show a preview that is driven by + container state. The preview classes go on `.plan-container` in the cell template, + which is evaluated on every change-detection pass. +2. **mtx-grid rebuilds `rowSelection` in `ngOnChanges` on *any* input change** + (`new SelectionModel(this.multiSelectable, this.rowSelected)`). That includes + `[columns]` and `[headerTemplate]`, not only `[data]`, and it emits nothing. +3. **`_selectRow()` skips the selection when `disableRowClickSelection` is set, but + still emits `rowClick`** (`mtxGrid.mjs:1226-1238`). +4. **mtx-grid's `headerTemplate` input accepts a `{[field]: TemplateRef}` map.** + Columns missing from the map keep the default header. +5. **`MatDialogRef.componentInstance` is set to `null` in `_finishDialogClose()`.** + Read it at open time, not after close. +6. **`index.html:15` loads `Material Symbols Outlined` as a static instance with FILL + fixed at 0.** On top of that, under `body.theme-workspace`, + `_workspace-mat-overrides.scss:27,36-44` sets + `.mat-icon { font-family: 'Material Symbols Outlined' !important }` with selector + specificity (0,3,1), for every icon. `Material Symbols Rounded` is loaded with + FILL 0..1. A filled glyph therefore needs Rounded **and** a rule above (0,3,1): the + `tp-seal` rule in 10A, at (0,4,1). +7. **This app switches dark mode with `body.theme-dark`** (`full-layout.component.ts:94-100`). + It does not use `prefers-color-scheme` or `data-theme`. +8. **The "Button conventions" step (`check-button-conventions.js`) runs in the plugin + PR's `build` job**, which is not `continue-on-error`. In `mat-dialog-actions`, every + button must be `.btn-primary`, `.btn-cancel`, `.btn-delete` or `.btn-quiet`, and the + first one in source order must be `.btn-cancel`. The script reads the whole row and + ignores `*ngIf`. +9. **`help/help-wiring.spec.ts` builds its `MARKUP` from the raw container, table and + dialog templates, comments included** (:37). It then checks four things: + - **Frozen keys:** the set of literal `'key' | translate` keys must equal + `TEMPLATE_TRANSLATE_KEYS`. + - **Exhaustive hints:** its list of `tp-help-hint` ids is complete. + - **Registry:** every `helpId=` and `data-tp-help=` value found by literal regex + (:69, :78) must exist in the registry. + - **Help icons:** it counts ` LandedOnBySiteId; int Applied; List SkippedAlreadyFurtherForward; List SkippedNoRegistration; List AlreadyReconciledSiteIds`. +- Produces: `ReconcileThroughResultModel { landedOnBySiteId, applied, skippedAlreadyFurtherForward, skippedNoRegistration, alreadyReconciledSiteIds }`. + 12A consumes it. + +Task 8's model had `{landedOn, applied, skippedSiteIds}`. The server never sends those +names, so every read in 12A would be `undefined`. The model must mirror the C# model +field for field. The host's Newtonsoft contract resolver camelCases the names, and +dictionary keys arrive as strings. + +- [ ] **Step 1: Replace the model** + +`models/plannings/reconcile-through-result.model.ts`: + +```ts +/** + * Mirrors the C# ReconcileThroughResultModel (plan Task 4) field for field. + * + * Per-worker landing, because the boundary is a staircase: the mark falls on each + * worker's own latest registered day at or before the requested date, so one shared + * "landedOn" would name the wrong date for most of them. + */ +export class ReconcileThroughResultModel { + /** siteId -> the day the mark landed on. JSON object keys arrive as strings. */ + landedOnBySiteId: { [siteId: number]: string } = {}; + /** Workers whose boundary actually moved. Excludes no-ops. */ + applied: number; + /** Already reconciled at or past the target. Moving them back would be an unlock. */ + skippedAlreadyFurtherForward: number[] = []; + /** No registration at or before the target, so there was nothing to mark. */ + skippedNoRegistration: number[] = []; + /** Already marked on exactly the landing day; nothing changed. */ + alreadyReconciledSiteIds: number[] = []; +} +``` + +Task 8 already exports it from `models/plannings/index.ts`. Check that the export line +is there. + +- [ ] **Step 2: Import the model where the service uses it** + +Task 8's `reconcileThrough()` names the type, but Task 8 never adds the import. Extend +the existing `from '../models'` import in `time-planning-pn-plannings.service.ts`: + +```ts +import { + PlanningPrDayModel, + ReconcileThroughResultModel, + TimeFlexesModel, + TimeFlexesUpdateModel, + TimePlanningModel, + TimePlanningsRequestModel, + TimePlanningsUpdateModel, + TimePlanningUpdateModel, + PlanRegistrationVersionHistoryModel, +} from '../models'; +``` + +- [ ] **Step 3: Task 8's own commit, staged by name** (replaces Task 8 Step 3) + +Run this only if Task 8's commit has not been made yet: + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin +git add eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/planning-pr-day.model.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/time-planning.model.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/reconcile-through-result.model.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/index.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/services/time-planning-pn-plannings.service.ts +git commit -m "feat(lock): add the lock fields and reconcile calls to the client" +``` + +- [ ] **Step 4: Commit 8A** + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin +git add eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/reconcile-through-result.model.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/services/time-planning-pn-plannings.service.ts +git commit -m "fix(lock): mirror the server's per-site reconcile-through result" +``` + +--- + +## Task 9A: Glyphs, tooltips, the legend, and the shared helpers (§8.1) + +**Follows:** Task 9. It needs `isDayLocked` and `isDayReconciled` from there. +**Supersedes:** nothing. It adds to Task 9. + +**Files:** +- Create: `eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/day-lock.util.ts` (final, including the bulk preview builder that 12A uses) +- Create: `eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/day-lock.util.spec.ts` (final) +- Modify: `.../components/plannings/time-plannings-table/time-plannings-table.component.ts` +- Modify: `.../components/plannings/time-plannings-table/time-plannings-table.component.html` (day-cell template :237-434; below `` :30) +- Create: `eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-helpers.ts` +- Create: `eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-glyphs.spec.ts` + +**Interfaces:** +- Consumes: + - `PlanningPrDayModel.reconciled` and `.reconciledAt`, `TimePlanningModel.lockedThrough` (Task 8) + - `isDayLocked` and `isDayReconciled` (Task 9) + - classes from 10A: `tp-day-glyph*`, `tp-seal`, `tp-lock-legend*` + - keys from 13A: `lockedTooltip`, `reconciledLegend`, `reconciledProvenance`, `Reconciled` +- Produces: + - `dayKey`, `formatReconciledProvenance`, `buildReconcilePreview`, `ReconcilePreview` and + `ReconcileRowOutcome`. 11A, 11B and 12A use them, and none of them rewrites this file. + - Table: `hasLockedDayInView` and `reconciledTooltip(row, field)`. + - Playwright: `s/reconcile-helpers.ts`, used by every later spec. + +**The two glyphs.** +- **`lock`:** an outline lock at the bottom-left marks a cascade-locked day. It uses + `fontSet="material-symbols-outlined" class="neutral-icon"`, like every other + day-cell icon. +- **`verified`:** a filled seal at the top-right marks the reconciled boundary day. It + uses `fontSet="material-symbols-rounded" class="neutral-icon filled tp-seal"` for + three reasons (fact 6): + - Outlined is loaded with FILL fixed at 0, so the glyph must come from Rounded. + - Theme-workspace pushes every `.mat-icon` back onto Outlined, and the `tp-seal` + rule in 10A overrides that. + - `neutral-icon` keeps it the same size and offset as its neighbours. + +**Positioning without fighting `.neutral-icon`.** That class forces +`position: relative !important; top: 5px !important`, so neither glyph is absolutely +positioned. +- **The lock** is the **last child of `.plan-content`**. 10A makes `.plan-content` a + flex column in a locked cell and gives the glyph `margin-top: auto`, which puts it + bottom-left. +- **The seal** is the **last child of `.plan-icons`**, which puts it at the top-right. + It never overlaps the message icons, because it takes a place in their row. + +- [ ] **Step 1: The shared helper, written once** + +`components/plannings/day-lock.util.ts`. The preview builder lives here from the start, +so 12A only consumes it: + +```ts +import {DatePipe} from '@angular/common'; +import {TranslateService} from '@ngx-translate/core'; +import {format} from 'date-fns'; +import {TimePlanningModel} from '../../models'; + +/** + * Calendar-day key 'yyyy-MM-dd', for comparing days with no timezone involved. + * + * Server dates (PlanningPrDayModel.date, TimePlanningModel.lockedThrough) arrive as + * server-local midnight with no offset, e.g. '2026-09-07T00:00:00', so the first ten + * characters ARE the calendar day. Turning them into a Date and comparing instants is + * how a boundary ends up a day off for part of every evening. A Date is formatted in + * local time. + */ +export function dayKey(value: string | Date | null | undefined): string | null { + if (!value) { + return null; + } + if (value instanceof Date) { + return isNaN(value.getTime()) ? null : format(value, 'yyyy-MM-dd'); + } + return /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : null; +} + +/** + * "Afstemt 14.09.2026 kl. 10:32" (spec §8.1, §8.2). There is no "by whom": + * ReconciledBy is a declared non-goal. + * + * ReconciledAt is written as DateTime.Now (Task 4) and read back from a datetime(6) + * column, so EF materialises it as DateTimeKind.Unspecified. Newtonsoft + * (RoundtripKind) serialises it with NO offset, and the browser reads it as local + * wall-clock time, which is the server's clock on a Danish deployment. Do NOT pass + * 'UTC' here, the way formatStamp does for the shift stamps. + */ +export function formatReconciledProvenance( + reconciledAt: string | null | undefined, + datePipe: DatePipe, + translate: TranslateService, +): string { + if (!reconciledAt) { + // I1 says a reconciled day always has a timestamp; this covers a malformed row. + return translate.instant('Reconciled'); + } + return translate.instant('reconciledProvenance', { + date: datePipe.transform(reconciledAt, 'dd.MM.yyyy'), + time: datePipe.transform(reconciledAt, 'HH:mm'), + }); +} + +/** 'lock' = the boundary moves to a day on screen. 'skip' = already at or past the target. */ +export type ReconcileRowOutcome = 'lock' | 'skip'; + +export interface ReconcilePreview { + /** 'yyyy-MM-dd'. Sent to reconcile-through as it is. */ + target: string; + /** + * Workers sent to reconcile-through: every row in scope that the preview drew, + * as 'lock' or as 'skip', each once. Skipped rows are sent too, so that the server + * reports them. A row the preview could not draw is never sent (§8.3: the region + * is previewed before it is committed). + */ + siteIds: number[]; + /** Per worker: the day the mark lands on. */ + landingBySiteId: Record; + /** Per worker: the boundary they already have, so already-locked cells are not re-highlighted. */ + existingBySiteId: Record; + outcomeBySiteId: Record; + /** Workers whose boundary will move. */ + willReconcileCount: number; + skipCount: number; +} + +/** + * The region a reconcile-through WILL lock, per worker (spec §8.3). It mirrors the + * server rules in Task 4: + * - A worker already reconciled at or past the target is skipped, never moved back. + * Moving back would be an unlock, which is a separate, heavier action (§8.4). + * - Otherwise the mark lands on the latest day at or before the target that has a + * registration. + * + * Only days on screen are known here. A row with no registered day on screen + * between its boundary and the target has nothing to draw, so it is left out of the + * commit. The date field is limited to days on screen (12A) and the index creates a + * registration for every visible day (fact 11), so this only happens when a visible + * day failed to materialise. + */ +export function buildReconcilePreview( + rows: TimePlanningModel[], + scopeSiteIds: number[], + target: string, +): ReconcilePreview { + const inScope = new Set(scopeSiteIds); + const preview: ReconcilePreview = { + target, + siteIds: [], + landingBySiteId: {}, + existingBySiteId: {}, + outcomeBySiteId: {}, + willReconcileCount: 0, + skipCount: 0, + }; + + for (const row of rows) { + if (!inScope.has(row.siteId) || row.siteId in preview.outcomeBySiteId) { + continue; + } + const existing = dayKey(row.lockedThrough); + preview.existingBySiteId[row.siteId] = existing; + + if (existing !== null && existing >= target) { + preview.outcomeBySiteId[row.siteId] = 'skip'; + preview.siteIds.push(row.siteId); + preview.skipCount++; + continue; + } + + const landing = (row.planningPrDayModels ?? []) + .filter(day => !!day?.id) + .map(day => dayKey(day.date)) + .filter((key): key is string => key !== null && key <= target) + .sort() + .pop(); + if (!landing || (existing !== null && landing <= existing)) { + continue; + } + + preview.landingBySiteId[row.siteId] = landing; + preview.outcomeBySiteId[row.siteId] = 'lock'; + preview.siteIds.push(row.siteId); + preview.willReconcileCount++; + } + return preview; +} +``` + +- [ ] **Step 2: Unit tests, written once** + +`components/plannings/day-lock.util.spec.ts`. It runs in CI's `angular-unit-test` job, +which is `continue-on-error` and so does not gate the PR. Keep it green anyway. + +```ts +import {DatePipe} from '@angular/common'; +import {buildReconcilePreview, dayKey, formatReconciledProvenance} from './day-lock.util'; + +describe('day-lock util', () => { + describe('dayKey', () => { + it('takes the calendar day from a server date without parsing it', () => { + expect(dayKey('2026-09-07T00:00:00')).toBe('2026-09-07'); + }); + + it('formats a Date in local time', () => { + expect(dayKey(new Date(2026, 8, 7, 23, 30))).toBe('2026-09-07'); + }); + + it('returns null for missing or malformed input', () => { + expect(dayKey(null)).toBeNull(); + expect(dayKey(undefined)).toBeNull(); + expect(dayKey('not a date')).toBeNull(); + }); + }); + + describe('formatReconciledProvenance', () => { + const translate = {instant: jest.fn((key: string, params?: object) => key)} as any; + + it('passes the wall-clock date and time through unshifted', () => { + formatReconciledProvenance('2026-09-14T10:32:11', new DatePipe('en-US'), translate); + expect(translate.instant) + .toHaveBeenLastCalledWith('reconciledProvenance', {date: '14.09.2026', time: '10:32'}); + }); + + it('falls back to the bare state name when there is no timestamp', () => { + formatReconciledProvenance(null, new DatePipe('en-US'), translate); + expect(translate.instant).toHaveBeenLastCalledWith('Reconciled'); + }); + }); + + describe('buildReconcilePreview', () => { + const day = (date: string, id = 1) => ({id, date: `${date}T00:00:00`}) as any; + const row = (siteId: number, lockedThrough: string | null, days: any[]) => ({ + siteId, + lockedThrough: lockedThrough ? `${lockedThrough}T00:00:00` : null, + planningPrDayModels: days, + }) as any; + const week = ['2026-09-07', '2026-09-08', '2026-09-09', '2026-09-10'].map(d => day(d)); + + it('lands on the latest registered day at or before the target', () => { + const preview = buildReconcilePreview([row(1, null, week)], [1], '2026-09-09'); + expect(preview.landingBySiteId[1]).toBe('2026-09-09'); + expect(preview.outcomeBySiteId[1]).toBe('lock'); + expect(preview.siteIds).toEqual([1]); + expect(preview.willReconcileCount).toBe(1); + }); + + it('skips a worker at or past the target, never moves the line back, and still sends it', () => { + const preview = buildReconcilePreview( + [row(1, '2026-09-10', week), row(2, '2026-09-09', week)], [1, 2], '2026-09-09'); + expect(preview.outcomeBySiteId[1]).toBe('skip'); + expect(preview.outcomeBySiteId[2]).toBe('skip'); + expect(preview.siteIds).toEqual([1, 2]); + expect(preview.skipCount).toBe(2); + expect(preview.willReconcileCount).toBe(0); + }); + + it('ignores days without a registration id', () => { + const days = [day('2026-09-07'), day('2026-09-08', 0), day('2026-09-09', 0)]; + const preview = buildReconcilePreview([row(1, null, days)], [1], '2026-09-09'); + expect(preview.landingBySiteId[1]).toBe('2026-09-07'); + }); + + it('never sends a row it could not draw', () => { + const unregistered = ['2026-09-07', '2026-09-08'].map(d => day(d, 0)); + const preview = buildReconcilePreview([row(1, null, unregistered)], [1], '2026-09-08'); + expect(preview.siteIds).toEqual([]); + expect(preview.outcomeBySiteId[1]).toBeUndefined(); + expect(preview.willReconcileCount).toBe(0); + }); + + it('keeps the existing boundary so already-locked days are not highlighted again', () => { + const preview = buildReconcilePreview([row(1, '2026-09-07', week)], [1], '2026-09-09'); + expect(preview.existingBySiteId[1]).toBe('2026-09-07'); + expect(preview.landingBySiteId[1]).toBe('2026-09-09'); + }); + + it('previews only the workers in scope, and sends each once', () => { + const preview = buildReconcilePreview([row(1, null, week), row(2, null, week)], [2, 2], '2026-09-08'); + expect(preview.siteIds).toEqual([2]); + expect(preview.outcomeBySiteId[1]).toBeUndefined(); + }); + }); +}); +``` + +- [ ] **Step 3: Table component, tooltip and legend switch** + +In `time-plannings-table.component.ts`, add the import. 12A extends this line: + +```ts +import {dayKey, formatReconciledProvenance} from '../day-lock.util'; +``` + +Add to the class, beside Task 9's `isDayLocked` and `isDayReconciled`: + +```ts + /** + * Legend switch (spec §8.1): shown whenever a locked day is on screen, because + * otherwise nobody learns what the hatch means. Recomputed when rows arrive, never + * on every change-detection pass. + */ + hasLockedDayInView = false; + + /** Seal tooltip: "Afstemt 14.09.2026 kl. 10:32". */ + reconciledTooltip(row: any, field: string): string { + return formatReconciledProvenance( + row?.planningPrDayModels?.[field]?.reconciledAt, this.datePipe, this.translateService); + } + + private computeHasLockedDayInView(): boolean { + return (this.timePlannings ?? []).some(row => { + const boundary = dayKey(row.lockedThrough); + return boundary !== null + && (row.planningPrDayModels ?? []).some(day => { + const key = dayKey(day?.date); + return key !== null && key <= boundary; + }); + }); + } +``` + +At the end of `ngOnChanges`, after the existing `waitingForFreshData` block (:81-85): + +```ts + if (changes.timePlannings) { + this.hasLockedDayInView = this.computeHasLockedDayInView(); + } +``` + +- [ ] **Step 4: The glyphs in the day cell** + +In `#dayColumnTemplate` in `time-plannings-table.component.html`: + +**(a)** Add the lock immediately before the `
` that closes `.plan-content`. That is +the line after the `(id: …)` block, :417-418: + +```html + + + lock + +``` + +**(b)** Add the seal immediately before the `` that closes `.plan-icons`, after +the `message === 13` icon, :431-432: + +```html + + + verified + +``` + +- [ ] **Step 5: The legend under the grid** + +In the same template, directly after `
` (:30) and before +``: + +```html + +
+ + + lock + + {{ 'lockedTooltip' | translate }} + + + + verified + + {{ 'reconciledLegend' | translate }} + +
+``` + +The legend reuses `lockedTooltip`, so the tooltip and the legend say the same words. +13A adds the keys. Until then they render as bare keys. + +- [ ] **Step 6: Shared Playwright helpers** + +`eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-helpers.ts`. +Playwright does not collect it as a test, because the name does not match `*.spec.ts`. +The helpers target the finished UI, including the 11A and 11B ids. They run only once +Task 14 adds shard `s` to the matrix, and by then every one of those ids exists. + +```ts +import { expect, Locator, Page, Response } from '@playwright/test'; + +/** + * Shared by every spec in shard s. Two rules are enforced here so no spec can forget + * them: + * + * 1. Rows are found by WORKER NAME, never by grid position. The `#cell{row}_{day}` + * ids are positional, and a row shift silently addresses another worker. That is + * exactly how e1m/dashboard-edit-multishift.spec.ts once failed. A position is + * read once per spec, to PICK a worker, and never again. + * 2. The grid is always opened on LAST week, so every visible day is in the past. + * Reconcile is refused for today and the future (I2), and the current week has + * no past day at all on a Monday. + * + * Every spec uses its own worker and unlocks what it locked, because the shard shares + * one database and runs its specs in file order (workers: 1). + */ + +const INDEX_PATH = '/api/time-planning-pn/plannings/index'; +export const RECONCILE_PATH = /\/api\/time-planning-pn\/plannings\/\d+\/reconcile$/; +export const UNRECONCILE_PATH = /\/api\/time-planning-pn\/plannings\/\d+\/unreconcile$/; +export const RECONCILE_THROUGH_PATH = /\/api\/time-planning-pn\/plannings\/reconcile-through$/; +/** Any write to a day: a save (PUT plannings/{id}) as well as reconcile and unlock. */ +export const PLANNING_PUT_PATH = /\/api\/time-planning-pn\/plannings\//; +/** Danish, like the rest of the suite: CI runs the UI in Danish. */ +export const UNLOCK_WORD = 'LÅS OP'; +export const LOCKED_TOOLTIP = 'Låst · ligger før en afstemt dag'; +export const PROVENANCE = /^Afstemt \d{2}\.\d{2}\.\d{4} kl\. \d{2}:\d{2}$/; + +export async function waitForSpinner(page: Page): Promise { + if (await page.locator('.overlay-spinner').count() > 0) { + await page.locator('.overlay-spinner').waitFor({ state: 'hidden', timeout: 30000 }); + } +} + +/** The grid's index POST. With `dateFrom` it only accepts a load of that period. */ +export function waitForIndex(page: Page, dateFrom?: string): Promise { + return page.waitForResponse(r => + r.url().includes(INDEX_PATH) + && r.request().method() === 'POST' + && (dateFrom === undefined || `${r.request().postDataJSON()?.dateFrom ?? ''}`.startsWith(dateFrom))); +} + +/** Matches on the pathname: a bare includes('/reconcile') also matches /unreconcile. */ +export function waitForPut(page: Page, path: RegExp): Promise { + return page.waitForResponse(r => + r.request().method() === 'PUT' && path.test(new URL(r.url()).pathname)); +} + +/** Counts the PUTs matching `path` from now on. Read `.count` when needed. */ +export function countPuts(page: Page, path: RegExp): { count: number } { + const counter = { count: 0 }; + page.on('request', req => { + if (req.method() === 'PUT' && path.test(new URL(req.url()).pathname)) { + counter.count++; + } + }); + return counter; +} + +/** OperationResult failures come back as HTTP 200 with success:false, so check both. */ +export async function expectSuccess(response: Response): Promise { + expect(response.status(), `${response.url()} HTTP status`).toBeLessThan(400); + const body = await response.json(); + expect(body.success, `${response.url()} failed: ${body.message}`).toBe(true); + return body; +} + +/** Last week's Monday as yyyy-MM-dd, in the same local calendar the grid's dateFrom uses. */ +export function lastWeekMonday(): string { + const d = new Date(); + d.setHours(0, 0, 0, 0); + const sinceMonday = (d.getDay() + 6) % 7; + d.setDate(d.getDate() - sinceMonday - 7); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +export async function openDashboardLastWeek(page: Page): Promise { + await page.locator('mat-nested-tree-node').filter({ hasText: 'Timeregistrering' }).click(); + const initial = waitForIndex(page); + await page.locator('mat-tree-node').filter({ hasText: 'Dashboard' }).click(); + await initial; + await waitForSpinner(page); + // Filtered on dateFrom, so a late current-week response cannot satisfy the wait. + const monday = lastWeekMonday(); + const lastWeek = waitForIndex(page, monday); + await page.locator('#backwards').click(); + const response = await lastWeek; + expect(response.request().postDataJSON().dateFrom, 'the grid must be on last week').toMatch(new RegExp(`^${monday}`)); + await waitForSpinner(page); +} + +/** The worker rendered at a grid position. Call it once per spec, to pick a worker. */ +export async function workerAtRow(page: Page, rowIndex: number): Promise { + const name = (await page.locator(`#firstColumn${rowIndex} .hours-info strong`).innerText()).trim(); + // A blank name would make every later lookup match vacuously. + expect(name, `row ${rowIndex} must name a worker`).toMatch(/^[^\s-]/); + return name; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** The grid row for a worker, found by name, never by position. */ +export function rowOf(page: Page, worker: string): Locator { + return page.locator('#main-header-text tr.mat-mdc-row').filter({ + has: page.locator('.hours-info strong', { + hasText: new RegExp(`^\\s*${escapeRegExp(worker)}\\s*$`), + }), + }); +} + +export function rowCheckbox(page: Page, worker: string): Locator { + return rowOf(page, worker).locator('td.mtx-grid-checkbox-cell input[type="checkbox"]'); +} + +/** A worker's day cell (.plan-container). day = column index, 0 = first visible day. */ +export function cellOf(page: Page, worker: string, day: number): Locator { + return rowOf(page, worker).locator(`.plan-container[id^="cell"][id$="_${day}"]`); +} + +/** The mtx-grid stamps getCellClass onto. */ +export function tdOf(page: Page, worker: string, day: number): Locator { + return cellOf(page, worker, day).locator('xpath=..'); +} + +export async function dialogTitle(page: Page): Promise<{ worker: string; date: string }> { + const title = page.locator('mat-dialog-container [mat-dialog-title]'); + await expect(title).toBeVisible({ timeout: 10000 }); + // " - ()", with the date on its own line. + const raw = (await title.innerText()).replace(/\s+/g, ' ').trim(); + return { + worker: raw.split(/\s+-\s+/)[0].trim(), + date: /(\d{2}\.\d{2}\.\d{4})/.exec(raw)?.[1] ?? '', + }; +} + +/** Opens a day, asserts the dialog is that worker's, and returns the date as dd.MM.yyyy. */ +export async function openDay(page: Page, worker: string, day: number): Promise { + const cell = cellOf(page, worker, day); + await expect(cell).toHaveCount(1); + await cell.scrollIntoViewIfNeeded(); + await cell.click(); + const title = await dialogTitle(page); + expect(title.worker, 'the dialog must belong to the worker the row was found by').toBe(worker); + expect(title.date).toMatch(/^\d{2}\.\d{2}\.\d{4}$/); + return title.date; +} + +/** Cancel with nothing persisted: no save and no reload. */ +export async function closeDayWithoutChange(page: Page): Promise { + await page.locator('#cancelButton').click(); + await expect(page.locator('mat-dialog-container')).toHaveCount(0); +} + +/** Close after a reconcile made in the dialog: the table reloads the grid instead of saving. */ +export async function closeDayAfterLockChange(page: Page): Promise { + const reload = waitForIndex(page, lastWeekMonday()); + await page.locator('#cancelButton').click(); + await reload; + await waitForSpinner(page); +} + +/** Two-step reconcile from the open dialog (§8.2). The dialog stays open, read-only. */ +export async function reconcileOpenDay(page: Page): Promise { + await page.locator('#reconcileButton').click(); + await expect(page.locator('#reconcileConfirmButton')).toBeVisible(); + const put = waitForPut(page, RECONCILE_PATH); + await page.locator('#reconcileConfirmButton').click(); + await expectSuccess(await put); + await expect(page.locator('#reconciledProvenanceText')).toHaveText(PROVENANCE); +} + +/** Reconciles one day and returns its date (dd.MM.yyyy), with the grid reloaded. */ +export async function reconcileDay(page: Page, worker: string, day: number): Promise { + const date = await openDay(page, worker, day); + await reconcileOpenDay(page); + await closeDayAfterLockChange(page); + return date; +} + +/** Typed-word unlock of the open boundary day (§8.4). The dialog closes and the grid reloads. */ +export async function unlockOpenDay(page: Page): Promise { + await page.locator('#unlockButton').click(); + await page.locator('#unlockWordInput').fill(UNLOCK_WORD); + const put = waitForPut(page, UNRECONCILE_PATH); + const reload = waitForIndex(page, lastWeekMonday()); + await page.locator('#unlockConfirmButton').click(); + await expectSuccess(await put); + await reload; + await waitForSpinner(page); +} + +export async function unlockDay(page: Page, worker: string, day: number): Promise { + await openDay(page, worker, day); + await unlockOpenDay(page); +} +``` + +- [ ] **Step 7: Playwright, glyphs and legend** + +`eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-glyphs.spec.ts`: + +```ts +import { test, expect } from '@playwright/test'; +import { LoginPage } from '../../../Page objects/Login.page'; +import { + cellOf, LOCKED_TOOLTIP, openDashboardLastWeek, PROVENANCE, reconcileDay, unlockDay, workerAtRow, +} from './reconcile-helpers'; + +/** + * Spec §8.1: the lock and seal glyphs, their tooltips, and the legend under the + * grid. The worker is grid row 5 at the start and is found by name after that. + * Day 4 (last week's Friday) becomes the boundary, so day 2 is cascade-locked and + * day 5 stays open. + */ +test.describe('Reconciled day lock: glyphs and legend', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:4200'); + await new LoginPage(page).login(); + await openDashboardLastWeek(page); + }); + + test('the boundary shows the seal, earlier days the lock, and the legend explains both', async ({ page }) => { + // Precondition, and a leak detector for the specs before this one: nothing + // locked is in view, so there is no legend. + await expect(page.locator('#lockLegend')).toHaveCount(0); + + const worker = await workerAtRow(page, 5); + await reconcileDay(page, worker, 4); + + // Boundary: the seal and no lock. Its tooltip is the provenance line. + const seal = cellOf(page, worker, 4).locator('.tp-day-glyph--seal'); + await expect(seal).toBeVisible(); + await expect(seal.locator('mat-icon')).toHaveClass(/tp-seal/); + await expect(cellOf(page, worker, 4).locator('.tp-day-glyph--lock')).toHaveCount(0); + await seal.hover(); + await expect(page.locator('.cdk-overlay-container .mat-mdc-tooltip-surface') + .filter({ hasText: PROVENANCE })).toBeVisible({ timeout: 10000 }); + + // Cascade: the lock and no seal. Its tooltip says what the day is. + const lock = cellOf(page, worker, 2).locator('.tp-day-glyph--lock'); + await expect(lock).toBeVisible(); + await expect(cellOf(page, worker, 2).locator('.tp-day-glyph--seal')).toHaveCount(0); + await lock.hover(); + await expect(page.locator('.cdk-overlay-container .mat-mdc-tooltip-surface') + .filter({ hasText: LOCKED_TOOLTIP })).toBeVisible({ timeout: 10000 }); + + // Above the boundary: no glyph at all. + await expect(cellOf(page, worker, 5).locator('.tp-day-glyph')).toHaveCount(0); + + // The legend appears with the first locked day and names both states. + await expect(page.locator('#lockLegend')).toBeVisible(); + await expect(page.locator('#lockLegendLocked')).toContainText(LOCKED_TOOLTIP); + await expect(page.locator('#lockLegendReconciled')).toContainText('Afstemt'); + + // Cleanup. With nothing locked, the legend goes away again. + await unlockDay(page, worker, 4); + await expect(cellOf(page, worker, 2).locator('.tp-day-glyph')).toHaveCount(0); + await expect(page.locator('#lockLegend')).toHaveCount(0); + }); +}); +``` + +- [ ] **Step 8: Verify what can be verified** + +Tests run only in CI. The plugin has no standalone Angular build either, because it +compiles inside the host. Two checks: +- `git diff` touches only the lines named above. +- `grep -c 'tp-day-glyph--\|lockLegend' eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.html` + prints **5**. The five lines are the lock span, the seal span, the legend wrapper and + its two items. `grep -c` counts lines, and the glyph comments do not contain either + pattern. **Do not add markup to reach any other number.** + +- [ ] **Step 9: Commit** + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin +git add eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/day-lock.util.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/day-lock.util.spec.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.html \ + eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-helpers.ts \ + eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-glyphs.spec.ts +git commit -m "feat(lock): lock and seal glyphs, tooltips and a legend in the grid" +``` + +--- + +## Task 10A: All lock styles in the host stylesheet (HOST REPO, same PR as Task 10) + +**Follows:** Task 10. +**Supersedes:** Task 10 Step 1 in full. The block below replaces the block Task 10 +appended. If Task 10 has not run yet, append this block instead. Task 10 Step 2 (the +branch) stands, and Step 3 here commits on that branch. + +**Files (different repository):** `/home/rene/Documents/workspace/microting/eform-angular-frontend` +- Modify: `eform-client/src/scss/styles.scss`. Place the block after the + `.red-background .plan-container` block, which ends at :358, where Task 10 put its + own block. + +**Interfaces:** +- Consumes: the class names written by 9A, 11A, 11B and 12A. +- Produces: the tokens `--tp-locked-bg`, `--tp-locked-hatch`, `--tp-preview-hatch`, + `--tp-seal-ink` and `--tp-select-col-width`, and every class in the table below. + +**Where each rule lives.** The plugin adds **no** SCSS. Every rule below extends Task +10's host PR. The plugin does have component `.scss` files with +`ViewEncapsulation.None`, but per CLAUDE.md and this plan none of these rules go there. + +| Rule group | Extends Task 10 by | Used by | +|---|---|---| +| Tokens (`:root` + `body.theme-dark`) | **correcting** Task 10's dark block; adding `--tp-preview-hatch` | all | +| `.locked-background` / `.reconciled-background` | Task 10's rules plus text colour (`.plan-text` *and* `.comment`) and a glyph-aware dim | Task 9 | +| `.tp-day-glyph*` | new | 9A | +| `.tp-seal` | new: filled Rounded seal on every theme | 9A, 11A, 11B | +| `.tp-lock-legend*` | new | 9A | +| `.tp-day-header-btn` | new | 12A | +| `.tp-preview-*`, `.tp-reconcile-scope*` | new | 12A | +| `.time-dashboard` selection column | new: sticky checkbox column; Name shifted right | 12A | +| `.tp-dialog-footer`, `.tp-footer-*`, `.tp-unlock-word` | new | 11A, 11B | + +**What this block changes in Task 10's rules.** +1. **The dark selector.** It keys off `body.theme-dark` (fact 7). Task 10 used + `prefers-color-scheme` and `data-theme`, which this app does not use. +2. **Text in dark mode.** The locked ground `#262B29` sits under the state classes' + hard-coded text colour: `#0F1316` on green, and `--text-header` on grey and white. + That applies to `.plan-text` and also to `.comment`, which inherits + `#0F1316 !important` from `.X-background .plan-container`. On the neutral lock + ground the state text colour is only decoration anyway, because colour is never + load-bearing (§8.1). The text becomes `--tp-text`, set on the container, on + `.plan-text` and on `.comment`. +3. **The dim.** Task 10 dims the whole `.plan-content`, which would also dim the lock + glyph. The dim now skips the glyph. + +**The seal on every theme (fact 6).** Theme-workspace's rule +`body.theme-workspace .mat-icon.filled` has specificity (0,3,1) and forces Outlined, +which is loaded with FILL 0. `body .mat-icon.material-symbols-rounded.filled.tp-seal` +has specificity (0,4,1). It wins regardless of source order, and both sides use +`!important`. + +**The selection column (fact 13).** The checkbox column is made sticky, and the pinned +Name column moves right by the checkbox column's 60px. I did not unpin Name instead. +In a grid a week or a month wide, Name is the only thing on screen that says whose row +a cell belongs to, and 12A's preview is read row by row. + +**Palette check.** +- No yellow outline, which belongs to `.highlight-cell`. +- No blue, which belongs to `.setting-ico.active`. +- The preview uses a *dashed* outline in `--tp-seal-ink` (green-teal). That is the + same channel as `.highlight-cell`, but in another colour and texture, and it sits on + `.plan-container` rather than on the ``. + +**Known spec tension, left as the spec says.** §8.1 gives a locked cell the cursor +`not-allowed`, but §8.5 says the cell still opens a read-only dialog when clicked. + +- [ ] **Step 1: Replace Task 10's block with this one** + +```scss +/* --------------------------------------------------------------------------- + Time planning: reconciled ("Afstemt") day lock. Plugin templates: + time-plannings-table, time-plannings-container, workday-entity-dialog. + + Four independent channels carry the state (texture, glyph, cursor and tooltip + text), so colour is never load-bearing. The 3px right border on a reconciled + cell draws the staircase boundary down the grid. + + Theme-agnostic on purpose: body.theme-eform rules do not apply under + body.theme-workspace, and these must read on both. Dark mode in this app is the + body.theme-dark class (full-layout.component.ts), NOT prefers-color-scheme. + Palette: no yellow outline (.highlight-cell) and no blue (.setting-ico.active). + --------------------------------------------------------------------------- */ +:root { + --tp-locked-bg: #E4E7E4; + --tp-locked-hatch: rgba(22, 33, 30, 0.055); + --tp-preview-hatch: rgba(47, 93, 80, 0.16); + --tp-seal-ink: #2F5D50; +} + +body.theme-dark { + --tp-locked-bg: #262B29; + --tp-locked-hatch: rgba(230, 234, 232, 0.06); + --tp-preview-hatch: rgba(127, 209, 185, 0.18); + --tp-seal-ink: #7FD1B9; +} + +// A mixin, not a custom property holding the gradient. A custom property that +// contains var() is resolved where it is declared (:root), so the body.theme-dark +// hatch override would never reach it. +@mixin tp-locked-texture { + background: repeating-linear-gradient(135deg, + var(--tp-locked-hatch) 0 2px, transparent 2px 6px), + var(--tp-locked-bg) !important; +} + +.locked-background .plan-container { + @include tp-locked-texture; + /* Beats the shared `.plan-container, .progress-container { cursor: pointer }` + rule by specificity. Scoped by .locked-background, so the avatar and progress + circle are unaffected. */ + cursor: not-allowed !important; +} + +.reconciled-background .plan-container { + background: var(--tp-locked-bg) !important; + border-right: 3px solid var(--tp-seal-ink) !important; + cursor: default !important; +} + +/* The state classes (green, red, grey, white) hard-code a text colour for their own + light grounds, on .plan-text and on the container itself, which .comment + inherits. On the neutral lock ground that colour is decoration, and in dark mode + #0F1316 on --tp-locked-bg cannot be read. Same specificity as the state rules and + later in the file, so these win. */ +.locked-background .plan-container, +.reconciled-background .plan-container { + color: var(--tp-text) !important; + + .plan-text, + .plan-text strong, + .plan-text span, + .plan-text mat-icon, + .comment { + color: var(--tp-text) !important; + } +} + +/* Dimmed, not hidden: people read closed days constantly. The glyph is excluded, + so the one element that explains the dimming is not dimmed with it. */ +.locked-background .plan-content > :not(.tp-day-glyph) { + opacity: 0.72; +} + +/* Glyphs (9A). The lock is the last child of .plan-content; a flex column plus + margin-top:auto puts it bottom-left. The seal is the last .plan-icons item, + top-right. Neither is absolutely positioned: .neutral-icon forces + position:relative and top:5px with !important. */ +.locked-background .plan-content { + display: flex; + flex-direction: column; +} + +.tp-day-glyph { + display: inline-flex; + align-self: flex-start; + + mat-icon { + color: var(--tp-seal-ink) !important; + } +} + +.tp-day-glyph--lock { + margin-top: auto; +} + +/* The filled seal, on every theme. Outlined is loaded with FILL fixed at 0 + (index.html), and body.theme-workspace forces every .mat-icon onto Outlined with + !important (_workspace-mat-overrides.scss). At (0,4,1) this beats that rule's + (0,3,1) whatever the source order. */ +body .mat-icon.material-symbols-rounded.filled.tp-seal { + font-family: 'Material Symbols Rounded' !important; + font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24 !important; +} + +/* Legend under the grid (9A). Shown only while a locked day is on screen. */ +.tp-lock-legend { + display: flex; + flex-wrap: wrap; + gap: 8px 24px; + margin-top: 8px; + padding: 8px 4px; + color: var(--tp-text); + font-size: 13px; +} + +.tp-lock-legend__item { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.tp-lock-legend__swatch { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 22px; + border: 1px solid var(--tp-border); + border-radius: 3px; + + .neutral-icon { + top: 0 !important; + font-size: 16px !important; + width: 16px; + height: 16px; + color: var(--tp-seal-ink) !important; + } +} + +.tp-lock-legend__swatch--locked { + @include tp-locked-texture; +} + +.tp-lock-legend__swatch--reconciled { + background: var(--tp-locked-bg); + box-shadow: inset -3px 0 0 var(--tp-seal-ink); +} + +/* Clickable header of a past day column (12A). It only starts a preview, so the + affordance stays quiet: a dotted underline on hover or keyboard focus. */ +.tp-day-header-btn { + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; + text-decoration: underline dotted transparent; + text-underline-offset: 3px; + + &:hover, + &:focus-visible { + text-decoration-color: var(--tp-seal-ink); + } +} + +/* Bulk reconcile preview (12A): what WILL lock, drawn in place before the commit. + Dashed because it is not true yet. It is layered over the cell's own state colour + (background-image only), so the day stays recognisable underneath. Same + specificity as `.X-background .plan-container`, placed later, so it wins. */ +.plan-container.tp-preview-lock { + background-image: repeating-linear-gradient(135deg, + var(--tp-preview-hatch) 0 2px, transparent 2px 6px) !important; + outline: 2px dashed var(--tp-seal-ink); + outline-offset: -3px; +} + +.plan-container.tp-preview-boundary { + box-shadow: inset -3px 0 0 var(--tp-seal-ink); +} + +.plan-container.tp-preview-skip { + opacity: 0.55; +} + +.tp-preview-skip-label { + display: inline-block; + margin-top: 2px; + padding: 0 6px; + border-radius: 4px; + background: var(--tp-locked-bg); + color: var(--tp-text); + font-size: 11px; + font-weight: 600; +} + +/* Bulk reconcile scope bar (12A), between the toolbar and the grid. */ +.tp-reconcile-scope { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin: 0 0 12px; + padding: 8px 12px; + border: 1px solid var(--tp-border); + border-left: 3px solid var(--tp-seal-ink); + border-radius: 8px; + background: var(--tp-td-bg); + color: var(--tp-text); + + .neutral-icon { + top: 0 !important; + color: var(--tp-seal-ink) !important; + } +} + +.tp-reconcile-scope__text { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 2px; +} + +/* Bulk selection column (12A). mtx-grid puts its checkbox column in front of the + pinned Name column but never makes it sticky, so on a sideways scroll the + checkboxes would slide under Name. Pin it too, and move Name right by its width. + Unpinning Name instead was rejected: in a wide grid, Name is the only thing that + says whose row a cell belongs to. */ +.time-dashboard { + --tp-select-col-width: 60px; /* mtx-grid's own .mtx-grid-checkbox-cell width */ + + th.mtx-grid-checkbox-cell, + td.mtx-grid-checkbox-cell { + position: sticky; + left: 0; + z-index: 2; + box-sizing: border-box; + width: var(--tp-select-col-width); + min-width: var(--tp-select-col-width); + max-width: var(--tp-select-col-width); + background: var(--tp-td-bg, #FFF); + } + + /* CDK and mtx-grid write an inline left:0 on the sticky Name cells. A stylesheet + !important beats a non-important inline style. */ + .mat-column-siteName.mat-table-sticky-left { + left: var(--tp-select-col-width) !important; + } +} + +/* Day dialog footer (11A, 11B). It morphs in place between modes. */ +.tp-dialog-footer { + flex-wrap: wrap; + gap: 12px; +} + +.tp-footer-status, +.tp-footer-confirm, +.tp-footer-note { + flex: 1 1 auto; + color: var(--tp-text); +} + +.tp-footer-status { + display: flex; + flex-direction: column; + gap: 2px; + + > span { + display: inline-flex; + align-items: center; + gap: 6px; + } + + .neutral-icon { + top: 0 !important; + color: var(--tp-seal-ink) !important; + } +} + +.tp-footer-note { + font-size: 13px; + opacity: 0.8; +} + +.tp-unlock-word { + width: 12ch; + height: 40px; + padding: 0 12px; + border: 1px solid var(--tp-border); + border-radius: var(--rounded-full); + background: var(--tp-td-bg); + color: var(--tp-text); + font: inherit; + letter-spacing: 0.08em; + text-transform: uppercase; /* display only; the comparison ignores case */ +} +``` + +- [ ] **Step 2: Verify** + +This SCSS compiles only inside `ng build`, which CI runs. Locally, check that: +- `git diff eform-client/src/scss/styles.scss` shows one contiguous block after :358; +- `grep -n "prefers-color-scheme" eform-client/src/scss/styles.scss` prints nothing. + +In the browser (step 6 of the development cycle), check on **both** `theme-eform` and +`theme-workspace`: +- the seal renders filled; +- the checkbox column stays put while the grid scrolls sideways. + +- [ ] **Step 3: Commit on Task 10's branch** + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-frontend +git checkout feat/reconciled-day-lock-styles +git add eform-client/src/scss/styles.scss +git commit -m "feat(timeplanning): lock glyphs, legend, bulk preview, selection column and dialog footer styles" +``` + +This commit goes into Task 10's PR. Three FOSSA checks fail on every +`eform-angular-frontend` PR and do not gate the merge. + +**Merge order.** The plugin's CI checks out host `stable`. The Playwright specs assert +classes, ids and text, not visuals, so they pass either way. Merge this host PR before +the plugin PR anyway, so that the browser check at step 6 of the development cycle +shows the real look. + +--- + +## Task 11A: Two-step reconcile in the dialog footer, read-only in place (§8.2) + +**Follows:** Task 11. +**Supersedes:** +- **Task 11 Step 3, the banner.** It is replaced by two hints with fixed ids. The + bound `[helpId]` version does not fail the wiring spec; it escapes it, so its ids + would never be checked against the registry or the exhaustive hint list (fact 9). +- **Task 11 Step 4, the footer.** `btn-secondary` fails CI's "Button conventions" + step (fact 8). The footer also **drops Task 11's direct `#unlockButton`**: unlock + returns in 11B with the typed word. Nothing is added here only to be deleted. +- **Task 11 Step 5, the handlers.** They close the dialog with `this.data`, which the + table then saves on a day that is now locked. +- **Task 11 Step 6, the directory `git add`.** +- **Task 9 Step 2, `onDayColumnClick`,** merged below. + +**Files:** +- Modify: `.../time-planning-actions/workday-entity/workday-entity-dialog.component.ts` +- Modify: `.../time-planning-actions/workday-entity/workday-entity-dialog.component.html` (the hint block ~:461 and `mat-dialog-actions` :494-514) +- Modify: `.../time-plannings-table/time-plannings-table.component.ts` (`onDayColumnClick` :458-485) +- Create: `eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-dialog-confirm.spec.ts` + +**Interfaces:** +- Consumes: + - `reconcileDay(id)` (Task 8) + - the dialog data fields and `isLocked` (Task 11 Steps 1-2) + - `dayKey` and `formatReconciledProvenance` (9A) + - the footer classes and `tp-seal` (10A) + - the keys `Reconcile day`, `reconcileDayConfirm`, `reconcileNeedsSave`, `Cancel` and `Save` (13A) +- Produces: + - `footerMode`, `lockStateChanged`, `lockRequestInFlight` and `setLockRequestInFlight()` + - the **close contract:** after any close, the table reads `lockStateChanged` and + reloads the grid instead of saving + +**Design.** +- **Why the dialog.** It is the one place that already shows whose day, which date + and which hours. The footer morphs in place instead of opening a second modal, + which would cover exactly that context. +- **After a successful PUT the dialog stays open.** The same dialog turns read-only, + and the provenance line `Afstemt kl. ` sits where the actions were. + There is no "by whom", because ReconciledBy is a declared non-goal. Until the dialog + closes, the client clock stands in for `ReconciledAt`. The reload after the close + brings the stored server value, which is what every later open shows. +- **Unsaved edits block reconcile** (kept at review). Reconcile writes only the flag, + not the form, so reconciling a dirty form would freeze a view of unsaved values as + if they were the sealed figures, and the edits would be lost. A dirty form shows a + note in place of the button. +- **Closing mid-PUT is blocked.** While the PUT is in flight, `dialogRef.disableClose` + is `true`. Otherwise Esc or a backdrop click could close the dialog before + `lockStateChanged` is set, and the grid would draw a sealed day as open. +- **Every close path reloads.** The dialog can close through Cancel, Esc or the + backdrop. Whichever path ends a dialog in which a reconcile happened, the grid must + reload and must not save. The table reads `lockStateChanged` from the component + instance it captured when it opened the dialog (fact 5). + +- [ ] **Step 1: The banner** (replaces Task 11 Step 3) + +Beside the `dayCell.futureDisabled` hint (:461-465): + +```html + + + +``` + +The comment deliberately never writes an id attribute with a quoted value. The wiring +spec reads comments as markup (fact 9), and 13A Step 5 greps for exactly that. + +- [ ] **Step 2: The footer** (replaces Task 11 Step 4 and the original :494-514; 11B replaces it again) + +```html + + +``` + +The buttons run Cancel, then Reconcile (quiet), then Save (primary). Save stays at the +right edge, under the cursor, where it has always been. + +- [ ] **Step 3: The handlers** (replace Task 11 Step 5) + +In `workday-entity-dialog.component.ts`, add these imports: + +```ts +import {format} from 'date-fns'; +import {dayKey, formatReconciledProvenance} from '../../day-lock.util'; +``` + +Do **not** add Task 11's `canReconcile`, `onReconcile()` or `onUnlock()`. Add: + +```ts + // ---- Reconcile footer (spec §8.2) ---------------------------------------- + + /** The footer morphs in place instead of opening a second modal. */ + footerMode: 'actions' | 'confirmReconcile' | 'confirmUnlock' = 'actions'; + + /** + * Set once a reconcile or unlock has reached the server. After the close, whatever + * its path (Cancel, Esc, backdrop), the table reads this and reloads the grid + * instead of treating the close payload as a save. + */ + lockStateChanged = false; + + /** True while a reconcile or unlock PUT is in flight. */ + lockRequestInFlight = false; + + /** Captured at construction so it can be restored after the PUT. */ + private readonly defaultDisableClose = this.dialogRef.disableClose; + + /** + * While the PUT is in flight the dialog cannot be dismissed. Otherwise Esc or a + * backdrop click would close it before lockStateChanged is set, and the grid would + * go on drawing a day the server has just sealed as open. + */ + private setLockRequestInFlight(inFlight: boolean): void { + this.lockRequestInFlight = inFlight; + this.dialogRef.disableClose = inFlight || this.defaultDisableClose; + } + + /** I2: today and future days stay open so time can still be registered. */ + get canReconcile(): boolean { + const day = dayKey(this.data.planningPrDayModels.date); + return !!this.data.planningPrDayModels.id && day !== null && day < dayKey(new Date()); + } + + /** "Afstemt 14.09.2026 kl. 10:32", shown where the actions were. */ + get reconciledProvenance(): string { + return formatReconciledProvenance( + this.data.planningPrDayModels.reconciledAt, this.datePipe, this.translateService); + } + + get dayLabel(): string { + return this.datePipe.transform(this.data.planningPrDayModels.date, 'dd.MM.yyyy') ?? ''; + } + + onReconcileStart(): void { + if (!this.isLocked && this.canReconcile && !this.workdayForm.dirty) { + this.footerMode = 'confirmReconcile'; + } + } + + onReconcileCancel(): void { + this.footerMode = 'actions'; + } + + onReconcileConfirm(): void { + if (this.lockRequestInFlight) { + return; + } + // An edit made while the confirm was showing would be frozen unsaved. Go back to + // the actions, where the "save first" note explains why. + if (this.workdayForm.dirty) { + this.footerMode = 'actions'; + return; + } + this.setLockRequestInFlight(true); + this.planningsService.reconcileDay(this.data.planningPrDayModels.id).subscribe({ + next: result => { + if (result && result.success) { + this.applyReconciledInPlace(); + } else { + // ApiBaseService has already shown the server's message as a toast. + this.footerMode = 'actions'; + } + this.setLockRequestInFlight(false); + }, + error: () => { + this.setLockRequestInFlight(false); + this.footerMode = 'actions'; + }, + }); + } + + /** + * Turns this open dialog read-only instead of closing it (spec §8.2). The server + * stamped ReconciledAt a moment ago. The client clock stands in for it until the + * close, and the reload that follows brings the stored value for every later open. + */ + private applyReconciledInPlace(): void { + const day = this.data.planningPrDayModels; + day.reconciled = true; + day.reconciledAt = format(new Date(), "yyyy-MM-dd'T'HH:mm:ss"); + this.data.isReconciled = true; + // Only open days above the boundary offer reconcile, so this day IS the new boundary. + this.data.lockedThrough = day.date; + this.isLocked = true; + // Task 11's setDisabled guard stops every later cascade call from re-enabling a control. + this.workdayForm.disable({emitEvent: false}); + this.lockStateChanged = true; + this.footerMode = 'actions'; + } +``` + +`lockStateChanged` is set **before** `setLockRequestInFlight(false)` re-enables Esc and +the backdrop. That ordering is what the guard exists for. + +- [ ] **Step 4: The table's side of the close contract** (replaces Task 9 Step 2) + +The final `onDayColumnClick` in `time-plannings-table.component.ts`: + +```ts + onDayColumnClick(row: any, field: string): void { + const siteId = row.siteId; + const cellData = R.clone(row.planningPrDayModels[field]); + this.timePlanningPnSettingsService.getAssignedSite(siteId).subscribe(result => { + if (result && result.success) { + const dialogRef = this.dialog.open(WorkdayEntityDialogComponent, { + data: { + planningPrDayModels: cellData, + assignedSiteModel: result.model, + tags: row.tags ?? [], + isLocked: this.isDayLocked(row, field), + isReconciled: this.isDayReconciled(row, field), + lockedThrough: row.lockedThrough ?? null, + }, + minWidth: 1024, + minHeight: 500, + maxWidth: '95vw', + maxHeight: '95vh', + panelClass: 'time-planning-dialog' + }); + // Captured now, because MatDialogRef sets componentInstance to null on close, + // and every close path must be able to report a reconcile or unlock that has + // already reached the server. + const dialog = dialogRef.componentInstance; + dialogRef.afterClosed().subscribe((data: any) => { + if (dialog?.lockStateChanged) { + // Already persisted by the dialog's own PUT. Never fall through to + // updatePlanning: the day is now locked, and the save would be refused. + this.pendingHighlight = { siteId, field }; + this.highlightApplied = false; + this.waitingForFreshData = true; + this.timePlanningChanged.emit(null); + return; + } + if (data !== '' && data !== undefined) { + this.pendingHighlight = { siteId, field }; + this.highlightApplied = false; + this.waitingForFreshData = true; + this.planningsService.updatePlanning(data.planningPrDayModels, data.planningPrDayModels.id).subscribe(result => { + if (result && result.success) { + this.timePlanningChanged.emit(data); + } + }); + } + }); + } + }); + } +``` + +The container's `onTimePlanningChanged` ignores its argument and calls +`getPlannings()`, so emitting `null` is enough. + +- [ ] **Step 5: Playwright, two-step confirm and read-only in place** + +`eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-dialog-confirm.spec.ts`: + +```ts +import { test, expect } from '@playwright/test'; +import { LoginPage } from '../../../Page objects/Login.page'; +import { + closeDayAfterLockChange, closeDayWithoutChange, countPuts, openDashboardLastWeek, openDay, + PLANNING_PUT_PATH, PROVENANCE, RECONCILE_PATH, reconcileOpenDay, tdOf, unlockDay, workerAtRow, +} from './reconcile-helpers'; + +/** Spec §8.2. The worker is grid row 6 at the start and is found by name after that. */ +test.describe('Reconciled day lock: dialog confirm', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:4200'); + await new LoginPage(page).login(); + await openDashboardLastWeek(page); + }); + + test('reconcile takes a second click in the same footer, then the dialog stays open read-only', async ({ page }) => { + const worker = await workerAtRow(page, 6); + const reconciles = countPuts(page, RECONCILE_PATH); + + const date = await openDay(page, worker, 3); + await expect(page.locator('#saveButton')).toBeVisible(); + await expect(page.locator('#CommentOffice')).toBeEnabled(); + + // The first click morphs the footer. It commits nothing and opens no second modal. + await page.locator('#reconcileButton').click(); + await expect(page.locator('#reconcileConfirmText')).toContainText(worker); + await expect(page.locator('#reconcileConfirmText')).toContainText(date); + await expect(page.locator('#saveButton')).toHaveCount(0); + await expect(page.locator('mat-dialog-container')).toHaveCount(1); + expect(reconciles.count).toBe(0); + + // Backing out restores the normal footer, and still nothing is sent. + await page.locator('#reconcileCancelButton').click(); + await expect(page.locator('#saveButton')).toBeVisible(); + expect(reconciles.count).toBe(0); + + // The second click commits. The SAME dialog turns read-only, with the provenance + // line where the actions were. + await reconcileOpenDay(page); + expect(reconciles.count).toBe(1); + await expect(page.locator('mat-dialog-container')).toHaveCount(1); + await expect(page.locator('#saveButton')).toHaveCount(0); + await expect(page.locator('#reconcileButton')).toHaveCount(0); + await expect(page.locator('#reconciledProvenanceText')).toHaveText(PROVENANCE); + await expect(page.locator('#CommentOffice')).toBeDisabled(); + + // Closing reloads the grid and does not save. + await closeDayAfterLockChange(page); + await expect(tdOf(page, worker, 3)).toHaveClass(/reconciled-background/); + await expect(tdOf(page, worker, 2)).toHaveClass(/locked-background/); + await expect(tdOf(page, worker, 4)).not.toHaveClass(/locked-background|reconciled-background/); + + // A later open shows the server's stored ReconciledAt. + await openDay(page, worker, 3); + await expect(page.locator('#reconciledProvenanceText')).toHaveText(PROVENANCE); + await closeDayWithoutChange(page); + + // Cleanup. + await unlockDay(page, worker, 3); + await expect(tdOf(page, worker, 3)).not.toHaveClass(/reconciled-background/); + }); + + test('a day with unsaved edits offers no reconcile, and Cancel writes nothing', async ({ page }) => { + const worker = await workerAtRow(page, 6); + await openDay(page, worker, 1); + await expect(page.locator('#reconcileButton')).toBeVisible(); + + await page.locator('#CommentOffice').fill('reconcile-guard'); + await expect(page.locator('#reconcileButton')).toHaveCount(0); + await expect(page.locator('#reconcileNeedsSave')).toBeVisible(); + + // Cancel closes with '': no save, no reconcile, no write of any kind. + const writes = countPuts(page, PLANNING_PUT_PATH); + await closeDayWithoutChange(page); + await page.waitForTimeout(1000); + expect(writes.count, 'Cancel must not send a save or a reconcile').toBe(0); + }); +}); +``` + +- [ ] **Step 6: Verify the button row locally** + +This is the same checker CI runs, pointed at the source tree. It is a lint script, not +a test runner: + +```bash +node /home/rene/Documents/workspace/microting/eform-angular-frontend/eform-client/scripts/check-button-conventions.js \ + /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin/eform-client/src/app/plugins/modules/time-planning-pn +``` + +Expected output: `Button conventions: OK`. + +- [ ] **Step 7: Task 11's own commit, staged by name** (replaces Task 11 Step 6) + +Run this only if Task 11's commit has not been made yet: + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin +git add eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.html +git commit -m "feat(lock): open locked days read-only, with reconcile and unlock" +``` + +- [ ] **Step 8: Commit 11A** + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin +git add eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.html \ + eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.ts \ + eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-dialog-confirm.spec.ts +git commit -m "feat(lock): two-step reconcile in the day dialog, read-only in place" +``` + +--- + +## Task 11B: Typed-word unlock and "free this day first" (§8.4) + +**Follows:** Task 11A. +**Supersedes:** 11A Step 2 in full. This task has the final `mat-dialog-actions`. + +**Files:** +- Modify: `.../workday-entity/workday-entity-dialog.component.ts` +- Modify: `.../workday-entity/workday-entity-dialog.component.html` (`mat-dialog-actions`) +- Create: `eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-unlock-word.spec.ts` + +**Interfaces:** +- Consumes: + - `unreconcileDay(id)` (Task 8) + - `data.lockedThrough` (Task 9, merged in 11A) + - `dayKey` (9A) + - `footerMode`, `lockStateChanged`, `lockRequestInFlight` and `setLockRequestInFlight()` (11A) + - the keys `UNLOCK`, `unlockTypeWordPrompt`, `unlockFreeFirst` and `Unlock` (13A) +- Produces: `isBoundaryDay`, `freeFirstDate`, `unlockWord`, `unlockWordMatches`, + `onUnlockStart()`, `onUnlockCancel()` and `onUnlockConfirm()`. + +**Only the boundary day offers unlock.** +- **The boundary** is the row's `lockedThrough`. Unlocking it moves the line back one + notch. +- **Why no other day.** The lock is derived: every day at or before `lockedThrough` is + locked. Unlocking a lower day would need an editable day under a later sealed one, + and a derived lock cannot represent that. The server rejects it (Task 4). +- **What a lower day shows instead.** It names the day to free first: + `Låst, fordi er afstemt. Lås op først.` That replaces a disabled + control with no explanation. +- **A reconciled day that is not the boundary** (an older month-end below a newer + one) shows both its provenance line and that "free first" line. + +**Why sealing takes a click and unsealing takes a word.** The two directions do not +carry the same risk. +- **Sealing is cheap to undo and fails safe.** At worst a period is frozen a day + early, and one unlock moves the line back. +- **Unsealing reopens settled figures.** They have usually already gone to payroll or + been checked against it. Unsealing exposes them again to edits, and to the + recalculation paths that Task 5 made skip locked days. +- **Nothing else guards the reverse direction.** Any web user may reconcile (§8.6). + The reverse-order rule plus this confirmation is the only safeguard on unlocking. +- **A click can be made by reflex; a word cannot.** A second click can be made without + thinking, because the confirm appears where the first button was. Typing a word + cannot, and it makes the user read the sentence that says what is about to happen. + +**How the word is checked.** +- **Case and whitespace are ignored.** The friction should be the word, not the Shift + key. +- **The word is localized:** `LÅS OP` in Danish, `UNLOCK` in English. +- **Untranslated locales still work.** The key is `UNLOCK`, and its value in those + locales is `UNLOCK`, so a missing translation still gives a word the user can type. + +- [ ] **Step 1: The final footer** (replaces 11A Step 2) + +```html + + +``` + +- [ ] **Step 2: The handlers** + +In `workday-entity-dialog.component.ts`, add `ElementRef` to the `@angular/core` +import. `ViewChild` is already there: + +```ts +import {Component, ElementRef, OnInit, TemplateRef, ViewChild, + inject, OnDestroy +} from '@angular/core'; +``` + +Add: + +```ts + // ---- Unlock (spec §8.4) ------------------------------------------------------ + + readonly unlockWordCtrl = new FormControl('', {nonNullable: true}); + + @ViewChild('unlockWordInput') private unlockWordInput?: ElementRef; + + /** Only the boundary day offers unlock. It moves the line back one notch. */ + get isBoundaryDay(): boolean { + return this.isLocked + && this.data.isReconciled === true + && dayKey(this.data.planningPrDayModels.date) === dayKey(this.data.lockedThrough); + } + + /** The day to free first, for every other locked day: the row's boundary. */ + get freeFirstDate(): string { + return this.datePipe.transform(this.data.lockedThrough, 'dd.MM.yyyy') ?? ''; + } + + /** The localized word to type. The key doubles as its own fallback in untranslated locales. */ + get unlockWord(): string { + return this.translateService.instant('UNLOCK'); + } + + /** Case and spacing are not the friction; the word is. */ + get unlockWordMatches(): boolean { + const norm = (value: string) => value.trim().replace(/\s+/g, ' ').toLocaleUpperCase(); + return norm(this.unlockWordCtrl.value) === norm(this.unlockWord); + } + + onUnlockStart(): void { + if (!this.isBoundaryDay) { + return; + } + this.unlockWordCtrl.setValue(''); + this.footerMode = 'confirmUnlock'; + // The input exists only once this change-detection pass has rendered the mode. + setTimeout(() => this.unlockWordInput?.nativeElement.focus()); + } + + onUnlockCancel(): void { + this.unlockWordCtrl.setValue(''); + this.footerMode = 'actions'; + } + + onUnlockConfirm(): void { + if (!this.unlockWordMatches || this.lockRequestInFlight) { + return; + } + this.setLockRequestInFlight(true); + this.planningsService.unreconcileDay(this.data.planningPrDayModels.id).subscribe({ + next: result => { + if (result && result.success) { + // The day is editable again, but this form was built locked. The only way + // back to a form whose enable/disable cascade ran from a clean start is to + // close and reopen from a reloaded grid. lockStateChanged is set before + // the guard lifts, so no close path can miss it. + this.lockStateChanged = true; + this.setLockRequestInFlight(false); + this.dialogRef.close(); + return; + } + // On failure (a newer boundary appeared meanwhile, say) stay in this mode. + // ApiBaseService has already toasted the server's message, which names the + // day to free first. + this.setLockRequestInFlight(false); + }, + error: () => { + this.setLockRequestInFlight(false); + }, + }); + } +``` + +- [ ] **Step 3: Playwright, typed-word unlock** + +`eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-unlock-word.spec.ts`: + +```ts +import { test, expect } from '@playwright/test'; +import { LoginPage } from '../../../Page objects/Login.page'; +import { + closeDayWithoutChange, expectSuccess, lastWeekMonday, openDashboardLastWeek, openDay, reconcileDay, + tdOf, UNLOCK_WORD, UNRECONCILE_PATH, waitForIndex, waitForPut, waitForSpinner, workerAtRow, +} from './reconcile-helpers'; + +/** Spec §8.4. The worker is grid row 9 at the start and is found by name after that. */ +test.describe('Reconciled day lock: unlock', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:4200'); + await new LoginPage(page).login(); + await openDashboardLastWeek(page); + }); + + test('only the boundary offers unlock, earlier days name it, and unlocking takes the word', async ({ page }) => { + const worker = await workerAtRow(page, 9); + const boundaryDate = await reconcileDay(page, worker, 3); + + // A day below the boundary names the day to free first and offers no unlock. + await openDay(page, worker, 1); + await expect(page.locator('#lockedFreeFirstText')) + .toHaveText(`Låst, fordi ${boundaryDate} er afstemt. Lås ${boundaryDate} op først.`); + await expect(page.locator('#unlockButton')).toHaveCount(0); + await expect(page.locator('#saveButton')).toHaveCount(0); + await closeDayWithoutChange(page); + + // The boundary: its provenance, no "free first" line, and an unlock gated by the word. + await openDay(page, worker, 3); + await expect(page.locator('#reconciledProvenanceText')).toBeVisible(); + await expect(page.locator('#lockedFreeFirst')).toHaveCount(0); + + await page.locator('#unlockButton').click(); + await expect(page.locator('#unlockPrompt')).toContainText(UNLOCK_WORD); + await expect(page.locator('#unlockWordInput')).toBeFocused(); + const confirm = page.locator('#unlockConfirmButton'); + await expect(confirm).toBeDisabled(); + await page.locator('#unlockWordInput').fill('LÅS'); + await expect(confirm).toBeDisabled(); + await page.locator('#unlockWordInput').fill(' lås op '); + await expect(confirm).toBeEnabled(); + + // Backing out keeps the day reconciled. + await page.locator('#unlockCancelButton').click(); + await expect(page.locator('#unlockWordInput')).toHaveCount(0); + await expect(page.locator('#reconciledProvenanceText')).toBeVisible(); + + // Enter in the field confirms. It must not submit the surrounding form, whose first + // button is the version-history button in the title. + await page.locator('#unlockButton').click(); + await page.locator('#unlockWordInput').fill(UNLOCK_WORD); + const put = waitForPut(page, UNRECONCILE_PATH); + const reload = waitForIndex(page, lastWeekMonday()); + await page.locator('#unlockWordInput').press('Enter'); + await expectSuccess(await put); + await reload; + await waitForSpinner(page); + await expect(page.locator('app-version-history-modal')).toHaveCount(0); + + // The line moved back: both days are open again. + await expect(tdOf(page, worker, 3)).not.toHaveClass(/reconciled-background/); + await expect(tdOf(page, worker, 1)).not.toHaveClass(/locked-background/); + }); +}); +``` + +- [ ] **Step 4: Verify the button row locally** + +```bash +node /home/rene/Documents/workspace/microting/eform-angular-frontend/eform-client/scripts/check-button-conventions.js \ + /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin/eform-client/src/app/plugins/modules/time-planning-pn +``` + +Expected output: `Button conventions: OK`. + +- [ ] **Step 5: Commit** + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin +git add eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.html \ + eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-unlock-word.spec.ts +git commit -m "feat(lock): unlock the boundary day with a typed word; name the day to free first" +``` + +--- + +## Task 12A: Bulk reconcile with an in-place preview (§8.3) + +**Follows:** Task 12. +**Supersedes:** +- **Task 12 Step 1** in full: the grid inputs, `onRowSelected`, `stopRowClick`, the + day-cell line, and the `ngOnChanges` re-emit. +- **Task 12 Step 2:** the toolbar markup. +- **Task 12 Step 3:** the container logic. +- **Task 12 Step 4:** the directory `git add`. +- **9A Step 3:** its `ngOnChanges` addition, merged below. + +**Files:** +- Modify: `.../time-plannings-table/time-plannings-table.component.ts` +- Modify: `.../time-plannings-table/time-plannings-table.component.html` +- Modify: `.../time-plannings-container/time-plannings-container.component.ts` +- Modify: `.../time-plannings-container/time-plannings-container.component.html` +- Modify: `.../time-plannings-container/time-plannings-container.component.spec.ts` +- Create: `eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-bulk-preview.spec.ts` + +**Interfaces:** +- Consumes: + - `reconcileThrough(date, siteIds)` (Task 8) + - `ReconcileThroughResultModel` (8A) + - `isDayLocked` (Task 9) + - `dayKey`, `buildReconcilePreview` and `ReconcilePreview` (9A, unchanged) + - classes from 10A + - keys from 13A +- Produces: + - table: `@Input reconcilePreview`, plus the outputs `selectionChanged` (Task 12's + name, kept), `selectionReset` and `reconcileDateRequested` + - container: `onReconcileDateChanged`, `onSelectionChanged`, `onSelectionReset`, + `confirmReconcileThrough` and `cancelReconcilePreview` + +**Scope = target date × set of workers.** +- **The date** comes from clicking a past day-column header (spec §8.3, "Selected + column") or from the toolbar date field. Both only **preview**. +- **The commit** is one button, in a scope bar under the toolbar. The bar names the + worker count and the target date. +- **The workers** are the ticked rows. With nothing ticked, they are every worker + currently visible. + +**The preview.** +- **The rules** mirror Task 4, per worker, from `row.planningPrDayModels` and + `row.lockedThrough` (see `buildReconcilePreview`, 9A). +- **A row already at or past the target is shown as skipped.** It gets a + "Springes over" label and is dimmed. +- **A locked row is drawn from its boundary up to its landing day.** Every cell after + its existing boundary, up to and including that day, gets `tp-preview-lock`, and the + landing day also gets `tp-preview-boundary`. +- **A row the preview cannot draw is not sent.** +- **The preview must be on screen** (kept at review). The date field's `min` is the + first visible day. Its `max` is the earlier of the last visible day and yesterday + (I2). §8.3's preview needs the cells on screen. +- **The classes go on the template, not `getCellClass`**, because mtx-grid's `colClass` + pipe is pure (fact 1). + +**A preview never widens silently.** An earlier revision cleared the ticked rows on any +reload and then rebuilt the preview. The empty selection then fell back to "every +visible worker", so a day save, the assigned-site dialog, Reload, a tag filter, the +resigned toggle or a language switch would quietly move the scope from N ticked +workers to all of them. A mistaken commit would then cost one typed-word unlock per +worker. +- **Nothing ticked:** the preview is rebuilt on the new rows. The scope is still + "everyone visible", so it does not widen. +- **Rows were ticked:** when the grid drops the ticked rows, the preview is + **cancelled**, never rebuilt. +- **Reloads** (`applyPlannings`) decide from `hadSelection`. +- **The grid's own resets** (language switch, new columns) arrive as a separate + `selectionReset` output, and the container cancels. +- **Unticking the last row by hand** is the one case that goes back to "everyone + visible". It is a deliberate spec rule, and the scope bar's count changes where the + user can see it. + +**The two mtx-grid gotchas, handled at the source.** +1. **A click selects the row.** `[disableRowClickSelection]="true"` makes `_selectRow` + skip the selection while still emitting `rowClick` (fact 3). Task 12 fixed only the + day cell; the Name column's click (the assigned-site dialog) would still have + cleared the batch. Rows are selected by their checkbox only. 10A makes that column + sticky. +2. **The selection is rebuilt empty, silently.** mtx-grid does this on *any* input + change (fact 2). + - The table emits `selectionReset` from `ngOnChanges` (`timePlannings`) and from + `updateTableHeaders` (`[columns]` and `[headerTemplate]`), but only when a + selection was active. + - The container clears its scope itself inside `applyPlannings`, before change + detection runs. The scope bar therefore never changes after it has been checked, + so there is no NG0100 in a dev build. + +**The result toast.** It reads the corrected per-site model: +- `applied`; +- skipped = `skippedAlreadyFurtherForward.length + skippedNoRegistration.length`; +- an "Uændret: n" suffix when `alreadyReconciledSiteIds` is not empty. This is kept + at review, because it reports AlreadyReconciled. + +It is a success toast when `applied > 0` and a warning otherwise. + +**Header affordance** (scope ruling). The past-day header is a plain text button with a +dotted underline on hover. The hover lock icon from the earlier revision is dropped, +because nobody asked for it. + +- [ ] **Step 1: Table component TS** + +In `time-plannings-table.component.ts`, extend 9A's util import: + +```ts +import {dayKey, formatReconciledProvenance, ReconcilePreview} from '../day-lock.util'; +``` + +Add these members. Do **not** add Task 12's `stopRowClick`: + +```ts + /** The bulk preview from the container (spec §8.3). Null when none is on screen. */ + @Input() reconcilePreview: ReconcilePreview | null = null; + /** Task 12: the ticked rows as site ids, emitted when the user ticks or unticks. */ + @Output() selectionChanged: EventEmitter = new EventEmitter(); + /** The grid dropped a non-empty selection by itself (mtx-grid gotcha 2). */ + @Output() selectionReset: EventEmitter = new EventEmitter(); + /** A past day header was clicked: preview a reconcile through that day. Commits nothing. */ + @Output() reconcileDateRequested: EventEmitter = new EventEmitter(); + + @ViewChild('reconcileDayHeaderTemplate', {static: true}) reconcileDayHeaderTemplate!: TemplateRef; + + /** + * The mtx-grid [headerTemplate] map. Only past day columns get the clickable header + * (I2). Columns missing from the map keep mtx-grid's default header. + */ + dayHeaderTemplates: {[field: string]: TemplateRef} = {}; + private columnDates: {[field: string]: Date} = {}; + private selectionActive = false; + + onRowSelected(rows: any[]): void { + const siteIds = (rows ?? []).map(r => r.siteId); + this.selectionActive = siteIds.length > 0; + this.selectionChanged.emit(siteIds); + } + + /** + * mtx-grid gotcha 2: the grid rebuilds its SelectionModel empty in ngOnChanges on + * ANY input change ([data], [columns], [headerTemplate]) and emits nothing. This + * reports that, and only when rows were ticked, as a RESET, not as an empty + * selection. An empty selection would mean "everyone visible" and would widen a + * previewed scope. The container cancels the preview instead. + */ + private resetSelection(): void { + if (this.selectionActive) { + this.selectionActive = false; + this.selectionReset.emit(); + } + } + + onDayHeaderClick(field: string): void { + const date = this.columnDates[field]; + if (date) { + this.reconcileDateRequested.emit(new Date(date)); + } + } + + /** A cell that WILL lock if the preview is committed. Cells already locked are left alone. */ + isPreviewLocked(row: any, field: string): boolean { + const landing = this.reconcilePreview?.landingBySiteId[row?.siteId]; + const day = dayKey(row?.planningPrDayModels?.[field]?.date); + if (!landing || !day) { + return false; + } + const existing = this.reconcilePreview.existingBySiteId[row.siteId]; + return day <= landing && (!existing || day > existing); + } + + /** The cell the mark will land on: the new boundary. */ + isPreviewBoundary(row: any, field: string): boolean { + const landing = this.reconcilePreview?.landingBySiteId[row?.siteId]; + return !!landing && dayKey(row?.planningPrDayModels?.[field]?.date) === landing; + } + + /** Already reconciled at or past the target: shown as skipped (§8.3). */ + isPreviewSkipped(row: any): boolean { + return this.reconcilePreview?.outcomeBySiteId[row?.siteId] === 'skip'; + } +``` + +The final `ngOnChanges` merges the existing body, 9A's legend line and gotcha 2: + +```ts + ngOnChanges(changes: SimpleChanges): void { + if (changes.dateFrom || changes.dateTo) { + if (changes.dateFrom !== undefined) { + this.dateFrom = changes.dateFrom.currentValue; + } + if (changes.dateTo !== undefined) { + this.dateTo = changes.dateTo.currentValue; + this.updateTableHeaders(); + } + } + if (changes.timePlannings && this.waitingForFreshData && this.pendingHighlight) { + // Fresh data has arrived after highlight was requested — now we can scroll + this.waitingForFreshData = false; + this.highlightApplied = false; + } + if (changes.timePlannings) { + this.hasLockedDayInView = this.computeHasLockedDayInView(); + this.resetSelection(); + } + } +``` + +The final `updateTableHeaders()` replaces :138-175: + +```ts + private updateTableHeaders(): void { + this.tableHeaders = []; + this.dayHeaderTemplates = {}; + this.columnDates = {}; + this.cdr.detectChanges(); + const startDate = new Date(this.dateFrom); + const endDate = new Date(this.dateTo); + const today = new Date(); + const todayMidnight = new Date(); + todayMidnight.setHours(0, 0, 0, 0); + const tempEndDate = new Date(endDate); + tempEndDate.setHours(0, 0, 0, 0); + const diff = (tempEndDate.getTime() - startDate.getTime()) / (1000 * 3600 * 24); + let daysCount = Math.floor(diff) +1; + let todayTranslated = this.translateService.stream('Today'); + const headerTemplates: {[field: string]: TemplateRef} = {}; + + this.tableHeaders = [ + { + cellTemplate: this.firstColumnTemplate, + header: this.translateService.stream('Name'), + pinned: 'left', + field: 'siteName', + sortable: true, + }, + ...Array.from({length: daysCount}).map((_, index) => { + const currentDate = new Date(startDate); + currentDate.setDate(startDate.getDate() + index); + const field = index.toString(); + this.columnDates[field] = currentDate; + // Only past days can be reconciled (I2), so only their headers become + // buttons. A past header is always a plain string: the Observable header is + // today's, and today never gets this template. + if (currentDate < todayMidnight) { + headerTemplates[field] = this.reconcileDayHeaderTemplate; + } + const isToday = currentDate.toDateString() === today.toDateString(); + const formattedDate = isToday + ? todayTranslated + : this.datePipe.transform(currentDate, 'E dd/MM', undefined, this.currentLocale) || ''; + return { + cellTemplate: this.dayColumnTemplate, + header: formattedDate, + field, + sortable: false, + class: (row: any) => this.getCellClass(row, field), + }; + }), + ]; + this.dayHeaderTemplates = headerTemplates; + // New [columns] and [headerTemplate] make mtx-grid drop its selection silently. + this.resetSelection(); + this.cdr.detectChanges(); + } +``` + +- [ ] **Step 2: Table template** + +**(a)** The final `` tag, replacing :19-30: + +```html + + + +``` + +**(b)** The final opening tag of `.plan-container` in `#dayColumnTemplate`, replacing +:238 and Task 12's version of it: + +```html + +
+``` + +**(c)** In **both** branches of `#firstColumnTemplate`, directly after +`{{ row[col.field] }}` (:63 and :162): + +```html + {{ 'reconcileRowSkipped' | translate }} +``` + +**(d)** A new template, after the closing `` of `#dayColumnTemplate` +(:434): + +```html + + + + +``` + +- [ ] **Step 3: Container TS** + +In `time-plannings-container.component.ts`, change the imports to: + +```ts +import {startOfWeek, endOfWeek, format, startOfDay, subDays} from 'date-fns'; +import {ToastrService} from 'ngx-toastr'; +import {TranslateService} from '@ngx-translate/core'; +import {ReconcileThroughResultModel} from '../../../models'; +import {buildReconcilePreview, ReconcilePreview} from '../day-lock.util'; +``` + +Add the injections beside the existing ones: + +```ts + private toastrService = inject(ToastrService); + private translateService = inject(TranslateService); +``` + +Add the fields. They replace Task 12's `selectedSiteIds`, `reconcileThroughDate` and +`maxReconcileDate`. Task 12's getter used `toISOString()`, which is UTC, so it gave a +max one day early between 00:00 and 02:00 Danish time. + +```ts + /** Ticked rows (site ids). Empty means every visible worker. */ + selectedSiteIds: number[] = []; + /** The bulk target, always a day on screen. See refreshReconcileBounds. */ + reconcileThroughDate: Date | null = null; + reconcilePreview: ReconcilePreview | null = null; + reconcileInFlight = false; + reconcileMinDate: Date | null = null; + reconcileMaxDate: Date | null = null; +``` + +The final `getPlannings()` replaces :142-152: + +```ts + getPlannings() { + this.buildTimePlanningsRequest(); + this.getTimePlannings$ = this.planningsService + .getPlannings(this.timePlanningsRequest) + .subscribe((data) => { + if (data && data.success) { + this.applyPlannings(data.model); + } + this.startPageTourOnce(); + }); + } +``` + +In `onShowResignedSitesChanged` (:298-300), replace +`this.timePlannings = planningsResult.model;` with: + +```ts + this.applyPlannings(planningsResult.model); +``` + +Add: + +```ts + /** + * Every path that replaces the rows comes through here: a day save, the + * assigned-site dialog, Reload, a filter, the resigned toggle. The grid drops the + * ticked rows on new data (mtx-grid gotcha 2). + * - If rows were ticked, the preview is CANCELLED, never rebuilt. A rebuild would + * fall back to "every visible worker" and silently widen the scope from the + * ticked rows to all of them. + * - If nothing was ticked, the scope was already "everyone visible", so the preview + * is redrawn on the new rows. + * Both happen before change detection, so the scope bar never changes after it + * has been checked. + */ + private applyPlannings(model: TimePlanningModel[]): void { + const hadSelection = this.selectedSiteIds.length > 0; + this.timePlannings = model; + this.selectedSiteIds = []; + this.refreshReconcileBounds(); + if (hadSelection) { + this.cancelReconcilePreview(); + } else { + this.rebuildReconcilePreview(); + } + } + + /** + * The bulk target must be on screen, because the preview can only draw what is on + * screen (§8.3). I2 caps it at yesterday. A null max means nothing in view can be + * reconciled. + */ + private refreshReconcileBounds(): void { + const from = startOfDay(this.dateFrom); + const yesterday = startOfDay(subDays(new Date(), 1)); + const lastVisible = startOfDay(this.dateTo); + const max = lastVisible < yesterday ? lastVisible : yesterday; + this.reconcileMinDate = from; + this.reconcileMaxDate = max < from ? null : max; + } + + get reconcileTargetLabel(): string { + return this.reconcileThroughDate ? format(this.reconcileThroughDate, 'dd.MM.yyyy') : ''; + } + + /** + * The user ticked or unticked a row. Unticking the last one returns the scope to + * "every visible worker", as the spec defines, and the scope bar's count changes + * in plain sight. + */ + onSelectionChanged(siteIds: number[]): void { + this.selectedSiteIds = siteIds; + this.rebuildReconcilePreview(); + } + + /** The grid dropped the ticked rows by itself (new columns). Cancel rather than widen. */ + onSelectionReset(): void { + if (this.selectedSiteIds.length === 0) { + return; // applyPlannings has already handled a reload + } + this.selectedSiteIds = []; + this.cancelReconcilePreview(); + } + + /** From the toolbar field or a day-column header. Starts the preview and commits nothing. */ + onReconcileDateChanged(date: Date | null): void { + this.reconcileThroughDate = date ? startOfDay(date) : null; + this.rebuildReconcilePreview(); + } + + cancelReconcilePreview(): void { + this.reconcileThroughDate = null; + this.reconcilePreview = null; + } + + private rebuildReconcilePreview(): void { + const target = this.reconcileThroughDate; + const onScreen = !!target && !!this.reconcileMinDate && !!this.reconcileMaxDate + && target >= this.reconcileMinDate && target <= this.reconcileMaxDate; + if (!onScreen) { + // Also covers navigating away from the target's week: the preview leaves with it. + this.cancelReconcilePreview(); + return; + } + // No selection means every worker currently visible under the active filters. + const scope = this.selectedSiteIds.length + ? this.selectedSiteIds + : this.timePlannings.map(x => x.siteId); + this.reconcilePreview = buildReconcilePreview(this.timePlannings, scope, format(target, 'yyyy-MM-dd')); + } + + confirmReconcileThrough(): void { + const preview = this.reconcilePreview; + if (!preview || this.reconcileInFlight || preview.willReconcileCount === 0) { + return; + } + this.reconcileInFlight = true; + // Exactly the rows the preview drew. Skipped rows go too, so that the toast reports + // what the server decided rather than what the client predicted. + this.planningsService.reconcileThrough(preview.target, preview.siteIds).subscribe({ + next: result => { + this.reconcileInFlight = false; + if (result && result.success && result.model) { + this.reportReconcileThrough(result.model); + this.cancelReconcilePreview(); + this.getPlannings(); + } + }, + error: () => { + this.reconcileInFlight = false; + }, + }); + } + + /** Applied and skipped counts, from the per-site result (Task 4, 8A). */ + private reportReconcileThrough(model: ReconcileThroughResultModel): void { + const skipped = model.skippedAlreadyFurtherForward.length + model.skippedNoRegistration.length; + let message = this.translateService.instant('reconcileThroughResult', {applied: model.applied, skipped}); + if (model.alreadyReconciledSiteIds.length) { + message += ' · ' + this.translateService.instant('reconcileThroughUnchanged', + {count: model.alreadyReconciledSiteIds.length}); + } + if (model.applied > 0) { + this.toastrService.success(message); + } else { + this.toastrService.warning(message); + } + } +``` + +- [ ] **Step 4: Container template** + +**(a)** The toolbar field goes after the `#workingHoursReload` button (:100-108). It +replaces Task 12 Step 2's `` and its `#reconcileThrough` button. The +field previews directly, so it needs no button: + +```html + +
+ + {{ 'Reconcile through' | translate }} + + + + +
+``` + +**(b)** The scope bar goes directly after `` (:124): + +```html + +
+ lock +
+ {{ 'reconcileScopeSummary' | translate: {date: reconcileTargetLabel, count: preview.willReconcileCount} }} + {{ 'reconcileScopeSkipped' | translate: {count: preview.skipCount} }} +
+ + +
+``` + +**(c)** The final table tag, replacing :136-144: + +```html + +``` + +- [ ] **Step 5: Container unit spec** + +The container now injects `ToastrService`, which the spec's TestBed does not provide, +so every test in the file would fail with `NullInjectorError`. In +`time-plannings-container.component.spec.ts`: + +Add the imports: + +```ts +import { ToastrService } from 'ngx-toastr'; +import { addDays, endOfWeek, format, startOfWeek, subDays } from 'date-fns'; +``` + +Add `reconcileThrough` to the plannings mock: + +```ts + mockPlanningsService = { + getPlannings: jest.fn(), + updatePlanning: jest.fn(), + reconcileThrough: jest.fn(), + } as any; +``` + +Add the provider: + +```ts + { provide: Store, useValue: mockStore }, + { provide: ToastrService, useValue: { success: jest.fn(), warning: jest.fn() } }, +``` + +Add these blocks at the end of the outer `describe`: + +```ts + describe('Bulk reconcile preview', () => { + const lastWeekStart = startOfWeek(subDays(new Date(), 7), { weekStartsOn: 1 }); + const lastWeekEnd = endOfWeek(lastWeekStart, { weekStartsOn: 1 }); + const rowFor = (siteId: number) => ({ + siteId, + lockedThrough: null, + planningPrDayModels: Array.from({ length: 7 }, (_, i) => ({ + id: siteId * 10 + i + 1, + date: `${format(addDays(lastWeekStart, i), 'yyyy-MM-dd')}T00:00:00`, + })), + }) as any; + + beforeEach(() => { + component.dateFrom = lastWeekStart; + component.dateTo = lastWeekEnd; + mockPlanningsService.getPlannings.mockReturnValue( + of({ success: true, model: [rowFor(1), rowFor(2)] }) as any); + component.getPlannings(); + }); + + it('previews every visible worker when no row is ticked', () => { + component.onReconcileDateChanged(lastWeekStart); + expect(component.reconcilePreview?.siteIds).toEqual([1, 2]); + }); + + it('drops a target outside the days on screen', () => { + component.onReconcileDateChanged(subDays(lastWeekStart, 1)); + expect(component.reconcilePreview).toBeNull(); + expect(component.reconcileThroughDate).toBeNull(); + }); + + it('cancels, never widens, the preview when a reload drops the ticked rows', () => { + component.onSelectionChanged([1]); + component.onReconcileDateChanged(lastWeekStart); + expect(component.reconcilePreview?.siteIds).toEqual([1]); + + component.getPlannings(); + + expect(component.selectedSiteIds).toEqual([]); + expect(component.reconcilePreview).toBeNull(); + }); + + it('cancels the preview when the grid drops the ticked rows by itself', () => { + component.onSelectionChanged([1]); + component.onReconcileDateChanged(lastWeekStart); + + component.onSelectionReset(); + + expect(component.reconcilePreview).toBeNull(); + }); + }); + + describe('Reconcile through', () => { + it('sends the previewed rows and toasts the counts the server decided', () => { + const toastr = TestBed.inject(ToastrService) as any; + mockPlanningsService.reconcileThrough.mockReturnValue(of({ + success: true, + model: { + landedOnBySiteId: { 1: '2026-09-07T00:00:00' }, + applied: 1, + skippedAlreadyFurtherForward: [2], + skippedNoRegistration: [], + alreadyReconciledSiteIds: [], + }, + }) as any); + component.reconcilePreview = { + target: '2026-09-07', + siteIds: [1, 2], + landingBySiteId: { 1: '2026-09-07' }, + existingBySiteId: { 1: null, 2: '2026-09-09' }, + outcomeBySiteId: { 1: 'lock', 2: 'skip' }, + willReconcileCount: 1, + skipCount: 1, + }; + + component.confirmReconcileThrough(); + + expect(mockPlanningsService.reconcileThrough).toHaveBeenCalledWith('2026-09-07', [1, 2]); + // TranslateModule.forRoot() has no catalogue loaded, so instant() echoes the key. + expect(toastr.success).toHaveBeenCalledWith('reconcileThroughResult'); + expect(component.reconcilePreview).toBeNull(); + }); + }); +``` + +- [ ] **Step 6: Playwright, preview, skip and counts** + +`eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-bulk-preview.spec.ts`: + +```ts +import { test, expect } from '@playwright/test'; +import { LoginPage } from '../../../Page objects/Login.page'; +import { + cellOf, closeDayWithoutChange, countPuts, expectSuccess, lastWeekMonday, openDashboardLastWeek, + openDay, RECONCILE_THROUGH_PATH, reconcileDay, rowCheckbox, rowOf, tdOf, unlockDay, + waitForIndex, waitForPut, waitForSpinner, workerAtRow, +} from './reconcile-helpers'; + +/** + * Spec §8.3. The workers are picked from grid rows 7 (A), 8 (B) and 10 (outsider) at + * the start and are found by name after that. B is reconciled further forward (day 5) + * than the bulk target (day 3), so it must be shown as skipped and left where it is. + */ +test.describe('Reconciled day lock: bulk reconcile', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:4200'); + await new LoginPage(page).login(); + await openDashboardLastWeek(page); + }); + + test('a header click previews per worker, skips rows already further, and toasts counts', async ({ page }) => { + const a = await workerAtRow(page, 7); + const b = await workerAtRow(page, 8); + const outsider = await workerAtRow(page, 10); + const throughs = countPuts(page, RECONCILE_THROUGH_PATH); + + await reconcileDay(page, b, 5); + + await rowCheckbox(page, a).check(); + await rowCheckbox(page, b).check(); + + // mtx-grid gotcha 1: opening a day must not clear the batch selection. + await openDay(page, a, 0); + await closeDayWithoutChange(page); + await expect(rowCheckbox(page, a)).toBeChecked(); + await expect(rowCheckbox(page, b)).toBeChecked(); + + // Preview: the region is drawn in place, and nothing is written. + await page.locator('#dayHeader3').click(); + for (const day of [0, 1, 2, 3]) { + await expect(cellOf(page, a, day)).toHaveClass(/tp-preview-lock/); + } + await expect(cellOf(page, a, 3)).toHaveClass(/tp-preview-boundary/); + await expect(cellOf(page, a, 4)).not.toHaveClass(/tp-preview-lock/); + await expect(rowOf(page, b).locator('.tp-preview-skip-label')).toBeVisible(); + await expect(cellOf(page, b, 3)).toHaveClass(/tp-preview-skip/); + await expect(cellOf(page, b, 3)).not.toHaveClass(/tp-preview-lock/); + // The selection is the scope: an unticked worker is not previewed. + await expect(cellOf(page, outsider, 0)).not.toHaveClass(/tp-preview-lock/); + const summary = page.locator('#reconcileScopeSummary'); + await expect(summary).toHaveText(/^Afstem til og med \d{2}\.\d{2}\.\d{4} · Medarbejdere: 1$/); + await expect(page.locator('#reconcileScopeSkipped')).toContainText('Springes over: 1'); + expect(throughs.count).toBe(0); + + // Cancel drops the preview and still writes nothing. + await page.locator('#reconcileScopeCancel').click(); + await expect(page.locator('#reconcileScopeBar')).toHaveCount(0); + await expect(page.locator('.tp-preview-lock')).toHaveCount(0); + expect(throughs.count).toBe(0); + + // Commit. + await page.locator('#dayHeader3').click(); + const [, dd, mm, yyyy] = /(\d{2})\.(\d{2})\.(\d{4})/.exec(await summary.innerText())!; + const put = waitForPut(page, RECONCILE_THROUGH_PATH); + const reload = waitForIndex(page, lastWeekMonday()); + await page.locator('#reconcileScopeConfirm').click(); + const response = await put; + const body = await expectSuccess(response); + const sent = response.request().postDataJSON(); + expect(sent.date, 'the date sent is the date the scope bar named').toBe(`${yyyy}-${mm}-${dd}`); + expect(sent.siteIds).toHaveLength(2); + expect(body.model.applied).toBe(1); + expect(body.model.skippedAlreadyFurtherForward).toHaveLength(1); + await expect(page.locator('#toast-container')).toContainText('Afstemt: 1 · Sprunget over: 1'); + await reload; + await waitForSpinner(page); + + // The staircase: A at day 3, B untouched at day 5; the selection and the preview are gone. + await expect(tdOf(page, a, 3)).toHaveClass(/reconciled-background/); + await expect(tdOf(page, a, 2)).toHaveClass(/locked-background/); + await expect(tdOf(page, a, 4)).not.toHaveClass(/locked-background|reconciled-background/); + await expect(tdOf(page, b, 5)).toHaveClass(/reconciled-background/); + await expect(page.locator('#reconcileScopeBar')).toHaveCount(0); + await expect(rowCheckbox(page, a)).not.toBeChecked(); + + // Cleanup. + await unlockDay(page, a, 3); + await unlockDay(page, b, 5); + }); + + test('a reload while ticked rows are previewed cancels the preview instead of widening it', async ({ page }) => { + const a = await workerAtRow(page, 7); + const outsider = await workerAtRow(page, 10); + + await rowCheckbox(page, a).check(); + await page.locator('#dayHeader3').click(); + await expect(page.locator('#reconcileScopeSummary')).toContainText('Medarbejdere: 1'); + + const reload = waitForIndex(page, lastWeekMonday()); + await page.locator('#workingHoursReload').click(); + await reload; + await waitForSpinner(page); + + // Silently becoming "every visible worker" would put the outsider in scope. Instead the preview is gone. + await expect(page.locator('#reconcileScopeBar')).toHaveCount(0); + await expect(cellOf(page, outsider, 0)).not.toHaveClass(/tp-preview-lock/); + await expect(page.locator('.tp-preview-lock')).toHaveCount(0); + }); +}); +``` + +- [ ] **Step 7: Commit** (replaces Task 12 Step 4's directory add) + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin +git add eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-table/time-plannings-table.component.html \ + eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.html \ + eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.spec.ts \ + eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-bulk-preview.spec.ts +git commit -m "feat(lock): preview a bulk reconcile in place and report per-site counts" +``` + +If Task 12's own commit has not been made yet, it stages the same four component files +by name. It does not stage the spec or the Playwright file. + +--- + +## Task 13A: Strings in every locale, and the help-wiring contract + +**Follows:** Task 13. +**Supersedes:** +- Task 13 Step 6, the directory `git add`. + +Otherwise this task only adds: keys after Task 13 Step 5's block, and Task 13's five +keys in the locale files Task 13 did not touch. + +**Files:** +- Modify: `.../time-planning-pn/i18n/da.ts` and `.../i18n/enUS.ts` +- Modify: the 24 other locale files in `.../time-planning-pn/i18n/`, through the script in Step 3 +- Modify: `.../time-planning-pn/help/help-wiring.spec.ts` + +**Interfaces:** +- Consumes: the keys used in 9A, 11A, 11B and 12A. +- Produces: every key, present in all 26 locale files. + +**Why all locales.** `help-wiring.spec.ts` freezes the set of literal template +translate keys. Its comment gives the contract: a new key "forces a deliberate update +of all 25 shared locale files". The directory actually holds 26, next to +`translates.ts`. +- Identifier keys such as `reconcileScopeSummary` would render as raw keys in German, + Norwegian and every other locale. +- The 24 other files therefore get the English values. +- `UNLOCK` is `UNLOCK` in those files, so the typed-word check still works in every + language. + +- [ ] **Step 1: Danish** + +In `i18n/da.ts`, directly after Task 13 Step 5's five keys and before `};`: + +```ts + // Reconciled day lock: grid, dialog footer and bulk scope (Tasks 9A-12A). + Reconcile: 'Afstem', + lockedTooltip: 'Låst · ligger før en afstemt dag', + reconciledLegend: 'Afstemt · dagens tal er endelige', + reconciledProvenance: 'Afstemt {{date}} kl. {{time}}', + reconcileHeaderTooltip: 'Afstem til og med denne dag', + reconcileRowSkipped: 'Springes over · allerede afstemt længere frem', + reconcileScopeSummary: 'Afstem til og med {{date}} · Medarbejdere: {{count}}', + reconcileScopeSkipped: 'Springes over: {{count}} (allerede afstemt længere frem)', + reconcileThroughResult: 'Afstemt: {{applied}} · Sprunget over: {{skipped}}', + reconcileThroughUnchanged: 'Uændret: {{count}}', + reconcileDayConfirm: 'Afstem {{worker}} {{date}}? Dagen og alle dage før den bliver låst.', + reconcileNeedsSave: 'Gem ændringerne, før dagen afstemmes', + unlockFreeFirst: 'Låst, fordi {{date}} er afstemt. Lås {{date}} op først.', + unlockTypeWordPrompt: 'Afstemningen fjernes, og dagen kan redigeres igen. Skriv {{word}} for at bekræfte.', + UNLOCK: 'LÅS OP', +``` + +- [ ] **Step 2: English** + +In `i18n/enUS.ts`, directly after Task 13 Step 5's five keys and before `};`: + +```ts + // Reconciled day lock: grid, dialog footer and bulk scope (Tasks 9A-12A). + Reconcile: 'Reconcile', + lockedTooltip: 'Locked · falls before a reconciled day', + reconciledLegend: 'Reconciled · the figures for the day are final', + reconciledProvenance: 'Reconciled {{date}} at {{time}}', + reconcileHeaderTooltip: 'Reconcile through this day', + reconcileRowSkipped: 'Skipped · already reconciled further ahead', + reconcileScopeSummary: 'Reconcile through {{date}} · Workers: {{count}}', + reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', + reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', + reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', + reconcileNeedsSave: 'Save your changes before reconciling the day', + unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', + unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', + UNLOCK: 'UNLOCK', +``` + +No string mentions who may do what. Each one says what the day is, or what is about to +happen to it. + +- [ ] **Step 3: The other 24 locales, English until translated** + +Every locale file ends with a property that has a trailing comma, then `};`. This was +checked for all 26. The script inserts one block before that final `};` and is +idempotent: + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin +node - <<'EOF' +const fs = require('fs'); +const path = require('path'); +const dir = 'eform-client/src/app/plugins/modules/time-planning-pn/i18n'; +const block = ` // Reconciled day lock (Tasks 11-13A). English until translated. + 'Reconcile day': 'Reconcile day', + 'Reconcile through': 'Reconcile through', + Unlock: 'Unlock', + Reconciled: 'Reconciled', + Locked: 'Locked', + Reconcile: 'Reconcile', + lockedTooltip: 'Locked · falls before a reconciled day', + reconciledLegend: 'Reconciled · the figures for the day are final', + reconciledProvenance: 'Reconciled {{date}} at {{time}}', + reconcileHeaderTooltip: 'Reconcile through this day', + reconcileRowSkipped: 'Skipped · already reconciled further ahead', + reconcileScopeSummary: 'Reconcile through {{date}} · Workers: {{count}}', + reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', + reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', + reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', + reconcileNeedsSave: 'Save your changes before reconciling the day', + unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', + unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', + UNLOCK: 'UNLOCK', +`; +const skip = new Set(['da.ts', 'enUS.ts', 'translates.ts']); +let changed = 0; +for (const name of fs.readdirSync(dir).sort()) { + if (!name.endsWith('.ts') || skip.has(name)) continue; + const file = path.join(dir, name); + const src = fs.readFileSync(file, 'utf8'); + if (src.includes('reconciledProvenance:')) continue; + const at = src.lastIndexOf('};'); + if (at < 0) throw new Error(`${name}: no closing '};'`); + fs.writeFileSync(file, src.slice(0, at) + block + src.slice(at)); + changed++; +} +console.log(`updated ${changed} locale files`); +EOF +``` + +Expected output: `updated 24 locale files`. + +- [ ] **Step 4: Update the help-wiring contract** + +In `help/help-wiring.spec.ts`, replace `TEMPLATE_TRANSLATE_KEYS` (:44-53): + +```ts +const TEMPLATE_TRANSLATE_KEYS = [ + 'Actual', 'Auto break calculation', 'Cancel', 'CommentOffice', 'CommentWorker', 'Date range', + 'Download Excel', 'Export to payroll', 'Flex', 'Flex balance at start of day', + 'Flex balance to date', 'keyboard_tab', 'keyboard_tab_rtl', 'Needs update!', 'NettoHours', + 'NettoHours override', 'No pay rule set selected', 'PaidOutFlex', 'Pause', 'Plan hours', + 'Planned working hours', 'Reload table', 'Reset pause to recorded', 'Save', + 'Shift not stopped by user!', 'Shifts across midnight', 'Show resigned', 'Start', 'Stop', + 'Tags', 'Total breaktime', 'Total working hours', 'Use 1-minute intervals', + 'View GPS Location', 'View history', 'View Snapshot', 'Worker', 'Worktime start', + 'Worktime stop', + // Reconciled day lock (Tasks 11-12A). Every one is in all 26 locale files (Task 13A). + 'Reconcile', 'Reconcile day', 'Reconcile through', 'Unlock', + 'lockedTooltip', 'reconciledLegend', 'reconcileHeaderTooltip', 'reconcileRowSkipped', + 'reconcileScopeSummary', 'reconcileScopeSkipped', 'reconcileDayConfirm', 'reconcileNeedsSave', + 'unlockFreeFirst', 'unlockTypeWordPrompt', +]; +``` + +In the test `places an inline hint only where something conditional has just +happened`, replace the expected list (:147-149) to add the two banners from 11A Step 1: + +```ts + expect(hintIds.sort()).toEqual([ + 'dayCell.futureDisabled', 'dayCell.lockedByReconciled', 'dayCell.planHoursLimit', + 'dayCell.reconciled', 'grid.noWorkers', + ]); +``` + +- [ ] **Step 5: Verify the contract by hand** + +The spec itself runs only in CI, so the same checks are done with grep. + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin/eform-client/src/app/plugins/modules/time-planning-pn +T="components/plannings/time-plannings-container/time-plannings-container.component.html +components/plannings/time-plannings-table/time-plannings-table.component.html +components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.html" +``` + +**(a)** The literal template keys. The output must equal the new list exactly: + +```bash +grep -ohE "'[^']+'\s*\|\s*translate" $T | sed -E "s/^'([^']+)'.*/\1/" | sort -u +``` + +**(b)** No placeholder id in any template, comments included. The wiring spec reads +comments as markup (fact 9). Expect no output: + +```bash +grep -n 'helpId="…"\|data-tp-help="…"' $T +``` + +**(c)** Every literal help id or anchor in the templates has the `section.name` shape. +This catches any other placeholder written in a comment. Expect no output: + +```bash +grep -ohE '(helpId|data-tp-help)="[^"]*"' $T | grep -vE '="[a-zA-Z]+\.[a-zA-Z]+"' +``` + +**(d)** Every new key is in every locale file. Expect no output: + +```bash +for k in reconciledProvenance reconcileScopeSummary unlockTypeWordPrompt UNLOCK "'Reconcile day'" "'Reconcile through'"; do + for f in i18n/*.ts; do + [ "$(basename "$f")" = translates.ts ] && continue + grep -q "$k" "$f" || echo "MISSING $k in $f" + done +done +``` + +- [ ] **Step 6: Task 13's own commit, staged by name** (replaces Task 13 Step 6) + +Run this only if Task 13's commit has not been made yet: + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin +P=eform-client/src/app/plugins/modules/time-planning-pn +git add $P/help/help.model.ts \ + $P/help/planning-help.registry.ts \ + $P/help/i18n/da.ts \ + $P/help/i18n/enUS.ts \ + $P/i18n/da.ts \ + $P/i18n/enUS.ts +git commit -m "feat(lock): add help entries and translations for the day lock" +``` + +- [ ] **Step 7: Commit 13A, every file by name** + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin +I=eform-client/src/app/plugins/modules/time-planning-pn/i18n +git add $I/bgBG.ts $I/csCZ.ts $I/da.ts $I/deDE.ts $I/elGR.ts $I/enUS.ts \ + $I/esES.ts $I/etET.ts $I/fiFI.ts $I/frFR.ts $I/hrHR.ts $I/huHU.ts \ + $I/isIS.ts $I/itIT.ts $I/ltLT.ts $I/lvLV.ts $I/nlNL.ts $I/noNO.ts \ + $I/plPL.ts $I/ptBR.ts $I/ptPT.ts $I/roRO.ts $I/skSK.ts $I/slSL.ts \ + $I/svSE.ts $I/ukUA.ts \ + eform-client/src/app/plugins/modules/time-planning-pn/help/help-wiring.spec.ts +git status --short # expect exactly these 27 files, and translates.ts untouched +git commit -m "feat(lock): translate the day-lock strings into every locale" +``` + +--- + +## Task 14A: Shard `s` bootstrap and Task 14's spec on the shared helpers + +**Follows:** Task 14. +**Supersedes:** +- **Task 14 Step 1:** the contents of `s/reconcile-day-lock.spec.ts`. +- **Task 14 Step 3:** the directory `git add`. + +Task 14 Step 2 (the matrix entry `s`) stands. + +**Files:** +- Create: `eform-client/playwright/e2e/plugins/time-planning-pn/s/activate-plugin.spec.ts` (copied from `r/`) +- Create: `eform-client/playwright/e2e/plugins/time-planning-pn/s/assert-true.spec.ts` (copied from `r/`) +- Modify: `eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-day-lock.spec.ts` + +**Interfaces:** +- Consumes: `s/reconcile-helpers.ts` (9A) and every UI id from 9A-12A. +- Produces: a shard that loads the plugin before any spec runs. + +**Three defects in Task 14 as written.** +1. **The plugin never loads in shard `s`.** Lanes `q` and `r` run on `a`'s seed with no + seed of their own, and they first run `activate-plugin.spec.ts` and + `assert-true.spec.ts`. Alphabetical order and `workers: 1` put those two first. + Setting `EformPlugins.Status = 1` in the seed does not load the plugin. Without the + bootstrap, the first `/plannings/index` wait in every `s` spec times out. +2. **The spec uses the current week.** On a Monday, `#cell3_1` (Tuesday) is in the + future; on a Tuesday it is today. Either way I2 hides `#reconcileButton`. +3. **The spec addresses rows by position and uses the old flows.** Its rows are + `#cell3_N` ids, and it clicks reconcile and unlock once each. After 11A and 11B, + reconcile takes two clicks and keeps the dialog open, and unlock takes a word. + +- [ ] **Step 1: The bootstrap** + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin/eform-client/playwright/e2e/plugins/time-planning-pn +cp r/activate-plugin.spec.ts s/activate-plugin.spec.ts +cp r/assert-true.spec.ts s/assert-true.spec.ts +``` + +- [ ] **Step 2: Rewrite Task 14's spec on the helpers** + +`s/reconcile-day-lock.spec.ts`: + +```ts +import { test, expect } from '@playwright/test'; +import { LoginPage } from '../../../Page objects/Login.page'; +import { + closeDayWithoutChange, openDashboardLastWeek, openDay, reconcileDay, tdOf, unlockDay, workerAtRow, +} from './reconcile-helpers'; + +/** + * The end-to-end lock: reconcile, cascade, read-only, unlock. The worker is grid + * row 3 at the start and is found by name after that. Last week, so both days are + * in the past (I2) whatever weekday CI runs on. + */ +test.describe('Reconciled day lock', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:4200'); + await new LoginPage(page).login(); + await openDashboardLastWeek(page); + }); + + test('reconciling a day locks it and every earlier day for that worker', async ({ page }) => { + const worker = await workerAtRow(page, 3); + + await reconcileDay(page, worker, 1); + + // The boundary treatment, and the day before it locked but not reconciled. + await expect(tdOf(page, worker, 1)).toHaveClass(/reconciled-background/); + await expect(tdOf(page, worker, 0)).toHaveClass(/locked-background/); + await expect(tdOf(page, worker, 0)).not.toHaveClass(/reconciled-background/); + + // The locked day opens read-only, for the same worker, with no save and no unlock. + await openDay(page, worker, 0); + await expect(page.locator('#saveButton')).toHaveCount(0); + await expect(page.locator('#unlockButton')).toHaveCount(0); + await closeDayWithoutChange(page); + + // The boundary day offers unlock, and using it frees both days. + await unlockDay(page, worker, 1); + await expect(tdOf(page, worker, 1)).not.toHaveClass(/reconciled-background/); + await expect(tdOf(page, worker, 0)).not.toHaveClass(/locked-background/); + }); +}); +``` + +- [ ] **Step 3: Task 14's own commit, staged by name** (replaces Task 14 Step 3) + +Run this only if Task 14's commit has not been made yet: + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin +git add eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-day-lock.spec.ts \ + .github/workflows/dotnet-core-pr.yml \ + .github/workflows/dotnet-core-master.yml +git commit -m "test(lock): end-to-end reconcile, cascade and unlock" +``` + +- [ ] **Step 4: Commit 14A** + +```bash +cd /home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin +git add eform-client/playwright/e2e/plugins/time-planning-pn/s/activate-plugin.spec.ts \ + eform-client/playwright/e2e/plugins/time-planning-pn/s/assert-true.spec.ts \ + eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-day-lock.spec.ts +git commit -m "test(lock): bootstrap shard s and anchor the lock e2e by worker" +``` + +**Watching CI (Task 15).** +- **File order.** The `s` job runs `activate-plugin`, `assert-true`, + `reconcile-bulk-preview`, `reconcile-day-lock`, `reconcile-dialog-confirm`, + `reconcile-glyphs`, `reconcile-unlock-word`, in that order. +- **A failure can leave a worker locked.** Each later spec uses its own worker, so the + specs after it still run. +- **The canary.** The glyph spec's precondition, `#lockLegend` count 0, fails when an + earlier spec leaked a lock. A red there is a leak, not a glyph bug: fix the first + failure first. diff --git a/docs/superpowers/plans/2026-09-13-HANDOFF-reconciled-day-lock.md b/docs/superpowers/plans/2026-09-13-HANDOFF-reconciled-day-lock.md index e7898404..36225dd0 100644 --- a/docs/superpowers/plans/2026-09-13-HANDOFF-reconciled-day-lock.md +++ b/docs/superpowers/plans/2026-09-13-HANDOFF-reconciled-day-lock.md @@ -2,7 +2,11 @@ **Parked 2026-09-13. This file is the entry point — read it before the spec or the plan.** -**State:** design and plan complete and reviewed. **No implementation code written.** +**State (updated 2026-09-15):** execution under way on `feat/reconciled-day-lock-backend` +(PR #1711). "THE OPEN DECISION" section below is settled: §8.1–8.4 are all in scope, with tasks in +the plan's Addendum B. Rulings taken during execution are summarised in spec §13. + +**Original state (2026-09-13):** design and plan complete and reviewed. No implementation code written. **Branch:** `docs/reconciled-day-lock-spec` — 3 commits, **never pushed, no PR**, working tree clean. diff --git a/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md b/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md index c69d5856..1196dd42 100644 --- a/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md +++ b/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md @@ -230,9 +230,14 @@ Gap-fill **stops at the boundary**: no row creation, no recomputation for `date <= lockedThrough`. A locked period is frozen exactly as it stands. Consequence, accepted deliberately: a day inside a locked range that never had -a registration stays empty in the grid rather than materialising a blank row. -That is the correct reading — nothing was registered that day — and it is what -makes "reconciled" mean byte-stable rather than merely read-only. +a registration gets no row. Nothing was registered that day, and not creating +one is what makes "reconciled" mean byte-stable rather than merely read-only. + +**Revised during implementation (ruling F17):** the dashboard grid reads days +*by position* (`field = index`), so a missing day would shift every later day +one column left. The read model therefore carries a **non-persisted +placeholder day** (`Id = 0`) for each locked missing date, keeping one entry +per date. Nothing is written to the database; the cell renders empty. ### 6.4 Timezone @@ -371,8 +376,9 @@ this plugin has never used. The host's backend-configuration task-list is the precedent and documents two gotchas that apply verbatim: 1. mtx-grid binds `(click)="_selectRow()"` on the ``; with `[rowSelectable]` - this **clears the batch selection** when a day cell is clicked. The day cell - must call `stopRowClick($event)`. + this **clears the batch selection** when a day cell is clicked. Use mtx-grid's + `[disableRowClickSelection]` (it still emits `rowClick`), which also covers + clicks on the Name column; a per-cell `stopRowClick($event)` would not. 2. mtx-grid rebuilds its internal `SelectionModel` empty in `ngOnChanges` **without emitting** `rowSelectedChange`, so the component must re-emit an empty selection itself. @@ -473,3 +479,39 @@ None blocking. Two worth revisiting after the first release: - Whether `ReconciledBy` is wanted on the face of the record. It is currently recoverable from `PlanRegistrationVersion.UpdatedByUserId` (§3), and adding it would require the base-repo migration this design otherwise avoids. +- **Open (from implementation):** §8.1 gives locked cells a `not-allowed` + cursor, while §8.5 has them open a read-only dialog on click. Left as + written; worth a look in the browser. + +## 13. Revisions during implementation (2026-09-15) + +Rulings taken while executing the plan. Each is recorded, with its cost if +wrong, in the SDD ledger. They supersede the sections they name. + +- **§8.1–8.4 are all in scope** (user decision). Tasks for them were written + and reviewed before the frontend phase. +- **I3 and payroll (F10).** The interceptor permits a *payroll-flag-only* + write inside the lock (`TransferredToPayroll`/`TransferredToPayrollAt` plus + bookkeeping columns). Without this, exporting a reconciled period fails + midway: the file is built, some flags are set and the rest are not. This is + what §11.2's independence requires. Hours are never writable. +- **I3 is checked on the original and the current slot.** Changing a locked + row's `Date` or `SdkSitId` cannot move it out of the lock. +- **Unlock must clear both columns (I1 at the choke point).** Clearing + `Reconciled` while leaving `ReconciledAt` set is rejected. +- **Recalculation reverts, it does not just skip saves (F15).** The dashboard + recompute mutates *tracked* rows. Skipping only the save lets the next open + day's save flush a locked row. Locked days are reverted before any later + save, so the grid shows the stored, reconciled values. +- **Gap-fill placeholders (F17).** See §6.3. +- **Working-hours bulk save skips (F16).** That page posts every row in its + range, so §6.1's "localized failure" would make any range touching a + reconciled day unsaveable. The save skips locked rows, and the page shows + them read-only through its existing `IsLocked` flag. +- **More write paths to guard (F11, F12, F13). Ruled and briefed; not yet + implemented.** An audit found writes outside §5's list that reach locked + days: startup pause-id repair (unguarded, it would crash host startup), + Google Sheet pull, Excel import, the flex screen, absence approval and shift + handover, plus the service repo's sheet pull and flex catch-up. Task 5B + (plugin) and Task 7B (service repo) will make bulk re-syncs skip locked days + and give a user acting on specific days a message. From 928a69be388223323e217647d7f10036926c00da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 15 Sep 2026 16:02:34 +0200 Subject: [PATCH 12/28] fix(lock): keep startup repair, sheet pull, import, flex, absence and handover inside the lock - The startup pause-id repair skips locked rows, runs on an interceptor-guarded context, and a DayLockedException can no longer take the host down. - Sheet pull, import and the flex follow-up skip locked rows before tracking them. - Flex corrections, absence approval and handover accept are refused up front with a message that says what the day is. - CorruptedPauseIdRepairTests now runs in CI (it was in no shard). Co-Authored-By: Claude Opus 5 --- .github/workflows/dotnet-core-master.yml | 2 +- .github/workflows/dotnet-core-pr.yml | 2 +- .../AbsenceRequestServiceTests.cs | 100 ++++++++++ .../ContentHandoverServiceTests.cs | 107 ++++++++++ .../CorruptedPauseIdRepairTests.cs | 74 +++++-- ...yrollExportRemovedPlanRegistrationTests.cs | 47 ++++- .../TimePlanningFlexServiceRemovedRowTests.cs | 184 ++++++++++++++++++ .../WorkingHoursImportRemovedRowTests.cs | 101 ++++++++-- .../EformTimePlanningPlugin.cs | 26 ++- .../Helpers/CorruptedPauseIdRepair.cs | 22 ++- .../Infrastructure/Helpers/DayLockHelper.cs | 33 ++++ .../Helpers/GoogleSheetHelper.cs | 16 ++ .../AbsenceRequestService.cs | 16 ++ .../ContentHandoverService.cs | 18 ++ .../TimePlanningFlexService.cs | 29 +++ .../TimePlanningPlanningService.cs | 4 +- .../TimePlanningWorkingHoursService.cs | 19 +- 17 files changed, 754 insertions(+), 46 deletions(-) diff --git a/.github/workflows/dotnet-core-master.yml b/.github/workflows/dotnet-core-master.yml index 60789b59..bb53b746 100644 --- a/.github/workflows/dotnet-core-master.yml +++ b/.github/workflows/dotnet-core-master.yml @@ -259,7 +259,7 @@ jobs: - name: b filter: "FullyQualifiedName=TimePlanning.Pn.Test.PictureSnapshotServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.DanLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.DataLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.ConfigurationSeedDataTests" - name: c - filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockInterceptorTests|FullyQualifiedName=TimePlanning.Pn.Test.ReconcileServiceTests" + filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockInterceptorTests|FullyQualifiedName=TimePlanning.Pn.Test.ReconcileServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.CorruptedPauseIdRepairTests" - name: d filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationVersionHistoryTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetControllerTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTierRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PraktikantPayLineRoutingTests|FullyQualifiedName=TimePlanning.Pn.Test.MobileFlexRecomputeAndCascadeTests|FullyQualifiedName=TimePlanning.Pn.Test.SiteWorkerResolverTests" - name: e diff --git a/.github/workflows/dotnet-core-pr.yml b/.github/workflows/dotnet-core-pr.yml index ca6dc05f..969029ca 100644 --- a/.github/workflows/dotnet-core-pr.yml +++ b/.github/workflows/dotnet-core-pr.yml @@ -248,7 +248,7 @@ jobs: - name: b filter: "FullyQualifiedName=TimePlanning.Pn.Test.PictureSnapshotServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.DanLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.DataLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.ConfigurationSeedDataTests" - name: c - filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockInterceptorTests|FullyQualifiedName=TimePlanning.Pn.Test.ReconcileServiceTests" + filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockInterceptorTests|FullyQualifiedName=TimePlanning.Pn.Test.ReconcileServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.CorruptedPauseIdRepairTests" - name: d filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationVersionHistoryTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetControllerTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTierRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PraktikantPayLineRoutingTests|FullyQualifiedName=TimePlanning.Pn.Test.MobileFlexRecomputeAndCascadeTests|FullyQualifiedName=TimePlanning.Pn.Test.SiteWorkerResolverTests" - name: e diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/AbsenceRequestServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/AbsenceRequestServiceTests.cs index 110525ee..027ee2d4 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/AbsenceRequestServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/AbsenceRequestServiceTests.cs @@ -195,6 +195,106 @@ public async Task ApproveAsync_UpdatesPlanRegistrations_AndSetsAbsenceFlags() Assert.That(pr2.OnVacation, Is.False); } + /// + /// Approving writes the request's status BEFORE the per-day loop. Without + /// the up-front check the status is saved as Approved, the locked day's + /// save is then refused, and the request is left Approved with only some + /// of its days flagged. + /// + /// The message says what the earliest locked requested day IS: a day with + /// no registration yet, or with a plain one, is locked by the boundary; + /// the reconciled boundary day itself is reconciled. + /// + [TestCase(2, "DayIsLockedByReconciledDay", + TestName = "ApproveAsync_Fails_AndPersistsNothing_WhenTheFirstLockedDayHasNoRow")] + [TestCase(3, "DayIsLockedByReconciledDay", + TestName = "ApproveAsync_Fails_AndPersistsNothing_WhenTheFirstLockedDayIsBelowTheBoundary")] + [TestCase(4, "DayIsReconciled", + TestName = "ApproveAsync_Fails_AndPersistsNothing_WhenTheFirstLockedDayIsTheReconciledDay")] + public async Task ApproveAsync_Fails_AndPersistsNothing_WhenARequestedDayIsLocked( + int firstRequestedDayOfMarch, string expectedMessage) + { + const int sdkSitId = 11; + var firstRequestedDay = new DateTime(2024, 3, firstRequestedDayOfMarch); + var lockedDay = new DateTime(2024, 3, 3); + var boundaryDay = new DateTime(2024, 3, 4); + var openDay = new DateTime(2024, 3, 5); + + // Lock-safe order: the requested locked day exists BEFORE the later + // boundary day is reconciled. + var locked = new PlanRegistration + { + SdkSitId = sdkSitId, + Date = lockedDay, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await locked.Create(TimePlanningPnDbContext); + var boundary = new PlanRegistration + { + SdkSitId = sdkSitId, + Date = boundaryDay, + Reconciled = true, + ReconciledAt = new DateTime(2024, 3, 6, 9, 12, 0), + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await boundary.Create(TimePlanningPnDbContext); + var before = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking().Where(pr => pr.SdkSitId == sdkSitId).ToListAsync(); + + // One day per date in the range, as CreateAsync builds it, always + // ending on the open day after the boundary. 2 March has no row. + var request = new AbsenceRequest + { + RequestedBySdkSitId = sdkSitId, + DateFrom = firstRequestedDay, + DateTo = openDay, + Status = AbsenceRequestStatus.Pending, + RequestedAtUtc = DateTime.UtcNow, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await request.Create(TimePlanningPnDbContext); + for (var date = firstRequestedDay; date <= openDay; date = date.AddDays(1)) + { + await new AbsenceRequestDay + { + AbsenceRequestId = request.Id, + Date = date, + MessageId = 2, // Vacation + CreatedByUserId = 1, + UpdatedByUserId = 1 + }.Create(TimePlanningPnDbContext); + } + + var result = await _absenceRequestService.ApproveAsync(request.Id, + new AbsenceRequestDecisionModel { ManagerSdkSitId = 2, DecisionComment = "Approved" }); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo(expectedMessage)); + + // AsNoTracking: the database, not the tracked instance the service + // may have changed in memory. + var requestAfter = await TimePlanningPnDbContext.AbsenceRequests + .AsNoTracking().FirstAsync(ar => ar.Id == request.Id); + Assert.That(requestAfter.Status, Is.EqualTo(AbsenceRequestStatus.Pending)); + Assert.That(requestAfter.DecidedBySdkSitId, Is.Null); + + foreach (var id in new[] { locked.Id, boundary.Id }) + { + var after = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking().FirstAsync(pr => pr.Id == id); + Assert.That(after.OnVacation, Is.False, "a locked day must not be flagged"); + Assert.That(after.MessageId, Is.Null); + Assert.That(after.Version, Is.EqualTo(before.Single(pr => pr.Id == id).Version)); + } + + var rowCount = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking().CountAsync(pr => pr.SdkSitId == sdkSitId); + Assert.That(rowCount, Is.EqualTo(2), "no day of a refused request is written"); + } + [Test] public async Task RejectAsync_ChangesStatus_WithoutUpdatingPlanRegistrations() { diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ContentHandoverServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ContentHandoverServiceTests.cs index 9ce6dd7b..56b0edc0 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ContentHandoverServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ContentHandoverServiceTests.cs @@ -370,6 +370,113 @@ public async Task AcceptAsync_Fails_WhenTargetHasContent() Assert.That(result.Message, Is.EqualTo("TargetPlanRegistrationMustBeEmpty")); } + /// + /// The two rows belong to two workers, each with their own boundary, and + /// the receiver is persisted before the sender. Without the up-front check: + /// a locked SENDER leaves the shift copied onto the receiver and still on + /// the sender; a locked RECEIVER fails with the generic error. Either way + /// both rows and the request must stay exactly as they were. + /// + /// The message says what the blocking row IS, checked on whichever + /// worker's row blocks (the sender is the counterpart of the accepting + /// receiver): locked by a later reconciled day, or reconciled itself. + /// + [TestCase(true, false, "DayIsLockedByReconciledDay", + TestName = "AcceptAsync_Fails_AndWritesNothing_WhenTheSendersDayIsLocked")] + [TestCase(false, false, "DayIsLockedByReconciledDay", + TestName = "AcceptAsync_Fails_AndWritesNothing_WhenTheReceiversDayIsLocked")] + [TestCase(true, true, "DayIsReconciled", + TestName = "AcceptAsync_Fails_AndWritesNothing_WhenTheSendersDayIsReconciled")] + [TestCase(false, true, "DayIsReconciled", + TestName = "AcceptAsync_Fails_AndWritesNothing_WhenTheReceiversDayIsReconciled")] + public async Task AcceptAsync_Fails_AndWritesNothing_WhenEitherWorkersDayIsLocked( + bool lockSender, bool reconcileTheHandoverDay, string expectedMessage) + { + var date = new DateTime(2024, 4, 8); + + var sourcePR = new PlanRegistration + { + Date = date, + SdkSitId = 1, + PlanHours = 8, + PlanHoursInSeconds = 28800, + PlanText = "Important work", + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + var targetPR = new PlanRegistration + { + Date = date, + SdkSitId = 2, + PlanHours = 0, + PlanHoursInSeconds = 0, + PlanText = null, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + var lockedWorkersRow = lockSender ? sourcePR : targetPR; + if (reconcileTheHandoverDay) + { + // The handover day itself is the locked worker's boundary. + lockedWorkersRow.Reconciled = true; + lockedWorkersRow.ReconciledAt = new DateTime(2024, 4, 9, 9, 12, 0); + } + await sourcePR.Create(TimePlanningPnDbContext); + await targetPR.Create(TimePlanningPnDbContext); + + if (!reconcileTheHandoverDay) + { + // Lock-safe order: the handover day exists BEFORE a later day of + // the locked worker is reconciled, which locks the handover day too. + await new PlanRegistration + { + Date = date.AddDays(2), + SdkSitId = lockedWorkersRow.SdkSitId, + Reconciled = true, + ReconciledAt = new DateTime(2024, 4, 11, 9, 12, 0), + CreatedByUserId = 1, + UpdatedByUserId = 1 + }.Create(TimePlanningPnDbContext); + } + + var request = new PlanRegistrationContentHandoverRequest + { + FromSdkSitId = 1, + ToSdkSitId = 2, + Date = date, + FromPlanRegistrationId = sourcePR.Id, + ToPlanRegistrationId = targetPR.Id, + Status = HandoverRequestStatus.Pending, + RequestedAtUtc = DateTime.UtcNow, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await request.Create(TimePlanningPnDbContext); + + var result = await _contentHandoverService.AcceptAsync(request.Id, 2, + new ContentHandoverDecisionModel { DecisionComment = "Accepted" }); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo(expectedMessage)); + + // AsNoTracking: the database, not the tracked instances the service + // shares with this test. + var sourceAfter = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking().FirstAsync(pr => pr.Id == sourcePR.Id); + var targetAfter = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking().FirstAsync(pr => pr.Id == targetPR.Id); + Assert.That(sourceAfter.PlanText, Is.EqualTo("Important work")); + Assert.That(sourceAfter.PlanHoursInSeconds, Is.EqualTo(28800)); + Assert.That(sourceAfter.Version, Is.EqualTo(1), "the sender's row must not be saved"); + Assert.That(targetAfter.PlanText, Is.Null); + Assert.That(targetAfter.PlanHoursInSeconds, Is.EqualTo(0)); + Assert.That(targetAfter.Version, Is.EqualTo(1), "the receiver's row must not be saved"); + + var requestAfter = await TimePlanningPnDbContext.PlanRegistrationContentHandoverRequests + .AsNoTracking().FirstAsync(r => r.Id == request.Id); + Assert.That(requestAfter.Status, Is.EqualTo(HandoverRequestStatus.Pending)); + } + [Test] public async Task RejectAsync_ChangesStatus_WithoutMovingContent() { diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/CorruptedPauseIdRepairTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/CorruptedPauseIdRepairTests.cs index c01a3983..0c3cc5bd 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/CorruptedPauseIdRepairTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/CorruptedPauseIdRepairTests.cs @@ -14,17 +14,9 @@ namespace TimePlanning.Pn.Test; [TestFixture] public class CorruptedPauseIdRepairTests : TestBaseSetup { - [SetUp] - public async Task SetUp() - { - await base.Setup(); - } - - [TearDown] - public new async Task TearDown() - { - await base.TearDown(); - } + // No [SetUp]/[TearDown] here: TestBaseSetup's own run for every test, and + // re-declaring them made NUnit run each twice (a second drop/migrate/seed + // and a leaked context per test). // ---- B0: characterize the timestamp-preferring netto writer ---------- @@ -69,14 +61,17 @@ public async Task Repair_FixesOnlyInWindow5MinCorruptedRows() var fiveMinSite = await SeedAssignedSite(ctx, siteId: 100, useOneMinute: false); var oneMinSite = await SeedAssignedSite(ctx, siteId: 200, useOneMinute: true); + // (a)-(c) sit on consecutive in-window days: PlanRegistration has a + // unique (SdkSitId, Date, WorkflowState) index, so one site cannot + // hold two active rows on the same day. // (a) corrupted in-window: 30m real pause, Pause1Id=145 (absolute 12:00 tick). var corrupted = await SeedRow(ctx, fiveMinSite.SiteId, inWindow, pauseStart: 12, pauseStopMin: 30, pause1Id: 145, work: (8, 16)); // (b) correct in-window: 30m pause, Pause1Id=7 ((30/5)+1). - var correct = await SeedRow(ctx, fiveMinSite.SiteId, inWindow, + var correct = await SeedRow(ctx, fiveMinSite.SiteId, inWindow.AddDays(1), pauseStart: 12, pauseStopMin: 30, pause1Id: 7, work: (8, 16)); // (c) off-by-one in-window: Pause1Id=6 (min/5, missing +1) -> must be left alone. - var offByOne = await SeedRow(ctx, fiveMinSite.SiteId, inWindow, + var offByOne = await SeedRow(ctx, fiveMinSite.SiteId, inWindow.AddDays(2), pauseStart: 12, pauseStopMin: 30, pause1Id: 6, work: (8, 16)); // (d) corrupted but out of window (on/before the locked cutoff). var oldRow = await SeedRow(ctx, fiveMinSite.SiteId, locked, @@ -144,6 +139,55 @@ public async Task Repair_DoesNotWrite_WhenCorruptIdHasNoTimestamps() Assert.That((await Reload(ctx, row)).Pause1Id, Is.EqualTo(145)); // untouched } + // ---- day lock: frozen means frozen ------------------------------------- + + /// + /// The repair window is a fixed calendar cutoff, so it can reach days at + /// or below a worker's reconciled boundary. Those rows must be skipped + /// before anything mutates them. Without the skip the first locked row's + /// Update is refused by the interceptor, and Run throws: in production + /// that is host startup. + /// + [Test] + public async Task Repair_SkipsRowsAtOrBelowTheReconciledBoundary_AndRepairsTheRest() + { + var ctx = TimePlanningPnDbContext!; + var site = await SeedAssignedSite(ctx, siteId: 300, useOneMinute: false); + var windowStart = CorruptedPauseIdRepair.FirstUnlockedDate(DateTime.UtcNow.Date); + + // Lock-safe order: the row below the boundary is created BEFORE the + // boundary is reconciled, and the row above it after. All three carry + // the same repairable corruption (30m real pause, absolute tick 145). + // Near the payroll cutoff the boundary (W+1) is not in the past: it is + // in the future on the 21st and 22nd and is today on the 23rd. The lock + // itself does not check I2, so the test holds on every day. + var belowBoundary = await SeedRow(ctx, site.SiteId, windowStart, + pauseStart: 12, pauseStopMin: 30, pause1Id: 145, work: (8, 16)); + var boundary = await SeedRow(ctx, site.SiteId, windowStart.AddDays(1), + pauseStart: 12, pauseStopMin: 30, pause1Id: 145, work: (8, 16), + reconciled: true); + var aboveBoundary = await SeedRow(ctx, site.SiteId, windowStart.AddDays(2), + pauseStart: 12, pauseStopMin: 30, pause1Id: 145, work: (8, 16)); + + var belowBefore = await Reload(ctx, belowBoundary); + var boundaryBefore = await Reload(ctx, boundary); + + Assert.DoesNotThrowAsync(async () => await CorruptedPauseIdRepair.Run(ctx)); + + foreach (var (before, label) in new[] { (belowBefore, "below"), (boundaryBefore, "boundary") }) + { + var after = await Reload(ctx, before); + Assert.That(after.Pause1Id, Is.EqualTo(145), $"{label}: a locked row must not be repaired"); + Assert.That(after.NettoHoursInSeconds, Is.EqualTo(before.NettoHoursInSeconds), label); + Assert.That(after.Version, Is.EqualTo(before.Version), $"{label}: a locked row must not be saved"); + Assert.That(after.UpdatedAt, Is.EqualTo(before.UpdatedAt), label); + } + + var repaired = await Reload(ctx, aboveBoundary); + Assert.That(repaired.Pause1Id, Is.EqualTo(7), "the row above the boundary is still repaired"); + Assert.That(repaired.NettoHoursInSeconds, Is.EqualTo(27000)); + } + // ---- helpers ----------------------------------------------------------- private static async Task SeedAssignedSite( @@ -163,7 +207,7 @@ private static async Task SeedAssignedSite( private static async Task SeedRow( TimePlanningPnDbContext ctx, int sdkSitId, DateTime date, int pauseStart, int pauseStopMin, int pause1Id, (int Start, int Stop) work, - bool seedPauseTimestamps = true) + bool seedPauseTimestamps = true, bool reconciled = false) { var pr = new PlanRegistration { @@ -174,6 +218,8 @@ private static async Task SeedRow( Pause1StartedAt = seedPauseTimestamps ? date.AddHours(pauseStart) : (DateTime?)null, Pause1StoppedAt = seedPauseTimestamps ? date.AddHours(pauseStart).AddMinutes(pauseStopMin) : (DateTime?)null, Pause1Id = pause1Id, + Reconciled = reconciled, + ReconciledAt = reconciled ? new DateTime(2026, 1, 20, 9, 12, 0) : (DateTime?)null, CreatedByUserId = 1, UpdatedByUserId = 1 }; diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayrollExportRemovedPlanRegistrationTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayrollExportRemovedPlanRegistrationTests.cs index 853fbbda..1c21a814 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayrollExportRemovedPlanRegistrationTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayrollExportRemovedPlanRegistrationTests.cs @@ -50,12 +50,14 @@ public async Task SetUpTest() } private async Task SeedPlanRegistrationWithPayLine( - int sdkSitId, DateTime date, double hours, bool removed) + int sdkSitId, DateTime date, double hours, bool removed, bool reconciled = false) { var pr = new PlanRegistration { SdkSitId = sdkSitId, Date = date, + Reconciled = reconciled, + ReconciledAt = reconciled ? new DateTime(2026, 2, 2, 9, 12, 0) : (DateTime?)null, CreatedByUserId = 1, UpdatedByUserId = 1 }; @@ -131,4 +133,47 @@ public async Task ExportPayroll_TreatsRemovedPlanRegistrationPayLineAsNoData() Assert.That(result.ErrorMessage, Is.EqualTo("NoPayrollDataForPeriod"), "A removed PlanRegistration must not be exported as payable hours"); } + + /// + /// Spec §11.2 (ruling F10): reconciling a period must not block exporting + /// it. Export flags every exported row, locked or not, so without the + /// interceptor's payroll-flag exemption the first locked row's Update is + /// refused and the export fails with the file already produced. + /// + [Test] + public async Task ExportPayroll_FlagsRowsInsideAReconciledPeriod() + { + var periodStart = new DateTime(2026, 1, 1); + var periodEnd = new DateTime(2026, 1, 31); + + var settings = new PayrollIntegrationSettings + { + PayrollSystem = 1, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await settings.Create(TimePlanningPnDbContext); + + // Lock-safe order: the day below the boundary is created BEFORE the + // boundary day is reconciled. + var belowBoundary = await SeedPlanRegistrationWithPayLine( + 400, new DateTime(2026, 1, 13), 7.0, removed: false); + var boundary = await SeedPlanRegistrationWithPayLine( + 400, new DateTime(2026, 1, 14), 8.0, removed: false, reconciled: true); + + var result = await _service.ExportPayroll(periodStart, periodEnd); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + foreach (var id in new[] { belowBoundary.Id, boundary.Id }) + { + var row = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking().FirstAsync(x => x.Id == id); + Assert.That(row.TransferredToPayroll, Is.True, $"row {id} must be flagged as exported"); + Assert.That(row.TransferredToPayrollAt, Is.Not.Null); + } + + var reloadedBoundary = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking().FirstAsync(x => x.Id == boundary.Id); + Assert.That(reloadedBoundary.Reconciled, Is.True, "exporting must not unlock the day"); + } } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TimePlanningFlexServiceRemovedRowTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TimePlanningFlexServiceRemovedRowTests.cs index 1f6620fe..6846fd1b 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TimePlanningFlexServiceRemovedRowTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TimePlanningFlexServiceRemovedRowTests.cs @@ -15,6 +15,7 @@ using TimePlanning.Pn.Infrastructure.Models.Settings; using TimePlanning.Pn.Services.TimePlanningFlexService; using TimePlanning.Pn.Services.TimePlanningLocalizationService; +using AssignedSiteEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.AssignedSite; namespace TimePlanning.Pn.Test; @@ -117,4 +118,187 @@ public async Task UpdateCreate_DoesNotWriteIntoRemovedRow_CreatesFreshActiveRow( Assert.That(activeRows[0].CommentOffice, Is.EqualTo("NEW")); Assert.That(activeRows[0].PaiedOutFlex, Is.EqualTo(9)); } + + // ---- day lock ----------------------------------------------------------- + + private async Task SeedFlexRow( + int sdkSitId, DateTime date, string commentOffice, bool reconciled = false, int statusCaseId = 0) + { + var row = new PlanRegistration + { + SdkSitId = sdkSitId, + Date = date, + CommentOffice = commentOffice, + PaiedOutFlex = 1, + SumFlexEnd = 10, + StatusCaseId = statusCaseId, + Reconciled = reconciled, + ReconciledAt = reconciled ? new DateTime(2026, 1, 20, 9, 12, 0) : (DateTime?)null, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await row.Create(TimePlanningPnDbContext); + return row; + } + + private static TimePlanningFlexUpdateModel FlexEntry(int sdkSitId, DateTime date, string commentOffice) => + new() + { + Date = date, + Worker = new CommonDictionaryModel { Id = sdkSitId }, + CommentOffice = commentOffice, + CommentOfficeAll = commentOffice, + PaidOutFlex = 4, + SumFlexStart = 20 + }; + + /// + /// The office edits specific days, so a locked day in the batch refuses + /// the WHOLE batch with a message before anything is written. The open + /// entry is posted FIRST: without the guard it is saved, and then the + /// locked entry's save is refused, leaving the batch half applied and + /// answering with the generic error. + /// + /// The message says what the blocking day IS: a day below the boundary + /// is locked by it, while the reconciled boundary day is reconciled. + /// + [TestCase(false, "DayIsLockedByReconciledDay", + TestName = "UpdateCreate_RejectsTheWholeBatch_WhenAnEntryIsBelowTheBoundary")] + [TestCase(true, "DayIsReconciled", + TestName = "UpdateCreate_RejectsTheWholeBatch_WhenAnEntryIsTheReconciledDay")] + public async Task UpdateCreate_RejectsTheWholeBatch_WhenAnyEntryIsOnALockedDay( + bool postTheReconciledDay, string expectedMessage) + { + const int sdkSitId = 556; + var lockedDay = DateTime.Now.Date.AddDays(-4); + var boundaryDay = DateTime.Now.Date.AddDays(-3); + var openDay = DateTime.Now.Date.AddDays(-1); + + // Lock-safe order: the row below the boundary, then the boundary, then + // the row above it. + var locked = await SeedFlexRow(sdkSitId, lockedDay, "LOCKED-ORIG"); + var boundary = await SeedFlexRow(sdkSitId, boundaryDay, "BOUNDARY-ORIG", reconciled: true); + var open = await SeedFlexRow(sdkSitId, openDay, "OPEN-ORIG"); + // Snapshots, not the seeded instances: the service loads the same + // tracked objects, so their Version would move along with any write. + var before = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking().Where(x => x.SdkSitId == sdkSitId).ToListAsync(); + + var result = await _service.UpdateCreate( + [ + FlexEntry(sdkSitId, openDay, "NEW-OPEN"), + FlexEntry(sdkSitId, postTheReconciledDay ? boundaryDay : lockedDay, "NEW-LOCKED") + ]); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo(expectedMessage)); + + foreach (var (id, comment) in new[] + { + (locked.Id, "LOCKED-ORIG"), (boundary.Id, "BOUNDARY-ORIG"), (open.Id, "OPEN-ORIG") + }) + { + var after = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking().FirstAsync(x => x.Id == id); + Assert.That(after.CommentOffice, Is.EqualTo(comment), "nothing in a refused batch is written"); + Assert.That(after.PaiedOutFlex, Is.EqualTo(1)); + Assert.That(after.Version, Is.EqualTo(before.Single(x => x.Id == id).Version)); + } + + var rowCount = await TimePlanningPnDbContext.PlanRegistrations.CountAsync(x => x.SdkSitId == sdkSitId); + Assert.That(rowCount, Is.EqualTo(3), "a refused batch creates no rows"); + } + + /// + /// A worker whose YESTERDAY is reconciled can still get today's flex entry: + /// the batch guard checks each posted day, not whether the worker has a + /// boundary at all. The follow-up loop then walks rows dated after + /// now minus two days, which includes that reconciled yesterday. The + /// loop's own skip is pinned by the next test. + /// + [Test] + public async Task UpdateCreate_AcceptsToday_WhenYesterdayIsReconciled_AndLeavesYesterdayAlone() + { + const int sdkSitId = 557; + var yesterday = DateTime.Now.Date.AddDays(-1); + var today = DateTime.Now.Date; + + await SeedSdkSite(sdkSitId, "FlexLockSite"); + + var reconciledYesterday = await SeedFlexRow( + sdkSitId, yesterday, "YESTERDAY-ORIG", reconciled: true, statusCaseId: 42); + var yesterdayBefore = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking().FirstAsync(x => x.Id == reconciledYesterday.Id); + + var result = await _service.UpdateCreate([FlexEntry(sdkSitId, today, "NEW-TODAY")]); + + Assert.That(result.Success, Is.True, result.Message); + + var yesterdayAfter = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking().FirstAsync(x => x.Id == reconciledYesterday.Id); + Assert.That(yesterdayAfter.CommentOffice, Is.EqualTo("YESTERDAY-ORIG")); + Assert.That(yesterdayAfter.Version, Is.EqualTo(yesterdayBefore.Version)); + Assert.That(yesterdayAfter.UpdatedAt, Is.EqualTo(yesterdayBefore.UpdatedAt)); + Assert.That(yesterdayAfter.Reconciled, Is.True); + + var todayRow = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking() + .SingleAsync(x => x.SdkSitId == sdkSitId && x.Date == today + && x.WorkflowState != Constants.WorkflowStates.Removed); + Assert.That(todayRow.CommentOffice, Is.EqualTo("NEW-TODAY")); + } + + /// + /// Pins the follow-up loop's WhereOpen skip. That loop calls Update on + /// every row it loads without changing it, and PnBase.Update saves only + /// when the context has ANY pending change, bumping the row's Version and + /// UpdatedAt as it does. So an unrelated unsaved change on the shared + /// context is enough: without the skip, the reconciled yesterday is + /// loaded, its Update carries that save, the interceptor refuses the + /// bumped locked row, and UpdateCreate fails. An EMPTY batch keeps the + /// posted-day guard out of it. + /// + [Test] + public async Task UpdateCreate_FollowUpLoop_NeverLoadsAReconciledYesterday() + { + const int sdkSitId = 558; + var yesterday = DateTime.Now.Date.AddDays(-1); + + // What the loop needs to select and walk the row: StatusCaseId != 0, + // a date after now minus two days, and an SDK site with a language. + await SeedSdkSite(sdkSitId, "FlexFollowUpSite"); + var reconciledYesterday = await SeedFlexRow( + sdkSitId, yesterday, "YESTERDAY-ORIG", reconciled: true, statusCaseId: 42); + var yesterdayBefore = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking().FirstAsync(x => x.Id == reconciledYesterday.Id); + + // An unrelated pending change, tracked but deliberately NOT saved. + var unrelated = new AssignedSiteEntity { SiteId = 900, CreatedByUserId = 1, UpdatedByUserId = 1 }; + await unrelated.Create(TimePlanningPnDbContext); + unrelated.UpdatedByUserId = 2; + + var result = await _service.UpdateCreate([]); + + Assert.That(result.Success, Is.True, result.Message); + + var yesterdayAfter = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking().FirstAsync(x => x.Id == reconciledYesterday.Id); + Assert.That(yesterdayAfter.Version, Is.EqualTo(yesterdayBefore.Version)); + Assert.That(yesterdayAfter.UpdatedAt, Is.EqualTo(yesterdayBefore.UpdatedAt)); + } + + /// + /// An SDK site with a language: the follow-up loop resolves both for every + /// row it loads, as production has them. + /// + private async Task SeedSdkSite(int sdkSitId, string name) + { + var core = await GetCore(); + var sdkDbContext = core.DbContextHelper.GetDbContext(); + var language = await sdkDbContext.Languages.FirstAsync(); + await new Microting.eForm.Infrastructure.Data.Entities.Site + { + Name = name, MicrotingUid = sdkSitId, LanguageId = language.Id + }.Create(sdkDbContext); + } } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursImportRemovedRowTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursImportRemovedRowTests.cs index 8f95c1a0..9e8a483b 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursImportRemovedRowTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursImportRemovedRowTests.cs @@ -108,17 +108,9 @@ public async Task Import_SkipsRemovedRow_AndDoesNotCrashOnRemovedActivePair() await active.Create(TimePlanningPnDbContext); var activeId = active.Id; - var xlsx = BuildWorkbook(siteName, dateStr, "8", "IMPORTED"); - var file = Substitute.For(); - file.CopyToAsync(Arg.Any(), Arg.Any()) - .Returns(ci => - { - var target = ci.Arg(); - target.Write(xlsx, 0, xlsx.Length); - return Task.CompletedTask; - }); + var xlsx = BuildWorkbook(siteName, (dateStr, "8", "IMPORTED")); - var result = await _service.Import(file); + var result = await _service.Import(FormFile(xlsx)); // Post-fix: no crash, and the ACTIVE row is the import target. Assert.That(result.Success, Is.True, result.Message); @@ -140,6 +132,79 @@ public async Task Import_SkipsRemovedRow_AndDoesNotCrashOnRemovedActivePair() Assert.That(activeCount, Is.EqualTo(1), "Import must not create a duplicate active row"); } + /// + /// A bulk import skips a locked day instead of failing the whole file. + /// Without the skip the locked row is loaded, changed and saved, the + /// interceptor refuses it, and Import returns Success false with the later + /// rows never imported. + /// + /// The boundary is in the FUTURE only because Import never reaches a past + /// day (it drops dates before now minus one day, which by time of day also + /// drops yesterday), so no boundary that satisfies I2 is reachable. The + /// lock itself does not check I2; this pins the skip as defense in depth. + /// + [Test] + public async Task Import_SkipsALockedDay_AndImportsTheRest() + { + const int microtingUid = 889; + const string siteName = "ImportLockSite"; + var lockedDay = DateTime.Now.AddDays(5).Date; + var openDay = lockedDay.AddDays(1); + + var core = await _coreService.GetCore(); + var sdkDbContext = core.DbContextHelper.GetDbContext(); + await new SdkSite { Name = siteName, MicrotingUid = microtingUid }.Create(sdkDbContext); + + var boundary = new PlanRegistration + { + SdkSitId = microtingUid, + Date = lockedDay, + PlanText = "LOCKED-ORIG", + Reconciled = true, + ReconciledAt = new DateTime(2026, 1, 20, 9, 12, 0), + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await boundary.Create(TimePlanningPnDbContext); + var boundaryBefore = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking().FirstAsync(x => x.Id == boundary.Id); + + // The locked day comes first, so without the skip the import aborts + // before it ever reaches the open day. + var xlsx = BuildWorkbook(siteName, + (lockedDay.ToString("dd.MM.yyyy"), "8", "IMPORTED-LOCKED"), + (openDay.ToString("dd.MM.yyyy"), "6", "IMPORTED-OPEN")); + + var result = await _service.Import(FormFile(xlsx)); + + Assert.That(result.Success, Is.True, result.Message); + + var lockedAfter = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking().FirstAsync(x => x.Id == boundary.Id); + Assert.That(lockedAfter.PlanText, Is.EqualTo("LOCKED-ORIG"), "a locked day must not be imported into"); + Assert.That(lockedAfter.Version, Is.EqualTo(boundaryBefore.Version)); + Assert.That(lockedAfter.UpdatedAt, Is.EqualTo(boundaryBefore.UpdatedAt)); + + var openRow = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking() + .SingleAsync(x => x.SdkSitId == microtingUid && x.Date == openDay + && x.WorkflowState != Constants.WorkflowStates.Removed); + Assert.That(openRow.PlanText, Is.EqualTo("IMPORTED-OPEN"), "the open day is still imported"); + } + + private static IFormFile FormFile(byte[] xlsx) + { + var file = Substitute.For(); + file.CopyToAsync(Arg.Any(), Arg.Any()) + .Returns(ci => + { + var target = ci.Arg(); + target.Write(xlsx, 0, xlsx.Length); + return Task.CompletedTask; + }); + return file; + } + private static Cell TextCell(string reference, string value) => new Cell { CellReference = reference, @@ -153,7 +218,8 @@ public async Task Import_SkipsRemovedRow_AndDoesNotCrashOnRemovedActivePair() CellValue = new CellValue(value) }; - private static byte[] BuildWorkbook(string sheetName, string dateStr, string hours, string text) + private static byte[] BuildWorkbook( + string sheetName, params (string Date, string Hours, string Text)[] rows) { using var ms = new MemoryStream(); using (var doc = SpreadsheetDocument.Create(ms, SpreadsheetDocumentType.Workbook)) @@ -178,10 +244,15 @@ private static byte[] BuildWorkbook(string sheetName, string dateStr, string hou header.Append(TextCell("A1", "Date"), TextCell("B1", "Hours"), TextCell("C1", "Text")); sheetData.Append(header); - // Data row (RowIndex 2): A=date, B=planHours, C=planText. - var data = new Row { RowIndex = 2 }; - data.Append(TextCell("A2", dateStr), NumberCell("B2", hours), TextCell("C2", text)); - sheetData.Append(data); + // Data rows (RowIndex 2..): A=date, B=planHours, C=planText. + for (var i = 0; i < rows.Length; i++) + { + var r = i + 2; + var data = new Row { RowIndex = (uint)r }; + data.Append(TextCell($"A{r}", rows[i].Date), NumberCell($"B{r}", rows[i].Hours), + TextCell($"C{r}", rows[i].Text)); + sheetData.Append(data); + } wbPart.Workbook.Save(); } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/EformTimePlanningPlugin.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/EformTimePlanningPlugin.cs index 697eacf6..9ca817cd 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/EformTimePlanningPlugin.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/EformTimePlanningPlugin.cs @@ -203,9 +203,23 @@ public void ConfigureDbContext(IServiceCollection services, string connectionStr // Seed database SeedDatabase(connectionString); - // One-shot, idempotent repair of pauseNId corruption (last 7 days, - // 5-minute sites). Safe to run on every startup. - RepairCorruptedPauseIds(connectionString); + // One-shot, idempotent repair of pauseNId corruption on 5-minute sites, + // for rows dated after the rolling payroll cutoff + // (CorruptedPauseIdRepair.FirstUnlockedDate). Safe to run on every startup. + try + { + RepairCorruptedPauseIds(connectionString); + } + catch (DayLockedException ex) + { + // The repair skips locked days itself, so reaching this means that + // skip has a bug. The interceptor has already refused the write, + // and a skip bug in a one-shot repair must never take the whole + // host down, so report it and keep starting. Any other exception + // keeps its existing behaviour. + Console.WriteLine($"[CorruptedPauseIdRepair] stopped by the day lock, startup continues: {ex.Message}"); + SentrySdk.CaptureException(ex); + } } public void Configure(IApplicationBuilder appBuilder) @@ -916,8 +930,10 @@ public void SeedDatabase(string connectionString) public void RepairCorruptedPauseIds(string connectionString) { - var contextFactory = new TimePlanningPnContextFactory(); - using var dbContext = contextFactory.CreateDbContext([connectionString]); + // Not TimePlanningPnContextFactory: that context has no day-lock + // interceptor, and this repair writes PlanRegistration rows. The + // helper builds the same options with the interceptor attached. + using var dbContext = new TimePlanningDbContextHelper(connectionString).GetDbContext(); CorruptedPauseIdRepair.Run(dbContext).GetAwaiter().GetResult(); } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/CorruptedPauseIdRepair.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/CorruptedPauseIdRepair.cs index ba345569..52e5c304 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/CorruptedPauseIdRepair.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/CorruptedPauseIdRepair.cs @@ -16,7 +16,9 @@ namespace TimePlanning.Pn.Infrastructure.Helpers; /// after the rolling payroll-lock cutoff, see FirstUnlockedDate) and to /// 5-minute sites. Recomputes pauseNId from the intact pause timestamps, /// writing only when a row is both clearly corrupted and confidently -/// repairable. Safe to run on every startup. +/// repairable. Safe to run on every startup. That payroll cutoff is not the +/// reconciled day lock: rows at or before a worker's reconciled boundary are +/// skipped as well, see Run. /// /// Netto consistency: on 5-minute (flag-off) sites the mobile save path /// (TimePlanningPlanningService.UpdateByCurrentUserNam) persists netto via the @@ -90,15 +92,31 @@ public static async Task Run(TimePlanningPnDbContext dbContext) .ToListAsync() .ConfigureAwait(false); + // The window is a fixed calendar cutoff, which can sit BEFORE a + // worker's reconciled boundary, so it reaches locked days. Frozen means + // frozen: a locked row is skipped before anything mutates it. It stays + // tracked but Unchanged, so no later save in this run can flush it. + // One query for every site in the scan, never one per row. + var lockedThroughBySite = await DayLockHelper + .LockedThroughForSitesAsync(dbContext, fiveMinuteSiteIds) + .ConfigureAwait(false); + // Observability counters (steps 1-3). They do not influence detection // or correction behaviour in any way. var rowsScanned = 0; var rowsCorrected = 0; var slotsCorrected = 0; var anomaliesFlagged = 0; + var rowsLocked = 0; foreach (var pr in rows) { + if (DayLockHelper.IsLocked(lockedThroughBySite, pr.SdkSitId, pr.Date)) + { + rowsLocked++; + continue; + } + rowsScanned++; // Worked span of shift 1, used only to gauge whether an @@ -156,7 +174,7 @@ public static async Task Run(TimePlanningPnDbContext dbContext) } // Step 3: run summary. - Console.WriteLine($"[CorruptedPauseIdRepair] summary: scanned {rowsScanned} rows, corrected {rowsCorrected} rows ({slotsCorrected} slots), flagged {anomaliesFlagged} anomalies."); + Console.WriteLine($"[CorruptedPauseIdRepair] summary: scanned {rowsScanned} rows, corrected {rowsCorrected} rows ({slotsCorrected} slots), flagged {anomaliesFlagged} anomalies, skipped {rowsLocked} locked rows."); } /// diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs index c1a5abdb..7f9b2487 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs @@ -66,6 +66,15 @@ public static class DayLockHelper public static bool IsLocked(DateTime? lockedThrough, DateTime date) => lockedThrough.HasValue && date.Date <= lockedThrough.Value.Date; + /// + /// against a boundary map from + /// . A site missing from the map + /// has no boundary, so its days are open. + /// + public static bool IsLocked( + IReadOnlyDictionary lockedThroughBySite, int sdkSitId, DateTime date) + => IsLocked(lockedThroughBySite.GetValueOrDefault(sdkSitId), date); + /// /// The rows NOT locked by , as a filter the /// database runs. Exactly equivalent to !IsLocked(lockedThrough, x.Date), @@ -87,6 +96,30 @@ public static IQueryable WhereOpen( return query.Where(x => x.Date >= firstOpenDay); } + /// + /// The message key for a write the lock refuses. It states what the + /// blocking day IS: reconciled itself, or locked by a later reconciled day. + /// One rule for every path, so web and mobile say the same (spec §11.3). + /// + public static string LockedMessageKey(bool blockingRowIsReconciled) + => blockingRowIsReconciled ? "DayIsReconciled" : "DayIsLockedByReconciledDay"; + + /// + /// for a locked day whose row is not loaded, + /// or does not exist (then the day is only locked). One cheap query, so + /// call it only once the day is known to be locked. + /// + public static async Task LockedMessageKeyAsync( + TimePlanningPnDbContext db, int sdkSitId, DateTime date) + { + var day = date.Date; + var reconciled = await db.PlanRegistrations + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .AnyAsync(x => x.SdkSitId == sdkSitId && x.Date == day && x.Reconciled) + .ConfigureAwait(false); + return LockedMessageKey(reconciled); + } + /// /// Invariant I2: today and future days must stay open so time can still be /// registered. This is also what makes the forward flex cascades unable to diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/GoogleSheetHelper.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/GoogleSheetHelper.cs index 54a8a3fd..a62e6997 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/GoogleSheetHelper.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/GoogleSheetHelper.cs @@ -258,6 +258,14 @@ public static async Task PullEverythingFromGoogleSheet(Core core, TimePlanningPn await OneMinuteModeTimeline.BuildAsync(dbContext, mappedAssignedSite); } + // This is a bulk re-sync over the sheet's whole history, so a locked + // day is skipped, never rejected: frozen means frozen. ONE query for + // every mapped site, built before the row loop and never per row. + // The timeline keys ARE the mapped sites: one per distinct non-null + // MicrotingUid in columnSiteMap. + var lockedThroughBySite = await DayLockHelper.LockedThroughForSitesAsync( + dbContext, oneMinuteTimelines.Keys.ToList()); + // Skip the header row (first row) for (var i = 1; i < values.Count; i++) { @@ -287,6 +295,14 @@ public static async Task PullEverythingFromGoogleSheet(Core core, TimePlanningPn continue; } + // Decided before the row is even loaded, so a locked row is + // never tracked and no later save in this loop can flush it. + if (site.MicrotingUid is { } lockSiteUid + && DayLockHelper.IsLocked(lockedThroughBySite, lockSiteUid, dateValue)) + { + continue; + } + Console.WriteLine($"Processing site: {site.Name}"); var planHours = row.Count > j ? row[j].ToString() : string.Empty; diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/AbsenceRequestService/AbsenceRequestService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/AbsenceRequestService/AbsenceRequestService.cs index 92cc2fdc..7f7e7853 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/AbsenceRequestService/AbsenceRequestService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/AbsenceRequestService/AbsenceRequestService.cs @@ -239,6 +239,22 @@ public async Task ApproveAsync(int absenceRequestId, AbsenceReq return new OperationResult(false, _localizationService.GetString("AbsenceRequestMustBePending")); } + // Checked before anything is persisted: the status below is saved + // BEFORE the per-day loop, so letting the lock refuse a day + // part-way through would leave the request Approved with only some + // of its days flagged. One boundary query for the worker. The + // earliest locked day blocks; the message says what that day is. + var lockedThrough = await DayLockHelper.LockedThroughAsync(_dbContext, request.RequestedBySdkSitId); + var blockingDay = request.Days! + .OrderBy(day => day.Date) + .FirstOrDefault(day => DayLockHelper.IsLocked(lockedThrough, day.Date)); + if (blockingDay != null) + { + return new OperationResult(false, _localizationService.GetString( + await DayLockHelper.LockedMessageKeyAsync( + _dbContext, request.RequestedBySdkSitId, blockingDay.Date))); + } + // Apply changes without an explicit transaction. // NOTE: Update() methods internally handle persistence, and using // an explicit BeginTransactionAsync here was causing a silent diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/ContentHandoverService/ContentHandoverService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/ContentHandoverService/ContentHandoverService.cs index 7fffcc67..8b7d25f8 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/ContentHandoverService/ContentHandoverService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/ContentHandoverService/ContentHandoverService.cs @@ -644,6 +644,24 @@ public async Task AcceptAsync( "[Handover] Accept request {RequestId}: loaded fromPR {FromPRId} (sdkSitId={FromSdkSitId}) and toPR {ToPRId} (sdkSitId={ToSdkSitId}), shiftIndex={ShiftIndex}", requestId, fromPR.Id, fromPR.SdkSitId, toPR.Id, toPR.SdkSitId, request.ShiftIndex); + // Two different workers, each with their own boundary. Checked + // before either row is touched: the receiver is persisted before + // the sender, so a lock refusing the sender afterwards would leave + // the shift on both days. Checked in that persist order, and the + // message says what the blocking row is. + var boundaries = await DayLockHelper.LockedThroughForSitesAsync( + _dbContext, [fromPR.SdkSitId, toPR.SdkSitId]); + var blockingRow = new[] { toPR, fromPR }.FirstOrDefault(pr => + DayLockHelper.IsLocked(boundaries, pr.SdkSitId, pr.Date)); + if (blockingRow != null) + { + _logger.LogWarning( + "[Handover] Accept request {RequestId}: rejected — PR {BlockingPRId} (sdkSitId={BlockingSdkSitId}) is on a locked day", + requestId, blockingRow.Id, blockingRow.SdkSitId); + return new OperationResult(false, _localizationService.GetString( + DayLockHelper.LockedMessageKey(blockingRow.Reconciled))); + } + // Resolve AssignedSites once (used by recalc helpers in both paths). var fromAssignedSite = await _dbContext.AssignedSites .FirstOrDefaultAsync(a => a.SiteId == fromPR.SdkSitId diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningFlexService/TimePlanningFlexService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningFlexService/TimePlanningFlexService.cs index efda3848..01636396 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningFlexService/TimePlanningFlexService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningFlexService/TimePlanningFlexService.cs @@ -147,6 +147,28 @@ public async Task UpdateCreate(List x.Worker?.Id) + .OfType() + .Distinct() + .ToList(); + var postedBoundaries = await DayLockHelper.LockedThroughForSitesAsync(dbContext, postedSiteIds); + // The first locked entry in posted order is the one that would + // have blocked a row-by-row save; the message says what that day is. + var blocking = model.FirstOrDefault(x => x.Worker?.Id is { } siteId + && DayLockHelper.IsLocked(postedBoundaries, siteId, x.Date)); + if (blocking != null) + { + return new OperationResult( + false, + localizationService.GetString(await DayLockHelper.LockedMessageKeyAsync( + dbContext, blocking.Worker.Id.Value, blocking.Date))); + } + foreach (var updateModel in model) { var planRegistration = await dbContext.PlanRegistrations @@ -174,6 +196,12 @@ public async Task UpdateCreate(List UpdateCreate(List x.StatusCaseId != 0) .Where(x => x.Date > DateTime.Now.AddDays(-2)) .Where(x => x.SdkSitId == listSiteId) + .WhereOpen(followUpBoundaries.GetValueOrDefault(listSiteId)) .ToListAsync(); foreach (PlanRegistration planRegistration in plannings) diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs index f002e6d4..08c4c96d 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs @@ -2313,7 +2313,7 @@ private void CompareBoolField(List changes, string fieldName, bool? var lockedThrough = await DayLockHelper.LockedThroughAsync(dbContext, planning.SdkSitId); return DayLockHelper.IsLocked(lockedThrough, planning.Date) ? new OperationResult(false, localizationService.GetString( - planning.Reconciled ? "DayIsReconciled" : "DayIsLockedByReconciledDay")) + DayLockHelper.LockedMessageKey(planning.Reconciled))) : null; } @@ -2516,7 +2516,7 @@ public async Task> ReconcileThr { // Already at or past the target: moving the boundary BACK would // be an unlock, which is deliberately a separate, heavier action. - if (DayLockHelper.IsLocked(boundaries.GetValueOrDefault(siteId), target)) + if (DayLockHelper.IsLocked(boundaries, siteId, target)) { result.SkippedAlreadyFurtherForward.Add(siteId); continue; diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs index 11ad6f9a..ec463a48 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs @@ -1380,12 +1380,8 @@ private static void ApplyPunchClockFlexChainDecimal( return null; } - var day = date.Date; - var reconciled = await dbContext.PlanRegistrations - .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) - .AnyAsync(x => x.SdkSitId == siteId && x.Date == day && x.Reconciled); return new OperationResult(false, localizationService.GetString( - reconciled ? "DayIsReconciled" : "DayIsLockedByReconciledDay")); + await DayLockHelper.LockedMessageKeyAsync(dbContext, siteId, date))); } public async Task UpdateWorkingHour(TimePlanningWorkingHoursUpdateModel model) @@ -4037,6 +4033,12 @@ public async Task Import(IFormFile file) .FirstOrDefaultAsync(x => x.SiteId == site.MicrotingUid); var importTimeline = await OneMinuteModeTimeline.BuildAsync(dbContext, importAssignedSite); + // A bulk import skips locked days (frozen means frozen) + // rather than failing the whole file. Once per sheet + // (= per site), never per row. + var importLockedThrough = site.MicrotingUid is { } importSiteUid + ? await DayLockHelper.LockedThroughAsync(dbContext, importSiteUid) + : null; var worksheetPart = (WorksheetPart)workbookPart.GetPartById(sheet.Id.Value); var sheetData = worksheetPart.Worksheet.Elements().First(); @@ -4090,6 +4092,13 @@ public async Task Import(IFormFile file) continue; } + // Before the row is loaded, so a locked row is never + // tracked and no later save can flush a change into it. + if (DayLockHelper.IsLocked(importLockedThrough, dateValue)) + { + continue; + } + var preTimePlanning = await dbContext.PlanRegistrations.AsNoTracking() .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) .Where(x => x.Date < dateValue && x.SdkSitId == (int)site.MicrotingUid!) From d1961c5abc5828c9ada0ea8622a3c671d07c37fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 15 Sep 2026 17:13:18 +0200 Subject: [PATCH 13/28] feat(lock): send the reconciled state and boundary to the client Each day carries Reconciled/ReconciledAt, and each row carries LockedThrough, set even when the worker has no rows in the window. Co-Authored-By: Claude Opus 5 --- .../ReconcileServiceTests.cs | 77 +++++++++++++++++++ .../Helpers/PlanRegistrationHelper.cs | 6 ++ .../Planning/TimePlanningPlanningModel.cs | 6 ++ .../TimePlanningPlanningPrDayModel.cs | 9 +++ 4 files changed, 98 insertions(+) diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs index c293dc16..a57bcbff 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs @@ -973,4 +973,81 @@ public async Task IndexByCurrentUserName_OverALockedRange_Succeeds() "the missing locked dates are placeholders on the mobile path too"); }); } + + // --------------------------------------------------------------------- + // Task 6: the read model carries the lock state -- the row-level boundary + // once per site, and Reconciled/ReconciledAt per day, so the client can + // render locked and reconciled days without scanning cells. + // --------------------------------------------------------------------- + + [Test] + public async Task Index_ProjectsReconciledStateOntoTheReadModel() + { + await using var baseDbContext = GetBaseDbContext(); + var svc = await BuildAdminIndexServiceAsync(baseDbContext); + await SeedAssignedSiteAsync(912); + // Locked by derivation only -- earlier than the boundary, never itself + // marked Reconciled. + var earlier = await SeedPlain(912, DateTime.Now.Date.AddDays(-6)); + var boundary = await SeedReconciledBoundaryAsync(912, DateTime.Now.Date.AddDays(-4)); + var window = new TimePlanningPlanningRequestModel + { + DateFrom = DateTime.Now.Date.AddDays(-6), + DateTo = DateTime.Now.Date + }; + + var result = await svc.Index(window); + + Assert.That(result.Success, Is.True, result.Message); + var siteRow = result.Model.Single(x => x.SiteId == 912); + var boundaryDay = siteRow.PlanningPrDayModels.Single(d => d.Date.Date == boundary.Date.Date); + var earlierDay = siteRow.PlanningPrDayModels.Single(d => d.Date.Date == earlier.Date.Date); + Assert.Multiple(() => + { + Assert.That(siteRow.LockedThrough, Is.EqualTo(boundary.Date), + "the client needs the boundary once per row, not per cell"); + Assert.That(boundaryDay.Reconciled, Is.True); + Assert.That(boundaryDay.ReconciledAt, Is.Not.Null); + Assert.That(earlierDay.Reconciled, Is.False, + "locked by derivation, not individually marked -- LockedThrough covers it"); + }); + } + + /// + /// The trap the brief names: UpdatePlanRegistrationsInPeriod's per-day loop + /// runs over planningsInPeriod, and a window that is entirely locked with + /// no existing rows leaves that list empty for the whole call -- the loop + /// never executes once. LockedThrough must still be set, because it is + /// resolved unconditionally before the loop, not inside it. + /// + [Test] + public async Task Index_WhenTheEntireWindowIsLockedWithNoRows_StillReturnsLockedThrough() + { + await using var baseDbContext = GetBaseDbContext(); + var svc = await BuildAdminIndexServiceAsync(baseDbContext); + await SeedAssignedSiteAsync(914); + var boundary = await SeedReconciledBoundaryAsync(914, DateTime.Now.Date.AddDays(-5)); + // Entirely before the boundary, so every day here is locked -- and + // gap-fill must not create rows inside a frozen period, so this + // window has zero PlanRegistrations of its own. + var window = new TimePlanningPlanningRequestModel + { + DateFrom = DateTime.Now.Date.AddDays(-20), + DateTo = DateTime.Now.Date.AddDays(-15) + }; + + var result = await svc.Index(window); + + Assert.That(result.Success, Is.True, result.Message); + var siteRow = result.Model.Single(x => x.SiteId == 914); + var rowsInWindow = await TimePlanningPnDbContext!.PlanRegistrations + .CountAsync(x => x.SdkSitId == 914 && x.Date >= window.DateFrom && x.Date <= window.DateTo); + Assert.Multiple(() => + { + Assert.That(rowsInWindow, Is.Zero, "the window must genuinely have no rows of its own"); + Assert.That(siteRow.LockedThrough, Is.EqualTo(boundary.Date), + "a fully-locked window with no rows must still carry the boundary, " + + "or the client renders an entirely locked worker as fully editable"); + }); + } } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs index c22d7bdf..5a934c36 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs @@ -424,6 +424,10 @@ public static async Task UpdatePlanRegistrationsInPer // skipped rather than raising. Without this, the interceptor turns // every visit to a closed month into a 500. var lockedThrough = await DayLockHelper.LockedThroughAsync(dbContext, dbAssignedSite.SiteId); + // Set once, here, before the per-day loop: a worker with NO rows in + // the window never enters that loop at all, and would otherwise leave + // LockedThrough null — rendering a fully-locked month as editable. + siteModel.LockedThrough = lockedThrough; foreach (var plan in planningsInPeriod) { var planRegistration = await dbContext.PlanRegistrations.AsTracking().FirstAsync(x => x.Id == plan.Id); @@ -1130,6 +1134,8 @@ await dbContext.PlanRegistrations.AsNoTracking() planningModel.IsDoubleShift = planningModel.Start2StartedAt != planningModel.Stop2StoppedAt; planningModel.NettoHoursOverride = planRegistration.NettoHoursOverride; planningModel.NettoHoursOverrideActive = planRegistration.NettoHoursOverrideActive; + planningModel.Reconciled = planRegistration.Reconciled; + planningModel.ReconciledAt = planRegistration.ReconciledAt; // Approach C READ projection: for any shift with a pause override, // present a single synthesized pause pair (sum = override) and empty diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/TimePlanningPlanningModel.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/TimePlanningPlanningModel.cs index e753b1d9..63bd5ac6 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/TimePlanningPlanningModel.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/TimePlanningPlanningModel.cs @@ -22,6 +22,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +using System; using System.Collections.Generic; using Microting.eFormApi.BasePn.Infrastructure.Models.Common; @@ -75,5 +76,10 @@ public class TimePlanningPlanningModel public bool ThirdShiftActive { get; set; } public bool FourthShiftActive { get; set; } public bool FifthShiftActive { get; set; } + /// The worker's newest reconciled day -- every day at or before this + /// is locked, and only this day can be unlocked. Null when nothing is + /// reconciled. Sent once per row so the client compares dates instead of + /// scanning cells. + public DateTime? LockedThrough { get; set; } public List PlanningPrDayModels { get; set; } } \ No newline at end of file diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/TimePlanningPlanningPrDayModel.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/TimePlanningPlanningPrDayModel.cs index f4bbd34f..73a5e887 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/TimePlanningPlanningPrDayModel.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/TimePlanningPlanningPrDayModel.cs @@ -234,4 +234,13 @@ public class TimePlanningPlanningPrDayModel public DateTime? Pause5StoppedAt { get; set; } public double NettoHoursOverride { get; set; } public bool NettoHoursOverrideActive { get; set; } + + /// The stored mark: true on every day the worker reconciled (there + /// can be several). Days before the newest mark are locked without being + /// marked. Whether a day is locked, and which day can be unlocked, comes + /// from LockedThrough on the row, never from this flag alone. + public bool Reconciled { get; set; } + + /// When this day was reconciled (server local time); null when not reconciled. + public DateTime? ReconciledAt { get; set; } } \ No newline at end of file From 7d2d7e341ca2fb7e9861a969fbe1cb2c5d4b715d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 15 Sep 2026 18:01:14 +0200 Subject: [PATCH 14/28] fix(lock): close the final-review gaps before merge - a test proves both production context paths attach the lock; - unlock refusals name the day to free first; - create-time lock checks for absence and handover requests; - lock refusals are no longer mislabelled or swallowed; - self-contained comments, twin headers, and skip counts logged. Co-Authored-By: Claude Opus 5 --- .github/workflows/dotnet-core-master.yml | 2 +- .github/workflows/dotnet-core-pr.yml | 2 +- .../AbsenceRequestServiceTests.cs | 41 ++++++++++ .../ContentHandoverServiceTests.cs | 67 +++++++++++++++++ .../DayLockHelperTests.cs | 12 +-- .../DayLockInterceptorTests.cs | 11 +-- .../DayLockWiringTests.cs | 74 +++++++++++++++++++ ...yrollExportRemovedPlanRegistrationTests.cs | 2 +- .../ReconcileServiceTests.cs | 63 +++++++++++----- .../TimePlanning.Pn.Test/TestBaseSetup.cs | 13 +++- .../EformTimePlanningPlugin.cs | 29 ++++++-- .../Infrastructure/Helpers/DayLockHelper.cs | 6 ++ .../Helpers/GoogleSheetHelper.cs | 5 ++ .../Helpers/PlanRegistrationHelper.cs | 8 +- .../ReconciledDayLockInterceptor.cs | 24 +++++- .../Resources/Translations.Designer.cs | 6 ++ .../Resources/Translations.da.resx | 5 +- .../Resources/Translations.resx | 5 +- .../AbsenceRequestService.cs | 12 +++ .../ContentHandoverService.cs | 36 ++++++--- .../Services/RebusService/RebusService.cs | 10 +-- .../TimePlanningPlanningService.cs | 33 ++++++--- .../TimePlanningWorkingHoursService.cs | 19 ++++- 23 files changed, 407 insertions(+), 78 deletions(-) create mode 100644 eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockWiringTests.cs diff --git a/.github/workflows/dotnet-core-master.yml b/.github/workflows/dotnet-core-master.yml index bb53b746..7dd7fbf9 100644 --- a/.github/workflows/dotnet-core-master.yml +++ b/.github/workflows/dotnet-core-master.yml @@ -259,7 +259,7 @@ jobs: - name: b filter: "FullyQualifiedName=TimePlanning.Pn.Test.PictureSnapshotServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.DanLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.DataLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.ConfigurationSeedDataTests" - name: c - filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockInterceptorTests|FullyQualifiedName=TimePlanning.Pn.Test.ReconcileServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.CorruptedPauseIdRepairTests" + filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockInterceptorTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockWiringTests|FullyQualifiedName=TimePlanning.Pn.Test.ReconcileServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.CorruptedPauseIdRepairTests" - name: d filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationVersionHistoryTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetControllerTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTierRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PraktikantPayLineRoutingTests|FullyQualifiedName=TimePlanning.Pn.Test.MobileFlexRecomputeAndCascadeTests|FullyQualifiedName=TimePlanning.Pn.Test.SiteWorkerResolverTests" - name: e diff --git a/.github/workflows/dotnet-core-pr.yml b/.github/workflows/dotnet-core-pr.yml index 969029ca..bbc4a5e1 100644 --- a/.github/workflows/dotnet-core-pr.yml +++ b/.github/workflows/dotnet-core-pr.yml @@ -248,7 +248,7 @@ jobs: - name: b filter: "FullyQualifiedName=TimePlanning.Pn.Test.PictureSnapshotServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.DanLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.DataLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.ConfigurationSeedDataTests" - name: c - filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockInterceptorTests|FullyQualifiedName=TimePlanning.Pn.Test.ReconcileServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.CorruptedPauseIdRepairTests" + filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockInterceptorTests|FullyQualifiedName=TimePlanning.Pn.Test.DayLockWiringTests|FullyQualifiedName=TimePlanning.Pn.Test.ReconcileServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.CorruptedPauseIdRepairTests" - name: d filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationVersionHistoryTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetControllerTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTierRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PraktikantPayLineRoutingTests|FullyQualifiedName=TimePlanning.Pn.Test.MobileFlexRecomputeAndCascadeTests|FullyQualifiedName=TimePlanning.Pn.Test.SiteWorkerResolverTests" - name: e diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/AbsenceRequestServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/AbsenceRequestServiceTests.cs index 027ee2d4..02774a3e 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/AbsenceRequestServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/AbsenceRequestServiceTests.cs @@ -116,6 +116,47 @@ public async Task CreateAsync_RejectsOverlappingPendingRequest() Assert.That(result.Message, Is.EqualTo("OverlappingAbsenceRequestExists")); } + /// + /// A request touching a locked day could never be approved, so it must + /// not be created and left pending. The range runs past the boundary into + /// an open day, so only the lock check can refuse it. The message says + /// what the first requested day IS: locked by the boundary (no row of its + /// own), or the reconciled boundary day itself. + /// + [TestCase(3, "DayIsLockedByReconciledDay", + TestName = "CreateAsync_RefusesAndCreatesNothing_WhenTheFirstDayIsBelowTheBoundary")] + [TestCase(4, "DayIsReconciled", + TestName = "CreateAsync_RefusesAndCreatesNothing_WhenTheFirstDayIsTheReconciledDay")] + public async Task CreateAsync_RefusesAndCreatesNothing_WhenTheFirstRequestedDayIsLocked( + int firstRequestedDayOfMarch, string expectedMessage) + { + const int sdkSitId = 12; + await new PlanRegistration + { + SdkSitId = sdkSitId, + Date = new DateTime(2024, 3, 4), + Reconciled = true, + ReconciledAt = new DateTime(2024, 3, 6, 9, 12, 0), + CreatedByUserId = 1, + UpdatedByUserId = 1 + }.Create(TimePlanningPnDbContext); + + var result = await _absenceRequestService.CreateAsync(new AbsenceRequestCreateModel + { + RequestedBySdkSitId = sdkSitId, + DateFrom = new DateTime(2024, 3, firstRequestedDayOfMarch), + DateTo = new DateTime(2024, 3, 6), + MessageId = 2, // Vacation + RequestComment = "Spans the boundary" + }); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo(expectedMessage)); + Assert.That(await TimePlanningPnDbContext.AbsenceRequests.AsNoTracking().CountAsync(), Is.Zero, + "a request that can never be approved must not be created"); + Assert.That(await TimePlanningPnDbContext.AbsenceRequestDays.AsNoTracking().CountAsync(), Is.Zero); + } + [Test] public async Task ApproveAsync_UpdatesPlanRegistrations_AndSetsAbsenceFlags() { diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ContentHandoverServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ContentHandoverServiceTests.cs index 56b0edc0..229b32eb 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ContentHandoverServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ContentHandoverServiceTests.cs @@ -244,6 +244,73 @@ public async Task CreateAsync_CreatesHandoverRequest_WhenSourceHasOnlyDoublePlan Assert.That(result.Model[0].ShiftIndex, Is.Null); } + /// + /// A handover touching a locked day could never be accepted, so it must + /// not be created and left pending. Otherwise a valid full-day request: + /// the source has content and the target is empty. The message says what + /// the blocking row IS, on whichever worker's row blocks: locked by a + /// later reconciled day, or reconciled itself. + /// + [TestCase(true, false, "DayIsLockedByReconciledDay", + TestName = "CreateAsync_RefusesAndCreatesNothing_WhenTheSendersDayIsLocked")] + [TestCase(false, true, "DayIsReconciled", + TestName = "CreateAsync_RefusesAndCreatesNothing_WhenTheReceiversDayIsReconciled")] + public async Task CreateAsync_RefusesAndCreatesNothing_WhenEitherWorkersDayIsLocked( + bool lockSender, bool reconcileTheHandoverDay, string expectedMessage) + { + var date = new DateTime(2024, 4, 8); + var sourcePR = new PlanRegistration + { + Date = date, + SdkSitId = 1, + PlanHoursInSeconds = 28800, + PlanText = "Important work", + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + var targetPR = new PlanRegistration + { + Date = date, + SdkSitId = 2, + PlanHoursInSeconds = 0, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + var lockedWorkersRow = lockSender ? sourcePR : targetPR; + if (reconcileTheHandoverDay) + { + // The handover day itself is the locked worker's boundary. + lockedWorkersRow.Reconciled = true; + lockedWorkersRow.ReconciledAt = new DateTime(2024, 4, 9, 9, 12, 0); + } + await sourcePR.Create(TimePlanningPnDbContext); + await targetPR.Create(TimePlanningPnDbContext); + + if (!reconcileTheHandoverDay) + { + // Lock-safe order: the handover day exists BEFORE a later day of + // the locked worker is reconciled, which locks the handover day too. + await new PlanRegistration + { + Date = date.AddDays(2), + SdkSitId = lockedWorkersRow.SdkSitId, + Reconciled = true, + ReconciledAt = new DateTime(2024, 4, 11, 9, 12, 0), + CreatedByUserId = 1, + UpdatedByUserId = 1 + }.Create(TimePlanningPnDbContext); + } + + var result = await _contentHandoverService.CreateAsync(sourcePR.Id, + new ContentHandoverRequestCreateModel { ToSdkSitId = 2, RequestComment = "Locked day" }); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo(expectedMessage)); + Assert.That(await TimePlanningPnDbContext.PlanRegistrationContentHandoverRequests + .AsNoTracking().CountAsync(), Is.Zero, + "a handover that can never be accepted must not be created"); + } + [Test] public async Task AcceptAsync_MovesContent_FromSourceToTarget() { diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs index a64e9847..4159fcbd 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs @@ -71,9 +71,9 @@ public async Task LockedThrough_SeveralReconciled_IsTheLatest() [Test] public async Task LockedThrough_IgnoresRemovedRows() { - // Once Task 2's interceptor exists, any write that touches a - // PlanRegistration at or before the current boundary (I3) is - // rejected. That rules out the obvious seeding -- reconcile a row, + // The fixture context carries the day-lock interceptor, so any write + // that touches a PlanRegistration at or before the current boundary + // (I3) is rejected. That rules out the obvious seeding -- reconcile a row, // then soft-delete it in a LATER, separate save -- because by the // time of that second save the boundary would already be the row // being deleted, and the delete would land ON the boundary day. @@ -85,9 +85,9 @@ public async Task LockedThrough_IgnoresRemovedRows() // it via Delete() (PnBase.Delete only ever issues one SaveChanges // when there are pending changes). At the moment that save runs, the // DB boundary is still the 12th, so the write to the 18th is above - // the boundary and permitted -- both before Task 2's interceptor - // exists and after. The row ends up Removed, so it must never - // surface as the new boundary. + // the boundary and permitted, with or without the interceptor + // attached. The row ends up Removed, so it must never surface as the + // new boundary. await Seed(702, new DateTime(2026, 1, 12), reconciled: true); await Seed(702, new DateTime(2026, 1, 18), reconciled: false); diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockInterceptorTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockInterceptorTests.cs index 08510ebf..085b3e30 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockInterceptorTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockInterceptorTests.cs @@ -10,10 +10,11 @@ namespace TimePlanning.Pn.Test; /// -/// The interceptor is the only layer that is COMPLETE -- it covers all 32 -/// PlanRegistration write sites and anything added later. These tests go -/// through a context built exactly as production builds it (interceptor -/// attached), so they prove the wiring, not just the class. +/// The interceptor is the only layer that covers every write path -- all 32 +/// PlanRegistration write sites and anything added later (see its race note +/// for the one gap). These tests prove the class's rule through the fixture +/// context, which attaches the interceptor itself; DayLockWiringTests proves +/// that production's own context builders attach it too. /// [TestFixture] public class DayLockInterceptorTests : TestBaseSetup @@ -152,7 +153,7 @@ public async Task SettingReconciled_OnTheBoundaryDay_IsAllowed() [Test] public async Task SettingTheTransferredToPayrollFlag_OnALockedDay_IsAllowed() { - // Mirrors PayrollExportService.ExportPayroll exactly (ruling F10): + // Mirrors PayrollExportService.ExportPayroll exactly (spec §11.2): // Reconciled and TransferredToPayroll are independent, so exporting a // reconciled period must not be blocked by the very lock reconciling // it created. diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockWiringTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockWiringTests.cs new file mode 100644 index 00000000..08de50c3 --- /dev/null +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockWiringTests.cs @@ -0,0 +1,74 @@ +using System; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microting.eForm.Infrastructure.Constants; +using Microting.TimePlanningBase.Infrastructure.Data; +using NUnit.Framework; +using TimePlanning.Pn.Infrastructure.Helpers; +using TimePlanning.Pn.Infrastructure.Interceptors; +using PlanRegistrationEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.PlanRegistration; + +namespace TimePlanning.Pn.Test; + +/// +/// The fixture attaches the day-lock interceptor to its own contexts, so every +/// other lock test would stay green if production stopped attaching it. These +/// tests go through production's two context builders instead: deleting the +/// interceptor from either one switches the lock off, and must fail here. +/// +[TestFixture] +public class DayLockWiringTests : TestBaseSetup +{ + [SetUp] + public async Task SetUpTest() => await base.Setup(); + + [Test] + public async Task TheContextHelper_AttachesTheLock() + { + // Seeded through the fixture context, in lock-safe order: the earlier + // row first, then the reconciled boundary that locks it. + var earlier = new PlanRegistrationEntity + { + SdkSitId = 820, Date = new DateTime(2026, 1, 13), + PlanText = "", CommentOffice = "", CommentOfficeAll = "", + WorkflowState = Constants.WorkflowStates.Created, + CreatedByUserId = 1, UpdatedByUserId = 1, + }; + await earlier.Create(TimePlanningPnDbContext!); + await new PlanRegistrationEntity + { + SdkSitId = 820, Date = new DateTime(2026, 1, 16), Reconciled = true, + ReconciledAt = new DateTime(2026, 1, 20, 9, 12, 0), + PlanText = "", CommentOffice = "", CommentOfficeAll = "", + WorkflowState = Constants.WorkflowStates.Created, + CreatedByUserId = 1, UpdatedByUserId = 1, + }.Create(TimePlanningPnDbContext!); + + await using var productionContext = + new TimePlanningDbContextHelper(PluginConnectionString).GetDbContext(); + var locked = await productionContext.PlanRegistrations + .AsTracking() + .FirstAsync(x => x.Id == earlier.Id); + locked.PlanHours = 9; + + Assert.ThrowsAsync(async () => await locked.Update(productionContext)); + } + + [Test] + public void ThePooledRegistration_AttachesTheLock() + { + // An explicit server version, so building the options opens no + // connection; production passes ServerVersion.AutoDetect here. + var builder = new DbContextOptionsBuilder(); + EformTimePlanningPlugin.ConfigureTimePlanningDbContext( + builder, PluginConnectionString, new MariaDbServerVersion(new Version(10, 5, 0))); + + // Null-safe, so a missing interceptor fails the assertion below rather + // than throwing: without AddInterceptors there may be no interceptor + // list, or no core extension at all. + var interceptors = builder.Options.FindExtension()?.Interceptors ?? []; + + Assert.That(interceptors, Does.Contain(ReconciledDayLockInterceptor.Instance)); + } +} diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayrollExportRemovedPlanRegistrationTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayrollExportRemovedPlanRegistrationTests.cs index 1c21a814..12ee52ee 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayrollExportRemovedPlanRegistrationTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayrollExportRemovedPlanRegistrationTests.cs @@ -135,7 +135,7 @@ public async Task ExportPayroll_TreatsRemovedPlanRegistrationPayLineAsNoData() } /// - /// Spec §11.2 (ruling F10): reconciling a period must not block exporting + /// Spec §11.2: reconciling a period must not block exporting /// it. Export flags every exported row, locked or not, so without the /// interceptor's payroll-flag exemption the first locked row's Update is /// refused and the export fails with the file already produced. diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs index a57bcbff..e0c05603 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; @@ -67,6 +68,10 @@ public async Task SetUpTest() _localizationService = Substitute.For(); _localizationService.GetString(Arg.Any()).Returns(x => x[0]?.ToString()); + // The format overload echoes its arguments, so a test can assert what + // a message names (e.g. the day to unlock first), not just its key. + _localizationService.GetString(Arg.Any(), Arg.Any()) + .Returns(x => x[0] + "|" + string.Join("|", (object[])x[1])); _coreService = Substitute.For(); var core = await GetCore(); @@ -170,10 +175,11 @@ private async Task BuildAdminIndexServiceAsync(Bas /// /// UpdatePlanRegistrationsInPeriod's catch-all logs and SWALLOWS whatever - /// its try throws, so a lock rejection there (a missing guard on an Update - /// inside the try) would not fail Index. It does log at Error level, which - /// this catches. Inspects ReceivedCalls, because LogError is an extension - /// method over the generic Log<TState> and cannot be matched directly. + /// its try throws, except a lock rejection, which it lets through to fail + /// Index. Anything else swallowed there would not fail Index, but it does + /// log at Error level, which this catches. Inspects ReceivedCalls, because + /// LogError is an extension method over the generic Log<TState> and + /// cannot be matched directly. /// private void AssertNoErrorLogged() { @@ -184,7 +190,8 @@ private void AssertNoErrorLogged() /// /// The dashboard grid reads days BY POSITION, so the list must hold - /// exactly one entry per date of the window, in order (ruling F17). + /// exactly one entry per date of the window, in order (spec §6.3); a + /// missing entry would shift every later day one column left. /// private static void AssertOneDayPerDate( IReadOnlyList days, TimePlanningPlanningRequestModel window) @@ -232,7 +239,8 @@ private async Task SeedReconciledBoundaryAsync(int siteUid, Da /// /// Everything Index() needs to actually process a site: the SDK Site the /// row takes its name from and the plugin AssignedSite it iterates. Without - /// both, Index() skips the site and a lock test passes vacuously (ruling F7). + /// both, Index() skips the site, so a lock test would pass without ever + /// exercising the lock. /// private async Task SeedAssignedSiteAsync(int siteUid, bool useGoogleSheetAsDefault = false) { @@ -285,8 +293,9 @@ private async Task SeedCurrentUserWorkerAsync(SdkSite sdkSite) /// /// A reconciled day (so the boundary) storing values a dashboard load /// rewrites in memory. That makes the loaded, TRACKED entity dirty -- the - /// precondition for the flush trap in ruling F15. Without it, merely - /// skipping the Update calls would look sufficient. + /// precondition for the flush trap: the next open day's save would flush + /// the dirty locked row and fail the load. Without it, merely skipping the + /// Update calls would look sufficient. /// /// IsSaturday is stored WRONG for the date, so the unconditional weekday /// assignment before the first Update (the one outside the try, whose @@ -409,8 +418,22 @@ public async Task Unreconcile_BelowTheBoundary_IsRejected_AndNamesTheBlockingDay var result = await _service.Unreconcile(earlier.Id); Assert.That(result.Success, Is.False); - Assert.That(result.Message, Is.EqualTo("OnlyLatestReconciledDayCanBeUnlocked"), - "the puzzle rule: free the outermost piece first"); + Assert.That(result.Message, + Is.EqualTo("OnlyLatestReconciledDayCanBeUnlocked|" + + later.Date.ToString("dd-MM-yyyy", CultureInfo.InvariantCulture)), + "the puzzle rule: free the outermost piece first, and the message names it"); + } + + [Test] + public async Task Unreconcile_WhenNothingIsReconciled_SaysSo() + { + // No boundary at all, so there is no day the message could name. + var row = await SeedPlain(915, DateTime.Now.Date.AddDays(-5)); + + var result = await _service.Unreconcile(row.Id); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("NothingIsReconciled")); } [Test] @@ -680,9 +703,8 @@ public async Task CreateUpdate_AcrossTheBoundary_SkipsLockedRowsAndSavesOpenOnes /// WhereOpen keeps locked ROWS out of the load, but a locked date with NO /// row (a gap row the working-hours Index emits, and the page posts every /// row) is not in the load either way. Without the skip, CreatePlanning - /// creates -5 inside the lock; its own catch swallows the interceptor's - /// throw, the Added entity stays tracked, the next save (-2's create, then - /// -1's update, which has no catch) flushes it again, and the request fails. + /// creates -5 inside the lock, the interceptor refuses it, and the refusal + /// propagates through CreatePlanning's catch and fails the whole request. /// [Test] public async Task CreateUpdate_ALockedGapRowInTheMiddle_IsNotCreatedAndTheRestSaves() @@ -862,15 +884,16 @@ public async Task UpdateWorkingHour_Personal_TheBoundaryDay_ReturnsDayIsReconcil // --------------------------------------------------------------------- // Layer 3: recalculation skips locked days, and a dashboard load over a - // closed period still succeeds (ruling F15: a dirty locked entity left - // behind would be flushed by the next open day's save and fail the load). - // Each asserts the positional one-entry-per-date contract (ruling F17) and - // that nothing was rejected and swallowed inside the recompute's catch. + // closed period still succeeds (a dirty locked entity left behind would be + // flushed by the next open day's save and fail the load). Each asserts the + // positional one-entry-per-date contract (spec §6.3) and that nothing on + // the way was logged as an error. // --------------------------------------------------------------------- /// /// Run once per recompute branch: each branch has its own guarded Update - /// inside the catch-all, and AssertNoErrorLogged is what pins it. + /// inside the catch-all. A missing guard there throws the lock rejection + /// through the catch-all, which fails Index and so result.Success. /// [TestCase(false)] [TestCase(true)] @@ -975,7 +998,7 @@ public async Task IndexByCurrentUserName_OverALockedRange_Succeeds() } // --------------------------------------------------------------------- - // Task 6: the read model carries the lock state -- the row-level boundary + // The read model carries the lock state -- the row-level boundary // once per site, and Reconciled/ReconciledAt per day, so the client can // render locked and reconciled days without scanning cells. // --------------------------------------------------------------------- @@ -1014,7 +1037,7 @@ public async Task Index_ProjectsReconciledStateOntoTheReadModel() } /// - /// The trap the brief names: UpdatePlanRegistrationsInPeriod's per-day loop + /// The trap: UpdatePlanRegistrationsInPeriod's per-day loop /// runs over planningsInPeriod, and a window that is entirely locked with /// no existing rows leaves that list empty for the whole call -- the loop /// never executes once. LockedThrough must still be set, because it is diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs index 365089de..adafa976 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs @@ -33,7 +33,7 @@ private TimePlanningPnDbContext GetTimePlanningPnDbContext(string connectionStr) var optionsBuilder = new DbContextOptionsBuilder(); optionsBuilder.UseMySql( - connectionStr.Replace("myDb", "420_eform-angular-items-planning-plugin").Replace("bla", "root"), + PluginConnectionString, new MariaDbServerVersion( ServerVersion.AutoDetect(connectionStr)), mySqlOptionsAction: builder => { @@ -103,6 +103,15 @@ protected BaseDbContext GetBaseDbContext() return baseDbContext; } + /// + /// The connection string of the plugin database + /// migrates, for tests that must build a context the way production does + /// (e.g. through TimePlanningDbContextHelper) rather than through this + /// fixture's own builders. + /// + protected string PluginConnectionString => _mariadbTestcontainer.GetConnectionString() + .Replace("myDb", "420_eform-angular-items-planning-plugin").Replace("bla", "root"); + /// /// Builds a NEW TimePlanningPnDbContext against the same (already /// migrated) plugin database as — @@ -116,7 +125,7 @@ protected TimePlanningPnDbContext CreateTimePlanningPnDbContext() var optionsBuilder = new DbContextOptionsBuilder(); optionsBuilder.UseMySql( - connectionStr.Replace("myDb", "420_eform-angular-items-planning-plugin").Replace("bla", "root"), + PluginConnectionString, new MariaDbServerVersion(ServerVersion.AutoDetect(connectionStr)), mySqlOptionsAction: builder => { builder.EnableRetryOnFailure(); diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/EformTimePlanningPlugin.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/EformTimePlanningPlugin.cs index 9ca817cd..948499af 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/EformTimePlanningPlugin.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/EformTimePlanningPlugin.cs @@ -181,14 +181,11 @@ public void ConfigureDbContext(IServiceCollection services, string connectionStr _connectionString = connectionString; services.AddSingleton(provider => new TimePlanningDbContextHelper(_connectionString)); + // Keep the options inside ConfigureTimePlanningDbContext: that is where + // DayLockWiringTests checks the day-lock interceptor is attached. services.AddDbContextPool(o => - o.UseMySql(connectionString, new MariaDbServerVersion( - ServerVersion.AutoDetect(connectionString)), mySqlOptionsAction: builder => - { - builder.EnableRetryOnFailure(); - builder.MigrationsAssembly(PluginAssembly().FullName); - }) - .AddInterceptors(ReconciledDayLockInterceptor.Instance)); + ConfigureTimePlanningDbContext(o, connectionString, + new MariaDbServerVersion(ServerVersion.AutoDetect(connectionString)))); var contextFactory = new TimePlanningPnContextFactory(); var context = contextFactory.CreateDbContext(new[] { connectionString }); @@ -222,6 +219,24 @@ public void ConfigureDbContext(IServiceCollection services, string connectionStr } } + /// + /// The options of the pooled TimePlanningPnDbContext registration. A + /// method, not an inline lambda, so DayLockWiringTests can prove the + /// day-lock interceptor is attached. The caller passes the server version + /// so that test opens no connection; the registration passes + /// ServerVersion.AutoDetect, as it always has. + /// + public static void ConfigureTimePlanningDbContext( + DbContextOptionsBuilder options, string connectionString, ServerVersion serverVersion) + { + options.UseMySql(connectionString, serverVersion, mySqlOptionsAction: builder => + { + builder.EnableRetryOnFailure(); + builder.MigrationsAssembly(typeof(EformTimePlanningPlugin).Assembly.FullName); + }) + .AddInterceptors(ReconciledDayLockInterceptor.Instance); + } + public void Configure(IApplicationBuilder appBuilder) { appBuilder.UseEndpoints(endpoints => diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs index 7f9b2487..b4cf9f8e 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs @@ -1,3 +1,9 @@ +// NOTE: a deliberate copy of this file lives in eform-service-timeplanning-plugin +// (ServiceTimePlanningPlugin/Infrastructure/Helpers/DayLockHelper.cs). The two +// repos share only the base NuGet package. If you change the lock logic here, +// change the twin too: a divergence lets background jobs write days the web +// refuses. The twin omits the message and reconcile members, which background +// jobs never need. #nullable enable namespace TimePlanning.Pn.Infrastructure.Helpers; diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/GoogleSheetHelper.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/GoogleSheetHelper.cs index a62e6997..0bb2f324 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/GoogleSheetHelper.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/GoogleSheetHelper.cs @@ -265,6 +265,8 @@ public static async Task PullEverythingFromGoogleSheet(Core core, TimePlanningPn // MicrotingUid in columnSiteMap. var lockedThroughBySite = await DayLockHelper.LockedThroughForSitesAsync( dbContext, oneMinuteTimelines.Keys.ToList()); + // Observability only: the skip is silent otherwise. + var lockedDaysSkipped = 0; // Skip the header row (first row) for (var i = 1; i < values.Count; i++) @@ -300,6 +302,7 @@ public static async Task PullEverythingFromGoogleSheet(Core core, TimePlanningPn if (site.MicrotingUid is { } lockSiteUid && DayLockHelper.IsLocked(lockedThroughBySite, lockSiteUid, dateValue)) { + lockedDaysSkipped++; continue; } @@ -481,6 +484,8 @@ public static async Task PullEverythingFromGoogleSheet(Core core, TimePlanningPn } } } + + Console.WriteLine($"[PullEverythingFromGoogleSheet] summary: skipped {lockedDaysSkipped} locked day(s)."); } else { diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs index 5a934c36..111462e5 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs @@ -14,6 +14,7 @@ using Microting.TimePlanningBase.Infrastructure.Data.Entities; using Microting.TimePlanningBase.Infrastructure.Helpers; using Sentry; +using TimePlanning.Pn.Infrastructure.Interceptors; using TimePlanning.Pn.Infrastructure.Models.Holiday; using TimePlanning.Pn.Infrastructure.Models.Planning; using TimePlanning.Pn.Infrastructure.Models.Settings; @@ -859,7 +860,12 @@ await dbContext.PlanRegistrations.AsNoTracking() } } } - catch (Exception e) + // A lock refusal passes through. Every Update in this try is + // skipped for a locked day, so one only arrives when the boundary + // moved mid-load or a guard is missing. It must fail the load, not + // be logged as a PlanText problem: swallowed, the rejected entry + // stays tracked and the next save fails on it anyway. + catch (Exception e) when (e is not DayLockedException) { logger.LogError( $"Could not parse PlanText for planning with id: {planRegistration.Id} the PlanText was: {planRegistration.PlanText}"); diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Interceptors/ReconciledDayLockInterceptor.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Interceptors/ReconciledDayLockInterceptor.cs index 591eaf99..6c7f92d0 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Interceptors/ReconciledDayLockInterceptor.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Interceptors/ReconciledDayLockInterceptor.cs @@ -1,3 +1,8 @@ +// NOTE: a deliberate copy of this file lives in eform-service-timeplanning-plugin +// (ServiceTimePlanningPlugin/Infrastructure/Interceptors/ReconciledDayLockInterceptor.cs). +// The two repos share only the base NuGet package. If you change the lock logic +// here, change the twin too: a divergence lets background jobs write days the +// web refuses. #nullable enable namespace TimePlanning.Pn.Infrastructure.Interceptors; @@ -36,7 +41,15 @@ public class DayLockedException(int sdkSitId, DateTime date, DateTime lockedThro } /// -/// Enforces invariant I3 across every PlanRegistration write path. +/// Enforces invariant I3 on every PlanRegistration write path; see the race +/// note below for the one gap. +/// +/// RACE NOTE. The boundary query and the write are separate statements with no +/// transaction around them, so a reconcile committed between the two lets one +/// write through onto a day that just became locked. This is race-only and +/// accepted. The services' up-front guards read the boundary earlier, but they +/// only pick the friendly message: a reconcile committed after a guard ran is +/// still refused here, at save time, as a DayLockedException. /// /// STATELESS BY DESIGN. The pooled context registration /// (EformTimePlanningPlugin.AddDbContextPool) reuses context instances, so an @@ -96,8 +109,10 @@ public override InterceptionResult SavingChanges( // Sync-over-async is safe here: ASP.NET Core requests run with no // SynchronizationContext, and GuardAsync/LockedThroughForSitesAsync use // ConfigureAwait(false) throughout, so there is no continuation to - // deadlock on. This path exists only because the plugin's own seed - // code calls the synchronous SaveChanges; those seeds never touch + // deadlock on. No production path reaches this: the plugin's seed code + // is the only caller of the synchronous SaveChanges, and in production + // it runs on the factory context, which has no interceptor. Only the + // test fixture seeds through a guarded context. The seeds never touch // PlanRegistration, so GuardAsync returns before awaiting anything. GuardAsync(eventData.Context, CancellationToken.None).GetAwaiter().GetResult(); return base.SavingChanges(eventData, result); @@ -184,7 +199,8 @@ private static async Task GuardAsync(DbContext? context, CancellationToken cance // The two permitted writes inside the locked range: clearing the // flag on the boundary day itself (that is what unlocking IS), and - // a payroll-flag-only write on any locked day (ruling F10). Both + // a payroll-flag-only write on any locked day (see + // PayrollFlagProperties for why exporting must stay possible). Both // are evaluated against the entry's CURRENT site's boundary -- // that is the only boundary either exemption is ever about -- and // both already fail whenever Date or SdkSitId is among the diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs index a9854710..004dd543 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs @@ -333,6 +333,12 @@ internal static string OnlyLatestReconciledDayCanBeUnlocked { } } + internal static string NothingIsReconciled { + get { + return ResourceManager.GetString("NothingIsReconciled", resourceCulture); + } + } + internal static string SuccessfullyReconciledDay { get { return ResourceManager.GetString("SuccessfullyReconciledDay", resourceCulture); diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx index d4887ddb..9bb92427 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx @@ -211,7 +211,10 @@ Kun dage før i dag kan afstemmes. - Lås den seneste afstemte dag op først. + Lås den seneste afstemte dag op først ({0}). + + + Intet er afstemt for denne medarbejder. Dagen er afstemt diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx index eaae564f..f1acdabb 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx @@ -211,7 +211,10 @@ Only days before today can be reconciled. - Unlock the most recent reconciled day first. + Unlock the most recent reconciled day first ({0}). + + + Nothing is reconciled for this worker. Day reconciled diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/AbsenceRequestService/AbsenceRequestService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/AbsenceRequestService/AbsenceRequestService.cs index 7f7e7853..ff97556d 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/AbsenceRequestService/AbsenceRequestService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/AbsenceRequestService/AbsenceRequestService.cs @@ -119,6 +119,18 @@ public async Task> CreateAsync(AbsenceR _localizationService.GetString("DateToMustBeGreaterThanOrEqualToDateFrom")); } + // A request touching a locked day could never be approved (Approve + // refuses it), so it is refused here instead of left pending. Locked + // days are a prefix of the calendar, so the range touches one + // exactly when its first day is locked, and that day is the earliest + // blocking one Approve would name too. + var lockedThrough = await DayLockHelper.LockedThroughAsync(_dbContext, model.RequestedBySdkSitId); + if (DayLockHelper.IsLocked(lockedThrough, dateFrom)) + { + return new OperationDataResult(false, _localizationService.GetString( + await DayLockHelper.LockedMessageKeyAsync(_dbContext, model.RequestedBySdkSitId, dateFrom))); + } + // Check for overlapping pending requests for the same worker var hasOverlap = await _dbContext.AbsenceRequests .AnyAsync(ar => ar.RequestedBySdkSitId == model.RequestedBySdkSitId diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/ContentHandoverService/ContentHandoverService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/ContentHandoverService/ContentHandoverService.cs index 8b7d25f8..968e72a4 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/ContentHandoverService/ContentHandoverService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/ContentHandoverService/ContentHandoverService.cs @@ -320,6 +320,15 @@ public async Task>> Create _localizationService.GetString("CannotHandoverToSameWorker")); } + // A handover touching a locked day could never be accepted (Accept + // refuses it), so it is refused here instead of left pending. + var blockingRow = await FirstLockedRowAsync(fromPR, toPR); + if (blockingRow != null) + { + return new OperationDataResult>(false, + _localizationService.GetString(DayLockHelper.LockedMessageKey(blockingRow.Reconciled))); + } + var shiftIndices = model.ShiftIndices ?? new List(); // Load existing pending requests scoped to (target, date). We'll @@ -462,6 +471,20 @@ public async Task>> Create } } + /// + /// The first of the two rows that sits on a locked day, or null. The rows + /// belong to two different workers, each with their own boundary, resolved + /// in one query. Checked so a refusal names the row that blocks it, in the + /// persist order Accept uses (receiver first, then sender). + /// + private async Task FirstLockedRowAsync(PlanRegistration fromPR, PlanRegistration toPR) + { + var boundaries = await DayLockHelper.LockedThroughForSitesAsync( + _dbContext, [fromPR.SdkSitId, toPR.SdkSitId]); + return new[] { toPR, fromPR }.FirstOrDefault(pr => + DayLockHelper.IsLocked(boundaries, pr.SdkSitId, pr.Date)); + } + private void FireCreatePush(int toSdkSitId, List requestIds, int shiftCount, DateTime date) { _ = Task.Run(() => SendCreatePushAsync(toSdkSitId, requestIds, shiftCount, date)); @@ -644,15 +667,10 @@ public async Task AcceptAsync( "[Handover] Accept request {RequestId}: loaded fromPR {FromPRId} (sdkSitId={FromSdkSitId}) and toPR {ToPRId} (sdkSitId={ToSdkSitId}), shiftIndex={ShiftIndex}", requestId, fromPR.Id, fromPR.SdkSitId, toPR.Id, toPR.SdkSitId, request.ShiftIndex); - // Two different workers, each with their own boundary. Checked - // before either row is touched: the receiver is persisted before - // the sender, so a lock refusing the sender afterwards would leave - // the shift on both days. Checked in that persist order, and the - // message says what the blocking row is. - var boundaries = await DayLockHelper.LockedThroughForSitesAsync( - _dbContext, [fromPR.SdkSitId, toPR.SdkSitId]); - var blockingRow = new[] { toPR, fromPR }.FirstOrDefault(pr => - DayLockHelper.IsLocked(boundaries, pr.SdkSitId, pr.Date)); + // Checked before either row is touched: the receiver is persisted + // before the sender, so a lock refusing the sender afterwards would + // leave the shift on both days. + var blockingRow = await FirstLockedRowAsync(fromPR, toPR); if (blockingRow != null) { _logger.LogWarning( diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/RebusService/RebusService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/RebusService/RebusService.cs index 034c5b46..5ef7a4ac 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/RebusService/RebusService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/RebusService/RebusService.cs @@ -28,10 +28,10 @@ namespace TimePlanning.Pn.Services.RebusService; using Castle.MicroKernel.Registration; using Castle.Windsor; using eFormCore; +using Infrastructure.Helpers; using Installers; using Microting.eFormApi.BasePn.Abstractions; using Microting.TimePlanningBase.Infrastructure.Data; -using Microting.TimePlanningBase.Infrastructure.Data.Factories; using Rebus.Bus; public class RebusService : IRebusService @@ -65,9 +65,9 @@ public IBus GetBus() { return _bus; } + // The day-lock guarded context, not TimePlanningPnContextFactory's: a + // future handler writing PlanRegistration through this registration must + // not bypass the lock. private TimePlanningPnDbContext GetContext() - { - TimePlanningPnContextFactory contextFactory = new TimePlanningPnContextFactory(); - return contextFactory.CreateDbContext(new[] {_connectionString}); - } + => new TimePlanningDbContextHelper(_connectionString).GetDbContext(); } \ No newline at end of file diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs index 08c4c96d..5bc39273 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs @@ -36,6 +36,7 @@ namespace TimePlanning.Pn.Services.TimePlanningPlanningService; using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Threading.Tasks; using Infrastructure.Models.Planning; @@ -2426,9 +2427,9 @@ public async Task Reconcile(int id) // Expected and routine: a blocked edit is a normal outcome, not an // incident. Do not report it to Sentry. // - // The rejected entry stays tracked on `dbContext` (a request-scoped, - // injected context — see Task 4 amendment A1) after this throws. - // That is acceptable because we return immediately; no further + // The rejected entry stays tracked on `dbContext` after this throws. + // That context is the request-scoped, injected one, so the entry + // dies with the request, and we return immediately: no further // save is attempted on this context for the rest of the request. return new OperationResult(false, localizationService.GetString("DayIsLockedByReconciledDay")); @@ -2456,10 +2457,13 @@ public async Task Unreconcile(int id) // inside the range) would be told "Dagen er låst op" while nothing // happened. var boundary = await DayLockHelper.LockedThroughAsync(dbContext, planning.SdkSitId); - if (boundary is null || planning.Date.Date != boundary.Value.Date) + if (boundary is null) { - return new OperationResult(false, - localizationService.GetString("OnlyLatestReconciledDayCanBeUnlocked")); + return new OperationResult(false, localizationService.GetString("NothingIsReconciled")); + } + if (planning.Date.Date != boundary.Value.Date) + { + return OnlyLatestReconciledDayCanBeUnlocked(boundary.Value); } if (!planning.Reconciled) @@ -2471,15 +2475,15 @@ public async Task Unreconcile(int id) return new OperationResult(true, localizationService.GetString("SuccessfullyUnlockedDay")); } - catch (DayLockedException) + catch (DayLockedException e) { // Race: another request reconciled a newer day for this site // between the boundary read above and this save, so `planning`'s // day is no longer the boundary the interceptor will permit an // unlock on. Routine, not an incident -- no Sentry, same as - // Reconcile's DayLockedException catch. - return new OperationResult(false, - localizationService.GetString("OnlyLatestReconciledDayCanBeUnlocked")); + // Reconcile's DayLockedException catch. The exception carries the + // new boundary, so naming it costs no query. + return OnlyLatestReconciledDayCanBeUnlocked(e.LockedThrough); } catch (Exception e) { @@ -2489,6 +2493,15 @@ public async Task Unreconcile(int id) } } + /// + /// The unlock refusal names the day to unlock first, so the user does not + /// have to hunt for it in the grid (spec §7). + /// + private OperationResult OnlyLatestReconciledDayCanBeUnlocked(DateTime boundary) + => new(false, localizationService.GetString( + "OnlyLatestReconciledDayCanBeUnlocked", + boundary.ToString("dd-MM-yyyy", CultureInfo.InvariantCulture))); + public async Task> ReconcileThrough( ReconcileThroughRequestModel model) { diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs index ec463a48..d882c906 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs @@ -35,6 +35,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE using Microting.TimePlanningBase.Infrastructure.Data.Entities; using Sentry; using TimePlanning.Pn.Infrastructure.Helpers; +using TimePlanning.Pn.Infrastructure.Interceptors; using TimePlanning.Pn.Infrastructure.Data.Seed.Data; using Microting.TimePlanningBase.Infrastructure.Helpers; using TimePlanning.Pn.Infrastructure.Models.WorkingHours.UpdateCreate; @@ -467,9 +468,10 @@ public async Task CreateUpdate(TimePlanningWorkingHoursUpdateCr // crafted POST cannot write one. var lockedThrough = await DayLockHelper.LockedThroughAsync(dbContext, model.SiteId); // Locked rows are never even loaded, so nothing below can mutate - // one and have a later save flush it (ruling F15). The forward - // cascade walks this same list, so it skips them too. Not redundant - // with the loop's skip: only this keeps the cascade out of the lock. + // one and have a later save (which saves the whole context) flush + // it. The forward cascade walks this same list, so it skips them + // too. Not redundant with the loop's skip: only this keeps the + // cascade out of the lock. var planRegistrations = await dbContext.PlanRegistrations .Where(x => x.SdkSitId == model.SiteId) .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) @@ -637,7 +639,11 @@ await dbContext.PlanRegistrations.AsNoTracking() await planRegistration.Create(dbContext); } - catch (Exception e) + // A lock refusal passes through to CreateUpdate's catch. CreateUpdate + // skips locked days before calling this, so one only arrives when the + // boundary moved mid-request. Swallowed, the rejected Added entry + // stays tracked and the next save in the request fails on it anyway. + catch (Exception e) when (e is not DayLockedException) { SentrySdk.CaptureException(e); logger.LogError(e.Message); @@ -4011,6 +4017,8 @@ public async Task Import(IFormFile file) return new OperationResult(false, localizationService.GetString("FileFormatError")); } + // Observability only: the locked-day skip below is silent otherwise. + var lockedDaysSkipped = 0; foreach (Sheet sheet in sheets) { if (sheet.Name?.Value == null || sheet.Id?.Value == null) @@ -4096,6 +4104,7 @@ public async Task Import(IFormFile file) // tracked and no later save can flush a change into it. if (DayLockHelper.IsLocked(importLockedThrough, dateValue)) { + lockedDaysSkipped++; continue; } @@ -4187,6 +4196,8 @@ public async Task Import(IFormFile file) } } } + + Console.WriteLine($"[Import] summary: skipped {lockedDaysSkipped} locked day(s)."); } } } From 9808b4db335e6c55533139243b18407c7239377c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 15 Sep 2026 18:04:53 +0200 Subject: [PATCH 15/28] docs(lock): bring the spec status in line with the implemented backend Status line, pinned base version, and section 13 now say what is built: the plugin write paths are guarded, unlock refusals name the day, the race window is documented, and the frontend must not ship before the service repo's PR is deployed. Co-Authored-By: Claude Opus 5 --- .../2026-09-12-reconciled-day-lock-design.md | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md b/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md index 1196dd42..0a4c77dc 100644 --- a/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md +++ b/docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md @@ -1,7 +1,7 @@ # Reconciled ("Afstemt") day lock — design **Date:** 2026-09-12 -**Status:** Design, awaiting review +**Status:** Accepted. Backend implemented (PR #1711); service repo, host styles and frontend follow. See §13 for the rulings taken during implementation. **Scope:** `eform-angular-timeplanning-plugin` (web UI + `TimePlanning.Pn` API), `eform-service-timeplanning-plugin` (background jobs). **No change to `eform-timeplanning-base`.** @@ -67,7 +67,7 @@ public DateTime? ReconciledAt { get; set; } // datetime(6) NULL ``` They are verified present in the currently pinned package -(`Microting.TimePlanningBase` **10.0.62**). Nothing writes them today; exactly +(`Microting.TimePlanningBase` **10.0.62**; implementation pinned 10.0.63, which still has them). Nothing writes them today; exactly one place reads them — the admin-only version diff in `TimePlanningPlanningService.CompareVersions`. @@ -508,10 +508,21 @@ wrong, in the SDD ledger. They supersede the sections they name. range, so §6.1's "localized failure" would make any range touching a reconciled day unsaveable. The save skips locked rows, and the page shows them read-only through its existing `IsLocked` flag. -- **More write paths to guard (F11, F12, F13). Ruled and briefed; not yet - implemented.** An audit found writes outside §5's list that reach locked - days: startup pause-id repair (unguarded, it would crash host startup), - Google Sheet pull, Excel import, the flex screen, absence approval and shift - handover, plus the service repo's sheet pull and flex catch-up. Task 5B - (plugin) and Task 7B (service repo) will make bulk re-syncs skip locked days - and give a user acting on specific days a message. +- **More write paths guarded (F11, F13 in the plugin; F12 in the service + repo).** An audit found writes outside §5's list that reach locked days. + In the plugin (PR #1711), each now skips locked days or answers with a + message: startup pause-id repair (unguarded, it would have crashed host + startup), Google Sheet pull, Excel import, the flex screen, and absence and + handover requests (checked when created, approved or accepted). In the + service repo (a separate PR), the sheet pull, the nightly recalculation and + the flex catch-up skip locked days. Bulk re-syncs skip; a user acting on + specific days gets a message. +- **Unlock refusals name the day to free first** (§7), and a worker with + nothing reconciled gets a distinct message. +- **The lock's race window is documented, not closed.** The boundary query + and the write are separate statements, so a reconcile committed between + them can let one write through. Every guarded path still saves through the + interceptor, which reads the boundary again. +- **Release order.** The frontend (PR4) must not reach production before the + service-repo PR is deployed; otherwise background jobs could still write + days the web shows as closed. From 6b0d128adc3cb132e932907d868681e93ae9af26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 15 Sep 2026 18:16:56 +0200 Subject: [PATCH 16/28] feat(lock): add the lock fields and reconcile calls to the client Co-Authored-By: Claude Opus 5 --- .../models/plannings/index.ts | 1 + .../models/plannings/planning-pr-day.model.ts | 17 ++++++++++++++ .../reconcile-through-result.model.ts | 19 ++++++++++++++++ .../models/plannings/time-planning.model.ts | 7 ++++++ .../time-planning-pn-plannings.service.ts | 22 +++++++++++++++++++ 5 files changed, 66 insertions(+) create mode 100644 eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/reconcile-through-result.model.ts diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/index.ts b/eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/index.ts index 6d1b2a19..592b4fe5 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/index.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/index.ts @@ -5,3 +5,4 @@ export * from './time-plannings-update.model'; export * from './planning-pr-day.model'; export * from './planning-pr-day-update.model'; export * from './plan-registration-version.model'; +export * from './reconcile-through-result.model'; 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 8c8d8e03..2a08f070 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 @@ -1,4 +1,10 @@ export class PlanningPrDayModel { + /** + * For a locked date with no registration, the server sends a non-persisted + * placeholder day with id === 0. Placeholder cells are not clickable/openable + * and render blank (no hours, no flex balance), though they still carry the + * locked texture. + */ id: number; siteName: string; siteId: number; @@ -165,4 +171,15 @@ export class PlanningPrDayModel { pause4OverrideMinutesSpecified?: boolean; pause5OverrideMinutesSpecified?: boolean; clearPauseOverrides?: boolean; + /** + * True on any day that has been reconciled ("afstemt"). Several days on the + * same row can have reconciled === true at once — each reconcile leaves its + * own mark. The boundary treatment (3px border, unlock offered) applies + * ONLY to the day whose date === row.lockedThrough; other reconciled days + * (date < lockedThrough) are locked by derivation and render read-only + * (locked hatch + filled verified glyph + provenance tooltip, no unlock). + */ + reconciled: boolean; + /** Server timestamp of when this day was reconciled; null when not reconciled. */ + reconciledAt: string | null; } diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/reconcile-through-result.model.ts b/eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/reconcile-through-result.model.ts new file mode 100644 index 00000000..5b2e710a --- /dev/null +++ b/eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/reconcile-through-result.model.ts @@ -0,0 +1,19 @@ +/** + * Mirrors the C# ReconcileThroughResultModel (plan Task 4) field for field. + * + * Per-worker landing, because the boundary is a staircase: the mark falls on each + * worker's own latest registered day at or before the requested date, so one shared + * "landedOn" would name the wrong date for most of them. + */ +export class ReconcileThroughResultModel { + /** siteId -> the day the mark landed on. JSON object keys arrive as strings. */ + landedOnBySiteId: { [siteId: number]: string } = {}; + /** Workers whose boundary actually moved. Excludes no-ops. */ + applied: number; + /** Already reconciled at or past the target. Moving them back would be an unlock. */ + skippedAlreadyFurtherForward: number[] = []; + /** No registration at or before the target, so there was nothing to mark. */ + skippedNoRegistration: number[] = []; + /** Already marked on exactly the landing day; nothing changed. */ + alreadyReconciledSiteIds: number[] = []; +} 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 29991b85..1a3ccf3d 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 @@ -36,4 +36,11 @@ export class TimePlanningModel { thirdShiftActive: boolean; fourthShiftActive: boolean; fifthShiftActive: boolean; + /** + * Latest ("boundary") date locked for this row, or null when nothing is + * locked. Every day at or before this date is locked; only the day whose + * date === lockedThrough is the boundary (unlock offered, boundary border). + * Older locked days are marked via PlanningPrDayModel.reconciled instead. + */ + lockedThrough: string | null; } diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/services/time-planning-pn-plannings.service.ts b/eform-client/src/app/plugins/modules/time-planning-pn/services/time-planning-pn-plannings.service.ts index 33d5551e..66061667 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/services/time-planning-pn-plannings.service.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/services/time-planning-pn-plannings.service.ts @@ -4,6 +4,7 @@ import { OperationDataResult, OperationResult } from 'src/app/common/models'; import { ApiBaseService } from 'src/app/common/services'; import { PlanningPrDayModel, + ReconcileThroughResultModel, TimeFlexesModel, TimeFlexesUpdateModel, TimePlanningModel, @@ -52,4 +53,25 @@ export class TimePlanningPnPlanningsService { TimePlanningPnPlanningsMethods.Plannings + '/' + planRegistrationId + '/version-history' ); } + + reconcileDay(id: number): Observable { + return this.apiBaseService.put( + TimePlanningPnPlanningsMethods.Plannings + '/' + id + '/reconcile', {} + ); + } + + unreconcileDay(id: number): Observable { + return this.apiBaseService.put( + TimePlanningPnPlanningsMethods.Plannings + '/' + id + '/unreconcile', {} + ); + } + + reconcileThrough( + date: string, siteIds: number[] + ): Observable> { + return this.apiBaseService.put( + TimePlanningPnPlanningsMethods.Plannings + '/reconcile-through', + {date, siteIds} + ); + } } From 2219ce40e178f72eb133156f58050b8f5f3737f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Wed, 16 Sep 2026 10:48:15 +0200 Subject: [PATCH 17/28] feat(lock): mark locked and reconciled days in the grid The boundary day carries the seal; earlier days show the lock, and earlier sealed days keep their seal without the boundary line. Placeholder days inside a locked range render blank and do not open. A legend appears while a locked day is on screen. The day-lock rules live in one util, with unit tests and an end-to-end spec. Co-Authored-By: Claude Opus 5 --- .../s/reconcile-glyphs.spec.ts | 106 +++++ .../time-planning-pn/s/reconcile-helpers.ts | 242 +++++++++++ .../plannings/day-lock.util.spec.ts | 264 ++++++++++++ .../components/plannings/day-lock.util.ts | 227 +++++++++++ .../time-plannings-table.component.html | 377 ++++++++++-------- .../time-plannings-table.component.spec.ts | 132 ++++++ .../time-plannings-table.component.ts | 66 ++- .../time-planning-pn/help/help-wiring.spec.ts | 9 +- 8 files changed, 1260 insertions(+), 163 deletions(-) create mode 100644 eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-glyphs.spec.ts create mode 100644 eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-helpers.ts create mode 100644 eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/day-lock.util.spec.ts create mode 100644 eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/day-lock.util.ts diff --git a/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-glyphs.spec.ts b/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-glyphs.spec.ts new file mode 100644 index 00000000..abaf65e5 --- /dev/null +++ b/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-glyphs.spec.ts @@ -0,0 +1,106 @@ +import { test, expect } from '@playwright/test'; +import { LoginPage } from '../../../Page objects/Login.page'; +import { + cellOf, expectTooltipOnHover, LOCKED_TOOLTIP, openDashboardLastWeek, PROVENANCE, reconcileDay, + rowOf, tdOf, unlockAll, unlockDay, workerAtRow, +} from './reconcile-helpers'; + +/** + * Spec §8.1: the lock and seal glyphs, their tooltips, and the legend under the + * grid. The worker is grid row 5 at the start and is found by name after that. + */ +test.describe('Reconciled day lock: glyphs and legend', () => { + /** + * The worker whose row a test locked. afterEach unlocks it even when an assertion + * failed halfway through: the shard shares one database and runs its files in + * order, so a row left locked would break every spec after this one. + */ + let worker = ''; + + test.beforeEach(async ({ page }) => { + worker = ''; + await page.goto('http://localhost:4200'); + await new LoginPage(page).login(); + await openDashboardLastWeek(page); + }); + + test.afterEach(async ({ page }) => { + await unlockAll(page, worker); + }); + + // Day 4 (last week's Friday) becomes the boundary, so day 2 is cascade-locked and + // day 5 stays open. + test('the boundary shows the seal, earlier days the lock, and the legend explains both', async ({ page }) => { + // Precondition, and a leak detector for the specs before this one: nothing + // locked is in view, so there is no legend. + await expect(page.locator('#lockLegend')).toHaveCount(0); + + worker = await workerAtRow(page, 5); + await reconcileDay(page, worker, 4); + + // Boundary: the seal and no lock. Its tooltip is the provenance line. + const seal = cellOf(page, worker, 4).locator('.tp-day-glyph--seal'); + await expect(seal).toBeVisible(); + await expect(seal.locator('mat-icon')).toHaveClass(/tp-seal/); + await expect(cellOf(page, worker, 4).locator('.tp-day-glyph--lock')).toHaveCount(0); + await expectTooltipOnHover(page, seal, PROVENANCE); + + // Cascade: the lock and no seal. Its tooltip says what the day is. + const lock = cellOf(page, worker, 2).locator('.tp-day-glyph--lock'); + await expect(lock).toBeVisible(); + await expect(cellOf(page, worker, 2).locator('.tp-day-glyph--seal')).toHaveCount(0); + await expectTooltipOnHover(page, lock, LOCKED_TOOLTIP); + + // Above the boundary: no glyph at all. + await expect(cellOf(page, worker, 5).locator('.tp-day-glyph')).toHaveCount(0); + + // The legend appears with the first locked day and names both states. + await expect(page.locator('#lockLegend')).toBeVisible(); + await expect(page.locator('#lockLegendLocked')).toContainText(LOCKED_TOOLTIP); + await expect(page.locator('#lockLegendReconciled')).toContainText('Afstemt'); + + // Unlocking is part of the behaviour under test: with nothing locked the glyphs + // and the legend go away again. afterEach only catches what a failure leaves. + await unlockDay(page, worker, 4); + await expect(cellOf(page, worker, 2).locator('.tp-day-glyph')).toHaveCount(0); + await expect(page.locator('#lockLegend')).toHaveCount(0); + }); + + // Ruling F20: every reconcile leaves its own mark, so a row can carry several + // sealed days. Only the day at lockedThrough is the boundary. + test('an older reconciled day stays sealed under a later boundary, without the boundary border', async ({ page }) => { + await expect(page.locator('#lockLegend')).toHaveCount(0); + + worker = await workerAtRow(page, 5); + // Day 1 first, then day 3, which becomes the boundary. + await reconcileDay(page, worker, 1); + await reconcileDay(page, worker, 3); + + // Day 1 keeps its mark: the locked texture and the seal, with no boundary + // border and no lock glyph. Its tooltip still says when it was reconciled. + const olderSeal = cellOf(page, worker, 1).locator('.tp-day-glyph--seal'); + await expect(tdOf(page, worker, 1)).toHaveClass(/locked-background/); + await expect(tdOf(page, worker, 1)).not.toHaveClass(/reconciled-background/); + await expect(olderSeal).toBeVisible(); + await expect(cellOf(page, worker, 1).locator('.tp-day-glyph--lock')).toHaveCount(0); + await expectTooltipOnHover(page, olderSeal, PROVENANCE); + + // Day 2, between the two marks, is plainly locked. + await expect(tdOf(page, worker, 2)).toHaveClass(/locked-background/); + await expect(cellOf(page, worker, 2).locator('.tp-day-glyph--lock')).toBeVisible(); + + // Day 3 is the one boundary on the row. + await expect(tdOf(page, worker, 3)).toHaveClass(/reconciled-background/); + await expect(rowOf(page, worker).locator('td.reconciled-background')).toHaveCount(1); + + // Unlocking the boundary moves the line back to the older mark, which becomes + // the boundary in turn. + await unlockDay(page, worker, 3); + await expect(tdOf(page, worker, 1)).toHaveClass(/reconciled-background/); + await expect(cellOf(page, worker, 2).locator('.tp-day-glyph')).toHaveCount(0); + await expect(tdOf(page, worker, 3)).not.toHaveClass(/locked-background|reconciled-background/); + + await unlockDay(page, worker, 1); + await expect(page.locator('#lockLegend')).toHaveCount(0); + }); +}); diff --git a/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-helpers.ts b/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-helpers.ts new file mode 100644 index 00000000..228c3708 --- /dev/null +++ b/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-helpers.ts @@ -0,0 +1,242 @@ +import { expect, Locator, Page, Response } from '@playwright/test'; + +/** + * Shared by every spec in shard s. Two rules are enforced here so no spec can forget + * them: + * + * 1. Rows are found by WORKER NAME, never by grid position. The `#cell{row}_{day}` + * ids are positional, and a row shift silently addresses another worker. That is + * exactly how e1m/dashboard-edit-multishift.spec.ts once failed. A position is + * read once per spec, to PICK a worker, and never again. + * 2. The grid is always opened on LAST week, so every visible day is in the past. + * Reconcile is refused for today and the future (I2), and the current week has + * no past day at all on a Monday. + * + * Every spec uses its own worker and unlocks what it locked, because the shard shares + * one database and runs its specs in file order (workers: 1). + */ + +const INDEX_PATH = '/api/time-planning-pn/plannings/index'; +export const RECONCILE_PATH = /\/api\/time-planning-pn\/plannings\/\d+\/reconcile$/; +export const UNRECONCILE_PATH = /\/api\/time-planning-pn\/plannings\/\d+\/unreconcile$/; +export const RECONCILE_THROUGH_PATH = /\/api\/time-planning-pn\/plannings\/reconcile-through$/; +/** Any write to a day: a save (PUT plannings/{id}) as well as reconcile and unlock. */ +export const PLANNING_PUT_PATH = /\/api\/time-planning-pn\/plannings\//; +/** Danish, like the rest of the suite: CI runs the UI in Danish. */ +export const UNLOCK_WORD = 'LÅS OP'; +export const LOCKED_TOOLTIP = 'Låst · ligger før en afstemt dag'; +export const PROVENANCE = /^Afstemt \d{2}\.\d{2}\.\d{4} kl\. \d{2}:\d{2}$/; + +export async function waitForSpinner(page: Page): Promise { + if (await page.locator('.overlay-spinner').count() > 0) { + await page.locator('.overlay-spinner').waitFor({ state: 'hidden', timeout: 30000 }); + } +} + +/** The grid's index POST. With `dateFrom` it only accepts a load of that period. */ +export function waitForIndex(page: Page, dateFrom?: string): Promise { + return page.waitForResponse(r => + r.url().includes(INDEX_PATH) + && r.request().method() === 'POST' + && (dateFrom === undefined || `${r.request().postDataJSON()?.dateFrom ?? ''}`.startsWith(dateFrom))); +} + +/** Matches on the pathname: a bare includes('/reconcile') also matches /unreconcile. */ +export function waitForPut(page: Page, path: RegExp): Promise { + return page.waitForResponse(r => + r.request().method() === 'PUT' && path.test(new URL(r.url()).pathname)); +} + +/** Counts the PUTs matching `path` from now on. Read `.count` when needed. */ +export function countPuts(page: Page, path: RegExp): { count: number } { + const counter = { count: 0 }; + page.on('request', req => { + if (req.method() === 'PUT' && path.test(new URL(req.url()).pathname)) { + counter.count++; + } + }); + return counter; +} + +/** OperationResult failures come back as HTTP 200 with success:false, so check both. */ +export async function expectSuccess(response: Response): Promise { + expect(response.status(), `${response.url()} HTTP status`).toBeLessThan(400); + const body = await response.json(); + expect(body.success, `${response.url()} failed: ${body.message}`).toBe(true); + return body; +} + +/** Last week's Monday as yyyy-MM-dd, in the same local calendar the grid's dateFrom uses. */ +export function lastWeekMonday(): string { + const d = new Date(); + d.setHours(0, 0, 0, 0); + const sinceMonday = (d.getDay() + 6) % 7; + d.setDate(d.getDate() - sinceMonday - 7); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +export async function openDashboardLastWeek(page: Page): Promise { + await page.locator('mat-nested-tree-node').filter({ hasText: 'Timeregistrering' }).click(); + const initial = waitForIndex(page); + await page.locator('mat-tree-node').filter({ hasText: 'Dashboard' }).click(); + await initial; + await waitForSpinner(page); + // Filtered on dateFrom, so a late current-week response cannot satisfy the wait. + const monday = lastWeekMonday(); + const lastWeek = waitForIndex(page, monday); + await page.locator('#backwards').click(); + const response = await lastWeek; + expect(response.request().postDataJSON().dateFrom, 'the grid must be on last week').toMatch(new RegExp(`^${monday}`)); + await waitForSpinner(page); +} + +/** The worker rendered at a grid position. Call it once per spec, to pick a worker. */ +export async function workerAtRow(page: Page, rowIndex: number): Promise { + const name = (await page.locator(`#firstColumn${rowIndex} .hours-info strong`).innerText()).trim(); + // A blank name would make every later lookup match vacuously. + expect(name, `row ${rowIndex} must name a worker`).toMatch(/^[^\s-]/); + return name; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** The grid row for a worker, found by name, never by position. */ +export function rowOf(page: Page, worker: string): Locator { + return page.locator('#main-header-text tr.mat-mdc-row').filter({ + has: page.locator('.hours-info strong', { + hasText: new RegExp(`^\\s*${escapeRegExp(worker)}\\s*$`), + }), + }); +} + +export function rowCheckbox(page: Page, worker: string): Locator { + return rowOf(page, worker).locator('td.mtx-grid-checkbox-cell input[type="checkbox"]'); +} + +/** A worker's day cell (.plan-container). day = column index, 0 = first visible day. */ +export function cellOf(page: Page, worker: string, day: number): Locator { + return rowOf(page, worker).locator(`.plan-container[id^="cell"][id$="_${day}"]`); +} + +/** The mtx-grid stamps getCellClass onto. */ +export function tdOf(page: Page, worker: string, day: number): Locator { + return cellOf(page, worker, day).locator('xpath=..'); +} + +export async function dialogTitle(page: Page): Promise<{ worker: string; date: string }> { + const title = page.locator('mat-dialog-container [mat-dialog-title]'); + await expect(title).toBeVisible({ timeout: 10000 }); + // " - ()", with the date on its own line. + const raw = (await title.innerText()).replace(/\s+/g, ' ').trim(); + return { + worker: raw.split(/\s+-\s+/)[0].trim(), + date: /(\d{2}\.\d{2}\.\d{4})/.exec(raw)?.[1] ?? '', + }; +} + +/** Opens a day, asserts the dialog is that worker's, and returns the date as dd.MM.yyyy. */ +export async function openDay(page: Page, worker: string, day: number): Promise { + const cell = cellOf(page, worker, day); + await expect(cell).toHaveCount(1); + await cell.scrollIntoViewIfNeeded(); + await cell.click(); + const title = await dialogTitle(page); + expect(title.worker, 'the dialog must belong to the worker the row was found by').toBe(worker); + expect(title.date).toMatch(/^\d{2}\.\d{2}\.\d{4}$/); + return title.date; +} + +/** Cancel with nothing persisted: no save and no reload. */ +export async function closeDayWithoutChange(page: Page): Promise { + await page.locator('#cancelButton').click(); + await expect(page.locator('mat-dialog-container')).toHaveCount(0); +} + +/** Close after a reconcile made in the dialog: the table reloads the grid instead of saving. */ +export async function closeDayAfterLockChange(page: Page): Promise { + const reload = waitForIndex(page, lastWeekMonday()); + await page.locator('#cancelButton').click(); + await reload; + await waitForSpinner(page); +} + +/** Two-step reconcile from the open dialog (§8.2). The dialog stays open, read-only. */ +export async function reconcileOpenDay(page: Page): Promise { + await page.locator('#reconcileButton').click(); + await expect(page.locator('#reconcileConfirmButton')).toBeVisible(); + const put = waitForPut(page, RECONCILE_PATH); + await page.locator('#reconcileConfirmButton').click(); + await expectSuccess(await put); + await expect(page.locator('#reconciledProvenanceText')).toHaveText(PROVENANCE); +} + +/** Reconciles one day and returns its date (dd.MM.yyyy), with the grid reloaded. */ +export async function reconcileDay(page: Page, worker: string, day: number): Promise { + const date = await openDay(page, worker, day); + await reconcileOpenDay(page); + await closeDayAfterLockChange(page); + return date; +} + +/** Typed-word unlock of the open boundary day (§8.4). The dialog closes and the grid reloads. */ +export async function unlockOpenDay(page: Page): Promise { + await page.locator('#unlockButton').click(); + await page.locator('#unlockWordInput').fill(UNLOCK_WORD); + const put = waitForPut(page, UNRECONCILE_PATH); + const reload = waitForIndex(page, lastWeekMonday()); + await page.locator('#unlockConfirmButton').click(); + await expectSuccess(await put); + await reload; + await waitForSpinner(page); +} + +export async function unlockDay(page: Page, worker: string, day: number): Promise { + await openDay(page, worker, day); + await unlockOpenDay(page); +} + +/** Leaves no dialog open, whatever the test was in the middle of. */ +async function closeAnyDialog(page: Page): Promise { + if (await page.locator('mat-dialog-container').count() === 0) { + return; + } + await page.keyboard.press('Escape'); + await expect(page.locator('mat-dialog-container'), + 'a dialog stayed open, so the row could not be cleaned up').toHaveCount(0, { timeout: 10000 }); +} + +/** + * Takes every mark off a worker's row, the newest first, because only the boundary + * can be unlocked. Call it from afterEach: the shard shares one database and runs its + * files in order, so a row left locked by a failed assertion breaks every spec after + * it. It is a no-op when nothing is locked, and it closes any open dialog first. + */ +export async function unlockAll(page: Page, worker: string): Promise { + if (!worker) { + return; + } + await closeAnyDialog(page); + const boundary = rowOf(page, worker).locator('td.reconciled-background'); + // One pass per mark on the row. The bound is the visible week plus slack, so a + // cleanup that stops making progress fails loudly instead of looping forever. + for (let pass = 0; pass < 10; pass++) { + if (await boundary.count() === 0) { + return; + } + await boundary.first().locator('.plan-container').click(); + await unlockOpenDay(page); + } + throw new Error(`${worker}'s row is still locked after 10 unlocks`); +} + +/** Hovers a target and waits for its tooltip. Material renders tooltips in the overlay. */ +export async function expectTooltipOnHover( + page: Page, target: Locator, text: string | RegExp, +): Promise { + await target.hover(); + await expect(page.locator('.cdk-overlay-container .mat-mdc-tooltip-surface') + .filter({ hasText: text })).toBeVisible({ timeout: 10000 }); +} diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/day-lock.util.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/day-lock.util.spec.ts new file mode 100644 index 00000000..0b5bfeb0 --- /dev/null +++ b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/day-lock.util.spec.ts @@ -0,0 +1,264 @@ +import {DatePipe} from '@angular/common'; +import { + buildReconcilePreview, + dayKey, + dayLockState, + formatReconciledProvenance, + hasRegistration, + isBoundaryDay, + isDayLocked, + isDaySealed, + isPlaceholderDay, +} from './day-lock.util'; + +describe('day-lock util', () => { + describe('dayKey', () => { + it('takes the calendar day from a server date without parsing it', () => { + expect(dayKey('2026-09-07T00:00:00')).toBe('2026-09-07'); + }); + + it('formats a Date in local time', () => { + expect(dayKey(new Date(2026, 8, 7, 23, 30))).toBe('2026-09-07'); + }); + + it('puts the last second before local midnight and midnight itself on different days', () => { + expect(dayKey(new Date(2026, 8, 7, 23, 59, 59))).toBe('2026-09-07'); + expect(dayKey(new Date(2026, 8, 8, 0, 0, 0))).toBe('2026-09-08'); + }); + + it('keeps the calendar day a server date names, whatever the offset', () => { + // Parsed as an instant and formatted locally, this is the 8th in UTC, which is + // where CI runs. The key is the day the server means. + expect(dayKey('2026-09-09T00:00:00+02:00')).toBe('2026-09-09'); + }); + + it('returns null for missing or malformed input', () => { + expect(dayKey(null)).toBeNull(); + expect(dayKey(undefined)).toBeNull(); + expect(dayKey('not a date')).toBeNull(); + expect(dayKey(new Date(NaN))).toBeNull(); + }); + }); + + describe('day states', () => { + const day = (date: string, extra: object = {}) => + ({id: 1, date: `${date}T00:00:00`, reconciled: false, reconciledAt: null, ...extra}) as any; + const row = (lockedThrough: string | null, days: any[]) => ({ + lockedThrough: lockedThrough ? `${lockedThrough}T00:00:00` : null, + planningPrDayModels: days, + }) as any; + + // Two reconciles on one row: the 7th first, then the 9th. Both keep their mark; + // the 9th is the boundary. + const staircase = row('2026-09-09', [ + day('2026-09-07', {reconciled: true, reconciledAt: '2026-09-08T10:32:00'}), + day('2026-09-08'), + day('2026-09-09', {reconciled: true, reconciledAt: '2026-09-10T09:15:00'}), + day('2026-09-10'), + ]); + + it('makes only the day at lockedThrough the boundary, and it is locked and sealed', () => { + expect(isBoundaryDay(staircase, '2')).toBe(true); + expect(isDayLocked(staircase, '2')).toBe(true); + expect(isDaySealed(staircase, '2')).toBe(true); + }); + + it('keeps an older reconciled day sealed and locked, but never the boundary', () => { + expect(isBoundaryDay(staircase, '0')).toBe(false); + expect(isDayLocked(staircase, '0')).toBe(true); + expect(isDaySealed(staircase, '0')).toBe(true); + }); + + it('locks an unreconciled day below the boundary without sealing it', () => { + expect(isBoundaryDay(staircase, '1')).toBe(false); + expect(isDayLocked(staircase, '1')).toBe(true); + expect(isDaySealed(staircase, '1')).toBe(false); + }); + + it('leaves a day after the boundary open', () => { + expect(isBoundaryDay(staircase, '3')).toBe(false); + expect(isDayLocked(staircase, '3')).toBe(false); + expect(isDaySealed(staircase, '3')).toBe(false); + }); + + it('reads the column field as a string or a number', () => { + expect(isBoundaryDay(staircase, 2)).toBe(true); + expect(isDayLocked(staircase, 1)).toBe(true); + expect(isDaySealed(staircase, 0)).toBe(true); + }); + + it('locks nothing on a row with no boundary', () => { + const open = row(null, [day('2026-09-07', {reconciled: true})]); + expect(isDayLocked(open, '0')).toBe(false); + expect(isBoundaryDay(open, '0')).toBe(false); + // A stray flag outside the lock seals nothing: the lock decides, not the flag. + expect(isDaySealed(open, '0')).toBe(false); + }); + + it('answers false for a column with no day, or no row at all', () => { + expect(isDayLocked(staircase, '9')).toBe(false); + expect(isBoundaryDay(staircase, '9')).toBe(false); + expect(isDaySealed(staircase, '9')).toBe(false); + expect(isPlaceholderDay(staircase, '9')).toBe(false); + expect(isDayLocked(null, '0')).toBe(false); + expect(isBoundaryDay(undefined, '0')).toBe(false); + }); + + describe('near midnight', () => { + it('matches a late-evening time to the boundary on its own calendar day', () => { + // Compared as instants, 23:30 is after 00:00 on the boundary day, so this + // day would drop out of the lock and lose the boundary. + const late = row('2026-09-09', [day('2026-09-09', {date: '2026-09-09T23:30:00', reconciled: true})]); + expect(isDayLocked(late, '0')).toBe(true); + expect(isBoundaryDay(late, '0')).toBe(true); + }); + + it('keeps the next day open when the boundary carries a late time', () => { + const lateBoundary = {lockedThrough: '2026-09-09T23:59:59', planningPrDayModels: [day('2026-09-10')]} as any; + expect(isDayLocked(lateBoundary, '0')).toBe(false); + expect(isBoundaryDay(lateBoundary, '0')).toBe(false); + }); + + it('never shifts a boundary written with an offset', () => { + // Only the boundary carries the offset, so an instant comparison moves it to + // the 8th in UTC (which is where CI runs) and the day stops being locked. + const withOffset = {lockedThrough: '2026-09-09T00:00:00+02:00', + planningPrDayModels: [day('2026-09-09'), day('2026-09-10')]} as any; + expect(isBoundaryDay(withOffset, '0')).toBe(true); + expect(isDayLocked(withOffset, '0')).toBe(true); + expect(isDayLocked(withOffset, '1')).toBe(false); + }); + }); + + describe('placeholder days', () => { + const withPlaceholder = row('2026-09-09', [ + day('2026-09-07'), + day('2026-09-08', {id: 0}), + day('2026-09-09', {reconciled: true, reconciledAt: '2026-09-10T09:15:00'}), + ]); + + it('treats a day with no registration id as a placeholder, and no other day', () => { + expect(isPlaceholderDay(withPlaceholder, '1')).toBe(true); + expect(isPlaceholderDay(withPlaceholder, '0')).toBe(false); + expect(isPlaceholderDay(withPlaceholder, '2')).toBe(false); + }); + + it('keeps a placeholder locked, so it carries the texture and the lock glyph', () => { + expect(isDayLocked(withPlaceholder, '1')).toBe(true); + expect(isDaySealed(withPlaceholder, '1')).toBe(false); + expect(isBoundaryDay(withPlaceholder, '1')).toBe(false); + }); + + it('reads the registration mark the same way the preview does', () => { + expect(hasRegistration(day('2026-09-07'))).toBe(true); + expect(hasRegistration(day('2026-09-07', {id: 0}))).toBe(false); + expect(hasRegistration(null)).toBe(false); + expect(hasRegistration(undefined)).toBe(false); + }); + }); + + describe('dayLockState', () => { + it('answers all four states in one read', () => { + expect(dayLockState(staircase, '2')).toEqual( + {locked: true, boundary: true, sealed: true, placeholder: false}); + expect(dayLockState(staircase, '0')).toEqual( + {locked: true, boundary: false, sealed: true, placeholder: false}); + expect(dayLockState(staircase, '1')).toEqual( + {locked: true, boundary: false, sealed: false, placeholder: false}); + expect(dayLockState(staircase, '3')).toEqual( + {locked: false, boundary: false, sealed: false, placeholder: false}); + }); + + it('reports a placeholder on a row that has no boundary at all', () => { + // The early return for an unlocked row must not lose the placeholder, which + // is what keeps a cell with nothing behind it from rendering zeros. + const unlocked = row(null, [day('2026-09-07', {id: 0})]); + expect(dayLockState(unlocked, '0')) + .toEqual({locked: false, boundary: false, sealed: false, placeholder: true}); + }); + + it('answers open for a column with no day', () => { + expect(dayLockState(staircase, '9')) + .toEqual({locked: false, boundary: false, sealed: false, placeholder: false}); + }); + }); + }); + + describe('formatReconciledProvenance', () => { + const translate = {instant: jest.fn((key: string, params?: object) => key)} as any; + + it('formats the stamp in the viewer\'s own zone, with no timezone argument', () => { + // The shift stamps pass 'UTC' to the same pipe (formatStamp). ReconciledAt is + // server wall-clock time with no offset, so passing a zone here would shift it. + // In UTC CI the rendered text is the same either way, so the CALL is asserted. + const datePipe = new DatePipe('en-US'); + const transform = jest.spyOn(datePipe, 'transform'); + + formatReconciledProvenance('2026-09-14T10:32:11', datePipe, translate); + + expect(transform).toHaveBeenCalledWith('2026-09-14T10:32:11', 'dd.MM.yyyy'); + expect(transform).toHaveBeenCalledWith('2026-09-14T10:32:11', 'HH:mm'); + expect(translate.instant) + .toHaveBeenLastCalledWith('reconciledProvenance', {date: '14.09.2026', time: '10:32'}); + }); + + it('falls back to the bare state name when there is no timestamp', () => { + formatReconciledProvenance(null, new DatePipe('en-US'), translate); + expect(translate.instant).toHaveBeenLastCalledWith('Reconciled'); + }); + }); + + describe('buildReconcilePreview', () => { + const day = (date: string, id = 1) => ({id, date: `${date}T00:00:00`}) as any; + const row = (siteId: number, lockedThrough: string | null, days: any[]) => ({ + siteId, + lockedThrough: lockedThrough ? `${lockedThrough}T00:00:00` : null, + planningPrDayModels: days, + }) as any; + const week = ['2026-09-07', '2026-09-08', '2026-09-09', '2026-09-10'].map(d => day(d)); + + it('lands on the latest registered day at or before the target', () => { + const preview = buildReconcilePreview([row(1, null, week)], [1], '2026-09-09'); + expect(preview.landingBySiteId[1]).toBe('2026-09-09'); + expect(preview.outcomeBySiteId[1]).toBe('lock'); + expect(preview.siteIds).toEqual([1]); + expect(preview.willReconcileCount).toBe(1); + }); + + it('skips a worker at or past the target, never moves the line back, and still sends it', () => { + const preview = buildReconcilePreview( + [row(1, '2026-09-10', week), row(2, '2026-09-09', week)], [1, 2], '2026-09-09'); + expect(preview.outcomeBySiteId[1]).toBe('skip'); + expect(preview.outcomeBySiteId[2]).toBe('skip'); + expect(preview.siteIds).toEqual([1, 2]); + expect(preview.skipCount).toBe(2); + expect(preview.willReconcileCount).toBe(0); + }); + + it('ignores days without a registration id', () => { + const days = [day('2026-09-07'), day('2026-09-08', 0), day('2026-09-09', 0)]; + const preview = buildReconcilePreview([row(1, null, days)], [1], '2026-09-09'); + expect(preview.landingBySiteId[1]).toBe('2026-09-07'); + }); + + it('never sends a row it could not draw', () => { + const unregistered = ['2026-09-07', '2026-09-08'].map(d => day(d, 0)); + const preview = buildReconcilePreview([row(1, null, unregistered)], [1], '2026-09-08'); + expect(preview.siteIds).toEqual([]); + expect(preview.outcomeBySiteId[1]).toBeUndefined(); + expect(preview.willReconcileCount).toBe(0); + }); + + it('keeps the existing boundary so already-locked days are not highlighted again', () => { + const preview = buildReconcilePreview([row(1, '2026-09-07', week)], [1], '2026-09-09'); + expect(preview.existingBySiteId[1]).toBe('2026-09-07'); + expect(preview.landingBySiteId[1]).toBe('2026-09-09'); + }); + + it('previews only the workers in scope, and sends each once', () => { + const preview = buildReconcilePreview([row(1, null, week), row(2, null, week)], [2, 2], '2026-09-08'); + expect(preview.siteIds).toEqual([2]); + expect(preview.outcomeBySiteId[1]).toBeUndefined(); + }); + }); +}); diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/day-lock.util.ts b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/day-lock.util.ts new file mode 100644 index 00000000..88165bf8 --- /dev/null +++ b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/day-lock.util.ts @@ -0,0 +1,227 @@ +import {DatePipe} from '@angular/common'; +import {TranslateService} from '@ngx-translate/core'; +import {format} from 'date-fns'; +import {PlanningPrDayModel, TimePlanningModel} from '../../models'; + +/** + * Calendar-day key 'yyyy-MM-dd', for comparing days with no timezone involved. + * + * Server dates (PlanningPrDayModel.date, TimePlanningModel.lockedThrough) arrive as + * server-local midnight with no offset, e.g. '2026-09-07T00:00:00', so the first ten + * characters ARE the calendar day. Turning them into a Date and comparing instants is + * how a boundary ends up a day off for part of every evening. A Date is formatted in + * local time. + */ +export function dayKey(value: string | Date | null | undefined): string | null { + if (!value) { + return null; + } + if (value instanceof Date) { + return isNaN(value.getTime()) ? null : format(value, 'yyyy-MM-dd'); + } + return /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : null; +} + +// ---- The three day states (spec §8.1, ruling F20) --------------------------------- +// +// A row's lock is derived from one date, row.lockedThrough (the boundary). Every day +// at or before it is LOCKED. Among the locked days: +// - the BOUNDARY is the single day whose date is lockedThrough; +// - a SEALED day carries its own reconcile mark (reconciled === true). The boundary +// is always sealed, and older days stay sealed after a later reconcile moves the +// boundary on, so several days of a row can be sealed at once. The flag alone +// therefore never makes a day the boundary. +// Every comparison is by calendar day (dayKey), never by instant. + +/** The part of a grid row the day-state predicates read. */ +export type DayLockRow = Pick; + +/** All four states of one day, resolved in a single pass. */ +export interface DayLockState { + /** At or before the row's boundary, the boundary included. A locked day is read-only. */ + locked: boolean; + /** The one day whose date is row.lockedThrough. Only it offers unlock. */ + boundary: boolean; + /** A locked day with its own reconcile mark: the boundary, or an older reconciled day. */ + sealed: boolean; + /** A day with no registration behind it (F17). It is locked, and it renders blank. */ + placeholder: boolean; +} + +/** + * The day under a grid column. The column field is the day's index in the row + * ('0', '1', …): the grid reads days by position. + */ +export function dayAt( + row: DayLockRow | null | undefined, field: string | number, +): PlanningPrDayModel | undefined { + return row?.planningPrDayModels?.[Number(field)]; +} + +/** + * F17: the server sends a non-persisted stand-in, with id 0, for a locked date that + * has no registration, so that every later day stays under its own column header. + * An id is therefore the one mark of a day that exists in the database: no id, no + * row to open, to save or to reconcile, and nothing to render. + */ +export function hasRegistration(day: PlanningPrDayModel | null | undefined): boolean { + return !!day?.id; +} + +/** + * The day's lock state, with the day looked up and the dates parsed once. The + * template, the cell class and the dialog all read it through this, so they can + * never disagree about a cell. + */ +export function dayLockState( + row: DayLockRow | null | undefined, field: string | number, +): DayLockState { + const day = dayAt(row, field); + const placeholder = day !== undefined && day !== null && !hasRegistration(day); + const boundaryKey = dayKey(row?.lockedThrough); + if (boundaryKey === null) { + // Nothing on this row is locked. That is every row of every tenant who has never + // reconciled, so it is answered before a single day date is parsed. + return {locked: false, boundary: false, sealed: false, placeholder}; + } + const key = dayKey(day?.date); + const locked = key !== null && key <= boundaryKey; + return { + locked, + boundary: key === boundaryKey, + // The lock decides, not the flag: an unlock clears the flag, so a mark that + // outlived its lock would be stale. + sealed: locked && day?.reconciled === true, + placeholder, + }; +} + +/** At or before the row's boundary, the boundary itself included. A locked day is read-only. */ +export function isDayLocked(row: DayLockRow | null | undefined, field: string | number): boolean { + return dayLockState(row, field).locked; +} + +/** The boundary: the one day whose date is row.lockedThrough. Only it offers unlock. */ +export function isBoundaryDay(row: DayLockRow | null | undefined, field: string | number): boolean { + return dayLockState(row, field).boundary; +} + +/** A locked day with its own reconcile mark: the boundary, or an older reconciled day. */ +export function isDaySealed(row: DayLockRow | null | undefined, field: string | number): boolean { + return dayLockState(row, field).sealed; +} + +/** A stand-in for a locked date with no registration (F17): not openable, rendered blank. */ +export function isPlaceholderDay(row: DayLockRow | null | undefined, field: string | number): boolean { + return dayLockState(row, field).placeholder; +} + +/** + * "Afstemt 14.09.2026 kl. 10:32" (spec §8.1, §8.2). There is no "by whom": + * ReconciledBy is a declared non-goal. + * + * ReconciledAt is written as DateTime.Now (Task 4) and read back from a datetime(6) + * column, so EF materialises it as DateTimeKind.Unspecified. Newtonsoft + * (RoundtripKind) serialises it with NO offset, and the browser reads it as local + * wall-clock time, which is the server's clock on a Danish deployment. Do NOT pass + * 'UTC' here, the way formatStamp does for the shift stamps. + */ +export function formatReconciledProvenance( + reconciledAt: string | null | undefined, + datePipe: DatePipe, + translate: TranslateService, +): string { + if (!reconciledAt) { + // I1 says a reconciled day always has a timestamp; this covers a malformed row. + return translate.instant('Reconciled'); + } + return translate.instant('reconciledProvenance', { + date: datePipe.transform(reconciledAt, 'dd.MM.yyyy'), + time: datePipe.transform(reconciledAt, 'HH:mm'), + }); +} + +/** 'lock' = the boundary moves to a day on screen. 'skip' = already at or past the target. */ +export type ReconcileRowOutcome = 'lock' | 'skip'; + +export interface ReconcilePreview { + /** 'yyyy-MM-dd'. Sent to reconcile-through as it is. */ + target: string; + /** + * Workers sent to reconcile-through: every row in scope that the preview drew, + * as 'lock' or as 'skip', each once. Skipped rows are sent too, so that the server + * reports them. A row the preview could not draw is never sent (§8.3: the region + * is previewed before it is committed). + */ + siteIds: number[]; + /** Per worker: the day the mark lands on. */ + landingBySiteId: Record; + /** Per worker: the boundary they already have, so already-locked cells are not re-highlighted. */ + existingBySiteId: Record; + outcomeBySiteId: Record; + /** Workers whose boundary will move. */ + willReconcileCount: number; + skipCount: number; +} + +/** + * The region a reconcile-through WILL lock, per worker (spec §8.3). It mirrors the + * server rules in Task 4: + * - A worker already reconciled at or past the target is skipped, never moved back. + * Moving back would be an unlock, which is a separate, heavier action (§8.4). + * - Otherwise the mark lands on the latest day at or before the target that has a + * registration. + * + * Only days on screen are known here. A row with no registered day on screen + * between its boundary and the target has nothing to draw, so it is left out of the + * commit. The date field is limited to days on screen (12A) and the index creates a + * registration for every visible day (fact 11), so this only happens when a visible + * day failed to materialise. + */ +export function buildReconcilePreview( + rows: TimePlanningModel[], + scopeSiteIds: number[], + target: string, +): ReconcilePreview { + const inScope = new Set(scopeSiteIds); + const preview: ReconcilePreview = { + target, + siteIds: [], + landingBySiteId: {}, + existingBySiteId: {}, + outcomeBySiteId: {}, + willReconcileCount: 0, + skipCount: 0, + }; + + for (const row of rows) { + if (!inScope.has(row.siteId) || row.siteId in preview.outcomeBySiteId) { + continue; + } + const existing = dayKey(row.lockedThrough); + preview.existingBySiteId[row.siteId] = existing; + + if (existing !== null && existing >= target) { + preview.outcomeBySiteId[row.siteId] = 'skip'; + preview.siteIds.push(row.siteId); + preview.skipCount++; + continue; + } + + const landing = (row.planningPrDayModels ?? []) + .filter(hasRegistration) + .map(day => dayKey(day.date)) + .filter((key): key is string => key !== null && key <= target) + .sort() + .pop(); + if (!landing || (existing !== null && landing <= existing)) { + continue; + } + + preview.landingBySiteId[row.siteId] = landing; + preview.outcomeBySiteId[row.siteId] = 'lock'; + preview.siteIds.push(row.siteId); + preview.willReconcileCount++; + } + return preview; +} 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 9b222658..53edbbe9 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 @@ -29,6 +29,27 @@ > + +
+ + + lock + + {{ 'lockedTooltip' | translate }} + + + + verified + + {{ 'reconciledLegend' | translate }} + +
+ @@ -238,183 +259,206 @@
- -
- - calendar_month - - {{ convertMinutesToTime(row.planningPrDayModels[col.field]?.plannedStartOfShift1) }} - - {{ convertMinutesToTime(row.planningPrDayModels[col.field]?.plannedEndOfShift1) }} / - {{ formatDuration(row.planningPrDayModels[col.field]?.plannedBreakOfShift1 / 60, false) }} - - -
- -
- - calendar_month - - {{ convertMinutesToTime(row.planningPrDayModels[col.field]?.plannedStartOfShift1) }} - - {{ convertMinutesToTime(row.planningPrDayModels[col.field]?.plannedEndOfShift1) }} / - {{ formatDuration(row.planningPrDayModels[col.field]?.plannedBreakOfShift1 / 60, false) }} - - -
+ + + + + +
+ + calendar_month + + {{ convertMinutesToTime(row.planningPrDayModels[col.field]?.plannedStartOfShift1) }} - + {{ convertMinutesToTime(row.planningPrDayModels[col.field]?.plannedEndOfShift1) }} / + {{ formatDuration(row.planningPrDayModels[col.field]?.plannedBreakOfShift1 / 60, false) }} + + +
-
- - calendar_month - - {{ formatDuration(row.planningPrDayModels[col.field]?.planHours, false) }} - - -
+
+ + calendar_month + + {{ convertMinutesToTime(row.planningPrDayModels[col.field]?.plannedStartOfShift1) }} - + {{ convertMinutesToTime(row.planningPrDayModels[col.field]?.plannedEndOfShift1) }} / + {{ formatDuration(row.planningPrDayModels[col.field]?.plannedBreakOfShift1 / 60, false) }} + + +
-
- login {{ formatStamp(row, row.planningPrDayModels[col.field]?.start1StartedAt) }} - - - {{ getStopTimeDisplayWithSeconds(row, row.planningPrDayModels[col.field]?.start1StartedAt, row.planningPrDayModels[col.field]?.stop1StoppedAt) }} logout - - - - warning - -
-
- - calendar_month - {{ convertMinutesToTime(row.planningPrDayModels[col.field]?.plannedStartOfShift2) }} - - {{ convertMinutesToTime(row.planningPrDayModels[col.field]?.plannedEndOfShift2) }} / - {{ formatDuration(row.planningPrDayModels[col.field]?.plannedBreakOfShift2 / 60, false) }} - -
-
- login {{ formatStamp(row, row.planningPrDayModels[col.field]?.start2StartedAt) }} - - - {{ getStopTimeDisplayWithSeconds(row, row.planningPrDayModels[col.field]?.start2StartedAt, row.planningPrDayModels[col.field]?.stop2StoppedAt) }} logout - - - - warning - -
+
+ + calendar_month + + {{ formatDuration(row.planningPrDayModels[col.field]?.planHours, false) }} + + +
-
- - calendar_month - {{ convertMinutesToTime(row.planningPrDayModels[col.field]?.plannedStartOfShift3) }} - - {{ convertMinutesToTime(row.planningPrDayModels[col.field]?.plannedEndOfShift3) }} / - {{ formatDuration(row.planningPrDayModels[col.field]?.plannedBreakOfShift3 / 60, false) }} - -
-
- login {{ formatStamp(row, row.planningPrDayModels[col.field]?.start3StartedAt) }} - - - {{ getStopTimeDisplayWithSeconds(row, row.planningPrDayModels[col.field]?.start3StartedAt, row.planningPrDayModels[col.field]?.stop3StoppedAt) }} logout - - - - warning - -
+
+ login {{ formatStamp(row, row.planningPrDayModels[col.field]?.start1StartedAt) }} + - + {{ getStopTimeDisplayWithSeconds(row, row.planningPrDayModels[col.field]?.start1StartedAt, row.planningPrDayModels[col.field]?.stop1StoppedAt) }} logout + + - + warning + +
+
+ + calendar_month + {{ convertMinutesToTime(row.planningPrDayModels[col.field]?.plannedStartOfShift2) }} - + {{ convertMinutesToTime(row.planningPrDayModels[col.field]?.plannedEndOfShift2) }} / + {{ formatDuration(row.planningPrDayModels[col.field]?.plannedBreakOfShift2 / 60, false) }} + +
+
+ login {{ formatStamp(row, row.planningPrDayModels[col.field]?.start2StartedAt) }} + - + {{ getStopTimeDisplayWithSeconds(row, row.planningPrDayModels[col.field]?.start2StartedAt, row.planningPrDayModels[col.field]?.stop2StoppedAt) }} logout + + - + warning + +
-
- - calendar_month - {{ convertMinutesToTime(row.planningPrDayModels[col.field]?.plannedStartOfShift4) }} - - {{ convertMinutesToTime(row.planningPrDayModels[col.field]?.plannedEndOfShift4) }} / - {{ formatDuration(row.planningPrDayModels[col.field]?.plannedBreakOfShift4 / 60, false) }} - -
-
- login {{ formatStamp(row, row.planningPrDayModels[col.field]?.start4StartedAt) }} - - - {{ getStopTimeDisplayWithSeconds(row, row.planningPrDayModels[col.field]?.start4StartedAt, row.planningPrDayModels[col.field]?.stop4StoppedAt) }} logout - - - - warning - -
+
+ + calendar_month + {{ convertMinutesToTime(row.planningPrDayModels[col.field]?.plannedStartOfShift3) }} - + {{ convertMinutesToTime(row.planningPrDayModels[col.field]?.plannedEndOfShift3) }} / + {{ formatDuration(row.planningPrDayModels[col.field]?.plannedBreakOfShift3 / 60, false) }} + +
+
+ login {{ formatStamp(row, row.planningPrDayModels[col.field]?.start3StartedAt) }} + - + {{ getStopTimeDisplayWithSeconds(row, row.planningPrDayModels[col.field]?.start3StartedAt, row.planningPrDayModels[col.field]?.stop3StoppedAt) }} logout + + - + warning + +
-
- - calendar_month - {{ convertMinutesToTime(row.planningPrDayModels[col.field]?.plannedStartOfShift5) }} - - {{ convertMinutesToTime(row.planningPrDayModels[col.field]?.plannedEndOfShift5) }} / - {{ formatDuration(row.planningPrDayModels[col.field]?.plannedBreakOfShift5 / 60, false) }} - -
-
- login {{ formatStamp(row, row.planningPrDayModels[col.field]?.start5StartedAt) }} - - - {{ getStopTimeDisplayWithSeconds(row, row.planningPrDayModels[col.field]?.start5StartedAt, row.planningPrDayModels[col.field]?.stop5StoppedAt) }} logout - - - - warning - -
+
+ + calendar_month + {{ convertMinutesToTime(row.planningPrDayModels[col.field]?.plannedStartOfShift4) }} - + {{ convertMinutesToTime(row.planningPrDayModels[col.field]?.plannedEndOfShift4) }} / + {{ formatDuration(row.planningPrDayModels[col.field]?.plannedBreakOfShift4 / 60, false) }} + +
+
+ login {{ formatStamp(row, row.planningPrDayModels[col.field]?.start4StartedAt) }} + - + {{ getStopTimeDisplayWithSeconds(row, row.planningPrDayModels[col.field]?.start4StartedAt, row.planningPrDayModels[col.field]?.stop4StoppedAt) }} logout + + - + warning + +
- -
-
- pause{{ formatDuration(row.planningPrDayModels[col.field]?.pauseMinutes / 60) }} +
+ + calendar_month + {{ convertMinutesToTime(row.planningPrDayModels[col.field]?.plannedStartOfShift5) }} - + {{ convertMinutesToTime(row.planningPrDayModels[col.field]?.plannedEndOfShift5) }} / + {{ formatDuration(row.planningPrDayModels[col.field]?.plannedBreakOfShift5 / 60, false) }} +
-
- +
+ login {{ formatStamp(row, row.planningPrDayModels[col.field]?.start5StartedAt) }} + - + {{ getStopTimeDisplayWithSeconds(row, row.planningPrDayModels[col.field]?.start5StartedAt, row.planningPrDayModels[col.field]?.stop5StoppedAt) }} logout + + - + warning + +
+ + +
+
+ pause{{ formatDuration(row.planningPrDayModels[col.field]?.pauseMinutes / 60) }} +
+
+
+ + + +
+
+ schedule{{ formatDuration(row.planningPrDayModels[col.field]?.actualHours) }} +
+
+
+ +
+
+ schedule{{ formatDuration(row.planningPrDayModels[col.field]?.nettoHoursOverride) }} +
+
+
+
- - -
+ +
+
+ payments{{ formatDuration(row.planningPrDayModels[col.field]?.paidOutFlex) }} +
+
+
+ + +
+
+ swap_vert{{ formatDuration(row.planningPrDayModels[col.field]?.sumFlexEnd) }} +
+
+
+
- schedule{{ formatDuration(row.planningPrDayModels[col.field]?.actualHours) }} + face + {{row.planningPrDayModels[col.field]?.workerComment}}
- - -
+
- schedule{{ formatDuration(row.planningPrDayModels[col.field]?.nettoHoursOverride) }} + gite + {{row.planningPrDayModels[col.field]?.commentOffice}}
- - - - -
-
- payments{{ formatDuration(row.planningPrDayModels[col.field]?.paidOutFlex) }} +
+
+ + (id: {{ row.planningPrDayModels[col.field]?.id }}) + +
-
- + - -
-
- swap_vert{{ formatDuration(row.planningPrDayModels[col.field]?.sumFlexEnd) }} -
-
+ + + lock +
-
-
- face - {{row.planningPrDayModels[col.field]?.workerComment}} -
-
-
-
- gite - {{row.planningPrDayModels[col.field]?.commentOffice}} -
-
-
-
- - (id: {{ row.planningPrDayModels[col.field]?.id }}) - -
-
@@ -429,6 +473,19 @@ clock_arrow_down luggage pregnant_woman + + + verified +
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 973bb118..8c387bbd 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 @@ -314,6 +314,138 @@ describe('TimePlanningsTableComponent', () => { expect(component.getCellClass(row, '0')).toBe('grey-background'); }); + + // The reconciled ("Afstemt") day lock layers on top of the four state + // backgrounds. Ruling F20: only the day at lockedThrough is the boundary, and it + // alone gets the border class. Every other day at or before it is locked, + // whether or not it carries a reconcile mark of its own. + describe('the day lock', () => { + const lockedRow = (date: string, extra: any = {}) => ({ + lockedThrough: '2026-09-09T00:00:00', + planningPrDayModels: { + '0': { + id: 1, + date: `${date}T00:00:00`, + reconciled: false, + planHours: 0, + start1StartedAt: null, + start2StartedAt: null, + workDayEnded: false, + plannedStartOfShift1: null, + message: null, + workerComment: null, + nettoHoursOverrideActive: false, + ...extra + } + } + }); + + it('gives the boundary day the reconciled background, on top of its state', () => { + expect(component.getCellClass(lockedRow('2026-09-09', { reconciled: true }), '0')) + .toBe('white-background reconciled-background'); + }); + + it('gives an older reconciled day the locked background, never the boundary border', () => { + expect(component.getCellClass(lockedRow('2026-09-07', { reconciled: true }), '0')) + .toBe('white-background locked-background'); + }); + + it('gives a day locked by a later reconciled day the locked background', () => { + expect(component.getCellClass(lockedRow('2026-09-08'), '0')) + .toBe('white-background locked-background'); + }); + + it('leaves a day after the boundary as it was', () => { + expect(component.getCellClass(lockedRow('2026-09-10'), '0')).toBe('white-background'); + }); + + it('leaves every day as it was when the row has no boundary', () => { + const row = lockedRow('2026-09-07', { reconciled: true }); + row.lockedThrough = null as any; + expect(component.getCellClass(row, '0')).toBe('white-background'); + }); + }); + }); + + // The legend under the grid explains the hatch, so it appears exactly when a + // locked day is on screen. + describe('hasLockedDayInView', () => { + const rowWithDays = (lockedThrough: string | null) => ({ + lockedThrough, + planningPrDayModels: [ + { id: 1, date: '2026-09-08T00:00:00', reconciled: false }, + { id: 1, date: '2026-09-09T00:00:00', reconciled: true } + ] + }) as any; + + it('is true once a row in view has a locked day', () => { + component.timePlannings = [rowWithDays('2026-09-09T00:00:00')]; + component.ngOnChanges({ timePlannings: { currentValue: component.timePlannings } } as any); + expect(component.hasLockedDayInView).toBe(true); + }); + + it('is false when nothing in view is locked', () => { + component.timePlannings = [rowWithDays(null)]; + component.ngOnChanges({ timePlannings: { currentValue: component.timePlannings } } as any); + expect(component.hasLockedDayInView).toBe(false); + }); + }); + + describe('onDayColumnClick', () => { + const dayRow = (date: string, extra: any = {}) => ({ + siteId: 7, + tags: [], + lockedThrough: '2026-09-09T00:00:00', + planningPrDayModels: { + '0': { id: 1, date: `${date}T00:00:00`, reconciled: false, ...extra } + } + }); + + const dialogData = () => (mockDialog.open as jest.Mock).mock.calls[0][1].data; + + beforeEach(() => { + mockSettingsService.getAssignedSite.mockReturnValue(of({ success: true, model: {} }) as any); + (mockDialog.open as jest.Mock).mockReturnValue({ afterClosed: () => of(undefined) } as any); + }); + + it('does not open a placeholder day, which has no registration behind it', () => { + // F17: id 0 stands in for a locked date with no row, so there is nothing to + // fetch and nothing to open. + component.onDayColumnClick(dayRow('2026-09-08', { id: 0 }), '0'); + + expect(mockSettingsService.getAssignedSite).not.toHaveBeenCalled(); + expect(mockDialog.open).not.toHaveBeenCalled(); + }); + + it('tells the dialog that the boundary day is locked, sealed and the boundary', () => { + component.onDayColumnClick(dayRow('2026-09-09', { reconciled: true }), '0'); + + expect(mockDialog.open).toHaveBeenCalled(); + expect(dialogData()).toMatchObject({ + isLocked: true, + isBoundary: true, + isSealed: true, + lockedThrough: '2026-09-09T00:00:00' + }); + }); + + it('tells the dialog that an older reconciled day is sealed but not the boundary', () => { + component.onDayColumnClick(dayRow('2026-09-07', { reconciled: true }), '0'); + + expect(dialogData()).toMatchObject({ isLocked: true, isBoundary: false, isSealed: true }); + }); + + it('tells the dialog that a cascade-locked day carries no mark of its own', () => { + component.onDayColumnClick(dayRow('2026-09-08'), '0'); + + expect(dialogData()).toMatchObject({ isLocked: true, isBoundary: false, isSealed: false }); + }); + + it('tells the dialog that a day after the boundary is open', () => { + component.onDayColumnClick(dayRow('2026-09-10'), '0'); + + expect(dialogData()).toMatchObject({ isLocked: false, isBoundary: false, isSealed: false }); + }); }); describe('isInOlderThanToday', () => { 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 0f5e9ced..39273756 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 @@ -17,6 +17,7 @@ import {selectAuthIsAdmin, selectCurrentUserIsFirstUser} from 'src/app/state'; import {applyGridHelpAnchors} from '../../../help/grid-help-anchors'; import {HelpEntryId} from '../../../help/help.model'; import {HelpPanelService} from '../../../help/services/help-panel.service'; +import {dayAt, dayLockState, formatReconciledProvenance, isDayLocked, isDaySealed} from '../day-lock.util'; @Component({ selector: 'app-time-plannings-table', @@ -59,6 +60,20 @@ export class TimePlanningsTableComponent implements OnInit, OnChanges, AfterView private waitingForFreshData = false; @Output() highlightedRowRendered: EventEmitter = new EventEmitter(); + // The day states (spec §8.1, ruling F20) come from the shared util, so the grid, + // the dialog and the bulk preview can never disagree about a cell. Exposed for the + // day-cell template: dayLockState resolves the cell once for the content column, + // and the seal sits in the icon column, outside that read. + protected readonly dayLockState = dayLockState; + protected readonly isDaySealed = isDaySealed; + + /** + * Legend switch (spec §8.1): shown whenever a locked day is on screen, because + * otherwise nobody learns what the hatch means. Recomputed when rows arrive, never + * on every change-detection pass. + */ + hasLockedDayInView = false; + ngOnInit(): void { this.enumKeys = Object.keys(TimePlanningMessagesEnum).filter(key => isNaN(Number(key))); this.updateTableHeaders(); @@ -83,6 +98,20 @@ export class TimePlanningsTableComponent implements OnInit, OnChanges, AfterView this.waitingForFreshData = false; this.highlightApplied = false; } + if (changes.timePlannings) { + this.hasLockedDayInView = this.computeHasLockedDayInView(); + } + } + + /** Seal tooltip: "Afstemt 14.09.2026 kl. 10:32". */ + reconciledTooltip(row: any, field: string): string { + return formatReconciledProvenance( + dayAt(row, field)?.reconciledAt, this.datePipe, this.translateService); + } + + private computeHasLockedDayInView(): boolean { + return (this.timePlannings ?? []).some(row => + (row.planningPrDayModels ?? []).some((_, index) => isDayLocked(row, index))); } /** @@ -198,7 +227,25 @@ export class TimePlanningsTableComponent implements OnInit, OnChanges, AfterView return rounded === '-0.00' ? '0.00' : rounded; } + /** + * The td class: the day's state background, with the lock layered on top (F20). + * Only the boundary gets reconciled-background, which draws the 3px staircase + * border. Every other day at or before it, older sealed days included, gets + * locked-background; the seal glyph in the cell tells a sealed day apart. + */ getCellClass(row: any, field: string): string { + const base = this.getCellStateClass(row, field); + const lock = dayLockState(row, field); + if (lock.boundary) { + return `${base} reconciled-background`; + } + if (lock.locked) { + return `${base} locked-background`; + } + return base; + } + + private getCellStateClass(row: any, field: string): string { // const date = row.planningPrDayModels[field]?.date; try { const cellData = row.planningPrDayModels[field]; @@ -456,12 +503,29 @@ export class TimePlanningsTableComponent implements OnInit, OnChanges, AfterView } onDayColumnClick(row: any, field: string): void { + const lock = dayLockState(row, field); + // F17: a placeholder has no registration behind it, so there is nothing to open. + if (lock.placeholder) { + return; + } const siteId = row.siteId; const cellData = R.clone(row.planningPrDayModels[field]); this.timePlanningPnSettingsService.getAssignedSite(siteId).subscribe(result => { if (result && result.success) { this.dialog.open(WorkdayEntityDialogComponent, { - data: {planningPrDayModels: cellData, assignedSiteModel: result.model, tags: row.tags ?? []}, + data: { + planningPrDayModels: cellData, + assignedSiteModel: result.model, + tags: row.tags ?? [], + // A locked day still opens, read-only: people read closed days + // constantly. Only the boundary offers unlock (F20); a sealed day shows + // its provenance; any other locked day names lockedThrough as the day + // to free first. + isLocked: lock.locked, + isBoundary: lock.boundary, + isSealed: lock.sealed, + lockedThrough: row.lockedThrough ?? null, + }, minWidth: 1024, minHeight: 500, maxWidth: '95vw', // Add this diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/help-wiring.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/help-wiring.spec.ts index 37102fb6..c4e972cc 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/help/help-wiring.spec.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/help-wiring.spec.ts @@ -41,13 +41,18 @@ const MARKUP = [read(CONTAINER_HTML), read(TABLE_HTML), read(DIALOG_HTML)].join( * stood before the help system was mounted. The help chrome takes its labels * from HelpUiStrings, so this task added none; a new entry here means someone * added a key that has to be translated into all 25 shared locale files. + * + * `lockedTooltip` and `reconciledLegend` are the reconciled day lock's two grid + * strings (Task 9A). Task 13A adds them, and the rest of the lock's keys, to every + * locale file. */ const TEMPLATE_TRANSLATE_KEYS = [ 'Actual', 'Auto break calculation', 'Cancel', 'CommentOffice', 'CommentWorker', 'Date range', 'Download Excel', 'Export to payroll', 'Flex', 'Flex balance at start of day', - 'Flex balance to date', 'keyboard_tab', 'keyboard_tab_rtl', 'Needs update!', 'NettoHours', + 'Flex balance to date', 'keyboard_tab', 'keyboard_tab_rtl', 'lockedTooltip', + 'Needs update!', 'NettoHours', 'NettoHours override', 'No pay rule set selected', 'PaidOutFlex', 'Pause', 'Plan hours', - 'Planned working hours', 'Reload table', 'Reset pause to recorded', 'Save', + 'Planned working hours', 'reconciledLegend', 'Reload table', 'Reset pause to recorded', 'Save', 'Shift not stopped by user!', 'Shifts across midnight', 'Show resigned', 'Start', 'Stop', 'Tags', 'Total breaktime', 'Total working hours', 'Use 1-minute intervals', 'View GPS Location', 'View history', 'View Snapshot', 'Worker', 'Worktime start', From c92d3ff99599191ca07d25bb78e7a4e119f682a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Wed, 16 Sep 2026 11:01:25 +0200 Subject: [PATCH 18/28] feat(lock): add help entries and translations for the day lock Add three help entries (Danish and English prose) for the reconcile-through toolbar control and the two new day-cell lock states. Add the day-lock UI strings in Danish and English, carrying English into the other 24 locales until translated. Co-Authored-By: Claude Opus 5 --- .../time-planning-pn/help/help.model.ts | 3 ++- .../modules/time-planning-pn/help/i18n/da.ts | 18 ++++++++++++++++ .../time-planning-pn/help/i18n/enUS.ts | 18 ++++++++++++++++ .../help/planning-help.registry.ts | 3 +++ .../modules/time-planning-pn/i18n/bgBG.ts | 21 +++++++++++++++++++ .../modules/time-planning-pn/i18n/csCZ.ts | 21 +++++++++++++++++++ .../modules/time-planning-pn/i18n/da.ts | 21 +++++++++++++++++++ .../modules/time-planning-pn/i18n/deDE.ts | 21 +++++++++++++++++++ .../modules/time-planning-pn/i18n/elGR.ts | 21 +++++++++++++++++++ .../modules/time-planning-pn/i18n/enUS.ts | 21 +++++++++++++++++++ .../modules/time-planning-pn/i18n/esES.ts | 21 +++++++++++++++++++ .../modules/time-planning-pn/i18n/etET.ts | 21 +++++++++++++++++++ .../modules/time-planning-pn/i18n/fiFI.ts | 21 +++++++++++++++++++ .../modules/time-planning-pn/i18n/frFR.ts | 21 +++++++++++++++++++ .../modules/time-planning-pn/i18n/hrHR.ts | 21 +++++++++++++++++++ .../modules/time-planning-pn/i18n/huHU.ts | 21 +++++++++++++++++++ .../modules/time-planning-pn/i18n/isIS.ts | 21 +++++++++++++++++++ .../modules/time-planning-pn/i18n/itIT.ts | 21 +++++++++++++++++++ .../modules/time-planning-pn/i18n/ltLT.ts | 21 +++++++++++++++++++ .../modules/time-planning-pn/i18n/lvLV.ts | 21 +++++++++++++++++++ .../modules/time-planning-pn/i18n/nlNL.ts | 21 +++++++++++++++++++ .../modules/time-planning-pn/i18n/noNO.ts | 21 +++++++++++++++++++ .../modules/time-planning-pn/i18n/plPL.ts | 21 +++++++++++++++++++ .../modules/time-planning-pn/i18n/ptBR.ts | 21 +++++++++++++++++++ .../modules/time-planning-pn/i18n/ptPT.ts | 21 +++++++++++++++++++ .../modules/time-planning-pn/i18n/roRO.ts | 21 +++++++++++++++++++ .../modules/time-planning-pn/i18n/skSK.ts | 21 +++++++++++++++++++ .../modules/time-planning-pn/i18n/slSL.ts | 21 +++++++++++++++++++ .../modules/time-planning-pn/i18n/svSE.ts | 21 +++++++++++++++++++ .../modules/time-planning-pn/i18n/ukUA.ts | 21 +++++++++++++++++++ 30 files changed, 587 insertions(+), 1 deletion(-) diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/help.model.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/help.model.ts index 52b3cd27..a4073214 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/help/help.model.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/help.model.ts @@ -8,6 +8,7 @@ export const HELP_IDS = [ 'toolbar.showResigned', 'toolbar.navBackward', 'toolbar.navForward', 'toolbar.workerFilter', 'toolbar.tagFilter', 'toolbar.dateRange', 'toolbar.downloadExcel', 'toolbar.payrollExport', 'toolbar.reload', + 'toolbar.reconcileThrough', // grid controls 'grid.nameColumn', 'grid.tagChips', 'grid.settingsStrip', 'grid.dayCellAnatomy', 'grid.weeklyPlannedHours', 'grid.messageIcons', 'grid.sortName', 'grid.openDay', @@ -18,7 +19,7 @@ export const HELP_IDS = [ 'dayCell.gps', 'dayCell.snapshot', 'dayCell.futureDisabled', 'dayCell.planHours', 'dayCell.nettoOverride', 'dayCell.paidOutFlex', 'dayCell.flags', 'dayCell.commentOffice', 'dayCell.save', 'dayCell.oneMinuteIntervals', - 'dayCell.planHoursLimit', + 'dayCell.planHoursLimit', 'dayCell.reconciled', 'dayCell.lockedByReconciled', // flex controls 'flex.whatIsFlex', 'flex.sumFlex', 'flex.paidOutFlexRelation', ] as const; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/da.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/da.ts index 53cb9177..128381f1 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/da.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/da.ts @@ -205,6 +205,12 @@ export const da: HelpProseMap = { detail: 'Skemaet opdaterer ikke sig selv. Brug knappen, når du venter på, at en medarbejder registrerer noget, eller hvis et tal ser forældet ud.', keywords: ['genindlæs', 'opdater', 'hent igen', 'forældet', 'vises ikke', 'synkroniser', 'gammelt tal'], }, + 'toolbar.reconcileThrough': { + title: 'Afstem til og med', + short: 'Sæt grænsen for flere medarbejdere på én gang.', + detail: 'Vælg en dato. Er ingen rækker markeret, gælder den alle synlige medarbejdere. Kun grænsedagen markeres som afstemt — alt før låses automatisk.', + keywords: ['afstem', 'flere', 'månedsafslutning', 'lås'], + }, // --------------------------------------------------------------- skemaet ---- 'grid.nameColumn': { @@ -365,6 +371,18 @@ export const da: HelpProseMap = { detail: 'Se på den planlagte start og slut for hver vagt på dagen: det er summen, der måles, ikke den enkelte vagt. En vagt, der går over midnat, hører delvist til den næste dag, så læg timerne efter midnat på den dag i stedet for at samle dem her.', keywords: ['24 timer', 'overstiger', 'for mange timer', 'planlagte timer', 'validering', 'fejl', 'rød', 'gem slået fra', 'kan ikke gemme', 'over 24', 'grænse'], }, + 'dayCell.reconciled': { + title: 'Afstemt', + short: 'Dagens tal er endelige.', + detail: 'Dagen er afstemt, og tallene ændres ikke længere — heller ikke af en efterberegning. Alle dage før denne er samtidig låst.', + keywords: ['afstemt', 'låst', 'endelig', 'afslutning'], + }, + 'dayCell.lockedByReconciled': { + title: 'Låst', + short: 'Dagen ligger før en afstemt dag.', + detail: 'Dagen kan ikke ændres, fordi en senere dag er afstemt. Lås den seneste afstemte dag op først.', + keywords: ['låst', 'afstemt', 'tidligere'], + }, // ------------------------------------------------------------------ flex ---- 'flex.whatIsFlex': { diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/enUS.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/enUS.ts index 8bb893ce..6c3ff941 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/enUS.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/enUS.ts @@ -201,6 +201,12 @@ export const enUS: HelpProseMap = { detail: 'The grid does not refresh itself. Use this when you are waiting for a worker to register something, or if a number looks stale.', keywords: ['reload', 'refresh', 'update', 'reset', 'fetch', 'stale', 'not showing', 'sync'], }, + 'toolbar.reconcileThrough': { + title: 'Reconcile through', + short: 'Sets the boundary for several workers at once.', + detail: 'Pick a date. With no rows selected it applies to every worker on screen. Only the boundary day is marked reconciled — everything before it locks automatically.', + keywords: ['reconcile', 'several', 'month end', 'lock'], + }, // ----------------------------------------------------------------- grid ---- 'grid.nameColumn': { @@ -361,6 +367,18 @@ export const enUS: HelpProseMap = { detail: 'Check the planned start and stop of each shift on the day: the total is what is being measured, not any single shift. A shift that runs past midnight belongs partly to the next day, so plan the hours after midnight on that day instead of stacking them onto this one.', keywords: ['24 hours', 'exceed', 'too many hours', 'planned hours', 'validation', 'error', 'red', 'save disabled', 'cannot save', 'over 24', 'limit'], }, + 'dayCell.reconciled': { + title: 'Reconciled', + short: 'The figures for the day are final.', + detail: 'The day is reconciled, and its figures no longer change — not even from a recalculation. Every day before it is locked at the same time.', + keywords: ['reconciled', 'locked', 'final', 'closed'], + }, + 'dayCell.lockedByReconciled': { + title: 'Locked', + short: 'The day falls before a reconciled day.', + detail: 'The day cannot be changed, because a later day is reconciled. Unlock the latest reconciled day first.', + keywords: ['locked', 'reconciled', 'earlier'], + }, // ----------------------------------------------------------------- flex ---- 'flex.whatIsFlex': { diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/planning-help.registry.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/planning-help.registry.ts index 2d3a5fee..2ac88e44 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/help/planning-help.registry.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/planning-help.registry.ts @@ -42,6 +42,7 @@ export const PLANNING_HELP_ENTRIES: HelpEntry[] = [ { id: 'toolbar.payrollExport', kind: 'control', section: 'toolbar', anchor: 'toolbar.payrollExport', tour: 'page', tourStep: 7, adminOnly: true }, { id: 'toolbar.reload', kind: 'control', section: 'toolbar', anchor: 'toolbar.reload' }, + { id: 'toolbar.reconcileThrough', kind: 'control', section: 'toolbar', anchor: 'toolbar.reconcileThrough' }, // ---- grid ---- { id: 'grid.nameColumn', kind: 'control', section: 'grid', anchor: 'grid.nameColumn', @@ -81,6 +82,8 @@ export const PLANNING_HELP_ENTRIES: HelpEntry[] = [ tour: 'dialog', tourStep: 6 }, { id: 'dayCell.oneMinuteIntervals', kind: 'control', section: 'dayCell', anchor: 'dayCell.oneMinuteIntervals' }, { id: 'dayCell.planHoursLimit', kind: 'control', section: 'dayCell', anchor: 'dayCell.planHoursLimit' }, + { id: 'dayCell.reconciled', kind: 'control', section: 'dayCell', anchor: 'dayCell.reconciled' }, + { id: 'dayCell.lockedByReconciled', kind: 'control', section: 'dayCell', anchor: 'dayCell.lockedByReconciled' }, // ---- flex ---- { id: 'flex.whatIsFlex', kind: 'control', section: 'flex', anchor: 'flex.whatIsFlex' }, diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/bgBG.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/bgBG.ts index 83e079bf..499c799b 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/bgBG.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/bgBG.ts @@ -471,4 +471,25 @@ export const bgBG = { 'Enter a duration as hours:minutes, for example 7:24': 'Въведете продължителност като часове:минути, например 7:24', 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Нивата определят времевите прагове като часове:минути. Например, Ниво 1: до 8:00 (8 часа) = Редовно заплащане. Оставете „До“ празно за неограничено.', PregnancyLeave: 'Отсъствие, свързано с бременност', + // Reconciled ("Afstemt") day lock. English until translated. + 'Reconcile day': 'Reconcile day', + 'Reconcile through': 'Reconcile through', + Unlock: 'Unlock', + Reconciled: 'Reconciled', + Locked: 'Locked', + Reconcile: 'Reconcile', + lockedTooltip: 'Locked · falls before a reconciled day', + reconciledLegend: 'Reconciled · the figures for the day are final', + reconciledProvenance: 'Reconciled {{date}} at {{time}}', + reconcileHeaderTooltip: 'Reconcile through this day', + reconcileRowSkipped: 'Skipped · already reconciled further ahead', + reconcileScopeSummary: 'Reconcile through {{date}} · Workers: {{count}}', + reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', + reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', + reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', + reconcileNeedsSave: 'Save your changes before reconciling the day', + unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', + unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', + UNLOCK: 'UNLOCK', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/csCZ.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/csCZ.ts index b40b76a7..f7a35af8 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/csCZ.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/csCZ.ts @@ -471,4 +471,25 @@ export const csCZ = { 'Enter a duration as hours:minutes, for example 7:24': 'Zadejte dobu trvání ve formátu hodiny:minuty, například 7:24', 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Úrovně definují časové prahy jako hodiny:minuty. Např. Úroveň 1: do 8:00 (8 hodin) = běžná mzda. Pro neomezený čas nechte pole „Do“ prázdné.', PregnancyLeave: 'Absence související s těhotenstvím', + // Reconciled ("Afstemt") day lock. English until translated. + 'Reconcile day': 'Reconcile day', + 'Reconcile through': 'Reconcile through', + Unlock: 'Unlock', + Reconciled: 'Reconciled', + Locked: 'Locked', + Reconcile: 'Reconcile', + lockedTooltip: 'Locked · falls before a reconciled day', + reconciledLegend: 'Reconciled · the figures for the day are final', + reconciledProvenance: 'Reconciled {{date}} at {{time}}', + reconcileHeaderTooltip: 'Reconcile through this day', + reconcileRowSkipped: 'Skipped · already reconciled further ahead', + reconcileScopeSummary: 'Reconcile through {{date}} · Workers: {{count}}', + reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', + reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', + reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', + reconcileNeedsSave: 'Save your changes before reconciling the day', + unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', + unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', + UNLOCK: 'UNLOCK', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/da.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/da.ts index fd8847ef..141d43e7 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/da.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/da.ts @@ -469,4 +469,25 @@ export const da = { Close: 'Luk', 'Once enabled, 1-minute intervals cannot be turned off': 'Når 1-minutters intervaller er aktiveret, kan de ikke slås fra', PregnancyLeave: 'Graviditetsbetinget fravær', + 'Reconcile day': 'Afstem dag', + 'Reconcile through': 'Afstem til og med', + Unlock: 'Lås op', + Reconciled: 'Afstemt', + Locked: 'Låst', + // Reconciled ("Afstemt") day lock: grid, dialog footer and bulk scope. + Reconcile: 'Afstem', + lockedTooltip: 'Låst · ligger før en afstemt dag', + reconciledLegend: 'Afstemt · dagens tal er endelige', + reconciledProvenance: 'Afstemt {{date}} kl. {{time}}', + reconcileHeaderTooltip: 'Afstem til og med denne dag', + reconcileRowSkipped: 'Springes over · allerede afstemt længere frem', + reconcileScopeSummary: 'Afstem til og med {{date}} · Medarbejdere: {{count}}', + reconcileScopeSkipped: 'Springes over: {{count}} (allerede afstemt længere frem)', + reconcileThroughResult: 'Afstemt: {{applied}} · Sprunget over: {{skipped}}', + reconcileThroughUnchanged: 'Uændret: {{count}}', + reconcileDayConfirm: 'Afstem {{worker}} {{date}}? Dagen og alle dage før den bliver låst.', + reconcileNeedsSave: 'Gem ændringerne, før dagen afstemmes', + unlockFreeFirst: 'Låst, fordi {{date}} er afstemt. Lås {{date}} op først.', + unlockTypeWordPrompt: 'Afstemningen fjernes, og dagen kan redigeres igen. Skriv {{word}} for at bekræfte.', + UNLOCK: 'LÅS OP', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/deDE.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/deDE.ts index 0d7593c7..42d46988 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/deDE.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/deDE.ts @@ -471,4 +471,25 @@ export const deDE = { 'Enter a duration as hours:minutes, for example 7:24': 'Geben Sie eine Dauer im Format Stunden:Minuten ein, zum Beispiel 7:24', 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Die Stufen definieren Zeitgrenzen in Stunden:Minuten. Beispiel: Stufe 1: bis zu 8:00 Uhr (8 Stunden) = Normalgehalt. Lassen Sie „Bis zu“ leer, wenn Sie unbegrenzt arbeiten möchten.', PregnancyLeave: 'Schwangerschaftsbedingte Abwesenheit', + // Reconciled ("Afstemt") day lock. English until translated. + 'Reconcile day': 'Reconcile day', + 'Reconcile through': 'Reconcile through', + Unlock: 'Unlock', + Reconciled: 'Reconciled', + Locked: 'Locked', + Reconcile: 'Reconcile', + lockedTooltip: 'Locked · falls before a reconciled day', + reconciledLegend: 'Reconciled · the figures for the day are final', + reconciledProvenance: 'Reconciled {{date}} at {{time}}', + reconcileHeaderTooltip: 'Reconcile through this day', + reconcileRowSkipped: 'Skipped · already reconciled further ahead', + reconcileScopeSummary: 'Reconcile through {{date}} · Workers: {{count}}', + reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', + reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', + reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', + reconcileNeedsSave: 'Save your changes before reconciling the day', + unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', + unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', + UNLOCK: 'UNLOCK', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/elGR.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/elGR.ts index b6509433..51b3bb9e 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/elGR.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/elGR.ts @@ -471,4 +471,25 @@ export const elGR = { 'Enter a duration as hours:minutes, for example 7:24': 'Εισαγάγετε μια διάρκεια ως ώρες:λεπτά, για παράδειγμα 7:24', 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Τα επίπεδα ορίζουν τα χρονικά όρια ως ώρες:λεπτά. Π.χ., Επίπεδο 1: έως 8:00 (8 ώρες) = Κανονική αμοιβή. Αφήστε το "Έως" κενό για απεριόριστο.', PregnancyLeave: 'Απουσία που σχετίζεται με την εγκυμοσύνη', + // Reconciled ("Afstemt") day lock. English until translated. + 'Reconcile day': 'Reconcile day', + 'Reconcile through': 'Reconcile through', + Unlock: 'Unlock', + Reconciled: 'Reconciled', + Locked: 'Locked', + Reconcile: 'Reconcile', + lockedTooltip: 'Locked · falls before a reconciled day', + reconciledLegend: 'Reconciled · the figures for the day are final', + reconciledProvenance: 'Reconciled {{date}} at {{time}}', + reconcileHeaderTooltip: 'Reconcile through this day', + reconcileRowSkipped: 'Skipped · already reconciled further ahead', + reconcileScopeSummary: 'Reconcile through {{date}} · Workers: {{count}}', + reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', + reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', + reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', + reconcileNeedsSave: 'Save your changes before reconciling the day', + unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', + unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', + UNLOCK: 'UNLOCK', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/enUS.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/enUS.ts index 0613a12d..a94b25ec 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/enUS.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/enUS.ts @@ -469,4 +469,25 @@ export const enUS = { t: 'h', min: 'min', timer: 'hrs', + 'Reconcile day': 'Reconcile day', + 'Reconcile through': 'Reconcile through', + Unlock: 'Unlock', + Reconciled: 'Reconciled', + Locked: 'Locked', + // Reconciled ("Afstemt") day lock: grid, dialog footer and bulk scope. + Reconcile: 'Reconcile', + lockedTooltip: 'Locked · falls before a reconciled day', + reconciledLegend: 'Reconciled · the figures for the day are final', + reconciledProvenance: 'Reconciled {{date}} at {{time}}', + reconcileHeaderTooltip: 'Reconcile through this day', + reconcileRowSkipped: 'Skipped · already reconciled further ahead', + reconcileScopeSummary: 'Reconcile through {{date}} · Workers: {{count}}', + reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', + reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', + reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', + reconcileNeedsSave: 'Save your changes before reconciling the day', + unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', + unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', + UNLOCK: 'UNLOCK', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/esES.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/esES.ts index 4c111df1..dae9a9b0 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/esES.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/esES.ts @@ -471,4 +471,25 @@ export const esES = { 'Enter a duration as hours:minutes, for example 7:24': 'Introduzca una duración en formato horas:minutos, por ejemplo 7:24.', 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Los niveles definen los umbrales de tiempo como horas:minutos. Por ejemplo, Nivel 1: hasta las 8:00 (8 horas) = Pago regular. Deje "Hasta" en blanco para un período ilimitado.', PregnancyLeave: 'Ausencia relacionada con el embarazo', + // Reconciled ("Afstemt") day lock. English until translated. + 'Reconcile day': 'Reconcile day', + 'Reconcile through': 'Reconcile through', + Unlock: 'Unlock', + Reconciled: 'Reconciled', + Locked: 'Locked', + Reconcile: 'Reconcile', + lockedTooltip: 'Locked · falls before a reconciled day', + reconciledLegend: 'Reconciled · the figures for the day are final', + reconciledProvenance: 'Reconciled {{date}} at {{time}}', + reconcileHeaderTooltip: 'Reconcile through this day', + reconcileRowSkipped: 'Skipped · already reconciled further ahead', + reconcileScopeSummary: 'Reconcile through {{date}} · Workers: {{count}}', + reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', + reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', + reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', + reconcileNeedsSave: 'Save your changes before reconciling the day', + unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', + unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', + UNLOCK: 'UNLOCK', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/etET.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/etET.ts index 6887d1ed..3946012c 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/etET.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/etET.ts @@ -471,4 +471,25 @@ export const etET = { 'Enter a duration as hours:minutes, for example 7:24': 'Sisesta kestus kujul tunnid:minutid, näiteks 7:24', 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Tasemed defineerivad ajalävesid tundide ja minutitena. Näiteks 1. tasand: kuni kella 8.00-ni (8 tundi) = regulaarne palk. Piiramatu aja saamiseks jätke "Kuni" tühjaks.', PregnancyLeave: 'Rasedusega seotud puudumine', + // Reconciled ("Afstemt") day lock. English until translated. + 'Reconcile day': 'Reconcile day', + 'Reconcile through': 'Reconcile through', + Unlock: 'Unlock', + Reconciled: 'Reconciled', + Locked: 'Locked', + Reconcile: 'Reconcile', + lockedTooltip: 'Locked · falls before a reconciled day', + reconciledLegend: 'Reconciled · the figures for the day are final', + reconciledProvenance: 'Reconciled {{date}} at {{time}}', + reconcileHeaderTooltip: 'Reconcile through this day', + reconcileRowSkipped: 'Skipped · already reconciled further ahead', + reconcileScopeSummary: 'Reconcile through {{date}} · Workers: {{count}}', + reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', + reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', + reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', + reconcileNeedsSave: 'Save your changes before reconciling the day', + unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', + unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', + UNLOCK: 'UNLOCK', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/fiFI.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/fiFI.ts index 1bd9acb4..322b58eb 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/fiFI.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/fiFI.ts @@ -471,4 +471,25 @@ export const fiFI = { 'Enter a duration as hours:minutes, for example 7:24': 'Syötä kesto muodossa tunnit:minuutit, esimerkiksi 7:24', 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Tasot määrittelevät aikarajat muodossa tunnit:minuutit. Esim. Taso 1: klo 8.00 asti (8 tuntia) = Normaali palkka. Jätä "Jopa" tyhjäksi, jos haluat rajoittamattoman ajan.', PregnancyLeave: 'Raskauteen liittyvä poissaolo', + // Reconciled ("Afstemt") day lock. English until translated. + 'Reconcile day': 'Reconcile day', + 'Reconcile through': 'Reconcile through', + Unlock: 'Unlock', + Reconciled: 'Reconciled', + Locked: 'Locked', + Reconcile: 'Reconcile', + lockedTooltip: 'Locked · falls before a reconciled day', + reconciledLegend: 'Reconciled · the figures for the day are final', + reconciledProvenance: 'Reconciled {{date}} at {{time}}', + reconcileHeaderTooltip: 'Reconcile through this day', + reconcileRowSkipped: 'Skipped · already reconciled further ahead', + reconcileScopeSummary: 'Reconcile through {{date}} · Workers: {{count}}', + reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', + reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', + reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', + reconcileNeedsSave: 'Save your changes before reconciling the day', + unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', + unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', + UNLOCK: 'UNLOCK', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/frFR.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/frFR.ts index bc528fc9..4d8521c0 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/frFR.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/frFR.ts @@ -471,4 +471,25 @@ export const frFR = { 'Enter a duration as hours:minutes, for example 7:24': 'Saisissez une durée au format heures:minutes, par exemple 7:24', 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Les paliers définissent des seuils temporels en heures:minutes. Ex. : Palier 1 : jusqu’à 8 h 00 (8 heures) = Rémunération normale. Laissez le champ « Jusqu’à » vide pour une durée illimitée.', PregnancyLeave: 'Absence liée à la grossesse', + // Reconciled ("Afstemt") day lock. English until translated. + 'Reconcile day': 'Reconcile day', + 'Reconcile through': 'Reconcile through', + Unlock: 'Unlock', + Reconciled: 'Reconciled', + Locked: 'Locked', + Reconcile: 'Reconcile', + lockedTooltip: 'Locked · falls before a reconciled day', + reconciledLegend: 'Reconciled · the figures for the day are final', + reconciledProvenance: 'Reconciled {{date}} at {{time}}', + reconcileHeaderTooltip: 'Reconcile through this day', + reconcileRowSkipped: 'Skipped · already reconciled further ahead', + reconcileScopeSummary: 'Reconcile through {{date}} · Workers: {{count}}', + reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', + reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', + reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', + reconcileNeedsSave: 'Save your changes before reconciling the day', + unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', + unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', + UNLOCK: 'UNLOCK', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/hrHR.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/hrHR.ts index 9a72f730..10aca118 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/hrHR.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/hrHR.ts @@ -471,4 +471,25 @@ export const hrHR = { 'Enter a duration as hours:minutes, for example 7:24': 'Unesite trajanje u formatu sati:minute, na primjer 7:24', 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Razine definiraju vremenske pragove kao sati:minute. Npr. Razina 1: do 8:00 (8 sati) = Redovna plaća. Ostavite "Do" prazno za neograničeno.', PregnancyLeave: 'Izostanci povezani s trudnoćom', + // Reconciled ("Afstemt") day lock. English until translated. + 'Reconcile day': 'Reconcile day', + 'Reconcile through': 'Reconcile through', + Unlock: 'Unlock', + Reconciled: 'Reconciled', + Locked: 'Locked', + Reconcile: 'Reconcile', + lockedTooltip: 'Locked · falls before a reconciled day', + reconciledLegend: 'Reconciled · the figures for the day are final', + reconciledProvenance: 'Reconciled {{date}} at {{time}}', + reconcileHeaderTooltip: 'Reconcile through this day', + reconcileRowSkipped: 'Skipped · already reconciled further ahead', + reconcileScopeSummary: 'Reconcile through {{date}} · Workers: {{count}}', + reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', + reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', + reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', + reconcileNeedsSave: 'Save your changes before reconciling the day', + unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', + unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', + UNLOCK: 'UNLOCK', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/huHU.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/huHU.ts index 03f9ec0b..cbb08962 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/huHU.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/huHU.ts @@ -471,4 +471,25 @@ export const huHU = { 'Enter a duration as hours:minutes, for example 7:24': 'Adja meg az időtartamot óra:perc formátumban, például 7:24', 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'A szintek óra:perc formátumban határozzák meg az időküszöböket. Pl. 1. szint: 8:00-ig (8 óra) = Rendszeres fizetés. Korlátlan időhöz hagyja üresen az „Egész” mezőt.', PregnancyLeave: 'Terhességgel kapcsolatos hiányzás', + // Reconciled ("Afstemt") day lock. English until translated. + 'Reconcile day': 'Reconcile day', + 'Reconcile through': 'Reconcile through', + Unlock: 'Unlock', + Reconciled: 'Reconciled', + Locked: 'Locked', + Reconcile: 'Reconcile', + lockedTooltip: 'Locked · falls before a reconciled day', + reconciledLegend: 'Reconciled · the figures for the day are final', + reconciledProvenance: 'Reconciled {{date}} at {{time}}', + reconcileHeaderTooltip: 'Reconcile through this day', + reconcileRowSkipped: 'Skipped · already reconciled further ahead', + reconcileScopeSummary: 'Reconcile through {{date}} · Workers: {{count}}', + reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', + reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', + reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', + reconcileNeedsSave: 'Save your changes before reconciling the day', + unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', + unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', + UNLOCK: 'UNLOCK', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/isIS.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/isIS.ts index e5498e0d..dec0a49e 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/isIS.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/isIS.ts @@ -471,4 +471,25 @@ export const isIS = { 'Enter a duration as hours:minutes, for example 7:24': 'Sláðu inn tímalengd sem klukkustundir:mínútur, til dæmis 7:24', 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Þrep skilgreina tímamörk sem klukkustundir:mínútur. T.d. Þrep 1: allt að 8:00 (8 klukkustundir) = Venjuleg laun. Skiljið „Allt að“ eftir autt fyrir ótakmarkaðan tíma.', PregnancyLeave: 'Fjarvera vegna meðgöngu', + // Reconciled ("Afstemt") day lock. English until translated. + 'Reconcile day': 'Reconcile day', + 'Reconcile through': 'Reconcile through', + Unlock: 'Unlock', + Reconciled: 'Reconciled', + Locked: 'Locked', + Reconcile: 'Reconcile', + lockedTooltip: 'Locked · falls before a reconciled day', + reconciledLegend: 'Reconciled · the figures for the day are final', + reconciledProvenance: 'Reconciled {{date}} at {{time}}', + reconcileHeaderTooltip: 'Reconcile through this day', + reconcileRowSkipped: 'Skipped · already reconciled further ahead', + reconcileScopeSummary: 'Reconcile through {{date}} · Workers: {{count}}', + reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', + reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', + reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', + reconcileNeedsSave: 'Save your changes before reconciling the day', + unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', + unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', + UNLOCK: 'UNLOCK', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/itIT.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/itIT.ts index 97296cbe..f1dfdcb9 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/itIT.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/itIT.ts @@ -471,4 +471,25 @@ export const itIT = { 'Enter a duration as hours:minutes, for example 7:24': 'Inserisci la durata nel formato ore:minuti, ad esempio 7:24', 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'I livelli definiscono le soglie temporali in ore:minuti. Ad esempio, Livello 1: fino a 8:00 (8 ore) = Retribuzione ordinaria. Lasciare vuoto il campo "Fino a" per una retribuzione illimitata.', PregnancyLeave: 'Assenza correlata alla gravidanza', + // Reconciled ("Afstemt") day lock. English until translated. + 'Reconcile day': 'Reconcile day', + 'Reconcile through': 'Reconcile through', + Unlock: 'Unlock', + Reconciled: 'Reconciled', + Locked: 'Locked', + Reconcile: 'Reconcile', + lockedTooltip: 'Locked · falls before a reconciled day', + reconciledLegend: 'Reconciled · the figures for the day are final', + reconciledProvenance: 'Reconciled {{date}} at {{time}}', + reconcileHeaderTooltip: 'Reconcile through this day', + reconcileRowSkipped: 'Skipped · already reconciled further ahead', + reconcileScopeSummary: 'Reconcile through {{date}} · Workers: {{count}}', + reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', + reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', + reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', + reconcileNeedsSave: 'Save your changes before reconciling the day', + unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', + unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', + UNLOCK: 'UNLOCK', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ltLT.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ltLT.ts index 256c4523..2fa9de13 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ltLT.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ltLT.ts @@ -471,4 +471,25 @@ export const ltLT = { 'Enter a duration as hours:minutes, for example 7:24': 'Įveskite trukmę valandų:minutių formatu, pavyzdžiui, 7:24', 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Pakopos apibrėžia laiko ribas kaip valandas:minutes. Pvz., 1 pakopa: iki 8:00 (8 valandos) = Įprastas atlyginimas. Palikite laukelį „Iki“ tuščią, jei norite neriboto laiko.', PregnancyLeave: 'Su nėštumu susijęs nebuvimas', + // Reconciled ("Afstemt") day lock. English until translated. + 'Reconcile day': 'Reconcile day', + 'Reconcile through': 'Reconcile through', + Unlock: 'Unlock', + Reconciled: 'Reconciled', + Locked: 'Locked', + Reconcile: 'Reconcile', + lockedTooltip: 'Locked · falls before a reconciled day', + reconciledLegend: 'Reconciled · the figures for the day are final', + reconciledProvenance: 'Reconciled {{date}} at {{time}}', + reconcileHeaderTooltip: 'Reconcile through this day', + reconcileRowSkipped: 'Skipped · already reconciled further ahead', + reconcileScopeSummary: 'Reconcile through {{date}} · Workers: {{count}}', + reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', + reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', + reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', + reconcileNeedsSave: 'Save your changes before reconciling the day', + unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', + unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', + UNLOCK: 'UNLOCK', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/lvLV.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/lvLV.ts index 1884d785..5496cfa7 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/lvLV.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/lvLV.ts @@ -471,4 +471,25 @@ export const lvLV = { 'Enter a duration as hours:minutes, for example 7:24': 'Ievadiet ilgumu kā stundas:minūtes, piemēram, 7:24', 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Līmeņi definē laika sliekšņus kā stundas:minūtes. Piemēram, 1. līmenis: līdz plkst. 8:00 (8 stundas) = regulāra samaksa. Lai iegūtu neierobežotu laiku, atstājiet lauku "Līdz" tukšu.', PregnancyLeave: 'Ar grūtniecību saistīta prombūtne', + // Reconciled ("Afstemt") day lock. English until translated. + 'Reconcile day': 'Reconcile day', + 'Reconcile through': 'Reconcile through', + Unlock: 'Unlock', + Reconciled: 'Reconciled', + Locked: 'Locked', + Reconcile: 'Reconcile', + lockedTooltip: 'Locked · falls before a reconciled day', + reconciledLegend: 'Reconciled · the figures for the day are final', + reconciledProvenance: 'Reconciled {{date}} at {{time}}', + reconcileHeaderTooltip: 'Reconcile through this day', + reconcileRowSkipped: 'Skipped · already reconciled further ahead', + reconcileScopeSummary: 'Reconcile through {{date}} · Workers: {{count}}', + reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', + reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', + reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', + reconcileNeedsSave: 'Save your changes before reconciling the day', + unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', + unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', + UNLOCK: 'UNLOCK', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/nlNL.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/nlNL.ts index 13756186..f4107c36 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/nlNL.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/nlNL.ts @@ -471,4 +471,25 @@ export const nlNL = { 'Enter a duration as hours:minutes, for example 7:24': 'Voer een tijdsduur in als uren:minuten, bijvoorbeeld 7:24', 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'De niveaus definiëren tijdslimieten in uren:minuten. Bijvoorbeeld: Niveau 1: tot 8:00 (8 uur) = Normaal loon. Laat "Tot" leeg voor onbeperkt.', PregnancyLeave: 'Zwangerschapsgerelateerde afwezigheid', + // Reconciled ("Afstemt") day lock. English until translated. + 'Reconcile day': 'Reconcile day', + 'Reconcile through': 'Reconcile through', + Unlock: 'Unlock', + Reconciled: 'Reconciled', + Locked: 'Locked', + Reconcile: 'Reconcile', + lockedTooltip: 'Locked · falls before a reconciled day', + reconciledLegend: 'Reconciled · the figures for the day are final', + reconciledProvenance: 'Reconciled {{date}} at {{time}}', + reconcileHeaderTooltip: 'Reconcile through this day', + reconcileRowSkipped: 'Skipped · already reconciled further ahead', + reconcileScopeSummary: 'Reconcile through {{date}} · Workers: {{count}}', + reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', + reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', + reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', + reconcileNeedsSave: 'Save your changes before reconciling the day', + unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', + unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', + UNLOCK: 'UNLOCK', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/noNO.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/noNO.ts index 8b0e44ea..2dc4a794 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/noNO.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/noNO.ts @@ -471,4 +471,25 @@ export const noNO = { 'Enter a duration as hours:minutes, for example 7:24': 'Skriv inn en varighet som timer:minutter, for eksempel 7:24', 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Nivåer definerer tidsgrenser som timer:minutter. F.eks. Nivå 1: opptil 8:00 (8 timer) = Vanlig lønn. La «Opptil» stå tomt for ubegrenset tid.', PregnancyLeave: 'Graviditetsrelatert fravær', + // Reconciled ("Afstemt") day lock. English until translated. + 'Reconcile day': 'Reconcile day', + 'Reconcile through': 'Reconcile through', + Unlock: 'Unlock', + Reconciled: 'Reconciled', + Locked: 'Locked', + Reconcile: 'Reconcile', + lockedTooltip: 'Locked · falls before a reconciled day', + reconciledLegend: 'Reconciled · the figures for the day are final', + reconciledProvenance: 'Reconciled {{date}} at {{time}}', + reconcileHeaderTooltip: 'Reconcile through this day', + reconcileRowSkipped: 'Skipped · already reconciled further ahead', + reconcileScopeSummary: 'Reconcile through {{date}} · Workers: {{count}}', + reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', + reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', + reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', + reconcileNeedsSave: 'Save your changes before reconciling the day', + unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', + unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', + UNLOCK: 'UNLOCK', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/plPL.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/plPL.ts index 397b9403..c0230e1e 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/plPL.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/plPL.ts @@ -471,4 +471,25 @@ export const plPL = { 'Enter a duration as hours:minutes, for example 7:24': 'Wprowadź czas trwania w formacie godziny:minuty, na przykład 7:24', 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Poziomy definiują progi czasowe w postaci godzin:minut. Np. Poziom 1: do 8:00 (8 godzin) = płaca regularna. Pozostaw pole „Do”, aby uzyskać nieograniczony czas.', PregnancyLeave: 'Nieobecność związana z ciążą', + // Reconciled ("Afstemt") day lock. English until translated. + 'Reconcile day': 'Reconcile day', + 'Reconcile through': 'Reconcile through', + Unlock: 'Unlock', + Reconciled: 'Reconciled', + Locked: 'Locked', + Reconcile: 'Reconcile', + lockedTooltip: 'Locked · falls before a reconciled day', + reconciledLegend: 'Reconciled · the figures for the day are final', + reconciledProvenance: 'Reconciled {{date}} at {{time}}', + reconcileHeaderTooltip: 'Reconcile through this day', + reconcileRowSkipped: 'Skipped · already reconciled further ahead', + reconcileScopeSummary: 'Reconcile through {{date}} · Workers: {{count}}', + reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', + reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', + reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', + reconcileNeedsSave: 'Save your changes before reconciling the day', + unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', + unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', + UNLOCK: 'UNLOCK', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ptBR.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ptBR.ts index 7ccc413d..27c0e168 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ptBR.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ptBR.ts @@ -471,4 +471,25 @@ export const ptBR = { 'Enter a duration as hours:minutes, for example 7:24': 'Insira a duração no formato horas:minutos, por exemplo, 7:24.', 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Os níveis definem limites de tempo em horas:minutos. Exemplo: Nível 1: até 8:00 (8 horas) = Pagamento normal. Deixe "Até" em branco para pagamento ilimitado.', PregnancyLeave: 'Ausência relacionada à gravidez', + // Reconciled ("Afstemt") day lock. English until translated. + 'Reconcile day': 'Reconcile day', + 'Reconcile through': 'Reconcile through', + Unlock: 'Unlock', + Reconciled: 'Reconciled', + Locked: 'Locked', + Reconcile: 'Reconcile', + lockedTooltip: 'Locked · falls before a reconciled day', + reconciledLegend: 'Reconciled · the figures for the day are final', + reconciledProvenance: 'Reconciled {{date}} at {{time}}', + reconcileHeaderTooltip: 'Reconcile through this day', + reconcileRowSkipped: 'Skipped · already reconciled further ahead', + reconcileScopeSummary: 'Reconcile through {{date}} · Workers: {{count}}', + reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', + reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', + reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', + reconcileNeedsSave: 'Save your changes before reconciling the day', + unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', + unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', + UNLOCK: 'UNLOCK', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ptPT.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ptPT.ts index a4464f8d..ab4ec0eb 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ptPT.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ptPT.ts @@ -471,4 +471,25 @@ export const ptPT = { 'Enter a duration as hours:minutes, for example 7:24': 'Insira a duração no formato horas:minutos, por exemplo, 7:24.', 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Os níveis definem limites de tempo em horas:minutos. Exemplo: Nível 1: até 8:00 (8 horas) = Pagamento normal. Deixe "Até" em branco para pagamento ilimitado.', PregnancyLeave: 'Ausência relacionada à gravidez', + // Reconciled ("Afstemt") day lock. English until translated. + 'Reconcile day': 'Reconcile day', + 'Reconcile through': 'Reconcile through', + Unlock: 'Unlock', + Reconciled: 'Reconciled', + Locked: 'Locked', + Reconcile: 'Reconcile', + lockedTooltip: 'Locked · falls before a reconciled day', + reconciledLegend: 'Reconciled · the figures for the day are final', + reconciledProvenance: 'Reconciled {{date}} at {{time}}', + reconcileHeaderTooltip: 'Reconcile through this day', + reconcileRowSkipped: 'Skipped · already reconciled further ahead', + reconcileScopeSummary: 'Reconcile through {{date}} · Workers: {{count}}', + reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', + reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', + reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', + reconcileNeedsSave: 'Save your changes before reconciling the day', + unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', + unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', + UNLOCK: 'UNLOCK', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/roRO.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/roRO.ts index 3a253a38..64701702 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/roRO.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/roRO.ts @@ -471,4 +471,25 @@ export const roRO = { 'Enter a duration as hours:minutes, for example 7:24': 'Introduceți o durată în format ore:minute, de exemplu 7:24', 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Nivelurile definesc pragurile de timp ca ore:minute. De exemplu, Nivelul 1: până la 8:00 (8 ore) = Salariu normal. Lăsați „Până la” gol pentru nelimitat.', PregnancyLeave: 'Absență legată de sarcină', + // Reconciled ("Afstemt") day lock. English until translated. + 'Reconcile day': 'Reconcile day', + 'Reconcile through': 'Reconcile through', + Unlock: 'Unlock', + Reconciled: 'Reconciled', + Locked: 'Locked', + Reconcile: 'Reconcile', + lockedTooltip: 'Locked · falls before a reconciled day', + reconciledLegend: 'Reconciled · the figures for the day are final', + reconciledProvenance: 'Reconciled {{date}} at {{time}}', + reconcileHeaderTooltip: 'Reconcile through this day', + reconcileRowSkipped: 'Skipped · already reconciled further ahead', + reconcileScopeSummary: 'Reconcile through {{date}} · Workers: {{count}}', + reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', + reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', + reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', + reconcileNeedsSave: 'Save your changes before reconciling the day', + unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', + unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', + UNLOCK: 'UNLOCK', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/skSK.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/skSK.ts index 34d9c09c..cb57a0f9 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/skSK.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/skSK.ts @@ -471,4 +471,25 @@ export const skSK = { 'Enter a duration as hours:minutes, for example 7:24': 'Zadajte trvanie vo formáte hodiny:minúty, napríklad 7:24', 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Úrovne definujú časové prahy ako hodiny:minúty. Napr. Úroveň 1: do 8:00 (8 hodín) = bežná mzda. Pre neobmedzený čas nechajte pole „Do“ prázdne.', PregnancyLeave: 'Neprítomnosť súvisiaca s tehotenstvom', + // Reconciled ("Afstemt") day lock. English until translated. + 'Reconcile day': 'Reconcile day', + 'Reconcile through': 'Reconcile through', + Unlock: 'Unlock', + Reconciled: 'Reconciled', + Locked: 'Locked', + Reconcile: 'Reconcile', + lockedTooltip: 'Locked · falls before a reconciled day', + reconciledLegend: 'Reconciled · the figures for the day are final', + reconciledProvenance: 'Reconciled {{date}} at {{time}}', + reconcileHeaderTooltip: 'Reconcile through this day', + reconcileRowSkipped: 'Skipped · already reconciled further ahead', + reconcileScopeSummary: 'Reconcile through {{date}} · Workers: {{count}}', + reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', + reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', + reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', + reconcileNeedsSave: 'Save your changes before reconciling the day', + unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', + unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', + UNLOCK: 'UNLOCK', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/slSL.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/slSL.ts index e7ceb7f2..84f302cb 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/slSL.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/slSL.ts @@ -471,4 +471,25 @@ export const slSL = { 'Enter a duration as hours:minutes, for example 7:24': 'Vnesite trajanje v obliki ur:minut, na primer 7:24', 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Stopnje določajo časovne pragove kot ure:minute. Npr. Stopnja 1: do 8:00 (8 ur) = Redno plačilo. Za neomejeno plačilo pustite polje »Do« prazno.', PregnancyLeave: 'Odsotnost zaradi nosečnosti', + // Reconciled ("Afstemt") day lock. English until translated. + 'Reconcile day': 'Reconcile day', + 'Reconcile through': 'Reconcile through', + Unlock: 'Unlock', + Reconciled: 'Reconciled', + Locked: 'Locked', + Reconcile: 'Reconcile', + lockedTooltip: 'Locked · falls before a reconciled day', + reconciledLegend: 'Reconciled · the figures for the day are final', + reconciledProvenance: 'Reconciled {{date}} at {{time}}', + reconcileHeaderTooltip: 'Reconcile through this day', + reconcileRowSkipped: 'Skipped · already reconciled further ahead', + reconcileScopeSummary: 'Reconcile through {{date}} · Workers: {{count}}', + reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', + reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', + reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', + reconcileNeedsSave: 'Save your changes before reconciling the day', + unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', + unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', + UNLOCK: 'UNLOCK', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/svSE.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/svSE.ts index cb297b1b..cdaedfc0 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/svSE.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/svSE.ts @@ -471,4 +471,25 @@ export const svSE = { 'Enter a duration as hours:minutes, for example 7:24': 'Ange en varaktighet som timmar:minuter, till exempel 7:24', 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Nivåer definierar tidsgränser som timmar:minuter. T.ex. Nivå 1: upp till 8:00 (8 timmar) = Ordinarie lön. Lämna "Upp till" tomt för obegränsat antal.', PregnancyLeave: 'Graviditetsrelaterad frånvaro', + // Reconciled ("Afstemt") day lock. English until translated. + 'Reconcile day': 'Reconcile day', + 'Reconcile through': 'Reconcile through', + Unlock: 'Unlock', + Reconciled: 'Reconciled', + Locked: 'Locked', + Reconcile: 'Reconcile', + lockedTooltip: 'Locked · falls before a reconciled day', + reconciledLegend: 'Reconciled · the figures for the day are final', + reconciledProvenance: 'Reconciled {{date}} at {{time}}', + reconcileHeaderTooltip: 'Reconcile through this day', + reconcileRowSkipped: 'Skipped · already reconciled further ahead', + reconcileScopeSummary: 'Reconcile through {{date}} · Workers: {{count}}', + reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', + reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', + reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', + reconcileNeedsSave: 'Save your changes before reconciling the day', + unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', + unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', + UNLOCK: 'UNLOCK', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ukUA.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ukUA.ts index baa7bd6a..feb4f45d 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ukUA.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ukUA.ts @@ -471,4 +471,25 @@ export const ukUA = { 'Enter a duration as hours:minutes, for example 7:24': 'Введіть тривалість у форматі годин:хвилини, наприклад, 7:24', 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Рівні визначають часові пороги як години:хвилини. Наприклад, Рівень 1: до 8:00 (8 годин) = Звичайна оплата. Залиште поле «До» порожнім для необмеженого часу.', PregnancyLeave: 'Відсутність, пов\'язана з вагітністю', + // Reconciled ("Afstemt") day lock. English until translated. + 'Reconcile day': 'Reconcile day', + 'Reconcile through': 'Reconcile through', + Unlock: 'Unlock', + Reconciled: 'Reconciled', + Locked: 'Locked', + Reconcile: 'Reconcile', + lockedTooltip: 'Locked · falls before a reconciled day', + reconciledLegend: 'Reconciled · the figures for the day are final', + reconciledProvenance: 'Reconciled {{date}} at {{time}}', + reconcileHeaderTooltip: 'Reconcile through this day', + reconcileRowSkipped: 'Skipped · already reconciled further ahead', + reconcileScopeSummary: 'Reconcile through {{date}} · Workers: {{count}}', + reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', + reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', + reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', + reconcileNeedsSave: 'Save your changes before reconciling the day', + unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', + unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', + UNLOCK: 'UNLOCK', }; From 1eef6c8c9ad8971887841bf8a77e7cc81541ed1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Wed, 16 Sep 2026 12:14:42 +0200 Subject: [PATCH 19/28] feat(lock): open locked days read-only, with reconcile and unlock A locked day opens read-only, including the reset affordances, and cannot be saved. Reconcile takes two steps in the footer and leaves the provenance line behind. Unlock is offered only on the newest reconciled day and asks for a typed word; other locked days name the day to free first. A request that strands, times out or answers late leaves the day's state uncertain rather than claiming failure, and the grid refreshes on close. Co-Authored-By: Claude Opus 5 --- .../s/reconcile-dialog-confirm.spec.ts | 99 +++ .../s/reconcile-unlock-word.spec.ts | 80 +++ .../workday-entity-dialog.component.html | 179 +++++- .../workday-entity-dialog.component.spec.ts | 590 +++++++++++++++++- .../workday-entity-dialog.component.ts | 351 ++++++++++- .../time-plannings-table.component.spec.ts | 36 ++ .../time-plannings-table.component.ts | 38 +- .../time-planning-pn/help/help-wiring.spec.ts | 16 +- .../modules/time-planning-pn/i18n/bgBG.ts | 2 + .../modules/time-planning-pn/i18n/csCZ.ts | 2 + .../modules/time-planning-pn/i18n/da.ts | 2 + .../modules/time-planning-pn/i18n/deDE.ts | 2 + .../modules/time-planning-pn/i18n/elGR.ts | 2 + .../modules/time-planning-pn/i18n/enUS.ts | 2 + .../modules/time-planning-pn/i18n/esES.ts | 2 + .../modules/time-planning-pn/i18n/etET.ts | 2 + .../modules/time-planning-pn/i18n/fiFI.ts | 2 + .../modules/time-planning-pn/i18n/frFR.ts | 2 + .../modules/time-planning-pn/i18n/hrHR.ts | 2 + .../modules/time-planning-pn/i18n/huHU.ts | 2 + .../modules/time-planning-pn/i18n/isIS.ts | 2 + .../modules/time-planning-pn/i18n/itIT.ts | 2 + .../modules/time-planning-pn/i18n/ltLT.ts | 2 + .../modules/time-planning-pn/i18n/lvLV.ts | 2 + .../modules/time-planning-pn/i18n/nlNL.ts | 2 + .../modules/time-planning-pn/i18n/noNO.ts | 2 + .../modules/time-planning-pn/i18n/plPL.ts | 2 + .../modules/time-planning-pn/i18n/ptBR.ts | 2 + .../modules/time-planning-pn/i18n/ptPT.ts | 2 + .../modules/time-planning-pn/i18n/roRO.ts | 2 + .../modules/time-planning-pn/i18n/skSK.ts | 2 + .../modules/time-planning-pn/i18n/slSL.ts | 2 + .../modules/time-planning-pn/i18n/svSE.ts | 2 + .../modules/time-planning-pn/i18n/ukUA.ts | 2 + 34 files changed, 1392 insertions(+), 49 deletions(-) create mode 100644 eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-dialog-confirm.spec.ts create mode 100644 eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-unlock-word.spec.ts diff --git a/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-dialog-confirm.spec.ts b/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-dialog-confirm.spec.ts new file mode 100644 index 00000000..fb64c77b --- /dev/null +++ b/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-dialog-confirm.spec.ts @@ -0,0 +1,99 @@ +import { test, expect } from '@playwright/test'; +import { LoginPage } from '../../../Page objects/Login.page'; +import { + closeDayAfterLockChange, closeDayWithoutChange, countPuts, openDashboardLastWeek, openDay, + PLANNING_PUT_PATH, PROVENANCE, RECONCILE_PATH, reconcileOpenDay, tdOf, unlockAll, unlockDay, + workerAtRow, +} from './reconcile-helpers'; + +/** + * Spec §8.2: reconcile takes two clicks in the dialog's own footer, and the dialog + * that was open stays open, read-only. The worker is grid row 6 at the start and is + * found by name after that. + */ +test.describe('Reconciled day lock: dialog confirm', () => { + /** + * The worker whose row a test locked. afterEach unlocks it even when an assertion + * failed halfway through: the shard shares one database and runs its files in + * order, so a row left locked would break every spec after this one. + */ + let worker = ''; + + test.beforeEach(async ({ page }) => { + worker = ''; + await page.goto('http://localhost:4200'); + await new LoginPage(page).login(); + await openDashboardLastWeek(page); + }); + + test.afterEach(async ({ page }) => { + await unlockAll(page, worker); + }); + + test('reconcile takes a second click in the same footer, then the dialog stays open read-only', async ({ page }) => { + worker = await workerAtRow(page, 6); + const reconciles = countPuts(page, RECONCILE_PATH); + + const date = await openDay(page, worker, 3); + await expect(page.locator('#saveButton')).toBeVisible(); + await expect(page.locator('#CommentOffice')).toBeEnabled(); + + // The first click morphs the footer. It commits nothing and opens no second modal. + await page.locator('#reconcileButton').click(); + await expect(page.locator('#reconcileConfirmText')).toContainText(worker); + await expect(page.locator('#reconcileConfirmText')).toContainText(date); + await expect(page.locator('#saveButton')).toHaveCount(0); + await expect(page.locator('mat-dialog-container')).toHaveCount(1); + // Polled, not read once: a PUT already on the wire would slip through a + // synchronous read taken the instant after the click. + await expect.poll(() => reconciles.count).toBe(0); + + // Backing out restores the normal footer, and still nothing is sent. + await page.locator('#reconcileCancelButton').click(); + await expect(page.locator('#saveButton')).toBeVisible(); + await expect.poll(() => reconciles.count).toBe(0); + + // The second click commits. The SAME dialog turns read-only, with the provenance + // line where the actions were. + await reconcileOpenDay(page); + await expect.poll(() => reconciles.count).toBe(1); + await expect(page.locator('mat-dialog-container')).toHaveCount(1); + await expect(page.locator('#saveButton')).toHaveCount(0); + await expect(page.locator('#reconcileButton')).toHaveCount(0); + await expect(page.locator('#reconciledProvenanceText')).toHaveText(PROVENANCE); + await expect(page.locator('#CommentOffice')).toBeDisabled(); + + // Closing reloads the grid and does not save. + await closeDayAfterLockChange(page); + await expect(tdOf(page, worker, 3)).toHaveClass(/reconciled-background/); + await expect(tdOf(page, worker, 2)).toHaveClass(/locked-background/); + await expect(tdOf(page, worker, 4)).not.toHaveClass(/locked-background|reconciled-background/); + + // A later open shows the server's stored timestamp. + await openDay(page, worker, 3); + await expect(page.locator('#reconciledProvenanceText')).toHaveText(PROVENANCE); + await closeDayWithoutChange(page); + + // Cleanup. + await unlockDay(page, worker, 3); + await expect(tdOf(page, worker, 3)).not.toHaveClass(/reconciled-background/); + }); + + test('a day with unsaved edits offers no reconcile, and Cancel writes nothing', async ({ page }) => { + worker = await workerAtRow(page, 6); + await openDay(page, worker, 1); + await expect(page.locator('#reconcileButton')).toBeVisible(); + + await page.locator('#CommentOffice').fill('reconcile-guard'); + await expect(page.locator('#reconcileButton')).toHaveCount(0); + await expect(page.locator('#reconcileNeedsSave')).toBeVisible(); + + // Cancel closes with '': no save, no reconcile, no write of any kind. + const writes = countPuts(page, PLANNING_PUT_PATH); + await closeDayWithoutChange(page); + await page.waitForTimeout(1000); + await expect + .poll(() => writes.count, { message: 'Cancel must not send a save or a reconcile' }) + .toBe(0); + }); +}); diff --git a/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-unlock-word.spec.ts b/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-unlock-word.spec.ts new file mode 100644 index 00000000..eb2d0170 --- /dev/null +++ b/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-unlock-word.spec.ts @@ -0,0 +1,80 @@ +import { test, expect } from '@playwright/test'; +import { LoginPage } from '../../../Page objects/Login.page'; +import { + closeDayWithoutChange, expectSuccess, lastWeekMonday, openDashboardLastWeek, openDay, reconcileDay, + tdOf, unlockAll, UNLOCK_WORD, UNRECONCILE_PATH, waitForIndex, waitForPut, waitForSpinner, + workerAtRow, +} from './reconcile-helpers'; + +/** + * Spec §8.4: only the boundary offers unlock, and unlocking it takes a typed word. + * The worker is grid row 9 at the start and is found by name after that. + */ +test.describe('Reconciled day lock: unlock', () => { + /** + * The worker whose row a test locked. afterEach unlocks it even when an assertion + * failed halfway through: the shard shares one database and runs its files in + * order, so a row left locked would break every spec after this one. + */ + let worker = ''; + + test.beforeEach(async ({ page }) => { + worker = ''; + await page.goto('http://localhost:4200'); + await new LoginPage(page).login(); + await openDashboardLastWeek(page); + }); + + test.afterEach(async ({ page }) => { + await unlockAll(page, worker); + }); + + test('only the boundary offers unlock, earlier days name it, and unlocking takes the word', async ({ page }) => { + worker = await workerAtRow(page, 9); + const boundaryDate = await reconcileDay(page, worker, 3); + + // A day below the boundary names the day to free first and offers no unlock. + await openDay(page, worker, 1); + await expect(page.locator('#lockedFreeFirstText')) + .toHaveText(`Låst, fordi ${boundaryDate} er afstemt. Lås ${boundaryDate} op først.`); + await expect(page.locator('#unlockButton')).toHaveCount(0); + await expect(page.locator('#saveButton')).toHaveCount(0); + await closeDayWithoutChange(page); + + // The boundary: its provenance, no "free first" line, and an unlock gated by the word. + await openDay(page, worker, 3); + await expect(page.locator('#reconciledProvenanceText')).toBeVisible(); + await expect(page.locator('#lockedFreeFirst')).toHaveCount(0); + + await page.locator('#unlockButton').click(); + await expect(page.locator('#unlockPrompt')).toContainText(UNLOCK_WORD); + await expect(page.locator('#unlockWordInput')).toBeFocused(); + const confirm = page.locator('#unlockConfirmButton'); + await expect(confirm).toBeDisabled(); + await page.locator('#unlockWordInput').fill('LÅS'); + await expect(confirm).toBeDisabled(); + await page.locator('#unlockWordInput').fill(' lås op '); + await expect(confirm).toBeEnabled(); + + // Backing out keeps the day reconciled. + await page.locator('#unlockCancelButton').click(); + await expect(page.locator('#unlockWordInput')).toHaveCount(0); + await expect(page.locator('#reconciledProvenanceText')).toBeVisible(); + + // Enter in the field confirms. It must not submit the surrounding form, whose first + // button is the version-history button in the title. + await page.locator('#unlockButton').click(); + await page.locator('#unlockWordInput').fill(UNLOCK_WORD); + const put = waitForPut(page, UNRECONCILE_PATH); + const reload = waitForIndex(page, lastWeekMonday()); + await page.locator('#unlockWordInput').press('Enter'); + await expectSuccess(await put); + await reload; + await waitForSpinner(page); + await expect(page.locator('app-version-history-modal')).toHaveCount(0); + + // The line moved back: both days are open again. + await expect(tdOf(page, worker, 3)).not.toHaveClass(/reconciled-background/); + await expect(tdOf(page, worker, 1)).not.toHaveClass(/locked-background/); + }); +}); 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 1f6b06d7..a15227db 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 @@ -61,7 +61,14 @@ (closed)="calculatePlanHours()"> -
@@ -95,7 +102,7 @@ (closed)="calculatePlanHours()"> -
@@ -124,7 +131,7 @@ (closed)="calculatePlanHours()"> -
@@ -200,7 +207,7 @@ (closed)="calculatePlanHours()"> - - @@ -285,7 +293,7 @@ (closed)="calculatePlanHours()"> - + + + +
@@ -491,26 +517,129 @@ -
- + + 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 d8e7483d..4c15513b 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 @@ -1,11 +1,13 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { WorkdayEntityDialogComponent } from './workday-entity-dialog.component'; +import { LOCK_REQUEST_TIMEOUT_MS, WorkdayEntityDialogComponent } from './workday-entity-dialog.component'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; import { TimePlanningPnPlanningsService, TimePlanningPnGpsCoordinatesService, TimePlanningPnPictureSnapshotsService } from '../../../../services'; import { TranslateService } from '@ngx-translate/core'; import { DatePipe, CommonModule } from '@angular/common'; -import { of } from 'rxjs'; +import { EMPTY, of, Subject, throwError } from 'rxjs'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; import { Store } from '@ngrx/store'; @@ -14,6 +16,7 @@ import { DomSanitizer } from '@angular/platform-browser'; import { TemplateFilesService } from 'src/app/common/services'; import { HelpPanelService } from '../../../../help/services/help-panel.service'; import { HelpTourService, TOUR_STORAGE_KEY } from '../../../../help/services/help-tour.service'; +import { ToastrService } from 'ngx-toastr'; describe('WorkdayEntityDialogComponent', () => { let component: WorkdayEntityDialogComponent; @@ -25,6 +28,7 @@ describe('WorkdayEntityDialogComponent', () => { let mockDomSanitizer: jest.Mocked; let mockTemplateFilesService: jest.Mocked; let mockDialogRef: jest.Mocked>; + let mockToastrService: jest.Mocked; const mockData = { planningPrDayModels: { @@ -97,6 +101,8 @@ describe('WorkdayEntityDialogComponent', () => { beforeEach(async () => { mockPlanningsService = { updatePlanning: jest.fn(), + reconcileDay: jest.fn(), + unreconcileDay: jest.fn(), } as any; mockTranslateService = { instant: jest.fn(), @@ -115,8 +121,15 @@ describe('WorkdayEntityDialogComponent', () => { mockTemplateFilesService = { getImage: jest.fn().mockReturnValue(of(new Blob())), } as any; + mockToastrService = { + error: jest.fn(), + success: jest.fn(), + } as any; mockDialogRef = { close: jest.fn(), + // The reconcile/unlock PUTs flip this while they are in flight, so it has to + // start as the real MatDialogRef default rather than undefined. + disableClose: false, _containerInstance: { _config: { width: '600px', @@ -148,7 +161,8 @@ describe('WorkdayEntityDialogComponent', () => { { provide: TimePlanningPnGpsCoordinatesService, useValue: mockGpsCoordinatesService }, { provide: TimePlanningPnPictureSnapshotsService, useValue: mockPictureSnapshotsService }, { provide: DomSanitizer, useValue: mockDomSanitizer }, - { provide: TemplateFilesService, useValue: mockTemplateFilesService } + { provide: TemplateFilesService, useValue: mockTemplateFilesService }, + { provide: ToastrService, useValue: mockToastrService } ] }).compileComponents(); @@ -510,4 +524,574 @@ describe('WorkdayEntityDialogComponent', () => { } }); }); + + + describe('Reconciled day lock', () => { + /** + * A date `offset` days from today, in the shape the server sends: local calendar + * midnight with no offset. Computed rather than hardcoded so "yesterday" stays + * yesterday, and offset-free so no viewer timezone can shift the calendar day. + */ + const dayString = (offset: number): string => { + const d = new Date(); + d.setHours(0, 0, 0, 0); + d.setDate(d.getDate() + offset); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T00:00:00`; + }; + + /** + * `mockData` is one module-level object shared by reference through + * MAT_DIALOG_DATA, and the outer beforeEach never deep-resets it. Snapshot + * whatever the describes before this one left behind, and put it back afterwards, + * so this block is order-independent in both directions. + */ + let dataSnapshot: string; + beforeAll(() => { + dataSnapshot = JSON.stringify(mockData); + }); + afterAll(() => { + const restored = JSON.parse(dataSnapshot); + for (const key of Object.keys(mockData)) { + delete (mockData as any)[key]; + } + Object.assign(mockData, restored); + }); + + /** An open, past day: reconcilable, nothing locked. */ + const openPastDay = () => { + const m = component.data.planningPrDayModels as any; + m.date = dayString(-1); + m.id = 1; + m.siteName = 'Test Worker'; + m.reconciled = false; + m.reconciledAt = null; + component.data.isLocked = false; + component.data.isBoundary = false; + component.data.isSealed = false; + component.data.lockedThrough = null; + }; + + /** The boundary: locked, sealed, and the row's lockedThrough. */ + const boundaryDay = () => { + openPastDay(); + const m = component.data.planningPrDayModels as any; + m.reconciled = true; + m.reconciledAt = '2026-09-14T10:32:11'; + component.data.isLocked = true; + component.data.isBoundary = true; + component.data.isSealed = true; + component.data.lockedThrough = m.date; + }; + + /** Opens the dialog on the day just configured and arms the reconcile confirm. */ + const armReconcile = () => { + component.ngOnInit(); + component.onReconcileStart(); + }; + + /** Opens the dialog on the day just configured and arms the unlock confirm. */ + const armUnlock = () => { + component.ngOnInit(); + component.onUnlockStart(); + }; + + beforeEach(() => { + openPastDay(); + mockPlanningsService.reconcileDay.mockReturnValue(of({ success: true, message: '' } as any)); + mockPlanningsService.unreconcileDay.mockReturnValue(of({ success: true, message: '' } as any)); + mockTranslateService.instant.mockImplementation((key: string, params?: any) => { + if (key === 'UNLOCK') { + return 'LÅS OP'; + } + if (key === 'reconciledProvenance') { + return `Afstemt ${params.date} kl. ${params.time}`; + } + return key; + }); + }); + + describe('a locked day opens read-only', () => { + it('disables the whole form, and the cascade cannot reopen it', () => { + component.data.isLocked = true; + + component.ngOnInit(); + + expect(component.isLocked).toBe(true); + expect(component.workdayForm.disabled).toBe(true); + // updateDisabledStates() runs again on every field change, and its shift-1 + // branch enables this control unconditionally once a start time is set. + (component as any).updateDisabledStates(); + expect(component.workdayForm.get('planned.shift1.stop')?.disabled).toBe(true); + expect(component.workdayForm.get('commentOffice')?.disabled).toBe(true); + }); + + it('leaves the same control enabled on an open day', () => { + // The negative control: without it the assertion above could pass on a + // control the cascade never enables in the first place. + component.ngOnInit(); + (component as any).updateDisabledStates(); + + expect(component.workdayForm.get('planned.shift1.stop')?.enabled).toBe(true); + }); + + it('keeps the unlock word typeable, because that control is not in the form', () => { + component.data.isLocked = true; + + component.ngOnInit(); + + expect(component.unlockWordCtrl.enabled).toBe(true); + }); + + it('ignores the reset affordances, which write through a disabled control', () => { + // patchValue/setValue do not respect AbstractControl.disabled, so without the + // handler guards a click on the delete icons blanked a sealed day on screen. + component.data.isLocked = true; + component.ngOnInit(); + const before = JSON.stringify(component.workdayForm.getRawValue()); + + component.resetPlannedTimes(1); + component.resetActualTimes(1); + component.resetPauseToRecorded(1); + + expect(JSON.stringify(component.workdayForm.getRawValue())).toBe(before); + expect(component.actualTimesReadOnly).toBe(true); + }); + + it('still resets an open day, and still refuses the registered times of a future one', () => { + // The negative control for the guard above, and the pre-existing hole it + // also closes: a future day's registered times are built disabled, and the + // reset wrote through that too. + component.ngOnInit(); + component.resetPlannedTimes(1); + expect(component.workdayForm.get('planned.shift1.start')?.value).toBeNull(); + + component.data.planningPrDayModels.date = dayString(3); + component.ngOnInit(); + expect(component.isInTheFuture).toBe(true); + expect(component.actualTimesReadOnly).toBe(true); + const before = JSON.stringify(component.workdayForm.getRawValue()); + component.resetActualTimes(1); + component.resetPauseToRecorded(1); + expect(JSON.stringify(component.workdayForm.getRawValue())).toBe(before); + }); + }); + + describe('the two-step reconcile confirm', () => { + it('arms on the first click and sends nothing', () => { + component.ngOnInit(); + expect(component.canReconcile).toBe(true); + expect(component.reconcileEligible).toBe(true); + + component.onReconcileStart(); + + expect(component.footerMode).toBe('confirmReconcile'); + expect(mockPlanningsService.reconcileDay).not.toHaveBeenCalled(); + expect(component.lockStateChanged).toBe(false); + }); + + it('commits on the second click and turns the open dialog read-only', () => { + armReconcile(); + + component.onReconcileConfirm(); + + expect(mockPlanningsService.reconcileDay).toHaveBeenCalledTimes(1); + expect(mockPlanningsService.reconcileDay).toHaveBeenCalledWith(1); + expect(component.isLocked).toBe(true); + expect(component.data.isLocked).toBe(true); + expect(component.workdayForm.disabled).toBe(true); + expect(component.data.isSealed).toBe(true); + expect(component.data.isBoundary).toBe(true); + expect(component.data.lockedThrough).toBe(component.data.planningPrDayModels.date); + expect(component.data.planningPrDayModels.reconciled).toBe(true); + expect(component.data.planningPrDayModels.reconciledAt) + .toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/); + expect(component.lockStateChanged).toBe(true); + expect(component.footerMode).toBe('actions'); + // The dialog stays open: the same one turns read-only. + expect(mockDialogRef.close).not.toHaveBeenCalled(); + }); + + it('cancelling the confirm commits nothing and leaves the day open', () => { + armReconcile(); + + component.onReconcileCancel(); + + expect(component.footerMode).toBe('actions'); + expect(mockPlanningsService.reconcileDay).not.toHaveBeenCalled(); + expect(component.isLocked).toBe(false); + expect(component.lockStateChanged).toBe(false); + }); + + it('refuses to arm, and refuses to commit, while the form is dirty', () => { + // Reconcile writes only the flag, so unsaved edits would be frozen behind + // the seal and then lost. + component.ngOnInit(); + component.workdayForm.markAsDirty(); + + component.onReconcileStart(); + expect(component.footerMode).toBe('actions'); + + // Armed on a clean form, then edited while the confirm was showing. + component.workdayForm.markAsPristine(); + component.onReconcileStart(); + expect(component.footerMode).toBe('confirmReconcile'); + component.workdayForm.markAsDirty(); + + component.onReconcileConfirm(); + + expect(mockPlanningsService.reconcileDay).not.toHaveBeenCalled(); + expect(component.footerMode).toBe('actions'); + }); + + it('never sends a reconcile for a locked day, for today or for the future', () => { + // Each case arms the confirm first, so that footerMode returning to 'actions' + // is a real transition and not just its initial value. + for (const setUp of [ + () => { openPastDay(); component.data.isLocked = true; }, + () => { openPastDay(); component.data.planningPrDayModels.date = dayString(0); }, + () => { openPastDay(); component.data.planningPrDayModels.date = dayString(3); }, + ]) { + setUp(); + component.ngOnInit(); + expect(component.reconcileEligible).toBe(false); + + // Even reached directly — an armed confirm whose day changed underneath. + component.footerMode = 'confirmReconcile'; + component.onReconcileConfirm(); + + expect(component.footerMode).toBe('actions'); + expect(mockPlanningsService.reconcileDay).not.toHaveBeenCalled(); + } + }); + + it('blocks dismissal while the PUT is in flight, and ignores a second click', () => { + const put = new Subject(); + mockPlanningsService.reconcileDay.mockReturnValue(put.asObservable()); + armReconcile(); + + component.onReconcileConfirm(); + + expect(component.lockRequestInFlight).toBe(true); + expect(mockDialogRef.disableClose).toBe(true); + component.onReconcileConfirm(); + expect(mockPlanningsService.reconcileDay).toHaveBeenCalledTimes(1); + + put.next({ success: true, message: '' }); + put.complete(); + + // lockStateChanged is set before the close, and the guard lifts with it. + expect(component.lockStateChanged).toBe(true); + expect(component.lockRequestInFlight).toBe(false); + expect(mockDialogRef.disableClose).toBe(false); + }); + + it('leaves the day open when the server refuses, and says so', () => { + mockPlanningsService.reconcileDay + .mockReturnValue(of({ success: false, message: '' } as any)); + armReconcile(); + + component.onReconcileConfirm(); + + expect(component.isLocked).toBe(false); + // KNOWN: the server itself answered "no", so there is nothing to re-read. + // This is what separates a message-less refusal from an empty completion. + expect(component.lockStateChanged).toBe(false); + expect(component.footerMode).toBe('actions'); + expect(component.lockRequestInFlight).toBe(false); + expect(mockDialogRef.disableClose).toBe(false); + // ApiBaseService toasts body.message, but only when there is one. + expect(mockToastrService.error).toHaveBeenCalledWith('lockRequestFailed'); + }); + + it('says nothing of its own when someone else already spoke', () => { + // A refusal that carries a message: ApiBaseService toasts it. + mockPlanningsService.reconcileDay + .mockReturnValue(of({ success: false, message: 'CannotReconcileTodayOrFuture' } as any)); + armReconcile(); + component.onReconcileConfirm(); + expect(mockToastrService.error).not.toHaveBeenCalled(); + + // And an error that reached us: the only one HttpErrorInterceptor lets + // through is its 400 branch, which has already toasted every errorMessage. + mockPlanningsService.reconcileDay.mockReturnValue(throwError(() => '')); + armReconcile(); + component.onReconcileConfirm(); + expect(mockToastrService.error).not.toHaveBeenCalled(); + // Recovered all the same, and KNOWN: the server rejected the request before + // doing anything, so no reload is needed. + expect(component.lockRequestInFlight).toBe(false); + expect(mockDialogRef.disableClose).toBe(false); + expect(component.footerMode).toBe('actions'); + expect(component.lockStateChanged).toBe(false); + }); + + it('treats a completion with no answer as unknown too', () => { + // HttpErrorInterceptor turns a sustained 5xx, and an offline network, into + // EMPTY after its retries: complete, with no value and no error. Left + // unhandled this strands the user — the backdrop is blocked, both confirm + // buttons are disabled, and neither carries mat-dialog-close. And a 5xx can + // be written after the save already committed, so a response arriving is not + // evidence that nothing changed. + mockPlanningsService.reconcileDay.mockReturnValue(EMPTY); + armReconcile(); + + component.onReconcileConfirm(); + + expect(component.lockRequestInFlight).toBe(false); + expect(mockDialogRef.disableClose).toBe(false); + expect(component.footerMode).toBe('actions'); + expect(mockToastrService.error).toHaveBeenCalledWith('lockRequestUncertain'); + // Not sealed in place off an answer we never got — the reload decides. + expect(component.isLocked).toBe(false); + expect(component.lockStateChanged).toBe(true); + }); + + it('treats its own timeout as unknown, not failed, because the answer may be late', () => { + // The belt to the EMPTY braces: a request that neither emits nor completes + // inside a window shorter than the interceptor's own ~75s retry budget. + // Timing out aborts the request from this end, but the server may commit a + // moment later, so the day's state is UNKNOWN rather than unchanged. + const tour = TestBed.inject(HelpTourService); + const start = jest.spyOn(tour, 'start').mockImplementation(() => undefined); + jest.useFakeTimers(); + const put = new Subject(); + mockPlanningsService.reconcileDay.mockReturnValue(put.asObservable()); + armReconcile(); + + component.onReconcileConfirm(); + expect(component.lockRequestInFlight).toBe(true); + expect(mockDialogRef.disableClose).toBe(true); + + jest.advanceTimersByTime(LOCK_REQUEST_TIMEOUT_MS + 1); + + // Settled and usable again... + expect(component.lockRequestInFlight).toBe(false); + expect(mockDialogRef.disableClose).toBe(false); + expect(component.footerMode).toBe('actions'); + expect(mockToastrService.error).toHaveBeenCalledWith('lockRequestUncertain'); + // ...and the close must reload the grid, or it would go on drawing a day the + // server may have sealed, which the user would then act against. + expect(component.lockStateChanged).toBe(true); + + // The late 200: timeout has unsubscribed, so it reaches nobody. The dialog + // must not seal itself in place off a stale answer — the reload decides. + put.next({ success: true, message: '' }); + put.complete(); + + expect(component.isLocked).toBe(false); + expect(component.data.isSealed).toBe(false); + expect(component.lockStateChanged).toBe(true); + expect(mockDialogRef.close).not.toHaveBeenCalled(); + + jest.useRealTimers(); + start.mockRestore(); + }); + + it('leaves the unlock confirm usable when its request strands', () => { + // The same recovery on the other verb: the word stays typed, and Cancel works. + mockPlanningsService.unreconcileDay.mockReturnValue(EMPTY); + boundaryDay(); + armUnlock(); + component.unlockWordCtrl.setValue('LÅS OP'); + + component.onUnlockConfirm(); + + expect(component.lockRequestInFlight).toBe(false); + expect(mockDialogRef.disableClose).toBe(false); + expect(component.footerMode).toBe('confirmUnlock'); + expect(mockToastrService.error).toHaveBeenCalledWith('lockRequestUncertain'); + expect(mockDialogRef.close).not.toHaveBeenCalled(); + // Unknown, so closing this dialog still reloads the grid. + expect(component.lockStateChanged).toBe(true); + }); + }); + + describe('the typed-word unlock', () => { + it('sends nothing until the word matches, and ignores case and spacing', () => { + boundaryDay(); + component.ngOnInit(); + expect(component.isBoundaryDay).toBe(true); + + component.onUnlockStart(); + expect(component.footerMode).toBe('confirmUnlock'); + expect(component.unlockWordCtrl.value).toBe(''); + expect(component.unlockWordMatches).toBe(false); + component.onUnlockConfirm(); + expect(mockPlanningsService.unreconcileDay).not.toHaveBeenCalled(); + + // Half the word is not the word. + component.unlockWordCtrl.setValue('LÅS'); + expect(component.unlockWordMatches).toBe(false); + component.onUnlockConfirm(); + expect(mockPlanningsService.unreconcileDay).not.toHaveBeenCalled(); + + // The friction is the word, not the Shift key or the spacing. + component.unlockWordCtrl.setValue(' lås op '); + expect(component.unlockWordMatches).toBe(true); + + component.onUnlockConfirm(); + + expect(mockPlanningsService.unreconcileDay).toHaveBeenCalledTimes(1); + expect(mockPlanningsService.unreconcileDay).toHaveBeenCalledWith(1); + // The form was built locked, so the only way back to a clean cascade is to + // close and reopen from a reloaded grid. + expect(component.lockStateChanged).toBe(true); + expect(mockDialogRef.close).toHaveBeenCalled(); + }); + + it('lifts the dismissal guard before it closes, not after', () => { + // Closing while disableClose is still true is how a dialog gets stuck. + boundaryDay(); + armUnlock(); + component.unlockWordCtrl.setValue('LÅS OP'); + let disableCloseAtClose: boolean | undefined; + (mockDialogRef.close as jest.Mock) + .mockImplementation(() => { disableCloseAtClose = mockDialogRef.disableClose; }); + + component.onUnlockConfirm(); + + expect(disableCloseAtClose).toBe(false); + }); + + it('backing out clears the word and keeps the day sealed', () => { + boundaryDay(); + armUnlock(); + component.unlockWordCtrl.setValue('LÅS OP'); + + component.onUnlockCancel(); + + expect(component.footerMode).toBe('actions'); + expect(component.unlockWordCtrl.value).toBe(''); + expect(mockPlanningsService.unreconcileDay).not.toHaveBeenCalled(); + expect(component.isLocked).toBe(true); + expect(component.data.isSealed).toBe(true); + expect(mockDialogRef.close).not.toHaveBeenCalled(); + }); + + it('keeps the day sealed when the server refuses', () => { + mockPlanningsService.unreconcileDay + .mockReturnValue(of({ success: false, message: 'OnlyLatestReconciledDayCanBeUnlocked' } as any)); + boundaryDay(); + armUnlock(); + component.unlockWordCtrl.setValue('LÅS OP'); + + component.onUnlockConfirm(); + + expect(component.lockStateChanged).toBe(false); + expect(mockDialogRef.close).not.toHaveBeenCalled(); + // Still in the confirm, so the word need not be typed again. + expect(component.footerMode).toBe('confirmUnlock'); + expect(component.lockRequestInFlight).toBe(false); + }); + + it('is not offered on a locked day that is not the boundary', () => { + // The lock is derived: every day at or before lockedThrough is locked, so + // unlocking a lower day would need an editable day under a sealed one. + boundaryDay(); + component.data.isBoundary = false; + component.data.lockedThrough = dayString(0); + component.ngOnInit(); + + expect(component.isBoundaryDay).toBe(false); + + component.onUnlockStart(); + expect(component.footerMode).toBe('actions'); + + // And the request is refused even with the word in the control. + component.unlockWordCtrl.setValue('LÅS OP'); + component.footerMode = 'confirmUnlock'; + component.onUnlockConfirm(); + expect(mockPlanningsService.unreconcileDay).not.toHaveBeenCalled(); + }); + + it('names the day to free first on every day but the boundary', () => { + boundaryDay(); + component.data.isBoundary = false; + component.data.lockedThrough = '2026-09-14T00:00:00'; + component.ngOnInit(); + + expect(component.freeFirstDate).toBe('14.09.2026'); + }); + }); + + describe('the provenance line', () => { + it('reads the stored timestamp on a sealed day', () => { + boundaryDay(); + component.ngOnInit(); + + expect(component.data.isSealed).toBe(true); + expect(component.reconciledProvenance).toBe('Afstemt 14.09.2026 kl. 10:32'); + }); + + it('falls back to the bare word when a sealed day carries no timestamp', () => { + boundaryDay(); + component.data.planningPrDayModels.reconciledAt = null; + component.ngOnInit(); + + expect(component.reconciledProvenance).toBe('Reconciled'); + }); + + it('labels the day the confirm sentence names', () => { + component.data.planningPrDayModels.date = '2026-09-14T00:00:00'; + component.ngOnInit(); + + expect(component.dayLabel).toBe('14.09.2026'); + }); + }); + + describe('the footer markup', () => { + // This suite never renders the template (the TranslateService here is a mock), + // so the structural invariants of the footer are read off the file. The + // Playwright specs in shard `s` exercise the rendered version. + const template = readFileSync( + join(__dirname, 'workday-entity-dialog.component.html'), 'utf8'); + + const buttonWithId = (id: string): string => { + const match = new RegExp(`]*\\bid="${id}"[^>]*>`).exec(template); + expect(match).not.toBeNull(); + return (match as RegExpExecArray)[0]; + }; + + it('removes Save from the DOM on a locked day rather than disabling it', () => { + // [mat-dialog-close] fires regardless of (click) and regardless of + // [disabled], so a merely disabled Save would still close with the payload + // the table then tries to save. + const save = buttonWithId('saveButton'); + expect(save).toContain('*ngIf="!isLocked"'); + expect(save).toContain('[mat-dialog-close]="data"'); + }); + + it('offers unlock only on the boundary', () => { + expect(buttonWithId('unlockButton')).toContain('*ngIf="isBoundaryDay"'); + }); + + it('offers reconcile only on an open, past, clean day', () => { + expect(buttonWithId('reconcileButton')) + .toContain('*ngIf="reconcileEligible && !workdayForm.dirty"'); + }); + + it('keeps every footer button out of the implicit form submit', () => { + // The row sits inside
, whose first button is the version-history one + // in the title; an implicit submit would click that. + for (const id of ['cancelButton', 'reconcileButton', 'unlockButton', + 'reconcileCancelButton', 'reconcileConfirmButton', + 'unlockCancelButton', 'unlockConfirmButton']) { + expect(buttonWithId(id)).toContain('type="button"'); + } + }); + + it('guards every reset affordance, not only the form controls', () => { + // Each delete/restore icon must state the rule; a bare (click) is the hole + // this round closed. + const resets = [...template.matchAll( + /]*\(click\)="reset(PlannedTimes|ActualTimes|PauseToRecorded)\([^>]*>/g)]; + expect(resets).toHaveLength(7); + for (const [tag, kind] of resets) { + expect(tag).toContain( + kind === 'PlannedTimes' ? '[disabled]="isLocked"' : '[disabled]="actualTimesReadOnly"'); + } + }); + }); + }); }); 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 a623c275..71aeae67 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 @@ -1,4 +1,4 @@ -import {Component, OnInit, TemplateRef, ViewChild, +import {Component, ElementRef, OnInit, TemplateRef, ViewChild, inject, OnDestroy } from '@angular/core'; import {MAT_DIALOG_DATA, MatDialog} from '@angular/material/dialog'; @@ -14,12 +14,15 @@ import {selectCurrentUserIsFirstUser, selectCurrentUserIsAdmin} from 'src/app/st import validator from 'validator'; import {DomSanitizer, SafeResourceUrl} from '@angular/platform-browser'; import {TemplateFilesService} from 'src/app/common/services'; -import {SharedTagModel} from 'src/app/common/models'; -import {Subscription} from 'rxjs'; +import {OperationResult, SharedTagModel} from 'src/app/common/models'; +import {Observable, Subscription, defaultIfEmpty, throwError, timeout} from 'rxjs'; +import {ToastrService} from 'ngx-toastr'; import { MatDialogRef } from '@angular/material/dialog'; import {HelpEntryId} from '../../../../help/help.model'; import {HelpPanelService} from '../../../../help/services/help-panel.service'; import {HelpTourService} from '../../../../help/services/help-tour.service'; +import {format} from 'date-fns'; +import {dayKey, formatReconciledProvenance} from '../../day-lock.util'; import { AbstractControl, @@ -32,6 +35,23 @@ import { FormArray, } from '@angular/forms'; +/** + * How long a reconcile or unlock may hang before this dialog stops waiting for it. + * + * It has to be shorter than HttpErrorInterceptor's own budget: its `default:` branch + * retries a failing request five times, fifteen seconds apart, which is about 75 + * seconds before it gives up. Waiting that out behind a blocked backdrop is not a + * state anyone should have to sit through. + */ +export const LOCK_REQUEST_TIMEOUT_MS = 30000; + +/** + * Thrown by the timeout above, so the failure handler can tell the one UNKNOWN outcome + * apart from the known ones: an error the interceptor has already reported, and an + * empty completion, which `defaultIfEmpty` turns into an ordinary answer instead. + */ +const LOCK_REQUEST_TIMED_OUT = {lockRequestTimedOut: true}; + @Component({ selector: 'app-workday-entity-dialog', templateUrl: './workday-entity-dialog.component.html', @@ -50,13 +70,22 @@ export class WorkdayEntityDialogComponent implements OnInit, OnDestroy { public data = inject<{ planningPrDayModels: PlanningPrDayModel, assignedSiteModel: AssignedSiteModel, - tags?: SharedTagModel[] + tags?: SharedTagModel[], + /** At or before the row's boundary: the day opens read-only. */ + isLocked?: boolean, + /** The one day whose date is the row's boundary. Only it offers unlock. */ + isBoundary?: boolean, + /** A locked day carrying its own reconcile mark: it shows its provenance. */ + isSealed?: boolean, + /** The row's boundary, named to the user as the day to free first. */ + lockedThrough?: string | null }>(MAT_DIALOG_DATA); protected datePipe = inject(DatePipe); private translateService = inject(TranslateService); private dialogRef = inject(MatDialogRef); private helpPanel = inject(HelpPanelService); private helpTour = inject(HelpTourService); + private toastrService = inject(ToastrService); private originalDialogWidth: string = '600px'; private originalDialogHeight: string = 'auto'; @@ -133,6 +162,57 @@ export class WorkdayEntityDialogComponent implements OnInit, OnDestroy { private dialogTourRunning = false; private helpTourState$: Subscription; + // ---- Reconcile and unlock footer (spec §8.2, §8.4) --------------------------- + // + // The footer morphs in place instead of stacking a second modal: this dialog is the + // one place that already shows whose day, which date and which hours, and a modal on + // top would cover exactly that. + + footerMode: 'actions' | 'confirmReconcile' | 'confirmUnlock' = 'actions'; + + /** + * A reconciled or cascade-locked day opens read-only. Derived from the dialog data + * rather than copied into a field, so an in-place reconcile has one thing to set + * and the two can never disagree. + */ + get isLocked(): boolean { + return this.data.isLocked === true; + } + + /** + * Registered times are read-only on a locked day, and on a day that has not + * happened yet — the actual controls are already built disabled for a future day, + * but the reset affordances write through patchValue, which ignores that. + */ + get actualTimesReadOnly(): boolean { + return this.isLocked || this.isInTheFuture; + } + + /** + * Whether this day may be sealed at all. Read once in ngOnInit: as a getter it was + * re-evaluated on every change-detection pass, so a dialog left open across + * midnight could arm the confirm on a day that had meanwhile become today. + */ + canReconcile = false; + + /** + * Set once a reconcile or unlock has reached the server. After the close, whatever + * its path (Cancel, Esc, backdrop), the table reads this and reloads the grid + * instead of treating the close payload as a save. + */ + lockStateChanged = false; + + /** True while a reconcile or unlock PUT is in flight. */ + lockRequestInFlight = false; + + /** The word the user types to unlock; standalone, so the form's disable never reaches it. */ + readonly unlockWordCtrl = new FormControl('', {nonNullable: true}); + + @ViewChild('unlockWordInput') private unlockWordInput?: ElementRef; + + /** Captured at construction so it can be restored after the PUT. */ + private readonly defaultDisableClose = this.dialogRef.disableClose; + ngOnInit(): void { @@ -247,6 +327,10 @@ export class WorkdayEntityDialogComponent implements OnInit, OnDestroy { // Er dato i fremtiden? this.isInTheFuture = Date.parse(this.data.planningPrDayModels.date) > Date.now(); + // Today and future days stay open so time can still be registered. + const day = dayKey(this.data.planningPrDayModels.date); + this.canReconcile = !!this.data.planningPrDayModels.id + && day !== null && day < dayKey(new Date()); this.todaysFlex = this.data.planningPrDayModels.actualHours - this.data.planningPrDayModels.planHours; this.date = Date.parse(this.data.planningPrDayModels.date); @@ -443,6 +527,11 @@ export class WorkdayEntityDialogComponent implements OnInit, OnDestroy { } this.updateDisabledStates(); + if (this.isLocked) { + // One sweep closes every control the cascade above left open; the guard inside + // setDisabled is what keeps them closed from here on. + this.workdayForm.disable({emitEvent: false}); + } this.loadGpsAndSnapshotData(); this.isAdmin$ = this.store.select(selectCurrentUserIsAdmin) @@ -483,10 +572,15 @@ export class WorkdayEntityDialogComponent implements OnInit, OnDestroy { if (!c) { return; } - if (disabled && c.enabled) { + // A locked day can never re-enable a control. The progressive-enable cascade in + // updateDisabledStates() calls setDisabled(path, false) from many branches, and it + // runs again on every field change; without this guard, one keystroke would reopen + // a read-only form. + const effective = disabled || this.isLocked; + if (effective && c.enabled) { c.disable({emitEvent: false}); } - if (!disabled && c.disabled) { + if (!effective && c.disabled) { c.enable({emitEvent: false}); } } @@ -1337,6 +1431,11 @@ export class WorkdayEntityDialogComponent implements OnInit, OnDestroy { // ===== Nulstil plan ===== resetPlannedTimes(number: number) { + // patchValue writes through a DISABLED control, so the guard has to be here and + // not only on the button: otherwise a sealed day could be blanked on screen. + if (this.isLocked) { + return; + } const s1 = this.workdayForm.get('planned.shift1') as FormGroup; const s2 = this.workdayForm.get('planned.shift2') as FormGroup; const s3 = this.workdayForm.get('planned.shift3') as FormGroup; @@ -1416,6 +1515,11 @@ export class WorkdayEntityDialogComponent implements OnInit, OnDestroy { // ===== Nulstil registreret ===== resetActualTimes(number: number) { + // Same hole as resetPlannedTimes, and it predates the lock: the actual controls + // are built disabled for a future day, and patchValue ignored that too. + if (this.actualTimesReadOnly) { + return; + } const a1 = this.workdayForm.get('actual.shift1') as FormGroup; const a2 = this.workdayForm.get('actual.shift2') as FormGroup; const a3 = this.workdayForm.get('actual.shift3') as FormGroup; @@ -1659,6 +1763,10 @@ export class WorkdayEntityDialogComponent implements OnInit, OnDestroy { // 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 { + // setValue writes through a DISABLED control, as above. + if (this.actualTimesReadOnly) { + return; + } const recordedMinutes = this.useOneMinuteIntervals ? this.computeExactPauseMinutes(shift) : (this.computeFiveMinutePauseMinutes(shift) ?? 0); @@ -1907,6 +2015,237 @@ export class WorkdayEntityDialogComponent implements OnInit, OnDestroy { onCancel() { } + // ---- Reconcile (spec §8.2) --------------------------------------------------- + + /** + * While the PUT is in flight the dialog cannot be dismissed. Otherwise Esc or a + * backdrop click would close it before lockStateChanged is set, and the grid would + * go on drawing a day the server has just sealed as open. + */ + private setLockRequestInFlight(inFlight: boolean): void { + this.lockRequestInFlight = inFlight; + this.dialogRef.disableClose = inFlight || this.defaultDisableClose; + } + + /** + * The one place reconcile and unlock talk to the server, so they cannot drift on + * the in-flight guard, on recovery or on what the user is told. + * + * **Every outcome must leave the dialog usable.** While the request is in flight the + * dialog cannot be dismissed and both confirm buttons are disabled, and neither of + * them carries mat-dialog-close — so an outcome that is not handled here does not + * degrade, it strands the user with nothing but a page reload. + * + * `settle` takes the message to show, or null when someone has already spoken: the + * server's own refusal message through ApiBaseService, or the 400 branch of + * HttpErrorInterceptor, which is the only error it lets through to a caller. + * + * The guard is cleared BEFORE the callbacks run, because the unlock path closes the + * dialog from its callback and must not do so while dismissal is still blocked. The + * callback runs in the same synchronous call stack, so nothing the user does can + * land in the gap; `lockStateChanged` is therefore still set before any close. + */ + private submitLockRequest( + request$: Observable, + onSuccess: () => void, + onFailure?: () => void, + ): void { + this.setLockRequestInFlight(true); + const settle = (message: string | null) => { + this.setLockRequestInFlight(false); + if (message) { + this.toastrService.error(this.translateService.instant(message)); + } + onFailure?.(); + }; + + request$.pipe( + timeout({ + each: LOCK_REQUEST_TIMEOUT_MS, + with: () => throwError(() => LOCK_REQUEST_TIMED_OUT), + }), + // HttpErrorInterceptor turns a sustained 5xx, and an offline network, into EMPTY + // after its retries: a completion with no value and no error. This turns that + // into an ordinary answer of "nothing", so it needs no state of its own to + // recognise; the next handler decides what it means. + defaultIfEmpty(null), + ).subscribe({ + next: (result: OperationResult | null) => { + if (result && result.success) { + this.setLockRequestInFlight(false); + onSuccess(); + return; + } + if (!result) { + // The EMPTY above. The interceptor only reaches it after retrying against + // 5xx or a dead connection, and a 5xx can be written AFTER the save has + // already committed — the server saves before it writes the response. So a + // response arriving is not evidence that nothing changed. + this.markLockStateUncertain(settle); + return; + } + // KNOWN: the server itself answered "no", so nothing changed. ApiBaseService + // toasts body.message when the refusal carries one; the fallback covers the + // rare refusal that does not. + settle(result.message ? null : 'lockRequestFailed'); + }, + error: error => { + if (error !== LOCK_REQUEST_TIMED_OUT) { + // KNOWN: the only error HttpErrorInterceptor lets through is its 400 branch, + // where the server rejected the request before doing anything. It has also + // already toasted every errorMessage, so saying it again would double up. + settle(null); + return; + } + // Our own timeout: the request is aborted from this end, but a slow server may + // commit it a moment later. + this.markLockStateUncertain(settle); + }, + }); + } + + /** + * The two outcomes where the day's state on the server is unknowable from here: our + * own timeout, and the interceptor giving up. Neither tells us whether the write + * landed, so the close must reload the grid and let the server say. + * + * Being wrong this way costs one unnecessary reload on a genuine failure. Being + * wrong the other way leaves a sealed day drawn as open, which the user then acts + * against. + */ + private markLockStateUncertain(settle: (message: string | null) => void): void { + this.lockStateChanged = true; + settle('lockRequestUncertain'); + } + + /** The day may be sealed, and is not sealed already. */ + get reconcileEligible(): boolean { + return !this.isLocked && this.canReconcile; + } + + /** "Afstemt 14.09.2026 kl. 10:32", shown where the actions were. */ + get reconciledProvenance(): string { + return formatReconciledProvenance( + this.data.planningPrDayModels.reconciledAt, this.datePipe, this.translateService); + } + + get dayLabel(): string { + return this.datePipe.transform(this.data.planningPrDayModels.date, 'dd.MM.yyyy') ?? ''; + } + + onReconcileStart(): void { + if (this.reconcileEligible && !this.workdayForm.dirty) { + this.footerMode = 'confirmReconcile'; + } + } + + onReconcileCancel(): void { + this.footerMode = 'actions'; + } + + onReconcileConfirm(): void { + if (this.lockRequestInFlight) { + return; + } + // The eligibility rule again, not only in the *ngIf that armed the confirm: this + // is where the request is actually made. + // Reconcile also writes only the flag, never the form, so an edit made while the + // confirm was showing would be frozen unsaved behind the seal. Either way, go + // back to the actions, where the note explains why. + if (!this.reconcileEligible || this.workdayForm.dirty) { + this.footerMode = 'actions'; + return; + } + this.submitLockRequest( + this.planningsService.reconcileDay(this.data.planningPrDayModels.id), + () => this.applyReconciledInPlace(), + () => this.footerMode = 'actions', + ); + } + + /** + * Turns this open dialog read-only instead of closing it (spec §8.2). The server + * stamped the timestamp a moment ago; the client clock stands in for it until the + * close, and the reload that follows brings the stored value for every later open. + */ + private applyReconciledInPlace(): void { + const day = this.data.planningPrDayModels; + day.reconciled = true; + day.reconciledAt = format(new Date(), 'yyyy-MM-dd\'T\'HH:mm:ss'); + this.data.isLocked = true; + this.data.isSealed = true; + // Only open days above the boundary offer reconcile, so this day IS the new boundary. + this.data.isBoundary = true; + this.data.lockedThrough = day.date; + // The setDisabled guard stops every later cascade call from re-enabling a control. + this.workdayForm.disable({emitEvent: false}); + // The in-flight guard has just lifted, but this runs in the same synchronous call + // stack, so no Esc or backdrop click can land between the two: by the time the + // dialog can be dismissed again, every close path already sees this. + this.lockStateChanged = true; + this.footerMode = 'actions'; + } + + // ---- Unlock (spec §8.4) ------------------------------------------------------ + + /** Only the boundary day offers unlock. It moves the line back one notch. */ + get isBoundaryDay(): boolean { + return this.isLocked && this.data.isBoundary === true; + } + + /** The day to free first, for every other locked day: the row's boundary. */ + get freeFirstDate(): string { + return this.datePipe.transform(this.data.lockedThrough, 'dd.MM.yyyy') ?? ''; + } + + /** The localized word to type. The key doubles as its own fallback in untranslated locales. */ + get unlockWord(): string { + return this.translateService.instant('UNLOCK'); + } + + /** Case and spacing are not the friction; the word is. */ + get unlockWordMatches(): boolean { + const norm = (value: string) => (value ?? '').trim().replace(/\s+/g, ' ').toLocaleUpperCase(); + return norm(this.unlockWordCtrl.value) === norm(this.unlockWord); + } + + onUnlockStart(): void { + if (!this.isBoundaryDay) { + return; + } + this.unlockWordCtrl.setValue(''); + this.footerMode = 'confirmUnlock'; + // The input exists only once this change-detection pass has rendered the mode. + setTimeout(() => this.unlockWordInput?.nativeElement.focus()); + } + + onUnlockCancel(): void { + this.unlockWordCtrl.setValue(''); + this.footerMode = 'actions'; + } + + onUnlockConfirm(): void { + // isBoundaryDay again, not only in the template: unlocking anything but the + // boundary is refused by the server, and the rule is worth stating where the + // request is actually made. + if (!this.isBoundaryDay || !this.unlockWordMatches || this.lockRequestInFlight) { + return; + } + // On failure (a newer boundary appeared meanwhile, say) the footer stays in this + // mode, so the word need not be typed again; the toast names the day to free first. + this.submitLockRequest( + this.planningsService.unreconcileDay(this.data.planningPrDayModels.id), + () => { + // The day is editable again, but this form was built locked. The only way + // back to a form whose enable/disable cascade ran from a clean start is to + // close and reopen from a reloaded grid. lockStateChanged is set before the + // close, so no close path can miss it. + this.lockStateChanged = true; + this.dialogRef.close(); + }, + ); + } + openVersionHistory() { this.dialog.open(VersionHistoryModalComponent, { data: { 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 8c387bbd..b84a16fe 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 @@ -446,6 +446,42 @@ describe('TimePlanningsTableComponent', () => { expect(dialogData()).toMatchObject({ isLocked: false, isBoundary: false, isSealed: false }); }); + + describe('the close contract after a reconcile or unlock', () => { + /** A dialog that closed with `payload`, having reported `lockStateChanged`. */ + const dialogThatClosed = (lockStateChanged: boolean, payload: any) => { + (mockDialog.open as jest.Mock).mockReturnValue({ + componentInstance: { lockStateChanged }, + afterClosed: () => of(payload), + } as any); + }; + + it('reloads the grid and sends no save when the dialog reconciled the day', () => { + const changed = jest.fn(); + component.timePlanningChanged.subscribe(changed); + // A reconcile can close through Cancel, Esc or the backdrop, so the close + // payload proves nothing either way; lockStateChanged is what decides. + dialogThatClosed(true, { planningPrDayModels: { id: 1 } }); + + component.onDayColumnClick(dayRow('2026-09-10'), '0'); + + // The day is locked now, so the save would be refused; and the grid memoises + // its cell classes on the row reference, so only a reload redraws it. + expect(mockPlanningsService.updatePlanning).not.toHaveBeenCalled(); + expect(changed).toHaveBeenCalledTimes(1); + }); + + it('still saves a plain close that carries a payload', () => { + // The negative control: without it the assertion above could pass on a close + // path that never saved in the first place. + mockPlanningsService.updatePlanning.mockReturnValue(of({ success: true }) as any); + dialogThatClosed(false, { planningPrDayModels: { id: 1 } }); + + component.onDayColumnClick(dayRow('2026-09-10'), '0'); + + expect(mockPlanningsService.updatePlanning).toHaveBeenCalledWith({ id: 1 }, 1); + }); + }); }); describe('isInOlderThanToday', () => { 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 39273756..800f0437 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 @@ -489,9 +489,7 @@ export class TimePlanningsTableComponent implements OnInit, OnChanges, AfterView this.convertStringToMinutes(data.autoBreakSettings.sunday.breakMinutesUpperLimit as string); this.timePlanningPnSettingsService.updateAssignedSite(data).subscribe(result => { if (result && result.success) { - this.pendingHighlight = { siteId: siteId, field: null }; - this.highlightApplied = false; - this.waitingForFreshData = true; + this.armPendingHighlight(siteId, null); this.assignedSiteChanged.emit(data); } }); @@ -502,6 +500,17 @@ export class TimePlanningsTableComponent implements OnInit, OnChanges, AfterView }}); } + /** + * Marks the cell the grid should flash once fresh data lands, and tells the render + * pass that the rows on screen are now stale. Every write path arms the same three + * fields; splitting them is how one of them gets forgotten. + */ + private armPendingHighlight(siteId: number, field: string | null): void { + this.pendingHighlight = { siteId, field }; + this.highlightApplied = false; + this.waitingForFreshData = true; + } + onDayColumnClick(row: any, field: string): void { const lock = dayLockState(row, field); // F17: a placeholder has no registration behind it, so there is nothing to open. @@ -512,7 +521,7 @@ export class TimePlanningsTableComponent implements OnInit, OnChanges, AfterView const cellData = R.clone(row.planningPrDayModels[field]); this.timePlanningPnSettingsService.getAssignedSite(siteId).subscribe(result => { if (result && result.success) { - this.dialog.open(WorkdayEntityDialogComponent, { + const dialogRef = this.dialog.open(WorkdayEntityDialogComponent, { data: { planningPrDayModels: cellData, assignedSiteModel: result.model, @@ -531,12 +540,23 @@ export class TimePlanningsTableComponent implements OnInit, OnChanges, AfterView maxWidth: '95vw', // Add this maxHeight: '95vh', // Add this panelClass: 'time-planning-dialog' - }) - .afterClosed().subscribe((data: any) => { + }); + // Captured now, because MatDialogRef sets componentInstance to null on close, + // and every close path (Cancel, Esc, backdrop) must be able to report a + // reconcile or unlock that has already reached the server. + const dialog = dialogRef.componentInstance; + dialogRef.afterClosed().subscribe((data: any) => { + if (dialog?.lockStateChanged) { + // Already persisted by the dialog's own PUT. Never fall through to + // updatePlanning: the day is locked now, and the save would be refused. + // A reload is also the only way the grid picks the new state up, since the + // cell classes are memoised on the row reference. + this.armPendingHighlight(siteId, field); + this.timePlanningChanged.emit(null); + return; + } if (data !== '' && data !== undefined) { - this.pendingHighlight = { siteId, field }; - this.highlightApplied = false; - this.waitingForFreshData = true; + this.armPendingHighlight(siteId, field); this.planningsService.updatePlanning(data.planningPrDayModels, data.planningPrDayModels.id).subscribe(result => { if (result && result.success) { this.timePlanningChanged.emit(data); diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/help-wiring.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/help-wiring.spec.ts index c4e972cc..615ba474 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/help/help-wiring.spec.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/help-wiring.spec.ts @@ -42,9 +42,10 @@ const MARKUP = [read(CONTAINER_HTML), read(TABLE_HTML), read(DIALOG_HTML)].join( * from HelpUiStrings, so this task added none; a new entry here means someone * added a key that has to be translated into all 25 shared locale files. * - * `lockedTooltip` and `reconciledLegend` are the reconciled day lock's two grid - * strings (Task 9A). Task 13A adds them, and the rest of the lock's keys, to every - * locale file. + * The reconciled ("afstemt") day lock adds its own strings: `lockedTooltip` and + * `reconciledLegend` in the grid, and `Reconcile day`, `Unlock`, `reconcileDayConfirm`, + * `reconcileNeedsSave`, `unlockFreeFirst` and `unlockTypeWordPrompt` in the day + * dialog's footer. All of them are translated in every shared locale file. */ const TEMPLATE_TRANSLATE_KEYS = [ 'Actual', 'Auto break calculation', 'Cancel', 'CommentOffice', 'CommentWorker', 'Date range', @@ -52,9 +53,11 @@ const TEMPLATE_TRANSLATE_KEYS = [ 'Flex balance to date', 'keyboard_tab', 'keyboard_tab_rtl', 'lockedTooltip', 'Needs update!', 'NettoHours', 'NettoHours override', 'No pay rule set selected', 'PaidOutFlex', 'Pause', 'Plan hours', - 'Planned working hours', 'reconciledLegend', 'Reload table', 'Reset pause to recorded', 'Save', + 'Planned working hours', 'Reconcile day', 'reconcileDayConfirm', 'reconciledLegend', + 'reconcileNeedsSave', 'Reload table', 'Reset pause to recorded', 'Save', 'Shift not stopped by user!', 'Shifts across midnight', 'Show resigned', 'Start', 'Stop', - 'Tags', 'Total breaktime', 'Total working hours', 'Use 1-minute intervals', + 'Tags', 'Total breaktime', 'Total working hours', 'Unlock', 'unlockFreeFirst', + 'unlockTypeWordPrompt', 'Use 1-minute intervals', 'View GPS Location', 'View history', 'View Snapshot', 'Worker', 'Worktime start', 'Worktime stop', ]; @@ -151,7 +154,8 @@ describe('help wiring', () => { .map(hint => /helpId="([^"]+)"/.exec(hint)?.[1]) .filter((id): id is string => !!id); expect(hintIds.sort()).toEqual([ - 'dayCell.futureDisabled', 'dayCell.planHoursLimit', 'grid.noWorkers', + 'dayCell.futureDisabled', 'dayCell.lockedByReconciled', 'dayCell.planHoursLimit', + 'dayCell.reconciled', 'grid.noWorkers', ]); }); diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/bgBG.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/bgBG.ts index 499c799b..a4560291 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/bgBG.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/bgBG.ts @@ -492,4 +492,6 @@ export const bgBG = { unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', + lockRequestFailed: 'The action could not be completed. Try again.', + lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/csCZ.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/csCZ.ts index f7a35af8..9e5714a3 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/csCZ.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/csCZ.ts @@ -492,4 +492,6 @@ export const csCZ = { unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', + lockRequestFailed: 'The action could not be completed. Try again.', + lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/da.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/da.ts index 141d43e7..db9dce0f 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/da.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/da.ts @@ -490,4 +490,6 @@ export const da = { unlockFreeFirst: 'Låst, fordi {{date}} er afstemt. Lås {{date}} op først.', unlockTypeWordPrompt: 'Afstemningen fjernes, og dagen kan redigeres igen. Skriv {{word}} for at bekræfte.', UNLOCK: 'LÅS OP', + lockRequestFailed: 'Handlingen kunne ikke gennemføres. Prøv igen.', + lockRequestUncertain: 'Der kom ikke noget svar. Dagens tilstand er usikker og opdateres, når vinduet lukkes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/deDE.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/deDE.ts index 42d46988..00f1d5fe 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/deDE.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/deDE.ts @@ -492,4 +492,6 @@ export const deDE = { unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', + lockRequestFailed: 'The action could not be completed. Try again.', + lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/elGR.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/elGR.ts index 51b3bb9e..2ca45e14 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/elGR.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/elGR.ts @@ -492,4 +492,6 @@ export const elGR = { unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', + lockRequestFailed: 'The action could not be completed. Try again.', + lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/enUS.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/enUS.ts index a94b25ec..e6e07112 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/enUS.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/enUS.ts @@ -490,4 +490,6 @@ export const enUS = { unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', + lockRequestFailed: 'The action could not be completed. Try again.', + lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/esES.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/esES.ts index dae9a9b0..e61b2fd2 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/esES.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/esES.ts @@ -492,4 +492,6 @@ export const esES = { unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', + lockRequestFailed: 'The action could not be completed. Try again.', + lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/etET.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/etET.ts index 3946012c..40670785 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/etET.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/etET.ts @@ -492,4 +492,6 @@ export const etET = { unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', + lockRequestFailed: 'The action could not be completed. Try again.', + lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/fiFI.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/fiFI.ts index 322b58eb..a0f8b84d 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/fiFI.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/fiFI.ts @@ -492,4 +492,6 @@ export const fiFI = { unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', + lockRequestFailed: 'The action could not be completed. Try again.', + lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/frFR.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/frFR.ts index 4d8521c0..5bbea579 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/frFR.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/frFR.ts @@ -492,4 +492,6 @@ export const frFR = { unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', + lockRequestFailed: 'The action could not be completed. Try again.', + lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/hrHR.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/hrHR.ts index 10aca118..5c8dfff5 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/hrHR.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/hrHR.ts @@ -492,4 +492,6 @@ export const hrHR = { unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', + lockRequestFailed: 'The action could not be completed. Try again.', + lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/huHU.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/huHU.ts index cbb08962..270e71b0 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/huHU.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/huHU.ts @@ -492,4 +492,6 @@ export const huHU = { unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', + lockRequestFailed: 'The action could not be completed. Try again.', + lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/isIS.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/isIS.ts index dec0a49e..6676a3e7 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/isIS.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/isIS.ts @@ -492,4 +492,6 @@ export const isIS = { unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', + lockRequestFailed: 'The action could not be completed. Try again.', + lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/itIT.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/itIT.ts index f1dfdcb9..56611dce 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/itIT.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/itIT.ts @@ -492,4 +492,6 @@ export const itIT = { unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', + lockRequestFailed: 'The action could not be completed. Try again.', + lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ltLT.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ltLT.ts index 2fa9de13..656ec796 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ltLT.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ltLT.ts @@ -492,4 +492,6 @@ export const ltLT = { unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', + lockRequestFailed: 'The action could not be completed. Try again.', + lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/lvLV.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/lvLV.ts index 5496cfa7..bff8b8a7 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/lvLV.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/lvLV.ts @@ -492,4 +492,6 @@ export const lvLV = { unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', + lockRequestFailed: 'The action could not be completed. Try again.', + lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/nlNL.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/nlNL.ts index f4107c36..a3059b87 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/nlNL.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/nlNL.ts @@ -492,4 +492,6 @@ export const nlNL = { unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', + lockRequestFailed: 'The action could not be completed. Try again.', + lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/noNO.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/noNO.ts index 2dc4a794..2246147c 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/noNO.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/noNO.ts @@ -492,4 +492,6 @@ export const noNO = { unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', + lockRequestFailed: 'The action could not be completed. Try again.', + lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/plPL.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/plPL.ts index c0230e1e..e8ef6942 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/plPL.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/plPL.ts @@ -492,4 +492,6 @@ export const plPL = { unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', + lockRequestFailed: 'The action could not be completed. Try again.', + lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ptBR.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ptBR.ts index 27c0e168..2c7b7748 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ptBR.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ptBR.ts @@ -492,4 +492,6 @@ export const ptBR = { unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', + lockRequestFailed: 'The action could not be completed. Try again.', + lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ptPT.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ptPT.ts index ab4ec0eb..088d7712 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ptPT.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ptPT.ts @@ -492,4 +492,6 @@ export const ptPT = { unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', + lockRequestFailed: 'The action could not be completed. Try again.', + lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/roRO.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/roRO.ts index 64701702..0a57c315 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/roRO.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/roRO.ts @@ -492,4 +492,6 @@ export const roRO = { unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', + lockRequestFailed: 'The action could not be completed. Try again.', + lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/skSK.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/skSK.ts index cb57a0f9..e45f69ba 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/skSK.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/skSK.ts @@ -492,4 +492,6 @@ export const skSK = { unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', + lockRequestFailed: 'The action could not be completed. Try again.', + lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/slSL.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/slSL.ts index 84f302cb..b074114c 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/slSL.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/slSL.ts @@ -492,4 +492,6 @@ export const slSL = { unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', + lockRequestFailed: 'The action could not be completed. Try again.', + lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/svSE.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/svSE.ts index cdaedfc0..66b93473 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/svSE.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/svSE.ts @@ -492,4 +492,6 @@ export const svSE = { unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', + lockRequestFailed: 'The action could not be completed. Try again.', + lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ukUA.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ukUA.ts index feb4f45d..f754502d 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ukUA.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ukUA.ts @@ -492,4 +492,6 @@ export const ukUA = { unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', + lockRequestFailed: 'The action could not be completed. Try again.', + lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', }; From d4d58444ddde6267f92c29e8e5a65d8239917be4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Wed, 16 Sep 2026 13:09:03 +0200 Subject: [PATCH 20/28] feat(lock): reconcile many workers through a date from the toolbar Pick a date and the grid shows exactly which days will be sealed, per worker, before anything is sent. With rows ticked the scope is those workers, otherwise every visible worker; a reload or a date change cancels the preview rather than widening it. The result names how many were sealed, how many were already sealed further ahead, and how many had no registration. A request that strands, times out or answers late leaves the state uncertain and refreshes the view. Co-Authored-By: Claude Opus 5 --- .../s/reconcile-bulk-preview.spec.ts | 126 ++++++ .../plannings/day-lock.util.spec.ts | 69 +++ .../components/plannings/day-lock.util.ts | 98 ++++ .../workday-entity-dialog.component.spec.ts | 3 +- .../workday-entity-dialog.component.ts | 76 +--- .../time-plannings-container.component.html | 56 +++ ...time-plannings-container.component.spec.ts | 427 +++++++++++++++++- .../time-plannings-container.component.ts | 246 +++++++++- .../time-plannings-table.component.html | 47 +- .../time-plannings-table.component.spec.ts | 195 ++++++++ .../time-plannings-table.component.ts | 92 +++- .../time-planning-pn/help/help-wiring.spec.ts | 17 +- .../modules/time-planning-pn/i18n/bgBG.ts | 3 +- .../modules/time-planning-pn/i18n/csCZ.ts | 3 +- .../modules/time-planning-pn/i18n/da.ts | 3 +- .../modules/time-planning-pn/i18n/deDE.ts | 3 +- .../modules/time-planning-pn/i18n/elGR.ts | 3 +- .../modules/time-planning-pn/i18n/enUS.ts | 3 +- .../modules/time-planning-pn/i18n/esES.ts | 3 +- .../modules/time-planning-pn/i18n/etET.ts | 3 +- .../modules/time-planning-pn/i18n/fiFI.ts | 3 +- .../modules/time-planning-pn/i18n/frFR.ts | 3 +- .../modules/time-planning-pn/i18n/hrHR.ts | 3 +- .../modules/time-planning-pn/i18n/huHU.ts | 3 +- .../modules/time-planning-pn/i18n/isIS.ts | 3 +- .../modules/time-planning-pn/i18n/itIT.ts | 3 +- .../modules/time-planning-pn/i18n/ltLT.ts | 3 +- .../modules/time-planning-pn/i18n/lvLV.ts | 3 +- .../modules/time-planning-pn/i18n/nlNL.ts | 3 +- .../modules/time-planning-pn/i18n/noNO.ts | 3 +- .../modules/time-planning-pn/i18n/plPL.ts | 3 +- .../modules/time-planning-pn/i18n/ptBR.ts | 3 +- .../modules/time-planning-pn/i18n/ptPT.ts | 3 +- .../modules/time-planning-pn/i18n/roRO.ts | 3 +- .../modules/time-planning-pn/i18n/skSK.ts | 3 +- .../modules/time-planning-pn/i18n/slSL.ts | 3 +- .../modules/time-planning-pn/i18n/svSE.ts | 3 +- .../modules/time-planning-pn/i18n/ukUA.ts | 3 +- 38 files changed, 1430 insertions(+), 100 deletions(-) create mode 100644 eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-bulk-preview.spec.ts diff --git a/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-bulk-preview.spec.ts b/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-bulk-preview.spec.ts new file mode 100644 index 00000000..a22cea3b --- /dev/null +++ b/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-bulk-preview.spec.ts @@ -0,0 +1,126 @@ +import { test, expect } from '@playwright/test'; +import { LoginPage } from '../../../Page objects/Login.page'; +import { + cellOf, closeDayWithoutChange, countPuts, expectSuccess, lastWeekMonday, openDashboardLastWeek, + openDay, RECONCILE_THROUGH_PATH, reconcileDay, rowCheckbox, rowOf, tdOf, unlockAll, unlockDay, + waitForIndex, waitForPut, waitForSpinner, workerAtRow, +} from './reconcile-helpers'; + +/** + * Spec §8.3. The workers are picked from grid rows 7 (A), 8 (B) and 10 (outsider) at the + * start and are found by name after that. B is reconciled further forward (day 5) than + * the bulk target (day 3), so it must be shown as skipped and left where it is. + * + * The shard shares one database and runs its files in order, so both workers are unlocked + * in afterEach as well: a row left locked by a failed assertion breaks every spec after + * this one. + */ +test.describe('Reconciled day lock: bulk reconcile', () => { + let locked: string[] = []; + + test.beforeEach(async ({ page }) => { + locked = []; + await page.goto('http://localhost:4200'); + await new LoginPage(page).login(); + await openDashboardLastWeek(page); + }); + + test.afterEach(async ({ page }) => { + for (const worker of locked) { + await unlockAll(page, worker); + } + }); + + test('a header click previews per worker, skips rows already further, and toasts counts', async ({ page }) => { + const a = await workerAtRow(page, 7); + const b = await workerAtRow(page, 8); + const outsider = await workerAtRow(page, 10); + locked = [a, b]; + const throughs = countPuts(page, RECONCILE_THROUGH_PATH); + + await reconcileDay(page, b, 5); + + await rowCheckbox(page, a).check(); + await rowCheckbox(page, b).check(); + + // Opening a day must not clear the batch selection: mtx-grid selects the row on any + // click inside it unless disableRowClickSelection is on. + await openDay(page, a, 0); + await closeDayWithoutChange(page); + await expect(rowCheckbox(page, a)).toBeChecked(); + await expect(rowCheckbox(page, b)).toBeChecked(); + + // Preview: the region is drawn in place, and nothing is written. + await page.locator('#dayHeader3').click(); + for (const day of [0, 1, 2, 3]) { + await expect(cellOf(page, a, day)).toHaveClass(/tp-preview-lock/); + } + await expect(cellOf(page, a, 3)).toHaveClass(/tp-preview-boundary/); + await expect(cellOf(page, a, 4)).not.toHaveClass(/tp-preview-lock/); + await expect(rowOf(page, b).locator('.tp-preview-skip-label')).toBeVisible(); + await expect(cellOf(page, b, 3)).toHaveClass(/tp-preview-skip/); + await expect(cellOf(page, b, 3)).not.toHaveClass(/tp-preview-lock/); + // The selection is the scope: an unticked worker is not previewed. + await expect(cellOf(page, outsider, 0)).not.toHaveClass(/tp-preview-lock/); + const summary = page.locator('#reconcileScopeSummary'); + await expect(summary).toHaveText(/^Afstem til og med \d{2}\.\d{2}\.\d{4} · Medarbejdere: 1$/); + await expect(page.locator('#reconcileScopeSkipped')).toContainText('Springes over: 1'); + await expect.poll(() => throughs.count, { message: 'the preview must write nothing' }).toBe(0); + + // Cancel drops the preview and still writes nothing. + await page.locator('#reconcileScopeCancel').click(); + await expect(page.locator('#reconcileScopeBar')).toHaveCount(0); + await expect(page.locator('.tp-preview-lock')).toHaveCount(0); + await expect.poll(() => throughs.count, { message: 'cancelling must write nothing' }).toBe(0); + + // Commit. + await page.locator('#dayHeader3').click(); + const [, dd, mm, yyyy] = /(\d{2})\.(\d{2})\.(\d{4})/.exec(await summary.innerText())!; + const put = waitForPut(page, RECONCILE_THROUGH_PATH); + const reload = waitForIndex(page, lastWeekMonday()); + await page.locator('#reconcileScopeConfirm').click(); + const response = await put; + const body = await expectSuccess(response); + const sent = response.request().postDataJSON(); + expect(sent.date, 'the date sent is the date the scope bar named').toBe(`${yyyy}-${mm}-${dd}`); + expect(sent.siteIds, 'the skipped row is sent too, so the server reports it').toHaveLength(2); + expect(body.model.applied).toBe(1); + expect(body.model.skippedAlreadyFurtherForward).toHaveLength(1); + await expect(page.locator('#toast-container')).toContainText('Afstemt: 1 · Sprunget over: 1'); + await reload; + await waitForSpinner(page); + + // The staircase: A at day 3, B untouched at day 5; the selection and preview are gone. + await expect(tdOf(page, a, 3)).toHaveClass(/reconciled-background/); + await expect(tdOf(page, a, 2)).toHaveClass(/locked-background/); + await expect(tdOf(page, a, 4)).not.toHaveClass(/locked-background|reconciled-background/); + await expect(tdOf(page, b, 5)).toHaveClass(/reconciled-background/); + await expect(page.locator('#reconcileScopeBar')).toHaveCount(0); + await expect(rowCheckbox(page, a)).not.toBeChecked(); + + // Cleanup. afterEach is only the safety net for a failure above. + await unlockDay(page, a, 3); + await unlockDay(page, b, 5); + locked = []; + }); + + test('a reload while ticked rows are previewed cancels the preview instead of widening it', async ({ page }) => { + const a = await workerAtRow(page, 7); + const outsider = await workerAtRow(page, 10); + + await rowCheckbox(page, a).check(); + await page.locator('#dayHeader3').click(); + await expect(page.locator('#reconcileScopeSummary')).toContainText('Medarbejdere: 1'); + + const reload = waitForIndex(page, lastWeekMonday()); + await page.locator('#workingHoursReload').click(); + await reload; + await waitForSpinner(page); + + // Silently becoming "every visible worker" would put the outsider in scope. Instead + // the preview is gone. + await expect(page.locator('#reconcileScopeBar')).toHaveCount(0); + await expect(cellOf(page, outsider, 0)).not.toHaveClass(/tp-preview-lock/); + await expect(page.locator('.tp-preview-lock')).toHaveCount(0); + }); +}); diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/day-lock.util.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/day-lock.util.spec.ts index 0b5bfeb0..598d6dd2 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/day-lock.util.spec.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/day-lock.util.spec.ts @@ -1,4 +1,5 @@ import {DatePipe} from '@angular/common'; +import {EMPTY, of, Subject, throwError} from 'rxjs'; import { buildReconcilePreview, dayKey, @@ -9,6 +10,9 @@ import { isDayLocked, isDaySealed, isPlaceholderDay, + LOCK_REQUEST_TIMEOUT_MS, + LockRequestOutcome, + sendLockRequest, } from './day-lock.util'; describe('day-lock util', () => { @@ -261,4 +265,69 @@ describe('day-lock util', () => { expect(preview.outcomeBySiteId[1]).toBeUndefined(); }); }); + /** + * The four outcomes both the day dialog and the toolbar's bulk reconcile decide from. + * Two of them are not failures, and reporting them as one is what makes a user redo a + * write that may already have landed. + */ + describe('sendLockRequest', () => { + const outcomes = (source: any): LockRequestOutcome[] => { + const seen: LockRequestOutcome[] = []; + sendLockRequest(source, outcome => seen.push(outcome)); + return seen; + }; + + it('passes a successful answer straight through', () => { + const answer = {success: true, model: {applied: 2}}; + expect(outcomes(of(answer))).toEqual([{kind: 'success', result: answer}]); + }); + + it('reports a refusal as known, carrying whatever the server said', () => { + expect(outcomes(of({success: false, message: 'DayIsReconciled'}))) + .toEqual([{kind: 'refused', message: 'DayIsReconciled'}]); + }); + + it('reports a refusal with no message as known all the same', () => { + // The caller adds its own fallback line; what matters here is that nothing + // changed on the server, so this must not be confused with an unanswered write. + expect(outcomes(of({success: false}))).toEqual([{kind: 'refused', message: null}]); + }); + + it('reports an error that reached us as known, because someone already spoke', () => { + // HttpErrorInterceptor toasts every 400 and rethrows an empty string. + expect(outcomes(throwError(() => ''))).toEqual([{kind: 'error'}]); + }); + + it('reports a completion with no answer as unknown', () => { + // The interceptor turns a sustained 5xx, and an offline network, into EMPTY. + expect(outcomes(EMPTY)).toEqual([{kind: 'unknown'}]); + }); + + it('stops waiting for a request that never answers, and calls that unknown', () => { + jest.useFakeTimers(); + const seen: LockRequestOutcome[] = []; + const request = new Subject(); + sendLockRequest(request, outcome => seen.push(outcome)); + + expect(seen).toEqual([]); + jest.advanceTimersByTime(LOCK_REQUEST_TIMEOUT_MS + 1); + + expect(seen).toEqual([{kind: 'unknown'}]); + jest.useRealTimers(); + }); + + it('lets no late answer through, because the abort is from this end only', () => { + jest.useFakeTimers(); + const seen: LockRequestOutcome[] = []; + const request = new Subject(); + sendLockRequest(request, outcome => seen.push(outcome)); + jest.advanceTimersByTime(LOCK_REQUEST_TIMEOUT_MS + 1); + + request.next({success: true}); + request.complete(); + + expect(seen).toEqual([{kind: 'unknown'}]); + jest.useRealTimers(); + }); + }); }); diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/day-lock.util.ts b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/day-lock.util.ts index 88165bf8..baf467e6 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/day-lock.util.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/day-lock.util.ts @@ -1,6 +1,8 @@ import {DatePipe} from '@angular/common'; import {TranslateService} from '@ngx-translate/core'; import {format} from 'date-fns'; +import {Observable, Subscription, defaultIfEmpty, throwError, timeout} from 'rxjs'; +import {OperationResult} from 'src/app/common/models'; import {PlanningPrDayModel, TimePlanningModel} from '../../models'; /** @@ -141,6 +143,102 @@ export function formatReconciledProvenance( }); } +// ---- One lock request, four outcomes ----------------------------------------------- + +/** + * How long a reconcile, an unlock or a bulk reconcile may hang before the caller + * stops waiting for it. + * + * It has to be shorter than HttpErrorInterceptor's own budget: its `default:` branch + * retries a failing request five times, fifteen seconds apart, which is about 75 + * seconds before it gives up. Waiting that out behind a blocked backdrop, or behind a + * disabled commit button, is not a state anyone should have to sit through. + */ +export const LOCK_REQUEST_TIMEOUT_MS = 30000; + +/** + * Thrown by the timeout above, so the failure handler can tell the one UNKNOWN error + * apart from the errors that reached us from the server. Recognised by reference, not + * by type, so a test needs no rxjs-internal error object to prove the branch. + */ +const LOCK_REQUEST_TIMED_OUT = {lockRequestTimedOut: true}; + +/** + * What came back. Four outcomes, because the two that are NOT failures must not be + * reported as one: + * - `success` — the server did it. + * - `refused` — KNOWN. The server itself answered "no", so nothing changed. + * ApiBaseService has already toasted `message` when there is one. + * - `error` — KNOWN. The only error HttpErrorInterceptor lets through to a caller + * is its 400 branch, where the request was rejected before anything + * happened — and it has already toasted it. + * - `unknown` — our timeout, or the interceptor giving up after retrying against a + * 5xx or a dead connection. Neither says whether the write landed, so + * the caller must refresh rather than claim nothing happened. + */ +export type LockRequestOutcome = + | {kind: 'success'; result: T} + | {kind: 'refused'; message: string | null} + | {kind: 'error'} + | {kind: 'unknown'}; + +/** + * Closes a `switch` over the outcomes: put `return assertLockOutcomeHandled(outcome);` + * after the last `case`, with every case returning. + * + * The argument narrows to `never` only while the union is exhausted, so adding a fifth + * outcome fails the build at each call site instead of quietly falling through — which, + * for a switch whose last branch means "we do not know what happened", would be the worst + * possible silence. + */ +export function assertLockOutcomeHandled(outcome: never): never { + return outcome; +} + +/** + * The one place a lock request is made, shared by the day dialog and the toolbar's + * bulk reconcile so they cannot drift on what an unanswered request means. + * + * Callers do the rest themselves — their in-flight guards, their messages, their + * refresh — because the dialog blocks its own dismissal while a request is out and the + * toolbar does not. What they must NOT each re-derive is the outcome. + */ +export function sendLockRequest( + request$: Observable, + handle: (outcome: LockRequestOutcome) => void, +): Subscription { + return request$.pipe( + timeout({ + each: LOCK_REQUEST_TIMEOUT_MS, + with: () => throwError(() => LOCK_REQUEST_TIMED_OUT), + }), + // HttpErrorInterceptor turns a sustained 5xx, and an offline network, into EMPTY + // after its retries: a completion with no value and no error. This turns that into + // an ordinary answer of "nothing", so it needs no state of its own to recognise. + defaultIfEmpty(null), + ).subscribe({ + next: (result: T | null) => { + if (result && result.success) { + handle({kind: 'success', result}); + return; + } + if (!result) { + // The EMPTY above. The interceptor reaches it only after retrying against a + // 5xx or a dead connection, and a 5xx can be written AFTER the save has + // committed — the server saves before it writes the response. So a response + // arriving is not evidence that nothing changed. + handle({kind: 'unknown'}); + return; + } + handle({kind: 'refused', message: result.message ?? null}); + }, + error: error => handle( + // Our own timeout aborts the request from this end, but a slow server may commit + // it a moment later. + error === LOCK_REQUEST_TIMED_OUT ? {kind: 'unknown'} : {kind: 'error'}), + }); +} + /** 'lock' = the boundary moves to a day on screen. 'skip' = already at or past the target. */ export type ReconcileRowOutcome = 'lock' | 'skip'; 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 4c15513b..b6b1d0d2 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 @@ -1,7 +1,8 @@ import { readFileSync } from 'fs'; import { join } from 'path'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { LOCK_REQUEST_TIMEOUT_MS, WorkdayEntityDialogComponent } from './workday-entity-dialog.component'; +import { WorkdayEntityDialogComponent } from './workday-entity-dialog.component'; +import { LOCK_REQUEST_TIMEOUT_MS } from '../../day-lock.util'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; import { TimePlanningPnPlanningsService, TimePlanningPnGpsCoordinatesService, TimePlanningPnPictureSnapshotsService } from '../../../../services'; 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 71aeae67..76add59f 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 @@ -15,14 +15,14 @@ import validator from 'validator'; import {DomSanitizer, SafeResourceUrl} from '@angular/platform-browser'; import {TemplateFilesService} from 'src/app/common/services'; import {OperationResult, SharedTagModel} from 'src/app/common/models'; -import {Observable, Subscription, defaultIfEmpty, throwError, timeout} from 'rxjs'; +import {Observable, Subscription} from 'rxjs'; import {ToastrService} from 'ngx-toastr'; import { MatDialogRef } from '@angular/material/dialog'; import {HelpEntryId} from '../../../../help/help.model'; import {HelpPanelService} from '../../../../help/services/help-panel.service'; import {HelpTourService} from '../../../../help/services/help-tour.service'; import {format} from 'date-fns'; -import {dayKey, formatReconciledProvenance} from '../../day-lock.util'; +import {assertLockOutcomeHandled, dayKey, formatReconciledProvenance, sendLockRequest} from '../../day-lock.util'; import { AbstractControl, @@ -35,23 +35,6 @@ import { FormArray, } from '@angular/forms'; -/** - * How long a reconcile or unlock may hang before this dialog stops waiting for it. - * - * It has to be shorter than HttpErrorInterceptor's own budget: its `default:` branch - * retries a failing request five times, fifteen seconds apart, which is about 75 - * seconds before it gives up. Waiting that out behind a blocked backdrop is not a - * state anyone should have to sit through. - */ -export const LOCK_REQUEST_TIMEOUT_MS = 30000; - -/** - * Thrown by the timeout above, so the failure handler can tell the one UNKNOWN outcome - * apart from the known ones: an error the interceptor has already reported, and an - * empty completion, which `defaultIfEmpty` turns into an ordinary answer instead. - */ -const LOCK_REQUEST_TIMED_OUT = {lockRequestTimedOut: true}; - @Component({ selector: 'app-workday-entity-dialog', templateUrl: './workday-entity-dialog.component.html', @@ -2044,6 +2027,10 @@ export class WorkdayEntityDialogComponent implements OnInit, OnDestroy { * dialog from its callback and must not do so while dismissal is still blocked. The * callback runs in the same synchronous call stack, so nothing the user does can * land in the gap; `lockStateChanged` is therefore still set before any close. + * + * The request itself, and what each outcome means, live in the shared util: the + * toolbar's bulk reconcile makes the same shape of request and must reach the same + * four answers. */ private submitLockRequest( request$: Observable, @@ -2059,48 +2046,27 @@ export class WorkdayEntityDialogComponent implements OnInit, OnDestroy { onFailure?.(); }; - request$.pipe( - timeout({ - each: LOCK_REQUEST_TIMEOUT_MS, - with: () => throwError(() => LOCK_REQUEST_TIMED_OUT), - }), - // HttpErrorInterceptor turns a sustained 5xx, and an offline network, into EMPTY - // after its retries: a completion with no value and no error. This turns that - // into an ordinary answer of "nothing", so it needs no state of its own to - // recognise; the next handler decides what it means. - defaultIfEmpty(null), - ).subscribe({ - next: (result: OperationResult | null) => { - if (result && result.success) { + sendLockRequest(request$, outcome => { + switch (outcome.kind) { + case 'success': this.setLockRequestInFlight(false); onSuccess(); return; - } - if (!result) { - // The EMPTY above. The interceptor only reaches it after retrying against - // 5xx or a dead connection, and a 5xx can be written AFTER the save has - // already committed — the server saves before it writes the response. So a - // response arriving is not evidence that nothing changed. - this.markLockStateUncertain(settle); + case 'refused': + // ApiBaseService toasts body.message when the refusal carries one; the + // fallback covers the rare refusal that does not. + settle(outcome.message ? null : 'lockRequestFailed'); return; - } - // KNOWN: the server itself answered "no", so nothing changed. ApiBaseService - // toasts body.message when the refusal carries one; the fallback covers the - // rare refusal that does not. - settle(result.message ? null : 'lockRequestFailed'); - }, - error: error => { - if (error !== LOCK_REQUEST_TIMED_OUT) { - // KNOWN: the only error HttpErrorInterceptor lets through is its 400 branch, - // where the server rejected the request before doing anything. It has also - // already toasted every errorMessage, so saying it again would double up. + case 'error': + // The interceptor has already toasted its 400, so saying it again would + // double up. settle(null); return; - } - // Our own timeout: the request is aborted from this end, but a slow server may - // commit it a moment later. - this.markLockStateUncertain(settle); - }, + case 'unknown': + this.markLockStateUncertain(settle); + return; + } + return assertLockOutcomeHandled(outcome); }); } diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.html b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.html index b4484d6a..a25ca3b1 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.html +++ b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.html @@ -106,6 +106,31 @@ > restart_alt + +
+ + {{ 'Reconcile through' | translate }} + + + + +
+
+ lock +
+ {{ 'reconcileScopeSummary' | translate: {date: reconcileTargetLabel, count: preview.willReconcileCount} }} + {{ 'reconcileScopeSkipped' | translate: {count: preview.skipCount} }} +
+ + +
+
@@ -137,10 +189,14 @@ [timePlannings]="timePlannings" [dateFrom]="dateFrom" [dateTo]="dateTo" + [reconcilePreview]="reconcilePreview" (timePlanningChanged)="onTimePlanningChanged($event)" (assignedSiteChanged)="onAssignedSiteChanged($event)" (tagSelected)="onTagSelectedFromRow($event)" (highlightedRowRendered)="onHighlightedRowRendered()" + (selectionChanged)="onSelectionChanged($event)" + (selectionReset)="onSelectionReset()" + (reconcileDateRequested)="onReconcileDateChanged($event)" > diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.spec.ts index c923bee3..0cd0d394 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.spec.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.spec.ts @@ -4,12 +4,14 @@ import { TimePlanningPnPlanningsService } from '../../../services/time-planning- import { TimePlanningPnSettingsService } from '../../../services/time-planning-pn-settings.service'; import { MatDialog } from '@angular/material/dialog'; import { Store } from '@ngrx/store'; -import { of } from 'rxjs'; -import { format } from 'date-fns'; +import { EMPTY, of, Subject, throwError } from 'rxjs'; +import { addDays, endOfWeek, format, startOfWeek, subDays } from 'date-fns'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { ToastrService } from 'ngx-toastr'; import { HelpTourService } from '../../../help/services/help-tour.service'; import { HelpVisibilityService } from '../../../help/services/help-visibility.service'; +import { LOCK_REQUEST_TIMEOUT_MS } from '../day-lock.util'; describe('TimePlanningsContainerComponent', () => { let component: TimePlanningsContainerComponent; @@ -23,6 +25,7 @@ describe('TimePlanningsContainerComponent', () => { mockPlanningsService = { getPlannings: jest.fn(), updatePlanning: jest.fn(), + reconcileThrough: jest.fn(), } as any; mockSettingsService = { getAvailableSites: jest.fn(), @@ -53,7 +56,11 @@ describe('TimePlanningsContainerComponent', () => { { provide: TimePlanningPnPlanningsService, useValue: mockPlanningsService }, { provide: TimePlanningPnSettingsService, useValue: mockSettingsService }, { provide: MatDialog, useValue: mockDialog }, - { provide: Store, useValue: mockStore } + { provide: Store, useValue: mockStore }, + { + provide: ToastrService, + useValue: { success: jest.fn(), warning: jest.fn(), error: jest.fn() }, + }, ] }).compileComponents(); @@ -334,4 +341,416 @@ describe('TimePlanningsContainerComponent', () => { expect(start).not.toHaveBeenCalled(); }); }); + /** + * Last week, so every visible day is in the past and a bulk target is never refused for + * being today or later. Every date is derived from the clock rather than written down, + * so no passing calendar date and no viewer timezone can turn these blocks red. + */ + const lastWeekStart = startOfWeek(subDays(new Date(), 7), { weekStartsOn: 1 }); + const lastWeekEnd = endOfWeek(lastWeekStart, { weekStartsOn: 1 }); + const dayOf = (index: number) => addDays(lastWeekStart, index); + const keyOf = (index: number) => format(dayOf(index), 'yyyy-MM-dd'); + /** A worker with a registration on each of the seven visible days. */ + const rowFor = (siteId: number, lockedThrough: string | null = null) => ({ + siteId, + siteName: `Worker ${siteId}`, + lockedThrough, + planningPrDayModels: Array.from({ length: 7 }, (_, index) => ({ + id: siteId * 10 + index + 1, + date: `${keyOf(index)}T00:00:00`, + })), + }) as any; + + describe('Bulk reconcile preview', () => { + const load = (...rows: any[]) => { + mockPlanningsService.getPlannings.mockReturnValue(of({ success: true, model: rows }) as any); + component.getPlannings(); + }; + + beforeEach(() => { + component.dateFrom = lastWeekStart; + component.dateTo = lastWeekEnd; + load(rowFor(1), rowFor(2)); + }); + + it('previews every visible worker when no row is ticked', () => { + component.onReconcileDateChanged(dayOf(3)); + + expect(component.reconcilePreview?.siteIds).toEqual([1, 2]); + expect(component.reconcilePreview?.target).toBe(keyOf(3)); + expect(component.reconcilePreview?.landingBySiteId).toEqual({ 1: keyOf(3), 2: keyOf(3) }); + expect(component.reconcilePreview?.willReconcileCount).toBe(2); + }); + + it('previews only the ticked rows, never the rest of the grid', () => { + component.onSelectionChanged([1]); + component.onReconcileDateChanged(dayOf(3)); + + expect(component.reconcilePreview?.siteIds).toEqual([1]); + expect(component.reconcilePreview?.willReconcileCount).toBe(1); + }); + + it('shows a row already reconciled past the target as skipped, not moved backwards', () => { + load(rowFor(1), rowFor(2, `${keyOf(5)}T00:00:00`)); + + component.onReconcileDateChanged(dayOf(3)); + + expect(component.reconcilePreview?.outcomeBySiteId).toEqual({ 1: 'lock', 2: 'skip' }); + expect(component.reconcilePreview?.willReconcileCount).toBe(1); + expect(component.reconcilePreview?.skipCount).toBe(1); + // Skipped rows are still sent, so the server reports them rather than the client + // predicting them. + expect(component.reconcilePreview?.siteIds).toEqual([1, 2]); + }); + + it('drops a target before the days on screen', () => { + component.onReconcileDateChanged(subDays(lastWeekStart, 1)); + + // The preview can only draw cells that are on screen, so an off-screen target is + // dropped rather than committed blind. + expect(component.reconcilePreview).toBeNull(); + expect(component.reconcileThroughDate).toBeNull(); + }); + + it('drops a target at or after today, which can never be reconciled', () => { + component.onReconcileDateChanged(new Date()); + + expect(component.reconcilePreview).toBeNull(); + expect(component.reconcileThroughDate).toBeNull(); + }); + + it('offers no target at all when the whole visible range is today or later', () => { + component.dateFrom = new Date(); + component.dateTo = addDays(new Date(), 6); + load(rowFor(1)); + + // Both ends, or the field would advertise a minimum it will never accept. + expect(component.reconcileMaxDate).toBeNull(); + expect(component.reconcileMinDate).toBeNull(); + }); + + it('drops the preview when the week navigates away from the target', () => { + component.onReconcileDateChanged(dayOf(3)); + expect(component.reconcilePreview).not.toBeNull(); + + component.dateFrom = addDays(lastWeekStart, 7); + component.dateTo = addDays(lastWeekEnd, 7); + load(rowFor(1), rowFor(2)); + + expect(component.reconcilePreview).toBeNull(); + }); + + it('cancels, never widens, the preview when a reload drops the ticked rows', () => { + component.onSelectionChanged([1]); + component.onReconcileDateChanged(dayOf(3)); + expect(component.reconcilePreview?.siteIds).toEqual([1]); + + // A day save, the assigned-site dialog, Reload, a filter, the resigned toggle. + component.getPlannings(); + + expect(component.selectedSiteIds).toEqual([]); + // Rebuilding here would fall back to "everyone visible" and put worker 2 in scope. + expect(component.reconcilePreview).toBeNull(); + }); + + it('cancels the preview when the grid drops the ticked rows by itself', () => { + component.onSelectionChanged([1]); + component.onReconcileDateChanged(dayOf(3)); + + component.onSelectionReset(); + + expect(component.selectedSiteIds).toEqual([]); + expect(component.reconcilePreview).toBeNull(); + }); + + it('redraws an unticked preview on the new rows, because that scope cannot widen', () => { + component.onReconcileDateChanged(dayOf(3)); + + load(rowFor(1), rowFor(2), rowFor(3)); + + // Still "everyone visible", so the preview follows the rows instead of vanishing. + expect(component.reconcilePreview?.siteIds).toEqual([1, 2, 3]); + }); + + it('ignores a reset the grid reports when nothing was ticked', () => { + component.onReconcileDateChanged(dayOf(3)); + + component.onSelectionReset(); + + expect(component.reconcilePreview?.siteIds).toEqual([1, 2]); + }); + + it('returns to every visible worker when the last row is unticked by hand', () => { + component.onSelectionChanged([1]); + component.onReconcileDateChanged(dayOf(3)); + expect(component.reconcilePreview?.siteIds).toEqual([1]); + + component.onSelectionChanged([]); + + // The deliberate spec rule: the scope bar's count changes in plain sight. + expect(component.reconcilePreview?.siteIds).toEqual([1, 2]); + }); + + it('names the target the way the scope bar reads it', () => { + component.onReconcileDateChanged(dayOf(3)); + + expect(component.reconcileTargetLabel).toBe(format(dayOf(3), 'dd.MM.yyyy')); + }); + + /** + * The date-range controls change the day columns in the change-detection pass that + * renders the scope bar, while their reload answers later. The grid reports its own + * silent reset inside that same pass, so a scope still standing at that moment would + * flip the bar from shown to gone after it had already been checked (NG0100). Each + * handler therefore clears the scope before it touches the dates. + */ + describe('clears the bulk scope before the reload answers', () => { + const navigations: [string, () => void][] = [ + ['goForward', () => component.goForward()], + ['goBackward', () => component.goBackward()], + ['updateDateTo', () => component.updateDateTo({ value: addDays(lastWeekEnd, 7) } as any)], + ]; + + for (const [name, navigate] of navigations) { + it(name, () => { + component.onSelectionChanged([1]); + component.onReconcileDateChanged(dayOf(3)); + expect(component.reconcilePreview).not.toBeNull(); + // A load that has NOT answered yet: this is the window the reset lands in. + mockPlanningsService.getPlannings.mockReturnValue(new Subject() as any); + + navigate(); + + expect(component.selectedSiteIds).toEqual([]); + expect(component.reconcilePreview).toBeNull(); + + // The grid's reset now finds an empty scope and changes nothing, which is what + // keeps the already-checked scope bar from moving. + component.onSelectionReset(); + expect(component.reconcilePreview).toBeNull(); + }); + } + }); + }); + + describe('Reconcile through', () => { + const resultModel = (overrides: any = {}) => ({ + landedOnBySiteId: { 1: `${keyOf(3)}T00:00:00` }, + applied: 1, + skippedAlreadyFurtherForward: [2], + skippedNoRegistration: [], + alreadyReconciledSiteIds: [], + ...overrides, + }); + let toastr: any; + let reloads: number; + + /** Worker 1 will be sealed on day 3; worker 2 is already further forward. */ + beforeEach(() => { + toastr = TestBed.inject(ToastrService) as any; + component.dateFrom = lastWeekStart; + component.dateTo = lastWeekEnd; + mockPlanningsService.getPlannings.mockReturnValue( + of({ success: true, model: [rowFor(1), rowFor(2, `${keyOf(5)}T00:00:00`)] }) as any); + component.getPlannings(); + component.onReconcileDateChanged(addDays(lastWeekStart, 3)); + reloads = mockPlanningsService.getPlannings.mock.calls.length; + }); + + it('sends exactly the rows the preview drew, skipped ones included', () => { + mockPlanningsService.reconcileThrough.mockReturnValue( + of({ success: true, model: resultModel() }) as any); + + component.confirmReconcileThrough(); + + expect(mockPlanningsService.reconcileThrough).toHaveBeenCalledWith(keyOf(3), [1, 2]); + }); + + it('toasts the counts the server decided, then clears the preview and reloads', () => { + mockPlanningsService.reconcileThrough.mockReturnValue( + of({ success: true, model: resultModel() }) as any); + + component.confirmReconcileThrough(); + + // TranslateModule.forRoot() has no catalogue loaded, so instant() echoes the key. + expect(toastr.success).toHaveBeenCalledWith('reconcileThroughResult'); + expect(component.reconcilePreview).toBeNull(); + expect(component.reconcileInFlight).toBe(false); + expect(mockPlanningsService.getPlannings.mock.calls.length).toBe(reloads + 1); + }); + + it('reports the two skip reasons as separate figures, never merged', () => { + const instant = jest.spyOn(TestBed.inject(TranslateService), 'instant'); + mockPlanningsService.reconcileThrough.mockReturnValue(of({ + success: true, + model: resultModel({ + applied: 2, + skippedAlreadyFurtherForward: [2, 5], + skippedNoRegistration: [7, 8, 9], + }), + }) as any); + + component.confirmReconcileThrough(); + + // "Already sealed further ahead" is benign; "no registration at all" means nothing + // was sealed for those workers. One merged figure reads as the first and hides the + // second, which is the thing the bulk action exists to surface. + expect(instant).toHaveBeenCalledWith('reconcileThroughResult', { applied: 2, skipped: 2 }); + expect(instant).toHaveBeenCalledWith('reconcileThroughNoRegistration', { count: 3 }); + expect(instant).not.toHaveBeenCalledWith('reconcileThroughResult', { applied: 2, skipped: 5 }); + expect(toastr.success) + .toHaveBeenCalledWith('reconcileThroughResult · reconcileThroughNoRegistration'); + instant.mockRestore(); + }); + + it('leaves the no-registration figure out when there is none', () => { + const instant = jest.spyOn(TestBed.inject(TranslateService), 'instant'); + mockPlanningsService.reconcileThrough.mockReturnValue( + of({ success: true, model: resultModel() }) as any); + + component.confirmReconcileThrough(); + + expect(instant).not.toHaveBeenCalledWith('reconcileThroughNoRegistration', expect.anything()); + expect(toastr.success).toHaveBeenCalledWith('reconcileThroughResult'); + instant.mockRestore(); + }); + + it('says the state is uncertain when a success carries no counts', () => { + // The rows were sealed; we simply cannot say how many. Saying nothing would leave a + // commit with no acknowledgement at all. + mockPlanningsService.reconcileThrough.mockReturnValue( + of({ success: true, model: null }) as any); + + component.confirmReconcileThrough(); + + expect(toastr.error).toHaveBeenCalledWith('lockRequestUncertain'); + expect(toastr.success).not.toHaveBeenCalled(); + expect(component.reconcilePreview).toBeNull(); + expect(mockPlanningsService.getPlannings.mock.calls.length).toBe(reloads + 1); + }); + + it('warns instead of celebrating when nothing was applied', () => { + mockPlanningsService.reconcileThrough.mockReturnValue( + of({ success: true, model: resultModel({ applied: 0, skippedNoRegistration: [1] }) }) as any); + + component.confirmReconcileThrough(); + + expect(toastr.warning).toHaveBeenCalledWith('reconcileThroughResult'); + expect(toastr.success).not.toHaveBeenCalled(); + }); + + it('reports the workers that were already reconciled on the landing day', () => { + mockPlanningsService.reconcileThrough.mockReturnValue( + of({ success: true, model: resultModel({ alreadyReconciledSiteIds: [2] }) }) as any); + + component.confirmReconcileThrough(); + + expect(toastr.success).toHaveBeenCalledWith('reconcileThroughResult · reconcileThroughUnchanged'); + }); + + it('says nothing of its own when the server refused with a message', () => { + // ApiBaseService has already toasted body.message. + mockPlanningsService.reconcileThrough.mockReturnValue( + of({ success: false, message: 'CannotReconcileTodayOrFuture' }) as any); + + component.confirmReconcileThrough(); + + expect(toastr.error).not.toHaveBeenCalled(); + // A KNOWN refusal changed nothing, so the preview stays up to be retried. + expect(component.reconcilePreview).not.toBeNull(); + expect(component.reconcileInFlight).toBe(false); + expect(mockPlanningsService.getPlannings.mock.calls.length).toBe(reloads); + }); + + it('falls back to its own message when a refusal carries none', () => { + mockPlanningsService.reconcileThrough.mockReturnValue(of({ success: false }) as any); + + component.confirmReconcileThrough(); + + expect(toastr.error).toHaveBeenCalledWith('lockRequestFailed'); + expect(component.reconcilePreview).not.toBeNull(); + expect(mockPlanningsService.getPlannings.mock.calls.length).toBe(reloads); + }); + + it('says nothing of its own when an error reaches it, since the interceptor spoke', () => { + // HttpErrorInterceptor toasts every 400 and rethrows an empty string. + mockPlanningsService.reconcileThrough.mockReturnValue(throwError(() => '') as any); + + component.confirmReconcileThrough(); + + expect(toastr.error).not.toHaveBeenCalled(); + expect(component.reconcileInFlight).toBe(false); + expect(component.reconcilePreview).not.toBeNull(); + expect(mockPlanningsService.getPlannings.mock.calls.length).toBe(reloads); + }); + + it('treats a completion with no answer as uncertain, and reloads', () => { + // The interceptor gives up with EMPTY after retrying against a 5xx or a dead + // connection, and a 5xx can be written after the rows were already sealed. + mockPlanningsService.reconcileThrough.mockReturnValue(EMPTY as any); + + component.confirmReconcileThrough(); + + expect(toastr.error).toHaveBeenCalledWith('lockRequestUncertain'); + expect(toastr.success).not.toHaveBeenCalled(); + expect(component.reconcilePreview).toBeNull(); + expect(component.reconcileInFlight).toBe(false); + expect(mockPlanningsService.getPlannings.mock.calls.length).toBe(reloads + 1); + }); + + it('stops waiting for a request that never answers, and reloads rather than claim failure', () => { + jest.useFakeTimers(); + const tour = jest.spyOn(TestBed.inject(HelpTourService), 'start').mockImplementation(() => undefined); + const request = new Subject(); + mockPlanningsService.reconcileThrough.mockReturnValue(request as any); + + component.confirmReconcileThrough(); + expect(component.reconcileInFlight).toBe(true); + + jest.advanceTimersByTime(LOCK_REQUEST_TIMEOUT_MS + 1); + + expect(component.reconcileInFlight).toBe(false); + expect(toastr.error).toHaveBeenCalledWith('lockRequestUncertain'); + expect(component.reconcilePreview).toBeNull(); + expect(mockPlanningsService.getPlannings.mock.calls.length).toBe(reloads + 1); + + // The abort is from this end only: a late answer must reach nobody. + const after = mockPlanningsService.getPlannings.mock.calls.length; + request.next({ success: true, model: resultModel() }); + request.complete(); + expect(toastr.success).not.toHaveBeenCalled(); + expect(mockPlanningsService.getPlannings.mock.calls.length).toBe(after); + + tour.mockRestore(); + jest.useRealTimers(); + }); + + it('refuses to commit twice while a request is in flight', () => { + const request = new Subject(); + mockPlanningsService.reconcileThrough.mockReturnValue(request as any); + + component.confirmReconcileThrough(); + component.confirmReconcileThrough(); + + expect(mockPlanningsService.reconcileThrough).toHaveBeenCalledTimes(1); + }); + + it('commits nothing when every row in the preview is skipped', () => { + // Only worker 2, who is already further forward than the target. + component.onSelectionChanged([2]); + expect(component.reconcilePreview?.willReconcileCount).toBe(0); + + component.confirmReconcileThrough(); + + expect(mockPlanningsService.reconcileThrough).not.toHaveBeenCalled(); + }); + + it('commits nothing when there is no preview', () => { + component.cancelReconcilePreview(); + + component.confirmReconcileThrough(); + + expect(mockPlanningsService.reconcileThrough).not.toHaveBeenCalled(); + }); + }); }); diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.ts b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.ts index 21f9ebd5..18abbb34 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.ts @@ -4,12 +4,19 @@ import { Component, OnDestroy, OnInit, import { AutoUnsubscribe } from 'ngx-auto-unsubscribe'; import {Subscription, take, forkJoin} from 'rxjs'; import { SiteDto } from 'src/app/common/models'; -import {AssignedSiteModel, CommonTagModel, TimePlanningModel, TimePlanningsRequestModel} from '../../../models'; +import { + AssignedSiteModel, CommonTagModel, ReconcileThroughResultModel, TimePlanningModel, TimePlanningsRequestModel, +} from '../../../models'; import { TimePlanningPnPlanningsService, TimePlanningPnSettingsService, } from '../../../services'; -import {startOfWeek, endOfWeek, format} from 'date-fns'; +import {startOfWeek, endOfWeek, format, startOfDay, subDays} from 'date-fns'; +import {ToastrService} from 'ngx-toastr'; +import {TranslateService} from '@ngx-translate/core'; +import { + assertLockOutcomeHandled, buildReconcilePreview, ReconcilePreview, sendLockRequest, +} from '../day-lock.util'; import {ExcelIcon, iOSIcon, PARSING_DATE_FORMAT} from 'src/app/common/const'; import {Store} from '@ngrx/store'; import {selectCurrentUserLocale, selectCurrentUserIsAdmin} from 'src/app/state'; @@ -37,6 +44,8 @@ export class TimePlanningsContainerComponent implements OnInit, OnDestroy { private helpContent = inject(HelpContentService); private helpPanel = inject(HelpPanelService); private helpTour = inject(HelpTourService); + private toastrService = inject(ToastrService); + private translateService = inject(TranslateService); /** Protected, not private: the ? button binds isVisible$ straight from the template. */ protected helpVisibility = inject(HelpVisibilityService); @@ -54,9 +63,19 @@ export class TimePlanningsContainerComponent implements OnInit, OnDestroy { dateTo: Date; siteId: number = null; // Default to 0 to get all sites + /** Ticked rows (site ids). Empty means every worker currently visible. */ + selectedSiteIds: number[] = []; + /** The bulk target, always a day on screen. See refreshReconcileBounds. */ + reconcileThroughDate: Date | null = null; + reconcilePreview: ReconcilePreview | null = null; + reconcileInFlight = false; + reconcileMinDate: Date | null = null; + reconcileMaxDate: Date | null = null; + getTimePlannings$: Subscription; updateTimePlanning$: Subscription; getAvailableSites$: Subscription; + reconcileThrough$: Subscription; public selectCurrentUserLocale$ = this.store.select(selectCurrentUserLocale); locale: string; @@ -145,12 +164,225 @@ export class TimePlanningsContainerComponent implements OnInit, OnDestroy { .getPlannings(this.timePlanningsRequest) .subscribe((data) => { if (data && data.success) { - this.timePlannings = data.model; + this.applyPlannings(data.model); } this.startPageTourOnce(); }); } + /** + * Drops the bulk scope without rebuilding anything. + * + * Anything that makes the grid throw the ticked rows away has to clear the scope BEFORE + * change detection reaches the scope bar. The grid reports its own reset synchronously, + * from inside the very pass that renders the bar, so a scope still standing at that + * moment would flip the bar from shown to gone after it had already been checked — an + * NG0100 in a dev build. Clearing first is what lets onSelectionReset return early + * instead. + */ + private clearBulkScope(): void { + this.selectedSiteIds = []; + this.cancelReconcilePreview(); + } + + /** + * Every path that replaces the rows with a fresh load comes through here: a day save, + * the assigned-site dialog, Reload, a filter, the resigned toggle. The grid drops the + * ticked rows on new data and says nothing. + * - If rows were ticked, the preview is CANCELLED, never rebuilt. A rebuild would fall + * back to "every visible worker" and silently widen the scope from the ticked rows to + * all of them, and a mistaken commit then costs one typed-word unlock per worker. + * - If nothing was ticked, the scope was already "everyone visible", so the preview is + * redrawn on the new rows. + * Both happen here, before change detection, for the reason clearBulkScope gives. + * + * The date-range handlers cannot wait for this: they change the day columns in the same + * pass and their load answers later, so they call clearBulkScope themselves first. + */ + private applyPlannings(model: TimePlanningModel[]): void { + const hadSelection = this.selectedSiteIds.length > 0; + this.timePlannings = model; + this.selectedSiteIds = []; + this.refreshReconcileBounds(); + if (hadSelection) { + this.cancelReconcilePreview(); + } else { + this.rebuildReconcilePreview(); + } + } + + /** + * The bulk target must be on screen, because the preview can only draw what is on + * screen. Nothing at or after today may be reconciled, so it is capped at yesterday. + * A null max means nothing in view can be reconciled at all. + */ + private refreshReconcileBounds(): void { + const from = startOfDay(this.dateFrom); + const yesterday = startOfDay(subDays(new Date(), 1)); + const lastVisible = startOfDay(this.dateTo); + const max = lastVisible < yesterday ? lastVisible : yesterday; + if (max < from) { + // Nothing in view can be reconciled, so there is no range to offer — not a range + // whose start is still a real day. Both ends go, or the field would advertise a + // minimum it will never accept. + this.reconcileMinDate = null; + this.reconcileMaxDate = null; + return; + } + this.reconcileMinDate = from; + this.reconcileMaxDate = max; + } + + get reconcileTargetLabel(): string { + return this.reconcileThroughDate ? format(this.reconcileThroughDate, 'dd.MM.yyyy') : ''; + } + + /** + * The user ticked or unticked a row. Unticking the last one returns the scope to + * "every visible worker", as the spec defines, and the scope bar's count changes in + * plain sight. + */ + onSelectionChanged(siteIds: number[]): void { + this.selectedSiteIds = siteIds; + this.rebuildReconcilePreview(); + } + + /** The grid dropped the ticked rows by itself (new columns). Cancel rather than widen. */ + onSelectionReset(): void { + if (this.selectedSiteIds.length === 0) { + // The scope is already gone: cleared by applyPlannings on a reload, or eagerly by + // the date-range handlers. Touching it again here would be the NG0100. + return; + } + this.clearBulkScope(); + } + + /** From the toolbar field or a day-column header. Starts the preview and commits nothing. */ + onReconcileDateChanged(date: Date | null): void { + this.reconcileThroughDate = date ? startOfDay(date) : null; + this.rebuildReconcilePreview(); + } + + cancelReconcilePreview(): void { + this.reconcileThroughDate = null; + this.reconcilePreview = null; + } + + private rebuildReconcilePreview(): void { + const target = this.reconcileThroughDate; + const onScreen = !!target && !!this.reconcileMinDate && !!this.reconcileMaxDate + && target >= this.reconcileMinDate && target <= this.reconcileMaxDate; + if (!onScreen) { + // Also covers navigating away from the target's week: the preview leaves with it. + this.cancelReconcilePreview(); + return; + } + // No selection means every worker currently visible under the active filters. + const scope = this.selectedSiteIds.length + ? this.selectedSiteIds + : this.timePlannings.map(x => x.siteId); + this.reconcilePreview = buildReconcilePreview(this.timePlannings, scope, format(target, 'yyyy-MM-dd')); + } + + /** + * Commits the previewed region. The request spans many rows, so it is the likeliest of + * all the lock requests to outlive a client timeout with a commit already in progress — + * which is why the outcome comes from the shared helper rather than a plain + * {next, error} subscribe: an unanswered bulk request reported as "nothing happened" + * would have the user reconcile the same region twice. + */ + confirmReconcileThrough(): void { + const preview = this.reconcilePreview; + if (!preview || this.reconcileInFlight || preview.willReconcileCount === 0) { + return; + } + this.reconcileInFlight = true; + // Exactly the rows the preview drew. Skipped rows go too, so that the toast reports + // what the server decided rather than what the client predicted. + this.reconcileThrough$ = sendLockRequest( + this.planningsService.reconcileThrough(preview.target, preview.siteIds), + outcome => { + this.reconcileInFlight = false; + switch (outcome.kind) { + case 'success': + if (!this.reportReconcileThrough(outcome.result.model)) { + // A success with no body still sealed the rows; we just cannot say how + // many. Saying the state is uncertain is true and the reload settles it, + // where saying nothing would leave a commit with no acknowledgement at all. + this.toastrService.error(this.translateService.instant('lockRequestUncertain')); + } + this.refreshAfterReconcileThrough(); + return; + case 'refused': + // KNOWN: the server answered "no" and nothing changed, so the preview stays + // up to be retried. ApiBaseService toasts body.message when there is one. + if (!outcome.message) { + this.toastrService.error(this.translateService.instant('lockRequestFailed')); + } + return; + case 'error': + // KNOWN: the interceptor's 400 branch, rejected before anything happened and + // already toasted. The preview stays up. + return; + case 'unknown': + // Our own timeout, or the interceptor giving up after retrying. Part of the + // region may well be sealed, so the grid on screen is no longer trustworthy: + // drop the preview drawn over it and reload. + this.toastrService.error(this.translateService.instant('lockRequestUncertain')); + this.refreshAfterReconcileThrough(); + return; + } + return assertLockOutcomeHandled(outcome); + }); + } + + /** + * The grid memoises a cell's background on the row reference, so only a whole new set + * of rows redraws the region that was just sealed. + */ + private refreshAfterReconcileThrough(): void { + this.cancelReconcilePreview(); + this.getPlannings(); + } + + /** + * What the server decided, worker by worker. Returns false when there was no body to + * report, so the caller can say something rather than nothing. + * + * The two skip reasons are reported SEPARATELY, and the server keeps them apart for the + * same reason. One merged "skipped" figure reads as "already sealed further ahead" — + * that is the benign one — while it may equally mean "these workers have no + * registration, so nothing was sealed for them at all". Those call for opposite + * responses from a planner, and the bulk action exists precisely to surface the second. + */ + private reportReconcileThrough(model: ReconcileThroughResultModel | null): boolean { + if (!model) { + return false; + } + const message = this.buildReconcileThroughMessage(model); + if (model.applied > 0) { + this.toastrService.success(message); + } else { + this.toastrService.warning(message); + } + return true; + } + + /** The segments the result is read as, in the order a planner needs them. */ + private buildReconcileThroughMessage(model: ReconcileThroughResultModel): string { + const segments = [this.translateService.instant('reconcileThroughResult', + {applied: model.applied, skipped: model.skippedAlreadyFurtherForward.length})]; + if (model.skippedNoRegistration.length) { + segments.push(this.translateService.instant('reconcileThroughNoRegistration', + {count: model.skippedNoRegistration.length})); + } + if (model.alreadyReconciledSiteIds.length) { + segments.push(this.translateService.instant('reconcileThroughUnchanged', + {count: model.alreadyReconciledSiteIds.length})); + } + return segments.join(' · '); + } + /** Help chrome labels. Never the shared ngx-translate catalogue. */ get helpUi(): HelpUiStrings { return this.helpContent.ui(); @@ -197,6 +429,8 @@ export class TimePlanningsContainerComponent implements OnInit, OnDestroy { } goBackward() { + // The columns change in this same pass; the load answers later. See clearBulkScope. + this.clearBulkScope(); const tempEndDate = new Date(this.dateTo); tempEndDate.setHours(0, 0, 0, 0); @@ -234,6 +468,8 @@ export class TimePlanningsContainerComponent implements OnInit, OnDestroy { } goForward() { + // The columns change in this same pass; the load answers later. See clearBulkScope. + this.clearBulkScope(); const tempEndDate = new Date(this.dateTo); tempEndDate.setHours(0, 0, 0, 0); let daysCount = Math.floor((tempEndDate.getTime() - this.dateFrom.getTime()) / (1000 * 3600 * 24)) +1; @@ -276,6 +512,8 @@ export class TimePlanningsContainerComponent implements OnInit, OnDestroy { updateDateTo(dateTo: MatDatepickerInputEvent) { if (dateTo.value) { + // The columns change in this same pass; the load answers later. See clearBulkScope. + this.clearBulkScope(); this.dateTo = dateTo.value; this.dateTo.setHours(23, 59, 59, 999); this.getPlannings(); @@ -296,7 +534,7 @@ export class TimePlanningsContainerComponent implements OnInit, OnDestroy { this.availableSites = sitesResult.model; } if (planningsResult && planningsResult.success) { - this.timePlannings = planningsResult.model; + this.applyPlannings(planningsResult.model); } }); } 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 53edbbe9..b48fbe48 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 @@ -16,6 +16,13 @@ helpId="grid.nameColumn" (openInPanel)="openHelp($event)"> + @@ -82,6 +94,12 @@
{{ row[col.field] }} + + {{ 'reconcileRowSkipped' | translate }}
{{ padZero(row['plannedHours']) }}:{{ padZero(row['plannedMinutes']) }} @@ -181,6 +199,12 @@
{{ row[col.field] }} + + {{ 'reconcileRowSkipped' | translate }}
{{ padZero(row['plannedHours']) }}:{{ padZero(row['plannedMinutes']) }} @@ -256,7 +280,17 @@ -
+ +
@@ -489,3 +523,14 @@
+ + + + + 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 b84a16fe..b731ab5a 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 @@ -693,4 +693,199 @@ describe('TimePlanningsTableComponent', () => { expect(component.convertHoursToTimeWithSeconds(-1.5)).toBe('-1:30:00'); }); }); + /** + * Rebuilding the day columns flushes change detection twice, so mtx-grid sees the + * emptied [columns] before the new ones. What these blocks assert is the map the + * rebuild produces, so the flush is stubbed out rather than driven through a live + * view. + */ + const stubHeaderFlush = (target: any) => { + target.cdr = { detectChanges: jest.fn() }; + }; + + /** + * The worker set for a bulk reconcile. mtx-grid rebuilds its SelectionModel empty on + * ANY input change and emits nothing, so the component has to report that itself — + * and report it as a RESET, because an empty selection means "everyone visible" and + * would silently widen a previewed scope. + */ + describe('row selection', () => { + const changed = jest.fn(); + const reset = jest.fn(); + + beforeEach(() => { + changed.mockClear(); + reset.mockClear(); + component.selectionChanged.subscribe(changed); + component.selectionReset.subscribe(reset); + }); + + it('emits the ticked rows as site ids', () => { + component.onRowSelected([{ siteId: 4 }, { siteId: 9 }]); + + expect(changed).toHaveBeenCalledWith([4, 9]); + expect(reset).not.toHaveBeenCalled(); + }); + + it('survives a grid that hands it nothing', () => { + component.onRowSelected(null as any); + + expect(changed).toHaveBeenCalledWith([]); + }); + + it('reports new rows dropping a live selection as a reset, not as an empty selection', () => { + component.onRowSelected([{ siteId: 4 }]); + changed.mockClear(); + + component.timePlannings = []; + component.ngOnChanges({ timePlannings: { currentValue: [] } } as any); + + expect(reset).toHaveBeenCalledTimes(1); + // An empty selection here would be read as "every visible worker". + expect(changed).not.toHaveBeenCalled(); + }); + + it('stays quiet when new rows arrive and nothing was ticked', () => { + component.timePlannings = []; + component.ngOnChanges({ timePlannings: { currentValue: [] } } as any); + + expect(reset).not.toHaveBeenCalled(); + }); + + it('reports the reset only once, since the grid drops the selection only once', () => { + component.onRowSelected([{ siteId: 4 }]); + + component.ngOnChanges({ timePlannings: { currentValue: [] } } as any); + component.ngOnChanges({ timePlannings: { currentValue: [] } } as any); + + expect(reset).toHaveBeenCalledTimes(1); + }); + + it('reports new columns dropping a live selection too', () => { + // Rebuilding the headers changes [columns] and [headerTemplate], and mtx-grid + // empties its SelectionModel on those just as it does on new data. + stubHeaderFlush(component); + component.onRowSelected([{ siteId: 4 }]); + component.dateFrom = new Date(2026, 8, 7); + component.dateTo = new Date(2026, 8, 13); + + component.ngOnChanges({ dateTo: { currentValue: component.dateTo } } as any); + + expect(reset).toHaveBeenCalledTimes(1); + }); + }); + + describe('the past-day header', () => { + beforeEach(() => stubHeaderFlush(component)); + + /** Three days ending yesterday, so every column is in the past. */ + const showLastThreeDays = () => { + const yesterday = new Date(); + yesterday.setHours(0, 0, 0, 0); + yesterday.setDate(yesterday.getDate() - 1); + component.dateFrom = new Date(yesterday); + component.dateFrom.setDate(yesterday.getDate() - 2); + component.dateTo = yesterday; + component.ngOnChanges({ dateTo: { currentValue: component.dateTo } } as any); + return yesterday; + }; + + it('offers the clickable header on past days only', () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + component.dateFrom = new Date(today); + component.dateFrom.setDate(today.getDate() - 1); + component.dateTo = new Date(today); + component.dateTo.setDate(today.getDate() + 1); + + component.ngOnChanges({ dateTo: { currentValue: component.dateTo } } as any); + + // Yesterday, today, tomorrow: nothing at or after today may be reconciled. + expect(Object.keys(component.dayHeaderTemplates)).toEqual(['0']); + }); + + it('asks for a preview through the day its column stands for', () => { + const requested: Date[] = []; + component.reconcileDateRequested.subscribe(date => requested.push(date)); + const yesterday = showLastThreeDays(); + + component.onDayHeaderClick('2'); + + expect(requested).toHaveLength(1); + expect(requested[0].toDateString()).toBe(yesterday.toDateString()); + }); + + it('asks for nothing when the column is not one it knows', () => { + const requested: Date[] = []; + component.reconcileDateRequested.subscribe(date => requested.push(date)); + showLastThreeDays(); + + component.onDayHeaderClick('siteName'); + + expect(requested).toHaveLength(0); + }); + }); + + /** + * The preview classes are read from the template on every pass, because mtx-grid + * stamps the class through a pure pipe that cannot follow the container's + * preview. + */ + describe('the bulk reconcile preview', () => { + const row = (siteId: number) => ({ + siteId, + planningPrDayModels: ['2026-09-07', '2026-09-08', '2026-09-09', '2026-09-10'] + .map((date, index) => ({ id: index + 1, date: `${date}T00:00:00` })), + }) as any; + + beforeEach(() => { + component.reconcilePreview = { + target: '2026-09-09', + siteIds: [1, 2, 3], + landingBySiteId: { 1: '2026-09-09', 3: '2026-09-09' }, + existingBySiteId: { 1: null, 2: '2026-09-10', 3: '2026-09-07' }, + outcomeBySiteId: { 1: 'lock', 2: 'skip', 3: 'lock' }, + willReconcileCount: 2, + skipCount: 1, + }; + }); + + it('draws every day up to the landing day of an unlocked row', () => { + expect(component.isPreviewLocked(row(1), '0')).toBe(true); + expect(component.isPreviewLocked(row(1), '2')).toBe(true); + expect(component.isPreviewBoundary(row(1), '2')).toBe(true); + }); + + it('leaves the days after the landing day alone', () => { + expect(component.isPreviewLocked(row(1), '3')).toBe(false); + expect(component.isPreviewBoundary(row(1), '3')).toBe(false); + }); + + it('draws a locked row only from its existing boundary forward', () => { + // Row 3 is already sealed through the 7th; re-hatching that day would say the + // preview is about to do something it is not. + expect(component.isPreviewLocked(row(3), '0')).toBe(false); + expect(component.isPreviewLocked(row(3), '1')).toBe(true); + expect(component.isPreviewBoundary(row(3), '2')).toBe(true); + }); + + it('draws nothing on a row the preview skipped, and labels it instead', () => { + expect(component.isPreviewSkipped(row(2))).toBe(true); + expect(component.isPreviewLocked(row(2), '0')).toBe(false); + expect(component.isPreviewBoundary(row(2), '2')).toBe(false); + }); + + it('draws nothing on a row outside the scope', () => { + expect(component.isPreviewLocked(row(9), '0')).toBe(false); + expect(component.isPreviewSkipped(row(9))).toBe(false); + }); + + it('draws nothing at all once the preview is cancelled', () => { + component.reconcilePreview = null; + + expect(component.isPreviewLocked(row(1), '0')).toBe(false); + expect(component.isPreviewBoundary(row(1), '2')).toBe(false); + expect(component.isPreviewSkipped(row(2))).toBe(false); + }); + }); }); 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 800f0437..e9f18457 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 @@ -17,7 +17,9 @@ import {selectAuthIsAdmin, selectCurrentUserIsFirstUser} from 'src/app/state'; import {applyGridHelpAnchors} from '../../../help/grid-help-anchors'; import {HelpEntryId} from '../../../help/help.model'; import {HelpPanelService} from '../../../help/services/help-panel.service'; -import {dayAt, dayLockState, formatReconciledProvenance, isDayLocked, isDaySealed} from '../day-lock.util'; +import { + dayAt, dayKey, dayLockState, formatReconciledProvenance, isDayLocked, isDaySealed, ReconcilePreview, +} from '../day-lock.util'; @Component({ selector: 'app-time-plannings-table', @@ -41,16 +43,34 @@ export class TimePlanningsTableComponent implements OnInit, OnChanges, AfterView @Input() timePlannings: TimePlanningModel[] = []; @Input() dateFrom!: Date; @Input() dateTo!: Date; + /** The region a bulk reconcile would lock (spec §8.3). Null when none is on screen. */ + @Input() reconcilePreview: ReconcilePreview | null = null; @Output() timePlanningChanged: EventEmitter = new EventEmitter(); @Output() assignedSiteChanged: EventEmitter = new EventEmitter(); @Output() sortChanged: EventEmitter = new EventEmitter(); @Output() tagSelected: EventEmitter = new EventEmitter(); + /** The ticked rows as site ids, emitted when the user ticks or unticks one. */ + @Output() selectionChanged: EventEmitter = new EventEmitter(); + /** The grid dropped a non-empty selection by itself. See resetSelection. */ + @Output() selectionReset: EventEmitter = new EventEmitter(); + /** A past day header was clicked: preview a reconcile through that day. Commits nothing. */ + @Output() reconcileDateRequested: EventEmitter = new EventEmitter(); tableHeaders: MtxGridColumn[] = []; enumKeys: string[]; currentLocale: string = 'da'; @ViewChild('firstColumnTemplate', {static: true}) firstColumnTemplate!: TemplateRef; @ViewChild('dayColumnTemplate', {static: true}) dayColumnTemplate!: TemplateRef; + @ViewChild('reconcileDayHeaderTemplate', {static: true}) reconcileDayHeaderTemplate!: TemplateRef; + + /** + * The mtx-grid [headerTemplate] map. Only past day columns get the clickable header, + * because nothing at or after today may be reconciled. Columns missing from the map + * keep mtx-grid's default header. + */ + dayHeaderTemplates: {[field: string]: TemplateRef} = {}; + private columnDates: {[field: string]: Date} = {}; + private selectionActive = false; protected selectAuthIsAdmin$ = this.store.select(selectAuthIsAdmin); public selectCurrentUserIsFirstUser$ = this.store.select(selectCurrentUserIsFirstUser); @@ -100,7 +120,57 @@ export class TimePlanningsTableComponent implements OnInit, OnChanges, AfterView } if (changes.timePlannings) { this.hasLockedDayInView = this.computeHasLockedDayInView(); + this.resetSelection(); + } + } + + onRowSelected(rows: any[]): void { + const siteIds = (rows ?? []).map(r => r.siteId); + this.selectionActive = siteIds.length > 0; + this.selectionChanged.emit(siteIds); + } + + /** + * mtx-grid rebuilds its SelectionModel empty on ANY input change ([data], [columns], + * [headerTemplate]) and emits nothing. This reports that, and only when rows were + * ticked, as a RESET rather than as an empty selection. An empty selection means + * "everyone visible", which would silently widen a previewed scope from the ticked + * rows to all of them; the container cancels the preview instead. + */ + private resetSelection(): void { + if (this.selectionActive) { + this.selectionActive = false; + this.selectionReset.emit(); + } + } + + onDayHeaderClick(field: string): void { + const date = this.columnDates[field]; + if (date) { + this.reconcileDateRequested.emit(new Date(date)); + } + } + + /** A cell that WILL lock if the preview is committed. Cells already locked are left alone. */ + isPreviewLocked(row: any, field: string): boolean { + const landing = this.reconcilePreview?.landingBySiteId[row?.siteId]; + const day = dayKey(dayAt(row, field)?.date); + if (!landing || !day) { + return false; } + const existing = this.reconcilePreview.existingBySiteId[row.siteId]; + return day <= landing && (!existing || day > existing); + } + + /** The cell the mark will land on: the new boundary. */ + isPreviewBoundary(row: any, field: string): boolean { + const landing = this.reconcilePreview?.landingBySiteId[row?.siteId]; + return !!landing && dayKey(dayAt(row, field)?.date) === landing; + } + + /** Already reconciled at or past the target: shown as skipped (§8.3). */ + isPreviewSkipped(row: any): boolean { + return this.reconcilePreview?.outcomeBySiteId[row?.siteId] === 'skip'; } /** Seal tooltip: "Afstemt 14.09.2026 kl. 10:32". */ @@ -166,15 +236,20 @@ export class TimePlanningsTableComponent implements OnInit, OnChanges, AfterView private updateTableHeaders(): void { this.tableHeaders = []; + this.dayHeaderTemplates = {}; + this.columnDates = {}; this.cdr.detectChanges(); const startDate = new Date(this.dateFrom); const endDate = new Date(this.dateTo); const today = new Date(); + const todayMidnight = new Date(); + todayMidnight.setHours(0, 0, 0, 0); const tempEndDate = new Date(endDate); tempEndDate.setHours(0, 0, 0, 0); const diff = (tempEndDate.getTime() - startDate.getTime()) / (1000 * 3600 * 24); let daysCount = Math.floor(diff) +1; let todayTranslated = this.translateService.stream('Today'); + const headerTemplates: {[field: string]: TemplateRef} = {}; this.tableHeaders = [ { @@ -187,6 +262,14 @@ export class TimePlanningsTableComponent implements OnInit, OnChanges, AfterView ...Array.from({length: daysCount}).map((_, index) => { const currentDate = new Date(startDate); currentDate.setDate(startDate.getDate() + index); + const field = index.toString(); + this.columnDates[field] = currentDate; + // Only past days can be reconciled, so only their headers become buttons. A + // past header is always a plain string: the Observable header is today's, and + // today never gets this template. + if (currentDate < todayMidnight) { + headerTemplates[field] = this.reconcileDayHeaderTemplate; + } const isToday = currentDate.toDateString() === today.toDateString(); const formattedDate = isToday ? todayTranslated @@ -194,12 +277,15 @@ export class TimePlanningsTableComponent implements OnInit, OnChanges, AfterView return { cellTemplate: this.dayColumnTemplate, header: formattedDate, - field: index.toString(), + field, sortable: false, - class: (row: any) => this.getCellClass(row, index.toString()), + class: (row: any) => this.getCellClass(row, field), }; }), ]; + this.dayHeaderTemplates = headerTemplates; + // New [columns] and [headerTemplate] make mtx-grid drop its selection silently. + this.resetSelection(); this.cdr.detectChanges(); } diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/help/help-wiring.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/help/help-wiring.spec.ts index 615ba474..ad48e46c 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/help/help-wiring.spec.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/help/help-wiring.spec.ts @@ -42,10 +42,13 @@ const MARKUP = [read(CONTAINER_HTML), read(TABLE_HTML), read(DIALOG_HTML)].join( * from HelpUiStrings, so this task added none; a new entry here means someone * added a key that has to be translated into all 25 shared locale files. * - * The reconciled ("afstemt") day lock adds its own strings: `lockedTooltip` and - * `reconciledLegend` in the grid, and `Reconcile day`, `Unlock`, `reconcileDayConfirm`, - * `reconcileNeedsSave`, `unlockFreeFirst` and `unlockTypeWordPrompt` in the day - * dialog's footer. All of them are translated in every shared locale file. + * The reconciled ("afstemt") day lock adds its own strings: `lockedTooltip`, + * `reconciledLegend`, `reconcileHeaderTooltip` and `reconcileRowSkipped` in the grid, + * `Reconcile day`, `Unlock`, `reconcileDayConfirm`, `reconcileNeedsSave`, + * `unlockFreeFirst` and `unlockTypeWordPrompt` in the day dialog's footer, and + * `Reconcile through`, `Reconcile`, `reconcileScopeSummary` and `reconcileScopeSkipped` + * in the toolbar and its bulk scope bar. All of them are translated in every shared + * locale file. */ const TEMPLATE_TRANSLATE_KEYS = [ 'Actual', 'Auto break calculation', 'Cancel', 'CommentOffice', 'CommentWorker', 'Date range', @@ -53,8 +56,10 @@ const TEMPLATE_TRANSLATE_KEYS = [ 'Flex balance to date', 'keyboard_tab', 'keyboard_tab_rtl', 'lockedTooltip', 'Needs update!', 'NettoHours', 'NettoHours override', 'No pay rule set selected', 'PaidOutFlex', 'Pause', 'Plan hours', - 'Planned working hours', 'Reconcile day', 'reconcileDayConfirm', 'reconciledLegend', - 'reconcileNeedsSave', 'Reload table', 'Reset pause to recorded', 'Save', + 'Planned working hours', 'Reconcile', 'Reconcile day', 'Reconcile through', + 'reconcileDayConfirm', 'reconciledLegend', 'reconcileHeaderTooltip', + 'reconcileNeedsSave', 'reconcileRowSkipped', 'reconcileScopeSkipped', + 'reconcileScopeSummary', 'Reload table', 'Reset pause to recorded', 'Save', 'Shift not stopped by user!', 'Shifts across midnight', 'Show resigned', 'Start', 'Stop', 'Tags', 'Total breaktime', 'Total working hours', 'Unlock', 'unlockFreeFirst', 'unlockTypeWordPrompt', 'Use 1-minute intervals', diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/bgBG.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/bgBG.ts index a4560291..95c3a58b 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/bgBG.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/bgBG.ts @@ -487,11 +487,12 @@ export const bgBG = { reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileThroughNoRegistration: 'No registration: {{count}}', reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', reconcileNeedsSave: 'Save your changes before reconciling the day', unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', - lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', + lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/csCZ.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/csCZ.ts index 9e5714a3..17a5820b 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/csCZ.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/csCZ.ts @@ -487,11 +487,12 @@ export const csCZ = { reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileThroughNoRegistration: 'No registration: {{count}}', reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', reconcileNeedsSave: 'Save your changes before reconciling the day', unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', - lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', + lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/da.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/da.ts index db9dce0f..48e4a9d3 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/da.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/da.ts @@ -485,11 +485,12 @@ export const da = { reconcileScopeSkipped: 'Springes over: {{count}} (allerede afstemt længere frem)', reconcileThroughResult: 'Afstemt: {{applied}} · Sprunget over: {{skipped}}', reconcileThroughUnchanged: 'Uændret: {{count}}', + reconcileThroughNoRegistration: 'Ingen registrering: {{count}}', reconcileDayConfirm: 'Afstem {{worker}} {{date}}? Dagen og alle dage før den bliver låst.', reconcileNeedsSave: 'Gem ændringerne, før dagen afstemmes', unlockFreeFirst: 'Låst, fordi {{date}} er afstemt. Lås {{date}} op først.', unlockTypeWordPrompt: 'Afstemningen fjernes, og dagen kan redigeres igen. Skriv {{word}} for at bekræfte.', UNLOCK: 'LÅS OP', lockRequestFailed: 'Handlingen kunne ikke gennemføres. Prøv igen.', - lockRequestUncertain: 'Der kom ikke noget svar. Dagens tilstand er usikker og opdateres, når vinduet lukkes.', + lockRequestUncertain: 'Der kom ikke noget svar. Tilstanden er usikker, og visningen opdateres.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/deDE.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/deDE.ts index 00f1d5fe..e73826a1 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/deDE.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/deDE.ts @@ -487,11 +487,12 @@ export const deDE = { reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileThroughNoRegistration: 'No registration: {{count}}', reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', reconcileNeedsSave: 'Save your changes before reconciling the day', unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', - lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', + lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/elGR.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/elGR.ts index 2ca45e14..e29f44ee 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/elGR.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/elGR.ts @@ -487,11 +487,12 @@ export const elGR = { reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileThroughNoRegistration: 'No registration: {{count}}', reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', reconcileNeedsSave: 'Save your changes before reconciling the day', unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', - lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', + lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/enUS.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/enUS.ts index e6e07112..042ea071 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/enUS.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/enUS.ts @@ -485,11 +485,12 @@ export const enUS = { reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileThroughNoRegistration: 'No registration: {{count}}', reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', reconcileNeedsSave: 'Save your changes before reconciling the day', unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', - lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', + lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/esES.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/esES.ts index e61b2fd2..6278f729 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/esES.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/esES.ts @@ -487,11 +487,12 @@ export const esES = { reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileThroughNoRegistration: 'No registration: {{count}}', reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', reconcileNeedsSave: 'Save your changes before reconciling the day', unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', - lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', + lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/etET.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/etET.ts index 40670785..9bb01dee 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/etET.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/etET.ts @@ -487,11 +487,12 @@ export const etET = { reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileThroughNoRegistration: 'No registration: {{count}}', reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', reconcileNeedsSave: 'Save your changes before reconciling the day', unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', - lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', + lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/fiFI.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/fiFI.ts index a0f8b84d..c3f15971 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/fiFI.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/fiFI.ts @@ -487,11 +487,12 @@ export const fiFI = { reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileThroughNoRegistration: 'No registration: {{count}}', reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', reconcileNeedsSave: 'Save your changes before reconciling the day', unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', - lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', + lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/frFR.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/frFR.ts index 5bbea579..d9335ee6 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/frFR.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/frFR.ts @@ -487,11 +487,12 @@ export const frFR = { reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileThroughNoRegistration: 'No registration: {{count}}', reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', reconcileNeedsSave: 'Save your changes before reconciling the day', unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', - lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', + lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/hrHR.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/hrHR.ts index 5c8dfff5..dbf7e279 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/hrHR.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/hrHR.ts @@ -487,11 +487,12 @@ export const hrHR = { reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileThroughNoRegistration: 'No registration: {{count}}', reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', reconcileNeedsSave: 'Save your changes before reconciling the day', unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', - lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', + lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/huHU.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/huHU.ts index 270e71b0..c755f426 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/huHU.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/huHU.ts @@ -487,11 +487,12 @@ export const huHU = { reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileThroughNoRegistration: 'No registration: {{count}}', reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', reconcileNeedsSave: 'Save your changes before reconciling the day', unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', - lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', + lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/isIS.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/isIS.ts index 6676a3e7..34a7e7c8 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/isIS.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/isIS.ts @@ -487,11 +487,12 @@ export const isIS = { reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileThroughNoRegistration: 'No registration: {{count}}', reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', reconcileNeedsSave: 'Save your changes before reconciling the day', unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', - lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', + lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/itIT.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/itIT.ts index 56611dce..9c298e9c 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/itIT.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/itIT.ts @@ -487,11 +487,12 @@ export const itIT = { reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileThroughNoRegistration: 'No registration: {{count}}', reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', reconcileNeedsSave: 'Save your changes before reconciling the day', unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', - lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', + lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ltLT.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ltLT.ts index 656ec796..fd80e4eb 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ltLT.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ltLT.ts @@ -487,11 +487,12 @@ export const ltLT = { reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileThroughNoRegistration: 'No registration: {{count}}', reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', reconcileNeedsSave: 'Save your changes before reconciling the day', unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', - lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', + lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/lvLV.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/lvLV.ts index bff8b8a7..e39e4dee 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/lvLV.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/lvLV.ts @@ -487,11 +487,12 @@ export const lvLV = { reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileThroughNoRegistration: 'No registration: {{count}}', reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', reconcileNeedsSave: 'Save your changes before reconciling the day', unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', - lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', + lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/nlNL.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/nlNL.ts index a3059b87..24f37ba3 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/nlNL.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/nlNL.ts @@ -487,11 +487,12 @@ export const nlNL = { reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileThroughNoRegistration: 'No registration: {{count}}', reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', reconcileNeedsSave: 'Save your changes before reconciling the day', unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', - lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', + lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/noNO.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/noNO.ts index 2246147c..135da87e 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/noNO.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/noNO.ts @@ -487,11 +487,12 @@ export const noNO = { reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileThroughNoRegistration: 'No registration: {{count}}', reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', reconcileNeedsSave: 'Save your changes before reconciling the day', unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', - lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', + lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/plPL.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/plPL.ts index e8ef6942..761b87fa 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/plPL.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/plPL.ts @@ -487,11 +487,12 @@ export const plPL = { reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileThroughNoRegistration: 'No registration: {{count}}', reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', reconcileNeedsSave: 'Save your changes before reconciling the day', unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', - lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', + lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ptBR.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ptBR.ts index 2c7b7748..c4e1be1e 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ptBR.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ptBR.ts @@ -487,11 +487,12 @@ export const ptBR = { reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileThroughNoRegistration: 'No registration: {{count}}', reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', reconcileNeedsSave: 'Save your changes before reconciling the day', unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', - lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', + lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ptPT.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ptPT.ts index 088d7712..603195b7 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ptPT.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ptPT.ts @@ -487,11 +487,12 @@ export const ptPT = { reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileThroughNoRegistration: 'No registration: {{count}}', reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', reconcileNeedsSave: 'Save your changes before reconciling the day', unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', - lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', + lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/roRO.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/roRO.ts index 0a57c315..5255319b 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/roRO.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/roRO.ts @@ -487,11 +487,12 @@ export const roRO = { reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileThroughNoRegistration: 'No registration: {{count}}', reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', reconcileNeedsSave: 'Save your changes before reconciling the day', unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', - lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', + lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/skSK.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/skSK.ts index e45f69ba..5962e6e5 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/skSK.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/skSK.ts @@ -487,11 +487,12 @@ export const skSK = { reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileThroughNoRegistration: 'No registration: {{count}}', reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', reconcileNeedsSave: 'Save your changes before reconciling the day', unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', - lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', + lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/slSL.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/slSL.ts index b074114c..6138cc36 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/slSL.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/slSL.ts @@ -487,11 +487,12 @@ export const slSL = { reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileThroughNoRegistration: 'No registration: {{count}}', reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', reconcileNeedsSave: 'Save your changes before reconciling the day', unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', - lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', + lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/svSE.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/svSE.ts index 66b93473..340ae881 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/svSE.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/svSE.ts @@ -487,11 +487,12 @@ export const svSE = { reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileThroughNoRegistration: 'No registration: {{count}}', reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', reconcileNeedsSave: 'Save your changes before reconciling the day', unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', - lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', + lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ukUA.ts b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ukUA.ts index f754502d..eae18afd 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ukUA.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/i18n/ukUA.ts @@ -487,11 +487,12 @@ export const ukUA = { reconcileScopeSkipped: 'Skipped: {{count}} (already reconciled further ahead)', reconcileThroughResult: 'Reconciled: {{applied}} · Skipped: {{skipped}}', reconcileThroughUnchanged: 'Unchanged: {{count}}', + reconcileThroughNoRegistration: 'No registration: {{count}}', reconcileDayConfirm: 'Reconcile {{worker}} on {{date}}? This day and every day before it become locked.', reconcileNeedsSave: 'Save your changes before reconciling the day', unlockFreeFirst: 'Locked because {{date}} is reconciled. Unlock {{date}} first.', unlockTypeWordPrompt: 'The reconciliation is removed and the day can be edited again. Type {{word}} to confirm.', UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', - lockRequestUncertain: 'No answer came back. The state of the day is uncertain and refreshes when this window closes.', + lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', }; From 84da4bd41537081d1bcacf550f43058d5a88fc45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Wed, 16 Sep 2026 13:31:05 +0200 Subject: [PATCH 21/28] test(lock): bootstrap shard s and cover the day lock end to end Shard s is added to the Playwright matrix in both dotnet-core-pr.yml and dotnet-core-master.yml, so the four reconcile specs written into it finally run. It gets no seed of its own -- the workflow's Create database step falls back to a/'s dumps -- but it does get the plugin-activation bootstrap that q and r carry, because setting EformPlugins.Status in the seed does not load the plugin, and without the load the first /plannings/index wait in every spec times out. activate-plugin and assert-true sort ahead of the reconcile specs, so they run first. reconcile-day-lock.spec.ts walks the whole flow: reconcile a day, watch the cascade shut every earlier day without sealing it, open one of those days read-only, be refused the unlock below the boundary and told which day to free first, then unlock at the boundary and see the cascade open again. It uses last week, so no day in view is today or later, and it anchors rows by worker name rather than by the positional cell ids. The frame all five specs repeated -- the login, the last-week navigation and the unlock afterEach -- moves into useLastWeekDashboard() in the shared helpers. Its pickWorker is now the only way to obtain a worker name, and it registers every row it hands out for cleanup, including rows a test expects to leave untouched. The cleanup re-reads the grid from the server before scanning, because a test that fails between a lock change and its reload leaves a stale grid that would report the row as already open, and it is best-effort throughout: one failing step no longer abandons the rows behind it, and the failures are raised together at the end. Co-Authored-By: Claude Opus 5 --- .github/workflows/dotnet-core-master.yml | 2 +- .github/workflows/dotnet-core-pr.yml | 2 +- .../s/activate-plugin.spec.ts | 47 ++++ .../time-planning-pn/s/assert-true.spec.ts | 6 + .../s/reconcile-bulk-preview.spec.ts | 45 ++-- .../s/reconcile-day-lock.spec.ts | 59 ++++ .../s/reconcile-dialog-confirm.spec.ts | 29 +- .../s/reconcile-glyphs.spec.ts | 30 +-- .../time-planning-pn/s/reconcile-helpers.ts | 253 ++++++++++++++++-- .../s/reconcile-unlock-word.spec.ts | 30 +-- 10 files changed, 377 insertions(+), 126 deletions(-) create mode 100644 eform-client/playwright/e2e/plugins/time-planning-pn/s/activate-plugin.spec.ts create mode 100644 eform-client/playwright/e2e/plugins/time-planning-pn/s/assert-true.spec.ts create mode 100644 eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-day-lock.spec.ts diff --git a/.github/workflows/dotnet-core-master.yml b/.github/workflows/dotnet-core-master.yml index 7dd7fbf9..00c81a4a 100644 --- a/.github/workflows/dotnet-core-master.yml +++ b/.github/workflows/dotnet-core-master.yml @@ -99,7 +99,7 @@ jobs: strategy: fail-fast: false matrix: - test: [a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,b1m,c1m,d1m,e1m,f1m,h1m,i1m,j1m,l1m] + test: [a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,b1m,c1m,d1m,e1m,f1m,h1m,i1m,j1m,l1m] steps: - uses: actions/checkout@v3 with: diff --git a/.github/workflows/dotnet-core-pr.yml b/.github/workflows/dotnet-core-pr.yml index bbc4a5e1..b651388b 100644 --- a/.github/workflows/dotnet-core-pr.yml +++ b/.github/workflows/dotnet-core-pr.yml @@ -92,7 +92,7 @@ jobs: strategy: fail-fast: false matrix: - test: [a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,b1m,c1m,d1m,e1m,f1m,h1m,i1m,j1m,l1m] + test: [a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,b1m,c1m,d1m,e1m,f1m,h1m,i1m,j1m,l1m] steps: - uses: actions/checkout@v3 with: diff --git a/eform-client/playwright/e2e/plugins/time-planning-pn/s/activate-plugin.spec.ts b/eform-client/playwright/e2e/plugins/time-planning-pn/s/activate-plugin.spec.ts new file mode 100644 index 00000000..05e9ede6 --- /dev/null +++ b/eform-client/playwright/e2e/plugins/time-planning-pn/s/activate-plugin.spec.ts @@ -0,0 +1,47 @@ +import { test, expect } from '@playwright/test'; +import { LoginPage } from '../../../Page objects/Login.page'; +import { PluginPage } from '../../../Page objects/Plugin.page'; + +test.describe('Enable Backend Config plugin', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:4200'); + await new LoginPage(page).login(); + await new PluginPage(page).Navbar.goToPluginsPage(); + }); + + test('should enabled Time registration plugin', async ({ page }) => { + test.setTimeout(180000); + const pluginName = 'Microting Time Planning Plugin'; + + // Open action menu for the plugin + await page.locator('.mat-mdc-row').filter({ hasText: pluginName }).first() + .locator('#actionMenu').click(); + await page.waitForTimeout(500); + + // Click the status button inside the menu to enable the plugin + await page.locator('#plugin-status-button0').scrollIntoViewIfNeeded(); + await expect(page.locator('#plugin-status-button0')).toBeVisible(); + await page.locator('#plugin-status-button0').click(); + await page.waitForTimeout(500); + + // Confirm activation in the modal if present + if (await page.locator('#pluginOKBtn').count() > 0) { + await page.locator('#pluginOKBtn').click(); + await page.waitForTimeout(100000); // Wait for plugin activation + } + + await page.goto('http://localhost:4200'); + await new LoginPage(page).login(); + await new PluginPage(page).Navbar.goToPluginsPage(); + + // Verify the plugin is enabled by checking the status + // Re-open the action menu to check the status + await page.locator('.mat-mdc-row').filter({ hasText: pluginName }).first() + .locator('#actionMenu').click(); + await page.waitForTimeout(500); + + await expect( + page.locator('#plugin-status-button0').locator('mat-icon') + ).toContainText('toggle_on'); // plugin is enabled + }); +}); diff --git a/eform-client/playwright/e2e/plugins/time-planning-pn/s/assert-true.spec.ts b/eform-client/playwright/e2e/plugins/time-planning-pn/s/assert-true.spec.ts new file mode 100644 index 00000000..a14cb84a --- /dev/null +++ b/eform-client/playwright/e2e/plugins/time-planning-pn/s/assert-true.spec.ts @@ -0,0 +1,6 @@ +import { test, expect } from '@playwright/test'; + +// create canary in a coal mine test asserting true +test('asserts true', () => { + expect(true).toBe(true); // this will pass +}); diff --git a/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-bulk-preview.spec.ts b/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-bulk-preview.spec.ts index a22cea3b..f386e39c 100644 --- a/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-bulk-preview.spec.ts +++ b/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-bulk-preview.spec.ts @@ -1,9 +1,8 @@ import { test, expect } from '@playwright/test'; -import { LoginPage } from '../../../Page objects/Login.page'; import { - cellOf, closeDayWithoutChange, countPuts, expectSuccess, lastWeekMonday, openDashboardLastWeek, - openDay, RECONCILE_THROUGH_PATH, reconcileDay, rowCheckbox, rowOf, tdOf, unlockAll, unlockDay, - waitForIndex, waitForPut, waitForSpinner, workerAtRow, + cellOf, closeDayWithoutChange, countPuts, expectSuccess, lastWeekMonday, + openDay, RECONCILE_THROUGH_PATH, reconcileDay, rowCheckbox, rowOf, tdOf, unlockDay, + useLastWeekDashboard, waitForIndex, waitForPut, waitForSpinner, } from './reconcile-helpers'; /** @@ -11,31 +10,20 @@ import { * start and are found by name after that. B is reconciled further forward (day 5) than * the bulk target (day 3), so it must be shown as skipped and left where it is. * - * The shard shares one database and runs its files in order, so both workers are unlocked - * in afterEach as well: a row left locked by a failed assertion breaks every spec after - * this one. + * The outsider is picked through the session as well, although the test's point is that + * it is never touched: if the bulk reconcile reaches outside the selection anyway, the + * session cleanup frees that row instead of leaving it to break the next spec. */ test.describe('Reconciled day lock: bulk reconcile', () => { - let locked: string[] = []; - - test.beforeEach(async ({ page }) => { - locked = []; - await page.goto('http://localhost:4200'); - await new LoginPage(page).login(); - await openDashboardLastWeek(page); - }); - - test.afterEach(async ({ page }) => { - for (const worker of locked) { - await unlockAll(page, worker); - } - }); + const session = useLastWeekDashboard(); test('a header click previews per worker, skips rows already further, and toasts counts', async ({ page }) => { - const a = await workerAtRow(page, 7); - const b = await workerAtRow(page, 8); - const outsider = await workerAtRow(page, 10); - locked = [a, b]; + // Three workers, a preview, a commit and three rows to clean up afterwards, and the + // frame's login, navigation and cleanup count against the same per-test budget. + test.slow(); + const a = await session.pickWorker(page, 7); + const b = await session.pickWorker(page, 8); + const outsider = await session.pickWorker(page, 10); const throughs = countPuts(page, RECONCILE_THROUGH_PATH); await reconcileDay(page, b, 5); @@ -98,15 +86,14 @@ test.describe('Reconciled day lock: bulk reconcile', () => { await expect(page.locator('#reconcileScopeBar')).toHaveCount(0); await expect(rowCheckbox(page, a)).not.toBeChecked(); - // Cleanup. afterEach is only the safety net for a failure above. + // Cleanup. The session cleanup is only the safety net for a failure above. await unlockDay(page, a, 3); await unlockDay(page, b, 5); - locked = []; }); test('a reload while ticked rows are previewed cancels the preview instead of widening it', async ({ page }) => { - const a = await workerAtRow(page, 7); - const outsider = await workerAtRow(page, 10); + const a = await session.pickWorker(page, 7); + const outsider = await session.pickWorker(page, 10); await rowCheckbox(page, a).check(); await page.locator('#dayHeader3').click(); diff --git a/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-day-lock.spec.ts b/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-day-lock.spec.ts new file mode 100644 index 00000000..0a0ca7a4 --- /dev/null +++ b/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-day-lock.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from '@playwright/test'; +import { + assertReadOnlyDialog, closeDayWithoutChange, openDay, reconcileDay, tdOf, unlockDay, + useLastWeekDashboard, +} from './reconcile-helpers'; + +/** + * The whole lock in one pass: reconcile a day, watch the cascade reach every earlier + * day, open one of them read-only, be refused the unlock below the boundary, and + * unlock at the boundary. The worker is grid row 3 at the start and is found by name + * after that; the grid is on last week, so every day in view is in the past (I2) + * whatever weekday CI runs on. + */ +test.describe('Reconciled day lock: end to end', () => { + const session = useLastWeekDashboard(); + + // Day 2 (last week's Wednesday) becomes the boundary: days 0 and 1 cascade shut + // behind it, and day 3 stays open. + test('reconciling a day locks it and every earlier day for that worker', async ({ page }) => { + // The whole flow in one test, and the frame's login, navigation and cleanup count + // against the same per-test budget. + test.slow(); + const worker = await session.pickWorker(page, 3); + + const boundaryDate = await reconcileDay(page, worker, 2); + + // The boundary treatment sits on the reconciled day, and on it alone. + await expect(tdOf(page, worker, 2)).toHaveClass(/reconciled-background/); + + // The cascade: earlier days are locked, but they were not themselves reconciled. + for (const day of [0, 1]) { + await expect(tdOf(page, worker, day)).toHaveClass(/locked-background/); + await expect(tdOf(page, worker, day)).not.toHaveClass(/reconciled-background/); + } + + // Nothing above the boundary moved. + await expect(tdOf(page, worker, 3)) + .not.toHaveClass(/locked-background|reconciled-background/); + + // A cascade-locked day still opens — people read closed days constantly — but it + // opens read-only, for the same worker. The fields are closed too, not just the + // footer: this is the cascade branch, where nothing was reconciled and the day is + // shut only by the boundary above it. The unlock is not offered here; the day that + // has to be freed first is named instead. + await openDay(page, worker, 0); + await assertReadOnlyDialog(page); + await expect(page.locator('#CommentOffice')).toBeDisabled(); + await expect(page.locator('#lockedFreeFirstText')).toContainText(boundaryDate); + await closeDayWithoutChange(page); + + // The boundary is where the unlock lives, and using it frees the whole cascade — + // the boundary day included, which must not merely lose its seal and stay shut. + await unlockDay(page, worker, 2); + await expect(tdOf(page, worker, 2)).not.toHaveClass(/reconciled-background/); + for (const day of [0, 1, 2]) { + await expect(tdOf(page, worker, day)).not.toHaveClass(/locked-background/); + } + }); +}); diff --git a/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-dialog-confirm.spec.ts b/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-dialog-confirm.spec.ts index fb64c77b..2520665f 100644 --- a/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-dialog-confirm.spec.ts +++ b/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-dialog-confirm.spec.ts @@ -1,9 +1,8 @@ import { test, expect } from '@playwright/test'; -import { LoginPage } from '../../../Page objects/Login.page'; import { - closeDayAfterLockChange, closeDayWithoutChange, countPuts, openDashboardLastWeek, openDay, - PLANNING_PUT_PATH, PROVENANCE, RECONCILE_PATH, reconcileOpenDay, tdOf, unlockAll, unlockDay, - workerAtRow, + closeDayAfterLockChange, closeDayWithoutChange, countPuts, openDay, + PLANNING_PUT_PATH, PROVENANCE, RECONCILE_PATH, reconcileOpenDay, tdOf, unlockDay, + useLastWeekDashboard, } from './reconcile-helpers'; /** @@ -12,26 +11,10 @@ import { * found by name after that. */ test.describe('Reconciled day lock: dialog confirm', () => { - /** - * The worker whose row a test locked. afterEach unlocks it even when an assertion - * failed halfway through: the shard shares one database and runs its files in - * order, so a row left locked would break every spec after this one. - */ - let worker = ''; - - test.beforeEach(async ({ page }) => { - worker = ''; - await page.goto('http://localhost:4200'); - await new LoginPage(page).login(); - await openDashboardLastWeek(page); - }); - - test.afterEach(async ({ page }) => { - await unlockAll(page, worker); - }); + const session = useLastWeekDashboard(); test('reconcile takes a second click in the same footer, then the dialog stays open read-only', async ({ page }) => { - worker = await workerAtRow(page, 6); + const worker = await session.pickWorker(page, 6); const reconciles = countPuts(page, RECONCILE_PATH); const date = await openDay(page, worker, 3); @@ -80,7 +63,7 @@ test.describe('Reconciled day lock: dialog confirm', () => { }); test('a day with unsaved edits offers no reconcile, and Cancel writes nothing', async ({ page }) => { - worker = await workerAtRow(page, 6); + const worker = await session.pickWorker(page, 6); await openDay(page, worker, 1); await expect(page.locator('#reconcileButton')).toBeVisible(); diff --git a/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-glyphs.spec.ts b/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-glyphs.spec.ts index abaf65e5..e9b75ce1 100644 --- a/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-glyphs.spec.ts +++ b/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-glyphs.spec.ts @@ -1,8 +1,7 @@ import { test, expect } from '@playwright/test'; -import { LoginPage } from '../../../Page objects/Login.page'; import { - cellOf, expectTooltipOnHover, LOCKED_TOOLTIP, openDashboardLastWeek, PROVENANCE, reconcileDay, - rowOf, tdOf, unlockAll, unlockDay, workerAtRow, + cellOf, expectTooltipOnHover, LOCKED_TOOLTIP, PROVENANCE, reconcileDay, + rowOf, tdOf, unlockDay, useLastWeekDashboard, } from './reconcile-helpers'; /** @@ -10,23 +9,7 @@ import { * grid. The worker is grid row 5 at the start and is found by name after that. */ test.describe('Reconciled day lock: glyphs and legend', () => { - /** - * The worker whose row a test locked. afterEach unlocks it even when an assertion - * failed halfway through: the shard shares one database and runs its files in - * order, so a row left locked would break every spec after this one. - */ - let worker = ''; - - test.beforeEach(async ({ page }) => { - worker = ''; - await page.goto('http://localhost:4200'); - await new LoginPage(page).login(); - await openDashboardLastWeek(page); - }); - - test.afterEach(async ({ page }) => { - await unlockAll(page, worker); - }); + const session = useLastWeekDashboard(); // Day 4 (last week's Friday) becomes the boundary, so day 2 is cascade-locked and // day 5 stays open. @@ -35,7 +18,7 @@ test.describe('Reconciled day lock: glyphs and legend', () => { // locked is in view, so there is no legend. await expect(page.locator('#lockLegend')).toHaveCount(0); - worker = await workerAtRow(page, 5); + const worker = await session.pickWorker(page, 5); await reconcileDay(page, worker, 4); // Boundary: the seal and no lock. Its tooltip is the provenance line. @@ -60,7 +43,8 @@ test.describe('Reconciled day lock: glyphs and legend', () => { await expect(page.locator('#lockLegendReconciled')).toContainText('Afstemt'); // Unlocking is part of the behaviour under test: with nothing locked the glyphs - // and the legend go away again. afterEach only catches what a failure leaves. + // and the legend go away again. The session cleanup only catches what a failure + // leaves behind. await unlockDay(page, worker, 4); await expect(cellOf(page, worker, 2).locator('.tp-day-glyph')).toHaveCount(0); await expect(page.locator('#lockLegend')).toHaveCount(0); @@ -71,7 +55,7 @@ test.describe('Reconciled day lock: glyphs and legend', () => { test('an older reconciled day stays sealed under a later boundary, without the boundary border', async ({ page }) => { await expect(page.locator('#lockLegend')).toHaveCount(0); - worker = await workerAtRow(page, 5); + const worker = await session.pickWorker(page, 5); // Day 1 first, then day 3, which becomes the boundary. await reconcileDay(page, worker, 1); await reconcileDay(page, worker, 3); diff --git a/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-helpers.ts b/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-helpers.ts index 228c3708..c91aee3a 100644 --- a/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-helpers.ts +++ b/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-helpers.ts @@ -1,4 +1,5 @@ -import { expect, Locator, Page, Response } from '@playwright/test'; +import { expect, Locator, Page, Response, test } from '@playwright/test'; +import { LoginPage } from '../../../Page objects/Login.page'; /** * Shared by every spec in shard s. Two rules are enforced here so no spec can forget @@ -13,7 +14,9 @@ import { expect, Locator, Page, Response } from '@playwright/test'; * no past day at all on a Monday. * * Every spec uses its own worker and unlocks what it locked, because the shard shares - * one database and runs its specs in file order (workers: 1). + * one database and runs its specs in file order (workers: 1). `useLastWeekDashboard` + * holds both rules: it is the only door to a worker name, and it installs the login, + * the last-week navigation and the cleanup as hooks, so no spec can forget them. */ const INDEX_PATH = '/api/time-planning-pn/plannings/index'; @@ -76,7 +79,11 @@ export function lastWeekMonday(): string { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; } -export async function openDashboardLastWeek(page: Page): Promise { +/** + * Deliberately not exported: `useLastWeekDashboard` is the only caller, so no spec can + * open the grid on a week whose days are not all in the past. + */ +async function openDashboardLastWeek(page: Page): Promise { await page.locator('mat-nested-tree-node').filter({ hasText: 'Timeregistrering' }).click(); const initial = waitForIndex(page); await page.locator('mat-tree-node').filter({ hasText: 'Dashboard' }).click(); @@ -91,8 +98,11 @@ export async function openDashboardLastWeek(page: Page): Promise { await waitForSpinner(page); } -/** The worker rendered at a grid position. Call it once per spec, to pick a worker. */ -export async function workerAtRow(page: Page, rowIndex: number): Promise { +/** + * The worker rendered at a grid position. Deliberately not exported: the only way in + * is `DashboardSession.pickWorker`, which also registers the row for cleanup. + */ +async function workerAtRow(page: Page, rowIndex: number): Promise { const name = (await page.locator(`#firstColumn${rowIndex} .hours-info strong`).innerText()).trim(); // A blank name would make every later lookup match vacuously. expect(name, `row ${rowIndex} must name a worker`).toMatch(/^[^\s-]/); @@ -149,6 +159,12 @@ export async function openDay(page: Page, worker: string, day: number): Promise< return title.date; } +/** A locked day's dialog: it opens and reads, but offers no way to change anything. */ +export async function assertReadOnlyDialog(page: Page): Promise { + await expect(page.locator('#saveButton')).toHaveCount(0); + await expect(page.locator('#unlockButton')).toHaveCount(0); +} + /** Cancel with nothing persisted: no save and no reload. */ export async function closeDayWithoutChange(page: Page): Promise { await page.locator('#cancelButton').click(); @@ -200,36 +216,223 @@ export async function unlockDay(page: Page, worker: string, day: number): Promis /** Leaves no dialog open, whatever the test was in the middle of. */ async function closeAnyDialog(page: Page): Promise { - if (await page.locator('mat-dialog-container').count() === 0) { - return; + const dialog = page.locator('mat-dialog-container'); + // The dialog sets disableClose while a PUT is in flight, and an Escape sent in that + // window is swallowed for good. So press until the dialog actually goes, instead of + // pressing once and waiting on a key that was already dropped. + for (let attempt = 0; attempt < 20; attempt++) { + if (await dialog.count() === 0) { + return; + } + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); } - await page.keyboard.press('Escape'); - await expect(page.locator('mat-dialog-container'), - 'a dialog stayed open, so the row could not be cleaned up').toHaveCount(0, { timeout: 10000 }); + throw new Error('a dialog stayed open, so the row could not be cleaned up'); } /** - * Takes every mark off a worker's row, the newest first, because only the boundary - * can be unlocked. Call it from afterEach: the shard shares one database and runs its - * files in order, so a row left locked by a failed assertion breaks every spec after - * it. It is a no-op when nothing is locked, and it closes any open dialog first. + * Re-reads the grid from the server. Cleanup cannot trust what is on screen: a test + * that failed between a lock change and the reload that follows it leaves a grid still + * drawing the row as open. A scan of that stale grid finds nothing to unlock and + * strands the lock for every spec after it. */ -export async function unlockAll(page: Page, worker: string): Promise { - if (!worker) { - return; - } - await closeAnyDialog(page); +async function reloadGridFromServer(page: Page): Promise { + await waitForSpinner(page); + const reload = waitForIndex(page, lastWeekMonday()); + await page.locator('#workingHoursReload').click(); + await reload; + await waitForSpinner(page); +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message.split('\n')[0] : String(error); +} + +/** + * What the scan could establish about one row. `unknown` is a weaker claim than + * `locked` and a different one from `clean`: it means a lock could be sitting there + * unseen, which is why the two are reported separately. + */ +type RowVerdict = + | { state: 'clean' } + | { state: 'locked'; detail: string } + | { state: 'unknown'; detail: string }; + +/** + * Takes every mark off a worker's row, the newest first, because only the boundary can + * be unlocked. Reports a verdict instead of throwing: the hook needs to know what the + * row ended up as, and an exception would also cost it every row after this one. + * Expects a freshly loaded grid. + */ +async function unlockRow(page: Page, worker: string): Promise { const boundary = rowOf(page, worker).locator('td.reconciled-background'); + + /** The row's marks, or `null` when the row could not be looked at at all. */ + const marksOnRow = async (): Promise => { + try { + // An empty grid would count 0 marks and read as clean, so the row itself has to + // be on screen before a count of its cells means anything. + if (await rowOf(page, worker).count() === 0) { + return null; + } + return await boundary.count(); + } catch { + return null; + } + }; + + const unseen: RowVerdict = { + state: 'unknown', + detail: `${worker}: the row was not on screen, so a lock on it could not be seen`, + }; + // One pass per mark on the row. The bound is the visible week plus slack, so a - // cleanup that stops making progress fails loudly instead of looping forever. + // cleanup that stops making progress gives up instead of looping forever. for (let pass = 0; pass < 10; pass++) { - if (await boundary.count() === 0) { - return; + const marks = await marksOnRow(); + if (marks === null) { + return unseen; + } + if (marks === 0) { + return { state: 'clean' }; + } + try { + await boundary.first().locator('.plan-container').click(); + await unlockOpenDay(page); + } catch (error) { + // The unlock threw, so re-read the row rather than assume: a failure after the + // PUT landed can still leave it clean. + const remaining = await marksOnRow(); + if (remaining === null) { + return unseen; + } + return remaining === 0 + ? { state: 'clean' } + : { state: 'locked', detail: `${worker}: ${describeError(error)}` }; } - await boundary.first().locator('.plan-container').click(); - await unlockOpenDay(page); } - throw new Error(`${worker}'s row is still locked after 10 unlocks`); + return { state: 'locked', detail: `${worker}: still marked after 10 unlocks` }; +} + +export interface DashboardSession { + /** + * Reads the worker rendered at a grid position ONCE, and registers that row so the + * cleanup unlocks it. Every worker a test touches goes through this, including one + * it expects to leave untouched: if a bug locks that row anyway, cleanup catches it + * instead of the next spec. + */ + pickWorker(page: Page, rowIndex: number): Promise; +} + +/** + * The frame every spec in this shard shares: log in, put the grid on last week, and + * afterwards put the database back the way it was found. Call it once in the describe + * body — it installs the hooks itself — and address rows only through `pickWorker`. + * + * The cleanup runs after a failed test too, which is the whole point: the shard shares + * one database and runs its files in order (workers: 1, retries: 0), so a row left + * locked by an assertion that failed halfway breaks every spec after it. + */ +export function useLastWeekDashboard(): DashboardSession { + let picked: string[] = []; + + test.beforeEach(async ({ page }) => { + picked = []; + await page.goto('http://localhost:4200'); + await new LoginPage(page).login(); + await openDashboardLastWeek(page); + }); + + test.afterEach(async ({ page }) => { + // Nothing picked means nothing could have been locked: a row can only be + // addressed by a name that came from pickWorker. + if (picked.length === 0) { + return; + } + // Best-effort on purpose, NOT fail-fast. The only job of this hook is that the + // next spec finds an unlocked database, and a throw from the dialog close or the + // reload would abandon every row after it — the bulk spec alone carries three. + // + // The SCAN below is the only authority on whether this hook failed. Closing a + // dialog and reloading the grid are how the scan gets to look at the rows; neither + // is evidence about the database. Every test in the shard now runs that reload, so + // failing on a reload that flaked while the scan then found every row clean would + // report a leaked lock to whoever triages CI that does not exist. A step that + // errored is recorded as an annotation instead, where it is visible without + // reading as a failure. + const notes: string[] = []; + const attempt = async (what: string, step: () => Promise): Promise => { + try { + await step(); + } catch (error) { + notes.push(`${what} failed (${describeError(error)})`); + } + }; + + await attempt('closing the open dialog', () => closeAnyDialog(page)); + // Only the reload decides this: a dialog that would not close does not make the + // grid stale, so a clean scan after that alone is still trustworthy. + const notesBeforeReload = notes.length; + await attempt('reloading the grid', () => reloadGridFromServer(page)); + const gridIsFresh = notes.length === notesBeforeReload; + + // A clean scan is only as good as the grid it read. reloadGridFromServer exists + // because a test that aborts between a lock change and its reload leaves the row + // still drawn as open — and that abort is exactly when the reload itself is most + // likely to fail. So when the refresh was not confirmed, `clean` means only "no + // lock VISIBLE", which is not the same claim as "no lock", and it is downgraded to + // `unknown`. `locked` is kept as it is: a mark you can see is real whether or not + // the grid is fresh, and the scan still gets to clear it. The asymmetry is + // deliberate — a false alarm costs one re-run, a swallowed leak poisons every spec + // that follows. + const stillLocked: string[] = []; + const unverified: string[] = []; + for (const worker of picked) { + let verdict: RowVerdict; + try { + verdict = await unlockRow(page, worker); + } catch (error) { + // unlockRow is written not to throw; if it ever does, that is one row unknown, + // not a reason to skip the rest. + verdict = { state: 'unknown', detail: `${worker}: ${describeError(error)}` }; + } + if (verdict.state === 'clean' && !gridIsFresh) { + verdict = { + state: 'unknown', + detail: `${worker}: the grid could not be refreshed, so a lock on this row would not have shown`, + }; + } + if (verdict.state === 'locked') { + stillLocked.push(verdict.detail); + } else if (verdict.state === 'unknown') { + unverified.push(verdict.detail); + } + } + + if (notes.length > 0) { + test.info().annotations.push({ type: 'cleanup', description: notes.join('; ') }); + } + + // A row left locked breaks every spec after this one, so it stays loud and names + // the workers. + if (stillLocked.length > 0) { + throw new Error(`cleanup left rows locked for the specs that follow:\n ${stillLocked.join('\n ')}`); + } + // A weaker, different claim: nothing was seen locked, but the scan could not be + // trusted to see it — the row was not on screen, or the grid could not be + // refreshed — so a lock could be sitting there unseen. Each message says which. + if (unverified.length > 0) { + throw new Error(`cleanup could not verify the lock state of these rows:\n ${unverified.join('\n ')}`); + } + }); + + return { + async pickWorker(page: Page, rowIndex: number): Promise { + const worker = await workerAtRow(page, rowIndex); + picked.push(worker); + return worker; + }, + }; } /** Hovers a target and waits for its tooltip. Material renders tooltips in the overlay. */ diff --git a/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-unlock-word.spec.ts b/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-unlock-word.spec.ts index eb2d0170..1ee1e9c4 100644 --- a/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-unlock-word.spec.ts +++ b/eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-unlock-word.spec.ts @@ -1,9 +1,8 @@ import { test, expect } from '@playwright/test'; -import { LoginPage } from '../../../Page objects/Login.page'; import { - closeDayWithoutChange, expectSuccess, lastWeekMonday, openDashboardLastWeek, openDay, reconcileDay, - tdOf, unlockAll, UNLOCK_WORD, UNRECONCILE_PATH, waitForIndex, waitForPut, waitForSpinner, - workerAtRow, + assertReadOnlyDialog, closeDayWithoutChange, expectSuccess, lastWeekMonday, openDay, + reconcileDay, tdOf, UNLOCK_WORD, UNRECONCILE_PATH, useLastWeekDashboard, waitForIndex, + waitForPut, waitForSpinner, } from './reconcile-helpers'; /** @@ -11,34 +10,17 @@ import { * The worker is grid row 9 at the start and is found by name after that. */ test.describe('Reconciled day lock: unlock', () => { - /** - * The worker whose row a test locked. afterEach unlocks it even when an assertion - * failed halfway through: the shard shares one database and runs its files in - * order, so a row left locked would break every spec after this one. - */ - let worker = ''; - - test.beforeEach(async ({ page }) => { - worker = ''; - await page.goto('http://localhost:4200'); - await new LoginPage(page).login(); - await openDashboardLastWeek(page); - }); - - test.afterEach(async ({ page }) => { - await unlockAll(page, worker); - }); + const session = useLastWeekDashboard(); test('only the boundary offers unlock, earlier days name it, and unlocking takes the word', async ({ page }) => { - worker = await workerAtRow(page, 9); + const worker = await session.pickWorker(page, 9); const boundaryDate = await reconcileDay(page, worker, 3); // A day below the boundary names the day to free first and offers no unlock. await openDay(page, worker, 1); await expect(page.locator('#lockedFreeFirstText')) .toHaveText(`Låst, fordi ${boundaryDate} er afstemt. Lås ${boundaryDate} op først.`); - await expect(page.locator('#unlockButton')).toHaveCount(0); - await expect(page.locator('#saveButton')).toHaveCount(0); + await assertReadOnlyDialog(page); await closeDayWithoutChange(page); // The boundary: its provenance, no "free first" line, and an unlock gated by the word. From 8d0fdd7a7b67424757a3828085bdb8a6c8a76a08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Wed, 16 Sep 2026 14:27:01 +0200 Subject: [PATCH 22/28] test(lock): fix the unit-test setup the new cases exposed Register the app's locales in the table spec's bed, because these cases are the first to format a header date. Override the auth selectors properly in the dialog's mock store; a string key overrode nothing, so an unhandled selector error had been leaking from every case that runs ngOnInit and only surfaced once a test held a clock. Assert the whole reconcile-through message, since the no-registration clause is reported separately by design. Co-Authored-By: Claude Opus 5 --- .../workday-entity-dialog.component.spec.ts | 10 ++- ...time-plannings-container.component.spec.ts | 5 +- .../time-plannings-table.component.spec.ts | 6 ++ .../testing/register-test-locales.ts | 71 +++++++++++++++++++ 4 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 eform-client/src/app/plugins/modules/time-planning-pn/testing/register-test-locales.ts 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 b6b1d0d2..a818c934 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 @@ -13,6 +13,7 @@ import { NO_ERRORS_SCHEMA } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; import { Store } from '@ngrx/store'; import { provideMockStore } from '@ngrx/store/testing'; +import { selectCurrentUserIsAdmin, selectCurrentUserIsFirstUser } from 'src/app/state'; import { DomSanitizer } from '@angular/platform-browser'; import { TemplateFilesService } from 'src/app/common/services'; import { HelpPanelService } from '../../../../help/services/help-panel.service'; @@ -149,10 +150,17 @@ describe('WorkdayEntityDialogComponent', () => { providers: [ FormBuilder, DatePipe, + // The bed has no auth slice, so the real projectors read `state.auth` as + // undefined and throw on the first ngOnInit. That error reaches a subscribe() + // with no error handler, so RxJS reports it on a timer — which a fake-timer + // case then flushes into whatever test happens to be running. Overriding the + // selectors the component actually asks for (a string key overrides nothing) + // keeps the projectors from running at all. provideMockStore({ initialState: {}, selectors: [ - { selector: 'selectCurrentUserIsFirstUser', value: false } + { selector: selectCurrentUserIsAdmin, value: false }, + { selector: selectCurrentUserIsFirstUser, value: false } ] }), { provide: MAT_DIALOG_DATA, useValue: mockData }, diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.spec.ts index 0cd0d394..dbaa8e9f 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.spec.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.spec.ts @@ -635,7 +635,10 @@ describe('TimePlanningsContainerComponent', () => { component.confirmReconcileThrough(); - expect(toastr.warning).toHaveBeenCalledWith('reconcileThroughResult'); + // The whole message, so a regression to one merged "skipped" figure fails here + // too: worker 1 has no registration, and that clause stays its own. + expect(toastr.warning) + .toHaveBeenCalledWith('reconcileThroughResult · reconcileThroughNoRegistration'); expect(toastr.success).not.toHaveBeenCalled(); }); 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 b731ab5a..90e8e318 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 @@ -9,6 +9,12 @@ import { ChangeDetectorRef, NO_ERRORS_SCHEMA } from '@angular/core'; import { Store } from '@ngrx/store'; import { of } from 'rxjs'; import { TranslateModule } from '@ngx-translate/core'; +import { registerTestLocales } from '../../../testing/register-test-locales'; + +// The header row formats each day through DatePipe in the user's language, and this +// component defaults to 'da'. main.ts registers that data before the app runs; a bed +// has to do it itself or the pipe throws NG0701. +registerTestLocales(); describe('TimePlanningsTableComponent', () => { let component: TimePlanningsTableComponent; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/testing/register-test-locales.ts b/eform-client/src/app/plugins/modules/time-planning-pn/testing/register-test-locales.ts new file mode 100644 index 00000000..416f83d1 --- /dev/null +++ b/eform-client/src/app/plugins/modules/time-planning-pn/testing/register-test-locales.ts @@ -0,0 +1,71 @@ +import {registerLocaleData} from '@angular/common'; +import localeBg from '@angular/common/locales/bg'; +import localeCz from '@angular/common/locales/cs'; +import localeDa from '@angular/common/locales/da'; +import localeDe from '@angular/common/locales/de'; +import localeEl from '@angular/common/locales/el'; +import localeEn from '@angular/common/locales/en'; +import localeEs from '@angular/common/locales/es'; +import localeEt from '@angular/common/locales/et'; +import localeFi from '@angular/common/locales/fi'; +import localeFr from '@angular/common/locales/fr'; +import localeHr from '@angular/common/locales/hr'; +import localeHu from '@angular/common/locales/hu'; +import localeIs from '@angular/common/locales/is'; +import localeIt from '@angular/common/locales/it'; +import localeLt from '@angular/common/locales/lt'; +import localeLv from '@angular/common/locales/lv'; +import localeNl from '@angular/common/locales/nl'; +import localeNo from '@angular/common/locales/no'; +import localePl from '@angular/common/locales/pl'; +import localePt from '@angular/common/locales/pt'; +import localeRo from '@angular/common/locales/ro'; +import localeSk from '@angular/common/locales/sk'; +import localeSl from '@angular/common/locales/sl'; +import localeSv from '@angular/common/locales/sv'; +import localeTr from '@angular/common/locales/tr'; +import localeUk from '@angular/common/locales/uk'; + +/** + * The locale data the running app registers, for a test bed. + * + * In production `src/main.ts` registers exactly this set before bootstrap, so + * every DatePipe call in the plugin can format in the user's language. A Jest + * bed never runs main.ts, and the host's `src/setup-jest.ts` (which is the only + * truly global hook, and lives in eform-angular-frontend, not here) does not + * register any of it — so a pipe asked for a non-English locale throws + * NG0701 "Missing locale data". Angular resolves plain `en` from its built-in + * data, which is why only the beds that format in another language notice. + * + * Call this from any spec whose component formats dates. Keep the list the same + * as main.ts's: a bed that registers less than production is a bed that fails on + * a language the app supports. + */ +export function registerTestLocales(): void { + registerLocaleData(localeBg); + registerLocaleData(localeCz); + registerLocaleData(localeDa); + registerLocaleData(localeDe); + registerLocaleData(localeEl); + registerLocaleData(localeEn); + registerLocaleData(localeEs); + registerLocaleData(localeEt); + registerLocaleData(localeFi); + registerLocaleData(localeFr); + registerLocaleData(localeHr); + registerLocaleData(localeHu); + registerLocaleData(localeIs); + registerLocaleData(localeIt); + registerLocaleData(localeLt); + registerLocaleData(localeLv); + registerLocaleData(localeNl); + registerLocaleData(localeNo); + registerLocaleData(localePl); + registerLocaleData(localePt); + registerLocaleData(localeRo); + registerLocaleData(localeSk); + registerLocaleData(localeSl); + registerLocaleData(localeSv); + registerLocaleData(localeTr); + registerLocaleData(localeUk); +} From 07b598e6cd14d77fc79724586dff704d3ee2b1f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Thu, 17 Sep 2026 05:23:58 +0200 Subject: [PATCH 23/28] feat(lock): restrict reconcile and unlock to admins Reconcile, unlock and bulk reconcile now require the admin role, matching how payroll export and the pay-rule endpoints are gated. Reading a day, editing an open one and the lock's own display are unchanged. Co-Authored-By: Claude Opus 5 --- .../ReconcileServiceTests.cs | 60 +++++++++++++++++++ .../TimePlanningPlanningController.cs | 5 ++ 2 files changed, 65 insertions(+) diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs index e0c05603..9a08952a 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs @@ -2,7 +2,9 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Reflection; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microting.eForm.Infrastructure.Constants; @@ -13,6 +15,7 @@ using Microting.TimePlanningBase.Infrastructure.Data.Entities; using NSubstitute; using NUnit.Framework; +using TimePlanning.Pn.Controllers; using TimePlanning.Pn.Infrastructure.Helpers; using TimePlanning.Pn.Infrastructure.Models.Planning; using TimePlanning.Pn.Infrastructure.Models.Settings; @@ -1073,4 +1076,61 @@ public async Task Index_WhenTheEntireWindowIsLockedWithNoRows_StillReturnsLocked "or the client renders an entirely locked worker as fully editable"); }); } + + // --------------------------------------------------------------------- + // Server-side enforcement of "only an admin may reconcile or unlock a day" + // (product decision reversal — the spec's earlier "any web user may + // reconcile" no longer holds). A reflection test over the controller is + // the honest option here: the suite is service-level and never goes + // through the ASP.NET Core auth pipeline, so nothing else would catch a + // silently-dropped [Authorize] attribute. + // --------------------------------------------------------------------- + + [Test] + public void Reconcile_Unreconcile_ReconcileThrough_RequireAdminRole() + { + foreach (var methodName in new[] { "Reconcile", "Unreconcile", "ReconcileThrough" }) + { + var method = typeof(TimePlanningPlanningController).GetMethod(methodName); + Assert.That(method, Is.Not.Null, $"TimePlanningPlanningController.{methodName} must exist"); + + var authorizeAttributes = method! + .GetCustomAttributes(true) + .ToList(); + + Assert.That(authorizeAttributes, Is.Not.Empty, + $"{methodName} must carry an AuthorizeAttribute"); + + var roles = authorizeAttributes + .Where(a => !string.IsNullOrWhiteSpace(a.Roles)) + .SelectMany(a => a.Roles!.Split(',')) + .Select(r => r.Trim()) + .ToList(); + + Assert.That( + roles.Any(r => string.Equals(r, EformRole.Admin, StringComparison.OrdinalIgnoreCase)), + Is.True, + $"{methodName} must be restricted to the '{EformRole.Admin}' role — found: " + + string.Join(", ", roles)); + } + } + + [Test] + public void Update_OpenDayAction_IsNotAdminRestricted() + { + // Reading and editing OPEN days is unchanged by the reconcile lock + // reversal — only reconcile/unreconcile/reconcile-through move behind + // the admin gate. Update is the named open-day action here. + var method = typeof(TimePlanningPlanningController).GetMethod("Update"); + Assert.That(method, Is.Not.Null, "TimePlanningPlanningController.Update must exist"); + + var roleRestricted = method! + .GetCustomAttributes(true) + .Where(a => !string.IsNullOrWhiteSpace(a.Roles)) + .ToList(); + + Assert.That(roleRestricted, Is.Empty, + "Update (editing an open day) must not gain an admin-role restriction from the reconcile lock change — found: " + + string.Join(", ", roleRestricted.Select(a => a.Roles))); + } } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/TimePlanningPlanningController.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/TimePlanningPlanningController.cs index e1a55912..65dfdffb 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/TimePlanningPlanningController.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/TimePlanningPlanningController.cs @@ -24,7 +24,9 @@ namespace TimePlanning.Pn.Controllers; using System.Collections.Generic; using System.Threading.Tasks; using Infrastructure.Models.Planning; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microting.eFormApi.BasePn.Infrastructure.Database.Entities; using Microting.eFormApi.BasePn.Infrastructure.Models.API; using Services.TimePlanningPlanningService; @@ -81,6 +83,7 @@ public async Task> GetV [HttpPut] [Route("{id}/reconcile")] + [Authorize(Roles = EformRole.Admin)] public async Task Reconcile(int id) { return await _planningService.Reconcile(id); @@ -88,6 +91,7 @@ public async Task Reconcile(int id) [HttpPut] [Route("{id}/unreconcile")] + [Authorize(Roles = EformRole.Admin)] public async Task Unreconcile(int id) { return await _planningService.Unreconcile(id); @@ -95,6 +99,7 @@ public async Task Unreconcile(int id) [HttpPut] [Route("reconcile-through")] + [Authorize(Roles = EformRole.Admin)] public async Task> ReconcileThrough( [FromBody] ReconcileThroughRequestModel model) { From 1eb5f77ee80f8a1ec74a8563b0d67f42b378a4f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Thu, 17 Sep 2026 05:46:21 +0200 Subject: [PATCH 24/28] fix(lock): gate reconcile and unlock by first user, not admin role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes 07b598e6 ("restrict reconcile and unlock to admins"), already pushed to this branch/PR #1711: the product decision changed again after that commit landed. It is not a role at all — only the first user (the account with the lowest AspNetUsers Id, per FirstUserHelper) may reconcile, unlock, or bulk-reconcile a day. The three controller actions now carry a bare [Authorize] (anonymous still refused by the pipeline); which signed-in caller may proceed is decided in TimePlanningPlanningService instead, since no [Authorize] role can express "the first user". Review follow-ups folded in: the first-user check now runs as the first statement inside each method's existing try (a DB round-trip above the try would have escaped the catch-all as a raw 500 with nothing in Sentry, instead of the OperationResult every other failure here produces); the refusal is extracted once into OnlyTheFirstUserCanReconcileOrUnlock(), mirroring the existing OnlyLatestReconciledDayCanBeUnlocked() precedent; the message key is renamed to match (it also covers unlock and bulk refusals, not just reconcile) with a trailing full stop in both languages, consistent with its resx neighbours; and the test suite gained the first-user behaviour tests (success, a different signed-in user refused with no write, and the userId-0-vs-empty-table edge case) alongside the restored open-day/no-role-restriction pins. Co-Authored-By: Claude Opus 5 --- .../ReconcileServiceTests.cs | 151 +++++++++++++++--- .../TimePlanningPlanningController.cs | 11 +- .../Infrastructure/Helpers/FirstUserHelper.cs | 20 +++ .../Resources/Translations.Designer.cs | 6 + .../Resources/Translations.da.resx | 3 + .../Resources/Translations.resx | 3 + .../TimePlanningPlanningService.cs | 26 +++ 7 files changed, 192 insertions(+), 28 deletions(-) create mode 100644 eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/FirstUserHelper.cs diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs index 9a08952a..58e5d2ec 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs @@ -68,6 +68,8 @@ public async Task SetUpTest() _userService = Substitute.For(); _userService.UserId.Returns(1); _userService.GetCurrentUserAsync().Returns(new EformUser { Id = 1 }); + // The fixture's default caller IS the first user; the gate tests override this. + _userService.GetFirstUserIdInDb().Returns(1); _localizationService = Substitute.For(); _localizationService.GetString(Arg.Any()).Returns(x => x[0]?.ToString()); @@ -162,6 +164,12 @@ private async Task BuildAdminIndexServiceAsync(Bas _userService.UserId.Returns(user.Id); _userService.GetCurrentUserAsync().Returns(new EformUser { Id = user.Id }); + // This admin user is also treated as the first user here: these tests + // exercise Index()'s recompute/lock-display behaviour, not the + // reconcile gate, and SeedReconciledBoundaryAsync/ + // SeedReconciledDayWithStaleStoredValuesAsync below reconcile THROUGH + // this same _userService substitute as a setup step. + _userService.GetFirstUserIdInDb().Returns(user.Id); _dbContextHelper.GetDbContext().Returns(_ => CreateTimePlanningPnDbContext()); _indexLogger = Substitute.For>(); @@ -1078,16 +1086,115 @@ public async Task Index_WhenTheEntireWindowIsLockedWithNoRows_StillReturnsLocked } // --------------------------------------------------------------------- - // Server-side enforcement of "only an admin may reconcile or unlock a day" - // (product decision reversal — the spec's earlier "any web user may - // reconcile" no longer holds). A reflection test over the controller is - // the honest option here: the suite is service-level and never goes - // through the ASP.NET Core auth pipeline, so nothing else would catch a - // silently-dropped [Authorize] attribute. + // Server-side enforcement of "only the FIRST USER may reconcile or unlock + // a day" (corrected product decision — it is not a role at all, admin or + // otherwise). The gate lives in the SERVICE layer + // (TimePlanningPlanningService.Reconcile/Unreconcile/ReconcileThrough, + // via FirstUserHelper.IsFirstUserAsync), because that is the only path to + // these writes (see the controller, which now carries a bare + // [Authorize] — anonymous is still refused by the pipeline, but which + // signed-in caller may proceed is decided here). These are therefore + // proper behaviour tests, not reflection: success/refusal and, on + // refusal, that nothing was written. // --------------------------------------------------------------------- [Test] - public void Reconcile_Unreconcile_ReconcileThrough_RequireAdminRole() + public async Task Reconcile_TheFirstUser_Succeeds() + { + // Deliberately explicit, not redundant with SeedReconciledBoundaryAsync's + // internal assert: on a gate feature, the ALLOWED case deserves a pin a + // reader can find by name, not one inferred from a fixture's internals. + var row = await SeedPlain(940, DateTime.Now.Date.AddDays(-5)); + + var result = await _service.Reconcile(row.Id); + + Assert.That(result.Success, Is.True, result.Message); + } + + [Test] + public async Task Reconcile_ADifferentSignedInUser_IsRefused_AndWritesNothing() + { + var row = await SeedPlain(941, DateTime.Now.Date.AddDays(-5)); + // Someone else is signed in; user 1 remains the first user. + _userService.UserId.Returns(2); + + var result = await _service.Reconcile(row.Id); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("OnlyTheFirstUserCanReconcileOrUnlock")); + var reloaded = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == row.Id); + Assert.Multiple(() => + { + Assert.That(reloaded.Reconciled, Is.False, "a refused caller must not reconcile the day"); + Assert.That(reloaded.ReconciledAt, Is.Null); + }); + } + + [Test] + public async Task Reconcile_CallerWithNoUserId_IsRefused_EvenWhenTheUsersTableIsEmpty() + { + // UserId 0 (no signed-in user) paired with GetFirstUserIdInDb also + // answering 0 (an empty users table) must NOT satisfy 0 == 0 -- the + // house rule's whole point (FirstUserHelper.IsFirstUserAsync). + var row = await SeedPlain(942, DateTime.Now.Date.AddDays(-5)); + _userService.UserId.Returns(0); + _userService.GetFirstUserIdInDb().Returns(0); + + var result = await _service.Reconcile(row.Id); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("OnlyTheFirstUserCanReconcileOrUnlock")); + var reloaded = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == row.Id); + Assert.That(reloaded.Reconciled, Is.False, + "userId 0 must never pass, even against an empty/zero first-user id"); + } + + [Test] + public async Task Unreconcile_ADifferentSignedInUser_IsRefused_AndWritesNothing() + { + var boundary = await SeedReconciledBoundaryAsync(943, DateTime.Now.Date.AddDays(-3)); + _userService.UserId.Returns(2); + + var result = await _service.Unreconcile(boundary.Id); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("OnlyTheFirstUserCanReconcileOrUnlock")); + var reloaded = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == boundary.Id); + Assert.That(reloaded.Reconciled, Is.True, "a refused caller must not unlock the day"); + } + + [Test] + public async Task ReconcileThrough_ADifferentSignedInUser_IsRefused_AndWritesNothing() + { + var row = await SeedPlain(944, DateTime.Now.Date.AddDays(-6)); + _userService.UserId.Returns(2); + + var result = await _service.ReconcileThrough(new ReconcileThroughRequestModel + { + Date = DateTime.Now.Date.AddDays(-6), + SiteIds = new List { 944 } + }); + + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Is.EqualTo("OnlyTheFirstUserCanReconcileOrUnlock")); + var reloaded = await TimePlanningPnDbContext!.PlanRegistrations.AsNoTracking() + .FirstAsync(x => x.Id == row.Id); + Assert.That(reloaded.Reconciled, Is.False, "a refused caller must not bulk-reconcile anything"); + } + + /// + /// Cheap and still worth pinning: anonymous access must stay blocked even + /// though the role check is gone -- these three keep a bare [Authorize] -- + /// AND that none of them carry a role restriction any more, since the + /// mechanism really changed to the service-layer first-user check, not + /// merely gained one. The real control is the behaviour tests above; this + /// only guards against either half of that shape drifting silently. + /// + [Test] + public void Reconcile_Unreconcile_ReconcileThrough_RequireAuthorize_ButCarryNoRole() { foreach (var methodName in new[] { "Reconcile", "Unreconcile", "ReconcileThrough" }) { @@ -1099,28 +1206,24 @@ public void Reconcile_Unreconcile_ReconcileThrough_RequireAdminRole() .ToList(); Assert.That(authorizeAttributes, Is.Not.Empty, - $"{methodName} must carry an AuthorizeAttribute"); + $"{methodName} must carry an AuthorizeAttribute so anonymous callers are refused"); - var roles = authorizeAttributes - .Where(a => !string.IsNullOrWhiteSpace(a.Roles)) - .SelectMany(a => a.Roles!.Split(',')) - .Select(r => r.Trim()) - .ToList(); - - Assert.That( - roles.Any(r => string.Equals(r, EformRole.Admin, StringComparison.OrdinalIgnoreCase)), - Is.True, - $"{methodName} must be restricted to the '{EformRole.Admin}' role — found: " + - string.Join(", ", roles)); + var roleRestricted = authorizeAttributes.Where(a => !string.IsNullOrWhiteSpace(a.Roles)).ToList(); + Assert.That(roleRestricted, Is.Empty, + $"{methodName} must NOT be role-restricted -- the first-user check lives in the service, " + + "not a role -- found: " + string.Join(", ", roleRestricted.Select(a => a.Roles))); } } + /// + /// Reading and editing OPEN days is unrelated to the reconcile gate, + /// whichever mechanism guards reconcile itself (admin role, then first + /// user). Update is the named open-day action here, and this assertion + /// stands on its own regardless of which gate reconcile currently uses. + /// [Test] - public void Update_OpenDayAction_IsNotAdminRestricted() + public void Update_OpenDayAction_NeverAcquiresARoleRestriction() { - // Reading and editing OPEN days is unchanged by the reconcile lock - // reversal — only reconcile/unreconcile/reconcile-through move behind - // the admin gate. Update is the named open-day action here. var method = typeof(TimePlanningPlanningController).GetMethod("Update"); Assert.That(method, Is.Not.Null, "TimePlanningPlanningController.Update must exist"); @@ -1130,7 +1233,7 @@ public void Update_OpenDayAction_IsNotAdminRestricted() .ToList(); Assert.That(roleRestricted, Is.Empty, - "Update (editing an open day) must not gain an admin-role restriction from the reconcile lock change — found: " + + "Update (editing an open day) must not gain a role restriction from the reconcile gate — found: " + string.Join(", ", roleRestricted.Select(a => a.Roles))); } } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/TimePlanningPlanningController.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/TimePlanningPlanningController.cs index 65dfdffb..4d9b0fe8 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/TimePlanningPlanningController.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/TimePlanningPlanningController.cs @@ -26,7 +26,6 @@ namespace TimePlanning.Pn.Controllers; using Infrastructure.Models.Planning; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microting.eFormApi.BasePn.Infrastructure.Database.Entities; using Microting.eFormApi.BasePn.Infrastructure.Models.API; using Services.TimePlanningPlanningService; @@ -81,9 +80,13 @@ public async Task> GetV return await _planningService.GetVersionHistory(planRegistrationId); } + // Anonymous callers are still refused by the pipeline, but WHICH signed-in + // caller may proceed is no longer a role — it is decided in the service + // layer (TimePlanningPlanningService.Reconcile/Unreconcile/ReconcileThrough), + // which is the only path to these writes. See FirstUserHelper.IsFirstUserAsync. [HttpPut] [Route("{id}/reconcile")] - [Authorize(Roles = EformRole.Admin)] + [Authorize] public async Task Reconcile(int id) { return await _planningService.Reconcile(id); @@ -91,7 +94,7 @@ public async Task Reconcile(int id) [HttpPut] [Route("{id}/unreconcile")] - [Authorize(Roles = EformRole.Admin)] + [Authorize] public async Task Unreconcile(int id) { return await _planningService.Unreconcile(id); @@ -99,7 +102,7 @@ public async Task Unreconcile(int id) [HttpPut] [Route("reconcile-through")] - [Authorize(Roles = EformRole.Admin)] + [Authorize] public async Task> ReconcileThrough( [FromBody] ReconcileThroughRequestModel model) { diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/FirstUserHelper.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/FirstUserHelper.cs new file mode 100644 index 00000000..7a90d3af --- /dev/null +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/FirstUserHelper.cs @@ -0,0 +1,20 @@ +using System.Threading.Tasks; +using Microting.eFormApi.BasePn.Abstractions; + +namespace TimePlanning.Pn.Infrastructure.Helpers; + +/// +/// Mirrors eFormAPI.Web's Infrastructure.Helpers.FirstUserHelper: the first +/// user is the account with the lowest AspNetUsers Id. A caller without a +/// user id is never the first user, even when the users table is empty (in +/// which case GetFirstUserIdInDb would also answer 0, and 0 == 0 must not +/// mean "everyone is the first user"). +/// +public static class FirstUserHelper +{ + public static async Task IsFirstUserAsync(this IUserService userService) + { + var userId = userService.UserId; + return userId > 0 && userId == await userService.GetFirstUserIdInDb(); + } +} diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs index 004dd543..c137ec3c 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs @@ -327,6 +327,12 @@ internal static string CannotReconcileTodayOrFuture { } } + internal static string OnlyTheFirstUserCanReconcileOrUnlock { + get { + return ResourceManager.GetString("OnlyTheFirstUserCanReconcileOrUnlock", resourceCulture); + } + } + internal static string OnlyLatestReconciledDayCanBeUnlocked { get { return ResourceManager.GetString("OnlyLatestReconciledDayCanBeUnlocked", resourceCulture); diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx index 9bb92427..0af50400 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx @@ -210,6 +210,9 @@ Kun dage før i dag kan afstemmes. + + Kun den første bruger kan afstemme og låse dage op. + Lås den seneste afstemte dag op først ({0}). diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx index f1acdabb..ebaf692a 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx @@ -210,6 +210,9 @@ Only days before today can be reconciled. + + Only the first user can reconcile or unlock days. + Unlock the most recent reconciled day first ({0}). diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs index 5bc39273..bc12b167 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs @@ -2387,6 +2387,11 @@ public async Task Reconcile(int id) { try { + if (!await userService.IsFirstUserAsync()) + { + return new OperationResult(false, OnlyTheFirstUserCanReconcileOrUnlock()); + } + var planning = await FindActivePlanningAsync(id); if (planning == null) @@ -2446,6 +2451,11 @@ public async Task Unreconcile(int id) { try { + if (!await userService.IsFirstUserAsync()) + { + return new OperationResult(false, OnlyTheFirstUserCanReconcileOrUnlock()); + } + var planning = await FindActivePlanningAsync(id); if (planning == null) @@ -2502,11 +2512,27 @@ private OperationResult OnlyLatestReconciledDayCanBeUnlocked(DateTime boundary) "OnlyLatestReconciledDayCanBeUnlocked", boundary.ToString("dd-MM-yyyy", CultureInfo.InvariantCulture))); + /// + /// No [Authorize] role can express "the first user" -- it names a single, + /// data-dependent account (the lowest AspNetUsers Id), not a role -- so + /// Reconcile, Unreconcile and ReconcileThrough each check + /// FirstUserHelper.IsFirstUserAsync in the service layer instead, and + /// share this refusal message. + /// + private string OnlyTheFirstUserCanReconcileOrUnlock() + => localizationService.GetString("OnlyTheFirstUserCanReconcileOrUnlock"); + public async Task> ReconcileThrough( ReconcileThroughRequestModel model) { try { + if (!await userService.IsFirstUserAsync()) + { + return new OperationDataResult(false, + OnlyTheFirstUserCanReconcileOrUnlock()); + } + if (model == null || model.SiteIds.Count == 0) { return new OperationDataResult(false, From cdab24ca99becd8495bd2b09f32b7205898ec525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Thu, 17 Sep 2026 06:02:52 +0200 Subject: [PATCH 25/28] feat(lock): give the Afstemt verbs to the first user only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconcile, unlock and the bulk reconcile are now offered only to the first user. This supersedes the admin-role approach: an earlier pass gated the same three actions on selectCurrentUserIsAdmin, and the product decision changed to the first user before it shipped. The two are not interchangeable. The admin flag is read off the token and is there the moment login lands; isFirstUser rides on the current-user slice, which is populated separately, so every read of it here is a live subscription and never take(1), and the grid rebuilds its header row if the flag answers after the first build. Hidden, never disabled, and with no line saying why: a disabled control would have to explain itself, and no copy here may explain a restriction by appealing to what some other user may do. What is hidden: - the Reconcile day button and its "save first" note (reconcileEligible); - the Unlock button (new canUnlock = isFirstUser && isBoundaryDay); - the "Reconcile through" toolbar field; - the bulk preview itself and its commit, guarded at the one place a preview is built and again where the request is made; - the clickable past-day column header, by not installing its template, so everyone else keeps the date above the column and loses only the affordance; - the row tick boxes, which exist to scope a bulk reconcile and nothing else; - the toolbar.reconcileThrough help entry, through a new firstUserOnly axis on HelpEntry. Deliberately NOT folded into adminOnly: several people hold the admin role while the first user is the single lowest-id account, so an admin who is not the first user was being taught a field their toolbar does not have. All lock DISPLAY stays for every user: the hatching and seals, the glyphs and tooltips, the legend, the read-only dialog, the provenance line and the "free this day first" line. Knowing a day is frozen is not a privilege. isBoundaryDay is deliberately left free of the flag, because it also decides whether the footer names the day to free first; gating it would have told a reader standing on the boundary to free that same day first. isAdmin keeps its own jobs — the help tour, the payroll export button and the help panel — so both flags now sit side by side, each with a comment saying the other is not its duplicate. The server enforces this independently; the UI is the courtesy, not the control. Tests: every gated control has a pair, the same call as someone else and as the first user, so no negative half can pass vacuously. Existing cases that built the component as the bed's default user now say which user they mean. Co-Authored-By: Claude Opus 5 --- .../workday-entity-dialog.component.html | 10 +- .../workday-entity-dialog.component.spec.ts | 122 +++++++++++++++- .../workday-entity-dialog.component.ts | 67 +++++++-- .../time-plannings-container.component.html | 15 +- ...time-plannings-container.component.spec.ts | 138 +++++++++++++++--- .../time-plannings-container.component.ts | 53 ++++++- .../time-plannings-table.component.html | 10 +- .../time-plannings-table.component.spec.ts | 127 ++++++++++++++-- .../time-plannings-table.component.ts | 68 +++++++-- .../help-panel/help-panel.component.ts | 27 ++-- .../time-planning-pn/help/help.model.ts | 19 +++ .../help/planning-help.registry.spec.ts | 9 ++ .../help/planning-help.registry.ts | 6 +- .../services/help-content.service.spec.ts | 16 +- .../help/services/help-content.service.ts | 10 +- .../help/services/help-search.service.ts | 4 +- .../help/services/help-tour.service.ts | 4 +- 17 files changed, 606 insertions(+), 99 deletions(-) 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 a15227db..539ca640 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 @@ -551,6 +551,14 @@ {{ 'Cancel' | translate }} +