feat(inventories): implement inventory movement list and transfer functionality

- Added InventoryMovementListComponent to display stock movements with filtering options.
- Created InventoriesTransferFormComponent for transferring stock between inventories.
- Implemented product cardex view to show detailed inventory movements for specific products.
- Enhanced product listing with loading states and improved UI components.
- Introduced Jalali date formatting directive for better date representation.
- Added utility functions for Jalali date conversions and comparisons.
- Created reusable details info card component for displaying inventory details.
- Updated movement reference types and tags for better categorization of inventory movements.
This commit is contained in:
2025-12-14 10:16:14 +03:30
parent b46b8b83f9
commit 35be7e0298
65 changed files with 1543 additions and 80 deletions
+2
View File
@@ -12,8 +12,10 @@
"@primeuix/themes": "^1.2.1", "@primeuix/themes": "^1.2.1",
"@tailwindcss/postcss": "^4.1.11", "@tailwindcss/postcss": "^4.1.11",
"chart.js": "4.4.2", "chart.js": "4.4.2",
"dayjs": "^1.11.19",
"flatpickr": "^4.6.13", "flatpickr": "^4.6.13",
"flatpickr-wrap": "^1.0.0", "flatpickr-wrap": "^1.0.0",
"jalaliday": "^3.1.1",
"jest-editor-support": "*", "jest-editor-support": "*",
"primeclt": "^0.1.5", "primeclt": "^0.1.5",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
+20
View File
@@ -44,12 +44,18 @@ importers:
chart.js: chart.js:
specifier: 4.4.2 specifier: 4.4.2
version: 4.4.2 version: 4.4.2
dayjs:
specifier: ^1.11.19
version: 1.11.19
flatpickr: flatpickr:
specifier: ^4.6.13 specifier: ^4.6.13
version: 4.6.13 version: 4.6.13
flatpickr-wrap: flatpickr-wrap:
specifier: ^1.0.0 specifier: ^1.0.0
version: 1.0.0 version: 1.0.0
jalaliday:
specifier: ^3.1.1
version: 3.1.1(dayjs@1.11.19)
jest-editor-support: jest-editor-support:
specifier: '*' specifier: '*'
version: 32.0.0-beta.1 version: 32.0.0-beta.1
@@ -3136,6 +3142,9 @@ packages:
resolution: {integrity: sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==} resolution: {integrity: sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==}
engines: {node: '>=4.0'} engines: {node: '>=4.0'}
dayjs@1.11.19:
resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==}
debug@2.6.9: debug@2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
peerDependencies: peerDependencies:
@@ -4200,6 +4209,11 @@ packages:
jackspeak@3.4.3: jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
jalaliday@3.1.1:
resolution: {integrity: sha512-exZzDOfp/32Gj2YkEHNhBBMcltBCTrIe8RXLTfpCvdjYHNfbYSQiNeFVf4RR59FV+2fixUOCEFfzOzyXfgenlg==}
peerDependencies:
dayjs: ^1.11.13
jasmine-core@4.6.1: jasmine-core@4.6.1:
resolution: {integrity: sha512-VYz/BjjmC3klLJlLwA4Kw8ytk0zDSmbbDLNs794VnWmkcCB7I9aAL/D48VNQtmITyPvea2C3jdUMfc3kAoy0PQ==} resolution: {integrity: sha512-VYz/BjjmC3klLJlLwA4Kw8ytk0zDSmbbDLNs794VnWmkcCB7I9aAL/D48VNQtmITyPvea2C3jdUMfc3kAoy0PQ==}
@@ -9374,6 +9388,8 @@ snapshots:
date-format@4.0.14: {} date-format@4.0.14: {}
dayjs@1.11.19: {}
debug@2.6.9: debug@2.6.9:
dependencies: dependencies:
ms: 2.0.0 ms: 2.0.0
@@ -10638,6 +10654,10 @@ snapshots:
optionalDependencies: optionalDependencies:
'@pkgjs/parseargs': 0.11.0 '@pkgjs/parseargs': 0.11.0
jalaliday@3.1.1(dayjs@1.11.19):
dependencies:
dayjs: 1.11.19
jasmine-core@4.6.1: {} jasmine-core@4.6.1: {}
jasmine-core@5.8.0: {} jasmine-core@5.8.0: {}
+5
View File
@@ -1,5 +1,6 @@
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 { PRODUCT_BRANDS_ROUTES } from '@/modules/productBrands/constants'; import { PRODUCT_BRANDS_ROUTES } from '@/modules/productBrands/constants';
import { PRODUCT_CATEGORIES_ROUTES } from '@/modules/productCategories/constants'; import { PRODUCT_CATEGORIES_ROUTES } from '@/modules/productCategories/constants';
import { PRODUCTS_ROUTES } from '@/modules/products/constants'; import { PRODUCTS_ROUTES } from '@/modules/products/constants';
@@ -31,6 +32,10 @@ export const appRoutes: Routes = [
{ path: 'pages', loadChildren: () => import('./app/pages/pages.routes') }, { path: 'pages', loadChildren: () => import('./app/pages/pages.routes') },
], ],
}, },
{
path: 'pos',
component: POSComponent,
},
{ 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' },
+2 -2
View File
@@ -10,11 +10,11 @@ export const MENU_ITEMS = [
], ],
}, },
{ {
label: 'موجودی‌ها', label: 'انبارها',
icon: 'pi pi-fw pi-box', icon: 'pi pi-fw pi-box',
items: [ items: [
{ {
label: 'لیست موجودی‌ها', label: 'لیست انبارها',
icon: 'pi pi-fw pi-list', icon: 'pi pi-fw pi-list',
routerLink: ['/inventories'], routerLink: ['/inventories'],
}, },
+1
View File
@@ -2,4 +2,5 @@ export * from './maybe.model';
export * from './namedRoutes.model'; export * from './namedRoutes.model';
export * from './navigation.model'; export * from './navigation.model';
export * from './route-utils.model'; export * from './route-utils.model';
export * from './tag';
export * from './user.model'; export * from './user.model';
+1
View File
@@ -0,0 +1 @@
export interface StockBalance {}
+9
View File
@@ -0,0 +1,9 @@
export type TagSeverity =
| 'success'
| 'secondary'
| 'info'
| 'warn'
| 'danger'
| 'contrast'
| null
| undefined;
@@ -4,7 +4,7 @@ import { FormFooterActionsComponent } from '@/shared/components/formFooterAction
import { Component, effect, EventEmitter, inject, Input, Output, signal } from '@angular/core'; import { Component, effect, EventEmitter, inject, Input, Output, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Dialog } from 'primeng/dialog'; import { Dialog } from 'primeng/dialog';
import { IInventoryRequest, IInventoryResponse } from '../models'; import { IInventoryRequest, IInventorySummaryResponse } from '../models';
import { InventoriesService } from '../services/main.service'; import { InventoriesService } from '../services/main.service';
@Component({ @Component({
@@ -13,7 +13,7 @@ import { InventoriesService } from '../services/main.service';
imports: [ReactiveFormsModule, Dialog, InputComponent, FormFooterActionsComponent], imports: [ReactiveFormsModule, Dialog, InputComponent, FormFooterActionsComponent],
}) })
export class InventoryFormComponent { export class InventoryFormComponent {
@Input() initialValues?: IInventoryResponse; @Input() initialValues?: IInventorySummaryResponse;
@Input() @Input()
set visible(v: boolean) { set visible(v: boolean) {
@@ -25,7 +25,7 @@ export class InventoryFormComponent {
private visibleSignal = signal(false); private visibleSignal = signal(false);
@Output() visibleChange = new EventEmitter<boolean>(); @Output() visibleChange = new EventEmitter<boolean>();
@Output() onSubmit = new EventEmitter<IInventoryResponse>(); @Output() onSubmit = new EventEmitter<IInventorySummaryResponse>();
private fb = inject(FormBuilder); private fb = inject(FormBuilder);
constructor( constructor(
@@ -0,0 +1,4 @@
export * from './form.component';
export * from './movementList/movement-list.component';
export * from './select/select.component';
export * from './transfer/transfer-form.component';
@@ -0,0 +1,114 @@
<p-card>
<ng-template pTemplate="header">
<div class="p-4 flex items-center justify-between">
<span class="text-xl font-bold">{{ title }}</span>
<button pButton outlined icon="pi pi-refresh" [loading]="loading()" (click)="getAll()"></button>
</div>
</ng-template>
<ng-template pTemplate="content">
<p-table [value]="movements()" [loading]="loading()" dataKey="receiptId" [expandedRowKeys]="expandedRows">
<ng-template pTemplate="header">
<tr>
<th [style]="{ width: '5rem' }"></th>
<th>شناسه رسید</th>
<th>نوع گردش</th>
<th>انواع کالاها</th>
<th>تعداد کالاها</th>
<th>مجموع قیمت</th>
<th>تاریخ</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-movement let-expanded="expanded">
<tr>
<td>
<p-button
type="button"
pRipple
[pRowToggler]="movement"
text
severity="secondary"
rounded
[icon]="expanded ? 'pi pi-chevron-down' : 'pi pi-chevron-left'"
/>
</td>
<td>{{ movement.receiptId }}</td>
<td><catalog-movement-reference-tag [type]="movement.info.referenceType" /></td>
<td>{{ movement.count }}</td>
<td>{{ movement.info.quantity }}</td>
<td><span [appPriceMask]="movement.info.totalCost"></span></td>
<td><span [jalaliDate]="movement.info.createdAt"></span></td>
</tr>
</ng-template>
<ng-template #expandedrow let-movement>
<tr>
<td colspan="7">
<p-card class="p-4 pt-0 border border-primary-700">
<p-table [value]="movement.movements" 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-movement>
<tr>
<td>{{ movement.product.id }}</td>
<td>{{ movement.product.name }}</td>
<td>{{ movement.quantity }}</td>
<td><span [appPriceMask]="movement.fee"></span></td>
<td><span [appPriceMask]="movement.totalCost"></span></td>
<td>
<!-- <p-tag [value]="order.status" [severity]="getStatusSeverity(order.status)" /> -->
</td>
<td>
<p-button type="button" icon="pi pi-search" [routerLink]="'/products/' + movement.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,69 @@
import { MovementType } from '@/shared/catalog';
import { CatalogMovementReferenceTagComponent } from '@/shared/catalog/movementReferenceTypes';
import { JalaliDateDirective, PriceMaskDirective } from '@/shared/directives';
import { Component, Input, 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 { IInventoryStockMovementResponse } from '../../models';
import { InventoriesService } from '../../services/main.service';
@Component({
selector: 'inventory-movement-list',
templateUrl: './movement-list.component.html',
imports: [
Card,
TableModule,
ButtonDirective,
Ripple,
Button,
RouterLink,
CatalogMovementReferenceTagComponent,
PriceMaskDirective,
JalaliDateDirective,
],
})
export class InventoryMovementListComponent {
@Input() inventoryId!: string;
@Input() movementType?: MovementType;
loading = signal(false);
movements = signal<IInventoryStockMovementResponse[]>([]);
expandedRows = {};
constructor(private service: InventoriesService) {}
get title() {
switch (this.movementType) {
case 'IN':
return 'گردش ورودی کالاها';
case 'OUT':
return 'گردش خروجی کالاها';
case 'ADJUST':
return 'گردش تعدیل کالاها';
default:
return 'تمام گردش کالاها';
}
}
ngOnInit() {
this.getAll();
}
getAll() {
this.loading.set(true);
console.log(this.inventoryId);
return this.service.getMovements(this.inventoryId, this.movementType).subscribe({
next: (res) => {
this.movements.set(res.data);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
},
});
}
}
@@ -3,7 +3,7 @@ import { UikitFieldComponent } from '@/uikit';
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms'; import { ReactiveFormsModule } from '@angular/forms';
import { Select } from 'primeng/select'; import { Select } from 'primeng/select';
import { IInventoryResponse } from '../../models'; import { IInventorySummaryResponse } from '../../models';
import { InventoriesService } from '../../services/main.service'; import { InventoriesService } from '../../services/main.service';
@Component({ @Component({
@@ -11,7 +11,7 @@ import { InventoriesService } from '../../services/main.service';
templateUrl: './select.component.html', templateUrl: './select.component.html',
imports: [ReactiveFormsModule, Select, UikitFieldComponent], imports: [ReactiveFormsModule, Select, UikitFieldComponent],
}) })
export class InventoriesSelectComponent extends AbstractSelectComponent<IInventoryResponse> { export class InventoriesSelectComponent extends AbstractSelectComponent<IInventorySummaryResponse> {
constructor(private service: InventoriesService) { constructor(private service: InventoriesService) {
super(); super();
this.getData(); this.getData();
@@ -0,0 +1,149 @@
<p-card class="">
<ng-template #title> انتقال موجودی بین انبارها </ng-template>
<form [formGroup]="form" (submit)="submit()" class="flex flex-col gap-4 mt-4">
<div class="flex flex-col gap-4">
<div class="max-w-xs">
<app-input [control]="form.controls.code" label="شماره انتقال" />
</div>
<uikit-field [control]="form.controls.description" label="توضیحات">
<textarea
pTextarea
rows="4"
cols="30"
[formControl]="form.controls.description"
placeholder="توضیحات"
></textarea>
</uikit-field>
<div class="flex items-stretch gap-4">
<p-card class="flex-1 border border-primary-300">
<ng-template #title> انبار مبدا </ng-template>
<div class="mt-5">
<p-select
[options]="inventories()"
optionLabel="name"
[loading]="inventoriesLoading()"
[formControl]="form.controls.fromInventory"
placeholder="انتخاب انبار مبدا"
class="w-full"
></p-select>
<p-card class="mt-10 border border-primary-700">
<ng-template #title> کالاهای موجود در انبار مبدا </ng-template>
<div class="mt-5">
@if (!form.controls.fromInventory.value) {
<div class="text-center text-gray-500 py-10">لطفا انبار مبدا را انتخاب کنید</div>
} @else {
<p-table
[value]="originInventoryStock()"
[loading]="originInventoryStockLoading()"
selectionMode="multiple"
[(selection)]="selectedProducts"
(onRowSelect)="setSelectedProductsInForm($event)"
(onRowUnselect)="clearSelectedProductsInForm($event)"
dataKey="id"
class="w-full"
>
<ng-template #header>
<tr>
<th pSelectableRowCheckbox></th>
<th>نام کالا</th>
<th>موجودی</th>
<th>میانگین قیمت</th>
</tr>
</ng-template>
<ng-template let-item pTemplate="body">
<tr>
<td>
<p-tableCheckbox [disabled]="item.quantity === 0" [value]="item"></p-tableCheckbox>
</td>
<td>{{ item.product.name }}</td>
<td>{{ item.quantity }}</td>
<td>{{ item.avgCost }}</td>
</tr>
</ng-template>
</p-table>
}
</div>
</p-card>
</div>
</p-card>
<div class="shrink-0">
<div class="flex items-center h-100">
<i class="pi pi-arrow-left text-2xl"></i>
</div>
</div>
<p-card class="flex-1 border border-primary-300">
<ng-template #title> انبار مقصد </ng-template>
<div class="mt-5">
<p-select
[options]="toInventories"
optionLabel="name"
[loading]="inventoriesLoading()"
[formControl]="form.controls.toInventory"
placeholder="انتخاب انبار مقصد"
class="w-full"
></p-select>
<p-card class="mt-10 border border-primary-700">
<ng-template #title> کالاهای انتخاب شده </ng-template>
<div class="mt-5">
@if (!form.controls.fromInventory.value) {
<div class="text-center text-gray-500 py-10">لطفا انبار مبدا را انتخاب کنید</div>
} @else {
{{ form.controls.items.value.length }}
<p-table [value]="form.controls.items.controls" class="w-full">
<ng-template #header>
<tr>
<th>نام کالا</th>
<th>مقدار برای انتقال</th>
</tr>
</ng-template>
<ng-template let-item let-rowIndex="rowIndex" pTemplate="body">
<tr>
<td>{{ item.controls.product.value?.name }}</td>
<td>
<input
pInputText
type="number"
min="1"
[max]="item.controls.product.value?.quantity"
[formControl]="form.controls.items.at(rowIndex).controls.count"
/>
</td>
</tr>
</ng-template>
</p-table>
}
</div>
</p-card>
</div>
</p-card>
</div>
<div class="flex items-center gap-2">
<button
pButton
label="انصراف"
type="button"
outlined
icon="pi pi-times"
routerLink="/products"
[disabled]="form.disabled || submitLoading()"
></button>
<button
pButton
label="ذخیره"
type="submit"
icon="pi pi-check"
[loading]="submitLoading()"
(click)="submit()"
></button>
</div>
</div>
</form>
</p-card>
@@ -0,0 +1,157 @@
import { Maybe } from '@/core';
import { InputComponent } from '@/shared/components';
import { UikitFieldComponent } from '@/uikit';
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ButtonDirective } from 'primeng/button';
import { Card } from 'primeng/card';
import { InputText } from 'primeng/inputtext';
import { Select } from 'primeng/select';
import { TableModule, TableRowSelectEvent, TableRowUnSelectEvent } from 'primeng/table';
import { Textarea } from 'primeng/textarea';
import {
IInventoryProduct,
IInventoryStockResponse,
IInventorySummaryResponse,
IInventoryTransferRequest,
} from '../../models';
import { InventoriesService } from '../../services/main.service';
import { InventoriesSelectComponent } from '../select/select.component';
@Component({
selector: 'inventories-transfer-form',
templateUrl: './transfer-form.component.html',
imports: [
ReactiveFormsModule,
Card,
InventoriesSelectComponent,
InputComponent,
Textarea,
UikitFieldComponent,
Select,
TableModule,
InputText,
ButtonDirective,
],
})
export class InventoriesTransferFormComponent {
fb = inject(FormBuilder);
form = this.fb.group({
fromInventory: [null as Maybe<IInventorySummaryResponse>, [Validators.required]],
toInventory: [null as Maybe<IInventorySummaryResponse>, [Validators.required]],
code: ['', [Validators.required]],
description: [''],
items: this.fb.array(
[
this.fb.group({
product: [null as Maybe<IInventoryProduct>, [Validators.required]],
count: [1, [Validators.required, Validators.min(1)]],
}),
],
Validators.required,
),
});
inventoriesLoading = signal<boolean>(false);
inventories = signal<Maybe<IInventorySummaryResponse>[]>([]);
get toInventories() {
const fromInventoryId = this.form.controls.fromInventory.value;
return this.inventories().filter((inv) => inv?.id !== fromInventoryId?.id);
}
originInventoryStock = signal<any[]>([]);
originInventoryStockLoading = signal(false);
selectedProducts = signal<Maybe<IInventoryStockResponse>[]>([]);
constructor(private service: InventoriesService) {
this.form.controls.toInventory.disable();
this.form.controls.fromInventory.valueChanges.subscribe((val) => {
if (val && val.id) {
this.form.controls.toInventory.enable();
this.getSelectedInventoryStock();
}
});
this.getInventories();
}
setSelectedProductsInForm($e: TableRowSelectEvent<IInventoryStockResponse>) {
const stock = $e.data as Maybe<IInventoryStockResponse>;
if (stock && stock.product) {
if (
this.form.controls.items.length === 1 &&
!this.form.controls.items.value?.at(0)?.product
) {
this.form.controls.items.removeAt(0);
}
this.form.controls.items.push(
// @ts-ignore
this.fb.group({
product: [stock.product, [Validators.required]],
count: [1, [Validators.required, Validators.min(1)]],
}),
);
console.log(this.form.controls.items);
}
}
clearSelectedProductsInForm($e: TableRowUnSelectEvent<IInventoryProduct>) {
console.log($e.index);
if ($e.index != null) {
this.form.controls.items.removeAt($e.index);
}
}
getInventories() {
this.inventoriesLoading.set(true);
this.service.getAll().subscribe({
next: (res) => {
this.inventories.set(res.data);
this.inventoriesLoading.set(false);
},
error: () => {
this.inventoriesLoading.set(false);
},
});
}
getSelectedInventoryStock() {
this.originInventoryStockLoading.set(true);
this.service.getStock(String(this.form.controls.fromInventory.value?.id)).subscribe({
next: (res) => {
this.originInventoryStock.set(res.data);
this.originInventoryStockLoading.set(false);
},
error: () => {
this.originInventoryStockLoading.set(false);
},
});
}
submitLoading = signal(false);
submit() {
this.submitLoading.set(true);
const payload = {
fromInventoryId: this.form.controls.fromInventory.value?.id,
toInventoryId: this.form.controls.toInventory.value?.id,
code: this.form.controls.code.value || '',
description: this.form.controls.description.value || '',
items: this.form.controls.items.value.map((item) => ({
productId: item.product?.id,
count: item.count,
})),
} as IInventoryTransferRequest;
this.service.transfer(payload).subscribe({
next: () => {
this.submitLoading.set(false);
// this.form.reset();
},
error: () => {
this.submitLoading.set(false);
},
});
}
}
@@ -3,4 +3,9 @@ const baseUrl = '/api/v1/inventories';
export const INVENTORIES_API_ROUTES = { export const INVENTORIES_API_ROUTES = {
list: () => `${baseUrl}`, list: () => `${baseUrl}`,
single: (inventoryId: string) => `${baseUrl}/${inventoryId}`, single: (inventoryId: string) => `${baseUrl}/${inventoryId}`,
inventoryMovements: (inventoryId: string) => `${baseUrl}/${inventoryId}/movements`,
transfer: () => `${baseUrl}/transfers`,
stock: (inventoryId: string) => `${baseUrl}/${inventoryId}/stock`,
productCardex: (inventoryId: string, productId: string) =>
`${baseUrl}/${inventoryId}/products/${productId}/cardex`,
}; };
@@ -1,7 +1,12 @@
import { NamedRoutes } from '@/core'; import { NamedRoutes } from '@/core';
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
export type TInventoriesRouteNames = 'inventories' | 'inventory'; export type TInventoriesRouteNames =
| 'inventories'
| 'inventory'
| 'transfer'
| 'products'
| 'productCardex';
export const inventoriesNamedRoutes: NamedRoutes<TInventoriesRouteNames> = { export const inventoriesNamedRoutes: NamedRoutes<TInventoriesRouteNames> = {
inventories: { inventories: {
@@ -12,6 +17,16 @@ export const inventoriesNamedRoutes: NamedRoutes<TInventoriesRouteNames> = {
pagePath: () => '/inventories', pagePath: () => '/inventories',
}, },
}, },
transfer: {
path: 'inventories/transfer',
loadComponent: () =>
import('../../views/transfer.component').then((m) => m.InventoriesTransferComponent),
meta: {
title: 'انتقال بین انبارها',
pagePath: () => '/inventories/transfer',
},
},
inventory: { inventory: {
path: 'inventories/:inventoryId', path: 'inventories/:inventoryId',
loadComponent: () => import('../../views/single.component').then((m) => m.InventoryComponent), loadComponent: () => import('../../views/single.component').then((m) => m.InventoryComponent),
@@ -20,6 +35,24 @@ export const inventoriesNamedRoutes: NamedRoutes<TInventoriesRouteNames> = {
pagePath: () => '/inventories/:inventoryId', pagePath: () => '/inventories/:inventoryId',
}, },
}, },
products: {
path: 'inventories/:inventoryId/products',
loadComponent: () =>
import('../../views/products.component').then((m) => m.InventoryProductsComponent),
meta: {
title: 'محصولات انبار',
pagePath: () => '/inventories/:inventoryId/products',
},
},
productCardex: {
path: 'inventories/:inventoryId/products/:productId/cardex',
loadComponent: () =>
import('../../views/product-cardex.component').then((m) => m.InventoryProductCardexComponent),
meta: {
title: 'کارتکس محصول',
pagePath: () => '/inventories/:inventoryId/products/:productId/cardex',
},
},
}; };
export const INVENTORIES_ROUTES: Routes = Object.values(inventoriesNamedRoutes); export const INVENTORIES_ROUTES: Routes = Object.values(inventoriesNamedRoutes);
@@ -1 +1,2 @@
export * from './io'; export * from './io';
export * from './types';
+54 -2
View File
@@ -1,14 +1,66 @@
export interface IInventoryRawResponse { import {
IInventoryInfo,
IInventoryMovement,
IInventoryStockProduct,
IInventoryTransferRequestItem,
} from './types';
export interface IInventorySummaryRawResponse {
id: number; id: number;
name: string; name: string;
location: string; location: string;
isActive: boolean; isActive: boolean;
isPointOfSale: boolean;
createdAt: string;
updatedAt: string;
deletedAt: Maybe<string>;
} }
export interface IInventoryResponse extends IInventoryRawResponse {} export interface IInventorySummaryResponse extends IInventorySummaryRawResponse {}
export interface IInventoryDetailRawResponse extends IInventorySummaryRawResponse {
availableProductTypes: number;
availableProductCount: number;
availableProductsCost: number;
}
export interface IInventoryDetailResponse extends IInventoryDetailRawResponse {}
export interface IInventoryRequest { export interface IInventoryRequest {
name: string; name: string;
location: string; location: string;
isActive: boolean; 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 {}
@@ -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 {}
@@ -1,9 +1,24 @@
import { IPaginatedResponse } from '@/core/models/service.model'; import { IPaginatedResponse } from '@/core/models/service.model';
import { MovementType } from '@/shared/catalog';
import { HttpClient } from '@angular/common/http'; 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 { INVENTORIES_API_ROUTES } from '../constants'; import { INVENTORIES_API_ROUTES } from '../constants';
import { IInventoryRawResponse, IInventoryRequest, IInventoryResponse } from '../models'; import {
IInventoryDetailRawResponse,
IInventoryDetailResponse,
IInventoryProductCardexRawResponse,
IInventoryProductCardexResponse,
IInventoryRequest,
IInventoryStockMovementRawResponse,
IInventoryStockMovementResponse,
IInventoryStockQuery,
IInventoryStockRawResponse,
IInventoryStockResponse,
IInventorySummaryRawResponse,
IInventorySummaryResponse,
IInventoryTransferRequest,
} from '../models';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class InventoriesService { export class InventoriesService {
@@ -11,15 +26,50 @@ export class InventoriesService {
private apiRoutes = INVENTORIES_API_ROUTES; private apiRoutes = INVENTORIES_API_ROUTES;
getAll(): Observable<IPaginatedResponse<IInventoryResponse>> { getAll(): Observable<IPaginatedResponse<IInventorySummaryResponse>> {
return this.http.get<IPaginatedResponse<IInventoryRawResponse>>(this.apiRoutes.list()); return this.http.get<IPaginatedResponse<IInventorySummaryRawResponse>>(this.apiRoutes.list());
} }
getSingle(inventoryId: string): Observable<IInventoryResponse> { getSingle(inventoryId: string): Observable<IInventoryDetailResponse> {
return this.http.get<IInventoryRawResponse>(this.apiRoutes.single(inventoryId)); return this.http.get<IInventoryDetailRawResponse>(this.apiRoutes.single(inventoryId));
} }
create(data: IInventoryRequest): Observable<IInventoryResponse> { create(data: IInventoryRequest): Observable<IInventorySummaryResponse> {
return this.http.post<IInventoryRawResponse>(this.apiRoutes.list(), data); return this.http.post<IInventorySummaryRawResponse>(this.apiRoutes.list(), data);
}
getMovements(
inventoryId: string,
movementType?: MovementType,
): Observable<IPaginatedResponse<IInventoryStockMovementResponse>> {
return this.http.get<IPaginatedResponse<IInventoryStockMovementRawResponse>>(
this.apiRoutes.inventoryMovements(inventoryId),
{
params: movementType ? { type: movementType } : {},
},
);
}
transfer(payload: IInventoryTransferRequest): Observable<void> {
return this.http.post<void>(this.apiRoutes.transfer(), payload);
}
getStock(
inventoryId: string,
params?: IInventoryStockQuery,
): Observable<IPaginatedResponse<IInventoryStockResponse>> {
return this.http.get<IPaginatedResponse<IInventoryStockRawResponse>>(
this.apiRoutes.stock(inventoryId),
{ params: params ? { ...params } : {} },
);
}
getProductCardex(
inventoryId: string,
productId: string,
): Observable<IPaginatedResponse<IInventoryProductCardexResponse>> {
return this.http.get<IPaginatedResponse<IInventoryProductCardexRawResponse>>(
`${this.apiRoutes.productCardex(inventoryId, productId)}`,
);
} }
} }
@@ -8,6 +8,7 @@
[items]="items()" [items]="items()"
[loading]="loading()" [loading]="loading()"
[showDetails]="true" [showDetails]="true"
(onDetails)="toDetails($event)"
(onAdd)="openAddForm()" (onAdd)="openAddForm()"
/> />
@@ -2,9 +2,10 @@ import {
IColumn, IColumn,
PageDataListComponent, PageDataListComponent,
} from '@/shared/components/pageDataList/page-data-list.component'; } from '@/shared/components/pageDataList/page-data-list.component';
import { Component, signal } from '@angular/core'; import { Component, inject, signal } from '@angular/core';
import { Router } from '@angular/router';
import { InventoryFormComponent } from '../components/form.component'; import { InventoryFormComponent } from '../components/form.component';
import { IInventoryResponse } from '../models'; import { IInventorySummaryResponse } from '../models';
import { InventoriesService } from '../services/main.service'; import { InventoriesService } from '../services/main.service';
@Component({ @Component({
@@ -13,6 +14,7 @@ import { InventoriesService } from '../services/main.service';
imports: [PageDataListComponent, InventoryFormComponent], imports: [PageDataListComponent, InventoryFormComponent],
}) })
export class InventoriesComponent { export class InventoriesComponent {
private router = inject(Router);
constructor(private inventoryService: InventoriesService) { constructor(private inventoryService: InventoriesService) {
this.getData(); this.getData();
} }
@@ -21,11 +23,12 @@ 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: 'isActive', header: 'فعال' }, { field: 'isActive', header: 'فعال' },
] as IColumn[]; ] as IColumn[];
loading = signal(false); loading = signal(false);
items = signal<IInventoryResponse[]>([]); items = signal<IInventorySummaryResponse[]>([]);
visibleForm = signal(false); visibleForm = signal(false);
refresh() { refresh() {
@@ -43,4 +46,8 @@ export class InventoriesComponent {
openAddForm() { openAddForm() {
this.visibleForm.set(true); this.visibleForm.set(true);
} }
toDetails(item: IInventorySummaryResponse) {
this.router.navigate(['/inventories', item.id]);
}
} }
@@ -0,0 +1,16 @@
@if (pageLoading) {
<div class="h-svh w-svw flex items-center justify-center">
<p-progress-spinner />
</div>
} @else {
<p-card>
<ng-template #header>
<div class="p-4 flex items-center justify-between">
<span class="text-xl font-bold">کاردکس کالای {{ product()?.name }} در انبار {{ inventory()?.name }}</span>
<!-- <button pButton outlined icon="pi pi-refresh" [loading]="get()" (click)="getData()"></button> -->
</div>
</ng-template>
<shared-cardex [loading]="getCardexLoading()" [items]="cardex() || []" />
</p-card>
}
@@ -0,0 +1,80 @@
import { Maybe } from '@/core';
import { IProductResponse } from '@/modules/products/models';
import { ProductsService } from '@/modules/products/services/main.service';
import { CardexComponent } from '@/shared/components/cardex/cardex.component';
import { Component, inject, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Card } from 'primeng/card';
import { ProgressSpinner } from 'primeng/progressspinner';
import { IInventoryDetailResponse, IInventoryProductCardexResponse } from '../models';
import { InventoriesService } from '../services/main.service';
@Component({
selector: 'inventory-product-cardex',
templateUrl: './product-cardex.component.html',
imports: [ProgressSpinner, Card, CardexComponent],
})
export class InventoryProductCardexComponent {
private route = inject(ActivatedRoute);
constructor(
private service: InventoriesService,
private productService: ProductsService,
) {
this.getInventory();
this.getProduct();
this.getCardex();
}
inventoryId = this.route.snapshot.params['inventoryId'];
productId = this.route.snapshot.params['productId'];
getInventoryLoading = signal(false);
inventory = signal<Maybe<IInventoryDetailResponse>>(null);
getProductLoading = signal(false);
product = signal<Maybe<IProductResponse>>(null);
getCardexLoading = signal(false);
cardex = signal<Maybe<IInventoryProductCardexResponse[]>>(null);
get pageLoading() {
return this.getInventoryLoading() || this.getProductLoading();
}
getInventory() {
this.getInventoryLoading.set(true);
this.service.getSingle(this.inventoryId).subscribe({
next: (res) => {
this.inventory.set(res);
this.getInventoryLoading.set(false);
},
error: () => {
this.getInventoryLoading.set(false);
},
});
}
getProduct() {
this.getProductLoading.set(true);
this.productService.getSingle(this.productId).subscribe({
next: (res) => {
this.getProductLoading.set(false);
this.product.set(res);
},
error: () => {
this.getProductLoading.set(false);
},
});
}
getCardex() {
this.service.getProductCardex(this.inventoryId, this.productId).subscribe({
next: (res) => {
this.getCardexLoading.set(false);
this.cardex.set(res.data);
},
error: () => {
this.getCardexLoading.set(false);
},
});
}
}
@@ -0,0 +1,51 @@
<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)="getData()"></button>
</div>
</ng-template>
<p-table [value]="data()" [loading]="loading()">
<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>
<button pButton type="button" icon="pi pi-search" title="کاردکس کالا"></button>
</td>
</tr>
</ng-template>
</p-table>
</p-card>
@@ -0,0 +1,37 @@
import { PriceMaskDirective } from '@/shared/directives';
import { Component, inject, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ButtonDirective } from 'primeng/button';
import { Card } from 'primeng/card';
import { TableModule } from 'primeng/table';
import { IInventoryStockResponse } from '../models';
import { InventoriesService } from '../services/main.service';
@Component({
selector: 'inventory-products',
templateUrl: './products.component.html',
imports: [Card, TableModule, ButtonDirective, PriceMaskDirective],
})
export class InventoryProductsComponent {
private route = inject(ActivatedRoute);
constructor(private service: InventoriesService) {
this.getData();
}
inventoryId = this.route.snapshot.params['inventoryId'];
loading = signal(false);
data = signal<IInventoryStockResponse[]>([]);
getData() {
this.loading.set(true);
this.service.getStock(this.inventoryId).subscribe({
next: (res) => {
this.data.set(res.data);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
},
});
}
}
@@ -1 +1,96 @@
<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]="['/inventories']" />
<h3 class="m-0">{{ data()?.name }}</h3>
</div>
</div>
<div class="flex items-center gap-2">
<button pButton type="button" label="افزایش موجودی" icon="pi pi-plus"></button>
<!-- [routerLink]="['/inventories', inventoryId, 'purchase']" -->
<button
pButton
type="button"
label="انتقال کالا"
icon="pi pi-plus"
[routerLink]="['/inventories', inventoryId, 'transfer']"
></button>
<button pButton type="button" label="ویرایش" icon="pi pi-pencil"></button>
<!-- [routerLink]="['/inventories', inventoryId, 'update']" -->
<button pButton icon="pi pi-trash" severity="danger" (click)="openDeleteConfirm()"></button>
</div>
</div>
<div class="inline-flex items-center gap-5">
<div class="">
<app-key-value label="موقعیت انبار" [value]="data()?.location || '-'" />
</div>
<div class="">
<app-key-value label="قابلیت فروش" [value]="data()?.isPointOfSale ? 'دارد' : 'ندارد'" />
</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>
<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>
<inventory-movement-list [inventoryId]="inventoryId" movementType="IN" />
<inventory-movement-list [inventoryId]="inventoryId" movementType="OUT" />
<inventory-movement-list [inventoryId]="inventoryId" movementType="ADJUST" />
</div>
@@ -1,12 +1,15 @@
import { Maybe } from '@/core'; import { Maybe } from '@/core';
import { ProductChargeFormComponent } from '@/modules/productCharges/components/form.component';
import { KeyValueComponent } from '@/shared/components'; import { KeyValueComponent } from '@/shared/components';
import { DetailsInfoCardComponent } from '@/shared/components/detailsInfoCard/details-info-card.component';
import { PriceMaskDirective } from '@/shared/directives';
import { Component, inject, signal } from '@angular/core'; import { Component, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router'; import { ActivatedRoute, RouterLink } from '@angular/router';
import { Button, ButtonDirective } from 'primeng/button'; import { Button, ButtonDirective } from 'primeng/button';
import { Card } from 'primeng/card'; import { Card } from 'primeng/card';
import { GalleriaModule } from 'primeng/galleria'; import { GalleriaModule } from 'primeng/galleria';
import { IInventoryResponse } from '../models'; import { TableModule } from 'primeng/table';
import { InventoryMovementListComponent } from '../components/movementList/movement-list.component';
import { IInventoryDetailResponse, IInventoryStockResponse } from '../models';
import { InventoriesService } from '../services/main.service'; import { InventoriesService } from '../services/main.service';
@Component({ @Component({
@@ -17,25 +20,28 @@ import { InventoriesService } from '../services/main.service';
RouterLink, RouterLink,
GalleriaModule, GalleriaModule,
KeyValueComponent, KeyValueComponent,
Card,
Button, Button,
ProductChargeFormComponent, InventoryMovementListComponent,
DetailsInfoCardComponent,
Card,
TableModule,
PriceMaskDirective,
], ],
}) })
export class InventoryComponent { export class InventoryComponent {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
constructor(private service: InventoriesService) { constructor(private service: InventoriesService) {
this.getData(); this.getData();
this.getStock();
} }
productId = this.route.snapshot.params['productId']; inventoryId = this.route.snapshot.params['inventoryId'];
isOpenChargeForm = signal(false);
loading = signal(false); loading = signal(false);
data = signal<Maybe<IInventoryResponse>>(null); data = signal<Maybe<IInventoryDetailResponse>>(null);
getData() { getData() {
this.loading.set(true); this.loading.set(true);
this.service.getSingle(this.productId).subscribe({ this.service.getSingle(this.inventoryId).subscribe({
next: (res) => { next: (res) => {
this.data.set(res); this.data.set(res);
this.loading.set(false); this.loading.set(false);
@@ -46,35 +52,41 @@ export class InventoryComponent {
}); });
} }
getStockLoading = signal(false);
stock = signal<IInventoryStockResponse[]>([]);
getStock() {
this.getStockLoading.set(true);
this.service.getStock(this.inventoryId, { isAvailable: true }).subscribe({
next: (res) => {
this.getStockLoading.set(false);
this.stock.set(res.data);
},
error: () => {
this.getStockLoading.set(false);
},
});
}
get topBarCardDetails() { get topBarCardDetails() {
return [ return [
// { {
// icon: 'pi pi-box', icon: 'pi pi-box',
// label: 'موجودی', label: 'تعداد کالاهای موجود',
// value: this.data()? || 0, value: this.data()?.availableProductCount,
// }, },
// { {
// icon: 'pi pi-dollar', icon: 'pi pi-dollar',
// label: 'قیمت پایه', label: 'تنوع کالاها',
// value: this.data()?.basePrice?.toString() || 'تعیین نشده', value: this.data()?.availableProductTypes,
// }, },
// { {
// icon: 'pi pi-calendar', icon: 'pi pi-dollar',
// label: 'تاریخ ایجاد', label: 'ارزش کلی کالاهای موجود',
// value: this.data()?.createdAt value: this.data()?.availableProductsCost,
// ? new Date(this.data()!.createdAt!).toLocaleDateString() },
// : '-',
// },
// {
// icon: 'pi pi-clock',
// label: 'تعداد فروش',
// value: 1200,
// },
]; ];
} }
openDeleteConfirm() {} openDeleteConfirm() {}
openChargeForm() {
this.isOpenChargeForm.set(true);
}
} }
@@ -0,0 +1,3 @@
<div class="max-w-6xl mx-auto">
<inventories-transfer-form></inventories-transfer-form>
</div>
@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
import { InventoriesTransferFormComponent } from '../components/transfer/transfer-form.component';
@Component({
selector: 'inventories-transfer',
templateUrl: './transfer.component.html',
imports: [InventoriesTransferFormComponent],
})
export class InventoriesTransferComponent {
constructor() {}
}
@@ -0,0 +1,6 @@
// const baseUrl = '/api/v1/product-categories';
// export const PRODUCT_CATEGORIES_API_ROUTES = {
// list: () => `${baseUrl}`,
// single: (categoryId: string) => `${baseUrl}/${categoryId}`,
// };
+2
View File
@@ -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 TPOSRouteNames = 'POS';
export const posNamedRoutes: NamedRoutes<TPOSRouteNames> = {
POS: {
path: 'pos',
loadComponent: () => import('../../views/pos.component').then((m) => m.POSComponent),
meta: {
title: 'نقطه فروش',
pagePath: () => '/pos',
},
},
};
export const POS_ROUTES: Routes = Object.values(posNamedRoutes);
@@ -0,0 +1 @@
<div class=""></div>
@@ -0,0 +1,9 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-pos',
templateUrl: './pos.component.html',
})
export class POSComponent {
constructor() {}
}
+2
View File
@@ -16,6 +16,8 @@ export interface IProductRawResponse {
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
deletedAt?: Maybe<Date>; deletedAt?: Maybe<Date>;
stock: number;
avgCost?: number;
} }
export interface IProductResponse extends IProductRawResponse {} export interface IProductResponse extends IProductRawResponse {}
@@ -30,17 +30,7 @@
<div class="flex grow flex-col gap-4"> <div class="flex grow flex-col gap-4">
<div class="grid grid-cols-4 gap-4"> <div class="grid grid-cols-4 gap-4">
@for (item of topBarCardDetails; track $index) { @for (item of topBarCardDetails; track $index) {
<p-card class="w-full border border-primary-700"> <details-info-card [icon]="item.icon" [label]="item.label" [value]="item.value.toString()" />
<div class="flex gap-5 text-primary-400">
<div class="shrink-0 pt-1">
<i [class]="'pi ' + item.icon + ' text-2xl'"></i>
</div>
<div class="grow flex flex-col gap-1">
<span class="text-sm">{{ item.label }}</span>
<span class="text-xl font-bold text-primary">{{ item.value }}</span>
</div>
</div>
</p-card>
} }
</div> </div>
@@ -1,6 +1,7 @@
import { Maybe } from '@/core'; import { Maybe } from '@/core';
import { PurchaseFormComponent } from '@/modules/purchases/components/form.component'; import { PurchaseFormComponent } from '@/modules/purchases/components/form.component';
import { KeyValueComponent } from '@/shared/components'; import { KeyValueComponent } from '@/shared/components';
import { DetailsInfoCardComponent } from '@/shared/components/detailsInfoCard/details-info-card.component';
import { Component, inject, signal } from '@angular/core'; import { Component, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router'; import { ActivatedRoute, RouterLink } from '@angular/router';
import { Button, ButtonDirective } from 'primeng/button'; import { Button, ButtonDirective } from 'primeng/button';
@@ -20,6 +21,7 @@ import { ProductsService } from '../services/main.service';
Card, Card,
Button, Button,
PurchaseFormComponent, PurchaseFormComponent,
DetailsInfoCardComponent,
], ],
}) })
export class ProductComponent { export class ProductComponent {
@@ -79,7 +81,7 @@ export class ProductComponent {
{ {
icon: 'pi pi-box', icon: 'pi pi-box',
label: 'موجودی', label: 'موجودی',
value: this.product()?.count || 0, value: this.product()?.stock || 0,
}, },
{ {
icon: 'pi pi-dollar', icon: 'pi pi-dollar',
@@ -87,16 +89,14 @@ export class ProductComponent {
value: this.product()?.basePrice?.toString() || 'تعیین نشده', value: this.product()?.basePrice?.toString() || 'تعیین نشده',
}, },
{ {
icon: 'pi pi-calendar', icon: 'pi pi-dollar',
label: 'تاریخ ایجاد', label: 'میانگین قیمت',
value: this.product()?.createdAt value: this.product()?.avgCost?.toString() || 'تعیین نشده',
? new Date(this.product()!.createdAt!).toLocaleDateString()
: '-',
}, },
{ {
icon: 'pi pi-clock', icon: 'pi pi-clock',
label: 'تعداد فروش', label: 'تعداد فروش',
value: 1200, value: '-',
}, },
]; ];
} }
@@ -1,5 +1,5 @@
import { InventoriesSelectComponent } from '@/modules/inventories/components/select/select.component'; import { InventoriesSelectComponent } from '@/modules/inventories/components/select/select.component';
import { IInventoryResponse } from '@/modules/inventories/models'; import { IInventorySummaryResponse } from '@/modules/inventories/models';
import { IProductResponse } from '@/modules/products/models'; import { IProductResponse } from '@/modules/products/models';
import { SuppliersSelectComponent } from '@/modules/suppliers/components/select/select.component'; import { SuppliersSelectComponent } from '@/modules/suppliers/components/select/select.component';
import { ISupplierResponse } from '@/modules/suppliers/models'; import { ISupplierResponse } from '@/modules/suppliers/models';
@@ -30,7 +30,7 @@ import { ProductChargeRowFormComponent } from './product-charge-row-form.compone
}) })
export class PurchaseFormComponent { export class PurchaseFormComponent {
@Input() product?: IProductResponse; @Input() product?: IProductResponse;
@Input() inventory?: IInventoryResponse; @Input() inventory?: IInventorySummaryResponse;
@Input() supplier?: ISupplierResponse; @Input() supplier?: ISupplierResponse;
private fb = inject(FormBuilder); private fb = inject(FormBuilder);
@@ -1,7 +1,7 @@
import { Maybe } from '@/core'; import { Maybe } from '@/core';
import { ToastService } from '@/core/services/toast.service'; import { ToastService } from '@/core/services/toast.service';
import { InventoriesSelectComponent } from '@/modules/inventories/components/select/select.component'; import { InventoriesSelectComponent } from '@/modules/inventories/components/select/select.component';
import { IInventoryResponse } from '@/modules/inventories/models'; import { IInventorySummaryResponse } from '@/modules/inventories/models';
import { IProductResponse } from '@/modules/products/models'; import { IProductResponse } from '@/modules/products/models';
import { SuppliersSelectComponent } from '@/modules/suppliers/components/select/select.component'; import { SuppliersSelectComponent } from '@/modules/suppliers/components/select/select.component';
import { ISupplierResponse } from '@/modules/suppliers/models'; import { ISupplierResponse } from '@/modules/suppliers/models';
@@ -44,7 +44,7 @@ export class PurchaseReceiptTemplateComponent {
private fb = inject(FormBuilder); private fb = inject(FormBuilder);
@Input() product?: IProductResponse; @Input() product?: IProductResponse;
@Input() inventory?: IInventoryResponse; @Input() inventory?: IInventorySummaryResponse;
@Input() supplier?: ISupplierResponse; @Input() supplier?: ISupplierResponse;
columns = [ columns = [
@@ -105,7 +105,7 @@ export class PurchaseReceiptTemplateComponent {
total: [300000, [Validators.required, Validators.min(0)]], total: [300000, [Validators.required, Validators.min(0)]],
}), }),
]), ]),
inventory: [{ id: 2 } as Maybe<IInventoryResponse>, [Validators.required]], inventory: [{ id: 2 } as Maybe<IInventorySummaryResponse>, [Validators.required]],
supplier: [{ id: 1 } as Maybe<ISupplierResponse>, [Validators.required]], supplier: [{ id: 1 } as Maybe<ISupplierResponse>, [Validators.required]],
}); });
@@ -179,7 +179,8 @@ export class PurchaseReceiptTemplateComponent {
next: (res) => { next: (res) => {
this.toastService.success({ text: 'ثبت سند خرید با موفقیت انجام شد' }); this.toastService.success({ text: 'ثبت سند خرید با موفقیت انجام شد' });
this.form.enable(); this.form.enable();
this.form.reset(); // this.form.reset();
this.form.controls.code.setValue(this.form.controls.code.value + '1');
this.formIsSubmitted.set(false); this.formIsSubmitted.set(false);
}, },
error: () => { error: () => {
+1
View File
@@ -1 +1,2 @@
export * from './gender'; export * from './gender';
export * from './movementTypes';
@@ -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 { MovementReferenceType } from './types';
import { getMovementReferenceTypeColor, getMovementReferenceTypeText } from './utils';
@Component({
selector: 'catalog-movement-reference-tag',
templateUrl: './tag.component.html',
imports: [Tag],
})
export class CatalogMovementReferenceTagComponent {
@Input() type!: MovementReferenceType;
constructor() {}
get text() {
return getMovementReferenceTypeText(this.type);
}
get color() {
return getMovementReferenceTypeColor(this.type);
}
}
@@ -0,0 +1,9 @@
export const MovementReferenceType = {
PURCHASE: 'PURCHASE',
SALES: 'SALES',
ADJUSTMENT: 'ADJUSTMENT',
INVENTORY_TRANSFER: 'INVENTORY_TRANSFER',
} as const;
export type MovementReferenceType =
(typeof MovementReferenceType)[keyof typeof MovementReferenceType];
@@ -0,0 +1,28 @@
import { TagSeverity } from '@/core/models/tag';
import { MovementReferenceType } from './types';
export const getMovementReferenceTypeText = (movementType: MovementReferenceType): string => {
switch (movementType) {
case MovementReferenceType.PURCHASE:
return 'خرید';
case MovementReferenceType.SALES:
return 'فروش';
case MovementReferenceType.ADJUSTMENT:
return 'تعدیل';
case MovementReferenceType.INVENTORY_TRANSFER:
return 'انتقال بین انبارها';
}
};
export const getMovementReferenceTypeColor = (movementType: MovementReferenceType): TagSeverity => {
switch (movementType) {
case MovementReferenceType.PURCHASE:
return 'success';
case MovementReferenceType.SALES:
return 'danger';
case MovementReferenceType.ADJUSTMENT:
return 'warn';
case MovementReferenceType.INVENTORY_TRANSFER:
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 { MovementType } from './types';
import { getMovementTypeColor, getMovementTypeText } from './utils';
@Component({
selector: 'catalog-movement-tag',
templateUrl: './tag.component.html',
imports: [Tag],
})
export class CatalogMovementTagComponent {
@Input() type!: MovementType;
constructor() {}
get text() {
return getMovementTypeText(this.type);
}
get color() {
return getMovementTypeColor(this.type);
}
}
@@ -0,0 +1,7 @@
export const MovementType = {
IN: 'IN',
OUT: 'OUT',
ADJUST: 'ADJUST',
} as const;
export type MovementType = (typeof MovementType)[keyof typeof MovementType];
@@ -0,0 +1,24 @@
import { TagSeverity } from '@/core/models/tag';
import { MovementType } from './types';
export const getMovementTypeText = (movementType: MovementType): string => {
switch (movementType) {
case MovementType.IN:
return 'ورودی';
case MovementType.OUT:
return 'خروجی';
case MovementType.ADJUST:
return 'تعدیل';
}
};
export const getMovementTypeColor = (movementType: MovementType): TagSeverity => {
switch (movementType) {
case MovementType.IN:
return 'success';
case MovementType.OUT:
return 'danger';
case MovementType.ADJUST:
return 'warn';
}
};
@@ -0,0 +1,65 @@
<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="3" class="text-center!" [style]="{ width: '25rem' }">مانده</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" /></td>
<td class="text-center!">{{ item.referenceId || "-" }}</td>
<td class="text-center!">{{ item.supplier?.name || "-" }}</td>
<td class="text-center!">{{ item.inventory.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 { ICardexItem } from './type';
@Component({
selector: 'shared-cardex',
templateUrl: './cardex.component.html',
imports: [
TableModule,
JalaliDateDirective,
CatalogMovementReferenceTagComponent,
PriceMaskDirective,
],
hostDirectives: [PriceMaskDirective],
})
export class CardexComponent {
@Input() items: ICardexItem[] = [];
@Input() loading: boolean = false;
constructor() {}
}
+3
View File
@@ -0,0 +1,3 @@
import { IInventoryStockMovementResponse } from '@/modules/inventories/models';
export interface ICardexItem extends IInventoryStockMovementResponse {}
@@ -0,0 +1,11 @@
<p-card class="w-full border border-primary-700">
<div class="flex gap-5 text-primary-400">
<div class="shrink-0 pt-1">
<i [class]="'pi ' + icon + ' text-2xl'"></i>
</div>
<div class="grow flex flex-col gap-1">
<span class="text-sm">{{ label }}</span>
<span class="text-xl font-bold text-primary">{{ value }}</span>
</div>
</div>
</p-card>
@@ -0,0 +1,14 @@
import { Component, Input } from '@angular/core';
import { Card } from 'primeng/card';
@Component({
selector: 'details-info-card',
templateUrl: './details-info-card.component.html',
imports: [Card],
})
export class DetailsInfoCardComponent {
@Input() icon: string = '';
@Input() label: string = '';
@Input() value: string = '';
constructor() {}
}
@@ -1,4 +1,5 @@
import { PaginatorComponent, UikitCopyComponent, UikitEmptyStateComponent } from '@/uikit'; import { PaginatorComponent, UikitCopyComponent, UikitEmptyStateComponent } from '@/uikit';
import { formatJalali } from '@/utils';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import {
Component, Component,
@@ -124,8 +125,7 @@ export class PageDataListComponent<I> {
switch (column.type) { switch (column.type) {
case 'date': case 'date':
if (!data) return '-'; if (!data) return '-';
const date = new Date(data); return formatJalali(data);
return isNaN(date.getTime()) ? '-' : date.toLocaleDateString();
case 'boolean': case 'boolean':
return data ? 'بله' : 'خیر'; return data ? 'بله' : 'خیر';
case 'price': case 'price':
+2
View File
@@ -1,2 +1,4 @@
export * from './price-alphabet.directive'; export * from './price-alphabet.directive';
export * from './price-mask.directive'; export * from './price-mask.directive';
export * from './jalali-date.directive';
@@ -0,0 +1,22 @@
import { formatJalali } from '@/utils';
import { Directive, ElementRef, Input, OnChanges, Renderer2, SimpleChanges } from '@angular/core';
@Directive({
selector: '[jalaliDate]',
})
export class JalaliDateDirective implements OnChanges {
@Input('jalaliDate') dateValue!: string | number | Date;
@Input() jalaliFormat: string = 'YYYY/MM/DD';
constructor(
private el: ElementRef,
private renderer: Renderer2,
) {}
ngOnChanges(changes: SimpleChanges): void {
if (this.dateValue) {
const formatted = formatJalali(this.dateValue, this.jalaliFormat);
this.renderer.setProperty(this.el.nativeElement, 'innerText', formatted);
}
}
}
+1
View File
@@ -2,6 +2,7 @@ export * from './copy/copy.component';
export * from './datepicker/datepicker.component'; export * from './datepicker/datepicker.component';
export * from './duration-picker/duration-picker.component'; export * from './duration-picker/duration-picker.component';
export * from './paginator.component'; export * from './paginator.component';
export * from './uikit-counter.component';
export * from './uikit-emptystate.component'; export * from './uikit-emptystate.component';
export * from './uikit-field.component'; export * from './uikit-field.component';
export * from './uikit-label.component'; export * from './uikit-label.component';
@@ -0,0 +1,35 @@
<div class="flex items-center border rounded overflow-hidden bg-white" [class.opacity-60]="disabled">
<button
type="button"
pButton
class="p-button p-component flex items-center justify-center px-3"
(click)="decrease()"
[disabled]="disabled"
aria-label="decrease"
>
-
</button>
<input
class="text-center w-16 px-2 py-1 outline-none"
type="number"
[value]="value"
(input)="onInput($event)"
[min]="min ?? undefined"
[max]="max ?? undefined"
[step]="step"
[disabled]="disabled"
aria-label="quantity"
/>
<button
type="button"
pButton
class="p-button p-component flex items-center justify-center px-3"
(click)="increase()"
[disabled]="disabled"
aria-label="increase"
>
+
</button>
</div>
@@ -0,0 +1,14 @@
:host {
display: inline-block;
}
/* Tailwind handles the main layout. Keep a couple of small overrides for cross-browser input appearance. */
.uikit-counter__input {
-moz-appearance: textfield;
}
.uikit-counter__input::-webkit-outer-spin-button,
.uikit-counter__input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
+52
View File
@@ -0,0 +1,52 @@
import { CommonModule } from '@angular/common';
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { ButtonModule } from 'primeng/button';
@Component({
selector: 'app-uikit-counter',
templateUrl: './uikit-counter.component.html',
styleUrls: ['./uikit-counter.component.scss'],
standalone: true,
imports: [CommonModule, ButtonModule],
})
export class UikitCounterComponent {
@Input() value = 0;
@Input() min: number | null = null;
@Input() max: number | null = null;
@Input() step = 1;
@Input() disabled = false;
@Output() valueChange = new EventEmitter<number>();
clamp(v: number): number {
if (this.min !== null && v < this.min) return this.min;
if (this.max !== null && v > this.max) return this.max;
return v;
}
setValue(v: number) {
const clamped = this.clamp(v);
if (clamped !== this.value) {
this.value = clamped;
this.valueChange.emit(this.value);
}
}
increase() {
if (this.disabled) return;
this.setValue(this.value + this.step);
}
decrease() {
if (this.disabled) return;
this.setValue(this.value - this.step);
}
onInput(e: Event) {
const v = (e.target as HTMLInputElement).value;
const n = Number(v);
if (!Number.isNaN(n)) {
this.setValue(n);
}
}
}
+1
View File
@@ -1,3 +1,4 @@
export * from './currency'; export * from './currency';
export * from './jalali-date.utils';
export * from './name'; export * from './name';
export * from './time'; export * from './time';
+43
View File
@@ -0,0 +1,43 @@
import dayjs, { Dayjs } from 'dayjs';
import jalaliday from 'jalaliday';
dayjs.extend(jalaliday);
export function toJalali(date: string | number | Date | Dayjs, format = 'YYYY/MM/DD'): Dayjs {
return dayjs(date).calendar('jalali');
}
export function fromJalali(jalaliDate: string): Dayjs {
// Parse Jalali date string (e.g., '1404-09-21')
return dayjs(jalaliDate, { jalali: true }).calendar('gregory');
}
export function formatJalali(date: string | number | Date | Dayjs, format = 'YYYY/MM/DD'): string {
return toJalali(date).format(format);
}
export function jalaliDiff(
date1: string | number | Date | Dayjs,
date2: string | number | Date | Dayjs,
unit: dayjs.OpUnitType = 'day',
): number {
return toJalali(date1).diff(toJalali(date2), unit);
}
export function isJalaliBefore(
date1: string | number | Date | Dayjs,
date2: string | number | Date | Dayjs,
): boolean {
return toJalali(date1).isBefore(toJalali(date2));
}
export function isJalaliAfter(
date1: string | number | Date | Dayjs,
date2: string | number | Date | Dayjs,
): boolean {
return toJalali(date1).isAfter(toJalali(date2));
}
export function nowJalali(): Dayjs {
return dayjs().calendar('jalali');
}