Files
ROLAC/docs/superpowers/plans/2026-06-25-vendor-payment-snapshot.md
2026-06-25 14:45:08 -07:00

55 KiB

Vendor Payment Snapshot Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Let finance save a vendor payment as a reusable named "snapshot" and re-apply it later, pre-filling every field except the Expense Date, to speed up entry of recurring fixed bills (rent, internet, fixed meal cost).

Architecture: A new ExpenseSnapshot aggregate (header + owned ExpenseSnapshotLine rows) mirrors the existing Expense/ExpenseLine shape minus ExpenseDate/receipt/MemberId. A thin service + controller (api/expense-snapshots) exposes list/get/create/update/delete, gated on the existing Expenses:Write permission. The Angular vendor-payment form gains a "Load from snapshot" picker and a "Save as snapshot" action; a new management page lists snapshots with rename/delete. Snapshots are shared church-wide and tagged with their creator (resolved at read time, like ReviewedByName).

Tech Stack: C# / EF Core (PostgreSQL) / ASP.NET controllers / xUnit + Moq + EF InMemory; Angular standalone components / Kendo UI / Tailwind v4 / Jasmine-Karma.

Spec: docs/superpowers/specs/2026-06-25-vendor-payment-snapshot-design.md

Conventions verified from the codebase:

  • CreatedBy/CreatedAt/UpdatedBy/UpdatedAt are auto-stamped by AuditSaveChangesInterceptor for any AuditableEntity (which SoftDeleteEntity extends). Do NOT set them manually.
  • Soft delete = set IsDeleted = true + DeletedAt; a HasQueryFilter(e => !e.IsDeleted) hides them.
  • Owned lines are replaced wholesale on update (RemoveRange then rebuild), exactly like ExpenseService.UpdateAsync.
  • Build/test under -c Release (Visual Studio locks bin/Debug). EF commands take --configuration Release for the same reason.
  • Frontend unit-tested files: scope ng test with --include and run under Edge via CHROME_BIN (full ng test is pre-existing broken; the esbuild runner has no .html loader, so only test plain services, not templated components).

File Structure

