feat(expense): add reusable expense form dialog with category cascade
Standalone ExpenseFormDialogComponent with Ministry → Category Group → SubCategory cascade, vendor/reimbursement modes, optional member picker (MemberApiService search-as-you-type), and receipt file input. Emits CreateExpenseRequest + File. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+102
@@ -0,0 +1,102 @@
|
|||||||
|
<kendo-dialog [title]="title" (close)="cancel.emit()" [width]="560">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
||||||
|
|
||||||
|
<!-- Member picker (finance creating on behalf of a member) -->
|
||||||
|
<label *ngIf="allowMemberPick" class="flex flex-col gap-1 md:col-span-2">Member
|
||||||
|
<kendo-dropdownlist
|
||||||
|
[data]="memberResults"
|
||||||
|
textField="displayName"
|
||||||
|
valueField="id"
|
||||||
|
[valuePrimitive]="true"
|
||||||
|
[filterable]="true"
|
||||||
|
(filterChange)="onMemberFilter($event)"
|
||||||
|
[(ngModel)]="form.memberId"
|
||||||
|
placeholder="Search member by name">
|
||||||
|
</kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Ministry -->
|
||||||
|
<label class="flex flex-col gap-1">Ministry
|
||||||
|
<kendo-dropdownlist
|
||||||
|
[data]="ministries"
|
||||||
|
textField="name_en"
|
||||||
|
valueField="id"
|
||||||
|
[valuePrimitive]="true"
|
||||||
|
[(ngModel)]="form.ministryId"
|
||||||
|
[defaultItem]="{ id: null, name_en: '-- Select ministry --' }">
|
||||||
|
</kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Category Group -->
|
||||||
|
<label class="flex flex-col gap-1">Category Group
|
||||||
|
<kendo-dropdownlist
|
||||||
|
[data]="groups"
|
||||||
|
textField="name_en"
|
||||||
|
valueField="id"
|
||||||
|
[valuePrimitive]="true"
|
||||||
|
[(ngModel)]="form.categoryGroupId"
|
||||||
|
(valueChange)="onGroupChange($event)"
|
||||||
|
[defaultItem]="{ id: null, name_en: '-- Select group --' }">
|
||||||
|
</kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Sub-Category -->
|
||||||
|
<label class="flex flex-col gap-1">Sub-Category
|
||||||
|
<kendo-dropdownlist
|
||||||
|
[data]="subs"
|
||||||
|
textField="name_en"
|
||||||
|
valueField="id"
|
||||||
|
[valuePrimitive]="true"
|
||||||
|
[(ngModel)]="form.subCategoryId"
|
||||||
|
[defaultItem]="{ id: null, name_en: '-- Select sub-category --' }"
|
||||||
|
[disabled]="!form.categoryGroupId">
|
||||||
|
</kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Amount -->
|
||||||
|
<label class="flex flex-col gap-1">Amount
|
||||||
|
<kendo-numerictextbox
|
||||||
|
[(ngModel)]="form.amount"
|
||||||
|
[min]="0"
|
||||||
|
[format]="'c2'">
|
||||||
|
</kendo-numerictextbox>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Expense Date -->
|
||||||
|
<label class="flex flex-col gap-1">Expense Date
|
||||||
|
<kendo-datepicker [(ngModel)]="form.expenseDate"></kendo-datepicker>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<label class="flex flex-col gap-1 md:col-span-2">Description
|
||||||
|
<kendo-textbox [(ngModel)]="form.description" placeholder="Brief description of expense"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Vendor mode: vendor name + check number -->
|
||||||
|
<ng-container *ngIf="mode === 'vendor'">
|
||||||
|
<label class="flex flex-col gap-1">Vendor Name
|
||||||
|
<kendo-textbox [(ngModel)]="form.vendorName" placeholder="Payee / vendor name"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">Check #
|
||||||
|
<kendo-textbox [(ngModel)]="form.checkNumber" placeholder="Check number (optional)"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Reimbursement mode: receipt file input -->
|
||||||
|
<ng-container *ngIf="mode === 'reimbursement'">
|
||||||
|
<label class="flex flex-col gap-1 md:col-span-2">Receipt (optional)
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*,application/pdf"
|
||||||
|
(change)="onFileSelected($event)"
|
||||||
|
class="block w-full text-sm text-gray-700 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:text-sm file:bg-gray-100 hover:file:bg-gray-200" />
|
||||||
|
</label>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<kendo-dialog-actions>
|
||||||
|
<button kendoButton (click)="cancel.emit()">Cancel</button>
|
||||||
|
<button kendoButton themeColor="primary" [disabled]="!isValid" (click)="emitSave()">Save</button>
|
||||||
|
</kendo-dialog-actions>
|
||||||
|
</kendo-dialog>
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||||
|
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||||
|
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||||||
|
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
||||||
|
import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
|
||||||
|
import { MinistryApiService } from '../../services/ministry-api.service';
|
||||||
|
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
|
||||||
|
import { MemberApiService } from '../../../members/services/member-api.service';
|
||||||
|
import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
|
||||||
|
import {
|
||||||
|
MinistryDto, ExpenseCategoryGroupDto, ExpenseSubCategoryDto, ExpenseType, CreateExpenseRequest,
|
||||||
|
} from '../../models/expense.model';
|
||||||
|
|
||||||
|
export interface ExpenseFormResult { request: CreateExpenseRequest; receipt: File | null; }
|
||||||
|
|
||||||
|
/** Flattened member item with a single displayName field for the dropdown. */
|
||||||
|
interface MemberOption { id: number; displayName: string; }
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-expense-form-dialog',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, InputsModule, ButtonsModule, DialogsModule, DropDownsModule, DateInputsModule],
|
||||||
|
templateUrl: './expense-form-dialog.component.html',
|
||||||
|
})
|
||||||
|
export class ExpenseFormDialogComponent implements OnInit {
|
||||||
|
@Input() mode: 'vendor' | 'reimbursement' = 'reimbursement';
|
||||||
|
@Input() allowMemberPick = false;
|
||||||
|
@Input() title = 'New Expense';
|
||||||
|
@Output() save = new EventEmitter<ExpenseFormResult>();
|
||||||
|
@Output() cancel = new EventEmitter<void>();
|
||||||
|
|
||||||
|
ministries: MinistryDto[] = [];
|
||||||
|
groups: ExpenseCategoryGroupDto[] = [];
|
||||||
|
subs: ExpenseSubCategoryDto[] = [];
|
||||||
|
|
||||||
|
memberResults: MemberOption[] = [];
|
||||||
|
|
||||||
|
form = {
|
||||||
|
ministryId: null as number | null,
|
||||||
|
categoryGroupId: null as number | null,
|
||||||
|
subCategoryId: null as number | null,
|
||||||
|
amount: 0,
|
||||||
|
description: '',
|
||||||
|
vendorName: '',
|
||||||
|
checkNumber: '',
|
||||||
|
memberId: null as number | null,
|
||||||
|
expenseDate: new Date(),
|
||||||
|
};
|
||||||
|
receipt: File | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private ministryApi: MinistryApiService,
|
||||||
|
private catApi: ExpenseCategoryApiService,
|
||||||
|
private memberApi: MemberApiService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.ministryApi.getAll().subscribe(m => (this.ministries = m));
|
||||||
|
this.catApi.getAll(false).subscribe(g => (this.groups = g));
|
||||||
|
}
|
||||||
|
|
||||||
|
onGroupChange(groupId: number | null): void {
|
||||||
|
this.form.subCategoryId = null;
|
||||||
|
this.subs = this.groups.find(g => g.id === groupId)?.subCategories ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
onMemberFilter(term: string): void {
|
||||||
|
if (!term || term.length < 1) { this.memberResults = []; return; }
|
||||||
|
this.memberApi.getPaged({ search: term, pageSize: 10 })
|
||||||
|
.subscribe(r => {
|
||||||
|
this.memberResults = r.items.map((m: MemberListItemDto) => ({
|
||||||
|
id: m.id,
|
||||||
|
displayName: memberDisplayName(m),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onFileSelected(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
this.receipt = input.files?.[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isValid(): boolean {
|
||||||
|
return !!this.form.ministryId && !!this.form.categoryGroupId && !!this.form.subCategoryId
|
||||||
|
&& this.form.amount > 0 && this.form.description.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
emitSave(): void {
|
||||||
|
if (!this.isValid) return;
|
||||||
|
const d = this.form.expenseDate;
|
||||||
|
const expenseDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
const request: CreateExpenseRequest = {
|
||||||
|
type: (this.mode === 'vendor' ? 'VendorPayment' : 'StaffReimbursement') as ExpenseType,
|
||||||
|
ministryId: this.form.ministryId!,
|
||||||
|
categoryGroupId: this.form.categoryGroupId!,
|
||||||
|
subCategoryId: this.form.subCategoryId!,
|
||||||
|
amount: this.form.amount,
|
||||||
|
description: this.form.description.trim(),
|
||||||
|
vendorName: this.mode === 'vendor' ? (this.form.vendorName || null) : null,
|
||||||
|
memberId: this.allowMemberPick ? this.form.memberId : null,
|
||||||
|
checkNumber: this.mode === 'vendor' ? (this.form.checkNumber || null) : null,
|
||||||
|
expenseDate,
|
||||||
|
notes: null,
|
||||||
|
};
|
||||||
|
this.save.emit({ request, receipt: this.receipt });
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user