feat(1099): 1099 year-end report page with drill-in, CSV, Copy B
Add Form1099ReportPageComponent (year selector, summary chips with a prominent missing-W-9 flag, desktop grid + mobile cards, recipient detail dialog). Per-row Copy B PDF via right-click context menu and a header Export filing CSV action, both downloaded as auth-correct blobs. Wire the eager route + sidebar nav item, gated on Form1099:read. Also convert the neighboring finance/payee-1099 route from lazy loadComponent to an eager component import so both 1099 routes match the surrounding convention. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,8 @@ import { DisbursementPageComponent } from './features/disbursement/pages/disburs
|
||||
import { CheckRegisterPageComponent } from './features/disbursement/pages/check-register-page/check-register-page.component';
|
||||
import { ChurchProfilePageComponent } from './features/disbursement/pages/church-profile-page/church-profile-page.component';
|
||||
import { Form990ReportPageComponent } from './features/finance-report/pages/form990-report-page/form990-report-page.component';
|
||||
import { Form1099ReportPageComponent } from './features/finance-report/pages/form1099-report-page/form1099-report-page.component';
|
||||
import { Payee1099PageComponent } from './features/payee1099/pages/payee-1099-page/payee-1099-page.component';
|
||||
import { AttendanceCounterPageComponent } from './features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component';
|
||||
import { OfferingEntryMobilePageComponent } from './features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component';
|
||||
import { SystemLogsPageComponent } from './features/logging/pages/system-logs-page/system-logs-page.component';
|
||||
@@ -230,13 +232,22 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'finance/payee-1099',
|
||||
loadComponent: () => import('./features/payee1099/pages/payee-1099-page/payee-1099-page.component').then(m => m.Payee1099PageComponent),
|
||||
component: Payee1099PageComponent,
|
||||
canActivate: [PermissionGuard],
|
||||
data: {
|
||||
permission: { module: PermissionModules.Form1099, action: 'read' },
|
||||
title: '1099 Recipients', titleZh: '1099 收款人', section: 'Finance',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'finance/form1099-report',
|
||||
component: Form1099ReportPageComponent,
|
||||
canActivate: [PermissionGuard],
|
||||
data: {
|
||||
permission: { module: PermissionModules.Form1099, action: 'read' },
|
||||
title: '1099 Year-End Report', titleZh: '1099 年度報表', section: 'Finance',
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
<div class="page">
|
||||
<ng-template appPageHeaderActions>
|
||||
<button kendoButton themeColor="primary" (click)="exportCsv()">
|
||||
Export filing CSV / 匯出申報資料
|
||||
</button>
|
||||
</ng-template>
|
||||
|
||||
<!-- Year selector -->
|
||||
<div class="flex flex-wrap items-end gap-3 mb-4">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span>Tax Year / 稅務年度</span>
|
||||
<kendo-dropdownlist [data]="years" [(ngModel)]="taxYear" [style.width.px]="140"></kendo-dropdownlist>
|
||||
</label>
|
||||
<button kendoButton themeColor="primary" (click)="load()">Load / 載入</button>
|
||||
</div>
|
||||
|
||||
<!-- Summary chips -->
|
||||
<div *ngIf="summary" class="flex flex-wrap gap-3 mb-4">
|
||||
<div class="summary-chip">
|
||||
<div class="summary-label">Total Reportable / 應申報總額</div>
|
||||
<div class="summary-value">{{ summary.totalReportable | currency }}</div>
|
||||
</div>
|
||||
<div class="summary-chip">
|
||||
<div class="summary-label">Recipients ≥ $600 / 達門檻收款人</div>
|
||||
<div class="summary-value">{{ summary.recipientsAtThreshold }}</div>
|
||||
</div>
|
||||
<div class="summary-chip" [class.summary-chip-flag]="summary.recipientsMissingW9 > 0">
|
||||
<div class="summary-label">Missing W-9 / 缺少 W-9</div>
|
||||
<div class="summary-value">{{ summary.recipientsMissingW9 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hint-text-sm">Click a name for payment detail · right-click a row for Copy B / 點選名稱檢視明細 · 右鍵下載 Copy B</div>
|
||||
|
||||
<!-- Desktop grid -->
|
||||
<div class="hidden md:block">
|
||||
<kendo-grid class="clickable-rows" [data]="summary?.rows ?? []" [loading]="loading"
|
||||
(cellClick)="onCellClick($event)">
|
||||
<kendo-grid-column field="legalName" title="Legal Name / 法定名稱">
|
||||
<ng-template kendoGridCellTemplate let-r>
|
||||
<span class="legal-name">{{ r.legalName }}</span>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column title="TIN" [width]="120">
|
||||
<ng-template kendoGridCellTemplate let-r>{{ r.tinLast4 ? '***-**-' + r.tinLast4 : '—' }}</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column field="w9Status" title="W-9" [width]="130">
|
||||
<ng-template kendoGridCellTemplate let-r>
|
||||
<span class="badge" [ngClass]="r.w9Missing ? 'badge-missing' : 'badge-' + r.w9Status.toLowerCase()">
|
||||
{{ r.w9Status }}
|
||||
</span>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column field="necTotal" title="NEC / 非雇員報酬" format="{0:c2}" [width]="150"></kendo-grid-column>
|
||||
<kendo-grid-column field="rentsTotal" title="Rents / 租金" format="{0:c2}" [width]="140"></kendo-grid-column>
|
||||
<kendo-grid-column field="grandTotal" title="Total / 總計" format="{0:c2}" [width]="150"></kendo-grid-column>
|
||||
<kendo-grid-column title="Threshold / 門檻" [width]="130">
|
||||
<ng-template kendoGridCellTemplate let-r>
|
||||
<span *ngIf="r.meetsThreshold" class="badge badge-threshold">≥ $600</span>
|
||||
<span *ngIf="!r.meetsThreshold">—</span>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
</kendo-grid>
|
||||
<kendo-contextmenu #rowMenu [items]="rowMenuItems" (select)="onRowMenuSelect($event)"></kendo-contextmenu>
|
||||
</div>
|
||||
|
||||
<!-- Mobile cards -->
|
||||
<div class="md:hidden flex flex-col gap-3">
|
||||
<div *ngFor="let r of summary?.rows ?? []" class="rounded border p-3" (click)="openDetail(r)">
|
||||
<div class="flex justify-between items-start gap-2">
|
||||
<div class="font-semibold">{{ r.legalName }}</div>
|
||||
<span class="badge" [ngClass]="r.w9Missing ? 'badge-missing' : 'badge-' + r.w9Status.toLowerCase()">
|
||||
{{ r.w9Status }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm flex justify-between"><span>TIN</span><span>{{ r.tinLast4 ? '***-**-' + r.tinLast4 : '—' }}</span></div>
|
||||
<div class="text-sm flex justify-between"><span>NEC / 非雇員報酬</span><span>{{ r.necTotal | currency }}</span></div>
|
||||
<div class="text-sm flex justify-between"><span>Rents / 租金</span><span>{{ r.rentsTotal | currency }}</span></div>
|
||||
<div class="text-sm flex justify-between font-semibold"><span>Total / 總計</span><span>{{ r.grandTotal | currency }}</span></div>
|
||||
<div class="text-sm flex justify-between">
|
||||
<span>Threshold / 門檻</span>
|
||||
<span><span *ngIf="r.meetsThreshold" class="badge badge-threshold">≥ $600</span><span *ngIf="!r.meetsThreshold">—</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recipient detail dialog -->
|
||||
<kendo-dialog *ngIf="detail || detailLoading"
|
||||
[title]="'Recipient Detail / 收款人明細'"
|
||||
(close)="closeDetail()"
|
||||
[width]="760" [maxWidth]="'95vw'">
|
||||
|
||||
<div *ngIf="detailLoading" class="p-3">Loading… / 載入中…</div>
|
||||
|
||||
<ng-container *ngIf="detail">
|
||||
<div class="detail-header">
|
||||
<div class="detail-name">{{ detail.legalName }}</div>
|
||||
<div class="detail-meta">
|
||||
<span>TIN {{ detail.tinLast4 ? '***-**-' + detail.tinLast4 : '—' }}</span>
|
||||
<span class="badge" [ngClass]="'badge-' + detail.w9Status.toLowerCase()">{{ detail.w9Status }}</span>
|
||||
<span>Year / 年度 {{ detail.taxYear }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<kendo-grid [data]="detail.payments">
|
||||
<kendo-grid-column field="paidDate" title="Date / 日期" [width]="120"></kendo-grid-column>
|
||||
<kendo-grid-column field="description" title="Description / 說明"></kendo-grid-column>
|
||||
<kendo-grid-column field="categoryName" title="Category / 類別" [width]="170"></kendo-grid-column>
|
||||
<kendo-grid-column field="boxCode" title="Box" [width]="90"></kendo-grid-column>
|
||||
<kendo-grid-column field="amount" title="Amount / 金額" format="{0:c2}" [width]="140"></kendo-grid-column>
|
||||
</kendo-grid>
|
||||
</ng-container>
|
||||
|
||||
<kendo-dialog-actions>
|
||||
<button kendoButton (click)="closeDetail()">Close / 關閉</button>
|
||||
</kendo-dialog-actions>
|
||||
</kendo-dialog>
|
||||
|
||||
</div>
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
.hint-text-sm {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.legal-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// Grid rows are clickable to open the recipient detail.
|
||||
.clickable-rows ::ng-deep .k-grid-content tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// Summary chips.
|
||||
.summary-chip {
|
||||
flex: 1 1 200px;
|
||||
min-width: 180px;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
// Missing-W-9 chip is a governance flag — make it stand out.
|
||||
.summary-chip-flag {
|
||||
border-color: #fca5a5;
|
||||
background-color: #fef2f2;
|
||||
|
||||
.summary-value {
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
|
||||
// Recipient detail header.
|
||||
.detail-header {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.detail-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
// Status / threshold badges.
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.badge-onfile {
|
||||
background-color: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.badge-requested {
|
||||
background-color: #fef9c3;
|
||||
color: #854d0e;
|
||||
}
|
||||
|
||||
.badge-missing {
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.badge-expired {
|
||||
background-color: #fed7aa;
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
.badge-threshold {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
import { Component, OnInit, ViewChild } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { GridModule, CellClickEvent } from '@progress/kendo-angular-grid';
|
||||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||||
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
||||
import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
|
||||
import { Form1099ReportApiService } from '../../../payee1099/services/form1099-report-api.service';
|
||||
import {
|
||||
Form1099Summary, Form1099RecipientRow, Form1099RecipientDetail,
|
||||
} from '../../../payee1099/models/payee1099.model';
|
||||
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
|
||||
|
||||
@Component({
|
||||
selector: 'app-form1099-report-page',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule, FormsModule, GridModule, ButtonsModule, DialogsModule,
|
||||
DropDownsModule, ContextMenuModule, PageHeaderActionsDirective,
|
||||
],
|
||||
templateUrl: './form1099-report-page.component.html',
|
||||
styleUrls: ['./form1099-report-page.component.scss'],
|
||||
})
|
||||
export class Form1099ReportPageComponent implements OnInit {
|
||||
/** Recent years offered in the selector: current year and the prior four. */
|
||||
readonly years: number[] = [];
|
||||
taxYear: number = new Date().getFullYear();
|
||||
|
||||
summary: Form1099Summary | null = null;
|
||||
loading = false;
|
||||
|
||||
// Per-row "Copy B" action, surfaced through a right-click context menu (matches
|
||||
// the recipients page convention of putting row actions in a context menu).
|
||||
@ViewChild('rowMenu') rowMenu!: ContextMenuComponent;
|
||||
rowMenuItems: { text: string }[] = [];
|
||||
private contextRow: Form1099RecipientRow | null = null;
|
||||
|
||||
detail: Form1099RecipientDetail | null = null;
|
||||
detailLoading = false;
|
||||
|
||||
constructor(private api: Form1099ReportApiService) {
|
||||
const currentYear = new Date().getFullYear();
|
||||
for (let offset = 0; offset < 5; offset++) {
|
||||
this.years.push(currentYear - offset);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.api.getSummary(this.taxYear).subscribe({
|
||||
next: (summary) => {
|
||||
this.summary = summary;
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => { this.loading = false; },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Row interaction: primary click opens the detail; right-click shows actions ──
|
||||
onCellClick(event: CellClickEvent): void {
|
||||
if (event.type === 'contextmenu') {
|
||||
event.originalEvent.preventDefault();
|
||||
this.contextRow = event.dataItem;
|
||||
this.rowMenuItems = [{ text: 'Copy B PDF' }];
|
||||
this.rowMenu.show({ left: event.originalEvent.pageX, top: event.originalEvent.pageY });
|
||||
} else {
|
||||
this.openDetail(event.dataItem);
|
||||
}
|
||||
}
|
||||
|
||||
onRowMenuSelect(event: ContextMenuSelectEvent): void {
|
||||
if (!this.contextRow) return;
|
||||
if (event.item.text === 'Copy B PDF') this.copyB(this.contextRow);
|
||||
}
|
||||
|
||||
openDetail(row: Form1099RecipientRow): void {
|
||||
this.detail = null;
|
||||
this.detailLoading = true;
|
||||
this.api.getRecipient(row.payeeId, this.taxYear).subscribe({
|
||||
next: (detail) => {
|
||||
this.detail = detail;
|
||||
this.detailLoading = false;
|
||||
},
|
||||
error: () => { this.detailLoading = false; },
|
||||
});
|
||||
}
|
||||
|
||||
closeDetail(): void {
|
||||
this.detail = null;
|
||||
this.detailLoading = false;
|
||||
}
|
||||
|
||||
// ── Downloads: fetched as blobs so the auth interceptor attaches the token ──────
|
||||
exportCsv(): void {
|
||||
this.api.downloadCsv(this.taxYear).subscribe((blob) => {
|
||||
this.saveBlob(blob, `1099-filing-${this.taxYear}.csv`);
|
||||
});
|
||||
}
|
||||
|
||||
copyB(row: Form1099RecipientRow): void {
|
||||
this.api.downloadCopyB(row.payeeId, this.taxYear).subscribe((blob) => {
|
||||
this.saveBlob(blob, `1099-NEC-${row.payeeId}-${this.taxYear}.pdf`);
|
||||
});
|
||||
}
|
||||
|
||||
/** Trigger a browser save of a downloaded blob via a temporary anchor. */
|
||||
private saveBlob(blob: Blob, fileName: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,8 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
||||
permission: { module: PermissionModules.Disbursements, action: 'read' } },
|
||||
{ text: '1099 Recipients', icon: fileReportIcon, path: '/user-portal/finance/payee-1099',
|
||||
permission: { module: PermissionModules.Form1099, action: 'read' } },
|
||||
{ text: '1099 Report', icon: fileReportIcon, path: '/user-portal/finance/form1099-report',
|
||||
permission: { module: PermissionModules.Form1099, action: 'read' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user