Backend (create):

  • API/ROLAC.API/Entities/ExpenseSnapshot.cs — snapshot header entity
  • API/ROLAC.API/Entities/ExpenseSnapshotLine.cs — snapshot line entity
  • API/ROLAC.API/DTOs/Expense/ExpenseSnapshotDtos.cs — request/response DTOs
  • API/ROLAC.API/Services/IExpenseSnapshotService.cs — service contract
  • API/ROLAC.API/Services/ExpenseSnapshotService.cs — service implementation
  • API/ROLAC.API/Controllers/ExpenseSnapshotsController.cs — REST endpoints
  • API/ROLAC.API.Tests/Services/ExpenseSnapshotServiceTests.cs — service unit tests
  • API/ROLAC.API/Migrations/*_AddExpenseSnapshots.cs — generated migration

Backend (modify):

  • API/ROLAC.API/Data/AppDbContext.cs — register DbSets + entity config
  • API/ROLAC.API/Program.cs — register IExpenseSnapshotService

Frontend (create):

  • APP/src/app/features/expense/models/expense-snapshot.model.ts — TS interfaces
  • APP/src/app/features/expense/services/expense-snapshot-api.service.ts — HTTP client
  • APP/src/app/features/expense/services/expense-snapshot-api.service.spec.ts — service tests
  • APP/src/app/features/expense/pages/expense-snapshots-page/expense-snapshots-page.component.ts — management page
  • APP/src/app/features/expense/pages/expense-snapshots-page/expense-snapshots-page.component.html
  • APP/src/app/features/expense/pages/expense-snapshots-page/expense-snapshots-page.component.scss

Frontend (modify):

  • APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.ts — load/save snapshot logic
  • APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.html — picker + save button + name prompt
  • APP/src/app/app.routes.ts — route for the management page
  • APP/src/app/portals/user-portal/user-portal.component.ts — sidebar nav item

Task 1: Snapshot entities

Files:

  • Create: API/ROLAC.API/Entities/ExpenseSnapshot.cs

  • Create: API/ROLAC.API/Entities/ExpenseSnapshotLine.cs

  • Step 1: Create the header entity

Create API/ROLAC.API/Entities/ExpenseSnapshot.cs:

using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;

/// <summary>
/// A reusable template of a vendor payment. Lets finance save a recurring fixed expense
/// (rent, internet, a fixed catered-meal cost) and re-apply it later, pre-filling everything
/// except the ExpenseDate. Shared church-wide; the creator is the auditable CreatedBy.
/// Lines are wholly owned by the header (replaced as a set on update, like ExpenseLine).
/// </summary>
public class ExpenseSnapshot : SoftDeleteEntity
{
    public int      Id          { get; set; }
    public string   Name        { get; set; } = null!;   // user label, e.g. "Monthly Rent — Landlord X"
    public int      MinistryId  { get; set; }
    public string   Description { get; set; } = null!;
    public string?  VendorName  { get; set; }
    public string?  CheckNumber { get; set; }
    public string?  Notes       { get; set; }

    public Ministry?                 Ministry { get; set; }
    public List<ExpenseSnapshotLine> Lines    { get; set; } = new();
}
  • Step 2: Create the line entity

Create API/ROLAC.API/Entities/ExpenseSnapshotLine.cs:

using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;

/// <summary>One category line of an <see cref="ExpenseSnapshot"/>, mirroring <see cref="ExpenseLine"/>.</summary>
public class ExpenseSnapshotLine : AuditableEntity
{
    public int      Id              { get; set; }
    public int      SnapshotId      { get; set; }
    public int      CategoryGroupId { get; set; }
    public int      SubCategoryId   { get; set; }
    public string?  FunctionalClass { get; set; }   // null = inherit Ministry.DefaultFunctionalClass
    public decimal  Amount          { get; set; }
    public string?  Description     { get; set; }

    public ExpenseSnapshot?      Snapshot      { get; set; }
    public ExpenseCategoryGroup? CategoryGroup { get; set; }
    public ExpenseSubCategory?   SubCategory   { get; set; }
}
  • Step 3: Commit
git add API/ROLAC.API/Entities/ExpenseSnapshot.cs API/ROLAC.API/Entities/ExpenseSnapshotLine.cs
git commit -m "feat(expense-snapshot): add ExpenseSnapshot + ExpenseSnapshotLine entities"

Task 2: Register entities in DbContext + migration

Files:

  • Modify: API/ROLAC.API/Data/AppDbContext.cs:25 (DbSets) and :293 (after ExpenseLine config block)

  • Step 1: Add the DbSets

In API/ROLAC.API/Data/AppDbContext.cs, after the ExpenseLines DbSet (line 25), add:

    public DbSet<ExpenseSnapshot>     ExpenseSnapshots     => Set<ExpenseSnapshot>();
    public DbSet<ExpenseSnapshotLine> ExpenseSnapshotLines => Set<ExpenseSnapshotLine>();
  • Step 2: Add entity configuration

In OnModelCreating, immediately after the closing }); of the ExpenseLine configuration block (ends at line 293, just before the ChurchProfile block), add:

        // ── ExpenseSnapshot (reusable vendor-payment template) ───────────────
        builder.Entity<ExpenseSnapshot>(entity =>
        {
            entity.HasQueryFilter(s => !s.IsDeleted);

            entity.Property(e => e.Name).HasMaxLength(150).IsRequired();
            entity.Property(e => e.Description).HasMaxLength(500).IsRequired();
            entity.Property(e => e.VendorName).HasMaxLength(200);
            entity.Property(e => e.CheckNumber).HasMaxLength(50);
            entity.Property(e => e.CreatedBy).HasMaxLength(450);
            entity.Property(e => e.UpdatedBy).HasMaxLength(450);
            entity.Property(e => e.DeletedBy).HasMaxLength(450);

            entity.HasIndex(e => e.CreatedAt);

            entity.HasOne(e => e.Ministry).WithMany()
                  .HasForeignKey(e => e.MinistryId).OnDelete(DeleteBehavior.Restrict);
        });

        // ── ExpenseSnapshotLine (category breakdown of one snapshot) ─────────
        builder.Entity<ExpenseSnapshotLine>(entity =>
        {
            // Mirror the parent snapshot's soft-delete filter (required relationship).
            entity.HasQueryFilter(l => !l.Snapshot!.IsDeleted);

            entity.Property(e => e.FunctionalClass).HasMaxLength(20);
            entity.Property(e => e.Amount).HasColumnType("decimal(18,2)");
            entity.Property(e => e.Description).HasMaxLength(500);
            entity.Property(e => e.CreatedBy).HasMaxLength(450);
            entity.Property(e => e.UpdatedBy).HasMaxLength(450);

            entity.HasIndex(e => e.SnapshotId);

            entity.HasOne(e => e.Snapshot).WithMany(x => x.Lines)
                  .HasForeignKey(e => e.SnapshotId).OnDelete(DeleteBehavior.Cascade);
            entity.HasOne(e => e.CategoryGroup).WithMany()
                  .HasForeignKey(e => e.CategoryGroupId).OnDelete(DeleteBehavior.Restrict);
            entity.HasOne(e => e.SubCategory).WithMany()
                  .HasForeignKey(e => e.SubCategoryId).OnDelete(DeleteBehavior.Restrict);
        });
  • Step 3: Verify it compiles

Run: dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release Expected: Build succeeded, 0 errors.

  • Step 4: Generate the migration

Run from the repo root: dotnet ef migrations add AddExpenseSnapshots --project API/ROLAC.API --configuration Release Expected: creates API/ROLAC.API/Migrations/<timestamp>_AddExpenseSnapshots.cs creating two tables (ExpenseSnapshots, ExpenseSnapshotLines). Open the file and confirm both CreateTable calls are present and the down-migration drops both.

(If dotnet ef is not installed: dotnet tool install --global dotnet-ef first.)

  • Step 5: Apply the migration

Run: dotnet ef database update --project API/ROLAC.API --configuration Release Expected: "Done." and the two tables exist in the dev database.

  • Step 6: Commit
git add API/ROLAC.API/Data/AppDbContext.cs API/ROLAC.API/Migrations/
git commit -m "feat(expense-snapshot): register snapshot tables + EF migration"

Task 3: DTOs

Files:

  • Create: API/ROLAC.API/DTOs/Expense/ExpenseSnapshotDtos.cs

  • Step 1: Create the DTO file

Note: ExpenseLineInput is reused from ExpenseDtos.cs (same namespace ROLAC.API.DTOs.Expense), so snapshot lines share the expense line validation.

Create API/ROLAC.API/DTOs/Expense/ExpenseSnapshotDtos.cs:

using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Expense;

public class ExpenseSnapshotLineDto
{
    public int      CategoryGroupId   { get; set; }
    public string   CategoryGroupName { get; set; } = "";
    public int      SubCategoryId     { get; set; }
    public string   SubCategoryName   { get; set; } = "";
    public string?  FunctionalClass   { get; set; }
    public decimal  Amount            { get; set; }
    public string?  Description       { get; set; }
}

public class ExpenseSnapshotDto
{
    public int      Id           { get; set; }
    public string   Name         { get; set; } = "";
    public int      MinistryId   { get; set; }
    public string   MinistryName { get; set; } = "";
    public string   Description  { get; set; } = "";
    public string?  VendorName   { get; set; }
    public string?  CheckNumber  { get; set; }
    public string?  Notes        { get; set; }
    public decimal  TotalAmount  { get; set; }   // sum of line amounts (list hint)
    public int      LineCount    { get; set; }
    public string?  CreatedByName { get; set; }  // resolved Member full name, email fallback
    public DateTimeOffset CreatedAt { get; set; }
    public List<ExpenseSnapshotLineDto> Lines { get; set; } = new();
}

public class CreateExpenseSnapshotRequest
{
    [Required, MaxLength(150)] public string Name { get; set; } = "";
    [Required] public int MinistryId { get; set; }
    [Required, MinLength(1)] public List<ExpenseLineInput> Lines { get; set; } = new();
    [Required, MaxLength(500)] public string Description { get; set; } = "";
    [MaxLength(200)] public string? VendorName  { get; set; }
    [MaxLength(50)]  public string? CheckNumber { get; set; }
    public string? Notes { get; set; }
}
public class UpdateExpenseSnapshotRequest : CreateExpenseSnapshotRequest { }
  • Step 2: Verify it compiles

Run: dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release Expected: Build succeeded, 0 errors.

  • Step 3: Commit
git add API/ROLAC.API/DTOs/Expense/ExpenseSnapshotDtos.cs
git commit -m "feat(expense-snapshot): add snapshot DTOs"

Task 4: Service (TDD)

Files:

  • Create: API/ROLAC.API/Services/IExpenseSnapshotService.cs

  • Create: API/ROLAC.API/Services/ExpenseSnapshotService.cs

  • Test: API/ROLAC.API.Tests/Services/ExpenseSnapshotServiceTests.cs

  • Step 1: Write the service contract

Create API/ROLAC.API/Services/IExpenseSnapshotService.cs:

using ROLAC.API.DTOs.Expense;
namespace ROLAC.API.Services;

public interface IExpenseSnapshotService
{
    Task<List<ExpenseSnapshotDto>> GetAllAsync();
    Task<ExpenseSnapshotDto?>      GetByIdAsync(int id);
    Task<int>                      CreateAsync(CreateExpenseSnapshotRequest r);
    Task                           UpdateAsync(int id, UpdateExpenseSnapshotRequest r);
    Task                           DeleteAsync(int id);
}
  • Step 2: Write the failing tests

Create API/ROLAC.API.Tests/Services/ExpenseSnapshotServiceTests.cs:

using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.DTOs.Expense;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using ROLAC.API.Services.Logging;
using Xunit;

namespace ROLAC.API.Tests.Services;

public class ExpenseSnapshotServiceTests
{
    // DbContext whose CreatedBy/CreatedAt are stamped from the given user id (via the audit interceptor),
    // mirroring the production wiring used in ExpenseServiceTests.
    private static AppDbContext BuildDb(string userId)
    {
        var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
        var ctx    = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
        var mock   = new Mock<IHttpContextAccessor>();
        mock.Setup(x => x.HttpContext).Returns(ctx);
        return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString())
            .AddInterceptors(new AuditSaveChangesInterceptor(new CurrentUserAccessor(mock.Object))).Options);
    }

    private static (ExpenseSnapshotService svc, AppDbContext db) Build(string userId = "u1")
    {
        var db = BuildDb(userId);
        db.Ministries.Add(new Ministry { Id = 1, Name_en = "Worship", Name_zh = "敬拜" });
        db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Facilities" });
        db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Rent" });
        db.SaveChanges();
        return (new ExpenseSnapshotService(db), db);
    }

    private static CreateExpenseSnapshotRequest Rent() => new()
    {
        Name = "Monthly Rent", MinistryId = 1, Description = "Office rent", VendorName = "Landlord X",
        CheckNumber = "1001",
        Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 1200m } },
    };

    [Fact]
    public async Task Create_PersistsHeaderAndLines_StampsCreator()
    {
        var (svc, db) = Build("creator-1");
        var id = await svc.CreateAsync(Rent());

        var saved = await db.ExpenseSnapshots.FindAsync(id);
        Assert.Equal("Monthly Rent", saved!.Name);
        Assert.Equal("creator-1", saved.CreatedBy);
        Assert.Equal(1, await db.ExpenseSnapshotLines.CountAsync(l => l.SnapshotId == id));
    }

    [Fact]
    public async Task Create_WithNoLines_Throws()
    {
        var (svc, _) = Build();
        var r = Rent(); r.Lines.Clear();
        await Assert.ThrowsAsync<InvalidOperationException>(() => svc.CreateAsync(r));
    }

    [Fact]
    public async Task GetById_ReturnsLines_TotalsAndCreatorName()
    {
        var (svc, db) = Build("creator-1");
        db.Members.Add(new Member { Id = 5, FirstName_en = "Joy", LastName_en = "Wong" });
        db.Users.Add(new AppUser { Id = "creator-1", MemberId = 5 });
        await db.SaveChangesAsync();

        var id = await svc.CreateAsync(Rent());
        var dto = await svc.GetByIdAsync(id);

        Assert.NotNull(dto);
        Assert.Equal(1200m, dto!.TotalAmount);
        Assert.Equal(1, dto.LineCount);
        Assert.Equal("Rent", dto.Lines.Single().SubCategoryName);
        Assert.Equal("Joy Wong", dto.CreatedByName);
    }

    [Fact]
    public async Task GetAll_ReturnsNewestFirst()
    {
        var (svc, _) = Build();
        var first  = await svc.CreateAsync(Rent());
        var second = await svc.CreateAsync(Rent());

        var all = await svc.GetAllAsync();

        Assert.Equal(2, all.Count);
        Assert.Equal(second, all[0].Id);  // CreatedAt desc, then Id desc
        Assert.Equal(first,  all[1].Id);
    }

    [Fact]
    public async Task Update_RenamesAndReplacesLines()
    {
        var (svc, db) = Build();
        db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Utilities" });
        db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 2, Name_en = "Internet" });
        await db.SaveChangesAsync();

        var id = await svc.CreateAsync(Rent());
        await svc.UpdateAsync(id, new UpdateExpenseSnapshotRequest
        {
            Name = "Monthly Internet", MinistryId = 1, Description = "ISP",
            Lines = { new ExpenseLineInput { CategoryGroupId = 2, SubCategoryId = 2, Amount = 80m } },
        });

        var dto = await svc.GetByIdAsync(id);
        Assert.Equal("Monthly Internet", dto!.Name);
        Assert.Equal(80m, dto.TotalAmount);
        Assert.Equal("Internet", dto.Lines.Single().SubCategoryName);
        Assert.Equal(1, await db.ExpenseSnapshotLines.CountAsync(l => l.SnapshotId == id));
    }

    [Fact]
    public async Task Update_MissingId_Throws()
    {
        var (svc, _) = Build();
        await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.UpdateAsync(999, new UpdateExpenseSnapshotRequest
        {
            Name = "x", MinistryId = 1, Description = "x",
            Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 1m } },
        }));
    }

    [Fact]
    public async Task Delete_SoftDeletes_HidesFromQueries()
    {
        var (svc, db) = Build();
        var id = await svc.CreateAsync(Rent());

        await svc.DeleteAsync(id);

        Assert.Empty(await svc.GetAllAsync());
        Assert.Null(await db.ExpenseSnapshots.FirstOrDefaultAsync(s => s.Id == id));
    }
}
  • Step 3: Run the tests to verify they fail to compile

Run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter FullyQualifiedName~ExpenseSnapshotServiceTests Expected: FAILS to compile — ExpenseSnapshotService does not exist yet.

  • Step 4: Implement the service

Create API/ROLAC.API/Services/ExpenseSnapshotService.cs:

using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Expense;
using ROLAC.API.Entities;

namespace ROLAC.API.Services;

public class ExpenseSnapshotService : IExpenseSnapshotService
{
    private readonly AppDbContext _db;
    public ExpenseSnapshotService(AppDbContext db) => _db = db;

    public async Task<List<ExpenseSnapshotDto>> GetAllAsync()
    {
        var snaps = await _db.ExpenseSnapshots.AsNoTracking()
            .OrderByDescending(s => s.CreatedAt).ThenByDescending(s => s.Id)
            .ToListAsync();
        if (snaps.Count == 0) return new();

        var ids = snaps.Select(s => s.Id).ToList();
        var lines = await _db.ExpenseSnapshotLines.AsNoTracking()
            .Where(l => ids.Contains(l.SnapshotId)).ToListAsync();
        var linesBySnapshot = lines.GroupBy(l => l.SnapshotId).ToDictionary(g => g.Key, g => g.ToList());

        var minNames = await _db.Ministries.AsNoTracking().ToDictionaryAsync(m => m.Id, m => $"{m.Name_en} / {m.Name_zh}");
        var creatorNames = await ResolveUserNamesAsync(snaps.Select(s => s.CreatedBy));

        return snaps.Select(s =>
        {
            linesBySnapshot.TryGetValue(s.Id, out var ls);
            return new ExpenseSnapshotDto
            {
                Id = s.Id, Name = s.Name, MinistryId = s.MinistryId,
                MinistryName = minNames.GetValueOrDefault(s.MinistryId, ""),
                Description = s.Description, VendorName = s.VendorName,
                CheckNumber = s.CheckNumber, Notes = s.Notes,
                TotalAmount = ls?.Sum(l => l.Amount) ?? 0,
                LineCount = ls?.Count ?? 0,
                CreatedByName = creatorNames.GetValueOrDefault(s.CreatedBy),
                CreatedAt = s.CreatedAt,
            };
        }).ToList();
    }

    public async Task<ExpenseSnapshotDto?> GetByIdAsync(int id)
    {
        var s = await _db.ExpenseSnapshots.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
        if (s is null) return null;

        var lines = await _db.ExpenseSnapshotLines.AsNoTracking()
            .Where(l => l.SnapshotId == id).OrderBy(l => l.Id).ToListAsync();
        var minName = await _db.Ministries.Where(m => m.Id == s.MinistryId).Select(m => m.Name_en).FirstOrDefaultAsync() ?? "";
        var grpNames = await _db.ExpenseCategoryGroups.AsNoTracking().ToDictionaryAsync(g => g.Id, g => g.Name_en);
        var subNames = await _db.ExpenseSubCategories.AsNoTracking().ToDictionaryAsync(x => x.Id, x => x.Name_en);
        var creatorName = (await ResolveUserNamesAsync(new[] { s.CreatedBy })).GetValueOrDefault(s.CreatedBy);

        return new ExpenseSnapshotDto
        {
            Id = s.Id, Name = s.Name, MinistryId = s.MinistryId, MinistryName = minName,
            Description = s.Description, VendorName = s.VendorName, CheckNumber = s.CheckNumber, Notes = s.Notes,
            TotalAmount = lines.Sum(l => l.Amount), LineCount = lines.Count,
            CreatedByName = creatorName, CreatedAt = s.CreatedAt,
            Lines = lines.Select(l => new ExpenseSnapshotLineDto
            {
                CategoryGroupId = l.CategoryGroupId, CategoryGroupName = grpNames.GetValueOrDefault(l.CategoryGroupId, ""),
                SubCategoryId = l.SubCategoryId, SubCategoryName = subNames.GetValueOrDefault(l.SubCategoryId, ""),
                FunctionalClass = l.FunctionalClass, Amount = l.Amount, Description = l.Description,
            }).ToList(),
        };
    }

    public async Task<int> CreateAsync(CreateExpenseSnapshotRequest r)
    {
        ValidateLines(r.Lines);
        var s = new ExpenseSnapshot
        {
            Name = r.Name.Trim(), MinistryId = r.MinistryId, Description = r.Description,
            VendorName = r.VendorName, CheckNumber = r.CheckNumber, Notes = r.Notes,
            Lines = BuildLines(r.Lines),
        };
        _db.ExpenseSnapshots.Add(s);
        await _db.SaveChangesAsync();
        return s.Id;
    }

    public async Task UpdateAsync(int id, UpdateExpenseSnapshotRequest r)
    {
        ValidateLines(r.Lines);
        // FirstOrDefaultAsync (not FindAsync) so the soft-delete query filter applies.
        var s = await _db.ExpenseSnapshots.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id)
            ?? throw new KeyNotFoundException($"Snapshot {id} not found.");

        s.Name = r.Name.Trim(); s.MinistryId = r.MinistryId; s.Description = r.Description;
        s.VendorName = r.VendorName; s.CheckNumber = r.CheckNumber; s.Notes = r.Notes;

        // Replace the line set wholesale (lines are owned by the header).
        _db.ExpenseSnapshotLines.RemoveRange(s.Lines);
        s.Lines = BuildLines(r.Lines);
        await _db.SaveChangesAsync();
    }

    public async Task DeleteAsync(int id)
    {
        var s = await _db.ExpenseSnapshots.FirstOrDefaultAsync(x => x.Id == id)
            ?? throw new KeyNotFoundException($"Snapshot {id} not found.");
        s.IsDeleted = true; s.DeletedAt = DateTimeOffset.UtcNow;
        await _db.SaveChangesAsync();
    }

    private static void ValidateLines(List<ExpenseLineInput> lines)
    {
        if (lines is null || lines.Count == 0)
            throw new InvalidOperationException("A snapshot must have at least one line.");
        foreach (var l in lines)
        {
            if (l.CategoryGroupId <= 0 || l.SubCategoryId <= 0)
                throw new InvalidOperationException("Each snapshot line needs a category group and subcategory.");
            if (l.Amount <= 0)
                throw new InvalidOperationException("Each snapshot line amount must be greater than zero.");
        }
    }

    private static List<ExpenseSnapshotLine> BuildLines(List<ExpenseLineInput> inputs) =>
        inputs.Select(l => new ExpenseSnapshotLine
        {
            CategoryGroupId = l.CategoryGroupId, SubCategoryId = l.SubCategoryId,
            FunctionalClass = l.FunctionalClass, Amount = l.Amount, Description = l.Description,
        }).ToList();

    // Resolve actor user ids (AppUser.Id, stored in CreatedBy) to a display name: the linked
    // Member's full name when present, otherwise the account email. Mirrors ExpenseService.
    private async Task<Dictionary<string, string>> ResolveUserNamesAsync(IEnumerable<string?> userIds)
    {
        var ids = userIds.Where(id => !string.IsNullOrEmpty(id)).Select(id => id!).Distinct().ToList();
        if (ids.Count == 0) return new();

        var users = await _db.Users.AsNoTracking()
            .Where(u => ids.Contains(u.Id))
            .Select(u => new { u.Id, u.Email, u.MemberId })
            .ToListAsync();

        var memberIds = users.Where(u => u.MemberId != null).Select(u => u.MemberId!.Value).ToHashSet();
        var memberNames = await _db.Members.AsNoTracking()
            .Where(m => memberIds.Contains(m.Id))
            .ToDictionaryAsync(m => m.Id, m => $"{m.FirstName_en} {m.LastName_en}".Trim());

        return users.ToDictionary(
            u => u.Id,
            u => u.MemberId != null && memberNames.TryGetValue(u.MemberId.Value, out var name) && name.Length > 0
                ? name
                : (u.Email ?? u.Id));
    }
}
  • Step 5: Run the tests to verify they pass

Run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter FullyQualifiedName~ExpenseSnapshotServiceTests Expected: PASS — 7 passed.

  • Step 6: Commit
git add API/ROLAC.API/Services/IExpenseSnapshotService.cs API/ROLAC.API/Services/ExpenseSnapshotService.cs API/ROLAC.API.Tests/Services/ExpenseSnapshotServiceTests.cs
git commit -m "feat(expense-snapshot): snapshot service with creator-name resolution + tests"

Task 5: Controller + DI registration

Files:

  • Create: API/ROLAC.API/Controllers/ExpenseSnapshotsController.cs

  • Modify: API/ROLAC.API/Program.cs:155

  • Step 1: Create the controller

Authorization mirrors ExpensesController: snapshots are a finance-only vendor-payment tool, so every endpoint is gated on Expenses:Write (super_admin bypasses). Create API/ROLAC.API/Controllers/ExpenseSnapshotsController.cs:

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Expense;
using ROLAC.API.Services;

namespace ROLAC.API.Controllers;

// Snapshots are reusable vendor-payment templates — a finance tool. Every action requires
// Expenses:Write (super_admin bypasses), matching who can create vendor payments.
[ApiController]
[Route("api/expense-snapshots")]
[Authorize]
public class ExpenseSnapshotsController : ControllerBase
{
    private readonly IExpenseSnapshotService _svc;
    private readonly IPermissionService _perms;
    public ExpenseSnapshotsController(IExpenseSnapshotService svc, IPermissionService perms)
    {
        _svc = svc;
        _perms = perms;
    }

    private List<string> Roles() => User.FindAll("role").Select(claim => claim.Value).ToList();
    private bool IsSuperAdmin() => User.IsInRole(PermissionAuthorizationHandler.SuperAdminRole);
    private async Task<bool> CanManageAsync() =>
        IsSuperAdmin() || await _perms.HasPermissionAsync(Roles(), Modules.Expenses, PermissionActions.Write);

    [HttpGet]
    public async Task<IActionResult> GetAll()
    {
        if (!await CanManageAsync()) return Forbid();
        return Ok(await _svc.GetAllAsync());
    }

    [HttpGet("{id:int}")]
    public async Task<IActionResult> GetById(int id)
    {
        if (!await CanManageAsync()) return Forbid();
        var dto = await _svc.GetByIdAsync(id);
        return dto is null ? NotFound() : Ok(dto);
    }

    [HttpPost]
    public async Task<IActionResult> Create([FromBody] CreateExpenseSnapshotRequest r)
    {
        if (!await CanManageAsync()) return Forbid();
        try { return Ok(new { id = await _svc.CreateAsync(r) }); }
        catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
    }

    [HttpPut("{id:int}")]
    public async Task<IActionResult> Update(int id, [FromBody] UpdateExpenseSnapshotRequest r)
    {
        if (!await CanManageAsync()) return Forbid();
        try { await _svc.UpdateAsync(id, r); return NoContent(); }
        catch (KeyNotFoundException) { return NotFound(); }
        catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
    }

    [HttpDelete("{id:int}")]
    public async Task<IActionResult> Delete(int id)
    {
        if (!await CanManageAsync()) return Forbid();
        try { await _svc.DeleteAsync(id); return NoContent(); }
        catch (KeyNotFoundException) { return NotFound(); }
    }
}
  • Step 2: Register the service in DI

In API/ROLAC.API/Program.cs, immediately after line 155 (builder.Services.AddScoped<IExpenseService, ExpenseService>();), add:

builder.Services.AddScoped<IExpenseSnapshotService, ExpenseSnapshotService>();
  • Step 3: Verify build + full test suite still green

Run: dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release Expected: Build succeeded, 0 errors. Run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter FullyQualifiedName~ExpenseSnapshotServiceTests Expected: PASS — 7 passed.

  • Step 4: Commit
git add API/ROLAC.API/Controllers/ExpenseSnapshotsController.cs API/ROLAC.API/Program.cs
git commit -m "feat(expense-snapshot): REST controller + DI registration"

Task 6: Frontend model + service (TDD)

Files:

  • Create: APP/src/app/features/expense/models/expense-snapshot.model.ts

  • Create: APP/src/app/features/expense/services/expense-snapshot-api.service.ts

  • Test: APP/src/app/features/expense/services/expense-snapshot-api.service.spec.ts

  • Step 1: Create the model

Create APP/src/app/features/expense/models/expense-snapshot.model.ts:

import { ExpenseLineInput, FunctionalClass } from './expense.model';

export interface ExpenseSnapshotLineDto {
  categoryGroupId: number; categoryGroupName: string;
  subCategoryId: number; subCategoryName: string;
  functionalClass: FunctionalClass | null; amount: number; description: string | null;
}

export interface ExpenseSnapshotDto {
  id: number; name: string; ministryId: number; ministryName: string;
  description: string; vendorName: string | null; checkNumber: string | null; notes: string | null;
  totalAmount: number; lineCount: number;
  createdByName: string | null; createdAt: string;
  lines: ExpenseSnapshotLineDto[];
}

export interface CreateExpenseSnapshotRequest {
  name: string; ministryId: number; lines: ExpenseLineInput[];
  description: string; vendorName: string | null; checkNumber: string | null; notes: string | null;
}
export type UpdateExpenseSnapshotRequest = CreateExpenseSnapshotRequest;
  • Step 2: Create the service

Create APP/src/app/features/expense/services/expense-snapshot-api.service.ts:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ApiConfigService } from '../../../core/services/api-config.service';
import {
  ExpenseSnapshotDto, CreateExpenseSnapshotRequest, UpdateExpenseSnapshotRequest,
} from '../models/expense-snapshot.model';

@Injectable({ providedIn: 'root' })
export class ExpenseSnapshotApiService {
  private readonly endpoint: string;
  constructor(private http: HttpClient, apiConfig: ApiConfigService) {
    this.endpoint = apiConfig.getApiUrl('expense-snapshots');
  }
  getAll(): Observable<ExpenseSnapshotDto[]> {
    return this.http.get<ExpenseSnapshotDto[]>(this.endpoint);
  }
  getById(id: number): Observable<ExpenseSnapshotDto> {
    return this.http.get<ExpenseSnapshotDto>(`${this.endpoint}/${id}`);
  }
  create(r: CreateExpenseSnapshotRequest): Observable<{ id: number }> {
    return this.http.post<{ id: number }>(this.endpoint, r);
  }
  update(id: number, r: UpdateExpenseSnapshotRequest): Observable<void> {
    return this.http.put<void>(`${this.endpoint}/${id}`, r);
  }
  delete(id: number): Observable<void> {
    return this.http.delete<void>(`${this.endpoint}/${id}`);
  }
}
  • Step 3: Write the failing test

Create APP/src/app/features/expense/services/expense-snapshot-api.service.spec.ts:

import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { ExpenseSnapshotApiService } from './expense-snapshot-api.service';
import { ApiConfigService } from '../../../core/services/api-config.service';
import { CreateExpenseSnapshotRequest } from '../models/expense-snapshot.model';

describe('ExpenseSnapshotApiService', () => {
  let service: ExpenseSnapshotApiService;
  let httpMock: HttpTestingController;
  const base = 'http://test/api/expense-snapshots';

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [
        ExpenseSnapshotApiService,
        { provide: ApiConfigService, useValue: { getApiUrl: () => base } },
      ],
    });
    service = TestBed.inject(ExpenseSnapshotApiService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  afterEach(() => httpMock.verify());

  it('getAll() GETs the collection endpoint', () => {
    service.getAll().subscribe();
    const req = httpMock.expectOne(base);
    expect(req.request.method).toBe('GET');
    req.flush([]);
  });

  it('create() POSTs the request body', () => {
    const body: CreateExpenseSnapshotRequest = {
      name: 'Rent', ministryId: 1, description: 'Office rent',
      vendorName: 'Landlord X', checkNumber: null, notes: null,
      lines: [{ categoryGroupId: 1, subCategoryId: 1, amount: 1200, functionalClass: null, description: null }],
    };
    service.create(body).subscribe();
    const req = httpMock.expectOne(base);
    expect(req.request.method).toBe('POST');
    expect(req.request.body.name).toBe('Rent');
    req.flush({ id: 7 });
  });

  it('delete() DELETEs by id', () => {
    service.delete(9).subscribe();
    const req = httpMock.expectOne(`${base}/9`);
    expect(req.request.method).toBe('DELETE');
    req.flush(null);
  });
});
  • Step 4: Run the test

Run from APP/:

$env:CHROME_BIN = "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"
npx ng test --include="**/expense-snapshot-api.service.spec.ts" --watch=false --browsers=ChromeHeadless

