538 lines
23 KiB
Markdown
538 lines
23 KiB
Markdown
# 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<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` 在 DTO(C# `SundayAttendanceCount`)與前端 model 對應;`AttendanceCounts` 前端模型已有 `adult/youth/kid`。
|