feat: add public sale invoices module with routing and service integration

- Introduced PUBLIC_SALE_INVOICES_ROUTES for handling sale invoice routes.
- Created PublicSaleInvoicesService to fetch single invoice data from the API.
- Implemented PublicSaleInvoiceStore for managing invoice state.
- Added PublicSaleInvoiceComponent to display invoice details.
- Updated app routes to include public sale invoices.
- Removed pre-invoice dialog component and its associated files.
- Enhanced order submitted dialog with QR code for invoice sharing.
- Updated sale invoice single view to reflect new data structure.
- Refactored page data list component for improved layout and functionality.
This commit is contained in:
2026-05-21 21:35:34 +03:30
parent 1b4ac0789c
commit 8c07dc7c3f
25 changed files with 1036 additions and 349 deletions
+428 -54
View File
@@ -4,15 +4,251 @@
- This file defines repository-specific instructions for coding agents. - This file defines repository-specific instructions for coding agents.
- Scope is the full repo unless a deeper `AGENT.md` overrides it. - Scope is the full repo unless a deeper `AGENT.md` overrides it.
- Use RTK to reduce token usage, avoid repeated context transfer, and preserve repository knowledge across sessions.
---
# ✅ RTK-FIRST RULES (STRICT)
Always prefer RTK commands for repository inspection, navigation, and code understanding.
## Command Rewrite Rules
Prefer:
```bash
git status -> rtk git status
git diff -> rtk git diff
git log -> rtk git log
ls -> rtk ls
tree -> rtk ls
cat <file> -> rtk read <file>
grep <pattern> -> rtk grep <pattern>
rg <pattern> -> rtk grep <pattern>
find -> rtk find
```
Avoid raw commands for repository inspection when RTK equivalents exist.
Do not:
- use raw `cat` for large files
- use raw `grep`/`rg` across the repository
- use recursive `find` blindly
- inspect large diffs with raw `git diff`
- scan directories manually before searching
Raw commands are allowed only when:
- RTK cannot perform the operation
- executing builds/tests/runtime commands
- debugging environment/runtime issues
- working outside indexed repository scope
---
# 📂 CODE NAVIGATION WORKFLOW (MANDATORY)
Follow this order when working in the repository.
## 1. Discover Structure
```bash
rtk ls
```
## 2. Search Before Opening Files
```bash
rtk grep <symbol | class | function | DTO | service>
```
Examples:
```bash
rtk grep "InputComponent"
rtk grep "fieldControl"
rtk grep "breadcrumbItems"
```
## 3. Read Only Necessary Files
```bash
rtk read <file>
```
## 4. Large File Strategy
For files larger than ~300 lines:
```bash
rtk read <file> -l aggressive
```
## 5. Fast Context Understanding
```bash
rtk smart <file>
```
Never:
- open many files blindly
- read entire modules without searching first
- inspect unrelated folders
- load generated directories into context
---
# 🧠 DIFF INSPECTION RULES
For repository changes use:
```bash
rtk git diff
```
For large diffs:
```bash
rtk git diff -l aggressive
```
Avoid raw:
```bash
git diff
```
unless RTK diff is unavailable.
---
# 🚫 LOW-VALUE PATHS
Avoid loading:
```txt
dist/
.angular/
coverage/
node_modules/
.git/
.cache/
.tmp/
```
unless explicitly required.
---
# RTK Usage Rules
## Required RTK Usage
- Always use RTK for:
- repository indexing
- semantic code search
- symbol lookup
- dependency tracing
- architectural context
- change impact analysis
- Prefer RTK context retrieval over:
- reading large files entirely
- repeatedly scanning unchanged directories
- re-sending full file contents
- broad grep operations
- Retrieve only the minimal relevant context before making changes.
## Recommended RTK Workflow
1. Index repository
2. Search related symbols/files/components
3. Retrieve minimal context
4. Apply scoped changes
5. Validate impacted areas only
Example workflow:
```bash
rtk index
rtk search "InputComponent number normalization"
rtk search "fieldControl"
rtk refs "goodListConfig"
```
## Token Reduction Rules
- Never load entire large files unless required.
- Never inspect generated folders (`dist`, `.angular`, coverage, etc.).
- Prefer symbol-level retrieval over file-level retrieval.
- Reuse previously retrieved RTK context whenever possible.
- Avoid duplicate searches for the same symbols within one task.
- Use targeted searches:
- component name
- selector
- signal/store name
- config constant
- Angular route
- injected service
- form control key
## Suggested RTK Queries
### Angular Components
```bash
rtk search "selector: 'field-"
rtk search "standalone: true"
rtk search "InputComponent"
```
### Forms & Controls
```bash
rtk search "fieldControl."
rtk search "ControlConfig"
rtk search "Validators.required"
```
### Stores & Signals
```bash
rtk search "breadcrumbItems"
rtk search "computed("
rtk search "signal("
```
### Tenant / Docker
```bash
rtk search "DIST_DIR"
rtk search "configuration tis"
rtk search "prebuild:tis"
```
### List Configs
```bash
rtk search "IListConfig"
rtk search "columns:"
rtk search "goodListConfig"
```
---
## Stack Context ## Stack Context
- Frontend: Angular 20 standalone app. - Frontend: Angular 20 standalone app.
- Package manager: `pnpm`. - Package manager: `pnpm`.
- Deployment: Docker / Docker Compose with tenant-specific services. - Deployment: Docker / Docker Compose with tenant-specific services.
- Current service mapping expectation: - AI-assisted repository navigation: RTK.
- `app_default` on host port `8090`
- `app_tis` on host port `8091` ### Current service mapping expectation
- `app_default` on host port `8090`
- `app_tis` on host port `8091`
---
## Tenant Build Rules ## Tenant Build Rules
@@ -23,6 +259,8 @@
- Do not assume `default` Angular configuration is usable unless verified (it may reference missing replacements). - Do not assume `default` Angular configuration is usable unless verified (it may reference missing replacements).
- Do not use Angular `fileReplacements` for static assets (`.png`, `.jpg`, etc.); use tenant public assets or prebuild copy scripts. - Do not use Angular `fileReplacements` for static assets (`.png`, `.jpg`, etc.); use tenant public assets or prebuild copy scripts.
---
## Tenant PWA Rules ## Tenant PWA Rules
- Keep tenant manifest files under tenant public assets (e.g. `public-tis/favicon/site.webmanifest`). - Keep tenant manifest files under tenant public assets (e.g. `public-tis/favicon/site.webmanifest`).
@@ -31,46 +269,76 @@
- subpath deploy example: `/tis/` - subpath deploy example: `/tis/`
- Keep `<link rel="manifest">` path and branding config `manifestPath` aligned. - Keep `<link rel="manifest">` path and branding config `manifestPath` aligned.
---
## Input Component Rules ## Input Component Rules
- File: `src/app/shared/components/input/input.component.ts` File:
- For `type === 'number'` or `type === 'price'`: - `src/app/shared/components/input/input.component.ts`
- Normalize Persian/Arabic digits to English digits.
- Allow only digits and `.`. For:
- Support `fixed` precision formatting when provided. - `type === 'number'`
- `type === 'price'`
Requirements:
- Normalize Persian/Arabic digits to English digits.
- Allow only digits and `.`.
- Support `fixed` precision formatting when provided.
Additional rule:
- Keep behavior for identifier fields (`mobile`, `phone`, `postalCode`, `nationalId`) string-safe. - Keep behavior for identifier fields (`mobile`, `phone`, `postalCode`, `nationalId`) string-safe.
---
## Change Policy ## Change Policy
- Keep changes minimal and scoped to user request. - Keep changes minimal and scoped to user request.
- Prefer root-cause fixes over temporary workarounds. - Prefer root-cause fixes over temporary workarounds.
- Avoid unrelated refactors. - Avoid unrelated refactors.
- Reuse existing patterns and naming conventions. - Reuse existing patterns and naming conventions.
- Use RTK to understand existing patterns before introducing new ones.
---
## Do / Don't ## Do / Don't
- Do follow existing field wrapper style in `src/app/shared/components/fields/*.component.ts`. ### Do
- Do reuse `app-input` and set only required props (`type`, `label`, `name`, constraints).
- Do register every new field in: - Follow existing field wrapper style in:
- `src/app/shared/components/fields/*.component.ts`
- Reuse `app-input` and set only required props:
- `type`
- `label`
- `name`
- constraints
- Register every new field in:
- `src/app/shared/components/fields/index.ts` - `src/app/shared/components/fields/index.ts`
- `src/app/shared/constants/fields/index.ts` - `src/app/shared/constants/fields/index.ts`
- Do keep control keys consistent across form group, field component `name`, and `fieldControl` key. - Keep control keys consistent across:
- form group
- field component `name`
- `fieldControl` key
- Use RTK searches before creating new abstractions.
### Don't
- Don't add one-off field patterns when an existing field component can be reused. - Don't add one-off field patterns when an existing field component can be reused.
- Don't use invalid Angular file replacements for directories or empty paths. - Don't use invalid Angular file replacements for directories or empty paths.
- Don't change tenant output directories without updating Docker `DIST_DIR`. - Don't change tenant output directories without updating Docker `DIST_DIR`.
- Don't scan unrelated folders/files when RTK can retrieve exact symbols.
- Don't load full files if RTK symbol extraction is sufficient.
---
## How To Create Form Fields ## How To Create Form Fields
- Create a wrapper component in `src/app/shared/components/fields`. - Create a wrapper component in:
- `src/app/shared/components/fields`
- Use the same pattern as existing files like: - Use the same pattern as existing files like:
- `src/app/shared/components/fields/name.component.ts` - `src/app/shared/components/fields/name.component.ts`
- `src/app/shared/components/fields/unit_price.component.ts` - `src/app/shared/components/fields/unit_price.component.ts`
- Minimal wrapper shape:
- `selector`: `field-<field-name>`
- template: `<app-input ... />`
- inputs: `control` (required), optional `name`, optional `label`
Example pattern: Minimal wrapper shape:
```ts ```ts
@Component({ @Component({
@@ -85,15 +353,19 @@ export class ExampleComponent {
} }
``` ```
---
## Register New Fields ## Register New Fields
- Export the component from: Export the component from:
- `src/app/shared/components/fields/index.ts` - `src/app/shared/components/fields/index.ts`
- Add its form control factory in:
- `src/app/shared/constants/fields/index.ts` Add its form control factory in:
- `fieldControl` entry shape: - `src/app/shared/constants/fields/index.ts`
- key must match form control name
- return tuple: `[defaultValue, validators]` `fieldControl` entry shape:
- key must match form control name
- return tuple: `[defaultValue, validators]`
Example: Example:
@@ -104,55 +376,157 @@ example: (value = '', isRequired = true): ControlConfig => [
], ],
``` ```
---
## Using Fields In Forms ## Using Fields In Forms
- In form group builders, use `fieldControl.<key>(initialValue, isRequired)` for consistency. - In form group builders, use:
- In templates, render matching wrapper component and pass the matching control: - `fieldControl.<key>(initialValue, isRequired)`
- In templates, render matching wrapper component and pass matching control:
- `<field-example [control]="form.controls.example" />` - `<field-example [control]="form.controls.example" />`
- For numeric/price behavior, use `app-input` `type="number"` or `type="price"` and optional `[fixed]`. - For numeric/price behavior:
- use `app-input`
- use `type="number"` or `type="price"`
- optional `[fixed]`
---
## List Component Configs ## List Component Configs
- Centralized list metadata is stored in `src/app/shared/constants/list-configs/`**never** in domain modules. Centralized list metadata is stored in:
- Structure by data type, not domain (e.g. `good-list.const.ts`, `sku-list.const.ts`, `category-list.const.ts`). - `src/app/shared/constants/list-configs/`
- Each config implements `IListConfig` (defined in `list-config.model.ts`):
- `pageTitle`: display title for the list page Never store configs inside domain modules.
- `addNewCtaLabel`: call-to-action button label
- `emptyPlaceholderTitle` and `emptyPlaceholderDescription`: empty state messaging ### Structure
- `columns`: array of `IColumn[]` definitions
- Usage in list components: Structure by data type, not domain:
```ts - `good-list.const.ts`
@Input() header: IColumn[] = goodListConfig.columns; - `sku-list.const.ts`
listConfig = goodListConfig; - `category-list.const.ts`
```
- Any domain needing a list config imports from `@/shared/constants/list-configs` — no cross-domain dependencies. ### Requirements
- **Do not** define configs inside domains or duplicate them across modules.
Each config implements `IListConfig`:
- `pageTitle`
- `addNewCtaLabel`
- `emptyPlaceholderTitle`
- `emptyPlaceholderDescription`
- `columns`
### Usage
```ts
@Input() header: IColumn[] = goodListConfig.columns;
listConfig = goodListConfig;
```
### Rules
- Any domain needing a list config imports from:
- `@/shared/constants/list-configs`
- No cross-domain dependencies.
- Do not duplicate configs.
---
## Breadcrumb Usage in Stores & Views ## Breadcrumb Usage in Stores & Views
- Entity stores expose `breadcrumbItems` as a computed signal. - Entity stores expose `breadcrumbItems` as a computed signal.
- Call `store.breadcrumbItems()` in view components and extend with current page: - Call `store.breadcrumbItems()` in view components and extend with current page:
```ts
setBreadcrumb() { ```ts
setBreadcrumb() {
this.breadcrumbService.setItems([ this.breadcrumbService.setItems([
...this.store.breadcrumbItems(), ...this.store.breadcrumbItems(),
{ title: 'Current Page' }, { title: 'Current Page' },
]); ]);
} }
``` ```
- Root page breadcrumbs are set in the store's `getData()` method once entity is loaded. - Root page breadcrumbs are set in the store's `getData()` method once entity is loaded.
---
## Validation Checklist ## Validation Checklist
- For TypeScript-only changes, run: ### TypeScript-only changes
- `pnpm -s exec tsc -p tsconfig.app.json --noEmit`
- For Docker/build changes, verify with: ```bash
- `docker compose build app_default` pnpm -s exec tsc -p tsconfig.app.json --noEmit
- `docker compose build app_tis` ```
- Start with targeted validation, then broader checks only if needed.
### Docker/build changes
```bash
docker compose build app_default
docker compose build app_tis
```
### Validation Strategy
- Start with targeted validation.
- Run broader checks only if needed.
- Validate only impacted tenants/features when possible.
---
## Communication Expectations ## Communication Expectations
- Report exactly which files changed and why. - Report exactly which files changed and why.
- Call out any assumptions or discovered config mismatches. - Call out assumptions and discovered config mismatches.
- If validation is blocked (permissions, missing dependencies), state it clearly and provide next command. - If validation is blocked:
- explain why
- provide next command
- Mention RTK searches/symbols used when relevant to implementation decisions.
---
## Preferred Investigation Order
Before editing code:
1. RTK symbol search
2. Existing implementation discovery
3. Shared abstraction verification
4. Minimal scoped change
5. Targeted validation
Avoid:
- broad exploratory reads
- repeated file dumping
- unnecessary context expansion
---
## Angular Architecture Preferences
- Prefer standalone components.
- Prefer signals/computed over legacy observable-only state when consistent with current architecture.
- Keep forms typed.
- Keep reusable UI in `shared`.
- Keep domain logic outside presentation components.
- Preserve existing tenant separation model.
---
## Performance & AI Efficiency
- Optimize for:
- minimal token usage
- minimal file reads
- scoped context retrieval
- reusable architectural understanding
Prefer:
- RTK symbol search
- targeted reads
- incremental inspection
Avoid:
- dumping full files
- repeated repository scans
- repeated searches for the same symbols
- unnecessary architectural exploration
- RTK should be the primary repository understanding tool.
+1
View File
@@ -13,6 +13,7 @@
"@primeuix/themes": "^1.2.5", "@primeuix/themes": "^1.2.5",
"@tailwindcss/postcss": "^4.2.3", "@tailwindcss/postcss": "^4.2.3",
"@zoomit/dayjs-jalali-plugin": "^0.1.11", "@zoomit/dayjs-jalali-plugin": "^0.1.11",
"angularx-qrcode": "20.0.0",
"chart.js": "4.4.2", "chart.js": "4.4.2",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"flatpickr": "^4.6.13", "flatpickr": "^4.6.13",
+311 -179
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -3,6 +3,7 @@ import { PARTNER_ROUTES } from '@/domains/partner/routes';
import { POS_ROUTES } from '@/domains/pos/routes'; import { POS_ROUTES } from '@/domains/pos/routes';
import { PROVIDER_ROUTES } from '@/domains/provider/routes'; import { PROVIDER_ROUTES } from '@/domains/provider/routes';
import { SUPER_ADMIN_ROUTES } from '@/domains/superAdmin/routes'; import { SUPER_ADMIN_ROUTES } from '@/domains/superAdmin/routes';
import { PUBLIC_SALE_INVOICES_ROUTES } from '@/modules/saleInvoices/constants';
import { Notfound } from '@/pages/notfound/notfound.component'; import { Notfound } from '@/pages/notfound/notfound.component';
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
@@ -23,6 +24,6 @@ export const appRoutes: Routes = [
path: 'auth', path: 'auth',
loadComponent: () => import('@/modules/auth/pages/auth.component').then((m) => m.AuthComponent), loadComponent: () => import('@/modules/auth/pages/auth.component').then((m) => m.AuthComponent),
}, },
{ path: 'notfound', component: Notfound }, ...PUBLIC_SALE_INVOICES_ROUTES,
{ path: '**', redirectTo: '/notfound' }, { path: '**', component: Notfound },
]; ];
@@ -4,13 +4,15 @@
[modal]="true" [modal]="true"
[style]="{ 'max-height': '90svh', height: 'auto' }" [style]="{ 'max-height': '90svh', height: 'auto' }"
[closable]="true" [closable]="true"
(onHide)="close()" (onHide)="close()">
> <div class="flex flex-col items-center justify-center gap-4 pt-10 pb-14 text-center">
<div class="text-center pt-10 pb-14 flex flex-col items-center justify-center gap-4"> <qrcode [qrdata]="publicInvoiceRoute()" [width]="220" [errorCorrectionLevel]="'M'"></qrcode>
<i class="" class="pi pi-check-circle text-6xl! text-green-700"></i> <div class="flex items-center gap-2">
<i class="" class="pi pi-check-circle text-xl! text-green-700"></i>
<p class="text-lg font-bold">فاکتور شما با موفقیت ایجاد شد.</p> <p class="text-lg font-bold">فاکتور شما با موفقیت ایجاد شد.</p>
</div> </div>
<div class="flex gap-2 justify-center"> </div>
<div class="flex justify-center gap-2">
<button pButton type="button" outlined (click)="printInvoice()">چاپ</button> <button pButton type="button" outlined (click)="printInvoice()">چاپ</button>
<button pButton type="button" outlined (click)="sendInvoice()">ارسال به سامانه مودیان</button> <button pButton type="button" outlined (click)="sendInvoice()">ارسال به سامانه مودیان</button>
<button pButton type="button" outlined (click)="openOrderDetails()">جزئیات</button> <button pButton type="button" outlined (click)="openOrderDetails()">جزئیات</button>
@@ -6,6 +6,7 @@ import { SharedLightBottomsheetComponent } from '@/shared/components/dialog/ligh
import { formatJalali } from '@/utils'; import { formatJalali } from '@/utils';
import { Component, computed, inject, Input, signal } from '@angular/core'; import { Component, computed, inject, Input, signal } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { QRCodeComponent } from 'angularx-qrcode';
import { ButtonDirective } from 'primeng/button'; import { ButtonDirective } from 'primeng/button';
import { finalize } from 'rxjs'; import { finalize } from 'rxjs';
import { posSaleInvoicesNamedRoutes } from '../../../saleInvoices/constants'; import { posSaleInvoicesNamedRoutes } from '../../../saleInvoices/constants';
@@ -15,7 +16,7 @@ import { IPosOrderResponse } from '../../models';
@Component({ @Component({
selector: 'pos-order-submitted-dialog', selector: 'pos-order-submitted-dialog',
templateUrl: 'order-submitted-dialog.component.html', templateUrl: 'order-submitted-dialog.component.html',
imports: [SharedLightBottomsheetComponent, ButtonDirective], imports: [SharedLightBottomsheetComponent, ButtonDirective, QRCodeComponent],
}) })
export class PosOrderSubmittedDialogComponent extends AbstractDialog { export class PosOrderSubmittedDialogComponent extends AbstractDialog {
@Input({ required: true }) invoice!: IPosOrderResponse; @Input({ required: true }) invoice!: IPosOrderResponse;
@@ -33,12 +34,16 @@ export class PosOrderSubmittedDialogComponent extends AbstractDialog {
return ''; return '';
}); });
readonly publicInvoiceRoute = computed(
() => `${window.location.origin}/sale-invoices/${this.invoice.id}`
);
sendingLoading = signal(false); sendingLoading = signal(false);
sended = signal(false); sended = signal(false);
openOrderDetails() { openOrderDetails() {
this.router.navigateByUrl( this.router.navigateByUrl(
posSaleInvoicesNamedRoutes.saleInvoice.meta.pagePath!(this.invoice.id), posSaleInvoicesNamedRoutes.saleInvoice.meta.pagePath!(this.invoice.id)
); );
} }
@@ -1,11 +0,0 @@
<shared-light-bottomsheet
[(visible)]="visible"
header="پیش فاکتور"
[modal]="true"
[style]="{ 'max-height': '90svh', height: 'auto' }"
[closable]="true"
(onHide)="close()"
>
<form [formGroup]="form"></form>
<uikit-datepicker [control]="form.controls.invoiceDate" label="تاریخ فاکتور" />
</shared-light-bottomsheet>
@@ -1,25 +0,0 @@
import { AbstractDialog } from '@/shared/abstractClasses/abstract-dialog';
import { SharedLightBottomsheetComponent } from '@/shared/components/dialog/light-bottomsheet.component';
import { UikitFlatpickrJalaliComponent } from '@/uikit';
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { PosLandingStore } from '../store/main.store';
@Component({
selector: 'pos-pre-invoice-dialog',
templateUrl: './pre-invoice-dialog.component.html',
imports: [SharedLightBottomsheetComponent, UikitFlatpickrJalaliComponent, ReactiveFormsModule],
})
export class PosPreInvoiceDialogComponent extends AbstractDialog {
private readonly store = inject(PosLandingStore);
private readonly fb = inject(FormBuilder);
private initForm() {
const form = this.fb.group({
invoiceDate: [this.store.invoiceDate(), [Validators.required]],
});
return form;
}
form = this.initForm();
}
@@ -0,0 +1,5 @@
const baseUrl = () => `/api/v1/public-invoices`;
export const PUBLIC_SALE_INVOICES_API_ROUTES = {
single: (invoiceId: string) => `${baseUrl()}/${invoiceId}`,
};
@@ -0,0 +1,2 @@
export * from './apiRoutes/index';
export * from './routes/index';
@@ -0,0 +1,18 @@
import { NamedRoutes } from '@/core';
import { Routes } from '@angular/router';
export type TPublicSaleInvoicesRouteNames = 'saleInvoice';
export const publicSaleInvoicesNamedRoutes: NamedRoutes<TPublicSaleInvoicesRouteNames> = {
saleInvoice: {
path: 'sale-invoices/:invoiceId',
loadComponent: () =>
import('../../views/single.component').then((m) => m.PublicSaleInvoiceComponent),
meta: {
title: 'جزئیات فاکتور',
pagePath: (invoiceId: string) => `sale-invoices/${invoiceId}`,
},
},
};
export const PUBLIC_SALE_INVOICES_ROUTES: Routes = [publicSaleInvoicesNamedRoutes.saleInvoice];
@@ -0,0 +1 @@
export * from './io';
+114
View File
@@ -0,0 +1,114 @@
import { Maybe } from '@/core';
import ISummary from '@/core/models/summary';
import { IEnumTranslate } from '@/shared/models/enum_translate.type';
export interface IPublicSaleInvoicesRawResponse {
id: string;
code: string;
invoice_date: string;
invoice_number: string;
total_amount: string;
pos: Pos;
consumer_account: ConsumerAccount;
payments: Payments[];
type: IEnumTranslate;
status: IEnumTranslate;
customer: Maybe<Customer>;
unknown_customer: Maybe<Record<string, unknown>>;
items: InvoiceItem[];
notes?: string;
}
export interface IPublicSaleInvoicesResponse extends IPublicSaleInvoicesRawResponse {}
interface ConsumerAccount {
role: string;
account: Account;
}
interface Account {
username: string;
}
interface Pos extends ISummary {
complex: Complex;
}
interface Complex extends ISummary {
business_activity: BA;
}
interface BA extends ISummary {
guild: ISummary;
}
interface Payments {
amount: string;
paid_at?: string;
payment_method: string;
}
interface Customer {
type: string;
individual: Individual;
legal: Legal;
}
interface Individual {
first_name: string;
last_name: string;
national_code: string;
mobile_number: string;
}
interface Legal {
company_name: string;
economic_code: string;
registration_number: string;
}
interface InvoiceItem {
good_snapshot: GoodSnapshot;
measure_unit_text: string;
quantity: string;
total_amount: string;
unit_price: string;
discount: string;
payload: InvoiceItemPayload;
sku_code: string;
sku_vat: string;
notes: Maybe<string>;
}
interface InvoiceItemPayload {
karat: string;
wages: number;
profit: number;
commission: number;
}
interface GoodSnapshot {
good: Good;
}
interface Good {
id: string;
sku: Sku;
name: string;
barcode: Maybe<string>;
category: ISummary;
image_url: string;
local_sku: Maybe<string>;
measure_unit: MeasureUnit;
pricing_model: string;
base_sale_price: string;
}
interface MeasureUnit extends ISummary {
code: string;
}
interface Sku extends ISummary {
VAT: string;
code: string;
}
@@ -0,0 +1,17 @@
import { ISaleInvoiceFullResponse } from '@/domains/consumer/models';
import { ISaleInvoiceFullRawResponse } from '@/shared/components/invoices/sale-invoice-full-response.model';
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { PUBLIC_SALE_INVOICES_API_ROUTES } from '../constants';
@Injectable({ providedIn: 'root' })
export class PublicSaleInvoicesService {
constructor(private http: HttpClient) {}
private apiRoutes = PUBLIC_SALE_INVOICES_API_ROUTES;
getSingle(invoiceId: string): Observable<ISaleInvoiceFullResponse> {
return this.http.get<ISaleInvoiceFullRawResponse>(this.apiRoutes.single(invoiceId));
}
}
@@ -0,0 +1,49 @@
import { defaultBaseStateData, EntityState, EntityStore } from '@/core/state';
import { ISaleInvoiceFullResponse } from '@/domains/consumer/models';
import { inject, Injectable } from '@angular/core';
import { catchError, finalize } from 'rxjs';
import { PublicSaleInvoicesService } from '../services/main.service';
interface PublicSaleInvoiceState extends EntityState<ISaleInvoiceFullResponse> {}
@Injectable({
providedIn: 'root',
})
export class PublicSaleInvoiceStore extends EntityStore<
ISaleInvoiceFullResponse,
PublicSaleInvoiceState
> {
private readonly service = inject(PublicSaleInvoicesService);
constructor() {
super({
...defaultBaseStateData,
});
}
getData(invoiceId: string) {
this.patchState({ loading: true });
this.service
.getSingle(invoiceId)
.pipe(
finalize(() => {
this.patchState({ loading: false });
}),
catchError((error) => {
this.setError(error);
throw error;
})
)
.subscribe((entity) => {
console.log('entity', entity);
this.patchState({ entity });
});
}
override reset(): void {
this.setState({
...defaultBaseStateData,
});
}
}
@@ -0,0 +1 @@
export * from './single.component';
@@ -0,0 +1,3 @@
<div class="p-4">
<shared-sale-invoice-single-view [loading]="loading()" [invoice]="invoice()" />
</div>
@@ -0,0 +1,25 @@
import { SharedSaleInvoiceSingleViewComponent } from '@/shared/components/invoices/sale-invoice-single-view.component';
import pageParamsUtils from '@/utils/page-params.utils';
import { Component, computed, inject, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { PublicSaleInvoiceStore } from '../store/main.store';
@Component({
selector: 'public-saleInvoice',
templateUrl: './single.component.html',
imports: [SharedSaleInvoiceSingleViewComponent],
})
export class PublicSaleInvoiceComponent {
private readonly route = inject(ActivatedRoute);
private readonly store = inject(PublicSaleInvoiceStore);
pageParams = computed(() => pageParamsUtils(this.route));
invoiceId = signal<string>(this.pageParams()['invoiceId']);
readonly invoice = computed(() => this.store.entity());
readonly loading = computed(() => this.store.loading());
ngOnInit() {
this.store.getData(this.invoiceId());
}
}
@@ -30,8 +30,6 @@ import { Button } from 'primeng/button';
position: absolute; position: absolute;
inset: 0; inset: 0;
background: rgba(15, 23, 42, 0.22); background: rgba(15, 23, 42, 0.22);
will-change: opacity;
animation: sheetFadeIn var(--sheet-transition, 130ms cubic-bezier(0.2, 0, 0, 1));
} }
.light-bottomsheet-panel { .light-bottomsheet-panel {
@@ -45,10 +43,7 @@ import { Button } from 'primeng/button';
background: var(--surface-card, #fff); background: var(--surface-card, #fff);
border-radius: 12px 12px 0 0; border-radius: 12px 12px 0 0;
box-shadow: none; box-shadow: none;
transform: translate3d(0, 0, 0);
will-change: transform;
backface-visibility: hidden; backface-visibility: hidden;
animation: sheetSlideUp var(--sheet-transition, 130ms cubic-bezier(0.2, 0, 0, 1));
overflow: hidden; overflow: hidden;
} }
@@ -87,24 +82,6 @@ import { Button } from 'primeng/button';
line-height: 1; line-height: 1;
font-size: 1.25rem; font-size: 1.25rem;
} }
@keyframes sheetSlideUp {
from {
transform: translate3d(0, 100%, 0);
}
to {
transform: translate3d(0, 0, 0);
}
}
@keyframes sheetFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
`, `,
], ],
host: { host: {
@@ -139,7 +116,7 @@ export class SharedLightBottomsheetComponent implements OnInit, OnDestroy, OnCha
constructor( constructor(
private readonly elementRef: ElementRef<HTMLElement>, private readonly elementRef: ElementRef<HTMLElement>,
private readonly renderer: Renderer2, private readonly renderer: Renderer2,
@Inject(DOCUMENT) private readonly document: Document, @Inject(DOCUMENT) private readonly document: Document
) {} ) {}
ngOnInit() { ngOnInit() {
@@ -214,7 +191,7 @@ export class SharedLightBottomsheetComponent implements OnInit, OnDestroy, OnCha
private syncZIndex() { private syncZIndex() {
const host = this.elementRef.nativeElement; const host = this.elementRef.nativeElement;
const siblingDrawers = Array.from( const siblingDrawers = Array.from(
this.document.body.querySelectorAll('.p-drawer'), this.document.body.querySelectorAll('.p-drawer')
) as HTMLElement[]; ) as HTMLElement[];
const drawerZIndexes = siblingDrawers const drawerZIndexes = siblingDrawers
.map((drawer) => Number.parseInt(drawer.style.zIndex || '0', 10)) .map((drawer) => Number.parseInt(drawer.style.zIndex || '0', 10))
@@ -13,8 +13,7 @@
icon="pi pi-print" icon="pi pi-print"
outlined outlined
size="small" size="small"
(click)="printInvoice()" (click)="printInvoice()"></button>
></button>
</ng-template> </ng-template>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div class="listKeyValue"> <div class="listKeyValue">
@@ -29,14 +28,13 @@
</div> </div>
</div> </div>
<p-divider align="center"> اطلاعات پرداخت </p-divider> <p-divider align="center"> اطلاعات پرداخت </p-divider>
<div class="grid md:grid-cols-3 sm:grid-cols-2 sm:gap-4 gap-3 items-center"> <div class="grid items-center gap-3 sm:grid-cols-2 sm:gap-4 md:grid-cols-3">
<app-key-value label="مجموع قابل پرداخت" [value]="invoice.total_amount" type="price" /> <app-key-value label="مجموع قابل پرداخت" [value]="invoice.total_amount" type="price" />
@for (payment of invoice.payments; track $index) { @for (payment of invoice.payments; track $index) {
<app-key-value <app-key-value
[label]="`${payment.payment_method === 'SET_OFF' ? 'تهاتر' : payment.payment_method === 'TERMINAL' ? 'پوز' : 'نقدی/ کارت‌خوان دیگر/ کارت به کارت'}`" [label]="`${payment.payment_method === 'SET_OFF' ? 'تهاتر' : payment.payment_method === 'TERMINAL' ? 'پوز' : 'نقدی/ کارت‌خوان دیگر/ کارت به کارت'}`"
[value]="payment.amount" [value]="payment.amount"
type="price" type="price" />
/>
} }
</div> </div>
@@ -45,14 +43,16 @@
<app-key-value label="کسب و کار" [value]="invoice.pos!.complex.business_activity.name" /> <app-key-value label="کسب و کار" [value]="invoice.pos!.complex.business_activity.name" />
<app-key-value label="مجموعه" [value]="invoice.pos!.complex.name" /> <app-key-value label="مجموعه" [value]="invoice.pos!.complex.name" />
<app-key-value label="پایانه فروش" [value]="invoice.pos!.name" /> <app-key-value label="پایانه فروش" [value]="invoice.pos!.name" />
@if (invoice.consumer_account?.account?.username) {
<app-key-value label="ایجاد کننده" [value]="invoice.consumer_account.account.username" /> <app-key-value label="ایجاد کننده" [value]="invoice.consumer_account.account.username" />
}
</div> </div>
@if (variant !== "customer") { @if (variant !== 'customer') {
<p-divider align="center"> اطلاعات مشتری </p-divider> <p-divider align="center"> اطلاعات مشتری </p-divider>
@if (invoice.customer) { @if (invoice.customer) {
<div class="listKeyValue"> <div class="listKeyValue">
@if (invoice.customer.type === "INDIVIDUAL") { @if (invoice.customer.type === 'INDIVIDUAL') {
<app-key-value label="نوع مشتری" value="حقیقی" /> <app-key-value label="نوع مشتری" value="حقیقی" />
<app-key-value label="نام" [value]="invoice.customer.individual?.first_name" /> <app-key-value label="نام" [value]="invoice.customer.individual?.first_name" />
<app-key-value label="نام خانوادگی" [value]="invoice.customer.individual?.last_name" /> <app-key-value label="نام خانوادگی" [value]="invoice.customer.individual?.last_name" />
@@ -74,7 +74,7 @@
<app-key-value label="کد ثبتی" [value]="invoice.unknown_customer.national_code" /> <app-key-value label="کد ثبتی" [value]="invoice.unknown_customer.national_code" />
</div> </div>
} @else { } @else {
<p class="text-center text-text-color pt-3 pb-5">اطلاعات مشتری ثبت نشده است.</p> <p class="text-text-color pt-3 pb-5 text-center">اطلاعات مشتری ثبت نشده است.</p>
} }
} }
</div> </div>
@@ -88,16 +88,10 @@
[items]="invoice.items" [items]="invoice.items"
[loading]="loading" [loading]="loading"
pageTitle="" pageTitle=""
[showRefresh]="false" [showRefresh]="false">
>
<ng-template #totalAmount let-item> <ng-template #totalAmount let-item>
@if (!item.discount_amount) { @if (!item.discount_amount) {
<span [appPriceMask]="item.total_amount"></span> <span [appPriceMask]="item.total_amount"></span>
} @else {
<!-- <div class="flex flex-col items-end">
<span class="line-through text-muted-color text-xs" [appPriceMask]="item.total_amount"></span>
<span [appPriceMask]="item.total_amount - item.discount_amount"></span>
</div> -->
} }
</ng-template> </ng-template>
</app-page-data-list> </app-page-data-list>
@@ -250,7 +250,7 @@ export class SharedSaleInvoiceSingleViewComponent {
header: 'عنوان', header: 'عنوان',
type: 'nested', type: 'nested',
nestedOption: { nestedOption: {
path: 'good.name', path: 'good_snapshot.name',
}, },
}, },
{ {
@@ -1,8 +1,12 @@
<div class="h-full bg-surface-overlay rounded-lg shadow border border-surface-border p-0! overflow-hidden"> <div
[ngClass]="{
'bg-surface-overlay h-full overflow-hidden rounded-lg p-0!': true,
'border-surface-border border shadow': hasCaption(),
}">
<ng-template #captionTemplate let-isMobileView="isMobileView"> <ng-template #captionTemplate let-isMobileView="isMobileView">
@if (pageTitle || showAdd || filter || showRefresh || moreActions) { @if (pageTitle || showAdd || filter || showRefresh || moreActions) {
<ng-container [ngTemplateOutlet]="caption"> <ng-container [ngTemplateOutlet]="caption">
<div class="flex justify-between items-center gap-4"> <div class="flex items-center justify-between gap-4">
<h5 class="font-bold">{{ pageTitle }}</h5> <h5 class="font-bold">{{ pageTitle }}</h5>
@if (showAdd || filter || showRefresh || moreActions) { @if (showAdd || filter || showRefresh || moreActions) {
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -15,8 +19,7 @@
badgeSeverity="info" badgeSeverity="info"
size="small" size="small"
[badge]="isFiltered ? '1' : undefined" [badge]="isFiltered ? '1' : undefined"
(click)="openFilter()" (click)="openFilter()"></p-button>
></p-button>
} }
@if (showRefresh) { @if (showRefresh) {
<p-button icon="pi pi-refresh" outlined size="small" (click)="refresh()"></p-button> <p-button icon="pi pi-refresh" outlined size="small" (click)="refresh()"></p-button>
@@ -36,8 +39,7 @@
label="{{ addNewCtaLabel }}" label="{{ addNewCtaLabel }}"
icon="pi pi-plus" icon="pi pi-plus"
size="small" size="small"
(click)="openAddForm()" (click)="openAddForm()"></p-button>
></p-button>
} }
} }
</div> </div>
@@ -52,8 +54,7 @@
[description]="emptyPlaceholderDescription" [description]="emptyPlaceholderDescription"
[ctaLabel]="emptyPlaceholderCtaLabel || addNewCtaLabel" [ctaLabel]="emptyPlaceholderCtaLabel || addNewCtaLabel"
[showCTA]="showAdd" [showCTA]="showAdd"
(ctaClick)="openAddForm()" (ctaClick)="openAddForm()"></uikit-empty-state>
></uikit-empty-state>
</ng-template> </ng-template>
<ng-template #paginatorTemplate> <ng-template #paginatorTemplate>
<app-paginator <app-paginator
@@ -61,8 +62,7 @@
[totalRecords]="totalRecords" [totalRecords]="totalRecords"
[perPage]="perPage || 10" [perPage]="perPage || 10"
[loading]="loading" [loading]="loading"
(onChange)="onPage($event)" (onChange)="onPage($event)" />
/>
</ng-template> </ng-template>
@if (!isMobile) { @if (!isMobile) {
@@ -81,7 +81,7 @@
[showDelete]="showDelete" [showDelete]="showDelete"
[showDetails]="showDetails" [showDetails]="showDetails"
[showIndex]="showIndex" [showIndex]="showIndex"
[hasCaption]="!!(pageTitle || showAdd || filter || showRefresh || moreActions)" [hasCaption]="hasCaption()"
[expandable]="expandable" [expandable]="expandable"
[expandableItemKey]="expandableItemKey" [expandableItemKey]="expandableItemKey"
[expandColumns]="expandColumns" [expandColumns]="expandColumns"
@@ -89,8 +89,7 @@
(onEdit)="edit($event)" (onEdit)="edit($event)"
(onDelete)="remove($event)" (onDelete)="remove($event)"
(onDetails)="details($event)" (onDetails)="details($event)"
(onChangePage)="onPage($event)" (onChangePage)="onPage($event)">
>
@if (pageTitle || showAdd || filter || showRefresh || moreActions) { @if (pageTitle || showAdd || filter || showRefresh || moreActions) {
<ng-template #captionBox> <ng-template #captionBox>
<ng-container [ngTemplateOutlet]="captionTemplate" [ngTemplateOutletContext]="{ isMobileView: false }" /> <ng-container [ngTemplateOutlet]="captionTemplate" [ngTemplateOutletContext]="{ isMobileView: false }" />
@@ -117,14 +116,13 @@
[showEdit]="showEdit" [showEdit]="showEdit"
[showDelete]="showDelete" [showDelete]="showDelete"
[showDetails]="showDetails" [showDetails]="showDetails"
[hasCaption]="!!(pageTitle || showAdd || filter || showRefresh || moreActions)" [hasCaption]="hasCaption()"
[expandable]="expandable" [expandable]="expandable"
[expandableItemKey]="expandableItemKey" [expandableItemKey]="expandableItemKey"
[expandColumns]="expandColumns" [expandColumns]="expandColumns"
(onEdit)="edit($event)" (onEdit)="edit($event)"
(onDelete)="remove($event)" (onDelete)="remove($event)"
(onDetails)="details($event)" (onDetails)="details($event)">
>
<ng-template #captionBox> <ng-template #captionBox>
@if (pageTitle || showAdd || filter || showRefresh || moreActions) { @if (pageTitle || showAdd || filter || showRefresh || moreActions) {
<ng-container [ngTemplateOutlet]="captionTemplate" [ngTemplateOutletContext]="{ isMobileView: true }" /> <ng-container [ngTemplateOutlet]="captionTemplate" [ngTemplateOutletContext]="{ isMobileView: true }" />
@@ -145,8 +143,7 @@
(onHide)="closeFilter()" (onHide)="closeFilter()"
header="فیلتر" header="فیلتر"
class="contnet" class="contnet"
styleClass="!w-[80vw] md:!w-96 md:!max-w-96 !max-w-sm" styleClass="!w-[80vw] md:!w-96 md:!max-w-96 !max-w-sm">
>
<hr class="mt-0!" /> <hr class="mt-0!" />
<div class="pt-2"> <div class="pt-2">
<ng-container [ngTemplateOutlet]="filter" [ngTemplateOutletContext]="{ $implicit: columns }"> </ng-container> <ng-container [ngTemplateOutlet]="filter" [ngTemplateOutletContext]="{ $implicit: columns }"> </ng-container>
@@ -4,6 +4,7 @@ import { jalaliDiff } from '@/utils';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import {
Component, Component,
computed,
ContentChild, ContentChild,
ElementRef, ElementRef,
EventEmitter, EventEmitter,
@@ -92,7 +93,7 @@ export interface IColumn<T = any> {
export class PageDataListComponent<I = any> { export class PageDataListComponent<I = any> {
constructor( constructor(
private host: ElementRef, private host: ElementRef,
private renderer: Renderer2, private renderer: Renderer2
) {} ) {}
@Input({ required: true }) pageTitle!: string; @Input({ required: true }) pageTitle!: string;
@@ -138,12 +139,16 @@ export class PageDataListComponent<I = any> {
filterDrawerVisible = signal<boolean>(false); filterDrawerVisible = signal<boolean>(false);
expandedRows: { [key: string]: boolean } = {}; expandedRows: { [key: string]: boolean } = {};
hasCaption = computed(
() => !!(this.pageTitle || this.showAdd || this.filter || this.showRefresh || this.moreActions)
);
ngOnInit() { ngOnInit() {
if (this.height) if (this.height)
this.renderer.setStyle( this.renderer.setStyle(
this.host.nativeElement, this.host.nativeElement,
'height', 'height',
this.fullHeight ? '100cqmin' : this.height, this.fullHeight ? '100cqmin' : this.height
); );
// if (this.fullHeight) { // if (this.fullHeight) {
// } // }
+1 -2
View File
@@ -23,6 +23,5 @@ export const appRoutes: Routes = [
path: 'auth', path: 'auth',
loadComponent: () => import('@/modules/auth/pages/auth.component').then((m) => m.AuthComponent), loadComponent: () => import('@/modules/auth/pages/auth.component').then((m) => m.AuthComponent),
}, },
{ path: 'notfound', component: Notfound }, { path: '**', component: Notfound },
{ path: '**', redirectTo: '/notfound' },
]; ];
+3 -2
View File
@@ -1,5 +1,6 @@
import { posAuthNamedRoutes } from '@/domains/pos/modules/auth/constants'; import { posAuthNamedRoutes } from '@/domains/pos/modules/auth/constants';
import { POS_ROUTES } from '@/domains/pos/routes'; import { POS_ROUTES } from '@/domains/pos/routes';
import { PUBLIC_SALE_INVOICES_ROUTES } from '@/modules/saleInvoices/constants';
import { Notfound } from '@/pages/notfound/notfound.component'; import { Notfound } from '@/pages/notfound/notfound.component';
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
@@ -14,6 +15,6 @@ export const appRoutes: Routes = [
...posAuthNamedRoutes.login, ...posAuthNamedRoutes.login,
path: 'auth', path: 'auth',
}, },
{ path: 'notfound', component: Notfound }, ...PUBLIC_SALE_INVOICES_ROUTES,
{ path: '**', redirectTo: '/notfound' }, { path: '**', component: Notfound },
]; ];