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 { 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 { 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 { 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 { 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 { 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';
|
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',
|
path: 'finance/payee-1099',
|
||||||
loadComponent: () => import('./features/payee1099/pages/payee-1099-page/payee-1099-page.component').then(m => m.Payee1099PageComponent),
|
component: Payee1099PageComponent,
|
||||||
canActivate: [PermissionGuard],
|
canActivate: [PermissionGuard],
|
||||||
data: {
|
data: {
|
||||||
permission: { module: PermissionModules.Form1099, action: 'read' },
|
permission: { module: PermissionModules.Form1099, action: 'read' },
|
||||||
title: '1099 Recipients', titleZh: '1099 收款人', section: 'Finance',
|
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' } },
|
permission: { module: PermissionModules.Disbursements, action: 'read' } },
|
||||||
{ text: '1099 Recipients', icon: fileReportIcon, path: '/user-portal/finance/payee-1099',
|
{ text: '1099 Recipients', icon: fileReportIcon, path: '/user-portal/finance/payee-1099',
|
||||||
permission: { module: PermissionModules.Form1099, action: 'read' } },
|
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