diff --git a/APP/src/app/app.routes.ts b/APP/src/app/app.routes.ts
index 7695999..e07e601 100644
--- a/APP/src/app/app.routes.ts
+++ b/APP/src/app/app.routes.ts
@@ -228,6 +228,15 @@ export const routes: Routes = [
title: 'Form 990 — Functional Expenses', titleZh: 'Form 990 功能性費用表', section: 'Finance',
},
},
+ {
+ path: 'finance/payee-1099',
+ loadComponent: () => import('./features/payee1099/pages/payee-1099-page/payee-1099-page.component').then(m => m.Payee1099PageComponent),
+ canActivate: [PermissionGuard],
+ data: {
+ permission: { module: PermissionModules.Form1099, action: 'read' },
+ title: '1099 Recipients', titleZh: '1099 收款人', section: 'Finance',
+ },
+ },
]
},
diff --git a/APP/src/app/features/payee1099/pages/payee-1099-page/payee-1099-page.component.html b/APP/src/app/features/payee1099/pages/payee-1099-page/payee-1099-page.component.html
new file mode 100644
index 0000000..4288e8f
--- /dev/null
+++ b/APP/src/app/features/payee1099/pages/payee-1099-page/payee-1099-page.component.html
@@ -0,0 +1,165 @@
+
+
+
+
+
+
+
Click a name to edit · right-click a row for actions / 點選名稱編輯 · 右鍵顯示動作
+
+
+
+
+
+
+ {{ r.legalName }}
+ ({{ r.displayName }})
+
+
+
+ {{ r.memberName || '—' }}
+
+
+
+ {{ r.tinLast4 ? '***-**-' + r.tinLast4 : '—' }}
+
+
+
+ {{ r.w9Status }}
+
+
+
+ {{ r.is1099Tracked ? 'Yes' : 'No' }}
+
+
+ {{ r.isActive ? 'Yes' : 'No' }}
+
+
+
+
+
+
+
+
+
+
{{ r.legalName }}
+
{{ r.w9Status }}
+
+
{{ r.displayName }}
+
Member / 會友{{ r.memberName || '—' }}
+
Tax Class{{ r.taxClassification }}
+
TIN{{ r.tinLast4 ? '***-**-' + r.tinLast4 : '—' }}
+
1099 Tracked{{ r.is1099Tracked ? 'Yes' : 'No' }}
+
Active{{ r.isActive ? 'Yes' : 'No' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/APP/src/app/features/payee1099/pages/payee-1099-page/payee-1099-page.component.scss b/APP/src/app/features/payee1099/pages/payee-1099-page/payee-1099-page.component.scss
new file mode 100644
index 0000000..f823388
--- /dev/null
+++ b/APP/src/app/features/payee1099/pages/payee-1099-page/payee-1099-page.component.scss
@@ -0,0 +1,55 @@
+.hint-text-sm {
+ margin-bottom: 0.5rem;
+ font-size: 0.8rem;
+ color: #999;
+}
+
+.inactive-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ font-size: 0.85rem;
+}
+
+.legal-name {
+ font-weight: 600;
+}
+
+.display-name {
+ color: #777;
+}
+
+// Grid rows are clickable to open the editor.
+.clickable-rows ::ng-deep .k-grid-content tr {
+ cursor: pointer;
+}
+
+// W-9 status 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;
+}
diff --git a/APP/src/app/features/payee1099/pages/payee-1099-page/payee-1099-page.component.ts b/APP/src/app/features/payee1099/pages/payee-1099-page/payee-1099-page.component.ts
new file mode 100644
index 0000000..229b0e3
--- /dev/null
+++ b/APP/src/app/features/payee1099/pages/payee-1099-page/payee-1099-page.component.ts
@@ -0,0 +1,234 @@
+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 { InputsModule } from '@progress/kendo-angular-inputs';
+import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
+import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
+import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
+import { Payee1099ApiService } from '../../services/payee1099-api.service';
+import { Payee1099ListItem, Payee1099, SavePayee1099Request } from '../../models/payee1099.model';
+import { MemberApiService } from '../../../members/services/member-api.service';
+import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
+import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
+import { HasPermissionDirective } from '../../../../core/directives/has-permission.directive';
+
+/** Flattened member item with a single displayName field for the picker. */
+interface MemberOption { id: number; displayName: string; }
+
+/** Editable form model for the New/Edit dialog. */
+interface Payee1099Form {
+ legalName: string;
+ displayName: string;
+ memberId: number | null;
+ taxClassification: string;
+ is1099Tracked: boolean;
+ tinType: string;
+ tin: string;
+ addressLine1: string;
+ addressLine2: string;
+ city: string;
+ state: string;
+ zip: string;
+ email: string;
+ phone: string;
+ w9Status: string;
+ w9ReceivedDate: Date | null;
+ isActive: boolean;
+ notes: string;
+}
+
+@Component({
+ selector: 'app-payee-1099-page',
+ standalone: true,
+ imports: [
+ CommonModule, FormsModule, GridModule, ButtonsModule, DialogsModule,
+ InputsModule, DropDownsModule, DateInputsModule, ContextMenuModule,
+ PageHeaderActionsDirective, HasPermissionDirective,
+ ],
+ templateUrl: './payee-1099-page.component.html',
+ styleUrls: ['./payee-1099-page.component.scss'],
+})
+export class Payee1099PageComponent implements OnInit {
+ recipients: Payee1099ListItem[] = [];
+ loading = false;
+ includeInactive = false;
+
+ readonly taxClassifications = ['Individual', 'SoleProprietor', 'Partnership', 'CCorp', 'SCorp', 'LLC', 'Other'];
+ readonly tinTypes = ['SSN', 'EIN'];
+ readonly w9Statuses = ['Missing', 'Requested', 'OnFile', 'Expired'];
+
+ /** Member picker options, filled on demand from the members search. */
+ memberResults: MemberOption[] = [];
+
+ @ViewChild('rowMenu') rowMenu!: ContextMenuComponent;
+ rowMenuItems: { text: string }[] = [];
+ private contextRow: Payee1099ListItem | null = null;
+
+ dialogOpen = false;
+ editingId: number | null = null;
+ /** Last-4 of the existing TIN (edit mode), so the TIN box can show a masked placeholder. */
+ editingTinLast4: string | null = null;
+ form: Payee1099Form = this.blankForm();
+
+ constructor(
+ private api: Payee1099ApiService,
+ private memberApi: MemberApiService,
+ ) {}
+
+ ngOnInit(): void {
+ this.load();
+ }
+
+ load(): void {
+ this.loading = true;
+ this.api.getAll(this.includeInactive).subscribe({
+ next: (rows) => {
+ this.recipients = rows;
+ this.loading = false;
+ },
+ error: () => { this.loading = false; },
+ });
+ }
+
+ private blankForm(): Payee1099Form {
+ return {
+ legalName: '', displayName: '', memberId: null,
+ taxClassification: 'Individual', is1099Tracked: true,
+ tinType: 'SSN', tin: '',
+ addressLine1: '', addressLine2: '', city: '', state: '', zip: '',
+ email: '', phone: '',
+ w9Status: 'Missing', w9ReceivedDate: null,
+ isActive: true, notes: '',
+ };
+ }
+
+ // ── Member picker (server-side search, same source as the expense form) ──────
+ onMemberFilter(term: string): void {
+ if (!term || term.length < 1) { this.memberResults = []; return; }
+ this.memberApi.getPaged({ search: term, pageSize: 10 }).subscribe((result) => {
+ this.memberResults = result.items.map((member: MemberListItemDto) => ({
+ id: member.id,
+ displayName: memberDisplayName(member),
+ }));
+ });
+ }
+
+ // ── Row interaction: primary click opens the editor; right-click shows actions ──
+ onCellClick(event: CellClickEvent): void {
+ if (event.type === 'contextmenu') {
+ event.originalEvent.preventDefault();
+ this.contextRow = event.dataItem;
+ this.rowMenuItems = this.buildMenuItems(event.dataItem.isActive);
+ this.rowMenu.show({ left: event.originalEvent.pageX, top: event.originalEvent.pageY });
+ } else {
+ this.openEdit(event.dataItem);
+ }
+ }
+
+ onRowMenuSelect(event: ContextMenuSelectEvent): void {
+ if (!this.contextRow) return;
+ if (event.item.text === 'Edit') this.openEdit(this.contextRow);
+ else if (event.item.text === 'Deactivate') this.deactivate(this.contextRow);
+ }
+
+ private buildMenuItems(isActive: boolean): { text: string }[] {
+ const items: { text: string }[] = [{ text: 'Edit' }];
+ if (isActive) items.push({ text: 'Deactivate' });
+ return items;
+ }
+
+ // ── Dialog open ──────────────────────────────────────────────────────────────
+ openNew(): void {
+ this.editingId = null;
+ this.editingTinLast4 = null;
+ this.form = this.blankForm();
+ this.dialogOpen = true;
+ }
+
+ openEdit(row: Payee1099ListItem): void {
+ this.editingId = row.id;
+ this.dialogOpen = true;
+ // Load the full record so the dialog can prefill the address/contact/notes fields.
+ this.api.getById(row.id).subscribe((payee: Payee1099) => {
+ this.editingTinLast4 = payee.tinLast4 ?? null;
+ this.form = {
+ legalName: payee.legalName,
+ displayName: payee.displayName ?? '',
+ memberId: payee.memberId ?? null,
+ taxClassification: payee.taxClassification,
+ is1099Tracked: payee.is1099Tracked,
+ tinType: payee.tinType ?? 'SSN',
+ tin: '',
+ addressLine1: payee.addressLine1 ?? '',
+ addressLine2: payee.addressLine2 ?? '',
+ city: payee.city ?? '',
+ state: payee.state ?? '',
+ zip: payee.zip ?? '',
+ email: payee.email ?? '',
+ phone: payee.phone ?? '',
+ w9Status: payee.w9Status,
+ w9ReceivedDate: this.parseDateOnly(payee.w9ReceivedDate),
+ isActive: payee.isActive,
+ notes: payee.notes ?? '',
+ };
+ // Seed the picker with the linked member so its name shows even before a search.
+ if (payee.memberId != null && payee.memberName) {
+ this.memberResults = [{ id: payee.memberId, displayName: payee.memberName }];
+ }
+ });
+ }
+
+ // ── Save ─────────────────────────────────────────────────────────────────────
+ save(): void {
+ if (!this.form.legalName.trim()) return;
+ const typedTin = this.form.tin.trim();
+ const request: SavePayee1099Request = {
+ legalName: this.form.legalName.trim(),
+ displayName: this.form.displayName.trim() || undefined,
+ memberId: this.form.memberId ?? null,
+ taxClassification: this.form.taxClassification,
+ is1099Tracked: this.form.is1099Tracked,
+ tinType: this.form.tinType,
+ // Send the typed TIN when present. On edit a blank leaves the stored value
+ // unchanged (null = no change); on new a blank simply means no TIN yet.
+ tin: typedTin || null,
+ addressLine1: this.form.addressLine1.trim() || undefined,
+ addressLine2: this.form.addressLine2.trim() || undefined,
+ city: this.form.city.trim() || undefined,
+ state: this.form.state.trim() || undefined,
+ zip: this.form.zip.trim() || undefined,
+ email: this.form.email.trim() || undefined,
+ phone: this.form.phone.trim() || undefined,
+ w9Status: this.form.w9Status,
+ w9ReceivedDate: this.toDateOnly(this.form.w9ReceivedDate),
+ isActive: this.form.isActive,
+ notes: this.form.notes.trim() || undefined,
+ };
+ const done = () => { this.dialogOpen = false; this.load(); };
+ if (this.editingId == null) this.api.create(request).subscribe(done);
+ else this.api.update(this.editingId, request).subscribe(done);
+ }
+
+ deactivate(row: Payee1099ListItem): void {
+ if (!confirm(`Deactivate "${row.legalName}"?`)) return;
+ this.api.delete(row.id).subscribe(() => this.load());
+ }
+
+ // ── Date-only helpers: build/parse "yyyy-MM-dd" from LOCAL components ─────────
+ private parseDateOnly(value: string | undefined | null): Date | null {
+ if (!value) return null;
+ const [year, month, day] = value.split('-').map(Number);
+ return new Date(year, month - 1, day);
+ }
+
+ private toDateOnly(date: Date | null): string | null {
+ if (!date) return null;
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ return `${year}-${month}-${day}`;
+ }
+}
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 1d1c991..7f3ac55 100644
--- a/APP/src/app/portals/user-portal/user-portal.component.ts
+++ b/APP/src/app/portals/user-portal/user-portal.component.ts
@@ -138,6 +138,8 @@ export class UserPortalComponent implements OnInit, OnDestroy {
permission: { module: PermissionModules.Disbursements, action: 'read' } },
{ text: 'Check Register', icon: walletOutlineIcon, path: '/user-portal/finance/check-register',
permission: { module: PermissionModules.Disbursements, action: 'read' } },
+ { text: '1099 Recipients', icon: fileReportIcon, path: '/user-portal/finance/payee-1099',
+ permission: { module: PermissionModules.Form1099, action: 'read' } },
],
},
{