diff --git a/src/Infrastructure/Arbetsmiljo/Incident.cs b/src/Infrastructure/Arbetsmiljo/Incident.cs index 8f30e5b..64fbb8a 100644 --- a/src/Infrastructure/Arbetsmiljo/Incident.cs +++ b/src/Infrastructure/Arbetsmiljo/Incident.cs @@ -48,4 +48,34 @@ public static Incident Skapa( CreatedAt = DateTime.UtcNow }; } + + /// + /// Nästa status i incidentens livscykel + /// (Rapporterad → Under utredning → Åtgärd vidtagen → Avslutad), + /// eller null om ärendet redan är avslutat. + /// + public IncidentStatus? NastaStatus => Status switch + { + IncidentStatus.Rapporterad => IncidentStatus.UnderUtredning, + IncidentStatus.UnderUtredning => IncidentStatus.AtgardVidtagen, + IncidentStatus.AtgardVidtagen => IncidentStatus.Avslutad, + _ => null + }; + + /// + /// Flyttar incidenten ett steg framåt i livscykeln. Ett avslutat ärende + /// påverkas inte (använd för att öppna igen). + /// + public void FlyttaFram() + { + if (NastaStatus is { } nasta) + Status = nasta; + } + + /// Återöppnar ett avslutat ärende för fortsatt utredning. + public void Ateroppna() + { + if (Status == IncidentStatus.Avslutad) + Status = IncidentStatus.UnderUtredning; + } } diff --git a/src/Infrastructure/Arbetsmiljo/RiskAssessment.cs b/src/Infrastructure/Arbetsmiljo/RiskAssessment.cs index afbaaa1..daf709b 100644 --- a/src/Infrastructure/Arbetsmiljo/RiskAssessment.cs +++ b/src/Infrastructure/Arbetsmiljo/RiskAssessment.cs @@ -51,4 +51,25 @@ public static RiskAssessment Skapa( CreatedAt = DateTime.UtcNow }; } + + /// + /// Nästa status i riskbedömningens livscykel + /// (Identifierad → Under behandling → Åtgärdad), eller null om redan åtgärdad. + /// + public RiskStatus? NastaStatus => Status switch + { + RiskStatus.Identifierad => RiskStatus.UnderBehandling, + RiskStatus.UnderBehandling => RiskStatus.Atgardad, + _ => null + }; + + /// Flyttar riskbedömningen ett steg framåt i åtgärdskedjan. + public void FlyttaFram() + { + if (NastaStatus is { } nasta) + Status = nasta; + } + + /// Sätter eller uppdaterar deadline för åtgärden. + public void SattDeadline(DateOnly? deadline) => Deadline = deadline; } diff --git a/src/Infrastructure/Arbetsmiljo/SafetyRound.cs b/src/Infrastructure/Arbetsmiljo/SafetyRound.cs index e29a269..9179440 100644 --- a/src/Infrastructure/Arbetsmiljo/SafetyRound.cs +++ b/src/Infrastructure/Arbetsmiljo/SafetyRound.cs @@ -39,4 +39,23 @@ public static SafetyRound Skapa( CreatedAt = DateTime.UtcNow }; } + + /// + /// Registrerar att ronden genomförts. Antalet upptäckta brister och + /// eventuella anteckningar sparas och status sätts till Genomförd. + /// + public void Genomfor(int antalBrister, string? anteckningar = null) + { + if (antalBrister < 0) + throw new ArgumentOutOfRangeException(nameof(antalBrister), "Antal brister kan inte vara negativt."); + AntalBrister = antalBrister; + Anteckningar = anteckningar; + Status = SafetyRoundStatus.Genomford; + } + + /// Stänger ronden när alla brister åtgärdats. + public void Avsluta() + { + Status = SafetyRoundStatus.Avslutad; + } } diff --git a/src/Infrastructure/Export/LonespecifikationPdfGenerator.cs b/src/Infrastructure/Export/LonespecifikationPdfGenerator.cs new file mode 100644 index 0000000..007c4be --- /dev/null +++ b/src/Infrastructure/Export/LonespecifikationPdfGenerator.cs @@ -0,0 +1,346 @@ +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; +using RegionHR.Payroll.Domain; + +namespace RegionHR.Infrastructure.Export; + +/// +/// Genererar en fullständig lönespecifikation som PDF via QuestPDF direkt ur ett +/// (löneresultatet per anställd och period). Till skillnad från +/// — som bara tar sammanfattade totaler — +/// tar den här generatorn med de detaljerade löneraderna (), +/// alla tillägg, avdrag, arbetsgivarens kostnader och semestersaldo. +/// +/// Klassen är tillståndslös och kan instansieras direkt med new; QuestPDF-licensen +/// sätts i den statiska konstruktorn. +/// +public class LonespecifikationPdfGenerator +{ + public const string ArbetsgivareNamn = PdfGenerator.ArbetsgivareNamn; + public const string ArbetsgivareOrgnr = PdfGenerator.ArbetsgivareOrgnr; + public const string ArbetsgivareAdress = PdfGenerator.ArbetsgivareAdress; + + static LonespecifikationPdfGenerator() + { + QuestPDF.Settings.License = LicenseType.Community; + } + + public byte[] Generate(LonespecifikationDokument data) + { + return Document.Create(container => + { + container.Page(page => + { + page.Size(PageSizes.A4); + page.Margin(2.0f, Unit.Centimetre); + page.DefaultTextStyle(t => t.FontSize(10)); + + page.Header().Column(header => + { + header.Item().Row(row => + { + row.RelativeItem().Column(c => + { + c.Item().Text("LÖNESPECIFIKATION").FontSize(18).Bold().FontColor(Colors.Teal.Darken3); + c.Item().Text($"{ArbetsgivareNamn} (org.nr {ArbetsgivareOrgnr}) — OpenHR") + .FontSize(9).FontColor(Colors.Grey.Darken1); + }); + row.ConstantItem(150).AlignRight().Column(c => + { + c.Item().AlignRight().Text(data.Period).FontSize(12).SemiBold(); + c.Item().AlignRight().Text($"Utbetalas {data.Utbetalningsdag}") + .FontSize(9).FontColor(Colors.Grey.Darken1); + }); + }); + header.Item().PaddingTop(6).LineHorizontal(1.2f).LineColor(Colors.Teal.Darken3); + }); + + page.Content().PaddingVertical(12).Column(col => + { + col.Spacing(6); + + // ---- Anställningsuppgifter ---- + UppgiftsTabell(col, + ("Namn", data.Namn), + ("Personnummer", data.Personnummer), + ("Befattning", string.IsNullOrWhiteSpace(data.Befattning) ? "—" : data.Befattning), + ("Sysselsättningsgrad", $"{data.Sysselsattningsgrad:0.#} %"), + ("Kollektivavtal", string.IsNullOrWhiteSpace(data.Kollektivavtal) ? "—" : data.Kollektivavtal), + ("Period", data.Period)); + + // ---- Detaljerade lönerader ---- + if (data.Rader.Count > 0) + { + Sektionsrubrik(col, "Specifikation"); + RadTabell(col, data.Rader); + } + + // ---- Sammanställning: inkomster ---- + Sektionsrubrik(col, "Inkomster"); + var inkomster = new List<(string, decimal, bool)> + { + ("Grundlön", data.Grundlon, false) + }; + AddOm(inkomster, "OB-tillägg", data.OBTillagg); + AddOm(inkomster, "Övertidstillägg", data.Overtidstillagg); + AddOm(inkomster, "Jourtillägg", data.JourTillagg); + AddOm(inkomster, "Beredskapstillägg", data.BeredskapsTillagg); + AddOm(inkomster, "Sjuklön", data.Sjuklon); + AddOm(inkomster, "Semesterlön", data.Semesterlon); + AddOm(inkomster, "Semestertillägg", data.Semestertillagg); + AddOm(inkomster, "Föräldralön (utfyllnad)", data.ForaldraloneUtfyllnad); + inkomster.Add(("Bruttolön", data.Brutto, true)); + BeloppTabell(col, inkomster.ToArray()); + + // ---- Sammanställning: avdrag ---- + Sektionsrubrik(col, "Avdrag"); + var avdrag = new List<(string, decimal, bool)> + { + ("Preliminärskatt", -data.Skatt, false) + }; + AddOm(avdrag, "Karensavdrag", -data.Karensavdrag); + AddOm(avdrag, "Löneutmätning (KFM)", -data.Loneutmatning); + AddOm(avdrag, "Fackavgift", -data.Fackavgift); + AddOm(avdrag, "Övriga avdrag", -data.OvrigaAvdrag); + avdrag.Add(("Nettolön (utbetalas)", data.Netto, true)); + BeloppTabell(col, avdrag.ToArray()); + + // ---- Arbetsgivarens kostnader ---- + Sektionsrubrik(col, "Arbetsgivarens kostnader"); + BeloppTabell(col, + ("Arbetsgivaravgifter", data.Arbetsgivaravgifter, false), + ("Pensionsavsättning (AKAP-KR)", data.Pensionsavgift, false)); + + // ---- Semester ---- + if (data.SemesterdagarIntjanade > 0 || data.SemesterdagarUttagna > 0) + { + Sektionsrubrik(col, "Semester"); + UppgiftsTabell(col, + ("Intjänade dagar (perioden)", data.SemesterdagarIntjanade.ToString()), + ("Uttagna dagar (perioden)", data.SemesterdagarUttagna.ToString())); + } + + col.Item().PaddingTop(10).Text( + "Lönespecifikationen är en sammanställning av periodens löneutbetalning. " + + "Kontrollera uppgifterna och kontakta HR/löneenheten vid frågor.") + .FontSize(8).Italic().FontColor(Colors.Grey.Darken1); + }); + + page.Footer().AlignCenter().Text(t => + { + t.DefaultTextStyle(s => s.FontSize(8).FontColor(Colors.Grey.Darken1)); + t.Span($"{ArbetsgivareNamn} — genererad {DateTime.Today:yyyy-MM-dd} i OpenHR — sida "); + t.CurrentPageNumber(); + t.Span(" av "); + t.TotalPages(); + }); + }); + }).GeneratePdf(); + } + + private static void AddOm(List<(string, decimal, bool)> lista, string text, decimal belopp) + { + if (belopp != 0m) lista.Add((text, belopp, false)); + } + + // ---------- Interna byggstenar ---------- + + private static void Sektionsrubrik(ColumnDescriptor col, string rubrik) + { + col.Item().PaddingTop(8).Text(rubrik).FontSize(11).SemiBold().FontColor(Colors.Teal.Darken3); + col.Item().LineHorizontal(0.6f).LineColor(Colors.Grey.Lighten2); + } + + private static void UppgiftsTabell(ColumnDescriptor col, params (string Rubrik, string Varde)[] rader) + { + col.Item().Table(table => + { + table.ColumnsDefinition(c => + { + c.ConstantColumn(180); + c.RelativeColumn(); + }); + + foreach (var (rubrik, varde) in rader) + { + table.Cell().PaddingVertical(2).Text(rubrik).SemiBold(); + table.Cell().PaddingVertical(2).Text(varde); + } + }); + } + + private static void BeloppTabell(ColumnDescriptor col, params (string Text, decimal Belopp, bool ArSumma)[] rader) + { + col.Item().Table(table => + { + table.ColumnsDefinition(c => + { + c.RelativeColumn(); + c.ConstantColumn(130); + }); + + foreach (var (text, belopp, arSumma) in rader) + { + if (arSumma) + { + table.Cell().BorderTop(0.8f).PaddingVertical(3).Text(text.ToUpperInvariant()).SemiBold(); + table.Cell().BorderTop(0.8f).PaddingVertical(3).AlignRight().Text(Kr(belopp)).SemiBold(); + } + else + { + table.Cell().PaddingVertical(2).Text(text); + table.Cell().PaddingVertical(2).AlignRight().Text(Kr(belopp)); + } + } + }); + } + + private static void RadTabell(ColumnDescriptor col, IReadOnlyList rader) + { + col.Item().Table(table => + { + table.ColumnsDefinition(c => + { + c.ConstantColumn(55); // Löneart + c.RelativeColumn(); // Benämning + c.ConstantColumn(55); // Antal + c.ConstantColumn(75); // Á-pris + c.ConstantColumn(85); // Belopp + }); + + table.Header(header => + { + header.Cell().Background(Colors.Grey.Lighten3).PaddingVertical(3).PaddingHorizontal(2) + .Text("Löneart").SemiBold().FontSize(9); + header.Cell().Background(Colors.Grey.Lighten3).PaddingVertical(3).PaddingHorizontal(2) + .Text("Benämning").SemiBold().FontSize(9); + header.Cell().Background(Colors.Grey.Lighten3).PaddingVertical(3).PaddingHorizontal(2) + .AlignRight().Text("Antal").SemiBold().FontSize(9); + header.Cell().Background(Colors.Grey.Lighten3).PaddingVertical(3).PaddingHorizontal(2) + .AlignRight().Text("Á-pris").SemiBold().FontSize(9); + header.Cell().Background(Colors.Grey.Lighten3).PaddingVertical(3).PaddingHorizontal(2) + .AlignRight().Text("Belopp").SemiBold().FontSize(9); + }); + + foreach (var rad in rader) + { + table.Cell().PaddingVertical(2).Text(rad.Kod); + table.Cell().PaddingVertical(2).Text(rad.Benamning); + table.Cell().PaddingVertical(2).AlignRight().Text(rad.Antal.ToString("0.##")); + table.Cell().PaddingVertical(2).AlignRight().Text(Kr(rad.Sats)); + table.Cell().PaddingVertical(2).AlignRight() + .Text(Kr(rad.ArAvdrag ? -Math.Abs(rad.Belopp) : rad.Belopp)); + } + }); + } + + private static string Kr(decimal belopp) => $"{belopp:N0} kr"; +} + +/// +/// Fullständigt underlag för en lönespecifikation. Byggs ur ett +/// via och renderas av . +/// +public sealed record LonespecifikationDokument +{ + public string Namn { get; init; } = ""; + public string Personnummer { get; init; } = ""; + public string Befattning { get; init; } = ""; + public string Period { get; init; } = ""; + public string Utbetalningsdag { get; init; } = ""; + public decimal Sysselsattningsgrad { get; init; } + public string Kollektivavtal { get; init; } = ""; + + // Inkomster + public decimal Grundlon { get; init; } + public decimal Brutto { get; init; } + public decimal OBTillagg { get; init; } + public decimal Overtidstillagg { get; init; } + public decimal JourTillagg { get; init; } + public decimal BeredskapsTillagg { get; init; } + public decimal Sjuklon { get; init; } + public decimal Semesterlon { get; init; } + public decimal Semestertillagg { get; init; } + public decimal ForaldraloneUtfyllnad { get; init; } + + // Avdrag + public decimal Skatt { get; init; } + public decimal Karensavdrag { get; init; } + public decimal Loneutmatning { get; init; } + public decimal Fackavgift { get; init; } + public decimal OvrigaAvdrag { get; init; } + public decimal Netto { get; init; } + + // Arbetsgivarens kostnader + public decimal Arbetsgivaravgifter { get; init; } + public decimal Pensionsavgift { get; init; } + + // Semester + public int SemesterdagarIntjanade { get; init; } + public int SemesterdagarUttagna { get; init; } + + public IReadOnlyList Rader { get; init; } = []; + + /// + /// Bygger ett lönespec-underlag ur ett löneresultat plus den anställdes identitetsuppgifter. + /// Identitetsuppgifterna skickas in som strängar för att hålla generatorn frikopplad från + /// Core.Employee (och därmed lätt att enhetstesta). + /// + public static LonespecifikationDokument FromPayrollResult( + PayrollResult r, string namn, string personnummer, string befattning = "") + { + return new LonespecifikationDokument + { + Namn = namn, + Personnummer = personnummer, + Befattning = befattning, + Period = FormatPeriod(r.Year, r.Month), + Utbetalningsdag = new DateOnly(r.Year, r.Month, Math.Min(25, DateTime.DaysInMonth(r.Year, r.Month))) + .ToString("yyyy-MM-dd"), + Sysselsattningsgrad = r.Sysselsattningsgrad, + Kollektivavtal = r.Kollektivavtal.ToString(), + + Grundlon = r.Manadslon.Amount, + Brutto = r.Brutto.Amount, + OBTillagg = r.OBTillagg.Amount, + Overtidstillagg = r.Overtidstillagg.Amount, + JourTillagg = r.JourTillagg.Amount, + BeredskapsTillagg = r.BeredskapsTillagg.Amount, + Sjuklon = r.Sjuklon.Amount, + Semesterlon = r.Semesterlon.Amount, + Semestertillagg = r.Semestertillagg.Amount, + ForaldraloneUtfyllnad = r.ForaldraloneUtfyllnad.Amount, + + Skatt = r.Skatt.Amount, + Karensavdrag = r.Karensavdrag.Amount, + Loneutmatning = r.Loneutmatning.Amount, + Fackavgift = r.Fackavgift.Amount, + OvrigaAvdrag = r.OvrigaAvdrag.Amount, + Netto = r.Netto.Amount, + + Arbetsgivaravgifter = r.Arbetsgivaravgifter.Amount, + Pensionsavgift = r.Pensionsavgift.Amount, + + SemesterdagarIntjanade = r.SemesterdagarIntjanade, + SemesterdagarUttagna = r.SemesterdagarUttagna, + + Rader = r.Rader + .OrderBy(l => l.LoneartKod) + .Select(l => new LonespecRad( + l.LoneartKod, l.Benamning, l.Antal, l.Sats.Amount, l.Belopp.Amount, l.ArAvdrag)) + .ToList() + }; + } + + private static string FormatPeriod(int year, int month) + { + var monthNames = new[] { "", "Januari", "Februari", "Mars", "April", "Maj", "Juni", + "Juli", "Augusti", "September", "Oktober", "November", "December" }; + var namn = month >= 1 && month <= 12 ? monthNames[month] : month.ToString(); + return $"{namn} {year}"; + } +} + +public sealed record LonespecRad( + string Kod, string Benamning, decimal Antal, decimal Sats, decimal Belopp, bool ArAvdrag); diff --git a/src/Modules/Recruitment/Domain/Vacancy.cs b/src/Modules/Recruitment/Domain/Vacancy.cs index c8a426e..52646a0 100644 --- a/src/Modules/Recruitment/Domain/Vacancy.cs +++ b/src/Modules/Recruitment/Domain/Vacancy.cs @@ -38,8 +38,23 @@ public static Vacancy Skapa( }; } + /// + /// Sätter (eller rensar) vakansens lönespann. Ett angivet minimum får inte överstiga maximum. + /// + public void SattLonespann(Money? min, Money? max) + { + if (min is not null && max is not null && min.Value.Amount > max.Value.Amount) + throw new ArgumentException("Lägsta lön kan inte vara högre än högsta lön."); + + Lonespann_Min = min; + Lonespann_Max = max; + } + public void Publicera(bool externt = true, bool platsbanken = false) { + if (Status == VacancyStatus.Tillsatt) + throw new InvalidOperationException("En tillsatt vakans kan inte publiceras om."); + Status = VacancyStatus.Publicerad; PubliceradExternt = externt; PubliceradPlatsbanken = platsbanken; diff --git a/src/Web/Components/Layout/NavMenu.razor b/src/Web/Components/Layout/NavMenu.razor index 43c05b0..01edda8 100644 --- a/src/Web/Components/Layout/NavMenu.razor +++ b/src/Web/Components/Layout/NavMenu.razor @@ -46,6 +46,7 @@ @L["Staffing"] @L["Optimization"] Behovsstyrd automatik + Bemanningsmallar @L["DemandForecast"] @L["Availability"] @L["TimeClocking"] diff --git a/src/Web/Components/Pages/Admin/AnpassatObjekt.razor b/src/Web/Components/Pages/Admin/AnpassatObjekt.razor index 47e7fb9..ae5438b 100644 --- a/src/Web/Components/Pages/Admin/AnpassatObjekt.razor +++ b/src/Web/Components/Pages/Admin/AnpassatObjekt.razor @@ -213,8 +213,19 @@ else if (_editRecord != null) { - _editRecord.UppdateraData(data); + // _editRecord laddades i en annan (nu disposad) DbContext — hämta den spårade + // posten i denna context innan mutation, annars persisteras inget. + var tracked = await db.CustomObjectRecords.FirstOrDefaultAsync(r => r.Id == _editRecord.Id); + if (tracked == null) + { + _error = "Posten kunde inte hittas i databasen."; + _showForm = false; + return; + } + tracked.UppdateraData(data); await db.SaveChangesAsync(); + var idx = _records.FindIndex(r => r.Id == _editRecord.Id); + if (idx >= 0) _records[idx] = tracked; _success = "Post uppdaterad"; } else diff --git a/src/Web/Components/Pages/Admin/Anslagstavla.razor b/src/Web/Components/Pages/Admin/Anslagstavla.razor index 4993d24..ff1d87b 100644 --- a/src/Web/Components/Pages/Admin/Anslagstavla.razor +++ b/src/Web/Components/Pages/Admin/Anslagstavla.razor @@ -100,14 +100,16 @@ else private async Task PubliceraAnslag(Announcement a) { await using var db = await DbFactory.CreateDbContextAsync(); - try { a.Publicera(); await db.SaveChangesAsync(); } + // a laddades i init-contexten (nu disposad). Attacha i denna context före + // mutation så EF upptäcker statusändringen och persisterar den. + try { db.Attach(a); a.Publicera(); await db.SaveChangesAsync(); } catch (Exception ex) { _error = $"Kunde inte publicera: {ex.Message}"; } } private async Task ArkiveraAnslag(Announcement a) { await using var db = await DbFactory.CreateDbContextAsync(); - try { a.Arkivera(); await db.SaveChangesAsync(); } + try { db.Attach(a); a.Arkivera(); await db.SaveChangesAsync(); } catch (Exception ex) { _error = $"Kunde inte arkivera: {ex.Message}"; } } diff --git a/src/Web/Components/Pages/Admin/ApiNycklar.razor b/src/Web/Components/Pages/Admin/ApiNycklar.razor index 35f4781..08ce405 100644 --- a/src/Web/Components/Pages/Admin/ApiNycklar.razor +++ b/src/Web/Components/Pages/Admin/ApiNycklar.razor @@ -137,7 +137,10 @@ else private async Task InaktiveraNyckel(RegionHR.Platform.Domain.ApiKey key) { await using var db = await DbFactory.CreateDbContextAsync(); - key.Inaktivera(); + // key laddades i en annan (disposad) context — hämta spårad entitet här. + var tracked = await db.ApiKeys.FirstOrDefaultAsync(k => k.Id == key.Id); + if (tracked == null) return; + tracked.Inaktivera(); await db.SaveChangesAsync(); _success = $"API-nyckel '{key.Namn}' inaktiverad."; _newPlaintext = null; diff --git a/src/Web/Components/Pages/Admin/AutomationForslag.razor b/src/Web/Components/Pages/Admin/AutomationForslag.razor index 3f35bb7..19ede3f 100644 --- a/src/Web/Components/Pages/Admin/AutomationForslag.razor +++ b/src/Web/Components/Pages/Admin/AutomationForslag.razor @@ -126,6 +126,8 @@ else await using var db = await DbFactory.CreateDbContextAsync(); try { + // forslag laddades i en annan (disposad) context — attacha före mutation. + db.Attach(forslag); forslag.Acceptera(); await db.SaveChangesAsync(); _success = "Forslag accepterat."; @@ -139,6 +141,7 @@ else await using var db = await DbFactory.CreateDbContextAsync(); try { + db.Attach(forslag); forslag.Avvisa(); await db.SaveChangesAsync(); _success = "Forslag avvisat."; diff --git a/src/Web/Components/Pages/Admin/Formaner/Perioder.razor b/src/Web/Components/Pages/Admin/Formaner/Perioder.razor index e9f2f0f..d9c385b 100644 --- a/src/Web/Components/Pages/Admin/Formaner/Perioder.razor +++ b/src/Web/Components/Pages/Admin/Formaner/Perioder.razor @@ -153,14 +153,15 @@ else private async Task OppnaPeriod(EnrollmentPeriod p) { await using var db = await DbFactory.CreateDbContextAsync(); - try { p.Oppna(); await db.SaveChangesAsync(); _success = $"Period '{p.Namn}' öppnad."; } + // p laddades i init-contexten — attacha före mutation för att persistera. + try { db.Attach(p); p.Oppna(); await db.SaveChangesAsync(); _success = $"Period '{p.Namn}' öppnad."; } catch (Exception ex) { _error = $"Fel: {ex.Message}"; } } private async Task StangPeriod(EnrollmentPeriod p) { await using var db = await DbFactory.CreateDbContextAsync(); - try { p.Stang(); await db.SaveChangesAsync(); _success = $"Period '{p.Namn}' stängd."; } + try { db.Attach(p); p.Stang(); await db.SaveChangesAsync(); _success = $"Period '{p.Namn}' stängd."; } catch (Exception ex) { _error = $"Fel: {ex.Message}"; } } diff --git a/src/Web/Components/Pages/Admin/Formaner/Planer.razor b/src/Web/Components/Pages/Admin/Formaner/Planer.razor index 1735c57..63696ff 100644 --- a/src/Web/Components/Pages/Admin/Formaner/Planer.razor +++ b/src/Web/Components/Pages/Admin/Formaner/Planer.razor @@ -192,6 +192,8 @@ else await using var db = await DbFactory.CreateDbContextAsync(); try { + // b laddades i init-contexten — attacha före mutation för att persistera. + db.Attach(b); if (b.ArAktiv) b.Inaktivera(); else b.Aktivera(); await db.SaveChangesAsync(); _success = $"Status för '{b.Namn}' uppdaterad."; diff --git a/src/Web/Components/Pages/Admin/Karriarvagar.razor b/src/Web/Components/Pages/Admin/Karriarvagar.razor index 0c5e6f9..d23e0c8 100644 --- a/src/Web/Components/Pages/Admin/Karriarvagar.razor +++ b/src/Web/Components/Pages/Admin/Karriarvagar.razor @@ -157,7 +157,10 @@ else await using var db = await DbFactory.CreateDbContextAsync(); if (string.IsNullOrWhiteSpace(_newStepBefattning)) return; - cp.LaggTillSteg(_newStepBefattning, _newStepOrdning, _newStepTid); + // cp kommer från init-contexten (disposad). Skapa steget via aggregatet (så UI:t + // uppdateras) och lägg till det nya steget direkt i denna context för persistens. + var steg = cp.LaggTillSteg(_newStepBefattning, _newStepOrdning, _newStepTid); + await db.CareerPathSteps.AddAsync(steg); await db.SaveChangesAsync(); _newStepBefattning = ""; diff --git a/src/Web/Components/Pages/Admin/Konfiguration.razor b/src/Web/Components/Pages/Admin/Konfiguration.razor index 5b0924b..7527863 100644 --- a/src/Web/Components/Pages/Admin/Konfiguration.razor +++ b/src/Web/Components/Pages/Admin/Konfiguration.razor @@ -1,6 +1,8 @@ @page "/admin/konfiguration" @layout RegionHR.Web.Components.Layout.AdminLayout @using RegionHR.Infrastructure.Persistence +@using RegionHR.Payroll.Domain +@using RegionHR.Configuration.Domain @using Microsoft.EntityFrameworkCore @inject IDbContextFactory DbFactory @@ -24,21 +26,40 @@ else - Systemparametrar - Systeminställningar lagras i TenantConfiguration. Ändring kräver admin-behörighet. + @if (_tenant is not null) + { + Organisation + + InställningVärde + + Organisation@_tenant.TenantNamn + Organisationsnummer@_tenant.Organisationsnummer + Land@_tenant.Land + Språk@_tenant.Sprak + Valuta@_tenant.Valuta + + + } + + Löne- och avtalsgrundvärden + + Värdena nedan hämtas direkt ur skattemotorn och de årsversionerade kollektivavtalstabellerna (AB/HOK) + för @KommunSkattesatser.DefaultKommun / Region Örebro län @DateTime.Now.Year. De är beräkningsgrund, inte fritt redigerbara här. + InställningVärdeBeskrivning KollektivavtalAB + HOKPrimära avtal för löneberäkning LöneperiodMånad (25:e)Utbetalningsdatum - AG-avgift31,42%Standard arbetsgivaravgift - Kommunalskatt32,13%Standard kommunalskatt Göteborg + Arbetsgivaravgift@FormatProcent(Arbetsgivaravgift.FullSats2026)Full arbetsgivaravgift @DateTime.Now.Year + Kommunalskatt (@KommunSkattesatser.DefaultKommun)@FormatProcent(KommunSkattesatser.OrebroTotal2026)Kommun 21,35 % + regionskatt @FormatProcent(KommunSkattesatser.RegionOrebroLan2026) } @code { private int _antalAnstallda, _antalEnheter, _antalPositioner, _antalKorningar, _antalLonearter, _antalPolicyer; + private TenantConfiguration? _tenant; private bool _loading = true; private string? _error; @@ -53,9 +74,15 @@ else _antalKorningar = await db.PayrollRuns.CountAsync(); _antalLonearter = await db.SalaryCodes.CountAsync(); _antalPolicyer = await db.Policies.CountAsync(); + _tenant = await db.TenantConfigurations + .OrderByDescending(t => t.ArAktiv) + .FirstOrDefaultAsync(); } catch (Exception ex) { _error = $"Kunde inte hämta systemstatus: {ex.Message}"; } finally { _loading = false; } } + + private static string FormatProcent(decimal andel) => + (andel * 100m).ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("sv-SE")) + " %"; } diff --git a/src/Web/Components/Pages/Admin/ONA/Index.razor b/src/Web/Components/Pages/Admin/ONA/Index.razor index 7aae1b7..a5fa6a9 100644 --- a/src/Web/Components/Pages/Admin/ONA/Index.razor +++ b/src/Web/Components/Pages/Admin/ONA/Index.razor @@ -143,14 +143,14 @@ else private async Task OppnaSurvey(ONASurvey survey) { await using var db = await DbFactory.CreateDbContextAsync(); - try { survey.Oppna(); await db.SaveChangesAsync(); Snackbar.Add("Undersökning öppnad!", Severity.Success); await LaddaData(); } + try { db.Attach(survey); survey.Oppna(); await db.SaveChangesAsync(); Snackbar.Add("Undersökning öppnad!", Severity.Success); await LaddaData(); } catch (Exception ex) { _error = ex.Message; } } private async Task StangSurvey(ONASurvey survey) { await using var db = await DbFactory.CreateDbContextAsync(); - try { survey.Stang(); await db.SaveChangesAsync(); Snackbar.Add("Undersökning stängd.", Severity.Info); await LaddaData(); } + try { db.Attach(survey); survey.Stang(); await db.SaveChangesAsync(); Snackbar.Add("Undersökning stängd.", Severity.Info); await LaddaData(); } catch (Exception ex) { _error = ex.Message; } } @@ -159,6 +159,8 @@ else await using var db = await DbFactory.CreateDbContextAsync(); try { + // survey laddades i init-contexten — attacha så statusändringen persisteras. + db.Attach(survey); var responses = await db.ONAResponses.Where(r => r.SurveyId == survey.Id).ToListAsync(); var result = ONACalculationService.Berakna(survey.Id, responses); diff --git a/src/Web/Components/Pages/Admin/Pulsundersokning.razor b/src/Web/Components/Pages/Admin/Pulsundersokning.razor index 5b0dd7f..c2de167 100644 --- a/src/Web/Components/Pages/Admin/Pulsundersokning.razor +++ b/src/Web/Components/Pages/Admin/Pulsundersokning.razor @@ -178,14 +178,15 @@ else private async Task OppnaEnkat(PulseSurvey s) { await using var db = await DbFactory.CreateDbContextAsync(); - try { s.Oppna(); await db.SaveChangesAsync(); } + // s laddades i init-contexten — attacha före mutation för att persistera. + try { db.Attach(s); s.Oppna(); await db.SaveChangesAsync(); } catch (Exception ex) { _error = $"Kunde inte öppna: {ex.Message}"; } } private async Task StangEnkat(PulseSurvey s) { await using var db = await DbFactory.CreateDbContextAsync(); - try { s.Stang(); await db.SaveChangesAsync(); } + try { db.Attach(s); s.Stang(); await db.SaveChangesAsync(); } catch (Exception ex) { _error = $"Kunde inte stänga: {ex.Message}"; } } diff --git a/src/Web/Components/Pages/Admin/Talangmarknad.razor b/src/Web/Components/Pages/Admin/Talangmarknad.razor index 6cdc8cf..532e466 100644 --- a/src/Web/Components/Pages/Admin/Talangmarknad.razor +++ b/src/Web/Components/Pages/Admin/Talangmarknad.razor @@ -168,6 +168,8 @@ else private async Task PubliceraMojlighet(InternalOpportunity opp) { await using var db = await DbFactory.CreateDbContextAsync(); + // opp laddades i init-contexten — attacha före mutation för att persistera. + db.Attach(opp); opp.Publicera(); await db.SaveChangesAsync(); } @@ -175,6 +177,7 @@ else private async Task StangMojlighet(InternalOpportunity opp) { await using var db = await DbFactory.CreateDbContextAsync(); + db.Attach(opp); opp.Stang(); await db.SaveChangesAsync(); } @@ -182,6 +185,7 @@ else private async Task TillsattMojlighet(InternalOpportunity opp) { await using var db = await DbFactory.CreateDbContextAsync(); + db.Attach(opp); opp.Tillsatt(); await db.SaveChangesAsync(); } diff --git a/src/Web/Components/Pages/Admin/Tillagg/Detalj.razor b/src/Web/Components/Pages/Admin/Tillagg/Detalj.razor index 5c20e44..a29cdec 100644 --- a/src/Web/Components/Pages/Admin/Tillagg/Detalj.razor +++ b/src/Web/Components/Pages/Admin/Tillagg/Detalj.razor @@ -171,6 +171,8 @@ else if (_extension != null) await using var db = await DbFactory.CreateDbContextAsync(); try { + // _installation laddades i init-contexten — attacha före mutation för persistens. + db.Attach(_installation!); if (_installation!.Status == InstallationStatus.Active) { _installation.Inaktivera(); diff --git a/src/Web/Components/Pages/Admin/Tillagg/Index.razor b/src/Web/Components/Pages/Admin/Tillagg/Index.razor index ac255fe..e318917 100644 --- a/src/Web/Components/Pages/Admin/Tillagg/Index.razor +++ b/src/Web/Components/Pages/Admin/Tillagg/Index.razor @@ -181,6 +181,8 @@ else await using var db = await DbFactory.CreateDbContextAsync(); try { + // Installationen laddades i init-contexten — attacha före mutation. + db.Attach(vm.Installation!); vm.Installation!.Inaktivera(); await db.SaveChangesAsync(); _success = $"'{vm.Extension.Namn}' inaktiverat."; @@ -195,6 +197,7 @@ else await using var db = await DbFactory.CreateDbContextAsync(); try { + db.Attach(vm.Installation!); vm.Installation!.Aktivera(); await db.SaveChangesAsync(); _success = $"'{vm.Extension.Namn}' aktiverat."; diff --git a/src/Web/Components/Pages/Admin/Webhooks.razor b/src/Web/Components/Pages/Admin/Webhooks.razor index 3b4f3dd..aa5f51e 100644 --- a/src/Web/Components/Pages/Admin/Webhooks.razor +++ b/src/Web/Components/Pages/Admin/Webhooks.razor @@ -145,7 +145,9 @@ else private async Task PauseSubscription(EventSubscription sub) { await using var db = await DbFactory.CreateDbContextAsync(); - sub.Pausa(); + var tracked = await db.EventSubscriptions.FirstOrDefaultAsync(s => s.Id == sub.Id); + if (tracked == null) return; + tracked.Pausa(); await db.SaveChangesAsync(); _success = $"Webhook '{sub.Namn}' pausad."; await LoadSubscriptions(); @@ -154,7 +156,9 @@ else private async Task ActivateSubscription(EventSubscription sub) { await using var db = await DbFactory.CreateDbContextAsync(); - sub.Aktivera(); + var tracked = await db.EventSubscriptions.FirstOrDefaultAsync(s => s.Id == sub.Id); + if (tracked == null) return; + tracked.Aktivera(); await db.SaveChangesAsync(); _success = $"Webhook '{sub.Namn}' aktiverad."; await LoadSubscriptions(); diff --git a/src/Web/Components/Pages/Anstallda/Detalj.razor b/src/Web/Components/Pages/Anstallda/Detalj.razor index 6a987a7..c01f3f3 100644 --- a/src/Web/Components/Pages/Anstallda/Detalj.razor +++ b/src/Web/Components/Pages/Anstallda/Detalj.razor @@ -165,10 +165,38 @@ LAS-information - Tillsvidareanställd — LAS ej tillämpligt - - Denna anställd har en tillsvidareanställning. LAS-ackumulering för konvertering gäller inte. - + @{ + var aktivLas = _emp.AktivAnstallning(DateOnly.FromDateTime(DateTime.Today)) + ?? _emp.Anstallningar.OrderByDescending(a => a.Giltighetsperiod.Start).FirstOrDefault(); + } + @if (aktivLas is null) + { + Ingen aktiv anställning registrerad — LAS-uppföljning är inte tillämplig. + } + else if (aktivLas.ArTillsvidareanstallning) + { + Tillsvidareanställd — LAS-ackumulering för konvertering är inte tillämplig. + + En tillsvidareanställning har redan det anställningsskydd som LAS-konverteringen syftar till. Ackumulering av visstidsdagar sker inte. + + } + else if (aktivLas.ArProvanstallning) + { + Provanställning — övergår till tillsvidareanställning om den inte avbryts (LAS 6 §, högst 6 månader). + + Anställd sedan @aktivLas.Giltighetsperiod.Start.ToString("yyyy-MM-dd")@(aktivLas.Giltighetsperiod.End is { } slut ? $", planerat slut {slut:yyyy-MM-dd}" : ""). Om provanställningen inte avbryts i tid övergår den automatiskt i en tillsvidareanställning. + + } + else + { + Tidsbegränsad anställning (@aktivLas.Anstallningsform) — LAS-dagar ackumuleras mot konvertering till tillsvidareanställning. + + Anställd sedan @aktivLas.Giltighetsperiod.Start.ToString("yyyy-MM-dd")@(aktivLas.Giltighetsperiod.End is { } slut ? $", t.o.m. {slut:yyyy-MM-dd}" : ""). Ackumulerade LAS-dagar och konverteringströsklar (SÄVA/vikariat) följs upp i LAS-modulen. + + + Öppna LAS-uppföljning + + } diff --git a/src/Web/Components/Pages/Arbetsmiljo/Index.razor b/src/Web/Components/Pages/Arbetsmiljo/Index.razor index 1ee8804..0398644 100644 --- a/src/Web/Components/Pages/Arbetsmiljo/Index.razor +++ b/src/Web/Components/Pages/Arbetsmiljo/Index.razor @@ -1,9 +1,11 @@ @page "/arbetsmiljo" @layout RegionHR.Web.Components.Layout.AdminLayout +@using System.Text @using RegionHR.Infrastructure.Arbetsmiljo @using RegionHR.Infrastructure.Persistence @using Microsoft.EntityFrameworkCore @inject IDbContextFactory DbFactory +@inject IJSRuntime JS OpenHR — Arbetsmiljö (SAM) @@ -26,7 +28,7 @@ else @_incidents.Count(i => i.Status != IncidentStatus.Avslutad) - Öppna tillbud + Öppna ärenden @@ -50,13 +52,22 @@ else - @* === TILLBUD === *@ - + @* === TILLBUD / OLYCKOR / ARBETSSKADOR === *@ +
- - @(_showIncidentForm ? "Stäng" : "Rapportera tillbud") - +
+ + @(_showIncidentForm ? "Stäng" : "Rapportera händelse") + + @if (_incidents.Count > 0) + { + + Ladda ner CSV + + } +
@if (_showIncidentForm) { @@ -68,6 +79,11 @@ else @foreach (var e in _enheter) { @e.Value } + + + @foreach (var t in Enum.GetValues()) { @TypText(t) } + + @@ -77,12 +93,13 @@ else - Spara + Spara } - DatumRapportörEnhetPlatsAllvarlighetTypStatus + DatumRapportörEnhetPlatsAllvarlighetTypStatusÅtgärd @foreach (var i in _incidents.OrderByDescending(i => i.Datum)) { @@ -92,8 +109,22 @@ else @GetEnhetNamn(i.EnhetId) @i.Plats @i.Allvarlighetsgrad - @i.Typ - @i.Status + @TypText(i.Typ) + @StatusText(i.Status) + + @if (i.NastaStatus is { } nasta) + { + @StatusText(nasta) + } + else + { + Återöppna + } + } @@ -121,12 +152,13 @@ else - Spara + Spara } - DatumEnhetDeltagareBristerStatus + DatumEnhetDeltagareBristerStatusÅtgärd @foreach (var r in _rounds.OrderByDescending(r => r.Datum)) { @@ -136,7 +168,43 @@ else @r.Deltagare @r.AntalBrister @r.Status + + @if (r.Status == SafetyRoundStatus.Planerad) + { + Registrera resultat + } + else if (r.Status == SafetyRoundStatus.Genomford) + { + Avsluta + } + + @if (_roundBeingEdited?.Id == r.Id) + { + + + + Registrera resultat av skyddsronden + + + + + + + + +
+ Spara resultat + Avbryt +
+
+ + + } }
@@ -146,10 +214,19 @@ else @* === RISKBEDÖMNINGAR === *@
- - @(_showRiskForm ? "Stäng" : "Ny riskbedömning") - +
+ + @(_showRiskForm ? "Stäng" : "Ny riskbedömning") + + @if (_risks.Count > 0) + { + + Ladda ner CSV + + } +
@if (_showRiskForm) { @@ -167,10 +244,15 @@ else - - + + + + + - Spara + Spara } @@ -195,7 +277,7 @@ else
- RiskSKRiskvärdeÅtgärdAnsvarigDeadlineStatus + RiskSKRiskvärdeÅtgärdAnsvarigDeadlineStatus @foreach (var r in _risks.OrderByDescending(r => r.RiskVarde)) { @@ -206,8 +288,16 @@ else @r.RiskVarde @(r.Atgard ?? "—") @(r.Ansvarig ?? "—") - @(r.Deadline?.ToString("yyyy-MM-dd") ?? "—") - @r.Status + @DeadlineText(r) + @RiskStatusText(r.Status) + + @if (r.NastaStatus is { } nasta) + { + @RiskStatusText(nasta) + } + } @@ -230,11 +320,18 @@ else private string _newIncRapporterare = "", _newIncPlats = "", _newIncBeskrivning = "", _newIncAtgard = ""; private Guid _newIncEnhetId; private IncidentAllvarlighetsgrad _newIncAllvarlighet = IncidentAllvarlighetsgrad.Medel; + private IncidentTyp _newIncTyp = IncidentTyp.Tillbud; private DateTime? _newRoundDatum = DateTime.Today; private Guid _newRoundEnhetId; private string _newRoundDeltagare = ""; private string _newRiskNamn = "", _newRiskBeskrivning = "", _newRiskAtgard = "", _newRiskAnsvarig = ""; private int _newRiskSannolikhet = 3, _newRiskKonsekvens = 3; + private DateTime? _newRiskDeadline; + + // Skyddsrond-resultat (inline-redigering) + private SafetyRound? _roundBeingEdited; + private int _editBrister; + private string _editAnteckningar = ""; protected override async Task OnInitializedAsync() { @@ -261,43 +358,217 @@ else private async Task SparaIncident() { - await using var db = await DbFactory.CreateDbContextAsync(); if (string.IsNullOrWhiteSpace(_newIncBeskrivning)) return; + await using var db = await DbFactory.CreateDbContextAsync(); var inc = Incident.Skapa(DateTime.UtcNow, _newIncRapporterare, _newIncEnhetId, _newIncPlats, - _newIncBeskrivning, _newIncAllvarlighet, IncidentTyp.Tillbud, _newIncAtgard); + _newIncBeskrivning, _newIncAllvarlighet, _newIncTyp, _newIncAtgard); db.Incidents.Add(inc); await db.SaveChangesAsync(); - _incidents.Add(inc); + _incidents = await db.Incidents.ToListAsync(); _showIncidentForm = false; _newIncRapporterare = ""; _newIncPlats = ""; _newIncBeskrivning = ""; _newIncAtgard = ""; + _newIncTyp = IncidentTyp.Tillbud; _newIncAllvarlighet = IncidentAllvarlighetsgrad.Medel; } - private async Task SparaRound() + /// Flyttar ett ärende ett steg framåt i livscykeln och persisterar. + private async Task AvanceraIncident(Incident inc) + { + await using var db = await DbFactory.CreateDbContextAsync(); + var e = await db.Incidents.FirstOrDefaultAsync(x => x.Id == inc.Id); + if (e is null) return; + e.FlyttaFram(); + await db.SaveChangesAsync(); + _incidents = await db.Incidents.ToListAsync(); + } + + /// Återöppnar ett avslutat ärende och persisterar. + private async Task AteroppnaIncident(Incident inc) { await using var db = await DbFactory.CreateDbContextAsync(); + var e = await db.Incidents.FirstOrDefaultAsync(x => x.Id == inc.Id); + if (e is null) return; + e.Ateroppna(); + await db.SaveChangesAsync(); + _incidents = await db.Incidents.ToListAsync(); + } + + private async Task SparaRound() + { if (string.IsNullOrWhiteSpace(_newRoundDeltagare)) return; + await using var db = await DbFactory.CreateDbContextAsync(); var round = SafetyRound.Skapa(_newRoundDatum?.ToUniversalTime() ?? DateTime.UtcNow, _newRoundEnhetId, _newRoundDeltagare, 0, SafetyRoundStatus.Planerad); db.SafetyRounds.Add(round); await db.SaveChangesAsync(); - _rounds.Add(round); + _rounds = await db.SafetyRounds.ToListAsync(); _showRoundForm = false; _newRoundDeltagare = ""; } - private async Task SparaRisk() + private void OppnaRondResultat(SafetyRound r) + { + _roundBeingEdited = r; + _editBrister = r.AntalBrister; + _editAnteckningar = r.Anteckningar ?? ""; + } + + /// Registrerar rondens resultat (antal brister + anteckningar) och persisterar. + private async Task GenomforRound() + { + if (_roundBeingEdited is null) return; + await using var db = await DbFactory.CreateDbContextAsync(); + var e = await db.SafetyRounds.FirstOrDefaultAsync(x => x.Id == _roundBeingEdited.Id); + if (e is null) return; + e.Genomfor(_editBrister, string.IsNullOrWhiteSpace(_editAnteckningar) ? null : _editAnteckningar); + await db.SaveChangesAsync(); + _rounds = await db.SafetyRounds.ToListAsync(); + _roundBeingEdited = null; + } + + /// Stänger en genomförd rond och persisterar. + private async Task AvslutaRound(SafetyRound r) { await using var db = await DbFactory.CreateDbContextAsync(); + var e = await db.SafetyRounds.FirstOrDefaultAsync(x => x.Id == r.Id); + if (e is null) return; + e.Avsluta(); + await db.SaveChangesAsync(); + _rounds = await db.SafetyRounds.ToListAsync(); + } + + private async Task SparaRisk() + { if (string.IsNullOrWhiteSpace(_newRiskNamn)) return; + await using var db = await DbFactory.CreateDbContextAsync(); + var deadline = _newRiskDeadline.HasValue ? DateOnly.FromDateTime(_newRiskDeadline.Value) : (DateOnly?)null; var risk = RiskAssessment.Skapa(_newRiskNamn, _newRiskBeskrivning, - _newRiskSannolikhet, _newRiskKonsekvens, _newRiskAtgard, _newRiskAnsvarig, null); + _newRiskSannolikhet, _newRiskKonsekvens, _newRiskAtgard, _newRiskAnsvarig, deadline); db.RiskAssessments.Add(risk); await db.SaveChangesAsync(); - _risks.Add(risk); + _risks = await db.RiskAssessments.ToListAsync(); _showRiskForm = false; _newRiskNamn = ""; _newRiskBeskrivning = ""; _newRiskAtgard = ""; _newRiskAnsvarig = ""; + _newRiskDeadline = null; + } + + /// Flyttar en riskbedömning ett steg framåt i åtgärdskedjan och persisterar. + private async Task AvanceraRisk(RiskAssessment r) + { + await using var db = await DbFactory.CreateDbContextAsync(); + var e = await db.RiskAssessments.FirstOrDefaultAsync(x => x.Id == r.Id); + if (e is null) return; + e.FlyttaFram(); + await db.SaveChangesAsync(); + _risks = await db.RiskAssessments.ToListAsync(); + } + + private async Task LaddaNerIncidenterCsv() + { + var sb = new StringBuilder(); + sb.AppendLine("Datum;Rapportör;Enhet;Plats;Allvarlighet;Typ;Status;Åtgärdsförslag"); + foreach (var i in _incidents.OrderByDescending(i => i.Datum)) + { + sb.AppendLine(string.Join(';', new[] + { + i.Datum.ToLocalTime().ToString("yyyy-MM-dd"), + CsvFalt(i.RapporterareNamn), + CsvFalt(GetEnhetNamn(i.EnhetId)), + CsvFalt(i.Plats), + i.Allvarlighetsgrad.ToString(), + TypText(i.Typ), + StatusText(i.Status), + CsvFalt(i.AtgardsForslag ?? "") + })); + } + await LaddaNerCsv("tillbud-skador.csv", sb.ToString()); + } + + private async Task LaddaNerRiskerCsv() + { + var sb = new StringBuilder(); + sb.AppendLine("Risk;Sannolikhet;Konsekvens;Riskvärde;Åtgärd;Ansvarig;Deadline;Status"); + foreach (var r in _risks.OrderByDescending(r => r.RiskVarde)) + { + sb.AppendLine(string.Join(';', new[] + { + CsvFalt(r.RiskNamn), + r.Sannolikhet.ToString(), + r.Konsekvens.ToString(), + r.RiskVarde.ToString(), + CsvFalt(r.Atgard ?? ""), + CsvFalt(r.Ansvarig ?? ""), + r.Deadline?.ToString("yyyy-MM-dd") ?? "", + RiskStatusText(r.Status) + })); + } + await LaddaNerCsv("riskbedomningar.csv", sb.ToString()); + } + + private async Task LaddaNerCsv(string filnamn, string innehall) + { + var bytes = new byte[] { 0xEF, 0xBB, 0xBF } + .Concat(Encoding.UTF8.GetBytes(innehall)).ToArray(); + await JS.InvokeVoidAsync("downloadFile", filnamn, Convert.ToBase64String(bytes), "text/csv"); + } + + private static string CsvFalt(string v) + { + const char q = '\u0022'; + if (v.Contains(';') || v.Contains(q) || v.Contains('\n')) + return q + v.Replace("\u0022", "\u0022\u0022") + q; + return v; } + private static string DeadlineText(RiskAssessment r) + { + if (r.Deadline is not { } d) return "—"; + var idag = DateOnly.FromDateTime(DateTime.Today); + var forsenad = idag > d && r.Status != RiskStatus.Atgardad; + return forsenad ? $"{d:yyyy-MM-dd} (försenad)" : d.ToString("yyyy-MM-dd"); + } + + private static string TypText(IncidentTyp t) => t switch + { + IncidentTyp.Tillbud => "Tillbud", + IncidentTyp.Olycka => "Olycka", + IncidentTyp.Arbetsskada => "Arbetsskada", + _ => t.ToString() + }; + + private static string StatusText(IncidentStatus s) => s switch + { + IncidentStatus.Rapporterad => "Rapporterad", + IncidentStatus.UnderUtredning => "Under utredning", + IncidentStatus.AtgardVidtagen => "Åtgärd vidtagen", + IncidentStatus.Avslutad => "Avslutad", + _ => s.ToString() + }; + + private static Color IncidentStatusFarg(IncidentStatus s) => s switch + { + IncidentStatus.Rapporterad => Color.Error, + IncidentStatus.UnderUtredning => Color.Warning, + IncidentStatus.AtgardVidtagen => Color.Info, + IncidentStatus.Avslutad => Color.Success, + _ => Color.Default + }; + + private static string RiskStatusText(RiskStatus s) => s switch + { + RiskStatus.Identifierad => "Identifierad", + RiskStatus.UnderBehandling => "Under behandling", + RiskStatus.Atgardad => "Åtgärdad", + _ => s.ToString() + }; + + private static Color RiskStatusFarg(RiskStatus s) => s switch + { + RiskStatus.Identifierad => Color.Error, + RiskStatus.UnderBehandling => Color.Warning, + RiskStatus.Atgardad => Color.Success, + _ => Color.Default + }; + private static Color AllvarlighetFarg(IncidentAllvarlighetsgrad a) => a switch { IncidentAllvarlighetsgrad.Kritisk => Color.Error, @@ -321,4 +592,3 @@ else _ => Color.Success }; } - diff --git a/src/Web/Components/Pages/Arenden/Detalj.razor b/src/Web/Components/Pages/Arenden/Detalj.razor index 6605702..d44c06b 100644 --- a/src/Web/Components/Pages/Arenden/Detalj.razor +++ b/src/Web/Components/Pages/Arenden/Detalj.razor @@ -1,6 +1,8 @@ @page "/arenden/{Id:guid}" +@using Microsoft.EntityFrameworkCore @inject RegionHR.Web.Services.ArendeService ArendeService @inject RegionHR.Web.Services.AuthService Auth +@inject Microsoft.EntityFrameworkCore.IDbContextFactory DbFactory Ärende - RegionHR @@ -19,6 +21,10 @@ else { @_meddelande } + @if (_fel is not null) + { + @_fel + }
@@ -61,12 +67,12 @@ else
Chefsgodkännande
- +
- Godkänn - Avslå + Godkänn + Avslå
@@ -101,6 +107,8 @@ else private RegionHR.CaseManagement.Domain.Case? _arende; private string? _godkannandeKommentar; private string? _meddelande; + private string? _fel; + private bool _arbetar; protected override async Task OnInitializedAsync() { @@ -109,20 +117,68 @@ else private async Task Godkann() { - if (!Auth.EmployeeId.HasValue) return; + if (!Auth.EmployeeId.HasValue) { _fel = "Kräver kopplad anställd."; return; } var godkannare = RegionHR.SharedKernel.Domain.EmployeeId.From(Auth.EmployeeId.Value); - // First send for approval, then approve - _arende!.SkickaForGodkannande("Chefsgodkännande", godkannare); - await ArendeService.GodkannAsync( - RegionHR.SharedKernel.Domain.CaseId.From(Id), godkannare, _godkannandeKommentar); - _meddelande = "Ärende godkänt!"; + _fel = null; _meddelande = null; _arbetar = true; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var caseId = RegionHR.SharedKernel.Domain.CaseId.From(Id); + var arende = await db.Cases.Include(c => c.Godkannanden).Include(c => c.Kommentarer) + .FirstOrDefaultAsync(c => c.Id == caseId); + if (arende is null) { _fel = "Ärendet hittades inte."; return; } + + // Säkerställ att ett väntande godkännande finns för denna beslutsfattare innan Godkann(). + var pending = arende.Godkannanden.LastOrDefault(g => + g.GodkannareId == godkannare && g.Status == RegionHR.CaseManagement.Domain.ApprovalStatus.Vantar); + if (pending is null) + arende.SkickaForGodkannande("Chefsgodkännande", godkannare); + + arende.Godkann(godkannare, _godkannandeKommentar); + arende.Avsluta(); + await db.SaveChangesAsync(); + _meddelande = "Ärende godkänt och avslutat."; + } + catch (Exception ex) { _fel = $"Kunde inte godkänna: {ex.Message}"; } + finally { _arbetar = false; } + _arende = await ArendeService.HamtaAsync(RegionHR.SharedKernel.Domain.CaseId.From(Id)); } - private Task Avslag() + private async Task Avslag() { - _meddelande = "Ärende avslaget."; - return Task.CompletedTask; + if (!Auth.EmployeeId.HasValue) { _fel = "Kräver kopplad anställd."; return; } + if (string.IsNullOrWhiteSpace(_godkannandeKommentar)) { _fel = "Ange en motivering för avslaget."; return; } + var beslutsfattare = RegionHR.SharedKernel.Domain.EmployeeId.From(Auth.EmployeeId.Value); + _fel = null; _meddelande = null; _arbetar = true; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var caseId = RegionHR.SharedKernel.Domain.CaseId.From(Id); + var arende = await db.Cases.Include(c => c.Godkannanden).Include(c => c.Kommentarer) + .FirstOrDefaultAsync(c => c.Id == caseId); + if (arende is null) { _fel = "Ärendet hittades inte."; return; } + + // Registrera ett avslagsbeslut på godkännandet och stäng ärendet. + var pending = arende.Godkannanden.LastOrDefault(g => + g.Status == RegionHR.CaseManagement.Domain.ApprovalStatus.Vantar); + if (pending is null) + { + arende.SkickaForGodkannande("Chefsgodkännande", beslutsfattare); + pending = arende.Godkannanden.Last(); + } + pending.Status = RegionHR.CaseManagement.Domain.ApprovalStatus.Avslagen; + pending.BeslutVid = DateTime.UtcNow; + pending.Kommentar = _godkannandeKommentar; + arende.LaggTillKommentar(beslutsfattare, $"Avslag: {_godkannandeKommentar}"); + arende.Avsluta(); + await db.SaveChangesAsync(); + _meddelande = "Ärende avslaget. Beslut och motivering sparade."; + } + catch (Exception ex) { _fel = $"Kunde inte avslå: {ex.Message}"; } + finally { _arbetar = false; } + + _arende = await ArendeService.HamtaAsync(RegionHR.SharedKernel.Domain.CaseId.From(Id)); } private static string StatusVariant(RegionHR.SharedKernel.Domain.CaseStatus status) => status switch diff --git a/src/Web/Components/Pages/Arenden/Index.razor b/src/Web/Components/Pages/Arenden/Index.razor index 15471b5..85d18ac 100644 --- a/src/Web/Components/Pages/Arenden/Index.razor +++ b/src/Web/Components/Pages/Arenden/Index.razor @@ -7,6 +7,7 @@ @using Microsoft.Extensions.Localization @inject IDbContextFactory DbFactory @inject IStringLocalizer L +@inject NavigationManager Nav OpenHR - @L["Cases"]
@@ -37,16 +38,17 @@ else else { - @L["Type"]@L["Employee"]@L["Description"]@L["Status"]@L["Created"] + @L["Type"]@L["Employee"]@L["Description"]@L["Status"]@L["Created"] @foreach (var r in _rader) { - + @r.Typ @r.Anstalld @r.Beskrivning @r.StatusText @r.Skapad + Visa } @@ -77,6 +79,7 @@ else var names = employees.ToDictionary(e => e.Id, e => $"{e.Fornamn} {e.Efternamn}"); _rader = cases.Select(c => new ArendeRad( + c.Id.Value, MapTyp(c.Typ), names.TryGetValue(c.AnstallId, out var n) ? n : L["Unknown"].Value, c.Beskrivning, @@ -124,6 +127,6 @@ else _ => Color.Default }; - private record ArendeRad(string Typ, string Anstalld, string Beskrivning, string StatusText, Color StatusFarg, string Skapad); + private record ArendeRad(Guid Id, string Typ, string Anstalld, string Beskrivning, string StatusText, Color StatusFarg, string Skapad); } diff --git a/src/Web/Components/Pages/Arenden/MBL.razor b/src/Web/Components/Pages/Arenden/MBL.razor index 3e51901..320bda2 100644 --- a/src/Web/Components/Pages/Arenden/MBL.razor +++ b/src/Web/Components/Pages/Arenden/MBL.razor @@ -31,6 +31,8 @@ else + + Skapa @@ -43,7 +45,7 @@ else else { - ÄrendeTypDatumStatusFackombudAG-ombud + ÄrendeTypDatumStatusFackombudAG-ombudÅtgärder @foreach (var m in _forhandlingar) { @@ -54,7 +56,38 @@ else @StatusText(m.Status) @(m.Fackombud ?? "—") @(m.Arbetsgivarombud ?? "—") + + @if (m.Status == MBLStatus.Kallad) + { + Påbörja + } + @if (m.Status == MBLStatus.Pagaende) + { + Avsluta + } + @if (m.Status == MBLStatus.Avslutad || m.Status == MBLStatus.Pagaende) + { + Protokollför + } + @if (m.Status == MBLStatus.Protokollfrd && !string.IsNullOrWhiteSpace(m.Protokoll)) + { + Protokoll + } + + @if (_protokollFor == m.Id) + { + + + + + Spara protokoll + Avbryt + + + + } } @@ -65,10 +98,16 @@ else private List _forhandlingar = []; private bool _loading = true, _saving, _showForm; private string? _error, _success, _formArende = ""; + private string _formFackombud = "", _formArbetsgivarombud = ""; private MBLType _formTyp = MBLType.Information; private DateTime? _formDatum; - protected override async Task OnInitializedAsync() + private Guid? _protokollFor; + private string _protokollText = ""; + + protected override async Task OnInitializedAsync() => await Load(); + + private async Task Load() { await using var db = await DbFactory.CreateDbContextAsync(); try { _forhandlingar = await db.MBLNegotiations.OrderByDescending(m => m.Datum).ToListAsync(); } @@ -83,16 +122,55 @@ else _saving = true; _error = null; try { - var mbl = MBLNegotiation.Skapa(_formArende!, _formTyp, DateOnly.FromDateTime(_formDatum.Value)); + var mbl = MBLNegotiation.Skapa(_formArende!, _formTyp, DateOnly.FromDateTime(_formDatum.Value), + string.IsNullOrWhiteSpace(_formFackombud) ? null : _formFackombud, + string.IsNullOrWhiteSpace(_formArbetsgivarombud) ? null : _formArbetsgivarombud); db.MBLNegotiations.Add(mbl); await db.SaveChangesAsync(); - _forhandlingar.Insert(0, mbl); _showForm = false; _success = "MBL-förhandling skapad."; _formArende = ""; + _formFackombud = ""; _formArbetsgivarombud = ""; + await Load(); } catch (Exception ex) { _error = $"Kunde inte skapa: {ex.Message}"; } finally { _saving = false; } } + private async Task Paborja(MBLNegotiation m) => await KorAsync(m, x => x.Paborja(), "Förhandling påbörjad."); + private async Task Avsluta(MBLNegotiation m) => await KorAsync(m, x => x.Avsluta(), "Förhandling avslutad."); + + private void StartaProtokoll(MBLNegotiation m) + { + _protokollFor = m.Id; + _protokollText = m.Protokoll ?? ""; + } + + private async Task SparaProtokoll(MBLNegotiation m) + { + var text = _protokollText; + await KorAsync(m, x => x.RegistreraProtokoll(text), "Protokoll registrerat."); + _protokollFor = null; + _protokollText = ""; + } + + // Attacherar den (från annan context laddade) entiteten i en ny context så att + // statusövergången faktiskt persisteras, och laddar sedan om listan. + private async Task KorAsync(MBLNegotiation m, Action operation, string okMsg) + { + _saving = true; _error = null; _success = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + db.Attach(m); + operation(m); + await db.SaveChangesAsync(); + _success = okMsg; + await Load(); + } + catch (InvalidOperationException ex) { _error = ex.Message; } + catch (Exception ex) { _error = $"Åtgärden misslyckades: {ex.Message}"; } + finally { _saving = false; } + } + private static string StatusText(MBLStatus s) => s switch { MBLStatus.Kallad => "Kallad", MBLStatus.Pagaende => "Pågående", MBLStatus.Avslutad => "Avslutad", MBLStatus.Protokollfrd => "Protokollförd", _ => s.ToString() }; private static Color StatusFarg(MBLStatus s) => s switch { MBLStatus.Kallad => Color.Warning, MBLStatus.Pagaende => Color.Info, MBLStatus.Avslutad => Color.Success, MBLStatus.Protokollfrd => Color.Success, _ => Color.Default }; } diff --git a/src/Web/Components/Pages/Audit/Index.razor b/src/Web/Components/Pages/Audit/Index.razor index dea94ed..78dc33d 100644 --- a/src/Web/Components/Pages/Audit/Index.razor +++ b/src/Web/Components/Pages/Audit/Index.razor @@ -2,8 +2,10 @@ @layout RegionHR.Web.Components.Layout.AdminLayout @using RegionHR.Infrastructure.Persistence @using RegionHR.Audit.Domain +@using System.Text @using Microsoft.EntityFrameworkCore @inject IDbContextFactory DbFactory +@inject IJSRuntime JS OpenHR - Granskningslogg Granskningslogg @@ -25,7 +27,11 @@ else @_deleteCountTa bort - Senaste händelser +
+ Senaste händelser + Ladda ner CSV +
@if (_entries.Count == 0) { Inga audit-händelser registrerade. @@ -90,15 +96,54 @@ else finally { _loading = false; } } - private static string FormatSummary(AuditEntry entry) => entry.Action switch + private async Task ExporteraCsv() { - AuditAction.Create => $"Nytt objekt skapat i {entry.EntityType}", - AuditAction.Update => entry.NewValues is not null ? $"Uppdaterad: {Truncate(entry.NewValues, 80)}" : $"{entry.EntityType} uppdaterad", - AuditAction.Delete => $"{entry.EntityType} borttagen", - _ => "" - }; + var sb = new StringBuilder(); + sb.AppendLine("Tidpunkt;Anvandare;Entitet;EntitetsId;Atgard;Sammanfattning"); + foreach (var e in _entries) + { + var row = new[] + { + e.Timestamp.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"), + e.UserName ?? e.UserId, + e.EntityType, + e.EntityId, + e.Action.ToString(), + FormatSummary(e) + }; + sb.AppendLine(string.Join(";", row.Select(EscapeCsv))); + } + + var bytes = new byte[] { 0xEF, 0xBB, 0xBF } + .Concat(Encoding.UTF8.GetBytes(sb.ToString())).ToArray(); + await JS.InvokeVoidAsync("downloadFile", + $"granskningslogg_{DateTime.Now:yyyyMMdd_HHmm}.csv", + Convert.ToBase64String(bytes), "text/csv"); + } + + private static string EscapeCsv(string value) + { + const char q = '\u0022'; + if (value.Contains(';') || value.Contains(q) || value.Contains('\n')) + return q + value.Replace("\u0022", "\u0022\u0022") + q; + return value; + } - private static string Truncate(string value, int maxLength) => - value.Length <= maxLength ? value : value[..maxLength] + "..."; + private static string FormatSummary(AuditEntry entry) + { + return entry.Action switch + { + AuditAction.Create => $"Nytt objekt skapat i {entry.EntityType}", + AuditAction.Update => entry.NewValues is not null ? $"Uppdaterad: {Truncate(entry.NewValues, 80)}" : $"{entry.EntityType} uppdaterad", + AuditAction.Delete => $"{entry.EntityType} borttagen", + _ => "" + }; + } + + private static string Truncate(string value, int maxLength) + { + if (maxLength >= value.Length) return value; + return value[..maxLength] + "..."; + } } diff --git a/src/Web/Components/Pages/Chef/OneOnOne.razor b/src/Web/Components/Pages/Chef/OneOnOne.razor index 7ad408a..78d2a70 100644 --- a/src/Web/Components/Pages/Chef/OneOnOne.razor +++ b/src/Web/Components/Pages/Chef/OneOnOne.razor @@ -197,6 +197,8 @@ else await using var db = await DbFactory.CreateDbContextAsync(); try { + // meeting laddades i init-contexten — attacha före mutation för persistens. + db.Attach(meeting); meeting.Genomfor("Möte genomfört."); await db.SaveChangesAsync(); Snackbar.Add("Möte markerat som genomfört!", Severity.Success); @@ -210,6 +212,7 @@ else await using var db = await DbFactory.CreateDbContextAsync(); try { + db.Attach(meeting); meeting.Avboka(); await db.SaveChangesAsync(); Snackbar.Add("Möte avbokat.", Severity.Warning); @@ -223,6 +226,7 @@ else await using var db = await DbFactory.CreateDbContextAsync(); try { + db.Attach(item); item.Paborja(); await db.SaveChangesAsync(); await LaddaData(); @@ -235,6 +239,7 @@ else await using var db = await DbFactory.CreateDbContextAsync(); try { + db.Attach(item); item.Slutfor(); await db.SaveChangesAsync(); Snackbar.Add("Åtgärd slutförd!", Severity.Success); diff --git a/src/Web/Components/Pages/Chef/Scorecard.razor b/src/Web/Components/Pages/Chef/Scorecard.razor index 60da87f..6ff5729 100644 --- a/src/Web/Components/Pages/Chef/Scorecard.razor +++ b/src/Web/Components/Pages/Chef/Scorecard.razor @@ -134,6 +134,8 @@ else private async Task MarkeraLast(CoachingNudge nudge) { await using var db = await DbFactory.CreateDbContextAsync(); + // nudge laddades i init-contexten — attacha före mutation för att persistera. + db.Attach(nudge); nudge.MarkeraSomLast(); await db.SaveChangesAsync(); _nudges.Remove(nudge); diff --git a/src/Web/Components/Pages/Dokument/MallGenerator.razor b/src/Web/Components/Pages/Dokument/MallGenerator.razor index 31cc9b2..29091c4 100644 --- a/src/Web/Components/Pages/Dokument/MallGenerator.razor +++ b/src/Web/Components/Pages/Dokument/MallGenerator.razor @@ -12,7 +12,7 @@ @inject IJSRuntime JS OpenHR - Generera dokument -Tillbaka +Tillbaka @switch (_step) diff --git a/src/Web/Components/Pages/Dokument/Policyer.razor b/src/Web/Components/Pages/Dokument/Policyer.razor index a3dac20..cb00e37 100644 --- a/src/Web/Components/Pages/Dokument/Policyer.razor +++ b/src/Web/Components/Pages/Dokument/Policyer.razor @@ -182,14 +182,16 @@ else private async Task PubliceraPolicy(Policy p) { await using var db = await DbFactory.CreateDbContextAsync(); - try { p.Publicera(); await db.SaveChangesAsync(); } + // p är laddad i en tidigare (disposad) context — attacha i denna context + // så att statusändringen faktiskt spåras och persisteras. + try { db.Attach(p); p.Publicera(); await db.SaveChangesAsync(); _success = $"Policy \"{p.Titel}\" publicerad."; } catch (Exception ex) { _error = $"Kunde inte publicera: {ex.Message}"; } } private async Task ArkiveraPolicy(Policy p) { await using var db = await DbFactory.CreateDbContextAsync(); - try { p.Arkivera(); await db.SaveChangesAsync(); } + try { db.Attach(p); p.Arkivera(); await db.SaveChangesAsync(); _success = $"Policy \"{p.Titel}\" arkiverad."; } catch (Exception ex) { _error = $"Kunde inte arkivera: {ex.Message}"; } } diff --git a/src/Web/Components/Pages/Formaner/Enrollment.razor b/src/Web/Components/Pages/Formaner/Enrollment.razor index 88934db..89d0a6c 100644 --- a/src/Web/Components/Pages/Formaner/Enrollment.razor +++ b/src/Web/Components/Pages/Formaner/Enrollment.razor @@ -138,8 +138,12 @@ else @if (s.Status == EnrollmentStatus.Ansokt) { - Godkänn +
+ Godkänn + Neka +
} @@ -190,9 +194,39 @@ else private async Task GodkannVal(EmployeeBenefit val) { + _error = null; _success = null; + // Hämta entiteten i samma context som SaveChanges — den listade instansen + // kommer från OnInitialized-contextet (disposat) och trackas inte här, + // så en mutation på den skulle inte persisteras. await using var db = await DbFactory.CreateDbContextAsync(); - val.Godkann(); - await db.SaveChangesAsync(); + try + { + var e = await db.EmployeeBenefits.FirstOrDefaultAsync(x => x.Id == val.Id); + if (e is null) { _error = "Förmånsvalet hittades inte."; return; } + e.Godkann(); + await db.SaveChangesAsync(); + var idx = _selections.IndexOf(val); + if (idx >= 0) _selections[idx] = e; + _success = $"Förmånsval godkänt för {GetEmployeeName(e.AnstallId)}."; + } + catch (Exception ex) { _error = $"Kunde inte godkänna: {ex.Message}"; } + } + + private async Task NekaVal(EmployeeBenefit val) + { + _error = null; _success = null; + await using var db = await DbFactory.CreateDbContextAsync(); + try + { + var e = await db.EmployeeBenefits.FirstOrDefaultAsync(x => x.Id == val.Id); + if (e is null) { _error = "Förmånsvalet hittades inte."; return; } + e.Neka(); + await db.SaveChangesAsync(); + var idx = _selections.IndexOf(val); + if (idx >= 0) _selections[idx] = e; + _success = $"Förmånsval nekat för {GetEmployeeName(e.AnstallId)}."; + } + catch (Exception ex) { _error = $"Kunde inte neka: {ex.Message}"; } } private string GetEmployeeName(Guid id) diff --git a/src/Web/Components/Pages/Formaner/Friskvard.razor b/src/Web/Components/Pages/Formaner/Friskvard.razor index 7eab128..cca40f0 100644 --- a/src/Web/Components/Pages/Formaner/Friskvard.razor +++ b/src/Web/Components/Pages/Formaner/Friskvard.razor @@ -74,17 +74,42 @@ else @c.Claim.Aktivitet @c.Claim.Belopp.ToString("N0") kr @c.Claim.Datum.ToString("yyyy-MM-dd") - @StatusText(c.Claim.Status) - @* Godkännande kräver chefs-/HR-behörighet och får inte avse egen ansökan + @StatusText(c.Claim.Status) + @if (c.Claim.Status == WellnessClaimStatus.Avslagen && !string.IsNullOrWhiteSpace(c.Claim.Kommentar)) + { + @c.Claim.Kommentar + } + + + @* Godkännande/avslag kräver chefs-/HR-behörighet och får inte avse egen ansökan (jämför FKAnmalan-sidans HR-spärr). *@ @if (c.Claim.Status == WellnessClaimStatus.Inskickad && Auth.EmployeeId.HasValue && Auth.IsChef && c.Claim.AnstallId != Auth.EmployeeId.Value) { - Godkänn +
+ Godkänn + Avslå +
} + @if (_avslarClaimId == c.Claim.Id) + { + + + + +
+ Bekräfta avslag + Avbryt +
+
+ + + } }
@@ -162,6 +187,40 @@ else catch (Exception ex) { _error = $"Fel: {ex.Message}"; } } + private Guid? _avslarClaimId; + private string _avslaKommentar = ""; + + private void OppnaAvsla(ClaimVm vm) + { + _avslarClaimId = vm.Claim.Id; + _avslaKommentar = ""; + _error = null; + } + + private async Task AvslaClaim(ClaimVm vm) + { + if (!Auth.EmployeeId.HasValue) { _error = "Kräver kopplad anställd."; return; } + // Samma behörighetsspärr som vid godkännande: endast chef/HR, aldrig egen ansökan. + if (!Auth.IsChef) { _error = "Endast chef eller HR kan avslå friskvårdsansökningar."; return; } + if (vm.Claim.AnstallId == Auth.EmployeeId.Value) { _error = "Du kan inte avslå din egen ansökan."; return; } + if (string.IsNullOrWhiteSpace(_avslaKommentar)) return; + _error = null; + await using var db = await DbFactory.CreateDbContextAsync(); + try + { + var claim = await db.WellnessClaims.FirstOrDefaultAsync(c => c.Id == vm.Claim.Id); + if (claim is null) { _error = "Ansökan hittades inte."; return; } + claim.Avvisa(Auth.EmployeeId.Value, _avslaKommentar.Trim()); + await db.SaveChangesAsync(); + + var idx = _claims.IndexOf(vm); + if (idx >= 0) _claims[idx] = vm with { Claim = claim }; + _avslarClaimId = null; + _success = "Ansökan avslagen."; + } + catch (Exception ex) { _error = $"Fel: {ex.Message}"; } + } + /// Summan av årets redan godkända friskvårdsansökningar för den anställde. private static async Task SummaGodkantUnderAretAsync( RegionHRDbContext db, Guid anstallId, int ar, Guid? exkluderaClaimId = null) diff --git a/src/Web/Components/Pages/Formaner/Index.razor b/src/Web/Components/Pages/Formaner/Index.razor index e3bdb5e..de7aad9 100644 --- a/src/Web/Components/Pages/Formaner/Index.razor +++ b/src/Web/Components/Pages/Formaner/Index.razor @@ -23,11 +23,21 @@ Skapa ny förmån
- - - - Skapa + + + + + @foreach (var k in Enum.GetValues()) { @KategoriText(k) } + + + + + + + + Skapa } @@ -66,12 +76,31 @@ @b.Namn + @if (!string.IsNullOrWhiteSpace(b.Beskrivning)) + { + Beskrivning: @b.Beskrivning + } Kategori: @KategoriText(b.Kategori) Maxbelopp: @(b.MaxBelopp > 0 ? $"{b.MaxBelopp:N0} kr" : "—") + Arbetsgivarandel: @b.ArbetsgivarAndel % Skattepliktig: @(b.ArSkattepliktig ? "Ja" : "Nej") Antal anmälda: @AnmaldaCount(b.Id) Status: @(b.ArAktiv ? "Aktiv" : "Inaktiv") - Stäng +
+ @if (b.ArAktiv) + { + Inaktivera + } + else + { + Aktivera + } + Stäng +
@@ -83,18 +112,13 @@ @code { private bool _showForm; private bool _skapad; + private bool _saving; private string _newNamn = ""; private string _newBeskrivning = ""; - private string _newBudget = ""; - - private void SkapaForman() - { - _showForm = false; - _skapad = true; - _newNamn = ""; - _newBeskrivning = ""; - _newBudget = ""; - } + private decimal _newBudget; + private decimal _newArbetsgivarAndel = 100; + private bool _newSkattepliktig; + private BenefitCategory _newKategori = BenefitCategory.Friskvard; private List _benefits = []; private Benefit? _valdForman; @@ -103,9 +127,11 @@ private void ValjForman(Benefit b) => _valdForman = _valdForman == b ? null : b; - protected override async Task OnInitializedAsync() + protected override async Task OnInitializedAsync() => await LaddaBenefits(); + + private async Task LaddaBenefits() { - using var db = DbFactory.CreateDbContext(); + await using var db = await DbFactory.CreateDbContextAsync(); try { _benefits = await db.Benefits @@ -122,6 +148,54 @@ catch (Exception ex) { _error = $"Kunde inte hämta förmåner: {ex.Message}"; } } + private async Task SkapaForman() + { + if (string.IsNullOrWhiteSpace(_newNamn)) return; + _saving = true; _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var forman = Benefit.Skapa( + _newNamn.Trim(), + _newBeskrivning.Trim(), + _newKategori, + _newBudget, + _newArbetsgivarAndel, + _newSkattepliktig); + db.Benefits.Add(forman); + await db.SaveChangesAsync(); + + _showForm = false; + _skapad = true; + _newNamn = ""; _newBeskrivning = ""; _newBudget = 0; + _newArbetsgivarAndel = 100; _newSkattepliktig = false; + _newKategori = BenefitCategory.Friskvard; + await LaddaBenefits(); + } + catch (Exception ex) { _error = $"Kunde inte skapa förmån: {ex.Message}"; } + finally { _saving = false; } + } + + /// + /// Aktiverar/inaktiverar en förmån. Entiteten hämtas i samma context som + /// SaveChanges eftersom den listade instansen kommer från en annan (disposad) context. + /// + private async Task VaxlaAktiv(Benefit b) + { + _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var forman = await db.Benefits.FirstOrDefaultAsync(x => x.Id == b.Id); + if (forman is null) { _error = "Förmånen hittades inte."; return; } + if (forman.ArAktiv) forman.Inaktivera(); else forman.Aktivera(); + await db.SaveChangesAsync(); + _valdForman = null; + await LaddaBenefits(); + } + catch (Exception ex) { _error = $"Kunde inte uppdatera status: {ex.Message}"; } + } + private int AnmaldaCount(Guid benefitId) => _employeeBenefits.Count(eb => eb.BenefitId == benefitId); @@ -137,4 +211,3 @@ _ => k.ToString() }; } - diff --git a/src/Web/Components/Pages/Formaner/Val.razor b/src/Web/Components/Pages/Formaner/Val.razor index 601986d..309c5a1 100644 --- a/src/Web/Components/Pages/Formaner/Val.razor +++ b/src/Web/Components/Pages/Formaner/Val.razor @@ -177,14 +177,22 @@ else private async Task AvbrytForman(Benefit benefit) { - await using var db = await DbFactory.CreateDbContextAsync(); if (!Auth.EmployeeId.HasValue) return; - var enrollment = _myEnrollments.FirstOrDefault(e => e.BenefitId == benefit.Id && e.Status is "Active" or "Pending"); - if (enrollment is null) return; + var local = _myEnrollments.FirstOrDefault(e => e.BenefitId == benefit.Id && e.Status is "Active" or "Pending"); + if (local is null) return; + _error = null; _success = null; + // Hämta entiteten i samma context som SaveChanges — den lokala instansen kommer + // från OnInitialized-contextet (disposat) och trackas inte här, så Avbryt() på + // den skulle inte persisteras. + await using var db = await DbFactory.CreateDbContextAsync(); try { + var enrollment = await db.BenefitEnrollments.FirstOrDefaultAsync(e => e.Id == local.Id); + if (enrollment is null) { _error = "Förmånsvalet hittades inte."; return; } enrollment.Avbryt(); await db.SaveChangesAsync(); + var idx = _myEnrollments.IndexOf(local); + if (idx >= 0) _myEnrollments[idx] = enrollment; _success = $"Förmånsval för {benefit.Namn} avbrutet."; } catch (Exception ex) { _error = $"Kunde inte avbryta: {ex.Message}"; } diff --git a/src/Web/Components/Pages/GDPR/Index.razor b/src/Web/Components/Pages/GDPR/Index.razor index d0177b3..ca302cd 100644 --- a/src/Web/Components/Pages/GDPR/Index.razor +++ b/src/Web/Components/Pages/GDPR/Index.razor @@ -3,8 +3,13 @@ @using RegionHR.Infrastructure.Persistence @using RegionHR.GDPR.Domain @using RegionHR.SharedKernel.Domain +@using RegionHR.Infrastructure.GDPR +@using RegionHR.Infrastructure.Export @using Microsoft.EntityFrameworkCore @inject IDbContextFactory DbFactory +@inject RegisterutdragGenerator Generator +@inject PdfGenerator Pdf +@inject IJSRuntime JS OpenHR - GDPR GDPR-efterlevnad @@ -92,17 +97,29 @@ else + + Fullständigt registerutdrag enligt GDPR Art 15 samlar personuppgifter, anställningar, ärenden, + löneresultat, ledighet, dokument, certifieringar, medarbetarsamtal, LAS-uppföljning, rehab och granskningslogg. + @if (_registerutdragResult is not null) { - Registerutdrag + Förhandsvisning @_registerutdragResult } + @if (_nedladdningsInfo is not null) + { + @_nedladdningsInfo + } Stäng - Hämta data + Förhandsvisa + Ladda ner JSON + Ladda ner PDF } @@ -114,10 +131,18 @@ else private string? _error; private bool _showRegisterutdrag; + private bool _genererar; private string _registerutdragAnstallId = ""; private string? _registerutdragResult; + private string? _nedladdningsInfo; protected override async Task OnInitializedAsync() + { + await LaddaDsr(); + _loading = false; + } + + private async Task LaddaDsr() { await using var db = await DbFactory.CreateDbContextAsync(); try @@ -130,23 +155,68 @@ else _expiredCount = _requests.Count(r => r.ArForsenad); _pendingCount = _requests.Count(r => r.Status == RequestStatus.Mottagen || r.Status == RequestStatus.UnderBehandling); - if (_requests.Count > 0) - { - var completed = _requests.Count(r => r.Status == RequestStatus.Klar); - _compliancePercent = (int)((double)completed / _requests.Count * 100); - } + _compliancePercent = _requests.Count > 0 + ? (int)((double)_requests.Count(r => r.Status == RequestStatus.Klar) / _requests.Count * 100) + : 0; } catch (Exception ex) { _error = $"Kunde inte hämta GDPR-data: {ex.Message}"; } - finally { _loading = false; } } private void GenereraRegisterutdrag() { _showRegisterutdrag = true; _registerutdragResult = null; + _nedladdningsInfo = null; _registerutdragAnstallId = ""; } + private async Task LaddaNer(string format) + { + _nedladdningsInfo = null; + if (!Guid.TryParse(_registerutdragAnstallId, out var anstallId)) + { + _registerutdragResult = "Ogiltigt anställd-ID. Ange ett giltigt GUID."; + return; + } + + _genererar = true; + try + { + // Anropar den kanoniska infrastruktur-generatorn (samma väg som REST-API:t). + var json = await Generator.GenerateAsync(anstallId); + var kort = anstallId.ToString()[..8]; + + if (format == "pdf") + { + var bytes = Pdf.GenerateEnkeltDokument("Registerutdrag enligt GDPR Art 15", json); + await JS.InvokeVoidAsync("downloadFile", $"registerutdrag_{kort}.pdf", + Convert.ToBase64String(bytes), "application/pdf"); + } + else + { + var bytes = new byte[] { 0xEF, 0xBB, 0xBF } + .Concat(System.Text.Encoding.UTF8.GetBytes(json)).ToArray(); + await JS.InvokeVoidAsync("downloadFile", $"registerutdrag_{kort}.json", + Convert.ToBase64String(bytes), "application/json"); + } + + // Logga begäran som en slutförd DSR så listan och efterlevnadsmåtten blir riktiga. + await using var db = await DbFactory.CreateDbContextAsync(); + var dsr = DataSubjectRequest.Skapa(anstallId, RequestType.Registerutdrag); + dsr.Slutfor($"registerutdrag_{kort}.{format}"); + db.DataSubjectRequests.Add(dsr); + await db.SaveChangesAsync(); + await LaddaDsr(); + + _nedladdningsInfo = $"Registerutdrag ({format.ToUpper()}) genererat och loggat som slutförd DSR."; + } + catch (Exception ex) + { + _registerutdragResult = $"Kunde inte generera registerutdrag: {ex.Message}"; + } + finally { _genererar = false; } + } + private async Task HamtaRegisterutdrag() { await using var db = await DbFactory.CreateDbContextAsync(); diff --git a/src/Web/Components/Pages/HalsoSAM/Arende.razor b/src/Web/Components/Pages/HalsoSAM/Arende.razor new file mode 100644 index 0000000..3ae812b --- /dev/null +++ b/src/Web/Components/Pages/HalsoSAM/Arende.razor @@ -0,0 +1,352 @@ +@page "/halsosam/{Id:guid}" +@layout RegionHR.Web.Components.Layout.AdminLayout +@using RegionHR.HalsoSAM.Domain +@using RegionHR.Infrastructure.Persistence +@using Microsoft.EntityFrameworkCore +@using SK = RegionHR.SharedKernel.Domain +@inject IDbContextFactory DbFactory +@inject RegionHR.Web.Services.AuthService Auth + +OpenHR — Rehabärende + +Tillbaka till översikt + +Rehabärende + +@if (_loading) +{ + +} +else if (_error != null) +{ + @_error +} +else if (_case == null) +{ + Rehabärendet hittades inte. +} +else +{ + @if (_success != null) + { + @_success + } + + @* === Översikt === *@ + +
+ @_empNamn + @StatusText(_case.Status) +
+ + + Trigger + @TriggerText(_case.Trigger) + + + Sjukfall dag 1 + @(_case.SjukfallDag1?.ToString("yyyy-MM-dd") ?? "Ej angiven") + + + Ärendeägare (HR) + @(_case.ArendeagareHR != null ? GetEmployeeName(_case.ArendeagareHR.Value) : "Ej tilldelad") + + + Skapad + @_case.SkapadVid.ToLocalTime().ToString("yyyy-MM-dd") + + + +
+ + Registrera uppföljning + + + FK-anmälan + +
+
+ + @if (_arAvslutad) + { + + Ärendet är avslutat. + @if (_case.GallringsDatum.HasValue) + { + Gallras enligt GDPR den @_case.GallringsDatum.Value.ToLocalTime().ToString("yyyy-MM-dd"). + } + + } + + @* === Ärendeägare === *@ + @if (!_arAvslutad) + { + + Ärendeägare + + Tilldela en HR-handläggare. När ärendeägare tilldelas flyttas ärendet till Under utredning. + + + + + @foreach (var e in _employees) + { + @e.Fornamn @e.Efternamn + } + + + + + Tilldela ärendeägare + + + + + + @* === Rehabplan === *@ + + Rehabiliteringsplan + + Plan för återgång i arbete. När en plan sparas flyttas ärendet till Aktiv rehab. + (Lagkrav: plan senast dag 30 om sjukfrånvaron antas pågå > 60 dagar.) + + + + @(string.IsNullOrEmpty(_case.RehabPlan) ? "Upprätta rehabplan" : "Uppdatera rehabplan") + + + } + else if (!string.IsNullOrEmpty(_case.RehabPlan)) + { + + Rehabiliteringsplan + @_case.RehabPlan + + } + + @* === Anteckningar === *@ + + Anteckningar (@_case.Anteckningar.Count) + @if (_case.Anteckningar.Count == 0) + { + Inga anteckningar ännu. + } + else + { + + @foreach (var n in _case.Anteckningar.OrderByDescending(a => a.SkapadVid)) + { + + @n.Text + + @GetEmployeeName(n.ForfattareId) · @n.SkapadVid.ToLocalTime().ToString("yyyy-MM-dd HH:mm") + + + } + + } + + @if (!_arAvslutad) + { + + + + @foreach (var e in _employees) + { + @e.Fornamn @e.Efternamn + } + + + + + + + + Lägg till anteckning + + } + + + @* === Avsluta === *@ + @if (!_arAvslutad) + { + + Avsluta ärende + + Anger en slutsats, sätter status till Avslutad och registrerar GDPR-gallringsdatum (2 år). + + + + Avsluta ärende + + + } +} + +@code { + [Parameter] public Guid Id { get; set; } + + private RehabCase? _case; + private List _employees = []; + private Dictionary _empNames = new(); + private string _empNamn = "Okänd"; + private bool _loading = true, _saving; + private string? _error, _success; + + private Guid? _agareId; + private Guid? _noteAuthorId; + private string _noteText = ""; + private string _planText = ""; + private string _slutsats = ""; + + private bool _arAvslutad => _case?.Status == RehabStatus.Avslutad; + + protected override async Task OnInitializedAsync() => await Ladda(); + + private async Task Ladda() + { + _loading = true; + await using var db = await DbFactory.CreateDbContextAsync(); + try + { + _case = await db.RehabCases + .Include(c => c.Uppfoljningar) + .Include(c => c.Anteckningar) + .FirstOrDefaultAsync(c => c.Id == Id); + + _employees = await db.Employees.OrderBy(e => e.Efternamn).ToListAsync(); + _empNames = _employees.ToDictionary(e => e.Id, e => $"{e.Fornamn} {e.Efternamn}"); + + if (_case != null) + { + _empNamn = GetEmployeeName(_case.AnstallId); + _planText = _case.RehabPlan ?? ""; + _agareId ??= _case.ArendeagareHR?.Value; + _noteAuthorId ??= _case.ArendeagareHR?.Value ?? Auth.EmployeeId; + } + } + catch (Exception ex) { _error = $"Kunde inte hämta data: {ex.Message}"; } + finally { _loading = false; } + } + + private string GetEmployeeName(SK.EmployeeId id) => + _empNames.TryGetValue(id, out var namn) ? namn : "Okänd"; + + private async Task TilldelaArendeagare() + { + if (_agareId == null) return; + _saving = true; _error = null; _success = null; + await using var db = await DbFactory.CreateDbContextAsync(); + try + { + var arende = await db.RehabCases.FirstOrDefaultAsync(c => c.Id == Id); + if (arende == null) { _error = "Ärendet hittades inte."; return; } + arende.TilldelaArendeagare(SK.EmployeeId.From(_agareId.Value)); + await db.SaveChangesAsync(); + _success = "Ärendeägare tilldelad."; + await Ladda(); + } + catch (Exception ex) { _error = $"Kunde inte tilldela: {ex.Message}"; } + finally { _saving = false; } + } + + private async Task SparaRehabplan() + { + if (string.IsNullOrWhiteSpace(_planText)) return; + _saving = true; _error = null; _success = null; + await using var db = await DbFactory.CreateDbContextAsync(); + try + { + var arende = await db.RehabCases.FirstOrDefaultAsync(c => c.Id == Id); + if (arende == null) { _error = "Ärendet hittades inte."; return; } + arende.SattRehabPlan(_planText.Trim()); + await db.SaveChangesAsync(); + _success = "Rehabiliteringsplan sparad."; + await Ladda(); + } + catch (Exception ex) { _error = $"Kunde inte spara plan: {ex.Message}"; } + finally { _saving = false; } + } + + private async Task LaggTillAnteckning() + { + if (_noteAuthorId == null || string.IsNullOrWhiteSpace(_noteText)) return; + _saving = true; _error = null; _success = null; + await using var db = await DbFactory.CreateDbContextAsync(); + try + { + var arende = await db.RehabCases + .Include(c => c.Anteckningar) + .FirstOrDefaultAsync(c => c.Id == Id); + if (arende == null) { _error = "Ärendet hittades inte."; return; } + arende.LaggTillAnteckning(_noteText.Trim(), SK.EmployeeId.From(_noteAuthorId.Value)); + await db.SaveChangesAsync(); + _noteText = ""; + _success = "Anteckning tillagd."; + await Ladda(); + } + catch (Exception ex) { _error = $"Kunde inte lägga till anteckning: {ex.Message}"; } + finally { _saving = false; } + } + + private async Task AvslutaArende() + { + if (string.IsNullOrWhiteSpace(_slutsats)) return; + _saving = true; _error = null; _success = null; + await using var db = await DbFactory.CreateDbContextAsync(); + try + { + var arende = await db.RehabCases + .Include(c => c.Anteckningar) + .FirstOrDefaultAsync(c => c.Id == Id); + if (arende == null) { _error = "Ärendet hittades inte."; return; } + arende.Avsluta(_slutsats.Trim()); + await db.SaveChangesAsync(); + _slutsats = ""; + _success = "Ärendet är avslutat."; + await Ladda(); + } + catch (Exception ex) { _error = $"Kunde inte avsluta: {ex.Message}"; } + finally { _saving = false; } + } + + private static string TriggerText(RehabTrigger t) => t switch + { + RehabTrigger.SexTillfallenTolvManader => "6+ sjuktillfällen/12 mån", + RehabTrigger.FjortonSammanhangandeDagar => "14+ sammanhängande dagar", + RehabTrigger.MonsterDetekterat => "Mönster detekterat", + RehabTrigger.ChefInitierat => "Chef initierat", + RehabTrigger.MedarbetareInitierat => "Medarbetare initierat", + _ => t.ToString() + }; + + private static string StatusText(RehabStatus s) => s switch + { + RehabStatus.Signal => "Signal", + RehabStatus.UnderUtredning => "Under utredning", + RehabStatus.AktivRehab => "Aktiv rehab", + RehabStatus.Uppfoljning => "Uppföljning", + RehabStatus.Avslutad => "Avslutad", + _ => s.ToString() + }; + + private static Color StatusFarg(RehabStatus s) => s switch + { + RehabStatus.Signal => Color.Warning, + RehabStatus.UnderUtredning => Color.Info, + RehabStatus.AktivRehab => Color.Primary, + RehabStatus.Uppfoljning => Color.Success, + RehabStatus.Avslutad => Color.Default, + _ => Color.Default + }; +} diff --git a/src/Web/Components/Pages/HalsoSAM/Index.razor b/src/Web/Components/Pages/HalsoSAM/Index.razor index 24d6ee5..4d0fb89 100644 --- a/src/Web/Components/Pages/HalsoSAM/Index.razor +++ b/src/Web/Components/Pages/HalsoSAM/Index.razor @@ -165,6 +165,12 @@ else + Hantera ärende + + Registrera uppföljning diff --git a/src/Web/Components/Pages/Helpdesk/Detalj.razor b/src/Web/Components/Pages/Helpdesk/Detalj.razor index 781183b..75abb17 100644 --- a/src/Web/Components/Pages/Helpdesk/Detalj.razor +++ b/src/Web/Components/Pages/Helpdesk/Detalj.razor @@ -172,31 +172,51 @@ else finally { _loading = false; } } + // Laddar ärendet spårat i den angivna context:en. _request kommer annars från en + // disposad context och mutationer på den skulle inte persisteras vid SaveChanges. + private async Task LaddaSparadAsync(RegionHRDbContext db) + { + if (_request is null) return null; + return await db.ServiceRequests + .Include(r => r.Kommentarer) + .Include(r => r.SLAMilestones) + .FirstOrDefaultAsync(r => r.Id == _request.Id); + } + private async Task AddComment() { - await using var db = await DbFactory.CreateDbContextAsync(); if (_request is null || string.IsNullOrWhiteSpace(_newComment)) return; - _request.LaggTillKommentar(null, _newComment, false); + await using var db = await DbFactory.CreateDbContextAsync(); + var req = await LaddaSparadAsync(db); + if (req is null) return; + req.LaggTillKommentar(null, _newComment, false); await db.SaveChangesAsync(); + _request = req; _newComment = ""; } private async Task CloseRequest() { - await using var db = await DbFactory.CreateDbContextAsync(); if (_request is null) return; - _request.Stang(); + await using var db = await DbFactory.CreateDbContextAsync(); + var req = await LaddaSparadAsync(db); + if (req is null) return; + req.Stang(); await db.SaveChangesAsync(); + _request = req; } private async Task SubmitSatisfaction() { - await using var db = await DbFactory.CreateDbContextAsync(); if (_request is null || _satisfactionRating < 1) return; - _request.SattNojdhet(_satisfactionRating); - var satisfaction = CaseSatisfaction.Skapa(_request.Id, _satisfactionRating, _satisfactionComment); + await using var db = await DbFactory.CreateDbContextAsync(); + var req = await LaddaSparadAsync(db); + if (req is null) return; + req.SattNojdhet(_satisfactionRating); + var satisfaction = CaseSatisfaction.Skapa(req.Id, _satisfactionRating, _satisfactionComment); await db.CaseSatisfactions.AddAsync(satisfaction); await db.SaveChangesAsync(); + _request = req; } private Color GetStatusColor(ServiceRequestStatus status) => status switch diff --git a/src/Web/Components/Pages/Helpdesk/Index.razor b/src/Web/Components/Pages/Helpdesk/Index.razor index 8aaa2a9..59a015c 100644 --- a/src/Web/Components/Pages/Helpdesk/Index.razor +++ b/src/Web/Components/Pages/Helpdesk/Index.razor @@ -28,7 +28,7 @@ else Kunskapsbas Sok bland vanliga fragor och svar. - Sok artiklar + Sok artiklar diff --git a/src/Web/Components/Pages/Journeys/Journey.razor b/src/Web/Components/Pages/Journeys/Journey.razor index a90db7d..777da8f 100644 --- a/src/Web/Components/Pages/Journeys/Journey.razor +++ b/src/Web/Components/Pages/Journeys/Journey.razor @@ -114,10 +114,17 @@ else private async Task MarkeraKlar(Guid stegId) { - await using var db = await DbFactory.CreateDbContextAsync(); if (_instance == null) return; - _instance.MarkeraStegKlart(stegId, "Admin"); + await using var db = await DbFactory.CreateDbContextAsync(); + // Ladda instansen spårad i denna context så att ändringen faktiskt persisteras + // (den tidigare _instance kommer från en disposad context och skulle inte sparas). + var inst = await db.JourneyInstances + .Include("Steg") + .FirstOrDefaultAsync(i => i.Id == _instance.Id); + if (inst == null) return; + inst.MarkeraStegKlart(stegId, "Admin"); await db.SaveChangesAsync(); + _instance = inst; } } diff --git a/src/Web/Components/Pages/Kompetens/Index.razor b/src/Web/Components/Pages/Kompetens/Index.razor index b09765b..10b9430 100644 --- a/src/Web/Components/Pages/Kompetens/Index.razor +++ b/src/Web/Components/Pages/Kompetens/Index.razor @@ -2,9 +2,11 @@ @layout RegionHR.Web.Components.Layout.AdminLayout @using RegionHR.Infrastructure.Persistence @using RegionHR.Competence.Domain +@using RegionHR.Notifications.Domain @using RegionHR.Web.Components.Shared @using Microsoft.EntityFrameworkCore @inject IDbContextFactory DbFactory +@inject NavigationManager Nav OpenHR - Kompetensregister
@@ -37,6 +39,11 @@ @_skapadFel } +@if (_actionMsg != null) +{ + @_actionMsg +} + @* --- Expiry alert banner --- *@ @if (_expiringCount > 0) { @@ -57,40 +64,43 @@ { "boka", "Boka utbildning" }, { "paminn", "Påminn anställd" } })" - OnActionClicked="@HandleExpiryAction" /> + OnActionClicked="@(key => HandleExpiryAction(key, cert))" /> } -@* --- Obligatoriska utbildningar per enhet --- *@ +@* --- Obligatoriska utbildningar (kravkatalog per roll) --- *@ Obligatoriska utbildningar - Genomförandegrad per enhet för lagstadgade och verksamhetskritiska utbildningar. + Kravkatalog: vilka utbildningar som är obligatoriska per roll och hur ofta de behöver förnyas. - - - - Utbildning - Enhet - Genomförd - Andel - Status - - - - @foreach (var row in _mandatoryRows) - { +@if (_mandatoryRows.Count == 0) +{ + Inga obligatoriska utbildningar registrerade. +} +else +{ + + - @row.Utbildning - @row.Enhet - @row.Genomforda / @row.Totalt - - - - @($"{row.Andel:0}%") + Utbildning + Gäller roll + Giltighet + Beskrivning - } - - + + + @foreach (var row in _mandatoryRows) + { + + @row.Utbildning + @row.Roll + @GiltighetText(row.GiltighetManader) + @(row.Beskrivning ?? "—") + + } + + +} @* --- Full certification table --- *@ Alla certifieringar @@ -245,23 +255,64 @@ System.Diagnostics.Debug.WriteLine($"Kompetens cert load: {ex.Message}"); } - // Mandatory training from DB + // Obligatoriska utbildningar (kravkatalog) från DB — genuin data, ingen påhittad genomförandegrad. var mandatoryTrainings = await db.MandatoryTrainings.ToListAsync(); - _mandatoryRows = mandatoryTrainings.Select(m => new MandatoryRow(m.UtbildningNamn, m.RollNamn, 0, 0)).ToList(); + _mandatoryRows = mandatoryTrainings + .OrderBy(m => m.RollNamn).ThenBy(m => m.UtbildningNamn) + .Select(m => new MandatoryRow(m.UtbildningNamn, m.RollNamn, m.GiltighetManader, m.Beskrivning)) + .ToList(); } - private void HandleExpiryAction(string actionKey) + private string? _actionMsg; + + /// + /// Åtgärder på en utgående certifiering: "boka" navigerar till utbildningskatalogen, + /// "paminn" skapar en riktig InApp-notis till den berörda anställda (persisteras i DB). + /// + private async Task HandleExpiryAction(string actionKey, Certification cert) { - // Future: route to booking system or send reminder notification - } + _actionMsg = null; + _skapadFel = null; + + if (actionKey == "boka") + { + Nav.NavigateTo("/utbildning/elearning"); + return; + } - // MandatoryRows now loaded from Db.MandatoryTrainings in OnInitializedAsync + if (actionKey == "paminn") + { + try + { + using var db = DbFactory.CreateDbContext(); + var notis = Notification.Create( + cert.AnstallId, + "Certifiering går ut", + $"Din certifiering \"{cert.Namn}\" går ut {cert.GiltigTill:yyyy-MM-dd}. Boka förnyelse via utbildningskatalogen.", + NotificationType.Reminder, + NotificationChannel.InApp, + actionUrl: "/utbildning/elearning", + relatedEntityType: "Certification", + relatedEntityId: cert.Id.ToString()); + db.Notifications.Add(notis); + await db.SaveChangesAsync(); + _actionMsg = $"Påminnelse skickad till anställd om \"{cert.Namn}\"."; + } + catch (Exception ex) + { + _skapadFel = $"Kunde inte skapa påminnelse: {ex.Message}"; + } + } + } - private static Color ProgressColor(double pct) => pct switch + private static string GiltighetText(int manader) => manader switch { - >= 95 => Color.Success, - >= 80 => Color.Warning, - _ => Color.Error + <= 0 => "Engångskrav", + 12 => "Årligen", + 24 => "Vartannat år", + 36 => "Vart tredje år", + _ when manader % 12 == 0 => $"Var {manader / 12}:e år", + _ => $"Var {manader}:e månad" }; private Color CertFarg(Certification c) @@ -302,9 +353,6 @@ _ => t.ToString() }; - private record MandatoryRow(string Utbildning, string Enhet, int Genomforda, int Totalt) - { - public double Andel => Totalt > 0 ? (double)Genomforda / Totalt * 100 : 0; - } + private record MandatoryRow(string Utbildning, string Roll, int GiltighetManader, string? Beskrivning); } diff --git a/src/Web/Components/Pages/Lon/KorningDetalj.razor b/src/Web/Components/Pages/Lon/KorningDetalj.razor index 73d8d31..9f51e5e 100644 --- a/src/Web/Components/Pages/Lon/KorningDetalj.razor +++ b/src/Web/Components/Pages/Lon/KorningDetalj.razor @@ -6,6 +6,7 @@ @using RegionHR.SharedKernel.Domain @using Microsoft.EntityFrameworkCore @inject IDbContextFactory DbFactory +@inject RegionHR.Web.Services.AuthService Auth OpenHR - Lönekörning @@ -65,7 +66,8 @@ else + Actions="@_anomaliActions" + OnActionClicked="@(_ => KvitteraAnomali(a))" /> } } @@ -129,14 +131,58 @@ else - - Godkänn och skicka till utbetalning - + @if (_actionError != null) + { + @_actionError + } + @if (_actionSuccess != null) + { + @_actionSuccess + } - - Nästa steg: AGI-filen till Skatteverket skapas automatiskt den 12:e. - + @if (_status is PayrollRunStatus.Beraknad or PayrollRunStatus.Granskad) + { + + @if (_saving) { } + Godkänn lönekörning + + + Efter godkännande skapas betalfil (pain.001) och AGI till Skatteverket under + Export. + + } + else if (_status == PayrollRunStatus.Godkand) + { + + Lönekörningen är godkänd@(string.IsNullOrWhiteSpace(_godkandAv) ? "" : $" av {_godkandAv}"). + + + + Skapa betalfil & AGI + + + @if (_saving) { } + Markera som utbetald + + + } + else if (_status == PayrollRunStatus.Utbetald) + { + + Lönekörningen är utbetald@(string.IsNullOrWhiteSpace(_godkandAv) ? "" : $" (godkänd av {_godkandAv})"). + + } + else + { + + Körningen har status @_status. Den kan godkännas när den är beräknad. + + } } @code { @@ -152,8 +198,11 @@ else private List _anomalier = []; private List _loner = []; private readonly HashSet _expanded = []; - private bool _loading = true, _found; + private bool _loading = true, _found, _saving; private string? _error; + private PayrollRunStatus _status; + private string? _godkandAv; + private string? _actionError, _actionSuccess; protected override async Task OnInitializedAsync() { @@ -172,6 +221,8 @@ else } _found = true; + _status = run.Status; + _godkandAv = run.GodkandAv; _rubrik = FormatPeriod(run.Year, run.Month); _retroaktiv = run.ArRetroaktiv; _retroPeriod = run.RetroaktivtForPeriod; @@ -226,12 +277,64 @@ else finally { _loading = false; } } + private static readonly Dictionary _anomaliActions = new() + { + ["kvittera"] = "Kvittera – kontrollerad" + }; + private void ToggleRad(int index) { if (!_expanded.Add(index)) _expanded.Remove(index); } + private void KvitteraAnomali(Anomali a) => _anomalier.Remove(a); + + private async Task GodkannKorning() + { + _saving = true; + _actionError = null; + _actionSuccess = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var run = await db.PayrollRuns.FirstOrDefaultAsync(r => r.Id == PayrollRunId.From(Id)); + if (run is null) { _actionError = "Lönekörningen hittades inte."; return; } + + run.Godkann(Auth.UserName ?? "HR"); + await db.SaveChangesAsync(); + + _status = run.Status; + _godkandAv = run.GodkandAv; + _actionSuccess = "Lönekörningen är godkänd. Nästa steg: skapa betalfil och AGI under Export."; + } + catch (InvalidOperationException ex) { _actionError = ex.Message; } + catch (Exception ex) { _actionError = $"Kunde inte godkänna körningen: {ex.Message}"; } + finally { _saving = false; } + } + + private async Task MarkeraUtbetald() + { + _saving = true; + _actionError = null; + _actionSuccess = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var run = await db.PayrollRuns.FirstOrDefaultAsync(r => r.Id == PayrollRunId.From(Id)); + if (run is null) { _actionError = "Lönekörningen hittades inte."; return; } + + run.MarkeraSomUtbetald(); + await db.SaveChangesAsync(); + + _status = run.Status; + _actionSuccess = "Lönekörningen är markerad som utbetald."; + } + catch (InvalidOperationException ex) { _actionError = ex.Message; } + catch (Exception ex) { _actionError = $"Kunde inte markera som utbetald: {ex.Message}"; } + finally { _saving = false; } + } + private void BuildAnomalies(PayrollRun run, Dictionary empNames, Dictionary foregaendeBrutto) { // Retroaktiva körningar innehåller differensposter — de avviker per definition diff --git a/src/Web/Components/Pages/Lon/Statistik.razor b/src/Web/Components/Pages/Lon/Statistik.razor index abbcea6..c76d5f2 100644 --- a/src/Web/Components/Pages/Lon/Statistik.razor +++ b/src/Web/Components/Pages/Lon/Statistik.razor @@ -1,13 +1,24 @@ @page "/lon/statistik" @layout RegionHR.Web.Components.Layout.AdminLayout +@using System.Text @using RegionHR.Infrastructure.Persistence @using RegionHR.Payroll.Domain @using RegionHR.SharedKernel.Domain @using Microsoft.EntityFrameworkCore @inject IDbContextFactory DbFactory +@inject IJSRuntime JS OpenHR - Lönestatistik -Lönestatistik +
+ Lönestatistik + @if (!_loading && _runs.Count > 0) + { + + Ladda ner CSV + + } +
@if (_loading) { } else if (_error != null) { @_error } @@ -66,5 +77,27 @@ else } private static string FormatKr(decimal v) => $"{v:N0} kr"; + + private async Task LaddaNerCsv() + { + var sb = new StringBuilder(); + sb.AppendLine("Period;Bruttolon;Arbetsgivaravgifter;Nettolon;Skatt;Antal anstallda"); + foreach (var r in _runs) + { + sb.AppendLine(string.Join(';', + r.Period, + r.TotalBrutto.Amount.ToString("F2"), + r.TotalArbetsgivaravgifter.Amount.ToString("F2"), + r.TotalNetto.Amount.ToString("F2"), + r.TotalSkatt.Amount.ToString("F2"), + r.AntalAnstallda)); + } + + // UTF-8 med BOM så svenska tecken visas rätt i Excel. + var bytes = new byte[] { 0xEF, 0xBB, 0xBF }.Concat(Encoding.UTF8.GetBytes(sb.ToString())).ToArray(); + var base64 = Convert.ToBase64String(bytes); + await JS.InvokeVoidAsync("downloadFile", + $"lonestatistik-{DateTime.Today:yyyyMMdd}.csv", base64, "text/csv"); + } } diff --git a/src/Web/Components/Pages/Medarbetarsamtal/Feedback360.razor b/src/Web/Components/Pages/Medarbetarsamtal/Feedback360.razor index 7455456..3d5a783 100644 --- a/src/Web/Components/Pages/Medarbetarsamtal/Feedback360.razor +++ b/src/Web/Components/Pages/Medarbetarsamtal/Feedback360.razor @@ -45,6 +45,39 @@ else @if (_success != null) { @_success } + @if (_svarRoundId != null) + { + +
+ Registrera feedbacksvar — @_svarRoundTitel + +
+ @if (_svarFel != null) + { + @_svarFel + } + + + + @foreach (var e in _employees) { @e.Fornamn @e.Efternamn } + + + + + @foreach (var rel in _relationer) { @rel } + + + + Betyg + + + + + Spara svar +
+ } + @if (_rounds.Count == 0) { Inga feedbackrundor registrerade. } else { @@ -61,7 +94,11 @@ else @(r.AntalSvar > 0 ? $"{r.SnittBetyg:F1} / 5" : "—") @if (r.Round.Status == FeedbackRoundStatus.Utkast) { Öppna } - else if (r.Round.Status == FeedbackRoundStatus.Oppnad) { Stäng } + else if (r.Round.Status == FeedbackRoundStatus.Oppnad) + { + Registrera svar + Stäng + } } @@ -78,11 +115,24 @@ else private string? _error, _success, _formTitel = ""; private Guid? _formAnstallId; - protected override async Task OnInitializedAsync() + // Registrera svar + private static readonly string[] _relationer = ["Chef", "Kollega", "Underordnad", "Extern"]; + private Guid? _svarRoundId; + private string _svarRoundTitel = ""; + private Guid? _svarBedomareId; + private string _svarRelation = "Kollega"; + private int _svarBetyg = 4; + private string _svarKommentar = ""; + private string? _svarFel; + + protected override async Task OnInitializedAsync() => await Ladda(); + + private async Task Ladda() { - await using var db = await DbFactory.CreateDbContextAsync(); + _loading = true; try { + await using var db = await DbFactory.CreateDbContextAsync(); var rounds = await db.FeedbackRounds.OrderByDescending(r => r.SkapadVid).ToListAsync(); var responses = await db.FeedbackResponses.ToListAsync(); _employees = await db.Employees.OrderBy(e => e.Efternamn).ToListAsync(); @@ -96,11 +146,48 @@ else resps.Count > 0 ? resps.Average(x => x.Betyg) : 0); }).ToList(); _totalResponses = responses.Count; + _error = null; } catch (Exception ex) { _error = $"Kunde inte hämta data: {ex.Message}"; } finally { _loading = false; } } + private void OppnaSvar(FeedbackRound r) + { + _svarRoundId = r.Id; + _svarRoundTitel = r.Titel; + _svarBedomareId = null; + _svarRelation = "Kollega"; + _svarBetyg = 4; + _svarKommentar = ""; + _svarFel = null; + } + + private async Task SparaSvar() + { + _svarFel = null; + if (_svarRoundId is null) return; + if (_svarBedomareId is null) { _svarFel = "Välj en bedömare."; return; } + if (_svarBetyg is < 1 or > 5) { _svarFel = "Sätt ett betyg 1–5."; return; } + + _saving = true; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var svar = FeedbackResponse.Skapa( + _svarRoundId.Value, _svarBedomareId.Value, _svarRelation, _svarBetyg, + string.IsNullOrWhiteSpace(_svarKommentar) ? null : _svarKommentar.Trim()); + db.FeedbackResponses.Add(svar); + await db.SaveChangesAsync(); + _success = "Feedbacksvar registrerat."; + _svarRoundId = null; + await Ladda(); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) { _svarFel = ex.Message; } + catch (Exception ex) { _svarFel = $"Kunde inte spara svar: {ex.Message}"; } + finally { _saving = false; } + } + private async Task SkapaRunda() { await using var db = await DbFactory.CreateDbContextAsync(); diff --git a/src/Web/Components/Pages/Medarbetarsamtal/Index.razor b/src/Web/Components/Pages/Medarbetarsamtal/Index.razor index 9b6978c..cece759 100644 --- a/src/Web/Components/Pages/Medarbetarsamtal/Index.razor +++ b/src/Web/Components/Pages/Medarbetarsamtal/Index.razor @@ -31,7 +31,7 @@ @foreach (var r in _reviews) { - @r.AnstallId.ToString()[..8]... + @NamnFor(r.AnstallId) @r.Ar @(r.OverallRating.HasValue ? $"{r.OverallRating}/5" : "—") @(r.GenomfordVid?.ToString("yyyy-MM-dd") ?? r.SkapadVid.ToString("yyyy-MM-dd")) @@ -47,7 +47,7 @@ Medarbetarsamtal @r.Ar - Anställd-ID: @r.AnstallId + Anställd: @NamnFor(r.AnstallId) Betyg: @(r.OverallRating.HasValue ? $"{r.OverallRating}/5" : "Ej satt") Status: @StatusText(r.Status) Skapad: @r.SkapadVid.ToString("yyyy-MM-dd") @@ -66,6 +66,7 @@ @code { private List _reviews = []; + private Dictionary _namn = new(); private PerformanceReview? _valdSamtal; private void ValjSamtal(PerformanceReview r) => _valdSamtal = _valdSamtal == r ? null : r; @@ -81,10 +82,15 @@ .OrderByDescending(x => x.SkapadVid) .Take(50) .ToListAsync(); + + _namn = (await db.Employees.ToListAsync()) + .ToDictionary(e => e.Id.Value, e => $"{e.Fornamn} {e.Efternamn}"); } catch (Exception ex) { _error = $"Kunde inte hämta medarbetarsamtal: {ex.Message}"; } } + private string NamnFor(Guid id) => _namn.TryGetValue(id, out var n) ? n : id.ToString()[..8] + "…"; + private static Color StatusFarg(ReviewStatus s) => s switch { ReviewStatus.Planerad => Color.Info, diff --git a/src/Web/Components/Pages/MinSida/Lonespecifikationer.razor b/src/Web/Components/Pages/MinSida/Lonespecifikationer.razor index 380a198..f9422d2 100644 --- a/src/Web/Components/Pages/MinSida/Lonespecifikationer.razor +++ b/src/Web/Components/Pages/MinSida/Lonespecifikationer.razor @@ -1,11 +1,14 @@ @page "/minsida/lonespecifikationer" @layout RegionHR.Web.Components.Layout.AdminLayout @using RegionHR.Infrastructure.Persistence +@using RegionHR.Infrastructure.Export @using RegionHR.Payroll.Domain +@using RegionHR.Core.Domain @using RegionHR.SharedKernel.Domain @using Microsoft.EntityFrameworkCore @inject IDbContextFactory DbFactory @inject RegionHR.Web.Services.AuthService Auth +@inject IJSRuntime JS OpenHR - Lönespecifikationer Tillbaka @@ -23,7 +26,15 @@ else @foreach (var r in _results) { - @r.Year-@r.Month.ToString("D2") +
+ @r.Year-@r.Month.ToString("D2") + + Ladda ner PDF + +
Brutto@r.Brutto.Amount.ToString("N0") kr Skatt-@r.Skatt.Amount.ToString("N0") kr @@ -47,21 +58,53 @@ else @code { private List _results = []; private bool _loading = true; + private bool _genererar; private string? _error; + private string _namn = "Anställd"; + private string _personnummer = ""; + private string _befattning = ""; + protected override async Task OnInitializedAsync() { - await using var db = await DbFactory.CreateDbContextAsync(); if (!Auth.EmployeeId.HasValue) { _loading = false; return; } + await using var db = await DbFactory.CreateDbContextAsync(); try { + var empId = EmployeeId.From(Auth.EmployeeId.Value); _results = await db.PayrollResults - .Where(r => r.AnstallId == EmployeeId.From(Auth.EmployeeId.Value)) + .Include(r => r.Rader) + .Where(r => r.AnstallId == empId) .OrderByDescending(r => r.Year).ThenByDescending(r => r.Month) .Take(12).ToListAsync(); + + var emp = await db.Employees + .Include(e => e.Anstallningar) + .FirstOrDefaultAsync(e => e.Id == empId); + if (emp is not null) + { + _namn = emp.FulltNamn; + _personnummer = (string)emp.Personnummer; + _befattning = emp.AktivAnstallning(DateOnly.FromDateTime(DateTime.Today))?.Befattningstitel ?? ""; + } } catch (Exception ex) { _error = $"Kunde inte hämta specifikationer: {ex.Message}"; } finally { _loading = false; } } -} + private async Task LaddaNerPdf(PayrollResult r) + { + _genererar = true; + _error = null; + try + { + var dokument = LonespecifikationDokument.FromPayrollResult(r, _namn, _personnummer, _befattning); + var pdf = new LonespecifikationPdfGenerator().Generate(dokument); + var base64 = Convert.ToBase64String(pdf); + await JS.InvokeVoidAsync("downloadFile", + $"lonespecifikation-{r.Year}-{r.Month:D2}.pdf", base64, "application/pdf"); + } + catch (Exception ex) { _error = $"Kunde inte skapa PDF: {ex.Message}"; } + finally { _genererar = false; } + } +} diff --git a/src/Web/Components/Pages/MinSida/MinLon.razor b/src/Web/Components/Pages/MinSida/MinLon.razor index 0d4e82a..eb43ee2 100644 --- a/src/Web/Components/Pages/MinSida/MinLon.razor +++ b/src/Web/Components/Pages/MinSida/MinLon.razor @@ -3,9 +3,13 @@ @using RegionHR.Web.Services @using RegionHR.SharedKernel.Domain @using RegionHR.Infrastructure.Export +@using RegionHR.Infrastructure.Persistence +@using RegionHR.Payroll.Domain +@using RegionHR.Core.Domain +@using Microsoft.EntityFrameworkCore @inject SelfServiceApiClient SelfService +@inject IDbContextFactory DbFactory @inject AuthService Auth -@inject PdfPayslipGenerator PdfGenerator @inject IJSRuntime JS OpenHR - Min lön @@ -17,62 +21,84 @@ 💰 Min lön - - @_senastePeriod - - - Bruttolön - @FormatKr(_senasteBrutto) - - - Skatt - -@FormatKr(_senasteSkatt) - - - Tillägg - +@FormatKr(_senasteTillagg) - - - Nettolön - @FormatKr(_senasteNetto) - - - - - Ladda ner lönespecifikation (PDF) - - - -Tidigare månader - - - PeriodBruttoNetto - - - @foreach (var period in _historik) - { - - @period.Period - @period.Brutto - @period.Netto - - - - - } - - +@if (_error != null) +{ + @_error +} + +@if (string.IsNullOrEmpty(_senastePeriod)) +{ + Ingen lönedata registrerad ännu. +} +else +{ + + @_senastePeriod + + + Bruttolön + @FormatKr(_senasteBrutto) + + + Skatt + -@FormatKr(_senasteSkatt) + + + Tillägg + +@FormatKr(_senasteTillagg) + + + Nettolön + @FormatKr(_senasteNetto) + + + + + Ladda ner lönespecifikation (PDF) + + + + Tidigare månader + @if (_historik.Count == 0) + { + Ingen tidigare historik. + } + else + { + + + PeriodBruttoNetto + + + @foreach (var period in _historik) + { + + @period.Period + @period.Brutto + @period.Netto + + + + + } + + + } +} @code { private List _historik = []; private string _senastePeriod = ""; private decimal _senasteBrutto, _senasteSkatt, _senasteTillagg, _senasteNetto; + private bool _genererar; private string? _error; protected override async Task OnInitializedAsync() @@ -93,55 +119,61 @@ _senasteNetto = senaste.Netto; _historik = data.Skip(1).Select(r => new LonPeriod( - r.Period, FormatKr(r.Brutto), FormatKr(r.Netto), - r.Brutto, r.Skatt, r.Netto)).ToList(); + r.Period, FormatKr(r.Brutto), FormatKr(r.Netto))).ToList(); } } catch (Exception ex) { _error = $"Kunde inte hämta lönedata: {ex.Message}"; } } - private async Task LaddaNerPdf(string period, decimal brutto, decimal skatt, decimal netto, decimal tillagg) + private async Task LaddaNerPdf(string period) { - var payslipData = new PayslipData - { - AnstallNamn = "Anställd", - Personnummer = "XXXXXX-XXXX", - Period = period, - Enhet = "Min enhet", - Brutto = brutto, - Skatt = skatt, - Netto = netto, - OBTillagg = tillagg, - Rader = new List - { - new() { Loneart = "1010", Benamning = "Grundlön", Antal = 1, Sats = brutto, Belopp = brutto }, - } - }; - - // Try to enrich with real employee data + if (Auth.EmployeeId is null || !TryParsePeriod(period, out var year, out var month)) return; + _genererar = true; + _error = null; try { - var profil = await SelfService.GetProfileAsync(EmployeeId.From(Auth.EmployeeId!.Value)); - if (profil is not null) + await using var db = await DbFactory.CreateDbContextAsync(); + var empId = EmployeeId.From(Auth.EmployeeId.Value); + + var result = await db.PayrollResults + .Include(r => r.Rader) + .FirstOrDefaultAsync(r => r.AnstallId == empId && r.Year == year && r.Month == month); + + if (result is null) { - payslipData.AnstallNamn = $"{profil.Fornamn} {profil.Efternamn}"; - payslipData.Personnummer = profil.PersonnummerMaskerat; - payslipData.Enhet = profil.Enhet; + _error = $"Ingen lönespecifikation hittades för {period}."; + return; } + + var emp = await db.Employees + .Include(e => e.Anstallningar) + .FirstOrDefaultAsync(e => e.Id == empId); + + var namn = emp?.FulltNamn ?? "Anställd"; + var pnr = emp is not null ? (string)emp.Personnummer : ""; + var befattning = emp?.AktivAnstallning(DateOnly.FromDateTime(DateTime.Today))?.Befattningstitel ?? ""; + + var dokument = LonespecifikationDokument.FromPayrollResult(result, namn, pnr, befattning); + var pdf = new LonespecifikationPdfGenerator().Generate(dokument); + var base64 = Convert.ToBase64String(pdf); + await JS.InvokeVoidAsync("downloadFile", + $"lonespecifikation-{year}-{month:D2}.pdf", base64, "application/pdf"); } - catch - { - // Use defaults - } + catch (Exception ex) { _error = $"Kunde inte skapa PDF: {ex.Message}"; } + finally { _genererar = false; } + } - var pdfBytes = PdfGenerator.Generate(payslipData); - var base64 = Convert.ToBase64String(pdfBytes); - var safeFilename = period.Replace(" ", "-").ToLowerInvariant(); - await JS.InvokeVoidAsync("downloadFile", $"lonespec-{safeFilename}.pdf", base64, "application/pdf"); + private static bool TryParsePeriod(string period, out int year, out int month) + { + year = 0; month = 0; + var parts = period.Split('-'); + return parts.Length == 2 + && int.TryParse(parts[0], out year) + && int.TryParse(parts[1], out month); } private static string FormatKr(decimal belopp) => $"{belopp:N0} kr".Replace(",", " "); - private record LonPeriod(string Period, string Brutto, string Netto, decimal BruttoDecimal, decimal SkattDecimal, decimal NettoDecimal); + private record LonPeriod(string Period, string Brutto, string Netto); } diff --git a/src/Web/Components/Pages/MinSida/MinProfil.razor b/src/Web/Components/Pages/MinSida/MinProfil.razor index 4f1df4d..0740844 100644 --- a/src/Web/Components/Pages/MinSida/MinProfil.razor +++ b/src/Web/Components/Pages/MinSida/MinProfil.razor @@ -2,8 +2,12 @@ @layout RegionHR.Web.Components.Layout.AdminLayout @using RegionHR.Web.Services @using RegionHR.SharedKernel.Domain +@using RegionHR.Core.Domain +@using RegionHR.Infrastructure.Persistence +@using Microsoft.EntityFrameworkCore @inject SelfServiceApiClient SelfService @inject AuthService Auth +@inject IDbContextFactory DbFactory OpenHR - Om mig @@ -70,15 +74,29 @@ Vem ska vi kontakta om något händer dig på jobbet? - - - NamnRelationTelefon - - - Karl SvenssonMake070-987 65 43 - Maria LindgrenMor070-111 22 33 - - + @if (_nodkontakter.Count == 0) + { + Du har inga registrerade nödkontakter ännu. + } + else + { + + + NamnRelationTelefonE-post + + + @foreach (var nk in _nodkontakter) + { + + @nk.Namn @if (nk.ArPrimar) { Primär } + @nk.Relation + @nk.Telefon + @(nk.Epost ?? "—") + + } + + + } @@ -91,9 +109,14 @@ Ny nödkontakt
- - - + + + + + @if (_nodkontaktFel != null) + { + @_nodkontaktFel + } Spara } @@ -144,6 +167,8 @@ _postnummer = profil.Postnummer; _ort = profil.Ort; } + + await LaddaNodkontakter(); } catch (Exception ex) { @@ -156,14 +181,59 @@ private string _nodkontaktNamn = ""; private string _nodkontaktRelation = ""; private string _nodkontaktTelefon = ""; + private string _nodkontaktEpost = ""; + private string? _nodkontaktFel; + private List _nodkontakter = []; + + private async Task LaddaNodkontakter() + { + if (Auth.EmployeeId == null) return; + await using var db = await DbFactory.CreateDbContextAsync(); + _nodkontakter = await db.EmergencyContacts + .Where(k => k.AnstallId == Auth.EmployeeId.Value) + .OrderByDescending(k => k.ArPrimar) + .ToListAsync(); + } - private void LaggTillNodkontakt() + private async Task LaggTillNodkontakt() { - _visaNodkontaktForm = false; - _nodkontaktSparad = true; - _nodkontaktNamn = ""; - _nodkontaktRelation = ""; - _nodkontaktTelefon = ""; + _nodkontaktFel = null; + if (Auth.EmployeeId == null) { _nodkontaktFel = "Ingen anställd kopplad — kan inte spara."; return; } + if (string.IsNullOrWhiteSpace(_nodkontaktNamn) || + string.IsNullOrWhiteSpace(_nodkontaktRelation) || + string.IsNullOrWhiteSpace(_nodkontaktTelefon)) + { + _nodkontaktFel = "Fyll i namn, relation och telefon."; + return; + } + + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + // Första kontakten blir primär (den vi ringer först). + var primar = !await db.EmergencyContacts.AnyAsync(k => k.AnstallId == Auth.EmployeeId.Value); + var kontakt = EmergencyContact.Skapa( + Auth.EmployeeId.Value, + _nodkontaktNamn.Trim(), + _nodkontaktRelation.Trim(), + _nodkontaktTelefon.Trim(), + string.IsNullOrWhiteSpace(_nodkontaktEpost) ? null : _nodkontaktEpost.Trim(), + primar); + db.EmergencyContacts.Add(kontakt); + await db.SaveChangesAsync(); + + await LaddaNodkontakter(); + _visaNodkontaktForm = false; + _nodkontaktSparad = true; + _nodkontaktNamn = ""; + _nodkontaktRelation = ""; + _nodkontaktTelefon = ""; + _nodkontaktEpost = ""; + } + catch (Exception ex) + { + _nodkontaktFel = $"Kunde inte spara: {ex.Message}"; + } } private async Task SparaKontaktuppgifter() diff --git a/src/Web/Components/Pages/Notiser/Index.razor b/src/Web/Components/Pages/Notiser/Index.razor index ee64182..ed57976 100644 --- a/src/Web/Components/Pages/Notiser/Index.razor +++ b/src/Web/Components/Pages/Notiser/Index.razor @@ -118,6 +118,8 @@ else private async Task MarkeraLast(Notification n) { await using var db = await DbFactory.CreateDbContextAsync(); + // n laddades i init-contexten — attacha före mutation för att persistera. + db.Attach(n); n.MarkAsRead(); await db.SaveChangesAsync(); } @@ -126,8 +128,12 @@ else { await using var db = await DbFactory.CreateDbContextAsync(); var unread = _notiser.Where(n => !n.IsRead).ToList(); + // Notiserna kommer från init-contexten — attacha var och en före mutation. foreach (var n in unread) + { + db.Attach(n); n.MarkAsRead(); + } await db.SaveChangesAsync(); Snackbar.Add($"{unread.Count} {L["NotificationsMarkedAsRead"]}", MudBlazor.Severity.Success); } diff --git a/src/Web/Components/Pages/Notiser/Installningar.razor b/src/Web/Components/Pages/Notiser/Installningar.razor index ad3b55d..bc0f515 100644 --- a/src/Web/Components/Pages/Notiser/Installningar.razor +++ b/src/Web/Components/Pages/Notiser/Installningar.razor @@ -85,6 +85,10 @@ else _saving = true; _error = null; try { + // _prefs laddades/muterades i init-contexten (nu disposad). Denna context + // trackar dem inte, så en naken SaveChanges vore en no-op. Markera dem som + // ändrade i denna context så de faktiskt persisteras. + db.NotificationPreferences.UpdateRange(_prefs); await db.SaveChangesAsync(); _sparad = true; _dirty = false; diff --git a/src/Web/Components/Pages/Offboarding/Detalj.razor b/src/Web/Components/Pages/Offboarding/Detalj.razor new file mode 100644 index 0000000..0827d7c --- /dev/null +++ b/src/Web/Components/Pages/Offboarding/Detalj.razor @@ -0,0 +1,238 @@ +@page "/offboarding/{Id:guid}" +@layout RegionHR.Web.Components.Layout.AdminLayout +@using RegionHR.Infrastructure.Persistence +@using RegionHR.Offboarding.Domain +@using RegionHR.SharedKernel.Domain +@using Microsoft.EntityFrameworkCore +@inject IDbContextFactory DbFactory + +OpenHR - Offboarding-ärende +Tillbaka + +@if (_loading) +{ + +} +else if (_case is null) +{ + Offboarding-ärendet hittades inte. +} +else +{ + var total = _case.Steg.Count; + var done = _case.Steg.Count(s => s.Klar); + var pct = total > 0 ? (int)(100.0 * done / total) : 0; + +
+
+ Offboarding — @_anstalldNamn + @AnledningText(_case.Anledning) · sista arbetsdag @_case.SistaArbetsdag.ToString("yyyy-MM-dd") +
+ @StatusText(_case.Status) +
+ + @if (_success != null) + { + @_success + } + @if (_error != null) + { + @_error + } + + + + +
+ Checklista + @done / @total klara +
+ + + @for (int i = 0; i < _case.Steg.Count; i++) + { + var index = i; + var steg = _case.Steg[i]; +
+ +
+ @steg.Beskrivning + @if (steg.Klar && steg.KlarVid.HasValue) + { + Klar @steg.KlarVid.Value.ToLocalTime().ToString("yyyy-MM-dd HH:mm") + } +
+
+ } + +
+ @if (_case.Status == OffboardingStatus.Slutford) + { + + Offboarding slutförd @(_case.SlutfordVid?.ToLocalTime().ToString("yyyy-MM-dd HH:mm")). + + } + else + { + + Slutför offboarding + + @if (done < total) + { + Alla steg måste vara klara innan ärendet kan slutföras. + } + } +
+
+
+ + + + Exit-samtal + @if (_case.ExitSamtalGenomfort) + { + Exit-samtal genomfört + @(_case.ExitSamtalKommentar ?? "") + } + + + @(_case.ExitSamtalGenomfort ? "Uppdatera exit-samtal" : "Registrera exit-samtal") + + + + + Återanställningsbarhet + + Nuvarande status: + + @(_case.ArReHireEligible ? "Återanställningsbar" : "Ej återanställningsbar") + + + + + + Spara + + + +
+} + +@code { + [Parameter] public Guid Id { get; set; } + + private OffboardingCase? _case; + private bool _loading = true, _saving; + private string? _error, _success; + private string _anstalldNamn = ""; + + private string _exitKommentar = ""; + private bool _reHireEligible = true; + private string _reHireKommentar = ""; + + protected override async Task OnInitializedAsync() => await LaddaAsync(); + + private async Task LaddaAsync() + { + _loading = true; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + _case = await db.OffboardingCases + .Include(c => c.Steg) + .FirstOrDefaultAsync(c => c.Id == Id); + + if (_case is null) { _loading = false; return; } + + var emp = await db.Employees.FirstOrDefaultAsync(e => e.Id == EmployeeId.From(_case.AnstallId)); + _anstalldNamn = emp != null ? $"{emp.Fornamn} {emp.Efternamn}" : "Okänd anställd"; + + _reHireEligible = _case.ArReHireEligible; + _reHireKommentar = _case.ReHireKommentar ?? ""; + } + catch (Exception ex) { _error = $"Kunde inte hämta ärendet: {ex.Message}"; } + finally { _loading = false; } + } + + private async Task UtforAsync(Action mutate, string ok) + { + _saving = true; _error = null; _success = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var c = await db.OffboardingCases.Include(x => x.Steg).FirstOrDefaultAsync(x => x.Id == Id); + if (c is null) { _error = "Ärendet hittades inte längre."; return; } + mutate(c); + await db.SaveChangesAsync(); + _success = ok; + await LaddaAsync(); + } + catch (InvalidOperationException ex) { _error = ex.Message; } + catch (ArgumentOutOfRangeException) { _error = "Ogiltigt checklistesteg."; } + catch (Exception ex) { _error = $"Åtgärden kunde inte genomföras: {ex.Message}"; } + finally { _saving = false; } + } + + private async Task MarkeraSteg(int index) + => await UtforAsync(c => c.MarkeraStegKlart(index), "Checklistesteget markerades som klart."); + + private async Task Slutfor() + => await UtforAsync(c => c.Slutfor(), "Offboarding-ärendet är slutfört."); + + private async Task RegistreraExitSamtal() + { + var kommentar = _exitKommentar.Trim(); + await UtforAsync(c => c.RegistreraExitSamtal(kommentar), "Exit-samtalet är registrerat."); + _exitKommentar = ""; + } + + private async Task SattReHire() + { + var eligible = _reHireEligible; + var kommentar = string.IsNullOrWhiteSpace(_reHireKommentar) ? null : _reHireKommentar.Trim(); + await UtforAsync(c => c.SattReHireStatus(eligible, kommentar), "Återanställningsstatus sparad."); + } + + private static string AnledningText(AvslutAnledning a) => a switch + { + AvslutAnledning.EgenBegaran => "Egen uppsägning", + AvslutAnledning.Uppsagning => "Uppsägning", + AvslutAnledning.Pension => "Pension", + AvslutAnledning.Vikariat_Slut => "Vikariat slut", + AvslutAnledning.Provanstallning_Avbruten => "Provanst. avbruten", + AvslutAnledning.Overgang => "Övergång", + AvslutAnledning.Dodsfall => "Dödsfall", + AvslutAnledning.Annat => "Annat", + _ => a.ToString() + }; + + private static Color StatusFarg(OffboardingStatus s) => s switch + { + OffboardingStatus.Skapad => Color.Info, + OffboardingStatus.Pagar => Color.Warning, + OffboardingStatus.Slutford => Color.Success, + _ => Color.Default + }; + + private static string StatusText(OffboardingStatus s) => s switch + { + OffboardingStatus.Skapad => "Skapad", + OffboardingStatus.Pagar => "Pågår", + OffboardingStatus.Slutford => "Slutförd", + _ => s.ToString() + }; + + private static Color ProgressFarg(int pct) => pct switch + { + 100 => Color.Success, + >= 50 => Color.Warning, + _ => Color.Info + }; +} diff --git a/src/Web/Components/Pages/Offboarding/Index.razor b/src/Web/Components/Pages/Offboarding/Index.razor index 8379edb..ac3c59a 100644 --- a/src/Web/Components/Pages/Offboarding/Index.razor +++ b/src/Web/Components/Pages/Offboarding/Index.razor @@ -2,8 +2,10 @@ @layout RegionHR.Web.Components.Layout.AdminLayout @using RegionHR.Infrastructure.Persistence @using RegionHR.Offboarding.Domain +@using RegionHR.SharedKernel.Domain @using Microsoft.EntityFrameworkCore @inject IDbContextFactory DbFactory +@inject NavigationManager Nav OpenHR - Offboarding
@@ -16,69 +18,71 @@ @_error } - - - - Anställd - Anledning - Sista dag - Status - Checklista - - - - - @foreach (var c in _cases) - { - var total = c.Steg.Count; - var done = c.Steg.Count(s => s.Klar); - var pct = total > 0 ? (int)(100.0 * done / total) : 0; +@if (_loading) +{ + +} +else if (_cases.Count == 0) +{ + Inga offboarding-ärenden registrerade. +} +else +{ + + - @c.AnstallId.ToString()[..8]... - @AnledningText(c.Anledning) - @c.SistaArbetsdag.ToString("yyyy-MM-dd") - @StatusText(c.Status) - @done/@total - Öppna + Anställd + Anledning + Sista dag + Status + Checklista + - @if (_valdArende == c) + + + @foreach (var c in _cases) { - - - - Offboarding — @c.AnstallId.ToString()[..8]... - Anledning: @AnledningText(c.Anledning) - Sista arbetsdag: @c.SistaArbetsdag.ToString("yyyy-MM-dd") - Status: @StatusText(c.Status) - Checklista: @c.Steg.Count(s => s.Klar) / @c.Steg.Count klara - Stäng - - + var total = c.Steg.Count; + var done = c.Steg.Count(s => s.Klar); + var pct = total > 0 ? (int)(100.0 * done / total) : 0; + + @Namn(c.AnstallId) + @AnledningText(c.Anledning) + @c.SistaArbetsdag.ToString("yyyy-MM-dd") + @StatusText(c.Status) + @done/@total + Öppna } - } - - + + +} @code { private List _cases = []; - private OffboardingCase? _valdArende; + private Dictionary _namn = new(); + private bool _loading = true; private string? _error; - private void ValjArende(OffboardingCase c) => _valdArende = _valdArende == c ? null : c; + private string Namn(Guid anstallId) => _namn.GetValueOrDefault(anstallId, "Okänd anställd"); protected override async Task OnInitializedAsync() { - using var db = DbFactory.CreateDbContext(); try { + await using var db = await DbFactory.CreateDbContextAsync(); _cases = await db.OffboardingCases .Include(c => c.Steg) .OrderByDescending(c => c.SkapadVid) .Take(50) .ToListAsync(); + + var ids = _cases.Select(c => new EmployeeId(c.AnstallId)).Distinct().ToList(); + var employees = await db.Employees.Where(e => ids.Contains(e.Id)).ToListAsync(); + _namn = employees.ToDictionary(e => e.Id.Value, e => $"{e.Fornamn} {e.Efternamn}"); } catch (Exception ex) { _error = $"Kunde inte hämta offboarding-ärenden: {ex.Message}"; } + finally { _loading = false; } } private static string AnledningText(AvslutAnledning a) => a switch @@ -117,4 +121,3 @@ _ => Color.Info }; } - diff --git a/src/Web/Components/Pages/Organisation/Index.razor b/src/Web/Components/Pages/Organisation/Index.razor index 723dbce..885aede 100644 --- a/src/Web/Components/Pages/Organisation/Index.razor +++ b/src/Web/Components/Pages/Organisation/Index.razor @@ -1,14 +1,72 @@ @page "/organisation" @layout RegionHR.Web.Components.Layout.AdminLayout +@using RegionHR.SharedKernel.Domain +@using RegionHR.Web.Services @inject RegionHR.Web.Services.AnstallningService Service Organisation - RegionHR -

