feat: enhance bank accounts module with transaction handling and UI updates

This commit is contained in:
2026-01-05 18:38:11 +03:30
parent 502c592f56
commit a5bcab0064
20 changed files with 499 additions and 51 deletions
@@ -28,7 +28,7 @@ export class BankAccountFormTemplateComponent {
nonNullable: true,
validators: Validators.required,
}),
branchId: this.fb.control<number>(this.initialValues?.branchId || 0, {
branchId: this.fb.control<number>(this.initialValues?.branch?.id || 0, {
nonNullable: true,
validators: Validators.required,
}),
@@ -2,4 +2,6 @@ const baseUrl = '/api/v1/bank-accounts';
export const BANK_ACCOUNTS_API_ROUTES = {
list: () => `${baseUrl}`,
single: (bankAccountId: string) => `${baseUrl}/${bankAccountId}`,
transactions: (bankAccountId: string) => `${baseUrl}/${bankAccountId}/transactions`,
};
@@ -13,11 +13,11 @@ export const bankAccountsNamedRoutes: NamedRoutes<TBankAccountsRouteNames> = {
},
},
bankAccount: {
path: 'bankAccount',
path: 'bankAccounts/:bankAccountId',
loadComponent: () => import('../../views/single.component').then((m) => m.BankAccountComponent),
meta: {
title: 'حساب‌ بانکی',
pagePath: () => '/bankAccount',
pagePath: () => '/bankAccounts/:bankAccountId',
},
},
};
+28 -1
View File
@@ -1,7 +1,17 @@
import { Maybe } from '@/core';
import {
IBankAccountBranchSummary,
IReferencePos,
IReferencePosRaw,
IReferencePurchase,
IReferencePurchaseRaw,
} from './types';
export interface IBankAccountsRawResponse {
id: number;
name: string;
branchId: number;
branch: IBankAccountBranchSummary;
currentBalance: number;
accountNumber?: string;
iban?: string;
cardNumber?: string;
@@ -19,3 +29,20 @@ export interface IBankAccountsRequest {
iban?: string;
cardNumber?: string;
}
export interface IBankAccountTransactionRawResponse {
id: number;
bankAccountId: number;
type: string;
amount: string;
balanceAfter: string;
referenceId: number;
referenceType: string;
description: null;
createdAt: string;
reference: Maybe<IReferencePurchaseRaw | IReferencePosRaw>;
}
export interface IBankAccountTransactionResponse extends IBankAccountTransactionRawResponse {
reference: Maybe<IReferencePurchase | IReferencePos>;
}
@@ -0,0 +1,46 @@
export interface IBankAccountBranchSummary {
id: number;
name: string;
code: string;
bank: Bank;
}
interface Bank {
id: number;
name: string;
shortName: string;
}
interface IReferenceCommon {
id: number;
code: string;
totalAmount: string;
}
export interface IReferencePurchaseRaw extends IReferenceCommon {
supplier: IPersonSummary;
}
export interface IReferencePosRaw extends IReferenceCommon {
customer: IPersonSummary;
}
export interface IReferencePurchase extends IReferenceCommon {
supplier: {
id: number;
name: string;
};
}
export interface IReferencePos extends IReferenceCommon {
customer: {
id: number;
name: string;
};
}
interface IPersonSummary {
id: number;
firstName: string;
lastName: string;
}
@@ -1,9 +1,23 @@
import { Maybe } from '@/core';
import { IPaginatedResponse } from '@/core/models/service.model';
import { BankTransactionReferenceType } from '@/shared/catalog/transactionReferenceTypes';
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { map, Observable } from 'rxjs';
import { BANK_ACCOUNTS_API_ROUTES } from '../constants';
import { IBankAccountsRawResponse, IBankAccountsRequest, IBankAccountsResponse } from '../models';
import {
IBankAccountsRawResponse,
IBankAccountsRequest,
IBankAccountsResponse,
IBankAccountTransactionRawResponse,
IBankAccountTransactionResponse,
} from '../models';
import {
IReferencePos,
IReferencePosRaw,
IReferencePurchase,
IReferencePurchaseRaw,
} from '../models/types';
@Injectable({ providedIn: 'root' })
export class BankAccountsService {
@@ -15,7 +29,71 @@ export class BankAccountsService {
return this.http.get<IPaginatedResponse<IBankAccountsRawResponse>>(this.apiRoutes.list());
}
getSingle(bankAccountId: string): Observable<IBankAccountsResponse> {
return this.http.get<IBankAccountsRawResponse>(this.apiRoutes.single(bankAccountId));
}
create(data: IBankAccountsRequest): Observable<IBankAccountsResponse> {
return this.http.post<IBankAccountsRawResponse>(this.apiRoutes.list(), data);
}
getTransactions(
bankAccountId: string,
): Observable<IPaginatedResponse<IBankAccountTransactionResponse>> {
return this.http
.get<
IPaginatedResponse<IBankAccountTransactionRawResponse>
>(this.apiRoutes.transactions(bankAccountId))
.pipe(
map((res) => ({
meta: res.meta,
data: res.data.map((transaction) => this.transformTransaction(transaction)),
})),
);
}
private transformTransaction(
transaction: IBankAccountTransactionRawResponse,
): IBankAccountTransactionResponse {
const { reference, referenceType, ...rest } = transaction;
let transformedReference: Maybe<IReferencePurchase | IReferencePos> = null;
if (reference) {
if (
referenceType === BankTransactionReferenceType.PURCHASE_PAYMENT ||
referenceType === BankTransactionReferenceType.PURCHASE_REFUND
) {
const purchaseRef = reference as IReferencePurchaseRaw;
transformedReference = {
id: purchaseRef.id,
code: purchaseRef.code,
totalAmount: purchaseRef.totalAmount,
supplier: {
id: purchaseRef.supplier.id,
name: `${purchaseRef.supplier.firstName} ${purchaseRef.supplier.lastName}`,
},
};
} else if (
referenceType === BankTransactionReferenceType.POS_SALE ||
referenceType === BankTransactionReferenceType.POS_REFUND
) {
const posRef = reference as IReferencePosRaw;
transformedReference = {
id: posRef.id,
code: posRef.code,
totalAmount: posRef.totalAmount,
customer: {
id: posRef.customer.id,
name: `${posRef.customer.firstName} ${posRef.customer.lastName}`,
},
};
}
}
return {
...rest,
referenceType,
reference: transformedReference,
};
}
}
@@ -0,0 +1,76 @@
import { ToastService } from '@/core/services/toast.service';
import { EntityState, EntityStore } from '@/core/state';
import { Injectable } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { IBankAccountsResponse } from '../models';
import { BankAccountsService } from '../services/main.service';
export interface BankAccountState extends EntityState<IBankAccountsResponse> {}
@Injectable({
providedIn: 'root',
})
export class BankAccountStore extends EntityStore<IBankAccountsResponse, BankAccountState> {
constructor(
private activeRoute: ActivatedRoute,
private service: BankAccountsService,
private toastService: ToastService,
) {
super({
loading: false,
initialized: false,
error: null,
isRefreshing: false,
entities: {},
selectedId: null,
ids: [],
});
this.initial();
}
supplierId!: string;
initial() {}
getSingle(bankAccountId: string) {
if (this.entities()[bankAccountId]) {
return;
}
this.patchState({ loading: true });
this.service.getSingle(bankAccountId).subscribe({
next: (res) => {
this.patchState({
entities: { [res.id]: res },
loading: false,
});
},
error: (err) => {
this.patchState({ loading: false, error: err });
this.toastService.error({
text: 'خطا در دریافت اطلاعات حساب بانکی',
});
},
});
}
refresh(bankAccountId: string) {
const { bankAccountId: _, ...entities } = this.entities();
this.patchState({
entities,
});
this.getSingle(bankAccountId);
}
reset(): void {
const queryParams = this.activeRoute.snapshot.queryParams;
this.setState({
loading: false,
initialized: false,
error: null,
isRefreshing: false,
entities: {},
selectedId: null,
ids: [],
});
}
}
@@ -22,7 +22,7 @@ export class BankAccountsComponent {
{
field: 'name',
header: 'نام',
minWidth: '100px',
minWidth: '160px',
},
{
field: 'bank',
@@ -32,6 +32,12 @@ export class BankAccountsComponent {
},
minWidth: '200px',
},
{
field: 'currentBalance',
header: 'مانده حساب',
type: 'price',
minWidth: '180px',
},
{
field: 'accountNumber',
header: 'شماره حساب بانکی',
@@ -50,8 +56,7 @@ export class BankAccountsComponent {
canCopy: true,
minWidth: '160px',
},
{ field: 'createdAt', header: 'تاریخ ایجاد', type: 'date' },
{ field: 'updatedAt', header: 'تاریخ به‌روزرسانی', type: 'date' },
{ field: 'createdAt', header: 'تاریخ ایجاد', type: 'date', minWidth: '120px' },
] as IColumn[];
loading = signal(false);
@@ -1,8 +1,48 @@
<div class="flex flex-col gap-4">
<app-inner-pages-header [pageTitle]="pageTitle()" [backRoute]="['/bankAccounts']" />
@if (!loading()) {
<div class="inline-flex items-center gap-5">
<div class="">
<app-key-value label="بانک" [value]="bankAccount().branch.bank.name" />
</div>
<div class="">
<app-key-value label="شعبه‌ی بانک" [value]="bankAccount().branch.name" />
</div>
<div class="">
<app-key-value label="موجودی حساب">
<span [appPriceMask]="bankAccount().currentBalance"></span>
</app-key-value>
</div>
</div>
}
<app-page-data-list
[pageTitle]="'تراکنش‌های حساب‌های بانک'"
[columns]="columns"
[pageTitle]="'تراکنش‌های حساب بانکی'"
[columns]="transactionsColumn"
[items]="transactions()"
[loading]="transactionsLoading()"
[totalRecords]="transactionsPagination()?.totalRecords || 0"
[perPage]="transactionsPagination()?.perPage || 0"
[currentPage]="transactionsPagination()?.page || 1"
emptyPlaceholderTitle="تراکنشی یافت نشد"
[items]="items()"
[loading]="loading()"
[showDetails]="true"
/>
emptyPlaceholderDescription="این حساب بانکی تاکنون تراکنشی نداشته است."
>
<ng-template #typeTpl let-item>
<catalog-bank-account-transaction-type-tag [type]="item.type" />
</ng-template>
<ng-template #referenceTypeTpl let-item>
<catalog-bank-account-transaction-reference-type-tag [type]="item.referenceType" />
</ng-template>
<ng-template #counterpartyTpl let-item>
@if (item.referenceType === "PURCHASE_PAYMENT" || item.referenceType === "PURCHASE_REFUND") {
{{ item.reference.supplier?.name || "-" }}
} @else if (item.referenceType === "CUSTOMER_PAYMENT" || item.referenceType === "CUSTOMER_REFUND") {
{{ item.reference.customer?.name || "-" }}
} @else {
{{ item.referenceType }}
}
</ng-template>
</app-page-data-list>
<!-- (onPageChange)="getTransactions($event.page + 1)" -->
</div>
@@ -1,48 +1,85 @@
import { Maybe } from '@/core';
import { IResponseMetadata } from '@/core/models/service.model';
import { CatalogBankAccountTransactionReferenceTypeTagComponent } from '@/shared/catalog/transactionReferenceTypes';
import { CatalogBankAccountTransactionTypeTagComponent } from '@/shared/catalog/transactionTypes';
import { KeyValueComponent } from '@/shared/components';
import { InnerPagesHeaderComponent } from '@/shared/components/innerPagesHeader/inner-pages-header.component';
import {
IColumn,
PageDataListComponent,
} from '@/shared/components/pageDataList/page-data-list.component';
import { Component, signal } from '@angular/core';
import { IBankAccountsResponse } from '../models';
import { PriceMaskDirective } from '@/shared/directives';
import { Component, computed, inject, signal, TemplateRef, ViewChild } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { IBankAccountTransactionResponse } from '../models';
import { BankAccountsService } from '../services/main.service';
import { BankAccountStore } from '../store/bankAccount.store';
@Component({
selector: 'app-bank-account',
templateUrl: './single.component.html',
imports: [PageDataListComponent],
imports: [
InnerPagesHeaderComponent,
KeyValueComponent,
PriceMaskDirective,
PageDataListComponent,
CatalogBankAccountTransactionTypeTagComponent,
CatalogBankAccountTransactionReferenceTypeTagComponent,
],
})
export class BankAccountComponent {
constructor() {}
private route = inject(ActivatedRoute);
private store = inject(BankAccountStore);
columns = [
{ field: 'id', header: 'شناسه', width: '60px' },
{
field: 'name',
header: 'نام',
minWidth: '100px',
},
@ViewChild('typeTpl', { static: true }) typeTpl!: TemplateRef<any>;
@ViewChild('referenceTypeTpl', { static: true }) referenceTypeTpl!: TemplateRef<any>;
@ViewChild('counterpartyTpl', { static: true }) counterpartyTpl!: TemplateRef<any>;
{
field: 'accountNumber',
header: 'شماره حساب بانکی',
canCopy: true,
minWidth: '160px',
},
{
field: 'iban',
header: 'شماره شبا',
canCopy: true,
minWidth: '200px',
},
{
field: 'cardNumber',
header: 'شماره کارت بانکی',
canCopy: true,
minWidth: '160px',
},
{ field: 'createdAt', header: 'تاریخ ایجاد', type: 'date' },
{ field: 'updatedAt', header: 'تاریخ به‌روزرسانی', type: 'date' },
] as IColumn[];
bankAccountId = this.route.snapshot.paramMap.get('bankAccountId')!;
loading = signal(false);
items = signal<IBankAccountsResponse[]>([]);
transactionsColumn = [] as IColumn[];
bankAccount = computed(() => this.store.entities()[this.bankAccountId]);
loading = this.store.loading;
transactions = signal<IBankAccountTransactionResponse[]>([]);
transactionsLoading = signal<boolean>(false);
transactionsPagination = signal<Maybe<IResponseMetadata>>(null);
constructor(private readonly service: BankAccountsService) {
this.store.getSingle(this.bankAccountId);
this.getTransactions();
}
pageTitle = computed(() => {
const account = this.bankAccount();
return account ? `حساب بانک: ${account.name}` : 'حساب بانک';
});
ngOnInit() {
this.transactionsColumn = [
{ field: 'id', header: 'شناسه', width: '60px' },
{ field: 'type', header: 'نوع تراکنش', customDataModel: this.typeTpl },
{ field: 'amount', header: 'مبلغ', type: 'price' },
{ field: 'balanceAfter', header: 'مانده حساب', type: 'price' },
{ field: 'type', header: 'نوع رسید', customDataModel: this.referenceTypeTpl },
{ field: 'counterparty', header: 'طرف حساب', customDataModel: this.counterpartyTpl },
{ field: 'description', header: 'توضیحات' },
{ field: 'createdAt', header: 'تاریخ ایجاد', type: 'date' },
];
}
getTransactions() {
this.transactionsLoading.set(true);
this.service.getTransactions(this.bankAccountId).subscribe({
next: (res) => {
this.transactions.set(res.data);
this.transactionsPagination.set(res.meta);
this.transactionsLoading.set(false);
},
error: () => {
this.transactionsLoading.set(false);
},
});
}
}
@@ -0,0 +1,3 @@
export * from './tag.component';
export * from './types';
export * from './utils';
@@ -0,0 +1,3 @@
<p-tag [severity]="color">
{{ text }}
</p-tag>
@@ -0,0 +1,24 @@
import { Component, Input } from '@angular/core';
import { Tag } from 'primeng/tag';
import { BankTransactionReferenceType } from './types';
import {
getBankAccountTransactionReferenceTypeColor,
getBankAccountTransactionReferenceTypeText,
} from './utils';
@Component({
selector: 'catalog-bank-account-transaction-reference-type-tag',
templateUrl: './tag.component.html',
imports: [Tag],
})
export class CatalogBankAccountTransactionReferenceTypeTagComponent {
@Input() type!: BankTransactionReferenceType;
constructor() {}
get text() {
return getBankAccountTransactionReferenceTypeText(this.type);
}
get color() {
return getBankAccountTransactionReferenceTypeColor(this.type);
}
}
@@ -0,0 +1,11 @@
export const BankTransactionReferenceType = {
PURCHASE_PAYMENT: 'PURCHASE_PAYMENT',
PURCHASE_REFUND: 'PURCHASE_REFUND',
POS_SALE: 'POS_SALE',
POS_REFUND: 'POS_REFUND',
BANK_TRANSFER: 'BANK_TRANSFER',
MANUAL_ADJUSTMENT: 'MANUAL_ADJUSTMENT',
} as const;
export type BankTransactionReferenceType =
(typeof BankTransactionReferenceType)[keyof typeof BankTransactionReferenceType];
@@ -0,0 +1,40 @@
import { TagSeverity } from '@/core/models/tag';
import { BankTransactionReferenceType } from './types';
export const getBankAccountTransactionReferenceTypeText = (
type: BankTransactionReferenceType,
): string => {
switch (type) {
case BankTransactionReferenceType.BANK_TRANSFER:
return 'انتقال بانکی';
case BankTransactionReferenceType.MANUAL_ADJUSTMENT:
return 'تعدیل دستی';
case BankTransactionReferenceType.POS_REFUND:
return 'بازپرداخت فروشگاه';
case BankTransactionReferenceType.POS_SALE:
return 'فروش فروشگاه';
case BankTransactionReferenceType.PURCHASE_REFUND:
return 'بازپرداخت خرید';
case BankTransactionReferenceType.PURCHASE_PAYMENT:
return 'پرداخت خرید';
}
};
export const getBankAccountTransactionReferenceTypeColor = (
type: BankTransactionReferenceType,
): TagSeverity => {
switch (type) {
case BankTransactionReferenceType.BANK_TRANSFER:
return 'secondary';
case BankTransactionReferenceType.MANUAL_ADJUSTMENT:
return 'secondary';
case BankTransactionReferenceType.POS_REFUND:
return 'warn';
case BankTransactionReferenceType.POS_SALE:
return 'info';
case BankTransactionReferenceType.PURCHASE_REFUND:
return 'warn';
case BankTransactionReferenceType.PURCHASE_PAYMENT:
return 'info';
}
};
@@ -0,0 +1,3 @@
export * from './tag.component';
export * from './types';
export * from './utils';
@@ -0,0 +1,3 @@
<p-tag [severity]="color">
{{ text }}
</p-tag>
@@ -0,0 +1,21 @@
import { Component, Input } from '@angular/core';
import { Tag } from 'primeng/tag';
import { BankAccountTransactionType } from './types';
import { getBankAccountTransactionTypeColor, getBankAccountTransactionTypeText } from './utils';
@Component({
selector: 'catalog-bank-account-transaction-type-tag',
templateUrl: './tag.component.html',
imports: [Tag],
})
export class CatalogBankAccountTransactionTypeTagComponent {
@Input() type!: BankAccountTransactionType;
constructor() {}
get text() {
return getBankAccountTransactionTypeText(this.type);
}
get color() {
return getBankAccountTransactionTypeColor(this.type);
}
}
@@ -0,0 +1,7 @@
export const BankAccountTransactionType = {
DEPOSIT: 'DEPOSIT',
WITHDRAWAL: 'WITHDRAWAL',
} as const;
export type BankAccountTransactionType =
(typeof BankAccountTransactionType)[keyof typeof BankAccountTransactionType];
@@ -0,0 +1,22 @@
import { TagSeverity } from '@/core/models/tag';
import { BankAccountTransactionType } from './types';
export const getBankAccountTransactionTypeText = (type: BankAccountTransactionType): string => {
switch (type) {
case BankAccountTransactionType.DEPOSIT:
return 'واریز';
case BankAccountTransactionType.WITHDRAWAL:
return 'برداشت';
}
};
export const getBankAccountTransactionTypeColor = (
type: BankAccountTransactionType,
): TagSeverity => {
switch (type) {
case BankAccountTransactionType.DEPOSIT:
return 'success';
case BankAccountTransactionType.WITHDRAWAL:
return 'danger';
}
};