Expected: PASS — 3 specs, 0 failures. (If the Edge path differs on this machine, point CHROME_BIN at the local msedge.exe.)

  • Step 5: Commit
git add APP/src/app/features/expense/models/expense-snapshot.model.ts APP/src/app/features/expense/services/expense-snapshot-api.service.ts APP/src/app/features/expense/services/expense-snapshot-api.service.spec.ts
git commit -m "feat(expense-snapshot): frontend model + api service with tests"

Task 7: Form dialog — load + save snapshot (vendor mode)

Files:

  • Modify: APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.ts

  • Modify: APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.html

  • Step 1: Add the snapshot imports + state to the component

In expense-form-dialog.component.ts, add to the imports near the other service imports (after line 12, import { ExpenseApiService } ...):

import { ExpenseSnapshotApiService } from '../../services/expense-snapshot-api.service';
import { ExpenseSnapshotDto, CreateExpenseSnapshotRequest } from '../../models/expense-snapshot.model';

Inside the class, after the groups field (line 65), add:

  /** Saved snapshots (vendor mode only) for the "Load from snapshot" picker. */
  snapshots: ExpenseSnapshotDto[] = [];
  /** Picker binding; reset to null after each apply so the same snapshot can be re-picked. */
  selectedSnapshotId: number | null = null;

  /** "Save as snapshot" name-prompt state. */
  showSnapshotNamePrompt = false;
  snapshotName = '';
  snapshotSaving = false;

  /** Snapshot tools (load/save) are a vendor-payment feature only. */
  get showSnapshotTools(): boolean { return this.mode === 'vendor'; }
  • Step 2: Inject the snapshot service

