# 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 + 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`。