# Templates Folder Hierarchy Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.

**Goal:** Replace the flat `/design/templates` list with a Wonderware-style folder tree: nested `TemplateFolder` entity, `Template.FolderId`, composition children as inline tree leaves, split-pane editor on the right, context menus + drag-drop reorganization.

**Architecture:** New `TemplateFolder` entity (self-referencing `ParentFolderId`); nullable `FolderId` on `Template`. A new `TemplateFolderService` mirrors the existing `TemplateService` pattern (Result<T>, audit, repository). Repository methods are added to `ITemplateEngineRepository`. Management commands follow the existing `<Verb><Noun>Command` record convention auto-registered by `ManagementCommandRegistry`. The Blazor page uses the existing generic `TreeView<TItem>` with a unified `TmplNode` discriminated by kind (Folder / Template / Composition) and a split-pane Bootstrap layout. Drag-drop uses native HTML5; server enforces all invariants (cycle detection, sibling-name uniqueness, non-empty-on-delete).

**Tech Stack:** .NET 9, Akka.NET, EF Core (SQLite), Blazor Server, Bootstrap 5, bUnit + xUnit + Moq.

**Design doc:** [`docs/plans/2026-05-11-templates-folder-hierarchy-design.md`](2026-05-11-templates-folder-hierarchy-design.md)

**Conventions confirmed from codebase exploration:**
- Service file: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/TemplateFolderService.cs` (matches AreaService/InstanceService/SiteService convention).
- Audit signature: `IAuditService.LogAsync(user, operation, entityType, entityId, displayName, payload, ct)`.
- Repository: extend `ITemplateEngineRepository` + `TemplateEngineRepository.cs` (single concrete impl).
- DbContext: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs`, configurations in `Configurations/`.
- Management commands: simple records in `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/*.cs`; auto-discovered by reflection.
- ManagementActor dispatch + authorization both via `switch` on command type.
- UI injects `TemplateService` directly (and will inject `TemplateFolderService` the same way) — no HTTP round trip.

---

## Task 0: Confirm baseline + create work branch

**Files:** none modified — verification only.

**Step 1: Verify clean working tree**

Run: `git status`
Expected: `nothing to commit, working tree clean` (the design doc was committed in commit `daa0126`).

**Step 2: Verify the design doc is present**

Run: `ls docs/plans/2026-05-11-templates-folder-hierarchy-design.md`
Expected: file exists.

**Step 3: Build baseline passes**

Run: `dotnet build ZB.MOM.WW.ScadaBridge.slnx`
Expected: build succeeds with 0 errors.

**Step 4: Existing tests pass**

Run: `dotnet test ZB.MOM.WW.ScadaBridge.slnx --filter "FullyQualifiedName~TemplateEngine.Tests|FullyQualifiedName~CentralUI.Tests" --nologo`
Expected: all green.

**No commit at this task** — it's verification only.

---

## Task 1: Add `TemplateFolder` entity + `Template.FolderId`

**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Templates/TemplateFolder.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Templates/Template.cs`

**Step 1: Write the failing test**

Add to `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/TemplateFolderTests.cs` (create new file):

```csharp
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using Xunit;

namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Entities;

public class TemplateFolderTests
{
    [Fact]
    public void Constructor_SetsName()
    {
        var folder = new TemplateFolder("Dev");

        Assert.Equal("Dev", folder.Name);
        Assert.Null(folder.ParentFolderId);
        Assert.Equal(0, folder.SortOrder);
    }

    [Fact]
    public void Constructor_NullName_Throws()
    {
        Assert.Throws<ArgumentNullException>(() => new TemplateFolder(null!));
    }

    [Fact]
    public void Template_FolderId_DefaultsToNull()
    {
        var template = new Template("X");
        Assert.Null(template.FolderId);
    }
}
```

**Step 2: Run test — confirm fail**

Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ZB.MOM.WW.ScadaBridge.Commons.Tests.csproj --filter "FullyQualifiedName~TemplateFolderTests" --nologo`
Expected: FAIL (`TemplateFolder` type not found, `Template.FolderId` not found).

**Step 3: Implement the entity**

Create `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Templates/TemplateFolder.cs`:

```csharp
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;

public class TemplateFolder
{
    public int Id { get; set; }
    public string Name { get; set; }
    public int? ParentFolderId { get; set; }
    public int SortOrder { get; set; }

    public TemplateFolder(string name)
    {
        Name = name ?? throw new ArgumentNullException(nameof(name));
    }
}
```

**Step 4: Add `FolderId` to `Template`**

Edit `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Templates/Template.cs` — add this property after `ParentTemplateId`:

```csharp
public int? FolderId { get; set; }
```

**Step 5: Run tests — confirm pass**

Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ZB.MOM.WW.ScadaBridge.Commons.Tests.csproj --filter "FullyQualifiedName~TemplateFolderTests" --nologo`
Expected: PASS.

**Step 6: Commit**

```bash
git add src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Templates/TemplateFolder.cs \
        src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Templates/Template.cs \
        tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/TemplateFolderTests.cs
git commit -m "feat(templates): add TemplateFolder entity and Template.FolderId"
```

---

## Task 2: EF configuration for `TemplateFolder` + `Template.FolderId`

**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/TemplateConfiguration.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs`

**Step 1: Add `DbSet<TemplateFolder>` to DbContext**

Edit `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs` — under the `// Templates` region, add:

```csharp
public DbSet<TemplateFolder> TemplateFolders => Set<TemplateFolder>();
```

**Step 2: Add `TemplateFolderConfiguration` class**

Append to `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/TemplateConfiguration.cs`:

```csharp
public class TemplateFolderConfiguration : IEntityTypeConfiguration<TemplateFolder>
{
    public void Configure(EntityTypeBuilder<TemplateFolder> builder)
    {
        builder.HasKey(f => f.Id);

        builder.Property(f => f.Name)
            .IsRequired()
            .HasMaxLength(200);

        builder.HasOne<TemplateFolder>()
            .WithMany()
            .HasForeignKey(f => f.ParentFolderId)
            .OnDelete(DeleteBehavior.Restrict)
            .IsRequired(false);

        // Unique sibling name (case-insensitive enforced at service layer; this index is for fast lookup)
        builder.HasIndex(f => new { f.ParentFolderId, f.Name }).IsUnique();
    }
}
```

**Step 3: Add `FolderId` FK to `TemplateConfiguration`**

In the same file, inside `TemplateConfiguration.Configure`, add after the parent-template FK block:

```csharp
builder.HasOne<TemplateFolder>()
    .WithMany()
    .HasForeignKey(t => t.FolderId)
    .OnDelete(DeleteBehavior.Restrict)
    .IsRequired(false);
```

**Step 4: Verify build**

Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.csproj`
Expected: 0 errors.

**Step 5: Commit**

```bash
git add src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/TemplateConfiguration.cs \
        src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs
git commit -m "feat(db): map TemplateFolder entity and Template.FolderId"
```

---

## Task 3: Generate EF migration `AddTemplateFolders`

**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/<timestamp>_AddTemplateFolders.cs` (generated)
- Create: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/<timestamp>_AddTemplateFolders.Designer.cs` (generated)
- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs` (generated)

**Step 1: Run migration generator**

Run: `dotnet ef migrations add AddTemplateFolders --project src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase --startup-project src/ZB.MOM.WW.ScadaBridge.Host`
Expected: three files created/updated under `Migrations/`. Dev DB (auto-apply on startup per CLAUDE.md) will apply on next host start.

**Step 2: Inspect the generated `Up()` method**

Open the new `*_AddTemplateFolders.cs` and confirm:
- `CreateTable("TemplateFolders", ...)` with columns `Id`, `Name`, `ParentFolderId` (nullable), `SortOrder`.
- Self-referencing FK on `ParentFolderId` with `onDelete: Restrict`.
- Unique index on `(ParentFolderId, Name)`.
- `AddColumn<int>("FolderId", "Templates", nullable: true)`.
- FK on `Templates.FolderId` → `TemplateFolders.Id` with `onDelete: Restrict`.

If any are missing, the entity config in Task 2 was wrong — fix and regenerate (delete the migration files, redo Task 2 fix, rerun the migration command).

**Step 3: Build to verify**

Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.csproj`
Expected: 0 errors.

**Step 4: Commit**

```bash
git add src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/
git commit -m "feat(db): EF migration AddTemplateFolders"
```

---

## Task 4: Repository methods on `ITemplateEngineRepository`

**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs`

**Step 1: Add interface methods**

Edit `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs`. Above the trailing `Task<int> SaveChangesAsync(...)`, insert:

```csharp
// TemplateFolder
Task<TemplateFolder?> GetFolderByIdAsync(int id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<TemplateFolder>> GetAllFoldersAsync(CancellationToken cancellationToken = default);
Task AddFolderAsync(TemplateFolder folder, CancellationToken cancellationToken = default);
Task UpdateFolderAsync(TemplateFolder folder, CancellationToken cancellationToken = default);
Task DeleteFolderAsync(int id, CancellationToken cancellationToken = default);
```

(Move-template is just an `UpdateTemplateAsync` of the `FolderId` field — no new method needed.)

**Step 2: Implement on `TemplateEngineRepository`**

