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.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' } }, ], }, {