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:
@@ -1,6 +1,7 @@
|
||||
export * from './breadcrumb.component';
|
||||
export * from './card-data.component';
|
||||
export * from './inlineConfirmation/inline-confirmation.component';
|
||||
export * from './inlineEdit/inline-edit.component';
|
||||
export * from './input/input.component';
|
||||
export * from './key-value.component/key-value.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") {
|
||||
<p-toggleSwitch [formControl]="control" />
|
||||
} @else {
|
||||
|
||||
@@ -23,6 +23,7 @@ export class InputComponent {
|
||||
@Input() disabled = false;
|
||||
@Input() size?: 'small' | 'large';
|
||||
@Input() autocomplete?: string = 'off';
|
||||
@Input() showErrors = false;
|
||||
@Output() valueChange = new EventEmitter<string>();
|
||||
|
||||
onInput(ev: Event) {
|
||||
|
||||
@@ -17,14 +17,14 @@ import { SkeletonModule } from 'primeng/skeleton';
|
||||
import { TableModule } from 'primeng/table';
|
||||
import { TableActionRowComponent } from '../table-action-row.component';
|
||||
|
||||
export interface IColumn {
|
||||
field: string;
|
||||
export interface IColumn<T = any> {
|
||||
field: T extends object ? keyof T | string : string;
|
||||
header: string;
|
||||
width?: string;
|
||||
minWidth?: string;
|
||||
canCopy?: boolean;
|
||||
type?: 'text' | 'price' | 'boolean' | 'date';
|
||||
customDataModel?: TemplateRef<any> | ((item: any) => string | number | boolean);
|
||||
customDataModel?: TemplateRef<any> | ((item: T) => string | number | boolean);
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -120,7 +120,7 @@ export class PageDataListComponent<I> {
|
||||
if (column.customDataModel) {
|
||||
return this.renderCustom(column, item);
|
||||
}
|
||||
const data = item[field];
|
||||
const data = item[String(field)];
|
||||
switch (column.type) {
|
||||
case 'date':
|
||||
if (!data) return '-';
|
||||
|
||||
@@ -20,6 +20,6 @@
|
||||
/>
|
||||
}
|
||||
@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>
|
||||
|
||||
@@ -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,
|
||||
HostListener,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnInit,
|
||||
Optional,
|
||||
Renderer2,
|
||||
Self,
|
||||
SimpleChanges,
|
||||
} from '@angular/core';
|
||||
import { NgControl } from '@angular/forms';
|
||||
|
||||
@@ -14,7 +16,9 @@ import { NgControl } from '@angular/forms';
|
||||
selector: '[appPriceMask]',
|
||||
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('appPriceMaskFraction') fraction = 0;
|
||||
/**
|
||||
@@ -22,6 +26,8 @@ export class PriceMaskDirective implements OnInit {
|
||||
* If false, uses the provided `appPriceMaskLocale`.
|
||||
*/
|
||||
@Input('appPriceMaskUseComma') useComma = false;
|
||||
/** Optional currency code to append for non-input hosts (e.g., "ریال" | "تومان") */
|
||||
@Input('appPriceMaskCurrency') currency: string | null = 'ریال';
|
||||
|
||||
private inputEl!: HTMLInputElement | null;
|
||||
|
||||
@@ -38,6 +44,17 @@ export class PriceMaskDirective implements OnInit {
|
||||
host.tagName.toLowerCase() === 'input'
|
||||
? (host as HTMLInputElement)
|
||||
: 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 {
|
||||
@@ -74,6 +91,15 @@ export class PriceMaskDirective implements OnInit {
|
||||
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 {
|
||||
try {
|
||||
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'])
|
||||
onInput(ev: Event) {
|
||||
if (!this.inputEl) return;
|
||||
@@ -128,4 +165,28 @@ export class PriceMaskDirective implements OnInit {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user