In the constructor (line 114-121), add the parameter:

    private snapshotApi: ExpenseSnapshotApiService,

(Place it after private expenseApi: ExpenseApiService,.)

  • Step 3: Load snapshots on init (vendor mode)

In ngOnInit(), after the this.ministryApi.getAll()... line (line 124), add:

    if (this.showSnapshotTools) this.loadSnapshots();
  • Step 4: Add the load/save methods

Add these methods to the class (e.g. just before get isValid() at line 318):

  private loadSnapshots(): void {
    this.snapshotApi.getAll().subscribe(list => (this.snapshots = list));
  }

  /** Apply a saved snapshot: prefill header + lines, but keep today's Expense Date. */
  applySnapshot(snapshotId: number | null): void {
    if (snapshotId == null) return;
    this.snapshotApi.getById(snapshotId).subscribe(s => {
      this.form.ministryId = s.ministryId;
      this.form.description = s.description;
      this.form.vendorName = s.vendorName ?? '';
      this.form.checkNumber = s.checkNumber ?? '';
      // Expense Date is intentionally NOT taken from the snapshot — leave it as-is (today).
      this.lines = s.lines.map(line => ({
        categoryGroupId: line.categoryGroupId,
        subCategoryId: line.subCategoryId,
        amount: line.amount,
        description: line.description ?? '',
        functionalClass: line.functionalClass,
        subs: this.groups.find(g => g.id === line.categoryGroupId)?.subCategories ?? [],
      }));
      if (this.lines.length === 0) this.lines = [this.emptyLine()];
      this.selectedSnapshotId = null;
    });
  }

  /** Open the name prompt for saving the current form as a snapshot (requires a valid form). */
  openSnapshotPrompt(): void {
    if (!this.isValid) return;
    this.snapshotName = '';
    this.showSnapshotNamePrompt = true;
  }

  cancelSnapshotPrompt(): void { this.showSnapshotNamePrompt = false; }

  /** Save the current header + lines as a named snapshot (Expense Date is not stored). */
  saveSnapshot(): void {
    const name = this.snapshotName.trim();
    if (!name || this.snapshotSaving) return;
    const request: CreateExpenseSnapshotRequest = {
      name,
      ministryId: this.form.ministryId!,
      lines: this.lines.map(l => ({
        categoryGroupId: l.categoryGroupId!,
        subCategoryId: l.subCategoryId!,
        amount: l.amount,
        functionalClass: l.functionalClass,
        description: l.description.trim() || null,
      })),
      description: this.form.description.trim(),
      vendorName: this.form.vendorName || null,
      checkNumber: this.form.checkNumber || null,
      notes: null,
    };
    this.snapshotSaving = true;
    this.snapshotApi.create(request).subscribe({
      next: () => {
        this.snapshotSaving = false;
        this.showSnapshotNamePrompt = false;
        this.loadSnapshots();
      },
      error: () => { this.snapshotSaving = false; },
    });
  }
  • Step 5: Add the picker + save button to the template

