diff --git a/.github/workflows/dotnet-core-master.yml b/.github/workflows/dotnet-core-master.yml index 21a6d5704..093bc2183 100644 --- a/.github/workflows/dotnet-core-master.yml +++ b/.github/workflows/dotnet-core-master.yml @@ -276,7 +276,7 @@ jobs: - 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|FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceAdminEditNonRoundMinutesTests|FullyQualifiedName=TimePlanning.Pn.Test.CalculatePayLinesForDayTests" - name: e - filter: "FullyQualifiedName=TimePlanning.Pn.Test.PushNotificationIntegrationTests|FullyQualifiedName=TimePlanning.Pn.Test.WorkingHoursDisplayParityTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTimeBandRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperComputationTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperHolidayTests|FullyQualifiedName=TimePlanning.Pn.Test.WorkingHoursExcelExportTagsColumnTests|FullyQualifiedName=TimePlanning.Pn.Test.PauseIdSelfHealGuardTests|FullyQualifiedName=TimePlanning.Pn.Test.FirstUnlockedDateTests" + filter: "FullyQualifiedName=TimePlanning.Pn.Test.PushNotificationIntegrationTests|FullyQualifiedName=TimePlanning.Pn.Test.WorkingHoursDisplayParityTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTimeBandRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperComputationTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperHolidayTests|FullyQualifiedName=TimePlanning.Pn.Test.WorkingHoursExcelExportTagsColumnTests|FullyQualifiedName=TimePlanning.Pn.Test.PauseIdSelfHealGuardTests|FullyQualifiedName=TimePlanning.Pn.Test.FirstUnlockedDateTests|FullyQualifiedName=TimePlanning.Pn.Test.ExportTagFilterAndSiteTagsTests" - name: f filter: "FullyQualifiedName=TimePlanning.Pn.Test.SettingsServiceExtendedTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperDisplayParityTests|FullyQualifiedName=TimePlanning.Pn.Test.OneMinuteModeTimelineTests|FullyQualifiedName=TimePlanning.Pn.Test.OneMinuteIntervalsEffectiveDateTests|FullyQualifiedName=TimePlanning.Pn.Test.RunningFlexChainModeBoundaryTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperReadBySiteAndDateTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.PushNotificationServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.WorkingHoursGrpcKioskNonRoundMinutesTests|FullyQualifiedName=TimePlanning.Pn.Test.ResolveShiftSecondsTests|FullyQualifiedName=TimePlanning.Pn.Test.ShardCoverageTests" - name: g diff --git a/.github/workflows/dotnet-core-pr.yml b/.github/workflows/dotnet-core-pr.yml index b6d906863..e064c77b3 100644 --- a/.github/workflows/dotnet-core-pr.yml +++ b/.github/workflows/dotnet-core-pr.yml @@ -265,7 +265,7 @@ jobs: - 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|FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceAdminEditNonRoundMinutesTests|FullyQualifiedName=TimePlanning.Pn.Test.CalculatePayLinesForDayTests" - name: e - filter: "FullyQualifiedName=TimePlanning.Pn.Test.PushNotificationIntegrationTests|FullyQualifiedName=TimePlanning.Pn.Test.WorkingHoursDisplayParityTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTimeBandRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperComputationTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperHolidayTests|FullyQualifiedName=TimePlanning.Pn.Test.WorkingHoursExcelExportTagsColumnTests|FullyQualifiedName=TimePlanning.Pn.Test.PauseIdSelfHealGuardTests|FullyQualifiedName=TimePlanning.Pn.Test.FirstUnlockedDateTests" + filter: "FullyQualifiedName=TimePlanning.Pn.Test.PushNotificationIntegrationTests|FullyQualifiedName=TimePlanning.Pn.Test.WorkingHoursDisplayParityTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTimeBandRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperComputationTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperHolidayTests|FullyQualifiedName=TimePlanning.Pn.Test.WorkingHoursExcelExportTagsColumnTests|FullyQualifiedName=TimePlanning.Pn.Test.PauseIdSelfHealGuardTests|FullyQualifiedName=TimePlanning.Pn.Test.FirstUnlockedDateTests|FullyQualifiedName=TimePlanning.Pn.Test.ExportTagFilterAndSiteTagsTests" - name: f filter: "FullyQualifiedName=TimePlanning.Pn.Test.SettingsServiceExtendedTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperDisplayParityTests|FullyQualifiedName=TimePlanning.Pn.Test.OneMinuteModeTimelineTests|FullyQualifiedName=TimePlanning.Pn.Test.OneMinuteIntervalsEffectiveDateTests|FullyQualifiedName=TimePlanning.Pn.Test.RunningFlexChainModeBoundaryTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperReadBySiteAndDateTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.PushNotificationServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.WorkingHoursGrpcKioskNonRoundMinutesTests|FullyQualifiedName=TimePlanning.Pn.Test.ResolveShiftSecondsTests|FullyQualifiedName=TimePlanning.Pn.Test.ShardCoverageTests" - name: g diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DagsoversigtWorksheetExportTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DagsoversigtWorksheetExportTests.cs index e10b6b9c7..8a0fd3a5a 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DagsoversigtWorksheetExportTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DagsoversigtWorksheetExportTests.cs @@ -10,6 +10,8 @@ using Microsoft.Extensions.Logging; using Microting.eForm.Infrastructure.Constants; using Microting.eFormApi.BasePn.Abstractions; +using Microting.EformAngularFrontendBase.Infrastructure.Data; +using Microting.eFormApi.BasePn.Infrastructure.Database.Entities; using Microting.eFormApi.BasePn.Infrastructure.Helpers.PluginDbOptions; using Microting.TimePlanningBase.Infrastructure.Data.Entities; using NSubstitute; @@ -76,12 +78,17 @@ public async Task SetUpTest() SnapshotEnabled = "0" }); + // The all-workers export scopes its site list to the signed-in caller, + // so these fixtures need a real one. Admin: scoping is a no-op. + var adminUserId = await GetBaseDbContextWithAdminAsync(); + userService.GetCurrentUserAsync().Returns(new EformUser { Id = adminUserId }); + _service = new TimePlanningWorkingHoursService( Substitute.For>(), TimePlanningPnDbContext!, userService, localizationService, - baseDbContext: null!, + baseDbContext: SeededBaseDbContext!, options, coreService); } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ExportTagFilterAndSiteTagsTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ExportTagFilterAndSiteTagsTests.cs new file mode 100644 index 000000000..16e5fd0df --- /dev/null +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ExportTagFilterAndSiteTagsTests.cs @@ -0,0 +1,736 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Spreadsheet; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; +using Microting.eForm.Infrastructure; +using Microting.eForm.Infrastructure.Constants; +using Microting.eFormApi.BasePn.Abstractions; +using Microting.eFormApi.BasePn.Infrastructure.Database.Entities; +using Microting.eFormApi.BasePn.Infrastructure.Helpers.PluginDbOptions; +using Microting.EformAngularFrontendBase.Infrastructure.Data; +using NSubstitute; +using NUnit.Framework; +using TimePlanning.Pn.Infrastructure.Helpers; +using TimePlanning.Pn.Infrastructure.Models.Settings; +using TimePlanning.Pn.Infrastructure.Models.WorkingHours.Index; +using TimePlanning.Pn.Services.TimePlanningLocalizationService; +using TimePlanning.Pn.Services.TimePlanningPlanningService; +using TimePlanning.Pn.Services.TimePlanningWorkingHoursService; +using AssignedSiteEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.AssignedSite; +using AssignedSiteManagingTagEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.AssignedSiteManagingTag; +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 making the "all workers" Excel export filterable by SDK site +/// tags, and for the site-to-tag lookup the export dialog uses to count how many +/// workers a tag selection covers. +/// +/// Two contracts are under test, and they are the same contract seen from two +/// ends: the export's tag filter and the dialog's count must always agree, or +/// the count stops predicting the file. So a site is in when it carries ANY of +/// the selected tags; an EMPTY selection means no filter at all; a soft-deleted +/// Tag counts for neither; the caller's own site scoping applies to both; and +/// one site is one row no matter how many AssignedSite rows it has. +/// +[TestFixture] +public class ExportTagFilterAndSiteTagsTests : TestBaseSetup +{ + private TimePlanningWorkingHoursService _workingHoursService = null!; + private IUserService _userService = null!; + private IEFormCoreService _coreService = null!; + private ITimePlanningLocalizationService _localizationService = null!; + private IPluginDbOptions _options = null!; + private ITimePlanningDbContextHelper _dbContextHelper = null!; + + [SetUp] + public async Task SetUpTest() + { + await base.Setup(); + + _userService = Substitute.For(); + _userService.UserId.Returns(1); + + _localizationService = Substitute.For(); + _localizationService.GetString(Arg.Any()).Returns(x => x[0]?.ToString()); + + _coreService = Substitute.For(); + var core = await GetCore(); + _coreService.GetCore().Returns(core); + + 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); + + _dbContextHelper = Substitute.For(); + _dbContextHelper.GetDbContext().Returns(TimePlanningPnDbContext); + + _options = Substitute.For>(); + _options.Value.Returns(new TimePlanningBaseSettings + { + AutoBreakCalculationActive = "0", + DayOfPayment = 20, + GpsEnabled = "0", + SnapshotEnabled = "0" + }); + + // Both services scope their result to the signed-in caller, so every + // test needs one. The default is an admin, for whom scoping is a no-op; + // the scoping tests below seed a second, narrower caller into the same + // context and re-point the substitute at it. + var adminUserId = await GetBaseDbContextWithAdminAsync(); + _userService.GetCurrentUserAsync().Returns(new EformUser { Id = adminUserId }); + + _workingHoursService = new TimePlanningWorkingHoursService( + Substitute.For>(), + TimePlanningPnDbContext!, + _userService, + _localizationService, + SeededBaseDbContext!, + _options, + _coreService); + } + + // ------------------------------------------------------------------ + // 0. The integration seam: the client sends the tag selection as + // repeated query keys, and the action takes a plain complex type. + // ------------------------------------------------------------------ + + /// + /// GenerateReportFileByAllWorkers is a GET whose parameter is a plain + /// complex type with no [FromQuery], so MVC binds it from the value + /// providers — the query string. This runs the real + /// ComplexObjectModelBinder over the real model type to pin the part that + /// is easy to get wrong: that `tagIds=1&tagIds=2` becomes a two-element + /// List<int>, and that omitting the key leaves an EMPTY list rather + /// than null. A filter that silently failed to bind would export every + /// worker, which is the worst available failure for an export. + /// + /// + /// That the query string is consulted at all (rather than the body) follows + /// from there being no [ApiController] attribute and no MVC convention + /// overriding binding sources anywhere in the plugin, the host app or + /// BasePn — and from dateFrom/dateTo already binding this way in + /// production today. + /// + [TestCase("1", "2", new[] { 1, 2 }, Description = "repeated keys: tagIds=1&tagIds=2")] + [TestCase("7", null, new[] { 7 }, Description = "one key: tagIds=7")] + public void TagIdsQueryKeys_BindIntoTheAllWorkersRequestModel( + string first, string? second, int[] expected) + { + var values = second == null ? new StringValues(first) : new StringValues([first, second]); + var model = BindAllWorkersRequest(new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["dateFrom"] = "2026-07-01", + ["dateTo"] = "2026-07-31", + ["tagIds"] = values, + }); + + Assert.That(model.TagIds, Is.EqualTo(expected), + "tagIds query keys must bind into List TagIds"); + Assert.That(model.DateFrom, Is.EqualTo(new DateTime(2026, 7, 1)), + "The dates must still bind alongside the new collection property"); + } + + [Test] + public void OmittedTagIds_BindsToAnEmptyList_NotNull() + { + var model = BindAllWorkersRequest(new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["dateFrom"] = "2026-07-01", + ["dateTo"] = "2026-07-31", + }); + + Assert.That(model.TagIds, Is.Not.Null, + "The client omits tagIds when nothing is picked; the service reads .Count on it"); + Assert.That(model.TagIds, Is.Empty, "Omitting tagIds must mean 'no filter', not 'no sites'"); + } + + /// Runs the real MVC binder over the real request model. The + /// dictionary is deliberately case-insensitive, as a genuine + /// HttpRequest.Query is — a case-sensitive one silently binds nothing. + private static TimePlanningWorkingHoursReportForAllWorkersRequestModel BindAllWorkersRequest( + Dictionary query) + { + var provider = new ServiceCollection() + .AddLogging() + .AddMvcCore() + .Services + .BuildServiceProvider(); + + var metadata = provider.GetRequiredService() + .GetMetadataForType(typeof(TimePlanningWorkingHoursReportForAllWorkersRequestModel)); + var binder = provider.GetRequiredService() + .CreateBinder(new ModelBinderFactoryContext { Metadata = metadata }); + + var bindingContext = DefaultModelBindingContext.CreateBindingContext( + new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()), + new QueryStringValueProvider(BindingSource.Query, new QueryCollection(query), CultureInfo.InvariantCulture), + metadata, + bindingInfo: null, + modelName: string.Empty); + + binder.BindModelAsync(bindingContext).GetAwaiter().GetResult(); + + Assert.That(bindingContext.Result.IsModelSet, Is.True); + return (TimePlanningWorkingHoursReportForAllWorkersRequestModel)bindingContext.Result.Model!; + } + + // ------------------------------------------------------------------ + // 1. All-workers export: the tag selection decides which sites are in. + // ------------------------------------------------------------------ + + [Test] + public async Task AllWorkersExport_TagIdsSelected_IncludesOnlySitesCarryingASelectedTag() + { + var date = new DateTime(2026, 7, 20); + await SeedSiteAndPlanRegistration(siteUid: 9631, employeeNo: "1", date: date); + await SeedSiteAndPlanRegistration(siteUid: 9632, employeeNo: "2", date: date); + var elTagId = await TagSiteByUid(9631, "EL"); + await TagSiteByUid(9632, "Brand"); + + var sheetNames = await ExportSheetNames(date, [elTagId]); + + Assert.That(sheetNames, Does.Contain("Site 9631"), + "The site carrying the selected tag must be in the export"); + Assert.That(sheetNames, Does.Not.Contain("Site 9632"), + "A site carrying only a non-selected tag must be filtered out"); + } + + [Test] + public async Task AllWorkersExport_EmptyTagIds_IncludesEverySite() + { + var date = new DateTime(2026, 7, 21); + await SeedSiteAndPlanRegistration(siteUid: 9633, employeeNo: "1", date: date); + await SeedSiteAndPlanRegistration(siteUid: 9634, employeeNo: "2", date: date); + await TagSiteByUid(9633, "EL"); + // Site 9634 stays untagged: an unfiltered export must not silently + // start excluding sites that have no tags at all. + + var sheetNames = await ExportSheetNames(date, []); + + Assert.That(sheetNames, Does.Contain("Site 9633")); + Assert.That(sheetNames, Does.Contain("Site 9634"), + "An empty tag selection means no filter — every site stays in, tagged or not"); + } + + [Test] + public async Task AllWorkersExport_SiteWithSeveralTags_MatchesWhenAnyOneIsSelected() + { + var date = new DateTime(2026, 7, 22); + await SeedSiteAndPlanRegistration(siteUid: 9635, employeeNo: "1", date: date); + await SeedSiteAndPlanRegistration(siteUid: 9636, employeeNo: "2", date: date); + await TagSiteByUid(9635, "EL"); + var brandTagId = await TagSiteByUid(9635, "Brand"); + await TagSiteByUid(9636, "VVS"); + + var sheetNames = await ExportSheetNames(date, [brandTagId]); + + Assert.That(sheetNames, Does.Contain("Site 9635"), + "A multi-tagged site must match on ANY one of its tags, not only on all of them"); + Assert.That(sheetNames, Does.Not.Contain("Site 9636")); + } + + /// + /// The export filter is deliberately stricter than the planning grid's + /// filter it was modelled on: it checks the Tag's workflow state too. Both + /// ends of this feature must agree about a soft-deleted Tag — the dialog's + /// count comes from GetSiteTags, which excludes it (asserted below), so the + /// export must exclude it as well or the count stops predicting the file. + /// + [Test] + public async Task AllWorkersExport_SoftDeletedTag_DoesNotPullItsSiteIntoTheExport() + { + var date = new DateTime(2026, 7, 23); + await SeedSiteAndPlanRegistration(siteUid: 9637, employeeNo: "1", date: date); + await SeedSiteAndPlanRegistration(siteUid: 9638, employeeNo: "2", date: date); + + // The SiteTag row survives; only the Tag itself is soft-deleted. + var (deadTagId, _) = await TagSiteRaw(9637, "Nedlagt"); + await SoftDeleteTagAsync(deadTagId); + var liveTagId = await TagSiteByUid(9638, "EL"); + + // The dead tag id IS in the selection: that is what makes this test + // discriminate on the Tag.WorkflowState predicate rather than merely on + // the tag not being selected. + var sheetNames = await ExportSheetNames(date, [deadTagId, liveTagId]); + + Assert.That(sheetNames, Does.Not.Contain("Site 9637"), + "A soft-deleted Tag must not pull its site into the export, even though the SiteTag row survives"); + Assert.That(sheetNames, Does.Contain("Site 9638")); + } + + /// + /// The export is scoped to the caller by the same rule as the planning board + /// and the dialog's count. Before this, a manager who saw three workers on + /// the page downloaded a workbook containing the whole organisation. + /// + [Test] + public async Task AllWorkersExport_ManagerUser_ContainsOnlyTheirScopedSites() + { + const string email = "manager9681@example.com"; + var date = new DateTime(2026, 7, 24); + var managerAssignedSite = await SeedSiteAndPlanRegistration( + siteUid: 9681, employeeNo: "1", date: date, email: email, isManager: true); + await SeedSiteAndPlanRegistration(siteUid: 9682, employeeNo: "2", date: date); + await SeedSiteAndPlanRegistration(siteUid: 9683, employeeNo: "3", date: date); + + // The manager manages "EL": site 9682 carries it, 9683 does not. + var elTagId = await TagSiteByUid(9682, "EL"); + await TagSiteByUid(9683, "Brand"); + await new AssignedSiteManagingTagEntity + { + AssignedSiteId = managerAssignedSite.Id, + TagId = elTagId, + WorkflowState = Constants.WorkflowStates.Created, + CreatedByUserId = 1, + UpdatedByUserId = 1, + }.Create(TimePlanningPnDbContext!); + + await SeedNonAdminCallerAsync(email); + + // No tag filter: the scoping alone must narrow the workbook. + var sheetNames = await ExportSheetNames(date, []); + + Assert.That(sheetNames, Does.Contain("Site 9681"), "A manager's own site must be in their export"); + Assert.That(sheetNames, Does.Contain("Site 9682"), "A site in a tag the manager manages must be in"); + Assert.That(sheetNames, Does.Not.Contain("Site 9683"), + "A site outside the manager's tags must NOT be in their export, as it is not on their page"); + } + + /// + /// The counterpart, and the one that decides whether scoping is safe to + /// ship: an admin's export must still contain every site. + /// + [Test] + public async Task AllWorkersExport_AdminUser_StillContainsEverySite() + { + var date = new DateTime(2026, 7, 25); + await SeedSiteAndPlanRegistration(siteUid: 9691, employeeNo: "1", date: date); + await SeedSiteAndPlanRegistration(siteUid: 9692, employeeNo: "2", date: date); + // A manager elsewhere in the system must not narrow an ADMIN's export. + await SeedSiteAndPlanRegistration( + siteUid: 9693, employeeNo: "3", date: date, email: "other@example.com", isManager: true); + + // The caller stays the admin seeded in SetUp. + var sheetNames = await ExportSheetNames(date, []); + + Assert.That(sheetNames, Does.Contain("Site 9691")); + Assert.That(sheetNames, Does.Contain("Site 9692")); + Assert.That(sheetNames, Does.Contain("Site 9693"), + "Scoping must be a no-op for an admin — every site stays in the workbook"); + } + + // ------------------------------------------------------------------ + // 2. site-tags lookup: one row per SiteId, scoped to the caller. + // ------------------------------------------------------------------ + + [Test] + public async Task GetSiteTags_ReturnsTagIdsPerAssignedSite_WithResignedFlag() + { + // One site per case, so a failure names the rule that broke. + foreach (var uid in new[] { 9641, 9642, 9643, 9644, 9645, 9646 }) + { + await CreateSdkSite(uid); + } + + await CreateAssignedSite(9641, resigned: false); + await CreateAssignedSite(9642, resigned: true); + await CreateAssignedSite(9643, resigned: false); + await CreateAssignedSite(9645, resigned: false); + await CreateAssignedSite(9646, resigned: false); + var removedAssignedSite = await CreateAssignedSite(9644, resigned: false); + await removedAssignedSite.Delete(TimePlanningPnDbContext!); + + var elTagId = await TagSiteByUid(9641, "EL"); + var brandTagId = await TagSiteByUid(9641, "Brand"); + var vvsTagId = await TagSiteByUid(9642, "VVS"); + // 9643 stays untagged. 9644 is absent for its own reason (removed + // AssignedSite) and needs no tag — it previously carried a removed + // SiteTag that proved nothing, because 9644 never reaches the result. + // 9645: the SiteTag row survives, the Tag is soft-deleted. + var (deadTagId, _) = await TagSiteRaw(9645, "Nedlagt"); + await SoftDeleteTagAsync(deadTagId); + // 9646: the mirror image — the Tag lives, the SiteTag row is removed. + var (_, removedSiteTagId) = await TagSiteRaw(9646, "Afkoblet"); + await SoftDeleteSiteTagAsync(removedSiteTagId); + + var service = BuildPlanningService(); + + var result = await service.GetSiteTags(); + + Assert.That(result.Success, Is.True, result.Message); + var rows = result.Model!; + Assert.That(rows.Select(x => x.SiteId), Is.EquivalentTo(new[] { 9641, 9642, 9643, 9645, 9646 }), + "One row per non-removed AssignedSite — removed rows must not be listed"); + + var tagged = rows.Single(x => x.SiteId == 9641); + Assert.That(tagged.TagIds, Is.EquivalentTo(new[] { elTagId, brandTagId })); + Assert.That(tagged.Resigned, Is.False); + + var resigned = rows.Single(x => x.SiteId == 9642); + Assert.That(resigned.TagIds, Is.EquivalentTo(new[] { vvsTagId })); + Assert.That(resigned.Resigned, Is.True, + "Resigned must be reported so a count can drop workers the board hides by default"); + + var untagged = rows.Single(x => x.SiteId == 9643); + Assert.That(untagged.TagIds, Is.Empty, "An untagged site must come back with an empty list, not be omitted"); + + var softDeletedTagOnly = rows.Single(x => x.SiteId == 9645); + Assert.That(softDeletedTagOnly.TagIds, Is.Empty, + "A soft-deleted Tag must not be reported, so the dialog's count matches the export's filter"); + + var removedSiteTagOnly = rows.Single(x => x.SiteId == 9646); + Assert.That(removedSiteTagOnly.TagIds, Is.Empty, + "A removed SiteTag row must not be reported even though its Tag is still live"); + } + + /// + /// A SiteId with two non-removed AssignedSite rows is still ONE worker. Per + /// AssignedSite row, the dialog would count that worker twice and promise an + /// export bigger than the one it produces (the export takes SiteIds + /// Distinct()). + /// + [Test] + public async Task GetSiteTags_DuplicateAssignedSiteRowsForOneSite_CollapseIntoOneRow() + { + await CreateSdkSite(9651); + // Both rows resigned=false on purpose for the second site below; here the + // duplicate pair disagrees, which is the case that decides the flag. + await CreateAssignedSite(9651, resigned: true); + await CreateAssignedSite(9651, resigned: false); + + var elTagId = await TagSiteByUid(9651, "EL"); + + var service = BuildPlanningService(); + + var result = await service.GetSiteTags(); + + Assert.That(result.Success, Is.True, result.Message); + var rows = result.Model!; + Assert.That(rows.Count(x => x.SiteId == 9651), Is.EqualTo(1), + "Two AssignedSite rows for one SiteId must collapse to a single row, or the worker is counted twice"); + + var row = rows.Single(x => x.SiteId == 9651); + Assert.That(row.TagIds, Is.EquivalentTo(new[] { elTagId }), + "The merged row must still carry the site's tags"); + Assert.That(row.Resigned, Is.False, + "A site with one active row is active — the export keeps it, so the count must too"); + } + + // ------------------------------------------------------------------ + // 3. Caller scoping: the count must never exceed what the page shows. + // ------------------------------------------------------------------ + + /// + /// A non-admin whose own AssignedSite is not a manager sees only themselves + /// on the planning board. Unscoped, the dialog would promise a whole- + /// organisation export to a worker who can see exactly one row. + /// + [Test] + public async Task GetSiteTags_PlainWorker_ReturnsOnlyTheirOwnSite() + { + const string email = "worker9661@example.com"; + await CreateSdkSite(9661); + await CreateSdkSite(9662); + await CreateAssignedSite(9661, resigned: false); + await CreateAssignedSite(9662, resigned: false); + await LinkWorkerToSite(9661, email, employeeNo: "1"); + await TagSiteByUid(9661, "EL"); + await TagSiteByUid(9662, "Brand"); + + await SeedNonAdminCallerAsync(email); + var service = BuildPlanningService(); + + var result = await service.GetSiteTags(); + + Assert.That(result.Success, Is.True, result.Message); + Assert.That(result.Model!.Select(x => x.SiteId), Is.EquivalentTo(new[] { 9661 }), + "A non-manager worker must be scoped to their own site, exactly as the planning board scopes them"); + } + + /// + /// A manager sees the sites carrying the tags they manage, plus their own — + /// the same rule Index applies, from the same code. + /// + [Test] + public async Task GetSiteTags_ManagerUser_ReturnsOnlySitesInTheirManagedTags() + { + const string email = "manager9671@example.com"; + await CreateSdkSite(9671); + await CreateSdkSite(9672); + await CreateSdkSite(9673); + var managerAssignedSite = await CreateAssignedSite(9671, resigned: false, isManager: true); + await CreateAssignedSite(9672, resigned: false); + await CreateAssignedSite(9673, resigned: false); + await LinkWorkerToSite(9671, email, employeeNo: "1"); + + // The manager manages "EL": site 9672 carries it, 9673 does not. + var elTagId = await TagSiteByUid(9672, "EL"); + await TagSiteByUid(9673, "Brand"); + await new AssignedSiteManagingTagEntity + { + AssignedSiteId = managerAssignedSite.Id, + TagId = elTagId, + WorkflowState = Constants.WorkflowStates.Created, + CreatedByUserId = 1, + UpdatedByUserId = 1, + }.Create(TimePlanningPnDbContext!); + + await SeedNonAdminCallerAsync(email); + var service = BuildPlanningService(); + + var result = await service.GetSiteTags(); + + Assert.That(result.Success, Is.True, result.Message); + Assert.That(result.Model!.Select(x => x.SiteId), Is.EquivalentTo(new[] { 9671, 9672 }), + "A manager sees the sites in their managed tags plus their own — and nothing else"); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + /// Seeds a second, non-admin eform user into the fixture's + /// BaseDbContext and points the IUserService substitute at it, so the next + /// service call resolves that caller instead of the default admin. Whether + /// they read as a plain worker or a manager is decided by their own + /// AssignedSite.IsManager, not here. + private async Task SeedNonAdminCallerAsync(string email) + { + var user = new EformUser + { + UserName = email, + Email = email, + FirstName = "Test", + LastName = "User" + }; + var baseDb = SeededBaseDbContext!; + baseDb.Users.Add(user); + await baseDb.SaveChangesAsync(); + + _userService.UserId.Returns(user.Id); + _userService.GetCurrentUserAsync().Returns(new EformUser { Id = user.Id }); + } + + private ITimePlanningPlanningService BuildPlanningService() => + new TimePlanningPlanningService( + Substitute.For>(), + _options, + TimePlanningPnDbContext!, + _dbContextHelper, + _userService, + _localizationService, + SeededBaseDbContext!, + _coreService); + + /// Runs the all-workers export for a single day and returns the + /// names of the sheets it produced. Per-site tabs are named after the site, + /// so the sheet list IS the list of sites that survived the tag filter. + private async Task> ExportSheetNames(DateTime date, List tagIds) + { + var result = await _workingHoursService.GenerateExcelDashboard( + new TimePlanningWorkingHoursReportForAllWorkersRequestModel + { + DateFrom = date, + DateTo = date, + TagIds = tagIds, + }); + + 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); + return doc.WorkbookPart!.Workbook.Descendants() + .Select(s => s.Name!.Value!) + .ToList(); + } + finally + { + await result.Model!.DisposeAsync(); + } + } + + private async Task CreateSdkSite(int siteUid) + { + await using var sdkDb = await SdkDbContext(); + var site = new SdkSite { Name = $"Site {siteUid}", MicrotingUid = siteUid }; + await site.Create(sdkDb); + return site; + } + + private async Task CreateAssignedSite( + int siteUid, bool resigned, bool isManager = false) + { + var assignedSite = new AssignedSiteEntity + { + SiteId = siteUid, + UseOneMinuteIntervals = false, + Resigned = resigned, + IsManager = isManager, + WorkflowState = Constants.WorkflowStates.Created, + CreatedByUserId = 1, + UpdatedByUserId = 1, + }; + await assignedSite.Create(TimePlanningPnDbContext!); + return assignedSite; + } + + /// Creates the SDK Worker/SiteWorker pair that lets the service + /// resolve a signed-in user (matched by email) to a site, and that the + /// exports read the worker name and employee number from. + private async Task LinkWorkerToSite(int siteUid, string email, string employeeNo) + { + await using var sdkDb = await SdkDbContext(); + var site = await sdkDb.Sites.FirstAsync(x => x.MicrotingUid == siteUid); + + var worker = new SdkWorker + { + FirstName = "Test", + LastName = "Worker", + Email = email, + MicrotingUid = 3000 + siteUid, + EmployeeNo = employeeNo, + }; + await worker.Create(sdkDb); + + await new SdkSiteWorker + { + SiteId = site.Id, + WorkerId = worker.Id, + MicrotingUid = 4000 + siteUid, + }.Create(sdkDb); + } + + /// Tags the site with the given MicrotingUid and returns the Tag id + /// — the value the API takes in TagIds. + private async Task TagSiteByUid(int siteUid, string tagName) + { + var (tagId, _) = await TagSiteRaw(siteUid, tagName); + return tagId; + } + + /// Creates a Tag and links it to the site; returns both IDs so a + /// test can soft-delete either end of the edge independently. Deliberately + /// returns ids rather than entities — see + /// for why handing a caller a detached entity to delete is a trap. + private async Task<(int TagId, int SiteTagId)> TagSiteRaw(int siteUid, string tagName) + { + await using var sdkDb = await SdkDbContext(); + var site = await sdkDb.Sites.FirstAsync(x => x.MicrotingUid == siteUid); + 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 (tag.Id, siteTag.Id); + } + + /// A NEW SDK context — DbContextHelper.GetDbContext() builds one + /// per call, it never hands back a shared instance. + private async Task SdkDbContext() => + (await GetCore()).DbContextHelper.GetDbContext(); + + /// + /// Soft-deletes an SDK Tag, and proves the row actually reached "removed". + /// + /// + /// PnBase.Delete sets WorkflowState on the in-memory entity and then saves + /// ONLY if the context it was handed reports ChangeTracker.HasChanges(). + /// Since every GetDbContext() call returns a fresh context, deleting an + /// entity that a DIFFERENT context created is a SILENT no-op: the row stays + /// live, no exception, and the test then asserts against a tag that was + /// never removed. That is exactly what made these tests fail in CI. Reading + /// the row back through the context we delete on guarantees it is tracked, + /// and the verification read — from a separate context, so it cannot be + /// answered out of the change tracker — turns any future regression into a + /// failure at the seeding step rather than a misleading one at the assert. + /// + private async Task SoftDeleteTagAsync(int tagId) + { + await using (var sdkDb = await SdkDbContext()) + { + var tag = await sdkDb.Tags.FirstAsync(x => x.Id == tagId); + await tag.Delete(sdkDb); + } + + await using var verifyDb = await SdkDbContext(); + var persisted = await verifyDb.Tags.AsNoTracking().FirstAsync(x => x.Id == tagId); + Assert.That(persisted.WorkflowState, Is.EqualTo(Constants.WorkflowStates.Removed), + "Seeding precondition: the Tag must really be soft-deleted in the database"); + } + + /// Soft-deletes an SDK SiteTag row, leaving its Tag alone. Same + /// tracking trap as . + private async Task SoftDeleteSiteTagAsync(int siteTagId) + { + await using (var sdkDb = await SdkDbContext()) + { + var siteTag = await sdkDb.SiteTags.FirstAsync(x => x.Id == siteTagId); + await siteTag.Delete(sdkDb); + } + + await using var verifyDb = await SdkDbContext(); + var persisted = await verifyDb.SiteTags.AsNoTracking().FirstAsync(x => x.Id == siteTagId); + Assert.That(persisted.WorkflowState, Is.EqualTo(Constants.WorkflowStates.Removed), + "Seeding precondition: the SiteTag must really be soft-deleted in the database"); + } + + /// Seeds SDK Site/Worker/SiteWorker + AssignedSite + a prior-day + /// registration (dropped via the export's Skip(1)) + a registration on + /// . Returns the AssignedSite so a caller can hang + /// managing tags off it. + private async Task SeedSiteAndPlanRegistration( + int siteUid, string employeeNo, DateTime date, string? email = null, bool isManager = false) + { + await CreateSdkSite(siteUid); + await LinkWorkerToSite(siteUid, email ?? $"test{siteUid}@example.com", employeeNo); + var assignedSite = await CreateAssignedSite(siteUid, resigned: false, isManager: isManager); + + foreach (var d in new[] { date.AddDays(-1), date }) + { + await new PlanRegistrationEntity + { + SdkSitId = siteUid, + Date = d, + Start1Id = d == date ? 97 : 0, + Stop1Id = d == date ? 121 : 0, + Pause1Id = 0, + PlanText = "", + CommentOffice = "", + CommentOfficeAll = "", + WorkflowState = Constants.WorkflowStates.Created, + CreatedByUserId = 1, + UpdatedByUserId = 1, + }.Create(TimePlanningPnDbContext!); + } + + return assignedSite; + } +} diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs index adafa976e..6e0a0b3f9 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs @@ -5,6 +5,7 @@ using eFormCore; using Microsoft.EntityFrameworkCore; using Microting.eForm.Infrastructure; +using Microting.eFormApi.BasePn.Infrastructure.Database.Entities; using Microting.EformAngularFrontendBase.Infrastructure.Data; using Microting.TimePlanningBase.Infrastructure.Data; using NUnit.Framework; @@ -103,6 +104,46 @@ protected BaseDbContext GetBaseDbContext() return baseDbContext; } + /// + /// The context built by , held + /// here so disposes it for every fixture instead of + /// each one repeating the same teardown. + /// + protected BaseDbContext? SeededBaseDbContext; + + /// + /// Seeds a holding one admin user and + /// returns that user's id. Services that scope their result to the signed-in + /// caller — the planning board, the site-tags lookup, the all-workers + /// export — need a real caller to resolve; this is the admin caller, for + /// whom scoping is a no-op. Point the IUserService substitute's + /// GetCurrentUserAsync at the returned id. + /// + protected async Task GetBaseDbContextWithAdminAsync(string email = "admin@example.com") + { + var db = GetBaseDbContext(); + SeededBaseDbContext = db; + + var role = new EformRole { Name = "admin", NormalizedName = "ADMIN" }; + db.Roles.Add(role); + await db.SaveChangesAsync(); + + var user = new EformUser + { + UserName = email, + Email = email, + FirstName = "Admin", + LastName = "User" + }; + db.Users.Add(user); + await db.SaveChangesAsync(); + + db.UserRoles.Add(new EformUserRole { UserId = user.Id, RoleId = role.Id }); + await db.SaveChangesAsync(); + + return user.Id; + } + /// /// The connection string of the plugin database /// migrates, for tests that must build a context the way production does @@ -231,6 +272,13 @@ public async Task OneTimeTearDown() [TearDown] public async Task TearDown() { + // Null unless this test seeded a caller; disposing blind would mask a + // SetUp failure with an NRE from TearDown. + if (SeededBaseDbContext != null) + { + await SeededBaseDbContext.DisposeAsync(); + SeededBaseDbContext = null; + } await TimePlanningPnDbContext!.DisposeAsync(); } } \ No newline at end of file diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursExcelExportTagsColumnTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursExcelExportTagsColumnTests.cs index f889ed7fd..8753e51e9 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursExcelExportTagsColumnTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursExcelExportTagsColumnTests.cs @@ -9,6 +9,8 @@ using Microsoft.Extensions.Logging; using Microting.eForm.Infrastructure.Constants; using Microting.eFormApi.BasePn.Abstractions; +using Microting.EformAngularFrontendBase.Infrastructure.Data; +using Microting.eFormApi.BasePn.Infrastructure.Database.Entities; using Microting.eFormApi.BasePn.Infrastructure.Helpers.PluginDbOptions; using NSubstitute; using NUnit.Framework; @@ -77,12 +79,17 @@ public async Task SetUpTest() SnapshotEnabled = "0" }); + // The all-workers export scopes its site list to the signed-in caller, + // so these fixtures need a real one. Admin: scoping is a no-op. + var adminUserId = await GetBaseDbContextWithAdminAsync(); + userService.GetCurrentUserAsync().Returns(new EformUser { Id = adminUserId }); + _service = new TimePlanningWorkingHoursService( Substitute.For>(), TimePlanningPnDbContext!, userService, localizationService, - baseDbContext: null!, + baseDbContext: SeededBaseDbContext!, options, coreService); } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursExcelHolidayColumnTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursExcelHolidayColumnTests.cs index 9b2cc52bd..3f511a9ed 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursExcelHolidayColumnTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursExcelHolidayColumnTests.cs @@ -9,6 +9,8 @@ using Microsoft.Extensions.Logging; using Microting.eForm.Infrastructure.Constants; using Microting.eFormApi.BasePn.Abstractions; +using Microting.EformAngularFrontendBase.Infrastructure.Data; +using Microting.eFormApi.BasePn.Infrastructure.Database.Entities; using Microting.eFormApi.BasePn.Infrastructure.Helpers.PluginDbOptions; using NSubstitute; using NUnit.Framework; @@ -104,12 +106,17 @@ public async Task SetUpTest() SnapshotEnabled = "0" }); + // The all-workers export scopes its site list to the signed-in caller, + // so these fixtures need a real one. Admin: scoping is a no-op. + var adminUserId = await GetBaseDbContextWithAdminAsync(); + userService.GetCurrentUserAsync().Returns(new EformUser { Id = adminUserId }); + _service = new TimePlanningWorkingHoursService( Substitute.For>(), TimePlanningPnDbContext!, userService, localizationService, - baseDbContext: null!, + baseDbContext: SeededBaseDbContext!, options, coreService); } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursExcelShiftColumnOrderTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursExcelShiftColumnOrderTests.cs index 096a4dfef..8b886e574 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursExcelShiftColumnOrderTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursExcelShiftColumnOrderTests.cs @@ -9,6 +9,8 @@ using Microsoft.Extensions.Logging; using Microting.eForm.Infrastructure.Constants; using Microting.eFormApi.BasePn.Abstractions; +using Microting.EformAngularFrontendBase.Infrastructure.Data; +using Microting.eFormApi.BasePn.Infrastructure.Database.Entities; using Microting.eFormApi.BasePn.Infrastructure.Helpers.PluginDbOptions; using NSubstitute; using NUnit.Framework; @@ -100,12 +102,17 @@ public async Task SetUpTest() SnapshotEnabled = "0" }); + // The all-workers export scopes its site list to the signed-in caller, + // so these fixtures need a real one. Admin: scoping is a no-op. + var adminUserId = await GetBaseDbContextWithAdminAsync(); + userService.GetCurrentUserAsync().Returns(new EformUser { Id = adminUserId }); + _service = new TimePlanningWorkingHoursService( Substitute.For>(), TimePlanningPnDbContext!, userService, localizationService, - baseDbContext: null!, + baseDbContext: SeededBaseDbContext!, options, coreService); } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/TimePlanningPlanningController.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/TimePlanningPlanningController.cs index 4d9b0fe83..dee496863 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/TimePlanningPlanningController.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/TimePlanningPlanningController.cs @@ -73,6 +73,14 @@ public async Task> IndexByCurrent return await _planningService.IndexByCurrentUserName(model, softwareVersion, deviceModel, manufacturer, osVersion); } + [HttpGet] + [Route("site-tags")] + [Authorize] + public async Task>> GetSiteTags() + { + return await _planningService.GetSiteTags(); + } + [HttpGet] [Route("{planRegistrationId}/version-history")] public async Task> GetVersionHistory(int planRegistrationId) diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/SiteScopeResolver.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/SiteScopeResolver.cs new file mode 100644 index 000000000..12ca66b49 --- /dev/null +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/SiteScopeResolver.cs @@ -0,0 +1,238 @@ +/* +The MIT License (MIT) +Copyright (c) 2007 - 2021 Microting A/S +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +#nullable enable +namespace TimePlanning.Pn.Infrastructure.Helpers; + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microting.eForm.Infrastructure; +using Microting.eForm.Infrastructure.Constants; +using Microting.eFormApi.BasePn.Abstractions; +using Microting.EformAngularFrontendBase.Infrastructure.Data; +using Microting.TimePlanningBase.Infrastructure.Data; +using Microting.TimePlanningBase.Infrastructure.Data.Entities; +using Sentry; + +/// +/// The set of AssignedSites a signed-in caller may see. +/// +/// +/// Built only through the three factories, so every call site reads as its +/// outcome rather than as four positional arguments. is +/// kept separate from so a worker whose site +/// carries no MicrotingUid still reads as restricted instead of unrestricted. +/// +public sealed class SiteScope +{ + private SiteScope( + List assignedSites, bool restrictToOwnSite, int? ownSiteId, string? errorKey) + { + AssignedSites = assignedSites; + RestrictToOwnSite = restrictToOwnSite; + OwnSiteId = ownSiteId; + ErrorKey = errorKey; + } + + /// The unnarrowed pool. Private on purpose: reading it instead of + /// silently skips the own-site restriction, which + /// is how a restricted worker ends up seeing the whole board. + private List AssignedSites { get; } + + public bool RestrictToOwnSite { get; } + + public int? OwnSiteId { get; } + + /// Localization key of the failure, or null when the scope resolved. + /// Callers must check this before reading anything else. + public string? ErrorKey { get; } + + /// The caller could not be resolved; no scope exists. + public static SiteScope Failed(string errorKey) => new([], false, null, errorKey); + + /// These sites, with no further narrowing — an admin (every site) + /// or a manager (the sites of the tags they manage, plus their own). + public static SiteScope Unrestricted(List assignedSites) => + new(assignedSites, false, null, null); + + /// A plain worker: only their own site, named by + /// . + public static SiteScope OwnSiteOnly(List assignedSites, int? ownSiteId) => + new(assignedSites, true, ownSiteId, null); + + /// The AssignedSites this caller may see, with the own-site + /// restriction applied. A restricted worker whose site carries no + /// MicrotingUid narrows to nothing rather than to everything: showing too + /// little is a far cheaper failure than showing the whole organisation. + public List ScopedSites => + RestrictToOwnSite + ? AssignedSites.Where(x => x.SiteId == OwnSiteId).ToList() + : AssignedSites; + + /// Narrows a list of site MicrotingUids to this scope, preserving + /// the caller's order. An unrestricted scope whose AssignedSites cover the + /// input returns it unchanged, which is what keeps an admin's output + /// byte-identical to an unscoped query. + public List Narrow(List siteIds) + { + if (RestrictToOwnSite) + { + // Null OwnSiteId narrows to nothing — see ScopedSites. + return siteIds.Where(x => x == OwnSiteId).ToList(); + } + + var allowed = AssignedSites.Select(x => x.SiteId).ToHashSet(); + return siteIds.Where(allowed.Contains).ToList(); + } +} + +/// +/// Resolves which sites the signed-in caller may see on the planning board. +/// +/// +/// Lives here, rather than on either service, because BOTH the planning board +/// and the all-workers export must apply it and must apply the SAME one: the +/// export dialog's worker count is only meaningful if it cannot exceed what the +/// page shows and what the export produces, and two copies of this non-trivial +/// rule would drift apart. +/// +public static class SiteScopeResolver +{ + /// Every non-removed AssignedSite — the pool to + /// narrow. The manager branch narrows this list; the restricted branch + /// reports its site instead, because the callers apply it differently. + public static async Task ResolveForCurrentUserAsync( + List assignedSites, + TimePlanningPnDbContext dbContext, + MicrotingDbContext sdkDbContext, + BaseDbContext baseDbContext, + IUserService userService) + { + var currentUserAsync = await userService.GetCurrentUserAsync(); + if (currentUserAsync == null) + { + return SiteScope.Failed("UserNotFound"); + } + + var currentUser = baseDbContext.Users + .Include(x => x.UserRoles) + .ThenInclude(x => x.Role) + .Single(x => x.Id == currentUserAsync.Id); + + var isAdmin = currentUser.UserRoles + .Any(x => x.Role.Name == "admin"); + if (!isAdmin) + { + var userSecurityGroups = baseDbContext.SecurityGroupUsers + .Include(x => x.SecurityGroup) + .Where(x => x.EformUserId == currentUser.Id) + .ToList(); + var eFormAdminsGroup = userSecurityGroups + .Any(x => x.SecurityGroup.Name == "eForm admins"); + isAdmin = eFormAdminsGroup; + if (!isAdmin) + { + var isEformUsersGroup = userSecurityGroups + .Any(x => x.SecurityGroup.Name == "eForm users"); + var isKunTidGroup = userSecurityGroups + .Any(x => x.SecurityGroup.Name == "Kun tid"); + if (isEformUsersGroup && !isKunTidGroup) + { + // Fallback: when no user in the system is configured as a manager, + // grant "eForm users" members the admin-for-visibility view on this + // endpoint so the planning dashboard isn't empty in that degenerate + // state. Users also in "Kun tid" (time-registration device users with + // WebAccess) are explicitly excluded and stay restricted to own site. + var anyManagerExists = await dbContext.AssignedSites + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .AnyAsync(x => x.IsManager) + .ConfigureAwait(false); + if (!anyManagerExists) + { + isAdmin = true; + } + } + } + } + + if (isAdmin) + { + return SiteScope.Unrestricted(assignedSites); + } + + var worker = await sdkDbContext.Workers + .Include(x => x.SiteWorkers) + .ThenInclude(x => x.Site) + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .FirstOrDefaultAsync(x => x.Email == currentUser.Email); + + if (worker == null) + { + SentrySdk.CaptureMessage($"Worker with email {currentUser.Email} not found"); + return SiteScope.Failed("ErrorWhileObtainingPlannings"); + } + + // Deterministically resolve the active site (excludes removed + // SiteWorker/Site rows). No active site -> same error path as a + // missing worker (previously NRE'd on empty SiteWorkers). + var site = worker!.ResolveActiveSite(); + if (site == null) + { + SentrySdk.CaptureMessage($"No active site for worker with email {currentUser.Email}"); + return SiteScope.Failed("ErrorWhileObtainingPlannings"); + } + + var assignedSite = assignedSites + .FirstOrDefault(x => x.SiteId == site.MicrotingUid); + if (assignedSite == null || !assignedSite.IsManager) + { + return SiteScope.OwnSiteOnly(assignedSites, site.MicrotingUid); + } + + var assignedSiteTags = await dbContext.AssignedSiteManagingTags + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .Where(x => x.AssignedSiteId == assignedSite.Id) + .Select(x => x.TagId) + .ToListAsync(); + var assignedSiteIdsWithTags = await sdkDbContext.SiteTags + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .Where(x => x.TagId != null && assignedSiteTags.Contains(x.TagId.Value)) + .Where(x => x.Site.MicrotingUid != null) + .Select(x => x.Site.MicrotingUid!.Value) + .Distinct() + .ToListAsync(); + var managedSites = assignedSites + .Where(x => assignedSiteIdsWithTags.Contains(x.SiteId)) + .ToList(); + // Only re-add the manager's own AssignedSite if the manager-tag + // filter dropped it. When the manager is in their own managed + // tag (SiteTag joins the manager's own SiteId to that TagId), + // it is already present and a blind Add() produced two rows + // for the manager on the planning page. + if (managedSites.All(x => x.Id != assignedSite.Id)) + { + managedSites.Add(assignedSite); + } + + return SiteScope.Unrestricted(managedSites); + } +} diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/SiteTagsModel.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/SiteTagsModel.cs new file mode 100644 index 000000000..7e7520e6f --- /dev/null +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/SiteTagsModel.cs @@ -0,0 +1,44 @@ +/* +The MIT License (MIT) +Copyright (c) 2007 - 2021 Microting A/S +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +#nullable enable +namespace TimePlanning.Pn.Infrastructure.Models.Planning; + +using System.Collections.Generic; + +/// +/// The site-to-tag edges of the planning board, one row per non-removed +/// AssignedSite. Deliberately date-free and free of per-day work: it exists so a +/// dialog can answer "how many workers does this tag selection cover?" with one +/// cheap call when it opens, instead of re-running the planning query. +/// +public class SiteTagsModel +{ + /// The site's SDK MicrotingUid — the same identifier + /// AssignedSite.SiteId and the planning grid rows carry. + public int SiteId { get; set; } + + /// Ids of the non-removed SDK tags on the site; empty when untagged. + public List TagIds { get; set; } = new(); + + /// Resigned workers are hidden by default on the board and excluded + /// from the all-workers export, so a count must be able to drop them. + public bool Resigned { get; set; } +} diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/WorkingHours/Index/TimePlanningWorkingHoursReportForAllWorkersRequestModel.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/WorkingHours/Index/TimePlanningWorkingHoursReportForAllWorkersRequestModel.cs index ce8cc0431..a0582a909 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/WorkingHours/Index/TimePlanningWorkingHoursReportForAllWorkersRequestModel.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/WorkingHours/Index/TimePlanningWorkingHoursReportForAllWorkersRequestModel.cs @@ -21,10 +21,16 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE namespace TimePlanning.Pn.Infrastructure.Models.WorkingHours.Index; using System; +using System.Collections.Generic; public class TimePlanningWorkingHoursReportForAllWorkersRequestModel { public DateTime DateFrom { get; set; } public DateTime DateTo { get; set; } + + /// SDK site tags to narrow the export to. Empty means no filter — + /// the same contract the planning grid's Etiketter filter uses, so an export + /// started from an unfiltered grid still covers every worker. + public List TagIds { get; set; } = new(); } \ No newline at end of file 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 f902015f8..e067c7859 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/ITimePlanningPlanningService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/ITimePlanningPlanningService.cs @@ -44,4 +44,6 @@ public interface ITimePlanningPlanningService Task Reconcile(int id); Task Unreconcile(int id); Task> ReconcileThrough(ReconcileThroughRequestModel model); + + Task>> GetSiteTags(); } \ 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 a817655a2..e0ec77423 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs @@ -42,6 +42,7 @@ namespace TimePlanning.Pn.Services.TimePlanningPlanningService; using Infrastructure.Models.Planning; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using Microting.eForm.Infrastructure; using Microting.eForm.Infrastructure.Constants; using Microting.eFormApi.BasePn.Abstractions; using Microting.eFormApi.BasePn.Infrastructure.Models.API; @@ -75,113 +76,25 @@ await dbContext.AssignedSites .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) .ToListAsync().ConfigureAwait(false); - var currentUserAsync = await userService.GetCurrentUserAsync(); - if (currentUserAsync == null) + var scope = await SiteScopeResolver + .ResolveForCurrentUserAsync(assignedSites, dbContext, sdkDbContext, baseDbContext, userService) + .ConfigureAwait(false); + if (scope.ErrorKey != null) { return new OperationDataResult>(false, - localizationService.GetString("UserNotFound"), null!); - } - var currentUser = baseDbContext.Users - .Include(x => x.UserRoles) - .ThenInclude(x => x.Role) - .Single(x => x.Id == currentUserAsync.Id); - - var isAdmin = currentUser.UserRoles - .Any(x => x.Role.Name == "admin"); - if (!isAdmin) - { - var userSecurityGroups = baseDbContext.SecurityGroupUsers - .Include(x => x.SecurityGroup) - .Where(x => x.EformUserId == currentUser.Id) - .ToList(); - var eFormAdminsGroup = userSecurityGroups - .Any(x => x.SecurityGroup.Name == "eForm admins"); - isAdmin = eFormAdminsGroup; - if (!isAdmin) - { - var isEformUsersGroup = userSecurityGroups - .Any(x => x.SecurityGroup.Name == "eForm users"); - var isKunTidGroup = userSecurityGroups - .Any(x => x.SecurityGroup.Name == "Kun tid"); - if (isEformUsersGroup && !isKunTidGroup) - { - // Fallback: when no user in the system is configured as a manager, - // grant "eForm users" members the admin-for-visibility view on this - // endpoint so the planning dashboard isn't empty in that degenerate - // state. Users also in "Kun tid" (time-registration device users with - // WebAccess) are explicitly excluded and stay restricted to own site. - var anyManagerExists = await dbContext.AssignedSites - .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) - .AnyAsync(x => x.IsManager) - .ConfigureAwait(false); - if (!anyManagerExists) - { - isAdmin = true; - } - } - } + localizationService.GetString(scope.ErrorKey), null!); } - if (!isAdmin) + assignedSites = scope.ScopedSites; + if (scope.RestrictToOwnSite) { - var worker = await sdkDbContext.Workers - .Include(x => x.SiteWorkers) - .ThenInclude(x => x.Site) - .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) - .FirstOrDefaultAsync(x => x.Email == currentUser.Email); - - if (worker == null) - { - SentrySdk.CaptureMessage($"Worker with email {currentUser.Email} not found"); - return new OperationDataResult>( - false, - localizationService.GetString("ErrorWhileObtainingPlannings")); - } - - // Deterministically resolve the active site (excludes removed - // SiteWorker/Site rows). No active site -> same error path as a - // missing worker (previously NRE'd on empty SiteWorkers). - var site = worker!.ResolveActiveSite(); - if (site == null) - { - SentrySdk.CaptureMessage($"No active site for worker with email {currentUser.Email}"); - return new OperationDataResult>( - false, - localizationService.GetString("ErrorWhileObtainingPlannings")); - } - - var assignedSite = assignedSites - .FirstOrDefault(x => x.SiteId == site.MicrotingUid); - if (assignedSite == null || !assignedSite.IsManager) - { - model.SiteId = site.MicrotingUid; - } else if (assignedSite.IsManager) - { - var assignedSiteTags = await dbContext.AssignedSiteManagingTags - .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) - .Where(x => x.AssignedSiteId == assignedSite.Id) - .Select(x => x.TagId) - .ToListAsync(); - var assignedSiteIdsWithTags = await sdkDbContext.SiteTags - .Include(x => x.Site) - .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) - .Where(x => assignedSiteTags.Contains((int)x.TagId!)) - .Select(x => x.Site.MicrotingUid) - .Distinct() - .ToListAsync(); - assignedSites = assignedSites - .Where(x => assignedSiteIdsWithTags.Contains(x.SiteId)) - .ToList(); - // Only re-add the manager's own AssignedSite if the manager-tag - // filter dropped it. When the manager is in their own managed - // tag (SiteTag joins the manager's own SiteId to that TagId), - // it is already present and a blind Add() produced two rows - // for the manager on the planning page. - if (assignedSites.All(x => x.Id != assignedSite.Id)) - { - assignedSites.Add(assignedSite); - } - } + // Still stamp the request model: the SiteId filter below and the + // per-site work downstream read it. ScopedSites has already + // applied the same narrowing, so this is idempotent for a real + // site id — and it is what keeps a worker whose site has no + // MicrotingUid (OwnSiteId null, so the filter below is skipped) + // from falling through to every site on the board. + model.SiteId = scope.OwnSiteId; } // Defensive dedup: guarantee no duplicate AssignedSite rows reach the @@ -235,18 +148,9 @@ await dbContext.AssignedSites // Batch-resolve SDK site tags for the listed sites (one query, // same shape as the Etiketter filter query above), grouped per // site MicrotingUid. - var siteMicrotingUids = assignedSites.Select(x => x.SiteId).ToList(); - var siteTagRows = await sdkDbContext.SiteTags - .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) - .Where(x => x.Tag.WorkflowState != Constants.WorkflowStates.Removed) - .Where(x => x.Site.MicrotingUid != null && siteMicrotingUids.Contains(x.Site.MicrotingUid.Value)) - .Select(x => new - { - SiteMicrotingUid = x.Site.MicrotingUid!.Value, - x.TagId, - TagName = x.Tag.Name - }) - .ToListAsync().ConfigureAwait(false); + var siteTagRows = await LoadSiteTagRowsAsync( + sdkDbContext, assignedSites.Select(x => x.SiteId).ToList()) + .ConfigureAwait(false); var tagsBySiteUid = siteTagRows .GroupBy(x => x.SiteMicrotingUid) .ToDictionary( @@ -2632,4 +2536,86 @@ public async Task> ReconcileThr localizationService.GetString("ErrorWhileUpdatingPlanning")); } } + + /// + /// The live SDK site-tag edges for the given site MicrotingUids, one row per + /// edge. Shared by the planning board and the site-tags lookup so the two + /// cannot disagree about which edges are live — both drop removed SiteTag + /// rows, removed Tags, and rows whose site or tag id is null. + /// + private static async Task> LoadSiteTagRowsAsync( + MicrotingDbContext sdkDbContext, + List siteMicrotingUids) + { + return await sdkDbContext.SiteTags + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .Where(x => x.Tag.WorkflowState != Constants.WorkflowStates.Removed) + .Where(x => x.TagId != null) + .Where(x => x.Site.MicrotingUid != null && siteMicrotingUids.Contains(x.Site.MicrotingUid.Value)) + .Select(x => new SiteTagRow(x.Site.MicrotingUid!.Value, x.TagId!.Value, x.Tag.Name)) + .ToListAsync().ConfigureAwait(false); + } + + private sealed record SiteTagRow(int SiteMicrotingUid, int TagId, string TagName); + + public async Task>> GetSiteTags() + { + try + { + var sdkCore = await core.GetCore(); + var sdkDbContext = sdkCore.DbContextHelper.GetDbContext(); + + var assignedSites = await dbContext.AssignedSites + .AsNoTracking() + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .ToListAsync().ConfigureAwait(false); + + var scope = await SiteScopeResolver + .ResolveForCurrentUserAsync(assignedSites, dbContext, sdkDbContext, baseDbContext, userService) + .ConfigureAwait(false); + if (scope.ErrorKey != null) + { + return new OperationDataResult>(false, + localizationService.GetString(scope.ErrorKey)); + } + + assignedSites = scope.ScopedSites; + + // One tag query for the whole board — this endpoint is called on + // every dialog open, so it must not grow a per-site query. TagName + // comes along unused; one shared query beats two that can drift. + var tagIdsBySiteUid = (await LoadSiteTagRowsAsync( + sdkDbContext, assignedSites.Select(x => x.SiteId).Distinct().ToList()) + .ConfigureAwait(false)) + .GroupBy(x => x.SiteMicrotingUid) + .ToDictionary(g => g.Key, g => g.Select(x => x.TagId).Distinct().ToList()); + + // One row per SiteId, not per AssignedSite row: a site with two + // non-removed AssignedSite rows is still ONE worker, and counting it + // twice is precisely the error this endpoint exists to prevent. Tag + // ids merge for free — they hang off the SDK site, not off the + // AssignedSite row, so they are already keyed by SiteId. + var result = assignedSites + .GroupBy(x => x.SiteId) + .Select(g => new SiteTagsModel + { + SiteId = g.Key, + TagIds = tagIdsBySiteUid.TryGetValue(g.Key, out var tagIds) ? tagIds : new List(), + // Resigned only when EVERY row for the site is, matching the + // export, which keeps a site as long as one non-resigned row + // qualifies it. + Resigned = g.All(x => x.Resigned) + }) + .ToList(); + + return new OperationDataResult>(true, result); + } + catch (Exception e) + { + SentrySdk.CaptureException(e); + logger.LogError(e, "TimePlanningPlanningService.GetSiteTags failed"); + return new OperationDataResult>(false, + localizationService.GetString("ErrorWhileObtainingPlannings")); + } + } } \ No newline at end of file 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 57e1cf977..b5eb42686 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs @@ -3368,6 +3368,54 @@ public async Task> GenerateExcelDashboard( var core = await coreHelper.GetCore(); var sdkContext = core.DbContextHelper.GetDbContext(); + + // Scope to the caller, from the same code the planning board and the + // export dialog's worker count use. Without this a manager saw "3 + // workers" in the dialog and downloaded the whole organisation — + // and any non-admin could export every worker in the system. + var assignedSites = await dbContext.AssignedSites + .AsNoTracking() + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .ToListAsync(); + var scope = await SiteScopeResolver + .ResolveForCurrentUserAsync(assignedSites, dbContext, sdkContext, baseDbContext, userService) + .ConfigureAwait(false); + if (scope.ErrorKey != null) + { + return new OperationDataResult(false, + localizationService.GetString(scope.ErrorKey)); + } + // Narrowing the ids (rather than rebuilding them from the scoped + // AssignedSites) keeps an admin's workbook byte-identical: every id + // the query above produced is in an admin's scope, and Narrow + // preserves order, so the sheets stay in the order they were. + siteIds = scope.Narrow(siteIds); + + // Etiketter filter, applied before any per-site work: an empty + // selection means "no filter", so an export started from an + // unfiltered grid still covers every worker. + if (model.TagIds is { Count: > 0 }) + { + // Stricter than the planning grid's filter this is modelled on + // (TimePlanningPlanningService.Index), which checks only the + // SiteTag row: a soft-deleted Tag whose SiteTag survives must + // not pull a site in here, because the dialog's worker count + // comes from GetSiteTags, which excludes it. The count is only + // worth showing if it predicts what the export contains. + var sdkSitesWithAnyOfTags = await sdkContext.SiteTags + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .Where(x => x.Tag.WorkflowState != Constants.WorkflowStates.Removed) + .Where(x => x.TagId != null && model.TagIds.Contains(x.TagId.Value)) + .Where(x => x.Site.MicrotingUid != null) + .Select(x => x.Site.MicrotingUid!.Value) + .Distinct() + .ToListAsync(); + + siteIds = siteIds + .Where(sdkSitesWithAnyOfTags.Contains) + .ToList(); + } + // 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); diff --git a/eform-client/playwright/e2e/plugins/time-planning-pn/n/tags-download-excel-modal.spec.ts b/eform-client/playwright/e2e/plugins/time-planning-pn/n/tags-download-excel-modal.spec.ts new file mode 100644 index 000000000..9a790814a --- /dev/null +++ b/eform-client/playwright/e2e/plugins/time-planning-pn/n/tags-download-excel-modal.spec.ts @@ -0,0 +1,124 @@ +import { test, expect, Page } from '@playwright/test'; +import { LoginPage } from '../../../Page objects/Login.page'; + +const BASE_URL = 'http://localhost:4200'; + +// The Download Excel modal opens on whatever the dashboard is showing — same period, +// same tags — and says how much the export covers. Changing a filter inside the modal +// only changes that export, and the count line follows it without a reload. +// Seeded via API: one fresh tag on exactly one worker, so the filtered count is 1. +test.describe.serial('Time Planning - download excel modal inherits the page filters', () => { + const tagName = `ExportTag-${Date.now()}-${Math.random().toString(36).substring(7)}`; + let tagId = 0; + let siteName = ''; + + async function apiHeaders(page: Page): Promise<{ Authorization: string }> { + const res = await page.request.post(`${BASE_URL}/api/auth/token`, { + form: { username: 'admin@admin.com', password: 'secretpassword', grant_type: 'password' }, + }); + const json = await res.json(); + return { Authorization: `Bearer ${json.model.accessToken}` }; + } + + async function openDashboard(page: Page) { + const indexResponse = page.waitForResponse( + r => r.url().includes('/api/time-planning-pn/plannings/index') && r.request().method() === 'POST'); + await page.goto(`${BASE_URL}/plugins/time-planning-pn/planning`); + await indexResponse; + await page.waitForTimeout(1000); + } + + async function openDownloadExcelDialog(page: Page) { + await page.locator('#file-export-excel').click(); + await page.locator('mat-dialog-container').waitFor({ state: 'visible', timeout: 10000 }); + } + + /** "14 workers · 30 days" → 14, whatever the locale calls a worker. */ + async function workersInScope(page: Page): Promise { + const line = page.locator('mat-dialog-container #downloadExcelScope'); + await expect(line).toBeVisible({ timeout: 10000 }); + const text = (await line.textContent()) || ''; + const match = text.match(/\d+/); + expect(match, `a worker count in "${text}"`).toBeTruthy(); + return Number(match![0]); + } + + test.beforeEach(async ({ page }) => { + await page.goto(BASE_URL); + await new LoginPage(page).login(); + await page.waitForTimeout(2000); + }); + + test('seed: a fresh tag on exactly one worker', async ({ page }) => { + test.setTimeout(120000); + const headers = await apiHeaders(page); + + await page.request.post(`${BASE_URL}/api/tags`, { headers, data: { id: 0, name: tagName } }); + const tagsRes = await page.request.get(`${BASE_URL}/api/tags/index`, { headers }); + const tag = ((await tagsRes.json()).model || []).find((t: any) => t.name === tagName); + expect(tag, `tag ${tagName} created`).toBeTruthy(); + tagId = tag.id; + + // The count is only meaningful against several workers, so the seed says so out loud. + const sitesRes = await page.request.get(`${BASE_URL}/api/time-planning-pn/settings/sites`, { headers }); + const tpSites = (await sitesRes.json()).model || []; + expect(tpSites.length, 'more than one assigned site').toBeGreaterThan(1); + siteName = tpSites[0].siteName; + + // Core id differs from the time-planning siteId/uid; SiteModel.tags is a number[]. + const coreRes = await page.request.get(`${BASE_URL}/api/sites/pairing`, { headers }); + const coreSite = ((await coreRes.json()).model || []).find((s: any) => s.siteName === siteName); + expect(coreSite, `core site named ${siteName}`).toBeTruthy(); + const putRes = await page.request.put(`${BASE_URL}/api/sites`, { + headers, + data: { id: coreSite.id, siteName: coreSite.siteName, tags: [...(coreSite.tags || []), tagId] }, + }); + expect(putRes.status()).toBe(200); + }); + + test('opens with the period and the tags the page already has', async ({ page }) => { + test.setTimeout(120000); + await openDashboard(page); + + // Filter the page by the seeded tag first — that is what the modal must inherit. + const filteredIndex = page.waitForRequest( + r => r.url().includes('/api/time-planning-pn/plannings/index') && r.method() === 'POST' + && ((r.postDataJSON()?.tagIds || []) as number[]).includes(tagId)); + await page.locator('#planningTags').click(); + await page.locator('.ng-option').filter({ hasText: tagName }).first().click(); + await filteredIndex; + await page.waitForTimeout(1000); + + const pageRange = page.locator('#workingHoursRange input.workingHoursRange'); + const pageFrom = await pageRange.nth(0).inputValue(); + const pageTo = await pageRange.nth(1).inputValue(); + + await openDownloadExcelDialog(page); + const dialog = page.locator('mat-dialog-container'); + + await expect(dialog.locator('#downloadExcelTags')).toContainText(tagName); + const dialogRange = dialog.locator('#workingHoursRange input.workingHoursRange'); + expect(await dialogRange.nth(0).inputValue()).toBe(pageFrom); + expect(await dialogRange.nth(1).inputValue()).toBe(pageTo); + + // One worker carries the seeded tag. + expect(await workersInScope(page)).toBe(1); + }); + + test('the count follows a tag picked inside the modal', async ({ page }) => { + test.setTimeout(120000); + await openDashboard(page); + await openDownloadExcelDialog(page); + + const unfiltered = await workersInScope(page); + expect(unfiltered).toBeGreaterThan(1); + + await page.locator('mat-dialog-container #downloadExcelTags').click(); + await page.locator('.ng-option').filter({ hasText: tagName }).first().click(); + + // Local recount, no reload: the line is already right on the next paint. + await expect(page.locator('mat-dialog-container #downloadExcelScope')) + .not.toHaveText(new RegExp(`^\\s*${unfiltered}\\D`)); + expect(await workersInScope(page)).toBe(1); + }); +}); diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/download-excel/download-excel-dialog.component.html b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/download-excel/download-excel-dialog.component.html index fd66a947c..165a839ba 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/download-excel/download-excel-dialog.component.html +++ b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/download-excel/download-excel-dialog.component.html @@ -24,6 +24,27 @@ +
+ + {{ 'Tags' | translate }} + + + +
+ {{ 'exportTagsNotUsedForWorker' | translate }} +
+
+ {{ (workerCount === 1 ? 'exportScopeWorker' : 'exportScopeWorkers') | translate:{count: workerCount} }} · {{ (dayCount === 1 ? 'exportScopeDay' : 'exportScopeDays') | translate:{count: dayCount} }} +
+
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/download-excel/download-excel-dialog.component.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/download-excel/download-excel-dialog.component.spec.ts index 304b95895..d0bb91246 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/download-excel/download-excel-dialog.component.spec.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/download-excel/download-excel-dialog.component.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { DownloadExcelDialogComponent } from './download-excel-dialog.component'; +import { DownloadExcelDialogComponent, DownloadExcelDialogData } from './download-excel-dialog.component'; import { MAT_DIALOG_DATA } from '@angular/material/dialog'; -import { TimePlanningPnWorkingHoursService } from '../../../../services'; +import { TimePlanningPnPlanningsService, TimePlanningPnWorkingHoursService } from '../../../../services'; import { ToastrService } from 'ngx-toastr'; import { of, throwError } from 'rxjs'; import { format } from 'date-fns'; @@ -14,7 +14,17 @@ describe('DownloadExcelDialogComponent', () => { let component: DownloadExcelDialogComponent; let fixture: ComponentFixture; let mockWorkingHoursService: jest.Mocked; + let mockPlanningsService: jest.Mocked; let mockToastrService: jest.Mocked; + let dialogData: DownloadExcelDialogData; + + /** Two active workers on tag 1, one on tag 2, and a resigned one that also carries tag 1. */ + const siteTags = [ + { siteId: 11, tagIds: [1], resigned: false }, + { siteId: 12, tagIds: [1, 2], resigned: false }, + { siteId: 13, tagIds: [], resigned: false }, + { siteId: 14, tagIds: [1], resigned: true }, + ]; beforeEach(async () => { // Mock URL.createObjectURL and URL.revokeObjectURL for file-saver @@ -32,18 +42,31 @@ describe('DownloadExcelDialogComponent', () => { downloadReport: jest.fn(), downloadReportAllWorkers: jest.fn(), } as any; + mockPlanningsService = { + getSiteTags: jest.fn().mockReturnValue(of({ success: true, model: siteTags })), + } as any; mockToastrService = { error: jest.fn(), success: jest.fn(), } as any; + dialogData = { + availableSites: [{ siteId: 11, siteName: 'A' }, { siteId: 12, siteName: 'B' }] as any, + availableTags: [{ id: 1, name: 'Tag 1' }, { id: 2, name: 'Tag 2' }], + dateFrom: new Date(2024, 0, 15), + dateTo: new Date(2024, 0, 21), + selectedTagIds: [1], + siteId: null, + }; + await TestBed.configureTestingModule({ declarations: [DownloadExcelDialogComponent], imports: [CommonModule, FormsModule, TranslateModule.forRoot()], schemas: [NO_ERRORS_SCHEMA], providers: [ - { provide: MAT_DIALOG_DATA, useValue: [] }, + { provide: MAT_DIALOG_DATA, useValue: dialogData }, { provide: TimePlanningPnWorkingHoursService, useValue: mockWorkingHoursService }, + { provide: TimePlanningPnPlanningsService, useValue: mockPlanningsService }, { provide: ToastrService, useValue: mockToastrService } ] }).compileComponents(); @@ -56,6 +79,65 @@ describe('DownloadExcelDialogComponent', () => { expect(component).toBeTruthy(); }); + describe('Inherited page filters', () => { + it('should start from the period, tags and worker the page had', () => { + expect(component.dateFrom).toEqual(dialogData.dateFrom); + expect(component.dateTo).toEqual(dialogData.dateTo); + expect(component.selectedTagIds).toEqual([1]); + expect(component.availableTags).toBe(dialogData.availableTags); + }); + + it('should not write changes back to the page state', () => { + component.onTagsChanged([2]); + component.updateDateFrom({ value: new Date(2024, 1, 1) } as any); + + expect(dialogData.selectedTagIds).toEqual([1]); + expect(dialogData.dateFrom).toEqual(new Date(2024, 0, 15)); + }); + }); + + describe('Export scope count', () => { + it('should count the workers carrying any of the selected tags, over an inclusive period', () => { + component.ngOnInit(); + + expect(component.workerCount).toBe(2); // sites 11 and 12 + expect(component.dayCount).toBe(7); + }); + + it('should count every non-resigned worker when no tag is selected', () => { + component.ngOnInit(); + + component.onTagsChanged([]); + + expect(component.workerCount).toBe(3); + }); + + it('should never count a resigned worker, because the export leaves them out', () => { + component.ngOnInit(); + + // Site 14 carries tag 1 and is the only other worker that would qualify. + expect(component.workerCount).toBe(2); + component.onTagsChanged([]); + expect(component.workerCount).toBe(3); + }); + + it('should count a single worker as one, whatever the tags say', () => { + component.ngOnInit(); + + component.onSiteChanged(11); + + expect(component.workerCount).toBe(1); + }); + + it('should hide the count when the tag map cannot be fetched', () => { + mockPlanningsService.getSiteTags.mockReturnValue(throwError(() => new Error('nope'))); + + component.ngOnInit(); + + expect(component.workerCount).toBeNull(); + }); + }); + describe('Site Selection', () => { it('should update siteId when onSiteChanged is called', () => { const testSiteId = 123; @@ -135,10 +217,22 @@ describe('DownloadExcelDialogComponent', () => { expect(mockWorkingHoursService.downloadReportAllWorkers).toHaveBeenCalledWith({ dateFrom: '2024-01-15', - dateTo: '2024-01-21' + dateTo: '2024-01-21', + tagIds: [1] }); }); + it('should leave tagIds out when no tag is selected', () => { + const mockBlob = new Blob(['test'], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + mockWorkingHoursService.downloadReportAllWorkers.mockReturnValue(of(mockBlob)); + component.onTagsChanged([]); + + component.onDownloadExcelReportAllWorkers(); + + const callArgs = mockWorkingHoursService.downloadReportAllWorkers.mock.calls[0][0]; + expect('tagIds' in callArgs).toBe(false); + }); + it('should show error toast when download all workers fails', (done) => { mockWorkingHoursService.downloadReportAllWorkers.mockReturnValue( throwError(() => new Error('Download failed')) diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/download-excel/download-excel-dialog.component.ts b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/download-excel/download-excel-dialog.component.ts index f7c239039..b53b05d6f 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/download-excel/download-excel-dialog.component.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/download-excel/download-excel-dialog.component.ts @@ -5,17 +5,35 @@ import {EMPTY, Subscription} from 'rxjs'; import {MatDatepickerInputEvent} from '@angular/material/datepicker'; import {SiteDto} from 'src/app/common/models'; import { - AssignedSiteModel, + CommonTagModel, + SiteTagsModel, TimePlanningsReportAllWorkersDownloadRequestModel, WorkingHourRequestModel } from 'src/app/plugins/modules/time-planning-pn/models'; -import {format} from 'date-fns'; -import {catchError} from 'rxjs/operators'; +import {differenceInCalendarDays, format} from 'date-fns'; +import {catchError, take} from 'rxjs/operators'; import {saveAs} from 'file-saver'; -import {TimePlanningPnWorkingHoursService} from 'src/app/plugins/modules/time-planning-pn/services'; +import { + TimePlanningPnPlanningsService, + TimePlanningPnWorkingHoursService +} from 'src/app/plugins/modules/time-planning-pn/services'; import {ToastrService} from 'ngx-toastr'; import {MAT_DIALOG_DATA} from '@angular/material/dialog'; +/** + * The filters the planning page has on screen when the dialog is opened. The export + * starts from what the user is looking at; the dialog then works on its own copies, so + * narrowing an export never moves the page behind it. + */ +export interface DownloadExcelDialogData { + availableSites: SiteDto[]; + availableTags: CommonTagModel[]; + dateFrom: Date; + dateTo: Date; + selectedTagIds: number[]; + siteId: number | null; +} + @Component({ selector: 'app-download-excel-dialog', templateUrl: './download-excel-dialog.component.html', @@ -23,37 +41,95 @@ import {MAT_DIALOG_DATA} from '@angular/material/dialog'; standalone: false, }) export class DownloadExcelDialogComponent implements OnInit, OnDestroy { - public availableSites = inject(MAT_DIALOG_DATA); + private data = inject(MAT_DIALOG_DATA); private toastrService = inject(ToastrService); private workingHoursService = inject(TimePlanningPnWorkingHoursService); + private planningsService = inject(TimePlanningPnPlanningsService); - siteId: number; + availableSites: SiteDto[] = this.data.availableSites; + availableTags: CommonTagModel[] = this.data.availableTags; - dateFrom: Date = null; - dateTo: Date = null; - downloadReportSub$: Subscription; + siteId: number = this.data.siteId; + // Copies, not the page's own array and Date instances. See DownloadExcelDialogData. + selectedTagIds: number[] = [...this.data.selectedTagIds]; + dateFrom: Date = this.data.dateFrom ? new Date(this.data.dateFrom) : null; + dateTo: Date = this.data.dateTo ? new Date(this.data.dateTo) : null; + + /** null hides the count line: without the tag map there is no honest number to show. */ + workerCount: number | null = null; + dayCount = 0; - + private siteTags: SiteTagsModel[] | null = null; + downloadReportSub$: Subscription; + getSiteTags$: Subscription; ngOnInit(): void { - // this.getAvailableSites(); - // this.store.dispatch(new PlanningActions.GetPlanning(this.planningId)); + this.recomputeScope(); + // Fetched once for the life of the dialog, so the count follows every tag, period or + // worker change without a round trip. A failure only costs the count line — the + // export itself does not depend on it. + this.getSiteTags$ = this.planningsService.getSiteTags() + .pipe(take(1), catchError(() => EMPTY)) + .subscribe((result) => { + if (result && result.success) { + this.siteTags = result.model || []; + this.recomputeScope(); + } + }); } ngOnDestroy(): void { - // this.store.dispatch(new PlanningActions.ResetPlanning()); + // Closing the dialog abandons both: an export still running would otherwise drop a + // file on the user long after they left, and the tag fetch has nothing left to count. + this.downloadReportSub$?.unsubscribe(); + this.getSiteTags$?.unsubscribe(); } onSiteChanged(siteId: number) { this.siteId = siteId; + this.recomputeScope(); + } + + onTagsChanged(tagIds: number[]) { + this.selectedTagIds = tagIds || []; + this.recomputeScope(); } updateDateFrom(dateFrom: MatDatepickerInputEvent) { this.dateFrom = dateFrom.value; + this.recomputeScope(); } updateDateTo(dateTo: MatDatepickerInputEvent) { this.dateTo = dateTo.value; + this.recomputeScope(); + } + + /** Keeps the "N workers · M days" line in step with the fields above it. */ + private recomputeScope(): void { + this.dayCount = this.dateFrom && this.dateTo + ? Math.max(differenceInCalendarDays(this.dateTo, this.dateFrom) + 1, 0) + : 0; + this.workerCount = this.countWorkers(); + } + + private countWorkers(): number | null { + if (this.siteId) { + // A single worker's report covers that one person, whatever the tags say. + return 1; + } + if (!this.siteTags) { + return null; + } + return this.siteTags.filter(row => + // Never resigned workers, whatever the page's "Show resigned" toggle says: the + // all-workers export leaves them out, and a count that promised them would name + // workers the workbook does not contain. + !row.resigned + // Any-of, the same rule the dashboard's own tag filter applies. + && (this.selectedTagIds.length === 0 + || row.tagIds.some(tagId => this.selectedTagIds.includes(tagId))) + ).length; } onDownloadExcelReport() { @@ -81,6 +157,9 @@ export class DownloadExcelDialogComponent implements OnInit, OnDestroy { dateFrom: format(this.dateFrom, 'yyyy-MM-dd'), dateTo: format(this.dateTo, 'yyyy-MM-dd'), }; + if (this.selectedTagIds.length > 0) { + model.tagIds = this.selectedTagIds; + } this.downloadReportSub$ = this.workingHoursService .downloadReportAllWorkers(model) .pipe(catchError( 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 997628f9c..695fb0aa9 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 @@ -169,14 +169,26 @@ describe('TimePlanningsContainerComponent', () => { }); describe('Dialog', () => { - it('should open download excel dialog with available sites', () => { + it('should open download excel dialog with the filters the page is showing', () => { component.availableSites = [{ id: 1, name: 'Test Site' } as any]; - const mockDialogRef = { afterClosed: () => of(null) }; - mockDialog.open.mockReturnValue(mockDialogRef as any); + component.availableTags = [{ id: 7, name: 'Test Tag' }]; + component.selectedTagIds = [7]; + component.dateFrom = new Date(2024, 0, 15); + component.dateTo = new Date(2024, 0, 21); + component.siteId = 42; component.openDownloadExcelDialog(); - expect(mockDialog.open).toHaveBeenCalled(); + expect(mockDialog.open).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + data: { + availableSites: component.availableSites, + availableTags: component.availableTags, + dateFrom: component.dateFrom, + dateTo: component.dateTo, + selectedTagIds: component.selectedTagIds, + siteId: 42, + }, + })); }); }); @@ -224,6 +236,23 @@ describe('TimePlanningsContainerComponent', () => { expect(component.getPlannings).toHaveBeenCalled(); }); + it('should hold an empty list, never null, when the select clears its last tag', () => { + jest.spyOn(component, 'getPlannings'); + component.selectedTagIds = [1]; + + component.onTagsChanged(null); + + expect(component.selectedTagIds).toEqual([]); + }); + + it('should hold an empty list when the tags call answers without a body', () => { + mockSettingsService.getAvailableTags.mockReturnValue(of({ success: true, model: null } as any)); + + component.ngOnInit(); + + expect(component.availableTags).toEqual([]); + }); + it('should include tagIds in request when tags are selected', () => { component.selectedTagIds = [1, 2]; component.dateFrom = new Date(2024, 0, 15); 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 d34575ccd..aa0651c2d 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 @@ -21,7 +21,9 @@ import {ExcelIcon, iOSIcon, PARSING_DATE_FORMAT} from 'src/app/common/const'; import {Store} from '@ngrx/store'; import {selectCurrentUserLocale, selectCurrentUserIsAdmin, selectCurrentUserIsFirstUser} from 'src/app/state'; import {MatDialog} from '@angular/material/dialog'; -import {DownloadExcelDialogComponent, PayrollExportDialogComponent} from 'src/app/plugins/modules/time-planning-pn/components'; +import { + DownloadExcelDialogComponent, DownloadExcelDialogData, PayrollExportDialogComponent +} from 'src/app/plugins/modules/time-planning-pn/components'; import {MatDatepickerInputEvent} from '@angular/material/datepicker'; import {HelpAudience, HelpEntryId, HelpTourName, HelpUiStrings} from '../../../help/help.model'; import {HelpContentService} from '../../../help/services/help-content.service'; @@ -109,7 +111,9 @@ export class TimePlanningsContainerComponent implements OnInit, OnDestroy { .pipe(take(1)) .subscribe((data) => { if (data && data.success) { - this.availableTags = data.model; + // A success with no body still means "no tags", and everything downstream — + // the filter, the export dialog — is written against a list. + this.availableTags = data.model || []; } }); @@ -487,15 +491,20 @@ export class TimePlanningsContainerComponent implements OnInit, OnDestroy { } openDownloadExcelDialog() { - const dialogRef = this.dialog.open(DownloadExcelDialogComponent, { - width: '600px', - data: this.availableSites, - }); - dialogRef.afterClosed().subscribe((result) => { - // if (result) { - // this.getPlannings(); - // } - }); + // The export opens on what the page is showing. The dialog copies these and + // never writes back, so narrowing an export leaves the page as it was. + const data: DownloadExcelDialogData = { + availableSites: this.availableSites, + availableTags: this.availableTags, + dateFrom: this.dateFrom, + dateTo: this.dateTo, + selectedTagIds: this.selectedTagIds, + siteId: this.siteId, + }; + this.dialog.open(DownloadExcelDialogComponent, { + width: '600px', + data, + }); } openPayrollExportDialog() { @@ -586,7 +595,9 @@ export class TimePlanningsContainerComponent implements OnInit, OnDestroy { } onTagsChanged($event: number[]) { - this.selectedTagIds = $event; + // The select emits null when the last tag is cleared, and "no tags" is an empty + // list everywhere else: the request builder, the row chips, the export dialog. + this.selectedTagIds = $event || []; this.getPlannings(); } 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 98b026067..f1de53eca 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 @@ -495,4 +495,10 @@ export const bgBG = { UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', + // Download Excel dialog: how much the export covers, and why tags are inert for one worker. + exportScopeWorker: '1 worker', + exportScopeWorkers: '{{count}} workers', + exportScopeDay: '1 day', + exportScopeDays: '{{count}} days', + exportTagsNotUsedForWorker: 'Tags are not used when a single worker is selected', }; 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 1396fc7b3..c0d639814 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 @@ -495,4 +495,10 @@ export const csCZ = { UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', + // Download Excel dialog: how much the export covers, and why tags are inert for one worker. + exportScopeWorker: '1 worker', + exportScopeWorkers: '{{count}} workers', + exportScopeDay: '1 day', + exportScopeDays: '{{count}} days', + exportTagsNotUsedForWorker: 'Tags are not used when a single worker is selected', }; 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 a3a53594e..75447c8ca 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 @@ -493,4 +493,10 @@ export const da = { UNLOCK: 'LÅS OP', lockRequestFailed: 'Handlingen kunne ikke gennemføres. Prøv igen.', lockRequestUncertain: 'Der kom ikke noget svar. Tilstanden er usikker, og visningen opdateres.', + // Download Excel dialog: how much the export covers, and why tags are inert for one worker. + exportScopeWorker: '1 medarbejder', + exportScopeWorkers: '{{count}} medarbejdere', + exportScopeDay: '1 dag', + exportScopeDays: '{{count}} dage', + exportTagsNotUsedForWorker: 'Etiketter bruges ikke, når der er valgt én medarbejder', }; 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 5544b9c2b..d9484237a 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 @@ -495,4 +495,10 @@ export const deDE = { UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', + // Download Excel dialog: how much the export covers, and why tags are inert for one worker. + exportScopeWorker: '1 worker', + exportScopeWorkers: '{{count}} workers', + exportScopeDay: '1 day', + exportScopeDays: '{{count}} days', + exportTagsNotUsedForWorker: 'Tags are not used when a single worker is selected', }; 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 ec45c7096..5da193168 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 @@ -495,4 +495,10 @@ export const elGR = { UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', + // Download Excel dialog: how much the export covers, and why tags are inert for one worker. + exportScopeWorker: '1 worker', + exportScopeWorkers: '{{count}} workers', + exportScopeDay: '1 day', + exportScopeDays: '{{count}} days', + exportTagsNotUsedForWorker: 'Tags are not used when a single worker is selected', }; 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 e4f7655b8..000e56249 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 @@ -493,4 +493,10 @@ export const enUS = { UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', + // Download Excel dialog: how much the export covers, and why tags are inert for one worker. + exportScopeWorker: '1 worker', + exportScopeWorkers: '{{count}} workers', + exportScopeDay: '1 day', + exportScopeDays: '{{count}} days', + exportTagsNotUsedForWorker: 'Tags are not used when a single worker is selected', }; 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 c1b7eddc8..0529dde5c 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 @@ -495,4 +495,10 @@ export const esES = { UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', + // Download Excel dialog: how much the export covers, and why tags are inert for one worker. + exportScopeWorker: '1 worker', + exportScopeWorkers: '{{count}} workers', + exportScopeDay: '1 day', + exportScopeDays: '{{count}} days', + exportTagsNotUsedForWorker: 'Tags are not used when a single worker is selected', }; 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 f55e43f58..0e546dae2 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 @@ -495,4 +495,10 @@ export const etET = { UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', + // Download Excel dialog: how much the export covers, and why tags are inert for one worker. + exportScopeWorker: '1 worker', + exportScopeWorkers: '{{count}} workers', + exportScopeDay: '1 day', + exportScopeDays: '{{count}} days', + exportTagsNotUsedForWorker: 'Tags are not used when a single worker is selected', }; 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 6413a5b4c..8648ef4b9 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 @@ -495,4 +495,10 @@ export const fiFI = { UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', + // Download Excel dialog: how much the export covers, and why tags are inert for one worker. + exportScopeWorker: '1 worker', + exportScopeWorkers: '{{count}} workers', + exportScopeDay: '1 day', + exportScopeDays: '{{count}} days', + exportTagsNotUsedForWorker: 'Tags are not used when a single worker is selected', }; 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 a1f779cf2..c3e7a2e0d 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 @@ -495,4 +495,10 @@ export const frFR = { UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', + // Download Excel dialog: how much the export covers, and why tags are inert for one worker. + exportScopeWorker: '1 worker', + exportScopeWorkers: '{{count}} workers', + exportScopeDay: '1 day', + exportScopeDays: '{{count}} days', + exportTagsNotUsedForWorker: 'Tags are not used when a single worker is selected', }; 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 e8f665a0d..da569a62d 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 @@ -495,4 +495,10 @@ export const hrHR = { UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', + // Download Excel dialog: how much the export covers, and why tags are inert for one worker. + exportScopeWorker: '1 worker', + exportScopeWorkers: '{{count}} workers', + exportScopeDay: '1 day', + exportScopeDays: '{{count}} days', + exportTagsNotUsedForWorker: 'Tags are not used when a single worker is selected', }; 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 2ac598ec2..91c201e31 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 @@ -495,4 +495,10 @@ export const huHU = { UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', + // Download Excel dialog: how much the export covers, and why tags are inert for one worker. + exportScopeWorker: '1 worker', + exportScopeWorkers: '{{count}} workers', + exportScopeDay: '1 day', + exportScopeDays: '{{count}} days', + exportTagsNotUsedForWorker: 'Tags are not used when a single worker is selected', }; 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 5b42240d5..3c34bd7e9 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 @@ -495,4 +495,10 @@ export const isIS = { UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', + // Download Excel dialog: how much the export covers, and why tags are inert for one worker. + exportScopeWorker: '1 worker', + exportScopeWorkers: '{{count}} workers', + exportScopeDay: '1 day', + exportScopeDays: '{{count}} days', + exportTagsNotUsedForWorker: 'Tags are not used when a single worker is selected', }; 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 73bab0f3c..13bb8916f 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 @@ -495,4 +495,10 @@ export const itIT = { UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', + // Download Excel dialog: how much the export covers, and why tags are inert for one worker. + exportScopeWorker: '1 worker', + exportScopeWorkers: '{{count}} workers', + exportScopeDay: '1 day', + exportScopeDays: '{{count}} days', + exportTagsNotUsedForWorker: 'Tags are not used when a single worker is selected', }; 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 fbb570b41..d2449a37d 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 @@ -495,4 +495,10 @@ export const ltLT = { UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', + // Download Excel dialog: how much the export covers, and why tags are inert for one worker. + exportScopeWorker: '1 worker', + exportScopeWorkers: '{{count}} workers', + exportScopeDay: '1 day', + exportScopeDays: '{{count}} days', + exportTagsNotUsedForWorker: 'Tags are not used when a single worker is selected', }; 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 00098991b..a00f2faf8 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 @@ -495,4 +495,10 @@ export const lvLV = { UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', + // Download Excel dialog: how much the export covers, and why tags are inert for one worker. + exportScopeWorker: '1 worker', + exportScopeWorkers: '{{count}} workers', + exportScopeDay: '1 day', + exportScopeDays: '{{count}} days', + exportTagsNotUsedForWorker: 'Tags are not used when a single worker is selected', }; 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 d9f9eb6d3..aeb338b24 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 @@ -495,4 +495,10 @@ export const nlNL = { UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', + // Download Excel dialog: how much the export covers, and why tags are inert for one worker. + exportScopeWorker: '1 worker', + exportScopeWorkers: '{{count}} workers', + exportScopeDay: '1 day', + exportScopeDays: '{{count}} days', + exportTagsNotUsedForWorker: 'Tags are not used when a single worker is selected', }; 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 9594cf879..05c0d5a66 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 @@ -495,4 +495,10 @@ export const noNO = { UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', + // Download Excel dialog: how much the export covers, and why tags are inert for one worker. + exportScopeWorker: '1 worker', + exportScopeWorkers: '{{count}} workers', + exportScopeDay: '1 day', + exportScopeDays: '{{count}} days', + exportTagsNotUsedForWorker: 'Tags are not used when a single worker is selected', }; 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 118014f3e..2c1836b7b 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 @@ -495,4 +495,10 @@ export const plPL = { UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', + // Download Excel dialog: how much the export covers, and why tags are inert for one worker. + exportScopeWorker: '1 worker', + exportScopeWorkers: '{{count}} workers', + exportScopeDay: '1 day', + exportScopeDays: '{{count}} days', + exportTagsNotUsedForWorker: 'Tags are not used when a single worker is selected', }; 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 6f5607ff9..a6295b53b 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 @@ -495,4 +495,10 @@ export const ptBR = { UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', + // Download Excel dialog: how much the export covers, and why tags are inert for one worker. + exportScopeWorker: '1 worker', + exportScopeWorkers: '{{count}} workers', + exportScopeDay: '1 day', + exportScopeDays: '{{count}} days', + exportTagsNotUsedForWorker: 'Tags are not used when a single worker is selected', }; 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 c21e47258..637d1d930 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 @@ -495,4 +495,10 @@ export const ptPT = { UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', + // Download Excel dialog: how much the export covers, and why tags are inert for one worker. + exportScopeWorker: '1 worker', + exportScopeWorkers: '{{count}} workers', + exportScopeDay: '1 day', + exportScopeDays: '{{count}} days', + exportTagsNotUsedForWorker: 'Tags are not used when a single worker is selected', }; 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 2cc4785df..ba219bf25 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 @@ -495,4 +495,10 @@ export const roRO = { UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', + // Download Excel dialog: how much the export covers, and why tags are inert for one worker. + exportScopeWorker: '1 worker', + exportScopeWorkers: '{{count}} workers', + exportScopeDay: '1 day', + exportScopeDays: '{{count}} days', + exportTagsNotUsedForWorker: 'Tags are not used when a single worker is selected', }; 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 4f83684ba..1a89a6532 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 @@ -495,4 +495,10 @@ export const skSK = { UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', + // Download Excel dialog: how much the export covers, and why tags are inert for one worker. + exportScopeWorker: '1 worker', + exportScopeWorkers: '{{count}} workers', + exportScopeDay: '1 day', + exportScopeDays: '{{count}} days', + exportTagsNotUsedForWorker: 'Tags are not used when a single worker is selected', }; 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 64a5e2a37..c9bc9a3c5 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 @@ -495,4 +495,10 @@ export const slSL = { UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', + // Download Excel dialog: how much the export covers, and why tags are inert for one worker. + exportScopeWorker: '1 worker', + exportScopeWorkers: '{{count}} workers', + exportScopeDay: '1 day', + exportScopeDays: '{{count}} days', + exportTagsNotUsedForWorker: 'Tags are not used when a single worker is selected', }; 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 cae53fc9e..27d84866a 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 @@ -495,4 +495,10 @@ export const svSE = { UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', + // Download Excel dialog: how much the export covers, and why tags are inert for one worker. + exportScopeWorker: '1 worker', + exportScopeWorkers: '{{count}} workers', + exportScopeDay: '1 day', + exportScopeDays: '{{count}} days', + exportTagsNotUsedForWorker: 'Tags are not used when a single worker is selected', }; 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 a8a44371f..e8818662b 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 @@ -495,4 +495,10 @@ export const ukUA = { UNLOCK: 'UNLOCK', lockRequestFailed: 'The action could not be completed. Try again.', lockRequestUncertain: 'No answer came back. The state is uncertain, and the view refreshes.', + // Download Excel dialog: how much the export covers, and why tags are inert for one worker. + exportScopeWorker: '1 worker', + exportScopeWorkers: '{{count}} workers', + exportScopeDay: '1 day', + exportScopeDays: '{{count}} days', + exportTagsNotUsedForWorker: 'Tags are not used when a single worker is selected', }; 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 592b4fe51..072a9b8b5 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 @@ -6,3 +6,4 @@ 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'; +export * from './site-tags.model'; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/site-tags.model.ts b/eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/site-tags.model.ts new file mode 100644 index 000000000..04bdffa11 --- /dev/null +++ b/eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/site-tags.model.ts @@ -0,0 +1,9 @@ +/** + * One worker's tag membership, as the dashboard sees it. Fetched whole so a caller can + * answer "how many workers do these tags cover" without a request per filter change. + */ +export interface SiteTagsModel { + siteId: number; + tagIds: number[]; + resigned: boolean; +} diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/models/working-hours/time-plannings-report-all-workers-download-request.model.ts b/eform-client/src/app/plugins/modules/time-planning-pn/models/working-hours/time-plannings-report-all-workers-download-request.model.ts index 3758befac..8e662fbec 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/models/working-hours/time-plannings-report-all-workers-download-request.model.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/models/working-hours/time-plannings-report-all-workers-download-request.model.ts @@ -1,4 +1,6 @@ export interface TimePlanningsReportAllWorkersDownloadRequestModel { dateFrom: string; dateTo: string; + /** Any-of, like the dashboard's own tag filter. Left out entirely when nothing is picked. */ + tagIds?: number[]; } 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 660616672..7e9edb0c6 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 @@ -12,11 +12,13 @@ import { TimePlanningsUpdateModel, TimePlanningUpdateModel, PlanRegistrationVersionHistoryModel, + SiteTagsModel, } from '../models'; export let TimePlanningPnPlanningsMethods = { Plannings: 'api/time-planning-pn/plannings', SimplePlannings: 'api/time-planning-pn/plannings/index', + SiteTags: 'api/time-planning-pn/plannings/site-tags', IndexWorkingHours: 'api/time-planning-pn/working-hours/index', WorkingHours: 'api/time-planning-pn/working-hours', }; @@ -37,6 +39,11 @@ export class TimePlanningPnPlanningsService { ); } + /** Every worker with the tags on them, in one call. See SiteTagsModel. */ + getSiteTags(): Observable> { + return this.apiBaseService.get(TimePlanningPnPlanningsMethods.SiteTags); + } + updatePlanning( model: PlanningPrDayModel, id: number ): Observable { diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/services/time-planning-pn-working-hours.service.spec.ts b/eform-client/src/app/plugins/modules/time-planning-pn/services/time-planning-pn-working-hours.service.spec.ts new file mode 100644 index 000000000..fa8ba9a60 --- /dev/null +++ b/eform-client/src/app/plugins/modules/time-planning-pn/services/time-planning-pn-working-hours.service.spec.ts @@ -0,0 +1,64 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TimePlanningPnWorkingHoursService } from './time-planning-pn-working-hours.service'; +import { ApiBaseService } from 'src/app/common/services'; +import { of } from 'rxjs'; + +describe('TimePlanningPnWorkingHoursService', () => { + let service: TimePlanningPnWorkingHoursService; + let mockApiBaseService: jest.Mocked; + let httpTestingController: HttpTestingController; + + beforeEach(() => { + mockApiBaseService = { + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + getBlobData: jest.fn().mockReturnValue(of(new Blob())), + } as any; + + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + TimePlanningPnWorkingHoursService, + { provide: ApiBaseService, useValue: mockApiBaseService }, + ], + }); + + service = TestBed.inject(TimePlanningPnWorkingHoursService); + httpTestingController = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTestingController.verify(); + }); + + describe('downloadReportAllWorkers', () => { + // The whole point of this suite: a key that carries its values as "1,2" binds to an + // empty list on the server, and the filtered export silently returns every worker. + it('should repeat the tagIds key once per tag', () => { + service.downloadReportAllWorkers({ + dateFrom: '2024-01-15', + dateTo: '2024-01-21', + tagIds: [1, 2], + }).subscribe(); + + expect(mockApiBaseService.getBlobData).toHaveBeenCalledWith( + 'api/time-planning-pn/working-hours/reports/file-all-workers?tagIds=1&tagIds=2', + { dateFrom: '2024-01-15', dateTo: '2024-01-21' } + ); + }); + + it('should leave the url clean when no tag is selected', () => { + service.downloadReportAllWorkers({ + dateFrom: '2024-01-15', + dateTo: '2024-01-21', + }).subscribe(); + + expect(mockApiBaseService.getBlobData).toHaveBeenCalledWith( + 'api/time-planning-pn/working-hours/reports/file-all-workers', + { dateFrom: '2024-01-15', dateTo: '2024-01-21' } + ); + }); + }); +}); diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/services/time-planning-pn-working-hours.service.ts b/eform-client/src/app/plugins/modules/time-planning-pn/services/time-planning-pn-working-hours.service.ts index 997b14cfc..af5aa38c5 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/services/time-planning-pn-working-hours.service.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/services/time-planning-pn-working-hours.service.ts @@ -1,3 +1,4 @@ +import { HttpParams } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { OperationDataResult, OperationResult } from 'src/app/common/models'; @@ -53,9 +54,18 @@ export class TimePlanningPnWorkingHoursService { } downloadReportAllWorkers(model: TimePlanningsReportAllWorkersDownloadRequestModel): Observable { - return this.apiBaseService.getBlobData( - TimePlanningPnWorkingHoursMethods.ReportsAllWorkers, - model - ); + const {tagIds, ...params} = model; + // The tags cannot go through the shared params helper: it sets one value per key, so + // an array would be flattened to "tagIds=1,2", which the server cannot read back as a + // list — a filtered export would then silently contain every worker. The list has to + // repeat the key, "tagIds=1&tagIds=2", which is what append builds. Angular joins this + // query string and the remaining params with "&". + const tagQuery = (tagIds || []) + .reduce((httpParams, tagId) => httpParams.append('tagIds', tagId), new HttpParams()) + .toString(); + const url = tagQuery + ? `${TimePlanningPnWorkingHoursMethods.ReportsAllWorkers}?${tagQuery}` + : TimePlanningPnWorkingHoursMethods.ReportsAllWorkers; + return this.apiBaseService.getBlobData(url, params); } }