Edit `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs`. Append (above the closing brace of the class):

```csharp
// TemplateFolder

public async Task<TemplateFolder?> GetFolderByIdAsync(int id, CancellationToken cancellationToken = default)
    => await _context.TemplateFolders.FirstOrDefaultAsync(f => f.Id == id, cancellationToken);

public async Task<IReadOnlyList<TemplateFolder>> GetAllFoldersAsync(CancellationToken cancellationToken = default)
    => await _context.TemplateFolders.AsNoTracking().ToListAsync(cancellationToken);

public async Task AddFolderAsync(TemplateFolder folder, CancellationToken cancellationToken = default)
    => await _context.TemplateFolders.AddAsync(folder, cancellationToken);

public async Task UpdateFolderAsync(TemplateFolder folder, CancellationToken cancellationToken = default)
{
    _context.TemplateFolders.Update(folder);
    await Task.CompletedTask;
}

public async Task DeleteFolderAsync(int id, CancellationToken cancellationToken = default)
{
    var folder = await _context.TemplateFolders.FirstOrDefaultAsync(f => f.Id == id, cancellationToken);
    if (folder != null) _context.TemplateFolders.Remove(folder);
}
```

**Step 3: Build**

Run: `dotnet build ZB.MOM.WW.ScadaBridge.slnx`
Expected: 0 errors. Any compile failure means a mock somewhere implements `ITemplateEngineRepository` without these methods — search and fix:

Run: `grep -rln "ITemplateEngineRepository" tests/ --include="*.cs"` and confirm Moq mocks don't need stubs (Moq generates defaults).

**Step 4: Commit**

```bash
git add src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs \
        src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs
git commit -m "feat(repo): add TemplateFolder repository methods"
```

---

## Task 5: `TemplateFolderService` — CreateFolderAsync (TDD)

**Files:**
- Create: `tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/TemplateFolderService.cs`

**Step 1: Write failing tests**

Create `tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs`:

```csharp
using Moq;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;

namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Services;

public class TemplateFolderServiceTests
{
    private readonly Mock<ITemplateEngineRepository> _repoMock = new();
    private readonly Mock<IAuditService> _auditMock = new();
    private readonly TemplateFolderService _sut;

    public TemplateFolderServiceTests()
    {
        _sut = new TemplateFolderService(_repoMock.Object, _auditMock.Object);
    }

    [Fact]
    public async Task CreateFolder_ValidInput_ReturnsSuccess()
    {
        _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<TemplateFolder>());

        var result = await _sut.CreateFolderAsync("Dev", null, "admin");

        Assert.True(result.IsSuccess);
        Assert.Equal("Dev", result.Value.Name);
        Assert.Null(result.Value.ParentFolderId);
        _repoMock.Verify(r => r.AddFolderAsync(It.IsAny<TemplateFolder>(), It.IsAny<CancellationToken>()), Times.Once);
        _repoMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
    }

    [Fact]
    public async Task CreateFolder_EmptyName_ReturnsFailure()
    {
        var result = await _sut.CreateFolderAsync("  ", null, "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("required", result.Error, StringComparison.OrdinalIgnoreCase);
    }

    [Fact]
    public async Task CreateFolder_DuplicateSiblingName_CaseInsensitive_ReturnsFailure()
    {
        _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<TemplateFolder>
            {
                new("Dev") { Id = 1, ParentFolderId = null }
            });

        var result = await _sut.CreateFolderAsync("dev", null, "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("already exists", result.Error);
    }

    [Fact]
    public async Task CreateFolder_ParentNotFound_ReturnsFailure()
    {
        _repoMock.Setup(r => r.GetFolderByIdAsync(99, It.IsAny<CancellationToken>()))
            .ReturnsAsync((TemplateFolder?)null);

        var result = await _sut.CreateFolderAsync("Sub", 99, "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("not found", result.Error);
    }
}
```

**Step 2: Run — confirm fail**

Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/ --filter "FullyQualifiedName~TemplateFolderServiceTests" --nologo`
Expected: FAIL (`TemplateFolderService` not found).

**Step 3: Implement minimal service**

Create `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/TemplateFolderService.cs`:

```csharp
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types;

namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;

public class TemplateFolderService
{
    private readonly ITemplateEngineRepository _repository;
    private readonly IAuditService _auditService;

    public TemplateFolderService(ITemplateEngineRepository repository, IAuditService auditService)
    {
        _repository = repository ?? throw new ArgumentNullException(nameof(repository));
        _auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
    }

    public async Task<Result<TemplateFolder>> CreateFolderAsync(
        string name, int? parentFolderId, string user,
        CancellationToken cancellationToken = default)
    {
        if (string.IsNullOrWhiteSpace(name))
            return Result<TemplateFolder>.Failure("Folder name is required.");

        if (parentFolderId.HasValue)
        {
            var parent = await _repository.GetFolderByIdAsync(parentFolderId.Value, cancellationToken);
            if (parent == null)
                return Result<TemplateFolder>.Failure($"Parent folder with ID {parentFolderId.Value} not found.");
        }

        var all = await _repository.GetAllFoldersAsync(cancellationToken);
        if (all.Any(f => f.ParentFolderId == parentFolderId
                         && string.Equals(f.Name, name, StringComparison.OrdinalIgnoreCase)))
            return Result<TemplateFolder>.Failure($"A folder named '{name}' already exists at this level.");

        var folder = new TemplateFolder(name) { ParentFolderId = parentFolderId };
        await _repository.AddFolderAsync(folder, cancellationToken);
        await _repository.SaveChangesAsync(cancellationToken);
        await _auditService.LogAsync(user, "Create", "TemplateFolder", folder.Id.ToString(), name, folder, cancellationToken);

        return Result<TemplateFolder>.Success(folder);
    }
}
```

**Step 4: Run — confirm pass**

Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/ --filter "FullyQualifiedName~TemplateFolderServiceTests" --nologo`
Expected: PASS (4 tests).

**Step 5: Commit**

```bash
git add tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs \
        src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/TemplateFolderService.cs
git commit -m "feat(template-folder): add TemplateFolderService.CreateFolderAsync with validation"
```

---

## Task 6: `TemplateFolderService.RenameFolderAsync`

**Files:**
- Modify: `tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/TemplateFolderService.cs`

**Step 1: Add failing tests**

Append to `TemplateFolderServiceTests.cs`:

```csharp
[Fact]
public async Task RenameFolder_ValidInput_ReturnsSuccess()
{
    var folder = new TemplateFolder("Old") { Id = 1, ParentFolderId = null };
    _repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
    _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
        .ReturnsAsync(new List<TemplateFolder> { folder });

    var result = await _sut.RenameFolderAsync(1, "New", "admin");

    Assert.True(result.IsSuccess);
    Assert.Equal("New", result.Value.Name);
}

[Fact]
public async Task RenameFolder_NotFound_ReturnsFailure()
{
    _repoMock.Setup(r => r.GetFolderByIdAsync(99, It.IsAny<CancellationToken>()))
        .ReturnsAsync((TemplateFolder?)null);

    var result = await _sut.RenameFolderAsync(99, "New", "admin");

    Assert.True(result.IsFailure);
}

[Fact]
public async Task RenameFolder_DuplicateSibling_ReturnsFailure()
{
    var folder = new TemplateFolder("Old") { Id = 1, ParentFolderId = null };
    var sibling = new TemplateFolder("Other") { Id = 2, ParentFolderId = null };
    _repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
    _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
        .ReturnsAsync(new List<TemplateFolder> { folder, sibling });

    var result = await _sut.RenameFolderAsync(1, "Other", "admin");

    Assert.True(result.IsFailure);
    Assert.Contains("already exists", result.Error);
}
```

**Step 2: Run — confirm fail**

Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/ --filter "FullyQualifiedName~TemplateFolderServiceTests.RenameFolder" --nologo`
Expected: FAIL (method missing).

**Step 3: Implement `RenameFolderAsync`**

Add to `TemplateFolderService.cs`:

```csharp
public async Task<Result<TemplateFolder>> RenameFolderAsync(
    int folderId, string newName, string user,
    CancellationToken cancellationToken = default)
{
    if (string.IsNullOrWhiteSpace(newName))
        return Result<TemplateFolder>.Failure("Folder name is required.");

    var folder = await _repository.GetFolderByIdAsync(folderId, cancellationToken);
    if (folder == null)
        return Result<TemplateFolder>.Failure($"Folder with ID {folderId} not found.");

    var all = await _repository.GetAllFoldersAsync(cancellationToken);
    if (all.Any(f => f.Id != folderId
                     && f.ParentFolderId == folder.ParentFolderId
                     && string.Equals(f.Name, newName, StringComparison.OrdinalIgnoreCase)))
        return Result<TemplateFolder>.Failure($"A folder named '{newName}' already exists at this level.");

    folder.Name = newName;
    await _repository.UpdateFolderAsync(folder, cancellationToken);
    await _repository.SaveChangesAsync(cancellationToken);
    await _auditService.LogAsync(user, "Update", "TemplateFolder", folder.Id.ToString(), newName, folder, cancellationToken);

    return Result<TemplateFolder>.Success(folder);
}
```

**Step 4: Run — confirm pass**

Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/ --filter "FullyQualifiedName~TemplateFolderServiceTests" --nologo`
Expected: PASS (7 tests now).

