Consolidated commit message to v22 signals

This commit is contained in:
tanshu committed 2026-08-25 14:45:09 +00:00
1 parent 6403d25d3e
commit df289b20b8
443 files changed
+16058 -17332

No files matched your search

@@ -1,18 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { ResolveFn } from '@angular/router';
import { Account } from '../core/account';
import { paymentAccountsResolver } from './payment-accounts.resolver';
describe('paymentAccountsResolver', () => {
const executeResolver: ResolveFn<Account[]> = (...resolverParameters) =>
TestBed.runInInjectionContext(() => paymentAccountsResolver(...resolverParameters));
beforeEach(() => {
TestBed.configureTestingModule({});
});
it('should be created', () => {
expect(executeResolver).toBeTruthy();
});
});
@@ -1,9 +0,0 @@
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { Account } from '../core/account';
import { AccountService } from '../core/account.service';
export const paymentAccountsResolver: ResolveFn<Account[]> = () => {
return inject(AccountService).paymentAutocomplete('');
};
@@ -1,16 +0,0 @@
import { DataSource } from '@angular/cdk/collections';
import { Observable } from 'rxjs';
import { Journal } from '../core/journal';
export class PaymentDataSource extends DataSource<Journal> {
constructor(private data: Observable<Journal[]>) {
super();
}
connect(): Observable<Journal[]> {
return this.data;
}
disconnect() {}
}
@@ -1,6 +1,6 @@
<h1 mat-dialog-title>Edit Payment Entry</h1>
<div mat-dialog-content>
<form [formGroup]="form">
<form [formRoot]="form">
<div class="row-container">
<mat-form-field class="flex-auto basis-2-3">
<mat-label>Account</mat-label>
@@ -9,29 +9,27 @@
matInput
#accountElement
[matAutocomplete]="auto"
formControlName="account"
[formField]="form.account"
autocomplete="off"
/>
@if (accBal) {
@if (accBal()) {
<mat-hint>
Balance as on Date: <strong>{{ accBal.date | currency: 'INR' | accounting }}</strong> / Final balance:
<strong>{{ accBal.total | currency: 'INR' | accounting }}</strong>
<div class="balance">
Balance as on Date: <strong>{{ accBal()?.date | currency: 'INR' | accounting }}</strong
><br />
Total Balance: <strong>{{ accBal()?.total | currency: 'INR' | accounting }}</strong>
</div>
</mat-hint>
}
<mat-autocomplete #auto="matAutocomplete" autoActiveFirstOption [displayWith]="displayFn">
@for (account of accounts(); track account.id) {
<mat-option [value]="account">{{ account.name }}</mat-option>
}
</mat-autocomplete>
</mat-form-field>
<mat-autocomplete
#auto="matAutocomplete"
autoActiveFirstOption
(optionSelected)="accountSelected($event)"
[displayWith]="displayFn"
>
@for (account of accounts | async; track account) {
<mat-option [value]="account">{{ account.name }}</mat-option>
}
</mat-autocomplete>
<mat-form-field class="flex-auto basis-1-3">
<mat-label>Amount</mat-label>
<input type="text" matInput formControlName="amount" autocomplete="off" />
<input type="text" matInput [formField]="form.amount" autocomplete="off" />
<span matTextPrefix>₹&nbsp;</span>
</mat-form-field>
</div>
@@ -1,23 +1,24 @@
import { CdkScrollableModule } from '@angular/cdk/scrolling';
import { AsyncPipe, CurrencyPipe } from '@angular/common';
import { ChangeDetectorRef, Component, inject, OnInit } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { CurrencyPipe } from '@angular/common';
import { Component, computed, inject, linkedSignal } from '@angular/core';
import { form, FormField, FormRoot } from '@angular/forms/signals';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button';
import { MatOptionModule } from '@angular/material/core';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { Observable, of as observableOf } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
import { Account } from '../core/account';
import { AccountBalance } from '../core/account-balance';
import { AccountService } from '../core/account.service';
import { Journal } from '../core/journal';
import { AccountingPipe } from '../shared/accounting.pipe';
import { MathService } from '../shared/math.service';
export interface PaymentDialogFormData {
account: Account | string | null;
amount: string;
}
@Component({
selector: 'app-payment-dialog',
templateUrl: './payment-dialog.component.html',
@@ -25,18 +26,17 @@ import { MathService } from '../shared/math.service';
imports: [
MatDialogModule,
CdkScrollableModule,
ReactiveFormsModule,
FormField,
FormRoot,
MatFormFieldModule,
MatInputModule,
MatAutocompleteModule,
MatOptionModule,
MatButtonModule,
AsyncPipe,
CurrencyPipe,
AccountingPipe,
],
})
export class PaymentDialogComponent implements OnInit {
export class PaymentDialogComponent {
dialogRef = inject<MatDialogRef<PaymentDialogComponent>>(MatDialogRef);
data = inject<{
journal: Journal;
@@ -45,56 +45,52 @@ export class PaymentDialogComponent implements OnInit {
private math = inject(MathService);
private accountSer = inject(AccountService);
private cd = inject(ChangeDetectorRef);
accounts: Observable<Account[]>;
form: FormGroup<{
account: FormControl<string | null>;
amount: FormControl<string>;
}>;
formModel = linkedSignal<
{
journal: Journal;
date: string;
},
PaymentDialogFormData
>({
source: () => this.data,
computation: (data: { journal: Journal; date: string }) => ({
account: data.journal.account,
amount: `${data.journal.amount}`,
}),
});
account: Account = new Account();
accBal: AccountBalance | null = null;
form = form(this.formModel);
constructor() {
this.form = new FormGroup({
account: new FormControl(''),
amount: new FormControl('', { nonNullable: true }),
});
this.accBal = null;
// Setup Account Autocomplete
this.accounts = this.form.controls.account.valueChanges.pipe(
debounceTime(150),
distinctUntilChanged(),
switchMap((x) => (x === null ? observableOf([]) : this.accountSer.autocomplete(x))),
);
}
accountSearch = computed(() => {
const v = this.form.account().value();
return typeof v === 'string' ? v : null;
});
ngOnInit() {
this.form.setValue({
account: this.data.journal.account.name,
amount: `${this.data.journal.amount}`,
});
this.account = this.data.journal.account;
}
accountsResource = this.accountSer.autocomplete(this.accountSearch);
accounts = computed(() => this.accountsResource.value() ?? []);
displayFn(account?: Account | string): string {
return !account ? '' : typeof account === 'string' ? account : account.name;
}
balanceDate = computed(() => this.data.date);
accountId = computed((): string | null => {
const account = this.form.account().value();
return typeof account === 'string' ? null : (account?.id ?? null);
});
accountSelected(event: MatAutocompleteSelectedEvent): void {
const account = event.option.value;
this.account = account;
this.accountSer.balance(account.id as string, this.data.date).subscribe((v) => {
this.accBal = v;
this.cd.detectChanges();
});
accBalResource = this.accountSer.balance(this.accountId, this.balanceDate);
accBal = computed(() => this.accBalResource.value() ?? null);
displayFn(account?: Account): string {
return account ? account.name : '';
}
accept(): void {
const formValue = this.form.value;
const amount = this.math.parseAmount(formValue.amount, 2);
this.data.journal.account = this.account;
const formValue = this.form().value();
const account = typeof formValue.account === 'string' ? null : formValue.account;
if (!account || !account.id) {
return;
}
const amount = this.math.parseAmount(formValue.amount ?? '0', 2);
this.data.journal.account = account;
this.data.journal.amount = amount;
this.dialogRef.close(this.data.journal);
}
+129 -118
View File
@@ -1,21 +1,26 @@
<h2 class="row-container space-between">
<span>Payment</span>
<mat-icon matSuffix (click)="voucher.isStarred = !voucher.isStarred" class="pointer" [class.gold]="voucher.isStarred">
{{ voucher.isStarred ? 'star' : 'star_border' }}
<mat-icon
matSuffix
(click)="form.isStarred().value.set(!form.isStarred())"
class="pointer"
[class.gold]="form.isStarred()"
>
{{ form.isStarred() ? 'star' : 'star_border' }}
</mat-icon>
</h2>
<form [formGroup]="form" class="flex-col">
<form class="flex-col" [formRoot]="form">
<div class="row-container">
<mat-form-field class="flex-auto basis-2-5">
<mat-label>Date</mat-label>
<input matInput [matDatepicker]="date" formControlName="date" autocomplete="off" #dateElement />
<input matInput [matDatepicker]="date" [formField]="form.date" autocomplete="off" #dateElement />
<mat-datepicker-toggle matSuffix [for]="date"></mat-datepicker-toggle>
<mat-datepicker #date></mat-datepicker>
</mat-form-field>
<mat-form-field class="flex-auto basis-2-5">
<mat-select formControlName="paymentAccount">
@for (account of paymentAccounts; track account) {
<mat-select [formField]="form.paymentAccount">
@for (account of paymentAccounts(); track account.id) {
<mat-option [value]="account.id">
{{ account.name }}
</mat-option>
@@ -24,12 +29,12 @@
</mat-form-field>
<mat-form-field class="flex-auto basis-1-5">
<mat-label>Payment Amount</mat-label>
<input type="text" matInput formControlName="paymentAmount" />
<input type="number" matInput [formField]="form.paymentAmount" />
<span matTextPrefix>₹&nbsp;</span>
</mat-form-field>
</div>
<div formGroupName="addRow" class="row-container">
<div class="row-container">
<mat-form-field class="flex-auto basis-55">
<mat-label>Account</mat-label>
<input
@@ -37,143 +42,149 @@
matInput
#accountElement
[matAutocomplete]="auto"
formControlName="account"
[formField]="form.addRow.account"
autocomplete="off"
/>
@if (accBal) {
@if (accBal()) {
<mat-hint>
Balance as on Date: <strong>{{ accBal.date | currency: 'INR' | accounting }}</strong> / Final balance:
<strong>{{ accBal.total | currency: 'INR' | accounting }}</strong>
Balance as on Date: <strong>{{ accBal()?.date | currency: 'INR' | accounting }}</strong> / Final balance:
<strong>{{ accBal()?.total | currency: 'INR' | accounting }}</strong>
</mat-hint>
}
<mat-autocomplete #auto="matAutocomplete" autoActiveFirstOption [displayWith]="displayFn">
@for (account of accounts(); track account.id) {
<mat-option [value]="account">{{ account.name }}</mat-option>
}
</mat-autocomplete>
</mat-form-field>
<mat-autocomplete
#auto="matAutocomplete"
autoActiveFirstOption
[displayWith]="displayFn"
(optionSelected)="accountSelected($event)"
>
@for (account of accounts | async; track account) {
<mat-option [value]="account">{{ account.name }}</mat-option>
}
</mat-autocomplete>
<mat-form-field class="flex-auto basis-1-4">
<mat-label>Amount</mat-label>
<input type="text" matInput formControlName="amount" autocomplete="off" />
<input type="text" matInput [formField]="form.addRow.amount" autocomplete="off" />
<span matTextPrefix>₹&nbsp;</span>
</mat-form-field>
<button mat-raised-button color="primary" (click)="addRow()" class="flex-auto basis-1-5">Add</button>
</div>
<mat-table #table [dataSource]="dataSource" matSort aria-label="Elements">
<!-- Account Column -->
<ng-container matColumnDef="account">
<mat-header-cell *matHeaderCellDef>Account</mat-header-cell>
<mat-cell *matCellDef="let row">{{ row.account.name }}</mat-cell>
</ng-container>
@if (itemResource.isLoading()) {
<app-skeleton-loader type="table" [count]="1" />
} @else if (itemResource.error()) {
<app-error-state [message]="'Failed to load data.'" (retryAction)="itemResource.reload()" />
} @else {
<mat-table #table [dataSource]="form.journals().value() ?? []" matSort (matSortChange)="sortData($event)">
<!-- Account Column -->
<ng-container matColumnDef="account">
<mat-header-cell *matHeaderCellDef>Account</mat-header-cell>
<mat-cell *matCellDef="let row">{{ row.account.name }}</mat-cell>
</ng-container>
<!-- Amount Column -->
<ng-container matColumnDef="amount">
<mat-header-cell *matHeaderCellDef class="right">Amount</mat-header-cell>
<mat-cell *matCellDef="let row" class="right">{{ row.amount | currency: 'INR' }}</mat-cell>
</ng-container>
<!-- Amount Column -->
<ng-container matColumnDef="amount">
<mat-header-cell *matHeaderCellDef class="right">Amount</mat-header-cell>
<mat-cell *matCellDef="let row" class="right">{{ row.amount | currency: 'INR' }}</mat-cell>
</ng-container>
<!-- Action Column -->
<ng-container matColumnDef="action">
<mat-header-cell *matHeaderCellDef class="center">Action</mat-header-cell>
<mat-cell *matCellDef="let row" class="center">
<button mat-icon-button tabindex="-1" (click)="editRow(row)">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button tabindex="-1" color="warn" (click)="deleteRow(row)">
<mat-icon>delete</mat-icon>
</button>
</mat-cell>
</ng-container>
<!-- Action Column -->
<ng-container matColumnDef="action">
<mat-header-cell *matHeaderCellDef class="center">Action</mat-header-cell>
<mat-cell *matCellDef="let row" class="center">
<button mat-icon-button tabindex="-1" (click)="editRow(row)">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button tabindex="-1" color="warn" (click)="deleteRow(row)">
<mat-icon>delete</mat-icon>
</button>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns"></mat-row>
</mat-table>
<div class="row-container">
<mat-form-field class="flex-auto">
<mat-label>Narration</mat-label>
<textarea matInput matAutosizeMinRows="5" formControlName="narration"></textarea>
</mat-form-field>
</div>
<div class="row-container">
<mat-form-field class="flex-auto">
<mat-label>Tags</mat-label>
<mat-chip-grid #chipGrid aria-label="Tag selection">
@for (tag of voucher.tags; track tag) {
<mat-chip-row (removed)="removeTag(tag)">
{{ tag.name }}
<button matChipRemove [attr.aria-label]="'remove ' + tag.name">
<mat-icon>cancel</mat-icon>
</button>
</mat-chip-row>
}
</mat-chip-grid>
<input
placeholder="New Tag..."
#tagInput
formControlName="tags"
[matChipInputFor]="chipGrid"
[matAutocomplete]="autoTag"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
(matChipInputTokenEnd)="addTag($event)"
/>
<mat-autocomplete #autoTag="matAutocomplete" (optionSelected)="selectedTag($event)">
@for (tag of tags | async; track tag) {
<mat-option [value]="tag">{{ tag.name }}</mat-option>
}
</mat-autocomplete>
</mat-form-field>
</div>
<div class="row-container">
@for (item of voucher.files; track item) {
<div class="img-container" class="flex-auto basis-1-5">
<img [src]="item.thumbnail" alt="img" (click)="zoomImage(item)" (keyup.enter)="zoomImage(item)" tabindex="0" />
<button mat-icon-button class="overlay" (click)="deleteImage(item)">
<mat-icon>delete</mat-icon>
</button>
</div>
}
<div class="img-container" class="flex flex-auto basis-1-5">
<label ng-href for="fileUp">
<img
alt="add"
src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMTUwIj48cmVjdCB3aWR0aD0iMTAwIiBoZWlnaHQ9IjE1MCIgZmlsbD0iI2VlZSIvPjx0ZXh0IHRleHQtYW5jaG9yPSJtaWRkbGUiIHg9IjUwIiB5PSI3NSIgc3R5bGU9ImZpbGw6I2FhYTtmb250LXdlaWdodDpib2xkO2ZvbnQtc2l6ZToxMDBweDtmb250LWZhbWlseTpBcmlhbCxIZWx2ZXRpY2Esc2Fucy1zZXJpZjtkb21pbmFudC1iYXNlbGluZTpjZW50cmFsIj4rPC90ZXh0Pjwvc3ZnPg=="
/>
</label>
<input
type="file"
id="fileUp"
multiple
accept="image/*"
style="display: none"
(change)="image.detectFiles($event, voucher)"
/>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns"></mat-row>
</mat-table>
<div class="row-container">
<mat-form-field class="flex-auto">
<mat-label>Narration</mat-label>
<textarea matInput matAutosizeMinRows="5" [formField]="form.narration"></textarea>
</mat-form-field>
</div>
</div>
<div class="row-container">
<mat-form-field class="flex-auto">
<mat-label>Tags</mat-label>
<mat-chip-grid #chipGrid aria-label="Tag selection">
@for (tag of form.tagList().value(); track tag) {
<mat-chip-row (removed)="removeTag(tag)">
{{ tag.name }}
<button matChipRemove [attr.aria-label]="'remove ' + tag.name">
<mat-icon>cancel</mat-icon>
</button>
</mat-chip-row>
}
</mat-chip-grid>
<input
placeholder="New Tag..."
#tagInput
[formField]="form.tagInput"
[matChipInputFor]="chipGrid"
[matAutocomplete]="autoTag"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
(matChipInputTokenEnd)="addTag()"
/>
<mat-autocomplete #autoTag="matAutocomplete" (optionSelected)="selectedTag($event)">
@for (tag of tags(); track tag.id) {
<mat-option [value]="tag">{{ tag.name }}</mat-option>
}
</mat-autocomplete>
</mat-form-field>
</div>
<div class="row-container">
@for (item of form.files().value(); track item) {
<div class="img-container" class="flex-auto basis-1-5">
<img
[src]="item.thumbnail"
alt="img"
(click)="zoomImage(item)"
(keyup.enter)="zoomImage(item)"
tabindex="0"
/>
<button mat-icon-button class="overlay" (click)="deleteImage(item)">
<mat-icon>delete</mat-icon>
</button>
</div>
}
<div class="img-container" class="flex flex-auto basis-1-5">
<label ng-href for="fileUp">
<img
alt="add"
src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMTUwIj48cmVjdCB3aWR0aD0iMTAwIiBoZWlnaHQ9IjE1MCIgZmlsbD0iI2VlZSIvPjx0ZXh0IHRleHQtYW5jaG9yPSJtaWRkbGUiIHg9IjUwIiB5PSI3NSIgc3R5bGU9ImZpbGw6I2FhYTtmb250LXdlaWdodDpib2xkO2ZvbnQtc2l6ZToxMDBweDtmb250LWZhbWlseTpBcmlhbCxIZWx2ZXRpY2Esc2Fucy1zZXJpZjtkb21pbmFudC1iYXNlbGluZTpjZW50cmFsIj4rPC90ZXh0Pjwvc3ZnPg=="
/>
</label>
<input type="file" id="fileUp" multiple accept="image/*" style="display: none" (change)="detectFiles($event)" />
</div>
</div>
}
</form>
<div class="row-container">
<button mat-raised-button color="primary" (click)="save()" [disabled]="!canSave()" class="">
{{ voucher.id ? 'Update' : 'Save' }}
{{ id() ? 'Update' : 'Save' }}
</button>
@if (voucher.id) {
<button mat-raised-button (click)="post()" [disabled]="voucher.posted || !auth.allowed('post-vouchers')" class="">
{{ voucher.posted ? 'Posted' : 'Post' }}
@if (id()) {
<button
mat-raised-button
(click)="post()"
[disabled]="form.posted().controlValue() || !auth.allowed('post-vouchers')"
class=""
>
{{ form.posted().controlValue() ? 'Posted' : 'Post' }}
</button>
<button mat-raised-button color="warn" (click)="confirmDelete()" [disabled]="!canSave()">Delete</button>
}
<span class="spacer"></span>
@if (voucher.id) {
@if (id()) {
<span class="flex">
Created on&nbsp;<strong>{{ voucher.creationDate | localTime }}</strong
>&nbsp;and Last Edited on&nbsp; <strong>{{ voucher.lastEditDate | localTime }}</strong
>&nbsp;by&nbsp; <strong>{{ voucher.user.name }}</strong
>. {{ voucher.poster ? 'Posted by ' + voucher.poster : '' }}
Created on&nbsp;<strong>{{ form.creationDate().value() | localTime }}</strong
>&nbsp;and Last Edited on&nbsp; <strong>{{ form.lastEditDate().value() | localTime }}</strong
>&nbsp;by&nbsp; <strong>{{ form.userName() }}</strong
>.
{{ form.posted() ? 'Posted by ' + form.poster() : '' }}
</span>
}
</div>
+337 -254
View File
@@ -1,19 +1,20 @@
import { COMMA, ENTER } from '@angular/cdk/keycodes';
import { AsyncPipe, CurrencyPipe } from '@angular/common';
import { CurrencyPipe } from '@angular/common';
import {
AfterViewInit,
ChangeDetectorRef,
Component,
ElementRef,
HostListener,
inject,
OnInit,
ViewChild,
computed,
effect,
signal,
input,
linkedSignal,
afterNextRender,
} from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { form, FormField, FormRoot } from '@angular/forms/signals';
import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button';
import { MatChipInputEvent, MatChipsModule } from '@angular/material/chips';
import { MatChipsModule } from '@angular/material/chips';
import { MatOptionModule } from '@angular/material/core';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatDialog } from '@angular/material/dialog';
@@ -22,18 +23,17 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatSortModule } from '@angular/material/sort';
import { MatSortModule, Sort } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { Router } from '@angular/router';
import { round } from 'mathjs';
import moment from 'moment';
import { BehaviorSubject, Observable, of as observableOf } from 'rxjs';
import { debounceTime, distinctUntilChanged, map, switchMap } from 'rxjs/operators';
import { zip } from 'rxjs';
import { AuthService } from '../auth/auth.service';
import { Account } from '../core/account';
import { AccountBalance } from '../core/account-balance';
import { AccountService } from '../core/account.service';
import { CostCentre } from '../core/cost-centre';
import { DbFile } from '../core/db-file';
import { Journal } from '../core/journal';
import { User } from '../core/user';
@@ -41,22 +41,52 @@ import { Voucher } from '../core/voucher';
import { VoucherService } from '../core/voucher.service';
import { AccountingPipe } from '../shared/accounting.pipe';
import { ConfirmDialogComponent } from '../shared/confirm-dialog/confirm-dialog.component';
import { ErrorStateComponent } from '../shared/error-state/error-state.component';
import { ImageDialogComponent } from '../shared/image-dialog/image-dialog.component';
import { ImageService } from '../shared/image.service';
import { LocalTimePipe } from '../shared/local-time.pipe';
import { MathService } from '../shared/math.service';
import { SkeletonLoaderComponent } from '../shared/skeleton-loader/skeleton-loader.component';
import { Tag } from '../tag/tag';
import { TagService } from '../tag/tag.service';
import { PaymentDataSource } from './payment-datasource';
import { PaymentDialogComponent } from './payment-dialog.component';
export interface PaymentItemFormData {
id: string | undefined;
amount: number;
account: Account;
costCentre: CostCentre | null;
}
export interface PaymentFormModel {
isStarred: boolean;
date: Date;
paymentAccount: string;
paymentAmount: number;
addRow: {
account: Account | string | null;
amount: string;
};
journals: PaymentItemFormData[];
narration: string;
tagInput: string | Tag | null;
tagList: Tag[];
files: DbFile[];
posted: boolean;
creationDate: string;
lastEditDate: string;
userName: string;
poster: string;
}
@Component({
selector: 'app-payment',
templateUrl: './payment.component.html',
styleUrls: ['./payment.component.css'],
imports: [
FormField,
FormRoot,
MatIconModule,
ReactiveFormsModule,
MatFormFieldModule,
MatInputModule,
MatDatepickerModule,
@@ -67,14 +97,20 @@ import { PaymentDialogComponent } from './payment-dialog.component';
MatTableModule,
MatSortModule,
MatChipsModule,
AsyncPipe,
CurrencyPipe,
AccountingPipe,
LocalTimePipe,
SkeletonLoaderComponent,
ErrorStateComponent,
],
})
export class PaymentComponent implements OnInit, AfterViewInit {
private route = inject(ActivatedRoute);
export class PaymentComponent {
id = input(null, { transform: (v: string | null | undefined) => v ?? null });
d = input(null, { transform: (v: string | null | undefined) => v ?? null });
a = input(null, { transform: (v: string | null | undefined) => v ?? null });
type = signal('Payment');
private router = inject(Router);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
@@ -84,17 +120,89 @@ export class PaymentComponent implements OnInit, AfterViewInit {
private ser = inject(VoucherService);
private accountSer = inject(AccountService);
private tagSer = inject(TagService);
private cd = inject(ChangeDetectorRef);
@ViewChild('accountElement', { static: true }) accountElement!: ElementRef<HTMLInputElement>;
@ViewChild('dateElement', { static: true }) dateElement!: ElementRef<HTMLInputElement>;
@ViewChild('tagInput') tagInput?: ElementRef<HTMLInputElement>;
sortActive = signal('');
sortDirection = signal('');
itemResource = this.ser.getVoucher(this.id, this.type, this.a, this.d);
item = linkedSignal(() => this.itemResource.value() ?? new Voucher());
formModel = linkedSignal<Voucher, PaymentFormModel>({
source: this.item,
computation: (voucher): PaymentFormModel => {
const pj = voucher.journals.find((x) => x.debit === -1);
const debitJournals = voucher.journals.filter((x) => x.debit === 1);
const paymentAmount =
pj?.amount ??
round(
debitJournals.reduce((acc, j) => acc + j.amount, 0),
2,
);
return {
isStarred: voucher.isStarred,
date: voucher.date ? moment(voucher.date, 'DD-MMM-YYYY').toDate() : new Date(),
paymentAccount: pj?.account?.id ?? this.a() ?? '',
paymentAmount,
addRow: {
account: null as Account | string | null,
amount: '',
},
journals: debitJournals.map((j) => ({
id: j.id,
amount: j.amount,
account: j.account,
costCentre: j.costCentre,
})),
narration: voucher.narration ?? '',
tagInput: null as string | Tag | null,
tagList: [...voucher.tags],
files: [...voucher.files],
posted: voucher.posted,
creationDate: voucher.creationDate,
lastEditDate: voucher.lastEditDate,
userName: voucher.user?.name ?? '',
poster: voucher.poster ?? '',
};
},
});
form = form(this.formModel);
displayedColumns = ['account', 'amount', 'action'];
separatorKeysCodes: number[] = [ENTER, COMMA];
accountSearch = computed(() => {
const v = this.form.addRow.account().value();
return typeof v === 'string' ? v : null;
});
accountsResource = this.accountSer.autocomplete(this.accountSearch);
accounts = computed(() => this.accountsResource.value() ?? []);
tagQuery = computed(() => {
const tag = this.form.tagInput().value();
return tag === null ? '' : typeof tag !== 'string' ? tag.name.toLowerCase() : tag;
});
tagsResource = this.tagSer.autocomplete(this.tagQuery);
tags = computed(() => this.tagsResource.value() ?? []);
balanceDate = computed(() => moment(this.form.date().value()).format('DD-MMM-YYYY'));
accountId = computed((): string | null => {
const account = this.form.addRow.account().value();
return typeof account === 'string' ? null : (account?.id ?? null);
});
accBalResource = this.accountSer.balance(this.accountId, this.balanceDate);
accBal = computed(() => this.accBalResource.value() ?? null);
paymentAccountsResource = this.accountSer.paymentAutocomplete(signal(''));
paymentAccounts = computed(() => this.paymentAccountsResource.value() ?? []);
@HostListener('window:keydown.f2', ['$event'])
focusDate(event: Event) {
event.preventDefault();
this.dateElement.nativeElement.focus();
this.dateElement.nativeElement.select();
this.form().focusBoundControl();
}
@HostListener('window:keydown.f5', ['$event'])
@@ -107,8 +215,8 @@ export class PaymentComponent implements OnInit, AfterViewInit {
event.preventDefault();
this.router.navigate(['receipt'], {
queryParams: {
a: this.form.controls.paymentAccount.value,
d: this.form.controls.date.value.format('DD-MMM-YYYY'),
a: this.formModel().paymentAccount,
d: moment(this.formModel().date).format('DD-MMM-YYYY'),
},
queryParamsHandling: 'merge',
});
@@ -119,8 +227,7 @@ export class PaymentComponent implements OnInit, AfterViewInit {
event.preventDefault();
this.router.navigate(['journal'], {
queryParams: {
a: this.form.controls.paymentAccount.value,
d: this.form.controls.date.value.format('DD-MMM-YYYY'),
d: moment(this.formModel().date).format('DD-MMM-YYYY'),
},
queryParamsHandling: 'merge',
});
@@ -137,222 +244,174 @@ export class PaymentComponent implements OnInit, AfterViewInit {
@HostListener('window:keydown.control.p', ['$event'])
postListner(event: Event) {
event.preventDefault();
if (this.voucher.id && !this.voucher.posted && this.auth.allowed('post-vouchers')) {
if (this.id() && !this.form.posted().controlValue() && this.auth.allowed('post-vouchers')) {
this.post();
}
}
separatorKeysCodes: number[] = [ENTER, COMMA];
public journalObservable = new BehaviorSubject<Journal[]>([]);
dataSource: PaymentDataSource = new PaymentDataSource(this.journalObservable);
form: FormGroup<{
date: FormControl<moment.Moment>;
paymentAccount: FormControl<string>;
paymentAmount: FormControl<number>;
addRow: FormGroup<{
account: FormControl<string | null>;
amount: FormControl<string>;
}>;
narration: FormControl<string>;
tags: FormControl<string | null>;
}>;
paymentAccounts: Account[] = [];
paymentJournal: Journal = new Journal();
voucher: Voucher = new Voucher();
account: Account | null;
accBal: AccountBalance | null = null;
displayedColumns = ['account', 'amount', 'action'];
accounts: Observable<Account[]>;
tags: Observable<Tag[]>;
constructor() {
this.account = null;
this.form = new FormGroup({
date: new FormControl(moment(new Date()), { nonNullable: true }),
paymentAccount: new FormControl('', { nonNullable: true }),
paymentAmount: new FormControl({ value: 0, disabled: true }, { nonNullable: true }),
addRow: new FormGroup({
account: new FormControl(''),
amount: new FormControl('', { nonNullable: true }),
}),
narration: new FormControl('', { nonNullable: true }),
tags: new FormControl(''),
afterNextRender(() => {
this.form.addRow.account().focusBoundControl();
});
this.accBal = null;
// Listen to Account Autocomplete Change
this.accounts = this.form.controls.addRow.controls.account.valueChanges.pipe(
debounceTime(150),
distinctUntilChanged(),
switchMap((x) => (x === null ? observableOf([]) : this.accountSer.autocomplete(x))),
);
this.tags = this.form.controls.tags.valueChanges.pipe(
debounceTime(150),
distinctUntilChanged(),
map((tag: string | Tag | null) => (tag === null ? '' : typeof tag !== 'string' ? tag.name.toLowerCase() : tag)),
switchMap((tag: string) => this.tagSer.autocomplete(tag)),
);
// Listen to Date Change
this.form.controls.date.valueChanges.subscribe((x) =>
this.router.navigate([], {
relativeTo: this.route,
queryParams: { d: x.format('DD-MMM-YYYY') },
replaceUrl: true,
queryParamsHandling: 'merge',
}),
);
// Listen to Payment Account Change
this.form.controls.paymentAccount.valueChanges.subscribe((x) =>
this.router.navigate([], {
relativeTo: this.route,
queryParams: { a: x },
replaceUrl: true,
queryParamsHandling: 'merge',
}),
);
}
ngOnInit() {
this.route.data.subscribe((value) => {
const data = value as { voucher: Voucher; paymentAccounts: Account[] };
effect(() => {
const d = this.formModel().date;
if (!d) return;
this.paymentAccounts = data.paymentAccounts;
this.loadVoucher(data.voucher);
const url = new URL(window.location.href);
url.searchParams.set('d', moment(d).format('DD-MMM-YYYY'));
window.history.replaceState({}, '', url.toString());
});
}
ngAfterViewInit() {
this.focusAccount();
}
effect(() => {
const a = this.formModel().paymentAccount;
if (!a) return;
loadVoucher(voucher: Voucher) {
this.voucher = voucher;
[this.paymentJournal] = this.voucher.journals.filter((x) => x.debit === -1);
this.form.setValue({
date: moment(this.voucher.date, 'DD-MMM-YYYY'),
paymentAccount: this.paymentJournal.account.id ?? '',
paymentAmount: this.paymentJournal.amount,
addRow: {
account: '',
amount: '',
},
narration: this.voucher.narration,
tags: '',
const url = new URL(window.location.href);
url.searchParams.set('a', a);
window.history.replaceState({}, '', url.toString());
});
this.dataSource = new PaymentDataSource(this.journalObservable);
this.updateView();
}
focusAccount() {
setTimeout(() => {
this.accountElement.nativeElement.focus();
}, 0);
}
addRow() {
const amount = this.math.parseAmount(this.form.value.addRow?.amount ?? '0', 2);
const debit = 1;
if (this.account === null || amount <= 0) {
const formValue = this.form().value().addRow;
if (!formValue) return;
const amount = this.math.parseAmount(formValue.amount ?? '0', 2);
const account = formValue.account as Account;
if (!account || typeof account === 'string' || amount <= 0) {
return;
}
const oldFiltered = this.voucher.journals.filter((x) => x.account.id === (this.account as Account).id);
const old = oldFiltered.length ? oldFiltered[0] : null;
if (old && (old.debit === -1 || old.id === this.paymentJournal.id)) {
return;
}
if (old) {
old.amount += amount;
} else {
this.voucher.journals.push(
new Journal({
debit,
amount,
account: this.account,
costCentre: null,
}),
const currentJournals = this.formModel().journals;
const oldIndex = currentJournals.findIndex((x) => x.account.id === account.id);
let updatedJournals: PaymentItemFormData[];
if (oldIndex >= 0) {
updatedJournals = currentJournals.map((j, i) =>
i === oldIndex ? { ...j, amount: round(j.amount + amount, 2) } : j,
);
} else {
updatedJournals = [...currentJournals, { id: undefined, account, amount, costCentre: null }];
}
this.resetAddRow();
this.updateView();
const totalPaymentAmount = round(
updatedJournals.reduce((acc, j) => acc + j.amount, 0),
2,
);
this.formModel.update((m) => ({
...m,
journals: updatedJournals,
paymentAmount: totalPaymentAmount,
}));
this.form.addRow.account().value.set(null);
this.form.addRow.amount().value.set('');
this.form.addRow.account().focusBoundControl();
}
resetAddRow() {
this.form.controls.addRow.reset({ account: null, amount: '' });
this.account = null;
this.accBal = null;
setTimeout(() => {
this.accountElement.nativeElement.focus();
}, 0);
deleteRow(row: PaymentItemFormData) {
const updatedJournals = this.formModel().journals.filter((j) => j !== row);
const totalPaymentAmount = round(
updatedJournals.reduce((acc, j) => acc + j.amount, 0),
2,
);
this.formModel.update((m) => ({
...m,
journals: updatedJournals,
paymentAmount: totalPaymentAmount,
}));
this.form.addRow.account().value.set(null);
this.form.addRow.account().focusBoundControl();
}
updateView() {
const journals = this.voucher.journals.filter((x) => x.debit === 1);
this.journalObservable.next(journals);
this.paymentJournal.amount = round(Math.abs(journals.map((x) => x.amount).reduce((p, c) => p + c, 0)), 2);
this.form.controls.paymentAmount.setValue(this.paymentJournal.amount);
}
editRow(row: Journal) {
editRow(row: PaymentItemFormData) {
const dialogRef = this.dialog.open(PaymentDialogComponent, {
width: '750px',
data: {
journal: { ...row },
date: moment(this.form.value.date).format('DD-MMM-YYYY'),
journal: new Journal({
id: row.id,
debit: 1,
amount: row.amount,
account: row.account,
costCentre: row.costCentre,
}),
date: moment(this.formModel().date).format('DD-MMM-YYYY'),
},
});
dialogRef.afterClosed().subscribe((result: boolean | Journal) => {
if (!result) {
if (!result || typeof result === 'boolean') {
return;
}
const j = result as Journal;
if (
j.account.id !== row.account.id &&
this.voucher.journals.filter((x) => x.account.id === j.account.id).length
) {
const j = result;
if (j.account.id !== row.account.id && this.formModel().journals.some((x) => x.account.id === j.account.id)) {
this.snackBar.open('Account already exists in journals', 'Danger');
return;
}
Object.assign(row, j);
this.updateView();
const updatedJournals = this.formModel().journals.map((item) =>
item === row
? {
...item,
account: j.account,
amount: j.amount,
}
: item,
);
const totalPaymentAmount = round(
updatedJournals.reduce((acc, jItem) => acc + jItem.amount, 0),
2,
);
this.formModel.update((m) => ({
...m,
journals: updatedJournals,
paymentAmount: totalPaymentAmount,
}));
this.form.addRow.account().focusBoundControl();
});
}
deleteRow(row: Journal) {
this.voucher.journals.splice(this.voucher.journals.indexOf(row), 1);
this.updateView();
getVoucher(): Voucher {
const m = this.formModel();
const base = this.item();
const v = new Voucher({ ...base });
v.date = moment(m.date).format('DD-MMM-YYYY');
v.narration = m.narration ?? '';
const existingPj = base.journals.find((x) => x.debit === -1);
const paymentAccountObj =
this.paymentAccounts().find((a) => a.id === m.paymentAccount) ?? new Account({ id: m.paymentAccount });
const pj = new Journal({
id: existingPj?.id,
debit: -1,
amount: m.paymentAmount,
account: paymentAccountObj,
costCentre: existingPj?.costCentre ?? null,
});
const debitJournals = m.journals.map(
(j) => new Journal({ id: j.id, debit: 1, amount: j.amount, account: j.account, costCentre: j.costCentre }),
);
v.journals = [pj, ...debitJournals];
v.tags = [...m.tagList];
v.files = [...m.files];
return v;
}
canSave() {
if (!this.voucher.id) {
if (!this.id()) {
return true;
}
if (this.voucher.posted && this.auth.allowed('edit-posted-vouchers')) {
return true;
if (this.form.posted().value() && !this.auth.allowed('edit-posted-vouchers')) {
return false;
}
return this.voucher.user.id === (this.auth.user as User).id || this.auth.allowed("edit-other-user's-vouchers");
}
post() {
this.ser.post(this.voucher.id as string).subscribe({
next: (result) => {
this.loadVoucher(result);
this.snackBar.open('Voucher Posted', 'Success');
},
error: (error) => {
this.snackBar.open(error, 'Danger');
},
});
const user = this.auth.user as User;
return this.item().user?.id === user?.id || this.auth.allowed("edit-other-user's-vouchers");
}
save() {
const voucher: Voucher = this.getVoucher();
this.ser.saveOrUpdate(voucher).subscribe({
this.ser.saveOrUpdate(this.getVoucher()).subscribe({
next: (result) => {
this.snackBar.open('', 'Success');
if (voucher.id === result.id) {
this.loadVoucher(result);
this.snackBar.open('Voucher Saved', undefined, { duration: 2000 });
if (this.item().id === result.id) {
this.item.set(result);
} else {
this.router.navigate(['/payment', result.id]);
}
@@ -363,96 +422,120 @@ export class PaymentComponent implements OnInit, AfterViewInit {
});
}
getVoucher(): Voucher {
const formModel = this.form.value;
this.voucher.date = moment(formModel.date).format('DD-MMM-YYYY');
this.paymentJournal.account.id = formModel.paymentAccount;
this.voucher.narration = formModel.narration ?? '';
return this.voucher;
}
delete() {
this.ser.delete(this.voucher.id as string).subscribe({
next: () => {
this.snackBar.open('', 'Success');
this.router.navigate(['/payment'], { replaceUrl: true });
},
error: (error) => {
this.snackBar.open(error, 'Danger');
},
});
post() {
const id = this.id();
if (id) {
this.ser.post(id).subscribe({
next: (result) => {
this.snackBar.open('Voucher Posted', undefined, { duration: 2000 });
this.item.set(result);
},
error: (error) => {
this.snackBar.open(error, 'Danger');
},
});
}
}
confirmDelete(): void {
const id = this.id();
if (!id) return;
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
width: '250px',
data: { title: 'Delete Voucher?', content: 'Are you sure? This cannot be undone.' },
});
dialogRef.afterClosed().subscribe((result: boolean) => {
dialogRef.afterClosed().subscribe((result) => {
if (result) {
this.delete();
}
});
}
displayFn(account?: Account | string): string {
return !account ? '' : typeof account === 'string' ? account : account.name;
delete() {
const id = this.id();
if (id) {
this.ser.delete(id).subscribe({
next: () => {
this.snackBar.open('Voucher Deleted', undefined, { duration: 2000 });
this.router.navigate(['/payment'], { replaceUrl: true });
},
error: (error) => {
this.snackBar.open(error, 'Danger');
},
});
}
}
accountSelected(event: MatAutocompleteSelectedEvent): void {
const account = event.option.value;
this.account = account;
const date = moment(this.form.value.date).format('DD-MMM-YYYY');
this.accountSer.balance(account.id as string, date).subscribe((v) => {
this.accBal = v;
this.cd.detectChanges();
});
displayFn(account?: Account): string {
return account ? account.name : '';
}
zoomImage(file: DbFile) {
this.dialog.open(ImageDialogComponent, {
width: '750px',
data: file.resized,
data: file,
panelClass: 'full-picture',
});
}
deleteImage(file: DbFile) {
const index = this.voucher.files.indexOf(file);
this.voucher.files.splice(index, 1);
this.formModel.update((m) => ({
...m,
files: m.files.filter((f) => f !== file),
}));
}
detectFiles(event: Event) {
const fileList = (event.target as HTMLInputElement).files;
if (!fileList) return;
for (const file of Array.from(fileList)) {
const reader = new FileReader();
reader.onload = (e) => {
const src = (e.target as FileReader).result as string;
zip(this.image.resizeImage(src, 100, 150), this.image.resizeImage(src, 825, 1170)).subscribe(
([thumbnail, resized]) => {
this.formModel.update((m) => ({
...m,
files: [...m.files, new DbFile({ thumbnail, resized })],
}));
},
);
};
reader.readAsDataURL(file);
}
}
removeTag(tag: Tag): void {
const index = this.voucher.tags.indexOf(tag);
if (index >= 0) {
this.voucher.tags.splice(index, 1);
}
this.formModel.update((m) => ({
...m,
tagList: m.tagList.filter((t) => t !== tag),
}));
}
addTag(event: MatChipInputEvent): void {
const value = (event.value || '').trim();
// Add our tag
if (value) {
this.voucher.tags.push(new Tag({ name: value }));
addTag(): void {
const query = this.form.tagInput().value();
if (!query) return;
const val = (query as string).trim().toLowerCase();
if (!val) return;
const existing = this.formModel().tagList;
const old = existing.find((t) => t.name.toLowerCase() === val);
if (!old) {
this.formModel.update((m) => ({ ...m, tagList: [...m.tagList, { id: null, name: val }] }));
}
// Clear the input value
event.chipInput!.clear();
this.form.controls.tags.setValue(null);
this.form.tagInput().value.set(null);
}
selectedTag(event: MatAutocompleteSelectedEvent): void {
const tag = event.option.value as Tag;
const index = this.voucher.tags.findIndex((t) => (tag.id === null ? t.name === tag.name : t.id === tag.id));
if (index === -1) {
this.voucher.tags.push(tag);
const existing = this.formModel().tagList;
const old = existing.find((t) => t.name.toLowerCase() === tag.name.toLowerCase());
if (!old) {
this.formModel.update((m) => ({ ...m, tagList: [...m.tagList, tag] }));
}
if (this.tagInput) {
this.tagInput.nativeElement.value = '';
}
this.form.controls.tags.setValue(null);
this.form.tagInput().value.set(null);
}
sortData(sort: Sort) {
this.sortActive.set(sort.active);
this.sortDirection.set(sort.direction);
}
}
@@ -1,18 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { ResolveFn } from '@angular/router';
import { Voucher } from '../core/voucher';
import { paymentResolver } from './payment.resolver';
describe('paymentResolver', () => {
const executeResolver: ResolveFn<Voucher> = (...resolverParameters) =>
TestBed.runInInjectionContext(() => paymentResolver(...resolverParameters));
beforeEach(() => {
TestBed.configureTestingModule({});
});
it('should be created', () => {
expect(executeResolver).toBeTruthy();
});
});
@@ -1,15 +0,0 @@
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { Voucher } from '../core/voucher';
import { VoucherService } from '../core/voucher.service';
export const paymentResolver: ResolveFn<Voucher> = (route) => {
const id = route.paramMap.get('id');
const account = route.queryParamMap.get('a') || null;
const date = route.queryParamMap.get('d') || null;
if (id !== null) {
return inject(VoucherService).get(id, 'Payment');
}
return inject(VoucherService).getOfType('Payment', account, date);
};
@@ -1,9 +1,7 @@
import { Routes } from '@angular/router';
import { authGuard } from '../auth/auth-guard.service';
import { paymentAccountsResolver } from './payment-accounts.resolver';
import { PaymentComponent } from './payment.component';
import { paymentResolver } from './payment.resolver';
export const routes: Routes = [
{
@@ -13,10 +11,6 @@ export const routes: Routes = [
data: {
permission: 'journal',
},
resolve: {
paymentAccounts: paymentAccountsResolver,
voucher: paymentResolver,
},
runGuardsAndResolvers: 'paramsChange',
},
{
@@ -26,10 +20,6 @@ export const routes: Routes = [
data: {
permission: 'journal',
},
resolve: {
paymentAccounts: paymentAccountsResolver,
voucher: paymentResolver,
},
runGuardsAndResolvers: 'paramsChange',
},
];