Files
ROLAC/docs/superpowers/plans/2026-06-24-offering-session-attendance.md
2026-06-24 11:35:34 -07:00

538 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`