**Step 5: Commit**

```bash
git add tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs \
        src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/TemplateFolderService.cs
git commit -m "feat(template-folder): rename folder with sibling uniqueness check"
```

---

## Task 7: `TemplateFolderService.MoveFolderAsync` with cycle detection

**Files:**
- Modify: `tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/TemplateFolderService.cs`

**Step 1: Add failing tests**

Append:

```csharp
[Fact]
public async Task MoveFolder_ValidParent_ReturnsSuccess()
{
    var f1 = new TemplateFolder("A") { Id = 1, ParentFolderId = null };
    var f2 = new TemplateFolder("B") { Id = 2, ParentFolderId = null };
    _repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(f1);
    _repoMock.Setup(r => r.GetFolderByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(f2);
    _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
        .ReturnsAsync(new List<TemplateFolder> { f1, f2 });

    var result = await _sut.MoveFolderAsync(1, 2, "admin");

    Assert.True(result.IsSuccess);
    Assert.Equal(2, result.Value.ParentFolderId);
}

[Fact]
public async Task MoveFolder_OntoSelf_ReturnsFailure()
{
    var f1 = new TemplateFolder("A") { Id = 1 };
    _repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(f1);

    var result = await _sut.MoveFolderAsync(1, 1, "admin");

    Assert.True(result.IsFailure);
    Assert.Contains("cycle", result.Error, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task MoveFolder_OntoDescendant_ReturnsFailure()
{
    // A -> B -> C; attempting to move A under C must fail.
    var fa = new TemplateFolder("A") { Id = 1, ParentFolderId = null };
    var fb = new TemplateFolder("B") { Id = 2, ParentFolderId = 1 };
    var fc = new TemplateFolder("C") { Id = 3, ParentFolderId = 2 };
    _repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(fa);
    _repoMock.Setup(r => r.GetFolderByIdAsync(3, It.IsAny<CancellationToken>())).ReturnsAsync(fc);
    _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
        .ReturnsAsync(new List<TemplateFolder> { fa, fb, fc });

    var result = await _sut.MoveFolderAsync(1, 3, "admin");

    Assert.True(result.IsFailure);
    Assert.Contains("cycle", result.Error, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task MoveFolder_ToRoot_ReturnsSuccess()
{
    var f = new TemplateFolder("Sub") { Id = 1, ParentFolderId = 99 };
    _repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(f);
    _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
        .ReturnsAsync(new List<TemplateFolder> { f });

    var result = await _sut.MoveFolderAsync(1, null, "admin");

    Assert.True(result.IsSuccess);
    Assert.Null(result.Value.ParentFolderId);
}
```

**Step 2: Run — confirm fail**

Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/ --filter "FullyQualifiedName~MoveFolder" --nologo`
Expected: FAIL (method missing).

**Step 3: Implement**

Add to `TemplateFolderService.cs`:

```csharp
public async Task<Result<TemplateFolder>> MoveFolderAsync(
    int folderId, int? newParentId, string user,
    CancellationToken cancellationToken = default)
{
    var folder = await _repository.GetFolderByIdAsync(folderId, cancellationToken);
    if (folder == null)
        return Result<TemplateFolder>.Failure($"Folder with ID {folderId} not found.");

    if (newParentId.HasValue)
    {
        if (newParentId.Value == folderId)
            return Result<TemplateFolder>.Failure("Cannot move a folder into itself (cycle).");

        var newParent = await _repository.GetFolderByIdAsync(newParentId.Value, cancellationToken);
        if (newParent == null)
            return Result<TemplateFolder>.Failure($"Target folder with ID {newParentId.Value} not found.");

        var all = await _repository.GetAllFoldersAsync(cancellationToken);
        // Walk up from newParentId — if we encounter folderId, the move would create a cycle.
        var byId = all.ToDictionary(f => f.Id);
        var cursor = newParentId;
        while (cursor.HasValue)
        {
            if (cursor.Value == folderId)
                return Result<TemplateFolder>.Failure("Cannot move a folder under one of its descendants (cycle).");
            cursor = byId.TryGetValue(cursor.Value, out var node) ? node.ParentFolderId : null;
        }

        // Sibling-name uniqueness in destination.
        if (all.Any(f => f.Id != folderId
                         && f.ParentFolderId == newParentId
                         && string.Equals(f.Name, folder.Name, StringComparison.OrdinalIgnoreCase)))
            return Result<TemplateFolder>.Failure($"A folder named '{folder.Name}' already exists in the target folder.");
    }
    else
    {
        var all = await _repository.GetAllFoldersAsync(cancellationToken);
        if (all.Any(f => f.Id != folderId
                         && f.ParentFolderId == null
                         && string.Equals(f.Name, folder.Name, StringComparison.OrdinalIgnoreCase)))
            return Result<TemplateFolder>.Failure($"A folder named '{folder.Name}' already exists at the root.");
    }

    folder.ParentFolderId = newParentId;
    await _repository.UpdateFolderAsync(folder, cancellationToken);
    await _repository.SaveChangesAsync(cancellationToken);
    await _auditService.LogAsync(user, "Move", "TemplateFolder", folder.Id.ToString(), folder.Name, folder, cancellationToken);

    return Result<TemplateFolder>.Success(folder);
}
```

**Step 4: Run — confirm pass**

Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/ --filter "FullyQualifiedName~TemplateFolderServiceTests" --nologo`
Expected: PASS (11 tests).

**Step 5: Commit**

```bash
git add tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs \
        src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/TemplateFolderService.cs
git commit -m "feat(template-folder): move with cycle detection and sibling uniqueness"
```

---

## Task 8: `TemplateFolderService.DeleteFolderAsync` (non-empty check)

**Files:**
- Modify: `tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/TemplateFolderService.cs`

**Step 1: Add failing tests**

Append:

```csharp
[Fact]
public async Task DeleteFolder_Empty_ReturnsSuccess()
{
    var f = new TemplateFolder("Empty") { Id = 1 };
    _repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(f);
    _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
        .ReturnsAsync(new List<TemplateFolder> { f });
    _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
        .ReturnsAsync(new List<Template>());

    var result = await _sut.DeleteFolderAsync(1, "admin");

    Assert.True(result.IsSuccess);
    _repoMock.Verify(r => r.DeleteFolderAsync(1, It.IsAny<CancellationToken>()), Times.Once);
}

[Fact]
public async Task DeleteFolder_HasChildFolders_ReturnsFailure_WithCounts()
{
    var parent = new TemplateFolder("P") { Id = 1 };
    var child = new TemplateFolder("C") { Id = 2, ParentFolderId = 1 };
    _repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(parent);
    _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
        .ReturnsAsync(new List<TemplateFolder> { parent, child });
    _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
        .ReturnsAsync(new List<Template>());

    var result = await _sut.DeleteFolderAsync(1, "admin");

    Assert.True(result.IsFailure);
    Assert.Contains("1 subfolder", result.Error);
}

[Fact]
public async Task DeleteFolder_HasTemplates_ReturnsFailure_WithCounts()
{
    var f = new TemplateFolder("P") { Id = 1 };
    var t = new Template("X") { Id = 5, FolderId = 1 };
    _repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(f);
    _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
        .ReturnsAsync(new List<TemplateFolder> { f });
    _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
        .ReturnsAsync(new List<Template> { t });

    var result = await _sut.DeleteFolderAsync(1, "admin");

    Assert.True(result.IsFailure);
    Assert.Contains("1 template", result.Error);
}
```

**Step 2: Run — confirm fail**

Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/ --filter "FullyQualifiedName~DeleteFolder" --nologo`
Expected: FAIL.

**Step 3: Implement**

Add to `TemplateFolderService.cs`:

```csharp
public async Task<Result<bool>> DeleteFolderAsync(
    int folderId, string user,
    CancellationToken cancellationToken = default)
{
    var folder = await _repository.GetFolderByIdAsync(folderId, cancellationToken);
    if (folder == null)
        return Result<bool>.Failure($"Folder with ID {folderId} not found.");

    var allFolders = await _repository.GetAllFoldersAsync(cancellationToken);
    var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);

    var childFolderCount = allFolders.Count(f => f.ParentFolderId == folderId);
    var childTemplateCount = allTemplates.Count(t => t.FolderId == folderId);

    if (childFolderCount > 0 || childTemplateCount > 0)
    {
        var parts = new List<string>();
        if (childTemplateCount > 0)
            parts.Add($"{childTemplateCount} template{(childTemplateCount == 1 ? "" : "s")}");
        if (childFolderCount > 0)
            parts.Add($"{childFolderCount} subfolder{(childFolderCount == 1 ? "" : "s")}");
        return Result<bool>.Failure(
            $"Cannot delete folder '{folder.Name}': it contains {string.Join(" and ", parts)}. " +
            "Move or delete contents first.");
    }

    await _repository.DeleteFolderAsync(folderId, cancellationToken);
    await _repository.SaveChangesAsync(cancellationToken);
    await _auditService.LogAsync(user, "Delete", "TemplateFolder", folderId.ToString(), folder.Name, null, cancellationToken);

    return Result<bool>.Success(true);
}
```

**Step 4: Run — confirm pass**

Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/ --filter "FullyQualifiedName~TemplateFolderServiceTests" --nologo`
Expected: PASS (14 tests).

