From ddced87dc667c095eabd1e354114a53bde56a3c3 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Sat, 20 Jun 2026 22:26:52 -0700 Subject: [PATCH] Update --- .gitea/workflows/ci-cd-nas.yml | 52 ++++-- .../Controllers/OfferingEntryController.cs | 24 +++ .../DTOs/Giving/OfferingEntrySummaryDto.cs | 1 + .../Services/IOfferingSessionService.cs | 4 + .../Services/OfferingSessionService.cs | 34 ++++ .../features/giving/models/giving.model.ts | 1 + .../offering-entry-mobile-page.component.html | 101 ++++++++++- .../offering-entry-mobile-page.component.scss | 160 +++++++++++++++++ .../offering-entry-mobile-page.component.ts | 168 ++++++++++++++++-- .../services/offering-entry-api.service.ts | 18 ++ deploy/nas/README.md | 87 ++++++--- ...026-06-20-offering-totals-dialog-design.md | 57 ++++++ 12 files changed, 655 insertions(+), 52 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-20-offering-totals-dialog-design.md diff --git a/.gitea/workflows/ci-cd-nas.yml b/.gitea/workflows/ci-cd-nas.yml index 33ba373..0193ec5 100644 --- a/.gitea/workflows/ci-cd-nas.yml +++ b/.gitea/workflows/ci-cd-nas.yml @@ -4,27 +4,28 @@ on: branches: [main] jobs: - # Runs in a normal container on the runner — only needs the .NET SDK. - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v4 - with: { dotnet-version: '8.0.x' } - - run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release - - # Runs on the NAS runner (label `nas`) which has the host Docker socket mounted - # and /volume1/docker/rolac bind-mounted at the same path. Builds, pushes to the - # local Gitea registry, then (re)starts the stack. - deploy: - needs: test - runs-on: nas + # Runs on the DEV PC runner (label `builder`): Docker Desktop + .NET SDK. + # DS220+ (Celeron J4025 / 2GB RAM) cannot build these images, so all the heavy + # work (test, dotnet publish, ng build) happens here, then images are pushed + # to the Gitea registry on the NAS. + build-push: + # Label is registered on the dev PC as `windows:host`; runs-on matches the + # label NAME (before the colon). `:host` means it runs directly on the PC, + # using its installed Docker Desktop + .NET SDK (no container). + runs-on: windows + defaults: + run: + # Git Bash (bundled with Git for Windows) — needed for `$REGISTRY` and + # the heredoc-style multi-line steps below. + shell: bash env: REGISTRY: git.golife.love/chrischen - DEPLOY_DIR: /volume1/docker/rolac steps: - uses: actions/checkout@v4 + - name: Test API + run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release + - name: Registry login run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.golife.love -u "${{ secrets.REGISTRY_USER }}" --password-stdin @@ -38,6 +39,23 @@ jobs: docker push --all-tags "$REGISTRY/rolac-api" docker push --all-tags "$REGISTRY/rolac-app" + # Runs on the NAS runner (label `nas`): host Docker socket mounted and + # /volume1/docker/rolac bind-mounted at the same path. Deploy ONLY — it just + # pulls the freshly-built images and (re)starts the stack. No building here. + deploy: + needs: build-push + runs-on: nas + defaults: + run: + shell: sh + env: + DEPLOY_DIR: /volume1/docker/rolac + steps: + - uses: actions/checkout@v4 + + - name: Registry login + run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.golife.love -u "${{ secrets.REGISTRY_USER }}" --password-stdin + - name: Sync compose + nginx to deploy dir run: | mkdir -p "$DEPLOY_DIR/nginx/conf.d" "$DEPLOY_DIR/data/api-storage" @@ -47,6 +65,8 @@ jobs: - name: Deploy run: | cd "$DEPLOY_DIR" + export TAG=${{ github.sha }} + docker compose pull docker compose up -d sleep 5 curl -fsS http://localhost:8080/api/health diff --git a/API/ROLAC.API/Controllers/OfferingEntryController.cs b/API/ROLAC.API/Controllers/OfferingEntryController.cs index 05185c7..1e1d94b 100644 --- a/API/ROLAC.API/Controllers/OfferingEntryController.cs +++ b/API/ROLAC.API/Controllers/OfferingEntryController.cs @@ -85,4 +85,28 @@ public class OfferingEntryController : ControllerBase await _hub.Clients.Group(result.SessionDate).SendAsync("LineAdded", result); return Ok(result); } + + // ── Paper-proof PDF for the date's session (merged client-side) ────────── + // Date-keyed so the anonymous page (which has no session id) can attach the + // count sheet / envelope photos. Mirrors OfferingSessionsController's proof + // validation; the desktop session page reviews/deletes the result. + + [HttpGet("proof")] + public async Task GetProof([FromQuery] DateOnly date) + { + var result = await _sessions.OpenProofForDateAsync(date); + if (result is null) return NoContent(); // no session/proof yet — client merges nothing + return File(result.Value.stream, result.Value.contentType); + } + + [HttpPost("proof")] + [RequestSizeLimit(52_428_800)] // 50 MB — a merged multi-image PDF is larger than one receipt + public async Task UploadProof([FromForm] DateOnly date, IFormFile file) + { + if (file is null || file.Length == 0) return BadRequest(new { message = "No file." }); + if (file.ContentType != "application/pdf") return BadRequest(new { message = "Proof must be a PDF." }); + await using var stream = file.OpenReadStream(); + await _sessions.SaveProofForDateAsync(date, stream, file.FileName); + return NoContent(); + } } diff --git a/API/ROLAC.API/DTOs/Giving/OfferingEntrySummaryDto.cs b/API/ROLAC.API/DTOs/Giving/OfferingEntrySummaryDto.cs index 0ee2e88..debd140 100644 --- a/API/ROLAC.API/DTOs/Giving/OfferingEntrySummaryDto.cs +++ b/API/ROLAC.API/DTOs/Giving/OfferingEntrySummaryDto.cs @@ -10,5 +10,6 @@ public class OfferingEntrySummaryDto public string? Status { get; set; } // null when no session yet public decimal SystemTotal { get; set; } public int LineCount { get; set; } + public bool HasProof { get; set; } // a merged paper-proof PDF is attached to this session public List Lines { get; set; } = []; } diff --git a/API/ROLAC.API/Services/IOfferingSessionService.cs b/API/ROLAC.API/Services/IOfferingSessionService.cs index 768dffc..352b70a 100644 --- a/API/ROLAC.API/Services/IOfferingSessionService.cs +++ b/API/ROLAC.API/Services/IOfferingSessionService.cs @@ -21,4 +21,8 @@ public interface IOfferingSessionService Task SaveProofAsync(int id, Stream content, string fileName); Task<(Stream stream, string contentType)?> OpenProofAsync(int id); Task DeleteProofAsync(int id); + + // Date-keyed variants for the anonymous mobile page (knows the date, not the id). + Task SaveProofForDateAsync(DateOnly date, Stream content, string fileName); + Task<(Stream stream, string contentType)?> OpenProofForDateAsync(DateOnly date); } diff --git a/API/ROLAC.API/Services/OfferingSessionService.cs b/API/ROLAC.API/Services/OfferingSessionService.cs index e174b56..d7a67d5 100644 --- a/API/ROLAC.API/Services/OfferingSessionService.cs +++ b/API/ROLAC.API/Services/OfferingSessionService.cs @@ -183,6 +183,7 @@ public class OfferingSessionService : IOfferingSessionService Status = session.Status, SystemTotal = session.SystemTotal, LineCount = lines.Count, + HasProof = session.ProofPdfPath != null, Lines = await BuildLineDtosAsync(lines), }; } @@ -339,6 +340,39 @@ public class OfferingSessionService : IOfferingSessionService await _db.SaveChangesAsync(); } + // ── Date-keyed proof (anonymous mobile page knows the date, not the id) ── + + // Attach a proof PDF to the date's session, creating a Draft session if none + // exists yet (the volunteer may snap the count sheet before any line is keyed). + public async Task SaveProofForDateAsync(DateOnly date, Stream content, string fileName) + { + var session = await _db.OfferingSessions.FirstOrDefaultAsync(s => s.SessionDate == date); + if (session is null) + { + session = new OfferingSession { SessionDate = date, Status = "Draft" }; + _db.OfferingSessions.Add(session); + try + { + await _db.SaveChangesAsync(); + } + catch (DbUpdateException) + { + // Lost the create race — another phone inserted today's session first. + _db.ChangeTracker.Clear(); + session = await _db.OfferingSessions.FirstAsync(s => s.SessionDate == date); + } + } + await SaveProofAsync(session.Id, content, fileName); + } + + public async Task<(Stream stream, string contentType)?> OpenProofForDateAsync(DateOnly date) + { + var session = await _db.OfferingSessions.AsNoTracking() + .FirstOrDefaultAsync(s => s.SessionDate == date); + if (session is null) return null; + return await OpenProofAsync(session.Id); + } + private static Giving MapLine(OfferingGivingLineRequest line, DateOnly sessionDate) => new() { MemberId = line.IsAnonymous ? null : line.MemberId, diff --git a/APP/src/app/features/giving/models/giving.model.ts b/APP/src/app/features/giving/models/giving.model.ts index b4e1a69..5411d53 100644 --- a/APP/src/app/features/giving/models/giving.model.ts +++ b/APP/src/app/features/giving/models/giving.model.ts @@ -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. */ diff --git a/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.html b/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.html index 2bbe8d8..201aa1e 100644 --- a/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.html +++ b/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.html @@ -3,7 +3,7 @@
River of Life · Offering

