-
-
@Enhet.Namn
-
(@Enhet.Kostnadsstalle)
+
0 ? "0.75rem" : "0");">
+
+ @if (Nod.AntalUnderenheter > 0)
+ {
+
+ }
+ else
+ {
+
+ }
+
+
+
+ @Nod.Namn
+
+ (@Nod.Kostnadsstalle)
+
+ @if (Nod.AntalUnderenheter > 0)
+ {
+ · @Nod.AntalUnderenheter @(Nod.AntalUnderenheter == 1 ? "underenhet" : "underenheter")
+ }
+ @if (Nod.AntalAnstalldaTotalt > 0)
+ {
+ · @Nod.AntalAnstalldaTotalt anställda
+ }
- @if (Enhet.Underenheter.Count > 0)
+ @if (_expanded && Nod.Barn.Count > 0)
{
- @foreach (var child in Enhet.Underenheter)
+ @foreach (var barn in Nod.Barn)
{
-
+
}
}
@code {
- [Parameter] public OrganizationUnit Enhet { get; set; } = default!;
+ [Parameter] public OrgTreeNode Nod { get; set; } = default!;
[Parameter] public int Level { get; set; }
- private string TypVariant => Enhet.Typ switch
+ private bool _expanded;
+
+ // Region (0) och förvaltning (1) fälls ut som standard, så de ~110 verksamhetsnivå-
+ // enheterna syns direkt; djupare nivåer expanderas på klick.
+ protected override void OnInitialized() => _expanded = Level <= 1;
+
+ private void Toggle() => _expanded = !_expanded;
+
+ private void Oppna() => Nav.NavigateTo($"/organisation/{Nod.Id.Value}");
+
+ private void OppnaViaTangent(KeyboardEventArgs e)
+ {
+ if (e.Key is "Enter" or " ")
+ Oppna();
+ }
+
+ 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()
+ };
+
+ private static string TypVariant(OrganizationUnitType t) => t switch
{
OrganizationUnitType.Region => "info",
OrganizationUnitType.Forvaltning => "info",
diff --git a/src/Web/Services/AnstallningService.cs b/src/Web/Services/AnstallningService.cs
index 2a51f90..637d23c 100644
--- a/src/Web/Services/AnstallningService.cs
+++ b/src/Web/Services/AnstallningService.cs
@@ -196,13 +196,200 @@ public async Task UppdateraSkatteuppgifterAsync(
await db.SaveChangesAsync(ct);
}
- public async Task
> HamtaOrganisationAsync(CancellationToken ct = default)
+ ///
+ /// Hämtar HELA organisationsträdet i EN platt fråga och bygger parent→barn-hierarkin
+ /// i minnet (via ). Den gamla varianten
+ /// laddade bara roten + en nivå barn med .Include(o => o.Underenheter), så nivå
+ /// 3–4 (kliniker/vårdcentraler/underenheter) blev osynliga. Nu returneras rot-noderna
+ /// med fullt påfyllda barn i alla nivåer — alla enheter är nåbara. Varje nod bär även
+ /// antal aktiva anställda (direkt + rullat upp över subträdet) för att kunna visas i UI.
+ ///
+ public async Task> HamtaOrganisationAsync(CancellationToken ct = default)
+ {
+ await using var db = await _dbFactory.CreateDbContextAsync(ct);
+
+ // En platt fråga över hela (lilla) enhetstabellen, ordnad på namn.
+ var enheter = await db.OrganizationUnits
+ .AsNoTracking()
+ .OrderBy(o => o.Namn)
+ .ToListAsync(ct);
+
+ var antalPerEnhet = await HamtaAntalAnstalldaPerEnhetAsync(db, ct);
+
+ var platta = enheter.Select(o => new OrgTreeNode(
+ o.Id,
+ o.Namn,
+ o.Typ,
+ o.Kostnadsstalle,
+ o.OverordnadEnhetId is { } p ? p.Value : (Guid?)null,
+ antalPerEnhet.TryGetValue(o.Id.Value, out var n) ? n : 0));
+
+ return ByggHierarki(platta);
+ }
+
+ ///
+ /// Rent in-minne-trädbygge: kopplar en platt lista av noder till en parent→barn-hierarki
+ /// via och returnerar rot-noderna. Barnens ordning
+ /// bevaras från inlistan (kalla med namnsorterad lista för namnsorterat träd). En nod utan
+ /// förälder — ELLER med en förälder som inte finns i urvalet (dinglande FK) — behandlas som
+ /// rot, så att ingen enhet någonsin blir onåbar. Antal anställda rullas upp per subträd.
+ /// Ren funktion utan DB-beroende → enhetstestbar.
+ ///
+ public static List ByggHierarki(IEnumerable platta)
+ {
+ var noder = platta as IList ?? platta.ToList();
+
+ var perId = new Dictionary(noder.Count);
+ foreach (var n in noder)
+ {
+ n.Barn.Clear();
+ perId[n.Id.Value] = n; // vid ev. dubbletter vinner den sista
+ }
+
+ var rotter = new List();
+ foreach (var n in noder)
+ {
+ if (n.OverordnadId is { } pid
+ && perId.TryGetValue(pid, out var parent)
+ && !ReferenceEquals(parent, n))
+ {
+ parent.Barn.Add(n);
+ }
+ else
+ {
+ rotter.Add(n);
+ }
+ }
+
+ foreach (var rot in rotter)
+ BeraknaTotaltAntal(rot);
+
+ return rotter;
+ }
+
+ private static int BeraknaTotaltAntal(OrgTreeNode nod)
+ {
+ var summa = nod.AntalAnstalldaDirekt;
+ foreach (var barn in nod.Barn)
+ summa += BeraknaTotaltAntal(barn);
+ nod.AntalAnstalldaTotalt = summa;
+ return summa;
+ }
+
+ ///
+ /// Antal AKTIVA anställda per enhet (nyckel = enhetens Guid). Hämtar bara enhets-id-kolumnen
+ /// för aktiva anställningar i en fråga och grupperar i minnet — undviker att materialisera
+ /// hela anställningsraderna och är oberoende av GroupBy-översättning för värde-konverterade
+ /// nycklar. Enhetsurvalet är litet; anställningarna filtreras på giltighetsperioden i SQL.
+ ///
+ ///
+ /// Om satt: räkna bara dessa enheter (IN-filter i SQL) i stället för hela registret. Används
+ /// av enhetsdetaljen som bara behöver enheten själv + dess direkta barn — då transporteras
+ /// bara de matchande radernas enhets-id, inte alla ~11 000. Trädvyn kallar utan filter.
+ ///
+ private static async Task> HamtaAntalAnstalldaPerEnhetAsync(
+ RegionHRDbContext db, CancellationToken ct, IReadOnlyCollection? begransaTill = null)
+ {
+ var idag = DateOnly.FromDateTime(DateTime.Today);
+ var query = db.Employments
+ .Where(a => a.Giltighetsperiod.Start <= idag
+ && (a.Giltighetsperiod.End == null || a.Giltighetsperiod.End >= idag));
+
+ if (begransaTill is { Count: > 0 })
+ query = query.Where(a => begransaTill.Contains(a.EnhetId));
+
+ var enhetsIds = await query
+ .Select(a => a.EnhetId)
+ .ToListAsync(ct);
+
+ return enhetsIds
+ .GroupBy(id => id.Value)
+ .ToDictionary(g => g.Key, g => g.Count());
+ }
+
+ ///
+ /// Detaljdata för EN organisationsenhet: enhetens egna fält, dess överordnade enhet
+ /// (för länk tillbaka) och dess DIREKTA underenheter (med antal aktiva anställda per barn).
+ /// Antal anställda i själva enheten räknas i DB. Laddar aldrig anställningsrader hit — den
+ /// paginerade anställdlistan hämtas separat via .
+ ///
+ public async Task HamtaEnhetDetaljAsync(Guid id, CancellationToken ct = default)
{
await using var db = await _dbFactory.CreateDbContextAsync(ct);
- return await db.OrganizationUnits
- .Include(o => o.Underenheter)
- .Where(o => o.OverordnadEnhetId == null)
+ var oid = OrganizationId.From(id);
+
+ var enhet = await db.OrganizationUnits
+ .AsNoTracking()
+ .FirstOrDefaultAsync(o => o.Id == oid, ct);
+ if (enhet is null)
+ return null;
+
+ string? overordnadNamn = null;
+ if (enhet.OverordnadEnhetId is { } pid)
+ {
+ overordnadNamn = await db.OrganizationUnits
+ .AsNoTracking()
+ .Where(o => o.Id == pid)
+ .Select(o => o.Namn)
+ .FirstOrDefaultAsync(ct);
+ }
+
+ var barn = await db.OrganizationUnits
+ .AsNoTracking()
+ .Where(o => o.OverordnadEnhetId == oid)
+ .OrderBy(o => o.Namn)
.ToListAsync(ct);
+
+ // Räkna anställda bara för enheten själv + dess direkta barn (inte hela registret).
+ var relevanta = new List(barn.Count + 1) { enhet.Id };
+ relevanta.AddRange(barn.Select(b => b.Id));
+ var antalPerEnhet = await HamtaAntalAnstalldaPerEnhetAsync(db, ct, relevanta);
+ int Antal(OrganizationId x) => antalPerEnhet.TryGetValue(x.Value, out var n) ? n : 0;
+
+ var barnDto = barn
+ .Select(b => new OrgEnhetBarn(b.Id, b.Namn, b.Typ, Antal(b.Id)))
+ .ToList();
+
+ return new OrgEnhetDetalj(
+ enhet.Id,
+ enhet.Namn,
+ enhet.Typ,
+ enhet.Kostnadsstalle,
+ enhet.CFARKod,
+ enhet.HsaId,
+ enhet.Giltighet.Start,
+ enhet.Giltighet.End,
+ enhet.OverordnadEnhetId is { } op ? op : (OrganizationId?)null,
+ overordnadNamn,
+ barnDto,
+ Antal(enhet.Id));
+ }
+
+ ///
+ /// Sammanfattning "antal aktiva anställda per befattning" för en enhet. Aggregeras i DB
+ /// (bara befattning + antal transporteras) och filtreras på enhet + aktiv giltighetsperiod.
+ ///
+ public async Task> HamtaBefattningsfordelningAsync(
+ Guid enhetId, CancellationToken ct = default)
+ {
+ await using var db = await _dbFactory.CreateDbContextAsync(ct);
+ var oid = OrganizationId.From(enhetId);
+ var idag = DateOnly.FromDateTime(DateTime.Today);
+
+ // Hämtar bara befattningskolumnen för enhetens aktiva anställningar; grupperar i minnet.
+ var titlar = await db.Employments
+ .Where(a => a.EnhetId == oid
+ && a.Giltighetsperiod.Start <= idag
+ && (a.Giltighetsperiod.End == null || a.Giltighetsperiod.End >= idag))
+ .Select(a => a.Befattningstitel)
+ .ToListAsync(ct);
+
+ return titlar
+ .GroupBy(t => string.IsNullOrWhiteSpace(t) ? "Ej angiven" : t!.Trim())
+ .Select(g => new BefattningsAntal(g.Key, g.Count()))
+ .OrderByDescending(r => r.Antal)
+ .ThenBy(r => r.Befattning, StringComparer.CurrentCultureIgnoreCase)
+ .ToList();
}
/// Platt lista över alla enheter för val i anställningsformulär.
@@ -348,3 +535,68 @@ public record PagedResult(IReadOnlyList Items, int TotalAntal);
public record EnhetVal(OrganizationId Id, string Namn, string Kostnadsstalle);
public record AvtalVal(CollectiveAgreementId Id, string Namn);
+
+///
+/// En nod i organisationsträdet (vy-modell). Bär enhetens visningsfält, förälder-id (för
+/// hierarkibygget), antal aktiva anställda direkt i enheten och — efter — det totala antalet i hela subträdet samt
+/// barnnoderna. Muterbar där det behövs (Barn/summor fylls under trädbygget); allt annat är
+/// oföränderligt efter konstruktion.
+///
+public sealed class OrgTreeNode
+{
+ public OrganizationId Id { get; }
+ public string Namn { get; }
+ public OrganizationUnitType Typ { get; }
+ public string Kostnadsstalle { get; }
+ public Guid? OverordnadId { get; }
+
+ /// Antal aktiva anställda direkt i denna enhet (exkl. underenheter).
+ public int AntalAnstalldaDirekt { get; set; }
+
+ /// Antal aktiva anställda i hela subträdet (inkl. denna enhet). Sätts av trädbygget.
+ public int AntalAnstalldaTotalt { get; set; }
+
+ public List Barn { get; } = [];
+
+ /// Antal direkta underenheter.
+ public int AntalUnderenheter => Barn.Count;
+
+ public OrgTreeNode(
+ OrganizationId id,
+ string namn,
+ OrganizationUnitType typ,
+ string kostnadsstalle,
+ Guid? overordnadId,
+ int antalAnstalldaDirekt = 0)
+ {
+ Id = id;
+ Namn = namn;
+ Typ = typ;
+ Kostnadsstalle = kostnadsstalle;
+ OverordnadId = overordnadId;
+ AntalAnstalldaDirekt = antalAnstalldaDirekt;
+ AntalAnstalldaTotalt = antalAnstalldaDirekt;
+ }
+}
+
+/// Detaljvy för en organisationsenhet (enhetsfält + förälder + direkta barn).
+public record OrgEnhetDetalj(
+ OrganizationId Id,
+ string Namn,
+ OrganizationUnitType Typ,
+ string Kostnadsstalle,
+ string? CFARKod,
+ string? HsaId,
+ DateOnly GiltigFran,
+ DateOnly? GiltigTill,
+ OrganizationId? OverordnadId,
+ string? OverordnadNamn,
+ IReadOnlyList DirektaUnderenheter,
+ int AntalAnstallda);
+
+/// En direkt underenhet i detaljvyn (klickbar, med antal anställda).
+public record OrgEnhetBarn(OrganizationId Id, string Namn, OrganizationUnitType Typ, int AntalAnstallda);
+
+/// Rad i sammanfattningen "antal anställda per befattning".
+public record BefattningsAntal(string Befattning, int Antal);
diff --git a/tests/Web.Tests/Services/OrgTreeBuilderTests.cs b/tests/Web.Tests/Services/OrgTreeBuilderTests.cs
new file mode 100644
index 0000000..53d795e
--- /dev/null
+++ b/tests/Web.Tests/Services/OrgTreeBuilderTests.cs
@@ -0,0 +1,146 @@
+using RegionHR.SharedKernel.Domain;
+using RegionHR.Web.Services;
+
+namespace RegionHR.Web.Tests.Services;
+
+///
+/// Enhetstest för det rena in-minne-trädbygget ()
+/// som ersatte den gamla ".Include(Underenheter).Where(root)"-frågan (den laddade bara roten
+/// + en nivå barn). Bygget måste koppla ALLA nivåer och aldrig tappa en enhet.
+///
+public class OrgTreeBuilderTests
+{
+ private static OrgTreeNode Nod(Guid id, string namn, Guid? parent, int antalDirekt = 0) =>
+ new(OrganizationId.From(id), namn, OrganizationUnitType.Enhet, "10", parent, antalDirekt);
+
+ private static int RaknaNaabara(IEnumerable rotter)
+ {
+ var summa = 0;
+ foreach (var n in rotter)
+ summa += 1 + RaknaNaabara(n.Barn);
+ return summa;
+ }
+
+ [Fact]
+ public void ByggHierarki_KopplarAllaFyraNivaer()
+ {
+ var region = Guid.NewGuid();
+ var forvaltning = Guid.NewGuid();
+ var klinik = Guid.NewGuid();
+ var underenhet = Guid.NewGuid();
+
+ var platta = new[]
+ {
+ Nod(region, "Region Örebro län", null),
+ Nod(forvaltning, "Universitetssjukhuset", region),
+ Nod(klinik, "Kardiologkliniken", forvaltning),
+ Nod(underenhet, "Avdelning 32", klinik),
+ };
+
+ var rotter = AnstallningService.ByggHierarki(platta);
+
+ // Exakt EN rot, och hela kedjan region → förvaltning → klinik → underenhet är nåbar.
+ var rot = Assert.Single(rotter);
+ Assert.Equal("Region Örebro län", rot.Namn);
+
+ var f = Assert.Single(rot.Barn);
+ Assert.Equal("Universitetssjukhuset", f.Namn);
+
+ var k = Assert.Single(f.Barn);
+ Assert.Equal("Kardiologkliniken", k.Namn);
+
+ var u = Assert.Single(k.Barn);
+ Assert.Equal("Avdelning 32", u.Namn);
+
+ // Ingen enhet tappas: alla 4 är nåbara från roten.
+ Assert.Equal(4, RaknaNaabara(rotter));
+ }
+
+ [Fact]
+ public void ByggHierarki_BehandlarDinglandeForalderSomRot_SaIngenTappas()
+ {
+ var saknadForalder = Guid.NewGuid(); // finns INTE i urvalet
+ var barn = Guid.NewGuid();
+
+ var platta = new[]
+ {
+ Nod(barn, "Föräldralös enhet", saknadForalder),
+ };
+
+ var rotter = AnstallningService.ByggHierarki(platta);
+
+ var rot = Assert.Single(rotter);
+ Assert.Equal("Föräldralös enhet", rot.Namn);
+ Assert.Equal(1, RaknaNaabara(rotter));
+ }
+
+ [Fact]
+ public void ByggHierarki_StoderFleraRotter()
+ {
+ var r1 = Guid.NewGuid();
+ var r2 = Guid.NewGuid();
+ var barnTillR1 = Guid.NewGuid();
+
+ var platta = new[]
+ {
+ Nod(r1, "Rot A", null),
+ Nod(r2, "Rot B", null),
+ Nod(barnTillR1, "Barn under A", r1),
+ };
+
+ var rotter = AnstallningService.ByggHierarki(platta);
+
+ Assert.Equal(2, rotter.Count);
+ Assert.Equal(3, RaknaNaabara(rotter));
+ var rotA = Assert.Single(rotter, x => x.Namn == "Rot A");
+ Assert.Single(rotA.Barn);
+ Assert.Empty(Assert.Single(rotter, x => x.Namn == "Rot B").Barn);
+ }
+
+ [Fact]
+ public void ByggHierarki_RullarUppAntalAnstalldaOverSubtradet()
+ {
+ var region = Guid.NewGuid();
+ var forvaltning = Guid.NewGuid();
+ var klinikA = Guid.NewGuid();
+ var klinikB = Guid.NewGuid();
+
+ var platta = new[]
+ {
+ Nod(region, "Region", null, antalDirekt: 0),
+ Nod(forvaltning, "Förvaltning", region, antalDirekt: 2),
+ Nod(klinikA, "Klinik A", forvaltning, antalDirekt: 5),
+ Nod(klinikB, "Klinik B", forvaltning, antalDirekt: 3),
+ };
+
+ var rotter = AnstallningService.ByggHierarki(platta);
+ var rot = Assert.Single(rotter);
+
+ // Totalt = 0 + 2 + 5 + 3 = 10 i hela subträdet; direkt i roten = 0.
+ Assert.Equal(10, rot.AntalAnstalldaTotalt);
+ Assert.Equal(0, rot.AntalAnstalldaDirekt);
+
+ var f = Assert.Single(rot.Barn);
+ Assert.Equal(10, f.AntalAnstalldaTotalt); // 2 + 5 + 3
+ Assert.Equal(2, f.AntalAnstalldaDirekt);
+ Assert.Equal(2, f.AntalUnderenheter);
+
+ var kA = Assert.Single(f.Barn, x => x.Namn == "Klinik A");
+ Assert.Equal(5, kA.AntalAnstalldaTotalt); // löv → totalt == direkt
+ Assert.Equal(5, kA.AntalAnstalldaDirekt);
+ }
+
+ [Fact]
+ public void ByggHierarki_IgnorererSjalvreferens_UtanAttTappaNoden()
+ {
+ var id = Guid.NewGuid();
+ // En enhet som (felaktigt) pekar på sig själv får inte bli sitt eget barn eller försvinna.
+ var platta = new[] { Nod(id, "Självreferens", id) };
+
+ var rotter = AnstallningService.ByggHierarki(platta);
+
+ var rot = Assert.Single(rotter);
+ Assert.Empty(rot.Barn);
+ Assert.Equal(1, RaknaNaabara(rotter));
+ }
+}