**Step 5: Commit**

```bash
git add tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs \
        src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/TemplateFolderService.cs
git commit -m "feat(template-folder): delete folder blocked if non-empty"
```

---

## Task 9: `TemplateService.MoveTemplateAsync`

**Files:**
- Modify: `tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateServiceTests.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs`

**Step 1: Add failing tests**

Append to `tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateServiceTests.cs` (inside the existing test class):

```csharp
[Fact]
public async Task MoveTemplate_ToFolder_ReturnsSuccess()
{
    var t = new Template("X") { Id = 1, FolderId = null };
    var folder = new TemplateFolder("Dev") { Id = 7 };
    _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(t);
    _repoMock.Setup(r => r.GetFolderByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(folder);

    var result = await _sut.MoveTemplateAsync(1, 7, "admin");

    Assert.True(result.IsSuccess);
    Assert.Equal(7, result.Value.FolderId);
}

[Fact]
public async Task MoveTemplate_ToRoot_ReturnsSuccess()
{
    var t = new Template("X") { Id = 1, FolderId = 7 };
    _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(t);

    var result = await _sut.MoveTemplateAsync(1, null, "admin");

    Assert.True(result.IsSuccess);
    Assert.Null(result.Value.FolderId);
}

[Fact]
public async Task MoveTemplate_TargetFolderMissing_ReturnsFailure()
{
    var t = new Template("X") { Id = 1 };
    _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(t);
    _repoMock.Setup(r => r.GetFolderByIdAsync(99, It.IsAny<CancellationToken>())).ReturnsAsync((TemplateFolder?)null);

    var result = await _sut.MoveTemplateAsync(1, 99, "admin");

    Assert.True(result.IsFailure);
    Assert.Contains("not found", result.Error);
}
```

(If `_repoMock` / `_sut` are named differently in the existing file, mirror the existing convention there.)

**Step 2: Run — confirm fail**

Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/ --filter "FullyQualifiedName~MoveTemplate" --nologo`
Expected: FAIL.

**Step 3: Implement on `TemplateService`**

Add to `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs` (above the helper methods section):

```csharp
public async Task<Result<Template>> MoveTemplateAsync(
    int templateId, int? newFolderId, string user,
    CancellationToken cancellationToken = default)
{
    var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
    if (template == null)
        return Result<Template>.Failure($"Template with ID {templateId} not found.");

    if (newFolderId.HasValue)
    {
        var folder = await _repository.GetFolderByIdAsync(newFolderId.Value, cancellationToken);
        if (folder == null)
            return Result<Template>.Failure($"Target folder with ID {newFolderId.Value} not found.");
    }

    template.FolderId = newFolderId;
    await _repository.UpdateTemplateAsync(template, cancellationToken);
    await _repository.SaveChangesAsync(cancellationToken);
    await _auditService.LogAsync(user, "Move", "Template", template.Id.ToString(), template.Name, template, cancellationToken);

    return Result<Template>.Success(template);
}
```

**Step 4: Run — confirm pass**

Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/ --filter "FullyQualifiedName~TemplateServiceTests" --nologo`
Expected: all pass (existing + 3 new).

**Step 5: Commit**

```bash
git add tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateServiceTests.cs \
        src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs
git commit -m "feat(template-engine): TemplateService.MoveTemplateAsync"
```

---

## Task 10: DI registration

**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/ServiceCollectionExtensions.cs`

**Step 1: Add registration**

Inside `AddTemplateEngine`, alongside the other `AddScoped<...>()` lines:

```csharp
services.AddScoped<TemplateFolderService>();
```

**Step 2: Build**

Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.TemplateEngine/ZB.MOM.WW.ScadaBridge.TemplateEngine.csproj`
Expected: 0 errors.

**Step 3: Commit**

```bash
git add src/ZB.MOM.WW.ScadaBridge.TemplateEngine/ServiceCollectionExtensions.cs
git commit -m "feat(di): register TemplateFolderService"
```

---

## Task 11: Management command records

**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TemplateFolderCommands.cs`

**Step 1: Create file**

```csharp
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;

public record ListTemplateFoldersCommand;
public record CreateTemplateFolderCommand(string Name, int? ParentFolderId);
public record RenameTemplateFolderCommand(int FolderId, string NewName);
public record MoveTemplateFolderCommand(int FolderId, int? NewParentFolderId);
public record DeleteTemplateFolderCommand(int FolderId);
public record MoveTemplateToFolderCommand(int TemplateId, int? NewFolderId);
```

**Step 2: Verify auto-registration**

Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ --nologo` — if a `ManagementCommandRegistry` smoke test exists it picks up these new types automatically (registration is reflection-based).

If no existing smoke test catches missing dispatch, this is just a build check:
Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj`
Expected: 0 errors.

**Step 3: Commit**

```bash
git add src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TemplateFolderCommands.cs
git commit -m "feat(management): add TemplateFolder command records"
```

---

## Task 12: `ManagementActor` authorization + handlers

**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs`

**Step 1: Add to `GetRequiredRole`**

In `GetRequiredRole`, inside the `=> "Design"` pattern-match block (around line 113), append the new commands:

```csharp
or CreateTemplateFolderCommand or RenameTemplateFolderCommand
or MoveTemplateFolderCommand or DeleteTemplateFolderCommand
or MoveTemplateToFolderCommand => "Design",
```

(`ListTemplateFoldersCommand` is a read-only query and falls through to the `_ => null` branch — any authenticated user can list.)

**Step 2: Add dispatch cases**

Inside `DispatchCommand`, alongside the existing template-member entries, add:

```csharp
// Template folders
ListTemplateFoldersCommand => await HandleListTemplateFolders(sp),
CreateTemplateFolderCommand cmd => await HandleCreateTemplateFolder(sp, cmd, user.Username),
RenameTemplateFolderCommand cmd => await HandleRenameTemplateFolder(sp, cmd, user.Username),
MoveTemplateFolderCommand cmd => await HandleMoveTemplateFolder(sp, cmd, user.Username),
DeleteTemplateFolderCommand cmd => await HandleDeleteTemplateFolder(sp, cmd, user.Username),
MoveTemplateToFolderCommand cmd => await HandleMoveTemplateToFolder(sp, cmd, user.Username),
```

**Step 3: Add handler methods**

Near the existing `HandleListTemplates` block in `ManagementActor.cs`, add:

```csharp
private static async Task<object?> HandleListTemplateFolders(IServiceProvider sp)
{
    var repo = sp.GetRequiredService<ITemplateEngineRepository>();
    return await repo.GetAllFoldersAsync();
}

private static async Task<object?> HandleCreateTemplateFolder(IServiceProvider sp, CreateTemplateFolderCommand cmd, string user)
{
    var svc = sp.GetRequiredService<TemplateFolderService>();
    var result = await svc.CreateFolderAsync(cmd.Name, cmd.ParentFolderId, user);
    return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
}

private static async Task<object?> HandleRenameTemplateFolder(IServiceProvider sp, RenameTemplateFolderCommand cmd, string user)
{
    var svc = sp.GetRequiredService<TemplateFolderService>();
    var result = await svc.RenameFolderAsync(cmd.FolderId, cmd.NewName, user);
    return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
}

private static async Task<object?> HandleMoveTemplateFolder(IServiceProvider sp, MoveTemplateFolderCommand cmd, string user)
{
    var svc = sp.GetRequiredService<TemplateFolderService>();
    var result = await svc.MoveFolderAsync(cmd.FolderId, cmd.NewParentFolderId, user);
    return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
}

private static async Task<object?> HandleDeleteTemplateFolder(IServiceProvider sp, DeleteTemplateFolderCommand cmd, string user)
{
    var svc = sp.GetRequiredService<TemplateFolderService>();
    var result = await svc.DeleteFolderAsync(cmd.FolderId, user);
    return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
}

private static async Task<object?> HandleMoveTemplateToFolder(IServiceProvider sp, MoveTemplateToFolderCommand cmd, string user)
{
    var svc = sp.GetRequiredService<TemplateService>();
    var result = await svc.MoveTemplateAsync(cmd.TemplateId, cmd.NewFolderId, user);
    return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
}
```

**Step 4: Add missing `using` if needed**

If `TemplateFolderService` isn't already imported, add `using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;` near the top.

**Step 5: Build + test**

Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.ManagementService/ZB.MOM.WW.ScadaBridge.ManagementService.csproj`
Expected: 0 errors.
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ --nologo`
Expected: all pass.

**Step 6: Commit**

```bash
git add src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
git commit -m "feat(management): handler + authorization for TemplateFolder commands"
```

---

## Task 13: Templates.razor — extend data loading to include folders

