WIP
This commit is contained in:
+120
@@ -0,0 +1,120 @@
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<h2>Check Register / 支票登記簿</h2>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-wrap gap-3 items-end mb-4">
|
||||
<label class="flex flex-col gap-1">
|
||||
Search
|
||||
<kendo-textbox placeholder="Check # / payee" [(ngModel)]="filter.search"
|
||||
(keydown.enter)="applyFilter()"></kendo-textbox>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
Status
|
||||
<kendo-dropdownlist [data]="statuses" textField="label" valueField="value" [valuePrimitive]="true"
|
||||
[(ngModel)]="filter.status" [defaultItem]="{ value: null, label: 'All Status/全部狀態' }">
|
||||
</kendo-dropdownlist>
|
||||
</label>
|
||||
<button kendoButton (click)="applyFilter()">Apply</button>
|
||||
</div>
|
||||
|
||||
<kendo-grid [data]="{ data: rows, total: total }" [loading]="loading" [pageable]="true" [skip]="skip"
|
||||
[pageSize]="pageSize" (pageChange)="onPageChange($event)">
|
||||
|
||||
<kendo-grid-column field="checkNumber" title="Check #" [width]="100"></kendo-grid-column>
|
||||
<kendo-grid-column field="checkDate" title="Date" [width]="110"></kendo-grid-column>
|
||||
<kendo-grid-column field="payeeName" title="Payee"></kendo-grid-column>
|
||||
<kendo-grid-column field="amount" title="Amount" [width]="120" format="c2"></kendo-grid-column>
|
||||
<kendo-grid-column title="Lines" [width]="80">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>{{ dataItem.lineCount }}</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column title="Status" [width]="110">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<span [class]="statusClass(dataItem.status)">{{ dataItem.status }}</span>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column title="Receipt / 簽收" [width]="180">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<ng-container *ngIf="dataItem.signed; else notSigned">
|
||||
<span class="badge-paid">Signed</span>
|
||||
<div class="text-xs" style="color:#6b7280;">
|
||||
{{ dataItem.receiptSignedName }} · {{ dataItem.receiptSignedAt | date:'short' }}
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #notSigned><span style="color:#9ca3af;">—</span></ng-template>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
|
||||
<kendo-grid-column title="Actions" [width]="600">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<button kendoButton fillMode="flat" (click)="view(dataItem)">View</button>
|
||||
<button kendoButton fillMode="flat" themeColor="primary" (click)="print(dataItem)">Print</button>
|
||||
<button *ngIf="canSign(dataItem)" kendoButton fillMode="flat" themeColor="success"
|
||||
(click)="openSign(dataItem)">簽收</button>
|
||||
<button *ngIf="dataItem.signed" kendoButton fillMode="flat" (click)="viewSignature(dataItem)">Signature</button>
|
||||
<button *ngIf="dataItem.signed" kendoButton fillMode="flat" themeColor="primary"
|
||||
(click)="printReceipt(dataItem)">收據</button>
|
||||
<button *ngIf="canVoid(dataItem)" kendoButton fillMode="flat" themeColor="error"
|
||||
(click)="openVoid(dataItem)">Void</button>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
</kendo-grid>
|
||||
|
||||
<!-- Detail dialog -->
|
||||
<kendo-dialog *ngIf="detail" title="Check #{{ detail.checkNumber }}" [width]="560" (close)="detail = null">
|
||||
<div class="p-2 flex flex-col gap-2">
|
||||
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||
<div><strong>Payee:</strong> {{ detail.payeeName }}</div>
|
||||
<div><strong>Date:</strong> {{ detail.checkDate }}</div>
|
||||
<div><strong>Amount:</strong> {{ detail.amount | currency }}</div>
|
||||
<div><strong>Status:</strong> {{ detail.status }}</div>
|
||||
<div class="col-span-2" *ngIf="detail.memo"><strong>Memo:</strong> {{ detail.memo }}</div>
|
||||
<div class="col-span-2" *ngIf="detail.signed">
|
||||
<strong>Signed:</strong> {{ detail.receiptSignedName }} · {{ detail.receiptSignedAt | date:'short' }}
|
||||
</div>
|
||||
<div class="col-span-2" *ngIf="detail.status === 'Voided'">
|
||||
<strong>Voided:</strong> {{ detail.voidReason }} · {{ detail.voidedAt | date:'short' }}
|
||||
</div>
|
||||
</div>
|
||||
<table class="w-full text-sm mt-2">
|
||||
<thead>
|
||||
<tr class="text-left" style="border-bottom:1px solid #e5e7eb;">
|
||||
<th class="py-1">Description</th>
|
||||
<th class="text-right">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let l of detail.lines" style="border-bottom:1px solid #f3f4f6;">
|
||||
<td class="py-1">{{ l.description }}</td>
|
||||
<td class="text-right">{{ l.amount | currency }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<kendo-dialog-actions>
|
||||
<button kendoButton (click)="detail = null">Close</button>
|
||||
</kendo-dialog-actions>
|
||||
</kendo-dialog>
|
||||
|
||||
<!-- Void dialog -->
|
||||
<kendo-dialog *ngIf="voidRow" title="Void Check #{{ voidRow.checkNumber }}" [width]="420" (close)="voidRow = null">
|
||||
<div class="p-2 flex flex-col gap-2">
|
||||
<p class="text-sm" style="color:#991b1b;">
|
||||
Voiding returns the bundled expenses to Approved so they can be re-issued.
|
||||
/ 作廢將使支出退回「已核准」可重新開立。
|
||||
</p>
|
||||
<label class="flex flex-col gap-1">
|
||||
Reason / 原因
|
||||
<kendo-textbox [(ngModel)]="voidReason" placeholder="Optional"></kendo-textbox>
|
||||
</label>
|
||||
</div>
|
||||
<kendo-dialog-actions>
|
||||
<button kendoButton (click)="voidRow = null">Cancel</button>
|
||||
<button kendoButton themeColor="error" (click)="confirmVoid()">Void</button>
|
||||
</kendo-dialog-actions>
|
||||
</kendo-dialog>
|
||||
|
||||
<!-- Receipt signature dialog -->
|
||||
<app-receipt-sign-dialog *ngIf="signRow" [check]="signRow" (save)="onSign($event)" (cancel)="signRow = null">
|
||||
</app-receipt-sign-dialog>
|
||||
</div>
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
%badge-base {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-approved {
|
||||
@extend %badge-base;
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.badge-paid {
|
||||
@extend %badge-base;
|
||||
background-color: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.badge-rejected {
|
||||
@extend %badge-base;
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { GridModule, PageChangeEvent } from '@progress/kendo-angular-grid';
|
||||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
||||
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||||
import { CHECK_STATUS_OPTIONS } from '../../../../shared/i18n/option-lists';
|
||||
import { DisbursementApiService, CheckRegisterQuery } from '../../services/disbursement-api.service';
|
||||
import { ReceiptSignDialogComponent, ReceiptSignResult } from '../../components/receipt-sign-dialog/receipt-sign-dialog.component';
|
||||
import { CheckListItemDto, CheckDetailDto } from '../../models/disbursement.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-check-register-page',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule, FormsModule, GridModule, ButtonsModule, DropDownsModule,
|
||||
InputsModule, DialogsModule, ReceiptSignDialogComponent,
|
||||
],
|
||||
templateUrl: './check-register-page.component.html',
|
||||
styleUrls: ['./check-register-page.component.scss'],
|
||||
})
|
||||
export class CheckRegisterPageComponent implements OnInit {
|
||||
rows: CheckListItemDto[] = [];
|
||||
total = 0;
|
||||
page = 1;
|
||||
pageSize = 20;
|
||||
loading = false;
|
||||
|
||||
readonly statuses = CHECK_STATUS_OPTIONS;
|
||||
filter: CheckRegisterQuery = {};
|
||||
|
||||
detail: CheckDetailDto | null = null;
|
||||
signRow: CheckListItemDto | null = null;
|
||||
voidRow: CheckListItemDto | null = null;
|
||||
voidReason = '';
|
||||
|
||||
constructor(private api: DisbursementApiService) {}
|
||||
|
||||
ngOnInit(): void { this.load(); }
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.api.getRegister({ ...this.filter, page: this.page, pageSize: this.pageSize }).subscribe({
|
||||
next: r => { this.rows = r.items; this.total = r.totalCount; this.loading = false; },
|
||||
error: () => (this.loading = false),
|
||||
});
|
||||
}
|
||||
|
||||
get skip(): number { return (this.page - 1) * this.pageSize; }
|
||||
applyFilter(): void { this.page = 1; this.load(); }
|
||||
onPageChange(e: PageChangeEvent): void { this.page = Math.floor(e.skip / this.pageSize) + 1; this.load(); }
|
||||
|
||||
view(row: CheckListItemDto): void {
|
||||
this.api.getCheck(row.id).subscribe(d => (this.detail = d));
|
||||
}
|
||||
|
||||
print(row: CheckListItemDto): void {
|
||||
this.api.downloadCheckPdf(row.id).subscribe(blob => this.openBlob(blob));
|
||||
}
|
||||
|
||||
viewSignature(row: CheckListItemDto): void {
|
||||
this.api.getSignature(row.id).subscribe(blob => this.openBlob(blob));
|
||||
}
|
||||
|
||||
printReceipt(row: CheckListItemDto): void {
|
||||
this.api.downloadReceiptPdf(row.id).subscribe(blob => this.openBlob(blob));
|
||||
}
|
||||
|
||||
openSign(row: CheckListItemDto): void { this.signRow = row; }
|
||||
|
||||
onSign(result: ReceiptSignResult): void {
|
||||
if (!this.signRow) return;
|
||||
this.api.acknowledge(this.signRow.id, result.signature, result.signedName).subscribe({
|
||||
next: () => { this.signRow = null; this.load(); },
|
||||
error: () => {
|
||||
// Error message is shown globally by httpErrorInterceptor; keep the dialog open to retry.
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openVoid(row: CheckListItemDto): void { this.voidRow = row; this.voidReason = ''; }
|
||||
|
||||
confirmVoid(): void {
|
||||
if (!this.voidRow) return;
|
||||
this.api.voidCheck(this.voidRow.id, this.voidReason || null).subscribe({
|
||||
next: () => { this.voidRow = null; this.load(); },
|
||||
error: () => {
|
||||
// Error message is shown globally by httpErrorInterceptor; keep the dialog open to retry.
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
canSign(row: CheckListItemDto): boolean { return row.status === 'Issued' && !row.signed; }
|
||||
canVoid(row: CheckListItemDto): boolean { return row.status === 'Issued'; }
|
||||
|
||||
statusClass(status: string): string {
|
||||
return ({ Issued: 'badge-approved', Voided: 'badge-rejected' } as Record<string, string>)[status] ?? '';
|
||||
}
|
||||
|
||||
private openBlob(blob: Blob): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank');
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<h2>Church Profile / 教會資料</h2>
|
||||
</header>
|
||||
|
||||
<div *ngIf="model" class="max-w-3xl">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
||||
<label class="flex flex-col gap-1 md:col-span-2">
|
||||
Church Name / 教會名稱
|
||||
<kendo-textbox [(ngModel)]="model.name"></kendo-textbox>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 md:col-span-2">
|
||||
Address / 地址
|
||||
<kendo-textbox [(ngModel)]="model.address"></kendo-textbox>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
City / 城市
|
||||
<kendo-textbox [(ngModel)]="model.city"></kendo-textbox>
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="flex flex-col gap-1">
|
||||
State / 州
|
||||
<kendo-textbox [(ngModel)]="model.state"></kendo-textbox>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
Zip / 郵遞區號
|
||||
<kendo-textbox [(ngModel)]="model.zipCode"></kendo-textbox>
|
||||
</label>
|
||||
</div>
|
||||
<label class="flex flex-col gap-1">
|
||||
Bank Name / 銀行名稱
|
||||
<kendo-textbox [(ngModel)]="model.bankName"></kendo-textbox>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
Bank Account # / 銀行帳號
|
||||
<kendo-textbox [(ngModel)]="model.bankAccountNumber"></kendo-textbox>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
Routing # / 路由號碼
|
||||
<kendo-textbox [(ngModel)]="model.bankRoutingNumber"></kendo-textbox>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
Next Check # / 下一張支票號碼
|
||||
<kendo-numerictextbox [(ngModel)]="model.nextCheckNumber" [min]="1" [decimals]="0" format="#"></kendo-numerictextbox>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 mt-4">
|
||||
<button kendoButton themeColor="primary" [disabled]="saving" (click)="save()">Save / 儲存</button>
|
||||
<span class="text-sm" style="color:#065f46;">{{ savedMsg }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||
import { DisbursementApiService } from '../../services/disbursement-api.service';
|
||||
import { ChurchProfileDto } from '../../models/disbursement.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-church-profile-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, ButtonsModule, InputsModule],
|
||||
templateUrl: './church-profile-page.component.html',
|
||||
})
|
||||
export class ChurchProfilePageComponent implements OnInit {
|
||||
model: ChurchProfileDto | null = null;
|
||||
saving = false;
|
||||
savedMsg = '';
|
||||
|
||||
constructor(private api: DisbursementApiService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.api.getChurchProfile().subscribe(p => (this.model = p));
|
||||
}
|
||||
|
||||
save(): void {
|
||||
if (!this.model || this.saving) return;
|
||||
this.saving = true;
|
||||
this.savedMsg = '';
|
||||
const { id, ...req } = this.model;
|
||||
this.api.updateChurchProfile(req).subscribe({
|
||||
next: () => { this.saving = false; this.savedMsg = 'Saved / 已儲存'; },
|
||||
error: () => {
|
||||
// Error message is shown globally by httpErrorInterceptor.
|
||||
this.saving = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
<div class="page">
|
||||
<header class="page-header flex items-center justify-between">
|
||||
<h2>Disbursement Management / 支票開立</h2>
|
||||
<button kendoButton themeColor="primary" [disabled]="selectedCount === 0" (click)="openIssue()">
|
||||
Issue Checks ({{ selectedCount }})
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p class="text-sm mb-3" style="color:#6b7280;">
|
||||
Approved expenses awaiting payment, grouped by payee. Select payees and issue one check each.
|
||||
/ 已核准待付款支出,依收款人彙整,每位收款人開立一張支票。
|
||||
</p>
|
||||
|
||||
<kendo-grid
|
||||
[kendoGridBinding]="rows"
|
||||
[loading]="loading"
|
||||
kendoGridSelectBy="key"
|
||||
[(selectedKeys)]="selectedKeys"
|
||||
[selectable]="{ checkboxOnly: true, mode: 'multiple' }"
|
||||
[sortable]="true">
|
||||
|
||||
<kendo-grid-checkbox-column [width]="44" [showSelectAll]="true"></kendo-grid-checkbox-column>
|
||||
|
||||
<kendo-grid-column field="payeeName" title="Payee / 收款人"></kendo-grid-column>
|
||||
<kendo-grid-column field="payeeType" title="Type" [width]="120"></kendo-grid-column>
|
||||
<kendo-grid-column title="# Expenses" [width]="120">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>{{ dataItem.lines.length }}</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column field="totalAmount" title="Total" [width]="140" format="c2"></kendo-grid-column>
|
||||
|
||||
<ng-template kendoGridDetailTemplate let-dataItem>
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left" style="border-bottom:1px solid #e5e7eb;">
|
||||
<th class="py-1">Date</th><th>Description</th><th>Ministry</th>
|
||||
<th>Category</th><th class="text-right">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let l of dataItem.lines" style="border-bottom:1px solid #f3f4f6;">
|
||||
<td class="py-1">{{ l.expenseDate }}</td>
|
||||
<td>{{ l.description }}</td>
|
||||
<td>{{ l.ministryName }}</td>
|
||||
<td>{{ l.categoryName }}</td>
|
||||
<td class="text-right">{{ l.amount | currency }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</ng-template>
|
||||
</kendo-grid>
|
||||
|
||||
<app-issue-check-dialog
|
||||
*ngIf="issueDialogGroups"
|
||||
[groups]="issueDialogGroups"
|
||||
[nextCheckNumber]="nextCheckNumber"
|
||||
(save)="onIssue($event)"
|
||||
(cancel)="issueDialogGroups = null">
|
||||
</app-issue-check-dialog>
|
||||
</div>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
.page-header {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { GridModule } from '@progress/kendo-angular-grid';
|
||||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||
import { DisbursementApiService } from '../../services/disbursement-api.service';
|
||||
import { IssueCheckDialogComponent } from '../../components/issue-check-dialog/issue-check-dialog.component';
|
||||
import { PayeeGroupDto, IssueChecksRequest } from '../../models/disbursement.model';
|
||||
|
||||
interface PayeeRow extends PayeeGroupDto { key: string; }
|
||||
|
||||
@Component({
|
||||
selector: 'app-disbursement-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, GridModule, ButtonsModule, IssueCheckDialogComponent],
|
||||
templateUrl: './disbursement-page.component.html',
|
||||
styleUrls: ['./disbursement-page.component.scss'],
|
||||
})
|
||||
export class DisbursementPageComponent implements OnInit {
|
||||
rows: PayeeRow[] = [];
|
||||
selectedKeys: string[] = [];
|
||||
loading = false;
|
||||
nextCheckNumber = 1001;
|
||||
|
||||
issueDialogGroups: PayeeGroupDto[] | null = null;
|
||||
|
||||
constructor(private api: DisbursementApiService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.api.getChurchProfile().subscribe(p => (this.nextCheckNumber = p.nextCheckNumber));
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.selectedKeys = [];
|
||||
this.api.getApprovedUnpaid().subscribe({
|
||||
next: groups => {
|
||||
this.rows = groups.map(g => ({ ...g, key: this.keyOf(g) }));
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => (this.loading = false),
|
||||
});
|
||||
}
|
||||
|
||||
private keyOf(g: PayeeGroupDto): string {
|
||||
return `${g.payeeType}:${g.payeeType === 'Member' ? g.memberId : g.vendorKey}`;
|
||||
}
|
||||
|
||||
get selectedCount(): number { return this.selectedKeys.length; }
|
||||
|
||||
openIssue(): void {
|
||||
const selected = this.rows.filter(r => this.selectedKeys.includes(r.key));
|
||||
if (selected.length === 0) return;
|
||||
this.issueDialogGroups = selected;
|
||||
}
|
||||
|
||||
onIssue(req: IssueChecksRequest): void {
|
||||
this.api.issue(req).subscribe({
|
||||
next: result => {
|
||||
this.issueDialogGroups = null;
|
||||
result.created.forEach(c => this.openPdf(c.checkId));
|
||||
this.load();
|
||||
},
|
||||
error: () => {
|
||||
// Error message is shown globally by httpErrorInterceptor; keep the dialog open to retry.
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private openPdf(checkId: number): void {
|
||||
this.api.downloadCheckPdf(checkId).subscribe(blob => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank');
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user