主日奉獻錄入 Sunday Offering Entry

-
{{ todayDate | date:'EEE, MMM d, y' }}
+
{{ sessionDate | date:'EEE, MMM d, y' }}
{{ connected ? '即時同步中 · Live' : '連線中… · Connecting' }} @@ -17,10 +17,18 @@ 今日筆數 · Lines
-
+
+ 今日總額 · Total + +
+ + @@ -134,4 +142,89 @@ + + + +
+

載入中… · Loading

+ + + +
+

各付款方式 · By method

+
    +
  • + {{ row.label }} + {{ row.total | currency }} +
  • +
  • 今日尚無紀錄 · No entries yet
  • +
+
+ + +
+

各支票 · Checks

+
    +
  • + # {{ check.checkNumber || '(無號碼 · no #)' }} + {{ check.amount | currency }} +
  • +
  • 今日無支票 · No checks
  • +
+
+ 支票合計 · Check total + {{ checkTotal | currency }} +
+
+ + +
+ 今日總計 · Total + {{ grandTotal | currency }} +
+
+
+ + + +
+ + + +
+

附上點算單/信封的照片或 PDF · Photo or PDF of the count sheet / envelopes

+ + + + + +
+ + +
+ +
    +
  • + {{ file.name }} + +
  • +
+ +

