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

23 KiB
Raw Blame History

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.cscreate: 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.cscreate.
  • 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:

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):

    /// <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):

    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
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:

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:

    /// <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
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:

    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):

    [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:

        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,:

            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
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;:

  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:

  /** 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
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):

import { GridModule, CellClickEvent } from '@progress/kendo-angular-grid';

b) Add these imports near the other Kendo imports (after line 11):

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):

import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';

d) Add ContextMenuModule to the component imports array (line 31-34):

  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):

    private mealAttendanceApi: MealAttendanceApiService,
  • Step 2: Add context-menu + dialog state and handlers (component.ts)

Add these members after confirmReopenOpen = false; (line 75):

  // 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):

  // 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:

      <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):

  <!-- ============================ 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:

.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

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