diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayDayTypeRuleServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayDayTypeRuleServiceTests.cs index ce0b4992c..f812c82b6 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayDayTypeRuleServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayDayTypeRuleServiceTests.cs @@ -8,6 +8,7 @@ using NSubstitute; using NUnit.Framework; using TimePlanning.Pn.Services.PayDayTypeRuleService; +using TimePlanning.Pn.Services.TimePlanningLocalizationService; using TimePlanning.Pn.Infrastructure.Models.PayDayTypeRule; namespace TimePlanning.Pn.Test @@ -21,9 +22,13 @@ public class PayDayTypeRuleServiceTests : TestBaseSetup public new async Task Setup() { await base.Setup(); + var localizationService = Substitute.For(); + localizationService.GetString(Arg.Any()) + .Returns(call => call.Arg()); _payDayTypeRuleService = new PayDayTypeRuleService( TimePlanningPnDbContext, - Substitute.For>()); + Substitute.For>(), + localizationService); } [Test] @@ -356,5 +361,203 @@ public async Task Index_ExcludesDeletedPayDayTypeRules() Assert.That(result.Model.PayDayTypeRules.Any(r => r.Id == deletedRule.Id), Is.False); Assert.That(result.Model.PayDayTypeRules.Any(r => r.Id == activeRule.Id), Is.True); } + + #region Locked Preset Guard Tests + + /// + /// Creates a PayRuleSet with the supplied name and returns it, so the + /// locked-preset tests only differ by that name. + /// + private async Task CreatePayRuleSetNamed(string payRuleSetName) + { + var payRuleSet = new PayRuleSet + { + Name = payRuleSetName, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + WorkflowState = Constants.WorkflowStates.Created + }; + await payRuleSet.Create(TimePlanningPnDbContext); + + return payRuleSet; + } + + [Test] + public async Task Create_OwnedByLockedPresetWithLegacyValidityPeriod_ReturnsFailure() + { + // Arrange - stored before the catalogue was renamed to "... 2026-2029" + var payRuleSet = await CreatePayRuleSetNamed("GLS-A / 3F - Jordbrug Dyrehold 2024-2026"); + + var model = new PayDayTypeRuleCreateModel + { + PayRuleSetId = payRuleSet.Id, + DayType = "Monday", + DefaultPayCode = "SNEAKED_IN", + Priority = 1 + }; + + // Act + var result = await _payDayTypeRuleService.Create(model); + + // Assert + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Does.Contain("CannotEditLockedPreset")); + var created = await TimePlanningPnDbContext.PayDayTypeRules + .IgnoreQueryFilters() + .FirstOrDefaultAsync(r => r.PayRuleSetId == payRuleSet.Id); + Assert.That(created, Is.Null); + } + + [Test] + public async Task Update_OwnedByLockedPresetWithLegacyValidityPeriod_ReturnsFailure() + { + // Arrange + var payRuleSet = await CreatePayRuleSetNamed("GLS-A / 3F - Jordbrug Dyrehold 2024-2026"); + + var rule = new PayDayTypeRule + { + PayRuleSetId = payRuleSet.Id, + DayType = DayType.Monday, + DefaultPayCode = "NORMAL", + Priority = 1, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + WorkflowState = Constants.WorkflowStates.Created + }; + await rule.Create(TimePlanningPnDbContext); + + var updateModel = new PayDayTypeRuleUpdateModel + { + DayType = "Tuesday", + DefaultPayCode = "HACKED", + Priority = 9 + }; + + // Act + var result = await _payDayTypeRuleService.Update(rule.Id, updateModel); + + // Assert + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Does.Contain("CannotEditLockedPreset")); + var unchanged = await TimePlanningPnDbContext.PayDayTypeRules + .FirstOrDefaultAsync(r => r.Id == rule.Id); + Assert.That(unchanged, Is.Not.Null); + Assert.That(unchanged.DayType, Is.EqualTo(DayType.Monday)); + Assert.That(unchanged.DefaultPayCode, Is.EqualTo("NORMAL")); + Assert.That(unchanged.Priority, Is.EqualTo(1)); + } + + [Test] + public async Task Update_OwnedByLockedPresetWithCurrentValidityPeriod_ReturnsFailure() + { + // Arrange + var payRuleSet = await CreatePayRuleSetNamed("GLS-A / 3F - Jordbrug Dyrehold 2026-2029"); + + var rule = new PayDayTypeRule + { + PayRuleSetId = payRuleSet.Id, + DayType = DayType.Monday, + DefaultPayCode = "NORMAL", + Priority = 1, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + WorkflowState = Constants.WorkflowStates.Created + }; + await rule.Create(TimePlanningPnDbContext); + + var updateModel = new PayDayTypeRuleUpdateModel + { + DayType = "Tuesday", + DefaultPayCode = "HACKED", + Priority = 9 + }; + + // Act + var result = await _payDayTypeRuleService.Update(rule.Id, updateModel); + + // Assert + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Does.Contain("CannotEditLockedPreset")); + } + + [Test] + public async Task Delete_OwnedByLockedPresetWithLegacyValidityPeriod_ReturnsFailure() + { + // Arrange + var payRuleSet = await CreatePayRuleSetNamed("GLS-A / 3F - Jordbrug Dyrehold 2024-2026"); + + var rule = new PayDayTypeRule + { + PayRuleSetId = payRuleSet.Id, + DayType = DayType.Monday, + DefaultPayCode = "NORMAL", + Priority = 1, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + WorkflowState = Constants.WorkflowStates.Created + }; + await rule.Create(TimePlanningPnDbContext); + + // Act + var result = await _payDayTypeRuleService.Delete(rule.Id); + + // Assert + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Does.Contain("CannotEditLockedPreset")); + var stillThere = await TimePlanningPnDbContext.PayDayTypeRules + .IgnoreQueryFilters() + .FirstOrDefaultAsync(r => r.Id == rule.Id); + Assert.That(stillThere, Is.Not.Null); + Assert.That(stillThere.WorkflowState, Is.EqualTo(Constants.WorkflowStates.Created)); + } + + [Test] + public async Task CreateUpdateDelete_OwnedByCustomNameWithValidityPeriod_Succeed() + { + // Arrange - stripping the year range must not make this collide with a preset + var payRuleSet = await CreatePayRuleSetNamed("Min egen aftale 2024-2026"); + + var createModel = new PayDayTypeRuleCreateModel + { + PayRuleSetId = payRuleSet.Id, + DayType = "Monday", + DefaultPayCode = "CUSTOM", + Priority = 1 + }; + + // Act - Create + var createResult = await _payDayTypeRuleService.Create(createModel); + + // Assert - Create + Assert.That(createResult.Success, Is.True); + var created = await TimePlanningPnDbContext.PayDayTypeRules + .Where(r => r.WorkflowState != Constants.WorkflowStates.Removed) + .FirstOrDefaultAsync(r => r.PayRuleSetId == payRuleSet.Id); + Assert.That(created, Is.Not.Null); + + // Act - Update + var updateResult = await _payDayTypeRuleService.Update(created.Id, new PayDayTypeRuleUpdateModel + { + DayType = "Tuesday", + DefaultPayCode = "CUSTOM_2", + Priority = 2 + }); + + // Assert - Update + Assert.That(updateResult.Success, Is.True); + + // Act - Delete + var deleteResult = await _payDayTypeRuleService.Delete(created.Id); + + // Assert - Delete + Assert.That(deleteResult.Success, Is.True); + var deleted = await TimePlanningPnDbContext.PayDayTypeRules + .IgnoreQueryFilters() + .FirstOrDefaultAsync(r => r.Id == created.Id); + Assert.That(deleted, Is.Not.Null); + Assert.That(deleted.WorkflowState, Is.EqualTo(Constants.WorkflowStates.Removed)); + } + + #endregion } } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayRuleSetServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayRuleSetServiceTests.cs index b386b322d..fb16a8557 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayRuleSetServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayRuleSetServiceTests.cs @@ -173,6 +173,176 @@ public async Task Delete_NonExistingId_ReturnsFailure() Assert.That(result.Message, Does.Contain("NotFound")); } + #region Locked Preset Guard Tests + + [Test] + public async Task Update_LockedPresetWithLegacyValidityPeriod_ReturnsFailure() + { + // Arrange - stored before the catalogue was renamed to "... 2026-2029" + var payRuleSet = new PayRuleSet + { + Name = "GLS-A / 3F - Jordbrug Dyrehold 2024-2026", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + WorkflowState = Constants.WorkflowStates.Created + }; + await payRuleSet.Create(TimePlanningPnDbContext); + + var updateModel = new PayRuleSetUpdateModel + { + Name = "Renamed By Client" + }; + + // Act + var result = await _service.Update(payRuleSet.Id, updateModel); + + // Assert + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Does.Contain("CannotEditLockedPreset")); + var unchanged = await TimePlanningPnDbContext.PayRuleSets + .FirstOrDefaultAsync(prs => prs.Id == payRuleSet.Id); + Assert.That(unchanged, Is.Not.Null); + Assert.That(unchanged.Name, Is.EqualTo("GLS-A / 3F - Jordbrug Dyrehold 2024-2026")); + } + + [Test] + public async Task Update_RenamingCustomSetIntoLockedPresetName_ReturnsFailure() + { + // Arrange + var payRuleSet = new PayRuleSet + { + Name = "Min egen aftale", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + WorkflowState = Constants.WorkflowStates.Created + }; + await payRuleSet.Create(TimePlanningPnDbContext); + + var updateModel = new PayRuleSetUpdateModel + { + Name = "GLS-A / 3F - Jordbrug Dyrehold 2026-2029" + }; + + // Act + var result = await _service.Update(payRuleSet.Id, updateModel); + + // Assert + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Does.Contain("CannotEditLockedPreset")); + var unchanged = await TimePlanningPnDbContext.PayRuleSets + .FirstOrDefaultAsync(prs => prs.Id == payRuleSet.Id); + Assert.That(unchanged, Is.Not.Null); + Assert.That(unchanged.Name, Is.EqualTo("Min egen aftale")); + } + + [Test] + public async Task Delete_LockedPresetWithLegacyValidityPeriod_ReturnsFailure() + { + // Arrange + var payRuleSet = new PayRuleSet + { + Name = "GLS-A / 3F - Jordbrug Dyrehold 2024-2026", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + WorkflowState = Constants.WorkflowStates.Created + }; + await payRuleSet.Create(TimePlanningPnDbContext); + + // Act + var result = await _service.Delete(payRuleSet.Id); + + // Assert + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Does.Contain("CannotDeleteLockedPreset")); + var stillThere = await TimePlanningPnDbContext.PayRuleSets + .IgnoreQueryFilters() + .FirstOrDefaultAsync(prs => prs.Id == payRuleSet.Id); + Assert.That(stillThere, Is.Not.Null); + Assert.That(stillThere.WorkflowState, Is.EqualTo(Constants.WorkflowStates.Created)); + } + + [Test] + public async Task Delete_LockedPresetWithCurrentValidityPeriod_ReturnsFailure() + { + // Arrange - the name exactly as shipped in the current catalogue + var payRuleSet = new PayRuleSet + { + Name = "GLS-A / 3F - Jordbrug Dyrehold 2026-2029", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + WorkflowState = Constants.WorkflowStates.Created + }; + await payRuleSet.Create(TimePlanningPnDbContext); + + // Act + var result = await _service.Delete(payRuleSet.Id); + + // Assert + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Does.Contain("CannotDeleteLockedPreset")); + var stillThere = await TimePlanningPnDbContext.PayRuleSets + .IgnoreQueryFilters() + .FirstOrDefaultAsync(prs => prs.Id == payRuleSet.Id); + Assert.That(stillThere, Is.Not.Null); + Assert.That(stillThere.WorkflowState, Is.EqualTo(Constants.WorkflowStates.Created)); + } + + [Test] + public async Task Update_CustomNameWithValidityPeriod_UpdatesPayRuleSet() + { + // Arrange - stripping the year range must not make this collide with a preset + var payRuleSet = new PayRuleSet + { + Name = "Min egen aftale 2024-2026", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + WorkflowState = Constants.WorkflowStates.Created + }; + await payRuleSet.Create(TimePlanningPnDbContext); + + var updateModel = new PayRuleSetUpdateModel + { + Name = "Min egen aftale 2026-2029" + }; + + // Act + var result = await _service.Update(payRuleSet.Id, updateModel); + + // Assert + Assert.That(result.Success, Is.True); + var updated = await TimePlanningPnDbContext.PayRuleSets + .FirstOrDefaultAsync(prs => prs.Id == payRuleSet.Id); + Assert.That(updated, Is.Not.Null); + Assert.That(updated.Name, Is.EqualTo("Min egen aftale 2026-2029")); + } + + [Test] + public async Task Delete_CustomNameWithValidityPeriod_SoftDeletesPayRuleSet() + { + // Arrange + var payRuleSet = new PayRuleSet + { + Name = "Min egen aftale 2024-2026", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + WorkflowState = Constants.WorkflowStates.Created + }; + await payRuleSet.Create(TimePlanningPnDbContext); + + // Act + var result = await _service.Delete(payRuleSet.Id); + + // Assert + Assert.That(result.Success, Is.True); + var deleted = await TimePlanningPnDbContext.PayRuleSets + .IgnoreQueryFilters() + .FirstOrDefaultAsync(prs => prs.Id == payRuleSet.Id); + Assert.That(deleted, Is.Not.Null); + Assert.That(deleted.WorkflowState, Is.EqualTo(Constants.WorkflowStates.Removed)); + } + + #endregion + [Test] public async Task Index_ReturnsPayRuleSets() { diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayTierRuleServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayTierRuleServiceTests.cs index a45db8258..752771103 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayTierRuleServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayTierRuleServiceTests.cs @@ -15,6 +15,7 @@ The MIT License (MIT) using NUnit.Framework; using TimePlanning.Pn.Infrastructure.Models.PayTierRule; using TimePlanning.Pn.Services.PayTierRuleService; +using TimePlanning.Pn.Services.TimePlanningLocalizationService; namespace TimePlanning.Pn.Test; @@ -27,9 +28,13 @@ public class PayTierRuleServiceTests : TestBaseSetup public new async Task Setup() { await base.Setup(); + var localizationService = Substitute.For(); + localizationService.GetString(Arg.Any()) + .Returns(call => call.Arg()); _service = new PayTierRuleService( TimePlanningPnDbContext, - Substitute.For>()); + Substitute.For>(), + localizationService); } [Test] @@ -265,6 +270,214 @@ public async Task Delete_NonExistingId_ReturnsFailure() Assert.That(result.Message, Does.Contain("not found")); } + #region Locked Preset Guard Tests + + /// + /// Creates a PayDayRule under a PayRuleSet with the supplied name and + /// returns it, so the locked-preset tests only differ by that name. + /// + private async Task CreatePayDayRuleUnderRuleSetNamed(string payRuleSetName) + { + var payRuleSet = new PayRuleSet + { + Name = payRuleSetName, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + WorkflowState = Constants.WorkflowStates.Created + }; + await payRuleSet.Create(TimePlanningPnDbContext); + + var payDayRule = new PayDayRule + { + PayRuleSetId = payRuleSet.Id, + DayCode = "SUNDAY", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + WorkflowState = Constants.WorkflowStates.Created + }; + await payDayRule.Create(TimePlanningPnDbContext); + + return payDayRule; + } + + [Test] + public async Task Create_OwnedByLockedPresetWithLegacyValidityPeriod_ReturnsFailure() + { + // Arrange - stored before the catalogue was renamed to "... 2026-2029" + var payDayRule = await CreatePayDayRuleUnderRuleSetNamed("GLS-A / 3F - Jordbrug Dyrehold 2024-2026"); + + var model = new PayTierRuleCreateModel + { + PayDayRuleId = payDayRule.Id, + Order = 1, + UpToSeconds = 39600, + PayCode = "SNEAKED_IN" + }; + + // Act + var result = await _service.Create(model); + + // Assert + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Does.Contain("CannotEditLockedPreset")); + var created = await TimePlanningPnDbContext.PayTierRules + .IgnoreQueryFilters() + .FirstOrDefaultAsync(ptr => ptr.PayCode == "SNEAKED_IN"); + Assert.That(created, Is.Null); + } + + [Test] + public async Task Update_OwnedByLockedPresetWithLegacyValidityPeriod_ReturnsFailure() + { + // Arrange + var payDayRule = await CreatePayDayRuleUnderRuleSetNamed("GLS-A / 3F - Jordbrug Dyrehold 2024-2026"); + + var payTierRule = new PayTierRule + { + PayDayRuleId = payDayRule.Id, + Order = 1, + UpToSeconds = 39600, + PayCode = "SUN_80", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + WorkflowState = Constants.WorkflowStates.Created + }; + await payTierRule.Create(TimePlanningPnDbContext); + + var updateModel = new PayTierRuleUpdateModel + { + Order = 2, + UpToSeconds = 43200, + PayCode = "SUN_90" + }; + + // Act + var result = await _service.Update(payTierRule.Id, updateModel); + + // Assert + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Does.Contain("CannotEditLockedPreset")); + var unchanged = await TimePlanningPnDbContext.PayTierRules + .FirstOrDefaultAsync(ptr => ptr.Id == payTierRule.Id); + Assert.That(unchanged, Is.Not.Null); + Assert.That(unchanged.Order, Is.EqualTo(1)); + Assert.That(unchanged.UpToSeconds, Is.EqualTo(39600)); + Assert.That(unchanged.PayCode, Is.EqualTo("SUN_80")); + } + + [Test] + public async Task Update_OwnedByLockedPresetWithCurrentValidityPeriod_ReturnsFailure() + { + // Arrange + var payDayRule = await CreatePayDayRuleUnderRuleSetNamed("GLS-A / 3F - Jordbrug Dyrehold 2026-2029"); + + var payTierRule = new PayTierRule + { + PayDayRuleId = payDayRule.Id, + Order = 1, + UpToSeconds = 39600, + PayCode = "SUN_80", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + WorkflowState = Constants.WorkflowStates.Created + }; + await payTierRule.Create(TimePlanningPnDbContext); + + var updateModel = new PayTierRuleUpdateModel + { + Order = 2, + UpToSeconds = 43200, + PayCode = "SUN_90" + }; + + // Act + var result = await _service.Update(payTierRule.Id, updateModel); + + // Assert + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Does.Contain("CannotEditLockedPreset")); + } + + [Test] + public async Task Delete_OwnedByLockedPresetWithLegacyValidityPeriod_ReturnsFailure() + { + // Arrange + var payDayRule = await CreatePayDayRuleUnderRuleSetNamed("GLS-A / 3F - Jordbrug Dyrehold 2024-2026"); + + var payTierRule = new PayTierRule + { + PayDayRuleId = payDayRule.Id, + Order = 1, + UpToSeconds = 39600, + PayCode = "SUN_80", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + WorkflowState = Constants.WorkflowStates.Created + }; + await payTierRule.Create(TimePlanningPnDbContext); + + // Act + var result = await _service.Delete(payTierRule.Id); + + // Assert + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Does.Contain("CannotEditLockedPreset")); + var stillThere = await TimePlanningPnDbContext.PayTierRules + .IgnoreQueryFilters() + .FirstOrDefaultAsync(ptr => ptr.Id == payTierRule.Id); + Assert.That(stillThere, Is.Not.Null); + Assert.That(stillThere.WorkflowState, Is.EqualTo(Constants.WorkflowStates.Created)); + } + + [Test] + public async Task CreateUpdateDelete_OwnedByCustomNameWithValidityPeriod_Succeed() + { + // Arrange - stripping the year range must not make this collide with a preset + var payDayRule = await CreatePayDayRuleUnderRuleSetNamed("Min egen aftale 2024-2026"); + + var createModel = new PayTierRuleCreateModel + { + PayDayRuleId = payDayRule.Id, + Order = 1, + UpToSeconds = 39600, + PayCode = "CUSTOM_80" + }; + + // Act - Create + var createResult = await _service.Create(createModel); + + // Assert - Create + Assert.That(createResult.Success, Is.True); + var created = await TimePlanningPnDbContext.PayTierRules + .Where(ptr => ptr.WorkflowState != Constants.WorkflowStates.Removed) + .FirstOrDefaultAsync(ptr => ptr.PayCode == "CUSTOM_80"); + Assert.That(created, Is.Not.Null); + + // Act - Update + var updateResult = await _service.Update(created.Id, new PayTierRuleUpdateModel + { + Order = 2, + UpToSeconds = 43200, + PayCode = "CUSTOM_90" + }); + + // Assert - Update + Assert.That(updateResult.Success, Is.True); + + // Act - Delete + var deleteResult = await _service.Delete(created.Id); + + // Assert - Delete + Assert.That(deleteResult.Success, Is.True); + var deleted = await TimePlanningPnDbContext.PayTierRules + .IgnoreQueryFilters() + .FirstOrDefaultAsync(ptr => ptr.Id == created.Id); + Assert.That(deleted, Is.Not.Null); + Assert.That(deleted.WorkflowState, Is.EqualTo(Constants.WorkflowStates.Removed)); + } + + #endregion + [Test] public async Task Index_ReturnsPayTierRules() { diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayTimeBandRuleServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayTimeBandRuleServiceTests.cs index 4dc92bc5e..d00bea49f 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayTimeBandRuleServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayTimeBandRuleServiceTests.cs @@ -16,6 +16,7 @@ The MIT License (MIT) using NUnit.Framework; using TimePlanning.Pn.Infrastructure.Models.PayTimeBandRule; using TimePlanning.Pn.Services.PayTimeBandRuleService; +using TimePlanning.Pn.Services.TimePlanningLocalizationService; namespace TimePlanning.Pn.Test; @@ -28,9 +29,13 @@ public class PayTimeBandRuleServiceTests : TestBaseSetup public new async Task Setup() { await base.Setup(); + var localizationService = Substitute.For(); + localizationService.GetString(Arg.Any()) + .Returns(call => call.Arg()); _service = new PayTimeBandRuleService( TimePlanningPnDbContext, - Substitute.For>()); + Substitute.For>(), + localizationService); } [Test] @@ -541,4 +546,220 @@ public async Task Index_ExcludesDeletedPayTimeBandRules() Assert.That(result.Model.Total, Is.EqualTo(1)); Assert.That(result.Model.PayTimeBandRules[0].PayCode, Is.EqualTo("Active")); } + + #region Locked Preset Guard Tests + + /// + /// Creates a PayDayTypeRule under a PayRuleSet with the supplied name and + /// returns it, so the locked-preset tests only differ by that name. + /// + private async Task CreatePayDayTypeRuleUnderRuleSetNamed(string payRuleSetName) + { + var payRuleSet = new PayRuleSet + { + Name = payRuleSetName, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + WorkflowState = Constants.WorkflowStates.Created + }; + await payRuleSet.Create(TimePlanningPnDbContext); + + var payDayTypeRule = new PayDayTypeRule + { + PayRuleSetId = payRuleSet.Id, + DayType = DayType.Monday, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + WorkflowState = Constants.WorkflowStates.Created + }; + await payDayTypeRule.Create(TimePlanningPnDbContext); + + return payDayTypeRule; + } + + [Test] + public async Task Create_OwnedByLockedPresetWithLegacyValidityPeriod_ReturnsFailure() + { + // Arrange - stored before the catalogue was renamed to "... 2026-2029" + var payDayTypeRule = await CreatePayDayTypeRuleUnderRuleSetNamed("GLS-A / 3F - Jordbrug Dyrehold 2024-2026"); + + var model = new PayTimeBandRuleCreateModel + { + PayDayTypeRuleId = payDayTypeRule.Id, + StartSecondOfDay = 0, + EndSecondOfDay = 64800, + PayCode = "SNEAKED_IN", + Priority = 1 + }; + + // Act + var result = await _service.Create(model); + + // Assert + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Does.Contain("CannotEditLockedPreset")); + var created = await TimePlanningPnDbContext.PayTimeBandRules + .IgnoreQueryFilters() + .FirstOrDefaultAsync(ptbr => ptbr.PayCode == "SNEAKED_IN"); + Assert.That(created, Is.Null); + } + + [Test] + public async Task Update_OwnedByLockedPresetWithLegacyValidityPeriod_ReturnsFailure() + { + // Arrange + var payDayTypeRule = await CreatePayDayTypeRuleUnderRuleSetNamed("GLS-A / 3F - Jordbrug Dyrehold 2024-2026"); + + var payTimeBandRule = new PayTimeBandRule + { + PayDayTypeRuleId = payDayTypeRule.Id, + StartSecondOfDay = 0, + EndSecondOfDay = 64800, + PayCode = "DAY", + Priority = 1, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + WorkflowState = Constants.WorkflowStates.Created + }; + await payTimeBandRule.Create(TimePlanningPnDbContext); + + var updateModel = new PayTimeBandRuleUpdateModel + { + StartSecondOfDay = 3600, + EndSecondOfDay = 7200, + PayCode = "HACKED", + Priority = 9 + }; + + // Act + var result = await _service.Update(payTimeBandRule.Id, updateModel); + + // Assert + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Does.Contain("CannotEditLockedPreset")); + var unchanged = await TimePlanningPnDbContext.PayTimeBandRules + .FirstOrDefaultAsync(ptbr => ptbr.Id == payTimeBandRule.Id); + Assert.That(unchanged, Is.Not.Null); + Assert.That(unchanged.StartSecondOfDay, Is.EqualTo(0)); + Assert.That(unchanged.EndSecondOfDay, Is.EqualTo(64800)); + Assert.That(unchanged.PayCode, Is.EqualTo("DAY")); + } + + [Test] + public async Task Update_OwnedByLockedPresetWithCurrentValidityPeriod_ReturnsFailure() + { + // Arrange + var payDayTypeRule = await CreatePayDayTypeRuleUnderRuleSetNamed("GLS-A / 3F - Jordbrug Dyrehold 2026-2029"); + + var payTimeBandRule = new PayTimeBandRule + { + PayDayTypeRuleId = payDayTypeRule.Id, + StartSecondOfDay = 0, + EndSecondOfDay = 64800, + PayCode = "DAY", + Priority = 1, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + WorkflowState = Constants.WorkflowStates.Created + }; + await payTimeBandRule.Create(TimePlanningPnDbContext); + + var updateModel = new PayTimeBandRuleUpdateModel + { + StartSecondOfDay = 3600, + EndSecondOfDay = 7200, + PayCode = "HACKED", + Priority = 9 + }; + + // Act + var result = await _service.Update(payTimeBandRule.Id, updateModel); + + // Assert + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Does.Contain("CannotEditLockedPreset")); + } + + [Test] + public async Task Delete_OwnedByLockedPresetWithLegacyValidityPeriod_ReturnsFailure() + { + // Arrange + var payDayTypeRule = await CreatePayDayTypeRuleUnderRuleSetNamed("GLS-A / 3F - Jordbrug Dyrehold 2024-2026"); + + var payTimeBandRule = new PayTimeBandRule + { + PayDayTypeRuleId = payDayTypeRule.Id, + StartSecondOfDay = 0, + EndSecondOfDay = 64800, + PayCode = "DAY", + Priority = 1, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + WorkflowState = Constants.WorkflowStates.Created + }; + await payTimeBandRule.Create(TimePlanningPnDbContext); + + // Act + var result = await _service.Delete(payTimeBandRule.Id); + + // Assert + Assert.That(result.Success, Is.False); + Assert.That(result.Message, Does.Contain("CannotEditLockedPreset")); + var stillThere = await TimePlanningPnDbContext.PayTimeBandRules + .IgnoreQueryFilters() + .FirstOrDefaultAsync(ptbr => ptbr.Id == payTimeBandRule.Id); + Assert.That(stillThere, Is.Not.Null); + Assert.That(stillThere.WorkflowState, Is.EqualTo(Constants.WorkflowStates.Created)); + } + + [Test] + public async Task CreateUpdateDelete_OwnedByCustomNameWithValidityPeriod_Succeed() + { + // Arrange - stripping the year range must not make this collide with a preset + var payDayTypeRule = await CreatePayDayTypeRuleUnderRuleSetNamed("Min egen aftale 2024-2026"); + + var createModel = new PayTimeBandRuleCreateModel + { + PayDayTypeRuleId = payDayTypeRule.Id, + StartSecondOfDay = 0, + EndSecondOfDay = 64800, + PayCode = "CUSTOM_DAY", + Priority = 1 + }; + + // Act - Create + var createResult = await _service.Create(createModel); + + // Assert - Create + Assert.That(createResult.Success, Is.True); + var created = await TimePlanningPnDbContext.PayTimeBandRules + .Where(ptbr => ptbr.WorkflowState != Constants.WorkflowStates.Removed) + .FirstOrDefaultAsync(ptbr => ptbr.PayCode == "CUSTOM_DAY"); + Assert.That(created, Is.Not.Null); + + // Act - Update + var updateResult = await _service.Update(created.Id, new PayTimeBandRuleUpdateModel + { + StartSecondOfDay = 3600, + EndSecondOfDay = 7200, + PayCode = "CUSTOM_NIGHT", + Priority = 2 + }); + + // Assert - Update + Assert.That(updateResult.Success, Is.True); + + // Act - Delete + var deleteResult = await _service.Delete(created.Id); + + // Assert - Delete + Assert.That(deleteResult.Success, Is.True); + var deleted = await TimePlanningPnDbContext.PayTimeBandRules + .IgnoreQueryFilters() + .FirstOrDefaultAsync(ptbr => ptbr.Id == created.Id); + Assert.That(deleted, Is.Not.Null); + Assert.That(deleted.WorkflowState, Is.EqualTo(Constants.WorkflowStates.Removed)); + } + + #endregion } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PayRuleSetLock.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PayRuleSetLock.cs new file mode 100644 index 000000000..8bbc249bd --- /dev/null +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PayRuleSetLock.cs @@ -0,0 +1,108 @@ +/* +The MIT License (MIT) + +Copyright (c) 2007 - 2021 Microting A/S +*/ + +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace TimePlanning.Pn.Infrastructure.Helpers; + +/// +/// Single source of truth for "is this pay rule set a locked GLS-A preset". +/// +/// The lock used to live as private statics on +/// PayRuleSetService, which meant only that service could enforce it. +/// The nested rule endpoints (pay tier rules, pay day type rules, pay time band +/// rules) hang off the very same rule set and had no guard at all, so a locked +/// preset could be rewritten one child row at a time — the lock was fully +/// bypassable. Hoisting the logic here lets every service that can mutate a +/// locked preset (directly or through a child row) share the exact same +/// definition. +/// +internal static class PayRuleSetLock +{ + /// + /// The GLS-A/3F overenskomst presets shipped by the platform. These rows are + /// read-only for customers: they are maintained centrally and re-seeded when + /// the agreement is renegotiated. + /// + internal static readonly HashSet LockedPresetNames = new HashSet + { + "GLS-A / 3F - Jordbrug Standard 2026-2029", + "GLS-A / 3F - Jordbrug Dyrehold 2026-2029", + "GLS-A / 3F - Jordbrug Elev u18 2026-2029", + "GLS-A / 3F - Jordbrug Elev o18 2026-2029", + "GLS-A / 3F - Jordbrug Elev u18 Dyrehold 2026-2029", + "GLS-A / 3F - Gartneri Standard 2026-2029", + "GLS-A / 3F - Gartneri Elev u18 2026-2029", + "GLS-A / 3F - Gartneri Elev o18 2026-2029", + "GLS-A / 3F - Skovbrug Standard 2026-2029", + "GLS-A / 3F - Skovbrug Elev u18 2026-2029", + "GLS-A / 3F - Skovbrug Elev o18 2026-2029", + "GLS-A / 3F - Golf Standard 2026-2029", + "GLS-A / 3F - Golf Elev 2026-2029", + "GLS-A / 3F - Agroindustri Fjerkrae Standard 2026-2029", + "GLS-A / 3F - Agroindustri Fjerkrae Elev 2026-2029", + "GLS-A / 3F - Agroindustri Grovvare Standard 2026-2029", + "GLS-A / 3F - Agroindustri Grovvare Elev 2026-2029", + "GLS-A / 3F - Agroindustri Gulerod Standard 2026-2029", + "GLS-A / 3F - Agroindustri Gulerod Elev 2026-2029", + "GLS-A / 3F - Agroindustri Kartoffelmel Standard 2026-2029", + "GLS-A / 3F - Agroindustri Kartoffelmel Elev 2026-2029", + "GLS-A / 3F - Agroindustri Kartoffelsorter Standard 2026-2029", + "GLS-A / 3F - Agroindustri Kartoffelsorter Elev 2026-2029", + "GLS-A / 3F - Agroindustri Lucerne Standard 2026-2029", + "GLS-A / 3F - Agroindustri Lucerne Elev 2026-2029", + "GLS-A / 3F - Agroindustri Minkfoder Standard 2026-2029", + "GLS-A / 3F - Agroindustri Minkfoder Elev 2026-2029", + "GLS-A / 3F - Agroindustri Ovrige Standard 2026-2029", + "GLS-A / 3F - Agroindustri Ovrige Elev 2026-2029", + "GLS-A / 3F - Udenlandske praktikanter Landbrug Andet arbejde 2026-2029", + "GLS-A / 3F - Udenlandske praktikanter Landbrug Staldarbejde 2026-2029" + }; + + /// + /// Trailing agreement validity period, e.g. " 2024-2026" or " 2026–2029". + /// Hyphen, en-dash and em-dash are all accepted so a stored name written + /// with a typographic dash normalizes to the same value. + /// + private static readonly Regex ValidityPeriodSuffixRegex = + new Regex(@"\s+\d{4}\s*[-–—]\s*\d{4}$", RegexOptions.Compiled); + + /// + /// with the validity period stripped. + /// Declared after the source set: static field initializers run in + /// textual order. + /// + private static readonly HashSet NormalizedLockedPresetNames = + new HashSet(LockedPresetNames.Select(NormalizePresetName)); + + /// + /// Strips the trailing validity period so names that differ only by + /// agreement period compare equal — "… Jordbrug Dyrehold 2024-2026" and + /// "… Jordbrug Dyrehold 2026-2029" both become "… Jordbrug Dyrehold". + /// + internal static string NormalizePresetName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return string.Empty; + } + + return ValidityPeriodSuffixRegex.Replace(name.Trim(), string.Empty).Trim(); + } + + /// + /// True when the name matches a locked preset once the validity period is + /// normalized away. Rule sets created before a catalogue rename therefore + /// stay locked. + /// + internal static bool IsLockedPresetName(string name) + { + var normalized = NormalizePresetName(name); + return normalized.Length > 0 && NormalizedLockedPresetNames.Contains(normalized); + } +} diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PayDayTypeRuleService/PayDayTypeRuleService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PayDayTypeRuleService/PayDayTypeRuleService.cs index 4c668f8e5..11b124eff 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PayDayTypeRuleService/PayDayTypeRuleService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PayDayTypeRuleService/PayDayTypeRuleService.cs @@ -10,6 +10,7 @@ namespace TimePlanning.Pn.Services.PayDayTypeRuleService; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Infrastructure.Helpers; using Infrastructure.Models.PayDayTypeRule; using Infrastructure.Models.PayTimeBandRule; using Microsoft.EntityFrameworkCore; @@ -18,18 +19,38 @@ namespace TimePlanning.Pn.Services.PayDayTypeRuleService; using Microting.eFormApi.BasePn.Infrastructure.Models.API; using Microting.TimePlanningBase.Infrastructure.Data; using Microting.TimePlanningBase.Infrastructure.Data.Entities; +using TimePlanning.Pn.Services.TimePlanningLocalizationService; public class PayDayTypeRuleService : IPayDayTypeRuleService { private readonly TimePlanningPnDbContext _dbContext; private readonly ILogger _logger; + private readonly ITimePlanningLocalizationService _localizationService; public PayDayTypeRuleService( TimePlanningPnDbContext dbContext, - ILogger logger) + ILogger logger, + ITimePlanningLocalizationService localizationService) { _dbContext = dbContext; _logger = logger; + _localizationService = localizationService; + } + + /// + /// A pay day type rule belongs directly to a PayRuleSet. Locked + /// overenskomst presets are read-only, and the guard on PayRuleSetService + /// only covers the rule set row itself — without this check a locked preset + /// could be rewritten one child row at a time. + /// + private async Task OwningPayRuleSetIsLocked(int payRuleSetId) + { + var payRuleSetName = await _dbContext.PayRuleSets + .Where(prs => prs.Id == payRuleSetId) + .Select(prs => prs.Name) + .FirstOrDefaultAsync(); + + return payRuleSetName != null && PayRuleSetLock.IsLockedPresetName(payRuleSetName); } public async Task> Index(PayDayTypeRulesRequestModel requestModel) @@ -127,6 +148,11 @@ public async Task Create(PayDayTypeRuleCreateModel model) { try { + if (await OwningPayRuleSetIsLocked(model.PayRuleSetId)) + { + return new OperationResult(false, _localizationService.GetString("CannotEditLockedPreset")); + } + // Parse DayType enum if (!Enum.TryParse(model.DayType, out var dayType)) { @@ -188,6 +214,11 @@ public async Task Update(int id, PayDayTypeRuleUpdateModel mode return new OperationResult(false, "Pay day type rule not found"); } + if (await OwningPayRuleSetIsLocked(rule.PayRuleSetId)) + { + return new OperationResult(false, _localizationService.GetString("CannotEditLockedPreset")); + } + // Parse DayType enum if (!Enum.TryParse(model.DayType, out var dayType)) { @@ -274,6 +305,11 @@ public async Task Delete(int id) return new OperationResult(false, "Pay day type rule not found"); } + if (await OwningPayRuleSetIsLocked(rule.PayRuleSetId)) + { + return new OperationResult(false, _localizationService.GetString("CannotEditLockedPreset")); + } + await rule.Delete(_dbContext); return new OperationResult(true, "Pay day type rule deleted successfully"); diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PayRuleSetService/PayRuleSetService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PayRuleSetService/PayRuleSetService.cs index c511ee61c..a8f7c714a 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PayRuleSetService/PayRuleSetService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PayRuleSetService/PayRuleSetService.cs @@ -10,6 +10,7 @@ namespace TimePlanning.Pn.Services.PayRuleSetService; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Infrastructure.Helpers; using Infrastructure.Models.PayDayTypeRule; using Infrastructure.Models.PayRuleSet; using Infrastructure.Models.PayTimeBandRule; @@ -23,41 +24,6 @@ namespace TimePlanning.Pn.Services.PayRuleSetService; public class PayRuleSetService : IPayRuleSetService { - private static readonly HashSet LockedPresetNames = new HashSet - { - "GLS-A / 3F - Jordbrug Standard 2026-2029", - "GLS-A / 3F - Jordbrug Dyrehold 2026-2029", - "GLS-A / 3F - Jordbrug Elev u18 2026-2029", - "GLS-A / 3F - Jordbrug Elev o18 2026-2029", - "GLS-A / 3F - Jordbrug Elev u18 Dyrehold 2026-2029", - "GLS-A / 3F - Gartneri Standard 2026-2029", - "GLS-A / 3F - Gartneri Elev u18 2026-2029", - "GLS-A / 3F - Gartneri Elev o18 2026-2029", - "GLS-A / 3F - Skovbrug Standard 2026-2029", - "GLS-A / 3F - Skovbrug Elev u18 2026-2029", - "GLS-A / 3F - Skovbrug Elev o18 2026-2029", - "GLS-A / 3F - Golf Standard 2026-2029", - "GLS-A / 3F - Golf Elev 2026-2029", - "GLS-A / 3F - Agroindustri Fjerkrae Standard 2026-2029", - "GLS-A / 3F - Agroindustri Fjerkrae Elev 2026-2029", - "GLS-A / 3F - Agroindustri Grovvare Standard 2026-2029", - "GLS-A / 3F - Agroindustri Grovvare Elev 2026-2029", - "GLS-A / 3F - Agroindustri Gulerod Standard 2026-2029", - "GLS-A / 3F - Agroindustri Gulerod Elev 2026-2029", - "GLS-A / 3F - Agroindustri Kartoffelmel Standard 2026-2029", - "GLS-A / 3F - Agroindustri Kartoffelmel Elev 2026-2029", - "GLS-A / 3F - Agroindustri Kartoffelsorter Standard 2026-2029", - "GLS-A / 3F - Agroindustri Kartoffelsorter Elev 2026-2029", - "GLS-A / 3F - Agroindustri Lucerne Standard 2026-2029", - "GLS-A / 3F - Agroindustri Lucerne Elev 2026-2029", - "GLS-A / 3F - Agroindustri Minkfoder Standard 2026-2029", - "GLS-A / 3F - Agroindustri Minkfoder Elev 2026-2029", - "GLS-A / 3F - Agroindustri Ovrige Standard 2026-2029", - "GLS-A / 3F - Agroindustri Ovrige Elev 2026-2029", - "GLS-A / 3F - Udenlandske praktikanter Landbrug Andet arbejde 2026-2029", - "GLS-A / 3F - Udenlandske praktikanter Landbrug Staldarbejde 2026-2029" - }; - private readonly TimePlanningPnDbContext _dbContext; private readonly ILogger _logger; private readonly ITimePlanningLocalizationService _localizationService; @@ -317,7 +283,11 @@ public async Task Update(int id, PayRuleSetUpdateModel model) // Locked overenskomst presets are read-only. The frontend renders a // summary view in the edit modal and disables the Update button, but // this server-side guard backstops direct API calls. - if (LockedPresetNames.Contains(payRuleSet.Name)) + // Blocked when EITHER the stored name or the incoming name is a + // locked preset: a locked row cannot be edited by sending a + // different name, and an unlocked row cannot be renamed into a + // locked preset name. + if (PayRuleSetLock.IsLockedPresetName(payRuleSet.Name) || PayRuleSetLock.IsLockedPresetName(model.Name)) { return new OperationResult(false, _localizationService.GetString("CannotEditLockedPreset")); } @@ -596,7 +566,9 @@ public async Task Delete(int id) return new OperationResult(false, _localizationService.GetString("PayRuleSetNotFound")); } - if (LockedPresetNames.Contains(payRuleSet.Name)) + // Comparison ignores the validity period, so rows stored under an + // earlier agreement period stay undeletable after a rename. + if (PayRuleSetLock.IsLockedPresetName(payRuleSet.Name)) { return new OperationResult(false, _localizationService.GetString("CannotDeleteLockedPreset")); } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PayTierRuleService/PayTierRuleService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PayTierRuleService/PayTierRuleService.cs index 15e619772..66237df23 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PayTierRuleService/PayTierRuleService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PayTierRuleService/PayTierRuleService.cs @@ -9,6 +9,7 @@ namespace TimePlanning.Pn.Services.PayTierRuleService; using System; using System.Linq; using System.Threading.Tasks; +using Infrastructure.Helpers; using Infrastructure.Models.PayTierRule; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -16,18 +17,38 @@ namespace TimePlanning.Pn.Services.PayTierRuleService; using Microting.eFormApi.BasePn.Infrastructure.Models.API; using Microting.TimePlanningBase.Infrastructure.Data; using Microting.TimePlanningBase.Infrastructure.Data.Entities; +using TimePlanning.Pn.Services.TimePlanningLocalizationService; public class PayTierRuleService : IPayTierRuleService { private readonly TimePlanningPnDbContext _dbContext; private readonly ILogger _logger; + private readonly ITimePlanningLocalizationService _localizationService; public PayTierRuleService( TimePlanningPnDbContext dbContext, - ILogger logger) + ILogger logger, + ITimePlanningLocalizationService localizationService) { _dbContext = dbContext; _logger = logger; + _localizationService = localizationService; + } + + /// + /// A pay tier rule belongs to a PayDayRule, which belongs to a PayRuleSet. + /// Locked overenskomst presets are read-only, and the guard on + /// PayRuleSetService only covers the rule set row itself — without this + /// check a locked preset could be rewritten one child row at a time. + /// + private async Task OwningPayRuleSetIsLocked(int payDayRuleId) + { + var payRuleSetName = await _dbContext.PayDayRules + .Where(pdr => pdr.Id == payDayRuleId) + .Select(pdr => pdr.PayRuleSet.Name) + .FirstOrDefaultAsync(); + + return payRuleSetName != null && PayRuleSetLock.IsLockedPresetName(payRuleSetName); } public async Task> Index(PayTierRulesRequestModel requestModel) @@ -113,6 +134,11 @@ public async Task Create(PayTierRuleCreateModel model) { try { + if (await OwningPayRuleSetIsLocked(model.PayDayRuleId)) + { + return new OperationResult(false, _localizationService.GetString("CannotEditLockedPreset")); + } + var rule = new PayTierRule { PayDayRuleId = model.PayDayRuleId, @@ -147,6 +173,11 @@ public async Task Update(int id, PayTierRuleUpdateModel model) return new OperationResult(false, "Pay tier rule not found"); } + if (await OwningPayRuleSetIsLocked(rule.PayDayRuleId)) + { + return new OperationResult(false, _localizationService.GetString("CannotEditLockedPreset")); + } + rule.Order = model.Order; rule.UpToSeconds = model.UpToSeconds; rule.PayCode = model.PayCode; @@ -175,6 +206,11 @@ public async Task Delete(int id) return new OperationResult(false, "Pay tier rule not found"); } + if (await OwningPayRuleSetIsLocked(rule.PayDayRuleId)) + { + return new OperationResult(false, _localizationService.GetString("CannotEditLockedPreset")); + } + await rule.Delete(_dbContext); return new OperationResult(true, "Pay tier rule deleted successfully"); diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PayTimeBandRuleService/PayTimeBandRuleService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PayTimeBandRuleService/PayTimeBandRuleService.cs index cd9a2076d..63c38c3e1 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PayTimeBandRuleService/PayTimeBandRuleService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PayTimeBandRuleService/PayTimeBandRuleService.cs @@ -9,6 +9,7 @@ namespace TimePlanning.Pn.Services.PayTimeBandRuleService; using System; using System.Linq; using System.Threading.Tasks; +using Infrastructure.Helpers; using Infrastructure.Models.PayTimeBandRule; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -16,18 +17,38 @@ namespace TimePlanning.Pn.Services.PayTimeBandRuleService; using Microting.eFormApi.BasePn.Infrastructure.Models.API; using Microting.TimePlanningBase.Infrastructure.Data; using Microting.TimePlanningBase.Infrastructure.Data.Entities; +using TimePlanning.Pn.Services.TimePlanningLocalizationService; public class PayTimeBandRuleService : IPayTimeBandRuleService { private readonly TimePlanningPnDbContext _dbContext; private readonly ILogger _logger; + private readonly ITimePlanningLocalizationService _localizationService; public PayTimeBandRuleService( TimePlanningPnDbContext dbContext, - ILogger logger) + ILogger logger, + ITimePlanningLocalizationService localizationService) { _dbContext = dbContext; _logger = logger; + _localizationService = localizationService; + } + + /// + /// A pay time band rule belongs to a PayDayTypeRule, which belongs to a + /// PayRuleSet. Locked overenskomst presets are read-only, and the guard on + /// PayRuleSetService only covers the rule set row itself — without this + /// check a locked preset could be rewritten one child row at a time. + /// + private async Task OwningPayRuleSetIsLocked(int payDayTypeRuleId) + { + var payRuleSetName = await _dbContext.PayDayTypeRules + .Where(pdtr => pdtr.Id == payDayTypeRuleId) + .Select(pdtr => pdtr.PayRuleSet.Name) + .FirstOrDefaultAsync(); + + return payRuleSetName != null && PayRuleSetLock.IsLockedPresetName(payRuleSetName); } public async Task> Index(PayTimeBandRulesRequestModel requestModel) @@ -114,6 +135,11 @@ public async Task Create(PayTimeBandRuleCreateModel model) { try { + if (await OwningPayRuleSetIsLocked(model.PayDayTypeRuleId)) + { + return new OperationResult(false, _localizationService.GetString("CannotEditLockedPreset")); + } + var rule = new PayTimeBandRule { PayDayTypeRuleId = model.PayDayTypeRuleId, @@ -149,6 +175,11 @@ public async Task Update(int id, PayTimeBandRuleUpdateModel mod return new OperationResult(false, "Pay time band rule not found"); } + if (await OwningPayRuleSetIsLocked(rule.PayDayTypeRuleId)) + { + return new OperationResult(false, _localizationService.GetString("CannotEditLockedPreset")); + } + rule.StartSecondOfDay = model.StartSecondOfDay; rule.EndSecondOfDay = model.EndSecondOfDay; rule.PayCode = model.PayCode; @@ -178,6 +209,11 @@ public async Task Delete(int id) return new OperationResult(false, "Pay time band rule not found"); } + if (await OwningPayRuleSetIsLocked(rule.PayDayTypeRuleId)) + { + return new OperationResult(false, _localizationService.GetString("CannotEditLockedPreset")); + } + await rule.Delete(_dbContext); return new OperationResult(true, "Pay time band rule deleted successfully"); diff --git a/eform-client/playwright/e2e/plugins/time-planning-pn/c/pay-rule-sets-lock-and-hhmm.spec.ts b/eform-client/playwright/e2e/plugins/time-planning-pn/c/pay-rule-sets-lock-and-hhmm.spec.ts new file mode 100644 index 000000000..500a2245a --- /dev/null +++ b/eform-client/playwright/e2e/plugins/time-planning-pn/c/pay-rule-sets-lock-and-hhmm.spec.ts @@ -0,0 +1,139 @@ +import { test, expect, Page } from '@playwright/test'; +import { LoginPage } from '../../../Page objects/Login.page'; + +const BASE_URL = 'http://localhost:4200'; + +// Two regressions on the pay-rule-sets screen: +// +// 1. Locked GLS-A presets became editable and deletable for rule sets stored +// under an older agreement period: lock status is a name-string match, and +// the preset catalogue was renamed "... 2024-2026" -> "... 2026-2029" +// without migrating existing rows. Matching now normalizes the trailing +// validity period away, so both spellings stay locked - in the row menu +// AND in the API (the client guard alone is not a guard). +// +// 2. Tier thresholds were edited and shown as raw seconds, and an unlimited +// top tier rendered as an empty field behind a "28800" placeholder, which +// reads as a real - and nonsensically low - value. Durations are now +// hours:minutes and unlimited says so in words. + +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 goToPayRuleSets(page: Page): Promise { + const loaded = page.waitForResponse( + r => r.url().includes('/api/time-planning-pn/pay-rule-sets') && r.request().method() === 'GET'); + await page.goto(`${BASE_URL}/plugins/time-planning-pn/pay-rule-sets`); + await loaded; + await page.locator('#time-planning-pn-pay-rule-sets-grid').waitFor({ state: 'visible', timeout: 30000 }); +} + +// The seeded set carries a locked preset name, so the API deliberately refuses +// to delete it again - that is the behaviour under test. CI runs each shard +// against a freshly loaded database, so the row does not outlive the run. +test.describe.serial('Pay rule sets - locked presets and hh:mm durations', () => { + let legacyId = 0; + + test.beforeEach(async ({ page }) => { + await page.goto(BASE_URL); + await new LoginPage(page).login(); + await page.waitForTimeout(2000); + }); + + test('seed: a rule set stored under the legacy agreement period', async ({ page }) => { + test.setTimeout(120000); + const headers = await apiHeaders(page); + // The unique suffix keeps parallel shards from colliding; the normalizer + // strips " 2024-2026" from the middle of the name only when it trails, so + // the seeded name is deliberately built to end with the period. + const name = 'GLS-A / 3F - Jordbrug Dyrehold 2024-2026'; + const res = await page.request.post(`${BASE_URL}/api/time-planning-pn/pay-rule-sets`, { + headers, + data: { + name, + payDayRules: [ + { + dayCode: 'WEEKDAY', + payTierRules: [ + { order: 1, upToSeconds: 26640, payCode: 'NORMAL' }, + { order: 2, upToSeconds: 33840, payCode: 'OVERTIME_30' }, + { order: 3, upToSeconds: null, payCode: 'OVERTIME_80' }, + ], + }, + ], + payDayTypeRules: [], + }, + }); + expect(res.status()).toBe(200); + + const list = await page.request.get(`${BASE_URL}/api/time-planning-pn/pay-rule-sets`, { headers }); + const rows = (await list.json()).model?.payRuleSets || (await list.json()).model || []; + const found = (Array.isArray(rows) ? rows : []).filter((r: any) => r.name === name); + expect(found.length, 'seeded rule set is listed').toBeGreaterThan(0); + legacyId = found[found.length - 1].id; + }); + + test('a legacy-named GLS-A set cannot be edited or deleted, but can be viewed', async ({ page }) => { + test.setTimeout(120000); + await goToPayRuleSets(page); + + const row = page.locator('.mat-mdc-row').filter({ hasText: 'Jordbrug Dyrehold 2024-2026' }).first(); + await expect(row).toBeVisible({ timeout: 30000 }); + await row.locator('button').first().click(); + + const menu = page.locator('.mat-mdc-menu-panel'); + await expect(menu).toBeVisible(); + // View stays available; edit and delete are disabled for a locked preset. + await expect(menu.locator('button').filter({ hasText: 'Vis' }).first()).toBeEnabled(); + await expect(menu.locator('button').filter({ hasText: 'Rediger' }).first()).toBeDisabled(); + await expect(menu.locator('button').filter({ hasText: 'Slet' }).first()).toBeDisabled(); + + await page.keyboard.press('Escape'); + }); + + test('the API rejects update and delete of a legacy-named locked preset', async ({ page }) => { + test.setTimeout(120000); + expect(legacyId, 'seeded id').toBeGreaterThan(0); + const headers = await apiHeaders(page); + + const update = await page.request.put(`${BASE_URL}/api/time-planning-pn/pay-rule-sets/${legacyId}`, { + headers, + data: { id: legacyId, name: 'Renamed by test', payDayRules: [], payDayTypeRules: [] }, + }); + expect((await update.json()).success, 'update rejected').toBe(false); + + const remove = await page.request.delete(`${BASE_URL}/api/time-planning-pn/pay-rule-sets/${legacyId}`, { headers }); + expect((await remove.json()).success, 'delete rejected').toBe(false); + + // Still there, still named as seeded. + const read = await page.request.get(`${BASE_URL}/api/time-planning-pn/pay-rule-sets/${legacyId}`, { headers }); + expect((await read.json()).model.name).toContain('Jordbrug Dyrehold 2024-2026'); + }); + + test('an unlimited top tier reads as "unlimited", never as a number', async ({ page }) => { + test.setTimeout(120000); + await goToPayRuleSets(page); + + const row = page.locator('.mat-mdc-row').filter({ hasText: 'Jordbrug Dyrehold 2024-2026' }).first(); + await row.locator('button').first().click(); + await page.locator('.mat-mdc-menu-panel button').filter({ hasText: 'Vis' }).first().click(); + + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toBeVisible({ timeout: 30000 }); + + // The weekday chain shows hh:mm-style durations and spells out the + // unbounded top tier instead of leaving a bare pay code (or a number). + const weekday = dialog.locator('tr').filter({ hasText: 'WEEKDAY' }).first(); + await expect(weekday).toContainText('7h24m'); + await expect(weekday).toContainText('9h24m'); + await expect(weekday).toContainText('OVERTIME_80'); + await expect(weekday).toContainText('Ubegrænset'); + // The old misleading placeholder value must not appear anywhere. + await expect(dialog).not.toContainText('28800'); + }); +}); 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 61ce44292..daf8fd466 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 @@ -348,11 +348,9 @@ export const bgBG = { 'Click "Add Day Type" to create your first rule.': 'Кликнете върху „Добавяне на тип ден“, за да създадете първото си правило.', 'Pay Tiers': 'Нива на заплащане', Tiers: 'Нива', - 'Tiers define time thresholds. E.g., Tier 1: up to 28800 seconds (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Нивата определят времеви прагове. Например, Ниво 1: до 28800 секунди (8 часа) = Редовно заплащане. Оставете „До“ празно за неограничено време.', 'Time Bands': 'Времеви ленти', 'Time Band Rules': 'Правила за времеви ленти', 'Time bands define pay code overrides for specific time ranges within the day. Use the time picker to select start and end times.': 'Времевите диапазони определят отменянията на кодовете за плащане за конкретни времеви диапазони в рамките на деня. Използвайте инструмента за избор на време, за да изберете начален и краен час.', - 'Up To (seconds)': 'До (секунди)', 'Default Pay Code': 'Код за плащане по подразбиране', 'Default pay code is required': 'Изисква се код за плащане по подразбиране', 'Please select a day type': 'Моля, изберете тип ден', @@ -467,4 +465,9 @@ export const bgBG = { 'No pay rule set selected': 'Няма избрано правило за плащане', 'Mobile time registration disabled': 'Регистрацията на мобилно време е деактивирана', 'Extra shifts': 'Допълнителни смени', + 'Up To (hh:mm)': 'До (чч:мм)', + Unlimited: 'Неограничен', + 'No tiers': 'Няма нива', + 'Enter a duration as hours:minutes, for example 7:24': 'Въведете продължителност като часове:минути, например 7:24', + 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Нивата определят времевите прагове като часове:минути. Например, Ниво 1: до 8:00 (8 часа) = Редовно заплащане. Оставете „До“ празно за неограничено.', }; 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 ea41c8f96..bc8102929 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 @@ -348,11 +348,9 @@ export const csCZ = { 'Click "Add Day Type" to create your first rule.': 'Klikněte na „Přidat typ dne“ a vytvořte si první pravidlo.', 'Pay Tiers': 'Platové úrovně', Tiers: 'Úrovně', - 'Tiers define time thresholds. E.g., Tier 1: up to 28800 seconds (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Úrovně definují časové prahy. Např. Úroveň 1: až 28 800 sekund (8 hodin) = běžná mzda. Pro neomezený čas nechte pole „Až“ prázdné.', 'Time Bands': 'Časová pásma', 'Time Band Rules': 'Pravidla časového pásma', 'Time bands define pay code overrides for specific time ranges within the day. Use the time picker to select start and end times.': 'Časová pásma definují přepsání platebních kódů pro konkrétní časové rozsahy v rámci dne. Pomocí nástroje pro výběr času vyberte čas zahájení a ukončení.', - 'Up To (seconds)': 'Až (sekundy)', 'Default Pay Code': 'Výchozí platební kód', 'Default pay code is required': 'Je vyžadován výchozí platební kód', 'Please select a day type': 'Vyberte prosím typ dne', @@ -467,4 +465,9 @@ export const csCZ = { 'No pay rule set selected': 'Nebylo vybráno žádné pravidlo pro platby', 'Mobile time registration disabled': 'Registrace mobilního času zakázána', 'Extra shifts': 'Další směny', + 'Up To (hh:mm)': 'Až do (hh:mm)', + Unlimited: 'Neomezený', + 'No tiers': 'Žádné úrovně', + 'Enter a duration as hours:minutes, for example 7:24': 'Zadejte dobu trvání ve formátu hodiny:minuty, například 7:24', + 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Úrovně definují časové prahy jako hodiny:minuty. Např. Úroveň 1: do 8:00 (8 hodin) = běžná mzda. Pro neomezený čas nechte pole „Do“ prázdné.', }; 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 da8988479..6392c63c1 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 @@ -349,11 +349,14 @@ export const da = { 'Click "Add Day Type" to create your first rule.': 'Klik "Tilføj dagtype" for at oprette din første regel.', 'Pay Tiers': 'Lønniveauer', Tiers: 'Niveauer', - 'Tiers define time thresholds. E.g., Tier 1: up to 28800 seconds (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Niveauer definerer tidsgrænser. F.eks.: Niveau 1: op til 28800 sekunder (8 timer) = Normalløn. Lad "Op til" være tom for ubegrænset.', 'Time Bands': 'Tidsintervaller', 'Time Band Rules': 'Tidsintervalregler', 'Time bands define pay code overrides for specific time ranges within the day. Use the time picker to select start and end times.': 'Tidsintervaller definerer lønkode-overskrivninger for specifikke tidsintervaller i løbet af dagen. Brug tidsvælgeren til at vælge start- og sluttider.', - 'Up To (seconds)': 'Op til (sekunder)', + 'Up To (hh:mm)': 'Op til (tt:mm)', + Unlimited: 'Ubegrænset', + 'No tiers': 'Ingen niveauer', + 'Enter a duration as hours:minutes, for example 7:24': 'Angiv en varighed som timer:minutter, for eksempel 7:24', + 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Niveauer definerer tidsgrænser som timer:minutter. F.eks.: Niveau 1: op til 8:00 (8 timer) = Normalløn. Lad "Op til" være tom for ubegrænset.', 'Default Pay Code': 'Standard lønkode', 'Default pay code is required': 'Standard lønkode er påkrævet', 'Please select a day type': 'Vælg en dagtype', 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 32982113b..91ac446d1 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 @@ -350,11 +350,9 @@ export const deDE = { 'Click "Add Day Type" to create your first rule.': 'Klicken Sie auf „Tagestyp hinzufügen“, um Ihre erste Regel zu erstellen.', 'Pay Tiers': 'Gehaltsstufen', Tiers: 'Stufen', - 'Tiers define time thresholds. E.g., Tier 1: up to 28800 seconds (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Stufen definieren Zeitlimits. Beispiel: Stufe 1: bis zu 28800 Sekunden (8 Stunden) = Normalgehalt. Lassen Sie „Bis zu“ leer, wenn unbegrenzt.', 'Time Bands': 'Zeitbänder', 'Time Band Rules': 'Zeitbandregeln', 'Time bands define pay code overrides for specific time ranges within the day. Use the time picker to select start and end times.': 'Zeitbänder definieren Lohncode-Überschreibungen für bestimmte Zeiträume innerhalb des Tages. Verwenden Sie die Zeitauswahl, um Start- und Endzeit auszuwählen.', - 'Up To (seconds)': 'Bis zu (Sekunden)', 'Default Pay Code': 'Standard-Zahlungscode', 'Default pay code is required': 'Ein Standard-Zahlungscode ist erforderlich.', 'Please select a day type': 'Bitte wählen Sie einen Tagestyp aus.', @@ -467,4 +465,9 @@ export const deDE = { 'No pay rule set selected': 'Keine Zahlungsregel ausgewählt', 'Mobile time registration disabled': 'Mobile Zeiterfassung deaktiviert', 'Extra shifts': 'Zusätzliche Schichten', + 'Up To (hh:mm)': 'Bis zu (hh:mm)', + Unlimited: 'Unbegrenzt', + 'No tiers': 'Keine Stufen', + 'Enter a duration as hours:minutes, for example 7:24': 'Geben Sie eine Dauer im Format Stunden:Minuten ein, zum Beispiel 7:24', + 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Die Stufen definieren Zeitgrenzen in Stunden:Minuten. Beispiel: Stufe 1: bis zu 8:00 Uhr (8 Stunden) = Normalgehalt. Lassen Sie „Bis zu“ leer, wenn Sie unbegrenzt arbeiten möchten.', }; 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 a3966611c..58334586c 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 @@ -348,11 +348,9 @@ export const elGR = { 'Click "Add Day Type" to create your first rule.': 'Κάντε κλικ στην επιλογή "Προσθήκη τύπου ημέρας" για να δημιουργήσετε τον πρώτο σας κανόνα.', 'Pay Tiers': 'Επίπεδα αμοιβών', Tiers: 'Επίπεδα', - 'Tiers define time thresholds. E.g., Tier 1: up to 28800 seconds (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Τα επίπεδα ορίζουν χρονικά όρια. Π.χ., Επίπεδο 1: έως 28800 δευτερόλεπτα (8 ώρες) = Κανονική αμοιβή. Αφήστε το "Έως" κενό για απεριόριστο.', 'Time Bands': 'Χρονικές ζώνες', 'Time Band Rules': 'Κανόνες Χρονικής Ζώνης', 'Time bands define pay code overrides for specific time ranges within the day. Use the time picker to select start and end times.': 'Οι χρονικές ζώνες ορίζουν παρακάμψεις κωδικών πληρωμής για συγκεκριμένα χρονικά εύρη εντός της ημέρας. Χρησιμοποιήστε τον επιλογέα ώρας για να επιλέξετε ώρες έναρξης και λήξης.', - 'Up To (seconds)': 'Έως (δευτερόλεπτα)', 'Default Pay Code': 'Προεπιλεγμένος κωδικός πληρωμής', 'Default pay code is required': 'Απαιτείται ο προεπιλεγμένος κωδικός πληρωμής', 'Please select a day type': 'Επιλέξτε έναν τύπο ημέρας', @@ -467,4 +465,9 @@ export const elGR = { 'No pay rule set selected': 'Δεν έχει οριστεί κανόνας πληρωμής', 'Mobile time registration disabled': 'Η εγγραφή χρόνου στο κινητό απενεργοποιήθηκε.', 'Extra shifts': 'Επιπλέον βάρδιες', + 'Up To (hh:mm)': 'Έως (ωω:λλ)', + Unlimited: 'Απεριόριστος', + 'No tiers': 'Χωρίς επίπεδα', + 'Enter a duration as hours:minutes, for example 7:24': 'Εισαγάγετε μια διάρκεια ως ώρες:λεπτά, για παράδειγμα 7:24', + 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Τα επίπεδα ορίζουν τα χρονικά όρια ως ώρες:λεπτά. Π.χ., Επίπεδο 1: έως 8:00 (8 ώρες) = Κανονική αμοιβή. Αφήστε το "Έως" κενό για απεριόριστο.', }; 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 03b9f4ab3..43cf8901d 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 @@ -351,11 +351,14 @@ export const enUS = { 'Click "Add Day Type" to create your first rule.': 'Click "Add Day Type" to create your first rule.', 'Pay Tiers': 'Pay Tiers', 'Tiers': 'Tiers', - 'Tiers define time thresholds. E.g., Tier 1: up to 28800 seconds (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Tiers define time thresholds. E.g., Tier 1: up to 28800 seconds (8 hours) = Regular pay. Leave "Up To" empty for unlimited.', 'Time Bands': 'Time Bands', 'Time Band Rules': 'Time Band Rules', 'Time bands define pay code overrides for specific time ranges within the day. Use the time picker to select start and end times.': 'Time bands define pay code overrides for specific time ranges within the day. Use the time picker to select start and end times.', - 'Up To (seconds)': 'Up To (seconds)', + 'Up To (hh:mm)': 'Up To (hh:mm)', + Unlimited: 'Unlimited', + 'No tiers': 'No tiers', + 'Enter a duration as hours:minutes, for example 7:24': 'Enter a duration as hours:minutes, for example 7:24', + 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.', 'Default Pay Code': 'Default Pay Code', 'Default pay code is required': 'Default pay code is required', 'Please select a day type': 'Please select a day type', 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 23bc33b49..f371149eb 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 @@ -348,11 +348,9 @@ export const esES = { 'Click "Add Day Type" to create your first rule.': 'Haz clic en "Agregar tipo de día" para crear tu primera regla.', 'Pay Tiers': 'Niveles salariales', Tiers: 'Niveles', - 'Tiers define time thresholds. E.g., Tier 1: up to 28800 seconds (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Los niveles definen los límites de tiempo. Por ejemplo, Nivel 1: hasta 28800 segundos (8 horas) = Pago regular. Deje "Hasta" en blanco para un tiempo ilimitado.', 'Time Bands': 'Bandas de tiempo', 'Time Band Rules': 'Reglas de la banda horaria', 'Time bands define pay code overrides for specific time ranges within the day. Use the time picker to select start and end times.': 'Las franjas horarias definen las anulaciones de códigos de pago para rangos de tiempo específicos dentro del día. Utilice el selector de hora para seleccionar la hora de inicio y la hora de finalización.', - 'Up To (seconds)': 'Hasta (segundos)', 'Default Pay Code': 'Código de pago predeterminado', 'Default pay code is required': 'Se requiere el código de pago predeterminado.', 'Please select a day type': 'Seleccione un tipo de día', @@ -467,4 +465,9 @@ export const esES = { 'No pay rule set selected': 'No se ha seleccionado ningún conjunto de reglas de pago.', 'Mobile time registration disabled': 'Registro de tiempo móvil deshabilitado', 'Extra shifts': 'Turnos extra', + 'Up To (hh:mm)': 'Hasta (hh:mm)', + Unlimited: 'Ilimitado', + 'No tiers': 'Sin niveles', + 'Enter a duration as hours:minutes, for example 7:24': 'Introduzca una duración en formato horas:minutos, por ejemplo 7:24.', + 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Los niveles definen los umbrales de tiempo como horas:minutos. Por ejemplo, Nivel 1: hasta las 8:00 (8 horas) = Pago regular. Deje "Hasta" en blanco para un período ilimitado.', }; 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 6641b4d64..1b3f9a214 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 @@ -348,11 +348,9 @@ export const etET = { 'Click "Add Day Type" to create your first rule.': 'Esimese reegli loomiseks klõpsake nuppu „Lisa päeva tüüp”.', 'Pay Tiers': 'Palgatasemed', Tiers: 'Tasemed', - 'Tiers define time thresholds. E.g., Tier 1: up to 28800 seconds (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Tasemed määravad ajaläve. Näiteks 1. tase: kuni 28 800 sekundit (8 tundi) = tavapärane palk. Piiramatu aja saamiseks jätke "Kuni" tühjaks.', 'Time Bands': 'Ajavööndid', 'Time Band Rules': 'Ajavahemiku reeglid', 'Time bands define pay code overrides for specific time ranges within the day. Use the time picker to select start and end times.': 'Ajavööndid määravad palgakoodi tühistamised päeva teatud ajavahemike jooksul. Algus- ja lõppaja valimiseks kasutage ajavalijat.', - 'Up To (seconds)': 'Kuni (sekundites)', 'Default Pay Code': 'Vaikimisi palgakood', 'Default pay code is required': 'Vaikimisi maksekood on nõutav', 'Please select a day type': 'Palun valige päeva tüüp', @@ -467,4 +465,9 @@ export const etET = { 'No pay rule set selected': 'Palgareeglit pole valitud', 'Mobile time registration disabled': 'Mobiilne aja registreerimine on keelatud', 'Extra shifts': 'Lisavahetused', + 'Up To (hh:mm)': 'Kuni (hh:mm)', + Unlimited: 'Piiramatu', + 'No tiers': 'Tasemeid pole', + 'Enter a duration as hours:minutes, for example 7:24': 'Sisesta kestus kujul tunnid:minutid, näiteks 7:24', + 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Tasemed defineerivad ajalävesid tundide ja minutitena. Näiteks 1. tasand: kuni kella 8.00-ni (8 tundi) = regulaarne palk. Piiramatu aja saamiseks jätke "Kuni" tühjaks.', }; 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 330ebff92..6222c161b 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 @@ -348,11 +348,9 @@ export const fiFI = { 'Click "Add Day Type" to create your first rule.': 'Luo ensimmäinen sääntösi napsauttamalla "Lisää päivätyyppi".', 'Pay Tiers': 'Palkkatasot', Tiers: 'Tasot', - 'Tiers define time thresholds. E.g., Tier 1: up to 28800 seconds (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Tasot määrittelevät aikarajat. Esim. Taso 1: jopa 28 800 sekuntia (8 tuntia) = Normaali palkka. Jätä "Jopa" tyhjäksi, jos haluat rajoittamattoman ajan.', 'Time Bands': 'Aikavyöhykkeet', 'Time Band Rules': 'Aikavyöhykkeen säännöt', 'Time bands define pay code overrides for specific time ranges within the day. Use the time picker to select start and end times.': 'Aikavyöhykkeet määrittävät maksukoodien ohitukset tietyille aikaväleille päivän sisällä. Valitse aloitus- ja päättymisajat aikavalitsimella.', - 'Up To (seconds)': 'Jopa (sekuntia)', 'Default Pay Code': 'Oletusmaksukoodi', 'Default pay code is required': 'Oletusmaksukoodi on pakollinen', 'Please select a day type': 'Valitse päivän tyyppi', @@ -467,4 +465,9 @@ export const fiFI = { 'No pay rule set selected': 'Ei valittua maksusääntöä', 'Mobile time registration disabled': 'Mobiiliajan rekisteröinti poistettu käytöstä', 'Extra shifts': 'Ylimääräiset vuorot', + 'Up To (hh:mm)': 'Jopa (hh:mm)', + Unlimited: 'Rajoittamaton', + 'No tiers': 'Ei tasoja', + 'Enter a duration as hours:minutes, for example 7:24': 'Syötä kesto muodossa tunnit:minuutit, esimerkiksi 7:24', + 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Tasot määrittelevät aikarajat muodossa tunnit:minuutit. Esim. Taso 1: klo 8.00 asti (8 tuntia) = Normaali palkka. Jätä "Jopa" tyhjäksi, jos haluat rajoittamattoman ajan.', }; 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 c3834bce3..b6cf20ad1 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 @@ -348,11 +348,9 @@ export const frFR = { 'Click "Add Day Type" to create your first rule.': 'Cliquez sur « Ajouter un type de jour » pour créer votre première règle.', 'Pay Tiers': 'Niveaux de rémunération', Tiers: 'Niveaux', - 'Tiers define time thresholds. E.g., Tier 1: up to 28800 seconds (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Les paliers définissent des seuils temporels. Par exemple, palier 1 : jusqu’à 28 800 secondes (8 heures) = rémunération normale. Laissez le champ « Jusqu’à » vide pour une durée illimitée.', 'Time Bands': 'Bandes horaires', 'Time Band Rules': 'Règles de la plage horaire', 'Time bands define pay code overrides for specific time ranges within the day. Use the time picker to select start and end times.': 'Les plages horaires définissent les modifications des codes de paie pour des plages horaires spécifiques au sein de la journée. Utilisez le sélecteur d'heure pour choisir les heures de début et de fin.', - 'Up To (seconds)': 'Jusqu'à (secondes)', 'Default Pay Code': 'Code de paiement par défaut', 'Default pay code is required': 'Le code de paiement par défaut est requis.', 'Please select a day type': 'Veuillez sélectionner un type de jour', @@ -467,4 +465,9 @@ export const frFR = { 'No pay rule set selected': 'Aucune règle de rémunération sélectionnée', 'Mobile time registration disabled': 'Enregistrement de l\'heure mobile désactivé', 'Extra shifts': 'Équipes supplémentaires', + 'Up To (hh:mm)': 'Jusqu’à (hh:mm)', + Unlimited: 'Illimité', + 'No tiers': 'Pas de niveaux', + 'Enter a duration as hours:minutes, for example 7:24': 'Saisissez une durée au format heures:minutes, par exemple 7:24', + 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Les paliers définissent des seuils temporels en heures:minutes. Ex. : Palier 1 : jusqu’à 8 h 00 (8 heures) = Rémunération normale. Laissez le champ « Jusqu’à » vide pour une durée illimitée.', }; 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 d7b2f1773..a7296c47e 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 @@ -348,11 +348,9 @@ export const hrHR = { 'Click "Add Day Type" to create your first rule.': 'Kliknite "Dodaj vrstu dana" da biste stvorili svoje prvo pravilo.', 'Pay Tiers': 'Platne razine', Tiers: 'Slojevi', - 'Tiers define time thresholds. E.g., Tier 1: up to 28800 seconds (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Razine definiraju vremenske pragove. Npr. Razina 1: do 28800 sekundi (8 sati) = Redovna plaća. Ostavite "Do" prazno za neograničeno.', 'Time Bands': 'Vremenski pojasevi', 'Time Band Rules': 'Pravila vremenskog pojasa', 'Time bands define pay code overrides for specific time ranges within the day. Use the time picker to select start and end times.': 'Vremenski rasponi definiraju nadjačavanja kodova plaćanja za određene vremenske raspone unutar dana. Pomoću alata za odabir vremena odaberite vrijeme početka i završetka.', - 'Up To (seconds)': 'Do (sekundi)', 'Default Pay Code': 'Zadani kod plaćanja', 'Default pay code is required': 'Potreban je zadani kod za plaćanje', 'Please select a day type': 'Molimo odaberite vrstu dana', @@ -467,4 +465,9 @@ export const hrHR = { 'No pay rule set selected': 'Nije odabrano nijedno pravilo plaćanja', 'Mobile time registration disabled': 'Onemogućena je registracija vremena na mobilnom uređaju', 'Extra shifts': 'Dodatne smjene', + 'Up To (hh:mm)': 'Do (hh:mm)', + Unlimited: 'Neograničen', + 'No tiers': 'Nema slojeva', + 'Enter a duration as hours:minutes, for example 7:24': 'Unesite trajanje u formatu sati:minute, na primjer 7:24', + 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Razine definiraju vremenske pragove kao sati:minute. Npr. Razina 1: do 8:00 (8 sati) = Redovna plaća. Ostavite "Do" prazno za neograničeno.', }; 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 7400f4949..ce1181d1d 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 @@ -348,11 +348,9 @@ export const huHU = { 'Click "Add Day Type" to create your first rule.': 'Az első szabály létrehozásához kattintson a „Naptípus hozzáadása” gombra.', 'Pay Tiers': 'Fizetési szintek', Tiers: 'Szintek', - 'Tiers define time thresholds. E.g., Tier 1: up to 28800 seconds (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'A szintek időküszöböket határoznak meg. Pl. 1. szint: legfeljebb 28800 másodperc (8 óra) = Normál fizetés. Korlátlan időhöz hagyja üresen az „Egész” mezőt.', 'Time Bands': 'Idősávok', 'Time Band Rules': 'Idősáv-szabályok', 'Time bands define pay code overrides for specific time ranges within the day. Use the time picker to select start and end times.': 'Az idősávok határozzák meg a fizetési kódok felülbírálását a nap adott időtartományaira. Az időválasztóval válassza ki a kezdési és befejezési időpontokat.', - 'Up To (seconds)': 'Legfeljebb (másodperc)', 'Default Pay Code': 'Alapértelmezett fizetési kód', 'Default pay code is required': 'Az alapértelmezett fizetési kód megadása kötelező', 'Please select a day type': 'Kérjük, válasszon egy naptípust', @@ -467,4 +465,9 @@ export const huHU = { 'No pay rule set selected': 'Nincs kiválasztva fizetési szabály', 'Mobile time registration disabled': 'Mobil időregisztráció letiltva', 'Extra shifts': 'Extra műszakok', + 'Up To (hh:mm)': 'Akár (óó:pp)', + Unlimited: 'Korlátlan', + 'No tiers': 'Nincsenek szintek', + 'Enter a duration as hours:minutes, for example 7:24': 'Adja meg az időtartamot óra:perc formátumban, például 7:24', + 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'A szintek óra:perc formátumban határozzák meg az időküszöböket. Pl. 1. szint: 8:00-ig (8 óra) = Rendszeres fizetés. Korlátlan időhöz hagyja üresen az „Egész” mezőt.', }; 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 3d0a6f22b..938a9f1c9 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 @@ -348,11 +348,9 @@ export const isIS = { 'Click "Add Day Type" to create your first rule.': 'Smelltu á „Bæta við dagtegund“ til að búa til fyrstu regluna þína.', 'Pay Tiers': 'Launaþrep', Tiers: 'Þrep', - 'Tiers define time thresholds. E.g., Tier 1: up to 28800 seconds (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Þrep skilgreina tímamörk. T.d. Þrep 1: allt að 28800 sekúndur (8 klukkustundir) = Venjuleg greiðsla. Skiljið „Allt að“ eftir autt fyrir ótakmarkaða tíma.', 'Time Bands': 'Tímabönd', 'Time Band Rules': 'Reglur um tímaband', 'Time bands define pay code overrides for specific time ranges within the day. Use the time picker to select start and end times.': 'Tímabil skilgreina yfirskriftir launakóða fyrir tiltekin tímabil innan dags. Notið tímavalið til að velja upphafs- og lokatíma.', - 'Up To (seconds)': 'Allt að (sekúndur)', 'Default Pay Code': 'Sjálfgefinn greiðslukóði', 'Default pay code is required': 'Sjálfgefinn greiðslukóði er krafist', 'Please select a day type': 'Veldu dagsgerð', @@ -467,4 +465,9 @@ export const isIS = { 'No pay rule set selected': 'Engin launaregla valin', 'Mobile time registration disabled': 'Tímaskráning í farsíma óvirk', 'Extra shifts': 'Aukavaktir', + 'Up To (hh:mm)': 'Upp að (klst:mm)', + Unlimited: 'Ótakmarkað', + 'No tiers': 'Engin stig', + 'Enter a duration as hours:minutes, for example 7:24': 'Sláðu inn tímalengd sem klukkustundir:mínútur, til dæmis 7:24', + 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Þrep skilgreina tímamörk sem klukkustundir:mínútur. T.d. Þrep 1: allt að 8:00 (8 klukkustundir) = Venjuleg laun. Skiljið „Allt að“ eftir autt fyrir ótakmarkaðan tíma.', }; 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 5ada3ed31..35894c270 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 @@ -348,11 +348,9 @@ export const itIT = { 'Click "Add Day Type" to create your first rule.': 'Fai clic su "Aggiungi tipo di giorno" per creare la tua prima regola.', 'Pay Tiers': 'Livelli retributivi', Tiers: 'Livelli', - 'Tiers define time thresholds. E.g., Tier 1: up to 28800 seconds (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'I livelli definiscono le soglie temporali. Ad esempio, Livello 1: fino a 28800 secondi (8 ore) = Retribuzione ordinaria. Lascia vuoto il campo "Fino a" per una retribuzione illimitata.', 'Time Bands': 'Fasce orarie', 'Time Band Rules': 'Regole della fascia oraria', 'Time bands define pay code overrides for specific time ranges within the day. Use the time picker to select start and end times.': 'Le fasce orarie definiscono le eccezioni al codice di pagamento per specifici intervalli di tempo durante la giornata. Utilizza il selettore orario per scegliere l'ora di inizio e di fine.', - 'Up To (seconds)': 'Fino a (secondi)', 'Default Pay Code': 'Codice di pagamento predefinito', 'Default pay code is required': 'È richiesto il codice di pagamento predefinito', 'Please select a day type': 'Seleziona un tipo di giorno', @@ -467,4 +465,9 @@ export const itIT = { 'No pay rule set selected': 'Nessuna regola di pagamento selezionata', 'Mobile time registration disabled': 'Registrazione presenze tramite dispositivo mobile disabilitata', 'Extra shifts': 'Turni extra', + 'Up To (hh:mm)': 'Fino a (hh:mm)', + Unlimited: 'Illimitato', + 'No tiers': 'Nessun livello', + 'Enter a duration as hours:minutes, for example 7:24': 'Inserisci la durata nel formato ore:minuti, ad esempio 7:24', + 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'I livelli definiscono le soglie temporali in ore:minuti. Ad esempio, Livello 1: fino a 8:00 (8 ore) = Retribuzione ordinaria. Lasciare vuoto il campo "Fino a" per una retribuzione illimitata.', }; 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 15e527d0a..d851bace9 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 @@ -348,11 +348,9 @@ export const ltLT = { 'Click "Add Day Type" to create your first rule.': 'Norėdami sukurti pirmąją taisyklę, spustelėkite „Pridėti dienos tipą“.', 'Pay Tiers': 'Mokėjimo pakopos', Tiers: 'Pakopos', - 'Tiers define time thresholds. E.g., Tier 1: up to 28800 seconds (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Pakopos apibrėžia laiko ribas. Pvz., 1 pakopa: iki 28 800 sekundžių (8 valandos) = įprastas atlyginimas. Palikite laukelį „Iki“ tuščią, jei norite neriboto laiko.', 'Time Bands': 'Laiko juostos', 'Time Band Rules': 'Laiko juostos taisyklės', 'Time bands define pay code overrides for specific time ranges within the day. Use the time picker to select start and end times.': 'Laiko juostos apibrėžia mokėjimo kodo pakeitimus konkretiems dienos laiko intervalams. Norėdami pasirinkti pradžios ir pabaigos laiką, naudokite laiko parinkiklį.', - 'Up To (seconds)': 'Iki (sekundės)', 'Default Pay Code': 'Numatytasis mokėjimo kodas', 'Default pay code is required': 'Būtinas numatytasis mokėjimo kodas', 'Please select a day type': 'Pasirinkite dienos tipą', @@ -467,4 +465,9 @@ export const ltLT = { 'No pay rule set selected': 'Nepasirinkta mokėjimo taisyklė', 'Mobile time registration disabled': 'Mobiliojo laiko registracija išjungta', 'Extra shifts': 'Papildomos pamainos', + 'Up To (hh:mm)': 'Iki (hh:mm)', + Unlimited: 'Neribotas', + 'No tiers': 'Nėra pakopų', + 'Enter a duration as hours:minutes, for example 7:24': 'Įveskite trukmę valandų:minutių formatu, pavyzdžiui, 7:24', + 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Pakopos apibrėžia laiko ribas kaip valandas:minutes. Pvz., 1 pakopa: iki 8:00 (8 valandos) = Įprastas atlyginimas. Palikite laukelį „Iki“ tuščią, jei norite neriboto laiko.', }; 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 398405993..1e6fed923 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 @@ -348,11 +348,9 @@ export const lvLV = { 'Click "Add Day Type" to create your first rule.': 'Noklikšķiniet uz "Pievienot dienas veidu", lai izveidotu savu pirmo noteikumu.', 'Pay Tiers': 'Maksājumu līmeņi', Tiers: 'Līmeņi', - 'Tiers define time thresholds. E.g., Tier 1: up to 28800 seconds (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Līmeņi nosaka laika sliekšņus. Piemēram, 1. līmenis: līdz 28 800 sekundēm (8 stundas) = regulāra samaksa. Lai iegūtu neierobežotu laiku, atstājiet lauku “Līdz” tukšu.', 'Time Bands': 'Laika joslas', 'Time Band Rules': 'Laika joslas noteikumi', 'Time bands define pay code overrides for specific time ranges within the day. Use the time picker to select start and end times.': 'Laika joslas nosaka algas koda ignorēšanu konkrētiem laika diapazoniem dienas ietvaros. Izmantojiet laika atlasītāju, lai atlasītu sākuma un beigu laikus.', - 'Up To (seconds)': 'Līdz (sekundes)', 'Default Pay Code': 'Noklusējuma algas kods', 'Default pay code is required': 'Obligāti jānorāda noklusējuma algas kods', 'Please select a day type': 'Lūdzu, atlasiet dienas veidu', @@ -467,4 +465,9 @@ export const lvLV = { 'No pay rule set selected': 'Nav atlasīts neviens algas noteikums', 'Mobile time registration disabled': 'Mobilā laika reģistrācija ir atspējota', 'Extra shifts': 'Papildu maiņas', + 'Up To (hh:mm)': 'Līdz (hh:mm)', + Unlimited: 'Neierobežots', + 'No tiers': 'Nav līmeņu', + 'Enter a duration as hours:minutes, for example 7:24': 'Ievadiet ilgumu kā stundas:minūtes, piemēram, 7:24', + 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Līmeņi definē laika sliekšņus kā stundas:minūtes. Piemēram, 1. līmenis: līdz plkst. 8:00 (8 stundas) = regulāra samaksa. Lai iegūtu neierobežotu laiku, atstājiet lauku "Līdz" tukšu.', }; 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 9d3b81a02..6a4902e4a 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 @@ -348,11 +348,9 @@ export const nlNL = { 'Click "Add Day Type" to create your first rule.': 'Klik op "Dagtype toevoegen" om je eerste regel aan te maken.', 'Pay Tiers': 'Salarisniveaus', Tiers: 'Niveaus', - 'Tiers define time thresholds. E.g., Tier 1: up to 28800 seconds (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'De niveaus definiëren tijdslimieten. Bijvoorbeeld: Niveau 1: tot 28800 seconden (8 uur) = Normale betaling. Laat "Tot" leeg voor onbeperkt.', 'Time Bands': 'Tijdsbanden', 'Time Band Rules': 'Tijdbandregels', 'Time bands define pay code overrides for specific time ranges within the day. Use the time picker to select start and end times.': 'Tijdsblokken definiëren uitzonderingen op de looncode voor specifieke tijdsperioden binnen de dag. Gebruik de tijdkiezer om de begin- en eindtijd te selecteren.', - 'Up To (seconds)': 'Tot (seconden)', 'Default Pay Code': 'Standaard betaalcode', 'Default pay code is required': 'Standaard betaalcode vereist', 'Please select a day type': 'Selecteer een dagtype', @@ -467,4 +465,9 @@ export const nlNL = { 'No pay rule set selected': 'Geen betalingsregelset geselecteerd', 'Mobile time registration disabled': 'Mobiele tijdregistratie uitgeschakeld', 'Extra shifts': 'Extra diensten', + 'Up To (hh:mm)': 'Tot (uh:mm)', + Unlimited: 'Onbeperkt', + 'No tiers': 'Geen niveaus', + 'Enter a duration as hours:minutes, for example 7:24': 'Voer een tijdsduur in als uren:minuten, bijvoorbeeld 7:24', + 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'De niveaus definiëren tijdslimieten in uren:minuten. Bijvoorbeeld: Niveau 1: tot 8:00 (8 uur) = Normaal loon. Laat "Tot" leeg voor onbeperkt.', }; 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 c0b3daaa5..bb362c58b 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 @@ -348,11 +348,9 @@ export const noNO = { 'Click "Add Day Type" to create your first rule.': 'Klikk på «Legg til dagstype» for å opprette din første regel.', 'Pay Tiers': 'Betalingsnivåer', Tiers: 'Nivåer', - 'Tiers define time thresholds. E.g., Tier 1: up to 28800 seconds (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Nivåer definerer tidsgrenser. F.eks. Nivå 1: opptil 28 800 sekunder (8 timer) = Vanlig lønn. La «Opptil» stå tomt for ubegrenset tid.', 'Time Bands': 'Tidsbånd', 'Time Band Rules': 'Regler for tidsbånd', 'Time bands define pay code overrides for specific time ranges within the day. Use the time picker to select start and end times.': 'Tidsintervaller definerer overstyringer av lønnskoder for bestemte tidsperioder i løpet av dagen. Bruk tidsvelgeren til å velge start- og sluttidspunkter.', - 'Up To (seconds)': 'Opptil (sekunder)', 'Default Pay Code': 'Standard betalingskode', 'Default pay code is required': 'Standard betalingskode kreves', 'Please select a day type': 'Vennligst velg en dagstype', @@ -467,4 +465,9 @@ export const noNO = { 'No pay rule set selected': 'Ingen betalingsregel er valgt', 'Mobile time registration disabled': 'Mobil tidsregistrering deaktivert', 'Extra shifts': 'Ekstravakter', + 'Up To (hh:mm)': 'Opptil (tt:mm)', + Unlimited: 'Ubegrenset', + 'No tiers': 'Ingen nivåer', + 'Enter a duration as hours:minutes, for example 7:24': 'Skriv inn en varighet som timer:minutter, for eksempel 7:24', + 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Nivåer definerer tidsgrenser som timer:minutter. F.eks. Nivå 1: opptil 8:00 (8 timer) = Vanlig lønn. La «Opptil» stå tomt for ubegrenset tid.', }; 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 8d4fffa7f..333d8d028 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 @@ -348,11 +348,9 @@ export const plPL = { 'Click "Add Day Type" to create your first rule.': 'Kliknij „Dodaj typ dnia”, aby utworzyć pierwszą regułę.', 'Pay Tiers': 'Poziomy płatności', Tiers: 'Schody', - 'Tiers define time thresholds. E.g., Tier 1: up to 28800 seconds (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Poziomy określają progi czasowe. Np. Poziom 1: do 28 800 sekund (8 godzin) = płaca regularna. Pozostaw pole „Do” puste, aby uzyskać nieograniczony czas.', 'Time Bands': 'Pasma czasowe', 'Time Band Rules': 'Zasady przedziału czasowego', 'Time bands define pay code overrides for specific time ranges within the day. Use the time picker to select start and end times.': 'Przedziały czasowe definiują nadpisania kodów płatności dla określonych przedziałów czasowych w ciągu dnia. Użyj selektora czasu, aby wybrać godzinę rozpoczęcia i zakończenia.', - 'Up To (seconds)': 'Do (sekund)', 'Default Pay Code': 'Domyślny kod płatności', 'Default pay code is required': 'Wymagany jest domyślny kod płatności', 'Please select a day type': 'Proszę wybrać typ dnia', @@ -467,4 +465,9 @@ export const plPL = { 'No pay rule set selected': 'Nie wybrano zestawu reguł płatności', 'Mobile time registration disabled': 'Rejestracja czasu mobilnego wyłączona', 'Extra shifts': 'Dodatkowe zmiany', + 'Up To (hh:mm)': 'Do (gg:mm)', + Unlimited: 'Nieograniczony', + 'No tiers': 'Brak poziomów', + 'Enter a duration as hours:minutes, for example 7:24': 'Wprowadź czas trwania w formacie godziny:minuty, na przykład 7:24', + 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Poziomy definiują progi czasowe w postaci godzin:minut. Np. Poziom 1: do 8:00 (8 godzin) = płaca regularna. Pozostaw pole „Do”, aby uzyskać nieograniczony czas.', }; 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 cab07dbae..7b0a54150 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 @@ -348,11 +348,9 @@ export const ptBR = { 'Click "Add Day Type" to create your first rule.': 'Clique em "Adicionar tipo de dia" para criar sua primeira regra.', 'Pay Tiers': 'Níveis de remuneração', Tiers: 'Níveis', - 'Tiers define time thresholds. E.g., Tier 1: up to 28800 seconds (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Os níveis definem limites de tempo. Por exemplo, Nível 1: até 28.800 segundos (8 horas) = Pagamento normal. Deixe "Até" em branco para pagamento ilimitado.', 'Time Bands': 'Faixas de horário', 'Time Band Rules': 'Regras de Faixa Temporal', 'Time bands define pay code overrides for specific time ranges within the day. Use the time picker to select start and end times.': 'As faixas horárias definem as alterações nos códigos de pagamento para intervalos de tempo específicos dentro do dia. Use o seletor de horário para escolher os horários de início e término.', - 'Up To (seconds)': 'Até (segundos)', 'Default Pay Code': 'Código de pagamento padrão', 'Default pay code is required': 'É necessário um código de pagamento padrão.', 'Please select a day type': 'Por favor, selecione um tipo de dia.', @@ -467,4 +465,9 @@ export const ptBR = { 'No pay rule set selected': 'Nenhuma regra de pagamento selecionada', 'Mobile time registration disabled': 'Registro de tempo móvel desativado', 'Extra shifts': 'Turnos extras', + 'Up To (hh:mm)': 'Até (hh:mm)', + Unlimited: 'Ilimitado', + 'No tiers': 'Sem níveis', + 'Enter a duration as hours:minutes, for example 7:24': 'Insira a duração no formato horas:minutos, por exemplo, 7:24.', + 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Os níveis definem limites de tempo em horas:minutos. Exemplo: Nível 1: até 8:00 (8 horas) = Pagamento normal. Deixe "Até" em branco para pagamento ilimitado.', }; 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 45d0dd012..c3617f4b9 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 @@ -348,11 +348,9 @@ export const ptPT = { 'Click "Add Day Type" to create your first rule.': 'Clique em "Adicionar tipo de dia" para criar sua primeira regra.', 'Pay Tiers': 'Níveis de remuneração', Tiers: 'Níveis', - 'Tiers define time thresholds. E.g., Tier 1: up to 28800 seconds (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Os níveis definem limites de tempo. Por exemplo, Nível 1: até 28.800 segundos (8 horas) = Pagamento normal. Deixe "Até" em branco para pagamento ilimitado.', 'Time Bands': 'Faixas de horário', 'Time Band Rules': 'Regras de Faixa Temporal', 'Time bands define pay code overrides for specific time ranges within the day. Use the time picker to select start and end times.': 'As faixas horárias definem as alterações nos códigos de pagamento para intervalos de tempo específicos dentro do dia. Use o seletor de horário para escolher os horários de início e término.', - 'Up To (seconds)': 'Até (segundos)', 'Default Pay Code': 'Código de pagamento padrão', 'Default pay code is required': 'É necessário um código de pagamento padrão.', 'Please select a day type': 'Por favor, selecione um tipo de dia.', @@ -467,4 +465,9 @@ export const ptPT = { 'No pay rule set selected': 'Nenhuma regra de pagamento definida', 'Mobile time registration disabled': 'Registro de tempo móvel desativado', 'Extra shifts': 'Turnos extras', + 'Up To (hh:mm)': 'Até (hh:mm)', + Unlimited: 'Ilimitado', + 'No tiers': 'Sem níveis', + 'Enter a duration as hours:minutes, for example 7:24': 'Insira a duração no formato horas:minutos, por exemplo, 7:24.', + 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Os níveis definem limites de tempo em horas:minutos. Exemplo: Nível 1: até 8:00 (8 horas) = Pagamento normal. Deixe "Até" em branco para pagamento ilimitado.', }; 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 859ace999..8b2446be7 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 @@ -348,11 +348,9 @@ export const roRO = { 'Click "Add Day Type" to create your first rule.': 'Faceți clic pe „Adăugați tip de zi” pentru a crea prima regulă.', 'Pay Tiers': 'Niveluri de plată', Tiers: 'Niveluri', - 'Tiers define time thresholds. E.g., Tier 1: up to 28800 seconds (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Nivelurile definesc praguri de timp. De exemplu, Nivelul 1: până la 28800 de secunde (8 ore) = Salariu obișnuit. Lăsați „Până la” gol pentru un timp nelimitat.', 'Time Bands': 'Intervale de timp', 'Time Band Rules': 'Reguli privind intervalele orare', 'Time bands define pay code overrides for specific time ranges within the day. Use the time picker to select start and end times.': 'Intervalurile orare definesc suprascrierile codurilor de plată pentru intervale orare specifice din cadrul zilei. Folosește selectorul de ore pentru a selecta orele de început și de sfârșit.', - 'Up To (seconds)': 'Până la (secunde)', 'Default Pay Code': 'Cod de plată implicit', 'Default pay code is required': 'Codul de plată implicit este obligatoriu', 'Please select a day type': 'Vă rugăm să selectați un tip de zi', @@ -467,4 +465,9 @@ export const roRO = { 'No pay rule set selected': 'Niciun set de reguli de plată selectat', 'Mobile time registration disabled': 'Înregistrarea timpului pe mobil dezactivată', 'Extra shifts': 'Ture suplimentare', + 'Up To (hh:mm)': 'Până la (hh:mm)', + Unlimited: 'Nelimitat', + 'No tiers': 'Fără niveluri', + 'Enter a duration as hours:minutes, for example 7:24': 'Introduceți o durată în format ore:minute, de exemplu 7:24', + 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Nivelurile definesc pragurile de timp ca ore:minute. De exemplu, Nivelul 1: până la 8:00 (8 ore) = Salariu normal. Lăsați „Până la” gol pentru nelimitat.', }; 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 80903dc4b..3ed9ea599 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 @@ -348,11 +348,9 @@ export const skSK = { 'Click "Add Day Type" to create your first rule.': 'Kliknite na tlačidlo „Pridať typ dňa“ a vytvorte si prvé pravidlo.', 'Pay Tiers': 'Platové úrovne', Tiers: 'Úrovne', - 'Tiers define time thresholds. E.g., Tier 1: up to 28800 seconds (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Úrovne definujú časové prahy. Napr. Úroveň 1: až 28 800 sekúnd (8 hodín) = bežná mzda. Pre neobmedzený čas nechajte pole „Do“ prázdne.', 'Time Bands': 'Časové pásma', 'Time Band Rules': 'Pravidlá časového pásma', 'Time bands define pay code overrides for specific time ranges within the day. Use the time picker to select start and end times.': 'Časové pásma definujú prepísania platobných kódov pre konkrétne časové rozsahy v rámci dňa. Na výber času začiatku a konca použite nástroj na výber času.', - 'Up To (seconds)': 'Až (sekundy)', 'Default Pay Code': 'Predvolený platobný kód', 'Default pay code is required': 'Vyžaduje sa predvolený platobný kód', 'Please select a day type': 'Vyberte typ dňa', @@ -467,4 +465,9 @@ export const skSK = { 'No pay rule set selected': 'Nie je vybraté žiadne pravidlo platby', 'Mobile time registration disabled': 'Registrácia mobilného času je zakázaná', 'Extra shifts': 'Mimoriadne zmeny', + 'Up To (hh:mm)': 'Do (hh:mm)', + Unlimited: 'Neobmedzené', + 'No tiers': 'Žiadne úrovne', + 'Enter a duration as hours:minutes, for example 7:24': 'Zadajte trvanie vo formáte hodiny:minúty, napríklad 7:24', + 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Úrovne definujú časové prahy ako hodiny:minúty. Napr. Úroveň 1: do 8:00 (8 hodín) = bežná mzda. Pre neobmedzený čas nechajte pole „Do“ prázdne.', }; 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 1f596b0cb..6a6c7bbb4 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 @@ -348,11 +348,9 @@ export const slSL = { 'Click "Add Day Type" to create your first rule.': 'Kliknite »Dodaj vrsto dneva«, da ustvarite svoje prvo pravilo.', 'Pay Tiers': 'Plačilne stopnje', Tiers: 'Stopnje', - 'Tiers define time thresholds. E.g., Tier 1: up to 28800 seconds (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Stopnje določajo časovne pragove. Npr. 1. stopnja: do 28800 sekund (8 ur) = redno plačilo. Za neomejeno plačilo pustite polje »Do« prazno.', 'Time Bands': 'Časovni pasovi', 'Time Band Rules': 'Pravila časovnega pasu', 'Time bands define pay code overrides for specific time ranges within the day. Use the time picker to select start and end times.': 'Časovni pasovi določajo preglasitve plačilnih kod za določena časovna obdobja znotraj dneva. Začetni in končni čas izberite z izbirnikom časa.', - 'Up To (seconds)': 'Do (sekunde)', 'Default Pay Code': 'Privzeta plačilna koda', 'Default pay code is required': 'Zahtevana je privzeta plačilna koda', 'Please select a day type': 'Izberite vrsto dneva', @@ -467,4 +465,9 @@ export const slSL = { 'No pay rule set selected': 'Ni izbranega nabora pravil za plačilo', 'Mobile time registration disabled': 'Registracija časa na mobilnem telefonu onemogočena', 'Extra shifts': 'Dodatne izmene', + 'Up To (hh:mm)': 'Do (hh:mm)', + Unlimited: 'Neomejeno', + 'No tiers': 'Brez stopenj', + 'Enter a duration as hours:minutes, for example 7:24': 'Vnesite trajanje v obliki ur:minut, na primer 7:24', + 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Stopnje določajo časovne pragove kot ure:minute. Npr. Stopnja 1: do 8:00 (8 ur) = Redno plačilo. Za neomejeno plačilo pustite polje »Do« prazno.', }; 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 0d4b182d9..6b5e4655b 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 @@ -348,11 +348,9 @@ export const svSE = { 'Click "Add Day Type" to create your first rule.': 'Klicka på "Lägg till dagtyp" för att skapa din första regel.', 'Pay Tiers': 'Lönenivåer', Tiers: 'Nivåer', - 'Tiers define time thresholds. E.g., Tier 1: up to 28800 seconds (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Nivåer definierar tidsgränser. T.ex. Nivå 1: upp till 28800 sekunder (8 timmar) = Ordinarie lön. Lämna "Upp till" tomt för obegränsad tid.', 'Time Bands': 'Tidsband', 'Time Band Rules': 'Regler för tidsband', 'Time bands define pay code overrides for specific time ranges within the day. Use the time picker to select start and end times.': 'Tidsintervall definierar åsidosättningar av lönekoder för specifika tidsintervall inom dagen. Använd tidsväljaren för att välja start- och sluttider.', - 'Up To (seconds)': 'Upp till (sekunder)', 'Default Pay Code': 'Standardlönekod', 'Default pay code is required': 'Standardbetalningskod krävs', 'Please select a day type': 'Vänligen välj en dagstyp', @@ -467,4 +465,9 @@ export const svSE = { 'No pay rule set selected': 'Ingen löneregel har valts', 'Mobile time registration disabled': 'Mobil tidsregistrering inaktiverad', 'Extra shifts': 'Extraskift', + 'Up To (hh:mm)': 'Upp till (hh:mm)', + Unlimited: 'Obegränsat', + 'No tiers': 'Inga nivåer', + 'Enter a duration as hours:minutes, for example 7:24': 'Ange en varaktighet som timmar:minuter, till exempel 7:24', + 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Nivåer definierar tidsgränser som timmar:minuter. T.ex. Nivå 1: upp till 8:00 (8 timmar) = Ordinarie lön. Lämna "Upp till" tomt för obegränsat antal.', }; 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 9898bbc47..f209f88b7 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 @@ -348,11 +348,9 @@ export const ukUA = { 'Click "Add Day Type" to create your first rule.': 'Натисніть кнопку «Додати тип дня», щоб створити своє перше правило.', 'Pay Tiers': 'Рівні оплати праці', Tiers: 'Рівні', - 'Tiers define time thresholds. E.g., Tier 1: up to 28800 seconds (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Рівні визначають часові обмеження. Наприклад, Рівень 1: до 28800 секунд (8 годин) = Звичайна оплата. Залиште поле «До» порожнім для необмеженого часу.', 'Time Bands': 'Часові діапазони', 'Time Band Rules': 'Правила часових діапазонів', 'Time bands define pay code overrides for specific time ranges within the day. Use the time picker to select start and end times.': 'Часові діапазони визначають заміну кодів оплати для певних часових діапазонів протягом дня. Використовуйте засіб вибору часу, щоб вибрати час початку та завершення.', - 'Up To (seconds)': 'До (секунди)', 'Default Pay Code': 'Код оплати за замовчуванням', 'Default pay code is required': 'Потрібен код оплати за замовчуванням', 'Please select a day type': 'Будь ласка, виберіть тип дня', @@ -467,4 +465,9 @@ export const ukUA = { 'No pay rule set selected': 'Правило оплати не вибрано', 'Mobile time registration disabled': 'Реєстрація мобільного часу вимкнена', 'Extra shifts': 'Додаткові зміни', + 'Up To (hh:mm)': 'До (гг:хх)', + Unlimited: 'Безлімітний', + 'No tiers': 'Без рівнів', + 'Enter a duration as hours:minutes, for example 7:24': 'Введіть тривалість у форматі годин:хвилини, наприклад, 7:24', + 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.': 'Рівні визначають часові пороги як години:хвилини. Наприклад, Рівень 1: до 8:00 (8 годин) = Звичайна оплата. Залиште поле «До» порожнім для необмеженого часу.', }; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-day-rule-form/pay-day-rule-form.component.html b/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-day-rule-form/pay-day-rule-form.component.html index 4c4451c71..b8b0be5b3 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-day-rule-form/pay-day-rule-form.component.html +++ b/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-day-rule-form/pay-day-rule-form.component.html @@ -37,17 +37,21 @@

