Update
ci-cd-nas / build-push (push) Failing after 27s
ci-cd-nas / deploy (push) Has been skipped

This commit is contained in:
Chris Chen
2026-06-20 22:26:52 -07:00
parent 7ab8e9703b
commit ddced87dc6
12 changed files with 655 additions and 52 deletions
@@ -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>
@@ -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;
@@ -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 {