Update
This commit is contained in:
@@ -137,6 +137,7 @@ export interface OfferingEntrySummaryDto {
|
||||
status: SessionStatus | null;
|
||||
systemTotal: number;
|
||||
lineCount: number;
|
||||
hasProof: boolean; // a merged paper-proof PDF is attached to this session
|
||||
lines: OfferingGivingLineDto[];
|
||||
}
|
||||
/** One-shot payload that seeds the mobile page. */
|
||||
|
||||
+97
-4
@@ -3,7 +3,7 @@
|
||||
<header class="oe__head">
|
||||
<span class="oe__eyebrow">River of Life · Offering</span>
|
||||
<h1 class="oe__title">主日奉獻錄入 <span>Sunday Offering Entry</span></h1>
|
||||
<div class="oe__date">{{ todayDate | date:'EEE, MMM d, y' }}</div>
|
||||
<div class="oe__date">{{ sessionDate | date:'EEE, MMM d, y' }}</div>
|
||||
<div class="oe__status" [class.is-on]="connected">
|
||||
<span class="oe__dot"></span>
|
||||
{{ connected ? '即時同步中 · Live' : '連線中… · Connecting' }}
|
||||
@@ -17,10 +17,18 @@
|
||||
<span class="oe__tally-label">今日筆數 · Lines</span>
|
||||
</div>
|
||||
<div class="oe__tally-divider"></div>
|
||||
<div class="oe__tally-item">
|
||||
<button type="button" class="oe__tally-item oe__tally-item--btn" (click)="openTotals()">
|
||||
<span class="oe__tally-num">{{ systemTotal | currency }}</span>
|
||||
<span class="oe__tally-label">今日總額 · Total</span>
|
||||
</div>
|
||||
<span class="oe__tally-label">今日總額 · Total <span class="oe__tally-hint">›</span></span>
|
||||
</button>
|
||||
<div class="oe__tally-divider"></div>
|
||||
<!-- Day-level paper proof (the whole session's count sheet / envelopes), not
|
||||
any single line — lives here, beside the running tally, on purpose. -->
|
||||
<button type="button" class="oe__tally-item oe__tally-item--btn oe__tally-item--proof"
|
||||
[class.is-attached]="hasProof" (click)="openPaperProof()">
|
||||
<span class="oe__tally-num oe__tally-icon">{{ hasProof ? '📎' : '+' }}</span>
|
||||
<span class="oe__tally-label">紙本證明 · Proof</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Entry form -->
|
||||
@@ -134,4 +142,89 @@
|
||||
</button>
|
||||
</kendo-dialog-actions>
|
||||
</kendo-dialog>
|
||||
|
||||
<!-- Today's totals: payment-method breakdown + per-check detail -->
|
||||
<kendo-dialog *ngIf="showTotals" title="今日總計 · Today's Totals"
|
||||
(close)="closeTotals()" [minWidth]="280" [width]="360">
|
||||
<div class="oe__qa">
|
||||
<p *ngIf="totalsLoading" class="oe__totals-loading">載入中… · Loading</p>
|
||||
|
||||
<ng-container *ngIf="!totalsLoading">
|
||||
<!-- By payment method -->
|
||||
<div class="oe__totals-section">
|
||||
<h3 class="oe__totals-heading">各付款方式 · By method</h3>
|
||||
<ul class="oe__totals-list">
|
||||
<li *ngFor="let row of methodSubtotals" class="oe__totals-row">
|
||||
<span class="oe__totals-name">{{ row.label }}</span>
|
||||
<span class="oe__totals-amount">{{ row.total | currency }}</span>
|
||||
</li>
|
||||
<li *ngIf="!methodSubtotals.length" class="oe__totals-empty">今日尚無紀錄 · No entries yet</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Per-check detail -->
|
||||
<div class="oe__totals-section">
|
||||
<h3 class="oe__totals-heading">各支票 · Checks</h3>
|
||||
<ul class="oe__totals-list">
|
||||
<li *ngFor="let check of checkLines" class="oe__totals-row">
|
||||
<span class="oe__totals-name"># {{ check.checkNumber || '(無號碼 · no #)' }}</span>
|
||||
<span class="oe__totals-amount">{{ check.amount | currency }}</span>
|
||||
</li>
|
||||
<li *ngIf="!checkLines.length" class="oe__totals-empty">今日無支票 · No checks</li>
|
||||
</ul>
|
||||
<div *ngIf="checkLines.length" class="oe__totals-row oe__totals-row--subtotal">
|
||||
<span class="oe__totals-name">支票合計 · Check total</span>
|
||||
<span class="oe__totals-amount">{{ checkTotal | currency }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grand total -->
|
||||
<div class="oe__totals-row oe__totals-row--grand">
|
||||
<span class="oe__totals-name">今日總計 · Total</span>
|
||||
<span class="oe__totals-amount">{{ grandTotal | currency }}</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
<kendo-dialog-actions>
|
||||
<button kendoButton themeColor="primary" (click)="closeTotals()">關閉 · Close</button>
|
||||
</kendo-dialog-actions>
|
||||
</kendo-dialog>
|
||||
|
||||
<!-- Add paper proof: capture photos / pick files → compress + merge to one PDF -->
|
||||
<kendo-dialog *ngIf="showPaperProof" title="新增 Paper Proof · 紙本證明"
|
||||
(close)="cancelPaperProof()" [minWidth]="280" [width]="360">
|
||||
<div class="oe__qa">
|
||||
<p class="oe__proof-hint">附上點算單/信封的照片或 PDF · Photo or PDF of the count sheet / envelopes</p>
|
||||
|
||||
<!-- Hidden native inputs, driven by the buttons below. -->
|
||||
<input #cameraInput type="file" hidden accept="image/*" capture="environment"
|
||||
(change)="onProofFilesSelected($event)" />
|
||||
<input #libraryInput type="file" hidden multiple accept="image/*,application/pdf"
|
||||
(change)="onProofFilesSelected($event)" />
|
||||
|
||||
<div class="oe__proof-actions">
|
||||
<button kendoButton fillMode="outline" size="large" (click)="cameraInput.click()">
|
||||
📷 拍照 · Camera
|
||||
</button>
|
||||
<button kendoButton fillMode="outline" size="large" (click)="libraryInput.click()">
|
||||
🖼️ 相簿/檔案 · Library
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul *ngIf="paperProofFiles.length" class="oe__proof-list">
|
||||
<li *ngFor="let file of paperProofFiles; let i = index" class="oe__proof-item">
|
||||
<span class="oe__proof-name">{{ file.name }}</span>
|
||||
<button kendoButton fillMode="flat" size="small" (click)="removeProofFile(i)">×</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p *ngIf="hasProof" class="oe__proof-merge">將與現有證明合併 · Will be merged with the existing proof</p>
|
||||
</div>
|
||||
<kendo-dialog-actions>
|
||||
<button kendoButton (click)="cancelPaperProof()">取消 · Cancel</button>
|
||||
<button kendoButton themeColor="primary" [disabled]="!canSavePaperProof" (click)="savePaperProof()">
|
||||
{{ paperProofSaving ? '處理中… · Saving' : '附加 · Attach' }}
|
||||
</button>
|
||||
</kendo-dialog-actions>
|
||||
</kendo-dialog>
|
||||
</div>
|
||||
|
||||
+160
@@ -106,6 +106,38 @@
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
/* The Total item is tappable — open the totals dialog. Reset button chrome so it
|
||||
looks identical to the static tally item beside it, just with a pointer + hint. */
|
||||
.oe__tally-item--btn {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
}
|
||||
|
||||
.oe__tally-hint {
|
||||
color: #94a3b8;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Proof slot: an icon where the other items show a number. Neutral by default,
|
||||
green once a proof PDF is attached so the volunteer can see it's on file. */
|
||||
.oe__tally-icon {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.oe__tally-item--proof.is-attached .oe__tally-icon,
|
||||
.oe__tally-item--proof.is-attached .oe__tally-label {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.oe__tally-num {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
@@ -185,6 +217,134 @@
|
||||
padding: 0.25rem 0.1rem;
|
||||
}
|
||||
|
||||
/* ── Totals dialog ──────────────────────────────────────────────────────── */
|
||||
.oe__totals-loading {
|
||||
margin: 0;
|
||||
padding: 1rem 0;
|
||||
text-align: center;
|
||||
font-size: 0.95rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.oe__totals-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.oe__totals-heading {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.oe__totals-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.oe__totals-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.oe__totals-name {
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.oe__totals-amount {
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.oe__totals-empty {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* Check subtotal: a hairline above to separate it from the per-check rows. */
|
||||
.oe__totals-row--subtotal {
|
||||
margin-top: 0.15rem;
|
||||
padding-top: 0.4rem;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Grand total: heavier divider + larger amount, the dialog's bottom line. */
|
||||
.oe__totals-row--grand {
|
||||
margin-top: 0.25rem;
|
||||
padding-top: 0.6rem;
|
||||
border-top: 2px solid #cbd5e1;
|
||||
font-size: 1.05rem;
|
||||
|
||||
.oe__totals-amount {
|
||||
color: #1d4ed8;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Paper proof ────────────────────────────────────────────────────────── */
|
||||
.oe__proof-hint {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
/* Two capture entry points side by side (camera + library). */
|
||||
.oe__proof-actions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.oe__proof-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.oe__proof-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
background: #f1f4f8;
|
||||
border: 1px solid #dbe2ea;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.oe__proof-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.oe__proof-merge {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
/* ── Sticky submit bar ──────────────────────────────────────────────────── */
|
||||
.oe__submit {
|
||||
position: fixed;
|
||||
|
||||
+157
-11
@@ -1,25 +1,30 @@
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
import { Subject, takeUntil, firstValueFrom } from 'rxjs';
|
||||
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
||||
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||||
import { buildProofPdf } from '../../services/proof-pdf.builder';
|
||||
import { OfferingEntryApiService } from '../../services/offering-entry-api.service';
|
||||
import { OfferingEntrySignalrService } from '../../services/offering-entry-signalr.service';
|
||||
import {
|
||||
GivingCategoryDto, OfferingGivingLineRequest, MemberTypeaheadDto, QuickAddMemberRequest,
|
||||
OfferingGivingLineDto, PaymentMethod,
|
||||
} from '../../models/giving.model';
|
||||
import { PAYMENT_METHOD_OPTIONS } from '../../../../shared/i18n/option-lists';
|
||||
|
||||
interface MemberOption { id: number; displayName: string; }
|
||||
|
||||
/** One row of the totals dialog's payment-method breakdown. */
|
||||
interface MethodSubtotal { method: PaymentMethod; label: string; total: number; }
|
||||
|
||||
/**
|
||||
* Portrait, phone-friendly page where a volunteer records one Sunday offering
|
||||
* at a time. Fields mirror the desktop "Add Giving" form. Each submit persists
|
||||
* a single line to today's session (find-or-create, server-side) and the form
|
||||
* resets blank for the next entry. No login required.
|
||||
* a single line to the current week's Sunday session (find-or-create, server-
|
||||
* side) and the form resets blank for the next entry. No login required.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-offering-entry-mobile-page',
|
||||
@@ -29,9 +34,11 @@ interface MemberOption { id: number; displayName: string; }
|
||||
styleUrls: ['./offering-entry-mobile-page.component.scss'],
|
||||
})
|
||||
export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy {
|
||||
/** Auto-selected current day (decision: page defaults to today's session). */
|
||||
readonly todayDate = new Date();
|
||||
private readonly today = this.toIso(this.todayDate);
|
||||
// The Sunday whose offering this session records. The week runs Sunday→Saturday,
|
||||
// so a Sunday's gifts can still be keyed any day through the following Saturday:
|
||||
// entered on Sat 6/20 → 6/14; on Sun 6/21 (and through Sat 6/27) → 6/21.
|
||||
readonly sessionDate = this.sundayOf(new Date());
|
||||
private readonly session = this.toIso(this.sessionDate);
|
||||
|
||||
categories: GivingCategoryDto[] = [];
|
||||
readonly paymentMethods = PAYMENT_METHOD_OPTIONS;
|
||||
@@ -56,6 +63,20 @@ export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy {
|
||||
quickAddSaving = false;
|
||||
quickAdd: QuickAddMemberRequest = this.blankQuickAdd();
|
||||
|
||||
// Paper-proof dialog: photos/PDFs of the count sheet / envelopes for today's
|
||||
// session. Staged here, compressed + merged into one PDF on attach.
|
||||
showPaperProof = false;
|
||||
paperProofSaving = false;
|
||||
paperProofFiles: File[] = [];
|
||||
hasProof = false; // whether today's session already has a proof PDF
|
||||
|
||||
// Totals dialog: opened from the "今日總額" tally. Lines are refetched on open so
|
||||
// the breakdown is a fresh cross-phone snapshot, not the (possibly stale) lines
|
||||
// loaded at bootstrap.
|
||||
showTotals = false;
|
||||
totalsLoading = false;
|
||||
private totalsLines: OfferingGivingLineDto[] = [];
|
||||
|
||||
private toastTimer?: ReturnType<typeof setTimeout>;
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
@@ -65,17 +86,18 @@ export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy {
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.api.bootstrap(this.today).subscribe(dto => {
|
||||
this.api.bootstrap(this.session).subscribe(dto => {
|
||||
this.categories = dto.categories;
|
||||
this.entry.givingCategoryId = dto.categories[0]?.id ?? 0;
|
||||
this.lineCount = dto.summary.lineCount;
|
||||
this.systemTotal = dto.summary.systemTotal;
|
||||
this.hasProof = dto.summary.hasProof;
|
||||
});
|
||||
|
||||
this.signalr.lineAdded$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(evt => {
|
||||
if (evt.sessionDate !== this.today) {
|
||||
if (evt.sessionDate !== this.session) {
|
||||
return;
|
||||
}
|
||||
this.lineCount = evt.lineCount;
|
||||
@@ -85,7 +107,7 @@ export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy {
|
||||
this.signalr.start()
|
||||
.then(() => {
|
||||
this.connected = true;
|
||||
return this.signalr.joinDate(this.today);
|
||||
return this.signalr.joinDate(this.session);
|
||||
})
|
||||
.catch(() => (this.connected = false));
|
||||
}
|
||||
@@ -96,7 +118,7 @@ export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy {
|
||||
if (this.toastTimer) {
|
||||
clearTimeout(this.toastTimer);
|
||||
}
|
||||
this.signalr.leaveDate(this.today)
|
||||
this.signalr.leaveDate(this.session)
|
||||
.catch(() => undefined)
|
||||
.then(() => this.signalr.stop());
|
||||
}
|
||||
@@ -105,6 +127,10 @@ export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy {
|
||||
if (this.submitting || this.entry.amount <= 0) {
|
||||
return false;
|
||||
}
|
||||
// A named gift must have a giver — only an explicitly anonymous gift may omit one.
|
||||
if (!this.entry.isAnonymous && this.entry.memberId == null) {
|
||||
return false;
|
||||
}
|
||||
if (this.entry.paymentMethod === 'Check' && !this.entry.checkNumber) {
|
||||
return false;
|
||||
}
|
||||
@@ -212,12 +238,123 @@ export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy {
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
// ── Paper proof ─────────────────────────────────────────────────────────────
|
||||
|
||||
openPaperProof(): void {
|
||||
this.paperProofFiles = [];
|
||||
this.showPaperProof = true;
|
||||
}
|
||||
|
||||
cancelPaperProof(): void {
|
||||
this.showPaperProof = false;
|
||||
}
|
||||
|
||||
// Shared by the camera and library file inputs — accumulate picks and clear the
|
||||
// input so the same file can be re-selected if it was removed.
|
||||
onProofFilesSelected(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const picked = Array.from(input.files ?? []);
|
||||
if (picked.length) {
|
||||
this.paperProofFiles = [...this.paperProofFiles, ...picked];
|
||||
}
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
removeProofFile(index: number): void {
|
||||
this.paperProofFiles = this.paperProofFiles.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
get canSavePaperProof(): boolean {
|
||||
return !this.paperProofSaving && this.paperProofFiles.length > 0;
|
||||
}
|
||||
|
||||
async savePaperProof(): Promise<void> {
|
||||
if (!this.canSavePaperProof) {
|
||||
return;
|
||||
}
|
||||
this.paperProofSaving = true;
|
||||
try {
|
||||
const files = [...this.paperProofFiles];
|
||||
if (this.hasProof) {
|
||||
// Merge into today's existing proof: prepend its pages so the order stays
|
||||
// chronological. A 204 (no proof) comes back as an empty Blob — skip it.
|
||||
const existing = await firstValueFrom(this.api.downloadProof(this.session));
|
||||
if (existing.size > 0) {
|
||||
files.unshift(new File([existing], 'existing-proof.pdf', { type: 'application/pdf' }));
|
||||
}
|
||||
}
|
||||
const { blob, skipped } = await buildProofPdf(files);
|
||||
await firstValueFrom(this.api.uploadProof(this.session, blob));
|
||||
this.hasProof = true;
|
||||
this.paperProofSaving = false;
|
||||
this.showPaperProof = false;
|
||||
this.paperProofFiles = [];
|
||||
this.showToast(skipped.length
|
||||
? `已附證明,略過 ${skipped.length} 個不支援檔案 · Attached (${skipped.length} skipped)`
|
||||
: '已附紙本證明 ✓ Proof attached');
|
||||
} catch (err: unknown) {
|
||||
this.paperProofSaving = false;
|
||||
const message = (err as { error?: { message?: string } })?.error?.message;
|
||||
this.showToast(message ?? '附加失敗 Attach failed');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Totals dialog ───────────────────────────────────────────────────────────
|
||||
|
||||
openTotals(): void {
|
||||
this.showTotals = true;
|
||||
this.totalsLoading = true;
|
||||
this.totalsLines = [];
|
||||
// Refetch so the breakdown reflects every phone's entries at this moment.
|
||||
this.api.bootstrap(this.session).subscribe({
|
||||
next: dto => {
|
||||
this.totalsLines = dto.summary.lines;
|
||||
this.totalsLoading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.showTotals = false;
|
||||
this.totalsLoading = false;
|
||||
this.showToast('讀取總計失敗 Failed to load totals');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
closeTotals(): void {
|
||||
this.showTotals = false;
|
||||
}
|
||||
|
||||
// One row per payment method that has at least one line, in the canonical
|
||||
// PAYMENT_METHOD_OPTIONS order (Cash → Check → Zelle → PayPal → Other).
|
||||
get methodSubtotals(): MethodSubtotal[] {
|
||||
return PAYMENT_METHOD_OPTIONS
|
||||
.map(option => {
|
||||
const method = option.value as PaymentMethod;
|
||||
const total = this.totalsLines
|
||||
.filter(line => line.paymentMethod === method)
|
||||
.reduce((sum, line) => sum + line.amount, 0);
|
||||
return { method, label: option.label, total };
|
||||
})
|
||||
.filter(row => row.total > 0);
|
||||
}
|
||||
|
||||
get checkLines(): OfferingGivingLineDto[] {
|
||||
return this.totalsLines.filter(line => line.paymentMethod === 'Check');
|
||||
}
|
||||
|
||||
get checkTotal(): number {
|
||||
return this.checkLines.reduce((sum, line) => sum + line.amount, 0);
|
||||
}
|
||||
|
||||
get grandTotal(): number {
|
||||
return this.totalsLines.reduce((sum, line) => sum + line.amount, 0);
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.canSubmit) {
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
this.api.appendLine(this.today, this.normalizedLine()).subscribe({
|
||||
this.api.appendLine(this.session, this.normalizedLine()).subscribe({
|
||||
next: res => {
|
||||
this.submitting = false;
|
||||
// Server is the source of truth; update now in case our own broadcast
|
||||
@@ -281,6 +418,15 @@ export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy {
|
||||
this.toastTimer = setTimeout(() => (this.toast = null), 2200);
|
||||
}
|
||||
|
||||
// The most recent Sunday on or before the given date (Sun→Sat week). getDay()
|
||||
// returns 0 for Sunday, so subtracting it lands on this week's Sunday — and on
|
||||
// a Sunday it subtracts 0, keeping that same day.
|
||||
private sundayOf(d: Date): Date {
|
||||
const sunday = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
sunday.setDate(sunday.getDate() - sunday.getDay());
|
||||
return sunday;
|
||||
}
|
||||
|
||||
// Format using LOCAL date components — NOT toISOString(), which converts to UTC
|
||||
// and can roll the date forward a day for behind-UTC users.
|
||||
private toIso(d: Date): string {
|
||||
|
||||
@@ -45,4 +45,22 @@ export class OfferingEntryApiService {
|
||||
quickAddMember(request: QuickAddMemberRequest): Observable<MemberTypeaheadDto> {
|
||||
return this.http.post<MemberTypeaheadDto>(`${this.endpoint}/members`, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the date's existing proof PDF (so new pages can be merged into it).
|
||||
* The server returns 204 No Content when none exists yet — the body is then an
|
||||
* empty Blob, which the caller treats as "nothing to merge".
|
||||
*/
|
||||
downloadProof(date: string): Observable<Blob> {
|
||||
const params = new HttpParams().set('date', date);
|
||||
return this.http.get(`${this.endpoint}/proof`, { params, responseType: 'blob' });
|
||||
}
|
||||
|
||||
/** Upload the merged paper-proof PDF (built client-side) for the date's session. */
|
||||
uploadProof(date: string, pdf: Blob): Observable<void> {
|
||||
const form = new FormData();
|
||||
form.append('date', date);
|
||||
form.append('file', pdf, 'proof.pdf');
|
||||
return this.http.post<void>(`${this.endpoint}/proof`, form);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user