feat: Add Cardex module with filters and invoices components

- Implemented Cardex routes and integrated with the main application routing.
- Created filters component for Cardex to filter inventory and product data.
- Developed invoices component to display supplier invoices with detailed views.
- Enhanced Products module with pagination and state management using a store.
- Updated Suppliers module to include invoice management and detailed supplier views.
- Refactored state management in Products and Suppliers to utilize signals for reactive updates.
- Added new models and services to support the Cardex functionality.
- Improved UI components for better user experience in displaying data.
This commit is contained in:
2025-12-21 19:09:13 +03:30
parent 108d192f88
commit e937c994d7
29 changed files with 716 additions and 49 deletions
+8 -1
View File
@@ -1,3 +1,5 @@
import { AuthComponent } from '@/modules/auth/pages/auth.component';
import { CARDEX_ROUTES } from '@/modules/cardex/constants';
import { CUSTOMERS_ROUTES } from '@/modules/customers/constants'; import { CUSTOMERS_ROUTES } from '@/modules/customers/constants';
import { INVENTORIES_ROUTES } from '@/modules/inventories/constants'; import { INVENTORIES_ROUTES } from '@/modules/inventories/constants';
import { POSComponent } from '@/modules/pos/views/pos.component'; import { POSComponent } from '@/modules/pos/views/pos.component';
@@ -27,6 +29,7 @@ export const appRoutes: Routes = [
...CUSTOMERS_ROUTES, ...CUSTOMERS_ROUTES,
...INVENTORIES_ROUTES, ...INVENTORIES_ROUTES,
...PRODUCTS_ROUTES, ...PRODUCTS_ROUTES,
...CARDEX_ROUTES,
{ path: 'uikit', loadChildren: () => import('./app/pages/uikit/uikit.routes') }, { path: 'uikit', loadChildren: () => import('./app/pages/uikit/uikit.routes') },
{ path: 'documentation', component: Documentation }, { path: 'documentation', component: Documentation },
{ path: 'pages', loadChildren: () => import('./app/pages/pages.routes') }, { path: 'pages', loadChildren: () => import('./app/pages/pages.routes') },
@@ -36,7 +39,11 @@ export const appRoutes: Routes = [
path: 'pos', path: 'pos',
component: POSComponent, component: POSComponent,
}, },
{
path: 'auth',
component: AuthComponent,
},
{ path: 'notfound', component: Notfound }, { path: 'notfound', component: Notfound },
{ path: 'auth', loadChildren: () => import('./app/pages/auth/auth.routes') }, // { path: 'auth', loadChildren: () => import('./app/pages/auth/auth.routes') },
{ path: '**', redirectTo: '/notfound' }, { path: '**', redirectTo: '/notfound' },
]; ];
+16 -10
View File
@@ -1,6 +1,7 @@
import { Signal, WritableSignal, computed, signal } from '@angular/core'; import { Signal, WritableSignal, computed, signal } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs'; import { BehaviorSubject, Observable } from 'rxjs';
import { Maybe } from '../models'; import { Maybe } from '../models';
import { IPaginatedQuery, IResponseMetadata } from '../models/service.model';
/** /**
* Base interface for all state objects * Base interface for all state objects
@@ -8,9 +9,10 @@ import { Maybe } from '../models';
export interface BaseState<T = any> { export interface BaseState<T = any> {
loading: boolean; loading: boolean;
error: Maybe<string>; error: Maybe<string>;
lastId?: string; currentPage?: number;
items?: T; items?: T;
meta?: Record<string, any>; meta?: Maybe<IPaginatedQuery>;
isRefreshing?: boolean;
} }
/** /**
@@ -21,6 +23,7 @@ export interface PaginatedState<T = any> extends BaseState {
totalCount: number; totalCount: number;
currentPage: number; currentPage: number;
pageSize: number; pageSize: number;
totalPages: number;
hasMore: boolean; hasMore: boolean;
} }
@@ -246,21 +249,24 @@ export abstract class PaginatedStore<
readonly currentPage = computed(() => this._state().currentPage); readonly currentPage = computed(() => this._state().currentPage);
readonly pageSize = computed(() => this._state().pageSize); readonly pageSize = computed(() => this._state().pageSize);
readonly hasMore = computed(() => this._state().hasMore); readonly hasMore = computed(() => this._state().hasMore);
readonly totalPages = computed(() => { readonly totalPages = computed(() => this._state().totalPages);
const state = this._state(); readonly paginatedMeta = computed(() => ({
return Math.ceil(state.totalCount / state.pageSize); currentPage: this.currentPage(),
}); totalPages: this.totalPages(),
totalCount: this.totalCount(),
pageSize: this.pageSize(),
}));
/** /**
* Set items for current page * Set items for current page
*/ */
protected setItems(items: T[], totalCount: number, currentPage: number): void { protected setItems(items: T[], meta: IResponseMetadata): void {
const state = this._state(); const state = this._state();
this.patchState({ this.patchState({
items, items,
totalCount, totalCount: meta.totalRecords,
currentPage, currentPage: meta.page,
hasMore: currentPage * state.pageSize < totalCount, hasMore: meta.page < meta.totalPages,
loading: false, loading: false,
error: null, error: null,
} as Partial<TState>); } as Partial<TState>);
+1 -1
View File
@@ -5,7 +5,7 @@ export type AuthRouteNames = 'auth';
export const authNamedRoutes: NamedRoutes<AuthRouteNames> = { export const authNamedRoutes: NamedRoutes<AuthRouteNames> = {
auth: { auth: {
path: '', path: 'auth',
loadComponent: () => import('./pages/auth.component').then((m) => m.AuthComponent), loadComponent: () => import('./pages/auth.component').then((m) => m.AuthComponent),
meta: { title: 'احراز هویت' }, meta: { title: 'احراز هویت' },
}, },
@@ -0,0 +1,11 @@
<form [formGroup]="form" class="flex items-end gap-4">
<div class="grow grid grid-cols-4 gap-2">
<inventories-select-field [control]="form.controls.inventoryId" />
<products-select-field [control]="form.controls.productId" />
<uikit-datepicker [control]="form.controls.startDate" />
<uikit-datepicker [control]="form.controls.endDate" />
</div>
<div class="shrink-0">
<button pButton>جستجو</button>
</div>
</form>
@@ -0,0 +1,31 @@
import { InventoriesSelectComponent } from '@/modules/inventories/components';
import { ProductsSelectComponent } from '@/modules/products/components/select/select.component';
import { UikitFlatpickrJalaliComponent } from '@/uikit';
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { ButtonDirective } from 'primeng/button';
@Component({
selector: 'cardex-filters',
templateUrl: './filters.component.html',
imports: [
ReactiveFormsModule,
InventoriesSelectComponent,
ProductsSelectComponent,
UikitFlatpickrJalaliComponent,
ButtonDirective,
],
})
export class CardexFiltersComponent {
private route = inject(ActivatedRoute);
private fb = inject(FormBuilder);
constructor() {}
form = this.fb.group({
inventoryId: [Number(this.route.snapshot.queryParams['inventoryId']) || 0],
productId: [Number(this.route.snapshot.queryParams['productId']) || 0],
startDate: [this.route.snapshot.queryParams['startDate'] || ''],
endDate: [this.route.snapshot.queryParams['endDate'] || ''],
});
}
@@ -0,0 +1 @@
export * from './filters/filters.component';
@@ -0,0 +1,5 @@
const baseUrl = '/api/v1/cardex';
export const CARDEX_API_ROUTES = {
cardex: () => `${baseUrl}`,
};
@@ -0,0 +1,2 @@
export * from './apiRoutes';
export * from './routes';
@@ -0,0 +1,17 @@
import { NamedRoutes } from '@/core';
import { Routes } from '@angular/router';
export type TCARDEXRouteNames = 'CARDEX';
export const cardexNamedRoutes: NamedRoutes<TCARDEXRouteNames> = {
CARDEX: {
path: 'cardex',
loadComponent: () => import('../../views/cardex.component').then((m) => m.CardexPageComponent),
meta: {
title: 'کاردکس',
pagePath: () => '/cardex',
},
},
};
export const CARDEX_ROUTES: Routes = Object.values(cardexNamedRoutes);
+2
View File
@@ -0,0 +1,2 @@
export * from './io';
export * from './types';
+66
View File
@@ -0,0 +1,66 @@
import {
IInventoryInfo,
IInventoryMovement,
IInventoryStockProduct,
IInventoryTransferRequestItem,
} from './types';
export interface IInventorySummaryRawResponse {
id: number;
name: string;
location: string;
isActive: boolean;
isPointOfSale: boolean;
createdAt: string;
updatedAt: string;
deletedAt: Maybe<string>;
}
export interface IInventorySummaryResponse extends IInventorySummaryRawResponse {}
export interface IInventoryDetailRawResponse extends IInventorySummaryRawResponse {
availableProductTypes: number;
availableProductCount: number;
availableProductsCost: number;
}
export interface IInventoryDetailResponse extends IInventoryDetailRawResponse {}
export interface IInventoryRequest {
name: string;
location: string;
isActive: boolean;
}
export interface IInventoryStockMovementRawResponse {
receiptId: string;
count: number;
info: IInventoryInfo;
movements: IInventoryMovement[];
}
export interface IInventoryStockMovementResponse extends IInventoryStockMovementRawResponse {}
export interface IInventoryTransferRequest {
code: string;
description: string;
fromInventoryId: number;
toInventoryId: number;
items: IInventoryTransferRequestItem[];
}
export interface IInventoryStockRawResponse {
quantity: number;
avgCost: number;
product: IInventoryStockProduct;
}
export interface IInventoryStockResponse extends IInventoryStockRawResponse {}
export interface IInventoryStockQuery {
isAvailable?: boolean;
}
export interface IInventoryProductCardexRawResponse extends IInventoryStockMovementRawResponse {}
export interface IInventoryProductCardexResponse extends IInventoryProductCardexRawResponse {}
+42
View File
@@ -0,0 +1,42 @@
import { IProductRawResponse } from '@/modules/products/models';
export interface IInventoryMovement {
id: number;
quantity: number;
fee: number;
totalCost: number;
avgCost: number;
product: IInventoryProduct;
remainedInStock: number;
}
export interface IInventoryProduct {
id: number;
name: string;
description: null;
sku: string;
barcode: string;
createdAt: string;
updatedAt: string;
deletedAt: null;
brandId: number;
categoryId: number;
}
export interface IInventoryInfo {
date: string;
type: string;
quantity: number;
fee: number;
totalCost: number;
referenceType: string;
referenceId: string;
createdAt: string;
}
export interface IInventoryTransferRequestItem {
productId: number;
count: number;
}
export interface IInventoryStockProduct extends IProductRawResponse {}
@@ -0,0 +1,8 @@
<div class="flex flex-col gap-4">
<p-card>
<cardex-filters />
</p-card>
<p-card>
<uikit-empty-state title="برای مشاهده‌ی کاردکس اطلاعات بالا را تکمیل کنید"> </uikit-empty-state>
</p-card>
</div>
@@ -0,0 +1,13 @@
import { UikitEmptyStateComponent } from '@/uikit';
import { Component } from '@angular/core';
import { Card } from 'primeng/card';
import { CardexFiltersComponent } from '../components';
@Component({
selector: 'app-cardex',
templateUrl: './cardex.component.html',
imports: [Card, CardexFiltersComponent, UikitEmptyStateComponent],
})
export class CardexPageComponent {
constructor() {}
}
@@ -23,8 +23,8 @@ export class InventoriesComponent {
{ field: 'id', header: 'شناسه' }, { field: 'id', header: 'شناسه' },
{ field: 'name', header: 'نام' }, { field: 'name', header: 'نام' },
{ field: 'location', header: 'موقعیت' }, { field: 'location', header: 'موقعیت' },
{ field: 'isPointOfSale', header: 'نقطه فروش' }, { field: 'isPointOfSale', header: 'نقطه فروش', type: 'boolean' },
{ field: 'isActive', header: 'فعال' }, { field: 'isActive', header: 'فعال', type: 'boolean' },
] as IColumn[]; ] as IColumn[];
loading = signal(false); loading = signal(false);
+3
View File
@@ -1,6 +1,9 @@
import { Maybe } from '@/core'; import { Maybe } from '@/core';
import { IPaginatedQuery } from '@/core/models/service.model';
import { IProductBrand, IProductCategory } from './others'; import { IProductBrand, IProductCategory } from './others';
export interface IGetProductsQueryParams extends IPaginatedQuery {}
export interface IProductRawResponse { export interface IProductRawResponse {
id: number; id: number;
name: string; name: string;
@@ -3,7 +3,12 @@ import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { PRODUCTS_API_ROUTES } from '../constants'; import { PRODUCTS_API_ROUTES } from '../constants';
import { IProductRawResponse, IProductRequest, IProductResponse } from '../models'; import {
IGetProductsQueryParams,
IProductRawResponse,
IProductRequest,
IProductResponse,
} from '../models';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ProductsService { export class ProductsService {
@@ -11,8 +16,10 @@ export class ProductsService {
private apiRoutes = PRODUCTS_API_ROUTES; private apiRoutes = PRODUCTS_API_ROUTES;
getAll(): Observable<IPaginatedResponse<IProductResponse>> { getAll(params?: IGetProductsQueryParams): Observable<IPaginatedResponse<IProductResponse>> {
return this.http.get<IPaginatedResponse<IProductRawResponse>>(this.apiRoutes.list()); return this.http.get<IPaginatedResponse<IProductRawResponse>>(this.apiRoutes.list(), {
params: { ...params },
});
} }
getSingle(productId: string): Observable<IProductResponse> { getSingle(productId: string): Observable<IProductResponse> {
+75
View File
@@ -0,0 +1,75 @@
import { PaginatedState, PaginatedStore } from '@/core/state';
import { Injectable } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { IProductResponse } from '../models';
import { ProductsService } from '../services/main.service';
export interface ProductsState extends PaginatedState {}
@Injectable({
providedIn: 'root',
})
export class ProductsStore extends PaginatedStore<IProductResponse> {
// Login-specific state
private readonly _productsState = {
isRefreshing: false,
};
constructor(
private activeRoute: ActivatedRoute,
private service: ProductsService,
) {
const queryParams = activeRoute.snapshot.queryParams;
super({
loading: false,
error: null,
isRefreshing: false,
items: [],
totalCount: 0,
currentPage: Number(queryParams['page']) || 1,
pageSize: Number(queryParams['pageSize']) || 30,
totalPages: 0,
hasMore: false,
});
this.initial();
}
/**
* Reset state to initial values
*/
reset(): void {
this.setState({
loading: false,
error: null,
isRefreshing: false,
items: [],
totalCount: 0,
currentPage: Number(this.activeRoute.snapshot.queryParams['page']) || 1,
pageSize: Number(this.activeRoute.snapshot.queryParams['pageSize']) || 30,
totalPages: 0,
hasMore: false,
});
}
initial() {
this.getAll();
}
getAll() {
this.setLoading(true);
this.service.getAll({ page: this.currentPage(), pageSize: this.pageSize() }).subscribe({
next: (res) => {
this.setItems(res.data, res.meta);
},
error: (err) => {
this.setError(err);
},
});
}
onPageChange(page: number) {
this.setCurrentPage(page);
this.getAll();
}
}
@@ -8,8 +8,12 @@
[items]="items()" [items]="items()"
[loading]="loading()" [loading]="loading()"
[showDetails]="true" [showDetails]="true"
[perPage]="paginatedMeta().pageSize"
[currentPage]="paginatedMeta().currentPage"
[totalRecords]="paginatedMeta().totalCount"
(onAdd)="openAddForm()" (onAdd)="openAddForm()"
(onDetails)="toDetails($event)" (onDetails)="toDetails($event)"
(pageChange)="onPageChange($event)"
/> />
<product-form [(visible)]="visibleForm" (onSubmit)="refresh()" /> <product-form [(visible)]="visibleForm" (onSubmit)="refresh()" />
@@ -8,18 +8,21 @@ import { ProductFormComponent } from '../components/form.component';
import { productsNamedRoutes } from '../constants'; import { productsNamedRoutes } from '../constants';
import { IProductResponse } from '../models'; import { IProductResponse } from '../models';
import { ProductsService } from '../services/main.service'; import { ProductsService } from '../services/main.service';
import { ProductsStore } from '../store/main';
@Component({ @Component({
selector: 'app-products', selector: 'app-products',
templateUrl: './list.component.html', templateUrl: './list.component.html',
imports: [PageDataListComponent, ProductFormComponent], imports: [PageDataListComponent, ProductFormComponent],
standalone: true,
}) })
export class ProductsComponent { export class ProductsComponent {
constructor( constructor(
private productService: ProductsService, private productService: ProductsService,
private router: Router, private router: Router,
private store: ProductsStore,
) { ) {
this.getData(); store.initial();
} }
columns = [ columns = [
@@ -41,27 +44,27 @@ export class ProductsComponent {
}, },
{ {
field: 'salePrice', field: 'salePrice',
header: 'قیمت فروش', header: 'قیمت پایه فروش',
type: 'price', type: 'price',
}, },
{ field: 'createdAt', header: 'تاریخ ایجاد', type: 'date' }, { field: 'createdAt', header: 'تاریخ ایجاد', type: 'date' },
{ field: 'updatedAt', header: 'تاریخ به‌روزرسانی', type: 'date' }, { field: 'updatedAt', header: 'تاریخ به‌روزرسانی', type: 'date' },
] as IColumn[]; ] as IColumn[];
loading = signal(false); get loading() {
items = signal<IProductResponse[]>([]); return this.store.loading;
}
get items() {
return this.store.items;
}
get paginatedMeta() {
return this.store.paginatedMeta;
}
visibleForm = signal(false); visibleForm = signal(false);
refresh() { refresh() {
this.getData(); this.store.initial();
}
getData() {
this.loading.set(true);
this.productService.getAll().subscribe((res) => {
this.loading.set(false);
this.items.set(res.data);
});
} }
openAddForm() { openAddForm() {
@@ -69,10 +72,13 @@ export class ProductsComponent {
if (pagePathFn) { if (pagePathFn) {
this.router.navigateByUrl(pagePathFn({})); this.router.navigateByUrl(pagePathFn({}));
} }
// this.visibleForm.set(true);
} }
toDetails(product: IProductResponse) { toDetails(product: IProductResponse) {
this.router.navigate(['/products', product.id]); this.router.navigate(['/products', product.id]);
} }
onPageChange(page: number) {
this.store.onPageChange(page);
}
} }
@@ -0,0 +1,107 @@
<p-card>
<ng-template pTemplate="header">
<div class="p-4 flex items-center justify-between">
<span class="text-xl font-bold">فاکتورهای خرید</span>
<button pButton outlined icon="pi pi-refresh" [loading]="loading()" (click)="getAll()"></button>
</div>
</ng-template>
<ng-template pTemplate="content">
<p-table [value]="items() || []" [loading]="loading()" dataKey="id" [expandedRowKeys]="expandedRows">
<ng-template pTemplate="header">
<tr>
<th [style]="{ width: '5rem' }"></th>
<th>شناسه رسید</th>
<th>انبار</th>
<th>مجموع قیمت</th>
<th>تاریخ</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-item let-expanded="expanded">
<tr>
<td>
<p-button
type="button"
pRipple
[pRowToggler]="item"
text
severity="secondary"
rounded
[icon]="expanded ? 'pi pi-chevron-down' : 'pi pi-chevron-left'"
/>
</td>
<td>{{ item.code }}</td>
<td>{{ item.inventory.name }}</td>
<td><span [appPriceMask]="item.totalAmount"></span></td>
<td><span [jalaliDate]="item.createdAt"></span></td>
</tr>
</ng-template>
<ng-template #expandedrow let-item>
<tr>
<td colspan="7">
<p-card class="p-4 pt-0 border border-primary-700">
<p-table [value]="item.items" dataKey="receiptId">
<ng-template #caption>
<h5 class="mb-4">کالاهای تامین شده</h5>
</ng-template>
<ng-template #header>
<tr>
<th pSortableColumn="product.id">
<div class="flex items-center gap-2">
شناسه کالا
<p-sortIcon field="product.id" />
</div>
</th>
<th pSortableColumn="product.name">
<div class="flex items-center gap-2">
عنوان کالا
<p-sortIcon field="product.name" />
</div>
</th>
<th pSortableColumn="quantity">
<div class="flex items-center gap-2">
تعداد
<p-sortIcon field="quantity" />
</div>
</th>
<th pSortableColumn="fee">
<div class="flex items-center gap-2">
قیمت واحد
<p-sortIcon field="fee" />
</div>
</th>
<th pSortableColumn="totalCost">
<div class="flex items-center gap-2">
قیمت نهایی
<p-sortIcon field="totalCost" />
</div>
</th>
<th style="width: 4rem"></th>
</tr>
</ng-template>
<ng-template #body let-item>
<tr>
<td>{{ item.product.id }}</td>
<td>{{ item.product.name }}</td>
<td>{{ item.count }}</td>
<td><span [appPriceMask]="item.fee"></span></td>
<td><span [appPriceMask]="item.total"></span></td>
<td>
<p-button type="button" icon="pi pi-search" [routerLink]="'/products/' + item.product.id" />
</td>
</tr>
</ng-template>
</p-table>
</p-card>
</td>
</tr>
</ng-template>
<ng-template pTemplate="emptymessage">
<tr>
<td colspan="7" class="text-center! p-10!">هیچ فاکتوری ثبت نشده است.</td>
</tr>
</ng-template>
</p-table>
</ng-template>
</p-card>
@@ -0,0 +1,54 @@
import { Maybe } from '@/core';
import { JalaliDateDirective, PriceMaskDirective } from '@/shared/directives';
import { CommonModule } from '@angular/common';
import { Component, Input, OnInit, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { Button, ButtonDirective } from 'primeng/button';
import { Card } from 'primeng/card';
import { Ripple } from 'primeng/ripple';
import { TableModule } from 'primeng/table';
import { ISupplierInvoicesResponse } from '../../models';
import { SuppliersService } from '../../services/main.service';
@Component({
selector: 'suppliers-invoices',
standalone: true,
imports: [
CommonModule,
Card,
ButtonDirective,
TableModule,
PriceMaskDirective,
JalaliDateDirective,
Ripple,
Button,
RouterLink,
],
templateUrl: './invoices.component.html',
})
export class SuppliersInvoicesComponent implements OnInit {
@Input() supplierId!: string;
constructor(private service: SuppliersService) {}
ngOnInit(): void {
this.getAll();
}
expandedRows = {};
loading = signal(true);
items = signal<Maybe<ISupplierInvoicesResponse[]>>(null);
getAll() {
this.loading.set(true);
this.service.getInvoices(this.supplierId).subscribe({
next: (res) => {
this.loading.set(false);
this.items.set(res.data);
},
error: () => {
this.loading.set(false);
},
});
}
}
@@ -3,4 +3,7 @@ const baseUrl = '/api/v1/suppliers';
export const SUPPLIERS_API_ROUTES = { export const SUPPLIERS_API_ROUTES = {
list: () => `${baseUrl}`, list: () => `${baseUrl}`,
single: (supplierId: string) => `${baseUrl}/${supplierId}`, single: (supplierId: string) => `${baseUrl}/${supplierId}`,
invoices: (supplierId: string) => `${baseUrl}/${supplierId}/invoices`,
invoice: (supplierId: string, invoiceId: string) =>
`${baseUrl}/${supplierId}/invoices/${invoiceId}`,
}; };
+25 -3
View File
@@ -1,3 +1,5 @@
import { IReceiptsOverview, ISupplierInventorySummary, ISupplierReceiptItemSummary } from './types';
export interface ISupplierRawResponse { export interface ISupplierRawResponse {
id: number; id: number;
firstName: string; firstName: string;
@@ -9,15 +11,22 @@ export interface ISupplierRawResponse {
state: string; state: string;
country: string; country: string;
isActive: boolean; isActive: boolean;
createdAt: Date; createdAt: string;
updatedAt: Date; updatedAt: string;
deletedAt: Date; deletedAt: string;
} }
export interface ISupplierResponse extends ISupplierRawResponse { export interface ISupplierResponse extends ISupplierRawResponse {
fullname: string; fullname: string;
} }
export interface ISupplierFullInfoRawResponse extends ISupplierRawResponse {
receiptsOverview: IReceiptsOverview;
}
export interface ISupplierFullInfoResponse extends ISupplierFullInfoRawResponse {
fullname: string;
}
export interface ISupplierRequest { export interface ISupplierRequest {
firstName: string; firstName: string;
lastName: string; lastName: string;
@@ -29,3 +38,16 @@ export interface ISupplierRequest {
country: string; country: string;
isActive: boolean; isActive: boolean;
} }
export interface ISupplierInvoicesRawResponse {
id: number;
code: string;
totalAmount: string;
description: string;
createdAt: string;
updatedAt: string;
items: ISupplierReceiptItemSummary[];
inventory: ISupplierInventorySummary;
}
export interface ISupplierInvoicesResponse extends ISupplierInvoicesRawResponse {}
+21
View File
@@ -0,0 +1,21 @@
export interface IReceiptsOverview {
totalPayment: string;
count: number;
}
export interface ISupplierInventorySummary {
id: number;
name: string;
}
export interface ISupplierReceiptItemSummary {
id: number;
count: string;
fee: string;
total: string;
product: {
id: string;
name: string;
sku: string;
};
}
@@ -1,9 +1,18 @@
import { IPaginatedResponse } from '@/core/models/service.model'; import { IPaginatedResponse } from '@/core/models/service.model';
import { getFullName } from '@/utils';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { map, Observable } from 'rxjs'; import { map, Observable } from 'rxjs';
import { SUPPLIERS_API_ROUTES } from '../constants'; import { SUPPLIERS_API_ROUTES } from '../constants';
import { ISupplierRawResponse, ISupplierRequest, ISupplierResponse } from '../models'; import {
ISupplierFullInfoRawResponse,
ISupplierFullInfoResponse,
ISupplierInvoicesRawResponse,
ISupplierInvoicesResponse,
ISupplierRawResponse,
ISupplierRequest,
ISupplierResponse,
} from '../models';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class SuppliersService { export class SuppliersService {
@@ -17,17 +26,17 @@ export class SuppliersService {
...res, ...res,
data: res.data.map((item) => ({ data: res.data.map((item) => ({
...item, ...item,
fullname: `${item.firstName} ${item.lastName}`, fullname: getFullName({ firstName: item.firstName, lastName: item.lastName }),
})), })),
})), })),
); );
} }
getSingle(supplierId: string): Observable<ISupplierResponse> { getSingle(supplierId: string): Observable<ISupplierFullInfoResponse> {
return this.http.get<ISupplierRawResponse>(this.apiRoutes.single(supplierId)).pipe( return this.http.get<ISupplierFullInfoRawResponse>(this.apiRoutes.single(supplierId)).pipe(
map((item) => ({ map((item) => ({
...item, ...item,
fullname: `${item.firstName} ${item.lastName}`, fullname: getFullName({ firstName: item.firstName, lastName: item.lastName }),
})), })),
); );
} }
@@ -36,8 +45,20 @@ export class SuppliersService {
return this.http.post<ISupplierRawResponse>(this.apiRoutes.list(), data).pipe( return this.http.post<ISupplierRawResponse>(this.apiRoutes.list(), data).pipe(
map((item) => ({ map((item) => ({
...item, ...item,
fullname: `${item.firstName} ${item.lastName}`, fullname: getFullName({ firstName: item.firstName, lastName: item.lastName }),
})), })),
); );
} }
getInvoices(supplierId: string): Observable<IPaginatedResponse<ISupplierInvoicesResponse>> {
return this.http.get<IPaginatedResponse<ISupplierInvoicesRawResponse>>(
this.apiRoutes.invoices(supplierId),
);
}
getInvoice(supplierId: string, invoiceId: string): Observable<ISupplierInvoicesResponse> {
return this.http.get<ISupplierInvoicesRawResponse>(
this.apiRoutes.invoice(supplierId, invoiceId),
);
}
} }
@@ -1 +1,81 @@
<div class=""></div> <div class="flex flex-col gap-4">
<div class="flex items-center">
<div class="grow">
<div class="flex items-center gap-2">
<p-button icon="pi pi-arrow-right" aria-label="بازگشت" outlined [routerLink]="['/suppliers']" />
<h3 class="m-0">{{ data()?.fullname }}</h3>
</div>
</div>
<div class="flex items-center gap-2">
<button pButton type="button" label="خرید کالا" icon="pi pi-plus"></button>
<button pButton type="button" label="ویرایش" icon="pi pi-pencil"></button>
<button pButton icon="pi pi-trash" severity="danger"></button>
</div>
</div>
<div class="inline-flex items-center gap-5">
<div class="">
<app-key-value label="شماره تماس" [value]="data()?.mobileNumber" />
</div>
</div>
<div class="mt-5">
<div class="grid grid-cols-4 gap-4">
@for (item of topBarCardDetails; track $index) {
<details-info-card [icon]="item.icon" [label]="item.label" [value]="item.value?.toString() || ''" />
}
</div>
</div>
<suppliers-invoices [supplierId]="supplierId" />
<!--
<p-card class="">
<ng-template pTemplate="header">
<div class="p-4 flex items-center justify-between">
<span class="text-xl font-bold">کالاهای موجود در انبار</span>
<button pButton outlined icon="pi pi-refresh" [loading]="loading()" (click)="getStock()"></button>
</div>
</ng-template>
<p-table [value]="stock()" [loading]="getStockLoading()">
<ng-template #header>
<tr>
<th pSortableColumn="product.id">
<div class="flex items-center gap-2">
شناسه کالا
<p-sortIcon field="product.id" />
</div>
</th>
<th pSortableColumn="product.name">
<div class="flex items-center gap-2">
عنوان کالا
<p-sortIcon field="product.name" />
</div>
</th>
<th pSortableColumn="quantity">
<div class="flex items-center gap-2">
تعداد
<p-sortIcon field="quantity" />
</div>
</th>
<th pSortableColumn="fee">
<div class="flex items-center gap-2">
قیمت متوسط
<p-sortIcon field="fee" />
</div>
</th>
<th style="width: 4rem"></th>
</tr>
</ng-template>
<ng-template #body let-stock>
<tr>
<td>{{ stock.product.sku }}</td>
<td>{{ stock.product.name }}</td>
<td>{{ stock.quantity }}</td>
<td><span [appPriceMask]="stock.avgCost"></span></td>
<td>
<p-button type="button" icon="pi pi-search" title="کاردکس کالا" />
</td>
</tr>
</ng-template>
</p-table>
</p-card> -->
</div>
@@ -1,9 +1,62 @@
import { Component } from '@angular/core'; import { Maybe } from '@/core';
import { KeyValueComponent } from '@/shared/components';
import { DetailsInfoCardComponent } from '@/shared/components/detailsInfoCard/details-info-card.component';
import { formatWithCurrency } from '@/utils';
import { Component, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { Button, ButtonDirective } from 'primeng/button';
import { SuppliersInvoicesComponent } from '../components/invoices/invoices.component';
import { ISupplierFullInfoResponse } from '../models';
import { SuppliersService } from '../services/main.service';
@Component({ @Component({
selector: 'app-supplier', selector: 'app-supplier',
templateUrl: './single.component.html', templateUrl: './single.component.html',
imports: [
DetailsInfoCardComponent,
KeyValueComponent,
Button,
RouterLink,
ButtonDirective,
SuppliersInvoicesComponent,
],
}) })
export class SupplierComponent { export class SupplierComponent {
constructor() {} private route = inject(ActivatedRoute);
constructor(private service: SuppliersService) {
this.getInfo();
}
supplierId = this.route.snapshot.params['supplierId'];
data = signal<Maybe<ISupplierFullInfoResponse>>(null);
loading = signal<Maybe<boolean>>(true);
getInfo() {
this.loading.set(true);
this.service.getSingle(this.supplierId).subscribe({
next: (res) => {
this.loading.set(false);
this.data.set(res);
},
error: (err) => {
this.loading.set(false);
},
});
}
get topBarCardDetails() {
return [
{
icon: 'pi pi-box',
label: 'تعداد فاکتورها',
value: this.data()?.receiptsOverview.count,
},
{
icon: 'pi pi-dollar',
label: 'ارزش کلی پرداختی‌ها',
value: formatWithCurrency(this.data()?.receiptsOverview.totalPayment || 0),
},
];
}
} }
@@ -14,9 +14,9 @@
<th rowspan="2" [style]="{ textAlign: 'center', width: '6rem' }">شماره</th> <th rowspan="2" [style]="{ textAlign: 'center', width: '6rem' }">شماره</th>
<th rowspan="2" [style]="{ textAlign: 'center', minWidth: '14rem', width: '14rem' }">طرف حساب</th> <th rowspan="2" [style]="{ textAlign: 'center', minWidth: '14rem', width: '14rem' }">طرف حساب</th>
<th rowspan="2" [style]="{ textAlign: 'center', width: '12rem' }">انبار</th> <th rowspan="2" [style]="{ textAlign: 'center', width: '12rem' }">انبار</th>
<th colspan="2" class="text-center!" [style]="{ width: '15rem' }">وارده</th> <th colspan="2" class="text-center!" [style]="{ width: '15rem' }">ورودی</th>
<th colspan="2" class="text-center!" [style]="{ width: '15rem' }">خروجی</th> <th colspan="2" class="text-center!" [style]="{ width: '15rem' }">خروجی</th>
<th colspan="3" class="text-center!" [style]="{ width: '25rem' }">مانده</th> <th colspan="1" class="text-center!" [style]="{ width: '5rem' }">مانده</th>
</tr> </tr>
<tr> <tr>
<th [style]="{ textAlign: 'center', width: '5rem' }">تعداد</th> <th [style]="{ textAlign: 'center', width: '5rem' }">تعداد</th>
@@ -24,8 +24,8 @@
<th [style]="{ textAlign: 'center', width: '5rem' }">تعداد</th> <th [style]="{ textAlign: 'center', width: '5rem' }">تعداد</th>
<th [style]="{ textAlign: 'center', width: '10rem' }">فی</th> <th [style]="{ textAlign: 'center', width: '10rem' }">فی</th>
<th [style]="{ textAlign: 'center', width: '5rem' }">تعداد</th> <th [style]="{ textAlign: 'center', width: '5rem' }">تعداد</th>
<th [style]="{ textAlign: 'center', width: '10rem' }">فی</th> <!-- <th [style]="{ textAlign: 'center', width: '10rem' }">فی</th>
<th [style]="{ textAlign: 'center', width: '10rem' }">مجموع قیمت</th> <th [style]="{ textAlign: 'center', width: '10rem' }">مجموع قیمت</th> -->
</tr> </tr>
</ng-template> </ng-template>
@@ -60,8 +60,8 @@
} }
</td> </td>
<td class="text-center!">{{ item.remainedInStock }}</td> <td class="text-center!">{{ item.remainedInStock }}</td>
<td class="text-center!"><span [appPriceMask]="item.fee"></span></td> <!-- <td class="text-center!"><span [appPriceMask]="item.fee"></span></td>
<td class="text-center!"><span [appPriceMask]="item.totalCost"></span></td> <td class="text-center!"><span [appPriceMask]="item.totalCost"></span></td> -->
</tr> </tr>
</ng-template> </ng-template>
</p-table> </p-table>