In expense-form-dialog.component.html, insert this block right after the opening <div class="flex-1 min-w-0 ..."> (after line 6), as the first child of the left form column:

      <!-- Snapshot tools (vendor mode): quick-load a saved template, or save the current form -->
      <div *ngIf="showSnapshotTools" class="md:col-span-2 flex flex-wrap items-end gap-2 rounded border border-gray-200 bg-gray-50 p-2">
        <label class="flex flex-1 min-w-[14rem] flex-col gap-1">範本 / Load from snapshot
          <kendo-dropdownlist [data]="snapshots" textField="name" valueField="id" [valuePrimitive]="true"
            [(ngModel)]="selectedSnapshotId" (valueChange)="applySnapshot($event)"
            [defaultItem]="{ id: null, name: '-- 選擇範本 / Select a saved snapshot --' }">
          </kendo-dropdownlist>
        </label>
        <button kendoButton fillMode="outline" themeColor="primary" type="button"
          [disabled]="!isValid" (click)="openSnapshotPrompt()"
          title="Save the current form as a reusable snapshot / 儲存為範本">💾 存為範本 / Save as snapshot</button>
      </div>

Then add the name-prompt dialog as a sibling of the main <kendo-dialog>, immediately after its closing </kendo-dialog> (after line 213):

<!-- Save-as-snapshot name prompt -->
<kendo-dialog *ngIf="showSnapshotNamePrompt" title="存為範本 / Save as Snapshot" [width]="420" [maxWidth]="'95vw'"
  (close)="cancelSnapshotPrompt()">
  <div class="flex flex-col gap-2 p-2">
    <label class="flex flex-col gap-1">名稱 / Name
      <kendo-textbox [(ngModel)]="snapshotName" placeholder="e.g. Monthly Rent — Landlord X"></kendo-textbox>
    </label>
    <p class="text-xs text-gray-500">費用日期不會存入範本 / The Expense Date is not saved in a snapshot.</p>
  </div>
  <kendo-dialog-actions>
    <button kendoButton (click)="cancelSnapshotPrompt()">Cancel</button>
    <button kendoButton themeColor="primary" [disabled]="!snapshotName.trim() || snapshotSaving"
      (click)="saveSnapshot()">{{ snapshotSaving ? '儲存中… / Saving…' : 'Save' }}</button>
  </kendo-dialog-actions>