{{ 'Pay Tiers' | translate }}

- + - {{ 'Up To (seconds)' | translate }} + {{ 'Up To (hh:mm)' | translate }} - {{ formatSeconds(getUpToSeconds(i)) | translate }} + [id]="'tierUpToInput_' + i" + [placeholder]="'Unlimited' | translate"> + {{ getUpToHint(i) }} + + {{ 'Enter a duration as hours:minutes, for example 7:24' | translate }} + @@ -100,7 +104,7 @@

{{ 'Pay Tiers' | translate }}

info - {{ 'Tiers define time thresholds. E.g., Tier 1: up to 28800 seconds (8 hours) = Regular pay. Leave "Up To" empty for unlimited.' | translate }} + {{ 'Tiers define time thresholds as hours:minutes. E.g., Tier 1: up to 8:00 (8 hours) = Regular pay. Leave "Up To" empty for unlimited.' | translate }}
diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-day-rule-form/pay-day-rule-form.component.ts b/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-day-rule-form/pay-day-rule-form.component.ts index 85ea80cc0..1686b7c6b 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-day-rule-form/pay-day-rule-form.component.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-day-rule-form/pay-day-rule-form.component.ts @@ -1,6 +1,8 @@ import {Component, Input, OnInit} from '@angular/core'; import {FormGroup, FormControl, Validators, FormArray, FormBuilder, AbstractControl, ValidationErrors} from '@angular/forms'; import {MatTableDataSource} from '@angular/material/table'; +import {TranslateService} from '@ngx-translate/core'; +import {secondsToHM} from '../../pay-rule-format.util'; @Component({ selector: 'app-pay-day-rule-form', @@ -28,7 +30,7 @@ export class PayDayRuleFormComponent implements OnInit { {value: 'GRUNDLOVSDAG', label: 'Grundlovsdag'} ]; - constructor(private fb: FormBuilder) {} + constructor(private fb: FormBuilder, private translateService: TranslateService) {} ngOnInit(): void { if (!this.payDayRuleForm) { @@ -103,15 +105,21 @@ export class PayDayRuleFormComponent implements OnInit { return this.payTierRules.at(index).get(controlName) as FormControl; } - formatSeconds(seconds: number | null): string { - if (seconds === null || seconds === undefined) { - return 'No limit'; + /** + * Subscript hint for the "Up To" field: the human reading of the stored + * seconds, or the translated "Unlimited" wording when the tier is unbounded. + * While the typed text is malformed the hint stays empty — the control still + * holds its previous value, and any wording there would describe something + * other than what the field shows. + */ + getUpToHint(index: number): string { + if (this.getTierControl(index, 'upToSeconds').hasError('hhMmFormat')) { + return ''; } - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - if (minutes > 0) { - return `${hours}h ${minutes}m`; + const seconds = this.getUpToSeconds(index); + if (seconds === null || seconds === undefined) { + return this.translateService.instant('Unlimited'); } - return `${hours}h`; + return secondsToHM(seconds); } } diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-day-rule-list/pay-day-rule-list.component.ts b/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-day-rule-list/pay-day-rule-list.component.ts index 18199cc1a..739c7c9d2 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-day-rule-list/pay-day-rule-list.component.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-day-rule-list/pay-day-rule-list.component.ts @@ -1,5 +1,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; import { FormArray, FormGroup } from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; +import { secondsToHM } from '../../pay-rule-format.util'; @Component({ selector: 'app-pay-day-rule-list', @@ -14,6 +16,8 @@ export class PayDayRuleListComponent { @Output() editRule = new EventEmitter(); @Output() deleteRule = new EventEmitter(); + constructor(private translateService: TranslateService) {} + /** * Get the display label for a day code */ @@ -48,34 +52,21 @@ export class PayDayRuleListComponent { getTierBreakdown(rule: FormGroup): string { const tiers = rule.get('payTierRules') as FormArray; if (!tiers || tiers.length === 0) { - return 'No tiers'; + return this.translateService.instant('No tiers'); } return tiers.controls .map(tier => { const upToSeconds = tier.get('upToSeconds')?.value; const payCode = tier.get('payCode')?.value || ''; - const timeStr = upToSeconds ? this.formatSeconds(upToSeconds) : 'unlimited'; + const timeStr = upToSeconds != null + ? secondsToHM(upToSeconds) + : this.translateService.instant('Unlimited'); return `${timeStr} → ${payCode}`; }) .join(', '); } - /** - * Format seconds into human-readable time - */ - formatSeconds(seconds: number | null): string { - if (seconds === null || seconds === undefined) { - return 'No limit'; - } - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - if (minutes > 0) { - return `${hours}h ${minutes}m`; - } - return `${hours}h`; - } - /** * Emit add rule event */ diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-rule-sets-container/pay-rule-sets-container.component.ts b/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-rule-sets-container/pay-rule-sets-container.component.ts index 290ffecb3..a8de5e493 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-rule-sets-container/pay-rule-sets-container.component.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-rule-sets-container/pay-rule-sets-container.component.ts @@ -1,6 +1,7 @@ import {Component, OnDestroy, OnInit} from '@angular/core'; import {AutoUnsubscribe} from 'ngx-auto-unsubscribe'; -import {PayRuleSetSimpleModel, PayRuleSetsRequestModel, PAY_RULE_SET_PRESETS} from '../../../../models'; +import {PayRuleSetSimpleModel, PayRuleSetsRequestModel} from '../../../../models'; +import {isLockedPresetName} from '../../pay-rule-lock.util'; import {MatDialog} from '@angular/material/dialog'; import {PayRuleSetsDeleteModalComponent} from '../pay-rule-sets-delete-modal/pay-rule-sets-delete-modal.component'; import {PayRuleSetsCreateModalComponent} from '../pay-rule-sets-create-modal/pay-rule-sets-create-modal.component'; @@ -82,8 +83,10 @@ export class PayRuleSetsContainerComponent implements OnInit, OnDestroy { // Locked presets (e.g. GLS-A / 3F overenskomster) are read-only. The // edit modal still opens but renders a summary view; this guard is a // belt-and-braces against direct calls (the table button is also - // disabled via isLockedPreset). - const isLockedPreset = PAY_RULE_SET_PRESETS.some(p => p.locked && p.name === payRuleSet.name); + // disabled via isLockedPreset). The name comparison ignores the trailing + // validity period, so rows stored under an earlier agreement period + // (e.g. "… 2024-2026") stay locked after a catalogue rename. + const isLockedPreset = isLockedPresetName(payRuleSet.name); const dialogRef = this.dialog.open(PayRuleSetsEditModalComponent, { data: { payRuleSetId: payRuleSet.id }, @@ -108,7 +111,7 @@ export class PayRuleSetsContainerComponent implements OnInit, OnDestroy { } onDeleteClicked(payRuleSet: PayRuleSetSimpleModel): void { - const isLockedPreset = PAY_RULE_SET_PRESETS.some(p => p.locked && p.name === payRuleSet.name); + const isLockedPreset = isLockedPresetName(payRuleSet.name); if (isLockedPreset) { this.toastrService.error(this.translateService.instant('Cannot delete locked preset')); return; diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-rule-sets-create-modal/pay-rule-sets-create-modal.component.ts b/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-rule-sets-create-modal/pay-rule-sets-create-modal.component.ts index e9e899017..ce665269d 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-rule-sets-create-modal/pay-rule-sets-create-modal.component.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-rule-sets-create-modal/pay-rule-sets-create-modal.component.ts @@ -89,7 +89,7 @@ export class PayRuleSetsCreateModalComponent implements OnInit { } formatTierChain(tiers: Array<{ order: number; upToSeconds: number | null; payCode: string }>): string { - return formatTierChain(tiers); + return formatTierChain(tiers, this.translateService.instant('Unlimited')); } formatTimeBands(bands: Array<{ startSecondOfDay: number; endSecondOfDay: number; payCode: string; priority: number }>): string { diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-rule-sets-edit-modal/pay-rule-sets-edit-modal.component.ts b/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-rule-sets-edit-modal/pay-rule-sets-edit-modal.component.ts index f0dba8a12..c0acafed1 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-rule-sets-edit-modal/pay-rule-sets-edit-modal.component.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-rule-sets-edit-modal/pay-rule-sets-edit-modal.component.ts @@ -9,6 +9,7 @@ import { PayRuleSetUpdateModel, PayRuleSetModel, PAY_RULE_SET_PRESETS, PayRuleSe import { PayDayRuleDialogComponent, PayDayRuleDialogData } from '../pay-day-rule-dialog/pay-day-rule-dialog.component'; import { DayTypeRuleDialogComponent, DayTypeRuleDialogData } from '../day-type-rule-dialog/day-type-rule-dialog.component'; import { formatTierChain, formatTimeBands } from '../../pay-rule-format.util'; +import { isLockedPresetName, normalizePayRuleSetName } from '../../pay-rule-lock.util'; export interface PayRuleSetsEditModalData { payRuleSetId: number; @@ -26,19 +27,25 @@ export class PayRuleSetsEditModalComponent implements OnInit { loading = true; /** - * Resolves the matching locked preset for the loaded rule set, by name. - * Returns null when the loaded rule set is custom (or the preset is not - * marked locked). When non-null, the modal renders a read-only summary - * view instead of the editable form, matching the create modal's locked - * preset path. + * Resolves the matching locked preset for the loaded rule set. Names are + * compared with the agreement validity period stripped, so a rule set stored + * under a legacy period (e.g. '... 2024-2026' while the catalogue now says + * '... 2026-2029') still resolves to its preset. Returns null when the + * loaded rule set is custom (or the preset is not marked locked). When + * non-null, the modal renders a read-only summary view instead of the + * editable form, matching the create modal's locked preset path. */ get lockedPreset(): PayRuleSetPreset | null { if (!this.payRuleSet) return null; - return PAY_RULE_SET_PRESETS.find(p => p.locked && p.name === this.payRuleSet.name) ?? null; + const normalizedName = normalizePayRuleSetName(this.payRuleSet.name); + return PAY_RULE_SET_PRESETS.find( + p => p.locked && normalizePayRuleSetName(p.name) === normalizedName + ) ?? null; } get isLocked(): boolean { - return this.lockedPreset !== null; + if (!this.payRuleSet) return false; + return isLockedPresetName(this.payRuleSet.name); } constructor( @@ -291,7 +298,7 @@ export class PayRuleSetsEditModalComponent implements OnInit { } formatTierChain(tiers: Array<{ order: number; upToSeconds: number | null; payCode: string }>): string { - return formatTierChain(tiers); + return formatTierChain(tiers, this.translateService.instant('Unlimited')); } formatTimeBands(bands: Array<{ startSecondOfDay: number; endSecondOfDay: number; payCode: string; priority: number }>): string { diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-rule-sets-table/pay-rule-sets-table.component.ts b/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-rule-sets-table/pay-rule-sets-table.component.ts index 420f0b269..91bfec6e1 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-rule-sets-table/pay-rule-sets-table.component.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-rule-sets-table/pay-rule-sets-table.component.ts @@ -2,7 +2,8 @@ import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular import { MatDialog } from '@angular/material/dialog'; import { MtxGridColumn } from '@ng-matero/extensions/grid'; import { TranslateService } from '@ngx-translate/core'; -import { PayRuleSetSimpleModel, PAY_RULE_SET_PRESETS } from '../../../../models'; +import { PayRuleSetSimpleModel } from '../../../../models'; +import { isLockedPresetName } from '../../pay-rule-lock.util'; @Component({ selector: 'app-pay-rule-sets-table', @@ -42,9 +43,12 @@ export class PayRuleSetsTableComponent implements OnInit { * (e.g. GLS-A / 3F overenskomster). Locked rule sets are read-only: * the edit and delete row actions are disabled, and the edit modal * renders a summary view instead of the form. + * + * The comparison ignores the trailing validity period so rows stored + * under an earlier agreement period stay locked after a catalogue rename. */ isLockedPreset(row: PayRuleSetSimpleModel): boolean { - return PAY_RULE_SET_PRESETS.some(p => p.locked && p.name === row.name); + return isLockedPresetName(row.name); } openCreateModal() { diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-rule-sets-view-modal/pay-rule-sets-view-modal.component.ts b/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-rule-sets-view-modal/pay-rule-sets-view-modal.component.ts index c8583f123..fe99ae248 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-rule-sets-view-modal/pay-rule-sets-view-modal.component.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/components/pay-rule-sets-view-modal/pay-rule-sets-view-modal.component.ts @@ -49,7 +49,9 @@ export class PayRuleSetsViewModalComponent implements OnInit { this.dialogRef.close(); } - formatTierChain = formatTierChain; + formatTierChain(tiers: Array<{ order: number; upToSeconds: number | null; payCode: string }>): string { + return formatTierChain(tiers, this.translateService.instant('Unlimited')); + } formatDayTypeRule(rule: { defaultPayCode: string; timeBandRules: Array<{ startSecondOfDay: number; endSecondOfDay: number; payCode: string }> }): string { const bands = formatTimeBands(rule.timeBandRules || []); diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/directives/hh-mm-seconds.directive.ts b/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/directives/hh-mm-seconds.directive.ts new file mode 100644 index 000000000..db01f51a9 --- /dev/null +++ b/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/directives/hh-mm-seconds.directive.ts @@ -0,0 +1,116 @@ +import {Directive, ElementRef, HostListener, Renderer2, forwardRef} from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, +} from '@angular/forms'; +import {parseHhMmToSeconds, secondsToHhMmInput} from '../pay-rule-format.util'; + +/** + * Value accessor that lets a text input edit a duration as hours:minutes + * (e.g. '7:24' or '07:24') while the bound form control keeps storing the + * duration in SECONDS. + * + * An empty field means "unlimited" and maps to a null control value. + * Malformed text sets an `hhMmFormat` error on the control, which keeps the + * surrounding form invalid so it cannot be saved. Malformed text never writes + * to the control: pushing null would be indistinguishable from the deliberate + * "unlimited" value and would make the field claim to be unlimited while the + * user is still typing. + */ +@Directive({ + selector: 'input[appHhMmSeconds]', + standalone: false, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => HhMmSecondsDirective), + multi: true, + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => HhMmSecondsDirective), + multi: true, + }, + ], +}) +export class HhMmSecondsDirective implements ControlValueAccessor, Validator { + private onChange: (value: number | null) => void = () => {}; + private onTouched: () => void = () => {}; + private onValidatorChange: () => void = () => {}; + private malformed = false; + + constructor( + private elementRef: ElementRef, + private renderer: Renderer2 + ) {} + + writeValue(value: number | null): void { + const wasMalformed = this.malformed; + this.malformed = false; + this.renderer.setProperty(this.elementRef.nativeElement, 'value', secondsToHhMmInput(value)); + if (wasMalformed) { + // A programmatic write replaces whatever the user typed, so the format + // error must not survive it. This also covers a directive instance being + // re-bound to another control when a table row is removed. + this.onValidatorChange(); + } + } + + registerOnChange(fn: (value: number | null) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + registerOnValidatorChange(fn: () => void): void { + this.onValidatorChange = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.renderer.setProperty(this.elementRef.nativeElement, 'disabled', isDisabled); + } + + @HostListener('input', ['$event.target.value']) + onInput(raw: string): void { + const text = (raw || '').trim(); + if (text === '') { + // Empty means unlimited. + this.malformed = false; + this.onValidatorChange(); + this.onChange(null); + return; + } + const seconds = parseHhMmToSeconds(text); + this.malformed = seconds === null; + this.onValidatorChange(); + if (seconds === null) { + // Malformed: leave the control value untouched. Writing null here would + // read as "unlimited"; the hhMmFormat error keeps the form invalid, so + // the stale value cannot be saved either. + return; + } + this.onChange(seconds); + } + + @HostListener('blur') + onBlur(): void { + this.onTouched(); + if (this.malformed) { + // Keep the offending text visible so the user can correct it. + return; + } + const text = (this.elementRef.nativeElement.value || '').trim(); + const seconds = text === '' ? null : parseHhMmToSeconds(text); + this.renderer.setProperty(this.elementRef.nativeElement, 'value', secondsToHhMmInput(seconds)); + } + + validate(_control: AbstractControl): ValidationErrors | null { + return this.malformed ? {hhMmFormat: true} : null; + } +} diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/pay-rule-format.util.ts b/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/pay-rule-format.util.ts index 3c0baf1df..554d9da9a 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/pay-rule-format.util.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/pay-rule-format.util.ts @@ -13,8 +13,41 @@ export function secondsToHHMM(seconds: number): string { return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`; } +/** + * Renders a duration for the hh:mm editor input, e.g. 26640 -> '7:24'. + * A null/undefined duration means "unlimited" and renders as an empty + * string, so the field shows its (translated) "Unlimited" placeholder. + */ +export function secondsToHhMmInput(seconds: number | null | undefined): string { + if (seconds === null || seconds === undefined) { + return ''; + } + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + return `${h}:${m.toString().padStart(2, '0')}`; +} + +/** + * Parses an 'h:mm' / 'hh:mm' duration into seconds. + * Returns null when the text is not a well-formed duration; an empty string + * is NOT handled here (empty means "unlimited" and is handled by the caller). + */ +export function parseHhMmToSeconds(text: string): number | null { + const match = /^(\d{1,3}):(\d{1,2})$/.exec((text || '').trim()); + if (!match) { + return null; + } + const hours = Number(match[1]); + const minutes = Number(match[2]); + if (minutes > 59) { + return null; + } + return hours * 3600 + minutes * 60; +} + export function formatTierChain( - tiers: Array<{ order: number; upToSeconds: number | null; payCode: string }> + tiers: Array<{ order: number; upToSeconds: number | null; payCode: string }>, + unlimitedLabel?: string ): string { return [...tiers] .sort((a, b) => a.order - b.order) @@ -22,7 +55,7 @@ export function formatTierChain( if (t.upToSeconds != null) { return `${t.payCode} (${secondsToHM(t.upToSeconds)})`; } - return t.payCode; + return unlimitedLabel ? `${t.payCode} (${unlimitedLabel})` : t.payCode; }) .join(' → '); } diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/pay-rule-lock.util.ts b/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/pay-rule-lock.util.ts new file mode 100644 index 000000000..b89a839f7 --- /dev/null +++ b/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/pay-rule-lock.util.ts @@ -0,0 +1,38 @@ +import { PAY_RULE_SET_PRESETS } from '../../models'; + +/** + * Trailing agreement validity period, e.g. ` 2024-2026` or ` 2026–2029`. + * Matched with hyphen, en-dash and em-dash so a stored name written with a + * typographic dash still normalizes to the same value. + */ +const VALIDITY_PERIOD_SUFFIX = /\s+\d{4}\s*[-–—]\s*\d{4}$/; + +/** + * Strips the trailing validity period from a pay rule set name so that names + * differing only by agreement period compare equal. + * + * `'GLS-A / 3F - Jordbrug Dyrehold 2024-2026'` and + * `'GLS-A / 3F - Jordbrug Dyrehold 2026-2029'` both normalize to + * `'GLS-A / 3F - Jordbrug Dyrehold'`. + */ +export function normalizePayRuleSetName(name: string): string { + if (!name) { + return ''; + } + return name.trim().replace(VALIDITY_PERIOD_SUFFIX, '').trim(); +} + +/** + * True when the given name matches — ignoring the validity period — a preset + * entry flagged as locked (e.g. GLS-A / 3F overenskomster). Rule sets created + * before a catalogue rename stay locked because only the period differs. + */ +export function isLockedPresetName(name: string): boolean { + const normalized = normalizePayRuleSetName(name); + if (!normalized) { + return false; + } + return PAY_RULE_SET_PRESETS.some( + p => p.locked && normalizePayRuleSetName(p.name) === normalized + ); +} diff --git a/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/pay-rule-sets.module.ts b/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/pay-rule-sets.module.ts index 415ac05e8..3acbd6809 100644 --- a/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/pay-rule-sets.module.ts +++ b/eform-client/src/app/plugins/modules/time-planning-pn/modules/pay-rule-sets/pay-rule-sets.module.ts @@ -17,6 +17,7 @@ import { DayTypeRuleListComponent, DayTypeRuleDialogComponent, } from './components'; +import {HhMmSecondsDirective} from './directives/hh-mm-seconds.directive'; import {TimePlanningPnPayRuleSetsService} from '../../services'; import {MtxGridModule} from '@ng-matero/extensions/grid'; import {MtxSelectModule} from '@ng-matero/extensions/select'; @@ -64,6 +65,7 @@ import {NgxMaterialTimepickerModule} from 'ngx-material-timepicker'; PayDayRuleDialogComponent, DayTypeRuleListComponent, DayTypeRuleDialogComponent, + HhMmSecondsDirective, ], providers: [ TimePlanningPnPayRuleSetsService,