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
+36 -16
View File
@@ -4,27 +4,28 @@ on:
branches: [main] branches: [main]
jobs: jobs:
# Runs in a normal container on the runner — only needs the .NET SDK. # Runs on the DEV PC runner (label `builder`): Docker Desktop + .NET SDK.
test: # DS220+ (Celeron J4025 / 2GB RAM) cannot build these images, so all the heavy
runs-on: ubuntu-latest # work (test, dotnet publish, ng build) happens here, then images are pushed
steps: # to the Gitea registry on the NAS.
- uses: actions/checkout@v4 build-push:
- uses: actions/setup-dotnet@v4 # Label is registered on the dev PC as `windows:host`; runs-on matches the
with: { dotnet-version: '8.0.x' } # label NAME (before the colon). `:host` means it runs directly on the PC,
- run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release # using its installed Docker Desktop + .NET SDK (no container).
runs-on: windows
# Runs on the NAS runner (label `nas`) which has the host Docker socket mounted defaults:
# and /volume1/docker/rolac bind-mounted at the same path. Builds, pushes to the run:
# local Gitea registry, then (re)starts the stack. # Git Bash (bundled with Git for Windows) — needed for `$REGISTRY` and
deploy: # the heredoc-style multi-line steps below.
needs: test shell: bash
runs-on: nas
env: env:
REGISTRY: git.golife.love/chrischen REGISTRY: git.golife.love/chrischen
DEPLOY_DIR: /volume1/docker/rolac
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Test API
run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release
- name: Registry login - name: Registry login
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.golife.love -u "${{ secrets.REGISTRY_USER }}" --password-stdin 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-api"
docker push --all-tags "$REGISTRY/rolac-app" 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 - name: Sync compose + nginx to deploy dir
run: | run: |
mkdir -p "$DEPLOY_DIR/nginx/conf.d" "$DEPLOY_DIR/data/api-storage" mkdir -p "$DEPLOY_DIR/nginx/conf.d" "$DEPLOY_DIR/data/api-storage"
@@ -47,6 +65,8 @@ jobs:
- name: Deploy - name: Deploy
run: | run: |
cd "$DEPLOY_DIR" cd "$DEPLOY_DIR"
export TAG=${{ github.sha }}
docker compose pull
docker compose up -d docker compose up -d
sleep 5 sleep 5
curl -fsS http://localhost:8080/api/health curl -fsS http://localhost:8080/api/health
@@ -85,4 +85,28 @@ public class OfferingEntryController : ControllerBase
await _hub.Clients.Group(result.SessionDate).SendAsync("LineAdded", result); await _hub.Clients.Group(result.SessionDate).SendAsync("LineAdded", result);
return Ok(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<IActionResult> 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<IActionResult> 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();
}
} }
@@ -10,5 +10,6 @@ public class OfferingEntrySummaryDto
public string? Status { get; set; } // null when no session yet public string? Status { get; set; } // null when no session yet
public decimal SystemTotal { get; set; } public decimal SystemTotal { get; set; }
public int LineCount { get; set; } public int LineCount { get; set; }
public bool HasProof { get; set; } // a merged paper-proof PDF is attached to this session
public List<OfferingGivingLineDto> Lines { get; set; } = []; public List<OfferingGivingLineDto> Lines { get; set; } = [];
} }
@@ -21,4 +21,8 @@ public interface IOfferingSessionService
Task SaveProofAsync(int id, Stream content, string fileName); Task SaveProofAsync(int id, Stream content, string fileName);
Task<(Stream stream, string contentType)?> OpenProofAsync(int id); Task<(Stream stream, string contentType)?> OpenProofAsync(int id);
Task DeleteProofAsync(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);
} }
@@ -183,6 +183,7 @@ public class OfferingSessionService : IOfferingSessionService
Status = session.Status, Status = session.Status,
SystemTotal = session.SystemTotal, SystemTotal = session.SystemTotal,
LineCount = lines.Count, LineCount = lines.Count,
HasProof = session.ProofPdfPath != null,
Lines = await BuildLineDtosAsync(lines), Lines = await BuildLineDtosAsync(lines),
}; };
} }
@@ -339,6 +340,39 @@ public class OfferingSessionService : IOfferingSessionService
await _db.SaveChangesAsync(); 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() private static Giving MapLine(OfferingGivingLineRequest line, DateOnly sessionDate) => new()
{ {
MemberId = line.IsAnonymous ? null : line.MemberId, MemberId = line.IsAnonymous ? null : line.MemberId,
@@ -137,6 +137,7 @@ export interface OfferingEntrySummaryDto {
status: SessionStatus | null; status: SessionStatus | null;
systemTotal: number; systemTotal: number;
lineCount: number; lineCount: number;
hasProof: boolean; // a merged paper-proof PDF is attached to this session
lines: OfferingGivingLineDto[]; lines: OfferingGivingLineDto[];
} }
/** One-shot payload that seeds the mobile page. */ /** One-shot payload that seeds the mobile page. */
@@ -3,7 +3,7 @@
<header class="oe__head"> <header class="oe__head">
<span class="oe__eyebrow">River of Life · Offering</span> <span class="oe__eyebrow">River of Life · Offering</span>
<h1 class="oe__title">主日奉獻錄入 <span>Sunday Offering Entry</span></h1> <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"> <div class="oe__status" [class.is-on]="connected">
<span class="oe__dot"></span> <span class="oe__dot"></span>
{{ connected ? '即時同步中 · Live' : '連線中… · Connecting' }} {{ connected ? '即時同步中 · Live' : '連線中… · Connecting' }}
@@ -17,10 +17,18 @@
<span class="oe__tally-label">今日筆數 · Lines</span> <span class="oe__tally-label">今日筆數 · Lines</span>
</div> </div>
<div class="oe__tally-divider"></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-num">{{ systemTotal | currency }}</span>
<span class="oe__tally-label">今日總額 · Total</span> <span class="oe__tally-label">今日總額 · Total <span class="oe__tally-hint"></span></span>
</div> </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> </div>
<!-- Entry form --> <!-- Entry form -->
@@ -134,4 +142,89 @@
</button> </button>
</kendo-dialog-actions> </kendo-dialog-actions>
</kendo-dialog> </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> </div>
@@ -106,6 +106,38 @@
gap: 0.15rem; 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 { .oe__tally-num {
font-size: 1.6rem; font-size: 1.6rem;
font-weight: 800; font-weight: 800;
@@ -185,6 +217,134 @@
padding: 0.25rem 0.1rem; 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 ──────────────────────────────────────────────────── */ /* ── Sticky submit bar ──────────────────────────────────────────────────── */
.oe__submit { .oe__submit {
position: fixed; position: fixed;
@@ -1,25 +1,30 @@
import { Component, OnDestroy, OnInit } from '@angular/core'; import { Component, OnDestroy, 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 { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil, firstValueFrom } from 'rxjs';
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';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns'; import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { DialogsModule } from '@progress/kendo-angular-dialog'; import { DialogsModule } from '@progress/kendo-angular-dialog';
import { buildProofPdf } from '../../services/proof-pdf.builder';
import { OfferingEntryApiService } from '../../services/offering-entry-api.service'; import { OfferingEntryApiService } from '../../services/offering-entry-api.service';
import { OfferingEntrySignalrService } from '../../services/offering-entry-signalr.service'; import { OfferingEntrySignalrService } from '../../services/offering-entry-signalr.service';
import { import {
GivingCategoryDto, OfferingGivingLineRequest, MemberTypeaheadDto, QuickAddMemberRequest, GivingCategoryDto, OfferingGivingLineRequest, MemberTypeaheadDto, QuickAddMemberRequest,
OfferingGivingLineDto, PaymentMethod,
} from '../../models/giving.model'; } from '../../models/giving.model';
import { PAYMENT_METHOD_OPTIONS } from '../../../../shared/i18n/option-lists'; import { PAYMENT_METHOD_OPTIONS } from '../../../../shared/i18n/option-lists';
interface MemberOption { id: number; displayName: string; } 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 * Portrait, phone-friendly page where a volunteer records one Sunday offering
* at a time. Fields mirror the desktop "Add Giving" form. Each submit persists * 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 * a single line to the current week's Sunday session (find-or-create, server-
* resets blank for the next entry. No login required. * side) and the form resets blank for the next entry. No login required.
*/ */
@Component({ @Component({
selector: 'app-offering-entry-mobile-page', selector: 'app-offering-entry-mobile-page',
@@ -29,9 +34,11 @@ interface MemberOption { id: number; displayName: string; }
styleUrls: ['./offering-entry-mobile-page.component.scss'], styleUrls: ['./offering-entry-mobile-page.component.scss'],
}) })
export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy { export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy {
/** Auto-selected current day (decision: page defaults to today's session). */ // The Sunday whose offering this session records. The week runs Sunday→Saturday,
readonly todayDate = new Date(); // so a Sunday's gifts can still be keyed any day through the following Saturday:
private readonly today = this.toIso(this.todayDate); // 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[] = []; categories: GivingCategoryDto[] = [];
readonly paymentMethods = PAYMENT_METHOD_OPTIONS; readonly paymentMethods = PAYMENT_METHOD_OPTIONS;
@@ -56,6 +63,20 @@ export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy {
quickAddSaving = false; quickAddSaving = false;
quickAdd: QuickAddMemberRequest = this.blankQuickAdd(); 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 toastTimer?: ReturnType<typeof setTimeout>;
private readonly destroy$ = new Subject<void>(); private readonly destroy$ = new Subject<void>();
@@ -65,17 +86,18 @@ export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy {
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
this.api.bootstrap(this.today).subscribe(dto => { this.api.bootstrap(this.session).subscribe(dto => {
this.categories = dto.categories; this.categories = dto.categories;
this.entry.givingCategoryId = dto.categories[0]?.id ?? 0; this.entry.givingCategoryId = dto.categories[0]?.id ?? 0;
this.lineCount = dto.summary.lineCount; this.lineCount = dto.summary.lineCount;
this.systemTotal = dto.summary.systemTotal; this.systemTotal = dto.summary.systemTotal;
this.hasProof = dto.summary.hasProof;
}); });
this.signalr.lineAdded$ this.signalr.lineAdded$
.pipe(takeUntil(this.destroy$)) .pipe(takeUntil(this.destroy$))
.subscribe(evt => { .subscribe(evt => {
if (evt.sessionDate !== this.today) { if (evt.sessionDate !== this.session) {
return; return;
} }
this.lineCount = evt.lineCount; this.lineCount = evt.lineCount;
@@ -85,7 +107,7 @@ export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy {
this.signalr.start() this.signalr.start()
.then(() => { .then(() => {
this.connected = true; this.connected = true;
return this.signalr.joinDate(this.today); return this.signalr.joinDate(this.session);
}) })
.catch(() => (this.connected = false)); .catch(() => (this.connected = false));
} }
@@ -96,7 +118,7 @@ export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy {
if (this.toastTimer) { if (this.toastTimer) {
clearTimeout(this.toastTimer); clearTimeout(this.toastTimer);
} }
this.signalr.leaveDate(this.today) this.signalr.leaveDate(this.session)
.catch(() => undefined) .catch(() => undefined)
.then(() => this.signalr.stop()); .then(() => this.signalr.stop());
} }
@@ -105,6 +127,10 @@ export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy {
if (this.submitting || this.entry.amount <= 0) { if (this.submitting || this.entry.amount <= 0) {
return false; 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) { if (this.entry.paymentMethod === 'Check' && !this.entry.checkNumber) {
return false; return false;
} }
@@ -212,12 +238,123 @@ export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy {
return trimmed ? trimmed : null; 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 { submit(): void {
if (!this.canSubmit) { if (!this.canSubmit) {
return; return;
} }
this.submitting = true; this.submitting = true;
this.api.appendLine(this.today, this.normalizedLine()).subscribe({ this.api.appendLine(this.session, this.normalizedLine()).subscribe({
next: res => { next: res => {
this.submitting = false; this.submitting = false;
// Server is the source of truth; update now in case our own broadcast // 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); 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 // Format using LOCAL date components — NOT toISOString(), which converts to UTC
// and can roll the date forward a day for behind-UTC users. // and can roll the date forward a day for behind-UTC users.
private toIso(d: Date): string { private toIso(d: Date): string {
@@ -45,4 +45,22 @@ export class OfferingEntryApiService {
quickAddMember(request: QuickAddMemberRequest): Observable<MemberTypeaheadDto> { quickAddMember(request: QuickAddMemberRequest): Observable<MemberTypeaheadDto> {
return this.http.post<MemberTypeaheadDto>(`${this.endpoint}/members`, request); 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);
}
} }
+66 -21
View File
@@ -1,10 +1,18 @@
# Deploy to Synology NAS (Container Manager) — LAN / HTTP # Deploy to Synology NAS (Container Manager) — LAN / HTTP
Target: run the ROLAC stack on a Synology NAS, reachable on the LAN at Target: run the ROLAC stack on a Synology **DS220+** (Celeron J4025 / 2GB RAM),
`http://<nas-ip>:8080`, with images built & pushed to the **local Gitea registry** reachable on the LAN at `http://<nas-ip>:8080`. Images are **built on the dev PC**
(`git.golife.love`, same NAS) and auto-deployed by a **Gitea act_runner** on push to `main`. (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://<nas-ip>:8080 browser (LAN) -> http://<nas-ip>:8080
│ nginx edge (container, 8080->80) │ nginx edge (container, 8080->80)
├── / -> app container (Angular static) ├── / -> app container (Angular static)
@@ -12,12 +20,42 @@ browser (LAN) -> http://<nas-ip>:8080
api ──> existing PostgreSQL @ 192.168.68.55:49154 (not containerized) 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), 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). 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): 1. **Deploy dir + secrets** (via SSH or File Station):
```bash ```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 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: or `docker run`). It must:
- mount the host Docker socket: `-v /var/run/docker.sock:/var/run/docker.sock` - 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` - 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). - register 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:
```bash ```bash
docker run -d --restart unless-stopped --name rolac-runner \ docker run -d --restart unless-stopped --name rolac-runner \
-v /var/run/docker.sock:/var/run/docker.sock \ -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 gitea/act_runner:latest
``` ```
4. **Gitea repo secrets** (Settings → Actions → Secrets): ## One-time setup — Gitea repo
- `REGISTRY_USER` = `ChrisChen`
- `REGISTRY_TOKEN` = the package token from step 2
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 ## Day-to-day
`git push` to `main` → `.gitea/workflows/ci-cd-nas.yml` runs: `git push` to `main` → `.gitea/workflows/ci-cd-nas.yml`:
**test → build both images → push to registry → sync compose/nginx → `docker compose up -d` → health check.**
1. **dev PC** (`builder`): `dotnet test` → build `rolac-api` + `rolac-app`
(tags `:latest` and `:<git-sha>`) → push to `git.golife.love/chrischen/*`.
2. **NAS** (`nas`): sync compose/nginx → `TAG=<git-sha> docker compose pull` →
`docker compose up -d` → `curl /api/health`.
Open `http://<nas-ip>:8080` and log in. Open `http://<nas-ip>:8080` and log in.
Deploy pins `TAG=<git-sha>` (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 ```powershell
# repo root, build + push (uses deploy/build-push.ps1) # repo root build + push both images (tags :latest and :<git-sha>)
.\deploy\build-push.ps1 .\deploy\build-push.ps1
``` ```
Then on the NAS: Then on the NAS:
```bash ```bash
cd /volume1/docker/rolac cd /volume1/docker/rolac
docker compose pull
docker compose up -d docker compose up -d
curl -fsS http://localhost:8080/api/health 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 - **First boot runs DB migrations** against `192.168.68.55` automatically
(`Program.cs` calls `MigrateAsync()` + seed). Make sure the DB user has DDL rights; (`Program.cs` calls `MigrateAsync()` + seed). Make sure the DB user has DDL rights;
back up before the first run. back up before the first run.
- **Bind-mount paths**: the runner deploys by running compose at `/volume1/docker/rolac` - **Bind-mount paths**: the NAS runner runs compose at `/volume1/docker/rolac` on the
on the host (socket-mounted), so `./nginx/conf.d` and `./data` resolve to real NAS host (socket-mounted), so `./nginx/conf.d` and `./data` resolve to real NAS paths —
paths — that's why the runner mounts that dir at the *same* path. that's why the runner mounts that dir at the *same* path.
- **Uploaded files** persist under `/volume1/docker/rolac/data/api-storage`. - **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) - To expose beyond the LAN later, put it behind DSM's reverse proxy (Application Portal)
or switch to the Azure `deploy/` files with certbot. or switch to the Azure `deploy/` files with certbot.
@@ -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.