This commit is contained in:
Chris Chen
2026-06-20 15:13:23 -07:00
parent b6c50a38aa
commit f55807fa7d
32 changed files with 866 additions and 18 deletions
@@ -101,6 +101,7 @@ export interface OfferingSessionDto {
systemTotal: number;
difference: number;
notes: string | null;
hasProof: boolean;
givings: OfferingGivingLineDto[];
}
export interface OfferingSessionListItemDto {
@@ -112,6 +113,7 @@ export interface OfferingSessionListItemDto {
systemTotal: number;
difference: number;
lineCount: number;
hasProof: boolean;
}
/** A row held in the client-side batch buffer before submit. */
@@ -50,6 +50,11 @@
</ng-template>
</kendo-grid-column>
<kendo-grid-column field="lineCount" title="Lines" [width]="80"></kendo-grid-column>
<kendo-grid-column title="Proof" [width]="70">
<ng-template kendoGridCellTemplate let-s>
<span *ngIf="s.hasProof" title="Paper proof attached · 已附證明">📎</span>
</ng-template>
</kendo-grid-column>
<kendo-grid-column field="systemTotal" title="System" [width]="120" format="c2"></kendo-grid-column>
<kendo-grid-column field="difference" title="Diff" [width]="110" format="c2"></kendo-grid-column>
<kendo-grid-column title="" [width]="110">
@@ -152,6 +157,35 @@
</section>
</div>
<!-- Paper proof -->
<section class="card rise" style="--d: 200ms">
<div class="card__head">
<h2 class="card__title">Paper Proof</h2>
<span class="card__zh">紙本證明</span>
</div>
<p class="proof-hint">
Attach photos or PDFs of the count sheets / envelopes. Images are compressed and all files
are merged into a single PDF when you submit.
<br><span>附上點算單/信封的照片或 PDF。圖片會自動壓縮,送出時合併為單一 PDF。</span>
</p>
<input type="file" multiple accept="image/*,application/pdf"
class="proof-input" (change)="onProofFilesSelected($event)" />
<ul *ngIf="pendingProofFiles.length" class="proof-list">
<li *ngFor="let f of pendingProofFiles; let i = index">
<span class="proof-name">{{ f.name }}</span>
<button kendoButton fillMode="flat" size="small" (click)="removeProofFile(i)">×</button>
</li>
</ul>
<p *ngIf="editingSessionId != null && pendingProofFiles.length" class="proof-warn">
Selecting files here replaces any existing proof for this session.
<br><span>於此選擇檔案會取代此 session 既有的證明。</span>
</p>
</section>
<!-- Reconcile & submit -->
<section class="card rise" style="--d: 240ms">
<div class="card__head">
@@ -230,8 +264,32 @@
</kendo-grid>
</section>
<!-- Paper proof (view) -->
<section class="card rise" style="--d: 200ms">
<div class="card__head">
<h2 class="card__title">Paper Proof</h2>
<span class="card__zh">紙本證明</span>
</div>
<input #proofInput type="file" multiple accept="image/*,application/pdf" hidden
(change)="replaceProof($event)" />
<div class="flex flex-wrap gap-2 items-center">
<ng-container *ngIf="v.hasProof; else noProof">
<button kendoButton themeColor="primary" (click)="openProof(v.id)" [disabled]="proofBusy">View proof PDF / 檢視證明</button>
<button kendoButton (click)="proofInput.click()" [disabled]="proofBusy">Replace / 更換</button>
<button kendoButton fillMode="flat" (click)="removeProof()" [disabled]="proofBusy">Remove / 移除</button>
</ng-container>
<ng-template #noProof>
<span class="proof-none">No proof attached · 尚未附上證明</span>
<button kendoButton themeColor="primary" (click)="proofInput.click()" [disabled]="proofBusy">Add proof / 新增證明</button>
</ng-template>
<span *ngIf="proofBusy" class="proof-busy">Working… · 處理中…</span>
</div>
</section>
<!-- Session notes -->
<section *ngIf="v.notes" class="card rise" style="--d: 240ms">
<section *ngIf="v.notes" class="card rise" style="--d: 280ms">
<div class="card__head">
<h2 class="card__title">Notes</h2>
<span class="card__zh">備註</span>
@@ -182,6 +182,22 @@
.lines-footer { margin-top: 10px; color: var(--ink-soft); font-weight: 600; font-variant-numeric: tabular-nums; }
.anon-chip { padding: 0.25rem 0.6rem; background: var(--kendo-color-base-subtle, #e6eaef); border-radius: 999px; font-size: 12px; font-weight: 600; }
.notes-text { margin: 0; color: var(--ink); line-height: 1.5; white-space: pre-wrap; }
/* ---- Paper proof ---- */
.proof-hint { margin: 0 0 12px; font-size: 13px; line-height: 1.5; color: var(--ink-soft); span { color: #9aa7b6; } }
.proof-input { display: block; font-size: 14px; }
.proof-list {
list-style: none; margin: 12px 0 0; padding: 0; display: flex; flex-direction: column; gap: 4px;
li {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 4px 10px; background: var(--kendo-color-base-subtle, #f1f4f8);
border: 1px solid var(--line); border-radius: 8px; font-size: 13px;
}
}
.proof-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.proof-warn { margin: 10px 0 0; font-size: 12px; line-height: 1.5; color: #b45309; span { color: #d97706; } }
.proof-none { font-size: 14px; color: var(--ink-soft); }
.proof-busy { font-size: 13px; color: var(--ink-soft); }
.dialog-text { margin: 0; line-height: 1.55; span { color: var(--ink-soft); font-size: 13px; } }
.empty {
@@ -1,7 +1,8 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Observable } from 'rxjs';
import { Observable, from, of, map, switchMap } from 'rxjs';
import { buildProofPdf } from '../../services/proof-pdf.builder';
import { GridModule } from '@progress/kendo-angular-grid';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
@@ -56,6 +57,10 @@ export class OfferingSessionPageComponent implements OnInit {
showQuickAdd = false;
submitting = false;
// Paper-proof attachments staged client-side; merged into one PDF on submit.
pendingProofFiles: File[] = [];
proofBusy = false;
sessions: OfferingSessionListItemDto[] = [];
editingSessionId: number | null = null;
@@ -99,6 +104,7 @@ export class OfferingSessionPageComponent implements OnInit {
this.viewSession = null;
this.buffer = [];
this.cashTotal = 0; this.checkTotal = 0; this.notes = null;
this.pendingProofFiles = [];
this.resetEntry();
this.mode = 'workspace';
}
@@ -167,6 +173,7 @@ export class OfferingSessionPageComponent implements OnInit {
this.cashTotal = dto.cashTotal;
this.checkTotal = dto.checkTotal;
this.notes = dto.notes;
this.pendingProofFiles = [];
this.buffer = dto.givings.map(g => ({
memberId: g.memberId, givingCategoryId: g.givingCategoryId, amount: g.amount,
paymentMethod: g.paymentMethod, checkNumber: g.checkNumber,
@@ -254,13 +261,21 @@ export class OfferingSessionPageComponent implements OnInit {
notes: l.notes,
})),
};
const obs: Observable<unknown> = this.editingSessionId != null
? this.api.replace(this.editingSessionId, req)
: this.api.create(req);
obs.subscribe({
const isEdit = this.editingSessionId != null;
// Save the session first, then resolve to its id so the proof PDF can be attached.
const savedId$: Observable<number> = isEdit
? this.api.replace(this.editingSessionId!, req).pipe(map(() => this.editingSessionId!))
: this.api.create(req).pipe(map(r => r.id));
savedId$.pipe(
switchMap(id => this.pendingProofFiles.length === 0
? of(void 0)
: from(buildProofPdf(this.pendingProofFiles)).pipe(
switchMap(({ blob }) => this.api.uploadProof(id, blob)))),
).subscribe({
next: () => {
this.submitting = false;
alert(this.editingSessionId != null ? 'Offering session updated.' : 'Offering session submitted.');
alert(isEdit ? 'Offering session updated.' : 'Offering session submitted.');
this.resetSession();
this.mode = 'landing';
this.loadSessions();
@@ -272,6 +287,65 @@ export class OfferingSessionPageComponent implements OnInit {
});
}
// ── Paper proof ───────────────────────────────────────────────────────────
/** Stage selected attachment files (appends, so multiple picks accumulate). */
onProofFilesSelected(event: Event): void {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
if (files.length) this.pendingProofFiles = [...this.pendingProofFiles, ...files];
input.value = ''; // allow re-selecting the same file
}
removeProofFile(i: number): void {
this.pendingProofFiles = this.pendingProofFiles.filter((_, idx) => idx !== i);
}
/** Open the stored proof PDF in a new tab (authenticated blob fetch). */
openProof(id: number): void {
this.api.downloadProof(id).subscribe(blob => {
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
setTimeout(() => URL.revokeObjectURL(url), 60_000);
});
}
/** Replace the stored proof from the read-only view (rebuild + upload + refresh). */
replaceProof(event: Event): void {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
input.value = '';
if (!files.length || !this.viewSession) return;
const id = this.viewSession.id;
this.proofBusy = true;
from(buildProofPdf(files)).pipe(
switchMap(({ blob }) => this.api.uploadProof(id, blob)),
switchMap(() => this.api.getById(id)),
).subscribe({
next: dto => { this.viewSession = dto; this.proofBusy = false; },
error: (err: { error?: { message?: string } }) => {
this.proofBusy = false;
alert(err?.error?.message ?? 'Proof upload failed.');
},
});
}
removeProof(): void {
if (!this.viewSession) return;
if (!confirm('Remove the paper proof for this session? / 移除此 session 的紙本證明?')) return;
const id = this.viewSession.id;
this.proofBusy = true;
this.api.deleteProof(id).pipe(
switchMap(() => this.api.getById(id)),
).subscribe({
next: dto => { this.viewSession = dto; this.proofBusy = false; },
error: (err: { error?: { message?: string } }) => {
this.proofBusy = false;
alert(err?.error?.message ?? 'Remove failed.');
},
});
}
/** Clear the whole working session back to a fresh state (today, empty buffer). */
private resetSession(): void {
this.editingSessionId = null;
@@ -279,6 +353,7 @@ export class OfferingSessionPageComponent implements OnInit {
this.viewSession = null;
this.buffer = [];
this.cashTotal = 0; this.checkTotal = 0; this.notes = null;
this.pendingProofFiles = [];
this.sessionDate = new Date();
this.resetEntry();
this.checkDate();
@@ -36,4 +36,23 @@ export class OfferingSessionApiService {
replace(id: number, request: CreateOfferingSessionRequest): Observable<void> {
return this.http.put<void>(`${this.endpoint}/${id}`, request);
}
/** Upload the merged paper-proof PDF (built client-side) for a session. */
uploadProof(id: number, pdf: Blob): Observable<void> {
const form = new FormData();
form.append('file', pdf, 'proof.pdf');
return this.http.post<void>(`${this.endpoint}/${id}/proof`, form);
}
/**
* Fetch the proof PDF as a Blob via HttpClient so the auth interceptor attaches the JWT.
* A bare window.open on the API URL would be an unauthenticated navigation → 401.
*/
downloadProof(id: number): Observable<Blob> {
return this.http.get(`${this.endpoint}/${id}/proof`, { responseType: 'blob' });
}
deleteProof(id: number): Observable<void> {
return this.http.delete<void>(`${this.endpoint}/${id}/proof`);
}
}
@@ -0,0 +1,77 @@
import imageCompression from 'browser-image-compression';
import { PDFDocument } from 'pdf-lib';
/**
* Builds a single merged PDF from a list of paper-proof attachments (mostly phone photos).
*
* - Images are compressed in the browser (resized + re-encoded as JPEG) so the upload stays
* small — phone photos are 12MP+ and would otherwise be huge. One image per page.
* - Files that are already PDFs are merged through page-by-page, unchanged.
* - Other file types are skipped (and reported in `skipped`).
*
* All work happens client-side; the resulting Blob is uploaded as the session's proof.pdf.
*/
// Tunables — adjust if proofs look too soft (raise) or files are too large (lower).
const MAX_EDGE_PX = 2000; // longest image edge after compression
const JPEG_QUALITY = 0.72; // 0..1
const MAX_SIZE_MB = 1; // target ceiling per image
// US Letter, in PDF points (72pt = 1in).
const PAGE_W = 612;
const PAGE_H = 792;
const MARGIN = 36;
export interface ProofBuildResult {
blob: Blob;
/** Names of files that were skipped because the type is unsupported. */
skipped: string[];
}
export async function buildProofPdf(files: File[]): Promise<ProofBuildResult> {
const doc = await PDFDocument.create();
const skipped: string[] = [];
for (const file of files) {
if (file.type === 'application/pdf') {
const bytes = new Uint8Array(await file.arrayBuffer());
const src = await PDFDocument.load(bytes);
const pages = await doc.copyPages(src, src.getPageIndices());
pages.forEach(p => doc.addPage(p));
continue;
}
if (file.type.startsWith('image/')) {
const compressed = await imageCompression(file, {
maxWidthOrHeight: MAX_EDGE_PX,
maxSizeMB: MAX_SIZE_MB,
initialQuality: JPEG_QUALITY,
fileType: 'image/jpeg',
useWebWorker: true,
});
const jpgBytes = new Uint8Array(await compressed.arrayBuffer());
const img = await doc.embedJpg(jpgBytes);
const page = doc.addPage([PAGE_W, PAGE_H]);
const maxW = PAGE_W - MARGIN * 2;
const maxH = PAGE_H - MARGIN * 2;
const scale = Math.min(maxW / img.width, maxH / img.height, 1);
const w = img.width * scale;
const h = img.height * scale;
page.drawImage(img, {
x: (PAGE_W - w) / 2,
y: (PAGE_H - h) / 2,
width: w,
height: h,
});
continue;
}
skipped.push(file.name);
}
const bytes = await doc.save();
// Copy into a fresh ArrayBuffer-backed view so the Blob type is unambiguous.
const blob = new Blob([bytes.slice()], { type: 'application/pdf' });
return { blob, skipped };
}
+1 -1
View File
@@ -1,4 +1,4 @@
export const environment = {
production: true,
apiUrl: 'https://your-production-api.com/api'
apiUrl: '/api'
};