feat(purchases): implement purchase receipt functionality with form and receipt template

- Added `ProductPurchaseComponent` to handle product purchases.
- Created `PurchaseReceiptTemplateComponent` for displaying purchase receipt details.
- Developed `PurchaseFormComponent` for managing purchase form inputs and submission.
- Introduced `ProductChargeRowFormComponent` and `ProductChargeRowFormFieldsComponent` for handling product charge rows in the form.
- Implemented `PurchaseReceiptProductRowComponent` for displaying individual product rows in the receipt.
- Established API routes for purchase operations in `apiRoutes`.
- Integrated suppliers and inventories selection components.
- Added utility functions for converting prices to Persian alphabet.
- Created a utility for generating full names from name parts.
- Enhanced form validation and submission handling in the purchase form.
- Updated styles and templates for improved UI/UX.
This commit is contained in:
2025-12-09 20:17:00 +03:30
parent abf53bac03
commit 50bc9a4632
77 changed files with 1807 additions and 18834 deletions
-18772
View File
File diff suppressed because it is too large Load Diff
@@ -8,7 +8,7 @@
<app-input label="شهر" [control]="form.controls.city" name="city" /> <app-input label="شهر" [control]="form.controls.city" name="city" />
<app-input label="استان" [control]="form.controls.state" name="state" /> <app-input label="استان" [control]="form.controls.state" name="state" />
<app-input label="کشور" [control]="form.controls.country" name="country" /> <app-input label="کشور" [control]="form.controls.country" name="country" />
<app-input label="فعال" [control]="form.controls.isActive" name="isActive" type="switch" /> <app-input label="وضعیت" [control]="form.controls.isActive" name="isActive" type="switch" />
<app-form-footer-actions [submitLabel]="'ذخیره'" [loading]="form.disabled" (onCancel)="close()" /> <app-form-footer-actions [submitLabel]="'ذخیره'" [loading]="form.disabled" (onCancel)="close()" />
</form> </form>
</p-dialog> </p-dialog>
@@ -0,0 +1,15 @@
<div class="">
<uikit-field label="مشتری">
<p-select
[options]="brands()"
optionLabel="name"
optionValue="id"
placeholder="انتخاب مشتری"
[formControl]="control"
[showClear]="true"
[filter]="true"
appendTo="body"
>
</p-select>
</uikit-field>
</div>
@@ -0,0 +1,47 @@
import { Maybe } from '@/core';
import { UikitFieldComponent } from '@/uikit';
import { Component, Input, signal } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { Select } from 'primeng/select';
import { ICustomerResponse } from '../../models';
import { CustomersService } from '../../services/main.service';
@Component({
selector: 'customers-select-field',
templateUrl: './select.component.html',
imports: [ReactiveFormsModule, Select, UikitFieldComponent],
})
export class CustomersSelectComponent {
@Input() control!: FormControl<Maybe<number>>;
@Input() canInsert: boolean = false;
constructor(private service: CustomersService) {
this.getData();
}
loading = signal(false);
brands = signal<Maybe<ICustomerResponse[]>>(null);
isOpenFormDialog = signal(false);
getData() {
this.loading.set(true);
this.service.getAll().subscribe({
next: (res) => {
this.brands.set(res.data);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
},
});
}
refresh() {
this.getData();
}
onOpenForm() {
// this.isOpenFormDialog.set(true);
}
}
@@ -28,8 +28,9 @@ export class CustomersComponent {
{ field: 'state', header: 'استان' }, { field: 'state', header: 'استان' },
{ field: 'country', header: 'کشور' }, { field: 'country', header: 'کشور' },
{ field: 'isActive', header: 'فعال' }, { field: 'isActive', header: 'فعال' },
{ field: 'createdAt', header: 'تاریخ ایجاد' }, { field: 'createdAt', header: 'تاریخ ایجاد', type: 'date' },
{ field: 'updatedAt', header: 'تاریخ به‌روزرسانی' }, { field: 'updatedAt', header: 'تاریخ به‌روزرسانی', type: 'date' },
,
] as IColumn[]; ] as IColumn[];
loading = signal(false); loading = signal(false);
@@ -2,7 +2,7 @@
<form [formGroup]="form" (submit)="submit()" class="flex flex-col gap-4"> <form [formGroup]="form" (submit)="submit()" class="flex flex-col gap-4">
<app-input label="نام" [control]="form.controls.name" name="name" /> <app-input label="نام" [control]="form.controls.name" name="name" />
<app-input label="موقعیت" [control]="form.controls.location" name="location" /> <app-input label="موقعیت" [control]="form.controls.location" name="location" />
<app-input label="فعال" [control]="form.controls.isActive" name="isActive" type="switch" /> <app-input label="وضعیت" [control]="form.controls.isActive" name="isActive" type="switch" />
<app-form-footer-actions [submitLabel]="'ذخیره'" [loading]="form.disabled" (onCancel)="close()" /> <app-form-footer-actions [submitLabel]="'ذخیره'" [loading]="form.disabled" (onCancel)="close()" />
</form> </form>
</p-dialog> </p-dialog>
@@ -0,0 +1,15 @@
<div class="">
<uikit-field label="انبار">
<p-select
[options]="brands()"
optionLabel="name"
optionValue="id"
placeholder="انتخاب انبار"
[formControl]="control"
[showClear]="true"
[filter]="true"
appendTo="body"
>
</p-select>
</uikit-field>
</div>
@@ -0,0 +1,47 @@
import { Maybe } from '@/core';
import { UikitFieldComponent } from '@/uikit';
import { Component, Input, signal } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { Select } from 'primeng/select';
import { IInventoryResponse } from '../../models';
import { InventoriesService } from '../../services/main.service';
@Component({
selector: 'inventories-select-field',
templateUrl: './select.component.html',
imports: [ReactiveFormsModule, Select, UikitFieldComponent],
})
export class InventoriesSelectComponent {
@Input() control!: FormControl<Maybe<number>>;
@Input() canInsert: boolean = false;
constructor(private service: InventoriesService) {
this.getData();
}
loading = signal(false);
brands = signal<Maybe<IInventoryResponse[]>>(null);
isOpenFormDialog = signal(false);
getData() {
this.loading.set(true);
this.service.getAll().subscribe({
next: (res) => {
this.brands.set(res.data);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
},
});
}
refresh() {
this.getData();
}
onOpenForm() {
// this.isOpenFormDialog.set(true);
}
}
@@ -1,9 +1,80 @@
import { Component } from '@angular/core'; import { Maybe } from '@/core';
import { ProductChargeFormComponent } from '@/modules/productCharges/components/form.component';
import { KeyValueComponent } from '@/shared/components';
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 { InventoriesService } from '../services/main.service';
@Component({ @Component({
selector: 'app-inventory', selector: 'app-product',
templateUrl: './single.component.html', templateUrl: './single.component.html',
imports: [
ButtonDirective,
RouterLink,
GalleriaModule,
KeyValueComponent,
Card,
Button,
ProductChargeFormComponent,
],
}) })
export class InventoryComponent { export class InventoryComponent {
constructor() {} private route = inject(ActivatedRoute);
constructor(private service: InventoriesService) {
this.getData();
}
productId = this.route.snapshot.params['productId'];
isOpenChargeForm = signal(false);
loading = signal(false);
data = signal<Maybe<IInventoryResponse>>(null);
getData() {
this.loading.set(true);
this.service.getSingle(this.productId).subscribe({
next: (res) => {
this.data.set(res);
this.loading.set(false);
},
error: () => {
this.loading.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,
// },
];
}
openDeleteConfirm() {}
openChargeForm() {
this.isOpenChargeForm.set(true);
}
} }
@@ -2,11 +2,11 @@
[pageTitle]="'مدیریت برندهای محصول'" [pageTitle]="'مدیریت برندهای محصول'"
[addNewCtaLabel]="'افزودن برند جدید'" [addNewCtaLabel]="'افزودن برند جدید'"
[columns]="columns" [columns]="columns"
[showAdd]="true"
emptyPlaceholderTitle="برندی یافت نشد"
emptyPlaceholderDescription="برای افزودن برند جدید، روی دکمهٔ بالا کلیک کنید."
[items]="items()" [items]="items()"
[loading]="loading()" [loading]="loading()"
emptyPlaceholderTitle="برندی یافت نشد"
emptyPlaceholderDescription="برای افزودن برند جدید، روی دکمهٔ بالا کلیک کنید."
[showAdd]="true"
[showDetails]="true" [showDetails]="true"
(onAdd)="openAddForm()" (onAdd)="openAddForm()"
/> />
@@ -22,8 +22,8 @@ export class ProductBrandsComponent {
{ field: 'name', header: 'نام' }, { field: 'name', header: 'نام' },
{ field: 'description', header: 'توضیحات' }, { field: 'description', header: 'توضیحات' },
{ field: 'imageUrl', header: 'تصویر' }, { field: 'imageUrl', header: 'تصویر' },
{ field: 'createdAt', header: 'تاریخ ایجاد' }, { field: 'createdAt', header: 'تاریخ ایجاد', type: 'date' },
{ field: 'updatedAt', header: 'تاریخ به‌روزرسانی' }, { field: 'updatedAt', header: 'تاریخ به‌روزرسانی', type: 'date' },
] as IColumn[]; ] as IColumn[];
loading = signal(false); loading = signal(false);
@@ -37,6 +37,8 @@ export class ProductBrandsComponent {
getData() { getData() {
this.loading.set(true); this.loading.set(true);
this.productBrandService.getAll().subscribe((res) => { this.productBrandService.getAll().subscribe((res) => {
console.log(res);
this.loading.set(false); this.loading.set(false);
this.items.set(res.data); this.items.set(res.data);
}); });
@@ -22,7 +22,7 @@ export class ProductCategoriesComponent {
{ field: 'name', header: 'نام' }, { field: 'name', header: 'نام' },
{ field: 'description', header: 'توضیحات' }, { field: 'description', header: 'توضیحات' },
{ field: 'imageUrl', header: 'تصویر' }, { field: 'imageUrl', header: 'تصویر' },
{ field: 'createdAt', header: 'تاریخ ایجاد' }, { field: 'createdAt', header: 'تاریخ ایجاد', type: 'date' },
] as IColumn[]; ] as IColumn[];
loading = signal(false); loading = signal(false);
@@ -0,0 +1,26 @@
<p-dialog [(visible)]="visible" [modal]="true" [style]="{ width: '50vw' }" header="افزودن هزینه محصول">
<form [formGroup]="form" (ngSubmit)="submit()">
<div class="grid grid-cols-2 gap-4">
<products-select-field [control]="form.controls.productId" />
<inventories-select-field [control]="form.controls.inventoryId" />
<suppliers-select-field [control]="form.controls.supplierId" />
<app-input label="تعداد" [control]="form.controls.count" />
<app-input label="قیمت اولیه" [control]="form.controls.fee" />
<app-input label="مبلغ کل" [control]="form.controls.totalAmount" />
<app-input label="تاریخ خرید" [control]="form.controls.buyAt" />
<div class="col-span-2">
<textarea pTextarea label="توضیحات" [formControl]="form.controls.description"></textarea>
</div>
<app-input label="تسویه شده" type="switch" [control]="form.controls.isSettled" />
</div>
<app-form-footer-actions (onSubmit)="submit()" (onCancel)="cancel()" />
</form>
</p-dialog>
@@ -0,0 +1,77 @@
import { InventoriesSelectComponent } from '@/modules/inventories/components/select/select.component';
import { ProductsSelectComponent } from '@/modules/products/components/select/select.component';
import { SuppliersSelectComponent } from '@/modules/suppliers/components/select/select.component';
import { InputComponent } from '@/shared/components';
import { FormFooterActionsComponent } from '@/shared/components/formFooterActions/form-footer-actions.component';
import { Component, effect, inject, model, output } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { DialogModule } from 'primeng/dialog';
import { Textarea } from 'primeng/textarea';
import { IProductChargeRequest } from '../models';
import { ProductChargesService } from '../services/main.service';
@Component({
selector: 'product-charge-form',
standalone: true,
imports: [
DialogModule,
ReactiveFormsModule,
InputComponent,
FormFooterActionsComponent,
Textarea,
ProductsSelectComponent,
InventoriesSelectComponent,
SuppliersSelectComponent,
],
templateUrl: './form.component.html',
})
export class ProductChargeFormComponent {
private fb = inject(FormBuilder);
private productChargeService = inject(ProductChargesService);
visible = model<boolean>(false);
onSubmit = output();
form = this.fb.group({
count: [0, [Validators.required, Validators.min(1)]],
fee: [0, [Validators.required, Validators.min(0)]],
totalAmount: [0, [Validators.required, Validators.min(0)]],
isSettled: [false],
buyAt: ['', Validators.required],
description: [''],
productId: [0, [Validators.required, Validators.min(1)]],
inventoryId: [0, [Validators.required, Validators.min(1)]],
supplierId: [0, [Validators.required, Validators.min(1)]],
});
constructor() {
effect(() => {
if (!this.visible()) {
this.form.reset();
}
});
}
submit() {
this.form.markAllAsTouched();
if (this.form.valid) {
this.form.disable();
const data = this.form.value as IProductChargeRequest;
this.productChargeService.create(data).subscribe({
next: () => {
this.form.enable();
this.visible.set(false);
this.onSubmit.emit();
},
error: () => {
this.form.enable();
},
});
}
}
cancel() {
this.visible.set(false);
}
}
@@ -0,0 +1,7 @@
const BASE_URL = '/api/v1/product-charges';
export const PRODUCT_CHARGES_API_ROUTES = {
getAll: BASE_URL,
getSingle: (id: number) => `${BASE_URL}/${id}`,
create: BASE_URL,
};
@@ -0,0 +1,2 @@
export * from './apiRoutes';
export * from './routes';
@@ -0,0 +1,16 @@
import { Routes } from '@angular/router';
export type TProductChargesRouteNames = 'product-charges' | 'product-charge';
export const PRODUCT_CHARGES_ROUTES: Routes = [
{
path: '',
loadComponent: () =>
import('../../views/list.component').then((m) => m.ProductChargesComponent),
},
{
path: ':productChargeId',
loadComponent: () =>
import('../../views/single.component').then((m) => m.ProductChargeComponent),
},
];
@@ -0,0 +1 @@
export * from './io';
+31
View File
@@ -0,0 +1,31 @@
export interface IProductChargeRaw {
id: number;
name: string;
count: number;
fee: number;
totalAmount: number;
isSettled?: boolean;
buyAt: string;
description?: string;
productId: number;
inventoryId: number;
supplierId: number;
createdAt: string;
updatedAt: string;
deletedAt?: string;
}
export interface IProductChargeResponse extends IProductChargeRaw {}
export interface IProductChargeRequest {
name: string;
count: number;
fee: number;
totalAmount: number;
isSettled?: boolean;
buyAt: string;
description?: string;
productId: number;
inventoryId: number;
supplierId: number;
}
@@ -0,0 +1,29 @@
import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { PRODUCT_CHARGES_API_ROUTES } from '../constants';
import { IProductChargeRequest, IProductChargeResponse } from '../models';
@Injectable({
providedIn: 'root',
})
export class ProductChargesService {
private http = inject(HttpClient);
getAll(): Observable<{ data: IProductChargeResponse[] }> {
return this.http.get<{ data: IProductChargeResponse[] }>(PRODUCT_CHARGES_API_ROUTES.getAll);
}
getSingle(id: number): Observable<{ data: IProductChargeResponse }> {
return this.http.get<{ data: IProductChargeResponse }>(
PRODUCT_CHARGES_API_ROUTES.getSingle(id),
);
}
create(data: IProductChargeRequest): Observable<{ data: IProductChargeResponse }> {
return this.http.post<{ data: IProductChargeResponse }>(
PRODUCT_CHARGES_API_ROUTES.create,
data,
);
}
}
@@ -0,0 +1,3 @@
<app-page-data-list [items]="items()" [columns]="columns" [loading]="loading()" (onAdd)="openAddForm()" />
<product-charge-form [(visible)]="visibleForm" (onSubmit)="refresh()" />
@@ -0,0 +1,59 @@
import {
IColumn,
PageDataListComponent,
} from '@/shared/components/pageDataList/page-data-list.component';
import { Component, effect, inject, signal } from '@angular/core';
import { ProductChargeFormComponent } from '../components/form.component';
import { IProductChargeResponse } from '../models';
import { ProductChargesService } from '../services/main.service';
@Component({
selector: 'app-product-charges',
standalone: true,
imports: [PageDataListComponent, ProductChargeFormComponent],
templateUrl: './list.component.html',
})
export class ProductChargesComponent {
private productChargeService = inject(ProductChargesService);
loading = signal(false);
items = signal<IProductChargeResponse[]>([]);
visibleForm = signal(false);
columns: IColumn<IProductChargeResponse>[] = [
{ field: 'id', header: 'شناسه' },
{ field: 'name', header: 'نام' },
{ field: 'count', header: 'تعداد' },
{ field: 'fee', header: 'هزینه' },
{ field: 'totalAmount', header: 'مبلغ کل' },
{ field: 'isSettled', header: 'تسویه شده' },
{ field: 'buyAt', header: 'تاریخ خرید' },
{ field: 'productId', header: 'شناسه محصول' },
{ field: 'inventoryId', header: 'شناسه انبار' },
{ field: 'supplierId', header: 'شناسه تامین‌کننده' },
{ field: 'createdAt', header: 'تاریخ ایجاد' },
{ field: 'updatedAt', header: 'تاریخ بروزرسانی' },
];
constructor() {
effect(() => {
this.getData();
});
}
refresh() {
this.getData();
}
openAddForm() {
this.visibleForm.set(true);
}
getData() {
this.loading.set(true);
this.productChargeService.getAll().subscribe((res) => {
this.loading.set(false);
this.items.set(res.data);
});
}
}
@@ -0,0 +1 @@
<p>product-charge works!</p>
@@ -0,0 +1,9 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-product-charge',
standalone: true,
imports: [],
templateUrl: './single.component.html',
})
export class ProductChargeComponent {}
@@ -0,0 +1,13 @@
<uikit-field label="محصول" [control]="control" [showLabel]="showLabel" [showErrors]="showErrors">
<p-select
[options]="items()"
optionLabel="name"
[optionValue]="selectOptionValue"
placeholder="انتخاب محصول"
[formControl]="control"
[showClear]="true"
[filter]="true"
appendTo="body"
>
</p-select>
</uikit-field>
@@ -0,0 +1,57 @@
import { Maybe } from '@/core';
import { UikitFieldComponent } from '@/uikit';
import { CommonModule } from '@angular/common';
import { Component, Input, signal } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { Select } from 'primeng/select';
import { IProductResponse } from '../../models';
import { ProductsService } from '../../services/main.service';
@Component({
selector: 'products-select-field',
templateUrl: './select.component.html',
imports: [ReactiveFormsModule, Select, UikitFieldComponent, CommonModule],
})
export class ProductsSelectComponent {
@Input() control!: FormControl<Maybe<number | IProductResponse>>;
@Input() canInsert: boolean = false;
@Input() showLabel: boolean = true;
@Input() showErrors: boolean = true;
constructor(private service: ProductsService) {
this.getData();
}
loading = signal(false);
items = signal<Maybe<IProductResponse[]>>(null);
isOpenFormDialog = signal(false);
getData() {
this.loading.set(true);
this.service.getAll().subscribe({
next: (res) => {
this.items.set(res.data);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
},
});
}
refresh() {
this.getData();
}
onOpenForm() {
// this.isOpenFormDialog.set(true);
}
get selectOptionValue() {
if (typeof this.control.value === 'number') {
return 'id';
}
return undefined;
}
}
@@ -1,7 +1,7 @@
import { NamedRoutes } from '@/core'; import { NamedRoutes } from '@/core';
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
export type TProductsRouteNames = 'products' | 'product' | 'create' | 'update'; export type TProductsRouteNames = 'products' | 'product' | 'create' | 'update' | 'purchase';
export const productsNamedRoutes: NamedRoutes<TProductsRouteNames> = { export const productsNamedRoutes: NamedRoutes<TProductsRouteNames> = {
products: { products: {
@@ -38,6 +38,15 @@ export const productsNamedRoutes: NamedRoutes<TProductsRouteNames> = {
pagePath: () => '/products/:productId/update', pagePath: () => '/products/:productId/update',
}, },
}, },
purchase: {
path: 'products/:productId/purchase',
loadComponent: () =>
import('../../views/purchase.component').then((m) => m.ProductPurchaseComponent),
meta: {
title: 'محصول',
pagePath: () => '/products/:productId/purchase',
},
},
}; };
export const PRODUCTS_ROUTES: Routes = Object.values(productsNamedRoutes); export const PRODUCTS_ROUTES: Routes = Object.values(productsNamedRoutes);
+1
View File
@@ -12,6 +12,7 @@ export interface IProductRawResponse {
category?: IProductCategory; category?: IProductCategory;
// supplierId: number; // supplierId: number;
basePrice?: string; basePrice?: string;
count?: number;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
deletedAt?: Maybe<Date>; deletedAt?: Maybe<Date>;
+1
View File
@@ -1,4 +1,5 @@
export * from './create.component'; export * from './create.component';
export * from './list.component'; export * from './list.component';
export * from './purchase.component';
export * from './single.component'; export * from './single.component';
export * from './update.component'; export * from './update.component';
@@ -0,0 +1 @@
<purchase-receipt-template />
@@ -0,0 +1,11 @@
import { PurchaseReceiptTemplateComponent } from '@/modules/purchases/components/purchase-receipt-template.component';
import { Component } from '@angular/core';
@Component({
selector: 'product-purchase',
templateUrl: './purchase.component.html',
imports: [PurchaseReceiptTemplateComponent],
})
export class ProductPurchaseComponent {
constructor() {}
}
@@ -7,6 +7,7 @@
</div> </div>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button pButton type="button" label="افزایش موجودی" icon="pi pi-plus" (click)="openChargeForm()"></button>
<button <button
pButton pButton
type="button" type="button"
@@ -51,14 +52,14 @@
</div> </div>
<div class="shrink-0 w-xs"> <div class="shrink-0 w-xs">
<p-card class="border border-primary-700"> <p-card class="border border-primary-700">
<div class="flex justify-between p-2 gap-2"> <div class="flex justify-between items-center p-2 gap-2">
<span class="font-bold text-lg">دسته‌بندی</span> <span class="">دسته‌بندی</span>
<p class="m-0">{{ product()?.category?.name || "بدون دسته‌بندی" }}</p> <p class="m-0 font-bold text-lg">{{ product()?.category?.name || "بدون دسته‌بندی" }}</p>
</div> </div>
<hr class="m-0" /> <hr class="m-0" />
<div class="flex justify-between p-2 gap-2"> <div class="flex justify-between items-center p-2 gap-2">
<span class="font-bold text-lg">برند</span> <span class="">برند</span>
<p class="m-0">{{ product()?.brand?.name || "بدون برند" }}</p> <p class="m-0 font-bold text-lg">{{ product()?.brand?.name || "بدون برند" }}</p>
</div> </div>
</p-card> </p-card>
</div> </div>
@@ -81,4 +82,5 @@
</ng-template> </ng-template>
</p-galleria> </p-galleria>
</div> </div>
<purchase-form [(visible)]="isOpenChargeForm" [product]="product()!" />
</div> </div>
@@ -1,4 +1,5 @@
import { Maybe } from '@/core'; import { Maybe } from '@/core';
import { PurchaseFormComponent } from '@/modules/purchases/components/form.component';
import { KeyValueComponent } from '@/shared/components'; import { KeyValueComponent } from '@/shared/components';
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';
@@ -11,7 +12,15 @@ import { ProductsService } from '../services/main.service';
@Component({ @Component({
selector: 'app-product', selector: 'app-product',
templateUrl: './single.component.html', templateUrl: './single.component.html',
imports: [ButtonDirective, RouterLink, GalleriaModule, KeyValueComponent, Card, Button], imports: [
ButtonDirective,
RouterLink,
GalleriaModule,
KeyValueComponent,
Card,
Button,
PurchaseFormComponent,
],
}) })
export class ProductComponent { export class ProductComponent {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
@@ -48,6 +57,7 @@ export class ProductComponent {
]); ]);
productId = this.route.snapshot.params['productId']; productId = this.route.snapshot.params['productId'];
isOpenChargeForm = signal(false);
loading = signal(false); loading = signal(false);
product = signal<Maybe<IProductResponse>>(null); product = signal<Maybe<IProductResponse>>(null);
@@ -69,7 +79,7 @@ export class ProductComponent {
{ {
icon: 'pi pi-box', icon: 'pi pi-box',
label: 'موجودی', label: 'موجودی',
value: 0, value: this.product()?.count || 0,
}, },
{ {
icon: 'pi pi-dollar', icon: 'pi pi-dollar',
@@ -92,4 +102,7 @@ export class ProductComponent {
} }
openDeleteConfirm() {} openDeleteConfirm() {}
openChargeForm() {
this.isOpenChargeForm.set(true);
}
} }
@@ -0,0 +1,25 @@
<p-dialog [(visible)]="visible" [modal]="true" [style]="{ width: '50vw' }" [header]="formTitle">
<form [formGroup]="form" (ngSubmit)="submit()">
<div class="grid grid-cols-1">
<product-charge-row-form [productsControl]="form.controls.products" [isSingleProduct]="!!product" />
<hr />
<div class="grid grid-cols-2 gap-4">
<inventories-select-field [control]="form.controls.inventoryId" />
<suppliers-select-field [control]="form.controls.supplierId" />
<app-input label="مبلغ کل" [control]="form.controls.totalAmount" />
<app-input label="تاریخ خرید" [control]="form.controls.buyAt" />
<div class="col-span-2">
<textarea pTextarea label="توضیحات" [formControl]="form.controls.description"></textarea>
</div>
<app-input label="تسویه شده" type="switch" [control]="form.controls.isSettled" />
</div>
<app-form-footer-actions (onSubmit)="submit()" (onCancel)="cancel()" />
</div>
</form>
</p-dialog>
@@ -0,0 +1,104 @@
import { InventoriesSelectComponent } from '@/modules/inventories/components/select/select.component';
import { IInventoryResponse } 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';
import { InputComponent } from '@/shared/components';
import { FormFooterActionsComponent } from '@/shared/components/formFooterActions/form-footer-actions.component';
import { Component, effect, inject, Input, model, output } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { DialogModule } from 'primeng/dialog';
import { Textarea } from 'primeng/textarea';
import { IPurchaseRequest } from '../models';
import { PurchasesService } from '../services/main.service';
import { ProductChargeRowFormComponent } from './product-charge-row-form.component';
@Component({
selector: 'purchase-form',
standalone: true,
imports: [
DialogModule,
ReactiveFormsModule,
InputComponent,
FormFooterActionsComponent,
Textarea,
InventoriesSelectComponent,
SuppliersSelectComponent,
ProductChargeRowFormComponent,
],
templateUrl: './form.component.html',
})
export class PurchaseFormComponent {
@Input() product?: IProductResponse;
@Input() inventory?: IInventoryResponse;
@Input() supplier?: ISupplierResponse;
private fb = inject(FormBuilder);
private service = inject(PurchasesService);
visible = model<boolean>(false);
onSubmit = output();
form = this.fb.group({
totalAmount: [0, [Validators.required, Validators.min(0)]],
isSettled: [false],
buyAt: ['', Validators.required],
description: [''],
products: this.fb.array([
this.fb.group({
productId: [this.product?.id || 0, [Validators.required]],
count: [0, [Validators.required, Validators.min(1)]],
fee: [0, [Validators.required, Validators.min(0)]],
}),
]),
inventoryId: [this.inventory?.id || 0, [Validators.required]],
supplierId: [this.supplier?.id || 0, [Validators.required]],
});
get formTitle() {
if (this.product) {
return `شارژ محصول ${this.product.name}`;
}
if (this.inventory) {
return `شارژ انبار ${this.inventory.name}`;
}
if (this.supplier) {
return `شارژ تامین‌کننده ${this.supplier.fullname}`;
}
return 'شارژ محصول';
}
constructor() {
effect(() => {
if (!this.visible()) {
this.form.reset();
}
});
}
submit() {
this.form.markAllAsTouched();
console.log(this.form);
console.log(this.form.value);
if (this.form.valid) {
this.form.disable();
const data = this.form.value as IPurchaseRequest;
this.service.create(data).subscribe({
next: () => {
this.form.enable();
this.visible.set(false);
this.onSubmit.emit();
},
error: () => {
this.form.enable();
},
});
}
}
cancel() {
this.visible.set(false);
}
}
@@ -0,0 +1,7 @@
<div class="grid grid-cols-2 gap-4">
<products-select-field [control]="productIdControl" />
<div class="grid grid-cols-2 gap-4">
<app-input label="تعداد" [control]="countControl" name="count" />
<app-input label="قیمت" [control]="feeControl" name="fee" />
</div>
</div>
@@ -0,0 +1,17 @@
import { Maybe } from '@/core';
import { ProductsSelectComponent } from '@/modules/products/components/select/select.component';
import { InputComponent } from '@/shared/components';
import { Component, Input } from '@angular/core';
import { FormControl } from '@angular/forms';
@Component({
selector: 'product-charge-row-form-fields',
templateUrl: './product-charge-row-form-fields.component.html',
imports: [ProductsSelectComponent, InputComponent],
})
export class ProductChargeRowFormFieldsComponent {
@Input() productIdControl!: FormControl<Maybe<number>>;
@Input() countControl!: FormControl<Maybe<number>>;
@Input() feeControl!: FormControl<Maybe<number>>;
constructor() {}
}
@@ -0,0 +1,32 @@
<div class="flex flex-col gap-4">
<div class="space-y-3">
@for (product of products; let i = $index; track product.controls["productId"].value) {
<div class="flex items-center gap-3 p-3 border border-surface-700 rounded-lg">
<div class="flex-1">
<product-charge-row-form-fields
[productIdControl]="$any(product.get('productId'))"
[countControl]="$any(product.get('count'))"
[feeControl]="$any(product.get('fee'))"
/>
</div>
@if (canAddOrRemoveProducts) {
<button
pButton
type="button"
icon="pi pi-trash"
severity="danger"
size="small"
class="shrink-0"
(click)="removeProduct(i)"
[disabled]="productsControl.length === 1"
pTooltip="حذف محصول"
></button>
}
</div>
}
</div>
@if (canAddOrRemoveProducts) {
<button pButton type="button" label="افزودن محصول" (click)="addProduct()" pTooltip="افزودن محصول"></button>
}
</div>
@@ -0,0 +1,61 @@
import { Maybe } from '@/core';
import { CommonModule } from '@angular/common';
import { Component, Input, OnInit, inject } from '@angular/core';
import {
FormArray,
FormBuilder,
FormControl,
FormGroup,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { ButtonModule } from 'primeng/button';
import { ProductChargeRowFormFieldsComponent } from './product-charge-row-form-fields.component';
@Component({
selector: 'product-charge-row-form',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, ButtonModule, ProductChargeRowFormFieldsComponent],
templateUrl: './product-charge-row-form.component.html',
})
export class ProductChargeRowFormComponent implements OnInit {
private fb = inject(FormBuilder);
@Input() productsControl!: FormArray<
FormGroup<{
productId: FormControl<Maybe<number>>;
count: FormControl<Maybe<number>>;
fee: FormControl<Maybe<number>>;
}>
>;
@Input() isSingleProduct = false;
ngOnInit() {
if (this.productsControl.length === 0) {
this.addProduct();
}
if (this.isSingleProduct) {
this.productsControl.at(0).get('productId')?.disable();
}
}
addProduct() {
const productFormGroup = this.fb.group({
productId: [0, [Validators.required]],
count: [1, [Validators.required, Validators.min(1)]],
fee: [0, [Validators.required, Validators.min(0)]],
});
this.productsControl.push(productFormGroup);
}
removeProduct(index: number) {
this.productsControl.removeAt(index);
}
get products() {
return this.productsControl.controls as FormGroup[];
}
get canAddOrRemoveProducts() {
return this.isSingleProduct === false;
}
}
@@ -0,0 +1,49 @@
<td>
{{ rowIndex + 1 }}
</td>
@if (editMode()) {
<td>
<input
pInputText
type="text"
[value]="purchaseItemControl.controls.product.value?.sku"
class="w-full"
readonly
[attr.disabled]="true"
/>
</td>
<td>
<products-select-field [control]="purchaseItemControl.controls.product" [showLabel]="false" [showErrors]="false" />
</td>
<td>
<textarea
pTextarea
[formControl]="purchaseItemControl.controls.description"
rows="1"
[autoResize]="false"
></textarea>
</td>
<td><app-input [control]="purchaseItemControl.controls.fee" [showErrors]="false" /></td>
<td><app-input [control]="purchaseItemControl.controls.count" /></td>
<td><span [appPriceMask]="purchaseItemControl.controls.total.value || 0"></span></td>
<td>
<div class="flex items-center gap-1">
<button pButton type="button" icon="pi pi-check" text size="small" (click)="toggleEditMode()"></button>
</div>
</td>
} @else {
<td>{{ purchaseItemControl.controls.product.value?.sku || "" }}</td>
<td>{{ purchaseItemControl.controls.product.value?.name || "" }}</td>
<td>{{ purchaseItemControl.controls.description.value || "" }}</td>
<td><span [appPriceMask]="purchaseItemControl.controls.fee.value || 0"></span></td>
<td>{{ purchaseItemControl.controls.count.value }}</td>
<td><span [appPriceMask]="purchaseItemControl.controls.total.value || 0"></span></td>
<td>
<div class="flex items-center gap-1">
<button pButton type="button" icon="pi pi-pencil" text size="small" (click)="toggleEditMode()"></button>
@if (canRemove) {
<button pButton type="button" icon="pi pi-trash" text size="small" (click)="onDeleteClick()"></button>
}
</div>
</td>
}
@@ -0,0 +1,59 @@
import { ProductsSelectComponent } from '@/modules/products/components/select/select.component';
import { InputComponent } from '@/shared/components';
import { PriceMaskDirective } from '@/shared/directives';
import { Component, EventEmitter, Input, Output, signal } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { ButtonDirective } from 'primeng/button';
import { InputText } from 'primeng/inputtext';
import { Textarea } from 'primeng/textarea';
import { IPurchaseItemFormGroup } from '../models';
@Component({
selector: 'tr[purchase-receipt-product-row]',
templateUrl: './purchase-receipt-product-row.component.html',
imports: [
ButtonDirective,
InputText,
ReactiveFormsModule,
ProductsSelectComponent,
InputComponent,
Textarea,
PriceMaskDirective,
],
})
export class PurchaseReceiptProductRowComponent {
@Input() rowIndex!: number;
@Input() canRemove: boolean = true;
@Input() purchaseItemControl!: IPurchaseItemFormGroup;
@Output() onRemove = new EventEmitter<void>();
editMode = signal(false);
constructor() {}
ngOnInit() {
this.purchaseItemControl?.controls.count.valueChanges.subscribe(() => {
this.updateTotal();
});
this.purchaseItemControl?.controls.fee.valueChanges.subscribe(() => {
this.updateTotal();
});
console.log('purchase item control', this.purchaseItemControl);
}
updateTotal() {
this.purchaseItemControl.controls.total.setValue(
(this.purchaseItemControl.controls.count.value || 0) *
(this.purchaseItemControl.controls.fee.value || 0),
);
}
toggleEditMode() {
this.editMode.set(!this.editMode());
}
onDeleteClick() {
this.onRemove.emit();
}
}
@@ -0,0 +1,142 @@
<form [formGroup]="form">
<p-card class="grid grid-cols-1 p-0 max-w-6xl mx-auto border border-surface-700">
<div class="mb-4 text-center">
<h2 class="m-0">رسید خرید</h2>
</div>
<div class="grid grid-cols-2 gap-4 border-b border-surface-700 pb-4">
<div class="grid grid-cols-1 gap-2">
<shared-inline-edit>
<ng-template #data>
<app-key-value label="انبار" value="انبار انتخاب شده" />
</ng-template>
<ng-template #field>
<inventories-select-field [control]="form.controls.inventoryId" />
</ng-template>
</shared-inline-edit>
<shared-inline-edit>
<ng-template #data>
<app-key-value label="تامین کننده" value="تامین‌کننده‌ی انتخاب شده" />
</ng-template>
<ng-template #field>
<suppliers-select-field [control]="form.controls.supplierId" />
</ng-template>
</shared-inline-edit>
</div>
<div class="">
<div class="flex flex-col gap-2 max-w-xs items-end ms-auto w-full">
<shared-inline-edit class="w-full block">
<ng-template #data>
<app-key-value label="شماره رسید" value="۲۱۳۱۲۳" />
</ng-template>
<ng-template #field>
<app-input label="شماره رسید" [control]="form.controls.code" />
</ng-template>
</shared-inline-edit>
<shared-inline-edit class="w-full block">
<ng-template #data>
<app-key-value label="تاریخ رسید" value="۲۱۳۱۲۳" />
</ng-template>
<ng-template #field>
<app-input label="تاریخ خرید" [control]="form.controls.buyAt" />
</ng-template>
</shared-inline-edit>
</div>
</div>
</div>
<p-table
[columns]="columns"
[value]="this.form.controls.products.controls"
[scrollable]="true"
[resizableColumns]="false"
[tableStyleClass]="'table-fixed'"
>
<ng-template #header let-columns>
<tr>
@for (col of columns; track $index) {
<th [style]="{ width: col.width, minWidth: col.minWidth }">
{{ col.header }}
</th>
}
</tr>
</ng-template>
<ng-template #body let-product let-columns="columns" let-rowIndex="rowIndex">
<tr
purchase-receipt-product-row
[rowIndex]="rowIndex"
[purchaseItemControl]="product"
[canRemove]="this.form.controls.products.controls.length > 1"
(onRemove)="removePurchaseItem(rowIndex)"
></tr>
<!-- <tr>
@for (col of columns; track $index) {
<td>
@if (col.field === "index") {
{{ rowIndex + 1 }}
} @else if (col.field === "name") {
<shared-inline-edit>
<ng-template #data>
{{ product.name || "-" }}
</ng-template>
<ng-template #field>
<products-select-field
[control]="form.controls.products.controls[rowIndex].controls.productId"
[showLabel]="false"
></products-select-field>
</ng-template>
</shared-inline-edit>
} @else if (col.field === "total") {
{{ product["fee"] * product["count"] || 0 }}
} @else if (col.field === "action") {
<div class="flex items-center gap-2">
<button></button>
</div>
} @else {
{{ product[col.field] }}
}
</td>
}
</tr> -->
</ng-template>
</p-table>
<div class="flex items-center justify-center py-3">
<button
pButton
type="button"
icon="pi pi-plus"
size="large"
rounded
outlined
(click)="onAddProductClick()"
></button>
</div>
<div class="flex border-t border-surface-700 items-stretch">
<div class="grow border-e border-surface-700"></div>
<div class="shrink-0 w-xs">
<div class="flex flex-col gap-2 p-4">
<div class="flex justify-between">
<span>جمع کل:</span>
<span [appPriceMask]="totalAmount"></span>
</div>
<div class="flex justify-between">
<span>مالیات (۹٪):</span>
<span [appPriceMask]="totalAmount * 0.09"></span>
</div>
</div>
</div>
</div>
<div class="flex border-t border-surface-700 pt-4">
<div class="grow">
<span>مبلغ قابل پرداخت:</span>
<span [appPriceAlphabet]="totalAmount + totalAmount * 0.09"></span>
</div>
<div class="shrink-0 w-xs">
<span [appPriceMask]="totalAmount + totalAmount * 0.09"></span>
</div>
</div>
<div class="flex items-center justify-center gap-2 mt-10">
<button pButton type="button" label="لغو" outlined (click)="onCancel()"></button>
<button pButton type="button" label="ثبت رسید" (click)="onSubmit()"></button>
</div>
</p-card>
</form>
@@ -0,0 +1,159 @@
import { Maybe } from '@/core';
import { InventoriesSelectComponent } from '@/modules/inventories/components/select/select.component';
import { IInventoryResponse } 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';
import { InlineEditComponent, InputComponent, KeyValueComponent } from '@/shared/components';
import { IColumn } from '@/shared/components/pageDataList/page-data-list.component';
import { PriceAlphabetDirective, PriceMaskDirective } from '@/shared/directives';
import { CommonModule } from '@angular/common';
import { Component, inject, Input } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ButtonDirective } from 'primeng/button';
import { Card } from 'primeng/card';
import { TableModule } from 'primeng/table';
import { PurchaseReceiptProductRowComponent } from './purchase-receipt-product-row.component';
@Component({
selector: 'purchase-receipt-template',
templateUrl: './purchase-receipt-template.component.html',
standalone: true,
imports: [
Card,
InventoriesSelectComponent,
SuppliersSelectComponent,
InputComponent,
TableModule,
InlineEditComponent,
KeyValueComponent,
PurchaseReceiptProductRowComponent,
PriceAlphabetDirective,
PriceMaskDirective,
ButtonDirective,
CommonModule,
ReactiveFormsModule,
],
})
export class PurchaseReceiptTemplateComponent {
private fb = inject(FormBuilder);
@Input() product?: IProductResponse;
@Input() inventory?: IInventoryResponse;
@Input() supplier?: ISupplierResponse;
constructor() {
this.form.controls.products.valueChanges.subscribe((products) => {
let totalAmount = 0;
products.forEach((p: any) => {
totalAmount += p.total || 0;
});
this.form.controls.totalAmount.setValue(totalAmount);
});
}
columns = [
{
field: 'index',
header: 'ردیف',
width: '60px',
},
{
field: 'sku',
header: 'کد کالا',
width: '120px',
},
{
field: 'name',
header: 'نام کالا',
minWidth: '160px',
},
{
field: 'description',
header: 'توضیحات',
width: '200px',
},
{
field: 'fee',
header: 'قیمت واحد',
width: '120px',
},
{
field: 'count',
header: 'تعداد',
width: '80px',
},
{
field: 'total',
header: 'مبلغ کل',
width: '140px',
},
{
field: 'action',
header: '',
width: '100px',
},
] as IColumn[];
form = this.fb.group({
totalAmount: [0, [Validators.required, Validators.min(0)]],
code: ['', [Validators.required]],
isSettled: [false],
buyAt: ['', Validators.required],
description: [''],
products: this.fb.array([
this.fb.group({
product: [null as Maybe<IProductResponse>, [Validators.required]],
count: [0, [Validators.required, Validators.min(1)]],
fee: [0, [Validators.required, Validators.min(0)]],
description: [''],
total: [0, [Validators.required, Validators.min(0)]],
}),
]),
inventoryId: [this.inventory?.id || 0, [Validators.required]],
supplierId: [this.supplier?.id || 0, [Validators.required]],
});
ngOnInit() {
if (this.product) {
this.form.controls.products.at(0).setValue({
product: this.product,
count: 0,
fee: 0,
description: '',
total: 0,
});
}
}
get totalAmount() {
return this.form.controls.totalAmount.value || 0;
}
onAddProductClick() {
this.form.controls.products.push(
this.fb.group({
product: [null as Maybe<IProductResponse>, [Validators.required]],
count: [0, [Validators.required, Validators.min(1)]],
fee: [0, [Validators.required, Validators.min(0)]],
description: [''],
total: [0, [Validators.required, Validators.min(0)]],
}),
);
}
removePurchaseItem(index: number) {
this.form.controls.products.removeAt(index);
}
onSubmit() {
if (this.form.valid) {
// Handle form submission logic here
console.log('Form Submitted', this.form.value);
} else {
this.form.markAllAsTouched();
}
}
onCancel() {}
}
@@ -0,0 +1,7 @@
const BASE_URL = '/api/v1/purchase-receipts';
export const PURCHASES_API_ROUTES = {
getAll: BASE_URL,
getSingle: (id: number) => `${BASE_URL}/${id}`,
create: BASE_URL,
};
@@ -0,0 +1,2 @@
export * from './apiRoutes';
export * from './routes';
@@ -0,0 +1,16 @@
import { Routes } from '@angular/router';
export type TProductChargesRouteNames = 'product-charges' | 'product-charge';
export const PRODUCT_CHARGES_ROUTES: Routes = [
{
path: '',
loadComponent: () =>
import('../../views/list.component').then((m) => m.ProductChargesComponent),
},
{
path: ':productChargeId',
loadComponent: () =>
import('../../views/single.component').then((m) => m.ProductChargeComponent),
},
];
+23
View File
@@ -0,0 +1,23 @@
import { Maybe } from '@/core';
import { IProductResponse } from '@/modules/products/models';
import { FormArray, FormControl, FormGroup } from '@angular/forms';
export * from './io';
export interface IPurchaseItemFormGroup extends FormGroup<{
product: FormControl<IProductResponse | null>;
count: FormControl<number>;
fee: FormControl<number>;
description: FormControl<string>;
total: FormControl<number>;
}> {}
export interface IPurchaseFormGroup extends FormGroup<{
code: FormControl<string>;
isSettled: FormControl<boolean>;
buyAt: FormControl<string>;
description: FormControl<Maybe<string>>;
products: FormArray<IPurchaseItemFormGroup>;
inventoryId: FormControl<number>;
supplierId: FormControl<number>;
}> {}
+57
View File
@@ -0,0 +1,57 @@
import { Maybe } from '@/core';
export interface IPurchaseRawResponse {
id: number;
totalAmount: number;
inventoryId: number;
supplierId: number;
code: string;
items: IPurchaseItemResponse[];
createdAt: string;
updatedAt: string;
deletedAt?: string;
}
export interface IPurchaseResponse extends IPurchaseRawResponse {}
export interface IPurchaseItemRawResponse {
id: number;
count: number;
fee: number;
total: number;
productId: number;
}
export interface IPurchaseRequest {
totalAmount: number;
inventoryId: number;
supplierId: number;
code?: string;
description?: string;
items: IPurchaseItemRequest[];
}
export interface IPurchaseItemRequest {
productId: number;
count: number;
fee: number;
total: number;
}
export interface IPurchaseForm {
inventory: Maybe<IInventoryResponse>;
supplier: Maybe<ISupplierResponse>;
code: string;
isSettled: boolean;
buyAt: Date | string;
description: string;
totalAmount: number;
products: IPurchaseItemForm[];
}
export interface IPurchaseItemForm {
product: Maybe<IProductResponse>;
count: number;
fee: number;
total: number;
description?: string;
}
@@ -0,0 +1,24 @@
import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { PURCHASES_API_ROUTES } from '../constants';
import { IPurchaseRawResponse, IPurchaseRequest, IPurchaseResponse } from '../models';
@Injectable({
providedIn: 'root',
})
export class PurchasesService {
private http = inject(HttpClient);
getAll(): Observable<{ data: IPurchaseRawResponse[] }> {
return this.http.get<{ data: IPurchaseResponse[] }>(PURCHASES_API_ROUTES.getAll);
}
getSingle(id: number): Observable<{ data: IPurchaseResponse }> {
return this.http.get<{ data: IPurchaseResponse }>(PURCHASES_API_ROUTES.getSingle(id));
}
create(data: IPurchaseRequest): Observable<{ data: IPurchaseRawResponse }> {
return this.http.post<{ data: IPurchaseResponse }>(PURCHASES_API_ROUTES.create, data);
}
}
@@ -0,0 +1,10 @@
import { PageDataListComponent } from '@/shared/components/pageDataList/page-data-list.component';
import { Component } from '@angular/core';
@Component({
selector: 'app-product-charges',
standalone: true,
imports: [PageDataListComponent],
templateUrl: './list.component.html',
})
export class ProductChargesComponent {}
@@ -0,0 +1 @@
<p>product-charge works!</p>
@@ -0,0 +1,9 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-product-charge',
standalone: true,
imports: [],
templateUrl: './single.component.html',
})
export class ProductChargeComponent {}
@@ -2,7 +2,7 @@
<form [formGroup]="form" (submit)="submit()" class="flex flex-col gap-4"> <form [formGroup]="form" (submit)="submit()" class="flex flex-col gap-4">
<app-input label="نام" [control]="form.controls.name" name="name" /> <app-input label="نام" [control]="form.controls.name" name="name" />
<app-input label="موقعیت" [control]="form.controls.location" name="location" /> <app-input label="موقعیت" [control]="form.controls.location" name="location" />
<app-input label="فعال" [control]="form.controls.isActive" name="isActive" type="switch" /> <app-input label="وضعیت" [control]="form.controls.isActive" name="isActive" type="switch" />
<app-form-footer-actions [submitLabel]="'ذخیره'" [loading]="form.disabled" (onCancel)="close()" /> <app-form-footer-actions [submitLabel]="'ذخیره'" [loading]="form.disabled" (onCancel)="close()" />
</form> </form>
</p-dialog> </p-dialog>
@@ -22,8 +22,9 @@ export class StoresComponent {
{ field: 'name', header: 'نام' }, { field: 'name', header: 'نام' },
{ field: 'location', header: 'موقعیت' }, { field: 'location', header: 'موقعیت' },
{ field: 'isActive', header: 'فعال' }, { field: 'isActive', header: 'فعال' },
{ field: 'createdAt', header: 'تاریخ ایجاد' }, { field: 'createdAt', header: 'تاریخ ایجاد', type: 'date' },
{ field: 'updatedAt', header: 'تاریخ به‌روزرسانی' }, { field: 'updatedAt', header: 'تاریخ به‌روزرسانی', type: 'date' },
,
] as IColumn[]; ] as IColumn[];
loading = signal(false); loading = signal(false);
@@ -8,7 +8,7 @@
<app-input label="شهر" [control]="form.controls.city" name="city" /> <app-input label="شهر" [control]="form.controls.city" name="city" />
<app-input label="استان" [control]="form.controls.state" name="state" /> <app-input label="استان" [control]="form.controls.state" name="state" />
<app-input label="کشور" [control]="form.controls.country" name="country" /> <app-input label="کشور" [control]="form.controls.country" name="country" />
<app-input label="فعال" [control]="form.controls.isActive" name="isActive" type="switch" /> <app-input label="وضعیت" [control]="form.controls.isActive" name="isActive" type="switch" />
<app-form-footer-actions [submitLabel]="'ذخیره'" [loading]="form.disabled" (onCancel)="close()" /> <app-form-footer-actions [submitLabel]="'ذخیره'" [loading]="form.disabled" (onCancel)="close()" />
</form> </form>
</p-dialog> </p-dialog>
@@ -41,13 +41,13 @@ export class SupplierFormComponent {
form = this.fb.group({ form = this.fb.group({
firstName: [this.initialValues?.firstName || null, [Validators.required]], firstName: [this.initialValues?.firstName || null, [Validators.required]],
lastName: [this.initialValues?.lastName || null, [Validators.required]], lastName: [this.initialValues?.lastName || null, [Validators.required]],
email: [this.initialValues?.email || null, [Validators.required, Validators.email]],
mobileNumber: [this.initialValues?.mobileNumber || null, [Validators.required]], mobileNumber: [this.initialValues?.mobileNumber || null, [Validators.required]],
address: [this.initialValues?.address || null, [Validators.required]], email: [this.initialValues?.email || null, [Validators.email]],
city: [this.initialValues?.city || null, [Validators.required]], address: [this.initialValues?.address || null],
state: [this.initialValues?.state || null, [Validators.required]], city: [this.initialValues?.city || null],
country: [this.initialValues?.country || null, [Validators.required]], state: [this.initialValues?.state || null],
isActive: [this.initialValues?.isActive || false, [Validators.required]], country: [this.initialValues?.country || null],
isActive: [this.initialValues?.isActive || false],
}); });
submitLoading = signal(false); submitLoading = signal(false);
@@ -0,0 +1,15 @@
<div class="">
<uikit-field label="تامین‌کننده">
<p-select
[options]="items()"
optionLabel="fullname"
optionValue="id"
placeholder="انتخاب تامین‌کننده"
[formControl]="control"
[showClear]="true"
[filter]="true"
appendTo="body"
>
</p-select>
</uikit-field>
</div>
@@ -0,0 +1,47 @@
import { Maybe } from '@/core';
import { UikitFieldComponent } from '@/uikit';
import { Component, Input, signal } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { Select } from 'primeng/select';
import { ISupplierResponse } from '../../models';
import { SuppliersService } from '../../services/main.service';
@Component({
selector: 'suppliers-select-field',
templateUrl: './select.component.html',
imports: [ReactiveFormsModule, Select, UikitFieldComponent],
})
export class SuppliersSelectComponent {
@Input() control!: FormControl<Maybe<number>>;
@Input() canInsert: boolean = false;
constructor(private service: SuppliersService) {
this.getData();
}
loading = signal(false);
items = signal<Maybe<ISupplierResponse[]>>(null);
isOpenFormDialog = signal(false);
getData() {
this.loading.set(true);
this.service.getAll().subscribe({
next: (res) => {
this.items.set(res.data);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
},
});
}
refresh() {
this.getData();
}
onOpenForm() {
// this.isOpenFormDialog.set(true);
}
}
+3 -1
View File
@@ -14,7 +14,9 @@ export interface ISupplierRawResponse {
deletedAt: Date; deletedAt: Date;
} }
export interface ISupplierResponse extends ISupplierRawResponse {} export interface ISupplierResponse extends ISupplierRawResponse {
fullname: string;
}
export interface ISupplierRequest { export interface ISupplierRequest {
firstName: string; firstName: string;
@@ -1,7 +1,7 @@
import { IPaginatedResponse } from '@/core/models/service.model'; import { IPaginatedResponse } from '@/core/models/service.model';
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 { map, Observable } from 'rxjs';
import { SUPPLIERS_API_ROUTES } from '../constants'; import { SUPPLIERS_API_ROUTES } from '../constants';
import { ISupplierRawResponse, ISupplierRequest, ISupplierResponse } from '../models'; import { ISupplierRawResponse, ISupplierRequest, ISupplierResponse } from '../models';
@@ -12,14 +12,32 @@ export class SuppliersService {
private apiRoutes = SUPPLIERS_API_ROUTES; private apiRoutes = SUPPLIERS_API_ROUTES;
getAll(): Observable<IPaginatedResponse<ISupplierResponse>> { getAll(): Observable<IPaginatedResponse<ISupplierResponse>> {
return this.http.get<IPaginatedResponse<ISupplierRawResponse>>(this.apiRoutes.list()); return this.http.get<IPaginatedResponse<ISupplierRawResponse>>(this.apiRoutes.list()).pipe(
map((res) => ({
...res,
data: res.data.map((item) => ({
...item,
fullname: `${item.firstName} ${item.lastName}`,
})),
})),
);
} }
getSingle(supplierId: string): Observable<ISupplierResponse> { getSingle(supplierId: string): Observable<ISupplierResponse> {
return this.http.get<ISupplierRawResponse>(this.apiRoutes.single(supplierId)); return this.http.get<ISupplierRawResponse>(this.apiRoutes.single(supplierId)).pipe(
map((item) => ({
...item,
fullname: `${item.firstName} ${item.lastName}`,
})),
);
} }
create(data: ISupplierRequest): Observable<ISupplierResponse> { create(data: ISupplierRequest): Observable<ISupplierResponse> {
return this.http.post<ISupplierRawResponse>(this.apiRoutes.list(), data); return this.http.post<ISupplierRawResponse>(this.apiRoutes.list(), data).pipe(
map((item) => ({
...item,
fullname: `${item.firstName} ${item.lastName}`,
})),
);
} }
} }
@@ -18,18 +18,24 @@ export class SuppliersComponent {
} }
columns = [ columns = [
{ field: 'id', header: 'شناسه' }, {
{ field: 'firstName', header: 'نام' }, field: 'name',
{ field: 'lastName', header: 'نام خانوادگی' }, header: 'نام',
{ field: 'email', header: 'ایمیل' }, customDataModel(item) {
return `${item.firstName} ${item.lastName}` || '-';
},
},
{ field: 'mobileNumber', header: 'شماره موبایل' }, { field: 'mobileNumber', header: 'شماره موبایل' },
{ field: 'address', header: 'آدرس' }, { field: 'address', header: 'آدرس' },
{ field: 'city', header: 'شهر' }, {
{ field: 'state', header: 'استان' }, field: 'isActive',
{ field: 'country', header: 'کشور' }, header: 'وضعیت',
{ field: 'isActive', header: 'فعال' }, customDataModel(item) {
{ field: 'createdAt', header: 'تاریخ ایجاد' }, return item.isActive ? 'فعال' : 'غیرفعال';
{ field: 'updatedAt', header: 'تاریخ به‌روزرسانی' }, },
},
{ field: 'createdAt', header: 'تاریخ ایجاد', type: 'date' },
{ field: 'updatedAt', header: 'تاریخ به‌روزرسانی', type: 'date' },
] as IColumn[]; ] as IColumn[];
loading = signal(false); loading = signal(false);
+1
View File
@@ -1,6 +1,7 @@
export * from './breadcrumb.component'; export * from './breadcrumb.component';
export * from './card-data.component'; export * from './card-data.component';
export * from './inlineConfirmation/inline-confirmation.component'; export * from './inlineConfirmation/inline-confirmation.component';
export * from './inlineEdit/inline-edit.component';
export * from './input/input.component'; export * from './input/input.component';
export * from './key-value.component/key-value.component'; export * from './key-value.component/key-value.component';
export * from './table-action-row.component'; export * from './table-action-row.component';
@@ -0,0 +1,22 @@
<div class="flex items-stretch gap-2">
<div class="shrink-0 flex items-center">
@if (editMode()) {
<ng-container [ngTemplateOutlet]="field"></ng-container>
} @else {
<ng-container [ngTemplateOutlet]="data"></ng-container>
}
</div>
<div class="relative shrink-0">
<div class="static end-0 top-0">
<button
pButton
size="small"
type="button"
[icon]="'pi ' + (editMode() ? 'pi-times' : 'pi-pencil')"
text
severity="secondary"
(click)="toggleEditMode()"
></button>
</div>
</div>
</div>
@@ -0,0 +1,20 @@
import { CommonModule } from '@angular/common';
import { Component, ContentChild, signal, TemplateRef } from '@angular/core';
import { ButtonDirective } from 'primeng/button';
@Component({
selector: 'shared-inline-edit',
templateUrl: './inline-edit.component.html',
imports: [CommonModule, ButtonDirective],
})
export class InlineEditComponent {
constructor() {}
editMode = signal<boolean>(false);
@ContentChild('data', { static: true }) data?: TemplateRef<any>;
@ContentChild('field', { static: true }) field?: TemplateRef<any>;
toggleEditMode() {
this.editMode.set(!this.editMode());
}
}
@@ -1,4 +1,4 @@
<uikit-field [label]="label" [name]="name" [control]="control"> <uikit-field [label]="label" [name]="name" [control]="control" [showLabel]="!!label" [showErrors]="showErrors">
@if (type === "switch") { @if (type === "switch") {
<p-toggleSwitch [formControl]="control" /> <p-toggleSwitch [formControl]="control" />
} @else { } @else {
@@ -23,6 +23,7 @@ export class InputComponent {
@Input() disabled = false; @Input() disabled = false;
@Input() size?: 'small' | 'large'; @Input() size?: 'small' | 'large';
@Input() autocomplete?: string = 'off'; @Input() autocomplete?: string = 'off';
@Input() showErrors = false;
@Output() valueChange = new EventEmitter<string>(); @Output() valueChange = new EventEmitter<string>();
onInput(ev: Event) { onInput(ev: Event) {
@@ -17,14 +17,14 @@ import { SkeletonModule } from 'primeng/skeleton';
import { TableModule } from 'primeng/table'; import { TableModule } from 'primeng/table';
import { TableActionRowComponent } from '../table-action-row.component'; import { TableActionRowComponent } from '../table-action-row.component';
export interface IColumn { export interface IColumn<T = any> {
field: string; field: T extends object ? keyof T | string : string;
header: string; header: string;
width?: string; width?: string;
minWidth?: string; minWidth?: string;
canCopy?: boolean; canCopy?: boolean;
type?: 'text' | 'price' | 'boolean' | 'date'; type?: 'text' | 'price' | 'boolean' | 'date';
customDataModel?: TemplateRef<any> | ((item: any) => string | number | boolean); customDataModel?: TemplateRef<any> | ((item: T) => string | number | boolean);
} }
@Component({ @Component({
@@ -120,7 +120,7 @@ export class PageDataListComponent<I> {
if (column.customDataModel) { if (column.customDataModel) {
return this.renderCustom(column, item); return this.renderCustom(column, item);
} }
const data = item[field]; const data = item[String(field)];
switch (column.type) { switch (column.type) {
case 'date': case 'date':
if (!data) return '-'; if (!data) return '-';
@@ -20,6 +20,6 @@
/> />
} }
@if (showDetails) { @if (showDetails) {
<p-button size="small" icon="pi pi-chevron-left" variant="outlined" (click)="details.emit()" title="جزئیات" /> <p-button size="small" icon="pi pi-chevron-left" (click)="details.emit()" title="جزئیات" />
} }
</td> </td>
+2
View File
@@ -0,0 +1,2 @@
export * from './price-alphabet.directive';
export * from './price-mask.directive';
@@ -0,0 +1,42 @@
import { getPriceInPersianAlphabet } from '@/utils';
import { Directive, ElementRef, Input, OnChanges, SimpleChanges } from '@angular/core';
@Directive({
selector: '[appPriceAlphabet]',
standalone: true,
})
export class PriceAlphabetDirective implements OnChanges {
/** Numeric price to render as Persian text */
@Input('appPriceAlphabet') price: number | string | null = null;
constructor(private el: ElementRef<HTMLElement>) {}
ngOnChanges(changes: SimpleChanges): void {
if (changes['price']) {
this.render();
}
}
private render() {
const value = this.normalizeToNumber(this.price);
if (value === null) {
this.el.nativeElement.textContent = '';
return;
}
this.el.nativeElement.textContent = getPriceInPersianAlphabet(value);
}
/**
* Normalize various input shapes to a number; returns null when not parsable
*/
private normalizeToNumber(raw: number | string | null): number | null {
if (raw === null || raw === undefined) return null;
if (typeof raw === 'number') {
return isFinite(raw) ? raw : null;
}
// remove common separators and whitespace before parsing
const cleaned = raw.replace(/[,\s]/g, '');
const num = Number(cleaned);
return isNaN(num) ? null : num;
}
}
@@ -3,10 +3,12 @@ import {
ElementRef, ElementRef,
HostListener, HostListener,
Input, Input,
OnChanges,
OnInit, OnInit,
Optional, Optional,
Renderer2, Renderer2,
Self, Self,
SimpleChanges,
} from '@angular/core'; } from '@angular/core';
import { NgControl } from '@angular/forms'; import { NgControl } from '@angular/forms';
@@ -14,7 +16,9 @@ import { NgControl } from '@angular/forms';
selector: '[appPriceMask]', selector: '[appPriceMask]',
standalone: true, standalone: true,
}) })
export class PriceMaskDirective implements OnInit { export class PriceMaskDirective implements OnInit, OnChanges {
/** Optional value to render/format when bound like [appPriceMask]="price" */
@Input('appPriceMask') priceValue: number | string | null | undefined;
@Input('appPriceMaskLocale') locale: string = 'fa-IR'; @Input('appPriceMaskLocale') locale: string = 'fa-IR';
@Input('appPriceMaskFraction') fraction = 0; @Input('appPriceMaskFraction') fraction = 0;
/** /**
@@ -22,6 +26,8 @@ export class PriceMaskDirective implements OnInit {
* If false, uses the provided `appPriceMaskLocale`. * If false, uses the provided `appPriceMaskLocale`.
*/ */
@Input('appPriceMaskUseComma') useComma = false; @Input('appPriceMaskUseComma') useComma = false;
/** Optional currency code to append for non-input hosts (e.g., "ریال" | "تومان") */
@Input('appPriceMaskCurrency') currency: string | null = 'ریال';
private inputEl!: HTMLInputElement | null; private inputEl!: HTMLInputElement | null;
@@ -38,6 +44,17 @@ export class PriceMaskDirective implements OnInit {
host.tagName.toLowerCase() === 'input' host.tagName.toLowerCase() === 'input'
? (host as HTMLInputElement) ? (host as HTMLInputElement)
: host.querySelector('input'); : host.querySelector('input');
// If a value is bound initially (e.g., span with [appPriceMask]), render it
if (this.priceValue !== undefined) {
this.renderFromBoundValue();
}
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['priceValue'] && this.priceValue !== undefined) {
this.renderFromBoundValue();
}
} }
private toArabicDigitsAwareNumber(str: string): string { private toArabicDigitsAwareNumber(str: string): string {
@@ -74,6 +91,15 @@ export class PriceMaskDirective implements OnInit {
return ascii.replace(/[^0-9.\-]/g, ''); return ascii.replace(/[^0-9.\-]/g, '');
} }
private normalizeToNumber(raw: number | string | null | undefined): number | null {
if (raw === null || raw === undefined) return null;
if (typeof raw === 'number') return isFinite(raw) ? raw : null;
const cleaned = this.cleanNumericString(String(raw));
if (cleaned === '') return null;
const num = Number(cleaned);
return isNaN(num) ? null : num;
}
private formatNumber(value: number): string { private formatNumber(value: number): string {
try { try {
const fmtLocale = this.useComma ? 'en-US' : this.locale; const fmtLocale = this.useComma ? 'en-US' : this.locale;
@@ -87,6 +113,17 @@ export class PriceMaskDirective implements OnInit {
} }
} }
private formatWithCurrency(num: number | null, isInputHost: boolean): string {
if (num === null) return '';
const formatted = this.formatNumber(num);
// For inputs we should NOT append currency to avoid polluting numeric entry
if (isInputHost) return formatted;
if (this.currency && this.currency.trim()) {
return `${formatted} ${this.currency.trim()}`;
}
return formatted;
}
@HostListener('input', ['$event']) @HostListener('input', ['$event'])
onInput(ev: Event) { onInput(ev: Event) {
if (!this.inputEl) return; if (!this.inputEl) return;
@@ -128,4 +165,28 @@ export class PriceMaskDirective implements OnInit {
// ignore selection errors // ignore selection errors
} }
} }
/** Render when bound via [appPriceMask] on non-input hosts (e.g., span) or to set initial value. */
private renderFromBoundValue() {
const num = this.normalizeToNumber(this.priceValue);
// If host has an input element, set both control value (if any) and displayed value
if (this.inputEl) {
if (this.ngControl?.control) {
try {
this.ngControl.control.setValue(num, { emitEvent: false });
} catch (err) {
// ignore
}
}
const formatted = this.formatWithCurrency(num, true);
this.renderer.setProperty(this.inputEl, 'value', formatted);
return;
}
// Otherwise, render to host text (e.g., span/div)
const formatted = this.formatWithCurrency(num, false);
this.renderer.setProperty(this.el.nativeElement, 'textContent', formatted);
}
} }
+16 -4
View File
@@ -1,13 +1,25 @@
<div class="flex flex-col gap-1"> <ng-container [ngTemplateOutlet]="fieldTemplate" />
<uikit-label [name]="name" [invalid]="!!invalid || showErrors">
<ng-template #fieldTemplate>
@if (showLabel) {
<div class="flex flex-col gap-1">
<uikit-label [name]="name" [invalid]="!!invalid || hasError">
{{ label }} {{ label }}
</uikit-label> </uikit-label>
<ng-container [ngTemplateOutlet]="contentTemplate" />
</div>
} @else {
<ng-container [ngTemplateOutlet]="contentTemplate" />
}
</ng-template>
<ng-template #contentTemplate>
<ng-content></ng-content> <ng-content></ng-content>
@if (showErrors) { @if (showErrors && hasError) {
<ng-container> <ng-container>
<p-message severity="error" size="small" variant="simple" class="formHint"> <p-message severity="error" size="small" variant="simple" class="formHint">
{{ errors[0] }} {{ errors[0] }}
</p-message> </p-message>
</ng-container> </ng-container>
} }
</div> </ng-template>
+5 -2
View File
@@ -14,7 +14,7 @@ type PSize = 'small' | 'normal' | 'large';
templateUrl: './uikit-field.component.html', templateUrl: './uikit-field.component.html',
}) })
export class UikitFieldComponent { export class UikitFieldComponent {
@Input() label!: string; @Input() label: string = '';
@Input() name!: string; @Input() name!: string;
@Input() pSize: PSize = 'normal'; @Input() pSize: PSize = 'normal';
@Input() className?: string; @Input() className?: string;
@@ -22,9 +22,12 @@ export class UikitFieldComponent {
// accept a FormControl or AbstractControl to display errors for // accept a FormControl or AbstractControl to display errors for
@Input() control?: AbstractControl | null; @Input() control?: AbstractControl | null;
@Input() showErrors: boolean = true;
@Input() showLabel: boolean = true;
private errorsService = inject(FormErrorsService); private errorsService = inject(FormErrorsService);
get showErrors(): boolean { get hasError(): boolean {
if (!this.control) return !!this.invalid; if (!this.control) return !!this.invalid;
return !!(this.control.invalid && (this.control.touched || this.control.dirty)); return !!(this.control.invalid && (this.control.touched || this.control.dirty));
} }
+91
View File
@@ -0,0 +1,91 @@
/**
* Persian number words for currency conversion
*/
const ONES = ['', 'یک', 'دو', 'سه', 'چهار', 'پنج', 'شش', 'هفت', 'هشت', 'نه'];
const TENS = ['', '', 'بیست', 'سی', 'چهل', 'پنجاه', 'شصت', 'هفتاد', 'هشتاد', 'نود'];
const HUNDREDS = ['', 'یکصد', 'دویست', 'سیصد', 'چهارصد', 'پانصد', 'ششصد', 'هفتصد', 'هشتصد', 'نهصد'];
const SCALE = ['', 'هزار', 'میلیون', 'میلیارد', 'تریلیون', 'کوادریلیون'];
const TEENS = [
'ده',
'یازده',
'دوازده',
'سیزده',
'چهارده',
'پانزده',
'شانزده',
'هفده',
'هجده',
'نوزده',
];
/**
* Convert a three-digit number to Persian words
*/
function convertThreeDigits(num: number): string {
if (num === 0) return '';
const hundred = Math.floor(num / 100);
const remainder = num % 100;
const ten = Math.floor(remainder / 10);
const one = remainder % 10;
let result = '';
if (hundred > 0) {
result += HUNDREDS[hundred];
}
if (remainder >= 10 && remainder <= 19) {
if (result) result += ' و ';
result += TEENS[remainder - 10];
} else {
if (ten > 0) {
if (result) result += ' و ';
result += TENS[ten];
}
if (one > 0) {
if (result) result += ' و ';
result += ONES[one];
}
}
return result;
}
/**
* Convert a number to Persian words for Rial currency
* @param price - The price as a number
* @returns Persian text representation of the price in Rials
* @example
* getPriceInPersianAlphabet(1500) // "هزار و پانصد ریال"
* getPriceInPersianAlphabet(1000000) // "یک میلیون ریال"
*/
export function getPriceInPersianAlphabet(price: number): string {
if (price === 0) return 'صفر ریال';
if (price < 0) return 'منفی ' + getPriceInPersianAlphabet(-price);
const parts: string[] = [];
let scaleIndex = 0;
while (price > 0 && scaleIndex < SCALE.length) {
const threeDigits = price % 1000;
if (threeDigits > 0) {
const threeDigitWords = convertThreeDigits(threeDigits);
if (SCALE[scaleIndex]) {
parts.unshift(threeDigitWords + ' ' + SCALE[scaleIndex]);
} else {
parts.unshift(threeDigitWords);
}
}
price = Math.floor(price / 1000);
scaleIndex++;
}
return parts.join(' و ') + ' ریال';
}
+2
View File
@@ -1 +1,3 @@
export * from './currency';
export * from './name';
export * from './time'; export * from './time';
+28
View File
@@ -0,0 +1,28 @@
export interface NameParts {
gender?: string;
firstName?: string;
lastName?: string;
}
/**
* Get full name from name parts
* @param nameParts - Object containing optional gender, firstName, and lastName
* @returns Full name string with parts separated by space
*/
export function getFullName(nameParts: NameParts): string {
const parts: string[] = [];
if (nameParts.gender) {
parts.push(nameParts.gender);
}
if (nameParts.firstName) {
parts.push(nameParts.firstName);
}
if (nameParts.lastName) {
parts.push(nameParts.lastName);
}
return parts.join(' ').trim();
}