diff --git a/package.json b/package.json index 5d94bed..0872646 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,10 @@ "@primeuix/themes": "^1.2.1", "@tailwindcss/postcss": "^4.1.11", "chart.js": "4.4.2", + "dayjs": "^1.11.19", "flatpickr": "^4.6.13", "flatpickr-wrap": "^1.0.0", + "jalaliday": "^3.1.1", "jest-editor-support": "*", "primeclt": "^0.1.5", "primeicons": "^7.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71e398e..f4829f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,12 +44,18 @@ importers: chart.js: specifier: 4.4.2 version: 4.4.2 + dayjs: + specifier: ^1.11.19 + version: 1.11.19 flatpickr: specifier: ^4.6.13 version: 4.6.13 flatpickr-wrap: specifier: ^1.0.0 version: 1.0.0 + jalaliday: + specifier: ^3.1.1 + version: 3.1.1(dayjs@1.11.19) jest-editor-support: specifier: '*' version: 32.0.0-beta.1 @@ -3136,6 +3142,9 @@ packages: resolution: {integrity: sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==} engines: {node: '>=4.0'} + dayjs@1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -4200,6 +4209,11 @@ packages: jackspeak@3.4.3: 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: resolution: {integrity: sha512-VYz/BjjmC3klLJlLwA4Kw8ytk0zDSmbbDLNs794VnWmkcCB7I9aAL/D48VNQtmITyPvea2C3jdUMfc3kAoy0PQ==} @@ -9374,6 +9388,8 @@ snapshots: date-format@4.0.14: {} + dayjs@1.11.19: {} + debug@2.6.9: dependencies: ms: 2.0.0 @@ -10638,6 +10654,10 @@ snapshots: optionalDependencies: '@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@5.8.0: {} diff --git a/src/app.routes.ts b/src/app.routes.ts index 7a89d43..5281e0c 100644 --- a/src/app.routes.ts +++ b/src/app.routes.ts @@ -1,5 +1,6 @@ import { CUSTOMERS_ROUTES } from '@/modules/customers/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_CATEGORIES_ROUTES } from '@/modules/productCategories/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: 'pos', + component: POSComponent, + }, { path: 'notfound', component: Notfound }, { path: 'auth', loadChildren: () => import('./app/pages/auth/auth.routes') }, { path: '**', redirectTo: '/notfound' }, diff --git a/src/app/core/constants/menuItems.const.ts b/src/app/core/constants/menuItems.const.ts index ad282ea..95694a8 100644 --- a/src/app/core/constants/menuItems.const.ts +++ b/src/app/core/constants/menuItems.const.ts @@ -10,11 +10,11 @@ export const MENU_ITEMS = [ ], }, { - label: 'موجودی‌ها', + label: 'انبارها', icon: 'pi pi-fw pi-box', items: [ { - label: 'لیست موجودی‌ها', + label: 'لیست انبارها', icon: 'pi pi-fw pi-list', routerLink: ['/inventories'], }, diff --git a/src/app/core/models/index.ts b/src/app/core/models/index.ts index b4b6500..ccd0e59 100644 --- a/src/app/core/models/index.ts +++ b/src/app/core/models/index.ts @@ -2,4 +2,5 @@ export * from './maybe.model'; export * from './namedRoutes.model'; export * from './navigation.model'; export * from './route-utils.model'; +export * from './tag'; export * from './user.model'; diff --git a/src/app/core/models/stockBalance.ts b/src/app/core/models/stockBalance.ts new file mode 100644 index 0000000..9c09773 --- /dev/null +++ b/src/app/core/models/stockBalance.ts @@ -0,0 +1 @@ +export interface StockBalance {} diff --git a/src/app/core/models/tag.ts b/src/app/core/models/tag.ts new file mode 100644 index 0000000..e4af9f2 --- /dev/null +++ b/src/app/core/models/tag.ts @@ -0,0 +1,9 @@ +export type TagSeverity = + | 'success' + | 'secondary' + | 'info' + | 'warn' + | 'danger' + | 'contrast' + | null + | undefined; diff --git a/src/app/modules/inventories/components/form.component.ts b/src/app/modules/inventories/components/form.component.ts index becc566..9183d7a 100644 --- a/src/app/modules/inventories/components/form.component.ts +++ b/src/app/modules/inventories/components/form.component.ts @@ -4,7 +4,7 @@ import { FormFooterActionsComponent } from '@/shared/components/formFooterAction import { Component, effect, EventEmitter, inject, Input, Output, signal } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { Dialog } from 'primeng/dialog'; -import { IInventoryRequest, IInventoryResponse } from '../models'; +import { IInventoryRequest, IInventorySummaryResponse } from '../models'; import { InventoriesService } from '../services/main.service'; @Component({ @@ -13,7 +13,7 @@ import { InventoriesService } from '../services/main.service'; imports: [ReactiveFormsModule, Dialog, InputComponent, FormFooterActionsComponent], }) export class InventoryFormComponent { - @Input() initialValues?: IInventoryResponse; + @Input() initialValues?: IInventorySummaryResponse; @Input() set visible(v: boolean) { @@ -25,7 +25,7 @@ export class InventoryFormComponent { private visibleSignal = signal(false); @Output() visibleChange = new EventEmitter(); - @Output() onSubmit = new EventEmitter(); + @Output() onSubmit = new EventEmitter(); private fb = inject(FormBuilder); constructor( diff --git a/src/app/modules/inventories/components/index.ts b/src/app/modules/inventories/components/index.ts new file mode 100644 index 0000000..915bb30 --- /dev/null +++ b/src/app/modules/inventories/components/index.ts @@ -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'; diff --git a/src/app/modules/inventories/components/movementList/movement-list.component.html b/src/app/modules/inventories/components/movementList/movement-list.component.html new file mode 100644 index 0000000..5e708a5 --- /dev/null +++ b/src/app/modules/inventories/components/movementList/movement-list.component.html @@ -0,0 +1,114 @@ + + +
+ {{ title }} + +
+
+ + + + + + شناسه رسید + نوع گردش + انواع کالاها + تعداد کالاها + مجموع قیمت + تاریخ + + + + + + + + {{ movement.receiptId }} + + {{ movement.count }} + {{ movement.info.quantity }} + + + + + + + + + + + +
کالاهای جابجا شده در این گردش
+
+ + + +
+ شناسه کالا + +
+ + +
+ عنوان کالا + +
+ + +
+ تعداد + +
+ + +
+ قیمت واحد + +
+ + +
+ قیمت نهایی + +
+ + + +
+ + + {{ movement.product.id }} + {{ movement.product.name }} + {{ movement.quantity }} + + + + + + + + + + +
+
+ + +
+ + + + هیچ گردش موجودی یافت نشد. + + +
+
+
diff --git a/src/app/modules/inventories/components/movementList/movement-list.component.ts b/src/app/modules/inventories/components/movementList/movement-list.component.ts new file mode 100644 index 0000000..230c96b --- /dev/null +++ b/src/app/modules/inventories/components/movementList/movement-list.component.ts @@ -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([]); + 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); + }, + }); + } +} diff --git a/src/app/modules/inventories/components/select/select.component.ts b/src/app/modules/inventories/components/select/select.component.ts index baba846..5c4f7af 100644 --- a/src/app/modules/inventories/components/select/select.component.ts +++ b/src/app/modules/inventories/components/select/select.component.ts @@ -3,7 +3,7 @@ import { UikitFieldComponent } from '@/uikit'; import { Component } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { Select } from 'primeng/select'; -import { IInventoryResponse } from '../../models'; +import { IInventorySummaryResponse } from '../../models'; import { InventoriesService } from '../../services/main.service'; @Component({ @@ -11,7 +11,7 @@ import { InventoriesService } from '../../services/main.service'; templateUrl: './select.component.html', imports: [ReactiveFormsModule, Select, UikitFieldComponent], }) -export class InventoriesSelectComponent extends AbstractSelectComponent { +export class InventoriesSelectComponent extends AbstractSelectComponent { constructor(private service: InventoriesService) { super(); this.getData(); diff --git a/src/app/modules/inventories/components/transfer/transfer-form.component.html b/src/app/modules/inventories/components/transfer/transfer-form.component.html new file mode 100644 index 0000000..e441942 --- /dev/null +++ b/src/app/modules/inventories/components/transfer/transfer-form.component.html @@ -0,0 +1,149 @@ + + انتقال موجودی بین انبارها + +
+
+
+ +
+ + + + + +
+ + انبار مبدا +
+ + + کالاهای موجود در انبار مبدا + +
+ @if (!form.controls.fromInventory.value) { +
لطفا انبار مبدا را انتخاب کنید
+ } @else { + + + + + نام کالا + موجودی + میانگین قیمت + + + + + + + + {{ item.product.name }} + {{ item.quantity }} + {{ item.avgCost }} + + + + } +
+
+
+
+ +
+
+ +
+
+ + + انبار مقصد +
+ + + + کالاهای انتخاب شده +
+ @if (!form.controls.fromInventory.value) { +
لطفا انبار مبدا را انتخاب کنید
+ } @else { + {{ form.controls.items.value.length }} + + + + نام کالا + مقدار برای انتقال + + + + + {{ item.controls.product.value?.name }} + + + + + + + } +
+
+
+
+
+ +
+ + +
+
+
+
diff --git a/src/app/modules/inventories/components/transfer/transfer-form.component.ts b/src/app/modules/inventories/components/transfer/transfer-form.component.ts new file mode 100644 index 0000000..52c1b77 --- /dev/null +++ b/src/app/modules/inventories/components/transfer/transfer-form.component.ts @@ -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, [Validators.required]], + toInventory: [null as Maybe, [Validators.required]], + code: ['', [Validators.required]], + description: [''], + items: this.fb.array( + [ + this.fb.group({ + product: [null as Maybe, [Validators.required]], + count: [1, [Validators.required, Validators.min(1)]], + }), + ], + Validators.required, + ), + }); + + inventoriesLoading = signal(false); + inventories = signal[]>([]); + get toInventories() { + const fromInventoryId = this.form.controls.fromInventory.value; + return this.inventories().filter((inv) => inv?.id !== fromInventoryId?.id); + } + + originInventoryStock = signal([]); + originInventoryStockLoading = signal(false); + + selectedProducts = signal[]>([]); + + 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) { + const stock = $e.data as Maybe; + 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) { + 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); + }, + }); + } +} diff --git a/src/app/modules/inventories/constants/apiRoutes/index.ts b/src/app/modules/inventories/constants/apiRoutes/index.ts index 7f00c77..39390eb 100644 --- a/src/app/modules/inventories/constants/apiRoutes/index.ts +++ b/src/app/modules/inventories/constants/apiRoutes/index.ts @@ -3,4 +3,9 @@ const baseUrl = '/api/v1/inventories'; export const INVENTORIES_API_ROUTES = { list: () => `${baseUrl}`, 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`, }; diff --git a/src/app/modules/inventories/constants/routes/index.ts b/src/app/modules/inventories/constants/routes/index.ts index 188fc36..a88289c 100644 --- a/src/app/modules/inventories/constants/routes/index.ts +++ b/src/app/modules/inventories/constants/routes/index.ts @@ -1,7 +1,12 @@ import { NamedRoutes } from '@/core'; import { Routes } from '@angular/router'; -export type TInventoriesRouteNames = 'inventories' | 'inventory'; +export type TInventoriesRouteNames = + | 'inventories' + | 'inventory' + | 'transfer' + | 'products' + | 'productCardex'; export const inventoriesNamedRoutes: NamedRoutes = { inventories: { @@ -12,6 +17,16 @@ export const inventoriesNamedRoutes: NamedRoutes = { pagePath: () => '/inventories', }, }, + + transfer: { + path: 'inventories/transfer', + loadComponent: () => + import('../../views/transfer.component').then((m) => m.InventoriesTransferComponent), + meta: { + title: 'انتقال بین انبارها', + pagePath: () => '/inventories/transfer', + }, + }, inventory: { path: 'inventories/:inventoryId', loadComponent: () => import('../../views/single.component').then((m) => m.InventoryComponent), @@ -20,6 +35,24 @@ export const inventoriesNamedRoutes: NamedRoutes = { 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); diff --git a/src/app/modules/inventories/models/index.ts b/src/app/modules/inventories/models/index.ts index 61a518a..d390b4d 100644 --- a/src/app/modules/inventories/models/index.ts +++ b/src/app/modules/inventories/models/index.ts @@ -1 +1,2 @@ export * from './io'; +export * from './types'; diff --git a/src/app/modules/inventories/models/io.d.ts b/src/app/modules/inventories/models/io.d.ts index 579d4e4..d213166 100644 --- a/src/app/modules/inventories/models/io.d.ts +++ b/src/app/modules/inventories/models/io.d.ts @@ -1,14 +1,66 @@ -export interface IInventoryRawResponse { +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; } -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 { 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 {} diff --git a/src/app/modules/inventories/models/types.ts b/src/app/modules/inventories/models/types.ts new file mode 100644 index 0000000..2093824 --- /dev/null +++ b/src/app/modules/inventories/models/types.ts @@ -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 {} diff --git a/src/app/modules/inventories/services/main.service.ts b/src/app/modules/inventories/services/main.service.ts index a335423..e9fa499 100644 --- a/src/app/modules/inventories/services/main.service.ts +++ b/src/app/modules/inventories/services/main.service.ts @@ -1,9 +1,24 @@ import { IPaginatedResponse } from '@/core/models/service.model'; +import { MovementType } from '@/shared/catalog'; import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; 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' }) export class InventoriesService { @@ -11,15 +26,50 @@ export class InventoriesService { private apiRoutes = INVENTORIES_API_ROUTES; - getAll(): Observable> { - return this.http.get>(this.apiRoutes.list()); + getAll(): Observable> { + return this.http.get>(this.apiRoutes.list()); } - getSingle(inventoryId: string): Observable { - return this.http.get(this.apiRoutes.single(inventoryId)); + getSingle(inventoryId: string): Observable { + return this.http.get(this.apiRoutes.single(inventoryId)); } - create(data: IInventoryRequest): Observable { - return this.http.post(this.apiRoutes.list(), data); + create(data: IInventoryRequest): Observable { + return this.http.post(this.apiRoutes.list(), data); + } + + getMovements( + inventoryId: string, + movementType?: MovementType, + ): Observable> { + return this.http.get>( + this.apiRoutes.inventoryMovements(inventoryId), + { + params: movementType ? { type: movementType } : {}, + }, + ); + } + + transfer(payload: IInventoryTransferRequest): Observable { + return this.http.post(this.apiRoutes.transfer(), payload); + } + + getStock( + inventoryId: string, + params?: IInventoryStockQuery, + ): Observable> { + return this.http.get>( + this.apiRoutes.stock(inventoryId), + { params: params ? { ...params } : {} }, + ); + } + + getProductCardex( + inventoryId: string, + productId: string, + ): Observable> { + return this.http.get>( + `${this.apiRoutes.productCardex(inventoryId, productId)}`, + ); } } diff --git a/src/app/modules/inventories/views/list.component.html b/src/app/modules/inventories/views/list.component.html index 8738bc3..903dd89 100644 --- a/src/app/modules/inventories/views/list.component.html +++ b/src/app/modules/inventories/views/list.component.html @@ -8,6 +8,7 @@ [items]="items()" [loading]="loading()" [showDetails]="true" + (onDetails)="toDetails($event)" (onAdd)="openAddForm()" /> diff --git a/src/app/modules/inventories/views/list.component.ts b/src/app/modules/inventories/views/list.component.ts index 249e85a..5d5f31d 100644 --- a/src/app/modules/inventories/views/list.component.ts +++ b/src/app/modules/inventories/views/list.component.ts @@ -2,9 +2,10 @@ import { IColumn, PageDataListComponent, } 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 { IInventoryResponse } from '../models'; +import { IInventorySummaryResponse } from '../models'; import { InventoriesService } from '../services/main.service'; @Component({ @@ -13,6 +14,7 @@ import { InventoriesService } from '../services/main.service'; imports: [PageDataListComponent, InventoryFormComponent], }) export class InventoriesComponent { + private router = inject(Router); constructor(private inventoryService: InventoriesService) { this.getData(); } @@ -21,11 +23,12 @@ export class InventoriesComponent { { field: 'id', header: 'شناسه' }, { field: 'name', header: 'نام' }, { field: 'location', header: 'موقعیت' }, + { field: 'isPointOfSale', header: 'نقطه فروش' }, { field: 'isActive', header: 'فعال' }, ] as IColumn[]; loading = signal(false); - items = signal([]); + items = signal([]); visibleForm = signal(false); refresh() { @@ -43,4 +46,8 @@ export class InventoriesComponent { openAddForm() { this.visibleForm.set(true); } + + toDetails(item: IInventorySummaryResponse) { + this.router.navigate(['/inventories', item.id]); + } } diff --git a/src/app/modules/inventories/views/product-cardex.component.html b/src/app/modules/inventories/views/product-cardex.component.html new file mode 100644 index 0000000..2780f5c --- /dev/null +++ b/src/app/modules/inventories/views/product-cardex.component.html @@ -0,0 +1,16 @@ +@if (pageLoading) { +
+ +
+} @else { + + +
+ کاردکس کالای {{ product()?.name }} در انبار {{ inventory()?.name }} + +
+
+ + +
+} diff --git a/src/app/modules/inventories/views/product-cardex.component.ts b/src/app/modules/inventories/views/product-cardex.component.ts new file mode 100644 index 0000000..1bb9e7f --- /dev/null +++ b/src/app/modules/inventories/views/product-cardex.component.ts @@ -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>(null); + getProductLoading = signal(false); + product = signal>(null); + + getCardexLoading = signal(false); + cardex = signal>(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); + }, + }); + } +} diff --git a/src/app/modules/inventories/views/products.component.html b/src/app/modules/inventories/views/products.component.html new file mode 100644 index 0000000..1c357d8 --- /dev/null +++ b/src/app/modules/inventories/views/products.component.html @@ -0,0 +1,51 @@ + + +
+ کالاهای موجود در انبار + +
+
+ + + + +
+ شناسه کالا + +
+ + +
+ عنوان کالا + +
+ + +
+ تعداد + +
+ + +
+ قیمت متوسط + +
+ + + +
+ + + {{ stock.product.sku }} + {{ stock.product.name }} + {{ stock.quantity }} + + + + + + + +
+
diff --git a/src/app/modules/inventories/views/products.component.ts b/src/app/modules/inventories/views/products.component.ts new file mode 100644 index 0000000..bf22f7a --- /dev/null +++ b/src/app/modules/inventories/views/products.component.ts @@ -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([]); + + 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); + }, + }); + } +} diff --git a/src/app/modules/inventories/views/single.component.html b/src/app/modules/inventories/views/single.component.html index 7144e26..8c262b9 100644 --- a/src/app/modules/inventories/views/single.component.html +++ b/src/app/modules/inventories/views/single.component.html @@ -1 +1,96 @@ -
+
+
+
+
+ +

{{ data()?.name }}

+
+
+
+ + + + + + +
+
+
+
+ +
+
+ +
+
+ +
+
+ @for (item of topBarCardDetails; track $index) { + + } +
+
+ + + +
+ کالاهای موجود در انبار + +
+
+ + + + +
+ شناسه کالا + +
+ + +
+ عنوان کالا + +
+ + +
+ تعداد + +
+ + +
+ قیمت متوسط + +
+ + + +
+ + + {{ stock.product.sku }} + {{ stock.product.name }} + {{ stock.quantity }} + + + + + + + +
+
+ + + + +
diff --git a/src/app/modules/inventories/views/single.component.ts b/src/app/modules/inventories/views/single.component.ts index ce20edc..5921fbf 100644 --- a/src/app/modules/inventories/views/single.component.ts +++ b/src/app/modules/inventories/views/single.component.ts @@ -1,12 +1,15 @@ import { Maybe } from '@/core'; -import { ProductChargeFormComponent } from '@/modules/productCharges/components/form.component'; 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 { ActivatedRoute, RouterLink } from '@angular/router'; import { Button, ButtonDirective } from 'primeng/button'; import { Card } from 'primeng/card'; 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'; @Component({ @@ -17,25 +20,28 @@ import { InventoriesService } from '../services/main.service'; RouterLink, GalleriaModule, KeyValueComponent, - Card, Button, - ProductChargeFormComponent, + InventoryMovementListComponent, + DetailsInfoCardComponent, + Card, + TableModule, + PriceMaskDirective, ], }) export class InventoryComponent { private route = inject(ActivatedRoute); constructor(private service: InventoriesService) { this.getData(); + this.getStock(); } - productId = this.route.snapshot.params['productId']; - isOpenChargeForm = signal(false); + inventoryId = this.route.snapshot.params['inventoryId']; loading = signal(false); - data = signal>(null); + data = signal>(null); getData() { this.loading.set(true); - this.service.getSingle(this.productId).subscribe({ + this.service.getSingle(this.inventoryId).subscribe({ next: (res) => { this.data.set(res); this.loading.set(false); @@ -46,35 +52,41 @@ export class InventoryComponent { }); } + getStockLoading = signal(false); + stock = signal([]); + + 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() { return [ - // { - // icon: 'pi pi-box', - // label: 'موجودی', - // value: this.data()? || 0, - // }, - // { - // icon: 'pi pi-dollar', - // label: 'قیمت پایه', - // value: this.data()?.basePrice?.toString() || 'تعیین نشده', - // }, - // { - // icon: 'pi pi-calendar', - // label: 'تاریخ ایجاد', - // value: this.data()?.createdAt - // ? new Date(this.data()!.createdAt!).toLocaleDateString() - // : '-', - // }, - // { - // icon: 'pi pi-clock', - // label: 'تعداد فروش', - // value: 1200, - // }, + { + icon: 'pi pi-box', + label: 'تعداد کالاهای موجود', + value: this.data()?.availableProductCount, + }, + { + icon: 'pi pi-dollar', + label: 'تنوع کالاها', + value: this.data()?.availableProductTypes, + }, + { + icon: 'pi pi-dollar', + label: 'ارزش کلی کالاهای موجود', + value: this.data()?.availableProductsCost, + }, ]; } openDeleteConfirm() {} - openChargeForm() { - this.isOpenChargeForm.set(true); - } } diff --git a/src/app/modules/inventories/views/transfer.component.html b/src/app/modules/inventories/views/transfer.component.html new file mode 100644 index 0000000..16e3ab5 --- /dev/null +++ b/src/app/modules/inventories/views/transfer.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/src/app/modules/inventories/views/transfer.component.ts b/src/app/modules/inventories/views/transfer.component.ts new file mode 100644 index 0000000..30517e0 --- /dev/null +++ b/src/app/modules/inventories/views/transfer.component.ts @@ -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() {} +} diff --git a/src/app/modules/pos/constants/apiRoutes/index.ts b/src/app/modules/pos/constants/apiRoutes/index.ts new file mode 100644 index 0000000..69bd806 --- /dev/null +++ b/src/app/modules/pos/constants/apiRoutes/index.ts @@ -0,0 +1,6 @@ +// const baseUrl = '/api/v1/product-categories'; + +// export const PRODUCT_CATEGORIES_API_ROUTES = { +// list: () => `${baseUrl}`, +// single: (categoryId: string) => `${baseUrl}/${categoryId}`, +// }; diff --git a/src/app/modules/pos/constants/index.ts b/src/app/modules/pos/constants/index.ts new file mode 100644 index 0000000..fdb1998 --- /dev/null +++ b/src/app/modules/pos/constants/index.ts @@ -0,0 +1,2 @@ +// export * from './apiRoutes'; +export * from './routes'; diff --git a/src/app/modules/pos/constants/routes/index.ts b/src/app/modules/pos/constants/routes/index.ts new file mode 100644 index 0000000..fa20cbc --- /dev/null +++ b/src/app/modules/pos/constants/routes/index.ts @@ -0,0 +1,17 @@ +import { NamedRoutes } from '@/core'; +import { Routes } from '@angular/router'; + +export type TPOSRouteNames = 'POS'; + +export const posNamedRoutes: NamedRoutes = { + POS: { + path: 'pos', + loadComponent: () => import('../../views/pos.component').then((m) => m.POSComponent), + meta: { + title: 'نقطه فروش', + pagePath: () => '/pos', + }, + }, +}; + +export const POS_ROUTES: Routes = Object.values(posNamedRoutes); diff --git a/src/app/modules/pos/views/pos.component.html b/src/app/modules/pos/views/pos.component.html new file mode 100644 index 0000000..7144e26 --- /dev/null +++ b/src/app/modules/pos/views/pos.component.html @@ -0,0 +1 @@ +
diff --git a/src/app/modules/pos/views/pos.component.ts b/src/app/modules/pos/views/pos.component.ts new file mode 100644 index 0000000..07e9eec --- /dev/null +++ b/src/app/modules/pos/views/pos.component.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-pos', + templateUrl: './pos.component.html', +}) +export class POSComponent { + constructor() {} +} diff --git a/src/app/modules/products/models/io.d.ts b/src/app/modules/products/models/io.d.ts index 02f3161..8fdfb6c 100644 --- a/src/app/modules/products/models/io.d.ts +++ b/src/app/modules/products/models/io.d.ts @@ -16,6 +16,8 @@ export interface IProductRawResponse { createdAt: Date; updatedAt: Date; deletedAt?: Maybe; + stock: number; + avgCost?: number; } export interface IProductResponse extends IProductRawResponse {} diff --git a/src/app/modules/products/views/single.component.html b/src/app/modules/products/views/single.component.html index b8bb685..04521dc 100644 --- a/src/app/modules/products/views/single.component.html +++ b/src/app/modules/products/views/single.component.html @@ -30,17 +30,7 @@
@for (item of topBarCardDetails; track $index) { - -
-
- -
-
- {{ item.label }} - {{ item.value }} -
-
-
+ }
diff --git a/src/app/modules/products/views/single.component.ts b/src/app/modules/products/views/single.component.ts index 5886c9c..b882168 100644 --- a/src/app/modules/products/views/single.component.ts +++ b/src/app/modules/products/views/single.component.ts @@ -1,6 +1,7 @@ import { Maybe } from '@/core'; import { PurchaseFormComponent } from '@/modules/purchases/components/form.component'; import { KeyValueComponent } from '@/shared/components'; +import { DetailsInfoCardComponent } from '@/shared/components/detailsInfoCard/details-info-card.component'; import { Component, inject, signal } from '@angular/core'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { Button, ButtonDirective } from 'primeng/button'; @@ -20,6 +21,7 @@ import { ProductsService } from '../services/main.service'; Card, Button, PurchaseFormComponent, + DetailsInfoCardComponent, ], }) export class ProductComponent { @@ -79,7 +81,7 @@ export class ProductComponent { { icon: 'pi pi-box', label: 'موجودی', - value: this.product()?.count || 0, + value: this.product()?.stock || 0, }, { icon: 'pi pi-dollar', @@ -87,16 +89,14 @@ export class ProductComponent { value: this.product()?.basePrice?.toString() || 'تعیین نشده', }, { - icon: 'pi pi-calendar', - label: 'تاریخ ایجاد', - value: this.product()?.createdAt - ? new Date(this.product()!.createdAt!).toLocaleDateString() - : '-', + icon: 'pi pi-dollar', + label: 'میانگین قیمت', + value: this.product()?.avgCost?.toString() || 'تعیین نشده', }, { icon: 'pi pi-clock', label: 'تعداد فروش', - value: 1200, + value: '-', }, ]; } diff --git a/src/app/modules/purchases/components/form.component.ts b/src/app/modules/purchases/components/form.component.ts index 57771e2..0255f24 100644 --- a/src/app/modules/purchases/components/form.component.ts +++ b/src/app/modules/purchases/components/form.component.ts @@ -1,5 +1,5 @@ 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 { SuppliersSelectComponent } from '@/modules/suppliers/components/select/select.component'; import { ISupplierResponse } from '@/modules/suppliers/models'; @@ -30,7 +30,7 @@ import { ProductChargeRowFormComponent } from './product-charge-row-form.compone }) export class PurchaseFormComponent { @Input() product?: IProductResponse; - @Input() inventory?: IInventoryResponse; + @Input() inventory?: IInventorySummaryResponse; @Input() supplier?: ISupplierResponse; private fb = inject(FormBuilder); diff --git a/src/app/modules/purchases/components/receiptTemplate/purchase-receipt-template.component.ts b/src/app/modules/purchases/components/receiptTemplate/purchase-receipt-template.component.ts index aec1dd6..a581d6f 100644 --- a/src/app/modules/purchases/components/receiptTemplate/purchase-receipt-template.component.ts +++ b/src/app/modules/purchases/components/receiptTemplate/purchase-receipt-template.component.ts @@ -1,7 +1,7 @@ import { Maybe } from '@/core'; import { ToastService } from '@/core/services/toast.service'; 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 { SuppliersSelectComponent } from '@/modules/suppliers/components/select/select.component'; import { ISupplierResponse } from '@/modules/suppliers/models'; @@ -44,7 +44,7 @@ export class PurchaseReceiptTemplateComponent { private fb = inject(FormBuilder); @Input() product?: IProductResponse; - @Input() inventory?: IInventoryResponse; + @Input() inventory?: IInventorySummaryResponse; @Input() supplier?: ISupplierResponse; columns = [ @@ -105,7 +105,7 @@ export class PurchaseReceiptTemplateComponent { total: [300000, [Validators.required, Validators.min(0)]], }), ]), - inventory: [{ id: 2 } as Maybe, [Validators.required]], + inventory: [{ id: 2 } as Maybe, [Validators.required]], supplier: [{ id: 1 } as Maybe, [Validators.required]], }); @@ -179,7 +179,8 @@ export class PurchaseReceiptTemplateComponent { next: (res) => { this.toastService.success({ text: 'ثبت سند خرید با موفقیت انجام شد' }); this.form.enable(); - this.form.reset(); + // this.form.reset(); + this.form.controls.code.setValue(this.form.controls.code.value + '1'); this.formIsSubmitted.set(false); }, error: () => { diff --git a/src/app/shared/catalog/index.ts b/src/app/shared/catalog/index.ts index 14cd84a..84a260b 100644 --- a/src/app/shared/catalog/index.ts +++ b/src/app/shared/catalog/index.ts @@ -1 +1,2 @@ export * from './gender'; +export * from './movementTypes'; diff --git a/src/app/shared/catalog/movementReferenceTypes/index.ts b/src/app/shared/catalog/movementReferenceTypes/index.ts new file mode 100644 index 0000000..7d1f2fb --- /dev/null +++ b/src/app/shared/catalog/movementReferenceTypes/index.ts @@ -0,0 +1,3 @@ +export * from './tag.component'; +export * from './types'; +export * from './utils'; diff --git a/src/app/shared/catalog/movementReferenceTypes/tag.component.html b/src/app/shared/catalog/movementReferenceTypes/tag.component.html new file mode 100644 index 0000000..d481d6b --- /dev/null +++ b/src/app/shared/catalog/movementReferenceTypes/tag.component.html @@ -0,0 +1,3 @@ + + {{ text }} + diff --git a/src/app/shared/catalog/movementReferenceTypes/tag.component.ts b/src/app/shared/catalog/movementReferenceTypes/tag.component.ts new file mode 100644 index 0000000..f5df8d0 --- /dev/null +++ b/src/app/shared/catalog/movementReferenceTypes/tag.component.ts @@ -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); + } +} diff --git a/src/app/shared/catalog/movementReferenceTypes/types.ts b/src/app/shared/catalog/movementReferenceTypes/types.ts new file mode 100644 index 0000000..4c3d142 --- /dev/null +++ b/src/app/shared/catalog/movementReferenceTypes/types.ts @@ -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]; diff --git a/src/app/shared/catalog/movementReferenceTypes/utils.ts b/src/app/shared/catalog/movementReferenceTypes/utils.ts new file mode 100644 index 0000000..a5178b6 --- /dev/null +++ b/src/app/shared/catalog/movementReferenceTypes/utils.ts @@ -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'; + } +}; diff --git a/src/app/shared/catalog/movementTypes/index.ts b/src/app/shared/catalog/movementTypes/index.ts new file mode 100644 index 0000000..7d1f2fb --- /dev/null +++ b/src/app/shared/catalog/movementTypes/index.ts @@ -0,0 +1,3 @@ +export * from './tag.component'; +export * from './types'; +export * from './utils'; diff --git a/src/app/shared/catalog/movementTypes/tag.component.html b/src/app/shared/catalog/movementTypes/tag.component.html new file mode 100644 index 0000000..d481d6b --- /dev/null +++ b/src/app/shared/catalog/movementTypes/tag.component.html @@ -0,0 +1,3 @@ + + {{ text }} + diff --git a/src/app/shared/catalog/movementTypes/tag.component.ts b/src/app/shared/catalog/movementTypes/tag.component.ts new file mode 100644 index 0000000..6535fdd --- /dev/null +++ b/src/app/shared/catalog/movementTypes/tag.component.ts @@ -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); + } +} diff --git a/src/app/shared/catalog/movementTypes/types.ts b/src/app/shared/catalog/movementTypes/types.ts new file mode 100644 index 0000000..5dc9255 --- /dev/null +++ b/src/app/shared/catalog/movementTypes/types.ts @@ -0,0 +1,7 @@ +export const MovementType = { + IN: 'IN', + OUT: 'OUT', + ADJUST: 'ADJUST', +} as const; + +export type MovementType = (typeof MovementType)[keyof typeof MovementType]; diff --git a/src/app/shared/catalog/movementTypes/utils.ts b/src/app/shared/catalog/movementTypes/utils.ts new file mode 100644 index 0000000..bbb8a61 --- /dev/null +++ b/src/app/shared/catalog/movementTypes/utils.ts @@ -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'; + } +}; diff --git a/src/app/shared/components/cardex/cardex.component.html b/src/app/shared/components/cardex/cardex.component.html new file mode 100644 index 0000000..851c4d3 --- /dev/null +++ b/src/app/shared/components/cardex/cardex.component.html @@ -0,0 +1,65 @@ + + + + ردیف + تاریخ + شرح + شماره + طرف حساب + انبار + وارده + خروجی + مانده + + + تعداد + فی + تعداد + فی + تعداد + فی + مجموع قیمت + + + + + + {{ i + 1 }} + + + {{ item.referenceId || "-" }} + {{ item.supplier?.name || "-" }} + {{ item.inventory.name || "-" }} + + {{ item.type === "IN" ? item.quantity : 0 }} + + + @if (item.type === "IN") { + + } @else { + 0 + } + + + {{ item.type === "OUT" ? item.quantity : 0 }} + + + @if (item.type === "OUT") { + + } @else { + 0 + } + + {{ item.remainedInStock }} + + + + + diff --git a/src/app/shared/components/cardex/cardex.component.ts b/src/app/shared/components/cardex/cardex.component.ts new file mode 100644 index 0000000..9605c36 --- /dev/null +++ b/src/app/shared/components/cardex/cardex.component.ts @@ -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() {} +} diff --git a/src/app/shared/components/cardex/type.ts b/src/app/shared/components/cardex/type.ts new file mode 100644 index 0000000..2909844 --- /dev/null +++ b/src/app/shared/components/cardex/type.ts @@ -0,0 +1,3 @@ +import { IInventoryStockMovementResponse } from '@/modules/inventories/models'; + +export interface ICardexItem extends IInventoryStockMovementResponse {} diff --git a/src/app/shared/components/detailsInfoCard/details-info-card.component.html b/src/app/shared/components/detailsInfoCard/details-info-card.component.html new file mode 100644 index 0000000..dbe46e8 --- /dev/null +++ b/src/app/shared/components/detailsInfoCard/details-info-card.component.html @@ -0,0 +1,11 @@ + +
+
+ +
+
+ {{ label }} + {{ value }} +
+
+
diff --git a/src/app/shared/components/detailsInfoCard/details-info-card.component.ts b/src/app/shared/components/detailsInfoCard/details-info-card.component.ts new file mode 100644 index 0000000..61109cc --- /dev/null +++ b/src/app/shared/components/detailsInfoCard/details-info-card.component.ts @@ -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() {} +} diff --git a/src/app/shared/components/pageDataList/page-data-list.component.ts b/src/app/shared/components/pageDataList/page-data-list.component.ts index c635392..2fdb930 100644 --- a/src/app/shared/components/pageDataList/page-data-list.component.ts +++ b/src/app/shared/components/pageDataList/page-data-list.component.ts @@ -1,4 +1,5 @@ import { PaginatorComponent, UikitCopyComponent, UikitEmptyStateComponent } from '@/uikit'; +import { formatJalali } from '@/utils'; import { CommonModule } from '@angular/common'; import { Component, @@ -124,8 +125,7 @@ export class PageDataListComponent { switch (column.type) { case 'date': if (!data) return '-'; - const date = new Date(data); - return isNaN(date.getTime()) ? '-' : date.toLocaleDateString(); + return formatJalali(data); case 'boolean': return data ? 'بله' : 'خیر'; case 'price': diff --git a/src/app/shared/directives/index.ts b/src/app/shared/directives/index.ts index 35f0bd0..96a36f2 100644 --- a/src/app/shared/directives/index.ts +++ b/src/app/shared/directives/index.ts @@ -1,2 +1,4 @@ export * from './price-alphabet.directive'; export * from './price-mask.directive'; + +export * from './jalali-date.directive'; diff --git a/src/app/shared/directives/jalali-date.directive.ts b/src/app/shared/directives/jalali-date.directive.ts new file mode 100644 index 0000000..a0214ab --- /dev/null +++ b/src/app/shared/directives/jalali-date.directive.ts @@ -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); + } + } +} diff --git a/src/app/uikit/index.ts b/src/app/uikit/index.ts index f7c3e0e..207b5bb 100644 --- a/src/app/uikit/index.ts +++ b/src/app/uikit/index.ts @@ -2,6 +2,7 @@ export * from './copy/copy.component'; export * from './datepicker/datepicker.component'; export * from './duration-picker/duration-picker.component'; export * from './paginator.component'; +export * from './uikit-counter.component'; export * from './uikit-emptystate.component'; export * from './uikit-field.component'; export * from './uikit-label.component'; diff --git a/src/app/uikit/uikit-counter.component.html b/src/app/uikit/uikit-counter.component.html new file mode 100644 index 0000000..a651bf2 --- /dev/null +++ b/src/app/uikit/uikit-counter.component.html @@ -0,0 +1,35 @@ +
+ + + + + +
diff --git a/src/app/uikit/uikit-counter.component.scss b/src/app/uikit/uikit-counter.component.scss new file mode 100644 index 0000000..84f0423 --- /dev/null +++ b/src/app/uikit/uikit-counter.component.scss @@ -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; +} diff --git a/src/app/uikit/uikit-counter.component.ts b/src/app/uikit/uikit-counter.component.ts new file mode 100644 index 0000000..e748a10 --- /dev/null +++ b/src/app/uikit/uikit-counter.component.ts @@ -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(); + + 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); + } + } +} diff --git a/src/app/utils/index.ts b/src/app/utils/index.ts index 5df301a..af2e5ff 100644 --- a/src/app/utils/index.ts +++ b/src/app/utils/index.ts @@ -1,3 +1,4 @@ export * from './currency'; +export * from './jalali-date.utils'; export * from './name'; export * from './time'; diff --git a/src/app/utils/jalali-date.utils.ts b/src/app/utils/jalali-date.utils.ts new file mode 100644 index 0000000..557b4f6 --- /dev/null +++ b/src/app/utils/jalali-date.utils.ts @@ -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'); +}