Compare commits

..

2 Commits

Author SHA1 Message Date
ahasani 6ad1a73c16 feat(pos): add shop and statistics modules with components, services, and routing
- Implemented shop module with root component, loading state, and invoice handling.
- Created statistics module with invoice type card component, API routes, and data models.
- Added season picker component for selecting year and season.
- Integrated services for fetching statistics data and managing state.
- Established routing for statistics and shop views.
2026-05-23 18:09:44 +03:30
ahasani 8c07dc7c3f 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.
2026-05-21 21:35:34 +03:30
121 changed files with 1802 additions and 578 deletions
+411 -37
View File
@@ -4,16 +4,252 @@
- 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.
### Current service mapping expectation
- `app_default` on host port `8090` - `app_default` on host port `8090`
- `app_tis` on host port `8091` - `app_tis` on host port `8091`
---
## Tenant Build Rules ## Tenant Build Rules
- `default` tenant currently builds via `ng build` and outputs to `dist/production`. - `default` tenant currently builds via `ng build` and outputs to `dist/production`.
@@ -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`
For:
- `type === 'number'`
- `type === 'price'`
Requirements:
- Normalize Persian/Arabic digits to English digits. - Normalize Persian/Arabic digits to English digits.
- Allow only digits and `.`. - Allow only digits and `.`.
- Support `fixed` precision formatting when provided. - 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,13 +353,17 @@ 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:
Add its form control factory in:
- `src/app/shared/constants/fields/index.ts` - `src/app/shared/constants/fields/index.ts`
- `fieldControl` entry shape:
`fieldControl` entry shape:
- key must match form control name - key must match form control name
- return tuple: `[defaultValue, validators]` - return tuple: `[defaultValue, validators]`
@@ -104,34 +376,65 @@ 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:
- `good-list.const.ts`
- `sku-list.const.ts`
- `category-list.const.ts`
### Requirements
Each config implements `IListConfig`:
- `pageTitle`
- `addNewCtaLabel`
- `emptyPlaceholderTitle`
- `emptyPlaceholderDescription`
- `columns`
### Usage
```ts ```ts
@Input() header: IColumn[] = goodListConfig.columns; @Input() header: IColumn[] = goodListConfig.columns;
listConfig = goodListConfig; listConfig = goodListConfig;
``` ```
- Any domain needing a list config imports from `@/shared/constants/list-configs` — no cross-domain dependencies.
- **Do not** define configs inside domains or duplicate them across modules. ### 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 ```ts
setBreadcrumb() { setBreadcrumb() {
this.breadcrumbService.setItems([ this.breadcrumbService.setItems([
@@ -140,19 +443,90 @@ example: (value = '', isRequired = true): ControlConfig => [
]); ]);
} }
``` ```
- 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.
+4
View File
@@ -209,6 +209,8 @@
".webp": "file" ".webp": "file"
}, },
"styles": [ "styles": [
"node_modules/flatpickr-wrap/dist/flatpickr.css",
"node_modules/flatpickr-wrap/dist/themes/confetti.css",
"src/assets/styles.scss" "src/assets/styles.scss"
], ],
"tsConfig": "tsconfig.app.json" "tsConfig": "tsconfig.app.json"
@@ -250,6 +252,8 @@
"inlineStyleLanguage": "scss", "inlineStyleLanguage": "scss",
"karmaConfig": "karma.conf.js", "karmaConfig": "karma.conf.js",
"styles": [ "styles": [
"node_modules/flatpickr-wrap/dist/flatpickr.css",
"node_modules/flatpickr-wrap/dist/themes/confetti.css",
"src/assets/styles.scss" "src/assets/styles.scss"
], ],
"tsConfig": "tsconfig.spec.json" "tsConfig": "tsconfig.spec.json"
+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",
+314 -182
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 },
]; ];
@@ -1,5 +1,5 @@
import ISummary from '@/core/models/summary'; import ISummary from '@/core/models/summary';
import { TCustomerInfo, TPosOrderGoodPayload } from '@/domains/pos/modules/landing/models'; import { TCustomerInfo, TPosOrderGoodPayload } from '@/domains/pos/modules/shop/models';
export interface ISalesInvoicesRawResponse { export interface ISalesInvoicesRawResponse {
id: string; id: string;
@@ -5,11 +5,12 @@ import { RouterLink } from '@angular/router';
import { Button } from 'primeng/button'; import { Button } from 'primeng/button';
import { Drawer } from 'primeng/drawer'; import { Drawer } from 'primeng/drawer';
import { Ripple } from 'primeng/ripple'; import { Ripple } from 'primeng/ripple';
import config from 'src/config';
import { posAboutNamedRoutes } from '../../modules/about/constants'; import { posAboutNamedRoutes } from '../../modules/about/constants';
import { posConfigNamedRoutes } from '../../modules/configs/constants'; import { posConfigNamedRoutes } from '../../modules/configs/constants';
import { PosGoodsManagementRoutes } from '../../modules/goodsManagement/constants'; import { PosGoodsManagementRoutes } from '../../modules/goodsManagement/constants';
import { posSaleInvoicesNamedRoutes } from '../../modules/saleInvoices/constants'; import { posSaleInvoicesNamedRoutes } from '../../modules/saleInvoices/constants';
import { PosShopNamedRoutes } from '../../modules/shop/constants/routes';
import { StatisticsRoutes } from '../../modules/statistics/constants';
import { posSupportNamedRoutes } from '../../modules/support/constants'; import { posSupportNamedRoutes } from '../../modules/support/constants';
import { PosInfoStore } from '../../store'; import { PosInfoStore } from '../../store';
@@ -37,10 +38,15 @@ export class PosMainMenuSidebarComponent extends AbstractDialog {
readonly menuItems = [ readonly menuItems = [
{ {
label: 'فروش', label: 'صفحه اصلی',
routerLink: config.isPosApplication ? '/' : '/pos', routerLink: StatisticsRoutes.statistics.meta.pagePath!(),
icon: 'pi pi-fw pi-home', icon: 'pi pi-fw pi-home',
}, },
{
label: 'فروش',
routerLink: PosShopNamedRoutes.shop.meta.pagePath!(),
icon: 'pi pi-fw pi-cart-arrow-down',
},
{ {
label: 'فاکتورها', label: 'فاکتورها',
routerLink: posSaleInvoicesNamedRoutes.saleInvoices.meta.pagePath!(), routerLink: posSaleInvoicesNamedRoutes.saleInvoices.meta.pagePath!(),
@@ -9,7 +9,7 @@ import { Message } from 'primeng/message';
import { catchError, finalize } from 'rxjs'; import { catchError, finalize } from 'rxjs';
import { COOKIE_KEYS } from 'src/assets/constants'; import { COOKIE_KEYS } from 'src/assets/constants';
import { IPosAccessibleResponse } from '../../models/pos.io'; import { IPosAccessibleResponse } from '../../models/pos.io';
import { PosService } from '../../modules/landing/services/main.service'; import { PosService } from '../../modules/shop/services/main.service';
@Component({ @Component({
selector: 'pos-choose-cards', selector: 'pos-choose-cards',
@@ -35,7 +35,7 @@ export class PosChooseCardsComponent {
catchError((err) => { catchError((err) => {
this.error.set(err); this.error.set(err);
throw err; throw err;
}), })
) )
.subscribe((res) => { .subscribe((res) => {
this.items.set(res?.data || []); this.items.set(res?.data || []);
@@ -1,8 +1,8 @@
<div class="w-full h-full flex items-center justify-center p-4"> <div class="flex h-full w-full items-center justify-center p-4">
<app-card-data cardTitle="تنظیمات فاکتور" class="w-full"> <app-card-data cardTitle="تنظیمات فاکتور" class="w-full">
<p-message variant="text" severity="info" icon="pi pi-info-circle"> <p-message variant="text" severity="info" icon="pi pi-info-circle">
هریک از موارد زیر را که می‌خواهید در چاپ فاکتور نمایش داده‌ شوند را انتخاب کنید هریک از موارد زیر را که می‌خواهید در چاپ فاکتور نمایش داده‌ شوند را انتخاب کنید
</p-message> </p-message>
<pos-config-print-form class="block w-full mt-6" (onClose)="returnToMainPage()" (onSubmit)="returnToMainPage()" /> <pos-config-print-form class="mt-6 block w-full" (onClose)="returnToMainPage()" (onSubmit)="returnToMainPage()" />
</app-card-data> </app-card-data>
</div> </div>
@@ -1,4 +1,5 @@
import { AppCardComponent } from '@/shared/components'; import { AppCardComponent } from '@/shared/components';
import { UikitFlatpickrJalaliComponent } from '@/uikit';
import { Component, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Message } from 'primeng/message'; import { Message } from 'primeng/message';
@@ -7,7 +8,7 @@ import { PosConfigPrintFormComponent } from '../components/print/form.component'
@Component({ @Component({
selector: 'pos-config-page', selector: 'pos-config-page',
templateUrl: './root.component.html', templateUrl: './root.component.html',
imports: [PosConfigPrintFormComponent, AppCardComponent, Message], imports: [PosConfigPrintFormComponent, AppCardComponent, Message, UikitFlatpickrJalaliComponent],
}) })
export class PosConfigPageComponent { export class PosConfigPageComponent {
private readonly router = inject(Router); private readonly router = inject(Router);
@@ -1,28 +0,0 @@
<div class="flex items-center gap-2 justify-between">
<div class="flex flex-col">
<span class="font-bold text-lg">سفارش #{{ heldOrder.orderNumber }}</span>
<span class="text-sm text-muted-color">
{{ heldOrder.orderItems.length }} کالا -
<span [appPriceMask]="heldOrder.totalAmount"></span>
</span>
</div>
<div class="flex items-center gap-2">
<button
pButton
size="small"
type="button"
label="بارگذاری"
icon="pi pi-download"
(click)="loadHeldOrder(heldOrder.id)"
></button>
<button
pButton
size="small"
type="button"
label="حذف"
icon="pi pi-trash"
severity="danger"
(click)="cancelHeldOrder(heldOrder.id)"
></button>
</div>
</div>
@@ -1,57 +0,0 @@
import { ToastService } from '@/core/services/toast.service';
import { IPosHeldOrderResponse } from '@/modules/pos/models';
import { ConfirmationDialogService } from '@/shared/components/confirmationDialog/confirmation-dialog.service';
import { PriceMaskDirective } from '@/shared/directives';
import { Component, EventEmitter, Input, Output, signal } from '@angular/core';
import { ButtonDirective } from 'primeng/button';
import { PosService } from '../../services/main.service';
@Component({
selector: 'app-held-order-item',
templateUrl: './held-order-item.component.html',
imports: [PriceMaskDirective, ButtonDirective],
})
export class HeldOrderItemComponent {
@Input() heldOrder!: IPosHeldOrderResponse;
@Input() posId!: number;
@Output() onCancel = new EventEmitter<number>();
@Output() onLoad = new EventEmitter<number>();
constructor(
private readonly posService: PosService,
private readonly confirmationService: ConfirmationDialogService,
private readonly toastService: ToastService,
) {}
actionLoading = signal<boolean>(false);
cancelHeldOrder(heldOrderId: number) {
this.confirmationService.confirm({
message: `آیا از لغو سفارش شماره‌ی #${this.heldOrder.orderNumber} اطمینان دارید؟`,
header: 'لغو سفارش موقت',
acceptLabel: 'بله',
rejectLabel: 'خیر',
accept: () => {
this.actionLoading.set(true);
// this.posService.cancelOrder(this.posId, heldOrderId).subscribe({
// next: () => {
// this.onCancel.emit(heldOrderId);
// this.actionLoading.set(false);
// this.toastService.success({
// text: `سفارش شماره‌ی #${this.heldOrder.orderNumber} با موفقیت لغو شد.`,
// });
// },
// error: () => {
// this.actionLoading.set(false);
// },
// });
},
reject: () => {},
});
}
loadHeldOrder(heldOrderId: number) {
this.onLoad.emit(heldOrderId);
}
}
@@ -1,19 +0,0 @@
<div class="d-flex flex-col gap-4">
<div class="flex items-center justify-between shrink-0">
<div class="flex items-center gap-2 text-muted-color">
<i class="pi pi-shopping-cart"></i>
<span class="text-base font-bold">سفارش‌های نگه‌داشته شده</span>
</div>
</div>
@if (!heldOrders()?.length) {
<div class="grow flex flex-col gap-5 justify-center items-center text-center text-muted-color">
<span class="text-base text-muted-color pt-4">هیچ سفارش نگه‌داشته شده‌ای وجود ندارد.</span>
</div>
} @else {
<div class="flex flex-col gap-2 mt-3">
@for (heldOrder of heldOrders(); track heldOrder.id) {
<app-held-order-item [heldOrder]="heldOrder" (onCancel)="removeHeldOrder($event)" />
}
</div>
}
</div>
@@ -1,39 +0,0 @@
import { Maybe } from '@/core';
import { IPosHeldOrderResponse } from '@/modules/pos/models';
import { Component, inject, signal } from '@angular/core';
import { PosService } from '../../services/main.service';
import { HeldOrderItemComponent } from './held-order-item.component';
@Component({
selector: 'app-held-orders',
templateUrl: './held-orders.component.html',
imports: [HeldOrderItemComponent],
})
export class HeldOrdersComponent {
private readonly service = inject(PosService);
heldOrders = signal<Maybe<IPosHeldOrderResponse[]>>(null);
loading = signal<boolean>(false);
ngOnInit() {
this.getHeldOrders();
}
getHeldOrders() {
this.loading.set(true);
// this.service.getHeldOrders().subscribe({
// next: (res) => {
// this.heldOrders.set(res.data);
// this.loading.set(false);
// },
// error: () => {
// this.loading.set(false);
// },
// });
}
removeHeldOrder(orderId: number) {
const currentOrders = this.heldOrders() || [];
this.heldOrders.set(currentOrders.filter((order) => order.id !== orderId));
}
}
@@ -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();
}
@@ -50,7 +50,7 @@ export class PosSaleInvoicesFilterDrawerComponent implements OnChanges {
customer_mobile: this.fb.control<string>(''), customer_mobile: this.fb.control<string>(''),
customer_national_id: this.fb.control<string>(''), customer_national_id: this.fb.control<string>(''),
customer_economic_code: this.fb.control<string>(''), customer_economic_code: this.fb.control<string>(''),
status: this.fb.control<string | null>(null), status: this.fb.control<'NOT_SEND' | 'SUCCESS' | 'FAILURE' | 'QUEUED' | null>(null),
total_amount_from: this.fb.control<number | null>(null), total_amount_from: this.fb.control<number | null>(null),
total_amount_to: this.fb.control<number | null>(null), total_amount_to: this.fb.control<number | null>(null),
}); });
@@ -74,7 +74,7 @@ export class PosSaleInvoicesFilterDrawerComponent implements OnChanges {
total_amount_from: this.value.total_amount_from ?? null, total_amount_from: this.value.total_amount_from ?? null,
total_amount_to: this.value.total_amount_to ?? null, total_amount_to: this.value.total_amount_to ?? null,
}, },
{ emitEvent: false }, { emitEvent: false }
); );
} }
} }
@@ -1,7 +1,8 @@
import { POS_ROUTES } from '@/domains/pos/routes'; import { POS_ROUTES } from '@/domains/pos/routes';
import { InnerPagesHeaderComponent } from '@/shared/components/innerPagesHeader/inner-pages-header.component'; import { InnerPagesHeaderComponent } from '@/shared/components/innerPagesHeader/inner-pages-header.component';
import { gregorianToJalali } from '@/utils';
import { Component, computed, inject, signal } from '@angular/core'; import { Component, computed, inject, signal } from '@angular/core';
import { Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { Button, ButtonDirective } from 'primeng/button'; import { Button, ButtonDirective } from 'primeng/button';
import { Chip } from 'primeng/chip'; import { Chip } from 'primeng/chip';
import { Skeleton } from 'primeng/skeleton'; import { Skeleton } from 'primeng/skeleton';
@@ -32,6 +33,7 @@ import { SaleInvoiceCardComponent } from './sale-invoice-card.component';
export class PosSaleInvoiceListComponent { export class PosSaleInvoiceListComponent {
private readonly service = inject(PosSaleInvoicesService); private readonly service = inject(PosSaleInvoicesService);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
loading = signal(true); loading = signal(true);
items = signal<IPosSaleInvoicesSummaryResponse[]>([]); items = signal<IPosSaleInvoicesSummaryResponse[]>([]);
@@ -57,16 +59,28 @@ export class PosSaleInvoiceListComponent {
.filter(([, value]) => value !== undefined && value !== null && value !== '') .filter(([, value]) => value !== undefined && value !== null && value !== '')
.map(([key, value]) => { .map(([key, value]) => {
const typedKey = key as keyof IPosSaleInvoicesFilterDto; const typedKey = key as keyof IPosSaleInvoicesFilterDto;
const formattedValue = let formattedValue = '';
typedKey === 'status' && value === 'NOT_SEND' if (typedKey === 'status') {
formattedValue =
value === 'NOT_SEND'
? 'ارسال نشده' ? 'ارسال نشده'
: typedKey === 'status' && value === 'SUCCESS' : value === 'SUCCESS'
? 'موفق' ? 'موفق'
: typedKey === 'status' && value === 'FAILURE' : value === 'FAILURE'
? 'ناموفق' ? 'ناموفق'
: typedKey === 'status' && value === 'QUEUED' : value === 'QUEUED'
? 'در انتظار ارسال' ? 'در انتظار ارسال'
: String(value); : String(value);
} else if (
key === 'invoice_date_from' ||
key === 'invoice_date_to' ||
key === 'created_at_from' ||
key === 'created_at_to'
) {
formattedValue = gregorianToJalali(value);
} else {
formattedValue = String(value);
}
return { key: typedKey, label: labels[typedKey] ?? typedKey, value: formattedValue }; return { key: typedKey, label: labels[typedKey] ?? typedKey, value: formattedValue };
}); });
}); });
@@ -74,6 +88,7 @@ export class PosSaleInvoiceListComponent {
backRoute = POS_ROUTES.path || '/'; backRoute = POS_ROUTES.path || '/';
ngOnInit() { ngOnInit() {
this.hydrateFiltersFromQueryParams();
this.getData(); this.getData();
} }
@@ -96,6 +111,7 @@ export class PosSaleInvoiceListComponent {
onFilterApply(query: IPosSaleInvoicesFilterDto) { onFilterApply(query: IPosSaleInvoicesFilterDto) {
this.filters.set(query); this.filters.set(query);
this.syncFiltersToQueryParams();
this.getData(); this.getData();
} }
@@ -103,10 +119,57 @@ export class PosSaleInvoiceListComponent {
const next = { ...this.filters() }; const next = { ...this.filters() };
delete next[key]; delete next[key];
this.filters.set(next); this.filters.set(next);
this.syncFiltersToQueryParams();
this.getData(); this.getData();
} }
toSinglePage(item: IPosSaleInvoicesResponse) { toSinglePage(item: IPosSaleInvoicesResponse) {
this.router.navigateByUrl(posSaleInvoicesNamedRoutes.saleInvoice.meta.pagePath!(item.id)); this.router.navigateByUrl(posSaleInvoicesNamedRoutes.saleInvoice.meta.pagePath!(item.id));
} }
private hydrateFiltersFromQueryParams() {
const queryParams = this.route.snapshot.queryParams;
const keys: (keyof IPosSaleInvoicesFilterDto)[] = [
'invoice_date_from',
'invoice_date_to',
'created_at_from',
'created_at_to',
'code',
'customer_name',
'customer_mobile',
'customer_national_id',
'customer_economic_code',
'status',
'total_amount_from',
'total_amount_to',
];
const parsed = keys.reduce<IPosSaleInvoicesFilterDto>((acc, key) => {
const value = queryParams[key];
if (value !== undefined && value !== null && value !== '') {
acc[key] = value;
}
return acc;
}, {});
this.filters.set(parsed);
}
private syncFiltersToQueryParams() {
const queryParams = Object.entries(this.filters()).reduce<Record<string, string>>(
(acc, [key, value]) => {
if (value !== undefined && value !== null && value !== '') {
acc[key] = String(value);
}
return acc;
},
{}
);
this.router.navigate([], {
relativeTo: this.route,
queryParams,
replaceUrl: true,
});
}
} }
@@ -10,7 +10,7 @@ export interface IPosSaleInvoicesFilterDto {
customer_mobile?: string; customer_mobile?: string;
customer_national_id?: string; customer_national_id?: string;
customer_economic_code?: string; customer_economic_code?: string;
status?: string; status?: 'NOT_SEND' | 'SUCCESS' | 'FAILURE' | 'QUEUED';
total_amount?: number | null; total_amount?: number | null;
total_amount_from?: number | null; total_amount_from?: number | null;
total_amount_to?: number | null; total_amount_to?: number | null;
@@ -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)
); );
} }
@@ -0,0 +1,20 @@
import { NamedRoutes } from '@/core';
import { Routes } from '@angular/router';
import config from 'src/config';
export type TShopRouteNames = 'shop';
const baseRoute = `${config.isPosApplication ? '' : '/pos'}/shop`;
export const PosShopNamedRoutes: NamedRoutes<TShopRouteNames> = {
shop: {
path: 'shop',
loadComponent: () => import('../../views/root.component').then((m) => m.PosShopComponent),
meta: {
title: 'فروشگاه',
pagePath: () => baseRoute,
},
},
};
export const POS_SHOP_ROUTES: Routes = [PosShopNamedRoutes.shop];
@@ -14,7 +14,7 @@ import { IPosOrderResponse } from '../models';
import { PosLandingStore } from '../store/main.store'; import { PosLandingStore } from '../store/main.store';
@Component({ @Component({
selector: 'pos-landing', selector: 'pos-shop',
templateUrl: './root.component.html', templateUrl: './root.component.html',
host: { class: 'grow overflow-hidden' }, host: { class: 'grow overflow-hidden' },
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -29,7 +29,7 @@ import { PosLandingStore } from '../store/main.store';
PosOrderSubmittedDialogComponent, PosOrderSubmittedDialogComponent,
], ],
}) })
export class PosLandingComponent extends AbstractIsMobileComponent { export class PosShopComponent extends AbstractIsMobileComponent {
private readonly infoStore = inject(PosInfoStore); private readonly infoStore = inject(PosInfoStore);
private readonly landingStore = inject(PosLandingStore); private readonly landingStore = inject(PosLandingStore);
@@ -0,0 +1 @@
export * from './invoice-type-card.component';
@@ -0,0 +1,33 @@
<div
class="text-surface-700 relative flex flex-col gap-4 overflow-hidden rounded-2xl border px-4 py-4 pe-2"
[style.backgroundColor]="preparedInfo().bgColor"
[style.borderColor]="preparedInfo().borderColor"
(click)="showDetails()">
<div class="absolute top-0 right-0 h-full w-1" [style.backgroundColor]="preparedInfo().borderColor"></div>
<div class="text-surface-700 flex items-center justify-between gap-2">
<div class="text-surface-700 flex items-center gap-2">
<span
class="text-surface-700 flex h-8 w-8 items-center justify-center rounded-full border"
[style.borderColor]="preparedInfo().borderColor"
[style.backgroundColor]="preparedInfo().borderColor + '33'">
<i class="text-sm" [ngClass]="'pi pi-' + preparedInfo().icon" [style.color]="preparedInfo().borderColor"></i>
</span>
<span class="text-surface-700 text-sm font-semibold">{{ preparedInfo().title }}</span>
</div>
<span
class="text-surface-700 rounded-full border px-2 py-1 text-xs font-bold"
[style.borderColor]="preparedInfo().borderColor">
{{ count || 0 }} عدد
</span>
</div>
<div class="text-surface-700 grid grid-cols-1 gap-2">
<div class="flex items-center gap-2">
<div class="text-surface-700/80 text-xs">مبلغ کل:</div>
<div class="text-surface-700 mt-1 text-sm font-bold">{{ formattedTotalAmount }}</div>
</div>
<div class="flex items-center gap-2">
<div class="text-surface-700/80 text-xs">مالیات کل:</div>
<div class="text-surface-700 mt-1 text-sm font-bold">{{ formattedTotalTax }}</div>
</div>
</div>
</div>
@@ -0,0 +1,77 @@
import { formatWithCurrency } from '@/utils';
import { CommonModule } from '@angular/common';
import { Component, computed, EventEmitter, Input, Output } from '@angular/core';
@Component({
selector: 'pos-statistics-invoice-type-card',
templateUrl: 'invoice-type-card.component.html',
imports: [CommonModule],
})
export class PosStatisticsInvoiceTypeCardComponent {
@Input() type: 'all' | 'success' | 'failure' | 'notSended' | 'pending' | 'credit' = 'all';
@Input() count?: number = 0;
@Input() totalAmount?: number = 0;
@Input() totalTaxAmount?: number = 0;
@Input() loading = false;
@Input() invoicesRoute = '';
@Output() onClick = new EventEmitter<void>();
get formattedTotalAmount() {
return formatWithCurrency(this.totalAmount || 0);
}
get formattedTotalTax() {
return formatWithCurrency(this.totalTaxAmount || 0);
}
readonly preparedInfo = computed(() => {
switch (this.type) {
case 'all':
return {
title: 'همه',
icon: 'close',
bgColor: '#6B72801A',
borderColor: '#6B7280',
};
case 'success':
return {
title: 'تایید شده',
icon: 'check',
bgColor: '#22C55E1A',
borderColor: '#22C55E',
};
case 'failure':
return {
title: 'خطادار',
icon: 'times',
bgColor: '#EF44441A',
borderColor: '#EF4444',
};
case 'notSended':
return {
title: 'ارسال نشده',
icon: 'clock',
bgColor: '#F59E0B1A',
borderColor: '#F59E0B',
};
case 'pending':
return {
title: 'انتظار ارسال',
icon: 'clock',
bgColor: '#3B82F61A',
borderColor: '#3B82F6',
};
case 'credit':
return {
title: 'اعتبار',
icon: 'credit-card',
bgColor: '#8B5CF61A',
borderColor: '#8B5CF6',
};
}
});
showDetails() {
this.onClick.emit();
}
}
@@ -0,0 +1,5 @@
const baseUrl = () => `/api/v1/pos/statistics`;
export const POS_STATISTICS_API_ROUTES = {
getAll: () => `${baseUrl()}/sale-invoices`,
};
@@ -0,0 +1,2 @@
export * from './apiRoutes/index';
export * from './routes/index';
@@ -0,0 +1,21 @@
import { NamedRoutes } from '@/core';
import { Routes } from '@angular/router';
import config from 'src/config';
export type TStatisticsRouteNames = 'statistics';
const baseRoute = `${config.isPosApplication ? '' : '/pos'}`;
export const StatisticsRoutes: NamedRoutes<TStatisticsRouteNames> = {
statistics: {
path: '',
loadComponent: () =>
import('../../views/root.component').then((m) => m.PosStatisticsRootComponent),
meta: {
title: 'داشبودر',
pagePath: () => baseRoute,
},
},
};
export const POS_STATISTICS_ROUTES: Routes = [StatisticsRoutes.statistics];
@@ -0,0 +1 @@
export * from './io';
+15
View File
@@ -0,0 +1,15 @@
export interface IPosStatisticsRawResponse {
all: IPosStatisticsItem;
success: IPosStatisticsItem;
failure: IPosStatisticsItem;
pending: IPosStatisticsItem;
notSended: IPosStatisticsItem;
credit: IPosStatisticsItem;
}
export interface IPosStatisticsResponse extends IPosStatisticsRawResponse {}
interface IPosStatisticsItem {
count: number;
total_amount: number;
total_tax: number;
}
@@ -0,0 +1,18 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { POS_STATISTICS_API_ROUTES } from '../constants';
import { IPosStatisticsRawResponse, IPosStatisticsResponse } from '../models';
@Injectable({ providedIn: 'root' })
export class PosStatisticsService {
constructor(private http: HttpClient) {}
private apiRoutes = POS_STATISTICS_API_ROUTES;
getAll(fromDate?: Date): Observable<IPosStatisticsResponse> {
return this.http.get<IPosStatisticsRawResponse>(this.apiRoutes.getAll(), {
params: { from_date: fromDate?.toISOString() || '' },
});
}
}
@@ -0,0 +1,50 @@
import { defaultBaseStateData, EntityState, EntityStore } from '@/core/state';
import { inject, Injectable } from '@angular/core';
import { catchError, finalize } from 'rxjs';
import { IPosStatisticsResponse } from '../models';
import { PosStatisticsService } from '../services/main.service';
interface PosStatisticsState extends EntityState<IPosStatisticsResponse> {
lastFromDate: Date;
}
@Injectable({
providedIn: 'root',
})
export class PosStatisticsStore extends EntityStore<IPosStatisticsResponse, PosStatisticsState> {
private readonly service = inject(PosStatisticsService);
constructor() {
super({
...defaultBaseStateData,
lastFromDate: new Date(),
});
}
getData(fromDate?: Date) {
if (fromDate && fromDate.getTime() === this._state().lastFromDate.getTime()) {
return;
}
this.patchState({ loading: true, lastFromDate: fromDate || new Date() });
this.service
.getAll(fromDate)
.pipe(
finalize(() => {
this.patchState({ loading: false });
}),
catchError((error) => {
this.setError(error);
throw error;
})
)
.subscribe((entity) => {
this.patchState({ entity });
});
}
override reset(): void {
this.setState({
...defaultBaseStateData,
lastFromDate: new Date(),
});
}
}
@@ -0,0 +1 @@
export * from './root.component';
@@ -0,0 +1,57 @@
<div class="flex flex-col gap-6 p-4">
<season-picker (onChange)="onDateChanged($event)" />
<div class="grid grid-cols-2 gap-4">
@if (loading()) {
@for (_ of [0, 1, 2, 3, 4, 5]; track $index) {
<div class="h-32 w-full">
<p-skeleton width="100%" height="100%"></p-skeleton>
</div>
}
} @else {
<pos-statistics-invoice-type-card
type="all"
[loading]="loading()"
[totalAmount]="item()?.all?.total_amount"
[totalTaxAmount]="item()?.all?.total_tax"
[count]="item()?.all?.count"
(onClick)="toInvoicesPage('all')" />
<pos-statistics-invoice-type-card
type="success"
[loading]="loading()"
[totalAmount]="item()?.success?.total_amount"
[totalTaxAmount]="item()?.success?.total_tax"
[count]="item()?.success?.count"
(onClick)="toInvoicesPage('success')" />
<pos-statistics-invoice-type-card
type="failure"
[loading]="loading()"
[totalAmount]="item()?.failure?.total_amount"
[totalTaxAmount]="item()?.failure?.total_tax"
[count]="item()?.failure?.count"
(onClick)="toInvoicesPage('failure')" />
<pos-statistics-invoice-type-card
type="notSended"
[loading]="loading()"
[totalAmount]="item()?.notSended?.total_amount"
[totalTaxAmount]="item()?.notSended?.total_tax"
[count]="item()?.notSended?.count"
(onClick)="toInvoicesPage('notSended')" />
<pos-statistics-invoice-type-card
type="pending"
[loading]="loading()"
[totalAmount]="item()?.pending?.total_amount"
[totalTaxAmount]="item()?.pending?.total_tax"
[count]="item()?.pending?.count"
(onClick)="toInvoicesPage('pending')" />
<pos-statistics-invoice-type-card
type="credit"
[loading]="loading()"
[totalAmount]="item()?.credit?.total_amount"
[totalTaxAmount]="item()?.credit?.total_tax"
[count]="item()?.credit?.count" />
}
</div>
<div class="fixed inset-x-0 bottom-0 p-4">
<button pButton [routerLink]="shopRoute" size="large" class="w-full">ایجاد صورت‌حساب جدید</button>
</div>
</div>
@@ -0,0 +1,76 @@
import { SeasonPickerComponent } from '@/shared/components/seasonPicker/season-picker.component';
import { formatGregorian, gregorianToJalali, parseJalali } from '@/utils';
import { Component, computed, inject, signal } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { ButtonDirective } from 'primeng/button';
import { Skeleton } from 'primeng/skeleton';
import { posSaleInvoicesNamedRoutes } from '../../saleInvoices/constants';
import { IPosSaleInvoicesFilterDto } from '../../saleInvoices/models';
import { PosShopNamedRoutes } from '../../shop/constants/routes';
import { PosStatisticsInvoiceTypeCardComponent } from '../components';
import { PosStatisticsStore } from '../store/main.store';
@Component({
selector: 'pos-statistics-root',
templateUrl: './root.component.html',
imports: [
PosStatisticsInvoiceTypeCardComponent,
Skeleton,
SeasonPickerComponent,
ButtonDirective,
RouterLink,
],
})
export class PosStatisticsRootComponent {
private readonly store = inject(PosStatisticsStore);
private readonly router = inject(Router);
readonly item = computed(() => this.store.entity());
readonly loading = computed(() => this.store.loading());
readonly shopRoute = PosShopNamedRoutes.shop.meta.pagePath!();
selectedDate = signal(new Date());
ngOnInit() {
this.getData(this.selectedDate());
}
onDateChanged(date: Date) {
this.selectedDate.set(date);
this.getData(date);
}
getData(date?: Date) {
this.store.getData(date);
}
toInvoicesPage(type: string) {
const mainRoute = posSaleInvoicesNamedRoutes.saleInvoices.meta.pagePath!();
const queryParams: IPosSaleInvoicesFilterDto = {
invoice_date_from: this.selectedDate().toISOString(),
invoice_date_to: new Date(
formatGregorian(
parseJalali(gregorianToJalali(this.selectedDate())).add(3, 'months').set('day', 1)
)
).toISOString(),
};
switch (type) {
case 'success':
queryParams.status = 'SUCCESS';
break;
case 'failure':
queryParams.status = 'FAILURE';
break;
case 'notSended':
queryParams.status = 'NOT_SEND';
break;
case 'pending':
queryParams.status = 'QUEUED';
break;
}
const queryString = new URLSearchParams(queryParams as Record<string, string>).toString();
this.router.navigateByUrl(`${mainRoute}?${queryString}`);
}
}
+4 -5
View File
@@ -3,21 +3,20 @@ import { POS_ABOUT_ROUTES } from './modules/about/constants';
import { POS_CONFIG_ROUTES } from './modules/configs/constants'; import { POS_CONFIG_ROUTES } from './modules/configs/constants';
import { POS_GOODS_MANAGEMENT_ROUTES } from './modules/goodsManagement/constants'; import { POS_GOODS_MANAGEMENT_ROUTES } from './modules/goodsManagement/constants';
import { POS_SALE_INVOICES_ROUTES } from './modules/saleInvoices/constants'; import { POS_SALE_INVOICES_ROUTES } from './modules/saleInvoices/constants';
import { POS_SHOP_ROUTES } from './modules/shop/constants/routes';
import { POS_STATISTICS_ROUTES } from './modules/statistics/constants';
import { POS_SUPPORT_ROUTES } from './modules/support/constants'; import { POS_SUPPORT_ROUTES } from './modules/support/constants';
export const POS_ROUTES = { export const POS_ROUTES = {
path: '', path: '',
loadComponent: () => import('@/layout/default/app.layout.component').then((m) => m.AppLayout), loadComponent: () => import('@/layout/default/app.layout.component').then((m) => m.AppLayout),
children: [ children: [
{ ...POS_SHOP_ROUTES,
path: '',
loadComponent: () =>
import('./modules/landing/views/root.component').then((m) => m.PosLandingComponent),
},
...POS_SALE_INVOICES_ROUTES, ...POS_SALE_INVOICES_ROUTES,
...POS_ABOUT_ROUTES, ...POS_ABOUT_ROUTES,
...POS_SUPPORT_ROUTES, ...POS_SUPPORT_ROUTES,
...POS_CONFIG_ROUTES, ...POS_CONFIG_ROUTES,
...POS_GOODS_MANAGEMENT_ROUTES, ...POS_GOODS_MANAGEMENT_ROUTES,
...POS_STATISTICS_ROUTES,
], ],
} as Route; } as Route;
+2 -2
View File
@@ -3,7 +3,7 @@ import { HttpErrorResponse } from '@angular/common/http';
import { inject, Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { catchError, finalize, map } from 'rxjs'; import { catchError, finalize, map } from 'rxjs';
import { IPosProfileResponse } from '../models'; import { IPosProfileResponse } from '../models';
import { PosService } from '../modules/landing/services/main.service'; import { PosService } from '../modules/shop/services/main.service';
interface PosProfileState extends EntityState<IPosProfileResponse> {} interface PosProfileState extends EntityState<IPosProfileResponse> {}
@@ -37,7 +37,7 @@ export class PosProfileStore extends EntityStore<IPosProfileResponse, PosProfile
this.setEntity(entity); this.setEntity(entity);
} }
return entity; return entity;
}), })
); );
} }
+2 -2
View File
@@ -5,7 +5,7 @@ import { CookieService } from 'ngx-cookie-service';
import { catchError, finalize, map } from 'rxjs'; import { catchError, finalize, map } from 'rxjs';
import { COOKIE_KEYS } from 'src/assets/constants'; import { COOKIE_KEYS } from 'src/assets/constants';
import { IPosInfoResponse } from '../models/pos.io'; import { IPosInfoResponse } from '../models/pos.io';
import { PosService } from '../modules/landing/services/main.service'; import { PosService } from '../modules/shop/services/main.service';
interface PosState extends EntityState<IPosInfoResponse> { interface PosState extends EntityState<IPosInfoResponse> {
posId: string; posId: string;
@@ -44,7 +44,7 @@ export class PosInfoStore extends EntityStore<IPosInfoResponse, PosState> {
this.setEntity(entity); this.setEntity(entity);
} }
return entity; return entity;
}), })
); );
} }
@@ -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;
}

Some files were not shown because too many files have changed in this diff Show More