**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/Templates.razor`

This task only changes the `@code` block. The markup is rewritten in Task 14.

**Step 1: Inject the new service**

Near the top of the file, add:

```razor
@inject TemplateFolderService TemplateFolderService
```

And the using:

```razor
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services
```

**Step 2: Add folder field**

In the `@code` block, alongside `_templates`:

```csharp
private List<TemplateFolder> _folders = new();
```

**Step 3: Load folders alongside templates**

Replace the body of `LoadTemplatesAsync` so it loads both:

```csharp
private async Task LoadTemplatesAsync()
{
    _loading = true;
    try
    {
        _templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
        _folders = (await TemplateEngineRepository.GetAllFoldersAsync()).ToList();
        BuildTemplateTree();
    }
    catch (Exception ex)
    {
        _errorMessage = $"Failed to load templates: {ex.Message}";
    }
    _loading = false;
}
```

**Step 4: Build verify**

Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj`
Expected: 0 errors. (`BuildTemplateTree` will be rewritten next task — current impl still compiles.)

**Step 5: Commit**

```bash
git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/Templates.razor
git commit -m "feat(ui/templates): load folders alongside templates"
```

---

## Task 14: Build the new `TmplNode` tree model

**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/Templates.razor`

**Step 1: Replace the existing `TmplTreeNode` record + `BuildTemplateTree` / `BuildTmplChildren`**

Delete the existing `TmplTreeNode` record and the `BuildTemplateTree` / `BuildTmplChildren` helpers. Replace with:

```csharp
private enum TmplNodeKind { Folder, Template, Composition }

private record TmplNode(
    string Key,
    TmplNodeKind Kind,
    int EntityId,
    string Label,
    int? ParentFolderId,
    int? OwnerTemplateId,
    Template? Template,
    TemplateComposition? Composition,
    List<TmplNode> Children);

private List<TmplNode> _treeRoots = new();

private void BuildTemplateTree()
{
    // 1. Folder nodes keyed by id
    var folderNodes = _folders.ToDictionary(
        f => f.Id,
        f => new TmplNode(
            Key: $"f:{f.Id}",
            Kind: TmplNodeKind.Folder,
            EntityId: f.Id,
            Label: f.Name,
            ParentFolderId: f.ParentFolderId,
            OwnerTemplateId: null,
            Template: null,
            Composition: null,
            Children: new List<TmplNode>()));

    // 2. Attach folder nodes by ParentFolderId
    var roots = new List<TmplNode>();
    foreach (var f in _folders.OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase))
    {
        var node = folderNodes[f.Id];
        if (f.ParentFolderId is int pid && folderNodes.TryGetValue(pid, out var parent))
            parent.Children.Add(node);
        else
            roots.Add(node);
    }

    // 3. Template nodes with composition leaves
    foreach (var t in _templates.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase))
    {
        var compChildren = t.Compositions
            .OrderBy(c => c.InstanceName, StringComparer.OrdinalIgnoreCase)
            .Select(c => new TmplNode(
                Key: $"c:{c.Id}",
                Kind: TmplNodeKind.Composition,
                EntityId: c.Id,
                Label: c.InstanceName,
                ParentFolderId: null,
                OwnerTemplateId: t.Id,
                Template: null,
                Composition: c,
                Children: new List<TmplNode>()))
            .ToList();

        var tNode = new TmplNode(
            Key: $"t:{t.Id}",
            Kind: TmplNodeKind.Template,
            EntityId: t.Id,
            Label: t.Name,
            ParentFolderId: t.FolderId,
            OwnerTemplateId: null,
            Template: t,
            Composition: null,
            Children: compChildren);

        if (t.FolderId is int fid && folderNodes.TryGetValue(fid, out var parentFolder))
            parentFolder.Children.Add(tNode);
        else
            roots.Add(tNode);
    }

    // 4. Sort each level: folders before templates, alphabetical
    SortChildren(roots);
    foreach (var node in folderNodes.Values)
        SortChildren(node.Children);

    _treeRoots = roots;
}

private static void SortChildren(List<TmplNode> children)
{
    children.Sort((a, b) =>
    {
        // Folders first
        var kindOrder = (int)a.Kind - (int)b.Kind; // Folder=0, Template=1, Composition=2
        if (kindOrder != 0) return kindOrder;
        return string.Compare(a.Label, b.Label, StringComparison.OrdinalIgnoreCase);
    });
}
```

**Step 2: Build**

Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj`
Expected: build error in the existing `<TreeView TItem="TmplTreeNode" ...>` — it references the removed type. That's expected; will be fixed in Task 15.

**Step 3: Don't commit yet — Task 15 finishes the markup change before this compiles.**

---

## Task 15: Templates.razor — split-pane layout + new TreeView wiring

**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/Templates.razor`

**Step 1: Replace the page body markup**

Replace the entire `<div class="container-fluid mt-3">` block with this skeleton — note that the **template detail markup** (properties card, validation block, four tabs) moves verbatim from the old `_selectedTemplate != null` branch into the new `else` branch inside the right column.

```razor
<div class="container-fluid mt-3">
    <ToastNotification @ref="_toast" />
    <ConfirmDialog @ref="_confirmDialog" />

    @if (_loading)
    {
        <LoadingSpinner IsLoading="true" />
    }
    else if (_errorMessage != null)
    {
        <div class="alert alert-danger">@_errorMessage</div>
    }
    else
    {
        <div class="row g-2">
            <div class="col-md-4 col-lg-3">
                <div class="d-flex justify-content-between align-items-center mb-2">
                    <h6 class="mb-0">Templates</h6>
                    <div class="btn-group btn-group-sm">
                        <button class="btn btn-outline-secondary" title="New folder at root"
                                @onclick="() => OpenNewFolderDialog(null)">+ Folder</button>
                        <button class="btn btn-outline-secondary" title="New template at root"
                                @onclick="() => OpenNewTemplateDialog(null)">+ Template</button>
                        <button class="btn btn-outline-secondary" @onclick="() => _tree.ExpandAll()">Expand</button>
                        <button class="btn btn-outline-secondary" @onclick="() => _tree.CollapseAll()">Collapse</button>
                    </div>
                </div>

                <div style="max-height: calc(100vh - 160px); overflow-y: auto;">
                    <TreeView @ref="_tree" TItem="TmplNode" Items="_treeRoots"
                              ChildrenSelector="n => n.Children"
                              HasChildrenSelector="n => n.Kind != TmplNodeKind.Composition && n.Children.Count > 0"
                              KeySelector="n => (object)n.Key"
                              StorageKey="templates-tree"
                              Selectable="true"
                              SelectedKeyChanged="OnTreeNodeSelected">
                        <NodeContent Context="node">
                            @RenderNodeLabel(node)
                        </NodeContent>
                        <ContextMenu Context="node">
                            @RenderNodeContextMenu(node)
                        </ContextMenu>
                        <EmptyContent>
                            <span class="text-muted fst-italic">No templates yet. Use the buttons above to create a folder or template.</span>
                        </EmptyContent>
                    </TreeView>
                </div>
            </div>

            <div class="col-md-8 col-lg-9">
                @if (_selectedTemplate == null)
                {
                    <div class="text-muted fst-italic mt-3">
                        Select a template on the left to view or edit.
                    </div>
                }
                else
                {
                    @* === existing template detail markup goes here, unchanged === *@
                    @RenderTemplateDetail()
                }
            </div>
        </div>
    }
</div>
```

**Step 2: Move the existing template detail markup into a method**

Wrap the existing properties card + validation block + tab nav + tab bodies (everything inside the original `else { ... template detail ... }` block, except the "Back to List" button which is removed) into a `RenderFragment RenderTemplateDetail()` method. The four tab `RenderFragment` methods already exist and stay as-is.

```csharp
private RenderFragment RenderTemplateDetail() => __builder =>
{
    // Paste the existing detail markup here (properties card, validation results,
    // <ul class="nav nav-tabs">, and the @if (_activeTab == ...) branches that
    // call RenderAttributesTab/RenderAlarmsTab/RenderScriptsTab/RenderCompositionsTab).
    // DELETE the "Back to List" button — it's no longer needed.
};
```

**Step 3: Add tree node label helpers**

```csharp
private TreeView<TmplNode> _tree = default!;

private RenderFragment RenderNodeLabel(TmplNode node) => __builder =>
{
    switch (node.Kind)
    {
        case TmplNodeKind.Folder:
            <span class="me-1">📁</span>
            <span>@node.Label</span>
            <span class="badge bg-light text-dark ms-2">@node.Children.Count</span>
            break;
        case TmplNodeKind.Template:
            <strong>@node.Label</strong>
            if (node.Template?.ParentTemplateId is int pid)
            {
                <span class="text-muted small ms-1">inherits @(_templates.FirstOrDefault(t => t.Id == pid)?.Name)</span>
            }
            <span class="badge bg-light text-dark ms-2">
                @node.Template!.Attributes.Count attr,
                @node.Template.Alarms.Count alm,
                @node.Template.Scripts.Count scr
            </span>
            if (node.Template.Compositions.Count > 0)
            {
                <span class="badge bg-info text-dark ms-1">@node.Template.Compositions.Count comp</span>
            }
            break;
        case TmplNodeKind.Composition:
            <span>@node.Label</span>
            <span class="text-muted small ms-1">→ @(_templates.FirstOrDefault(t => t.Id == node.Composition!.ComposedTemplateId)?.Name ?? $"#{node.Composition.ComposedTemplateId}")</span>
            break;
    }
};

