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:
Chris Chen
2026-06-25 17:39:20 -07:00
parent 6ffaaf37ac
commit 82096e7e6f
5 changed files with 355 additions and 1 deletions
+12 -1
View File
@@ -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',
},
},
]
},
@@ -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>
@@ -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;
}
@@ -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' } },
],
},
{