Organisationsstruktur

+
+ Organisationsstruktur + Ny enhet +
@if (_error != null) { - @_error + @_error +} +@if (_success != null) +{ + @_success +} + +@if (_visarForm) +{ + +
+ Skapa ny organisationsenhet + +
+ + + + + + + @foreach (var t in Enum.GetValues()) + { + @TypNamn(t) + } + + + + + + + + + + + @foreach (var e in _plattaEnheter) + { + @e.Namn (@e.Kostnadsstalle) + } + + + + + @if (_sparar) + { + + } + Skapa enhet + +
} @@ -19,7 +77,7 @@ } else if (_rotenheter.Count == 0) { - Ingen organisation konfigurerad ännu. Skapa enheter via databasen eller API:t. + Ingen organisation konfigurerad ännu. Skapa den första enheten med knappen "Ny enhet". } else { @@ -33,14 +91,29 @@ @code { private List _rotenheter = []; + private List _plattaEnheter = []; private bool _loading = true; + private bool _visarForm; + private bool _sparar; private string? _error; + private string? _success; + + // Formulärfält + private string _formNamn = ""; + private OrganizationUnitType _formTyp = OrganizationUnitType.Enhet; + private string _formKostnadsstalle = ""; + private string _formCfar = ""; + private Guid? _formOverordnad; + + protected override async Task OnInitializedAsync() => await LaddaAsync(); - protected override async Task OnInitializedAsync() + private async Task LaddaAsync() { + _loading = true; try { _rotenheter = await Service.HamtaOrganisationAsync(); + _plattaEnheter = await Service.HamtaEnheterAsync(); } catch (Exception ex) { @@ -51,4 +124,44 @@ _loading = false; } } + + private async Task SkapaEnhet() + { + if (string.IsNullOrWhiteSpace(_formNamn) || string.IsNullOrWhiteSpace(_formKostnadsstalle)) return; + _sparar = true; + _error = null; + _success = null; + try + { + var overordnad = _formOverordnad.HasValue ? OrganizationId.From(_formOverordnad.Value) : (OrganizationId?)null; + await Service.SkapaEnhetAsync(_formNamn, _formTyp, _formKostnadsstalle, overordnad, _formCfar); + _success = $"Enheten \"{_formNamn.Trim()}\" skapades."; + _visarForm = false; + _formNamn = ""; + _formKostnadsstalle = ""; + _formCfar = ""; + _formOverordnad = null; + _formTyp = OrganizationUnitType.Enhet; + await LaddaAsync(); + } + catch (Exception ex) + { + _error = $"Kunde inte skapa enhet: {ex.Message}"; + } + finally + { + _sparar = false; + } + } + + private static string TypNamn(OrganizationUnitType t) => t switch + { + OrganizationUnitType.Region => "Region", + OrganizationUnitType.Forvaltning => "Förvaltning", + OrganizationUnitType.Verksamhet => "Verksamhet", + OrganizationUnitType.Enhet => "Enhet", + OrganizationUnitType.Avdelning => "Avdelning", + OrganizationUnitType.Sektion => "Sektion", + _ => t.ToString() + }; } diff --git a/src/Web/Components/Pages/Positioner/Detalj.razor b/src/Web/Components/Pages/Positioner/Detalj.razor new file mode 100644 index 0000000..5a9bd42 --- /dev/null +++ b/src/Web/Components/Pages/Positioner/Detalj.razor @@ -0,0 +1,299 @@ +@page "/positioner/{Id:guid}" +@layout RegionHR.Web.Components.Layout.AdminLayout +@using RegionHR.Infrastructure.Persistence +@using RegionHR.Positions.Domain +@using RegionHR.SharedKernel.Domain +@using Microsoft.EntityFrameworkCore +@inject IDbContextFactory DbFactory +@inject NavigationManager Nav + +OpenHR - Position +Tillbaka + +@if (_loading) +{ + +} +else if (_position is null) +{ + Positionen hittades inte. +} +else +{ +
+
+ @_position.Titel + @_enhetNamn +
+ @StatusText(_position.Status) +
+ + @if (_success != null) + { + @_success + } + @if (_error != null) + { + @_error + } + + + + + Uppgifter + + + Befattning@_position.Titel + Enhet@_enhetNamn + Status@StatusText(_position.Status) + Innehavare@InnehavareNamn + Budgeterad månadslön@_position.BudgeteradManadslon.ToString("N0") kr + Sysselsättningsgrad@($"{_position.Sysselsattningsgrad:0.##}") % + BESTA-kod@(_position.BESTAKod ?? "—") + AID-kod@(_position.AIDKod ?? "—") + Planerad efterträdare@EftertradareNamn + Skapad@_position.SkapadVid.ToLocalTime().ToString("yyyy-MM-dd") + @if (_position.AvveckladVid.HasValue) + { + Avvecklad@_position.AvveckladVid.Value.ToLocalTime().ToString("yyyy-MM-dd") + } + + + + + + + + Åtgärder + + @if (_position.Status == PositionStatus.Avvecklad) + { + + Positionen är avvecklad och kan inte längre ändras. + + } + else + { + @* Tillsätt — endast när positionen inte redan är tillsatt *@ + @if (_position.Status != PositionStatus.Aktiv) + { + Tillsätt position +
+ + @foreach (var e in _employees) + { + @e.Fornamn @e.Efternamn + } + + + Tillsätt + +
+ } + + @* Vakanssätt — endast när positionen är tillsatt *@ + @if (_position.Status == PositionStatus.Aktiv) + { + Vakanssätt position +
+ + + Vakanssätt + +
+ } + + + +
+ @if (_position.Status != PositionStatus.Frysta) + { + + Frys + + } + + Avveckla + +
+ + + + Planera efterträdare +
+ + @foreach (var e in _employees) + { + @e.Fornamn @e.Efternamn + } + + + Spara + +
+ + + + Uppdatera budget +
+ + + Spara + +
+ } +
+
+
+ + + Tillsättningshistorik + @if (_position.Historik.Count == 0) + { + Ingen historik registrerad för denna position. + } + else + { + + DatumTidigare innehavareNy innehavareAnledning + + @foreach (var h in _position.Historik.OrderByDescending(x => x.AndradVid)) + { + + @h.AndradVid.ToLocalTime().ToString("yyyy-MM-dd HH:mm") + @NamnForGuid(h.TidigareInnehavare) + @NamnForGuid(h.NyInnehavare) + @(h.Anledning ?? "—") + + } + + + } + +} + +@code { + [Parameter] public Guid Id { get; set; } + + private RegionHR.Positions.Domain.Position? _position; + private bool _loading = true, _saving; + private string? _error, _success; + private string _enhetNamn = "—"; + private List _employees = []; + private Dictionary _empNamn = new(); + + private Guid? _valdAnstallId; + private Guid? _valdEftertradareId; + private string _vakansAnledning = ""; + private decimal _nyBudget; + + private string InnehavareNamn => _position?.InnehavareAnstallId is { } g ? NamnForGuid(g) : "— (vakant)"; + private string EftertradareNamn => _position?.EftertradarePlanerad is { } g ? NamnForGuid(g) : "—"; + + private string NamnForGuid(Guid? g) => + g.HasValue ? _empNamn.GetValueOrDefault(g.Value, "Okänd") : "—"; + + protected override async Task OnInitializedAsync() => await LaddaAsync(); + + private async Task LaddaAsync() + { + _loading = true; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + _position = await db.Positions_Table + .Include(p => p.Historik) + .FirstOrDefaultAsync(p => p.Id == Id); + + if (_position is null) { _loading = false; return; } + + var enhet = await db.OrganizationUnits.FirstOrDefaultAsync(o => o.Id == OrganizationId.From(_position.EnhetId)); + _enhetNamn = enhet?.Namn ?? "Okänd enhet"; + + _employees = await db.Employees.OrderBy(e => e.Efternamn).ToListAsync(); + _empNamn = _employees.ToDictionary(e => e.Id.Value, e => $"{e.Fornamn} {e.Efternamn}"); + + _nyBudget = _position.BudgeteradManadslon; + } + catch (Exception ex) { _error = $"Kunde inte hämta position: {ex.Message}"; } + finally { _loading = false; } + } + + private async Task UtforAsync(Action mutate, string ok) + { + if (_position is null) return; + _saving = true; _error = null; _success = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var pos = await db.Positions_Table.FirstOrDefaultAsync(p => p.Id == Id); + if (pos is null) { _error = "Positionen hittades inte längre."; return; } + mutate(pos); + await db.SaveChangesAsync(); + _success = ok; + await LaddaAsync(); + } + catch (InvalidOperationException ex) { _error = ex.Message; } + catch (Exception ex) { _error = $"Åtgärden kunde inte genomföras: {ex.Message}"; } + finally { _saving = false; } + } + + private async Task Tillsatt() + { + if (_valdAnstallId is null) return; + var namn = NamnForGuid(_valdAnstallId); + var anstId = _valdAnstallId.Value; + await UtforAsync(p => p.Tillsatt(anstId), $"Positionen tillsattes med {namn}."); + _valdAnstallId = null; + } + + private async Task Vakansatt() + { + var anledning = string.IsNullOrWhiteSpace(_vakansAnledning) ? null : _vakansAnledning.Trim(); + await UtforAsync(p => p.Vakansatt(anledning), "Positionen är vakanssatt."); + _vakansAnledning = ""; + } + + private async Task Frys() => await UtforAsync(p => p.Frys(), "Positionen är frusen."); + + private async Task Avveckla() => await UtforAsync(p => p.Avveckla(), "Positionen är avvecklad."); + + private async Task SattEftertradare() + { + if (_valdEftertradareId is null) return; + var namn = NamnForGuid(_valdEftertradareId); + var eftId = _valdEftertradareId.Value; + await UtforAsync(p => p.SattEftertrardare(eftId), $"{namn} är planerad som efterträdare."); + _valdEftertradareId = null; + } + + private async Task UppdateraBudget() + { + var budget = _nyBudget; + await UtforAsync(p => p.UppdateraBudget(budget), $"Budgeterad månadslön uppdaterad till {budget:N0} kr."); + } + + private static Color StatusColor(PositionStatus s) => s switch + { + PositionStatus.Aktiv => Color.Success, + PositionStatus.Vakant => Color.Warning, + PositionStatus.Frysta => Color.Default, + PositionStatus.Avvecklad => Color.Error, + _ => Color.Default + }; + + private static string StatusText(PositionStatus s) => s switch + { + PositionStatus.Aktiv => "Tillsatt", + PositionStatus.Vakant => "Vakant", + PositionStatus.Frysta => "Frusen", + PositionStatus.Avvecklad => "Avvecklad", + _ => s.ToString() + }; +} diff --git a/src/Web/Components/Pages/Positioner/Index.razor b/src/Web/Components/Pages/Positioner/Index.razor index c82b4a0..d13c03f 100644 --- a/src/Web/Components/Pages/Positioner/Index.razor +++ b/src/Web/Components/Pages/Positioner/Index.razor @@ -5,6 +5,7 @@ @using RegionHR.SharedKernel.Domain @using Microsoft.EntityFrameworkCore @inject IDbContextFactory DbFactory +@inject NavigationManager Nav OpenHR - Positioner
@@ -79,16 +80,17 @@ else else { - BefattningEnhetAnställdSysselsättningStatus + BefattningEnhetAnställdSysselsättningStatus @foreach (var r in _rader) { - + @r.Titel @r.Enhet @r.Anstalld @($"{r.Sysselsattningsgrad:F0}%") @StatusText(r.Status) + Öppna } diff --git a/src/Web/Components/Pages/Rapporter/Index.razor b/src/Web/Components/Pages/Rapporter/Index.razor index 1541629..15d57af 100644 --- a/src/Web/Components/Pages/Rapporter/Index.razor +++ b/src/Web/Components/Pages/Rapporter/Index.razor @@ -9,20 +9,25 @@ Rapportbibliotek - 12Standardrapporter - 3Schemalagda - 47Körda senaste 30 d + @Standardrapporter.CountStandardrapporter + @_scheduledReports.CountSchemalagda + @_korda30dKörda senaste 30 d Standardrapporter RapportBeskrivningKategoriFormatÅtgärd - PersonalförteckningKomplett lista över aktiva anställda med befattning och enhetPersonalExcel / PDFKör - LöneregisterLönespecifikationer per period med brutto, netto och avdragLönExcel / PDFKör - FrånvarostatistikSjukfrånvaro, VAB och övrig frånvaro per enhet och periodFrånvaroExcel / PDFKör - LAS-statusAckumulerade dagar för vikarier och SAVA med varningsnivåerJuridikExcel / PDFKör - BemanningsanalysBemanningsgrad per enhet, dag och skifttyp med avvikelserSchemaExcel / PDFKör + @foreach (var r in Standardrapporter) + { + + @r.Namn + @r.Beskrivning + @r.Kategori + @r.Format + Kör + + } @@ -62,6 +67,20 @@ @code { private List _scheduledReports = []; + private int _korda30d; + + private record StandardRapport(string Namn, string Beskrivning, string Kategori, string Format); + + // Standardrapporterna som kan köras via /rapporter/kor/{Namn} (se KorRapport.razor). + // Kortet "Standardrapporter" räknar denna lista så siffran aldrig kan divergera från raderna. + private static readonly List Standardrapporter = + [ + new("Personalförteckning", "Komplett lista över aktiva anställda med befattning och enhet", "Personal", "Excel / CSV"), + new("Löneregister", "Lönespecifikationer per period med brutto, netto och avdrag", "Lön", "Excel / CSV"), + new("Frånvarostatistik", "Sjukfrånvaro, VAB och övrig frånvaro per enhet och period", "Frånvaro", "Excel / CSV"), + new("LAS-status", "Ackumulerade dagar för vikarier och SAVA med varningsnivåer", "Juridik", "Excel / CSV"), + new("Bemanningsanalys", "Bemanningsgrad per enhet, dag och skifttyp med avvikelser", "Schema", "Excel / CSV"), + ]; protected override async Task OnInitializedAsync() { @@ -72,6 +91,10 @@ .Where(r => r.CronExpression != null) .OrderBy(r => r.Namn) .ToListAsync(); + + // Verkligt antal loggade rapportkörningar (KorRapport.razor + API loggar ReportExecution). + var sedan = DateTime.UtcNow.AddDays(-30); + _korda30d = await db.ReportExecutions.CountAsync(e => e.StartadVid >= sedan); } catch { diff --git a/src/Web/Components/Pages/Rapporter/KorRapport.razor b/src/Web/Components/Pages/Rapporter/KorRapport.razor index 08187c4..5d6b9ca 100644 --- a/src/Web/Components/Pages/Rapporter/KorRapport.razor +++ b/src/Web/Components/Pages/Rapporter/KorRapport.razor @@ -1,8 +1,11 @@ @page "/rapporter/kor/{Namn}" @layout RegionHR.Web.Components.Layout.AdminLayout @using RegionHR.Infrastructure.Persistence +@using RegionHR.Infrastructure.Export +@using RegionHR.Reporting.Domain @using Microsoft.EntityFrameworkCore @inject IDbContextFactory DbFactory +@inject ExportService Export @inject IJSRuntime JS OpenHR - @Namn @@ -187,6 +190,31 @@ else if (_result != null) _running = false; } + private bool _loggad; + + // Logga körningen EN gång i den interaktiva renderingen (OnAfterRender kallas inte under + // prerender) så att rapportbibliotekets "Körda senaste 30 d" speglar verkliga körningar — + // parity med REST-API:ts /kor-endpoint. Icke-kritiskt: får aldrig störa visningen. + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender || _loggad) return; + _loggad = true; + + string[] kandaRapporter = ["Personalförteckning", "Löneregister", "Frånvarostatistik", "LAS-status", "Bemanningsanalys"]; + if (_running || _error != null || !kandaRapporter.Contains(Namn)) return; + + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var def = await db.ReportDefinitions.FirstOrDefaultAsync(r => r.Namn == Namn); + var exec = ReportExecution.Starta(def?.Id ?? Guid.Empty, Namn); + exec.Slutfor($"web://rapporter/kor/{Namn}"); + db.ReportExecutions.Add(exec); + await db.SaveChangesAsync(); + } + catch { /* loggning är icke-kritisk */ } + } + private bool _visarCsv; private string _csvInnehall = ""; private bool _kopierad; @@ -222,10 +250,15 @@ else if (_result != null) private async Task ExporteraExcel() { - var csv = GenereraCsv(); - var bytes = System.Text.Encoding.UTF8.GetBytes(csv); + // Riktig .xlsx via ClosedXML (ExportService) — inte en omdöpt CSV. + // Excel-fliknamn får inte innehålla \ / ? * [ ] : och max 31 tecken. + var rensat = new string(Namn.Where(c => !"\\/?*[]:".Contains(c)).ToArray()); + if (string.IsNullOrWhiteSpace(rensat)) rensat = "Rapport"; + var sheetName = rensat.Length > 31 ? rensat[..31] : rensat; + var bytes = Export.ToExcel(_result, sheetName, _headers.ToArray(), row => row.Cast().ToArray()); var base64 = Convert.ToBase64String(bytes); - await JS.InvokeVoidAsync("downloadFile", $"{Namn}.csv", base64, "text/csv"); + await JS.InvokeVoidAsync("downloadFile", $"{Namn}.xlsx", base64, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); } } diff --git a/src/Web/Components/Pages/Rapporter/Prediktion.razor b/src/Web/Components/Pages/Rapporter/Prediktion.razor index 261500e..08b25ed 100644 --- a/src/Web/Components/Pages/Rapporter/Prediktion.razor +++ b/src/Web/Components/Pages/Rapporter/Prediktion.razor @@ -1,13 +1,23 @@ @page "/rapporter/prediktion" @layout RegionHR.Web.Components.Layout.AdminLayout @using RegionHR.Infrastructure.Persistence +@using RegionHR.Infrastructure.Analytics @using RegionHR.Analytics.Domain @using Microsoft.EntityFrameworkCore @inject IDbContextFactory DbFactory +@inject FlightRiskService RiskService OpenHR - Prediktiv analys -Prediktiv analys -AI-drivna modeller for att forutsaga personalfloden, sjukfranvaro och kostnader. +Prediktiv analys +Modeller för att bedöma personalflöden, sjukfrånvaro och kostnader. + + + Ärlig statusbild. Endast uppsägningsriskmodellen är aktiverad. Den drivs av en + regelbaserad heuristik (FlightRiskService) som beräknas live ur anställningsdata — inte en tränad + AI-/ML-modell, och därför visas inga precisions-/accuracy-siffror. Övriga modeller (bemannings-, sjukfrånvaro- och + lönekostnadsprognos) är ej aktiverade: de kräver ML-infrastruktur som inte ingår i OpenHR och visas + här som planerade. + @if (_loading) { } else if (_error != null) { @_error } @@ -16,41 +26,49 @@ else @foreach (var model in _models) { + var aktiv = ArAktiverad(model.Typ); - - + + @model.Namn @GetModelBeskrivning(model.Typ) -
- Precision: - @(model.Accuracy.HasValue ? $"{model.Accuracy.Value:P0}" : "Ej tranad") -
-
- Tranad: - @(model.SenasteTranningsDatum.HasValue ? model.SenasteTranningsDatum.Value.ToString("yyyy-MM-dd") : "-") -
- @if (model.Accuracy.HasValue) + + @(aktiv ? "Aktiverad (regelbaserad)" : "Ej aktiverad") + + @if (aktiv) + { +
+ Analyserade: + @_risk.Count +
+
+ Hög risk: + @_risk.Count(r => r.Niva == FlightRiskNiva.Hog) +
+ } + else { - + + Demonstration — genererar inga resultat. + } -
- Resultat: - @_resultCount.GetValueOrDefault(model.Id, 0) -
}
- Senaste prediktionsresultat - @if (_results.Count == 0) +
+ Uppsägningsrisk per anställd (regelbaserad) + Öppna full analys +
+ @if (_risk.Count == 0) { - Inga prediktionsresultat annu - Resultat genereras automatiskt nar modellerna kors med aktuell data. + Inga anställda med aktiv anställning + Riskpoäng beräknas live när det finns anställningsdata i databasen. } else @@ -59,35 +77,42 @@ else Modell - Entitetstyp - Score - Riskniva - Beraknad + Anställd + Befattning + Poäng + Risknivå + Beräknad - @foreach (var r in _results) + @foreach (var r in _risk.Take(20)) { - @(_modelNames.GetValueOrDefault(r.PredictionModelId, "-")) - @r.EntityTyp - @r.Score.ToString("F2") + Uppsägningsriskmodell + @r.Namn + @r.Befattning + @r.Poang / @r.MaxPoang - @r.RiskNiva + @GetRiskText(r.Niva) - @r.BeraknadVid.ToString("yyyy-MM-dd HH:mm") + @_beraknadVid.ToString("yyyy-MM-dd HH:mm") } + @if (_risk.Count > 20) + { + + Visar de 20 med högst poäng av @_risk.Count analyserade. Se full analys för samtliga. + + } } } @code { private List _models = []; - private List _results = []; - private Dictionary _modelNames = []; - private Dictionary _resultCount = []; + private List _risk = []; + private DateTime _beraknadVid = DateTime.Now; private bool _loading = true; private string? _error; @@ -97,21 +122,18 @@ else try { _models = await db.PredictionModels.OrderBy(m => m.Typ).ToListAsync(); - _modelNames = _models.ToDictionary(m => m.Id, m => m.Namn); - - _results = await db.PredictionResults - .OrderByDescending(r => r.BeraknadVid) - .Take(20) - .ToListAsync(); - _resultCount = _results - .GroupBy(r => r.PredictionModelId) - .ToDictionary(g => g.Key, g => g.Count()); + var employees = await db.Employees.Include(e => e.Anstallningar).ToListAsync(); + _risk = RiskService.BeraknaForAlla(employees); + _beraknadVid = DateTime.Now; } catch (Exception ex) { _error = $"Kunde inte ladda prediktioner: {ex.Message}"; } finally { _loading = false; } } + // Endast uppsägningsriskmodellen (Attrition) backas av en verklig, regelbaserad beräkning. + private static bool ArAktiverad(string typ) => typ == "Attrition"; + private string GetModelIcon(string typ) => typ switch { "Attrition" => Icons.Material.Filled.PersonOff, @@ -123,20 +145,26 @@ else private string GetModelBeskrivning(string typ) => typ switch { - "Attrition" => "Identifierar medarbetare med forhojd risk for uppsagning baserat pa historiska monster.", - "HeadcountForecast" => "Prognos for framtida bemanningsbehov baserat pa turnover, pension och tillvaxt.", - "SickLeaveForecast" => "Forutser sjukfranvaro per enhet baserat pa sasong, historik och arbetsbelastning.", - "LaborCostForecast" => "Beraknar framtida lonekostnader inklusive OB, overtid och arbetsgivaravgifter.", + "Attrition" => "Regelbaserad heuristik som flaggar medarbetare med förhöjd uppsägningsrisk ur anställningsdata.", + "HeadcountForecast" => "Prognos för framtida bemanningsbehov baserat på turnover, pension och tillväxt.", + "SickLeaveForecast" => "Förutser sjukfrånvaro per enhet baserat på säsong, historik och arbetsbelastning.", + "LaborCostForecast" => "Beräknar framtida lönekostnader inklusive OB, övertid och arbetsgivaravgifter.", _ => "" }; - private Color GetRiskColor(string riskNiva) => riskNiva switch + private static Color GetRiskColor(FlightRiskNiva niva) => niva switch { - "Critical" => Color.Error, - "High" => Color.Error, - "Medium" => Color.Warning, - "Low" => Color.Success, + FlightRiskNiva.Hog => Color.Error, + FlightRiskNiva.Medel => Color.Warning, + FlightRiskNiva.Lag => Color.Success, _ => Color.Default }; -} + private static string GetRiskText(FlightRiskNiva niva) => niva switch + { + FlightRiskNiva.Hog => "Hög", + FlightRiskNiva.Medel => "Medel", + FlightRiskNiva.Lag => "Låg", + _ => "—" + }; +} diff --git a/src/Web/Components/Pages/Rapporter/SCBExport.razor b/src/Web/Components/Pages/Rapporter/SCBExport.razor index 22d4187..90c3d9a 100644 --- a/src/Web/Components/Pages/Rapporter/SCBExport.razor +++ b/src/Web/Components/Pages/Rapporter/SCBExport.razor @@ -1,8 +1,11 @@ @page "/rapporter/scb" @layout RegionHR.Web.Components.Layout.AdminLayout @using RegionHR.Infrastructure.Persistence +@using RegionHR.IntegrationHub.Adapters.SCB +@using RegionHR.SharedKernel.Abstractions @using Microsoft.EntityFrameworkCore @inject IDbContextFactory DbFactory +@inject IJSRuntime JS OpenHR - SCB-export SCB-export @@ -16,13 +19,35 @@ else @_totalAnstalldaAnställda @_enheterEnheter @_befattningarUnika befattningar - KlarExportstatus + Ej inlämnadExportstatus - - Rapporten omfattar alla aktiva anställda med giltigt anställningsförhållande. Data exporteras i SCB:s KLR-format. + + Ej inlämnad. Filen produceras och laddas ner lokalt i SCB:s KLR-format (fast breddformat). + Skarp inlämning till SCB sker via deras portal och kräver inloggning — det steget är avsiktligt frånkopplat här. + Lön per timme och arbetade timmar härleds via schablonen @ManadsarbetstidHeltid h/månad heltid × sysselsättningsgrad. + + + + + Ladda ner KLR-fil (@_preview.Count individer) + + + @if (_status is not null) + { + @_status + } + @if (_fel is not null) + { + @_fel + } + + Förhandsgranska data @if (_preview.Count == 0) { Inga anställda att exportera. } else @@ -41,9 +66,18 @@ else } @code { + // Schablon: heltidsmått i timmar per månad (för att härleda lön/timme och arbetade timmar + // ur månadslön + sysselsättningsgrad). Tydligt markerat i UI:t. + private const decimal ManadsarbetstidHeltid = 165m; + private List _preview = []; + private List _klrKallor = []; private int _totalAnstallda, _enheter, _befattningar; private bool _loading = true; + private bool _genererar; + private string _orgnr = "232100-0164"; + private string? _status; + private string? _fel; private string? _error; protected override async Task OnInitializedAsync() @@ -53,11 +87,20 @@ else { var idag = DateOnly.FromDateTime(DateTime.Today); var employees = await db.Employees.Include(e => e.Anstallningar).ToListAsync(); - _preview = employees.Where(e => e.AktivAnstallning(idag) != null).Select(e => - { - var a = e.AktivAnstallning(idag)!; - return new SCBRad($"{e.Fornamn} {e.Efternamn}", a.Befattningstitel ?? "—", a.Anstallningsform.ToString(), a.Sysselsattningsgrad.Value, a.Manadslon.Amount); - }).OrderBy(r => r.Namn).ToList(); + var aktiva = employees + .Select(e => new { e, a = e.AktivAnstallning(idag) }) + .Where(x => x.a != null) + .ToList(); + + _preview = aktiva.Select(x => + new SCBRad($"{x.e.Fornamn} {x.e.Efternamn}", x.a!.Befattningstitel ?? "—", + x.a.Anstallningsform.ToString(), x.a.Sysselsattningsgrad.Value, x.a.Manadslon.Amount)) + .OrderBy(r => r.Namn).ToList(); + + _klrKallor = aktiva.Select(x => + new KlrKalla((string)x.e.Personnummer, x.a!.Manadslon.Amount, x.a.Sysselsattningsgrad.Value)) + .ToList(); + _totalAnstallda = _preview.Count; _enheter = (await db.OrganizationUnits.CountAsync()); _befattningar = _preview.Select(r => r.Befattning).Distinct().Count(); @@ -66,6 +109,51 @@ else finally { _loading = false; } } + private async Task LaddaNerKlr() + { + _genererar = true; + _status = null; + _fel = null; + try + { + var input = new KLRInput + { + Period = DateTime.Today.ToString("yyyyMM"), + Organisationsnummer = _orgnr.Trim(), + Individer = _klrKallor.Select(k => + { + var timmar = Math.Round(ManadsarbetstidHeltid * (k.Sysselsattningsgrad / 100m), 1); + var lonPerTimme = timmar > 0 ? Math.Round(k.Manadslon / timmar, 2) : 0m; + return new KLRIndivid + { + Personnummer = k.Pnr, + GenomsnittslonPerTimme = lonPerTimme, + ArbetadeTimmar = timmar, + Arsarbetare = Math.Round(k.Sysselsattningsgrad / 100m, 3) + }; + }).ToList() + }; + + var res = await new SCBKLRAdapter() + .ExecuteAsync(new IntegrationRequest("GenereraKLRRapport", input)); + + if (res.Success && res.ResponseData is KLRRapportFil fil) + { + var bytes = System.Text.Encoding.UTF8.GetBytes(fil.Content); + var base64 = Convert.ToBase64String(bytes); + await JS.InvokeVoidAsync("downloadFile", fil.FileName, base64, "text/plain;charset=utf-8"); + _status = $"KLR-fil genererad: {fil.FileName} ({_klrKallor.Count} individer). Ej inlämnad till SCB."; + } + else + { + _fel = res.Message ?? "Kunde inte generera KLR-filen."; + } + } + catch (Exception ex) { _fel = $"Kunde inte generera KLR-filen: {ex.Message}"; } + finally { _genererar = false; } + } + private record SCBRad(string Namn, string Befattning, string Form, decimal Sysselsattning, decimal Lon); + private record KlrKalla(string Pnr, decimal Manadslon, decimal Sysselsattningsgrad); } diff --git a/src/Web/Components/Pages/Rekrytering/Referenskontroll.razor b/src/Web/Components/Pages/Rekrytering/Referenskontroll.razor index a5fc61d..bb2f07c 100644 --- a/src/Web/Components/Pages/Rekrytering/Referenskontroll.razor +++ b/src/Web/Components/Pages/Rekrytering/Referenskontroll.razor @@ -6,12 +6,50 @@ @inject IDbContextFactory DbFactory OpenHR - Referenskontroller -Referenskontroller +
+ Referenskontroller + Begär referenskontroll +
@if (_loading) { } else if (_error != null) { @_error } else { + @if (_flash != null) + { + @_flash + } + + @if (_visaForm) + { + +
+ Begär referenskontroll + +
+ @if (_formFel != null) + { + @_formFel + } + + + + @foreach (var v in _vakanser) + { + @v.Titel + } + + + + + + + Registrera begäran +
+ } + @_checks.CountTotalt @_checks.Count(c => c.Status == ReferenceCheckStatus.Godkand)Godkända @@ -23,17 +61,47 @@ else else { - KandidatReferensRelationStatusKommentar + KandidatVakansReferensRelationStatusKommentar @foreach (var c in _checks) { @c.KandidatNamn + @VakansTitel(c.VacancyId) @c.ReferensNamn @c.ReferensRelation @StatusText(c.Status) @(c.Kommentar ?? "—") + + @if (c.Status == ReferenceCheckStatus.Begard) + { + Genomför + } + + @if (_genomforId == c.Id) + { + + + + Genomför referenskontroll — @c.ReferensNamn (@c.ReferensRelation) + @if (_genomforFel != null) + { + @_genomforFel + } + + + Godkänd — referensen stödjer anställning + Avvikelse — referensen väcker frågetecken + +
+ Spara resultat + Avbryt +
+
+ + + } }
@@ -42,18 +110,103 @@ else @code { private List _checks = []; - private bool _loading = true; - private string? _error; + private List _vakanser = []; + private Dictionary _vakansTitlar = []; + private bool _loading = true, _sparar, _visaForm; + private string? _error, _flash; - protected override async Task OnInitializedAsync() + // Begär-form + private Guid? _formVakansId; + private string _formKandidat = "", _formReferens = "", _formRelation = ""; + private string? _formFel; + + // Genomför-form + private Guid? _genomforId; + private string _genomforKommentar = ""; + private bool _genomforGodkand = true; + private string? _genomforFel; + + protected override async Task OnInitializedAsync() => await Ladda(); + + private async Task Ladda() { - await using var db = await DbFactory.CreateDbContextAsync(); - try { _checks = await db.ReferenceChecks.OrderByDescending(c => c.SkapadVid).ToListAsync(); } + _loading = true; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + _checks = await db.ReferenceChecks.OrderByDescending(c => c.SkapadVid).ToListAsync(); + _vakanser = await db.Vacancies + .OrderByDescending(v => v.SistaAnsokningsDag) + .Select(v => new VakansVm(v.Id, v.Titel)) + .ToListAsync(); + _vakansTitlar = _vakanser.ToDictionary(v => v.Id, v => v.Titel); + _error = null; + } catch (Exception ex) { _error = $"Kunde inte hämta referenskontroller: {ex.Message}"; } finally { _loading = false; } } + private async Task Begar() + { + _formFel = null; + if (_formVakansId is null) { _formFel = "Välj en vakans."; return; } + if (string.IsNullOrWhiteSpace(_formKandidat)) { _formFel = "Ange kandidatens namn."; return; } + if (string.IsNullOrWhiteSpace(_formReferens)) { _formFel = "Ange referenspersonens namn."; return; } + + _sparar = true; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var check = ReferenceCheck.Skapa( + _formVakansId.Value, _formKandidat.Trim(), _formReferens.Trim(), + string.IsNullOrWhiteSpace(_formRelation) ? "—" : _formRelation.Trim()); + db.ReferenceChecks.Add(check); + await db.SaveChangesAsync(); + _flash = $"Referenskontroll begärd för {_formKandidat.Trim()}."; + _visaForm = false; + _formKandidat = ""; _formReferens = ""; _formRelation = ""; _formVakansId = null; + await Ladda(); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) { _formFel = ex.Message; } + catch (Exception ex) { _formFel = $"Kunde inte spara: {ex.Message}"; } + finally { _sparar = false; } + } + + private void OppnaGenomfor(ReferenceCheck c) + { + _genomforId = c.Id; + _genomforKommentar = ""; + _genomforGodkand = true; + _genomforFel = null; + } + + private async Task Genomfor(ReferenceCheck c) + { + _genomforFel = null; + if (string.IsNullOrWhiteSpace(_genomforKommentar)) { _genomforFel = "Skriv en sammanfattning av samtalet."; return; } + + _sparar = true; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var check = await db.ReferenceChecks.FirstOrDefaultAsync(x => x.Id == c.Id) + ?? throw new InvalidOperationException("Referenskontrollen hittades inte."); + check.Genomfor(_genomforKommentar.Trim(), _genomforGodkand); + await db.SaveChangesAsync(); + _flash = _genomforGodkand + ? $"Referenskontroll för {c.KandidatNamn} godkänd." + : $"Avvikelse noterad för {c.KandidatNamn}."; + _genomforId = null; + await Ladda(); + } + catch (Exception ex) { _genomforFel = $"Kunde inte spara resultat: {ex.Message}"; } + finally { _sparar = false; } + } + + private string VakansTitel(Guid vacancyId) => _vakansTitlar.GetValueOrDefault(vacancyId, "—"); + private static string StatusText(ReferenceCheckStatus s) => s switch { ReferenceCheckStatus.Begard => "Begärd", ReferenceCheckStatus.Genomford => "Genomförd", ReferenceCheckStatus.Godkand => "Godkänd", ReferenceCheckStatus.Avvikelse => "Avvikelse", _ => s.ToString() }; private static Color StatusFarg(ReferenceCheckStatus s) => s switch { ReferenceCheckStatus.Godkand => Color.Success, ReferenceCheckStatus.Begard => Color.Warning, ReferenceCheckStatus.Avvikelse => Color.Error, _ => Color.Default }; -} + private sealed record VakansVm(Guid Id, string Titel); +} diff --git a/src/Web/Components/Pages/Rekrytering/Vakanser.razor b/src/Web/Components/Pages/Rekrytering/Vakanser.razor index 5be3e0f..4f6a41b 100644 --- a/src/Web/Components/Pages/Rekrytering/Vakanser.razor +++ b/src/Web/Components/Pages/Rekrytering/Vakanser.razor @@ -9,6 +9,9 @@ OpenHR - Rekrytering
Rekrytering + Ny vakans
@if (_loading) { } @@ -20,6 +23,47 @@ else @_flashMeddelande } + @if (_visaSkapa) + { + +
+ Skapa ny vakans + +
+ @if (_skapaFel is not null) + { + @_skapaFel + } + + + + @foreach (var e in _enheter) + { + @e.Namn + } + + + + + + + @foreach (var f in Enum.GetValues()) + { + @FormNamn(f) + } + + + + + + +
+ @(_sparar ? "Skapar..." : "Skapa vakans") + Avbryt +
+
+ } + @_vakanser.CountVakanser totalt @_vakanser.Count(v => v.Status == VacancyStatus.Publicerad)Publicerade @@ -47,6 +91,60 @@ else { + @* Vakansåtgärder — publicera, registrera ansökan, stäng *@ +
+ @if (v.Status == VacancyStatus.Utkast) + { + Publicera vakans + } + @if (v.Status == VacancyStatus.Publicerad) + { + Registrera ansökan + Stäng vakans + } + @StatusText(v.Status) +
+ + @if (_vakansFel is not null && _oppenVakansId == v.Id && _vakansLage == VakansLage.Ingen) + { + @_vakansFel + } + + @if (_vakansLage != VakansLage.Ingen && _oppenVakansId == v.Id) + { + + @if (_vakansFel is not null) + { + @_vakansFel + } + @switch (_vakansLage) + { + case VakansLage.Publicera: + Publicera vakans +
+ + +
+
+ Bekräfta publicering + Avbryt +
+ break; + case VakansLage.Ansokan: + Registrera inkommen ansökan + + + + +
+ Registrera ansökan + Avbryt +
+ break; + } +
+ } + @if (v.Ansokningar.Count == 0) { Inga ansökningar än. @@ -83,6 +181,10 @@ else { Avslå } + @if (a.Status == ApplicationStatus.Avslagen) + { + Lägg i talangpool + } @@ -124,6 +226,17 @@ else Avbryt break; + case Lage.Talangpool: + Lägg @a.Namn i talangpoolen + + + + +
+ Spara i talangpool + Avbryt +
+ break; case Lage.Tillsatt: Tillsätt tjänsten & skapa anställning @@ -178,18 +291,43 @@ else } @code { - private enum Lage { Ingen, Bedom, Intervju, Avsla, Tillsatt } + private enum Lage { Ingen, Bedom, Intervju, Avsla, Tillsatt, Talangpool } + private enum VakansLage { Ingen, Publicera, Ansokan } private List _vakanser = []; private Guid? _oppenVakansId; private Guid? _aktivAnsokanId; private Lage _lage = Lage.Ingen; + private VakansLage _vakansLage = VakansLage.Ingen; + private string? _vakansFel; private bool _loading = true; private string? _error; private string? _flashMeddelande; private string? _felmeddelande; private bool _sparar; + // Ny vakans + private bool _visaSkapa; + private string? _skapaFel; + private List _enheter = []; + private OrganizationId? _nyEnhetId; + private string _nyTitel = ""; + private string _nyBeskrivning = ""; + private EmploymentType _nyForm = EmploymentType.Tillsvidare; + private DateTime? _nySistaDag = DateTime.Today.AddDays(30); + private decimal? _nyLonMin; + private decimal? _nyLonMax; + + // Publicera / registrera ansökan + private bool _pubExternt = true; + private bool _pubPlatsbanken; + private string _ansNamn = ""; + private string _ansEpost = ""; + + // Talangpool + private string _poolKompetens = ""; + private string _poolAnteckningar = ""; + // Bedöm / intervju / avslå private int _poang = 70; private string _kommentar = ""; @@ -220,7 +358,12 @@ else _ => "" }; - protected override async Task OnInitializedAsync() => await LaddaAsync(); + protected override async Task OnInitializedAsync() + { + try { _enheter = await Service.HamtaEnheterAsync(); } + catch { /* enhetslistan är icke-kritisk för visningen; skapa-formuläret hanterar tom lista */ } + await LaddaAsync(); + } private async Task LaddaAsync() { @@ -234,6 +377,25 @@ else { _oppenVakansId = _oppenVakansId == vakansId ? null : vakansId; Avbryt(); + AvbrytVakansLage(); + } + + private void OppnaVakansLage(VakansOversikt v, VakansLage lage) + { + _oppenVakansId = v.Id; + Avbryt(); + _vakansLage = lage; + _vakansFel = null; + _pubExternt = true; + _pubPlatsbanken = false; + _ansNamn = ""; + _ansEpost = ""; + } + + private void AvbrytVakansLage() + { + _vakansLage = VakansLage.Ingen; + _vakansFel = null; } private void Avbryt() @@ -246,6 +408,7 @@ else private void OppnaBedom(AnsokanOversikt a) { StartAtgard(a, Lage.Bedom); _poang = a.Poang ?? 70; _kommentar = ""; } private void OppnaIntervju(AnsokanOversikt a) { StartAtgard(a, Lage.Intervju); _intervjuDatum = DateTime.Today.AddDays(7); } private void OppnaAvsla(AnsokanOversikt a) { StartAtgard(a, Lage.Avsla); _avslagAnledning = ""; } + private void OppnaTalangpool(AnsokanOversikt a) { StartAtgard(a, Lage.Talangpool); _poolKompetens = ""; _poolAnteckningar = ""; } private void OppnaTillsatt(VakansOversikt v, AnsokanOversikt a) { @@ -265,7 +428,82 @@ else _tBefattning = v.Titel; } - private void StartAtgard(AnsokanOversikt a, Lage lage) { _aktivAnsokanId = a.Id; _lage = lage; _felmeddelande = null; } + private void StartAtgard(AnsokanOversikt a, Lage lage) { AvbrytVakansLage(); _aktivAnsokanId = a.Id; _lage = lage; _felmeddelande = null; } + + private async Task SkapaVakans() + { + _skapaFel = null; + if (_nyEnhetId is null) { _skapaFel = "Välj en enhet."; return; } + if (string.IsNullOrWhiteSpace(_nyTitel)) { _skapaFel = "Titel är obligatoriskt."; return; } + if (_nySistaDag is null) { _skapaFel = "Ange sista ansökningsdag."; return; } + if (_nyLonMin is { } lmin && _nyLonMax is { } lmax && lmin > lmax) { _skapaFel = "Lägsta lön kan inte vara högre än högsta lön."; return; } + + _sparar = true; + try + { + await Service.SkapaVakansAsync( + _nyEnhetId.Value, _nyTitel, _nyBeskrivning, _nyForm, + DateOnly.FromDateTime(_nySistaDag.Value), _nyLonMin, _nyLonMax); + _flashMeddelande = $"Vakans \"{_nyTitel}\" skapad (status Utkast). Publicera den för att ta emot ansökningar."; + _visaSkapa = false; + _nyTitel = ""; _nyBeskrivning = ""; _nyLonMin = null; _nyLonMax = null; _nyEnhetId = null; + await LaddaAsync(); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) { _skapaFel = ex.Message; } + catch (Exception ex) { _skapaFel = $"Kunde inte skapa vakans: {ex.Message}"; } + finally { _sparar = false; } + } + + private async Task Publicera(VakansOversikt v) + { + _sparar = true; _vakansFel = null; + try + { + await Service.PubliceraVakansAsync(v.Id, _pubExternt, _pubPlatsbanken); + _flashMeddelande = $"Vakans \"{v.Titel}\" publicerad."; + AvbrytVakansLage(); + await LaddaAsync(); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) { _vakansFel = ex.Message; } + catch (Exception ex) { _vakansFel = $"Kunde inte publicera: {ex.Message}"; } + finally { _sparar = false; } + } + + private async Task RegistreraAnsokan(VakansOversikt v) + { + if (string.IsNullOrWhiteSpace(_ansNamn)) { _vakansFel = "Ange sökandens namn."; return; } + if (string.IsNullOrWhiteSpace(_ansEpost)) { _vakansFel = "Ange sökandens e-post."; return; } + _sparar = true; _vakansFel = null; + try + { + await Service.TaEmotAnsokanAsync(v.Id, _ansNamn, _ansEpost); + _flashMeddelande = $"Ansökan från {_ansNamn} registrerad på \"{v.Titel}\"."; + AvbrytVakansLage(); + await LaddaAsync(); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) { _vakansFel = ex.Message; } + catch (Exception ex) { _vakansFel = $"Kunde inte registrera ansökan: {ex.Message}"; } + finally { _sparar = false; } + } + + private async Task StangVakans(VakansOversikt v) + { + _sparar = true; _vakansFel = null; + try + { + await Service.StangVakansAsync(v.Id); + _flashMeddelande = $"Vakans \"{v.Titel}\" stängd."; + AvbrytVakansLage(); + await LaddaAsync(); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) { _vakansFel = ex.Message; } + catch (Exception ex) { _vakansFel = $"Kunde inte stänga: {ex.Message}"; } + finally { _sparar = false; } + } + + private async Task LaggITalangpool(VakansOversikt v, AnsokanOversikt a) => + await Kor(() => Service.FlyttaTillTalangpoolAsync(v.Id, a.Id, _poolKompetens, _poolAnteckningar), + $"{a.Namn} tillagd i talangpoolen."); private static decimal MittLon(VakansOversikt v) => (v.LonMin, v.LonMax) switch { diff --git a/src/Web/Components/Pages/Schema/Automatik.razor b/src/Web/Components/Pages/Schema/Automatik.razor index 308917a..0f3b369 100644 --- a/src/Web/Components/Pages/Schema/Automatik.razor +++ b/src/Web/Components/Pages/Schema/Automatik.razor @@ -13,7 +13,10 @@
Behovsstyrd schemaautomatik - Till schemaöversikt +
+ Bemanningsmallar + Till schemaöversikt +
Auto-generera ett schema ur en bemanningsmall som täcker behovet per pass och kompetens, @@ -75,7 +78,9 @@ @if (_mallarForEnhet.Count == 0 && !_loading) { - Ingen bemanningsmall finns för vald enhet. Skapa en mall med bemanningsbehov per veckodag och pass först. + Ingen bemanningsmall finns för vald enhet. + Skapa en bemanningsmall + med behov per veckodag och pass först. }
diff --git a/src/Web/Components/Pages/Schema/Bemanningsmallar.razor b/src/Web/Components/Pages/Schema/Bemanningsmallar.razor new file mode 100644 index 0000000..01a1dcd --- /dev/null +++ b/src/Web/Components/Pages/Schema/Bemanningsmallar.razor @@ -0,0 +1,442 @@ +@page "/schema/bemanningsmallar" +@layout RegionHR.Web.Components.Layout.AdminLayout +@using RegionHR.Infrastructure.Persistence +@using RegionHR.Scheduling.Domain +@using RegionHR.Core.Domain +@using RegionHR.SharedKernel.Domain +@using Microsoft.EntityFrameworkCore +@inject IDbContextFactory DbFactory +@inject ISnackbar Snackbar + +OpenHR - Bemanningsmallar + +
+ Bemanningsmallar + Till behovsstyrd automatik +
+ + En bemanningsmall beskriver det återkommande behovet per veckodag och pass (min- och optimalbemanning + samt eventuella kompetenskrav). Mallen används av den behovsstyrda schemaautomatiken för att auto-generera + ett lagenligt schema. + + +@if (_loading) +{ + +} +else if (_error != null) +{ + @_error +} +else +{ + @* ── Skapa ny mall ─────────────────────────────────────────────── *@ + + Ny bemanningsmall + + + + @foreach (var enhet in _enheter) + { + @enhet.Namn + } + + + + + + + + + + + + + + + + Lägg till bemanningsrader + + + + @foreach (var d in _veckodagarOrder) + { + @VeckodagNamn(d) + } + + + + + Dag + Kväll + Natt + Jour + + + + + + + + + + + + + + + + + + + + + + Lägg till rad(er) + Fyll dygnet-runt-mall + + + + @if (_draftRader.Count > 0) + { + Rader i mallen (@_draftRader.Count) + + + VeckodagPassTidRastTimmarMinOptimalKompetens + + + @for (var i = 0; i < _draftRader.Count; i++) + { + var index = i; + var r = _draftRader[index]; + + @VeckodagNamn(r.Veckodag) + @r.PassTyp + @r.Start.ToString("HH:mm")–@r.Slut.ToString("HH:mm") + @r.Rast.TotalMinutes min + @r.PlaneradeTimmar.ToString("0.#") h + @r.MinAntal + @r.OptimalAntal + @(r.KravdaKompetenser.Count == 0 ? "—" : string.Join(", ", r.KravdaKompetenser)) + + + } + + + } + + + @if (_sparar) + { + + } + Spara bemanningsmall + + + + @* ── Befintliga mallar ─────────────────────────────────────────── *@ + Befintliga mallar + @if (_mallar.Count == 0) + { + Inga bemanningsmallar skapade ännu. + } + else + { + @foreach (var mall in _mallar) + { + +
+
+ @mall.Namn + + @_enhetNamn.GetValueOrDefault(mall.EnhetId.Value, "Okänd enhet") • + @mall.Rader.Count rader • + giltig @mall.Giltighet.Start.ToString("yyyy-MM-dd")@(mall.Giltighet.End is { } slut ? $" – {slut:yyyy-MM-dd}" : " och tillsvidare") + +
+ Ta bort +
+ @if (mall.Rader.Count > 0) + { + + + VeckodagPassTidTimmarMinOptimalKompetens + + + @foreach (var r in mall.Rader.OrderBy(x => ((int)x.Veckodag + 6) % 7).ThenBy(x => x.Start)) + { + + @VeckodagNamn(r.Veckodag) + @r.PassTyp + @r.Start.ToString("HH:mm")–@r.Slut.ToString("HH:mm") + @r.PlaneradeTimmar.ToString("0.#") h + @r.MinAntal + @r.OptimalAntal + @(r.KravdaKompetenser.Count == 0 ? "—" : string.Join(", ", r.KravdaKompetenser)) + + } + + + } +
+ } + } +} + +@code { + private static readonly DayOfWeek[] _veckodagarOrder = + [ + DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, + DayOfWeek.Friday, DayOfWeek.Saturday, DayOfWeek.Sunday + ]; + + private List _enheter = []; + private Dictionary _enhetNamn = []; + private List _mallar = []; + private bool _loading = true, _sparar; + private string? _error; + + // Ny mall + private Guid _valdEnhet; + private string _nyNamn = ""; + private DateTime? _giltigFran = DateTime.Today; + private DateTime? _giltigTom; + + // Rad-formulär + private IReadOnlyCollection _valdaVeckodagar = new HashSet(); + private ShiftType _addPassTyp = ShiftType.Dag; + private TimeSpan? _addStart = new(7, 0, 0); + private TimeSpan? _addSlut = new(16, 0, 0); + private int _addRast = 60; + private int _addMin = 1; + private int _addOptimal = 2; + private string _addKompetenser = ""; + + private readonly List _draftRader = []; + + private bool KanSpara => + _valdEnhet != Guid.Empty && + !string.IsNullOrWhiteSpace(_nyNamn) && + _giltigFran is not null && + _draftRader.Count > 0; + + protected override async Task OnInitializedAsync() + { + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + _enheter = await db.OrganizationUnits.OrderBy(e => e.Namn).ToListAsync(); + _enhetNamn = _enheter.ToDictionary(e => e.Id.Value, e => e.Namn); + if (_enheter.Count > 0) _valdEnhet = _enheter[0].Id.Value; + + _mallar = await db.StaffingTemplates.ToListAsync(); + } + catch (Exception ex) { _error = $"Kunde inte ladda data: {ex.Message}"; } + finally { _loading = false; } + } + + private void OnAddPassTypChanged() + { + (_addStart, _addSlut, _addRast) = _addPassTyp switch + { + ShiftType.Dag => (new TimeSpan(7, 0, 0), new TimeSpan(16, 0, 0), 60), + ShiftType.Kvall => (new TimeSpan(15, 0, 0), new TimeSpan(22, 0, 0), 30), + ShiftType.Natt => (new TimeSpan(21, 0, 0), new TimeSpan(7, 0, 0), 45), + ShiftType.Jour => (new TimeSpan(8, 0, 0), new TimeSpan(20, 0, 0), 60), + _ => (_addStart, _addSlut, _addRast) + }; + } + + private void LaggTillRader() + { + var dagar = _valdaVeckodagar?.ToList() ?? []; + if (dagar.Count == 0) + { + Snackbar.Add("Välj minst en veckodag.", Severity.Warning); + return; + } + if (_addStart is null || _addSlut is null) + { + Snackbar.Add("Ange start- och sluttid.", Severity.Warning); + return; + } + if (_addMin < 0 || _addOptimal < _addMin) + { + Snackbar.Add("Optimalt antal måste vara minst lika stort som minimiantalet.", Severity.Error); + return; + } + + var kompetenser = _addKompetenser + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + + foreach (var dag in dagar) + { + _draftRader.Add(new StaffingRequirementLine + { + Veckodag = dag, + PassTyp = _addPassTyp, + Start = TimeOnly.FromTimeSpan(_addStart.Value), + Slut = TimeOnly.FromTimeSpan(_addSlut.Value), + Rast = TimeSpan.FromMinutes(_addRast), + MinAntal = _addMin, + OptimalAntal = _addOptimal, + KravdaKompetenser = [.. kompetenser] + }); + } + + Snackbar.Add($"{dagar.Count} rad(er) tillagda.", Severity.Success); + _valdaVeckodagar = new HashSet(); + _addKompetenser = ""; + } + + /// Fyller utkastet med en typisk dygnet-runt-mall: dag, kväll och natt alla 7 dagar. + private void SnabbfyllDygnetRunt() + { + _draftRader.Clear(); + foreach (var dag in _veckodagarOrder) + { + _draftRader.Add(NyRad(dag, ShiftType.Dag, new TimeOnly(7, 0), new TimeOnly(15, 30), 30, 1, 2)); + _draftRader.Add(NyRad(dag, ShiftType.Kvall, new TimeOnly(14, 0), new TimeOnly(22, 0), 30, 1, 1)); + _draftRader.Add(NyRad(dag, ShiftType.Natt, new TimeOnly(21, 30), new TimeOnly(7, 30), 60, 1, 1)); + } + Snackbar.Add("Dygnet-runt-mall ifylld (21 rader). Justera vid behov innan du sparar.", Severity.Info); + } + + private static StaffingRequirementLine NyRad( + DayOfWeek dag, ShiftType typ, TimeOnly start, TimeOnly slut, int rastMin, int min, int optimal) => new() + { + Veckodag = dag, + PassTyp = typ, + Start = start, + Slut = slut, + Rast = TimeSpan.FromMinutes(rastMin), + MinAntal = min, + OptimalAntal = optimal, + KravdaKompetenser = [] + }; + + private void TaBortDraftRad(int index) + { + if (index >= 0 && index < _draftRader.Count) + _draftRader.RemoveAt(index); + } + + private async Task SparaMall() + { + if (!KanSpara) return; + _sparar = true; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var enhet = OrganizationId.From(_valdEnhet); + var giltigFran = DateOnly.FromDateTime(_giltigFran!.Value); + + StaffingTemplate mall; + if (_giltigTom is { } tom) + { + var giltigTom = DateOnly.FromDateTime(tom); + if (giltigTom < giltigFran) + { + Snackbar.Add("Giltig till kan inte vara före giltig från.", Severity.Error); + _sparar = false; + return; + } + mall = StaffingTemplate.Skapa(enhet, _nyNamn.Trim(), giltigFran, giltigTom); + } + else + { + mall = StaffingTemplate.Skapa(enhet, _nyNamn.Trim(), giltigFran); + } + + foreach (var rad in _draftRader) + mall.LaggTillRad(rad); + + db.StaffingTemplates.Add(mall); + await db.SaveChangesAsync(); + + Snackbar.Add($"Bemanningsmall \"{mall.Namn}\" sparad med {mall.Rader.Count} rader.", Severity.Success); + + // Återställ formuläret och ladda om listan. + _nyNamn = ""; + _giltigTom = null; + _draftRader.Clear(); + _mallar = await db.StaffingTemplates.ToListAsync(); + } + catch (Exception ex) + { + Snackbar.Add($"Kunde inte spara mallen: {ex.Message}", Severity.Error); + } + finally + { + _sparar = false; + } + } + + private async Task TaBortMall(StaffingTemplateId id) + { + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var mall = await db.StaffingTemplates.FirstOrDefaultAsync(m => m.Id == id); + if (mall is not null) + { + db.StaffingTemplates.Remove(mall); + await db.SaveChangesAsync(); + } + _mallar = await db.StaffingTemplates.ToListAsync(); + Snackbar.Add("Bemanningsmall borttagen.", Severity.Info); + } + catch (Exception ex) + { + Snackbar.Add($"Kunde inte ta bort mallen: {ex.Message}", Severity.Error); + } + } + + private static string VeckodagNamn(DayOfWeek d) => d switch + { + DayOfWeek.Monday => "Måndag", + DayOfWeek.Tuesday => "Tisdag", + DayOfWeek.Wednesday => "Onsdag", + DayOfWeek.Thursday => "Torsdag", + DayOfWeek.Friday => "Fredag", + DayOfWeek.Saturday => "Lördag", + DayOfWeek.Sunday => "Söndag", + _ => d.ToString() + }; + + private static string VeckodagKort(DayOfWeek d) => d switch + { + DayOfWeek.Monday => "Mån", + DayOfWeek.Tuesday => "Tis", + DayOfWeek.Wednesday => "Ons", + DayOfWeek.Thursday => "Tor", + DayOfWeek.Friday => "Fre", + DayOfWeek.Saturday => "Lör", + DayOfWeek.Sunday => "Sön", + _ => d.ToString() + }; + + private static Color PassColor(ShiftType typ) => typ switch + { + ShiftType.Dag => Color.Success, + ShiftType.Kvall => Color.Warning, + ShiftType.Natt => Color.Info, + ShiftType.Jour => Color.Secondary, + _ => Color.Default + }; +} diff --git a/src/Web/Components/Pages/Schema/Optimering.razor b/src/Web/Components/Pages/Schema/Optimering.razor index ae5f50a..7c6d417 100644 --- a/src/Web/Components/Pages/Schema/Optimering.razor +++ b/src/Web/Components/Pages/Schema/Optimering.razor @@ -13,8 +13,15 @@
Schemaoptimering + Bemanningsmallar
+ + Constraint-lösaren genererar ett bemanningsförslag för perioden (dag- och kvällspass) som + respekterar Arbetstidslagen. Granska förslaget nedan och spara det som ett schemautkast. + + @@ -60,8 +67,8 @@ - @_resultat.TotalOvertidKostnad.ToString("N0") kr - Overtidskostnad + @_obemannade + Obemannade behov @@ -74,16 +81,57 @@ - - @if (_resultat.ATLKompliant) + + @if (_obemannade == 0) { - Optimeringen genererade @_resultat.GenereradePass pass som uppfyller alla ATL-krav. + Optimeringen genererade @_resultat.GenereradePass pass som täcker hela behovet och uppfyller ATL. } else { - Varning: Inte alla pass kunde bemannas. Kontrollera bemanning manuellt. + @_obemannade behov kunde inte bemannas (personalbrist eller ATL-begränsning). Inget pass har schemalagts olagligt. } + + @if (_solution is not null && _solution.Tilldelningar.Count > 0) + { +
+ Genererade pass — förhandsvy +
+ @if (_sparatSchemaId is not null) + { + Öppna i schemaredigeraren + } + + @if (_sparar) + { + + } + @(_sparatSchemaId is null ? "Spara som schema (utkast)" : "Sparat") + +
+
+ + + + Datum + Dag + Pass + Tid + Anställd + + + @context.Datum.ToString("yyyy-MM-dd") + @_sv.DateTimeFormat.GetAbbreviatedDayName(context.Datum.DayOfWeek) + @context.PassTyp + @context.Start.ToString("HH:mm")–@context.Slut.ToString("HH:mm") + @context.Namn + + + } } Tidigare korningar @@ -116,14 +164,27 @@ @code { + private static readonly System.Globalization.CultureInfo _sv = System.Globalization.CultureInfo.GetCultureInfo("sv-SE"); + private List _enheter = []; private List _tidigareKorningar = []; private Guid _valdEnhet; private DateTime? _periodFran = DateTime.Today; private DateTime? _periodTill = DateTime.Today.AddDays(7); private bool _optimerar; + private bool _sparar; private SchedulingRun? _resultat; + // Det senaste genererade förslaget (behålls så att det kan sparas som schema). + private ScheduleSolution? _solution; + private int _obemannade; + private Guid _optimeradEnhet; + private DateOnly _optimeradFran; + private DateOnly _optimeradTill; + private Guid? _sparatSchemaId; + private Dictionary _namnMap = []; + private List _forhandsPass = []; + protected override async Task OnInitializedAsync() { await using var db = await DbFactory.CreateDbContextAsync(); @@ -140,8 +201,15 @@ { await using var db = await DbFactory.CreateDbContextAsync(); if (_valdEnhet == Guid.Empty || _periodFran is null || _periodTill is null) return; + if (_periodTill < _periodFran) + { + Snackbar.Add("Slutdatum kan inte vara före startdatum.", Severity.Error); + return; + } _optimerar = true; + _sparatSchemaId = null; + _solution = null; StateHasChanged(); try @@ -156,8 +224,13 @@ var solver = new ConstraintScheduleSolver(); var employees = await db.Employees.Include(e => e.Anstallningar).ToListAsync(); var idag = DateOnly.FromDateTime(DateTime.Today); - var personal = employees + var enhetsAnstallda = employees .Where(e => e.Anstallningar.Any(a => a.EnhetId == OrganizationId.From(_valdEnhet) && a.Giltighetsperiod.IsActiveOn(idag))) + .ToList(); + + _namnMap = enhetsAnstallda.ToDictionary(e => e.Id.Value, e => $"{e.Fornamn} {e.Efternamn}"); + + var personal = enhetsAnstallda .Select(e => new PersonalInfo { AnstallId = e.Id, @@ -188,6 +261,19 @@ await db.SchedulingRuns.AddAsync(run); await db.SaveChangesAsync(); + // Behåll förslaget för att kunna spara det som schema, och bygg förhandsvyn. + _solution = solution; + _obemannade = solution.ObemannadeBehov.Count; + _optimeradEnhet = _valdEnhet; + _optimeradFran = fran; + _optimeradTill = till; + _forhandsPass = solution.Tilldelningar + .OrderBy(a => a.Datum).ThenBy(a => a.Start) + .Select(a => new ForhandsPass( + a.Datum, a.PassTyp, a.Start, a.Slut, + _namnMap.GetValueOrDefault(a.AnstallId.Value, "Okänd"))) + .ToList(); + _resultat = run; _tidigareKorningar.Insert(0, run); Snackbar.Add($"Optimering klar! {run.GenereradePass} pass genererade.", Severity.Success); @@ -202,6 +288,39 @@ } } + private async Task SparaSchema() + { + if (_solution is null || _solution.Tilldelningar.Count == 0 || _sparatSchemaId is not null) return; + + _sparar = true; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var enhet = OrganizationId.From(_optimeradEnhet); + var schema = Schedule.SkapaPeriodschema( + enhet, $"Optimering {_optimeradFran:yyyy-MM-dd}–{_optimeradTill:yyyy-MM-dd}", _optimeradFran, _optimeradTill); + + foreach (var a in _solution.Tilldelningar) + { + schema.LaggTillPass(a.AnstallId, a.Datum, a.PassTyp, a.Start, a.Slut, a.Rast); + } + + db.Schedules.Add(schema); + await db.SaveChangesAsync(); + + _sparatSchemaId = schema.Id.Value; + Snackbar.Add($"Schema sparat som utkast med {_solution.Tilldelningar.Count} pass.", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"Kunde inte spara schema: {ex.Message}", Severity.Error); + } + finally + { + _sparar = false; + } + } + private static Color RunStatusColor(string status) => status switch { "Complete" => Color.Success, @@ -209,5 +328,6 @@ "Failed" => Color.Error, _ => Color.Default }; -} + private readonly record struct ForhandsPass(DateOnly Datum, ShiftType PassTyp, TimeOnly Start, TimeOnly Slut, string Namn); +} diff --git a/src/Web/Components/Pages/Schema/Prognos.razor b/src/Web/Components/Pages/Schema/Prognos.razor index acd897b..4b9eb32 100644 --- a/src/Web/Components/Pages/Schema/Prognos.razor +++ b/src/Web/Components/Pages/Schema/Prognos.razor @@ -88,11 +88,15 @@ else } - Prognoser + Behovsprognos kommande 14 dagar + + Projicerat ur enhetens bemanningsmonster (snittbehov × sasongsfaktor) samt aktiva handelser. + Beraknat antal = personal per dag; timmar antar 8h-pass. + @if (_forecasts.Count == 0) { - Inga prognoser beraknade for denna enhet annu. Kor optimering for att generera prognoser. + Inga bemanningsmonster inlagda for denna enhet — behovet kan darfor inte projiceras. } else @@ -123,10 +127,12 @@ else private List _enheter = []; private List _patterns = []; private List _events = []; - private List _forecasts = []; + private List _forecasts = []; private Guid _valdEnhet; private bool _loading = true; + private sealed record PrognosRad(DateOnly Datum, int BeraknatAntal, decimal BeraknadeTidmmar, decimal Konfidensgrad); + protected override async Task OnInitializedAsync() { await using var db = await DbFactory.CreateDbContextAsync(); @@ -157,11 +163,48 @@ else .OrderBy(e => e.DatumFran) .ToListAsync(); - _forecasts = await db.DemandForecasts - .Where(f => f.EnhetId == OrganizationId.From(_valdEnhet)) - .OrderBy(f => f.Datum) - .Take(30) - .ToListAsync(); + // Projicera behovet ur mönster + händelser i stället för att läsa en tom prognostabell. + _forecasts = BeraknaPrognos(_patterns, _events); + } + + /// + /// Deterministisk behovsprognos för kommande 14 dagar: för varje dag tas veckodagens + /// snittbelastning × säsongsfaktor och multipliceras med påverkansgraden för eventuella + /// aktiva händelser. Detta är en härledd projektion ur befintliga mönster — ingen ML-modell. + /// + private static List BeraknaPrognos(List patterns, List events) + { + var result = new List(); + if (patterns.Count == 0) return result; + + var today = DateOnly.FromDateTime(DateTime.Today); + for (var i = 0; i < 14; i++) + { + var d = today.AddDays(i); + var pattern = patterns.FirstOrDefault(p => p.Veckodag == (int)d.DayOfWeek); + if (pattern is null) continue; + + var eventFaktor = 1m; + var harEvent = false; + foreach (var e in events) + { + if (d >= e.DatumFran && d <= e.DatumTill) + { + eventFaktor *= e.PaverkanGrad; + harEvent = true; + } + } + + var justerat = pattern.GenomsnittligBelastning * pattern.SasongsVariation * eventFaktor; + var antal = Math.Max(0, (int)Math.Round(justerat, MidpointRounding.AwayFromZero)); + var timmar = antal * 8m; + + // Konfidensen sjunker med prognoshorisonten och när en händelse gör dagen mer osäker. + var konfidens = Math.Max(50m, 92m - i * 2m) - (harEvent ? 12m : 0m); + + result.Add(new PrognosRad(d, antal, timmar, konfidens)); + } + return result; } private static string VeckodagNamn(int dag) => dag switch diff --git a/src/Web/Components/Pages/Trust.razor b/src/Web/Components/Pages/Trust.razor index c1c5f6c..508e032 100644 --- a/src/Web/Components/Pages/Trust.razor +++ b/src/Web/Components/Pages/Trust.razor @@ -232,7 +232,7 @@ - +
@@ -250,7 +250,7 @@ - +
@@ -268,7 +268,7 @@ - +
@@ -286,7 +286,7 @@ - +
diff --git a/src/Web/Services/AnstallningService.cs b/src/Web/Services/AnstallningService.cs index d415019..ebee8d8 100644 --- a/src/Web/Services/AnstallningService.cs +++ b/src/Web/Services/AnstallningService.cs @@ -118,6 +118,25 @@ public async Task> HamtaEnheterAsync(CancellationToken ct = defau return enheter.Select(o => new EnhetVal(o.Id, o.Namn, o.Kostnadsstalle)).ToList(); } + /// + /// Skapar en ny organisationsenhet. Anropar domänfabriken direkt och persisterar via DbContext. + /// Sätts blir enheten en underenhet till den valda enheten. + /// + public async Task SkapaEnhetAsync( + string namn, OrganizationUnitType typ, string kostnadsstalle, + OrganizationId? overordnadEnhetId = null, string? cfarKod = null, CancellationToken ct = default) + { + await using var db = await _dbFactory.CreateDbContextAsync(ct); + var enhet = OrganizationUnit.Skapa( + namn.Trim(), typ, kostnadsstalle.Trim(), + DateOnly.FromDateTime(DateTime.Today), + overordnadEnhetId, + string.IsNullOrWhiteSpace(cfarKod) ? null : cfarKod.Trim()); + await db.OrganizationUnits.AddAsync(enhet, ct); + await db.SaveChangesAsync(ct); + return enhet.Id; + } + /// Kollektivavtal att koppla en anställning till (DB-backade avtal). public async Task> HamtaAvtalAsync(CancellationToken ct = default) { diff --git a/src/Web/Services/RekryteringService.cs b/src/Web/Services/RekryteringService.cs index 8a042f9..5f1e134 100644 --- a/src/Web/Services/RekryteringService.cs +++ b/src/Web/Services/RekryteringService.cs @@ -21,6 +21,101 @@ public sealed class RekryteringService public RekryteringService(IDbContextFactory dbFactory) => _dbFactory = dbFactory; + /// Hämtar organisationsenheterna (id + namn) för vakansformulärets enhetsval. + public async Task> HamtaEnheterAsync(CancellationToken ct = default) + { + await using var db = await _dbFactory.CreateDbContextAsync(ct); + return (await db.OrganizationUnits.ToListAsync(ct)) + .OrderBy(e => e.Namn) + .Select(e => new EnhetVal(e.Id, e.Namn, e.Kostnadsstalle)) + .ToList(); + } + + /// + /// Skapar en ny vakans (status Utkast) via Recruitment-domänen och persisterar den. + /// Samma domänväg som REST-API:t använder — men anropad direkt utan HTTP. + /// + public async Task SkapaVakansAsync( + OrganizationId enhetId, string titel, string beskrivning, + EmploymentType anstallningsform, DateOnly sistadag, + decimal? lonMin, decimal? lonMax, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(titel)) + throw new ArgumentException("Titel måste anges.", nameof(titel)); + + await using var db = await _dbFactory.CreateDbContextAsync(ct); + var vakans = Vacancy.Skapa(enhetId, titel.Trim(), (beskrivning ?? "").Trim(), anstallningsform, sistadag); + vakans.SattLonespann( + lonMin is { } min ? Money.SEK(min) : null, + lonMax is { } max ? Money.SEK(max) : null); + + db.Vacancies.Add(vakans); + await db.SaveChangesAsync(ct); + return vakans.Id; + } + + /// Publicerar en vakans internt/externt/på Platsbanken (Utkast → Publicerad). + public async Task PubliceraVakansAsync(Guid vakansId, bool externt, bool platsbanken, CancellationToken ct = default) + { + await using var db = await _dbFactory.CreateDbContextAsync(ct); + var vakans = await db.Vacancies.FirstOrDefaultAsync(v => v.Id == vakansId, ct) + ?? throw new InvalidOperationException($"Vakans {vakansId} hittades inte."); + vakans.Publicera(externt, platsbanken); + await db.SaveChangesAsync(ct); + } + + /// Stänger en publicerad vakans så att inga fler ansökningar kan tas emot. + public async Task StangVakansAsync(Guid vakansId, CancellationToken ct = default) + { + await using var db = await _dbFactory.CreateDbContextAsync(ct); + var vakans = await db.Vacancies.FirstOrDefaultAsync(v => v.Id == vakansId, ct) + ?? throw new InvalidOperationException($"Vakans {vakansId} hittades inte."); + vakans.Stang(); + await db.SaveChangesAsync(ct); + } + + /// Registrerar en inkommen ansökan på en publicerad vakans. + public async Task TaEmotAnsokanAsync(Guid vakansId, string namn, string epost, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(namn)) + throw new ArgumentException("Sökandens namn måste anges.", nameof(namn)); + if (string.IsNullOrWhiteSpace(epost)) + throw new ArgumentException("Sökandens e-post måste anges.", nameof(epost)); + + await using var db = await _dbFactory.CreateDbContextAsync(ct); + var vakans = await db.Vacancies + .Include(v => v.Ansokngar) + .FirstOrDefaultAsync(v => v.Id == vakansId, ct) + ?? throw new InvalidOperationException($"Vakans {vakansId} hittades inte."); + vakans.TaEmotAnsokan(namn.Trim(), epost.Trim()); + await db.SaveChangesAsync(ct); + } + + /// + /// Flyttar en sökande till talangpoolen för framtida rekryteringar. Kopplar posten + /// till ursprungsansökan så spårbarheten bevaras. Typiskt använt för avslagna kandidater + /// som ändå är intressanta. + /// + public async Task FlyttaTillTalangpoolAsync( + Guid vakansId, Guid ansokanId, string? kompetensOmrade, string? anteckningar, CancellationToken ct = default) + { + await using var db = await _dbFactory.CreateDbContextAsync(ct); + var (_, ansokan) = await LaddaAsync(db, vakansId, ansokanId, ct); + + var redanIPool = await db.TalentPoolEntries.AnyAsync(t => t.UrsprungsAnsokanId == ansokanId, ct); + if (redanIPool) + throw new InvalidOperationException("Kandidaten ligger redan i talangpoolen."); + + var post = TalentPoolEntry.Skapa( + ansokan.Namn, ansokan.Epost, + string.IsNullOrWhiteSpace(kompetensOmrade) ? null : kompetensOmrade.Trim(), + string.IsNullOrWhiteSpace(anteckningar) ? null : anteckningar.Trim(), + ursprungsAnsokanId: ansokanId); + + db.TalentPoolEntries.Add(post); + await db.SaveChangesAsync(ct); + } + /// Hämtar vakanser med sina ansökningar för pipeline/tillsättnings-vyn. public async Task> HamtaVakanserAsync(CancellationToken ct = default) { diff --git a/tests/Positions.Tests/PositionTests.cs b/tests/Positions.Tests/PositionTests.cs index 32d2dca..c73112e 100644 --- a/tests/Positions.Tests/PositionTests.cs +++ b/tests/Positions.Tests/PositionTests.cs @@ -161,4 +161,51 @@ public void SattEftertrardare_SetsPlannedSuccessor() // Assert Assert.Equal(successorId, position.EftertradarePlanerad); } + + [Fact] + public void Tillsatt_FromFrysta_SetsAktivAndRecordsHistorik() + { + // En frusen position ska kunna tillsättas (webb-UI:t erbjuder Tillsätt för Vakant/Frusen). + // Arrange + var position = Position.Skapa(Guid.NewGuid(), "IT-tekniker", 38000m, 100m); + position.Frys(); + var anstallId = Guid.NewGuid(); + + // Act + position.Tillsatt(anstallId); + + // Assert + Assert.Equal(PositionStatus.Aktiv, position.Status); + Assert.Equal(anstallId, position.InnehavareAnstallId); + Assert.Single(position.Historik); + } + + [Fact] + public void UppdateraBudget_SetsNewAmount() + { + // Arrange + var position = Position.Skapa(Guid.NewGuid(), "Sjuksköterska", 34500m, 100m); + + // Act + position.UppdateraBudget(36200m); + + // Assert + Assert.Equal(36200m, position.BudgeteradManadslon); + } + + [Fact] + public void Vakansatt_RecordsAnledningInHistorik() + { + // Arrange + var position = Position.Skapa(Guid.NewGuid(), "Läkare", 62000m, 100m); + position.Tillsatt(Guid.NewGuid()); + + // Act + position.Vakansatt("Pensionsavgång"); + + // Assert + var senaste = position.Historik.OrderByDescending(h => h.AndradVid).First(); + Assert.Equal("Pensionsavgång", senaste.Anledning); + Assert.Null(senaste.NyInnehavare); + } } diff --git a/tests/Recruitment.Tests/RecruitmentTests.cs b/tests/Recruitment.Tests/RecruitmentTests.cs index d964fe7..745418e 100644 --- a/tests/Recruitment.Tests/RecruitmentTests.cs +++ b/tests/Recruitment.Tests/RecruitmentTests.cs @@ -127,6 +127,54 @@ public void Komplett_rekryteringsflow_publicera_till_anstalld() Assert.Equal(application.Id, vacancy.TillsattAnsokanId); } + [Fact] + public void SattLonespann_satter_min_och_max() + { + var vacancy = SkapaTestVakans(); + + vacancy.SattLonespann(Money.SEK(28000m), Money.SEK(34000m)); + + Assert.NotNull(vacancy.Lonespann_Min); + Assert.NotNull(vacancy.Lonespann_Max); + Assert.Equal(28000m, vacancy.Lonespann_Min!.Value.Amount); + Assert.Equal(34000m, vacancy.Lonespann_Max!.Value.Amount); + } + + [Fact] + public void SattLonespann_min_over_max_kastar_exception() + { + var vacancy = SkapaTestVakans(); + + Assert.Throws(() => + vacancy.SattLonespann(Money.SEK(40000m), Money.SEK(30000m))); + } + + [Fact] + public void SattLonespann_tillater_att_rensa_till_null() + { + var vacancy = SkapaTestVakans(); + vacancy.SattLonespann(Money.SEK(28000m), Money.SEK(34000m)); + + vacancy.SattLonespann(null, null); + + Assert.Null(vacancy.Lonespann_Min); + Assert.Null(vacancy.Lonespann_Max); + } + + [Fact] + public void Publicera_tillsatt_vakans_kastar_exception() + { + var vacancy = SkapaTestVakans(); + vacancy.Publicera(); + var application = vacancy.TaEmotAnsokan("Test", "test@test.se"); + application.Bedoma(80, "Bra"); + application.BjudInIntervju(new DateTime(2026, 4, 15, 10, 0, 0, DateTimeKind.Utc)); + application.ErbjudTjanst(); + vacancy.Tillsatt(application.Id); + + Assert.Throws(() => vacancy.Publicera()); + } + [Fact] public void Stang_vakans() { diff --git a/tests/RegionHR.Infrastructure.Tests/Payroll/LonespecifikationPdfGeneratorTests.cs b/tests/RegionHR.Infrastructure.Tests/Payroll/LonespecifikationPdfGeneratorTests.cs new file mode 100644 index 0000000..4291d40 --- /dev/null +++ b/tests/RegionHR.Infrastructure.Tests/Payroll/LonespecifikationPdfGeneratorTests.cs @@ -0,0 +1,95 @@ +using RegionHR.Infrastructure.Export; +using RegionHR.Payroll.Domain; +using RegionHR.SharedKernel.Domain; +using Xunit; + +namespace RegionHR.Infrastructure.Tests.Payroll; + +public class LonespecifikationPdfGeneratorTests +{ + private static PayrollResult SkapaResultat() + { + var r = PayrollResult.Skapa( + PayrollRunId.New(), EmployeeId.New(), EmploymentId.New(), + 2026, 6, Money.SEK(38500m), 100m, CollectiveAgreementType.AB); + + r.Brutto = Money.SEK(40000m); + r.Skatt = Money.SEK(12000m); + r.Netto = Money.SEK(27700m); + r.OBTillagg = Money.SEK(1500m); + r.Arbetsgivaravgifter = Money.SEK(12568m); + r.Pensionsavgift = Money.SEK(2400m); + r.Fackavgift = Money.SEK(300m); + r.SemesterdagarIntjanade = 2; + + r.LaggTillRad(new PayrollResultLine + { + LoneartKod = "1010", Benamning = "Grundlön", + Antal = 1, Sats = Money.SEK(38500m), Belopp = Money.SEK(38500m) + }); + r.LaggTillRad(new PayrollResultLine + { + LoneartKod = "3050", Benamning = "OB-tillägg kväll", + Antal = 10, Sats = Money.SEK(150m), Belopp = Money.SEK(1500m) + }); + r.LaggTillRad(new PayrollResultLine + { + LoneartKod = "7010", Benamning = "Fackavgift", + Antal = 1, Sats = Money.SEK(300m), Belopp = Money.SEK(300m), ArAvdrag = true + }); + + return r; + } + + [Fact] + public void FromPayrollResult_MapsCoreFieldsAndPeriod() + { + var doc = LonespecifikationDokument.FromPayrollResult( + SkapaResultat(), "Sara Andersson", "199001011234", "Sjuksköterska"); + + Assert.Equal("Sara Andersson", doc.Namn); + Assert.Equal("199001011234", doc.Personnummer); + Assert.Equal("Sjuksköterska", doc.Befattning); + Assert.Equal("Juni 2026", doc.Period); + Assert.Equal("2026-06-25", doc.Utbetalningsdag); + Assert.Equal(40000m, doc.Brutto); + Assert.Equal(12000m, doc.Skatt); + Assert.Equal(27700m, doc.Netto); + Assert.Equal(1500m, doc.OBTillagg); + Assert.Equal(300m, doc.Fackavgift); + Assert.Equal(2400m, doc.Pensionsavgift); + Assert.Equal(2, doc.SemesterdagarIntjanade); + Assert.Equal("AB", doc.Kollektivavtal); + } + + [Fact] + public void FromPayrollResult_MapsAllLineItemsOrderedByCode() + { + var doc = LonespecifikationDokument.FromPayrollResult( + SkapaResultat(), "Sara Andersson", "199001011234"); + + Assert.Equal(3, doc.Rader.Count); + Assert.Equal("1010", doc.Rader[0].Kod); // sorterade på löneartskod + Assert.Equal("3050", doc.Rader[1].Kod); + Assert.Equal("7010", doc.Rader[2].Kod); + Assert.True(doc.Rader[2].ArAvdrag); + Assert.Equal(1500m, doc.Rader[1].Belopp); + } + + [Fact] + public void Generate_ProducesValidPdfBytes() + { + var doc = LonespecifikationDokument.FromPayrollResult( + SkapaResultat(), "Sara Andersson", "199001011234", "Sjuksköterska"); + + var pdf = new LonespecifikationPdfGenerator().Generate(doc); + + Assert.NotNull(pdf); + Assert.True(pdf.Length > 500, "PDF bör innehålla verkligt innehåll"); + // PDF-filer inleds med magic-bytes "%PDF". + Assert.Equal((byte)'%', pdf[0]); + Assert.Equal((byte)'P', pdf[1]); + Assert.Equal((byte)'D', pdf[2]); + Assert.Equal((byte)'F', pdf[3]); + } +} diff --git a/tests/Scheduling.Tests/BemanningsmallOchOptimeringSparaTests.cs b/tests/Scheduling.Tests/BemanningsmallOchOptimeringSparaTests.cs new file mode 100644 index 0000000..6d6e957 --- /dev/null +++ b/tests/Scheduling.Tests/BemanningsmallOchOptimeringSparaTests.cs @@ -0,0 +1,142 @@ +using RegionHR.Scheduling.Domain; +using RegionHR.Scheduling.Optimization; +using RegionHR.SharedKernel.Domain; +using Xunit; + +namespace RegionHR.Scheduling.Tests; + +/// +/// Tester för spar-vägarna bakom UI:t: +/// (1) Schemaoptimeringen (Optimering.razor) ska kunna materialisera lösarens +/// tilldelningar till ett med faktiska . +/// (2) Bemanningsmallar (Bemanningsmallar.razor) ska byggas ur veckodagsrader och +/// persistera dem, med bevarad giltighet och kompetenskrav, samt hålla invarianten +/// att optimalbemanning aldrig får understiga minimibemanning. +/// +public class BemanningsmallOchOptimeringSparaTests +{ + private readonly OrganizationId _enhet = OrganizationId.New(); + + // 2025-03-17 = måndag. + private static readonly DateOnly Mandag = new(2025, 3, 17); + private static readonly DateOnly Fredag = new(2025, 3, 21); + + [Fact] + public void Optimering_SparaPath_MaterialiserarAllaTilldelningarSomPass() + { + // Kör lösaren precis som sidan gör (dag- och kvällspass mån–fre). + var personal = new[] + { + NyPerson(), NyPerson(), NyPerson(), NyPerson() + }; + var behov = new List(); + for (var d = Mandag; d <= Fredag; d = d.AddDays(1)) + { + behov.Add(new StaffingRequirement { Datum = d, PassTyp = ShiftType.Dag, Start = new TimeOnly(7, 0), Slut = new TimeOnly(16, 0), Rast = TimeSpan.FromMinutes(60), AntalBehov = 2 }); + behov.Add(new StaffingRequirement { Datum = d, PassTyp = ShiftType.Kvall, Start = new TimeOnly(15, 0), Slut = new TimeOnly(22, 0), Rast = TimeSpan.FromMinutes(30), AntalBehov = 1 }); + } + var problem = new ScheduleProblem + { + EnhetId = _enhet, + Period = new DateRange(Mandag, Fredag), + PassBehov = behov, + TillgangligPersonal = [.. personal] + }; + + var solution = new ConstraintScheduleSolver().Solve(problem); + Assert.NotEmpty(solution.Tilldelningar); + + // Spar-vägen (identisk med Optimering.razor.SparaSchema). + var schema = Schedule.SkapaPeriodschema(_enhet, "Optimering", Mandag, Fredag); + foreach (var a in solution.Tilldelningar) + schema.LaggTillPass(a.AnstallId, a.Datum, a.PassTyp, a.Start, a.Slut, a.Rast); + + Assert.Equal(ScheduleType.Periodschema, schema.Typ); + Assert.Equal(ScheduleStatus.Utkast, schema.Status); + Assert.Equal(solution.Tilldelningar.Count, schema.Pass.Count); + + // Varje tilldelning ska finnas som ett planerat pass med bevarad tid och person. + foreach (var a in solution.Tilldelningar) + { + Assert.Contains(schema.Pass, p => + p.AnstallId == a.AnstallId && + p.Datum == a.Datum && + p.PassTyp == a.PassTyp && + p.PlaneradStart == a.Start && + p.PlaneradSlut == a.Slut && + p.Rast == a.Rast && + p.Status == ShiftStatus.Planerad); + } + } + + [Fact] + public void Bemanningsmall_ByggMedRader_BevararRaderKompetensOchGiltighet() + { + var mall = StaffingTemplate.Skapa(_enhet, "Avd 3 grundbemanning", Mandag); + mall.LaggTillRad(new StaffingRequirementLine + { + Veckodag = DayOfWeek.Monday, + PassTyp = ShiftType.Dag, + Start = new TimeOnly(7, 0), + Slut = new TimeOnly(16, 0), + Rast = TimeSpan.FromMinutes(60), + MinAntal = 1, + OptimalAntal = 2, + KravdaKompetenser = ["Sjuksköterska", "HLR"] + }); + mall.LaggTillRad(new StaffingRequirementLine + { + Veckodag = DayOfWeek.Monday, + PassTyp = ShiftType.Natt, + Start = new TimeOnly(21, 30), + Slut = new TimeOnly(7, 30), + Rast = TimeSpan.FromMinutes(60), + MinAntal = 1, + OptimalAntal = 1 + }); + + Assert.Equal(2, mall.Rader.Count); + Assert.True(mall.Giltighet.IsOpenEnded); + Assert.Equal(Mandag, mall.Giltighet.Start); + Assert.Contains("Sjuksköterska", mall.Rader[0].KravdaKompetenser); + // Nattpass över midnatt: 07:30 nästa dag − 21:30 − 1h rast = 9h netto. + Assert.Equal(9m, mall.Rader[1].PlaneradeTimmar); + } + + [Fact] + public void Bemanningsmall_MedGiltigTom_SatterSlutdatum() + { + var tom = Mandag.AddDays(90); + var mall = StaffingTemplate.Skapa(_enhet, "Sommarbemanning", Mandag, tom); + + Assert.False(mall.Giltighet.IsOpenEnded); + Assert.Equal(tom, mall.Giltighet.End); + } + + [Fact] + public void Bemanningsmall_OptimalMindreAnMin_KastarUndantag() + { + // Invarianten som UI:t (Bemanningsmallar.razor) skyddar mot: optimal < min. + var mall = StaffingTemplate.Skapa(_enhet, "Ogiltig", Mandag); + + Assert.Throws(() => mall.LaggTillRad(new StaffingRequirementLine + { + Veckodag = DayOfWeek.Monday, + PassTyp = ShiftType.Dag, + Start = new TimeOnly(7, 0), + Slut = new TimeOnly(16, 0), + Rast = TimeSpan.FromMinutes(60), + MinAntal = 2, + OptimalAntal = 1 + })); + } + + private static PersonalInfo NyPerson() => new() + { + AnstallId = EmployeeId.New(), + Namn = "Test", + Sysselsattningsgrad = 100m, + Kompetenser = [], + LedigaDagar = [] + }; +}