wip
This commit is contained in:
@@ -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. */
|
||||
|
||||
+59
-1
@@ -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>
|
||||
|
||||
+16
@@ -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 {
|
||||
|
||||
+81
-6
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user