feat: enhance product and purchase components

- Added new components for handling purchase receipts and payment wrappers.
- Updated product details view to include sales count and stock alerts.
- Refactored invoice payment form to use a template for better structure.
- Introduced confirmation dialog service for payment confirmations.
- Improved state card component for better visual representation of orders.
- Added loading indicators and error handling in various components.
- Updated routes to include new purchase functionality for suppliers.
- Enhanced stock alert component to visually indicate low stock levels.
This commit is contained in:
2025-12-30 21:03:39 +03:30
parent 85a9c8714d
commit 83c3d57866
48 changed files with 797 additions and 259 deletions
@@ -1,19 +1,21 @@
import { ToastService } from '@/core/services/toast.service';
import { Component, effect, EventEmitter, inject, Input, Output, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { Subscribable } from 'rxjs';
import { ReactiveFormsModule } from '@angular/forms';
import { AbstractForm } from './abstract-form';
@Component({
selector: 'abstract-form-dialog',
template: '',
imports: [ReactiveFormsModule],
})
export abstract class AbstractFormDialog<Response, Request> {
@Input() initialValues?: Response;
export abstract class AbstractFormDialog<Response, Request> extends AbstractForm<
Response,
Request
> {
@Input()
set visible(v: boolean) {
this.visibleSignal.set(!!v);
this.visibleChange.emit(!!v);
}
get visible() {
return this.visibleSignal();
@@ -21,58 +23,19 @@ export abstract class AbstractFormDialog<Response, Request> {
private visibleSignal = signal(false);
@Output() visibleChange = new EventEmitter<boolean>();
@Output() onSubmit = new EventEmitter<Response>();
protected readonly fb = inject(FormBuilder);
constructor(private toastService: ToastService) {
constructor() {
super(inject(ToastService));
effect(() => {
const v = this.visibleSignal();
if (!v) this.form.reset(this.defaultValues);
});
this.submit.bind(() => {
this.close();
});
}
abstract form: ReturnType<FormBuilder['group']>;
defaultValues: Partial<Request> = {};
abstract submitForm(payload: Request): Subscribable<Response> | void;
submitLoading = signal(false);
submit() {
console.log('first');
this.form.markAllAsTouched();
if (this.form.valid) {
console.log('isValid');
console.log(this.submitForm);
this.form.disable();
this.submitLoading.set(true);
this.submitForm(this.form.value as Request)?.subscribe({
next: (res) => {
this.toastService.success({
text: `با موفقیت ایجاد شد`,
});
this.close();
this.submitLoading.set(false);
this.form.enable();
this.form.reset();
this.onSubmit.emit(res);
},
error: () => {
this.submitLoading.set(false);
this.form.enable();
},
complete: () => {
this.submitLoading.set(false);
this.form.enable();
console.log('end');
},
});
}
}
close() {
this.visibleChange.emit(false);
override close() {
this.visibleSignal.set(false);
}
}
@@ -0,0 +1,57 @@
import { ToastService } from '@/core/services/toast.service';
import { Component, EventEmitter, inject, Input, Output, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { Subscribable } from 'rxjs';
@Component({
selector: 'abstract-form-dialog',
template: '',
imports: [ReactiveFormsModule],
})
export abstract class AbstractForm<Response, Request> {
@Input() initialValues?: Response;
@Output() onSubmit = new EventEmitter<Response>();
@Output() onClose = new EventEmitter<void>();
protected readonly fb = inject(FormBuilder);
constructor(private toastService: ToastService) {}
abstract form: ReturnType<FormBuilder['group']>;
defaultValues: Partial<Request> = {};
abstract submitForm(payload: Request): Subscribable<Response> | void;
submitLoading = signal(false);
submit() {
this.form.markAllAsTouched();
if (this.form.valid) {
this.form.disable();
this.submitLoading.set(true);
this.submitForm(this.form.value as Request)?.subscribe({
next: (res) => {
this.toastService.success({
text: `با موفقیت ایجاد شد`,
});
this.submitLoading.set(false);
this.form.enable();
this.form.reset();
this.onSubmit.emit(res);
},
error: () => {
this.submitLoading.set(false);
this.form.enable();
},
complete: () => {
this.submitLoading.set(false);
this.form.enable();
},
});
}
}
close() {
this.onClose.emit();
}
}
@@ -0,0 +1,19 @@
<p-confirmdialog #cd>
<ng-template #headless let-message let-onAccept="onAccept" let-onReject="onReject">
@if (message) {
<div class="flex flex-col items-center p-8 bg-surface-0 dark:bg-surface-900 rounded">
<div
class="rounded-full bg-primary text-primary-contrast inline-flex justify-center items-center h-24 w-24 -mt-20"
>
<i class="pi pi-question text-5xl!"></i>
</div>
<span class="font-bold text-2xl block mb-2 mt-6">{{ message.header }}</span>
<p class="mb-0">{{ message.message }}</p>
<div class="flex items-center gap-2 mt-6">
<p-button label="{{ acceptLabel }}" (onClick)="onAccept()" styleClass="w-32"></p-button>
<p-button label="{{ rejectLabel }}" [outlined]="true" (onClick)="onReject()" styleClass="w-32"></p-button>
</div>
</div>
}
</ng-template>
</p-confirmdialog>
@@ -0,0 +1,48 @@
import { Component, EventEmitter, Input, OnDestroy, Output } from '@angular/core';
import { ConfirmationService } from 'primeng/api';
import { Button } from 'primeng/button';
import { ConfirmDialog } from 'primeng/confirmdialog';
@Component({
selector: 'app-shared-confirmation-dialog',
templateUrl: './confirmation-dialog.component.html',
providers: [ConfirmationService],
imports: [ConfirmDialog, Button],
})
export class ConfirmationDialogComponent implements OnDestroy {
@Input() message!: string;
@Input() header: string = 'تأیید عملیات';
@Input() acceptLabel: string = 'بله';
@Input() rejectLabel: string = 'خیر';
@Output() onAccept = new EventEmitter<void>();
@Output() onReject = new EventEmitter<void>();
constructor(private confirmationService: ConfirmationService) {}
show() {
this.confirmationService.confirm({
header: this.header,
message: this.message,
acceptLabel: this.acceptLabel,
rejectLabel: this.rejectLabel,
accept: () => {
this.accept();
},
reject: () => {
this.reject();
},
});
}
accept() {
this.onAccept.emit();
}
reject() {
this.onReject.emit();
}
ngOnDestroy() {
// Cleanup if needed
}
}
@@ -0,0 +1,78 @@
import {
ApplicationRef,
ComponentFactoryResolver,
ComponentRef,
Injectable,
Injector,
} from '@angular/core';
import { ConfirmationDialogComponent } from './confirmation-dialog.component';
@Injectable({
providedIn: 'root',
})
export class ConfirmationDialogService {
private componentRef: ComponentRef<ConfirmationDialogComponent> | null = null;
constructor(
private componentFactoryResolver: ComponentFactoryResolver,
private appRef: ApplicationRef,
private injector: Injector,
) {}
confirm(options: {
message: string;
header?: string;
acceptLabel?: string;
rejectLabel?: string;
accept?: () => void;
reject?: () => void;
}) {
// Create the component dynamically
const factory = this.componentFactoryResolver.resolveComponentFactory(
ConfirmationDialogComponent,
);
this.componentRef = factory.create(this.injector);
// Set inputs
this.componentRef.instance.message = options.message;
if (options.header) this.componentRef.instance.header = options.header;
if (options.acceptLabel) this.componentRef.instance.acceptLabel = options.acceptLabel;
if (options.rejectLabel) this.componentRef.instance.rejectLabel = options.rejectLabel;
// Subscribe to outputs and close after
if (options.accept) {
this.componentRef.instance.onAccept.subscribe(() => {
options.accept!();
this.close();
});
} else {
this.componentRef.instance.onAccept.subscribe(() => this.close());
}
if (options.reject) {
this.componentRef.instance.onReject.subscribe(() => {
options.reject!();
this.close();
});
} else {
this.componentRef.instance.onReject.subscribe(() => this.close());
}
// Attach to the app
this.appRef.attachView(this.componentRef.hostView);
// Append to body
document.body.appendChild(this.componentRef.location.nativeElement);
// Trigger change detection and show
this.componentRef.changeDetectorRef.detectChanges();
this.componentRef.instance.show();
}
close() {
if (this.componentRef) {
this.appRef.detachView(this.componentRef.hostView);
this.componentRef.destroy();
this.componentRef = null;
}
}
}
@@ -5,7 +5,9 @@
</div>
<div class="grow flex flex-col gap-1">
<span class="text-sm font-bold">{{ label }}</span>
<span class="text-xl font-bold text-primary">{{ value }}</span>
<ng-content>
<span class="text-xl font-bold text-primary">{{ value }}</span>
</ng-content>
</div>
</div>
</p-card>
@@ -1,4 +1,5 @@
import { Component, Input } from '@angular/core';
import { Maybe } from '@/core';
import { Component, ElementRef, Input, ViewChild } from '@angular/core';
import { Card } from 'primeng/card';
@Component({
@@ -10,5 +11,7 @@ export class DetailsInfoCardComponent {
@Input() icon: string = '';
@Input() label: string = '';
@Input() value: string = '';
@ViewChild('valueElement') valueElement: Maybe<ElementRef> = null;
constructor() {}
}
@@ -0,0 +1,9 @@
@if (showPaymentForm()) {
<app-supplier-invoice-pay-form
[(visible)]="showPaymentForm"
[debtAmount]="purchaseReceipt.totalAmount"
[supplierId]="purchaseReceipt.supplierId.toString()"
[inventoryId]="purchaseReceipt.inventoryId.toString()"
[invoiceId]="purchaseReceipt.id.toString()"
/>
}
@@ -0,0 +1,48 @@
import { ToastService } from '@/core/services/toast.service';
import { SupplierInvoicePayFormComponent } from '@/modules/suppliers/components/invoices/pay-form.component';
import { Component, Input, signal } from '@angular/core';
import { IPurchaseReceiptResponse } from '../../../modules/purchases/models';
import { ConfirmationDialogService } from '../confirmationDialog/confirmation-dialog.service';
@Component({
selector: 'app-purchase-receipt-payment-wrapper',
templateUrl: './wrapper.component.html',
imports: [SupplierInvoicePayFormComponent],
})
export class PurchaseReceiptPaymentWrapperComponent {
@Input() purchaseReceipt!: IPurchaseReceiptResponse;
showPaymentForm = signal(false);
constructor(
private confirmService: ConfirmationDialogService,
private toastService: ToastService,
) {
setTimeout(() => {
this.showConfirm();
}, 1);
}
showConfirm() {
this.confirmService.confirm({
message: 'آیا پرداخت فاکتور انجام شده است؟',
header: 'وضعیت پرداخت فاکتور',
acceptLabel: 'بله',
rejectLabel: 'خیر',
accept: () => {
this.toPaymentForm();
},
reject: () => {
this.toastService.info({
text: 'می‌توانید از طریق منوی پرداخت فاکتور، در آینده اقدام به پرداخت نمایید.',
title: 'پرداخت فاکتور انجام نشد',
});
},
});
}
submit() {}
toPaymentForm() {
this.showPaymentForm.set(true);
}
}
@@ -0,0 +1,7 @@
<span [class]="'text-xl font-bold ' + (stock! <= minimumStockAlertLevel! || !stock ? 'text-error' : 'text-primary')">
{{ stock || 0 }}
@if (stock! <= minimumStockAlertLevel! || !stock) {
<i class="pi pi-exclamation-triangle ml-2" pTooltip="موجودی کمتر از حداقل سطح هشدار است"></i>
}
</span>
@@ -0,0 +1,13 @@
import { Component, Input } from '@angular/core';
import { Tooltip } from 'primeng/tooltip';
@Component({
selector: 'app-shared-simple-stock-text-alert',
templateUrl: './simple-stock-text-alert.component.html',
imports: [Tooltip],
})
export class SimpleStockTextAlertComponent {
@Input() stock: number = 0;
@Input() minimumStockAlertLevel: number = 0;
constructor() {}
}
@@ -0,0 +1,16 @@
<div class="card mb-0">
<div class="flex justify-between mb-4">
<div>
<span class="block text-muted-color font-medium mb-4">Orders</span>
<div class="text-surface-900 dark:text-surface-0 font-medium text-xl">152</div>
</div>
<div
class="flex items-center justify-center bg-blue-100 dark:bg-blue-400/10 rounded-border"
style="width: 2.5rem; height: 2.5rem"
>
<i class="pi pi-shopping-cart text-blue-500 text-xl!"></i>
</div>
</div>
<span class="text-primary font-medium">24 new </span>
<span class="text-muted-color">since last visit</span>
</div>
@@ -0,0 +1,10 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
@Component({
standalone: true,
selector: 'app-shared-stat-widget',
imports: [CommonModule],
templateUrl: './stat-card.component.html',
})
export class StatWidget {}