Update
This commit is contained in:
@@ -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. */
|
||||||
|
|||||||
+97
-4
@@ -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>
|
||||||
|
|||||||
+160
@@ -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;
|
||||||
|
|||||||
+157
-11
@@ -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
@@ -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.
|
||||||
Reference in New Issue
Block a user