feat(cardex): implement cardex module with filters, templates, and service integration

This commit is contained in:
2025-12-23 20:35:20 +03:30
parent e937c994d7
commit 1373cc046d
23 changed files with 473 additions and 134 deletions
@@ -1,11 +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" />
<inventories-select-field [control]="form.controls.inventoryId" (onChange)="onChangeInventory($event)" />
<products-select-field [control]="form.controls.productId" (onChange)="onChangeProduct($event)" />
<uikit-datepicker [control]="form.controls.startDate" />
<uikit-datepicker [control]="form.controls.endDate" />
</div>
<div class="shrink-0">
<button pButton>جستجو</button>
<button pButton (click)="search()">جستجو</button>
</div>
</form>
@@ -1,10 +1,17 @@
import { Maybe } from '@/core';
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 { Component, EventEmitter, inject, Output } from '@angular/core';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import { ButtonDirective } from 'primeng/button';
import {
ICardexInventorySummary,
ICardexProductSummary,
ICardexRequestPayload,
} from '../../models';
import { CardexStore } from '../../store/main.store';
@Component({
selector: 'cardex-filters',
@@ -20,12 +27,32 @@ import { ButtonDirective } from 'primeng/button';
export class CardexFiltersComponent {
private route = inject(ActivatedRoute);
private fb = inject(FormBuilder);
constructor() {}
private store = inject(CardexStore);
@Output() onSearch = new EventEmitter<void>();
filters = {} as Partial<ICardexRequestPayload>;
constructor(private router: Router) {
this.filters = this.store.filters();
}
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'] || ''],
inventoryId: [this.filters.inventoryId || 0],
productId: [this.filters.productId || 0],
startDate: [this.filters.startDate || ''],
endDate: [this.filters.endDate || ''],
});
onChangeInventory(inventory: Maybe<ICardexInventorySummary>) {
this.store.setInventory(inventory);
}
onChangeProduct(product: Maybe<ICardexProductSummary>) {
this.store.setProduct(product);
}
search() {
this.store.updateFilter(this.form.value as ICardexRequestPayload);
this.store.getCardex();
}
}
@@ -0,0 +1,73 @@
<p-table
stripedRows
[value]="items"
[loading]="loading"
showGridlines
tableStyleClass="table-fixed border-collapse"
scrollable
>
<ng-template #header>
<tr>
<th rowspan="2" [style]="{ textAlign: 'center', width: '4rem' }">ردیف</th>
<th rowspan="2" [style]="{ textAlign: 'center', width: '8rem' }">تاریخ</th>
<th rowspan="2" [style]="{ textAlign: 'center', width: '12rem' }">شرح</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', 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="1" class="text-center!" [style]="{ width: '5rem' }">مانده</th>
</tr>
<tr>
<th [style]="{ textAlign: 'center', width: '5rem' }">تعداد</th>
<th [style]="{ textAlign: 'center', width: '10rem' }">فی</th>
<th [style]="{ textAlign: 'center', width: '5rem' }">تعداد</th>
<th [style]="{ textAlign: 'center', width: '10rem' }">فی</th>
<th [style]="{ textAlign: 'center', width: '5rem' }">تعداد</th>
<!-- <th [style]="{ textAlign: 'center', width: '10rem' }">فی</th>
<th [style]="{ textAlign: 'center', width: '10rem' }">مجموع قیمت</th> -->
</tr>
</ng-template>
<ng-template #body let-item let-i="rowIndex">
<tr>
<td class="text-center!">{{ i + 1 }}</td>
<td class="text-center!" [jalaliDate]="item.createdAt"></td>
<td class="text-center!">
<catalog-movement-reference-tag
[type]="item.referenceType"
[movementType]="item.type"
[counterName]="item.counterInventory?.name"
/>
</td>
<td class="text-center!">{{ item.referenceId || "-" }}</td>
<td class="text-center!">
{{ (item.referenceType === "SALES" ? item.customer?.name : item.supplier?.name) || "-" }}
</td>
<td class="text-center!">{{ item.product.name || "-" }}</td>
<td class="text-center!">
{{ item.type === "IN" ? item.quantity : 0 }}
</td>
<td class="text-center!">
@if (item.type === "IN") {
<span [appPriceMask]="item.fee"></span>
} @else {
0
}
</td>
<td class="text-center!">
{{ item.type === "OUT" ? item.quantity : 0 }}
</td>
<td class="text-center!">
@if (item.type === "OUT") {
<span [appPriceMask]="item.fee"></span>
} @else {
0
}
</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.totalCost"></span></td> -->
</tr>
</ng-template>
</p-table>
@@ -0,0 +1,22 @@
import { CatalogMovementReferenceTagComponent } from '@/shared/catalog/movementReferenceTypes';
import { JalaliDateDirective, PriceMaskDirective } from '@/shared/directives';
import { Component, Input } from '@angular/core';
import { TableModule } from 'primeng/table';
import { IInventoryCardex } from './types';
@Component({
selector: 'cardex-templates-inventory',
templateUrl: './inventory.component.html',
imports: [
TableModule,
JalaliDateDirective,
CatalogMovementReferenceTagComponent,
PriceMaskDirective,
],
hostDirectives: [PriceMaskDirective],
})
export class CardexComponent {
@Input() items: IInventoryCardex[] = [];
@Input() loading: boolean = false;
constructor() {}
}
@@ -0,0 +1,3 @@
import { ICardexResponse } from '../../models';
export interface IInventoryCardex extends ICardexResponse {}
+36 -58
View File
@@ -1,66 +1,44 @@
import {
IInventoryInfo,
IInventoryMovement,
IInventoryStockProduct,
IInventoryTransferRequestItem,
} from './types';
import { ICardexInventorySummary, ICardexProductSummary } from './types';
export interface IInventorySummaryRawResponse {
export interface ICardexRawResponse {
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 {
type: string;
quantity: number;
fee: number;
totalCost: number;
referenceType: string;
referenceId: string;
createdAt: string;
avgCost: number;
product: IInventoryStockProduct;
product: ICardexProductSummary;
inventory: ICardexInventorySummary;
supplier?: {
id: number;
firstName: string;
lastName: string;
};
customer?: {
id: number;
firstName: string;
lastName: string;
};
counterInventory?: ICardexInventorySummary;
}
export interface IInventoryStockResponse extends IInventoryStockRawResponse {}
export interface IInventoryStockQuery {
isAvailable?: boolean;
export interface ICardexResponse extends ICardexRawResponse {
supplier?: {
id: number;
name: string;
};
customer?: {
id: number;
name: string;
};
}
export interface IInventoryProductCardexRawResponse extends IInventoryStockMovementRawResponse {}
export interface IInventoryProductCardexResponse extends IInventoryProductCardexRawResponse {}
export interface ICardexRequestPayload {
startDate?: string;
endDate?: string;
inventoryId?: number;
productId?: number;
}
+7 -39
View File
@@ -1,42 +1,10 @@
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;
export interface ICardexInventorySummary {
name: string;
id: number;
}
export interface ICardexProductSummary {
name: string;
description: null;
sku: string;
barcode: string;
createdAt: string;
updatedAt: string;
deletedAt: null;
brandId: number;
categoryId: number;
id: 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,43 @@
import { IPaginatedResponse } from '@/core/models/service.model';
import { getFullName } from '@/utils';
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { map, Observable } from 'rxjs';
import { CARDEX_API_ROUTES } from '../constants';
import { ICardexRawResponse, ICardexRequestPayload, ICardexResponse } from '../models';
@Injectable({ providedIn: 'root' })
export class CardexService {
constructor(private http: HttpClient) {}
private apiRoutes = CARDEX_API_ROUTES;
getCardex(params: ICardexRequestPayload): Observable<IPaginatedResponse<ICardexResponse>> {
return this.http
.get<IPaginatedResponse<ICardexRawResponse>>(this.apiRoutes.cardex(), {
params: { ...params },
})
.pipe(
map((res) => {
return {
meta: res.meta,
data: res.data.map((item) => ({
...item,
supplier: item.supplier
? {
...item.supplier,
name: getFullName(item.supplier),
}
: undefined,
customer: item.customer
? {
...item.customer,
name: getFullName(item.customer),
}
: undefined,
})),
};
}),
);
}
}
+187
View File
@@ -0,0 +1,187 @@
import { Maybe } from '@/core';
import { ToastService } from '@/core/services/toast.service';
import { PaginatedState, PaginatedStore } from '@/core/state';
import { computed, Injectable } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import {
ICardexInventorySummary,
ICardexProductSummary,
ICardexRequestPayload,
ICardexResponse,
} from '../models';
import { CardexService } from '../services/main.service';
export interface CardexState extends PaginatedState<ICardexResponse> {
filters: ICardexRequestPayload;
selectedInventory: Maybe<ICardexInventorySummary>;
selectedProduct: Maybe<ICardexProductSummary>;
variant: 'inventory' | 'product' | 'both';
canChangeVariant: boolean;
}
@Injectable({
providedIn: 'root',
})
export class CardexStore extends PaginatedStore<ICardexResponse, CardexState> {
private readonly _cardexState = {
isRefreshing: false,
filters: {},
selectedInventory: null,
selectedProduct: null,
variant: 'both',
canChangeVariant: true,
};
constructor(
private router: Router,
private activeRoute: ActivatedRoute,
private service: CardexService,
private toastService: ToastService,
) {
const queryParams = activeRoute.snapshot.queryParams;
super({
loading: false,
initialized: false,
error: null,
isRefreshing: false,
items: [],
totalCount: 0,
currentPage: Number(queryParams['page']) || 1,
pageSize: Number(queryParams['pageSize']) || 30,
totalPages: 0,
hasMore: false,
filters: {
inventoryId: queryParams['inventoryId'],
productId: queryParams['productId'],
startDate: queryParams['startDate'],
endDate: queryParams['endDate'],
},
selectedInventory: null,
selectedProduct: null,
variant: 'both',
canChangeVariant: true,
});
this.initial();
}
readonly filters = computed(() => this._state().filters);
readonly variant = computed(() => this._state().variant);
readonly canChangeVariant = computed(() => this._state().canChangeVariant);
readonly cardexTitle = computed(() => {
switch (this._state().variant) {
case 'product':
return `کاردکس کالای ${this._state().selectedProduct?.name}`;
case 'inventory':
return `کاردکس انبار ${this._state().selectedInventory?.name}`;
default:
return 'کاردکس';
}
});
/**
* Reset state to initial values
*/
reset(): void {
const queryParams = this.activeRoute.snapshot.queryParams;
this.setState({
loading: false,
initialized: false,
error: null,
isRefreshing: false,
items: [],
totalCount: 0,
currentPage: Number(queryParams['page']) || 1,
pageSize: Number(queryParams['pageSize']) || 30,
totalPages: 0,
hasMore: false,
filters: {
inventoryId: queryParams['inventoryId'],
productId: queryParams['productId'],
startDate: queryParams['startDate'],
endDate: queryParams['endDate'],
},
selectedInventory: null,
selectedProduct: null,
variant: 'both',
canChangeVariant: this.state().canChangeVariant,
});
}
initial() {
this.getCardex();
}
getCardex() {
const filters = this.validateFilters();
if (filters) {
this.patchState({ initialized: true });
this.setLoading(true);
this.service.getCardex(filters).subscribe({
next: (res) => {
this.setItems(res.data, res.meta);
},
error: (err) => {
this.setError(err);
},
});
}
}
private validateFilters() {
const { productId, inventoryId, startDate, endDate } = this.filters();
const filters = {} as Partial<ICardexRequestPayload>;
Object.entries(this.filters()).forEach(([key, value]) => {
if (value) {
filters[key as keyof ICardexRequestPayload] = value;
}
});
if (!productId && !inventoryId) {
this.toastService.warn({ text: 'برای مشاهده‌ی کاردکس حداقل انبار و یا کالا را مشخص کنید' });
return false;
}
return filters;
}
onPageChange(page: number) {
this.setCurrentPage(page);
this.getCardex();
}
updateFilter(filters: ICardexRequestPayload) {
this.updateVariant();
console.log(filters);
this.router.navigate([], {
queryParams: filters,
skipLocationChange: true,
});
this.patchState({ filters });
}
setProduct(product: Maybe<ICardexProductSummary>) {
this.patchState({ selectedProduct: product });
}
setInventory(inventory: Maybe<ICardexInventorySummary>) {
this.patchState({ selectedInventory: inventory });
}
setCanChangeVariant(status: boolean) {
this.patchState({ canChangeVariant: status });
}
private updateVariant() {
const inventoryIsSelected = Boolean(this._state().selectedInventory);
const productIsSelected = Boolean(this._state().selectedProduct);
let variant = 'both' as 'product' | 'inventory' | 'both';
if (inventoryIsSelected) {
if (!productIsSelected) {
variant = 'inventory';
}
} else if (productIsSelected) {
variant = 'product';
}
this.patchState({ variant });
}
}
@@ -3,6 +3,13 @@
<cardex-filters />
</p-card>
<p-card>
<uikit-empty-state title="برای مشاهده‌ی کاردکس اطلاعات بالا را تکمیل کنید"> </uikit-empty-state>
@if (!initialized) {
<uikit-empty-state title="برای مشاهده‌ی کاردکس اطلاعات بالا را تکمیل کنید"> </uikit-empty-state>
} @else {
<h3 class="mb-4">{{ cardexTitle }}</h3>
@if (variant === "inventory") {
<cardex-templates-inventory [items]="items" [loading]="loading" />
}
}
</p-card>
</div>
@@ -2,12 +2,30 @@ import { UikitEmptyStateComponent } from '@/uikit';
import { Component } from '@angular/core';
import { Card } from 'primeng/card';
import { CardexFiltersComponent } from '../components';
import { CardexComponent } from '../components/templates/inventory.component';
import { CardexStore } from '../store/main.store';
@Component({
selector: 'app-cardex',
templateUrl: './cardex.component.html',
imports: [Card, CardexFiltersComponent, UikitEmptyStateComponent],
imports: [Card, CardexFiltersComponent, UikitEmptyStateComponent, CardexComponent],
})
export class CardexPageComponent {
constructor() {}
constructor(private store: CardexStore) {}
get cardexTitle() {
return this.store.cardexTitle();
}
get initialized() {
return this.store.initialized();
}
get loading() {
return this.store.loading();
}
get variant() {
return this.store.variant();
}
get items() {
return this.store.items();
}
}