</kendo-dialog>
  • Step 6: Verify the build compiles

Run from APP/: npx ng build --configuration development Expected: build completes with no errors referencing expense-form-dialog.

  • Step 7: Commit
git add APP/src/app/features/expense/components/expense-form-dialog/
git commit -m "feat(expense-snapshot): load/save snapshot in vendor payment form"

Task 8: Management page

Files:

  • Create: APP/src/app/features/expense/pages/expense-snapshots-page/expense-snapshots-page.component.ts

  • Create: APP/src/app/features/expense/pages/expense-snapshots-page/expense-snapshots-page.component.html

  • Create: APP/src/app/features/expense/pages/expense-snapshots-page/expense-snapshots-page.component.scss

  • Step 1: Create the component class

Rename re-uses the update endpoint: the page fetches the full snapshot, swaps name, and PUTs it back (the line set is preserved). Create expense-snapshots-page.component.ts:

import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { GridModule } from '@progress/kendo-angular-grid';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { DialogsModule } from '@progress/kendo-angular-dialog';
import { ExpenseSnapshotApiService } from '../../services/expense-snapshot-api.service';
import { ExpenseSnapshotDto } from '../../models/expense-snapshot.model';
import { switchMap } from 'rxjs';

@Component({
  selector: 'app-expense-snapshots-page',
  standalone: true,
  imports: [CommonModule, FormsModule, GridModule, ButtonsModule, InputsModule, DialogsModule],
  templateUrl: './expense-snapshots-page.component.html',
  styleUrls: ['./expense-snapshots-page.component.scss'],
})
export class ExpenseSnapshotsPageComponent implements OnInit {
  rows: ExpenseSnapshotDto[] = [];
  loading = false;