private async Task OnTreeNodeSelected(object? key)
{
    if (key is not string s) return;
    if (s.StartsWith("t:") && int.TryParse(s[2..], out var tid))
    {
        await SelectTemplate(tid);
    }
    else if (s.StartsWith("c:") && int.TryParse(s[2..], out var cid))
    {
        var comp = _templates.SelectMany(t => t.Compositions).FirstOrDefault(c => c.Id == cid);
        if (comp != null)
        {
            // Reveal + select the composed template.
            _tree.RevealNode($"t:{comp.ComposedTemplateId}", select: true);
            await SelectTemplate(comp.ComposedTemplateId);
        }
    }
    // Folder selection: no-op (Section 4 design — folder click does not load detail).
}
```

**Step 4: Stub the dialog/menu helpers (real impls in Tasks 16–18)**

```csharp
private RenderFragment RenderNodeContextMenu(TmplNode node) => __builder => { };

private void OpenNewFolderDialog(int? parentFolderId) { /* Task 17 */ }
private void OpenNewTemplateDialog(int? parentFolderId) { /* Task 17 */ }
```

**Step 5: Remove now-orphaned `BackToList`**

Delete `BackToList()` method and any remaining references. The "Back to List" button was removed in Step 2.

**Step 6: Build + smoke**

Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj`
Expected: 0 errors.

Manual smoke (no DB changes needed beyond Task 3's migration — auto-applied on host start):
Run: `bash docker/deploy.sh` to rebuild the cluster.
Then open http://localhost:9000/design/templates (login `multi-role` / `password`).
Expected: tree shows existing templates at the root (none in folders yet). Selecting a template loads its detail pane on the right.

**Step 7: Commit**

```bash
git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/Templates.razor
git commit -m "feat(ui/templates): split-pane layout with folder + composition tree"
```

---

## Task 16: Per-kind context menus

**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/Templates.razor`

**Step 1: Implement `RenderNodeContextMenu`**

Replace the stub with:

```csharp
private RenderFragment RenderNodeContextMenu(TmplNode node) => __builder =>
{
    switch (node.Kind)
    {
        case TmplNodeKind.Folder:
            <button class="dropdown-item" @onclick="() => OpenNewFolderDialog(node.EntityId)">New Folder</button>
            <button class="dropdown-item" @onclick="() => OpenNewTemplateDialog(node.EntityId)">New Template</button>
            <button class="dropdown-item" @onclick="() => OpenRenameFolderDialog(node.EntityId, node.Label)">Rename</button>
            <div class="dropdown-divider"></div>
            <button class="dropdown-item text-danger" @onclick="() => DeleteFolder(node.EntityId, node.Label)">Delete</button>
            break;

        case TmplNodeKind.Template:
            <button class="dropdown-item" @onclick="() => SelectTemplate(node.EntityId)">Edit</button>
            <button class="dropdown-item" @onclick="() => OpenMoveTemplateDialog(node.EntityId, node.Label)">Move to Folder…</button>
            <div class="dropdown-divider"></div>
            <button class="dropdown-item text-danger" @onclick="() => DeleteTemplate(node.Template!)">Delete</button>
            break;

        case TmplNodeKind.Composition:
            var composedId = node.Composition!.ComposedTemplateId;
            <button class="dropdown-item" @onclick="() => OnTreeNodeSelected($"t:{composedId}")">Open composed template</button>
            <button class="dropdown-item text-danger" @onclick="() => DeleteComposition(node.Composition)">Remove composition</button>
            break;
    }
};
```

**Step 2: Implement folder rename + delete handlers**

```csharp
// Rename dialog state
private bool _showRenameFolderDialog;
private int _renameFolderId;
private string _renameFolderName = string.Empty;
private string? _renameFolderError;

private void OpenRenameFolderDialog(int folderId, string currentName)
{
    _renameFolderId = folderId;
    _renameFolderName = currentName;
    _renameFolderError = null;
    _showRenameFolderDialog = true;
}

private async Task SubmitRenameFolder()
{
    _renameFolderError = null;
    var user = await GetCurrentUserAsync();
    var result = await TemplateFolderService.RenameFolderAsync(_renameFolderId, _renameFolderName.Trim(), user);
    if (result.IsSuccess)
    {
        _showRenameFolderDialog = false;
        _toast.ShowSuccess("Folder renamed.");
        await LoadTemplatesAsync();
    }
    else
    {
        _renameFolderError = result.Error;
    }
}

private async Task DeleteFolder(int folderId, string label)
{
    var confirmed = await _confirmDialog.ShowAsync($"Delete folder '{label}'?", "Delete Folder");
    if (!confirmed) return;

    var user = await GetCurrentUserAsync();
    var result = await TemplateFolderService.DeleteFolderAsync(folderId, user);
    if (result.IsSuccess)
    {
        _toast.ShowSuccess($"Folder '{label}' deleted.");
        await LoadTemplatesAsync();
    }
    else
    {
        _toast.ShowError(result.Error);
    }
}
```

**Step 3: Render the rename dialog**

Add inside the top-level `<div class="container-fluid mt-3">` (after `<ConfirmDialog />`):

```razor
@if (_showRenameFolderDialog)
{
    <div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
        <div class="modal-dialog modal-sm">
            <div class="modal-content">
                <div class="modal-header">
                    <h6 class="modal-title">Rename Folder</h6>
                    <button type="button" class="btn-close" @onclick="() => _showRenameFolderDialog = false"></button>
                </div>
                <div class="modal-body">
                    <input class="form-control form-control-sm" @bind="_renameFolderName" />
                    @if (_renameFolderError != null) { <div class="text-danger small mt-1">@_renameFolderError</div> }
                </div>
                <div class="modal-footer">
                    <button class="btn btn-outline-secondary btn-sm" @onclick="() => _showRenameFolderDialog = false">Cancel</button>
                    <button class="btn btn-primary btn-sm" @onclick="SubmitRenameFolder">Save</button>
                </div>
            </div>
        </div>
    </div>
}
```

**Step 4: Build + smoke**

Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj`
Expected: 0 errors.

Manual smoke: right-click a template → Edit / Move (stub) / Delete render correctly. Right-click a (future) folder → New Folder / New Template / Rename / Delete. Rename and Delete work end-to-end.

**Step 5: Commit**

```bash
git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/Templates.razor
git commit -m "feat(ui/templates): per-kind context menus + folder rename/delete"
```

---

## Task 17: New-folder / new-template / move-template dialogs

**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/Templates.razor`

**Step 1: New-folder dialog state + handler**

```csharp
private bool _showNewFolderDialog;
private int? _newFolderParentId;
private string _newFolderName = string.Empty;
private string? _newFolderError;

private void OpenNewFolderDialog(int? parentFolderId)
{
    _newFolderParentId = parentFolderId;
    _newFolderName = string.Empty;
    _newFolderError = null;
    _showNewFolderDialog = true;
}

private async Task SubmitNewFolder()
{
    _newFolderError = null;
    var user = await GetCurrentUserAsync();
    var result = await TemplateFolderService.CreateFolderAsync(_newFolderName.Trim(), _newFolderParentId, user);
    if (result.IsSuccess)
    {
        _showNewFolderDialog = false;
        _toast.ShowSuccess($"Folder '{result.Value.Name}' created.");
        await LoadTemplatesAsync();
    }
    else
    {
        _newFolderError = result.Error;
    }
}
```

**Step 2: New-template dialog state + handler**

The existing `/design/templates/create` route is the canonical create flow (the page currently navigates to it from the root toolbar). To keep one flow, the "New Template" dialog from a folder context **pre-selects the folder** and navigates to the create page with a query string. Simpler v1 option: dialog inline that calls `TemplateService.CreateTemplateAsync` directly, passing `FolderId`.

Since `TemplateService.CreateTemplateAsync` currently does not accept `FolderId`, extend it:

In `TemplateService.CreateTemplateAsync` signature, add an optional `int? folderId = null` parameter and apply it: `template.FolderId = folderId;` before `AddTemplateAsync`. (Single one-line change. Add a test for it in `TemplateServiceTests.cs` alongside the existing create tests.)

Then in the page:

```csharp
private bool _showNewTemplateDialog;
private int? _newTemplateFolderId;
private string _newTemplateName = string.Empty;
private string? _newTemplateDescription;
private string? _newTemplateError;

private void OpenNewTemplateDialog(int? folderId)
{
    _newTemplateFolderId = folderId;
    _newTemplateName = string.Empty;
    _newTemplateDescription = null;
    _newTemplateError = null;
    _showNewTemplateDialog = true;
}

private async Task SubmitNewTemplate()
{
    _newTemplateError = null;
    var user = await GetCurrentUserAsync();
    var result = await TemplateService.CreateTemplateAsync(
        _newTemplateName.Trim(), _newTemplateDescription?.Trim(), null, user, folderId: _newTemplateFolderId);
    if (result.IsSuccess)
    {
        _showNewTemplateDialog = false;
        _toast.ShowSuccess($"Template '{result.Value.Name}' created.");
        await LoadTemplatesAsync();
        await SelectTemplate(result.Value.Id);
    }
    else
    {
        _newTemplateError = result.Error;
    }
}
```

**Step 3: Move-template dialog state + folder picker**

```csharp
private bool _showMoveTemplateDialog;
private int _moveTemplateId;
private string _moveTemplateName = string.Empty;
private int? _moveTemplateTargetFolderId;
private string? _moveTemplateError;

private void OpenMoveTemplateDialog(int templateId, string label)
{
    _moveTemplateId = templateId;
    _moveTemplateName = label;
    _moveTemplateTargetFolderId = null;
    _moveTemplateError = null;
    _showMoveTemplateDialog = true;
}

private async Task SubmitMoveTemplate()
{
    _moveTemplateError = null;
    var user = await GetCurrentUserAsync();
    var result = await TemplateService.MoveTemplateAsync(_moveTemplateId, _moveTemplateTargetFolderId, user);
    if (result.IsSuccess)
    {
        _showMoveTemplateDialog = false;
        _toast.ShowSuccess($"Template '{_moveTemplateName}' moved.");
        await LoadTemplatesAsync();
    }
    else
    {
        _moveTemplateError = result.Error;
    }
}

// Flat list of folders with indentation labels, for the picker.
private IEnumerable<(int? Id, string Label)> EnumerateFolderOptions()
{
    yield return (null, "(Root)");
    foreach (var f in WalkFolderHierarchy(_folders.Where(f => f.ParentFolderId == null), 0))
        yield return f;
}

private IEnumerable<(int? Id, string Label)> WalkFolderHierarchy(IEnumerable<TemplateFolder> level, int depth)
{
    foreach (var f in level.OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase))
    {
        yield return ((int?)f.Id, new string(' ', depth * 2) + f.Name);
        foreach (var sub in WalkFolderHierarchy(_folders.Where(c => c.ParentFolderId == f.Id), depth + 1))
            yield return sub;
    }
}
```

**Step 4: Render the three modals**

Append to the same top-level container after the rename modal:

```razor
@if (_showNewFolderDialog)
{
    <div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
        <div class="modal-dialog modal-sm">
            <div class="modal-content">
                <div class="modal-header">
                    <h6 class="modal-title">New Folder</h6>
                    <button type="button" class="btn-close" @onclick="() => _showNewFolderDialog = false"></button>
                </div>
                <div class="modal-body">
                    <input class="form-control form-control-sm" placeholder="Folder name" @bind="_newFolderName" />
                    @if (_newFolderError != null) { <div class="text-danger small mt-1">@_newFolderError</div> }
                </div>
                <div class="modal-footer">
                    <button class="btn btn-outline-secondary btn-sm" @onclick="() => _showNewFolderDialog = false">Cancel</button>
                    <button class="btn btn-primary btn-sm" @onclick="SubmitNewFolder">Create</button>
                </div>
            </div>
        </div>
    </div>
}

