Save a vendor payment as a reusable named snapshot and re-apply it later, pre-filling every field except the Expense Date. Shared church-wide with a creator tag; quick picker in the vendor form + a management page (rename/delete). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
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
|
||||
{
|
||||
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();
|
||||
|
||||
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
|
||||
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
|
||||
var http = new Mock<IHttpContextAccessor>();
|
||||
http.Setup(x => x.HttpContext).Returns(ctx);
|
||||
return (new ExpenseSnapshotService(db, http.Object), 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 Create_WithInvalidFunctionalClass_Throws()
|
||||
{
|
||||
var (svc, _) = Build();
|
||||
var r = Rent();
|
||||
r.Lines[0].FunctionalClass = "NotAClass";
|
||||
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);
|
||||
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));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_StampsDeletedBy()
|
||||
{
|
||||
var (svc, db) = Build("deleter-1");
|
||||
var id = await svc.CreateAsync(Rent());
|
||||
await svc.DeleteAsync(id);
|
||||
var row = await db.ExpenseSnapshots.IgnoreQueryFilters().FirstAsync(s => s.Id == id);
|
||||
Assert.Equal("deleter-1", row.DeletedBy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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 { }
|
||||
@@ -23,6 +23,8 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
public DbSet<Form990ExpenseLine> Form990ExpenseLines => Set<Form990ExpenseLine>();
|
||||
public DbSet<Expense> Expenses => Set<Expense>();
|
||||
public DbSet<ExpenseLine> ExpenseLines => Set<ExpenseLine>();
|
||||
public DbSet<ExpenseSnapshot> ExpenseSnapshots => Set<ExpenseSnapshot>();
|
||||
public DbSet<ExpenseSnapshotLine> ExpenseSnapshotLines => Set<ExpenseSnapshotLine>();
|
||||
public DbSet<MonthlyStatement> MonthlyStatements => Set<MonthlyStatement>();
|
||||
public DbSet<ChurchProfile> ChurchProfiles => Set<ChurchProfile>();
|
||||
public DbSet<Check> Checks => Set<Check>();
|
||||
@@ -292,6 +294,47 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
.HasForeignKey(e => e.SubCategoryId).OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
// ── 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);
|
||||
});
|
||||
|
||||
// ── ChurchProfile (singleton settings) ───────────────────────────────
|
||||
builder.Entity<ChurchProfile>(entity =>
|
||||
{
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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; }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ROLAC.API.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddExpenseSnapshots : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExpenseSnapshots",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Name = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: false),
|
||||
MinistryId = table.Column<int>(type: "integer", nullable: false),
|
||||
Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||
VendorName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
CheckNumber = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||
Notes = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
CreatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
|
||||
DeletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ExpenseSnapshots", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExpenseSnapshots_Ministries_MinistryId",
|
||||
column: x => x.MinistryId,
|
||||
principalTable: "Ministries",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExpenseSnapshotLines",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
SnapshotId = table.Column<int>(type: "integer", nullable: false),
|
||||
CategoryGroupId = table.Column<int>(type: "integer", nullable: false),
|
||||
SubCategoryId = table.Column<int>(type: "integer", nullable: false),
|
||||
FunctionalClass = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
|
||||
Amount = table.Column<decimal>(type: "numeric(18,2)", nullable: false),
|
||||
Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
CreatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ExpenseSnapshotLines", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExpenseSnapshotLines_ExpenseCategoryGroups_CategoryGroupId",
|
||||
column: x => x.CategoryGroupId,
|
||||
principalTable: "ExpenseCategoryGroups",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExpenseSnapshotLines_ExpenseSnapshots_SnapshotId",
|
||||
column: x => x.SnapshotId,
|
||||
principalTable: "ExpenseSnapshots",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExpenseSnapshotLines_ExpenseSubCategories_SubCategoryId",
|
||||
column: x => x.SubCategoryId,
|
||||
principalTable: "ExpenseSubCategories",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExpenseSnapshotLines_CategoryGroupId",
|
||||
table: "ExpenseSnapshotLines",
|
||||
column: "CategoryGroupId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExpenseSnapshotLines_SnapshotId",
|
||||
table: "ExpenseSnapshotLines",
|
||||
column: "SnapshotId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExpenseSnapshotLines_SubCategoryId",
|
||||
table: "ExpenseSnapshotLines",
|
||||
column: "SubCategoryId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExpenseSnapshots_CreatedAt",
|
||||
table: "ExpenseSnapshots",
|
||||
column: "CreatedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExpenseSnapshots_MinistryId",
|
||||
table: "ExpenseSnapshots",
|
||||
column: "MinistryId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExpenseSnapshotLines");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExpenseSnapshots");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -762,6 +762,128 @@ namespace ROLAC.API.Migrations
|
||||
b.ToTable("ExpenseLines");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSnapshot", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("CheckNumber")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("character varying(450)");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("character varying(450)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("MinistryId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("character varying(150)");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("character varying(450)");
|
||||
|
||||
b.Property<string>("VendorName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("MinistryId");
|
||||
|
||||
b.ToTable("ExpenseSnapshots");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSnapshotLine", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int>("CategoryGroupId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("character varying(450)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("FunctionalClass")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<int>("SnapshotId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("SubCategoryId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("character varying(450)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CategoryGroupId");
|
||||
|
||||
b.HasIndex("SnapshotId");
|
||||
|
||||
b.HasIndex("SubCategoryId");
|
||||
|
||||
b.ToTable("ExpenseSnapshotLines");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -2128,6 +2250,44 @@ namespace ROLAC.API.Migrations
|
||||
b.Navigation("SubCategory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSnapshot", b =>
|
||||
{
|
||||
b.HasOne("ROLAC.API.Entities.Ministry", "Ministry")
|
||||
.WithMany()
|
||||
.HasForeignKey("MinistryId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Ministry");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSnapshotLine", b =>
|
||||
{
|
||||
b.HasOne("ROLAC.API.Entities.ExpenseCategoryGroup", "CategoryGroup")
|
||||
.WithMany()
|
||||
.HasForeignKey("CategoryGroupId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ROLAC.API.Entities.ExpenseSnapshot", "Snapshot")
|
||||
.WithMany("Lines")
|
||||
.HasForeignKey("SnapshotId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ROLAC.API.Entities.ExpenseSubCategory", "SubCategory")
|
||||
.WithMany()
|
||||
.HasForeignKey("SubCategoryId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CategoryGroup");
|
||||
|
||||
b.Navigation("Snapshot");
|
||||
|
||||
b.Navigation("SubCategory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b =>
|
||||
{
|
||||
b.HasOne("ROLAC.API.Entities.Form990ExpenseLine", "Form990Line")
|
||||
@@ -2273,6 +2433,11 @@ namespace ROLAC.API.Migrations
|
||||
b.Navigation("SubCategories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSnapshot", b =>
|
||||
{
|
||||
b.Navigation("Lines");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.OfferingSession", b =>
|
||||
{
|
||||
b.Navigation("Givings");
|
||||
|
||||
@@ -153,6 +153,7 @@ builder.Services.AddScoped<ROLAC.API.Services.Storage.IFileStorage,
|
||||
ROLAC.API.Services.Storage.LocalDiskFileStorage>();
|
||||
builder.Services.AddScoped<IExpenseCategoryService, ExpenseCategoryService>();
|
||||
builder.Services.AddScoped<IExpenseService, ExpenseService>();
|
||||
builder.Services.AddScoped<IExpenseSnapshotService, ExpenseSnapshotService>();
|
||||
builder.Services.AddScoped<IMonthlyStatementService, MonthlyStatementService>();
|
||||
builder.Services.AddScoped<IFinanceDashboardService, FinanceDashboardService>();
|
||||
builder.Services.AddScoped<IForm990ReportService, Form990ReportService>();
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
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;
|
||||
private readonly IHttpContextAccessor _http;
|
||||
public ExpenseSnapshotService(AppDbContext db, IHttpContextAccessor http)
|
||||
{ _db = db; _http = http; }
|
||||
|
||||
// The JWT carries the user id in the "sub" claim (NameClaimType="sub", MapInboundClaims=false),
|
||||
// so ClaimTypes.NameIdentifier is absent at runtime. Check NameIdentifier first (unit tests set it),
|
||||
// then fall back to "sub" (real tokens). Required for the self-ownership guard to work in production.
|
||||
private string CurrentUserId =>
|
||||
_http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? _http.HttpContext?.User.FindFirstValue("sub")
|
||||
?? "system";
|
||||
|
||||
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);
|
||||
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;
|
||||
|
||||
_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; s.DeletedBy = CurrentUserId;
|
||||
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.");
|
||||
if (l.FunctionalClass is not null && !FunctionalClasses.All.Contains(l.FunctionalClass))
|
||||
throw new InvalidOperationException($"Invalid functional class '{l.FunctionalClass}'.");
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
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);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { GivingsPageComponent } from './features/giving/pages/givings-page/givin
|
||||
import { OfferingSessionPageComponent } from './features/giving/pages/offering-session-page/offering-session-page.component';
|
||||
import { ExpenseCategoriesPageComponent } from './features/expense/pages/expense-categories-page/expense-categories-page.component';
|
||||
import { ExpensesPageComponent } from './features/expense/pages/expenses-page/expenses-page.component';
|
||||
import { ExpenseSnapshotsPageComponent } from './features/expense/pages/expense-snapshots-page/expense-snapshots-page.component';
|
||||
import { MyReimbursementsPageComponent } from './features/expense/pages/my-reimbursements-page/my-reimbursements-page.component';
|
||||
import { MonthlyStatementPageComponent } from './features/expense/pages/monthly-statement-page/monthly-statement-page.component';
|
||||
import { FinanceDashboardPageComponent } from './features/finance-dashboard/pages/finance-dashboard-page/finance-dashboard-page.component';
|
||||
@@ -162,6 +163,17 @@ export const routes: Routes = [
|
||||
title: 'Expenses', titleZh: '支出', section: 'Finance',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'finance/expense-snapshots',
|
||||
component: ExpenseSnapshotsPageComponent,
|
||||
canActivate: [PermissionGuard],
|
||||
data: {
|
||||
// Snapshots are a write-only management surface (the API gates every action on
|
||||
// Expenses:Write), so require write — a read-only user has nothing to do here.
|
||||
permission: { module: PermissionModules.Expenses, action: 'write' },
|
||||
title: 'Expense Snapshots', titleZh: '費用範本', section: 'Finance',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'finance/expense-categories',
|
||||
component: ExpenseCategoriesPageComponent,
|
||||
|
||||
+29
-5
@@ -5,6 +5,19 @@
|
||||
|
||||
<div class="flex-1 min-w-0 grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Continuous entry: keep member/ministry/category/date after each save (on-behalf reimbursement only) -->
|
||||
<label *ngIf="showContinueEntry" class="flex items-center gap-2 md:col-span-2">
|
||||
<kendo-switch [(ngModel)]="continueEntry"></kendo-switch>
|
||||
@@ -115,11 +128,6 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Per-line AI assist: translate this line's note + suggest its category from its own amount -->
|
||||
<div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Per-line suggestion card: the "suggest & confirm" step for this line -->
|
||||
<div *ngIf="hasLineSuggestion(line)"
|
||||
class="rounded border border-blue-200 bg-blue-50 p-3 flex flex-col gap-2 text-sm">
|
||||
@@ -210,4 +218,20 @@
|
||||
<button kendoButton (click)="cancel.emit()">Cancel</button>
|
||||
<button kendoButton themeColor="primary" [disabled]="!isValid" (click)="emitSave()">Save</button>
|
||||
</kendo-dialog-actions>
|
||||
</kendo-dialog>
|
||||
|
||||
<!-- 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>
|
||||
+82
@@ -10,6 +10,8 @@ import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
|
||||
import { MinistryApiService } from '../../services/ministry-api.service';
|
||||
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
|
||||
import { ExpenseApiService } from '../../services/expense-api.service';
|
||||
import { ExpenseSnapshotApiService } from '../../services/expense-snapshot-api.service';
|
||||
import { ExpenseSnapshotDto, CreateExpenseSnapshotRequest } from '../../models/expense-snapshot.model';
|
||||
import { ExpenseAiService } from '../../services/expense-ai.service';
|
||||
import { MemberApiService } from '../../../members/services/member-api.service';
|
||||
import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
|
||||
@@ -64,6 +66,19 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
|
||||
ministries: MinistryDto[] = [];
|
||||
groups: ExpenseCategoryGroupDto[] = [];
|
||||
|
||||
/** 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'; }
|
||||
|
||||
memberResults: MemberOption[] = [];
|
||||
|
||||
/** Continuous-entry toggle: keep member/ministry/category/date and the dialog open after each save. */
|
||||
@@ -116,12 +131,14 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
|
||||
private catApi: ExpenseCategoryApiService,
|
||||
private memberApi: MemberApiService,
|
||||
private expenseApi: ExpenseApiService,
|
||||
private snapshotApi: ExpenseSnapshotApiService,
|
||||
private aiApi: ExpenseAiService,
|
||||
private sanitizer: DomSanitizer,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.ministryApi.getAll().subscribe(m => (this.ministries = m));
|
||||
if (this.showSnapshotTools) this.loadSnapshots();
|
||||
this.catApi.getAll(false).subscribe(groups => {
|
||||
this.groups = groups;
|
||||
// Populate each line's sub-category list once the catalog is loaded (edit mode).
|
||||
@@ -315,6 +332,71 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
|
||||
this.receiptPdfUrl = null;
|
||||
}
|
||||
|
||||
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; },
|
||||
});
|
||||
}
|
||||
|
||||
get isValid(): boolean {
|
||||
return !!this.form.ministryId
|
||||
&& this.form.description.trim().length > 0
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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;
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
<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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
.page {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
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(); });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,8 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
||||
permission: { module: PermissionModules.Expenses, action: 'read' } },
|
||||
{ text: 'Expense Categories', icon: categorizeIcon, path: '/user-portal/finance/expense-categories',
|
||||
permission: { module: PermissionModules.ExpenseCategories, action: 'read' } },
|
||||
{ text: 'Expense Snapshots', icon: categorizeIcon, path: '/user-portal/finance/expense-snapshots',
|
||||
permission: { module: PermissionModules.Expenses, action: 'write' } },
|
||||
{ text: 'Disbursements', icon: banknoteOutlineIcon, path: '/user-portal/finance/disbursements',
|
||||
permission: { module: PermissionModules.Disbursements, action: 'read' } },
|
||||
{ text: 'Check Register', icon: walletOutlineIcon, path: '/user-portal/finance/check-register',
|
||||
|
||||
Reference in New Issue
Block a user