feat(giving): add reopen-and-edit flow + recent sessions list to offering page

This commit is contained in:
Chris Chen
2026-05-28 17:41:19 -07:00
parent a573179714
commit af21e50d9f
3 changed files with 78 additions and 6 deletions
@@ -2,11 +2,16 @@
<header class="page-header"> <header class="page-header">
<h2>Sunday Offering Entry / 主日奉獻錄入</h2> <h2>Sunday Offering Entry / 主日奉獻錄入</h2>
<label>Date <label>Date
<kendo-datepicker [(ngModel)]="sessionDate" (valueChange)="checkDate()"></kendo-datepicker> <kendo-datepicker [(ngModel)]="sessionDate" (valueChange)="checkDate()" [disabled]="editingSessionId != null"></kendo-datepicker>
</label> </label>
</header> </header>
<div *ngIf="dateConflict" class="warn"> <div *ngIf="editingSessionId != null" class="edit-banner">
Editing submitted session — make changes and click "Update Session".
<button kendoButton fillMode="flat" (click)="cancelEdit()">Cancel edit</button>
</div>
<div *ngIf="dateConflict && editingSessionId == null" class="warn">
An offering session for this date already exists. Pick another date, or reopen the existing session to edit. An offering session for this date already exists. Pick another date, or reopen the existing session to edit.
</div> </div>
@@ -61,7 +66,24 @@
<label>Check counted<kendo-numerictextbox [(ngModel)]="checkTotal" [min]="0" [format]="'c2'"></kendo-numerictextbox></label> <label>Check counted<kendo-numerictextbox [(ngModel)]="checkTotal" [min]="0" [format]="'c2'"></kendo-numerictextbox></label>
<div [class.ok]="difference === 0" [class.bad]="difference !== 0">Difference: {{ difference | currency }}</div> <div [class.ok]="difference === 0" [class.bad]="difference !== 0">Difference: {{ difference | currency }}</div>
<button kendoButton themeColor="primary" <button kendoButton themeColor="primary"
[disabled]="buffer.length === 0 || dateConflict || submitting" (click)="submit()">Submit</button> [disabled]="buffer.length === 0 || (editingSessionId == null && dateConflict) || submitting"
(click)="submit()">{{ editingSessionId != null ? 'Update Session' : 'Submit' }}</button>
</section>
<section class="sessions-list">
<h3>Recent Sessions</h3>
<kendo-grid [data]="sessions">
<kendo-grid-column field="sessionDate" title="Date" [width]="120"></kendo-grid-column>
<kendo-grid-column field="status" title="Status" [width]="110"></kendo-grid-column>
<kendo-grid-column field="lineCount" title="Lines" [width]="80"></kendo-grid-column>
<kendo-grid-column field="systemTotal" title="System" [width]="110" format="c2"></kendo-grid-column>
<kendo-grid-column field="difference" title="Diff" [width]="100" format="c2"></kendo-grid-column>
<kendo-grid-column title="" [width]="140">
<ng-template kendoGridCellTemplate let-s>
<button kendoButton fillMode="flat" *ngIf="s.status === 'Submitted'" (click)="reopenAndEdit(s)">Reopen &amp; Edit</button>
</ng-template>
</kendo-grid-column>
</kendo-grid>
</section> </section>
<app-member-quick-add-dialog *ngIf="showQuickAdd" <app-member-quick-add-dialog *ngIf="showQuickAdd"
@@ -7,3 +7,5 @@
.reconcile { display: flex; gap: 1rem; align-items: flex-end; margin-top: 1rem; } .reconcile { display: flex; gap: 1rem; align-items: flex-end; margin-top: 1rem; }
.reconcile .ok { color: green; font-weight: 600; } .reconcile .ok { color: green; font-weight: 600; }
.reconcile .bad { color: #c00; font-weight: 600; } .reconcile .bad { color: #c00; font-weight: 600; }
.edit-banner { display: flex; align-items: center; gap: 1rem; background: #fff3cd; border-left: 4px solid #f0a500; padding: 0.5rem 1rem; border-radius: 4px; margin-bottom: 1rem; font-weight: 500; }
.sessions-list { margin-top: 2rem; }
@@ -1,6 +1,7 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Observable } from 'rxjs';
import { GridModule } from '@progress/kendo-angular-grid'; import { GridModule } from '@progress/kendo-angular-grid';
import { InputsModule } from '@progress/kendo-angular-inputs'; import { InputsModule } from '@progress/kendo-angular-inputs';
import { ButtonsModule } from '@progress/kendo-angular-buttons'; import { ButtonsModule } from '@progress/kendo-angular-buttons';
@@ -13,6 +14,7 @@ import { MemberListItemDto, memberDisplayName } from '../../../members/models/me
import { MemberQuickAddDialogComponent } from '../../components/member-quick-add-dialog/member-quick-add-dialog.component'; import { MemberQuickAddDialogComponent } from '../../components/member-quick-add-dialog/member-quick-add-dialog.component';
import { import {
GivingCategoryDto, PaymentMethod, OfferingBufferLine, CreateOfferingSessionRequest, GivingCategoryDto, PaymentMethod, OfferingBufferLine, CreateOfferingSessionRequest,
OfferingSessionListItemDto, OfferingSessionDto,
} from '../../models/giving.model'; } from '../../models/giving.model';
interface MemberOption { id: number; displayName: string; } interface MemberOption { id: number; displayName: string; }
@@ -48,6 +50,9 @@ export class OfferingSessionPageComponent implements OnInit {
showQuickAdd = false; showQuickAdd = false;
submitting = false; submitting = false;
sessions: OfferingSessionListItemDto[] = [];
editingSessionId: number | null = null;
constructor( constructor(
private api: OfferingSessionApiService, private api: OfferingSessionApiService,
private categoryApi: GivingCategoryApiService, private categoryApi: GivingCategoryApiService,
@@ -60,6 +65,7 @@ export class OfferingSessionPageComponent implements OnInit {
this.entry.givingCategoryId = c[0]?.id ?? 0; this.entry.givingCategoryId = c[0]?.id ?? 0;
}); });
this.checkDate(); this.checkDate();
this.loadSessions();
} }
get systemTotal(): number { return this.buffer.reduce((s, l) => s + (l.amount || 0), 0); } get systemTotal(): number { return this.buffer.reduce((s, l) => s + (l.amount || 0), 0); }
@@ -69,6 +75,42 @@ export class OfferingSessionPageComponent implements OnInit {
this.api.checkDate(this.toIso(this.sessionDate)).subscribe(r => this.dateConflict = r.exists); this.api.checkDate(this.toIso(this.sessionDate)).subscribe(r => this.dateConflict = r.exists);
} }
loadSessions(): void {
this.api.getPaged(1, 20).subscribe(r => this.sessions = r.items);
}
reopenAndEdit(s: OfferingSessionListItemDto): void {
if (s.status !== 'Submitted') return;
this.api.reopen(s.id).subscribe({
next: () => this.api.getById(s.id).subscribe(dto => this.loadIntoBuffer(dto)),
error: (err: { error?: { message?: string } }) => alert(err?.error?.message ?? 'Reopen failed.'),
});
}
private loadIntoBuffer(dto: OfferingSessionDto): void {
this.editingSessionId = dto.id;
this.sessionDate = new Date(dto.sessionDate + 'T00:00:00');
this.dateConflict = false;
this.cashTotal = dto.cashTotal;
this.checkTotal = dto.checkTotal;
this.notes = dto.notes;
this.buffer = dto.givings.map(g => ({
memberId: g.memberId, givingCategoryId: g.givingCategoryId, amount: g.amount,
paymentMethod: g.paymentMethod, checkNumber: g.checkNumber,
zelleReferenceCode: g.zelleReferenceCode, payPalTransactionId: g.payPalTransactionId,
isAnonymous: g.isAnonymous, notes: g.notes,
memberName: g.memberName, categoryName: g.categoryName,
}));
this.resetEntry();
}
cancelEdit(): void {
this.editingSessionId = null;
this.buffer = []; this.cashTotal = 0; this.checkTotal = 0; this.notes = null;
this.sessionDate = new Date();
this.checkDate();
}
onMemberFilter(term: string): void { onMemberFilter(term: string): void {
if (!term) { this.memberResults = []; return; } if (!term) { this.memberResults = []; return; }
this.memberApi.getPaged({ search: term, pageSize: 10 }).subscribe(r => this.memberApi.getPaged({ search: term, pageSize: 10 }).subscribe(r =>
@@ -123,7 +165,7 @@ export class OfferingSessionPageComponent implements OnInit {
} }
submit(): void { submit(): void {
if (this.buffer.length === 0 || this.dateConflict) return; if (this.buffer.length === 0 || (this.editingSessionId == null && this.dateConflict)) return;
this.submitting = true; this.submitting = true;
const req: CreateOfferingSessionRequest = { const req: CreateOfferingSessionRequest = {
sessionDate: this.toIso(this.sessionDate), sessionDate: this.toIso(this.sessionDate),
@@ -142,12 +184,18 @@ export class OfferingSessionPageComponent implements OnInit {
notes: l.notes, notes: l.notes,
})), })),
}; };
this.api.create(req).subscribe({ const obs: Observable<unknown> = this.editingSessionId != null
? this.api.replace(this.editingSessionId, req)
: this.api.create(req);
obs.subscribe({
next: () => { next: () => {
this.submitting = false; this.submitting = false;
alert('Offering session submitted.'); alert(this.editingSessionId != null ? 'Offering session updated.' : 'Offering session submitted.');
this.editingSessionId = null;
this.buffer = []; this.cashTotal = 0; this.checkTotal = 0; this.notes = null; this.buffer = []; this.cashTotal = 0; this.checkTotal = 0; this.notes = null;
this.sessionDate = new Date();
this.checkDate(); this.checkDate();
this.loadSessions();
}, },
error: (err: { error?: { message?: string } }) => { error: (err: { error?: { message?: string } }) => {
this.submitting = false; this.submitting = false;