@if (_showNewTemplateDialog)
{
    <div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
        <div class="modal-dialog">
            <div class="modal-content">
                <div class="modal-header">
                    <h6 class="modal-title">New Template</h6>
                    <button type="button" class="btn-close" @onclick="() => _showNewTemplateDialog = false"></button>
                </div>
                <div class="modal-body">
                    <div class="mb-2">
                        <label class="form-label small">Name</label>
                        <input class="form-control form-control-sm" @bind="_newTemplateName" />
                    </div>
                    <div class="mb-2">
                        <label class="form-label small">Description</label>
                        <input class="form-control form-control-sm" @bind="_newTemplateDescription" />
                    </div>
                    @if (_newTemplateError != null) { <div class="text-danger small mt-1">@_newTemplateError</div> }
                </div>
                <div class="modal-footer">
                    <button class="btn btn-outline-secondary btn-sm" @onclick="() => _showNewTemplateDialog = false">Cancel</button>
                    <button class="btn btn-primary btn-sm" @onclick="SubmitNewTemplate">Create</button>
                </div>
            </div>
        </div>
    </div>
}

@if (_showMoveTemplateDialog)
{
    <div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
        <div class="modal-dialog">
            <div class="modal-content">
                <div class="modal-header">
                    <h6 class="modal-title">Move '@_moveTemplateName' to…</h6>
                    <button type="button" class="btn-close" @onclick="() => _showMoveTemplateDialog = false"></button>
                </div>
                <div class="modal-body">
                    <select class="form-select form-select-sm" @bind="_moveTemplateTargetFolderId">
                        @foreach (var opt in EnumerateFolderOptions())
                        {
                            <option value="@opt.Id">@opt.Label</option>
                        }
                    </select>
                    @if (_moveTemplateError != null) { <div class="text-danger small mt-1">@_moveTemplateError</div> }
                </div>
                <div class="modal-footer">
                    <button class="btn btn-outline-secondary btn-sm" @onclick="() => _showMoveTemplateDialog = false">Cancel</button>
                    <button class="btn btn-primary btn-sm" @onclick="SubmitMoveTemplate">Move</button>
                </div>
            </div>
        </div>
    </div>
}
```

**Step 5: Build + smoke**

Run: `dotnet build ZB.MOM.WW.ScadaBridge.slnx`
Expected: 0 errors. Run the cluster (`bash docker/deploy.sh`), exercise the three modals manually.

**Step 6: Commit**

```bash
git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/Templates.razor \
        src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs \
        tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateServiceTests.cs
git commit -m "feat(ui/templates): new-folder, new-template, move-template dialogs"
```

---

## Task 18: Drag-drop reorganization

**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/Templates.razor`

**Step 1: Drag state**

```csharp
private (TmplNodeKind kind, int id)? _dragPayload;
private string? _dragOverKey;

private void OnDragStart(TmplNode node)
{
    if (node.Kind == TmplNodeKind.Composition) return;
    _dragPayload = (node.Kind, node.EntityId);
}

private void OnDragEnd()
{
    _dragPayload = null;
    _dragOverKey = null;
}

private void OnDragEnter(TmplNode targetFolder)
{
    if (_dragPayload == null) return;
    if (targetFolder.Kind != TmplNodeKind.Folder) return;
    _dragOverKey = targetFolder.Key;
}

private void OnDragLeave(TmplNode targetFolder)
{
    if (_dragOverKey == targetFolder.Key) _dragOverKey = null;
}

private async Task OnDrop(TmplNode targetFolder)
{
    if (_dragPayload is not { } payload) return;
    if (targetFolder.Kind != TmplNodeKind.Folder) return;

    var user = await GetCurrentUserAsync();
    if (payload.kind == TmplNodeKind.Folder)
    {
        var result = await TemplateFolderService.MoveFolderAsync(payload.id, targetFolder.EntityId, user);
        if (result.IsFailure) _toast.ShowError(result.Error);
    }
    else if (payload.kind == TmplNodeKind.Template)
    {
        var result = await TemplateService.MoveTemplateAsync(payload.id, targetFolder.EntityId, user);
        if (result.IsFailure) _toast.ShowError(result.Error);
    }

    OnDragEnd();
    await LoadTemplatesAsync();
}

private async Task OnDropOnRoot()
{
    if (_dragPayload is not { } payload) return;

    var user = await GetCurrentUserAsync();
    if (payload.kind == TmplNodeKind.Folder)
    {
        var result = await TemplateFolderService.MoveFolderAsync(payload.id, null, user);
        if (result.IsFailure) _toast.ShowError(result.Error);
    }
    else if (payload.kind == TmplNodeKind.Template)
    {
        var result = await TemplateService.MoveTemplateAsync(payload.id, null, user);
        if (result.IsFailure) _toast.ShowError(result.Error);
    }

    OnDragEnd();
    await LoadTemplatesAsync();
}
```

**Step 2: Apply drag attributes inside `NodeContent` (in `RenderNodeLabel`)**

Wrap the label markup in a `<div>` carrying the events. Change the start of `RenderNodeLabel`:

```csharp
private RenderFragment RenderNodeLabel(TmplNode node) => __builder =>
{
    var draggable = node.Kind != TmplNodeKind.Composition;
    var isDropTarget = node.Kind == TmplNodeKind.Folder;
    var classes = "d-inline-block " + (_dragOverKey == node.Key ? "bg-info bg-opacity-25" : "");
    var style = node.Kind == TmplNodeKind.Composition && _dragPayload != null
        ? "opacity: 0.5;" : "";

    <div class="@classes" style="@style"
         draggable="@(draggable ? "true" : "false")"
         @ondragstart="() => OnDragStart(node)"
         @ondragend="OnDragEnd"
         @ondragenter="() => OnDragEnter(node)"
         @ondragleave="() => OnDragLeave(node)"
         @ondragover:preventDefault="@isDropTarget"
         @ondrop="() => OnDrop(node)">
        @* existing per-kind label switch (folder/template/composition) goes here *@
    </div>
};
```

(Move the existing switch body inside the `<div>`.)

**Step 3: Add a root-level drop zone**

Wrap the `<TreeView>` in a div that accepts drops onto the root:

```razor
<div @ondragover:preventDefault="true" @ondrop="OnDropOnRoot"
     style="min-height: 100%; padding: 4px;">
    <TreeView ... />
</div>
```

**Step 4: Build + smoke**

Run: `dotnet build ZB.MOM.WW.ScadaBridge.slnx`
Expected: 0 errors.

