Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)

Comment on lines +3 to +5
## 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<int> siteMicrotingUids)` →
`Dictionary<int, string>` 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.
Comment on lines +64 to +78

## 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.
Original file line number Diff line number Diff line change
Expand Up @@ -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 <c>ValidateExcel</c> swallows schema
/// errors, so opening/reading the file in a test is the only thing that catches
/// a malformed worksheet or table.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -148,17 +148,18 @@ await SeedSiteAndPlanRegistration(
var firstPart = (WorksheetPart)workbookPart.GetPartById(sheets[0].Id!);
var headerRow = firstPart.Worksheet.Descendants<Row>().First(r => r.RowIndex! == 1U);
var headerCells = headerRow.Elements<Cell>().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<Row>().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}"));
}

// ------------------------------------------------------------------
Expand Down Expand Up @@ -192,21 +193,21 @@ await SeedSiteAndPlanRegistration(

var dataRow = firstPart.Worksheet.Descendants<Row>().First(r => r.RowIndex! == 2U);

// Date cell (col D): StyleIndex 5 (dd/mm/yyyy), numeric OADate.
var dateCell = dataRow.Elements<Cell>().Single(c => c.CellReference == "D2");
// Date cell (col E): StyleIndex 5 (dd/mm/yyyy), numeric OADate.
var dateCell = dataRow.Elements<Cell>().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<Cell>().Single(c => c.CellReference == "F2");
// Shift 1 start cell (col G): StyleIndex 3 (hh:mm), value = (97-1)*5/1440.
var shift1StartCell = dataRow.Elements<Cell>().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<Cell>().Single(c => c.CellReference == "U2");
// NettoHours cell (col V): StyleIndex 4 (0.00).
var nettoCell = dataRow.Elements<Cell>().Single(c => c.CellReference == "V2");
Assert.That(nettoCell.StyleIndex!.Value, Is.EqualTo(4U));
}

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -414,7 +415,7 @@ await SeedSiteAndPlanRegistration(
/// <summary>
/// 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.
/// </summary>
private static (string Start, string Stop) ReadDashboardShift1Cells(Stream xlsx, string sheetName = "Dashboard")
{
Expand All @@ -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<Cell>().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);
Expand All @@ -445,7 +446,7 @@ private static void AssertRowDateAndEmployee(Row row, WorkbookPart wb, double ex
var employeeCell = row.Elements<Cell>().Single(c =>
c.CellReference!.Value!.StartsWith("A"));
var dateCell = row.Elements<Cell>().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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
/// <c>FillDataRow</c> (positional, 0-indexed): 0=EmployeeNo, 1=SiteName,
/// 2=WeekDay, 3=Date, 4=WeekNumber, 5=PlanText, 6=PlanHours, 7=Shift1Start,
/// 8=Shift1Stop, 9=Shift1Pause. <c>CreateCell</c> doesn't set
/// 2=Tags, 3=WeekDay, 4=Date, 5=WeekNumber, 6=PlanText, 7=PlanHours,
/// 8=Shift1Start, 9=Shift1Stop, 10=Shift1Pause. <c>CreateCell</c> doesn't set
/// <c>CellReference</c>, so cells are positional within the row, not
/// addressed by letter.
/// </summary>
Expand Down Expand Up @@ -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<Cell>().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);
Expand Down
Loading
Loading