  /** Row being renamed (drives the rename dialog); null when closed. */
  renameRow: ExpenseSnapshotDto | null = null;
  renameValue = '';
  renameSaving = false;

  /** Row pending delete confirmation. */
  deleteRow: ExpenseSnapshotDto | null = null;

  constructor(private api: ExpenseSnapshotApiService) {}

  ngOnInit(): void { this.load(); }

  load(): void {
    this.loading = true;
    this.api.getAll().subscribe({
      next: list => { this.rows = list; this.loading = false; },
      error: () => { this.loading = false; },
    });
  }

  openRename(row: ExpenseSnapshotDto): void {
    this.renameRow = row;
    this.renameValue = row.name;
  }
  cancelRename(): void { this.renameRow = null; }

  confirmRename(): void {
    const row = this.renameRow;
    const name = this.renameValue.trim();
    if (!row || !name || this.renameSaving) return;
    this.renameSaving = true;
    // Fetch the full snapshot, swap the name, PUT it back (lines/fields preserved).
    this.api.getById(row.id).pipe(
      switchMap(full => this.api.update(row.id, {
        name,
        ministryId: full.ministryId,
        description: full.description,
        vendorName: full.vendorName,
        checkNumber: full.checkNumber,
        notes: full.notes,
        lines: full.lines.map(l => ({
          categoryGroupId: l.categoryGroupId,
          subCategoryId: l.subCategoryId,
          amount: l.amount,
          functionalClass: l.functionalClass,
          description: l.description,
        })),
      })),
    ).subscribe({
      next: () => { this.renameSaving = false; this.renameRow = null; this.load(); },
      error: () => { this.renameSaving = false; },
    });
  }

  openDelete(row: ExpenseSnapshotDto): void { this.deleteRow = row; }
  cancelDelete(): void { this.deleteRow = null; }

  confirmDelete(): void {
    if (!this.deleteRow) return;
    this.api.delete(this.deleteRow.id).subscribe(() => { this.deleteRow = null; this.load(); });
  }
}
  • Step 2: Create the template (mobile-friendly: desktop grid + card list)

Per house rule, desktop uses hidden md:block, mobile uses a md:hidden card list, and the card list's flex layout lives in Tailwind utilities (never component SCSS). Create expense-snapshots-page.component.html:

<div class="page">
  <p class="mb-4 text-sm text-gray-600">
    儲存常用的固定費用(房租、網路、餐費…)為範本,下次可快速套用。費用日期不會儲存。<br>
    Save recurring fixed expenses as snapshots to quickly re-use them. The Expense Date is never saved.
  </p>

  <!-- Desktop: grid -->
  <div class="hidden md:block">
    <kendo-grid [data]="rows" [loading]="loading">
      <kendo-grid-column field="name" title="Snapshot / 範本" [width]="240"></kendo-grid-column>
      <kendo-grid-column field="vendorName" title="Vendor / 廠商" [width]="180">
        <ng-template kendoGridCellTemplate let-dataItem>{{ dataItem.vendorName || '—' }}</ng-template>
      </kendo-grid-column>
      <kendo-grid-column field="ministryName" title="Ministry / 事工"></kendo-grid-column>
      <kendo-grid-column field="totalAmount" title="Amount / 金額" [width]="120" format="c2"></kendo-grid-column>
      <kendo-grid-column title="Created by / 建立者" [width]="200">
        <ng-template kendoGridCellTemplate let-dataItem>
          {{ dataItem.createdByName || '—' }}<br>
          <span class="text-xs text-gray-500">{{ dataItem.createdAt | date:'yyyy-MM-dd' }}</span>
        </ng-template>
      </kendo-grid-column>
      <kendo-grid-column title="Actions" [width]="160">
        <ng-template kendoGridCellTemplate let-dataItem>
          <button kendoButton fillMode="flat" (click)="openRename(dataItem)">Rename</button>
          <button kendoButton fillMode="flat" themeColor="error" (click)="openDelete(dataItem)">Delete</button>
        </ng-template>
      </kendo-grid-column>
    </kendo-grid>
  </div>

  <!-- Mobile: card list -->
  <div class="md:hidden flex flex-col gap-3">
    <div *ngFor="let row of rows" class="rounded border border-gray-200 p-3 flex flex-col gap-1">
      <div class="flex items-center justify-between">
        <span class="font-semibold">{{ row.name }}</span>
        <span class="tabular-nums">{{ row.totalAmount | currency }}</span>
      </div>
      <div class="text-sm text-gray-600">{{ row.vendorName || '—' }} · {{ row.ministryName }}</div>
      <div class="text-xs text-gray-500">{{ row.createdByName || '—' }} · {{ row.createdAt | date:'yyyy-MM-dd' }}</div>
      <div class="flex gap-2 pt-1">
        <button kendoButton size="small" fillMode="outline" (click)="openRename(row)">Rename</button>
        <button kendoButton size="small" fillMode="outline" themeColor="error" (click)="openDelete(row)">Delete</button>
      </div>
    </div>
    <p *ngIf="!loading && rows.length === 0" class="text-sm text-gray-500">尚無範本 / No snapshots yet.</p>
  </div>

  <!-- Rename dialog -->
  <kendo-dialog *ngIf="renameRow" title="重新命名 / Rename Snapshot" [width]="420" [maxWidth]="'95vw'" (close)="cancelRename()">
    <label class="flex flex-col gap-1 p-2">名稱 / Name
      <kendo-textbox [(ngModel)]="renameValue"></kendo-textbox>
    </label>
    <kendo-dialog-actions>
      <button kendoButton (click)="cancelRename()">Cancel</button>
      <button kendoButton themeColor="primary" [disabled]="!renameValue.trim() || renameSaving"
        (click)="confirmRename()">{{ renameSaving ? 'Saving…' : 'Save' }}</button>
    </kendo-dialog-actions>
  </kendo-dialog>

  <!-- Delete confirm dialog -->
  <kendo-dialog *ngIf="deleteRow" title="刪除 / Delete Snapshot" [width]="420" [maxWidth]="'95vw'" (close)="cancelDelete()">
    <p class="p-2">確定刪除「{{ deleteRow.name }}」? / Delete "{{ deleteRow.name }}"?</p>
    <kendo-dialog-actions>
      <button kendoButton (click)="cancelDelete()">Cancel</button>
      <button kendoButton themeColor="error" (click)="confirmDelete()">Delete</button>
    </kendo-dialog-actions>
  </kendo-dialog>
</div>
  • Step 3: Create a minimal SCSS file

Per house rule, do NOT put display rules here. Create expense-snapshots-page.component.scss:

.page {
  padding: 0.5rem 0;
}
  • Step 4: Verify the build compiles

Run from APP/: npx ng build --configuration development Expected: build completes with no errors referencing expense-snapshots-page.

  • Step 5: Commit
git add APP/src/app/features/expense/pages/expense-snapshots-page/
git commit -m "feat(expense-snapshot): snapshot management page (rename/delete)"

Task 9: Route + sidebar nav

Files:

  • Modify: APP/src/app/app.routes.ts (finance routes block, near line 192)

  • Modify: APP/src/app/portals/user-portal/user-portal.component.ts:139 (Expenses nav group)

  • Step 1: Import the page component in the routes file

In APP/src/app/app.routes.ts, add an import alongside the other expense page imports (e.g. near the ExpensesPageComponent import):

import { ExpenseSnapshotsPageComponent } from './features/expense/pages/expense-snapshots-page/expense-snapshots-page.component';

(Confirm the exact relative path matches the other expense page imports in that file; they live under ./features/expense/pages/....)

  • Step 2: Add the route

In app.routes.ts, immediately after the finance/expenses route object (closes at line 164), add:

            {
                path: 'finance/expense-snapshots',
                component: ExpenseSnapshotsPageComponent,
                canActivate: [PermissionGuard],
                data: {
                    permission: { module: PermissionModules.Expenses, action: 'read' },
                    title: 'Expense Snapshots', titleZh: '費用範本', section: 'Finance',
                },
            },
  • Step 3: Add the sidebar nav item

In APP/src/app/portals/user-portal/user-portal.component.ts, inside the Expenses finance group's items array, after the Expense Categories item (line 133-134), add:

        { text: 'Expense Snapshots', icon: categorizeIcon,      path: '/user-portal/finance/expense-snapshots',
          permission: { module: PermissionModules.Expenses, action: 'read' } },

(categorizeIcon is already imported and used in this file.)

  • Step 4: Verify the build compiles

Run from APP/: npx ng build --configuration development Expected: build completes, 0 errors.

  • Step 5: Commit
git add APP/src/app/app.routes.ts APP/src/app/portals/user-portal/user-portal.component.ts
git commit -m "feat(expense-snapshot): route + sidebar nav for snapshot management"

Task 10: End-to-end verification

Files: none (manual + automated verification)

  • Step 1: Backend — full build + targeted tests green

Run: dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release → Build succeeded. Run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter FullyQualifiedName~ExpenseSnapshotServiceTests → 7 passed.

  • Step 2: Frontend — service tests + build green

Run from APP/:

$env:CHROME_BIN = "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"
npx ng test --include="**/expense-snapshot-api.service.spec.ts" --watch=false --browsers=ChromeHeadless
npx ng build --configuration development

Expected: 3 specs pass; build succeeds.

  • Step 3: Manual smoke test

Start the API and the frontend (see the Build/Run env memory). As a finance user:

  1. Finance → Expenses → + Vendor Payment. Fill ministry, description, vendor, one line with an amount. Click 存為範本 / Save as snapshot, name it "Smoke Rent", Save. Confirm no error.
  2. Close the dialog, reopen + Vendor Payment. In Load from snapshot, pick "Smoke Rent". Confirm ministry/description/vendor/line all prefill and Expense Date stays today (not blank/altered).
  3. Finance → Expense Snapshots. Confirm "Smoke Rent" appears with vendor, amount, and your name as creator. Rename it → confirm the new name shows. Delete it → confirm it disappears.
  4. Resize the browser to mobile width on the Expense Snapshots page → confirm the card list shows (not the grid) and actions work.
  • Step 4: Final commit (if any manual-fix tweaks were needed)
git add -A
git commit -m "test(expense-snapshot): verification fixes"

(Skip if nothing changed.)


Self-Review Notes

  • Spec coverage: capture fields (Task 1/3), shared + creator tag resolved at read (Task 4 ResolveUserNamesAsync + GetByIdName test), exclude ExpenseDate/receipt/MemberId (entities omit them; Task 7 applySnapshot keeps today's date), save-from-form (Task 7), quick picker + management page both present (Tasks 7 & 8 — spec Q4=C), rename + delete (Task 8), Expenses:Write gating (Task 5), mobile-friendly management page (Task 8). All covered.
  • CheckNumber: captured and editable per spec; no special handling needed.
  • Type consistency: CreateExpenseSnapshotRequest/UpdateExpenseSnapshotRequest, ExpenseSnapshotDto, ExpenseSnapshotLineDto, ExpenseLineInput names match across backend DTOs (Task 3), service (Task 4), frontend model (Task 6), and consumers (Tasks 7-8). Service method names (getAll/getById/create/update/delete) are identical across interface, service, and Angular service.