Manual smoke (after `bash docker/deploy.sh`):
- Drag a template into a folder → tree reloads, template moves.
- Drag a folder into one of its descendants → toast error appears (cycle).
- Drag a template onto the empty sidebar area → moves to root.

**Step 5: Commit**

```bash
git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/Templates.razor
git commit -m "feat(ui/templates): native HTML5 drag-drop reorganization"
```

---

## Task 19: Deep-link reveal on load

**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/Templates.razor`

**Step 1: Call `RevealNode` after initial load when route param is set**

Modify `OnInitializedAsync`:

```csharp
protected override async Task OnInitializedAsync()
{
    await LoadTemplatesAsync();
    if (TemplateIdParam > 0)
    {
        await SelectTemplate(TemplateIdParam);
    }
}

protected override void OnAfterRender(bool firstRender)
{
    if (firstRender && TemplateIdParam > 0 && _tree != null)
    {
        _tree.RevealNode($"t:{TemplateIdParam}", select: true);
    }
}
```

**Step 2: Build + smoke**

Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj`
Expected: 0 errors.

Manual smoke: navigate directly to `/design/templates/{existing-id}` — the tree reveals the template and selects it.

**Step 3: Commit**

```bash
git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/Templates.razor
git commit -m "feat(ui/templates): reveal deep-linked template on initial render"
```

---

## Task 20: bUnit tests for the new page

**Files:**
- Create: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TemplatesPageTests.cs`

**Step 1: Write rendering tests**

```csharp
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;

namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests;

public class TemplatesPageTests : BunitContext
{
    private readonly Mock<ITemplateEngineRepository> _repo = new();
    private readonly Mock<IAuditService> _audit = new();

    public TemplatesPageTests()
    {
        Services.AddSingleton(_repo.Object);
        Services.AddSingleton(_audit.Object);
        Services.AddScoped<TemplateService>();
        Services.AddScoped<TemplateFolderService>();
        AddTestAuth();
    }

    private void AddTestAuth()
    {
        var claims = new[] { new Claim("Username", "tester"), new Claim(ClaimTypes.Role, "Design") };
        var identity = new ClaimsIdentity(claims, "TestAuth");
        var user = new ClaimsPrincipal(identity);
        Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
        Services.AddAuthorizationCore();
    }

    [Fact]
    public void Renders_EmptyState_WhenNoTemplatesOrFolders()
    {
        _repo.Setup(r => r.GetAllTemplatesAsync(default)).ReturnsAsync(new List<Template>());
        _repo.Setup(r => r.GetAllFoldersAsync(default)).ReturnsAsync(new List<TemplateFolder>());

        var cut = Render<Templates>();

        Assert.Contains("No templates yet", cut.Markup);
    }

    [Fact]
    public void Renders_FolderAndTemplate_AtCorrectNesting()
    {
        var folder = new TemplateFolder("Dev") { Id = 1 };
        var template = new Template("TestMachine") { Id = 5, FolderId = 1 };
        _repo.Setup(r => r.GetAllTemplatesAsync(default)).ReturnsAsync(new List<Template> { template });
        _repo.Setup(r => r.GetAllFoldersAsync(default)).ReturnsAsync(new List<TemplateFolder> { folder });

        var cut = Render<Templates>();

        Assert.Contains("Dev", cut.Markup);
        Assert.Contains("TestMachine", cut.Markup);
    }

    [Fact]
    public void Renders_CompositionChildren_UnderOwningTemplate()
    {
        var template = new Template("TestMachine") { Id = 5 };
        template.Compositions.Add(new TemplateComposition("DelmiaReceiver") { Id = 10, ComposedTemplateId = 99 });
        _repo.Setup(r => r.GetAllTemplatesAsync(default))
            .ReturnsAsync(new List<Template> { template, new("Other") { Id = 99 } });
        _repo.Setup(r => r.GetAllFoldersAsync(default)).ReturnsAsync(new List<TemplateFolder>());

        var cut = Render<Templates>();

        Assert.Contains("DelmiaReceiver", cut.Markup);
        Assert.Contains("→", cut.Markup);
    }
}

internal class TestAuthStateProvider : AuthenticationStateProvider
{
    private readonly ClaimsPrincipal _user;
    public TestAuthStateProvider(ClaimsPrincipal user) => _user = user;
    public override Task<AuthenticationState> GetAuthenticationStateAsync()
        => Task.FromResult(new AuthenticationState(_user));
}
```

**Step 2: Run**

Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ --filter "FullyQualifiedName~TemplatesPageTests" --nologo`
Expected: PASS (3 tests).

**Step 3: Commit**

```bash
git add tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TemplatesPageTests.cs
git commit -m "test(ui/templates): bUnit rendering tests for folder tree"
```

---

## Task 21: Documentation updates

**Files:**
- Modify: `docs/requirements/Component-CentralUI.md`
- Modify: `docs/requirements/Component-TemplateEngine.md`
- Modify: `docs/requirements/Component-ConfigurationDatabase.md`
- Modify: `docs/requirements/Component-ManagementService.md`
- Modify: `README.md`

For each, do a targeted edit. Use `grep -n` to find the right section, then `Edit` with surgical replacements. Examples:

**Step 1: Component-CentralUI.md** — find the section describing the templates page (the current page has a "list view" description). Replace it with a paragraph describing: split-pane layout, folder tree, composition leaves, context menus, drag-drop.

**Step 2: Component-TemplateEngine.md** — under "Key Entities", add a `TemplateFolder` subsection between Template and Attribute. Under "Responsibilities", add: "Organize templates into nested folders (`TemplateFolder` entity) and validate folder hierarchy invariants (acyclicity, sibling uniqueness, non-empty-on-delete)."

**Step 3: Component-ConfigurationDatabase.md** — find the table listing entities. Add a row for `TemplateFolders`. Mention the new `Templates.FolderId` nullable column.

**Step 4: Component-ManagementService.md** — under the design/operations commands section, add the new commands (`CreateTemplateFolder`, `RenameTemplateFolder`, `MoveTemplateFolder`, `DeleteTemplateFolder`, `MoveTemplateToFolder`).

**Step 5: README.md** — find the component table row for Template Engine; append "folder organization (folders, nested hierarchy)" to its Responsibilities cell.

**Step 6: Verify** by re-reading the modified sections in each file (just `Read` with offset to confirm the wording sticks).

**Step 7: Commit**

```bash
git add docs/requirements/Component-CentralUI.md \
        docs/requirements/Component-TemplateEngine.md \
        docs/requirements/Component-ConfigurationDatabase.md \
        docs/requirements/Component-ManagementService.md \
        README.md
git commit -m "docs(templates): describe folder hierarchy and management commands"
```

---

## Task 22: Final smoke + green-suite check

**Files:** none modified.

**Step 1: Full test pass**

Run: `dotnet test ZB.MOM.WW.ScadaBridge.slnx --nologo`
Expected: all green.

**Step 2: Build cluster image and run**

Run: `bash docker/deploy.sh`
Expected: image rebuilds, 5-container cluster starts.

**Step 3: Manual smoke checklist**

Open http://localhost:9000/design/templates (login `multi-role` / `password`). Verify:

- [ ] Existing templates appear at root (no folder).
- [ ] Click `+ Folder` toolbar button → modal opens → create "Dev" → folder appears at root.
- [ ] Right-click "Dev" → "New Folder" → create "Sub" → nested under "Dev".
- [ ] Right-click "Dev" → "New Template" → creates template inside "Dev".
- [ ] Right-click a template → "Move to Folder…" → pick "Sub" → moves.
- [ ] Drag a template onto "Dev" → moves.
- [ ] Drag "Dev" onto "Sub" (its descendant) → toast error mentions cycle.
- [ ] Add a composition to an existing template → composition appears as a leaf under the template.
- [ ] Right-click composition → "Open composed template" → reveals and selects target.
- [ ] Right-click "Dev" (now non-empty) → "Delete" → error toast lists counts.
- [ ] Delete contents, then delete "Dev" → succeeds.
- [ ] Refresh the page → expansion state persists (sessionStorage).
- [ ] Navigate to `/design/templates/{id}` directly → tree reveals and selects.

**Step 4: No commit** — this is verification only. If something fails, return to the relevant task and fix.

---

## Out of scope (per design)

- CLI commands for folder operations (Management Service contracts now exist; CLI follows in a future plan).
- Tree search / filter input.
- Sibling reordering via drag-drop (alphabetical sort is fixed).
- Root-area context menu (right-click in empty tree space).
- Bootstrap Icons CDN (use Unicode `📁` glyph).

---

## Risks & rollback

- **Migration is purely additive** (new table + nullable column). Down-migration: `dotnet ef migrations remove ...` while disconnected.
- The page is server-rendered Blazor with no JS interop beyond what `TreeView` already uses for sessionStorage. Drag-drop uses native HTML5 `@ondragstart/@ondrop` — no new JS files.
- The `TemplateService.CreateTemplateAsync` signature gains an optional `folderId` parameter (defaults to `null`) — backward-compatible at call sites.
- If the cluster needs a clean DB after the migration, `docker/deploy.sh` recreates volumes by default; data is dev-only.