將與現有證明合併 · Will be merged with the existing proof

+
+ + + + +
diff --git a/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.scss b/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.scss index 08338d1..71ba320 100644 --- a/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.scss +++ b/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.scss @@ -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; diff --git a/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.ts b/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.ts index 955ad7e..e1a02d3 100644 --- a/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.ts +++ b/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.ts @@ -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; private readonly destroy$ = new Subject(); @@ -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 { + 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 { diff --git a/APP/src/app/features/giving/services/offering-entry-api.service.ts b/APP/src/app/features/giving/services/offering-entry-api.service.ts index d1bb7dc..b3d3772 100644 --- a/APP/src/app/features/giving/services/offering-entry-api.service.ts +++ b/APP/src/app/features/giving/services/offering-entry-api.service.ts @@ -45,4 +45,22 @@ export class OfferingEntryApiService { quickAddMember(request: QuickAddMemberRequest): Observable { return this.http.post(`${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 { + 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 { + const form = new FormData(); + form.append('date', date); + form.append('file', pdf, 'proof.pdf'); + return this.http.post(`${this.endpoint}/proof`, form); + } } diff --git a/deploy/nas/README.md b/deploy/nas/README.md index 4031046..0883f0f 100644 --- a/deploy/nas/README.md +++ b/deploy/nas/README.md @@ -1,10 +1,18 @@ # Deploy to Synology NAS (Container Manager) — LAN / HTTP -Target: run the ROLAC stack on a Synology NAS, reachable on the LAN at -`http://:8080`, with images built & pushed to the **local Gitea registry** -(`git.golife.love`, same NAS) and auto-deployed by a **Gitea act_runner** on push to `main`. +Target: run the ROLAC stack on a Synology **DS220+** (Celeron J4025 / 2GB RAM), +reachable on the LAN at `http://:8080`. Images are **built on the dev PC** +(the NAS is too weak to compile — Angular's build alone can need >2GB RAM), pushed to +the **Gitea registry** on the NAS, and the NAS only **pulls + restarts** the containers. ``` + push main + dev PC ───────────────► Gitea (NAS) + (runner: builder) │ triggers .gitea/workflows/ci-cd-nas.yml + test + build + push ─────────┤ + ▼ + NAS runner (label: nas) ── deploy only ──┐ + ▼ browser (LAN) -> http://:8080 │ nginx edge (container, 8080->80) ├── / -> app container (Angular static) @@ -12,12 +20,42 @@ browser (LAN) -> http://:8080 api ──> existing PostgreSQL @ 192.168.68.55:49154 (not containerized) ``` +Why this split: DS220+ can comfortably **run** these lightweight containers +(nginx + precompiled .NET + static files) but cannot **build** them. So building +(test, `dotnet publish`, `ng build`) runs on the dev PC; the NAS just pulls. + Differences vs the Azure plan: no TLS/certbot, edge on **8080** (DSM owns 80/443), reuse the LAN database, deploy via the on-NAS runner (no SSH). --- -## One-time NAS setup +## Two runners, two jobs + +| Job | `runs-on` | Where | Does | +|-----|-----------|-------|------| +| `build-push` | `windows` | **dev PC** | test → build both images → push to registry | +| `deploy` | `nas` | **NAS** | pull images → `docker compose up -d` → health check | + +> The dev-PC runner is registered with the label `windows:host` — `runs-on` matches +> the label NAME (`windows`); `:host` is the run mode (executes directly on the PC, +> not in a container, so it uses Docker Desktop + the installed .NET SDK). + +`deploy` has `needs: build-push`, so it only runs after the build succeeds. + +--- + +## One-time setup — DEV PC (the `windows` runner) ✅ already done + +The dev PC runs act_runner natively with the label `windows:host`, using its +installed Docker Desktop + .NET 8 SDK. The workflow's `build-push` job targets +`runs-on: windows`. Requirements (for reference): + +- Docker Desktop running, and `docker` on PATH. +- .NET 8 SDK on PATH (`dotnet test` runs on this machine). +- **Git for Windows** installed — the job uses `shell: bash` (Git Bash) for the + multi-line `docker build`/`push` steps. + +## One-time setup — NAS (the `nas` runner) 1. **Deploy dir + secrets** (via SSH or File Station): ```bash @@ -32,14 +70,11 @@ reuse the LAN database, deploy via the on-NAS runner (no SSH). docker login git.golife.love -u ChrisChen # paste the token ``` -3. **Install the act_runner on the NAS** (Container Manager → Registry → `gitea/act_runner`, +3. **Install act_runner on the NAS** (Container Manager → Registry → `gitea/act_runner`, or `docker run`). It must: - mount the host Docker socket: `-v /var/run/docker.sock:/var/run/docker.sock` - mount the deploy dir at the same path: `-v /volume1/docker/rolac:/volume1/docker/rolac` - - register against Gitea with the label **`nas`** (this is what `runs-on: nas` targets). - - Get a registration token in Gitea: Site/Repo → Settings → Actions → Runners → - "Create new runner". Example: + - register with the label **`nas`** (this is what `runs-on: nas` targets). ```bash docker run -d --restart unless-stopped --name rolac-runner \ -v /var/run/docker.sock:/var/run/docker.sock \ @@ -50,33 +85,42 @@ reuse the LAN database, deploy via the on-NAS runner (no SSH). gitea/act_runner:latest ``` -4. **Gitea repo secrets** (Settings → Actions → Secrets): - - `REGISTRY_USER` = `ChrisChen` - - `REGISTRY_TOKEN` = the package token from step 2 +## One-time setup — Gitea repo -5. **Enable Actions** for the repo if not already (Settings → Advanced → Actions). +1. **Secrets** (Settings → Actions → Secrets): + - `REGISTRY_USER` = `ChrisChen` + - `REGISTRY_TOKEN` = the package token (with `write:package`) +2. **Enable Actions** for the repo if not already (Settings → Advanced → Actions). --- ## Day-to-day -`git push` to `main` → `.gitea/workflows/ci-cd-nas.yml` runs: -**test → build both images → push to registry → sync compose/nginx → `docker compose up -d` → health check.** +`git push` to `main` → `.gitea/workflows/ci-cd-nas.yml`: + +1. **dev PC** (`builder`): `dotnet test` → build `rolac-api` + `rolac-app` + (tags `:latest` and `:`) → push to `git.golife.love/chrischen/*`. +2. **NAS** (`nas`): sync compose/nginx → `TAG= docker compose pull` → + `docker compose up -d` → `curl /api/health`. Open `http://:8080` and log in. +Deploy pins `TAG=` (not `latest`), so the NAS always runs exactly the image +this commit produced and `compose pull` forces a fresh fetch. + --- -## Manual deploy (no runner yet) +## Manual fallback (no runners yet) -From a machine with Docker + `docker login git.golife.love`: +From the dev PC (Docker Desktop + `docker login git.golife.love`): ```powershell -# repo root, build + push (uses deploy/build-push.ps1) +# repo root — build + push both images (tags :latest and :) .\deploy\build-push.ps1 ``` Then on the NAS: ```bash cd /volume1/docker/rolac +docker compose pull docker compose up -d curl -fsS http://localhost:8080/api/health ``` @@ -88,9 +132,10 @@ curl -fsS http://localhost:8080/api/health - **First boot runs DB migrations** against `192.168.68.55` automatically (`Program.cs` calls `MigrateAsync()` + seed). Make sure the DB user has DDL rights; back up before the first run. -- **Bind-mount paths**: the runner deploys by running compose at `/volume1/docker/rolac` - on the host (socket-mounted), so `./nginx/conf.d` and `./data` resolve to real NAS - paths — that's why the runner mounts that dir at the *same* path. +- **Bind-mount paths**: the NAS runner runs compose at `/volume1/docker/rolac` on the + host (socket-mounted), so `./nginx/conf.d` and `./data` resolve to real NAS paths — + that's why the runner mounts that dir at the *same* path. - **Uploaded files** persist under `/volume1/docker/rolac/data/api-storage`. +- **DS220+ runs, never builds.** Keep all compilation on the dev PC / a beefier runner. - To expose beyond the LAN later, put it behind DSM's reverse proxy (Application Portal) or switch to the Azure `deploy/` files with certbot. diff --git a/docs/superpowers/specs/2026-06-20-offering-totals-dialog-design.md b/docs/superpowers/specs/2026-06-20-offering-totals-dialog-design.md new file mode 100644 index 0000000..9228fca --- /dev/null +++ b/docs/superpowers/specs/2026-06-20-offering-totals-dialog-design.md @@ -0,0 +1,57 @@ +# 今日總計 Dialog — Design + +**Date:** 2026-06-20 +**Component:** `APP/src/app/features/giving/pages/offering-entry-mobile-page` +**Scope:** Frontend only. No backend or DTO changes (the bootstrap summary already returns every line with `paymentMethod`, `checkNumber`, `amount`). + +## Goal + +When the volunteer taps the **今日總額 · Total** tally item on the mobile offering-entry +page, open a dialog that breaks down today's giving by **payment method** and lists +**each check's number and amount**. + +> Note: the original request also mentioned a per-category total block. The user +> explicitly chose to omit it; this dialog shows payment-method + check breakdowns only. + +## Trigger + +Make the `oe__tally-item` wrapping `systemTotal` clickable → `openTotals()`. Add a +pointer cursor and a small affordance hint so it reads as tappable. + +## Data + +On open, refetch `api.bootstrap(today)` and read `summary.lines` — a fresh +cross-phone snapshot rather than the page-load list (which only stays live for +`systemTotal` / `lineCount`, not the lines array). Show a "載入中… · Loading" state +while in flight; on error, show a toast and close. + +## Compute (client-side, from the refetched lines) + +- **methodSubtotals** — group lines by `paymentMethod`, sum `amount`. Render in fixed + order Cash → Check → Zelle → PayPal → Other, skipping methods with no entries. + Labels from `PAYMENT_METHOD_OPTIONS` (bilingual). +- **checkLines** — lines where `paymentMethod === 'Check'`: `{ checkNumber, amount }`. + `checkTotal` = sum of their amounts. +- **grandTotal** — sum of all refetched line amounts (the snapshot's system total). + +## Dialog (new `kendo-dialog *ngIf="showTotals"`, styled like quick-add/paper-proof) + +- Title: **今日總計 · Today's Totals** +- Loading state: "載入中… · Loading" while the refetch is pending. +- Section A — **各付款方式 · By method**: rows of `label … amount` (currency pipe). +- Section B — **各支票 · Checks**: rows of `# checkNumber … amount`, with a **支票合計** + footer. Empty → "今日無支票 · No checks". Blank number → "(無號碼 · no #)". +- Footer: **今日總計 · Total** = grandTotal. +- Close button. + +## Files touched + +- `*.component.html` — clickable trigger + new dialog. +- `*.component.ts` — `showTotals` / `totalsLoading` / `totalsLines` state, `openTotals()`, + `closeTotals()`, and `methodSubtotals` / `checkLines` / `checkTotal` / `grandTotal` getters. +- `*.component.scss` — totals list styles (reuse `oe__qa` body). + +## Testing + +Manual: enter a mix of cash + check + Zelle lines, tap 今日總額, confirm method +subtotals and check list match; confirm empty-checks and blank-check-number cases.