update saleinvoice single and create return from sale form, update agent.md file,

This commit is contained in:
2026-06-01 13:53:42 +03:30
parent c271a36f7e
commit d44004d555
25 changed files with 562 additions and 199 deletions
+78 -19
View File
@@ -13,25 +13,6 @@ Stack:
--- ---
# EXECUTION RULES
- Keep responses short.
- Do not narrate thoughts.
- Do not explain obvious steps.
- Do not create plans for simple tasks.
- Prefer implementation over exploration.
Avoid:
- “I think…”
- “Let me check…”
- “I should inspect…”
- “Im going to…”
Prefer:
- “Updated environment files.”
- “Applied tenant config fix.”
---
# RTK RULES (MANDATORY) # RTK RULES (MANDATORY)
@@ -192,3 +173,81 @@ Updated:
Validation: Validation:
- tsc passed - tsc passed
# TARGETED READ RULES
Do not read files larger than 300 lines unless required.
For changes at a known location:
- read the surrounding symbol only
- avoid full-file reads
Prefer:
- rtk grep
- rtk smart
- targeted read
Avoid:
- opening 400+ line files for small edits
# REASONING OUTPUT RULES
Do not expose internal reasoning.
Never output:
- "I think..."
- "I'm considering..."
- "I wonder..."
- "Maybe..."
- implementation deliberation
Never explain alternative approaches unless requested.
Use:
Inspecting confirmation dialog.
Updating async accept support.
Validation passed.
# INVESTIGATION LIMITS
For localized fixes:
- maximum 3 file reads before first edit
- maximum 1 related-file read unless required
Stop searching once the edit target is identified.
# REVIEW MODE
During reviews:
- inspect changed files only
- avoid loading unrelated dependencies
- avoid architecture exploration
- avoid repository-wide searches
Review only code directly affected by the diff.
# HARD TOKEN LIMITS
For localized fixes:
- Never read files larger than 300 lines unless the target symbol cannot be isolated.
- Never read an entire file when a symbol-level read is possible.
- Never read more than 5 total files before the first edit.
- Never open a file already summarized by `rtk smart` unless implementation details are required.
When a file exceeds 300 lines:
1. rtk grep
2. rtk smart
3. read only the relevant symbol/section
Avoid full-file reads.
@@ -42,13 +42,7 @@
<div class="sticky bottom-0 flex flex-col gap-4 py-2"> <div class="sticky bottom-0 flex flex-col gap-4 py-2">
<p-card class="border-surface-border border"> <p-card class="border-surface-border border">
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<app-datepicker <field-invoice-date [control]="invoiceDate" alignment="horizontal" (onChangeDate)="changeDate($event)" />
label="تاریخ صورت‌حساب‌"
alignment="horizontal"
[min]="invoiceMinDate"
[max]="invoiceMaxDate"
[control]="invoiceDate"
(valueChange)="changeDate($event)" />
<div class="flex shrink-0 items-center justify-between gap-2"> <div class="flex shrink-0 items-center justify-between gap-2">
<app-key-value label="مشتری" [value]="customerNameToShow()" /> <app-key-value label="مشتری" [value]="customerNameToShow()" />
<button pButton outlined icon="pi pi-pencil" size="small" (click)="openCustomerDialog()"></button> <button pButton outlined icon="pi pi-pencil" size="small" (click)="openCustomerDialog()"></button>
@@ -1,8 +1,7 @@
// import { CustomersSelectComponent } from '@/modules/customers/components/select/select.component'; // import { CustomersSelectComponent } from '@/modules/customers/components/select/select.component';
// import { ICustomerResponse } from '@/modules/customers/models'; // import { ICustomerResponse } from '@/modules/customers/models';
import { KeyValueComponent } from '@/shared/components'; import { KeyValueComponent } from '@/shared/components';
import { MiniMonthlyCalendarComponent } from '@/uikit'; import { FieldInvoiceDateComponent } from '@/shared/components/fields/invoice_date.component';
import { formatGregorian, gregorianAddUnit } from '@/utils';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@@ -35,7 +34,8 @@ import { POSOrderPriceInfoCardComponent } from './price-info-card.component';
PosOrderCustomerDialogComponent, PosOrderCustomerDialogComponent,
KeyValueComponent, KeyValueComponent,
Card, Card,
MiniMonthlyCalendarComponent,
FieldInvoiceDateComponent,
], ],
}) })
export class PosOrderSectionComponent { export class PosOrderSectionComponent {
@@ -46,8 +46,6 @@ export class PosOrderSectionComponent {
placeholderImage = images.placeholders.default; placeholderImage = images.placeholders.default;
isVisibleCustomerForm = signal(false); isVisibleCustomerForm = signal(false);
invoiceMinDate = formatGregorian(gregorianAddUnit(new Date(), -7, 'days'));
invoiceMaxDate = formatGregorian(new Date());
invoiceDate = new FormControl(this.store.invoiceDate()); invoiceDate = new FormControl(this.store.invoiceDate());
@@ -1,5 +1,13 @@
import { ToastService } from '@/core/services/toast.service'; import { ToastService } from '@/core/services/toast.service';
import { Component, EventEmitter, inject, Input, Output, signal } from '@angular/core'; import {
Component,
EventEmitter,
inject,
Input,
Output,
signal,
SimpleChanges,
} from '@angular/core';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { finalize, Observable } from 'rxjs'; import { finalize, Observable } from 'rxjs';
@@ -43,7 +51,7 @@ export abstract class AbstractForm<
// }); // });
} }
ngOnChanges() { ngOnChanges(changes: SimpleChanges) {
this.form.patchValue(this.initialValues ?? this.defaultValues); this.form.patchValue(this.initialValues ?? this.defaultValues);
} }
@@ -1,7 +1,7 @@
<p-confirmdialog #cd> <p-confirmdialog #cd>
<ng-template #headless let-message let-onAccept="onAccept" let-onReject="onReject"> <ng-template #headless let-message let-onAccept="onAccept" let-onReject="onReject">
@if (message) { @if (message) {
<div class="bg-surface-0 dark:bg-surface-900 flex flex-col items-center rounded p-8"> <div class="bg-surface-card flex flex-col items-center rounded p-8">
<div <div
class="bg-primary text-primary-contrast -mt-20 inline-flex h-24 w-24 items-center justify-center rounded-full"> class="bg-primary text-primary-contrast -mt-20 inline-flex h-24 w-24 items-center justify-center rounded-full">
<i class="pi pi-question text-5xl!"></i> <i class="pi pi-question text-5xl!"></i>
@@ -9,11 +9,19 @@
<span class="mt-6 mb-2 block text-2xl font-bold">{{ message.header }}</span> <span class="mt-6 mb-2 block text-2xl font-bold">{{ message.header }}</span>
<p class="mb-0">{{ message.message }}</p> <p class="mb-0">{{ message.message }}</p>
<div class="mt-6 flex items-center gap-2"> <div class="mt-6 flex items-center gap-2">
<p-button [label]="message.acceptLabel" type="button" (onClick)="onAccept()" styleClass="w-32"></p-button> <p-button
[label]="message.acceptLabel"
type="button"
[loading]="!!message.acceptLoading"
[disabled]="!!message.acceptLoading"
[severity]="message.acceptButtonProps?.severity"
(onClick)="onAccept()"
styleClass="w-32"></p-button>
<p-button <p-button
[label]="message.rejectLabel" [label]="message.rejectLabel"
type="button" type="button"
[outlined]="true" [outlined]="true"
[disabled]="!!message.acceptLoading"
(onClick)="onReject()" (onClick)="onReject()"
styleClass="w-32"></p-button> styleClass="w-32"></p-button>
</div> </div>
@@ -1,13 +1,9 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { Confirmation, ConfirmationService } from 'primeng/api'; import { Confirmation, ConfirmationService } from 'primeng/api';
export interface AppConfirmOptions { export interface AppConfirmOptions extends Confirmation {
header: string; variant?: 'warn' | 'danger' | 'default';
message: string; acceptLoading?: boolean;
acceptLabel?: string;
rejectLabel?: string;
accept?: () => void;
reject?: () => void;
} }
@Injectable({ @Injectable({
@@ -16,16 +12,38 @@ export interface AppConfirmOptions {
export class AppConfirmationService { export class AppConfirmationService {
private confirmationService = inject(ConfirmationService); private confirmationService = inject(ConfirmationService);
ask(options: Confirmation): Promise<boolean> { ask(options: AppConfirmOptions): Promise<boolean> {
const { variant = 'default', ...confirmationOptions } = options;
switch (variant) {
case 'danger':
options.acceptButtonProps.severity = 'danger';
break;
case 'warn':
options.acceptButtonProps.severity = 'warn';
break;
}
return new Promise((resolve) => { return new Promise((resolve) => {
this.confirmationService.confirm({ this.confirmationService.confirm({
position: 'bottom', position: 'bottom',
acceptLabel: 'تایید', acceptLabel: 'تایید',
rejectLabel: 'لغو', rejectLabel: 'لغو',
...options,
closeOnEscape: true, closeOnEscape: true,
accept: () => options.accept?.() || resolve(true), ...confirmationOptions,
reject: () => resolve(false), accept: async () => {
try {
options.acceptLoading = true;
await confirmationOptions.accept?.();
resolve(true);
} finally {
options.acceptLoading = false;
}
},
reject: () => {
if (options.acceptLoading) return;
resolve(false);
},
}); });
}); });
} }
@@ -0,0 +1,31 @@
import { DatepickerComponent } from '@/uikit';
import { formatGregorian, gregorianAddUnit } from '@/utils';
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { FormControl } from '@angular/forms';
@Component({
selector: 'field-invoice-date',
template: `
<app-datepicker
label="تاریخ صورت‌حساب‌"
[alignment]="alignment"
[min]="invoiceMinDate"
[max]="invoiceMaxDate"
[control]="control"
(valueChange)="changeDate($event)"
/>
`,
imports: [DatepickerComponent],
})
export class FieldInvoiceDateComponent {
@Input() alignment: 'horizontal' | 'vertical' = 'vertical';
@Input({ required: true }) control!: FormControl<string | null>;
@Output() onChangeDate = new EventEmitter<string>();
invoiceMinDate = formatGregorian(gregorianAddUnit(new Date(), -7, 'days'));
invoiceMaxDate = formatGregorian(new Date());
changeDate(date: string) {
this.onChangeDate.emit(date);
}
}
@@ -62,7 +62,9 @@
@if (suffixTemp) { @if (suffixTemp) {
<ng-container [ngTemplateOutlet]="suffixTemp" /> <ng-container [ngTemplateOutlet]="suffixTemp" />
} @else if (preparedSuffix()) { } @else if (preparedSuffix()) {
<p-inputgroup-addon>{{ preparedSuffix() }}</p-inputgroup-addon> <p-inputgroup-addon [ngClass]="{ 'border-red-400!': (control.touched || control.dirty) && control.invalid }">{{
preparedSuffix()
}}</p-inputgroup-addon>
} }
</p-inputgroup> </p-inputgroup>
} }
@@ -0,0 +1,18 @@
<form [formGroup]="form" (ngSubmit)="submit()">
<p-message severity="info" icon="pi pi-info-circle">
در برگشت از فروش صورتحساب شما میتوانید صرفا تعداد محصولات و خدمات خود را کم و حذف کنید و حداقل مقدار محصولات و خدمات
شما ۱ می باشد.
</p-message>
<field-invoice-date [control]="form.controls.invoice_date" />
@for (item of items.controls; track (goodItems()[$index]?.good_snapshot!.id || '') + ($index + '')) {
<div class="py-2">
<shared-return-form-item
[control]="item.controls.quantity"
[good]="goodItems()[$index]!.good_snapshot"
[index]="$index" />
</div>
}
<app-form-footer-actions />
</form>
@@ -0,0 +1,108 @@
import { Maybe } from '@/core';
import { AbstractForm } from '@/shared/abstractClasses';
import { formatDate, GREGORIAN_DATE_FORMATS } from '@/utils';
import { Component, computed, Input, signal, SimpleChanges } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { Message } from 'primeng/message';
import { FieldInvoiceDateComponent } from '../../fields/invoice_date.component';
import { FormFooterActionsComponent } from '../../formFooterActions/form-footer-actions.component';
import { IInvoiceItem } from '../sale-invoice-full-response.model';
import { SharedReturnFormItemComponent } from './item-form.component';
type ItemForm = FormGroup<{
quantity: FormControl<number | null>;
}>;
type BackFromSaleFormValue = {
items: {
id: string;
quantity: number;
maxQuantity: number;
}[];
};
@Component({
selector: 'shared-return-form',
templateUrl: 'form.component.html',
imports: [
Message,
FormFooterActionsComponent,
ReactiveFormsModule,
FieldInvoiceDateComponent,
SharedReturnFormItemComponent,
],
})
export class SharedReturnFormComponent extends AbstractForm<
BackFromSaleFormValue,
any,
IInvoiceItem[]
> {
@Input({ required: true }) invoiceDate!: string;
form = this.fb.group({
invoice_date: [this.invoiceDate],
items: this.fb.array<ItemForm>([], {
// validators: [atLeastOneQuantityValidator],
}),
});
goodItems = computed(() => this.initialValues!);
get items() {
return this.form.controls.items;
}
preparedCalculation = signal<Maybe<any>>(null);
init() {
this.initForm();
this.prepareCalculation();
}
prepareCalculation() {
const calculated = [];
this.initialValues?.forEach((item) => {});
}
initForm() {
this.items.clear();
this.form.controls.invoice_date.setValue(
formatDate(this.invoiceDate, 'gregory', 'en', GREGORIAN_DATE_FORMATS.FULL)
);
this.initialValues?.forEach((item: any) => {
this.items.push(
this.fb.group({
quantity: [
Number(item.quantity),
[Validators.required, Validators.min(0), Validators.max(Number(item.quantity))],
],
})
);
});
}
override ngOnInit(): void {
this.init();
}
override ngOnChanges(changes: SimpleChanges) {
if (changes['initialValues']) {
this.init();
}
}
override submitForm() {
const payload = this.items.controls.map((control) => ({
id: control.get('id')?.value,
quantity: control.get('quantity')?.value,
}));
if (!payload.some((item) => item.quantity)) {
this.toastService.warn({
text: 'حداقل مقدار یکی از کالاها/خدمات باید بیشتر از ۰ باشد.',
});
return;
}
}
}
@@ -0,0 +1,13 @@
<div>
<p-divider align="right">
{{ index + 1 }}- <b>{{ good.name }}</b>
</p-divider>
<app-input
[control]="control"
name="quantity"
[label]="good.measure_unit.name"
type="number"
[suffix]="good.measure_unit.name"
[max]="control.defaultValue || undefined"
[min]="0" />
</div>
@@ -0,0 +1,45 @@
import { Maybe } from '@/core';
import { Component, Input, signal, SimpleChanges } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { Divider } from 'primeng/divider';
import { InputComponent } from '../../input/input.component';
import { Goodsnapshot } from '../sale-invoice-full-response.model';
type ItemForm = FormGroup<{
quantity: FormControl<number | null>;
}>;
type BackFromSaleFormValue = {
items: {
id: string;
quantity: number;
maxQuantity: number;
}[];
};
@Component({
selector: 'shared-return-form-item',
templateUrl: './item-form.component.html',
imports: [InputComponent, ReactiveFormsModule, Divider],
})
export class SharedReturnFormItemComponent {
@Input({ required: true }) control!: FormControl<number | null>;
@Input({ required: true }) good!: Goodsnapshot;
@Input({ required: true }) index!: number;
preparedCalculation = signal<Maybe<any>>(null);
prepareCalculation() {
const calculated = [];
}
ngOnInit(): void {
this.prepareCalculation();
}
ngOnChanges(changes: SimpleChanges) {
if (changes['initialValues']) {
this.prepareCalculation();
}
}
}
@@ -14,7 +14,7 @@ export interface ISaleInvoiceFullRawResponse {
consumer_account: ConsumerAccount; consumer_account: ConsumerAccount;
pos: Pos; pos: Pos;
payments: Payment[]; payments: Payment[];
items: Item[]; items: IInvoiceItem[];
invoice_number: number; invoice_number: number;
type: IEnumTranslate<InvoiceTypes>; type: IEnumTranslate<InvoiceTypes>;
created_at: string; created_at: string;
@@ -30,7 +30,7 @@ export interface ISaleInvoiceFullRawResponse {
export interface ISaleInvoiceFullResponse extends ISaleInvoiceFullRawResponse {} export interface ISaleInvoiceFullResponse extends ISaleInvoiceFullRawResponse {}
interface Item { export interface IInvoiceItem {
id: string; id: string;
good_id: string; good_id: string;
service_id: null; service_id: null;
@@ -64,9 +64,7 @@ interface Category {
updated_at: string; updated_at: string;
} }
interface Goodsnapshot { export interface Goodsnapshot extends Good {}
good: Good;
}
interface Good { interface Good {
id: string; id: string;
@@ -20,6 +20,7 @@
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div class="listKeyValue"> <div class="listKeyValue">
<app-key-value label="شماره صورت‌حساب" [value]="invoice.invoice_number" /> <app-key-value label="شماره صورت‌حساب" [value]="invoice.invoice_number" />
<app-key-value label="شماره منحصر به فرد مالیاتی" [value]="invoice.tax_id" />
<app-key-value label="تاریخ صورت‌حساب‌" [value]="invoice.invoice_date" type="date" /> <app-key-value label="تاریخ صورت‌حساب‌" [value]="invoice.invoice_date" type="date" />
<app-key-value label="تاریخ ایجاد صورت‌حساب‌" [value]="invoice.created_at" type="dateTime" /> <app-key-value label="تاریخ ایجاد صورت‌حساب‌" [value]="invoice.created_at" type="dateTime" />
<app-key-value label="نوع صورت‌حساب"> <app-key-value label="نوع صورت‌حساب">
@@ -109,4 +110,8 @@
</app-page-data-list> </app-page-data-list>
</app-card-data> </app-card-data>
</div> </div>
<shared-light-bottomsheet [(visible)]="showBackFromSaleForm" header=" برگشت از فروش">
<shared-return-form [initialValues]="invoice.items" [invoiceDate]="invoice.invoice_date" />
</shared-light-bottomsheet>
} }
@@ -43,6 +43,7 @@ import { ProgressSpinner } from 'primeng/progressspinner';
import { TableModule } from 'primeng/table'; import { TableModule } from 'primeng/table';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { AppConfirmationService } from '../confirmationDialog/confirmation-dialog.service'; import { AppConfirmationService } from '../confirmationDialog/confirmation-dialog.service';
import { SharedReturnFormComponent } from './returnForm/form.component';
export type TSaleInvoiceSingleViewVariant = 'full' | 'customer' | 'pos'; export type TSaleInvoiceSingleViewVariant = 'full' | 'customer' | 'pos';
@@ -65,6 +66,7 @@ export type TSaleInvoiceSingleViewVariant = 'full' | 'customer' | 'pos';
CatalogInvoiceSettlementTypeTagComponent, CatalogInvoiceSettlementTypeTagComponent,
SharedLightBottomsheetComponent, SharedLightBottomsheetComponent,
ProgressSpinner, ProgressSpinner,
SharedReturnFormComponent,
], ],
}) })
export class SharedSaleInvoiceSingleViewComponent { export class SharedSaleInvoiceSingleViewComponent {
@@ -90,6 +92,8 @@ export class SharedSaleInvoiceSingleViewComponent {
@ViewChild('totalAmount', { static: false }) totalAmount!: TemplateRef<any>; @ViewChild('totalAmount', { static: false }) totalAmount!: TemplateRef<any>;
moreActionMenuItems = signal<MenuItem[]>([]); moreActionMenuItems = signal<MenuItem[]>([]);
showCorrectionForm = signal(false);
showBackFromSaleForm = signal(false);
constructor() { constructor() {
this.showErrors = this.showErrors.bind(this); this.showErrors = this.showErrors.bind(this);
@@ -112,16 +116,17 @@ export class SharedSaleInvoiceSingleViewComponent {
send() { send() {
this.onSendToTsp.emit(); this.onSendToTsp.emit();
} }
showRevokeConfirmation() { async showRevokeConfirmation() {
this.confirmationService.ask({ await this.confirmationService.ask({
header: `ابطال صورت‌حساب‌ شماره ${this.invoice!.invoice_number}`, header: `ابطال صورت‌حساب‌ شماره ${this.invoice!.invoice_number}`,
message: `در صورت تایید ابطال صورت‌حساب‌ شماره ${this.invoice!.invoice_number} بر روی دکمه‌ی ابطال کلیک کنید.`, message: `در صورت تایید ابطال صورت‌حساب‌ شماره ${this.invoice!.invoice_number} بر روی دکمه‌ی ابطال کلیک کنید.`,
acceptLabel: 'ابطال', acceptLabel: 'ابطال',
acceptButtonProps: { acceptButtonProps: {
severity: 'dangerdock', severity: 'dangerdock',
}, },
variant: 'danger',
accept: () => { accept: async () => {
// this.deferredInstallPrompt.prompt(); // this.deferredInstallPrompt.prompt();
// this.deferredInstallPrompt.userChoice.finally(() => { // this.deferredInstallPrompt.userChoice.finally(() => {
// this.deferredInstallPrompt = null; // this.deferredInstallPrompt = null;
@@ -133,7 +138,9 @@ export class SharedSaleInvoiceSingleViewComponent {
}); });
} }
showCorrection() {} showCorrection() {}
showBackFromSale() {} showBackFromSale() {
this.showBackFromSaleForm.set(true);
}
ngOnChanges(changes: SimpleChanges) { ngOnChanges(changes: SimpleChanges) {
if (changes['invoice'] && this.invoice) { if (changes['invoice'] && this.invoice) {
@@ -320,21 +327,18 @@ export class SharedSaleInvoiceSingleViewComponent {
{ {
label: 'اجرت', label: 'اجرت',
value: formatWithCurrency(item.payload.wages), value: formatWithCurrency(item.payload.wages),
show: show: mustPrintConfig.items?.wage && item.good_snapshot.pricing_model === 'GOLD',
mustPrintConfig.items?.wage && item.good_snapshot.good.pricing_model === 'GOLD',
}, },
{ {
label: 'سود', label: 'سود',
value: formatWithCurrency(item.payload.wages), value: formatWithCurrency(item.payload.wages),
show: show: mustPrintConfig.items?.profit && item.good_snapshot.pricing_model === 'GOLD',
mustPrintConfig.items?.profit && item.good_snapshot.good.pricing_model === 'GOLD',
}, },
{ {
label: 'حق‌العمل', label: 'حق‌العمل',
value: formatWithCurrency(item.payload.commission), value: formatWithCurrency(item.payload.commission),
show: show:
mustPrintConfig.items?.commission && mustPrintConfig.items?.commission && item.good_snapshot.pricing_model === 'GOLD',
item.good_snapshot.good.pricing_model === 'GOLD',
}, },
{ {
label: 'مالیات بر ارزش افزوده', label: 'مالیات بر ارزش افزوده',
@@ -1,3 +1,4 @@
import { Maybe } from '@/core';
import { formatDurationToText, formatJalali, JALALI_DATE_FORMATS } from '@/utils'; import { formatDurationToText, formatJalali, JALALI_DATE_FORMATS } from '@/utils';
import priceMaskUtils from '@/utils/price-mask.utils'; import priceMaskUtils from '@/utils/price-mask.utils';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
@@ -16,7 +17,7 @@ import { TDataType } from '../pageDataList/page-data-list.component';
}) })
export class KeyValueComponent { export class KeyValueComponent {
@Input() label!: string; @Input() label!: string;
@Input() value?: string | boolean | number; @Input() value?: Maybe<string | boolean | number>;
@Input() hint?: string; @Input() hint?: string;
@Input() alignment?: 'start' | 'center' | 'end' = 'start'; @Input() alignment?: 'start' | 'center' | 'end' = 'start';
@Input() type: TDataType = 'simple'; @Input() type: TDataType = 'simple';
@@ -0,0 +1,46 @@
import { IPosOrderItem } from '@/domains/pos/modules/shop/models';
import { goldDiscountType, TGoldDiscountType } from '@/shared/localEnum/constants/goldDiscountType';
import { IGoldPayload } from '@/shared/models';
export interface IGoldAmountCalculationPayload {
commission: string;
wages: string;
discount_amount: string;
profit: string;
unit_price: string;
quantity: string;
discount_type: TGoldDiscountType;
}
export default (payload: Partial<IPosOrderItem<IGoldPayload>>) => {
const commissionAmount = Number(payload.payload?.commission ?? '0');
const wageAmount = Number(payload.payload?.wages ?? '0');
const discountAmount = Number(payload.discount_amount ?? '0');
const profitAmount = Number(payload.payload?.profit ?? '0');
const unitPrice = Number(payload.unit_price ?? '0');
const quantity = Number(payload.quantity ?? '0');
const discountType = payload.payload?.discount_type;
const unitWithQuantity = unitPrice * quantity;
const totalAmountBeforeProfit = unitWithQuantity + wageAmount + commissionAmount;
const baseTotalAmount = totalAmountBeforeProfit + profitAmount;
const baseAmountForDiscountCalculation =
discountType === goldDiscountType.PROFIT ? profitAmount : unitWithQuantity;
const taxAmount =
(profitAmount +
commissionAmount +
wageAmount -
(discountType === goldDiscountType.PROFIT ? discountAmount : 0)) *
0.1;
const totalAmount = baseTotalAmount - discountAmount + taxAmount;
return {
unitWithQuantity,
totalAmountBeforeProfit,
baseTotalAmount,
taxAmount,
totalAmount,
baseAmountForDiscountCalculation,
};
};
@@ -42,10 +42,10 @@
<ng-template #labelSuffix> <ng-template #labelSuffix>
<p-selectButton <p-selectButton
[options]="discountTypeItems" [options]="discountTypeItems"
[(ngModel)]="discountType" [(ngModel)]="form.controls.payload.controls.discount_type.value"
[allowEmpty]="false" [allowEmpty]="false"
optionLabel="label" optionLabel="name"
optionValue="value" optionValue="id"
(onChange)="changeDiscountCalculation($event)" /> (onChange)="changeDiscountCalculation($event)" />
</ng-template> </ng-template>
</app-amount-percentage-input> </app-amount-percentage-input>
@@ -8,6 +8,8 @@ import {
CalculatedAmountCardComponent, CalculatedAmountCardComponent,
InputComponent, InputComponent,
} from '@/shared/components'; } from '@/shared/components';
import { LOCAL_ENUMS } from '@/shared/localEnum/constants';
import { goldDiscountType } from '@/shared/localEnum/constants/goldDiscountType';
import { TGoldKarat } from '@/shared/localEnum/constants/goldKarat'; import { TGoldKarat } from '@/shared/localEnum/constants/goldKarat';
import { IGoldPayload } from '@/shared/models'; import { IGoldPayload } from '@/shared/models';
import { Component, computed, EventEmitter, inject, Input, Output, signal } from '@angular/core'; import { Component, computed, EventEmitter, inject, Input, Output, signal } from '@angular/core';
@@ -15,6 +17,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
import { ButtonDirective } from 'primeng/button'; import { ButtonDirective } from 'primeng/button';
import { SelectButton, SelectButtonChangeEvent } from 'primeng/selectbutton'; import { SelectButton, SelectButtonChangeEvent } from 'primeng/selectbutton';
import goldAmountCalculationUtil from './gold-amount-calculation.util';
@Component({ @Component({
selector: 'shared-gold-payload-form', selector: 'shared-gold-payload-form',
@@ -54,18 +57,8 @@ export class SharedGoldPayloadFormComponent extends AbstractForm<
private readonly posGoldConfig = inject(PosConfigGoldPriceService); private readonly posGoldConfig = inject(PosConfigGoldPriceService);
readonly discountTypeItems = [ readonly discountTypeItems = LOCAL_ENUMS.goldDiscountTypes.items;
{
label: 'از سود',
value: 1,
},
{
label: 'از کل',
value: 2,
},
];
discountType = signal<number>(1);
unitWithQuantity = signal<number>(0); unitWithQuantity = signal<number>(0);
totalAmountBeforeProfit = signal<number>(0); totalAmountBeforeProfit = signal<number>(0);
baseTotalAmount = signal<number>(0); baseTotalAmount = signal<number>(0);
@@ -105,6 +98,7 @@ export class SharedGoldPayloadFormComponent extends AbstractForm<
profit: [this.initialValues?.payload?.profit || 0, [Validators.required]], profit: [this.initialValues?.payload?.profit || 0, [Validators.required]],
profit_percentage: [0, [Validators.required, Validators.max(100), Validators.min(0)]], profit_percentage: [0, [Validators.required, Validators.max(100), Validators.min(0)]],
karat: [this.initialValues?.payload?.karat || '', [Validators.required]], karat: [this.initialValues?.payload?.karat || '', [Validators.required]],
discount_type: [this.initialValues?.payload?.discount_type || goldDiscountType.PROFIT],
}), }),
}); });
@@ -140,31 +134,21 @@ export class SharedGoldPayloadFormComponent extends AbstractForm<
karat: this.form.controls.payload.controls.karat.value as TGoldKarat, karat: this.form.controls.payload.controls.karat.value as TGoldKarat,
profit: this.form.controls.payload.controls.profit.value || 0, profit: this.form.controls.payload.controls.profit.value || 0,
wages: this.form.controls.payload.controls.wages.value || 0, wages: this.form.controls.payload.controls.wages.value || 0,
discount_type:
this.form.controls.payload.controls.discount_type.value || goldDiscountType.PROFIT,
}, },
}); });
} }
updateCalculateAmount(payload: Partial<IPosOrderItem<any>>) { updateCalculateAmount(payload: Partial<IPosOrderItem<IGoldPayload>>) {
const commissionAmount = Number(payload.payload?.commission ?? '0'); const {
const wageAmount = Number(payload.payload?.wages ?? '0'); unitWithQuantity,
const discountAmount = Number(payload.discount_amount ?? '0'); totalAmountBeforeProfit,
const profitAmount = Number(payload.payload?.profit ?? '0'); baseTotalAmount,
const unitPrice = Number(payload.unit_price ?? '0'); taxAmount,
const quantity = Number(payload.quantity ?? '0'); totalAmount,
baseAmountForDiscountCalculation,
const unitWithQuantity = unitPrice * quantity; } = goldAmountCalculationUtil(payload);
const totalAmountBeforeProfit = unitWithQuantity + wageAmount + commissionAmount;
const baseTotalAmount = totalAmountBeforeProfit + profitAmount;
const baseAmountForDiscountCalculation =
this.discountType() === 1 ? profitAmount : unitWithQuantity;
const taxAmount =
(profitAmount +
commissionAmount +
wageAmount -
(this.discountType() === 1 ? discountAmount : 0)) *
0.1;
const totalAmount = baseTotalAmount - discountAmount + taxAmount;
this.unitWithQuantity.set(unitWithQuantity); this.unitWithQuantity.set(unitWithQuantity);
this.totalAmountBeforeProfit.set(totalAmountBeforeProfit); this.totalAmountBeforeProfit.set(totalAmountBeforeProfit);
@@ -177,8 +161,7 @@ export class SharedGoldPayloadFormComponent extends AbstractForm<
} }
changeDiscountCalculation($event: SelectButtonChangeEvent) { changeDiscountCalculation($event: SelectButtonChangeEvent) {
this.discountType.set($event.value); this.form.controls.payload.controls.discount_type.setValue($event.value);
this.updateCalculateAmount(this.form.value as any); this.updateCalculateAmount(this.form.value as any);
} }
} }
@@ -1,2 +1,3 @@
export * from './gold-amount-calculation.util';
export * from './gold-form.component'; export * from './gold-form.component';
export * from './standard-form.component'; export * from './standard-form.component';
@@ -0,0 +1,19 @@
import { ISelectItem } from '@/shared/abstractClasses';
export const goldDiscountType = {
PROFIT: 'PROFIT',
TOTAL: 'TOTAL',
} as const;
export type TGoldDiscountType = (typeof goldDiscountType)[keyof typeof goldDiscountType];
const translates: Record<TGoldDiscountType, string> = {
TOTAL: 'از کل',
PROFIT: 'از سود',
};
export const goldDiscountTypeSelect: ISelectItem[] = Object.keys(goldDiscountType).map((key) => ({
id: key,
name: translates[key as TGoldDiscountType],
}));
export const goldDiscountTypeLabel = 'نوع تخفیف';
@@ -1,3 +1,4 @@
import { goldDiscountTypeLabel, goldDiscountTypeSelect } from './goldDiscountType';
import { goldKaratLabel, goldKaratSelect } from './goldKarat'; import { goldKaratLabel, goldKaratSelect } from './goldKarat';
import { licenseStatusLabel, licenseStatusSelect } from './licenseStatus'; import { licenseStatusLabel, licenseStatusSelect } from './licenseStatus';
@@ -11,4 +12,9 @@ export const LOCAL_ENUMS = {
label: licenseStatusLabel, label: licenseStatusLabel,
items: licenseStatusSelect, items: licenseStatusSelect,
}, },
goldDiscountTypes: {
label: goldDiscountTypeLabel,
items: goldDiscountTypeSelect,
},
}; };
@@ -1,6 +1,8 @@
import { TGoldKarat } from '@/shared/localEnum/constants/goldKarat'; import { TGoldKarat } from '@/shared/localEnum/constants/goldKarat';
import { TGoldDiscountType } from '../localEnum/constants/goldDiscountType';
export interface IGoldPayload { export interface IGoldPayload {
discount_type: TGoldDiscountType;
karat: TGoldKarat; karat: TGoldKarat;
wages: number; wages: number;
profit: number; profit: number;
@@ -2,7 +2,7 @@
<input pInputText readonly [name]="name" [value]="valueToShow" class="w-full" /> <input pInputText readonly [name]="name" [value]="valueToShow" class="w-full" />
</uikit-field> </uikit-field>
<p-popover #op appendTo="body"> <p-popover #op appendTo="body">
<div #wrapperRef class="dir-rtl border-divider bg-background-paper w-full min-w-xs overflow-auto rounded-md"> <div #wrapperRef class="dir-rtl border-divider bg-background-paper w-full min-w-sm overflow-auto rounded-md">
<div class="bg-primary-main sticky top-0 flex justify-center px-3"> <div class="bg-primary-main sticky top-0 flex justify-center px-3">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex h-8 items-center"> <div class="flex h-8 items-center">
@@ -10,7 +10,7 @@
<i class="pi pi-chevron-right cursor-pointer" (click)="changeMonth(-1)"></i> <i class="pi pi-chevron-right cursor-pointer" (click)="changeMonth(-1)"></i>
</div> </div>
<div class="flex w-32 items-center justify-center px-3 font-bold select-none"> <div class="flex w-32 items-center justify-center px-3 text-lg font-bold select-none">
{{ monthInfo().monthTitle }} {{ monthInfo().monthTitle }}
</div> </div>
@@ -39,7 +39,7 @@
[class.bg-background-default]="closedDays.includes($index)"> [class.bg-background-default]="closedDays.includes($index)">
@if (!(day.isNextMonth || day.isPastMonth)) { @if (!(day.isNextMonth || day.isPastMonth)) {
<span <span
class="flex aspect-square w-full cursor-pointer items-center justify-center rounded px-2 py-1 transition-colors select-none" class="flex aspect-square w-full cursor-pointer items-center justify-center rounded px-2 py-1 text-lg transition-colors select-none"
[ngClass]="{ [ngClass]="{
'bg-surface-ground font-bold': day.isCurrentDay, 'bg-surface-ground font-bold': day.isCurrentDay,
'bg-primary! text-primary-contrast!': isSelected(day), 'bg-primary! text-primary-contrast!': isSelected(day),
@@ -1,16 +1,28 @@
import { Component, EventEmitter, Input, Output, ViewChild, computed, signal } from '@angular/core'; import {
Component,
EventEmitter,
Input,
Output,
ViewChild,
computed,
effect,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormControl } from '@angular/forms'; import { FormControl } from '@angular/forms';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import jalaliday from 'jalaliday'; import jalaliday from 'jalaliday';
import { Maybe } from '@/core';
import { formatJalali } from '@/utils';
import { CommonModule } from '@angular/common';
import { InputText } from 'primeng/inputtext'; import { InputText } from 'primeng/inputtext';
import { Popover } from 'primeng/popover'; import { Popover } from 'primeng/popover';
import { Maybe } from '@/core';
import { formatJalali } from '@/utils';
import { UikitFieldComponent } from '../uikit-field.component'; import { UikitFieldComponent } from '../uikit-field.component';
import { import {
MonthInfo, MonthInfo,
MonthlyWeekDate, MonthlyWeekDate,
@@ -25,145 +37,129 @@ dayjs.extend(jalaliday);
selector: 'app-datepicker', selector: 'app-datepicker',
standalone: true, standalone: true,
templateUrl: './datepicker.component.html', templateUrl: './datepicker.component.html',
imports: [Popover, UikitFieldComponent, InputText, CommonModule], imports: [CommonModule, Popover, InputText, UikitFieldComponent],
}) })
export class MiniMonthlyCalendarComponent { export class DatepickerComponent {
@Input() control!: FormControl<Maybe<string>>; @Input({ required: true })
@Input() alignment?: 'vertical' | 'horizontal' = 'vertical'; control!: FormControl<Maybe<string>>;
@Input() name?: string = 'date';
@Input() label?: string = 'تاریخ';
@Input() closeOnSelect?: boolean = true;
@Input() min?: string;
@Input() max?: string;
@Input() closedDays: number[] = [];
@Output() clickOnDayEvent = new EventEmitter<MonthlyWeekDate>(); @Input()
@Output() valueChange = new EventEmitter<string>(); alignment: 'vertical' | 'horizontal' = 'vertical';
@ViewChild('op') op!: Popover; @Input()
name = 'date';
@Input()
label = 'تاریخ';
@Input()
closeOnSelect = true;
/**
* Gregorian YYYY-MM-DD
*/
@Input()
min?: string;
/**
* Gregorian YYYY-MM-DD
*/
@Input()
max?: string;
@Input()
closedDays: number[] = [];
@Output()
valueChange = new EventEmitter<string>();
@Output()
clickOnDayEvent = new EventEmitter<MonthlyWeekDate>();
@ViewChild('op')
op!: Popover;
readonly daysName = daysName; readonly daysName = daysName;
/** /**
* Current displayed month * Currently displayed month
* Gregorian YYYY-MM-DD
*/ */
private readonly activeDateSignal = signal(dayjs().format('YYYY-MM-DD')); private readonly activeDateSignal = signal(dayjs().format('YYYY-MM-DD'));
/**
* Selected value
* Gregorian YYYY-MM-DD
*/
private readonly selectedDateSignal = signal<Maybe<string>>(null);
disabled = false;
monthInfo = computed<MonthInfo>(() => prepareMonthInfo(this.activeDateSignal())); monthInfo = computed<MonthInfo>(() => prepareMonthInfo(this.activeDateSignal()));
weekDaysDates = computed(() => prepareWeekDayDates(this.monthInfo(), this.min, this.max)); weekDaysDates = computed(() => prepareWeekDayDates(this.monthInfo(), this.min, this.max));
// ----------------------- constructor() {
// Getters effect(() => {
// ----------------------- const value = this.value;
if (value) {
this.activeDateSignal.set(value);
}
});
}
ngOnInit(): void {
if (this.control?.value) {
this.activeDateSignal.set(this.control.value);
}
this.control.valueChanges.subscribe((value) => {
if (value) {
this.activeDateSignal.set(value);
}
});
}
get value(): Maybe<string> { get value(): Maybe<string> {
return this.selectedDateSignal(); return this.control?.value ?? null;
} }
get valueToShow(): Maybe<string> { get valueToShow(): Maybe<string> {
return this.selectedDateSignal() && formatJalali(this.selectedDateSignal()!); if (!this.value) {
return null;
}
return formatJalali(this.value);
} }
get activeDate(): string { get activeDate(): string {
return this.activeDateSignal(); return this.activeDateSignal();
} }
// -----------------------
// Setters
// -----------------------
set value(date: Maybe<string>) {
this.selectedDateSignal.set(date);
this.control.setValue(date);
if (date) {
this.activeDateSignal.set(date);
}
}
set activeDate(date: string) { set activeDate(date: string) {
this.activeDateSignal.set(date); this.activeDateSignal.set(date);
} }
// -----------------------
// Navigation
// -----------------------
changeMonth(months: number): void { changeMonth(months: number): void {
const newDate = dayjs(this.activeDateSignal()).add(months, 'month').format('YYYY-MM-DD'); this.activeDateSignal.set(
dayjs(this.activeDateSignal()).add(months, 'month').format('YYYY-MM-DD')
this.activeDateSignal.set(newDate); );
} }
// -----------------------
// Selection
// -----------------------
selectDate(day: MonthlyWeekDate): void { selectDate(day: MonthlyWeekDate): void {
if (this.disabled) {
return;
}
if (day.isDisabled) { if (day.isDisabled) {
return; return;
} }
this.value = day.gregorianDate; this.control.setValue(day.gregorianDate);
this.onChange(day.gregorianDate); this.control.markAsDirty();
this.control.markAsTouched();
this.onTouched(); this.valueChange.emit(day.gregorianDate);
this.clickOnDayEvent.emit(day);
if (this.closeOnSelect) { if (this.closeOnSelect) {
this.op.toggle(false); this.op.hide();
} }
this.clickOnDayEvent.emit(day);
this.valueChange.emit(day.gregorianDate);
} }
isSelected(day: MonthlyWeekDate): boolean { isSelected(day: MonthlyWeekDate): boolean {
return !!this.value && this.value === day.gregorianDate; return this.value === day.gregorianDate;
} }
// -----------------------
// CVA
// -----------------------
private onChange: (value: Maybe<string>) => void = () => {};
private onTouched: () => void = () => {};
writeValue(value: Maybe<string>): void {
this.value = value;
}
registerOnChange(fn: (value: Maybe<string>) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
// -----------------------
// Helpers
// -----------------------
trackByIndex(index: number): number { trackByIndex(index: number): number {
return index; return index;
} }