feat: Implement price info card component and product categories with grid view

- Added `price-info-card.component` to display total amount, tax, and payable amount.
- Integrated price masking directive for formatted price display.
- Created `categories.component` to list product categories with loading skeletons.
- Implemented `grid-view.component` for displaying products in a grid layout.
- Enhanced `products.component` to manage category selection and product display.
- Introduced new models for product categories and stock responses.
- Updated `main.service.ts` to fetch product categories and stock data.
- Refactored `POSStore` to manage state for products, categories, and order items.
- Added utility functions for price formatting and number normalization.
- Included placeholder images for product and logo.
This commit is contained in:
2025-12-14 20:34:15 +03:30
parent 35be7e0298
commit 17fa65407c
43 changed files with 894 additions and 132 deletions
@@ -1,10 +1,10 @@
<p-card class="w-full border border-primary-700">
<div class="flex gap-5 text-primary-400">
<div class="flex gap-3 text-primary-400">
<div class="shrink-0 pt-1">
<i [class]="'pi ' + icon + ' text-2xl'"></i>
</div>
<div class="grow flex flex-col gap-1">
<span class="text-sm">{{ label }}</span>
<span class="text-sm font-bold">{{ label }}</span>
<span class="text-xl font-bold text-primary">{{ value }}</span>
</div>
</div>
@@ -1,5 +1,5 @@
import { PaginatorComponent, UikitCopyComponent, UikitEmptyStateComponent } from '@/uikit';
import { formatJalali } from '@/utils';
import { formatJalali, formatWithCurrency } from '@/utils';
import { CommonModule } from '@angular/common';
import {
Component,
@@ -129,7 +129,7 @@ export class PageDataListComponent<I> {
case 'boolean':
return data ? 'بله' : 'خیر';
case 'price':
return typeof data === 'number' ? data.toLocaleString() : '-';
return formatWithCurrency(data, false, 'ریال');
default:
break;
}
@@ -11,6 +11,12 @@ import {
SimpleChanges,
} from '@angular/core';
import { NgControl } from '@angular/forms';
import {
cleanNumericString,
formatNumber,
formatWithCurrency,
normalizeToNumber,
} from '../../utils/price-mask.utils';
@Directive({
selector: '[appPriceMask]',
@@ -57,78 +63,13 @@ export class PriceMaskDirective implements OnInit, OnChanges {
}
}
private toArabicDigitsAwareNumber(str: string): string {
if (!str) return '';
// Map Arabic-Indic and Extended Arabic-Indic digits to ASCII digits
const map: Record<string, string> = {
'٠': '0',
'١': '1',
'٢': '2',
'٣': '3',
'٤': '4',
'٥': '5',
'٦': '6',
'٧': '7',
'٨': '8',
'٩': '9',
'۰': '0',
'۱': '1',
'۲': '2',
'۳': '3',
'۴': '4',
'۵': '5',
'۶': '6',
'۷': '7',
'۸': '8',
'۹': '9',
};
return str.replace(/[٠-٩۰-۹]/g, (d) => map[d] ?? d);
}
private cleanNumericString(raw: string): string {
// Convert localized digits to ASCII, then remove everything except digits, dot and minus
const ascii = this.toArabicDigitsAwareNumber(raw);
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;
return new Intl.NumberFormat(fmtLocale, {
maximumFractionDigits: this.fraction,
minimumFractionDigits: 0,
useGrouping: true,
}).format(value);
} catch (e) {
return String(value);
}
}
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;
}
// Numeric parsing/formatting helpers moved to utils/price-mask.utils.ts
@HostListener('input', ['$event'])
onInput(ev: Event) {
if (!this.inputEl) return;
const raw = (ev.target as HTMLInputElement).value || this.inputEl.value || '';
const cleaned = this.cleanNumericString(raw);
const cleaned = cleanNumericString(raw);
if (cleaned === '') {
// clear control
if (this.ngControl?.control) this.ngControl.control.setValue(null);
@@ -137,7 +78,11 @@ export class PriceMaskDirective implements OnInit, OnChanges {
const asNumber = Number(cleaned);
if (isNaN(asNumber)) return;
const formatted = this.formatNumber(asNumber);
const formatted = formatNumber(asNumber, {
locale: this.locale,
useComma: this.useComma,
fraction: this.fraction,
});
// preserve caret position roughly: compute delta and adjust
const prevPos = this.inputEl.selectionStart ?? raw.length;
@@ -168,7 +113,7 @@ export class PriceMaskDirective implements OnInit, OnChanges {
/** 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);
const num = normalizeToNumber(this.priceValue);
// If host has an input element, set both control value (if any) and displayed value
if (this.inputEl) {
@@ -180,13 +125,21 @@ export class PriceMaskDirective implements OnInit, OnChanges {
}
}
const formatted = this.formatWithCurrency(num, true);
const formatted = formatWithCurrency(num, true, this.currency, {
locale: this.locale,
useComma: this.useComma,
fraction: this.fraction,
});
this.renderer.setProperty(this.inputEl, 'value', formatted);
return;
}
// Otherwise, render to host text (e.g., span/div)
const formatted = this.formatWithCurrency(num, false);
const formatted = formatWithCurrency(num, false, this.currency, {
locale: this.locale,
useComma: this.useComma,
fraction: this.fraction,
});
this.renderer.setProperty(this.el.nativeElement, 'textContent', formatted);
}
}