Compare commits

...

5 Commits

28 changed files with 786 additions and 5 deletions
@@ -0,0 +1,60 @@
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.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class MealAttendanceServiceTests
{
// MealAttendance is auditable, so the InMemory provider requires CreatedBy/UpdatedBy
// to be set before insert. Wire in the AuditSaveChangesInterceptor (as the other
// service tests do) so those columns are stamped automatically on SaveChanges.
private static AppDbContext BuildDb()
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") };
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 ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
}
[Fact]
public async Task SetCountsAsync_CreatesRowWhenMissing_AndReturnsTotals()
{
using var db = BuildDb();
var svc = new MealAttendanceService(db);
var date = new DateOnly(2026, 5, 31);
var result = await svc.SetCountsAsync(date, adult: 40, youth: 12, kid: 8);
Assert.Equal("2026-05-31", result.Date);
Assert.Equal(40, result.Adult);
Assert.Equal(12, result.Youth);
Assert.Equal(8, result.Kid);
Assert.Single(db.MealAttendances.Where(a => a.AttendanceDate == date));
}
[Fact]
public async Task SetCountsAsync_OverwritesExistingRow_AndClampsNegativesToZero()
{
using var db = BuildDb();
var svc = new MealAttendanceService(db);
var date = new DateOnly(2026, 5, 31);
await svc.SetCountsAsync(date, 40, 12, 8);
var result = await svc.SetCountsAsync(date, adult: 50, youth: -3, kid: 0);
Assert.Equal(50, result.Adult);
Assert.Equal(0, result.Youth); // negative clamped to zero
Assert.Equal(0, result.Kid);
Assert.Single(db.MealAttendances.Where(a => a.AttendanceDate == date)); // still one row
}
}
@@ -164,4 +164,27 @@ public class OfferingSessionServiceTests
Assert.Equal("PP-456", line.PayPalTransactionId);
Assert.Equal("C-789", line.CheckNumber);
}
[Fact]
public async Task GetPagedAsync_IncludesSundayAttendanceTotal_WhenRowExists()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
var withDate = new DateOnly(2026, 5, 31);
var withoutDate = new DateOnly(2026, 5, 24);
await svc.CreateAsync(BuildRequest(catId, withDate));
await svc.CreateAsync(BuildRequest(catId, withoutDate));
db.MealAttendances.Add(new MealAttendance
{ AttendanceDate = withDate, AdultCount = 40, YouthCount = 12, KidCount = 8 });
await db.SaveChangesAsync();
var page = await svc.GetPagedAsync(1, 20, null, null);
var withItem = page.Items.Single(i => i.SessionDate == "2026-05-31");
var withoutItem = page.Items.Single(i => i.SessionDate == "2026-05-24");
Assert.Equal(60, withItem.SundayAttendanceCount); // 40 + 12 + 8
Assert.Null(withoutItem.SundayAttendanceCount); // no attendance row -> null
}
}
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.DTOs.MealAttendance;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
@@ -23,4 +24,10 @@ public class MealAttendanceController : ControllerBase
[Authorize]
public async Task<IActionResult> GetRange([FromQuery] DateOnly from, [FromQuery] DateOnly to)
=> Ok(await _svc.GetRangeAsync(from, to));
/// <summary>Overwrite a specific Sunday's counts (back-office editor). Authenticated only.</summary>
[HttpPut("{date}")]
[Authorize]
public async Task<IActionResult> SetCounts(DateOnly date, [FromBody] SetAttendanceRequest body)
=> Ok(await _svc.SetCountsAsync(date, body.Adult, body.Youth, body.Kid));
}
@@ -64,6 +64,7 @@ public class OfferingEntryController : ControllerBase
NickName = request.NickName,
FirstName_zh = request.FirstName_zh,
LastName_zh = request.LastName_zh,
Entity = request.Entity,
PhoneCell = request.PhoneCell,
Status = "Visitor",
Country = "USA",
@@ -73,6 +74,7 @@ public class OfferingEntryController : ControllerBase
{
Id = id, NickName = request.NickName,
FirstName_en = request.FirstName_en, LastName_en = request.LastName_en,
Entity = request.Entity,
});
}
@@ -9,4 +9,5 @@ public class MemberTypeaheadDto
public string? NickName { get; set; }
public string FirstName_en { get; set; } = "";
public string LastName_en { get; set; } = "";
public string? Entity { get; set; } // company / business name (公司行號), if any
}
@@ -11,4 +11,5 @@ public class OfferingSessionListItemDto
public decimal Difference { get; set; }
public int LineCount { get; set; }
public bool HasProof { get; set; }
public int? SundayAttendanceCount { get; set; } // null = no attendance recorded for the date
}
@@ -11,5 +11,6 @@ public class QuickAddMemberRequest
[MaxLength(100)] public string? NickName { get; set; }
[MaxLength(100)] public string? FirstName_zh { get; set; }
[MaxLength(100)] public string? LastName_zh { get; set; }
[MaxLength(200)] public string? Entity { get; set; }
[MaxLength(30)] public string? PhoneCell { get; set; }
}
@@ -0,0 +1,9 @@
namespace ROLAC.API.DTOs.MealAttendance;
/// <summary>Absolute head-counts to write for one Sunday, from the back-office editor.</summary>
public class SetAttendanceRequest
{
public int Adult { get; set; }
public int Youth { get; set; }
public int Kid { get; set; }
}
@@ -8,6 +8,7 @@ public class CreateMemberRequest
[MaxLength(100)] public string? NickName { get; set; }
[MaxLength(100)] public string? FirstName_zh { get; set; }
[MaxLength(100)] public string? LastName_zh { get; set; }
[MaxLength(200)] public string? Entity { get; set; }
[MaxLength(10)] public string? Gender { get; set; }
public DateOnly? DateOfBirth { get; set; }
public DateOnly? BaptismDate { get; set; }
@@ -8,6 +8,7 @@ public class MemberListItemDto
public string? NickName { get; set; }
public string? FirstName_zh { get; set; }
public string? LastName_zh { get; set; }
public string? Entity { get; set; }
public string Status { get; set; } = "";
public string? Email { get; set; }
public string? PhoneCell { get; set; }
+1
View File
@@ -118,6 +118,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Property(e => e.NickName).HasMaxLength(100);
entity.Property(e => e.FirstName_zh).HasMaxLength(100);
entity.Property(e => e.LastName_zh).HasMaxLength(100);
entity.Property(e => e.Entity).HasMaxLength(200);
entity.Property(e => e.Gender).HasMaxLength(10);
entity.Property(e => e.BaptismChurch).HasMaxLength(200);
entity.Property(e => e.Email).HasMaxLength(200);
+1
View File
@@ -10,6 +10,7 @@ public class Member : SoftDeleteEntity, IAuditable
public string? NickName { get; set; }
public string? FirstName_zh { get; set; }
public string? LastName_zh { get; set; }
public string? Entity { get; set; } // company / business name (公司行號) — used for company-check offerings
public string? Gender { get; set; } // 'M' | 'F' | 'Other'
public DateOnly? DateOfBirth { get; set; }
public DateOnly? BaptismDate { get; set; }
@@ -1140,6 +1140,10 @@ namespace ROLAC.API.Migrations
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Entity")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int?>("FamilyUnitId")
.HasColumnType("integer");
@@ -22,6 +22,13 @@ public interface IMealAttendanceService
/// </summary>
Task<AttendanceCountsDto> SetAsync(DateOnly date, string category, int value);
/// <summary>
/// Overwrites all three age-group columns for <paramref name="date"/> with absolute
/// values (each clamped at zero), creating the row if it does not exist, and returns
/// the resulting authoritative counts. Used by the back-office Sunday-attendance editor.
/// </summary>
Task<AttendanceCountsDto> SetCountsAsync(DateOnly date, int adult, int youth, int kid);
/// <summary>Returns the daily counts within the inclusive date range, ordered by date (for the dashboard).</summary>
Task<IReadOnlyList<AttendanceCountsDto>> GetRangeAsync(DateOnly from, DateOnly to);
}
@@ -82,6 +82,26 @@ public class MealAttendanceService : IMealAttendanceService
return await ReadAsync(date);
}
public async Task<AttendanceCountsDto> SetCountsAsync(DateOnly date, int adult, int youth, int kid)
{
// Single-editor back-office path, so a tracked load + SaveChanges is fine here; no need for the
// race-safe EnsureRowAsync + ExecuteUpdateAsync pattern, which the EF InMemory test provider can't run.
var row = await _db.MealAttendances.FirstOrDefaultAsync(a => a.AttendanceDate == date);
if (row is null)
{
row = new MealAttendance { AttendanceDate = date };
_db.MealAttendances.Add(row);
}
// Counts can never be negative; clamp before writing.
row.AdultCount = adult < 0 ? 0 : adult;
row.YouthCount = youth < 0 ? 0 : youth;
row.KidCount = kid < 0 ? 0 : kid;
await _db.SaveChangesAsync();
return ToDto(row);
}
public async Task<IReadOnlyList<AttendanceCountsDto>> GetRangeAsync(DateOnly from, DateOnly to)
{
var rows = await _db.MealAttendances.AsNoTracking()
+5
View File
@@ -38,6 +38,7 @@ public class MemberService : IMemberService
(m.NickName != null && m.NickName.ToLower().Contains(s)) ||
(m.FirstName_zh != null && m.FirstName_zh.Contains(search)) ||
(m.LastName_zh != null && m.LastName_zh.Contains(search)) ||
(m.Entity != null && m.Entity.ToLower().Contains(s)) ||
(m.Email != null && m.Email.ToLower().Contains(s)));
}
@@ -74,6 +75,7 @@ public class MemberService : IMemberService
NickName = m.NickName,
FirstName_zh = m.FirstName_zh,
LastName_zh = m.LastName_zh,
Entity = m.Entity,
Status = m.Status,
Email = m.Email,
PhoneCell = m.PhoneCell,
@@ -105,6 +107,7 @@ public class MemberService : IMemberService
{
Id = m.Id, FirstName_en = m.FirstName_en, LastName_en = m.LastName_en,
NickName = m.NickName, FirstName_zh = m.FirstName_zh, LastName_zh = m.LastName_zh,
Entity = m.Entity,
Gender = m.Gender, DateOfBirth = m.DateOfBirth, BaptismDate = m.BaptismDate,
BaptismChurch = m.BaptismChurch, Email = m.Email, PhoneCell = m.PhoneCell,
PhoneHome = m.PhoneHome, Address = m.Address, City = m.City, State = m.State,
@@ -157,6 +160,7 @@ public class MemberService : IMemberService
{
FirstName_en = r.FirstName_en, LastName_en = r.LastName_en,
NickName = r.NickName, FirstName_zh = r.FirstName_zh, LastName_zh = r.LastName_zh,
Entity = r.Entity,
Gender = r.Gender, DateOfBirth = r.DateOfBirth, BaptismDate = r.BaptismDate,
BaptismChurch = r.BaptismChurch, Email = r.Email, PhoneCell = r.PhoneCell,
PhoneHome = r.PhoneHome, Address = r.Address, City = r.City, State = r.State,
@@ -169,6 +173,7 @@ public class MemberService : IMemberService
{
m.FirstName_en = r.FirstName_en; m.LastName_en = r.LastName_en;
m.NickName = r.NickName; m.FirstName_zh = r.FirstName_zh; m.LastName_zh = r.LastName_zh;
m.Entity = r.Entity;
m.Gender = r.Gender; m.DateOfBirth = r.DateOfBirth; m.BaptismDate = r.BaptismDate;
m.BaptismChurch = r.BaptismChurch; m.Email = r.Email; m.PhoneCell = r.PhoneCell;
m.PhoneHome = r.PhoneHome; m.Address = r.Address; m.City = r.City; m.State = r.State;
@@ -45,6 +45,11 @@ public class OfferingSessionService : IOfferingSessionService
.Select(grp => new { Id = grp.Key, Count = grp.Count() })
.ToDictionaryAsync(x => x.Id, x => x.Count);
var dates = rows.Select(r => r.SessionDate).ToList();
var attendance = await _db.MealAttendances.AsNoTracking()
.Where(a => dates.Contains(a.AttendanceDate))
.ToDictionaryAsync(a => a.AttendanceDate, a => a.AdultCount + a.YouthCount + a.KidCount);
var items = rows.Select(s => new OfferingSessionListItemDto
{
Id = s.Id, SessionDate = s.SessionDate.ToString("yyyy-MM-dd"), Status = s.Status,
@@ -52,6 +57,7 @@ public class OfferingSessionService : IOfferingSessionService
SystemTotal = s.SystemTotal, Difference = s.Difference,
LineCount = counts.TryGetValue(s.Id, out var c) ? c : 0,
HasProof = s.ProofPdfPath != null,
SundayAttendanceCount = attendance.TryGetValue(s.SessionDate, out var att) ? att : (int?)null,
}).ToList();
return new PagedResult<OfferingSessionListItemDto>
@@ -4,6 +4,7 @@
<label class="flex flex-col gap-1">Last name (EN) *<kendo-textbox [(ngModel)]="lastName_en"></kendo-textbox></label>
<label class="flex flex-col gap-1">名 (中)<kendo-textbox [(ngModel)]="firstName_zh"></kendo-textbox></label>
<label class="flex flex-col gap-1">姓 (中)<kendo-textbox [(ngModel)]="lastName_zh"></kendo-textbox></label>
<label class="flex flex-col gap-1 md:col-span-2">公司行號 · Company<kendo-textbox [(ngModel)]="entity"></kendo-textbox></label>
<label class="flex flex-col gap-1 md:col-span-2">Cell phone<kendo-textbox [(ngModel)]="phoneCell"></kendo-textbox></label>
</div>
<kendo-dialog-actions>
@@ -21,6 +21,7 @@ export class MemberQuickAddDialogComponent {
lastName_en = '';
firstName_zh: string | null = null;
lastName_zh: string | null = null;
entity: string | null = null;
phoneCell: string | null = null;
saving = false;
@@ -35,6 +36,7 @@ export class MemberQuickAddDialogComponent {
nickName: null,
firstName_zh: this.firstName_zh,
lastName_zh: this.lastName_zh,
entity: this.entity,
gender: null,
dateOfBirth: null,
baptismDate: null,
@@ -63,6 +65,7 @@ export class MemberQuickAddDialogComponent {
nickName: null,
firstName_zh: this.firstName_zh,
lastName_zh: this.lastName_zh,
entity: this.entity,
status: 'Visitor',
email: null,
phoneCell: this.phoneCell,
@@ -114,6 +114,7 @@ export interface OfferingSessionListItemDto {
difference: number;
lineCount: number;
hasProof: boolean;
sundayAttendanceCount?: number | null;
}
/** A row held in the client-side batch buffer before submit. */
@@ -129,6 +130,7 @@ export interface MemberTypeaheadDto {
nickName: string | null;
firstName_en: string;
lastName_en: string;
entity: string | null;
}
/** A day's session as the mobile page sees it. */
export interface OfferingEntrySummaryDto {
@@ -158,6 +160,7 @@ export interface QuickAddMemberRequest {
nickName: string | null;
firstName_zh: string | null;
lastName_zh: string | null;
entity: string | null;
phoneCell: string | null;
}
/** Returned from append + broadcast over the OfferingEntryHub. */
@@ -130,6 +130,10 @@
<label class="oe__label">中文姓 · Chinese last name</label>
<kendo-textbox class="oe__control" [(ngModel)]="quickAdd.lastName_zh" size="large"></kendo-textbox>
</div>
<div class="oe__field">
<label class="oe__label">公司行號 · Company</label>
<kendo-textbox class="oe__control" [(ngModel)]="quickAdd.entity" size="large"></kendo-textbox>
</div>
<div class="oe__field">
<label class="oe__label">手機 · Cell phone</label>
<kendo-textbox class="oe__control" [(ngModel)]="quickAdd.phoneCell" size="large"></kendo-textbox>
@@ -151,10 +151,11 @@ export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy {
// is no nick name (or it's the same as the legal first name).
private giverLabel(m: MemberTypeaheadDto): string {
const legal = `${m.firstName_en} ${m.lastName_en}`.trim();
if (m.nickName && m.nickName !== m.firstName_en) {
return `${m.nickName} ${m.lastName_en} (${legal})`;
}
return legal;
const base = (m.nickName && m.nickName !== m.firstName_en)
? `${m.nickName} ${m.lastName_en} (${legal})`
: legal;
// Append the company / business name so a company-check giver is unambiguous.
return m.entity ? `${base} · ${m.entity}` : base;
}
onMemberSelected(id: number | null): void {
@@ -206,6 +207,7 @@ export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy {
nickName: this.trimToNull(this.quickAdd.nickName),
firstName_zh: this.trimToNull(this.quickAdd.firstName_zh),
lastName_zh: this.trimToNull(this.quickAdd.lastName_zh),
entity: this.trimToNull(this.quickAdd.entity),
phoneCell: this.trimToNull(this.quickAdd.phoneCell),
};
this.api.quickAddMember(request).subscribe({
@@ -229,7 +231,7 @@ export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy {
private blankQuickAdd(): QuickAddMemberRequest {
return {
firstName_en: '', lastName_en: '', nickName: null,
firstName_zh: null, lastName_zh: null, phoneCell: null,
firstName_zh: null, lastName_zh: null, entity: null, phoneCell: null,
};
}
@@ -22,4 +22,9 @@ export class MealAttendanceApiService {
const params = new HttpParams().set('from', from).set('to', to);
return this.http.get<AttendanceCounts[]>(this.endpoint, { params });
}
/** Overwrite a specific Sunday's counts (back-office editor). */
setCounts(date: string, counts: { adult: number; youth: number; kid: number }): Observable<AttendanceCounts> {
return this.http.put<AttendanceCounts>(`${this.endpoint}/${date}`, counts);
}
}
@@ -33,6 +33,11 @@
<kendo-textbox formControlName="lastName_zh"></kendo-textbox>
</kendo-formfield>
<kendo-formfield class="md:col-span-2">
<kendo-label text="公司行號 · Company / Entity"></kendo-label>
<kendo-textbox formControlName="entity" placeholder="e.g. ABC Trading Inc."></kendo-textbox>
</kendo-formfield>
<kendo-formfield>
<kendo-label text="Gender"></kendo-label>
<kendo-dropdownlist
@@ -43,6 +43,7 @@ export class MemberFormDialogComponent implements OnInit {
nickName: [this.member?.nickName ?? null, Validators.maxLength(100)],
firstName_zh: [this.member?.firstName_zh ?? null, Validators.maxLength(100)],
lastName_zh: [this.member?.lastName_zh ?? null, Validators.maxLength(100)],
entity: [this.member?.entity ?? null, Validators.maxLength(200)],
gender: [this.member?.gender ?? null],
dateOfBirth: [this.member?.dateOfBirth ?? null],
status: [this.member?.status ?? 'Member', Validators.required],
@@ -7,6 +7,7 @@ export interface MemberListItemDto {
nickName: string | null;
firstName_zh: string | null;
lastName_zh: string | null;
entity: string | null;
status: MemberStatus;
email: string | null;
phoneCell: string | null;
@@ -39,6 +40,7 @@ export interface CreateMemberRequest {
nickName: string | null;
firstName_zh: string | null;
lastName_zh: string | null;
entity: string | null;
gender: string | null;
dateOfBirth: string | null;
baptismDate: string | null;
@@ -0,0 +1,537 @@
# Offering Session — 顯示與修改主日參加人數 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:** 在 finance/offering-session 的 Recent Sessions 表格中,每筆 session 顯示該日的主日參加人數(MealAttendance 三分類之和),並以右鍵 context menu 提供修改該日參加人數的 Action。
**Architecture:** 後端 `OfferingSessionService.GetPagedAsync` 以 session 的 `SessionDate` 一次性 join `MealAttendance` 求和,填入 DTO 的新 nullable 欄位。修改走新的 REST 端點 `PUT /api/meal-attendance/{date}`SignalR 的 `SetCount` 只能改本週日,無法改任意日期),由新的 `IMealAttendanceService.SetCountsAsync` 一次寫三欄(load + set + SaveChangesclamp 至 0,無 row 則建立)。前端在 Kendo grid 加欄、加右鍵選單、加編輯 Dialog。
**Tech Stack:** C# / EF Core (PostgreSQL, InMemory for tests) / ASP.NET Core / xUnit + MoqAngular standalone component + Kendo UI (Grid / ContextMenu / Dialog / NumericTextBox)。
---
## File Structure
**Backend (modify):**
- `API/ROLAC.API/DTOs/MealAttendance/SetAttendanceRequest.cs`**create**: PUT body `{ adult, youth, kid }`.
- `API/ROLAC.API/Services/IMealAttendanceService.cs` — add `SetCountsAsync`.
- `API/ROLAC.API/Services/MealAttendanceService.cs` — implement `SetCountsAsync`.
- `API/ROLAC.API/Controllers/MealAttendanceController.cs` — add `PUT /{date}`.
- `API/ROLAC.API/DTOs/Giving/OfferingSessionListItemDto.cs` — add `SundayAttendanceCount`.
- `API/ROLAC.API/Services/OfferingSessionService.cs` — populate attendance in `GetPagedAsync`.
**Backend (tests):**
- `API/ROLAC.API.Tests/Services/MealAttendanceServiceTests.cs`**create**.
- `API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs` — add one test.
**Frontend (modify):**
- `APP/src/app/features/giving/models/giving.model.ts` — add `sundayAttendanceCount`.
- `APP/src/app/features/meal-attendance/services/meal-attendance-api.service.ts` — add `setCounts`.
- `APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.ts` — column data, context menu, edit dialog.
- `APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.html` — column, menu, dialog markup.
- `APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.scss` — total-line style (optional).
**Build/test commands** (Visual Studio locks `bin/Debug`; always use Release for CLI — per project convention):
- Backend tests: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release`
- Frontend build: run from `APP/`: `npm run build`
---
## Task 1: Backend service — `SetCountsAsync`
**Files:**
- Modify: `API/ROLAC.API/Services/IMealAttendanceService.cs`
- Modify: `API/ROLAC.API/Services/MealAttendanceService.cs`
- Test: `API/ROLAC.API.Tests/Services/MealAttendanceServiceTests.cs` (create)
- [ ] **Step 1: Write the failing test**
Create `API/ROLAC.API.Tests/Services/MealAttendanceServiceTests.cs`:
```csharp
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class MealAttendanceServiceTests
{
private static AppDbContext BuildDb() =>
new(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options);
[Fact]
public async Task SetCountsAsync_CreatesRowWhenMissing_AndReturnsTotals()
{
using var db = BuildDb();
var svc = new MealAttendanceService(db);
var date = new DateOnly(2026, 5, 31);
var result = await svc.SetCountsAsync(date, adult: 40, youth: 12, kid: 8);
Assert.Equal("2026-05-31", result.Date);
Assert.Equal(40, result.Adult);
Assert.Equal(12, result.Youth);
Assert.Equal(8, result.Kid);
Assert.Single(db.MealAttendances.Where(a => a.AttendanceDate == date));
}
[Fact]
public async Task SetCountsAsync_OverwritesExistingRow_AndClampsNegativesToZero()
{
using var db = BuildDb();
var svc = new MealAttendanceService(db);
var date = new DateOnly(2026, 5, 31);
await svc.SetCountsAsync(date, 40, 12, 8);
var result = await svc.SetCountsAsync(date, adult: 50, youth: -3, kid: 0);
Assert.Equal(50, result.Adult);
Assert.Equal(0, result.Youth); // negative clamped to zero
Assert.Equal(0, result.Kid);
Assert.Single(db.MealAttendances.Where(a => a.AttendanceDate == date)); // still one row
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter MealAttendanceServiceTests`
Expected: FAIL — compile error, `MealAttendanceService` has no `SetCountsAsync`.
- [ ] **Step 3: Add the interface method**
In `API/ROLAC.API/Services/IMealAttendanceService.cs`, add after the existing `SetAsync` declaration (before `GetRangeAsync`):
```csharp
/// <summary>
/// Overwrites all three age-group columns for <paramref name="date"/> with absolute
/// values (each clamped at zero), creating the row if it does not exist, and returns
/// the resulting authoritative counts. Used by the back-office Sunday-attendance editor.
/// </summary>
Task<AttendanceCountsDto> SetCountsAsync(DateOnly date, int adult, int youth, int kid);
```
- [ ] **Step 4: Implement the method**
In `API/ROLAC.API/Services/MealAttendanceService.cs`, add after `SetAsync` (before `GetRangeAsync`):
```csharp
public async Task<AttendanceCountsDto> SetCountsAsync(DateOnly date, int adult, int youth, int kid)
{
var row = await _db.MealAttendances.FirstOrDefaultAsync(a => a.AttendanceDate == date);
if (row is null)
{
row = new MealAttendance { AttendanceDate = date };
_db.MealAttendances.Add(row);
}
// Counts can never be negative; clamp before writing.
row.AdultCount = adult < 0 ? 0 : adult;
row.YouthCount = youth < 0 ? 0 : youth;
row.KidCount = kid < 0 ? 0 : kid;
await _db.SaveChangesAsync();
return ToDto(row);
}
```
(`ToDto` and the `MealAttendance` entity are already in this file's scope.)
- [ ] **Step 5: Run test to verify it passes**
Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter MealAttendanceServiceTests`
Expected: PASS (2 tests).
- [ ] **Step 6: Commit**
```bash
git add API/ROLAC.API/Services/IMealAttendanceService.cs API/ROLAC.API/Services/MealAttendanceService.cs API/ROLAC.API.Tests/Services/MealAttendanceServiceTests.cs
git commit -m "feat(attendance): add SetCountsAsync to set all three age groups for a date"
```
---
## Task 2: Backend endpoint — `PUT /api/meal-attendance/{date}`
**Files:**
- Create: `API/ROLAC.API/DTOs/MealAttendance/SetAttendanceRequest.cs`
- Modify: `API/ROLAC.API/Controllers/MealAttendanceController.cs`
- [ ] **Step 1: Create the request DTO**
Create `API/ROLAC.API/DTOs/MealAttendance/SetAttendanceRequest.cs`:
```csharp
namespace ROLAC.API.DTOs.MealAttendance;
/// <summary>Absolute head-counts to write for one Sunday, from the back-office editor.</summary>
public class SetAttendanceRequest
{
public int Adult { get; set; }
public int Youth { get; set; }
public int Kid { get; set; }
}
```
- [ ] **Step 2: Add the controller action**
In `API/ROLAC.API/Controllers/MealAttendanceController.cs`, add a `using` for the DTO namespace if not present (it already uses `ROLAC.API.Services`; add `using ROLAC.API.DTOs.MealAttendance;`), then add this action after `GetRange`:
```csharp
/// <summary>Overwrite a specific Sunday's counts (back-office editor). Authenticated only.</summary>
[HttpPut("{date}")]
[Authorize]
public async Task<IActionResult> SetCounts(DateOnly date, [FromBody] SetAttendanceRequest body)
=> Ok(await _svc.SetCountsAsync(date, body.Adult, body.Youth, body.Kid));
```
- [ ] **Step 3: Build to verify it compiles**
Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release`
Expected: Build succeeded.
- [ ] **Step 4: Commit**
```bash
git add API/ROLAC.API/DTOs/MealAttendance/SetAttendanceRequest.cs API/ROLAC.API/Controllers/MealAttendanceController.cs
git commit -m "feat(attendance): add PUT /api/meal-attendance/{date} to overwrite a Sunday's counts"
```
---
## Task 3: Backend — include attendance total in offering session list
**Files:**
- Modify: `API/ROLAC.API/DTOs/Giving/OfferingSessionListItemDto.cs`
- Modify: `API/ROLAC.API/Services/OfferingSessionService.cs:48-55` (the `items` projection in `GetPagedAsync`)
- Test: `API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs`
- [ ] **Step 1: Add the DTO field**
In `API/ROLAC.API/DTOs/Giving/OfferingSessionListItemDto.cs`, add after `HasProof`:
```csharp
public int? SundayAttendanceCount { get; set; } // null = no attendance recorded for the date
```
- [ ] **Step 2: Write the failing test**
In `API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs`, add this test (helpers `BuildDb`, `BuildAccessor`, `NoOpFileStorage`, `SeedCategoryAsync`, `BuildRequest` already exist in the file; add `using ROLAC.API.Entities;` is already present):
```csharp
[Fact]
public async Task GetPagedAsync_IncludesSundayAttendanceTotal_WhenRowExists()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
var withDate = new DateOnly(2026, 5, 31);
var withoutDate = new DateOnly(2026, 5, 24);
await svc.CreateAsync(BuildRequest(catId, withDate));
await svc.CreateAsync(BuildRequest(catId, withoutDate));
db.MealAttendances.Add(new MealAttendance
{ AttendanceDate = withDate, AdultCount = 40, YouthCount = 12, KidCount = 8 });
await db.SaveChangesAsync();
var page = await svc.GetPagedAsync(1, 20, null, null);
var withItem = page.Items.Single(i => i.SessionDate == "2026-05-31");
var withoutItem = page.Items.Single(i => i.SessionDate == "2026-05-24");
Assert.Equal(60, withItem.SundayAttendanceCount); // 40 + 12 + 8
Assert.Null(withoutItem.SundayAttendanceCount); // no attendance row → null
}
```
- [ ] **Step 3: Run test to verify it fails**
Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter GetPagedAsync_IncludesSundayAttendanceTotal_WhenRowExists`
Expected: FAIL — `SundayAttendanceCount` is null for the row that has attendance (not yet populated).
- [ ] **Step 4: Populate the field in `GetPagedAsync`**
In `API/ROLAC.API/Services/OfferingSessionService.cs`, inside `GetPagedAsync`, after the `counts` dictionary block (currently ends at line 46) and before `var items = rows.Select(...)` (line 48), insert:
```csharp
var dates = rows.Select(r => r.SessionDate).ToList();
var attendance = await _db.MealAttendances.AsNoTracking()
.Where(a => dates.Contains(a.AttendanceDate))
.ToDictionaryAsync(a => a.AttendanceDate, a => a.AdultCount + a.YouthCount + a.KidCount);
```
Then in the `new OfferingSessionListItemDto { ... }` initializer (currently lines 48-55), add this line after `HasProof = s.ProofPdfPath != null,`:
```csharp
SundayAttendanceCount = attendance.TryGetValue(s.SessionDate, out var att) ? att : (int?)null,
```
- [ ] **Step 5: Run the full test class to verify it passes**
Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter OfferingSessionServiceTests`
Expected: PASS (existing tests + the new one).
- [ ] **Step 6: Commit**
```bash
git add API/ROLAC.API/DTOs/Giving/OfferingSessionListItemDto.cs API/ROLAC.API/Services/OfferingSessionService.cs API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs
git commit -m "feat(giving): include Sunday attendance total in offering session list"
```
---
## Task 4: Frontend — model field + attendance API `setCounts`
**Files:**
- Modify: `APP/src/app/features/giving/models/giving.model.ts:107-117`
- Modify: `APP/src/app/features/meal-attendance/services/meal-attendance-api.service.ts`
- [ ] **Step 1: Add the model field**
In `APP/src/app/features/giving/models/giving.model.ts`, in `OfferingSessionListItemDto`, add after `hasProof: boolean;`:
```typescript
sundayAttendanceCount?: number | null;
```
- [ ] **Step 2: Add the `setCounts` API method**
In `APP/src/app/features/meal-attendance/services/meal-attendance-api.service.ts`, add this method after `getRange`:
```typescript
/** Overwrite a specific Sunday's counts (back-office editor). */
setCounts(date: string, counts: { adult: number; youth: number; kid: number }): Observable<AttendanceCounts> {
return this.http.put<AttendanceCounts>(`${this.endpoint}/${date}`, counts);
}
```
- [ ] **Step 3: Commit**
```bash
git add APP/src/app/features/giving/models/giving.model.ts APP/src/app/features/meal-attendance/services/meal-attendance-api.service.ts
git commit -m "feat(giving): add sundayAttendanceCount model field and attendance setCounts API"
```
---
## Task 5: Frontend — grid column, context menu, edit dialog
**Files:**
- Modify: `APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.ts`
- Modify: `APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.html:39-62`
- Modify: `APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.scss`
- [ ] **Step 1: Add imports and inject the attendance API (component.ts)**
In `APP/.../offering-session-page.component.ts`:
a) Update the grid import to also pull `CellClickEvent` (line 6):
```typescript
import { GridModule, CellClickEvent } from '@progress/kendo-angular-grid';
```
b) Add these imports near the other Kendo imports (after line 11):
```typescript
import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
import { MealAttendanceApiService } from '../../../meal-attendance/services/meal-attendance-api.service';
```
c) Add `ViewChild` to the `@angular/core` import (line 1):
```typescript
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
```
d) Add `ContextMenuModule` to the component `imports` array (line 31-34):
```typescript
imports: [
CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule,
DropDownsModule, DateInputsModule, DialogsModule, ContextMenuModule, MemberQuickAddDialogComponent,
],
```
e) Inject the attendance API in the constructor (after `private signalr: OfferingEntrySignalrService,` on line 81):
```typescript
private mealAttendanceApi: MealAttendanceApiService,
```
- [ ] **Step 2: Add context-menu + dialog state and handlers (component.ts)**
Add these members after `confirmReopenOpen = false;` (line 75):
```typescript
// Right-click actions on a Recent Sessions row.
@ViewChild('sessionMenu') sessionMenu!: ContextMenuComponent;
readonly sessionMenuItems = [{ text: 'View / 檢視' }, { text: '修改主日人數' }];
private contextSession: OfferingSessionListItemDto | null = null;
// Edit Sunday attendance dialog.
attDialogOpen = false;
attSaving = false;
private attDate: string | null = null; // yyyy-MM-dd of the session being edited
attForm = { adult: 0, youth: 0, kid: 0 };
get attTotal(): number { return this.attForm.adult + this.attForm.youth + this.attForm.kid; }
```
Add these methods after `loadSessions()` (line 161-163):
```typescript
// Left-click anywhere on a row opens it; right-click opens the actions menu.
onSessionCellClick(event: CellClickEvent): void {
if (event.type === 'contextmenu') {
event.originalEvent.preventDefault();
this.contextSession = event.dataItem;
this.sessionMenu.show({ left: event.originalEvent.pageX, top: event.originalEvent.pageY });
} else {
this.openView(event.dataItem);
}
}
onSessionMenuSelect(event: ContextMenuSelectEvent): void {
const session = this.contextSession;
if (!session) return;
if (event.item.text === 'View / 檢視') this.openView(session);
else if (event.item.text === '修改主日人數') this.openAttendanceEdit(session);
}
// Open the attendance editor, prefilling the three age groups from the existing row (zeros if none).
openAttendanceEdit(session: OfferingSessionListItemDto): void {
this.attDate = session.sessionDate;
this.attForm = { adult: 0, youth: 0, kid: 0 };
this.attSaving = false;
this.attDialogOpen = true;
this.mealAttendanceApi.getRange(session.sessionDate, session.sessionDate).subscribe(rows => {
const row = rows[0];
if (row) this.attForm = { adult: row.adult, youth: row.youth, kid: row.kid };
});
}
saveAttendance(): void {
if (!this.attDate) return;
const date = this.attDate;
this.attSaving = true;
this.mealAttendanceApi.setCounts(date, this.attForm).subscribe({
next: counts => {
const total = counts.adult + counts.youth + counts.kid;
const row = this.sessions.find(s => s.sessionDate === date);
if (row) row.sundayAttendanceCount = total;
this.attDialogOpen = false;
this.attSaving = false;
},
error: (err: { error?: { message?: string } }) => {
this.attSaving = false;
alert(err?.error?.message ?? 'Save failed.');
},
});
}
```
- [ ] **Step 3: Update the Recent Sessions grid markup (component.html)**
Replace the whole `<kendo-grid class="lined" ...>...</kendo-grid>` block (lines 39-62) with:
```html
<kendo-grid class="lined clickable-rows" [data]="sessions" (cellClick)="onSessionCellClick($event)">
<kendo-grid-column field="sessionDate" title="Date" [width]="120"></kendo-grid-column>
<kendo-grid-column title="Status" [width]="130">
<ng-template kendoGridCellTemplate let-s>
<span class="pill" [ngClass]="'pill--' + s.status.toLowerCase()">{{ s.status }}</span>
</ng-template>
</kendo-grid-column>
<kendo-grid-column field="lineCount" title="Lines" [width]="80"></kendo-grid-column>
<kendo-grid-column title="Attendance · 主日人數" [width]="140">
<ng-template kendoGridCellTemplate let-s>{{ s.sundayAttendanceCount ?? '—' }}</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Proof" [width]="70">
<ng-template kendoGridCellTemplate let-s>
<span *ngIf="s.hasProof" title="Paper proof attached · 已附證明">📎</span>
</ng-template>
</kendo-grid-column>
<kendo-grid-column field="systemTotal" title="System" [width]="120" format="c2"></kendo-grid-column>
<kendo-grid-column field="difference" title="Diff" [width]="110" format="c2"></kendo-grid-column>
<ng-template kendoGridNoRecordsTemplate>
<div class="empty">No sessions yet — pick a date above to start.<br><span>尚無紀錄 — 選擇上方日期開始</span></div>
</ng-template>
</kendo-grid>
<kendo-contextmenu #sessionMenu [items]="sessionMenuItems" (select)="onSessionMenuSelect($event)"></kendo-contextmenu>
<div class="hint-text-sm">點一列檢視 · 右鍵修改主日人數 / Click a row to view · right-click to edit attendance</div>
```
(The old inline "View" action column is removed — View is now a left-click and a context-menu item.)
- [ ] **Step 4: Add the attendance edit dialog (component.html)**
Add this dialog at the end of the file, just before the final closing `</div>` of the `.off` container (after the existing view-mode/`workspace` blocks and any existing dialogs):
```html
<!-- ============================ EDIT SUNDAY ATTENDANCE ============================ -->
<kendo-dialog *ngIf="attDialogOpen" title="修改主日參加人數 · Edit Sunday Attendance"
(close)="attDialogOpen = false" [width]="440" [maxWidth]="'95vw'">
<div class="grid grid-cols-1 md:grid-cols-3 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1">成人 Adult
<kendo-numerictextbox [(ngModel)]="attForm.adult" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
</label>
<label class="flex flex-col gap-1">青年 Youth
<kendo-numerictextbox [(ngModel)]="attForm.youth" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
</label>
<label class="flex flex-col gap-1">兒童 Kid
<kendo-numerictextbox [(ngModel)]="attForm.kid" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
</label>
</div>
<div class="att-total">總數 Total: {{ attTotal }}</div>
<kendo-dialog-actions>
<button kendoButton (click)="attDialogOpen = false">Cancel</button>
<button kendoButton themeColor="primary" [disabled]="attSaving" (click)="saveAttendance()">Save</button>
</kendo-dialog-actions>
</kendo-dialog>
```
- [ ] **Step 5: Add minimal styles (component.scss)**
Append to `APP/.../offering-session-page.component.scss`:
```scss
.clickable-rows {
.k-grid-table tr { cursor: pointer; }
}
.att-total {
margin-top: 0.75rem;
font-weight: 600;
text-align: right;
}
```
- [ ] **Step 6: Build the frontend to verify it compiles**
Run from `APP/`: `npm run build`
Expected: Build completes with no template/TS errors. (Per project convention, the scoped unit-test runner can't load this component's external `.html` template, so verification is via build + manual preview rather than a component unit test.)
- [ ] **Step 7: Manual verification (preview)**
Start the app, open finance/offering-session landing. Confirm:
- The Recent Sessions grid shows an `Attendance · 主日人數` column (a number for dates with a MealAttendance row, `—` otherwise).
- Left-click a row opens the read-only session view (unchanged behaviour).
- Right-click a row shows a menu with `View / 檢視` and `修改主日人數`.
- `修改主日人數` opens a dialog with three numeric fields prefilled from the day's counts, a live Total, and Save persists — the grid cell updates to the new total without a full reload.
- [ ] **Step 8: Commit**
```bash
git add APP/src/app/features/giving/pages/offering-session-page/
git commit -m "feat(giving): show Sunday attendance per session and add edit action"
```
---
## Self-Review Notes
- **Spec coverage:** 顯示總數 → Task 3 (backend) + Task 5 step 3 (column). 修改 Action → Task 1/2 (backend write path) + Task 5 (context menu + dialog). 右鍵 context menu / Date 可點 → Task 5 steps 2-3. 沿用 MealAttendance、三分類編輯、nullable 顯示 `—`、REST(非 SignalR)寫入 → 全部涵蓋。
- **Out of scope (per spec):** Recent Sessions grid 的手機卡片版未一併重構;optional SignalR 廣播(date == ServiceDay 時同步即時計數器)未實作。
- **Type consistency:** `SetCountsAsync(DateOnly, int, int, int)` 簽名在 interface / impl / controller / 前端 `setCounts(date, {adult,youth,kid})` 一致;`sundayAttendanceCount` 在 DTOC# `SundayAttendanceCount`)與前端 model 對應;`AttendanceCounts` 前端模型已有 `adult/youth/kid`
@@ -0,0 +1,68 @@
# Offering Session — 顯示與修改主日參加人數
Date: 2026-06-24
## Goal
在 finance/offering-session 的 **Recent Sessions** 表格中,每筆 session 顯示該日的「主日參加人數」總數,並新增一個 Action 可修改該日的參加人數。
## Confirmed Decisions
1. **資料來源** — 沿用既有 `MealAttendance`。主日參加人數 = 該日 `AdultCount + YouthCount + KidCount`。修改 Action 改的是同一筆 `MealAttendance` 紀錄。
2. **顯示** — 後端 joinDTO 加 nullable 欄位;該日無紀錄時顯示 `—`
3. **編輯介面** — Kendo Dialog 分別編輯三個分類(成人/青年/兒童),總數即時計算。
4. **Action 擺放** — 依既有慣例,Date 欄位可點擊觸發 View;View 與「修改主日人數」都進右鍵 context menu(沿用 expense-categories 範式)。
## Key Constraint
`AttendanceHub.SetCount` 只作用在 `ServiceDay`(本週日),無法改任意日期。因此編輯過去某場 session 的日期,**必須走新的 REST 端點**,不可用 SignalR。
## Backend Changes
### 1. DTO
`API/ROLAC.API/DTOs/Giving/OfferingSessionListItemDto.cs`
- 新增 `public int? SundayAttendanceCount { get; set; }`nullable:該日無 attendance row 為 null)。
### 2. OfferingSessionService.GetPaged
- 取得當頁 sessions 後,以這些 `SessionDate` 一次查 `MealAttendance`(單一 set-based 查詢,依日期分組求和),把總數填入各 DTO 的 `SundayAttendanceCount`;無紀錄者留 null。
- 不可對每筆 session 各發一次查詢(避免 N+1)。
### 3. IMealAttendanceService + MealAttendanceService
- 新增 `Task<AttendanceCountsDto> SetCountsAsync(DateOnly date, int adult, int youth, int kid)`
- 沿用既有 clamp-at-zero 語意,一次寫三欄並回傳 `AttendanceCountsDto`;該日無 row 則建立(沿用 `GetOrCreateAsync` 的建立邏輯)。
### 4. MealAttendanceController
- 新增 `PUT /api/meal-attendance/{date}``[Authorize]`(與既有 `GetRange` 一致),body `{ adult, youth, kid }` → 回傳 `AttendanceCountsDto`
- **Optionalplan 階段決定)**:若 `date == ServiceDay`,順手透過 `AttendanceHub` 廣播 `ReceiveCounts`,讓正在跑的即時計數器同步。預設先不做,避免增加耦合。
## Frontend Changes
### 1. Model
`APP/.../giving/models/giving.model.ts`
- `OfferingSessionListItemDto``sundayAttendanceCount?: number | null`
### 2. Attendance API service
`APP/.../meal-attendance/services/meal-attendance-api.service.ts`
- 新增 `setCounts(date: string, counts: { adult: number; youth: number; kid: number }): Observable<AttendanceCounts>` → 呼叫 `PUT /api/meal-attendance/{date}`
### 3. offering-session-page component
`APP/.../giving/pages/offering-session-page/offering-session-page.component.{ts,html}`
- imports 加 `ContextMenuModule``DialogsModule``InputsModule`NumericTextBox)。
- Recent Sessions grid
- 在 "Lines" 後新增欄 `Attendance · 主日人數`,顯示 `s.sundayAttendanceCount ?? '—'`
- 移除原本獨立的 "View" 按鈕欄;改為 `(cellClick)``event.type === 'contextmenu'` → 開 context menu;否則 `openView`。Date 欄加可點擊樣式(`clickable-rows`)。
- `kendo-contextmenu`items`View``修改主日人數`
- 修改主日人數 Dialog
- 開啟時以 `mealAttendanceApi.getRange(date, date)` 取該日 breakdown 預填(無紀錄則三欄為 0)。
- 三個 `kendo-numerictextbox`(成人/青年/兒童,min 0),即時顯示總數。
- Save → `setCounts(date, …)`;成功後就地把該列 `sundayAttendanceCount` 更新為三者之和。Cancel 關閉。
## Permissions
PUT 端點用 `[Authorize]`,與既有 `GetRange` 一致。
## Out of Scope
- Recent Sessions grid 目前尚無手機卡片版;本次只新增欄位與 action,不一併重構 mobile 版面(可另開 task)。
## Testing
- Backend`SetCountsAsync` 對負值 clamp 為 0、該日無 row 時建立;`GetPaged` 正確帶入 attendance 總數且無 N+1。
- Frontenddialog 總數計算(成人+青年+兒童)與存檔後就地更新該列。(前端測試環境較脆弱,採最小範圍 inline-template 測試。)