Compare commits

...

2 Commits

Author SHA1 Message Date
Chris Chen e9aad74df6 update quick add.
ci-cd-vm / ci-cd (push) Successful in 1m40s
2026-06-24 12:01:55 -07:00
Chris Chen e768f53ccc feat(giving): show Sunday attendance per session and add edit action 2026-06-24 11:40:44 -07:00
5 changed files with 113 additions and 15 deletions
@@ -2,6 +2,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1">First name (EN) *<kendo-textbox [(ngModel)]="firstName_en"></kendo-textbox></label>
<label class="flex flex-col gap-1">Last name (EN) *<kendo-textbox [(ngModel)]="lastName_en"></kendo-textbox></label>
<label class="flex flex-col gap-1 md:col-span-2">暱稱 · Nick name<kendo-textbox [(ngModel)]="nickName"></kendo-textbox></label>
<label class="flex flex-col gap-1">名 (中)<kendo-textbox [(ngModel)]="firstName_zh"></kendo-textbox></label>
<label class="flex flex-col gap-1">姓 (中)<kendo-textbox [(ngModel)]="lastName_zh"></kendo-textbox></label>
<label class="flex flex-col gap-1 md:col-span-2">公司行號 · Company<kendo-textbox [(ngModel)]="entity"></kendo-textbox></label>
@@ -19,6 +19,7 @@ export class MemberQuickAddDialogComponent {
firstName_en = '';
lastName_en = '';
nickName: string | null = null;
firstName_zh: string | null = null;
lastName_zh: string | null = null;
entity: string | null = null;
@@ -33,7 +34,7 @@ export class MemberQuickAddDialogComponent {
const req: CreateMemberRequest = {
firstName_en: this.firstName_en,
lastName_en: this.lastName_en,
nickName: null,
nickName: this.nickName,
firstName_zh: this.firstName_zh,
lastName_zh: this.lastName_zh,
entity: this.entity,
@@ -62,7 +63,7 @@ export class MemberQuickAddDialogComponent {
id,
firstName_en: this.firstName_en,
lastName_en: this.lastName_en,
nickName: null,
nickName: this.nickName,
firstName_zh: this.firstName_zh,
lastName_zh: this.lastName_zh,
entity: this.entity,
@@ -36,7 +36,7 @@
<span class="card__zh">最近的奉獻紀錄</span>
</div>
<kendo-grid class="lined" [data]="sessions">
<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>
@@ -44,6 +44,9 @@
</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>
@@ -51,15 +54,12 @@
</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>
<kendo-grid-column title="" [width]="110">
<ng-template kendoGridCellTemplate let-s>
<button kendoButton fillMode="flat" themeColor="primary" (click)="openView(s)">View</button>
</ng-template>
</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>
</section>
</ng-container>
@@ -306,4 +306,25 @@
<app-member-quick-add-dialog *ngIf="showQuickAdd" (created)="onMemberQuickCreated($event)"
(cancelled)="showQuickAdd = false"></app-member-quick-add-dialog>
<!-- ============================ 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>
</div>
@@ -233,3 +233,13 @@
@media (prefers-reduced-motion: reduce) {
.rise { animation: none; opacity: 1; transform: none; }
}
.clickable-rows {
.k-grid-table tr { cursor: pointer; }
}
.att-total {
margin-top: 0.75rem;
font-weight: 600;
text-align: right;
}
@@ -1,14 +1,16 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Observable, Subject, from, of, map, switchMap, takeUntil } from 'rxjs';
import { buildProofPdf } from '../../services/proof-pdf.builder';
import { GridModule } from '@progress/kendo-angular-grid';
import { GridModule, CellClickEvent } from '@progress/kendo-angular-grid';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
import { DialogsModule } from '@progress/kendo-angular-dialog';
import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
import { MealAttendanceApiService } from '../../../meal-attendance/services/meal-attendance-api.service';
import { OfferingSessionApiService } from '../../services/offering-session-api.service';
import { OfferingEntrySignalrService } from '../../services/offering-entry-signalr.service';
import { GivingCategoryApiService } from '../../services/giving-category-api.service';
@@ -30,7 +32,7 @@ type PageMode = 'landing' | 'workspace' | 'view';
standalone: true,
imports: [
CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule,
DropDownsModule, DateInputsModule, DialogsModule, MemberQuickAddDialogComponent,
DropDownsModule, DateInputsModule, DialogsModule, ContextMenuModule, MemberQuickAddDialogComponent,
],
templateUrl: './offering-session-page.component.html',
styleUrls: ['./offering-session-page.component.scss'],
@@ -74,12 +76,25 @@ export class OfferingSessionPageComponent implements OnInit, OnDestroy {
viewSession: OfferingSessionDto | null = null;
confirmReopenOpen = false;
// 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; }
constructor(
private api: OfferingSessionApiService,
private categoryApi: GivingCategoryApiService,
private memberApi: MemberApiService,
private signalr: OfferingEntrySignalrService,
) {}
private mealAttendanceApi: MealAttendanceApiService,
) { }
ngOnInit(): void {
this.categoryApi.getAll(false).subscribe(c => {
@@ -162,6 +177,55 @@ export class OfferingSessionPageComponent implements OnInit, OnDestroy {
this.api.getPaged(1, 20).subscribe(r => this.sessions = r.items);
}
// 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.');
},
});
}
// ── Flow: landing → workspace / view ──────────────────────────────────────
/** Free date chosen on the landing screen — begin a brand-new session. */
@@ -275,7 +339,7 @@ export class OfferingSessionPageComponent implements OnInit, OnDestroy {
clearAnonymous(): void {
this.entry.isAnonymous = false;
}
lastAddedLine: OfferingBufferLine | null = null;
addLine(): void {
if (this.entry.amount <= 0) return;
if (this.entry.paymentMethod === 'Check' && !this.entry.checkNumber) return;
@@ -287,6 +351,7 @@ export class OfferingSessionPageComponent implements OnInit, OnDestroy {
};
if (this.editingIndex !== null) { this.buffer[this.editingIndex] = line; this.editingIndex = null; }
else { this.buffer = [...this.buffer, line]; }
this.lastAddedLine = line;
this.resetEntry();
}
@@ -341,7 +406,7 @@ export class OfferingSessionPageComponent implements OnInit, OnDestroy {
switchMap(id => this.pendingProofFiles.length === 0
? of(void 0)
: from(buildProofPdf(this.pendingProofFiles)).pipe(
switchMap(({ blob }) => this.api.uploadProof(id, blob)))),
switchMap(({ blob }) => this.api.uploadProof(id, blob)))),
).subscribe({
next: () => {
this.submitting = false;
@@ -439,7 +504,7 @@ export class OfferingSessionPageComponent implements OnInit, OnDestroy {
private blankEntry(): OfferingBufferLine {
return {
memberId: null, givingCategoryId: 0, amount: 0, paymentMethod: 'Cash',
memberId: null, givingCategoryId: this.lastAddedLine?.givingCategoryId, amount: 0, paymentMethod: this.lastAddedLine?.paymentMethod ?? 'Cash',
checkNumber: null, zelleReferenceCode: null, payPalTransactionId: null,
isAnonymous: false, notes: null, memberName: null, categoryName: '',
};