diff --git a/docs/superpowers/specs/2026-07-22-excel-export-tags-column-design.md b/docs/superpowers/specs/2026-07-22-excel-export-tags-column-design.md new file mode 100644 index 00000000..7f398eec --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-excel-export-tags-column-design.md @@ -0,0 +1,87 @@ +# Excel Exports: Tags (Etiketter) Column — Design + +**Date:** 2026-07-22 +**Repo:** `/home/rene/Documents/workspace/microting/eform-angular-timeplanning-plugin` (backend only; frontend, endpoints, and request models unchanged) + +## Background + +The timeplanning Excel exports are generated by two OpenXML builders in +`eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs`: + +- **Single-site export** — `GenerateExcelDashboard(TimePlanningWorkingHoursRequestModel)` + (~line 2598): "Day overview" sheet (one row = one day, rows built via the + `DayOverviewRow` DTO ~line 4383 and `BuildDayOverviewWorksheet` ~line 4460) and a + "Dashboard" sheet (headers ~2711–2775, rows via `FillDataRow` ~2950). +- **All-workers export** — `GenerateExcelDashboard(TimePlanningWorkingHoursReportForAllWorkersRequestModel)` + (~line 3169): "Day overview" across all sites (~3312), a "Total" sheet with one + row per site (~3351, values ~3685–3739), and one per-site tab (same row shape as + the single-site Dashboard). + +Both are served by `TimePlanningWorkingHoursController` (`reports/file`, +`reports/file-all-workers`); all three UI export buttons converge on these two +endpoints. Tags ("Etiketter") are the eform-SDK's Site↔Tag many-to-many +(`SiteTags` join table, names in `Tags.Name`) — the same join the planning page's +Etiketter filter uses (`TimePlanningPlanningService.cs` ~lines 200–213). + +## Feature + +Every sheet that has a site/worker in scope gets one new **Tags** column, +placed **immediately after the worker/site name column**: + +1. Single-site "Dashboard" sheet +2. Single-site "Day overview" sheet +3. All-workers "Day overview" sheet +4. All-workers "Total" sheet (one row per site) +5. Each per-site tab in the all-workers export + +The cell value is the site's tag names, **sorted alphabetically (ordinal, +case-insensitive) and joined with ", "** — e.g. `Brand, EL`. Sites without tags +get an empty cell. On per-day sheets the value repeats on every row of the same +site, exactly like the worker name already does. + +## Implementation + +- **Tag lookup, once per export**: a private helper in + `TimePlanningWorkingHoursService`, e.g. + `GetSiteTagNames(sdkDbContext, IEnumerable siteMicrotingUids)` → + `Dictionary` keyed by the site's `MicrotingUid` (the id every + export code path already holds). One query: `SiteTags` filtered to + non-removed (`WorkflowState != Constants.WorkflowStates.Removed`, matching the + planning-filter idiom) joined with `Tags` for names, grouped per site, sorted, + `string.Join(", ", …)`. Called once at the top of each `GenerateExcelDashboard` + overload; the dictionary flows to the sheet builders. +- **`DayOverviewRow`** gains a `Tags` string property; the row-building code fills + it from the dictionary; `BuildDayOverviewWorksheet` emits the header + cell + after the name column. +- **Headers**: the "Tags" header is produced exactly the way the neighboring + headers are produced today (whatever localization mechanism the existing + header cells use — follow it; Danish label "Etiketter", English "Tags"). If the + existing headers are hard-coded English strings, the new one is too; if they go + through a localization service/translation entries, add matching entries in the + same place(s). +- No frontend change, no endpoint change, no migration, no base-repo change. + +## Tests (CI-only — never run locally) + +In the existing `TimePlanning.Pn.Test` project, following whatever pattern exists +for the working-hours/export service (or creating a focused test class if none +covers the generator): + +1. **Tag-map correctness**: sites with two tags produce the sorted, comma-joined + string; untagged sites are absent/empty; removed `SiteTags` rows are excluded. +2. **Workbook-level assertion** (via `DocumentFormat.OpenXml` reading the + generated stream): the Tags header appears immediately after the name header, + and a tagged site's row carries the joined value while an untagged site's cell + is empty — covering at least the Total sheet and one per-day sheet. + +Existing tests untouched. The C# build must pass locally before push; tests run +only in CI. + +## Ship flow + +Edit plugin repo → mirror the changed C# to the host app (targeted `cp`) → host +backend clean+rebuild+restart → live verification: download a real export via the +UI/endpoint and inspect the xlsx (header present after name column, tagged site +shows `Tag1, Tag2`, untagged site empty) → dual review gate → +`feat/excel-export-tags-column` branch → PR to `stable` → CI watch → merge only +on green. diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DagsoversigtWorksheetExportTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DagsoversigtWorksheetExportTests.cs index 1e39c9cc..0abd8706 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DagsoversigtWorksheetExportTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DagsoversigtWorksheetExportTests.cs @@ -31,7 +31,7 @@ namespace TimePlanning.Pn.Test; /// End-to-end coverage for the new "Dagsoversigt" (Day overview) worksheet that /// is added as the FIRST tab of both the single-worker and all-workers Excel /// exports. These tests open the produced xlsx with OpenXml and assert the sheet -/// order, the 21-column header, the Excel Table definition, the cell styles and +/// order, the 22-column header, the Excel Table definition, the cell styles and /// the OADate cell values. The export's ValidateExcel swallows schema /// errors, so opening/reading the file in a test is the only thing that catches /// a malformed worksheet or table. @@ -114,11 +114,11 @@ public void GetShiftTimeFraction_CoversGridStampAndEdgeCases() } // ------------------------------------------------------------------ - // 2. Single-worker: first sheet is Dagsoversigt with 21-column header. + // 2. Single-worker: first sheet is Dagsoversigt with 22-column header. // ------------------------------------------------------------------ [Test] - public async Task SingleWorker_FirstSheetIsDagsoversigt_With21ColumnHeaderAndTable() + public async Task SingleWorker_FirstSheetIsDagsoversigt_With22ColumnHeaderAndTable() { await SeedSiteAndPlanRegistration( siteUid: 9801, @@ -148,17 +148,18 @@ await SeedSiteAndPlanRegistration( var firstPart = (WorksheetPart)workbookPart.GetPartById(sheets[0].Id!); var headerRow = firstPart.Worksheet.Descendants().First(r => r.RowIndex! == 1U); var headerCells = headerRow.Elements().ToList(); - Assert.That(headerCells.Count, Is.EqualTo(21), "Dagsoversigt header must have 21 columns"); + Assert.That(headerCells.Count, Is.EqualTo(22), "Dagsoversigt header must have 22 columns"); Assert.That(CellText(headerCells[0], workbookPart), Is.EqualTo("Medarbejder nr.")); - Assert.That(CellText(headerCells[20], workbookPart), Is.EqualTo("Timer netto")); + Assert.That(CellText(headerCells[2], workbookPart), Is.EqualTo("Etiketter")); + Assert.That(CellText(headerCells[21], workbookPart), Is.EqualTo("Timer netto")); - // Exactly one Excel Table, named region A1:U{1+dataRows}. + // Exactly one Excel Table, named region A1:V{1+dataRows}. Assert.That(firstPart.TableDefinitionParts.Count(), Is.EqualTo(1)); var dataRows = firstPart.Worksheet.Descendants().Count(r => r.RowIndex! > 1U); Assert.That(dataRows, Is.EqualTo(1), "Single seeded plan registration => one data row"); Assert.That(firstPart.TableDefinitionParts.First().Table!.Reference!.Value, - Is.EqualTo($"A1:U{1 + dataRows}")); + Is.EqualTo($"A1:V{1 + dataRows}")); } // ------------------------------------------------------------------ @@ -192,21 +193,21 @@ await SeedSiteAndPlanRegistration( var dataRow = firstPart.Worksheet.Descendants().First(r => r.RowIndex! == 2U); - // Date cell (col D): StyleIndex 5 (dd/mm/yyyy), numeric OADate. - var dateCell = dataRow.Elements().Single(c => c.CellReference == "D2"); + // Date cell (col E): StyleIndex 5 (dd/mm/yyyy), numeric OADate. + var dateCell = dataRow.Elements().Single(c => c.CellReference == "E2"); Assert.That(dateCell.StyleIndex!.Value, Is.EqualTo(5U)); Assert.That(dateCell.DataType!.Value, Is.EqualTo(CellValues.Number)); Assert.That(double.Parse(dateCell.CellValue!.Text, CultureInfo.InvariantCulture), Is.EqualTo(new DateTime(2026, 5, 15).ToOADate()).Within(1e-9)); - // Shift 1 start cell (col F): StyleIndex 3 (hh:mm), value = (97-1)*5/1440. - var shift1StartCell = dataRow.Elements().Single(c => c.CellReference == "F2"); + // Shift 1 start cell (col G): StyleIndex 3 (hh:mm), value = (97-1)*5/1440. + var shift1StartCell = dataRow.Elements().Single(c => c.CellReference == "G2"); Assert.That(shift1StartCell.StyleIndex!.Value, Is.EqualTo(3U)); Assert.That(double.Parse(shift1StartCell.CellValue!.Text, CultureInfo.InvariantCulture), Is.EqualTo((97 - 1) * 5 / 1440.0).Within(1e-9)); - // NettoHours cell (col U): StyleIndex 4 (0.00). - var nettoCell = dataRow.Elements().Single(c => c.CellReference == "U2"); + // NettoHours cell (col V): StyleIndex 4 (0.00). + var nettoCell = dataRow.Elements().Single(c => c.CellReference == "V2"); Assert.That(nettoCell.StyleIndex!.Value, Is.EqualTo(4U)); } @@ -329,7 +330,7 @@ await SeedSiteAndPlanRegistration( // The all-workers workbook has no "Dashboard" sheet; the positional // FillDataRow layout lives on the per-site sheet, named after the site - // ("Site 9810"). Same 0-indexed columns: 7=Shift1Start, 8=Shift1Stop. + // ("Site 9810"). Same 0-indexed columns: 8=Shift1Start, 9=Shift1Stop. var (_, allShift1Stop) = ReadDashboardShift1Cells(allResult.Model!, "Site 9810"); Assert.That(allShift1Stop, Is.EqualTo("26:00"), "All-workers path (the one that crashed in production) must also render slot 313 as 26:00"); @@ -414,7 +415,7 @@ await SeedSiteAndPlanRegistration( /// /// Opens the xlsx stream and returns the (Shift1Start, Shift1Stop) cell text /// for the first populated data row of the positional "Dashboard" sheet. - /// Column layout from FillDataRow (0-indexed): 7=Shift1Start, 8=Shift1Stop. + /// Column layout from FillDataRow (0-indexed): 8=Shift1Start, 9=Shift1Stop. /// private static (string Start, string Stop) ReadDashboardShift1Cells(Stream xlsx, string sheetName = "Dashboard") { @@ -428,9 +429,9 @@ private static (string Start, string Stop) ReadDashboardShift1Cells(Stream xlsx, foreach (var row in rows.Where(r => r.RowIndex == null || r.RowIndex! > 1U)) { var cells = row.Elements().ToList(); - if (cells.Count < 9) continue; - var shift1Start = CellText(cells[7], workbookPart); - var shift1Stop = CellText(cells[8], workbookPart); + if (cells.Count < 10) continue; + var shift1Start = CellText(cells[8], workbookPart); + var shift1Stop = CellText(cells[9], workbookPart); if (!string.IsNullOrEmpty(shift1Start) || !string.IsNullOrEmpty(shift1Stop)) { return (shift1Start, shift1Stop); @@ -445,7 +446,7 @@ private static void AssertRowDateAndEmployee(Row row, WorkbookPart wb, double ex var employeeCell = row.Elements().Single(c => c.CellReference!.Value!.StartsWith("A")); var dateCell = row.Elements().Single(c => - c.CellReference!.Value!.StartsWith("D")); + c.CellReference!.Value!.StartsWith("E")); Assert.That(CellText(employeeCell, wb), Is.EqualTo(expectedEmployeeNo)); Assert.That(double.Parse(dateCell.CellValue!.Text, CultureInfo.InvariantCulture), diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursExcelExportE2ETests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursExcelExportE2ETests.cs index 489bb8bc..7708b9c4 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursExcelExportE2ETests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursExcelExportE2ETests.cs @@ -216,8 +216,8 @@ private async Task SeedSiteAndPlanRegistration( /// Opens the xlsx stream and returns the (Shift1Start, Shift1Stop) cell text /// for the first data row that has either populated. Column layout from /// FillDataRow (positional, 0-indexed): 0=EmployeeNo, 1=SiteName, - /// 2=WeekDay, 3=Date, 4=WeekNumber, 5=PlanText, 6=PlanHours, 7=Shift1Start, - /// 8=Shift1Stop, 9=Shift1Pause. CreateCell doesn't set + /// 2=Tags, 3=WeekDay, 4=Date, 5=WeekNumber, 6=PlanText, 7=PlanHours, + /// 8=Shift1Start, 9=Shift1Stop, 10=Shift1Pause. CreateCell doesn't set /// CellReference, so cells are positional within the row, not /// addressed by letter. /// @@ -246,9 +246,9 @@ string CellText(Cell c) foreach (var row in rows.Where(r => r.RowIndex == null || r.RowIndex! > 1U)) { var cells = row.Elements().ToList(); - if (cells.Count < 9) continue; - var shift1Start = CellText(cells[7]); - var shift1Stop = CellText(cells[8]); + if (cells.Count < 10) continue; + var shift1Start = CellText(cells[8]); + var shift1Stop = CellText(cells[9]); if (!string.IsNullOrEmpty(shift1Start) || !string.IsNullOrEmpty(shift1Stop)) { return (shift1Start, shift1Stop); diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursExcelExportTagsColumnTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursExcelExportTagsColumnTests.cs new file mode 100644 index 00000000..f889ed7f --- /dev/null +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursExcelExportTagsColumnTests.cs @@ -0,0 +1,365 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Spreadsheet; +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 NSubstitute; +using NUnit.Framework; +using TimePlanning.Pn.Infrastructure.Models.Settings; +using TimePlanning.Pn.Infrastructure.Models.WorkingHours.Index; +using TimePlanning.Pn.Services.TimePlanningLocalizationService; +using TimePlanning.Pn.Services.TimePlanningWorkingHoursService; +using AssignedSiteEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.AssignedSite; +using PlanRegistrationEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.PlanRegistration; +using SdkLanguage = Microting.eForm.Infrastructure.Data.Entities.Language; +using SdkSite = Microting.eForm.Infrastructure.Data.Entities.Site; +using SdkSiteTag = Microting.eForm.Infrastructure.Data.Entities.SiteTag; +using SdkSiteWorker = Microting.eForm.Infrastructure.Data.Entities.SiteWorker; +using SdkTag = Microting.eForm.Infrastructure.Data.Entities.Tag; +using SdkWorker = Microting.eForm.Infrastructure.Data.Entities.Worker; + +namespace TimePlanning.Pn.Test; + +/// +/// Coverage for the "Tags" (da: "Etiketter") column added to every sheet of the +/// working-hours Excel exports, placed immediately after the worker/site name +/// column. The cell value is the site's SDK tag names sorted alphabetically +/// (ordinal, case-insensitive) and joined with ", "; untagged sites get an +/// empty cell; removed SiteTags rows are excluded. Tests cover the +/// tag-map helper directly (internal, via InternalsVisibleTo) and the produced +/// workbooks at cell level via OpenXml — the Total sheet, a per-site tab, the +/// Dashboard sheet and the per-day "Dagsoversigt" sheet. +/// +[TestFixture] +public class WorkingHoursExcelExportTagsColumnTests : TestBaseSetup +{ + private TimePlanningWorkingHoursService _service = null!; + + [SetUp] + public async Task SetUpTest() + { + await base.Setup(); + + var userService = Substitute.For(); + userService.UserId.Returns(1); + + var localizationService = Substitute.For(); + localizationService.GetString(Arg.Any()).Returns(x => x[0]?.ToString()); + + var coreService = Substitute.For(); + var core = await GetCore(); + coreService.GetCore().Returns(core); + + // Danish user language so Translations.X resolves deterministically to + // the da values ("Etiketter", "Medarbejder") in the produced headers. + var sdkDb = core.DbContextHelper.GetDbContext(); + var language = await sdkDb.Languages.FirstOrDefaultAsync(l => l.LanguageCode == "da"); + if (language == null) + { + language = new SdkLanguage { LanguageCode = "da", Name = "Danish" }; + await language.Create(sdkDb); + } + userService.GetCurrentUserLanguage().Returns(language); + + var options = Substitute.For>(); + options.Value.Returns(new TimePlanningBaseSettings + { + AutoBreakCalculationActive = "0", + DayOfPayment = 20, + GpsEnabled = "0", + SnapshotEnabled = "0" + }); + + _service = new TimePlanningWorkingHoursService( + Substitute.For>(), + TimePlanningPnDbContext!, + userService, + localizationService, + baseDbContext: null!, + options, + coreService); + } + + // ------------------------------------------------------------------ + // 1. Tag-map helper: sorted comma-join, removed rows excluded, + // untagged sites absent. + // ------------------------------------------------------------------ + + [Test] + public async Task GetSiteTagNames_SortsCaseInsensitively_ExcludesRemoved_OmitsUntagged() + { + var core = await GetCore(); + var sdkDb = core.DbContextHelper.GetDbContext(); + + var taggedSite = new SdkSite { Name = "Site 9601", MicrotingUid = 9601 }; + await taggedSite.Create(sdkDb); + var untaggedSite = new SdkSite { Name = "Site 9602", MicrotingUid = 9602 }; + await untaggedSite.Create(sdkDb); + + // "alpha" + "Beta": ordinal-case-SENSITIVE order would be "Beta, alpha" + // ('B' < 'a'); the required ordinal-case-INSENSITIVE order is "alpha, Beta". + await TagSite(sdkDb, taggedSite, "Beta"); + await TagSite(sdkDb, taggedSite, "alpha"); + + // A removed SiteTag row must be excluded even though the Tag itself lives on. + var removedSiteTag = await TagSite(sdkDb, taggedSite, "Zulu"); + await removedSiteTag.Delete(sdkDb); + + var map = await TimePlanningWorkingHoursService.GetSiteTagNames( + sdkDb, new[] { 9601, 9602 }); + + Assert.That(map.ContainsKey(9601), Is.True); + Assert.That(map[9601], Is.EqualTo("alpha, Beta"), + "Tag names must be sorted ordinal-case-insensitively and joined with \", \"; removed SiteTags excluded"); + Assert.That(map.ContainsKey(9602), Is.False, "Untagged sites must be absent from the map"); + } + + // ------------------------------------------------------------------ + // 2. All-workers export: Total sheet + per-site tab carry the Tags + // column immediately after the worker/site name column. + // ------------------------------------------------------------------ + + [Test] + public async Task AllWorkersExport_TotalAndPerSiteSheets_TagsColumnAfterNameColumn() + { + var date = new DateTime(2026, 7, 15); + await SeedSiteAndPlanRegistration(siteUid: 9611, employeeNo: "1", date: date); + await SeedSiteAndPlanRegistration(siteUid: 9612, employeeNo: "2", date: date); + await TagSiteByUid(9611, "EL"); + await TagSiteByUid(9611, "Brand"); + // Site 9612 stays untagged. + + var result = await _service.GenerateExcelDashboard( + new TimePlanningWorkingHoursReportForAllWorkersRequestModel + { + DateFrom = date, + DateTo = date, + }); + + Assert.That(result.Success, Is.True, result.Message); + Assert.That(result.Model, Is.Not.Null); + + try + { + result.Model!.Position = 0; + using var doc = SpreadsheetDocument.Open(result.Model!, false); + var workbookPart = doc.WorkbookPart!; + + // --- Total sheet: header row (positional CreateCell layout: + // 0=From, 1=To, 2=Employee no, 3=Worker, 4=Tags, 5=PlanHours...) --- + var totalRows = SheetRows(workbookPart, "Total"); + var totalHeader = totalRows.First(r => r.RowIndex! == 1U).Elements().ToList(); + Assert.That(CellText(totalHeader[3], workbookPart), Is.EqualTo("Medarbejder")); + Assert.That(CellText(totalHeader[4], workbookPart), Is.EqualTo("Etiketter"), + "Total sheet: Tags header must sit immediately after the Worker header"); + + // --- Total sheet: one row per site; Tags value follows the site name --- + var totalDataRows = totalRows.Where(r => r.RowIndex! > 1U) + .Select(r => r.Elements().ToList()) + .ToList(); + var taggedRow = totalDataRows.Single(c => CellText(c[3], workbookPart) == "Site 9611"); + var untaggedRow = totalDataRows.Single(c => CellText(c[3], workbookPart) == "Site 9612"); + Assert.That(CellText(taggedRow[4], workbookPart), Is.EqualTo("Brand, EL"), + "Tagged site's Total row must carry the sorted, comma-joined tag names"); + Assert.That(CellText(untaggedRow[4], workbookPart), Is.EqualTo(string.Empty), + "Untagged site's Total row must have an empty Tags cell"); + + // --- Per-site tab (FillDataRow layout: 0=Employee no, 1=Worker, 2=Tags) --- + var siteRows = SheetRows(workbookPart, "Site 9611"); + var siteHeader = siteRows.First(r => r.RowIndex! == 1U).Elements().ToList(); + Assert.That(CellText(siteHeader[1], workbookPart), Is.EqualTo("Medarbejder")); + Assert.That(CellText(siteHeader[2], workbookPart), Is.EqualTo("Etiketter"), + "Per-site tab: Tags header must sit immediately after the Worker header"); + var siteDataCells = siteRows.First(r => r.RowIndex! == 2U).Elements().ToList(); + Assert.That(CellText(siteDataCells[2], workbookPart), Is.EqualTo("Brand, EL")); + + var untaggedSiteRows = SheetRows(workbookPart, "Site 9612"); + var untaggedSiteData = untaggedSiteRows.First(r => r.RowIndex! == 2U).Elements().ToList(); + Assert.That(CellText(untaggedSiteData[2], workbookPart), Is.EqualTo(string.Empty), + "Untagged site's per-site tab rows must have an empty Tags cell"); + } + finally + { + await result.Model!.DisposeAsync(); + } + } + + // ------------------------------------------------------------------ + // 3. Single-site export: Dashboard sheet + per-day "Dagsoversigt" sheet; + // the Tags value repeats on every row of the same site. + // ------------------------------------------------------------------ + + [Test] + public async Task SingleSiteExport_DashboardAndDayOverview_TagsColumnAfterNameColumn() + { + var dateA = new DateTime(2026, 7, 16); + var dateB = new DateTime(2026, 7, 17); + await SeedSiteAndPlanRegistration(siteUid: 9621, employeeNo: "1", date: dateA, extraDate: dateB); + await TagSiteByUid(9621, "EL"); + await TagSiteByUid(9621, "Brand"); + + var result = await _service.GenerateExcelDashboard(new TimePlanningWorkingHoursRequestModel + { + SiteId = 9621, + DateFrom = dateA, + DateTo = dateB, + }); + + Assert.That(result.Success, Is.True, result.Message); + Assert.That(result.Model, Is.Not.Null); + + try + { + result.Model!.Position = 0; + using var doc = SpreadsheetDocument.Open(result.Model!, false); + var workbookPart = doc.WorkbookPart!; + + // --- Dashboard sheet (FillDataRow layout: 0=Employee no, 1=Worker, 2=Tags) --- + var dashboardRows = SheetRows(workbookPart, "Dashboard"); + var dashboardHeader = dashboardRows.First(r => r.RowIndex! == 1U).Elements().ToList(); + Assert.That(CellText(dashboardHeader[1], workbookPart), Is.EqualTo("Medarbejder")); + Assert.That(CellText(dashboardHeader[2], workbookPart), Is.EqualTo("Etiketter"), + "Dashboard: Tags header must sit immediately after the Worker header"); + var dashboardData = dashboardRows.First(r => r.RowIndex! == 2U).Elements().ToList(); + Assert.That(CellText(dashboardData[2], workbookPart), Is.EqualTo("Brand, EL")); + + // --- Dagsoversigt sheet (cell-referenced layout: A=Employee no, + // B=Worker, C=Tags); the value repeats on every data row --- + var dayRows = SheetRows(workbookPart, "Dagsoversigt"); + var dayHeader = dayRows.First(r => r.RowIndex! == 1U).Elements().ToList(); + Assert.That(CellText(dayHeader[1], workbookPart), Is.EqualTo("Medarbejder")); + Assert.That(CellText(dayHeader[2], workbookPart), Is.EqualTo("Etiketter"), + "Dagsoversigt: Tags header must sit immediately after the Worker header"); + + foreach (var rowIdx in new[] { 2U, 3U }) + { + var row = dayRows.First(r => r.RowIndex! == rowIdx); + var tagsCell = row.Elements().Single(c => c.CellReference == $"C{rowIdx}"); + Assert.That(CellText(tagsCell, workbookPart), Is.EqualTo("Brand, EL"), + $"Dagsoversigt row {rowIdx}: Tags value must repeat on every row of the site"); + } + } + finally + { + await result.Model!.DisposeAsync(); + } + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + /// Creates a Tag named and links it to + /// via a SiteTag row; returns the SiteTag so the + /// caller can soft-delete it. + private static async Task TagSite( + Microting.eForm.Infrastructure.MicrotingDbContext sdkDb, SdkSite site, string tagName) + { + var tag = new SdkTag { Name = tagName }; + await tag.Create(sdkDb); + var siteTag = new SdkSiteTag { SiteId = site.Id, TagId = tag.Id }; + await siteTag.Create(sdkDb); + return siteTag; + } + + private async Task TagSiteByUid(int siteUid, string tagName) + { + var core = await GetCore(); + var sdkDb = core.DbContextHelper.GetDbContext(); + var site = await sdkDb.Sites.FirstAsync(x => x.MicrotingUid == siteUid); + await TagSite(sdkDb, site, tagName); + } + + /// Seeds SDK Site/Worker/SiteWorker + AssignedSite + a prior-day + /// registration (dropped via the export's Skip(1)) + a registration on + /// (and optionally ). + /// Mirrors the seeding helpers of the other export test fixtures. + private async Task SeedSiteAndPlanRegistration( + int siteUid, string employeeNo, DateTime date, DateTime? extraDate = null) + { + var core = await GetCore(); + var sdkDb = core.DbContextHelper.GetDbContext(); + + var site = new SdkSite { Name = $"Site {siteUid}", MicrotingUid = siteUid }; + await site.Create(sdkDb); + + var worker = new SdkWorker + { + FirstName = "Test", + LastName = "Worker", + Email = $"test{siteUid}@example.com", + MicrotingUid = 1000 + siteUid, + EmployeeNo = employeeNo, + }; + await worker.Create(sdkDb); + + var siteWorker = new SdkSiteWorker + { + SiteId = site.Id, + WorkerId = worker.Id, + MicrotingUid = 2000 + siteUid, + }; + await siteWorker.Create(sdkDb); + + await new AssignedSiteEntity + { + SiteId = siteUid, + UseOneMinuteIntervals = false, + Resigned = false, + WorkflowState = Constants.WorkflowStates.Created, + CreatedByUserId = 1, + UpdatedByUserId = 1, + }.Create(TimePlanningPnDbContext!); + + var dates = new List { date }; + if (extraDate.HasValue) + { + dates.Add(extraDate.Value); + } + + // Prior-day registration so Index() emits a "prePlanning" row at index 0 + // that the export drops via Skip(1). + foreach (var d in new[] { date.AddDays(-1) }.Concat(dates)) + { + await new PlanRegistrationEntity + { + SdkSitId = siteUid, + Date = d, + Start1Id = d == date.AddDays(-1) ? 0 : 97, + Stop1Id = d == date.AddDays(-1) ? 0 : 121, + Pause1Id = 0, + PlanText = "", + CommentOffice = "", + CommentOfficeAll = "", + WorkflowState = Constants.WorkflowStates.Created, + CreatedByUserId = 1, + UpdatedByUserId = 1, + }.Create(TimePlanningPnDbContext!); + } + } + + private static List SheetRows(WorkbookPart workbookPart, string sheetName) + { + var sheet = workbookPart.Workbook.Descendants().First(s => s.Name == sheetName); + var part = (WorksheetPart)workbookPart.GetPartById(sheet.Id!); + return part.Worksheet.Descendants().ToList(); + } + + private static string CellText(Cell c, WorkbookPart wb) + { + var sst = wb.SharedStringTablePart?.SharedStringTable; + var raw = c.CellValue?.Text ?? c.InnerText ?? ""; + if (c.DataType?.Value == CellValues.SharedString && sst != null && int.TryParse(raw, out var idx)) + { + return sst.ElementAt(idx).InnerText; + } + return raw; + } +} 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 1f01f855..4b821662 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs @@ -158,6 +158,12 @@ internal static string Worker { return ResourceManager.GetString("Worker", resourceCulture); } } + + internal static string Tags { + get { + return ResourceManager.GetString("Tags", resourceCulture); + } + } internal static string DayOfWeek { get { 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 a19db017..339d59ef 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx @@ -72,6 +72,9 @@ Medarbejder + + Etiketter + Ugedag diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx index b31828c3..151cadaf 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx @@ -75,6 +75,9 @@ Worker + + Tags + Day of week 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 8d0d51b8..a61cd4aa 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs @@ -2604,6 +2604,9 @@ public async Task> GenerateExcelDashboard(TimePlanni var site = await sdkContext.Sites.FirstAsync(x => x.MicrotingUid == model.SiteId); var siteWorker = await sdkContext.SiteWorkers.FirstAsync(x => x.SiteId == site.Id); var worker = await sdkContext.Workers.FirstAsync(x => x.Id == siteWorker!.WorkerId); + // Tag names for this site (sorted, comma-joined) — one lookup per export. + var tagNamesBySiteUid = await GetSiteTagNames(sdkContext, [(int)site.MicrotingUid!]); + var siteTagNames = tagNamesBySiteUid.GetValueOrDefault((int)site.MicrotingUid!, string.Empty); var language = await userService.GetCurrentUserLanguage(); var assignedSite = await dbContext.AssignedSites .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) @@ -2696,6 +2699,7 @@ public async Task> GenerateExcelDashboard(TimePlanni { EmployeeNo = worker.EmployeeNo ?? string.Empty, WorkerName = site.Name, + Tags = siteTagNames, Date = p.Date, Planning = p, // Per-row mode at registration: write-time marker first, @@ -2712,6 +2716,7 @@ public async Task> GenerateExcelDashboard(TimePlanni { Translations.Employee_no, Translations.Worker, + Translations.Tags, Translations.DayOfWeek, Translations.Date, Translations.Week_number, @@ -2829,7 +2834,7 @@ public async Task> GenerateExcelDashboard(TimePlanni foreach (var planning in timePlannings) { var dataRow = new Row() { RowIndex = (uint)rowIndex }; - FillDataRow(dataRow, worker, site, culture, planning, plr, language, isThirdShiftEnabled, isFourthShiftEnabled, isFifthShiftEnabled, planning.RegisteredUnderOneMinuteIntervals ?? oneMinuteTimeline.WasOneMinuteAt(planning.Date)); + FillDataRow(dataRow, worker, site, siteTagNames, culture, planning, plr, language, isThirdShiftEnabled, isFourthShiftEnabled, isFifthShiftEnabled, planning.RegisteredUnderOneMinuteIntervals ?? oneMinuteTimeline.WasOneMinuteAt(planning.Date)); // Append pay code values for this day var dayPayLines = payLinesByDate.ContainsKey(planning.Date) @@ -2873,6 +2878,7 @@ public async Task> GenerateExcelDashboard(TimePlanni var totalsRow = new Row { RowIndex = (uint)rowIndex }; totalsRow.Append(CreateCell((Resources.Translations.ResourceManager.GetString("PayRuleSetTotalRow") ?? "Total"))); // EmployeeNo column → "Total" label totalsRow.Append(CreateCell(string.Empty)); // Worker + totalsRow.Append(CreateCell(string.Empty)); // Tags totalsRow.Append(CreateCell(string.Empty)); // DayOfWeek totalsRow.Append(CreateCell(string.Empty)); // Date totalsRow.Append(CreateCell(string.Empty)); // Week @@ -2947,12 +2953,40 @@ public async Task> GenerateExcelDashboard(TimePlanni } } - private void FillDataRow(Row dataRow, Worker worker, Microting.eForm.Infrastructure.Data.Entities.Site site, CultureInfo culture, + /// + /// Resolves the tag names of the given sites (identified by their SDK + /// MicrotingUid — the id every export code path already holds). + /// One query over the SDK's SiteTags join table (non-removed rows + /// only, matching the planning-filter idiom) joined with Tags for + /// the names. Result: MicrotingUid → tag names sorted alphabetically + /// (ordinal, case-insensitive) and joined with ", ". Untagged sites are + /// absent from the dictionary. + /// + internal static async Task> GetSiteTagNames( + Microting.eForm.Infrastructure.MicrotingDbContext sdkDbContext, IEnumerable siteMicrotingUids) + { + var uids = siteMicrotingUids.ToList(); + var pairs = await sdkDbContext.SiteTags + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .Where(x => x.Site.MicrotingUid != null && uids.Contains(x.Site.MicrotingUid.Value)) + .Select(x => new { Uid = x.Site.MicrotingUid.Value, x.Tag.Name }) + .ToListAsync(); + + return pairs + .GroupBy(x => x.Uid) + .ToDictionary( + g => g.Key, + g => string.Join(", ", g.Select(t => t.Name) + .OrderBy(n => n, StringComparer.OrdinalIgnoreCase))); + } + + private void FillDataRow(Row dataRow, Worker worker, Microting.eForm.Infrastructure.Data.Entities.Site site, string siteTags, CultureInfo culture, TimePlanningWorkingHoursModel planning, PlanRegistration plr, Language language, bool isThirdShiftEnabled, bool isFourthShiftEnabled, bool isFifthShiftEnabled, bool useOneMinuteIntervals = false) { try { dataRow.Append(CreateCell(worker.EmployeeNo ?? string.Empty)); dataRow.Append(CreateCell(site.Name)); + dataRow.Append(CreateCell(siteTags)); dataRow.Append(CreateCell(planning.Date.ToString("dddd", culture))); dataRow.Append(CreateDateCell(planning.Date)); dataRow.Append(CreateWeekNumberCell(planning.Date)); @@ -3189,6 +3223,9 @@ public async Task> GenerateExcelDashboard( var core = await coreHelper.GetCore(); var sdkContext = core.DbContextHelper.GetDbContext(); + // Tag names per site (sorted, comma-joined), keyed by MicrotingUid — + // one lookup per export, shared by all sheet writers below. + var tagNamesBySiteUid = await GetSiteTagNames(sdkContext, siteIds); Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "results")); var timeStamp = $"{DateTime.UtcNow:yyyyMMdd_HHmmss}"; var resultDocument = Path.Combine(Path.GetTempPath(), "results", $"{timeStamp}_.xlsx"); @@ -3325,6 +3362,7 @@ await OneMinuteModeTimeline.BuildAsync(dbContext, assignedSiteForCache) { EmployeeNo = doWorker.EmployeeNo ?? string.Empty, WorkerName = doSite.Name, + Tags = tagNamesBySiteUid.GetValueOrDefault(siteIds[i], string.Empty), Date = planning.Date, Planning = planning, // Per-row mode at registration: write-time marker @@ -3357,6 +3395,7 @@ await OneMinuteModeTimeline.BuildAsync(dbContext, assignedSiteForCache) Translations.To, Translations.Employee_no, Translations.Worker, + Translations.Tags, Translations.PlanHours, Translations.NettoHours, Translations.SumFlexStart, @@ -3443,12 +3482,14 @@ await OneMinuteModeTimeline.BuildAsync(dbContext, assignedSiteForCache) if (site == null) continue; var siteWorker = await sdkContext.SiteWorkers.FirstAsync(x => x.SiteId == site.Id); var worker = await sdkContext.Workers.FirstAsync(x => x.Id == siteWorker.WorkerId); + var siteTagNames = tagNamesBySiteUid.GetValueOrDefault(siteIds[i], string.Empty); WorksheetPart worksheetPart1 = workbookPart1.AddNewPart($"rId{i + 3}"); var headers = new[] { Translations.Employee_no, Translations.Worker, + Translations.Tags, Translations.DayOfWeek, Translations.Date, Translations.Week_number, @@ -3575,7 +3616,7 @@ await OneMinuteModeTimeline.BuildAsync(dbContext, assignedSiteForCache) var dataRow = new Row() { RowIndex = (uint)rowIndex }; try { - FillDataRow(dataRow, worker, site, culture, planning, plr, language, isThirdShiftEnabled, isFourthShiftEnabled, isFifthShiftEnabled, planning.RegisteredUnderOneMinuteIntervals ?? cache?.OneMinuteTimeline?.WasOneMinuteAt(planning.Date) ?? false); + FillDataRow(dataRow, worker, site, siteTagNames, culture, planning, plr, language, isThirdShiftEnabled, isFourthShiftEnabled, isFifthShiftEnabled, planning.RegisteredUnderOneMinuteIntervals ?? cache?.OneMinuteTimeline?.WasOneMinuteAt(planning.Date) ?? false); // Append pay code values for this day var dayPayLines = (cache != null && cache.PayLinesByDate.ContainsKey(planning.Date)) @@ -3624,6 +3665,7 @@ await OneMinuteModeTimeline.BuildAsync(dbContext, assignedSiteForCache) var siteTotalsRow = new Row { RowIndex = (uint)rowIndex }; siteTotalsRow.Append(CreateCell((Resources.Translations.ResourceManager.GetString("PayRuleSetTotalRow") ?? "Total"))); siteTotalsRow.Append(CreateCell(string.Empty)); // Worker + siteTotalsRow.Append(CreateCell(string.Empty)); // Tags siteTotalsRow.Append(CreateCell(string.Empty)); // DayOfWeek siteTotalsRow.Append(CreateCell(string.Empty)); // Date siteTotalsRow.Append(CreateCell(string.Empty)); // Week @@ -3689,6 +3731,7 @@ await OneMinuteModeTimeline.BuildAsync(dbContext, assignedSiteForCache) totalRow.Append(CreateDateCell(model.DateTo)); totalRow.Append(CreateCell(worker.EmployeeNo ?? string.Empty)); totalRow.Append(CreateCell(site.Name)); + totalRow.Append(CreateCell(siteTagNames)); totalRow.Append(CreateNumericCell(siteTotalPlanHours)); totalRow.Append(CreateNumericCell(siteTotalNettoHours)); totalRow.Append(CreateNumericCell(timePlannings.Count > 0 ? timePlannings.Last().SumFlexEnd : 0.0)); @@ -4384,6 +4427,8 @@ private sealed class DayOverviewRow { public string EmployeeNo { get; set; } public string WorkerName { get; set; } + /// Sorted, comma-joined tag names of the row's site (empty when untagged). + public string Tags { get; set; } = string.Empty; public DateTime Date { get; set; } public TimePlanningWorkingHoursModel Planning { get; set; } public bool UseOneMinuteIntervals { get; set; } @@ -4459,10 +4504,10 @@ private Cell DayOverviewTimeCell(int col, uint rowIdx, double? fraction) private void BuildDayOverviewWorksheet(WorksheetPart worksheetPart, List rows, CultureInfo culture) { - const int colCount = 21; + const int colCount = 22; var headers = new[] { - Translations.Employee_no, Translations.Worker, Translations.DayOfWeek, + Translations.Employee_no, Translations.Worker, Translations.Tags, Translations.DayOfWeek, Translations.Date, Translations.Week_number, Translations.Shift_1__start, Translations.Shift_1__end, Translations.Shift_1__pause, Translations.Shift_2__start, Translations.Shift_2__end, Translations.Shift_2__pause, @@ -4486,11 +4531,12 @@ private void BuildDayOverviewWorksheet(WorksheetPart worksheetPart, List