fix(expense): authenticated receipt download + correct delete confirm

The receipt <a href target=_blank> was an unauthenticated browser navigation
that the API's [Authorize] rejects with 401. Replace with a HttpClient blob
download (downloadReceipt) so the auth interceptor attaches the JWT, opened
via an object URL. Also fix the delete button: confirm() must run inside the
component method (matching givings-page), not as a template expression where
confirm is not a component member.
This commit is contained in:
Chris Chen
2026-05-29 18:51:04 -07:00
parent 18b9707e44
commit 4704d33b4a
3 changed files with 23 additions and 6 deletions
@@ -23,10 +23,10 @@
<ng-template kendoGridCellTemplate let-dataItem> <ng-template kendoGridCellTemplate let-dataItem>
<ng-container *ngIf="canEdit(dataItem)"> <ng-container *ngIf="canEdit(dataItem)">
<button kendoButton themeColor="primary" fillMode="flat" (click)="submit(dataItem)">Submit</button> <button kendoButton themeColor="primary" fillMode="flat" (click)="submit(dataItem)">Submit</button>
<button kendoButton fillMode="flat" <button kendoButton fillMode="flat" (click)="remove(dataItem)">Delete</button>
(click)="confirm('Delete this reimbursement?') && remove(dataItem)">Delete</button>
</ng-container> </ng-container>
<a *ngIf="dataItem.hasReceipt" [href]="receiptUrl(dataItem.id)" target="_blank" class="receipt-link">Receipt</a> <button *ngIf="dataItem.hasReceipt" kendoButton fillMode="flat"
(click)="openReceipt(dataItem.id)" class="receipt-link">Receipt</button>
</ng-template> </ng-template>
</kendo-grid-column> </kendo-grid-column>
</kendo-grid> </kendo-grid>
@@ -42,11 +42,21 @@ export class MyReimbursementsPageComponent implements OnInit {
} }
submit(row: ExpenseListItemDto): void { this.api.submit(row.id).subscribe(() => this.load()); } submit(row: ExpenseListItemDto): void { this.api.submit(row.id).subscribe(() => this.load()); }
remove(row: ExpenseListItemDto): void { this.api.delete(row.id).subscribe(() => this.load()); } remove(row: ExpenseListItemDto): void {
if (!confirm('Delete this reimbursement?')) return;
this.api.delete(row.id).subscribe(() => this.load());
}
openReceipt(id: number): void {
this.api.downloadReceipt(id).subscribe(blob => {
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
setTimeout(() => URL.revokeObjectURL(url), 60_000);
});
}
canEdit(row: ExpenseListItemDto): boolean { return row.status === 'Draft'; } canEdit(row: ExpenseListItemDto): boolean { return row.status === 'Draft'; }
statusClass(status: string): string { statusClass(status: string): string {
return ({ Draft: 'badge-draft', PendingApproval: 'badge-pending', Approved: 'badge-approved', Paid: 'badge-paid', Rejected: 'badge-rejected' } as Record<string, string>)[status] ?? ''; return ({ Draft: 'badge-draft', PendingApproval: 'badge-pending', Approved: 'badge-approved', Paid: 'badge-paid', Rejected: 'badge-rejected' } as Record<string, string>)[status] ?? '';
} }
receiptUrl(id: number): string { return this.api.receiptUrl(id); }
} }
@@ -41,5 +41,12 @@ export class ExpenseApiService {
const form = new FormData(); form.append('file', file); const form = new FormData(); form.append('file', file);
return this.http.post<void>(`${this.endpoint}/${id}/receipt`, form); return this.http.post<void>(`${this.endpoint}/${id}/receipt`, form);
} }
receiptUrl(id: number): string { return `${this.endpoint}/${id}/receipt`; } /**
* Fetches the receipt as a Blob via HttpClient so the auth interceptor attaches
* the JWT. A plain <a href> / window.open on the API URL would be an unauthenticated
* browser navigation and the API's [Authorize] would reject it with 401.
*/
downloadReceipt(id: number): Observable<Blob> {
return this.http.get(`${this.endpoint}/${id}/receipt`, { responseType: 'blob' });
}
} }