From b0e2e112fc36a6feb43a9e90e114798d46c2af3e Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Wed, 24 Jun 2026 11:26:16 -0700 Subject: [PATCH] feat(giving): add sundayAttendanceCount model field and attendance setCounts API --- .../Controllers/OfferingEntryController.cs | 2 + .../DTOs/Giving/MemberTypeaheadDto.cs | 1 + .../DTOs/Giving/QuickAddMemberRequest.cs | 1 + .../DTOs/Members/CreateMemberRequest.cs | 1 + .../DTOs/Members/MemberListItemDto.cs | 1 + API/ROLAC.API/Data/AppDbContext.cs | 1 + API/ROLAC.API/Entities/Member.cs | 1 + .../Migrations/AppDbContextModelSnapshot.cs | 4 + API/ROLAC.API/Services/MemberService.cs | 5 + .../member-quick-add-dialog.component.html | 1 + .../member-quick-add-dialog.component.ts | 3 + .../features/giving/models/giving.model.ts | 3 + .../offering-entry-mobile-page.component.html | 4 + .../offering-entry-mobile-page.component.ts | 12 +- .../services/meal-attendance-api.service.ts | 5 + .../member-form-dialog.component.html | 5 + .../member-form-dialog.component.ts | 1 + .../features/members/models/member.model.ts | 2 + .../2026-06-24-offering-session-attendance.md | 537 ++++++++++++++++++ ...6-24-offering-session-attendance-design.md | 68 +++ 20 files changed, 653 insertions(+), 5 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-24-offering-session-attendance.md create mode 100644 docs/superpowers/specs/2026-06-24-offering-session-attendance-design.md diff --git a/API/ROLAC.API/Controllers/OfferingEntryController.cs b/API/ROLAC.API/Controllers/OfferingEntryController.cs index 1e1d94b..b9e4f9d 100644 --- a/API/ROLAC.API/Controllers/OfferingEntryController.cs +++ b/API/ROLAC.API/Controllers/OfferingEntryController.cs @@ -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, }); } diff --git a/API/ROLAC.API/DTOs/Giving/MemberTypeaheadDto.cs b/API/ROLAC.API/DTOs/Giving/MemberTypeaheadDto.cs index bd2d0e5..a8e9970 100644 --- a/API/ROLAC.API/DTOs/Giving/MemberTypeaheadDto.cs +++ b/API/ROLAC.API/DTOs/Giving/MemberTypeaheadDto.cs @@ -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 } diff --git a/API/ROLAC.API/DTOs/Giving/QuickAddMemberRequest.cs b/API/ROLAC.API/DTOs/Giving/QuickAddMemberRequest.cs index c9cd3c5..c90bb98 100644 --- a/API/ROLAC.API/DTOs/Giving/QuickAddMemberRequest.cs +++ b/API/ROLAC.API/DTOs/Giving/QuickAddMemberRequest.cs @@ -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; } } diff --git a/API/ROLAC.API/DTOs/Members/CreateMemberRequest.cs b/API/ROLAC.API/DTOs/Members/CreateMemberRequest.cs index 2863642..2535a91 100644 --- a/API/ROLAC.API/DTOs/Members/CreateMemberRequest.cs +++ b/API/ROLAC.API/DTOs/Members/CreateMemberRequest.cs @@ -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; } diff --git a/API/ROLAC.API/DTOs/Members/MemberListItemDto.cs b/API/ROLAC.API/DTOs/Members/MemberListItemDto.cs index 58a5a07..c01621d 100644 --- a/API/ROLAC.API/DTOs/Members/MemberListItemDto.cs +++ b/API/ROLAC.API/DTOs/Members/MemberListItemDto.cs @@ -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; } diff --git a/API/ROLAC.API/Data/AppDbContext.cs b/API/ROLAC.API/Data/AppDbContext.cs index ebbc83b..b4efd40 100644 --- a/API/ROLAC.API/Data/AppDbContext.cs +++ b/API/ROLAC.API/Data/AppDbContext.cs @@ -118,6 +118,7 @@ public class AppDbContext : IdentityDbContext 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); diff --git a/API/ROLAC.API/Entities/Member.cs b/API/ROLAC.API/Entities/Member.cs index 3a7a68b..59475a5 100644 --- a/API/ROLAC.API/Entities/Member.cs +++ b/API/ROLAC.API/Entities/Member.cs @@ -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; } diff --git a/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs b/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs index ea9a3b4..41603a1 100644 --- a/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs +++ b/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs @@ -1140,6 +1140,10 @@ namespace ROLAC.API.Migrations .HasMaxLength(200) .HasColumnType("character varying(200)"); + b.Property("Entity") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + b.Property("FamilyUnitId") .HasColumnType("integer"); diff --git a/API/ROLAC.API/Services/MemberService.cs b/API/ROLAC.API/Services/MemberService.cs index 578452c..91a9810 100644 --- a/API/ROLAC.API/Services/MemberService.cs +++ b/API/ROLAC.API/Services/MemberService.cs @@ -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; diff --git a/APP/src/app/features/giving/components/member-quick-add-dialog/member-quick-add-dialog.component.html b/APP/src/app/features/giving/components/member-quick-add-dialog/member-quick-add-dialog.component.html index d98e556..e245f00 100644 --- a/APP/src/app/features/giving/components/member-quick-add-dialog/member-quick-add-dialog.component.html +++ b/APP/src/app/features/giving/components/member-quick-add-dialog/member-quick-add-dialog.component.html @@ -4,6 +4,7 @@ + diff --git a/APP/src/app/features/giving/components/member-quick-add-dialog/member-quick-add-dialog.component.ts b/APP/src/app/features/giving/components/member-quick-add-dialog/member-quick-add-dialog.component.ts index caf7de5..501ba4a 100644 --- a/APP/src/app/features/giving/components/member-quick-add-dialog/member-quick-add-dialog.component.ts +++ b/APP/src/app/features/giving/components/member-quick-add-dialog/member-quick-add-dialog.component.ts @@ -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, diff --git a/APP/src/app/features/giving/models/giving.model.ts b/APP/src/app/features/giving/models/giving.model.ts index 5411d53..b943870 100644 --- a/APP/src/app/features/giving/models/giving.model.ts +++ b/APP/src/app/features/giving/models/giving.model.ts @@ -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. */ diff --git a/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.html b/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.html index f7b9f5d..035f93c 100644 --- a/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.html +++ b/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.html @@ -130,6 +130,10 @@ +
+ + +
diff --git a/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.ts b/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.ts index e1a02d3..ea3c948 100644 --- a/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.ts +++ b/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.ts @@ -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, }; } diff --git a/APP/src/app/features/meal-attendance/services/meal-attendance-api.service.ts b/APP/src/app/features/meal-attendance/services/meal-attendance-api.service.ts index 2bf97d0..8d62a59 100644 --- a/APP/src/app/features/meal-attendance/services/meal-attendance-api.service.ts +++ b/APP/src/app/features/meal-attendance/services/meal-attendance-api.service.ts @@ -22,4 +22,9 @@ export class MealAttendanceApiService { const params = new HttpParams().set('from', from).set('to', to); return this.http.get(this.endpoint, { params }); } + + /** Overwrite a specific Sunday's counts (back-office editor). */ + setCounts(date: string, counts: { adult: number; youth: number; kid: number }): Observable { + return this.http.put(`${this.endpoint}/${date}`, counts); + } } diff --git a/APP/src/app/features/members/components/member-form-dialog/member-form-dialog.component.html b/APP/src/app/features/members/components/member-form-dialog/member-form-dialog.component.html index 7a31d76..c6a24a2 100644 --- a/APP/src/app/features/members/components/member-form-dialog/member-form-dialog.component.html +++ b/APP/src/app/features/members/components/member-form-dialog/member-form-dialog.component.html @@ -33,6 +33,11 @@ + + + + + **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 + SaveChanges,clamp 至 0,無 row 則建立)。前端在 Kendo grid 加欄、加右鍵選單、加編輯 Dialog。 + +**Tech Stack:** C# / EF Core (PostgreSQL, InMemory for tests) / ASP.NET Core / xUnit + Moq;Angular 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() + .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 + /// + /// Overwrites all three age-group columns for 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. + /// + Task 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 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; + +/// Absolute head-counts to write for one Sunday, from the back-office editor. +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 + /// Overwrite a specific Sunday's counts (back-office editor). Authenticated only. + [HttpPut("{date}")] + [Authorize] + public async Task 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 { + return this.http.put(`${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 `...` block (lines 39-62) with: + +```html + + + + + {{ s.status }} + + + + + {{ s.sundayAttendanceCount ?? '—' }} + + + + 📎 + + + + + +
No sessions yet — pick a date above to start.
尚無紀錄 — 選擇上方日期開始
+
+
+ +
點一列檢視 · 右鍵修改主日人數 / Click a row to view · right-click to edit attendance
+``` + +(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 `
` of the `.off` container (after the existing view-mode/`workspace` blocks and any existing dialogs): + +```html + + +
+ + + +
+
總數 Total: {{ attTotal }}
+ + + + +
+``` + +- [ ] **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` 在 DTO(C# `SundayAttendanceCount`)與前端 model 對應;`AttendanceCounts` 前端模型已有 `adult/youth/kid`。 diff --git a/docs/superpowers/specs/2026-06-24-offering-session-attendance-design.md b/docs/superpowers/specs/2026-06-24-offering-session-attendance-design.md new file mode 100644 index 0000000..a6d9c27 --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-offering-session-attendance-design.md @@ -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. **顯示** — 後端 join,DTO 加 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 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`。 +- **Optional(plan 階段決定)**:若 `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` → 呼叫 `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。 +- Frontend:dialog 總數計算(成人+青年+兒童)與存檔後就地更新該列。(前端測試環境較脆弱,採最小範圍 inline-template 測試。)