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ällning
Vä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.
+
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)
+
+
+
Veckodag
Pass
Tid
Rast
Timmar
Min
Optimal
Kompetens
+
+
+ @for (var i = 0; i < _draftRader.Count; i++)
+ {
+ var index = i;
+ var r = _draftRader[index];
+