From 82096e7e6f2250c1fd7ed339faceadb1a34d613f Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 17:39:20 -0700 Subject: [PATCH] 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 --- APP/src/app/app.routes.ts | 13 +- .../form1099-report-page.component.html | 119 +++++++++++++++++ .../form1099-report-page.component.scss | 100 ++++++++++++++ .../form1099-report-page.component.ts | 122 ++++++++++++++++++ .../user-portal/user-portal.component.ts | 2 + 5 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 APP/src/app/features/finance-report/pages/form1099-report-page/form1099-report-page.component.html create mode 100644 APP/src/app/features/finance-report/pages/form1099-report-page/form1099-report-page.component.scss create mode 100644 APP/src/app/features/finance-report/pages/form1099-report-page/form1099-report-page.component.ts diff --git a/APP/src/app/app.routes.ts b/APP/src/app/app.routes.ts index e07e601..81a079e 100644 --- a/APP/src/app/app.routes.ts +++ b/APP/src/app/app.routes.ts @@ -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', + }, + }, ] }, diff --git a/APP/src/app/features/finance-report/pages/form1099-report-page/form1099-report-page.component.html b/APP/src/app/features/finance-report/pages/form1099-report-page/form1099-report-page.component.html new file mode 100644 index 0000000..8ff7580 --- /dev/null +++ b/APP/src/app/features/finance-report/pages/form1099-report-page/form1099-report-page.component.html @@ -0,0 +1,119 @@ +
+ + + + + +
+ + +
+ + +
+
+
Total Reportable / 應申報總額
+
{{ summary.totalReportable | currency }}
+
+
+
Recipients ≥ $600 / 達門檻收款人
+
{{ summary.recipientsAtThreshold }}
+
+
+
Missing W-9 / 缺少 W-9
+
{{ summary.recipientsMissingW9 }}
+
+
+ +
Click a name for payment detail · right-click a row for Copy B / 點選名稱檢視明細 · 右鍵下載 Copy B
+ + + + + +
+
+
+
{{ r.legalName }}
+ + {{ r.w9Status }} + +
+
TIN{{ r.tinLast4 ? '***-**-' + r.tinLast4 : '—' }}
+
NEC / 非雇員報酬{{ r.necTotal | currency }}
+
Rents / 租金{{ r.rentsTotal | currency }}
+
Total / 總計{{ r.grandTotal | currency }}
+
+ Threshold / 門檻 + ≥ $600 +
+
+
+ + + + +
Loading… / 載入中…
+ + +
+
{{ detail.legalName }}
+
+ TIN {{ detail.tinLast4 ? '***-**-' + detail.tinLast4 : '—' }} + {{ detail.w9Status }} + Year / 年度 {{ detail.taxYear }} +
+
+ + + + + + + + +
+ + + + +
+ +
diff --git a/APP/src/app/features/finance-report/pages/form1099-report-page/form1099-report-page.component.scss b/APP/src/app/features/finance-report/pages/form1099-report-page/form1099-report-page.component.scss new file mode 100644 index 0000000..6f9da0f --- /dev/null +++ b/APP/src/app/features/finance-report/pages/form1099-report-page/form1099-report-page.component.scss @@ -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; +} diff --git a/APP/src/app/features/finance-report/pages/form1099-report-page/form1099-report-page.component.ts b/APP/src/app/features/finance-report/pages/form1099-report-page/form1099-report-page.component.ts new file mode 100644 index 0000000..bc63424 --- /dev/null +++ b/APP/src/app/features/finance-report/pages/form1099-report-page/form1099-report-page.component.ts @@ -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); + } +} diff --git a/APP/src/app/portals/user-portal/user-portal.component.ts b/APP/src/app/portals/user-portal/user-portal.component.ts index 7f3ac55..dd73e28 100644 --- a/APP/src/app/portals/user-portal/user-portal.component.ts +++ b/APP/src/app/portals/user-portal/user-portal.component.ts @@ -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' } }, ], }, {