();
+ if (reserverade is not null)
+ {
+ foreach (var r in reserverade)
+ {
+ var normal = Normalisera(r);
+ if (normal is not null) _anvanda.Add(normal);
+ }
+ }
+ }
+
+ /// Antal hittills genererade (exklusive reserverade).
+ public int AntalGenererade { get; private set; }
+
+ ///
+ /// Returnerar ett nytt, unikt och giltigt personnummer för en person med
+ /// angiven ålder och kön (per ).
+ ///
+ public Personnummer NastaUnikt(int alder, bool arKvinna, DateOnly idag)
+ {
+ // Rimlighetsvakt: åldersintervall 18–100 (demo använder 20–67).
+ if (alder < 18) alder = 18;
+ if (alder > 100) alder = 100;
+
+ // Försök tills ett unikt nummer hittas. Sökrymden (≈17 000 datum × 1000
+ // födelsenummer) är enorm jämfört med ~11 000 personer → i praktiken 0–1 omtag.
+ for (var forsok = 0; forsok < 10_000; forsok++)
+ {
+ var fodelsear = idag.Year - alder;
+ var manad = _rng.Next(1, 13);
+ var dag = _rng.Next(1, 29); // 1–28 är alltid en giltig dag i alla månader
+
+ // 3-siffrigt födelsenummer där sista siffran matchar kön.
+ var forsta = _rng.Next(0, 10);
+ var andra = _rng.Next(0, 10);
+ var sista = arKvinna
+ ? _rng.Next(0, 5) * 2 // 0,2,4,6,8
+ : _rng.Next(0, 5) * 2 + 1; // 1,3,5,7,9
+ if (forsta == 0 && andra == 0 && sista == 0) continue; // undvik NNN = 000
+
+ var yyyymmdd = $"{fodelsear:D4}{manad:D2}{dag:D2}";
+ var nnn = $"{forsta}{andra}{sista}";
+ var kontroll = BeraknaLuhnKontrollsiffra(yyyymmdd[2..] + nnn); // YYMMDD + NNN (9 siffror)
+ var tolvSiffror = yyyymmdd + nnn + kontroll;
+
+ if (!_anvanda.Add(tolvSiffror)) continue; // krock → nytt försök
+
+ // Slutlig sanity-check mot värdesobjektet (format + Luhn + könsparitet).
+ var pnr = new Personnummer(tolvSiffror);
+ AntalGenererade++;
+ return pnr;
+ }
+
+ throw new InvalidOperationException(
+ "Kunde inte generera ett unikt personnummer efter 10 000 försök — sökrymden bör vara uttömd först.");
+ }
+
+ ///
+ /// Beräknar Luhn-kontrollsiffran för de 9 siffrorna YYMMDDNNN enligt exakt samma
+ /// algoritm som validerar med (multiplikator 2,1,2,1…).
+ ///
+ public static int BeraknaLuhnKontrollsiffra(string nioSiffror)
+ {
+ if (nioSiffror.Length != 9)
+ throw new ArgumentException("Luhn-underlaget måste vara exakt 9 siffror (YYMMDDNNN).", nameof(nioSiffror));
+
+ var sum = 0;
+ for (var i = 0; i < 9; i++)
+ {
+ var d = nioSiffror[i] - '0';
+ var m = d * (i % 2 == 0 ? 2 : 1);
+ sum += m > 9 ? m - 9 : m;
+ }
+ return (10 - sum % 10) % 10;
+ }
+
+ private static string? Normalisera(string input)
+ {
+ if (string.IsNullOrWhiteSpace(input)) return null;
+ var cleaned = input.Replace(" ", "").Replace("-", "").Replace("+", "");
+ return cleaned.Length == 12 && cleaned.All(char.IsDigit) ? cleaned : null;
+ }
+}
diff --git a/src/Infrastructure/Persistence/Demo/SvenskaNamn.cs b/src/Infrastructure/Persistence/Demo/SvenskaNamn.cs
new file mode 100644
index 0000000..1dcc236
--- /dev/null
+++ b/src/Infrastructure/Persistence/Demo/SvenskaNamn.cs
@@ -0,0 +1,90 @@
+namespace RegionHR.Infrastructure.Persistence.Demo;
+
+///
+/// Inbäddade listor med vanliga svenska förnamn (kvinnliga/manliga) och efternamn.
+/// Används av den procedurella demo-befolkningsgeneratorn för att skapa realistiska,
+/// könskonsekventa namnkombinationer. Ren data — inga externa beroenden.
+///
+/// ~150 kvinnliga + ~150 manliga förnamn + ~200 efternamn ger flera hundratusen
+/// unika hela-namn-kombinationer, vilket räcker gott för en ~11 000-personers styrka.
+///
+public static class SvenskaNamn
+{
+ /// Kvinnliga förnamn (juridiskt kön Kvinna → jämn näst sista personnummersiffra).
+ public static readonly string[] Kvinnonamn =
+ [
+ "Anna", "Maria", "Margareta", "Elisabeth", "Eva", "Kristina", "Birgitta", "Karin",
+ "Marie", "Ingrid", "Christina", "Linnéa", "Kerstin", "Marianne", "Lena", "Helena",
+ "Emma", "Johanna", "Linda", "Sofia", "Anita", "Sara", "Malin", "Hanna", "Inger",
+ "Cecilia", "Ulla", "Susanne", "Annika", "Åsa", "Camilla", "Barbro", "Jenny", "Ida",
+ "Josefin", "Julia", "Ellen", "Alice", "Astrid", "Wilma", "Ebba", "Elin", "Agneta",
+ "Gunilla", "Katarina", "Monica", "Berit", "Yvonne", "Carina", "Britt", "Louise",
+ "Frida", "Amanda", "Matilda", "Klara", "Isabelle", "Moa", "Nathalie", "Rebecca",
+ "Elsa", "Alva", "Lovisa", "Saga", "Alma", "Lilly", "Maja", "Olivia", "Nova", "Ella",
+ "Freja", "Agnes", "Signe", "Vera", "Ines", "Stella", "Selma", "Tuva", "Iris", "Molly",
+ "Ellie", "Juni", "Siri", "Nellie", "Tilde", "Meja", "Hedda", "Wilhelmina", "Ester",
+ "Edith", "Greta", "Dagny", "Solveig", "Gudrun", "Rut", "Majken", "Viola", "Doris",
+ "Gerd", "Rakel", "Gun", "Ann", "Ingegerd", "Siv", "Britt-Marie", "Ann-Christine",
+ "Gun-Britt", "Marita", "Pia", "Therese", "Petra", "Charlotta", "Emelie", "Sofie",
+ "Caroline", "Madeleine", "Victoria", "Emilia", "Felicia", "Cornelia", "Tindra",
+ "Melissa", "Isabella", "Alicia", "Leia", "My", "Ellinor", "Hilda", "Ottilia",
+ "Elvira", "Livia", "Thea", "Nora", "Lykke", "Minna", "Ronja", "Idun", "Gabriella",
+ "Amelia", "Beata", "Desirée", "Henrietta", "Ingela", "Lisbeth", "Ulrika", "Vendela",
+ "Zara", "Nadia", "Fatima", "Layla", "Sonja", "Vanja", "Majvor", "Elly"
+ ];
+
+ /// Manliga förnamn (juridiskt kön Man → udda näst sista personnummersiffra).
+ public static readonly string[] Mansnamn =
+ [
+ "Erik", "Lars", "Karl", "Anders", "Per", "Johan", "Nils", "Lennart", "Mikael", "Jan",
+ "Hans", "Carl", "Sven", "Peter", "Fredrik", "Daniel", "Gunnar", "Bengt", "Bo", "Åke",
+ "Mats", "Thomas", "Andreas", "Stefan", "Göran", "Björn", "Christer", "Ulf", "Magnus",
+ "Leif", "Rolf", "Kjell", "Marcus", "Henrik", "Roger", "Bertil", "David", "Alexander",
+ "Emil", "Oscar", "Anton", "Gustav", "Filip", "Ludvig", "William", "Lucas", "Elias",
+ "Hugo", "Liam", "Oliver", "Adam", "Isak", "Vincent", "Theodor", "Axel", "Leo", "Noah",
+ "Arvid", "Albin", "Melvin", "Wilmer", "Charlie", "Viktor", "Simon", "Jonathan",
+ "Sebastian", "Rasmus", "Robin", "Jesper", "Pontus", "Linus", "Martin", "Jonas",
+ "Niklas", "Patrik", "Joakim", "Tobias", "Jakob", "Samuel", "Benjamin", "Felix",
+ "Gabriel", "Love", "Sixten", "Frank", "Malte", "Loke", "Vidar", "Alfred", "Edvin",
+ "Folke", "Nataniel", "Kevin", "Kasper", "Måns", "Melker", "Milton", "Vilgot", "Ture",
+ "Ivar", "Harald", "Ragnar", "Sigge", "Sten", "Torbjörn", "Håkan", "Roland", "Ingemar",
+ "Sune", "Evert", "Holger", "Yngve", "Verner", "Assar", "Gösta", "Ove", "Tage", "Valter",
+ "Georg", "Curt", "Alf", "Arne", "Egon", "Helge", "Knut", "Olof", "Sigurd", "Vilhelm",
+ "Aron", "Casper", "Dante", "Elton", "Hjalmar", "Jack", "Joel", "Kian", "Ali", "Omar",
+ "Mohammed", "Hassan", "Ahmad", "Ibrahim", "Yusuf", "Sten-Åke", "Karl-Erik", "Jan-Erik",
+ "Lars-Göran", "Bror", "Einar", "Gottfrid", "Ejnar"
+ ];
+
+ /// Vanliga svenska efternamn (könsneutrala).
+ public static readonly string[] Efternamn =
+ [
+ "Andersson", "Johansson", "Karlsson", "Nilsson", "Eriksson", "Larsson", "Olsson",
+ "Persson", "Svensson", "Gustafsson", "Pettersson", "Jonsson", "Jansson", "Hansson",
+ "Bengtsson", "Jönsson", "Lindberg", "Jakobsson", "Magnusson", "Olofsson", "Lindström",
+ "Lindqvist", "Lindgren", "Berg", "Axelsson", "Bergström", "Lundberg", "Lind",
+ "Lundgren", "Lundqvist", "Mattsson", "Berglund", "Fredriksson", "Sandberg",
+ "Henriksson", "Forsberg", "Sjöberg", "Wallin", "Engström", "Eklund", "Danielsson",
+ "Håkansson", "Lundin", "Gunnarsson", "Holm", "Bergman", "Björk", "Wikström",
+ "Isaksson", "Fransson", "Bergqvist", "Nyström", "Holmberg", "Arvidsson", "Löfgren",
+ "Söderberg", "Nyberg", "Blomqvist", "Claesson", "Nordström", "Mårtensson",
+ "Lundström", "Björklund", "Eliasson", "Pålsson", "Viklund", "Berggren", "Sandström",
+ "Nordin", "Lindholm", "Hedlund", "Dahlberg", "Hellström", "Sjögren", "Abrahamsson",
+ "Ek", "Blom", "Åberg", "Gustavsson", "Sundberg", "Öberg", "Strömberg", "Ottosson",
+ "Hermansson", "Backlund", "Sundström", "Åkesson", "Norberg", "Dahl", "Falk", "Ström",
+ "Åström", "Blomgren", "Karlström", "Franzén", "Sundqvist", "Holmgren", "Samuelsson",
+ "Nordqvist", "Ahlström", "Lund", "Öhman", "Månsson", "Rosén", "Hedström", "Sjölund",
+ "Ivarsson", "Molin", "Wallander", "Åkerlund", "Ohlsson", "Petersson", "Sjöström",
+ "Sundin", "Almqvist", "Nyholm", "Rydberg", "Jonasson", "Palm", "Östberg", "Bohlin",
+ "Ekström", "Melin", "Norling", "Hägglund", "Björkman", "Sundell", "Wennberg",
+ "Ekberg", "Dahlström", "Boström", "Löfqvist", "Sjödin", "Kjellberg", "Norén",
+ "Wester", "Enqvist", "Grönlund", "Lindell", "Bergstrand", "Wiberg", "Svahn",
+ "Zetterlund", "Sköld", "Ljung", "Frisk", "Ahlin", "Rask", "Modig", "Stål", "Lönn",
+ "Kvist", "Florén", "Lindblad", "Sjöqvist", "Hallberg", "Wallgren", "Bäckström",
+ "Palmqvist", "Källström", "Rönnberg", "Malm", "Hjort", "Löf", "Ohlin", "Tegnér",
+ "Brandt", "Cederholm", "Hall", "Josefsson", "Lager", "Nordlund", "Roos", "Törnqvist",
+ "Wahlgren", "Åhlén", "Sylvan", "Wikner", "Ceder", "Ternström", "Halvarsson",
+ "Rehn", "Björnsson", "Ryman", "Öqvist", "Grahn", "Norman", "Ranger", "Söderlund",
+ "Wallström", "Åhman", "Ljungqvist", "Fagerberg", "Sträng", "Elmqvist", "Byström",
+ "Kihlström", "Rudberg", "Ahmadi", "Hussein", "Rahman", "Khan", "Yilmaz", "Nguyen"
+ ];
+}
diff --git a/src/Infrastructure/Persistence/SeedData.cs b/src/Infrastructure/Persistence/SeedData.cs
index dace05b..3fa883e 100644
--- a/src/Infrastructure/Persistence/SeedData.cs
+++ b/src/Infrastructure/Persistence/SeedData.cs
@@ -2203,5 +2203,20 @@ 3. Kontakta HR-systemförvaltning via servicedesk
db.DevelopmentPlans.Add(utvPlanAnna);
await db.SaveChangesAsync();
+
+ // === Procedurell demo-befolkning: ~11 000 anställda över hela Region Örebro län ===
+ // Bygger ut organisationsträdet (förvaltningar, sjukhus + kliniker, vårdcentraler,
+ // folktandvård, service, kultur, kollektivtrafik) och befolkar det. Körs EFTER all
+ // handplockad demo-data ovan — de namngivna demo-användarna (Anna Svensson,
+ // Eva Nilsson, Karl Berg m.fl.) och deras rika data lämnas helt orörda. Generatorn
+ // sköter sina egna batchade SaveChanges; guarden överst (tom DB) håller idempotensen.
+ await Demo.DemoBefolkningsGenerator.GenereraAsync(
+ db,
+ regionId: region.Id,
+ usoId: sjukhus.Id,
+ abAvtalsId: ab.Id,
+ // Reservera de FAKTISKT lagrade numren (CreateValidated kan ha räknat om
+ // kontrollsiffran) så generatorn garanterat inte krockar med demo-användarna.
+ reserveradePersonnummer: seedEmployees.Select(e => (string)Personnummer.CreateValidated(e.Pnr)));
}
}
diff --git a/src/Web/Components/Pages/Anstallda/Index.razor b/src/Web/Components/Pages/Anstallda/Index.razor
index bf9ee70..8d390b3 100644
--- a/src/Web/Components/Pages/Anstallda/Index.razor
+++ b/src/Web/Components/Pages/Anstallda/Index.razor
@@ -1,6 +1,7 @@
@page "/anstallda"
@layout RegionHR.Web.Components.Layout.AdminLayout
@using RegionHR.Infrastructure.Export
+@using RegionHR.Web.Services
@using Microsoft.Extensions.Localization
@inject RegionHR.Web.Services.AnstallningService Service
@inject RegionHR.Web.Services.UnitScopeService Scope
@@ -16,12 +17,12 @@
+ Disabled="_exporterar" Style="min-height: 48px;">
CSV
+ Disabled="_exporterar" Style="min-height: 48px;">
@L["ExportExcel"]
- Sok(_sokterm))" Class="mb-4" />
-
-@if (_anstallda.Count == 0 && !_loading)
-{
- @L["NoEmployeesFound"]
-}
-else
-{
-
-
-
- | @L["Name"] |
- @L["PersonalNumber"] |
- @L["JobTitle"] |
- @L["EmploymentForm"] |
- @L["EmploymentRate"] |
- |
-
-
-
- @foreach (var a in _anstallda)
- {
- Nav.NavigateTo($"/anstallda/{a.Id.Value}"))">
- | @a.Fornamn @a.Efternamn |
- @a.PersonnummerMaskerat |
- @a.Befattning |
- @a.Anstallningsform |
- @a.Sysselsattningsgrad% |
- @L["Open"] |
-
- }
-
-
-}
-
-@if (_loading)
-{
-
-}
+
+
+
+ @if (!Scope.IsUnitScoped && _enheter.Count > 0)
+ {
+
+ @foreach (var e in _enheter)
+ {
+ @e.Namn (@e.Kostnadsstalle)
+ }
+
+ }
+
+
+
+ @L["Name"]
+ @L["PersonalNumber"]
+ @L["JobTitle"]
+ @L["Unit"]
+ @L["EmploymentForm"]
+ @L["EmploymentRate"]
+
+
+
+ @context.Fornamn @context.Efternamn
+ @context.PersonnummerMaskerat
+ @context.Befattning
+ @context.Enhet
+ @context.Anstallningsform
+ @context.Sysselsattningsgrad%
+
+ @L["Open"]
+
+
+
+ @L["NoEmployeesFound"]
+
+
+
+
+
+
+
+
@if (_visarCsv)
{
@@ -95,62 +106,98 @@ else
}
@code {
+ private MudTable _table = null!;
private string _sokterm = "";
- private bool _loading;
- private List _anstallda = [];
+ private Guid? _valdEnhet;
+ private bool _exporterar;
+ private List _enheter = [];
protected override async Task OnInitializedAsync()
{
- await LaddaAnstallda();
+ // Enhetsväljaren visas bara för HR/Admin (chef är låst till sin egen enhet).
+ if (!Scope.IsUnitScoped)
+ {
+ try { _enheter = await Service.HamtaEnheterAsync(); }
+ catch { _enheter = []; }
+ }
}
- private async Task Sok(string term)
- {
- _sokterm = term;
- await LaddaAnstallda();
- }
+ private Task ReloadTable() => _table.ReloadServerData();
- private async Task LaddaAnstallda()
+ ///
+ /// ServerData-callback för MudTable: laddar EN sida ur DB (Skip/Take + totalantal).
+ /// En chef scopas till sin enhet direkt i SQL-frågan; kan enheten inte härledas
+ /// visas ingenting (fail-closed) i stället för hela organisationen.
+ ///
+ private async Task> LaddaData(TableState state, CancellationToken token)
{
- _loading = true;
+ var (visa, enhet) = await BestamEnhetsfilterAsync();
+ if (!visa)
+ return new TableData { Items = [], TotalItems = 0 };
+
try
{
- var alla = await Service.HamtaAllaAsync(string.IsNullOrWhiteSpace(_sokterm) ? null : _sokterm);
-
- // Enhets-scoping (samma mönster som Chef/Index.razor): en chef ser
- // bara sin egen enhets medarbetare; HR/Admin ser allt. Fail-closed:
- // kan enheten inte härledas för en chef visas INGET i stället för
- // hela organisationen.
- if (Scope.IsUnitScoped)
- {
- var scopeIds = await Scope.GetEmployeeIdsInScopeAsync();
- alla = scopeIds is null
- ? []
- : alla.Where(a => scopeIds.Contains(a.Id.Value)).ToList();
- }
-
- _anstallda = alla;
+ var sida = await Service.HamtaSidaAsync(
+ state.Page * state.PageSize,
+ state.PageSize,
+ string.IsNullOrWhiteSpace(_sokterm) ? null : _sokterm,
+ enhet,
+ token);
+ return new TableData { Items = sida.Items, TotalItems = sida.TotalAntal };
}
catch
{
- _anstallda = [];
+ return new TableData { Items = [], TotalItems = 0 };
+ }
+ }
+
+ ///
+ /// Löser det effektiva enhetsfiltret. Returnerar (visa, enhet):
+ /// visa=false innebär fail-closed (chef utan härledbar enhet → visa inget).
+ ///
+ private async Task<(bool Visa, Guid? Enhet)> BestamEnhetsfilterAsync()
+ {
+ if (Scope.IsUnitScoped)
+ {
+ var unit = await Scope.GetCurrentUnitIdAsync();
+ return unit is null ? (false, (Guid?)null) : (true, unit);
}
- _loading = false;
+ return (true, _valdEnhet);
+ }
+
+ private void OppnaRad(TableRowClickEventArgs args)
+ {
+ if (args.Item is { } rad)
+ Nav.NavigateTo($"/anstallda/{rad.Id.Value}");
}
private bool _visarCsv;
private string _csvInnehall = "";
private bool _kopierad;
- private void ExporteraCsv()
+ private async Task> HamtaExportdataAsync()
+ {
+ var (visa, enhet) = await BestamEnhetsfilterAsync();
+ if (!visa) return [];
+ return await Service.HamtaForExportAsync(
+ string.IsNullOrWhiteSpace(_sokterm) ? null : _sokterm, enhet);
+ }
+
+ private async Task ExporteraCsv()
{
- var sb = new System.Text.StringBuilder();
- sb.AppendLine($"{L["FirstName"]};{L["LastName"]};{L["PersonalNumber"]};{L["JobTitle"]};{L["EmploymentForm"]};{L["EmploymentRate"]} (%)");
- foreach (var a in _anstallda)
- sb.AppendLine($"{a.Fornamn};{a.Efternamn};{a.PersonnummerMaskerat};{a.Befattning};{a.Anstallningsform};{a.Sysselsattningsgrad}");
- _csvInnehall = sb.ToString();
- _visarCsv = true;
- _kopierad = false;
+ _exporterar = true;
+ try
+ {
+ var rader = await HamtaExportdataAsync();
+ var sb = new System.Text.StringBuilder();
+ sb.AppendLine($"{L["FirstName"]};{L["LastName"]};{L["PersonalNumber"]};{L["JobTitle"]};{L["Unit"]};{L["EmploymentForm"]};{L["EmploymentRate"]} (%)");
+ foreach (var a in rader)
+ sb.AppendLine($"{a.Fornamn};{a.Efternamn};{a.PersonnummerMaskerat};{a.Befattning};{a.Enhet};{a.Anstallningsform};{a.Sysselsattningsgrad}");
+ _csvInnehall = sb.ToString();
+ _visarCsv = true;
+ _kopierad = false;
+ }
+ finally { _exporterar = false; }
}
private async Task KopieraCsv()
@@ -168,27 +215,34 @@ else
private async Task ExporteraExcel()
{
- var headers = new[] { L["FirstName"].Value, L["LastName"].Value, L["PersonalNumber"].Value, L["JobTitle"].Value, L["EmploymentForm"].Value, $"{L["EmploymentRate"]} (%)" };
-
- var excelBytes = Export.ToExcel(
- _anstallda,
- L["Employees"].Value,
- headers,
- a => new object[]
- {
- a.Fornamn,
- a.Efternamn,
- a.PersonnummerMaskerat,
- a.Befattning,
- a.Anstallningsform,
- a.Sysselsattningsgrad
- });
-
- var base64 = Convert.ToBase64String(excelBytes);
- var timestamp = DateTime.Now.ToString("yyyy-MM-dd");
- await JS.InvokeVoidAsync("downloadFile",
- $"anstallda-{timestamp}.xlsx",
- base64,
- "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+ _exporterar = true;
+ try
+ {
+ var rader = await HamtaExportdataAsync();
+ var headers = new[] { L["FirstName"].Value, L["LastName"].Value, L["PersonalNumber"].Value, L["JobTitle"].Value, L["Unit"].Value, L["EmploymentForm"].Value, $"{L["EmploymentRate"]} (%)" };
+
+ var excelBytes = Export.ToExcel(
+ rader,
+ L["Employees"].Value,
+ headers,
+ a => new object[]
+ {
+ a.Fornamn,
+ a.Efternamn,
+ a.PersonnummerMaskerat,
+ a.Befattning,
+ a.Enhet,
+ a.Anstallningsform,
+ a.Sysselsattningsgrad
+ });
+
+ var base64 = Convert.ToBase64String(excelBytes);
+ var timestamp = DateTime.Now.ToString("yyyy-MM-dd");
+ await JS.InvokeVoidAsync("downloadFile",
+ $"anstallda-{timestamp}.xlsx",
+ base64,
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+ }
+ finally { _exporterar = false; }
}
}
diff --git a/src/Web/Components/Pages/Chef/Bemanning.razor b/src/Web/Components/Pages/Chef/Bemanning.razor
index 2e01874..3970de0 100644
--- a/src/Web/Components/Pages/Chef/Bemanning.razor
+++ b/src/Web/Components/Pages/Chef/Bemanning.razor
@@ -73,19 +73,31 @@ else
try
{
var idag = DateOnly.FromDateTime(DateTime.Today);
- // Enhets-scoping: chef ser bara sin egen enhet (null = ingen scoping).
- var scopeIds = await Scope.GetEmployeeIdsInScopeAsync();
var shifts = await db.ScheduledShifts
.Where(s => s.Datum == idag && s.Status != ShiftStatus.Avbokad)
.ToListAsync();
- if (scopeIds is not null)
- shifts = shifts.Where(s => scopeIds.Contains(s.AnstallId.Value)).ToList();
if (shifts.Count > 0)
{
var anstallIds = shifts.Select(s => s.AnstallId).Distinct().ToList();
+ // Endast anställda med pass idag laddas (bundet av dagens schema, aldrig hela
+ // registret) — inkl. anställningar för enhets-scoping.
var employees = await db.Employees
+ .Include(e => e.Anstallningar)
.Where(e => anstallIds.Contains(e.Id)).ToListAsync();
+
+ // Chef ser bara sin egen enhet. Enhets-id härleds billigt (cachat unit-id
+ // eller en enda uppslagsfråga) i stället för att materialisera alla anställda.
+ // Kan enheten inte härledas behålls nuvarande beteende (ingen scoping).
+ if (Scope.IsUnitScoped && await Scope.GetCurrentUnitIdAsync() is Guid enhetId)
+ {
+ var iEnhet = employees
+ .Where(e => e.AktivAnstallning(idag)?.EnhetId.Value == enhetId)
+ .Select(e => e.Id).ToHashSet();
+ shifts = shifts.Where(s => iEnhet.Contains(s.AnstallId)).ToList();
+ employees = employees.Where(e => iEnhet.Contains(e.Id)).ToList();
+ }
+
var names = employees.ToDictionary(e => e.Id.Value, e => $"{e.Fornamn} {e.Efternamn}");
_passgrupper = shifts.GroupBy(s => s.PassTyp)
diff --git a/src/Web/Components/Pages/Chef/MittTeam.razor b/src/Web/Components/Pages/Chef/MittTeam.razor
index f665539..ff054ea 100644
--- a/src/Web/Components/Pages/Chef/MittTeam.razor
+++ b/src/Web/Components/Pages/Chef/MittTeam.razor
@@ -1,7 +1,9 @@
@page "/chef/team"
@layout RegionHR.Web.Components.Layout.AdminLayout
@using RegionHR.Infrastructure.Persistence
+@using RegionHR.Core.Domain
@using RegionHR.Leave.Domain
+@using RegionHR.SharedKernel.Domain
@using Microsoft.EntityFrameworkCore
@inject IDbContextFactory DbFactory
@inject RegionHR.Web.Services.AuthService Auth
@@ -91,46 +93,61 @@ else
private bool _loading = true;
private string? _error, _enhetNamn;
+ // Skyddstak: en enhets team laddas i sin helhet (ingen paginering här ännu). Taket
+ // hindrar att en oväntat stor enhet — eller en chef utan härledbar enhet — fryser vyn.
+ private const int TeamTak = 1000;
+
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
try
{
var idag = DateOnly.FromDateTime(DateTime.Today);
- var allEmployees = await db.Employees.Include(e => e.Anstallningar).OrderBy(e => e.Efternamn).ToListAsync();
- // Filter on chef's unit if auth provides EmployeeId
- Guid? chefEnhetId = null;
- if (Auth.EmployeeId.HasValue)
+ // Chefens enhet: använd i första hand det cachade unit-id:t, annars EN uppslagsfråga.
+ Guid? chefEnhetId = Auth.UnitId;
+ if (chefEnhetId is null && Auth.EmployeeId is Guid empId)
{
- var chef = allEmployees.FirstOrDefault(e => e.Id.Value == Auth.EmployeeId.Value);
- var chefAnst = chef?.AktivAnstallning(idag);
- if (chefAnst != null)
- {
- chefEnhetId = chefAnst.EnhetId.Value;
- var enhet = await db.OrganizationUnits.FirstOrDefaultAsync(e => e.Id.Value == chefEnhetId.Value);
- _enhetNamn = enhet?.Namn;
- }
+ var chef = await db.Employees.Include(e => e.Anstallningar)
+ .FirstOrDefaultAsync(e => e.Id == EmployeeId.From(empId));
+ chefEnhetId = chef?.AktivAnstallning(idag)?.EnhetId.Value;
}
- var employees = chefEnhetId.HasValue
- ? allEmployees.Where(e => e.AktivAnstallning(idag)?.EnhetId.Value == chefEnhetId.Value).ToList()
- : allEmployees;
+ // Teamet filtreras på enhet DIREKT i SQL — hela personalregistret laddas aldrig.
+ IQueryable teamQuery = db.Employees.Include(e => e.Anstallningar);
+ if (chefEnhetId is Guid enhetId)
+ {
+ var oid = OrganizationId.From(enhetId);
+ teamQuery = teamQuery.Where(e => e.Anstallningar.Any(a =>
+ a.EnhetId == oid &&
+ a.Giltighetsperiod.Start <= idag &&
+ (a.Giltighetsperiod.End == null || a.Giltighetsperiod.End >= idag)));
+ _enhetNamn = (await db.OrganizationUnits.FirstOrDefaultAsync(o => o.Id == oid))?.Namn;
+ }
- // Aktiva frånvaroperioder idag
+ var employees = await teamQuery
+ .OrderBy(e => e.Efternamn).ThenBy(e => e.Fornamn)
+ .Take(TeamTak)
+ .ToListAsync();
+
+ var teamGuids = employees.Select(e => e.Id.Value).ToList();
+
+ // Aktiva frånvaroperioder idag — bara för teamet (inte hela regionen).
var aktivFranvaro = await db.LeaveRequests
.Where(r => r.FranDatum <= idag && r.TillDatum >= idag
- && (r.Status == LeaveRequestStatus.Inskickad || r.Status == LeaveRequestStatus.Godkand))
+ && (r.Status == LeaveRequestStatus.Inskickad || r.Status == LeaveRequestStatus.Godkand)
+ && teamGuids.Contains(r.AnstallId))
.ToListAsync();
var franvaroPerAnstall = aktivFranvaro.ToDictionary(r => r.AnstallId, r => r.Typ);
- // Semestersaldo
- var balances = await db.VacationBalances.Where(b => b.Ar == idag.Year).ToListAsync();
+ // Semestersaldo — bara för teamet.
+ var balances = await db.VacationBalances
+ .Where(b => b.Ar == idag.Year && teamGuids.Contains(b.AnstallId))
+ .ToListAsync();
var balansPerAnstall = balances.ToDictionary(b => b.AnstallId, b => b.TillgangligaDagar);
- // Enhetnamn
- var enheter = await db.OrganizationUnits.ToListAsync();
- var enhetNamn = enheter.ToDictionary(e => e.Id.Value, e => e.Namn);
+ // Enhetnamn (liten tabell)
+ var enhetNamn = await db.OrganizationUnits.ToDictionaryAsync(e => e.Id.Value, e => e.Namn);
_team = employees.Select(e =>
{
diff --git a/src/Web/Components/Pages/Home.razor b/src/Web/Components/Pages/Home.razor
index ba9da7e..920285e 100644
--- a/src/Web/Components/Pages/Home.razor
+++ b/src/Web/Components/Pages/Home.razor
@@ -124,20 +124,29 @@ else if (_showBemanning && _bemanningPerEnhet.Count == 0 && !_loading)
{
var idag = DateOnly.FromDateTime(DateTime.Today);
- // Headcount: anställda med aktiv anställning
- var employees = await db.Employees.Include(e => e.Anstallningar).ToListAsync();
- _headcount = employees.Count(e => e.AktivAnstallning(idag) != null);
+ // Headcount: anställda med aktiv anställning — SQL COUNT + EXISTS, inga entiteter
+ // materialiseras (skalar till tiotusentals anställda).
+ _headcount = await db.Employees.CountAsync(e => e.Anstallningar.Any(a =>
+ a.Giltighetsperiod.Start <= idag &&
+ (a.Giltighetsperiod.End == null || a.Giltighetsperiod.End >= idag)));
// LAS-alarm
_lasAlarm = await db.LASAccumulations
.CountAsync(l => l.Status == LASStatus.NaraGrans || l.Status == LASStatus.KritiskNara);
- // Positioner — en fråga, gruppera status i minnet
- var positions = await db.Positions_Table.ToListAsync();
- var statusCounts = positions.GroupBy(p => p.Status)
- .ToDictionary(g => g.Key, g => g.Count());
- var aktiva = statusCounts.GetValueOrDefault(PositionStatus.Aktiv);
- var vakanta = statusCounts.GetValueOrDefault(PositionStatus.Vakant);
+ // Positioner per enhet — aggregeras i databasen (GROUP BY), inte i minnet.
+ var perEnhetRaw = await db.Positions_Table
+ .GroupBy(p => p.EnhetId)
+ .Select(g => new
+ {
+ EnhetId = g.Key,
+ Aktiva = g.Sum(p => p.Status == PositionStatus.Aktiv ? 1 : 0),
+ Vakanta = g.Sum(p => p.Status == PositionStatus.Vakant ? 1 : 0)
+ })
+ .ToListAsync();
+
+ var aktiva = perEnhetRaw.Sum(x => x.Aktiva);
+ var vakanta = perEnhetRaw.Sum(x => x.Vakanta);
_vakantaPositioner = vakanta;
// Väntande godkännanden
@@ -148,16 +157,14 @@ else if (_showBemanning && _bemanningPerEnhet.Count == 0 && !_loading)
var total = aktiva + vakanta;
_bemanningsgrad = total > 0 ? (int)Math.Round(100.0 * aktiva / total) : 0;
- // Bemanning per enhet
- var enheter = await db.OrganizationUnits.ToListAsync();
- var enhetNamn = enheter.ToDictionary(e => e.Id.Value, e => e.Namn);
+ // Bemanning per enhet — enhetsnamn (liten tabell) slås upp för de grupperade raderna.
+ var enhetNamn = await db.OrganizationUnits.ToDictionaryAsync(e => e.Id.Value, e => e.Namn);
- _bemanningPerEnhet = positions
- .GroupBy(p => p.EnhetId)
+ _bemanningPerEnhet = perEnhetRaw
.Select(g => new EnhetBemanningVm(
- enhetNamn.TryGetValue(g.Key, out var namn) ? namn : L["UnknownUnit"].Value,
- g.Count(p => p.Status == PositionStatus.Aktiv),
- g.Count(p => p.Status == PositionStatus.Vakant)))
+ enhetNamn.TryGetValue(g.EnhetId, out var namn) ? namn : L["UnknownUnit"].Value,
+ g.Aktiva,
+ g.Vakanta))
.OrderByDescending(e => e.Vakanta)
.ToList();
}
diff --git a/src/Web/Resources/SharedResources.en.resx b/src/Web/Resources/SharedResources.en.resx
index b210138..56c4b31 100644
--- a/src/Web/Resources/SharedResources.en.resx
+++ b/src/Web/Resources/SharedResources.en.resx
@@ -29,7 +29,6 @@
Log in
Log out
- Log out
Choose login method
BankID
All employees
diff --git a/src/Web/Resources/SharedResources.resx b/src/Web/Resources/SharedResources.resx
index 8a053d7..8e60bbd 100644
--- a/src/Web/Resources/SharedResources.resx
+++ b/src/Web/Resources/SharedResources.resx
@@ -29,7 +29,6 @@
Logga in
Logga ut
- Logga ut
Valj inloggningsmetod
BankID
Alla anstallda
diff --git a/src/Web/Services/AnstallningService.cs b/src/Web/Services/AnstallningService.cs
index ebee8d8..2a51f90 100644
--- a/src/Web/Services/AnstallningService.cs
+++ b/src/Web/Services/AnstallningService.cs
@@ -12,38 +12,133 @@ public class AnstallningService
public AnstallningService(IDbContextFactory dbFactory) => _dbFactory = dbFactory;
+ ///
+ /// Bakåtkompatibel "hämta upp till 100"-lista (används av bl.a. mallgeneratorns
+ /// medarbetarväljare). För den paginerade listvyn: använd .
+ ///
public async Task> HamtaAllaAsync(string? sokterm = null, CancellationToken ct = default)
{
await using var db = await _dbFactory.CreateDbContextAsync(ct);
- var query = db.Employees
+ var idag = DateOnly.FromDateTime(DateTime.Today);
+
+ var employees = await ByggFiltreradFraga(db, idag, sokterm, null)
+ .OrderBy(e => e.Efternamn).ThenBy(e => e.Fornamn)
+ .Take(100)
+ .Include(e => e.Anstallningar)
+ .ToListAsync(ct);
+
+ var enhetNamn = await HamtaEnhetNamnAsync(db, ct);
+ return employees.Select(e => MapItem(e, idag, enhetNamn)).ToList();
+ }
+
+ ///
+ /// Serversidig paginering för anställd-listan. Laddar BARA en sida (typiskt 25–50)
+ /// ur databasen plus totalantal — skalar till tiotusentals anställda utan att frysa
+ /// Blazor-circuiten. Filtrerar valfritt på fritext (namn, befattning, e-post) och/eller
+ /// en exakt enhet (, används både för enhetsfilter i UI och
+ /// för chefs-scoping). Aktiv anställning bestäms i SQL via giltighetsperioden.
+ ///
+ /// Antal rader att hoppa över (sida × sidstorlek).
+ /// Sidstorlek (klampas till 1–200).
+ public async Task> HamtaSidaAsync(
+ int hoppaOver, int taAntal, string? sokterm = null, Guid? enhetId = null, CancellationToken ct = default)
+ {
+ await using var db = await _dbFactory.CreateDbContextAsync(ct);
+ var idag = DateOnly.FromDateTime(DateTime.Today);
+ var query = ByggFiltreradFraga(db, idag, sokterm, enhetId);
+
+ var total = await query.CountAsync(ct);
+
+ var employees = await query
+ .OrderBy(e => e.Efternamn).ThenBy(e => e.Fornamn)
+ .Skip(Math.Max(0, hoppaOver))
+ .Take(Math.Clamp(taAntal, 1, 200))
+ .Include(e => e.Anstallningar)
+ .ToListAsync(ct);
+
+ var enhetNamn = await HamtaEnhetNamnAsync(db, ct);
+ var items = employees.Select(e => MapItem(e, idag, enhetNamn)).ToList();
+ return new PagedResult(items, total);
+ }
+
+ ///
+ /// Hämtar hela det filtrerade urvalet (utan paginering) för CSV/Excel-export.
+ /// Har ett tak () som skyddsnät mot en oavsiktlig helexport
+ /// av hela registret; höj vid behov.
+ ///
+ public async Task> HamtaForExportAsync(
+ string? sokterm = null, Guid? enhetId = null, int max = 20000, CancellationToken ct = default)
+ {
+ await using var db = await _dbFactory.CreateDbContextAsync(ct);
+ var idag = DateOnly.FromDateTime(DateTime.Today);
+
+ var employees = await ByggFiltreradFraga(db, idag, sokterm, enhetId)
+ .OrderBy(e => e.Efternamn).ThenBy(e => e.Fornamn)
+ .Take(Math.Clamp(max, 1, 100000))
.Include(e => e.Anstallningar)
- .AsQueryable();
+ .ToListAsync(ct);
+
+ var enhetNamn = await HamtaEnhetNamnAsync(db, ct);
+ return employees.Select(e => MapItem(e, idag, enhetNamn)).ToList();
+ }
+
+ ///
+ /// Bygger den filtrerade grundfrågan (utan paginering/materialisering). All filtrering
+ /// sker i SQL: aktiv-anställnings-scoping via giltighetsperioden och fritext via LIKE.
+ ///
+ private static IQueryable ByggFiltreradFraga(
+ RegionHRDbContext db, DateOnly idag, string? sokterm, Guid? enhetId)
+ {
+ var query = db.Employees.AsQueryable();
+
+ if (enhetId is Guid eid)
+ {
+ var oid = OrganizationId.From(eid);
+ query = query.Where(e => e.Anstallningar.Any(a =>
+ a.EnhetId == oid &&
+ a.Giltighetsperiod.Start <= idag &&
+ (a.Giltighetsperiod.End == null || a.Giltighetsperiod.End >= idag)));
+ }
if (!string.IsNullOrWhiteSpace(sokterm))
{
- var term = sokterm.ToLower();
+ var t = sokterm.Trim().ToLower();
query = query.Where(e =>
- e.Fornamn.ToLower().Contains(term) ||
- e.Efternamn.ToLower().Contains(term));
+ e.Fornamn.ToLower().Contains(t) ||
+ e.Efternamn.ToLower().Contains(t) ||
+ (e.Fornamn + " " + e.Efternamn).ToLower().Contains(t) ||
+ (e.Epost != null && e.Epost.ToLower().Contains(t)) ||
+ e.Anstallningar.Any(a =>
+ a.Befattningstitel != null && a.Befattningstitel.ToLower().Contains(t)));
}
- var employees = await query.OrderBy(e => e.Efternamn).Take(100).ToListAsync(ct);
+ return query;
+ }
- return employees.Select(e =>
- {
- var aktiv = e.Anstallningar.FirstOrDefault(a =>
- a.Giltighetsperiod.Start <= DateOnly.FromDateTime(DateTime.Today) &&
- (a.Giltighetsperiod.End == null || a.Giltighetsperiod.End >= DateOnly.FromDateTime(DateTime.Today)));
- return new EmployeeListItem(
- e.Id,
- e.Fornamn,
- e.Efternamn,
- e.Personnummer.ToMaskedString(),
- e.Epost,
- aktiv?.Befattningstitel ?? "-",
- aktiv?.Anstallningsform.ToString() ?? "-",
- aktiv?.Sysselsattningsgrad.Value ?? 0);
- }).ToList();
+ /// Uppslag enhets-id → namn. Organisationsenheter är en liten tabell (≪ anställda).
+ private static async Task> HamtaEnhetNamnAsync(
+ RegionHRDbContext db, CancellationToken ct)
+ {
+ var enheter = await db.OrganizationUnits
+ .Select(o => new { o.Id, o.Namn })
+ .ToListAsync(ct);
+ return enheter.ToDictionary(x => x.Id.Value, x => x.Namn);
+ }
+
+ private static EmployeeListItem MapItem(Employee e, DateOnly idag, IReadOnlyDictionary enhetNamn)
+ {
+ var aktiv = e.AktivAnstallning(idag);
+ var enhet = aktiv != null && enhetNamn.TryGetValue(aktiv.EnhetId.Value, out var namn) ? namn : "-";
+ return new EmployeeListItem(
+ e.Id,
+ e.Fornamn,
+ e.Efternamn,
+ e.Personnummer.ToMaskedString(),
+ e.Epost,
+ aktiv?.Befattningstitel ?? "-",
+ aktiv?.Anstallningsform.ToString() ?? "-",
+ aktiv?.Sysselsattningsgrad.Value ?? 0,
+ enhet);
}
public async Task HamtaAsync(EmployeeId id, CancellationToken ct = default)
@@ -245,7 +340,11 @@ public record EmployeeListItem(
string? Epost,
string Befattning,
string Anstallningsform,
- decimal Sysselsattningsgrad);
+ decimal Sysselsattningsgrad,
+ string Enhet);
+
+/// En sida ur ett större resultat: raderna + totalantalet matchande poster.
+public record PagedResult(IReadOnlyList Items, int TotalAntal);
public record EnhetVal(OrganizationId Id, string Namn, string Kostnadsstalle);
public record AvtalVal(CollectiveAgreementId Id, string Namn);
diff --git a/tests/RegionHR.Infrastructure.Tests/Demo/DemoBefolkningsGeneratorTests.cs b/tests/RegionHR.Infrastructure.Tests/Demo/DemoBefolkningsGeneratorTests.cs
new file mode 100644
index 0000000..cecf10c
--- /dev/null
+++ b/tests/RegionHR.Infrastructure.Tests/Demo/DemoBefolkningsGeneratorTests.cs
@@ -0,0 +1,93 @@
+using RegionHR.Infrastructure.Persistence.Demo;
+using RegionHR.SharedKernel.Domain;
+using Xunit;
+
+namespace RegionHR.Infrastructure.Tests.Demo;
+
+///
+/// Enhetstester för den procedurella demo-befolkningsgeneratorns rena delar
+/// (personnummer, viktfördelning, organisationsträd) — kräver ingen databas.
+///
+public class DemoBefolkningsGeneratorTests
+{
+ private static readonly DateOnly Idag = new(2026, 7, 3);
+
+ [Fact]
+ public void PersonnummerGenerator_producerar_giltiga_unika_konskonsekventa_nummer()
+ {
+ var gen = new PersonnummerGenerator(new Random(12345));
+ var sedda = new HashSet();
+
+ for (var i = 0; i < 5000; i++)
+ {
+ var arKvinna = i % 2 == 0;
+ var alder = 20 + i % 48; // 20..67
+
+ var pnr = gen.NastaUnikt(alder, arKvinna, Idag);
+ var tolv = (string)pnr;
+
+ // Format: exakt 12 siffror och kan re-parsas av värdesobjektet (Luhn + datum).
+ Assert.Equal(12, tolv.Length);
+ _ = new Personnummer(tolv); // kastar om ogiltigt
+
+ // Unikhet.
+ Assert.True(sedda.Add(tolv), $"Dubblett genererad: {tolv}");
+
+ // Kön konsekvent med begärt kön.
+ Assert.Equal(arKvinna ? "Kvinna" : "Man", pnr.LegalGender);
+
+ // Ålder inom demo-intervallet.
+ Assert.Equal(alder, Idag.Year - pnr.BirthDate.Year);
+ }
+
+ Assert.Equal(5000, gen.AntalGenererade);
+ }
+
+ [Fact]
+ public void BeraknaLuhnKontrollsiffra_ger_giltigt_personnummer()
+ {
+ // YYMMDDNNN för 1985-03-15 + födelsenummer 238.
+ var kontroll = PersonnummerGenerator.BeraknaLuhnKontrollsiffra("850315238");
+
+ Assert.Equal(4, kontroll);
+ _ = new Personnummer("19850315238" + kontroll); // kastar om Luhn inte stämmer
+ }
+
+ [Fact]
+ public void Reserverade_personnummer_ateranvands_aldrig()
+ {
+ const string reserverat = "198503152384"; // motsvarar en handplockad demo-användare
+ var gen = new PersonnummerGenerator(new Random(1), [reserverat]);
+
+ for (var i = 0; i < 2000; i++)
+ {
+ var pnr = gen.NastaUnikt(30 + i % 30, i % 2 == 0, Idag);
+ Assert.NotEqual(reserverat, (string)pnr);
+ }
+ }
+
+ [Fact]
+ public void BeraknaFordelning_summerar_exakt_till_antal()
+ {
+ var (_, bemanningsbara) = DemoOrganisation.Bygg(OrganizationId.New(), OrganizationId.New());
+
+ var fordelning = DemoBefolkningsGenerator.BeraknaFordelning(bemanningsbara, 11_000);
+
+ Assert.Equal(bemanningsbara.Count, fordelning.Length);
+ Assert.Equal(11_000, fordelning.Sum());
+ Assert.All(fordelning, n => Assert.True(n >= 0));
+ }
+
+ [Fact]
+ public void DemoOrganisation_bygger_realistiskt_trad_med_unika_kostnadsstallen()
+ {
+ var (enheter, bemanningsbara) = DemoOrganisation.Bygg(OrganizationId.New(), OrganizationId.New());
+
+ Assert.True(enheter.Count > 100, $"Förväntade >100 enheter, fick {enheter.Count}");
+ Assert.True(bemanningsbara.Count > 80, $"Förväntade >80 bemanningsbara, fick {bemanningsbara.Count}");
+
+ // Kostnadsställen måste vara unika (inget krockar med varandra).
+ var ks = enheter.Select(e => e.Kostnadsstalle).ToList();
+ Assert.Equal(ks.Count, ks.Distinct().Count());
+ }
+}