Initialize the admin app with banner management for app sections.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-09 17:08:46 +03:30
co-authored by Cursor
commit d670921f74
104 changed files with 26137 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100
+4
View File
@@ -0,0 +1,4 @@
# Same-origin in dev (Vite proxies to the real API — avoids CORS)
VITE_API_BASE_URL=/server/api
# بدون سرور هم می‌شود وارد شد
VITE_USE_MOCK_AUTH=true
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=https://parche.montest.ir/server/api
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+44
View File
@@ -0,0 +1,44 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs
# env
.env
.env.local
.env.*.local
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue"],
"env": {
"browser": true
},
"categories": {
"correctness": "error"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}
+9
View File
@@ -0,0 +1,9 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"oxc.oxc-vscode",
"esbenp.prettier-vscode"
]
}
+48
View File
@@ -0,0 +1,48 @@
# fabric-trade-admin
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```
+17
View File
@@ -0,0 +1,17 @@
# Example: admin panel at https://parche.montech.ir/admin/
# Place dist/ contents in the server path mapped to /admin/
location /admin/ {
alias /var/www/parche/admin/;
try_files $uri $uri/ /admin/index.html;
}
location ~ ^/admin/assets/ {
alias /var/www/parche/admin/assets/;
expires 7d;
add_header Cache-Control "public, immutable";
types {
application/javascript js;
text/css css;
}
}
Vendored
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="unocss" />
+26
View File
@@ -0,0 +1,26 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import pluginOxlint from 'eslint-plugin-oxlint'
import skipFormatting from 'eslint-config-prettier/flat'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{vue,ts,mts,tsx}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
...pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
skipFormatting,
)
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8">
<link rel="icon" href="%BASE_URL%favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>پنل مدیریت | پارچه</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+6297
View File
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
{
"name": "fabric-trade-admin",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "run-s \"lint:*\"",
"lint:oxlint": "oxlint . --fix",
"lint:eslint": "eslint . --fix --cache",
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"@unocss/vite": "^66.7.5",
"@vueuse/core": "^14.4.0",
"lodash-es": "^4.18.1",
"pinia": "^4.0.2",
"unocss": "^66.7.5",
"vue": "^3.5.40",
"vue-router": "^5.2.0"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.13.3",
"@vitejs/plugin-vue": "^6.0.8",
"@vitejs/plugin-vue-jsx": "^5.1.6",
"@vue/eslint-config-typescript": "^14.9.0",
"@vue/tsconfig": "^0.9.1",
"eslint": "^10.7.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.73.0",
"eslint-plugin-vue": "~10.9.2",
"jiti": "^2.7.0",
"npm-run-all2": "^9.0.2",
"oxlint": "~1.74.0",
"prettier": "3.9.5",
"typescript": "~6.0.0",
"vite": "^8.1.5",
"vite-plugin-vue-devtools": "^8.1.5",
"vue-eslint-parser": "^10.4.1",
"vue-tsc": "^3.3.7"
},
"engines": {
"node": "^22.18.0 || >=24.12.0"
}
}
+18
View File
@@ -0,0 +1,18 @@
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /admin/
# Serve existing files and directories
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
# SPA fallback
RewriteRule ^ index.html [L]
</IfModule>
<IfModule mod_mime.c>
AddType application/javascript .js
AddType text/css .css
AddType application/wasm .wasm
</IfModule>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+10
View File
@@ -0,0 +1,10 @@
<script setup lang="ts">
import ToastContainer from '@/components/ui/ToastContainer.vue'
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
</script>
<template>
<RouterView />
<ToastContainer />
<ConfirmDialog />
</template>
+702
View File
@@ -0,0 +1,702 @@
.admin-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
}
.admin-page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.admin-page-header__title {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text);
}
.admin-table-wrap {
overflow-x: auto;
}
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.admin-table th {
padding: 11px 16px;
text-align: center;
font-weight: 600;
font-size: 0.8125rem;
color: var(--color-text-muted);
background: var(--color-bg);
border-bottom: 1px solid var(--color-border);
white-space: nowrap;
}
.admin-table td {
padding: 13px 16px;
text-align: center;
border-bottom: 1px solid var(--color-border-light);
vertical-align: middle;
color: var(--color-text);
}
.admin-table tbody tr {
transition: background var(--transition-fast);
}
.admin-table tbody tr:hover {
background: var(--color-border-light);
}
.admin-table tbody tr.is-selected {
background: var(--color-primary-soft);
}
.admin-table .col-numeric,
.admin-table td.numeric,
.admin-table th.numeric {
text-align: center;
font-variant-numeric: tabular-nums;
}
.admin-table .col-status,
.admin-table td.status,
.admin-table th.status {
text-align: center;
}
.admin-table .col-actions,
.admin-table td.actions,
.admin-table th.actions {
text-align: center;
white-space: nowrap;
}
.admin-table tbody tr:last-child td {
border-bottom: none;
}
.admin-table--compact td,
.admin-table--compact th {
padding: 10px 12px;
}
.admin-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: var(--control-height);
padding: 0 20px;
border-radius: var(--radius-sm);
font-size: 0.875rem;
font-weight: 600;
border: 1px solid transparent;
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast), box-shadow var(--transition-fast);
white-space: nowrap;
}
.admin-btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.admin-btn:active:not(:disabled) {
transform: translateY(1px);
}
.admin-btn--primary {
background: var(--btn-primary-bg);
color: #fff;
border-color: var(--btn-primary-bg);
}
.admin-btn--primary:hover:not(:disabled) {
background: var(--btn-primary-hover);
}
.admin-btn--danger {
background: var(--color-danger);
color: #fff;
border-color: var(--color-danger);
}
.admin-btn--danger:hover:not(:disabled) {
background: #c82333;
}
.admin-btn--success {
background: var(--color-success);
color: #fff;
}
.admin-btn--success:hover {
background: var(--color-success-dark);
}
.admin-btn--outline {
background: var(--color-surface);
color: var(--color-text);
border-color: var(--color-border);
}
.admin-btn--outline:hover {
background: var(--color-border-light);
}
.admin-btn--outline-success {
background: var(--color-surface);
color: var(--color-success);
border-color: var(--color-success);
}
.admin-btn--outline-danger {
background: var(--color-surface);
color: var(--color-danger);
border-color: var(--color-danger);
}
.admin-btn--outline-warning {
background: var(--color-surface);
color: var(--color-warning-dark);
border-color: var(--color-warning);
}
.admin-btn--ghost {
background: transparent;
color: var(--color-primary);
border-color: transparent;
padding: 6px 12px;
}
.admin-btn--ghost:hover {
background: var(--color-info-light);
}
.admin-btn--sm {
min-height: var(--control-height-sm);
padding: 0 14px;
font-size: 0.8125rem;
}
.admin-btn--xs {
min-height: 28px;
padding: 0 10px;
font-size: 0.75rem;
}
.admin-btn__spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: #fff;
border-radius: 50%;
animation: admin-spin 0.7s linear infinite;
}
.admin-btn--outline .admin-btn__spinner {
border-color: rgba(var(--color-primary-rgb), 0.2);
border-top-color: var(--color-primary);
}
@keyframes admin-spin {
to {
transform: rotate(360deg);
}
}
.admin-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
}
.admin-badge {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 2px 10px;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
border: 1px solid transparent;
}
.admin-badge--warning {
background: var(--color-warning-light);
color: var(--color-warning-dark);
}
.admin-badge--purple {
background: var(--color-purple-light);
color: var(--color-purple);
}
.admin-badge--success {
background: var(--color-success-light);
color: var(--color-success-dark);
}
.admin-badge--danger {
background: var(--color-danger-light);
color: var(--color-danger);
}
.admin-badge--info {
background: var(--color-info-light);
color: var(--color-info);
}
.admin-badge--neutral {
background: var(--color-border-light);
color: var(--color-text-muted);
}
.admin-badge--delivered {
background: var(--color-success-light);
color: var(--color-success-dark);
}
.admin-tabs {
display: flex;
flex-wrap: wrap;
gap: 0;
border-bottom: 1px solid var(--color-border);
overflow: visible;
}
.admin-tab {
padding: 12px 16px;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-muted);
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
white-space: nowrap;
flex-shrink: 0;
transition: color var(--transition-fast), border-color var(--transition-fast);
}
.admin-tab:focus-visible {
outline: none;
box-shadow: inset 0 -2px 0 var(--color-primary);
}
.admin-tab:hover {
color: var(--color-text);
}
.admin-tab.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
font-weight: 600;
}
.admin-input {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.875rem;
background: var(--color-surface);
color: var(--color-text);
}
.admin-input[type='number'],
.admin-input[type='tel'],
.admin-input[inputmode='numeric'],
.admin-input[inputmode='decimal'],
.admin-input[inputmode='tel'],
.admin-input[dir='ltr'] {
text-align: right;
}
select.admin-input {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.75' d='m7 10 5 5 5-5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: left 12px center;
padding-inline: 14px 36px;
cursor: pointer;
}
html.dark select.admin-input {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='%94a3b8' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.75' d='m7 10 5 5 5-5'/%3E%3C/svg%3E");
}
.admin-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.admin-input--sm {
padding: 6px 10px;
font-size: 0.8125rem;
max-width: 120px;
}
.admin-stat-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 20px;
display: flex;
flex-direction: column;
gap: 8px;
}
.admin-stat-card__label {
font-size: 0.8125rem;
color: var(--color-text-muted);
margin: 0;
}
.admin-stat-card__value {
font-size: 1.75rem;
font-weight: 700;
margin: 0;
line-height: 1.2;
}
.admin-stat-card__icon {
width: 40px;
height: 40px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 4px;
}
.admin-stat-card__link {
font-size: 0.75rem;
color: var(--color-primary);
margin-top: auto;
}
.admin-link {
color: var(--color-primary);
font-size: 0.8125rem;
font-weight: 500;
}
.admin-link:hover {
text-decoration: underline;
}
.admin-pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-top: 1px solid var(--color-border-light);
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.admin-pagination__pages {
display: flex;
gap: 4px;
}
.admin-pagination__page {
min-width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
background: var(--color-surface);
font-size: 0.8125rem;
}
.admin-pagination__page.active {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
.admin-summary-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.admin-summary-item {
background: var(--color-border-light);
border-radius: var(--radius-sm);
padding: 12px 16px;
}
.admin-summary-item__label {
font-size: 0.75rem;
color: var(--color-text-muted);
margin: 0 0 4px;
}
.admin-summary-item__value {
font-size: 0.9375rem;
font-weight: 600;
margin: 0;
}
.admin-info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.admin-info-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8125rem;
}
.admin-info-row__label {
color: var(--color-text-muted);
}
.admin-info-row__value {
font-weight: 500;
}
.admin-logo-par {
width: 40px;
height: 40px;
border-radius: var(--radius-sm);
background: var(--color-primary);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 900;
font-size: 1.125rem;
}
.admin-logo-fam {
width: 40px;
height: 40px;
border-radius: var(--radius-sm);
background: #f5f0e8;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 0.5rem;
font-weight: 700;
color: var(--color-primary);
line-height: 1.2;
}
.admin-logo-fam span:first-child {
font-size: 0.625rem;
}
.admin-action-group {
display: flex;
gap: 8px;
}
.admin-section-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 20px;
margin-bottom: 16px;
}
.admin-section-card__title {
margin: 0 0 16px;
font-size: 0.9375rem;
font-weight: 700;
}
.admin-form-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-top: 20px;
}
.admin-form-field label {
display: block;
font-size: 0.8125rem;
color: var(--color-text-muted);
margin-bottom: 6px;
}
.admin-form-field textarea {
width: 100%;
min-height: 80px;
padding: 10px 14px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.875rem;
resize: vertical;
}
.admin-footer-actions {
display: flex;
gap: 12px;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid var(--color-border-light);
}
.admin-empty-placeholder {
padding: 48px 24px;
text-align: center;
color: var(--color-text-muted);
font-size: 0.9375rem;
}
.admin-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.admin-form__error {
margin: 0;
padding: 10px 12px;
border-radius: var(--radius-sm);
background: var(--color-danger-light);
color: var(--color-danger);
font-size: 0.8125rem;
}
.admin-form__field {
display: flex;
flex-direction: column;
gap: 6px;
}
.admin-form__field label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text);
}
.admin-form__required {
color: var(--color-danger);
}
.admin-form__textarea {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.875rem;
resize: vertical;
min-height: 80px;
font-family: inherit;
}
.admin-form__textarea:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.admin-skeleton {
background: linear-gradient(
90deg,
var(--color-border-light) 25%,
var(--color-border) 50%,
var(--color-border-light) 75%
);
background-size: 200% 100%;
animation: admin-skeleton 1.2s ease-in-out infinite;
border-radius: var(--radius-sm);
}
@keyframes admin-skeleton {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.admin-skeleton {
animation: none;
}
.admin-btn__spinner {
animation: none;
}
}
.admin-form__row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media print {
@page {
margin: 12mm;
}
html.printing-order-detail body > *:not(.admin-modal-backdrop) {
display: none !important;
}
html.printing-order-detail .admin-modal-backdrop {
position: static !important;
inset: auto !important;
z-index: auto !important;
display: block !important;
padding: 0 !important;
background: transparent !important;
}
html.printing-order-detail .admin-modal {
width: 100% !important;
max-height: none !important;
overflow: visible !important;
border-radius: 0 !important;
box-shadow: none !important;
background: #fff !important;
color: #111 !important;
}
html.printing-order-detail .admin-modal__header {
border-bottom-color: #ddd !important;
padding: 0 0 12px !important;
}
html.printing-order-detail .admin-modal__close,
html.printing-order-detail .admin-modal__footer,
html.printing-order-detail .no-print {
display: none !important;
}
html.printing-order-detail .admin-modal__body {
overflow: visible !important;
max-height: none !important;
padding: 0 !important;
}
html.printing-order-detail .admin-modal__title {
color: #111 !important;
}
}
+39
View File
@@ -0,0 +1,39 @@
@font-face {
font-family: 'IRANSans';
src: url('../fonts/iranSans/woff2/IRANSansWeb(FaNum).woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IRANSans';
src: url('../fonts/iranSans/woff2/IRANSansWeb(FaNum)_Light.woff2') format('woff2');
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IRANSans';
src: url('../fonts/iranSans/woff2/IRANSansWeb(FaNum)_Medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IRANSans';
src: url('../fonts/iranSans/woff2/IRANSansWeb(FaNum)_Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IRANSans';
src: url('../fonts/iranSans/woff2/IRANSansWeb(FaNum)_Black.woff2') format('woff2');
font-weight: 900;
font-style: normal;
font-display: swap;
}
+195
View File
@@ -0,0 +1,195 @@
@import './fonts.css';
:root {
/* Brand — هم‌تراز اپ کاربر (mockups / main.css) */
--color-primary: #0a353c;
--color-primary-hover: #072a2f;
--color-primary-soft: #e8f4f1;
--color-primary-dark: #062428;
--color-primary-light: #e8f4f1;
--color-primary-rgb: 10, 53, 60;
--color-accent-navy: #1b223c;
--color-sidebar-bg: #0a353c;
--color-sidebar-active: #0e454e;
--color-sidebar-hover: #0e454e;
--color-success: #1b8a5a;
--color-success-light: #eaf8f1;
--color-success-dark: #0d7a4d;
--color-success-soft: #eaf8f1;
--color-warning: #e8a317;
--color-warning-light: #fff6e5;
--color-warning-dark: #d97706;
--color-danger: #dc3545;
--color-danger-light: #fee2e2;
--color-purple: #7c3aed;
--color-purple-light: #ede9fe;
--color-info: #3b6fa0;
--color-info-light: #eef3f7;
--color-bg: #f6f8f7;
--color-surface: #ffffff;
--color-elevated: #ffffff;
--color-border: #e5e9e8;
--color-border-light: #eef1f0;
--color-text: #121212;
--color-text-muted: #6b7280;
--color-text-secondary: #6b7280;
--admin-primary: var(--color-primary);
--admin-muted: var(--color-text-muted);
--admin-surface: var(--color-surface);
--admin-border: var(--color-border);
--admin-soft: var(--color-primary-soft);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.04);
--shadow-card: 0 1px 3px rgba(16, 24, 40, 0.06), 0 4px 16px rgba(16, 24, 40, 0.04);
--sidebar-width: 240px;
--header-height: 72px;
--font-family: 'IRANSans', 'Tahoma', sans-serif;
--btn-primary-bg: #0a353c;
--btn-primary-hover: #072a2f;
--admin-desktop-min: 1280px;
--control-height: 40px;
--control-height-sm: 32px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--transition-fast: 0.15s ease;
--transition-base: 0.18s ease;
--focus-ring: 0 0 0 3px rgba(10, 53, 60, 0.18);
}
html.dark {
--color-primary: #5eb8ad;
--color-primary-hover: #7ad0c5;
--color-primary-soft: #14353a;
--color-primary-dark: #7ad0c5;
--color-primary-light: #14353a;
--color-primary-rgb: 94, 184, 173;
--color-accent-navy: #9bb0d0;
--color-sidebar-bg: #0b0f14;
--color-sidebar-active: #14353a;
--color-sidebar-hover: #121821;
--color-success: #3dd68c;
--color-success-light: #123528;
--color-success-dark: #6ee7b7;
--color-success-soft: #123528;
--color-warning: #f5c14a;
--color-warning-light: #3a2f14;
--color-warning-dark: #fcd34d;
--color-danger: #f47979;
--color-danger-light: #3f1d22;
--color-purple: #b39afa;
--color-purple-light: #2a2144;
--color-info: #7b9cff;
--color-info-light: #1b2740;
--color-bg: #0b0f14;
--color-surface: #121821;
--color-elevated: #1a2230;
--color-border: #2a3444;
--color-border-light: #1a2230;
--color-text: #eef2f8;
--color-text-muted: #9aa8bc;
--color-text-secondary: #9aa8bc;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.4), 0 4px 16px rgba(0, 0, 0, 0.35);
--btn-primary-bg: #5eb8ad;
--btn-primary-hover: #7ad0c5;
--focus-ring: 0 0 0 3px rgba(94, 184, 173, 0.28);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
font-size: 16px;
-webkit-text-size-adjust: 100%;
height: 100%;
}
body {
margin: 0;
font-family: var(--font-family);
background: var(--color-bg);
color: var(--color-text);
line-height: 1.5;
direction: rtl;
height: 100%;
}
#app {
height: 100dvh;
}
/* Hide scrollbars across the admin panel (scroll still works) */
html,
body,
#app,
#app *,
.admin-modal-backdrop,
.admin-modal-backdrop * {
scrollbar-width: none;
-ms-overflow-style: none;
}
html::-webkit-scrollbar,
body::-webkit-scrollbar,
#app::-webkit-scrollbar,
#app *::-webkit-scrollbar,
.admin-modal-backdrop::-webkit-scrollbar,
.admin-modal-backdrop *::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
background: transparent;
}
a {
color: inherit;
text-decoration: none;
}
button {
font-family: inherit;
cursor: pointer;
}
button:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
input,
textarea,
select {
font-family: inherit;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
+414
View File
@@ -0,0 +1,414 @@
/* Admin responsive breakpoints:
xs (small mobile): below 480px
sm (mobile): 480px – 767px
md (tablet): 768px – 1023px
lg (small desktop): 1024px – 1279px
xl (desktop): 1280px – 1439px
2xl (large desktop): 1440px+
*/
:root {
--page-padding-mobile: 16px;
--page-padding-tablet: 20px;
--page-padding-desktop: 28px;
--page-padding: var(--page-padding-mobile);
--mobile-nav-height: 0px;
--drawer-width: min(300px, 88vw);
--admin-desktop-min: 1280px;
}
@media (min-width: 768px) {
:root {
--page-padding: var(--page-padding-tablet);
}
}
@media (min-width: 1280px) {
:root {
--page-padding: var(--page-padding-desktop);
}
}
/* Visibility utilities — desktop layout is source of truth at >= 1280px */
.desktop-only {
display: none !important;
}
.compact-only {
display: block;
}
@media (min-width: 1280px) {
.desktop-only {
display: revert !important;
}
.desktop-only.admin-shell__sidebar,
.desktop-only.admin-sidebar {
display: flex !important;
}
.desktop-only.orders-page,
.desktop-only.dashboard {
display: block !important;
}
.compact-only {
display: none !important;
}
}
/* Mobile drawer overlay */
.admin-drawer-backdrop {
position: fixed;
inset: 0;
z-index: 900;
background: rgba(15, 23, 42, 0.45);
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
}
.admin-drawer-backdrop.open {
opacity: 1;
visibility: visible;
}
.admin-drawer {
position: fixed;
top: 0;
right: 0;
z-index: 901;
width: var(--drawer-width);
height: 100dvh;
background: var(--color-sidebar-bg);
color: #fff;
transform: translateX(100%);
transition: transform 0.25s ease;
display: flex;
flex-direction: column;
overflow: hidden;
}
.admin-drawer.open {
transform: translateX(0);
}
/* Compact card list pattern */
.admin-card-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.admin-card-item {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 14px 16px;
text-align: right;
width: 100%;
transition: border-color 0.15s, background 0.15s;
}
.admin-card-item:hover {
border-color: #cbd5e1;
}
.admin-card-item.selected {
border-color: var(--color-primary);
background: var(--color-info-light);
}
.admin-card-item__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.admin-card-item__header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.admin-card-item__title {
margin: 0;
font-size: 0.875rem;
font-weight: 700;
}
.admin-card-item__meta {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.admin-card-item__meta span strong {
color: var(--color-text);
font-weight: 600;
}
.admin-card-item__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--color-border-light);
}
/* Full-screen detail panel (mobile) */
.admin-detail-panel {
position: fixed;
inset: 0;
z-index: 850;
background: var(--color-bg);
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.25s ease;
}
.admin-detail-panel.open {
transform: translateX(0);
}
.admin-detail-panel__header {
flex-shrink: 0;
display: grid;
grid-template-columns: 40px 1fr 40px;
align-items: center;
gap: 12px;
padding: 12px var(--page-padding);
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
}
.admin-detail-panel__back {
width: 40px;
height: 40px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text);
flex-shrink: 0;
grid-column: 1;
transition:
background var(--transition-fast),
border-color var(--transition-fast);
}
.admin-detail-panel__back:hover {
background: var(--color-border-light);
border-color: var(--color-border);
}
.admin-detail-panel__title {
margin: 0;
font-size: 1rem;
font-weight: 700;
text-align: center;
grid-column: 2;
}
.admin-detail-panel__body {
flex: 1;
overflow-y: auto;
padding: var(--page-padding);
}
/* Compact page header */
.compact-page-header {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
}
.compact-page-header__row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.compact-page-header__title {
margin: 0;
font-size: 1.125rem;
font-weight: 700;
}
.compact-page-header__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
@media (min-width: 768px) {
.compact-page-header__row {
align-items: center;
}
.compact-page-header__actions {
flex-shrink: 0;
}
}
/* Stat grid for compact dashboard — 4 KPI cards in one row on tablet+ */
.compact-stats-grid {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
margin-bottom: 16px;
}
@media (min-width: 480px) {
.compact-stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 768px) {
.compact-stats-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.compact-section-grid {
display: flex;
flex-direction: column;
gap: 16px;
}
.compact-section-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 16px;
box-shadow: var(--shadow-sm);
}
.compact-section-card__title {
margin: 0 0 12px;
font-size: 0.9375rem;
font-weight: 700;
}
/* Tablet / compact desktop: keep multi-column layouts usable in modals */
@media (max-width: 1279px) {
.admin-shell__content {
padding: var(--page-padding);
}
.admin-summary-grid {
grid-template-columns: repeat(2, 1fr);
}
.admin-form-grid {
grid-template-columns: repeat(2, 1fr);
}
.admin-info-grid {
grid-template-columns: repeat(2, 1fr);
}
.admin-pagination {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
.admin-pagination__pages {
justify-content: center;
flex-wrap: wrap;
}
.admin-tab {
padding: 10px 12px;
font-size: 0.8125rem;
}
.admin-page-header .admin-action-group {
flex-wrap: wrap;
gap: 8px;
}
}
/* Phone only: stack to single column / action sheet */
@media (max-width: 767px) {
:root {
--header-height: 60px;
}
.admin-page-header {
flex-direction: column;
align-items: stretch;
}
.admin-page-header .admin-btn,
.admin-page-header .admin-action-group {
width: 100%;
}
.admin-page-header .admin-action-group .admin-btn {
flex: 1;
min-width: fit-content;
}
.admin-summary-grid,
.admin-form-grid,
.admin-info-grid {
grid-template-columns: 1fr;
}
.admin-footer-actions {
flex-direction: column;
}
.admin-footer-actions .admin-btn {
width: 100%;
}
.admin-form__row {
grid-template-columns: 1fr;
}
.admin-tab {
padding: 8px 10px;
font-size: 0.75rem;
}
.admin-modal-backdrop {
padding: 0;
align-items: flex-end;
}
.admin-modal {
max-width: 100%;
max-height: 92dvh;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
}
.checks-page__footer {
flex-direction: column;
align-items: stretch;
}
.checks-page__footer .admin-action-group {
flex-direction: column;
width: 100%;
}
.checks-page__footer .admin-action-group .admin-btn {
width: 100%;
}
}
+77
View File
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { computed, useAttrs } from 'vue'
import icons from '@/components/BasicPacks/tools/icons/icons.json'
const props = withDefaults(
defineProps<{
icon: string
color?: string | null
size?: string | number
strokeWidth?: string | number
}>(),
{
color: null,
size: 24,
strokeWidth: 1.5,
},
)
defineOptions({ inheritAttrs: false })
const attrs = useAttrs()
const svgHtml = computed(() => {
const raw = (icons as Record<string, string>)[props.icon]
if (!raw) {
if (import.meta.env.DEV) {
console.warn(`[BasicIcon] missing icon: ${props.icon}`)
}
return ''
}
const size = String(props.size)
return raw
.replace(/\swidth="[^"]*"/, ` width="${size}"`)
.replace(/\sheight="[^"]*"/, ` height="${size}"`)
.replace(/stroke="#[^"]*"/g, 'stroke="currentColor"')
})
const style = computed(() => ({
color: props.color || undefined,
width: `${props.size}px`,
height: `${props.size}px`,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
lineHeight: 0,
flexShrink: 0,
}))
const ariaHidden = computed(() => {
const value = attrs['aria-hidden']
if (value === undefined || value === null) return true
return value as boolean | 'true' | 'false'
})
</script>
<template>
<span
class="basic-icon"
v-bind="attrs"
:style="style"
:aria-hidden="ariaHidden"
v-html="svgHtml"
/>
</template>
<style scoped>
.basic-icon :deep(svg) {
display: block;
width: 100%;
height: 100%;
}
.basic-icon :deep(svg path) {
stroke: currentColor;
stroke-width: v-bind('String(props.strokeWidth)');
}
</style>
@@ -0,0 +1,67 @@
{
"vuesax-linear:add": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M6 12H18\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M12 18V6\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:arrow-down": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M19.9201 8.94995L13.4001 15.47C12.6301 16.24 11.3701 16.24 10.6001 15.47L4.08008 8.94995\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:arrow-left-1": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M15 19.9201L8.47997 13.4001C7.70997 12.6301 7.70997 11.3701 8.47997 10.6001L15 4.08008\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:arrow-right-1": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M8.91003 19.9201L15.43 13.4001C16.2 12.6301 16.2 11.3701 15.43 10.6001L8.91003 4.08008\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:bag-2": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M7.5 7.67001V6.70001C7.5 4.45001 9.31 2.24001 11.56 2.03001C14.24 1.77001 16.5 3.88001 16.5 6.51001V7.89001\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M8.99999 22H15C19.02 22 19.74 20.39 19.95 18.43L20.7 12.43C20.97 9.99 20.27 8 16 8H7.99999C3.72999 8 3.02999 9.99 3.29999 12.43L4.04999 18.43C4.25999 20.39 4.97999 22 8.99999 22Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M15.4955 12H15.5045\" stroke=\"#292D32\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M8.49451 12H8.50349\" stroke=\"#292D32\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:bag-cross": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M13.39 17.36L10.64 14.61\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M13.36 14.64L10.61 17.39\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M8.81 2L5.19 5.63\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M15.19 2L18.81 5.63\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M2 7.84998C2 5.99998 2.99 5.84998 4.22 5.84998H19.78C21.01 5.84998 22 5.99998 22 7.84998C22 9.99998 21.01 9.84998 19.78 9.84998H4.22C2.99 9.84998 2 9.99998 2 7.84998Z\" stroke=\"#292D32\" stroke-width=\"1.5\"/>\n<path d=\"M3.5 10L4.91 18.64C5.23 20.58 6 22 8.86 22H14.89C18 22 18.46 20.64 18.82 18.76L20.5 10\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\"/>\n</svg>\n",
"vuesax-linear:box-1": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M3.17004 7.43994L12 12.5499L20.77 7.46994\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M12 21.61V12.54\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M9.93001 2.48004L4.59001 5.44004C3.38001 6.11004 2.39001 7.79004 2.39001 9.17004V14.82C2.39001 16.2 3.38001 17.88 4.59001 18.55L9.93001 21.52C11.07 22.15 12.94 22.15 14.08 21.52L19.42 18.55C20.63 17.88 21.62 16.2 21.62 14.82V9.17004C21.62 7.79004 20.63 6.11004 19.42 5.44004L14.08 2.47004C12.93 1.84004 11.07 1.84004 9.93001 2.48004Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:box-remove": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M3.17004 7.43994L12 12.5499L20.77 7.46991\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M12 21.6099V12.5399\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M21.61 9.17V14.83C21.61 14.88 21.61 14.92 21.6 14.97C20.9 14.36 20 14 19 14C18.06 14 17.19 14.33 16.5 14.88C15.58 15.61 15 16.74 15 18C15 18.75 15.21 19.46 15.58 20.06C15.67 20.22 15.78 20.37 15.9 20.51L14.07 21.52C12.93 22.16 11.07 22.16 9.92999 21.52L4.59 18.56C3.38 17.89 2.39001 16.21 2.39001 14.83V9.17C2.39001 7.79 3.38 6.11002 4.59 5.44002L9.92999 2.48C11.07 1.84 12.93 1.84 14.07 2.48L19.41 5.44002C20.62 6.11002 21.61 7.79 21.61 9.17Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M23 18C23 18.75 22.79 19.46 22.42 20.06C22.21 20.42 21.94 20.74 21.63 21C20.93 21.63 20.01 22 19 22C17.54 22 16.27 21.22 15.58 20.06C15.21 19.46 15 18.75 15 18C15 16.74 15.58 15.61 16.5 14.88C17.19 14.33 18.06 14 19 14C21.21 14 23 15.79 23 18Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M20.07 19.0399L17.95 16.9299\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M20.05 16.96L17.93 19.0699\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:box-search": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M3.17004 7.43994L12 12.5499L20.77 7.46991\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M12 21.6099V12.5399\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M21.61 12.83V9.17C21.61 7.79 20.62 6.11002 19.41 5.44002L14.07 2.48C12.93 1.84 11.07 1.84 9.92999 2.48L4.59 5.44002C3.38 6.11002 2.39001 7.79 2.39001 9.17V14.83C2.39001 16.21 3.38 17.89 4.59 18.56L9.92999 21.52C10.5 21.84 11.25 22 12 22C12.75 22 13.5 21.84 14.07 21.52\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M19.2 21.4C20.9673 21.4 22.4 19.9673 22.4 18.2C22.4 16.4327 20.9673 15 19.2 15C17.4327 15 16 16.4327 16 18.2C16 19.9673 17.4327 21.4 19.2 21.4Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M23 22L22 21\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:building": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M12.5 22.0001H4.07997C2.91997 22.0001 1.96997 21.0701 1.96997 19.9301V5.09011C1.96997 2.47011 3.91997 1.2801 6.30997 2.4501L10.75 4.63011C11.71 5.10011 12.5 6.3501 12.5 7.4101V22.0001Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M21.97 15.0602V18.8402C21.97 21.0002 20.97 22.0002 18.81 22.0002H12.5V10.4202L12.97 10.5202L17.47 11.5302L19.5 11.9802C20.82 12.2702 21.9 12.9502 21.96 14.8702C21.97 14.9302 21.97 14.9902 21.97 15.0602Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M5.5 9H8.97\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M5.5 13H8.97\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M17.47 11.53V14.75C17.47 15.99 16.46 17 15.22 17C13.98 17 12.97 15.99 12.97 14.75V10.52L17.47 11.53Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M21.96 14.87C21.9 16.05 20.92 17 19.72 17C18.48 17 17.47 15.99 17.47 14.75V11.53L19.5 11.98C20.82 12.27 21.9 12.95 21.96 14.87Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:buildings": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M13 22H5C3 22 2 21 2 19V11C2 9 3 8 5 8H10V19C10 21 11 22 13 22Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M10.11 4C10.03 4.3 10 4.63 10 5V8H5V6C5 4.9 5.9 4 7 4H10.11Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M14 8V13\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M18 8V13\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M17 17H15C14.45 17 14 17.45 14 18V22H18V18C18 17.45 17.55 17 17 17Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M6 13V17\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M10 19V5C10 3 11 2 13 2H19C21 2 22 3 22 5V19C22 21 21 22 19 22H13C11 22 10 21 10 19Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:calendar": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M8 2V5\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M16 2V5\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M3.5 9.08997H20.5\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M21 8.5V17C21 20 19.5 22 16 22H8C4.5 22 3 20 3 17V8.5C3 5.5 4.5 3.5 8 3.5H16C19.5 3.5 21 5.5 21 8.5Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M15.6947 13.7H15.7037\" stroke=\"#292D32\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M15.6947 16.7H15.7037\" stroke=\"#292D32\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M11.9955 13.7H12.0045\" stroke=\"#292D32\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M11.9955 16.7H12.0045\" stroke=\"#292D32\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M8.29431 13.7H8.30329\" stroke=\"#292D32\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M8.29431 16.7H8.30329\" stroke=\"#292D32\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:call": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M21.97 18.33C21.97 18.69 21.89 19.06 21.72 19.42C21.55 19.78 21.33 20.12 21.04 20.44C20.55 20.98 20.01 21.37 19.4 21.62C18.8 21.87 18.15 22 17.45 22C16.43 22 15.34 21.76 14.19 21.27C13.04 20.78 11.89 20.12 10.75 19.29C9.6 18.45 8.51 17.52 7.47 16.49C6.44 15.45 5.51 14.36 4.68 13.22C3.86 12.08 3.2 10.94 2.72 9.81C2.24 8.67 2 7.58 2 6.54C2 5.86 2.12 5.21 2.36 4.61C2.6 4 2.98 3.44 3.51 2.94C4.15 2.31 4.85 2 5.59 2C5.87 2 6.15 2.06 6.4 2.18C6.66 2.3 6.89 2.48 7.07 2.74L9.39 6.01C9.57 6.26 9.7 6.49 9.79 6.71C9.88 6.92 9.93 7.13 9.93 7.32C9.93 7.56 9.86 7.8 9.72 8.03C9.59 8.26 9.4 8.5 9.16 8.74L8.4 9.53C8.29 9.64 8.24 9.77 8.24 9.93C8.24 10.01 8.25 10.08 8.27 10.16C8.3 10.24 8.33 10.3 8.35 10.36C8.53 10.69 8.84 11.12 9.28 11.64C9.73 12.16 10.21 12.69 10.73 13.22C11.27 13.75 11.79 14.24 12.32 14.69C12.84 15.13 13.27 15.43 13.61 15.61C13.66 15.63 13.72 15.66 13.79 15.69C13.87 15.72 13.95 15.73 14.04 15.73C14.21 15.73 14.34 15.67 14.45 15.56L15.21 14.81C15.46 14.56 15.7 14.37 15.93 14.25C16.16 14.11 16.39 14.04 16.64 14.04C16.83 14.04 17.03 14.08 17.25 14.17C17.47 14.26 17.7 14.39 17.95 14.56L21.26 16.91C21.52 17.09 21.7 17.3 21.81 17.55C21.91 17.8 21.97 18.05 21.97 18.33Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\"/>\n</svg>\n",
"vuesax-linear:card": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M2 8.50488H22\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M6 16.5049H8\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M10.5 16.5049H14.5\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M6.44 3.50488H17.55C21.11 3.50488 22 4.38488 22 7.89488V16.1049C22 19.6149 21.11 20.4949 17.56 20.4949H6.44C2.89 20.5049 2 19.6249 2 16.1149V7.89488C2 4.38488 2.89 3.50488 6.44 3.50488Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:card-receive": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M2 8.5H14.5\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M6 16.5H8\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M10.5 16.5H14.5\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M22 14.03V16.11C22 19.62 21.11 20.5 17.56 20.5H6.44C2.89 20.5 2 19.62 2 16.11V7.89C2 4.38 2.89 3.5 6.44 3.5H14.5\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M20 3.5V9.5L22 7.5\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M20 9.5L18 7.5\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:category": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M5 10H7C9 10 10 9 10 7V5C10 3 9 2 7 2H5C3 2 2 3 2 5V7C2 9 3 10 5 10Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M17 10H19C21 10 22 9 22 7V5C22 3 21 2 19 2H17C15 2 14 3 14 5V7C14 9 15 10 17 10Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M17 22H19C21 22 22 21 22 19V17C22 15 21 14 19 14H17C15 14 14 15 14 17V19C14 21 15 22 17 22Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M5 22H7C9 22 10 21 10 19V17C10 15 9 14 7 14H5C3 14 2 15 2 17V19C2 21 3 22 5 22Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:chart-2": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M9 22H15C20 22 22 20 22 15V9C22 4 20 2 15 2H9C4 2 2 4 2 9V15C2 20 4 22 9 22Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M15.5 18.5C16.6 18.5 17.5 17.6 17.5 16.5V7.5C17.5 6.4 16.6 5.5 15.5 5.5C14.4 5.5 13.5 6.4 13.5 7.5V16.5C13.5 17.6 14.39 18.5 15.5 18.5Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M8.5 18.5C9.6 18.5 10.5 17.6 10.5 16.5V13C10.5 11.9 9.6 11 8.5 11C7.4 11 6.5 11.9 6.5 13V16.5C6.5 17.6 7.39 18.5 8.5 18.5Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:clipboard-close": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M14 16.16L10.04 12.2\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M13.96 12.24L10 16.2\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M10 6H14C16 6 16 5 16 4C16 2 15 2 14 2H10C9 2 8 2 8 4C8 6 9 6 10 6Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M16 4.02002C19.33 4.20002 21 5.43002 21 10V16C21 20 20 22 15 22H9C4 22 3 20 3 16V10C3 5.44002 4.67 4.20002 8 4.02002\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:clock": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M12 9.66003V12.45L13.4 13.85\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M5.5 12C5.5 8.41 8.41 5.5 12 5.5C15.59 5.5 18.5 8.41 18.5 12C18.5 14.08 17.52 15.94 16 17.13H15.99C14.89 17.99 13.51 18.5 12 18.5C10.51 18.5 9.14 18 8.04 17.15H8.03C6.49 15.96 5.5 14.1 5.5 12Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M8.03003 17.15H8.04003C9.14003 18 10.51 18.5 12 18.5C13.51 18.5 14.89 17.99 15.99 17.13H16L15.49 19.6C15 21.5 13.9 22 12.55 22H11.46C10.11 22 9.00003 21.5 8.52003 19.59L8.03003 17.15Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M8.03003 6.85H8.04003C9.14003 6 10.51 5.5 12 5.5C13.51 5.5 14.89 6.01 15.99 6.87H16L15.49 4.4C15 2.5 13.9 2 12.55 2H11.46C10.11 2 9.00003 2.5 8.52003 4.41L8.03003 6.85Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:close-circle": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M12 22C17.5 22 22 17.5 22 12C22 6.5 17.5 2 12 2C6.5 2 2 6.5 2 12C2 17.5 6.5 22 12 22Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M9.16998 14.83L14.83 9.17004\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M14.83 14.83L9.16998 9.17004\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:cpu": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M9.6 20H14.4C18.4 20 20 18.4 20 14.4V9.6C20 5.6 18.4 4 14.4 4H9.6C5.6 4 4 5.6 4 9.6V14.4C4 18.4 5.6 20 9.6 20Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M10.5 17H13.5C16 17 17 16 17 13.5V10.5C17 8 16 7 13.5 7H10.5C8 7 7 8 7 10.5V13.5C7 16 8 17 10.5 17Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M8.01001 4V2\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M12 4V2\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M16 4V2\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M20 8H22\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M20 12H22\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M20 16H22\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M16 20V22\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M12.01 20V22\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M8.01001 20V22\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M2 8H4\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M2 12H4\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M2 16H4\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:diamonds": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M16.29 2.15002H7.69995C5.99995 2.15002 5.24995 3.00002 4.78995 4.04002L2.22995 9.80002C1.76995 10.84 2.01995 12.39 2.78995 13.23L9.64995 20.77C10.95 22.19 13.07 22.19 14.36 20.77L21.21 13.22C21.98 12.37 22.23 10.83 21.76 9.79002L19.2 4.03002C18.74 3.00002 17.99 2.15002 16.29 2.15002Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M3.5 8H20.5\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:document-text": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M21 7V17C21 20 19.5 22 16 22H8C4.5 22 3 20 3 17V7C3 4 4.5 2 8 2H16C19.5 2 21 4 21 7Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M14.5 4.5V6.5C14.5 7.6 15.4 8.5 16.5 8.5H18.5\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M8 13H12\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M8 17H16\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:edit-2": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M13.26 3.59997L5.04997 12.29C4.73997 12.62 4.43997 13.27 4.37997 13.72L4.00997 16.96C3.87997 18.13 4.71997 18.93 5.87997 18.73L9.09997 18.18C9.54997 18.1 10.18 17.77 10.49 17.43L18.7 8.73997C20.12 7.23997 20.76 5.52997 18.55 3.43997C16.35 1.36997 14.68 2.09997 13.26 3.59997Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M11.89 5.05005C12.32 7.81005 14.56 9.92005 17.34 10.2\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M3 22H21\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:element-3": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M22 8.52V3.98C22 2.57 21.36 2 19.77 2H15.73C14.14 2 13.5 2.57 13.5 3.98V8.51C13.5 9.93 14.14 10.49 15.73 10.49H19.77C21.36 10.5 22 9.93 22 8.52Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M22 19.77V15.73C22 14.14 21.36 13.5 19.77 13.5H15.73C14.14 13.5 13.5 14.14 13.5 15.73V19.77C13.5 21.36 14.14 22 15.73 22H19.77C21.36 22 22 21.36 22 19.77Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M10.5 8.52V3.98C10.5 2.57 9.86 2 8.27 2H4.23C2.64 2 2 2.57 2 3.98V8.51C2 9.93 2.64 10.49 4.23 10.49H8.27C9.86 10.5 10.5 9.93 10.5 8.52Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M10.5 19.77V15.73C10.5 14.14 9.86 13.5 8.27 13.5H4.23C2.64 13.5 2 14.14 2 15.73V19.77C2 21.36 2.64 22 4.23 22H8.27C9.86 22 10.5 21.36 10.5 19.77Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:eye": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M15.58 12C15.58 13.98 13.98 15.58 12 15.58C10.02 15.58 8.42004 13.98 8.42004 12C8.42004 10.02 10.02 8.42004 12 8.42004C13.98 8.42004 15.58 10.02 15.58 12Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M12 20.27C15.53 20.27 18.82 18.19 21.11 14.59C22.01 13.18 22.01 10.81 21.11 9.39997C18.82 5.79997 15.53 3.71997 12 3.71997C8.46997 3.71997 5.17997 5.79997 2.88997 9.39997C1.98997 10.81 1.98997 13.18 2.88997 14.59C5.17997 18.19 8.46997 20.27 12 20.27Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:eye-slash": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M14.53 9.47004L9.47004 14.53C8.82004 13.88 8.42004 12.99 8.42004 12C8.42004 10.02 10.02 8.42004 12 8.42004C12.99 8.42004 13.88 8.82004 14.53 9.47004Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M17.82 5.76998C16.07 4.44998 14.07 3.72998 12 3.72998C8.46997 3.72998 5.17997 5.80998 2.88997 9.40998C1.98997 10.82 1.98997 13.19 2.88997 14.6C3.67997 15.84 4.59997 16.91 5.59997 17.77\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M8.42004 19.5301C9.56004 20.0101 10.77 20.2701 12 20.2701C15.53 20.2701 18.82 18.1901 21.11 14.5901C22.01 13.1801 22.01 10.8101 21.11 9.40005C20.78 8.88005 20.42 8.39005 20.05 7.93005\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M15.5099 12.7C15.2499 14.11 14.0999 15.26 12.6899 15.52\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M9.47 14.53L2 22\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M22 2L14.53 9.47\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:filter": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M5.40002 2.09998H18.6C19.7 2.09998 20.6 2.99998 20.6 4.09998V6.29998C20.6 7.09998 20.1 8.09998 19.6 8.59998L15.3 12.4C14.7 12.9 14.3 13.9 14.3 14.7V19C14.3 19.6 13.9 20.4 13.4 20.7L12 21.6C10.7 22.4 8.90002 21.5 8.90002 19.9V14.6C8.90002 13.9 8.50002 13 8.10002 12.5L4.30002 8.49998C3.80002 7.99998 3.40002 7.09998 3.40002 6.49998V4.19998C3.40002 2.99998 4.30002 2.09998 5.40002 2.09998Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M10.93 2.09998L6 9.99998\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:flash": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M9.31993 13.28H12.4099V20.48C12.4099 21.54 13.7299 22.04 14.4299 21.24L21.9999 12.64C22.6599 11.89 22.1299 10.72 21.1299 10.72H18.0399V3.51997C18.0399 2.45997 16.7199 1.95997 16.0199 2.75997L8.44994 11.36C7.79994 12.11 8.32993 13.28 9.31993 13.28Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M8.5 4H1.5\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M7.5 20H1.5\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M4.5 12H1.5\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:headphone": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M5.46005 18.49V15.57C5.46005 14.6 6.22005 13.73 7.30005 13.73C8.27005 13.73 9.14005 14.49 9.14005 15.57V18.38C9.14005 20.33 7.52005 21.9501 5.57005 21.9501C3.62005 21.9501 2.00005 20.32 2.00005 18.38V12.22C1.89005 6.60005 6.33005 2.05005 11.95 2.05005C17.57 2.05005 22 6.60005 22 12.11V18.2701C22 20.2201 20.38 21.84 18.43 21.84C16.48 21.84 14.86 20.2201 14.86 18.2701V15.46C14.86 14.49 15.62 13.62 16.7 13.62C17.67 13.62 18.54 14.38 18.54 15.46V18.49\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:home": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M12 18V15\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M10.07 2.81997L3.14002 8.36997C2.36002 8.98997 1.86002 10.3 2.03002 11.28L3.36002 19.24C3.60002 20.66 4.96002 21.81 6.40002 21.81H17.6C19.03 21.81 20.4 20.65 20.64 19.24L21.97 11.28C22.13 10.3 21.63 8.98997 20.86 8.36997L13.93 2.82997C12.86 1.96997 11.13 1.96997 10.07 2.81997Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:info-circle": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M12 22C17.5 22 22 17.5 22 12C22 6.5 17.5 2 12 2C6.5 2 2 6.5 2 12C2 17.5 6.5 22 12 22Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M12 8V13\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M11.9945 16H12.0035\" stroke=\"#292D32\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:lamp-on": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M8.30011 18.0399V16.8799C6.00011 15.4899 4.11011 12.7799 4.11011 9.89993C4.11011 4.94993 8.66011 1.06993 13.8001 2.18993C16.0601 2.68993 18.0401 4.18993 19.0701 6.25993C21.1601 10.4599 18.9601 14.9199 15.7301 16.8699V18.0299C15.7301 18.3199 15.8401 18.9899 14.7701 18.9899H9.26011C8.16011 18.9999 8.30011 18.5699 8.30011 18.0399Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M8.5 22C10.79 21.35 13.21 21.35 15.5 22\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:location": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M12 13.4299C13.7231 13.4299 15.12 12.0331 15.12 10.3099C15.12 8.58681 13.7231 7.18994 12 7.18994C10.2769 7.18994 8.88 8.58681 8.88 10.3099C8.88 12.0331 10.2769 13.4299 12 13.4299Z\" stroke=\"#292D32\" stroke-width=\"1.5\"/>\n<path d=\"M3.62001 8.49C5.59001 -0.169998 18.42 -0.159997 20.38 8.5C21.53 13.58 18.37 17.88 15.6 20.54C13.59 22.48 10.41 22.48 8.39001 20.54C5.63001 17.88 2.47001 13.57 3.62001 8.49Z\" stroke=\"#292D32\" stroke-width=\"1.5\"/>\n</svg>\n",
"vuesax-linear:lock": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M6 10V8C6 4.69 7 2 12 2C17 2 18 4.69 18 8V10\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M17 22H7C3 22 2 21 2 17V15C2 11 3 10 7 10H17C21 10 22 11 22 15V17C22 21 21 22 17 22Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M15.9965 16H16.0054\" stroke=\"#292D32\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M11.9955 16H12.0045\" stroke=\"#292D32\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M7.99451 16H8.00349\" stroke=\"#292D32\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:logout": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M8.90002 7.55999C9.21002 3.95999 11.06 2.48999 15.11 2.48999H15.24C19.71 2.48999 21.5 4.27999 21.5 8.74999V15.27C21.5 19.74 19.71 21.53 15.24 21.53H15.11C11.09 21.53 9.24002 20.08 8.91002 16.54\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M15 12H3.62\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M5.85 8.6499L2.5 11.9999L5.85 15.3499\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:menu": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M3 7H21\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\"/>\n<path d=\"M3 12H21\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\"/>\n<path d=\"M3 17H21\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\"/>\n</svg>\n",
"vuesax-linear:message": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M8.5 19H8C4 19 2 18 2 13V8C2 4 4 2 8 2H16C20 2 22 4 22 8V13C22 17 20 19 16 19H15.5C15.19 19 14.89 19.15 14.7 19.4L13.2 21.4C12.54 22.28 11.46 22.28 10.8 21.4L9.3 19.4C9.14 19.18 8.77 19 8.5 19Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M15.9965 11H16.0054\" stroke=\"#292D32\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M11.9955 11H12.0045\" stroke=\"#292D32\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M7.99451 11H8.00349\" stroke=\"#292D32\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:mobile": "<svg width=\"18\" height=\"22\" viewBox=\"0 0 18 22\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M17 6V16C17 20 16 21 12 21H6C2 21 1 20 1 16V6C1 2 2 1 6 1H12C16 1 17 2 17 6Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M11 4.5H7\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M8.99995 18.1C9.85599 18.1 10.55 17.406 10.55 16.55C10.55 15.694 9.85599 15 8.99995 15C8.14391 15 7.44995 15.694 7.44995 16.55C7.44995 17.406 8.14391 18.1 8.99995 18.1Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:money-4": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M17 20.5H7C4 20.5 2 19 2 15.5V8.5C2 5 4 3.5 7 3.5H17C20 3.5 22 5 22 8.5V15.5C22 19 20 20.5 17 20.5Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M8.5 6H7C5.62 6 4.5 7.12 4.5 8.5V10\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M15.5 6H17C18.38 6 19.5 7.12 19.5 8.5V10\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M8.5 18H7C5.62 18 4.5 16.88 4.5 15.5V14\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M15.5 18H17C18.38 18 19.5 16.88 19.5 15.5V14\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:monitor": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M6.44 2H17.55C21.11 2 22 2.89 22 6.44V12.77C22 16.33 21.11 17.21 17.56 17.21H6.44C2.89 17.22 2 16.33 2 12.78V6.44C2 2.89 2.89 2 6.44 2Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M12 17.22V22\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M2 13H22\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M7.5 22H16.5\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:moon": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M2.03009 12.42C2.39009 17.57 6.76009 21.76 11.9901 21.99C15.6801 22.15 18.9801 20.43 20.9601 17.72C21.7801 16.61 21.3401 15.87 19.9701 16.12C19.3001 16.24 18.6101 16.29 17.8901 16.26C13.0001 16.06 9.00009 11.97 8.98009 7.13996C8.97009 5.83996 9.24009 4.60996 9.73009 3.48996C10.2701 2.24996 9.62009 1.65996 8.37009 2.18996C4.41009 3.85996 1.70009 7.84996 2.03009 12.42Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:more": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M5 10C3.9 10 3 10.9 3 12C3 13.1 3.9 14 5 14C6.1 14 7 13.1 7 12C7 10.9 6.1 10 5 10Z\" stroke=\"#292D32\" stroke-width=\"1.5\"/>\n<path d=\"M19 10C17.9 10 17 10.9 17 12C17 13.1 17.9 14 19 14C20.1 14 21 13.1 21 12C21 10.9 20.1 10 19 10Z\" stroke=\"#292D32\" stroke-width=\"1.5\"/>\n<path d=\"M12 10C10.9 10 10 10.9 10 12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12C14 10.9 13.1 10 12 10Z\" stroke=\"#292D32\" stroke-width=\"1.5\"/>\n</svg>\n",
"vuesax-linear:note-remove": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M7 14H12\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M7 5.95996L3.25 2.20996\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M6.96002 2.25L3.21002 6\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M7 10H15\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M10 2H16C19.33 2.18 21 3.41 21 7.99V16\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M3 9.01001V15.98C3 19.99 4 22 9 22H12C12.17 22 14.84 22 15 22\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M21 16L15 22V19C15 17 16 16 18 16H21Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:notification": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M12.02 2.90991C8.70997 2.90991 6.01997 5.59991 6.01997 8.90991V11.7999C6.01997 12.4099 5.75997 13.3399 5.44997 13.8599L4.29997 15.7699C3.58997 16.9499 4.07997 18.2599 5.37997 18.6999C9.68997 20.1399 14.34 20.1399 18.65 18.6999C19.86 18.2999 20.39 16.8699 19.73 15.7699L18.58 13.8599C18.28 13.3399 18.02 12.4099 18.02 11.7999V8.90991C18.02 5.60991 15.32 2.90991 12.02 2.90991Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\"/>\n<path d=\"M13.87 3.19994C13.56 3.10994 13.24 3.03994 12.91 2.99994C11.95 2.87994 11.03 2.94994 10.17 3.19994C10.46 2.45994 11.18 1.93994 12.02 1.93994C12.86 1.93994 13.58 2.45994 13.87 3.19994Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M15.02 19.0601C15.02 20.7101 13.67 22.0601 12.02 22.0601C11.2 22.0601 10.44 21.7201 9.90002 21.1801C9.36002 20.6401 9.02002 19.8801 9.02002 19.0601\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\"/>\n</svg>\n",
"vuesax-linear:pen-add": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M21.19 8.0399L18.01 4.85991C16.81 3.65991 15.16 3.71993 14.35 5.00993L12.58 7.80992L18.25 13.4799L21.05 11.7099C22.26 10.9399 22.33 9.1699 21.19 8.0399Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M18.25 13.47L18.49 17.59C18.72 19.89 17.92 20.69 15.74 20.95L7.02 21.98C5.18 22.19 3.86 20.87 4.08 19.04L5.06 10.76\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M12.58 7.80994L10.83 7.69995\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M5.28 20.7799L8.46001 17.5898\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M11 6.5C11 6.91 10.94 7.32001 10.83 7.70001C10.72 8.10001 10.56 8.47001 10.35 8.82001C10.11 9.22001 9.81001 9.58 9.46001 9.88C8.67001 10.58 7.64 11 6.5 11C5.99 11 5.51 10.92 5.06 10.76C4.04 10.42 3.18999 9.72001 2.64999 8.82001C2.23999 8.14001 2 7.34 2 6.5C2 5.08 2.65 3.80999 3.69 2.98999C4.46 2.36999 5.44 2 6.5 2C8.99 2 11 4.01 11 6.5Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M6.52 8.18005V4.82007\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M8.16 6.5H4.8\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:people": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M18 7.16C17.94 7.15 17.87 7.15 17.81 7.16C16.43 7.11 15.33 5.98 15.33 4.58C15.33 3.15 16.48 2 17.91 2C19.34 2 20.49 3.16 20.49 4.58C20.48 5.98 19.38 7.11 18 7.16Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M16.9699 14.44C18.3399 14.67 19.8499 14.43 20.9099 13.72C22.3199 12.78 22.3199 11.24 20.9099 10.3C19.8399 9.59004 18.3099 9.35003 16.9399 9.59003\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M5.96998 7.16C6.02998 7.15 6.09998 7.15 6.15998 7.16C7.53998 7.11 8.63998 5.98 8.63998 4.58C8.63998 3.15 7.48998 2 6.05998 2C4.62998 2 3.47998 3.16 3.47998 4.58C3.48998 5.98 4.58998 7.11 5.96998 7.16Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M6.99994 14.44C5.62994 14.67 4.11994 14.43 3.05994 13.72C1.64994 12.78 1.64994 11.24 3.05994 10.3C4.12994 9.59004 5.65994 9.35003 7.02994 9.59003\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M12 14.63C11.94 14.62 11.87 14.62 11.81 14.63C10.43 14.58 9.32996 13.45 9.32996 12.05C9.32996 10.62 10.48 9.46997 11.91 9.46997C13.34 9.46997 14.49 10.63 14.49 12.05C14.48 13.45 13.38 14.59 12 14.63Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M9.08997 17.78C7.67997 18.72 7.67997 20.26 9.08997 21.2C10.69 22.27 13.31 22.27 14.91 21.2C16.32 20.26 16.32 18.72 14.91 17.78C13.32 16.72 10.69 16.72 9.08997 17.78Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:personalcard": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M17 21H7C3 21 2 20 2 16V8C2 4 3 3 7 3H17C21 3 22 4 22 8V16C22 20 21 21 17 21Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M14 8H19\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M15 12H19\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M17 16H19\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M8.49994 11.2899C9.49958 11.2899 10.3099 10.4796 10.3099 9.47992C10.3099 8.48029 9.49958 7.66992 8.49994 7.66992C7.50031 7.66992 6.68994 8.48029 6.68994 9.47992C6.68994 10.4796 7.50031 11.2899 8.49994 11.2899Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M12 16.33C11.86 14.88 10.71 13.74 9.26 13.61C8.76 13.56 8.25 13.56 7.74 13.61C6.29 13.75 5.14 14.88 5 16.33\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:printer": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M7.25 7H16.75V5C16.75 3 16 2 13.75 2H10.25C8 2 7.25 3 7.25 5V7Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M16 15V19C16 21 15 22 13 22H11C9 22 8 21 8 19V15H16Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M21 10V15C21 17 20 18 18 18H16V15H8V18H6C4 18 3 17 3 15V10C3 8 4 7 6 7H18C20 7 21 8 21 10Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M17 15H15.79H7\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M7 11H10\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:profile-2user": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M9.16006 10.87C9.06006 10.86 8.94006 10.86 8.83006 10.87C6.45006 10.79 4.56006 8.84 4.56006 6.44C4.56006 3.99 6.54006 2 9.00006 2C11.4501 2 13.4401 3.99 13.4401 6.44C13.4301 8.84 11.5401 10.79 9.16006 10.87Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M16.41 4C18.35 4 19.91 5.57 19.91 7.5C19.91 9.39 18.41 10.93 16.54 11C16.46 10.99 16.37 10.99 16.28 11\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M4.15997 14.56C1.73997 16.18 1.73997 18.82 4.15997 20.43C6.90997 22.27 11.42 22.27 14.17 20.43C16.59 18.81 16.59 16.17 14.17 14.56C11.43 12.73 6.91997 12.73 4.15997 14.56Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M18.3401 20C19.0601 19.85 19.7401 19.56 20.3001 19.13C21.8601 17.96 21.8601 16.03 20.3001 14.86C19.7501 14.44 19.0801 14.16 18.3701 14\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:profile-circle": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M12.12 12.78C12.05 12.77 11.96 12.77 11.88 12.78C10.12 12.72 8.71997 11.28 8.71997 9.50998C8.71997 7.69998 10.18 6.22998 12 6.22998C13.81 6.22998 15.28 7.69998 15.28 9.50998C15.27 11.28 13.88 12.72 12.12 12.78Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M18.74 19.3801C16.96 21.0101 14.6 22.0001 12 22.0001C9.40001 22.0001 7.04001 21.0101 5.26001 19.3801C5.36001 18.4401 5.96001 17.5201 7.03001 16.8001C9.77001 14.9801 14.25 14.9801 16.97 16.8001C18.04 17.5201 18.64 18.4401 18.74 19.3801Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:receipt-search": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M20.5 11.3V7.04001C20.5 3.01001 19.56 2 15.78 2H8.22C4.44 2 3.5 3.01001 3.5 7.04001V18.3C3.5 20.96 4.96001 21.59 6.73001 19.69L6.73999 19.68C7.55999 18.81 8.80999 18.88 9.51999 19.83L10.53 21.18\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M18.2 21.4C19.9673 21.4 21.4 19.9673 21.4 18.2C21.4 16.4327 19.9673 15 18.2 15C16.4327 15 15 16.4327 15 18.2C15 19.9673 16.4327 21.4 18.2 21.4Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M22 22L21 21\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M8 7H16\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M9 11H15\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:search-normal": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M11 20C15.9706 20 20 15.9706 20 11C20 6.02944 15.9706 2 11 2C6.02944 2 2 6.02944 2 11C2 15.9706 6.02944 20 11 20Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M18.9299 20.6898C19.4599 22.2898 20.6699 22.4498 21.5999 21.0498C22.4499 19.7698 21.8899 18.7198 20.3499 18.7198C19.2099 18.7098 18.5699 19.5998 18.9299 20.6898Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:search-status": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M14 5H20\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M14 8H17\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M21 11.5C21 16.75 16.75 21 11.5 21C6.25 21 2 16.75 2 11.5C2 6.25 6.25 2 11.5 2\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M22 22L20 20\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:setting-2": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M2 12.8799V11.1199C2 10.0799 2.85 9.21994 3.9 9.21994C5.71 9.21994 6.45 7.93994 5.54 6.36994C5.02 5.46994 5.33 4.29994 6.24 3.77994L7.97 2.78994C8.76 2.31994 9.78 2.59994 10.25 3.38994L10.36 3.57994C11.26 5.14994 12.74 5.14994 13.65 3.57994L13.76 3.38994C14.23 2.59994 15.25 2.31994 16.04 2.78994L17.77 3.77994C18.68 4.29994 18.99 5.46994 18.47 6.36994C17.56 7.93994 18.3 9.21994 20.11 9.21994C21.15 9.21994 22.01 10.0699 22.01 11.1199V12.8799C22.01 13.9199 21.16 14.7799 20.11 14.7799C18.3 14.7799 17.56 16.0599 18.47 17.6299C18.99 18.5399 18.68 19.6999 17.77 20.2199L16.04 21.2099C15.25 21.6799 14.23 21.3999 13.76 20.6099L13.65 20.4199C12.75 18.8499 11.27 18.8499 10.36 20.4199L10.25 20.6099C9.78 21.3999 8.76 21.6799 7.97 21.2099L6.24 20.2199C5.33 19.6999 5.02 18.5299 5.54 17.6299C6.45 16.0599 5.71 14.7799 3.9 14.7799C2.85 14.7799 2 13.9199 2 12.8799Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:shopping-bag": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M8.39999 6.5H15.6C19 6.5 19.34 8.09 19.57 10.03L20.47 17.53C20.76 19.99 20 22 16.5 22H7.50999C3.99999 22 3.23999 19.99 3.53999 17.53L4.44 10.03C4.66 8.09 4.99999 6.5 8.39999 6.5Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M8 8V4.5C8 3 9 2 10.5 2H13.5C15 2 16 3 16 4.5V8\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M20.41 17.03H8\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:sms": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M17 20.5H7C4 20.5 2 19 2 15.5V8.5C2 5 4 3.5 7 3.5H17C20 3.5 22 5 22 8.5V15.5C22 19 20 20.5 17 20.5Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M17 9L13.87 11.5C12.84 12.32 11.15 12.32 10.12 11.5L7 9\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:sun-1": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M12 18.5C15.5899 18.5 18.5 15.5899 18.5 12C18.5 8.41015 15.5899 5.5 12 5.5C8.41015 5.5 5.5 8.41015 5.5 12C5.5 15.5899 8.41015 18.5 12 18.5Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M19.14 19.14L19.01 19.01M19.01 4.99L19.14 4.86L19.01 4.99ZM4.86 19.14L4.99 19.01L4.86 19.14ZM12 2.08V2V2.08ZM12 22V21.92V22ZM2.08 12H2H2.08ZM22 12H21.92H22ZM4.99 4.99L4.86 4.86L4.99 4.99Z\" stroke=\"#292D32\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:tick-circle": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M12 22C17.5 22 22 17.5 22 12C22 6.5 17.5 2 12 2C6.5 2 2 6.5 2 12C2 17.5 6.5 22 12 22Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M7.75 12L10.58 14.83L16.25 9.17004\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:timer-1": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M20.75 13.25C20.75 18.08 16.83 22 12 22C7.17 22 3.25 18.08 3.25 13.25C3.25 8.42 7.17 4.5 12 4.5C16.83 4.5 20.75 8.42 20.75 13.25Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M12 8V13\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M9 2H15\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:trash": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M21 5.97998C17.67 5.64998 14.32 5.47998 10.98 5.47998C9 5.47998 7.02 5.57998 5.04 5.77998L3 5.97998\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M8.5 4.97L8.72 3.66C8.88 2.71 9 2 10.69 2H13.31C15 2 15.13 2.75 15.28 3.67L15.5 4.97\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M18.85 9.14001L18.2 19.21C18.09 20.78 18 22 15.21 22H8.79002C6.00002 22 5.91002 20.78 5.80002 19.21L5.15002 9.14001\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M10.33 16.5H13.66\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M9.5 12.5H14.5\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:truck-time": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M15 2V12C15 13.1 14.1 14 13 14H2V7.62C2.73 8.49 3.85003 9.03 5.09003 9C6.10003 8.98 7.01 8.59 7.69 7.94C8 7.68 8.26002 7.34999 8.46002 6.98999C8.82002 6.37999 9.02 5.65997 9 4.90997C8.97 3.73997 8.45001 2.71 7.64001 2H15Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M22 14V17C22 18.66 20.66 20 19 20H18C18 18.9 17.1 18 16 18C14.9 18 14 18.9 14 20H10C10 18.9 9.1 18 8 18C6.9 18 6 18.9 6 20H5C3.34 20 2 18.66 2 17V14H13C14.1 14 15 13.1 15 12V5H16.84C17.56 5 18.22 5.39001 18.58 6.01001L20.29 9H19C18.45 9 18 9.45 18 10V13C18 13.55 18.45 14 19 14H22Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M8 22C9.10457 22 10 21.1046 10 20C10 18.8954 9.10457 18 8 18C6.89543 18 6 18.8954 6 20C6 21.1046 6.89543 22 8 22Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M16 22C17.1046 22 18 21.1046 18 20C18 18.8954 17.1046 18 16 18C14.8954 18 14 18.8954 14 20C14 21.1046 14.8954 22 16 22Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M22 12V14H19C18.45 14 18 13.55 18 13V10C18 9.45 18.45 9 19 9H20.29L22 12Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M9 5C9 6.2 8.47001 7.27 7.64001 8C6.93001 8.62 6.01 9 5 9C2.79 9 1 7.21 1 5C1 3.74 1.58 2.61 2.5 1.88C3.19 1.33 4.06 1 5 1C7.21 1 9 2.79 9 5Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M5.25 3.75V5.25L4 6\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:verify": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M8.38 12L10.79 14.42L15.62 9.57996\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M10.75 2.44995C11.44 1.85995 12.57 1.85995 13.27 2.44995L14.85 3.80995C15.15 4.06995 15.71 4.27995 16.11 4.27995H17.81C18.87 4.27995 19.74 5.14995 19.74 6.20995V7.90995C19.74 8.29995 19.95 8.86995 20.21 9.16995L21.57 10.7499C22.16 11.4399 22.16 12.5699 21.57 13.2699L20.21 14.8499C19.95 15.1499 19.74 15.7099 19.74 16.1099V17.8099C19.74 18.8699 18.87 19.7399 17.81 19.7399H16.11C15.72 19.7399 15.15 19.9499 14.85 20.2099L13.27 21.5699C12.58 22.1599 11.45 22.1599 10.75 21.5699L9.17 20.2099C8.87 19.9499 8.31 19.7399 7.91 19.7399H6.18C5.12 19.7399 4.25 18.8699 4.25 17.8099V16.0999C4.25 15.7099 4.04 15.1499 3.79 14.8499L2.44 13.2599C1.86 12.5699 1.86 11.4499 2.44 10.7599L3.79 9.16995C4.04 8.86995 4.25 8.30995 4.25 7.91995V6.19995C4.25 5.13995 5.12 4.26995 6.18 4.26995H7.91C8.3 4.26995 8.87 4.05995 9.17 3.79995L10.75 2.44995Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:wallet": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M22 12V17C22 20 20 22 17 22H7C4 22 2 20 2 17V12C2 9.28 3.64 7.38 6.19 7.06C6.45 7.02 6.72 7 7 7H17C17.26 7 17.51 7.00999 17.75 7.04999C20.33 7.34999 22 9.26 22 12Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M17.7514 7.05C17.5114 7.01 17.2614 7.00001 17.0014 7.00001H7.00141C6.72141 7.00001 6.45141 7.02001 6.19141 7.06001C6.33141 6.78001 6.53141 6.52001 6.77141 6.28001L10.0214 3.02C11.3914 1.66 13.6114 1.66 14.9814 3.02L16.7314 4.79002C17.3714 5.42002 17.7114 6.22 17.7514 7.05Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M22 12.5H19C17.9 12.5 17 13.4 17 14.5C17 15.6 17.9 16.5 19 16.5H22\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:warning-2": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M12 7.75V13\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M21.08 8.58003V15.42C21.08 16.54 20.48 17.58 19.51 18.15L13.57 21.58C12.6 22.14 11.4 22.14 10.42 21.58L4.47998 18.15C3.50998 17.59 2.90997 16.55 2.90997 15.42V8.58003C2.90997 7.46003 3.50998 6.41999 4.47998 5.84999L10.42 2.42C11.39 1.86 12.59 1.86 13.57 2.42L19.51 5.84999C20.48 6.41999 21.08 7.45003 21.08 8.58003Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M12 16.2V16.2999\" stroke=\"#292D32\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n"
}
+228
View File
@@ -0,0 +1,228 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { formatNumber } from '@/utils/format'
const props = withDefaults(
defineProps<{
data: number[]
title?: string
subtitle?: string
height?: number
color?: string
unit?: string
labels?: string[]
}>(),
{
height: 160,
color: '#0a353c',
unit: 'میلیارد تومان',
},
)
const hoverIndex = ref<number | null>(null)
const chartWidth = 320
const chartHeight = 120
const padding = { top: 12, right: 8, bottom: 8, left: 8 }
const maxValue = computed(() => Math.max(...props.data, 0.001))
const points = computed(() => {
const innerW = chartWidth - padding.left - padding.right
const innerH = chartHeight - padding.top - padding.bottom
const step = innerW / Math.max(props.data.length - 1, 1)
return props.data.map((value, index) => ({
x: padding.left + index * step,
y: padding.top + innerH - (value / maxValue.value) * innerH,
value,
index,
}))
})
const linePoints = computed(() => points.value.map((p) => `${p.x},${p.y}`).join(' '))
const areaPoints = computed(() => {
const bottom = chartHeight - padding.bottom
const first = points.value[0]
const last = points.value[points.value.length - 1]
if (!first || !last) return ''
return `${first.x},${bottom} ${linePoints.value} ${last.x},${bottom}`
})
const gridLines = computed(() => {
const innerH = chartHeight - padding.top - padding.bottom
return [0, 0.5, 1].map((ratio) => padding.top + innerH * ratio)
})
const hoveredPoint = computed(() =>
hoverIndex.value === null ? null : points.value[hoverIndex.value] ?? null,
)
function onMove(event: MouseEvent) {
const svg = event.currentTarget as SVGSVGElement
const rect = svg.getBoundingClientRect()
const x = ((event.clientX - rect.left) / rect.width) * chartWidth
let nearest = 0
let minDist = Infinity
points.value.forEach((point) => {
const dist = Math.abs(point.x - x)
if (dist < minDist) {
minDist = dist
nearest = point.index
}
})
hoverIndex.value = nearest
}
function onLeave() {
hoverIndex.value = null
}
</script>
<template>
<div class="sales-chart">
<div v-if="title || subtitle" class="sales-chart__header">
<div>
<h3 v-if="title" class="sales-chart__title">{{ title }}</h3>
<p v-if="subtitle" class="sales-chart__subtitle">{{ subtitle }}</p>
</div>
</div>
<div class="sales-chart__body" :style="{ height: `${height}px` }">
<svg
class="sales-chart__svg"
:viewBox="`0 0 ${chartWidth} ${chartHeight}`"
preserveAspectRatio="none"
role="img"
:aria-label="title || 'نمودار فروش'"
@mousemove="onMove"
@mouseleave="onLeave"
>
<line
v-for="(y, index) in gridLines"
:key="index"
:x1="padding.left"
:x2="chartWidth - padding.right"
:y1="y"
:y2="y"
class="sales-chart__grid"
/>
<polygon v-if="areaPoints" :points="areaPoints" class="sales-chart__area" :style="{ fill: `${color}22` }" />
<polyline :points="linePoints" class="sales-chart__line" :style="{ stroke: color }" />
<template v-if="hoveredPoint">
<line
:x1="hoveredPoint.x"
:x2="hoveredPoint.x"
:y1="padding.top"
:y2="chartHeight - padding.bottom"
class="sales-chart__cursor"
/>
<circle :cx="hoveredPoint.x" :cy="hoveredPoint.y" r="4" class="sales-chart__dot" :style="{ fill: color }" />
</template>
</svg>
<div
v-if="hoveredPoint"
class="sales-chart__tooltip"
:style="{ left: `${(hoveredPoint.x / chartWidth) * 100}%` }"
>
<span class="sales-chart__tooltip-label">
{{ labels?.[hoveredPoint.index] ?? `روز ${hoveredPoint.index + 1}` }}
</span>
<strong>{{ formatNumber(hoveredPoint.value) }} {{ unit }}</strong>
</div>
</div>
<div v-if="labels?.length" class="sales-chart__axis">
<span v-for="(label, index) in labels" :key="index">{{ label }}</span>
</div>
</div>
</template>
<style scoped>
.sales-chart__header {
margin-bottom: 12px;
}
.sales-chart__title {
margin: 0;
font-size: 0.9375rem;
font-weight: 700;
}
.sales-chart__subtitle {
margin: 4px 0 0;
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.sales-chart__body {
position: relative;
}
.sales-chart__svg {
width: 100%;
height: 100%;
display: block;
}
.sales-chart__grid {
stroke: var(--color-border);
stroke-width: 1;
stroke-dasharray: 4 4;
}
.sales-chart__line {
fill: none;
stroke-width: 2.25;
stroke-linecap: round;
stroke-linejoin: round;
}
.sales-chart__cursor {
stroke: var(--color-border);
stroke-width: 1;
stroke-dasharray: 3 3;
}
.sales-chart__dot {
stroke: #fff;
stroke-width: 2;
}
.sales-chart__tooltip {
position: absolute;
top: 8px;
transform: translateX(-50%);
min-width: 120px;
padding: 8px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
background: var(--color-surface);
box-shadow: var(--shadow-sm);
pointer-events: none;
text-align: center;
}
.sales-chart__tooltip-label {
display: block;
font-size: 0.6875rem;
color: var(--color-text-muted);
margin-bottom: 2px;
}
.sales-chart__tooltip strong {
font-size: 0.8125rem;
}
.sales-chart__axis {
display: flex;
justify-content: space-between;
margin-top: 8px;
font-size: 0.6875rem;
color: var(--color-text-muted);
}
</style>
+77
View File
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { computed } from 'vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
const props = withDefaults(
defineProps<{
name: string
size?: number
strokeWidth?: number
color?: string | null
}>(),
{
size: 20,
strokeWidth: 1.5,
color: null,
},
)
/** Short admin names → Vuesax linear keys from BasicPacks icons.json */
const ICON_MAP: Record<string, string> = {
home: 'vuesax-linear:home',
orders: 'vuesax-linear:document-text',
customers: 'vuesax-linear:profile-2user',
products: 'vuesax-linear:box-1',
catalogs: 'vuesax-linear:category',
image: 'vuesax-linear:category',
companies: 'vuesax-linear:buildings',
checks: 'vuesax-linear:card-receive',
cash: 'vuesax-linear:money-4',
shipping: 'vuesax-linear:truck-time',
reports: 'vuesax-linear:chart-2',
settings: 'vuesax-linear:setting-2',
users: 'vuesax-linear:people',
logout: 'vuesax-linear:logout',
search: 'vuesax-linear:search-normal',
bell: 'vuesax-linear:notification',
message: 'vuesax-linear:message',
calendar: 'vuesax-linear:calendar',
menu: 'vuesax-linear:menu',
filter: 'vuesax-linear:filter',
more: 'vuesax-linear:more',
'chevron-left': 'vuesax-linear:arrow-left-1',
'chevron-right': 'vuesax-linear:arrow-right-1',
/** RTL back — same direction as user app IconChevronBack */
'chevron-back': 'vuesax-linear:arrow-right-1',
'chevron-down': 'vuesax-linear:arrow-down',
print: 'vuesax-linear:printer',
sms: 'vuesax-linear:sms',
check: 'vuesax-linear:tick-circle',
cart: 'vuesax-linear:bag-2',
file: 'vuesax-linear:document-text',
wallet: 'vuesax-linear:wallet',
'check-circle': 'vuesax-linear:tick-circle',
phone: 'vuesax-linear:call',
'id-card': 'vuesax-linear:personalcard',
lock: 'vuesax-linear:lock',
eye: 'vuesax-linear:eye',
'eye-off': 'vuesax-linear:eye-slash',
user: 'vuesax-linear:profile-circle',
building: 'vuesax-linear:building',
trash: 'vuesax-linear:trash',
}
const iconKey = computed(() => {
if (props.name.startsWith('vuesax-linear:')) return props.name
return ICON_MAP[props.name] ?? 'vuesax-linear:info-circle'
})
</script>
<template>
<BasicIcon
:icon="iconKey"
:size="size"
:stroke-width="strokeWidth"
:color="color"
/>
</template>
+729
View File
@@ -0,0 +1,729 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { onClickOutside, useEventListener } from '@vueuse/core'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { dashboardApi, notificationsApi } from '@/services/api'
import { formatDateTime } from '@/utils/format'
import { resolveAdminInboxLink } from '@/utils/inboxLinks'
import { useAuthStore } from '@/stores/auth'
import { useThemeStore, type ThemePreference } from '@/stores/theme'
import type { AdminNotification, InternalMessage } from '@/types'
defineEmits<{
menuClick: []
}>()
type OpenPanel = 'messages' | 'notifications' | 'theme' | null
const router = useRouter()
const auth = useAuthStore()
const theme = useThemeStore()
const openPanel = ref<OpenPanel>(null)
const actionsRef = ref<HTMLElement | null>(null)
const messageList = ref<InternalMessage[]>([])
const notificationList = ref<AdminNotification[]>([])
const unreadMessages = computed(() => messageList.value.filter((m) => !m.isRead).length)
const unreadNotifications = computed(() => notificationList.value.filter((n) => !n.isRead).length)
const themeOptions: { value: ThemePreference; label: string; icon: string }[] = [
{ value: 'light', label: 'روشن', icon: 'vuesax-linear:sun-1' },
{ value: 'dark', label: 'تاریک', icon: 'vuesax-linear:moon' },
{ value: 'system', label: 'سیستم', icon: 'vuesax-linear:mobile' },
]
const themeLabel = computed(
() => themeOptions.find((o) => o.value === theme.preference)?.label ?? 'سیستم',
)
const themeTriggerIcon = computed(
() => themeOptions.find((o) => o.value === theme.preference)?.icon ?? 'vuesax-linear:sun-1',
)
void dashboardApi
.get()
.then((data) => {
messageList.value = data.messages ?? []
notificationList.value = data.notifications ?? []
})
.catch(() => {
messageList.value = []
notificationList.value = []
})
onClickOutside(actionsRef, () => {
openPanel.value = null
})
useEventListener(document, 'keydown', (event: KeyboardEvent) => {
if (event.key === 'Escape') openPanel.value = null
})
function togglePanel(panel: Exclude<OpenPanel, null>) {
openPanel.value = openPanel.value === panel ? null : panel
}
function closePanel() {
openPanel.value = null
}
function markMessageRead(message: InternalMessage) {
message.isRead = true
}
function markNotificationRead(notification: AdminNotification) {
if (notification.isRead) return
notification.isRead = true
void notificationsApi.markRead(notification.id).catch(() => {
notification.isRead = false
})
}
function markAllMessagesRead() {
messageList.value.forEach((m) => {
m.isRead = true
})
}
function markAllNotificationsRead() {
const previouslyUnread = notificationList.value.filter((n) => !n.isRead)
if (!previouslyUnread.length) return
previouslyUnread.forEach((n) => {
n.isRead = true
})
void notificationsApi.markAllRead().catch(() => {
previouslyUnread.forEach((n) => {
n.isRead = false
})
})
}
function navigateInboxTarget(target: string | null) {
closePanel()
if (!target) return
void router.push(target)
}
function openMessage(message: InternalMessage) {
markMessageRead(message)
navigateInboxTarget(
resolveAdminInboxLink({
link: message.link,
orderId: message.orderId,
title: message.subject,
body: message.preview,
}),
)
}
function openNotification(notification: AdminNotification) {
markNotificationRead(notification)
navigateInboxTarget(
resolveAdminInboxLink({
link: notification.link,
orderId: notification.orderId,
title: notification.title,
body: notification.body,
}),
)
}
function selectTheme(value: ThemePreference) {
theme.setPreference(value)
closePanel()
}
</script>
<template>
<header class="admin-header">
<div class="admin-header__right">
<button
type="button"
class="admin-header__menu-btn compact-only"
aria-label="باز کردن منو"
@click="$emit('menuClick')"
>
<AdminIcon name="menu" :size="22" />
</button>
</div>
<div ref="actionsRef" class="admin-header__left">
<div class="admin-header__dropdown">
<button
type="button"
class="admin-header__icon-btn"
:class="{ 'admin-header__icon-btn--active': openPanel === 'messages' }"
title="پیام‌های داخلی"
:aria-label="`پیام‌های داخلی — ${unreadMessages} پیام خوانده‌نشده`"
:aria-expanded="openPanel === 'messages'"
aria-haspopup="true"
@click.stop="togglePanel('messages')"
>
<AdminIcon name="message" :size="22" />
<span v-if="unreadMessages" class="admin-header__badge" aria-hidden="true">
{{ unreadMessages }}
</span>
<span class="admin-header__icon-label">پیام</span>
</button>
<div
v-if="openPanel === 'messages'"
class="admin-header__panel"
role="dialog"
aria-label="پیام‌های داخلی"
@click.stop
>
<div class="admin-header__panel-header">
<h2 class="admin-header__panel-title">پیام‌های داخلی</h2>
<button
v-if="unreadMessages"
type="button"
class="admin-header__panel-action"
@click="markAllMessagesRead"
>
علامت‌گذاری همه به‌عنوان خوانده‌شده
</button>
</div>
<ul v-if="messageList.length" class="admin-header__panel-list">
<li
v-for="message in messageList"
:key="message.id"
class="admin-header__panel-item"
:class="{ unread: !message.isRead }"
>
<button type="button" class="admin-header__panel-item-btn" @click="openMessage(message)">
<span class="admin-header__panel-item-top">
<strong>{{ message.subject }}</strong>
<time :datetime="message.sentAt">{{ formatDateTime(message.sentAt) }}</time>
</span>
<span class="admin-header__panel-item-sender">از {{ message.senderName }}</span>
<span class="admin-header__panel-item-body">{{ message.preview }}</span>
</button>
</li>
</ul>
<p v-else class="admin-header__panel-empty">پیامی وجود ندارد.</p>
</div>
</div>
<div class="admin-header__dropdown">
<button
type="button"
class="admin-header__icon-btn"
:class="{ 'admin-header__icon-btn--active': openPanel === 'notifications' }"
title="اعلان‌ها"
:aria-label="`اعلان‌ها — ${unreadNotifications} اعلان جدید`"
:aria-expanded="openPanel === 'notifications'"
aria-haspopup="true"
@click.stop="togglePanel('notifications')"
>
<AdminIcon name="bell" :size="22" />
<span v-if="unreadNotifications" class="admin-header__badge" aria-hidden="true">
{{ unreadNotifications }}
</span>
<span class="admin-header__icon-label">اعلان</span>
</button>
<div
v-if="openPanel === 'notifications'"
class="admin-header__panel"
role="dialog"
aria-label="اعلان‌ها"
@click.stop
>
<div class="admin-header__panel-header">
<h2 class="admin-header__panel-title">اعلان‌ها</h2>
<button
v-if="unreadNotifications"
type="button"
class="admin-header__panel-action"
@click="markAllNotificationsRead"
>
علامت‌گذاری همه به‌عنوان خوانده‌شده
</button>
</div>
<ul v-if="notificationList.length" class="admin-header__panel-list">
<li
v-for="notification in notificationList"
:key="notification.id"
class="admin-header__panel-item"
:class="[
{ unread: !notification.isRead },
notification.tone ? `admin-header__panel-item--${notification.tone}` : '',
]"
>
<button
type="button"
class="admin-header__panel-item-btn"
@click="openNotification(notification)"
>
<span class="admin-header__panel-item-top">
<strong>{{ notification.title }}</strong>
<time :datetime="notification.sentAt">{{ formatDateTime(notification.sentAt) }}</time>
</span>
<span class="admin-header__panel-item-body">{{ notification.body }}</span>
</button>
</li>
</ul>
<p v-else class="admin-header__panel-empty">اعلانی وجود ندارد.</p>
</div>
</div>
<div class="admin-header__dropdown">
<button
type="button"
class="admin-header__icon-btn"
:class="{ 'admin-header__icon-btn--active': openPanel === 'theme' }"
title="حالت نمایش"
:aria-label="`حالت نمایش — ${themeLabel}`"
:aria-expanded="openPanel === 'theme'"
aria-haspopup="true"
@click.stop="togglePanel('theme')"
>
<span class="admin-header__theme-glyph" aria-hidden="true">
<BasicIcon :icon="themeTriggerIcon" :size="20" :stroke-width="1.5" />
</span>
<span class="admin-header__icon-label">تم</span>
</button>
<div
v-if="openPanel === 'theme'"
class="admin-header__panel admin-header__panel--theme"
role="dialog"
aria-label="انتخاب حالت نمایش"
@click.stop
>
<div class="admin-header__panel-header">
<h2 class="admin-header__panel-title">حالت نمایش</h2>
</div>
<ul class="admin-header__theme-list">
<li v-for="option in themeOptions" :key="option.value">
<button
type="button"
class="admin-header__theme-option"
:class="{ 'admin-header__theme-option--active': theme.preference === option.value }"
@click="selectTheme(option.value)"
>
<span class="admin-header__theme-option-icon" aria-hidden="true">
<BasicIcon :icon="option.icon" :size="18" :stroke-width="1.5" />
</span>
<span class="admin-header__theme-option-label">{{ option.label }}</span>
<BasicIcon
v-if="theme.preference === option.value"
class="admin-header__theme-option-check"
icon="vuesax-linear:tick-circle"
:size="16"
:stroke-width="1.75"
/>
</button>
</li>
</ul>
</div>
</div>
<div class="admin-header__user">
<div class="admin-header__avatar">{{ auth.fullName.charAt(0) || '؟' }}</div>
<div class="admin-header__user-info">
<strong>{{ auth.fullName || 'مدیر' }}</strong>
<span>{{ auth.roleLabel || 'پنل مدیریت' }}</span>
</div>
</div>
</div>
</header>
</template>
<style scoped>
.admin-header {
height: var(--header-height);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 0 28px;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
}
.admin-header__right {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.admin-header__menu-btn {
width: 40px;
height: 40px;
flex-shrink: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
display: flex;
align-items: center;
justify-content: center;
}
.admin-header__menu-btn:hover {
background: var(--color-border-light);
}
.admin-header__left {
display: flex;
align-items: center;
gap: 8px;
}
.admin-header__dropdown {
position: relative;
}
.admin-header__icon-btn {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
min-width: 52px;
height: 52px;
padding: 6px 8px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
transition: background 0.15s, border-color 0.15s;
}
.admin-header__icon-btn:hover,
.admin-header__icon-btn--active {
background: var(--color-border-light);
border-color: #cbd5e1;
}
.admin-header__icon-label {
font-size: 0.625rem;
font-weight: 600;
color: var(--color-text-muted);
line-height: 1;
}
.admin-header__badge {
position: absolute;
top: 2px;
left: 2px;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: var(--color-danger);
color: #fff;
font-size: 0.625rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid var(--color-surface);
line-height: 1;
}
.admin-header__panel {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 950;
width: min(360px, calc(100vw - 32px));
max-height: min(420px, calc(100dvh - var(--header-height) - 24px));
display: flex;
flex-direction: column;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.admin-header__panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
}
.admin-header__panel-title {
margin: 0;
font-size: 0.9375rem;
font-weight: 700;
color: var(--color-text);
}
.admin-header__panel-action {
border: none;
background: transparent;
color: var(--color-primary);
font-size: 0.75rem;
font-weight: 600;
padding: 0;
white-space: nowrap;
}
.admin-header__panel-action:hover {
text-decoration: underline;
}
.admin-header__panel-list {
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
}
.admin-header__panel-item {
border-bottom: 1px solid var(--color-border-light);
}
.admin-header__panel-item:last-child {
border-bottom: none;
}
.admin-header__panel-item.unread {
background: var(--color-info-light);
}
.admin-header__panel-item-btn {
width: 100%;
border: none;
background: transparent;
text-align: right;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 4px;
color: var(--color-text);
}
.admin-header__panel-item-btn:hover {
background: var(--color-border-light);
}
.admin-header__panel-item-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.admin-header__panel-item-top strong {
font-size: 0.8125rem;
font-weight: 700;
line-height: 1.4;
}
.admin-header__panel-item-top time {
flex-shrink: 0;
font-size: 0.6875rem;
color: var(--color-text-muted);
}
.admin-header__panel-item-sender {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.admin-header__panel-item-body {
font-size: 0.8125rem;
color: var(--color-text-muted);
line-height: 1.5;
}
.admin-header__panel-item--warning.unread {
background: var(--color-warning-light);
}
.admin-header__panel-item--danger.unread {
background: var(--color-danger-light);
}
.admin-header__panel-item--success.unread {
background: var(--color-success-light);
}
.admin-header__panel-empty {
margin: 0;
padding: 24px 16px;
text-align: center;
font-size: 0.875rem;
color: var(--color-text-muted);
}
.admin-header__user {
display: flex;
align-items: center;
gap: 10px;
padding-right: 12px;
margin-right: 4px;
border-right: 1px solid var(--color-border);
}
.admin-header__avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--color-primary);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.875rem;
}
.admin-header__user-info {
display: flex;
flex-direction: column;
gap: 1px;
}
.admin-header__user-info strong {
font-size: 0.8125rem;
font-weight: 600;
}
.admin-header__user-info span {
font-size: 0.6875rem;
color: var(--color-text-muted);
}
.admin-header__theme-glyph {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
color: inherit;
}
.admin-header__panel--theme {
min-width: 200px;
}
.admin-header__theme-list {
list-style: none;
margin: 0;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.admin-header__theme-option {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
text-align: right;
border: 1px solid transparent;
border-radius: 12px;
background: transparent;
color: var(--color-text);
padding: 10px 12px;
font-family: inherit;
font-size: 0.875rem;
cursor: pointer;
}
.admin-header__theme-option-icon {
width: 36px;
height: 36px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: var(--color-bg);
border: 1px solid var(--color-border);
color: var(--color-text);
}
.admin-header__theme-option-label {
flex: 1;
min-width: 0;
font-weight: 600;
}
.admin-header__theme-option-check {
flex-shrink: 0;
color: var(--color-primary);
}
.admin-header__theme-option:hover {
background: var(--color-border-light);
}
.admin-header__theme-option--active {
border-color: var(--color-primary);
background: var(--color-primary-soft);
color: var(--color-primary);
}
.admin-header__theme-option--active .admin-header__theme-option-icon {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
@media (max-width: 767px) {
.admin-header {
padding: 0 16px;
gap: 12px;
}
.admin-header__icon-label {
display: none;
}
.admin-header__icon-btn {
min-width: 40px;
height: 40px;
padding: 0;
}
.admin-header__panel {
position: fixed;
top: var(--header-height);
left: 16px;
right: 16px;
width: auto;
max-height: calc(100dvh - var(--header-height) - 16px);
}
.admin-header__panel-header {
flex-direction: column;
align-items: stretch;
}
.admin-header__panel-action {
align-self: flex-start;
}
.admin-header__user-info {
display: none;
}
.admin-header__user {
border-right: none;
padding-right: 0;
margin-right: 0;
}
}
</style>
+154
View File
@@ -0,0 +1,154 @@
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { ADMIN_NAV_ITEMS, isNavActive } from '@/utils/navigation'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import { useAuthStore } from '@/stores/auth'
defineProps<{
open: boolean
}>()
const emit = defineEmits<{
close: []
}>()
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
function navigate(path: string) {
router.push(path)
emit('close')
}
function logout() {
auth.logout()
router.push('/login')
emit('close')
}
</script>
<template>
<div
class="admin-drawer-backdrop"
:class="{ open }"
role="presentation"
@click="emit('close')"
/>
<aside
class="admin-drawer"
:class="{ open }"
aria-label="منوی ناوبری"
:aria-hidden="!open"
>
<div class="admin-drawer__brand">
<strong>پارچه</strong>
<span>پنل مدیریت</span>
<button type="button" class="admin-drawer__close" aria-label="بستن منو" @click="emit('close')">
×
</button>
</div>
<nav class="admin-drawer__nav">
<button
v-for="item in ADMIN_NAV_ITEMS"
:key="item.name"
type="button"
class="admin-drawer__item"
:class="{ active: isNavActive(item.path, route.path) }"
@click="navigate(item.path)"
>
<AdminIcon :name="item.icon" :size="20" />
<span>{{ item.label }}</span>
</button>
</nav>
<button type="button" class="admin-drawer__logout" @click="logout">
<AdminIcon name="logout" :size="20" />
<span>خروج</span>
</button>
</aside>
</template>
<style scoped>
.admin-drawer__brand {
position: relative;
padding: 20px 16px;
text-align: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.admin-drawer__brand strong {
display: block;
font-size: 1.0625rem;
font-weight: 700;
}
.admin-drawer__brand span {
display: block;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.6);
margin-top: 4px;
}
.admin-drawer__close {
position: absolute;
top: 16px;
left: 12px;
width: 32px;
height: 32px;
border: none;
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.08);
color: #fff;
font-size: 1.25rem;
line-height: 1;
}
.admin-drawer__nav {
flex: 1;
overflow-y: auto;
padding: 12px 10px;
display: flex;
flex-direction: column;
gap: 2px;
}
.admin-drawer__item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 12px 14px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: rgba(255, 255, 255, 0.85);
font-size: 0.875rem;
font-weight: 500;
text-align: right;
}
.admin-drawer__item.active {
background: var(--color-sidebar-active);
font-weight: 600;
}
.admin-drawer__logout {
display: flex;
align-items: center;
gap: 10px;
margin: 8px 10px 16px;
padding: 12px 14px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: #f87171;
font-size: 0.875rem;
}
.admin-drawer__logout:hover {
background: rgba(248, 113, 113, 0.12);
color: #fca5a5;
}
</style>
+139
View File
@@ -0,0 +1,139 @@
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { ADMIN_NAV_ITEMS, isNavActive } from '@/utils/navigation'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
function navigate(path: string) {
router.push(path)
}
function logout() {
auth.logout()
router.push('/login')
}
</script>
<template>
<aside class="admin-sidebar" aria-label="ناوبری پنل مدیریت">
<div class="admin-sidebar__brand">
<strong class="admin-sidebar__title">پارچه</strong>
<span class="admin-sidebar__subtitle">پنل مدیریت</span>
</div>
<nav class="admin-sidebar__nav">
<button
v-for="item in ADMIN_NAV_ITEMS"
:key="item.name"
type="button"
class="admin-sidebar__item"
:class="{ active: isNavActive(item.path, route.path) }"
@click="navigate(item.path)"
>
<AdminIcon :name="item.icon" :size="20" />
<span>{{ item.label }}</span>
</button>
</nav>
<button type="button" class="admin-sidebar__logout" @click="logout">
<AdminIcon name="logout" :size="20" />
<span>خروج</span>
</button>
</aside>
</template>
<style scoped>
.admin-sidebar {
width: var(--sidebar-width);
flex-shrink: 0;
height: 100%;
background: var(--color-sidebar-bg);
color: #fff;
display: flex;
flex-direction: column;
overflow: hidden;
}
.admin-sidebar__brand {
padding: 24px 20px 20px;
text-align: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.admin-sidebar__title {
display: block;
font-size: 1.125rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.admin-sidebar__subtitle {
display: block;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.6);
margin-top: 4px;
}
.admin-sidebar__nav {
flex: 1;
overflow-y: auto;
padding: 12px 10px;
display: flex;
flex-direction: column;
gap: 2px;
}
.admin-sidebar__item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 14px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: rgba(255, 255, 255, 0.85);
font-size: 0.8125rem;
font-weight: 500;
text-align: right;
transition: background var(--transition-fast), color var(--transition-fast);
}
.admin-sidebar__item:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.45);
outline-offset: 2px;
}
.admin-sidebar__item:hover {
background: var(--color-sidebar-hover);
}
.admin-sidebar__item.active {
background: var(--color-sidebar-active);
color: #fff;
font-weight: 600;
}
.admin-sidebar__logout {
display: flex;
align-items: center;
gap: 10px;
margin: 8px 10px 16px;
padding: 10px 14px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: #f87171;
font-size: 0.8125rem;
transition: background 0.15s, color 0.15s;
}
.admin-sidebar__logout:hover {
background: rgba(248, 113, 113, 0.12);
color: #fca5a5;
}
</style>
+57
View File
@@ -0,0 +1,57 @@
<script setup lang="ts">
import BaseSwitch from '@/components/ui/BaseSwitch.vue'
import { mockPersistToggle, useAsyncToggle } from '@/composables/useAsyncToggle'
import { confirm } from '@/composables/useConfirm'
const props = defineProps<{
id: string
modelValue: boolean
entityName: string
entityType?: string
/** When provided, called instead of the mock persist delay. */
persist?: (id: string, isActive: boolean) => Promise<void>
}>()
const emit = defineEmits<{
'update:modelValue': [boolean]
}>()
const { toggle, isLoading, hasError } = useAsyncToggle({
onPersist: async (value) => {
if (props.persist) await props.persist(props.id, value)
else await mockPersistToggle()
},
successMessage: (value) =>
value
? `${props.entityType ?? 'مورد'} «${props.entityName}» فعال شد.`
: `${props.entityType ?? 'مورد'} «${props.entityName}» غیرفعال شد.`,
})
async function onChange(next: boolean) {
if (next === props.modelValue) return
const entity = props.entityType ?? 'مورد'
const ok = await confirm({
title: next ? `فعال‌سازی ${entity}` : `غیرفعال‌سازی ${entity}`,
message: next
? `آیا از فعال کردن «${props.entityName}» مطمئن هستید؟`
: `آیا از غیرفعال کردن «${props.entityName}» مطمئن هستید؟`,
tone: next ? 'primary' : 'danger',
confirmLabel: next ? 'فعال کردن' : 'غیرفعال کردن',
cancelLabel: 'انصراف',
})
if (!ok) return
const result = await toggle(props.id, props.modelValue)
emit('update:modelValue', result)
}
</script>
<template>
<BaseSwitch
:model-value="modelValue"
:loading="isLoading(id)"
:error="hasError(id)"
@update:model-value="onChange"
/>
</template>
+65
View File
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed } from 'vue'
import { extractNumericString, formatNumber } from '@/utils/format'
const props = withDefaults(
defineProps<{
modelValue?: number | null
placeholder?: string
disabled?: boolean
id?: string
inputClass?: string
allowEmpty?: boolean
}>(),
{
modelValue: null,
placeholder: '',
disabled: false,
inputClass: 'admin-input',
allowEmpty: true,
},
)
const emit = defineEmits<{
'update:modelValue': [value: number | null]
}>()
const displayValue = computed(() => {
if (props.modelValue == null || !Number.isFinite(props.modelValue)) return ''
return formatNumber(props.modelValue)
})
function onInput(event: Event) {
const input = event.target as HTMLInputElement
const numeric = extractNumericString(input.value)
if (!numeric) {
emit('update:modelValue', props.allowEmpty ? null : 0)
input.value = ''
return
}
const next = Number(numeric)
emit('update:modelValue', next)
input.value = formatNumber(next)
}
</script>
<template>
<input
:id="id"
type="text"
inputmode="numeric"
dir="ltr"
class="admin-currency-input"
:class="inputClass"
:value="displayValue"
:placeholder="placeholder"
:disabled="disabled"
@input="onInput"
/>
</template>
<style scoped>
.admin-currency-input {
text-align: right;
}
</style>
+127
View File
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { ref } from 'vue'
import { fileToCatalogImage } from '@/utils/catalogImage'
const props = defineProps<{
modelValue?: string | null
label?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string | null]
}>()
const inputRef = ref<HTMLInputElement | null>(null)
const busy = ref(false)
const error = ref('')
function openPicker() {
inputRef.value?.click()
}
async function onFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
busy.value = true
error.value = ''
try {
emit('update:modelValue', await fileToCatalogImage(file))
} catch (e) {
error.value = e instanceof Error ? e.message : 'بارگذاری تصویر ناموفق بود.'
} finally {
busy.value = false
}
}
function remove() {
emit('update:modelValue', null)
}
</script>
<template>
<div class="image-picker">
<p v-if="label" class="image-picker__label">{{ label }}</p>
<div class="image-picker__row">
<div
class="image-picker__preview"
:class="{ 'image-picker__preview--empty': !props.modelValue }"
:style="props.modelValue ? { backgroundImage: `url(${props.modelValue})` } : undefined"
/>
<div class="image-picker__actions">
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm" :disabled="busy" @click="openPicker">
{{ props.modelValue ? 'تغییر تصویر' : 'افزودن تصویر' }}
</button>
<button
v-if="props.modelValue"
type="button"
class="admin-btn admin-btn--outline admin-btn--sm"
:disabled="busy"
@click="remove"
>
حذف
</button>
<p v-if="error" class="image-picker__error">{{ error }}</p>
</div>
</div>
<input
ref="inputRef"
type="file"
accept="image/*"
class="image-picker__file"
@change="onFileChange"
/>
</div>
</template>
<style scoped>
.image-picker__label {
margin: 0 0 8px;
font-size: 0.8125rem;
font-weight: 600;
}
.image-picker__row {
display: flex;
align-items: center;
gap: 12px;
}
.image-picker__preview {
width: 88px;
height: 72px;
border-radius: 10px;
background: #e8eceb center / cover no-repeat;
flex-shrink: 0;
border: 1px solid var(--color-border);
}
.image-picker__preview--empty {
background: repeating-linear-gradient(
45deg,
#eef1f0,
#eef1f0 8px,
#e4e8e6 8px,
#e4e8e6 16px
);
}
.image-picker__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.image-picker__file {
display: none;
}
.image-picker__error {
margin: 0;
width: 100%;
color: var(--color-danger);
font-size: 0.75rem;
}
</style>
+129
View File
@@ -0,0 +1,129 @@
<script setup lang="ts">
withDefaults(
defineProps<{
variant?: 'table' | 'cards' | 'form' | 'detail' | 'page'
rows?: number
}>(),
{
variant: 'cards',
rows: 5,
},
)
</script>
<template>
<div class="admin-page-skeleton" role="status" aria-live="polite" aria-busy="true" aria-label="در حال بارگذاری">
<template v-if="variant === 'table'">
<div class="admin-page-skeleton__toolbar">
<span class="admin-skeleton admin-page-skeleton__search" />
<span class="admin-skeleton admin-page-skeleton__btn" />
</div>
<div class="admin-card admin-page-skeleton__table">
<div v-for="n in rows" :key="n" class="admin-page-skeleton__tr">
<span class="admin-skeleton" style="width: 18%; height: 12px" />
<span class="admin-skeleton" style="width: 22%; height: 12px" />
<span class="admin-skeleton" style="width: 14%; height: 12px" />
<span class="admin-skeleton" style="width: 16%; height: 12px" />
<span class="admin-skeleton" style="width: 12%; height: 12px" />
</div>
</div>
</template>
<template v-else-if="variant === 'form'">
<div class="admin-card admin-page-skeleton__form">
<div v-for="n in rows" :key="n" class="admin-page-skeleton__field">
<span class="admin-skeleton" style="width: 28%; height: 10px" />
<span class="admin-skeleton" style="width: 100%; height: 40px; border-radius: 10px" />
</div>
</div>
</template>
<template v-else-if="variant === 'detail'">
<div class="admin-card admin-page-skeleton__detail">
<span class="admin-skeleton" style="width: 40%; height: 18px" />
<span class="admin-skeleton" style="width: 70%; height: 12px" />
<span class="admin-skeleton" style="width: 100%; height: 120px; border-radius: 12px" />
<span class="admin-skeleton" style="width: 100%; height: 12px" />
<span class="admin-skeleton" style="width: 88%; height: 12px" />
</div>
</template>
<template v-else>
<div class="admin-page-skeleton__cards">
<div v-for="n in rows" :key="n" class="admin-card admin-page-skeleton__card">
<span class="admin-skeleton" style="width: 45%; height: 14px" />
<span class="admin-skeleton" style="width: 70%; height: 11px" />
<span class="admin-skeleton" style="width: 35%; height: 11px" />
</div>
</div>
</template>
</div>
</template>
<style scoped>
.admin-page-skeleton {
display: flex;
flex-direction: column;
gap: 14px;
width: 100%;
}
.admin-page-skeleton__toolbar {
display: flex;
gap: 10px;
align-items: center;
}
.admin-page-skeleton__search {
flex: 1;
height: 40px;
border-radius: 10px;
}
.admin-page-skeleton__btn {
width: 96px;
height: 40px;
border-radius: 10px;
}
.admin-page-skeleton__table {
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px;
}
.admin-page-skeleton__tr {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.admin-page-skeleton__form,
.admin-page-skeleton__detail {
display: flex;
flex-direction: column;
gap: 14px;
padding: 20px;
}
.admin-page-skeleton__field {
display: flex;
flex-direction: column;
gap: 8px;
}
.admin-page-skeleton__cards {
display: flex;
flex-direction: column;
gap: 10px;
}
.admin-page-skeleton__card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px;
}
</style>
+357
View File
@@ -0,0 +1,357 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import { onClickOutside } from '@vueuse/core'
import AdminIcon from '@/components/icons/AdminIcon.vue'
export type AdminSelectOption = {
value: string
label: string
}
const props = withDefaults(
defineProps<{
modelValue: string
options: AdminSelectOption[]
placeholder?: string
ariaLabel?: string
block?: boolean
searchable?: boolean
searchPlaceholder?: string
id?: string
}>(),
{
placeholder: 'انتخاب کنید',
ariaLabel: 'انتخاب',
block: false,
searchable: false,
searchPlaceholder: 'جستجو…',
id: undefined,
},
)
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const open = ref(false)
const searchQuery = ref('')
const rootRef = ref<HTMLElement | null>(null)
const menuRef = ref<HTMLElement | null>(null)
const searchInputRef = ref<HTMLInputElement | null>(null)
const menuStyle = ref<Record<string, string>>({})
onClickOutside(
rootRef,
() => {
open.value = false
searchQuery.value = ''
},
{ ignore: [menuRef] },
)
const selectedLabel = computed(
() => props.options.find((option) => option.value === props.modelValue)?.label ?? props.placeholder,
)
const filteredOptions = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
if (!q) return props.options
return props.options.filter((option) => option.label.toLowerCase().includes(q))
})
function updateMenuPosition() {
const el = rootRef.value
if (!el) return
const rect = el.getBoundingClientRect()
const gap = 6
const maxMenu = Math.min(320, window.innerHeight * 0.5)
const spaceBelow = window.innerHeight - rect.bottom - gap
const spaceAbove = rect.top - gap
const openUp = spaceBelow < Math.min(160, maxMenu) && spaceAbove > spaceBelow
const maxHeight = Math.max(120, Math.min(maxMenu, openUp ? spaceAbove : spaceBelow))
menuStyle.value = {
position: 'fixed',
left: `${Math.max(8, Math.min(rect.left, window.innerWidth - Math.max(rect.width, 220) - 8))}px`,
width: `${Math.max(rect.width, 220)}px`,
zIndex: '1100',
maxHeight: `${maxHeight}px`,
...(openUp
? {
top: 'auto',
bottom: `${window.innerHeight - rect.top + gap}px`,
}
: {
top: `${rect.bottom + gap}px`,
bottom: 'auto',
}),
}
}
function bindPositionListeners(active: boolean) {
if (active) {
window.addEventListener('resize', updateMenuPosition)
window.addEventListener('scroll', updateMenuPosition, true)
} else {
window.removeEventListener('resize', updateMenuPosition)
window.removeEventListener('scroll', updateMenuPosition, true)
}
}
async function toggle() {
open.value = !open.value
if (open.value) {
await nextTick()
updateMenuPosition()
if (props.searchable) {
searchQuery.value = ''
await nextTick()
searchInputRef.value?.focus()
}
}
}
function pick(value: string) {
emit('update:modelValue', value)
open.value = false
searchQuery.value = ''
}
watch(open, (isOpen) => {
bindPositionListeners(isOpen)
if (isOpen) {
void nextTick(() => updateMenuPosition())
}
})
onUnmounted(() => {
bindPositionListeners(false)
})
</script>
<template>
<div
:id="id"
ref="rootRef"
class="admin-select"
:class="{ 'admin-select--open': open, 'admin-select--block': block }"
>
<button
type="button"
class="admin-select__trigger"
:aria-label="ariaLabel"
:aria-expanded="open"
aria-haspopup="listbox"
@click="toggle"
>
<span class="admin-select__value">{{ selectedLabel }}</span>
<span class="admin-select__chevron" aria-hidden="true">
<AdminIcon name="chevron-down" :size="16" />
</span>
</button>
<Teleport to="body">
<ul
v-if="open"
ref="menuRef"
class="admin-select__menu"
role="listbox"
:aria-label="ariaLabel"
:style="menuStyle"
>
<li v-if="searchable" class="admin-select__search" role="presentation">
<input
ref="searchInputRef"
v-model="searchQuery"
type="search"
class="admin-select__search-input"
:placeholder="searchPlaceholder"
@click.stop
@keydown.esc.stop="open = false"
/>
</li>
<li v-if="!filteredOptions.length" class="admin-select__empty" role="presentation">
موردی یافت نشد
</li>
<li v-for="option in filteredOptions" :key="option.value" role="presentation">
<button
type="button"
class="admin-select__option"
:class="{ 'admin-select__option--active': option.value === modelValue }"
role="option"
:aria-selected="option.value === modelValue"
@click="pick(option.value)"
>
<span>{{ option.label }}</span>
<AdminIcon
v-if="option.value === modelValue"
name="check"
:size="16"
class="admin-select__check"
/>
</button>
</li>
</ul>
</Teleport>
</div>
</template>
<style scoped>
.admin-select {
position: relative;
min-width: 200px;
max-width: 100%;
}
.admin-select--block {
width: 100%;
min-width: 0;
}
.admin-select__trigger {
width: 100%;
min-height: var(--control-height, 40px);
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 12px 0 10px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
font-family: inherit;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast),
background var(--transition-fast);
}
.admin-select__trigger:hover {
border-color: var(--color-primary);
background: var(--color-elevated, var(--color-surface));
}
.admin-select--open .admin-select__trigger,
.admin-select__trigger:focus-visible {
outline: none;
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.admin-select__value {
flex: 1;
min-width: 0;
text-align: start;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-select__chevron {
display: inline-flex;
flex-shrink: 0;
color: var(--color-text-muted);
transition: transform var(--transition-fast);
}
.admin-select--open .admin-select__chevron {
transform: rotate(180deg);
color: var(--color-primary);
}
.admin-select__menu {
margin: 0;
padding: 8px;
list-style: none;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-card, 0 12px 28px rgba(15, 23, 42, 0.12));
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
box-sizing: border-box;
}
.admin-select__search {
position: sticky;
top: 0;
z-index: 1;
background: var(--color-surface);
padding-bottom: 4px;
}
.admin-select__search-input {
width: 100%;
height: 36px;
padding: 0 12px;
border: 1px solid var(--color-border);
border-radius: 10px;
background: var(--color-bg);
color: var(--color-text);
font: inherit;
font-size: 0.8125rem;
}
.admin-select__search-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.admin-select__empty {
padding: 12px;
font-size: 0.8125rem;
color: var(--color-text-muted);
text-align: center;
}
.admin-select__option {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border: 1px solid transparent;
border-radius: 12px;
background: transparent;
color: var(--color-text);
font-family: inherit;
font-size: 0.875rem;
font-weight: 500;
text-align: start;
cursor: pointer;
transition:
background var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast);
}
.admin-select__option:hover {
background: var(--color-bg);
border-color: var(--color-border);
}
.admin-select__option--active {
background: var(--color-primary-soft);
border-color: transparent;
color: var(--color-primary);
font-weight: 600;
}
.admin-select__option--active:hover {
background: var(--color-primary-soft);
border-color: transparent;
}
.admin-select__check {
flex-shrink: 0;
color: var(--color-primary);
}
</style>
+90
View File
@@ -0,0 +1,90 @@
<script setup lang="ts">
import AdminIcon from '@/components/icons/AdminIcon.vue'
withDefaults(
defineProps<{
icon?: string
title: string
description?: string
compact?: boolean
}>(),
{
icon: 'file',
},
)
defineEmits<{
action: []
secondary: []
}>()
</script>
<template>
<div class="base-empty" :class="{ 'base-empty--compact': compact }">
<div class="base-empty__icon" aria-hidden="true">
<AdminIcon :name="icon" :size="compact ? 28 : 36" />
</div>
<h3 class="base-empty__title">{{ title }}</h3>
<p v-if="description" class="base-empty__description">{{ description }}</p>
<div v-if="$slots.default || $slots.secondary" class="base-empty__actions">
<slot />
<slot name="secondary" />
</div>
</div>
</template>
<style scoped>
.base-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 40px 24px;
gap: 8px;
}
.base-empty--compact {
padding: 28px 16px;
}
.base-empty__icon {
width: 56px;
height: 56px;
border-radius: var(--radius-md);
background: var(--color-border-light);
color: var(--color-text-muted);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 4px;
}
.base-empty--compact .base-empty__icon {
width: 48px;
height: 48px;
}
.base-empty__title {
margin: 0;
font-size: 0.9375rem;
font-weight: 700;
color: var(--color-text);
}
.base-empty__description {
margin: 0;
max-width: 28rem;
font-size: 0.8125rem;
color: var(--color-text-muted);
line-height: 1.6;
}
.base-empty__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
margin-top: 8px;
}
</style>
+180
View File
@@ -0,0 +1,180 @@
<script setup lang="ts">
defineProps<{
open: boolean
title: string
size?: 'md' | 'lg' | 'xl'
}>()
const emit = defineEmits<{
close: []
}>()
function onBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
emit('close')
}
}
</script>
<template>
<Teleport to="body">
<div
v-if="open"
class="admin-modal-backdrop"
role="presentation"
@click="onBackdropClick"
>
<div
class="admin-modal"
:class="{
'admin-modal--lg': size === 'lg',
'admin-modal--xl': size === 'xl',
}"
role="dialog"
aria-modal="true"
:aria-label="title"
@click.stop
>
<header class="admin-modal__header">
<h2 class="admin-modal__title">{{ title }}</h2>
<button type="button" class="admin-modal__close" aria-label="بستن" @click="emit('close')">
×
</button>
</header>
<div class="admin-modal__body">
<slot />
</div>
<footer v-if="$slots.footer" class="admin-modal__footer">
<slot name="footer" />
</footer>
</div>
</div>
</Teleport>
</template>
<style scoped>
.admin-modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(15, 23, 42, 0.52);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.admin-modal {
width: min(100%, 480px);
max-height: calc(100dvh - 32px);
overflow: hidden;
display: flex;
flex-direction: column;
background: var(--color-surface);
border-radius: var(--radius-lg);
box-shadow: 0 20px 48px rgba(15, 23, 42, 0.18);
}
.admin-modal--lg {
width: min(100%, 640px);
}
.admin-modal--xl {
width: min(100%, 960px);
}
.admin-modal__header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid var(--color-border);
}
.admin-modal__title {
margin: 0;
font-size: 1rem;
font-weight: 700;
color: var(--color-text);
}
.admin-modal__close {
width: 32px;
height: 32px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-muted);
font-size: 1.5rem;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
}
.admin-modal__close:hover {
background: var(--color-border-light);
color: var(--color-text);
}
.admin-modal__body {
flex: 1 1 auto;
min-height: 0;
padding: 16px 20px 20px;
overflow-y: auto;
}
.admin-modal__footer {
flex-shrink: 0;
display: flex;
flex-direction: row-reverse;
align-items: center;
justify-content: flex-start;
gap: 10px;
padding: 14px 20px;
border-top: 1px solid var(--color-border);
background: var(--color-bg);
}
.admin-modal__footer :deep(.admin-btn) {
min-width: 0;
}
@media (max-width: 767px) {
.admin-modal-backdrop {
padding: 0;
align-items: flex-end;
}
.admin-modal,
.admin-modal--lg,
.admin-modal--xl {
width: 100%;
max-height: 92dvh;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
}
.admin-modal__header {
padding: 14px 16px;
}
.admin-modal__body {
padding: 14px 16px 18px;
}
.admin-modal__footer {
flex-direction: row-reverse;
gap: 12px;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
}
.admin-modal__footer :deep(.admin-btn) {
flex: 1;
min-height: 44px;
}
}
</style>
+164
View File
@@ -0,0 +1,164 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
modelValue: boolean
disabled?: boolean
loading?: boolean
error?: boolean
label?: string
activeText?: string
inactiveText?: string
id?: string
}>(),
{
activeText: 'فعال',
inactiveText: 'غیرفعال',
},
)
const emit = defineEmits<{
'update:modelValue': [boolean]
}>()
const inputId = computed(() => props.id ?? `switch-${crypto.randomUUID().slice(0, 8)}`)
const stateText = computed(() => (props.modelValue ? props.activeText : props.inactiveText))
function onChange(event: Event) {
const input = event.target as HTMLInputElement
const next = input.checked
// Keep the visual state locked to modelValue until the parent accepts the change.
// Native click flips the DOM checkbox even when the parent cancels.
input.checked = props.modelValue
if (props.disabled || props.loading) return
emit('update:modelValue', next)
}
</script>
<template>
<label
class="base-switch"
:class="{ 'base-switch--disabled': disabled || loading, 'base-switch--error': error }"
:title="stateText"
>
<span v-if="label" class="base-switch__label">{{ label }}</span>
<span class="base-switch__control">
<input
:id="inputId"
class="base-switch__input"
type="checkbox"
role="switch"
:checked="modelValue"
:disabled="disabled || loading"
:aria-checked="modelValue"
:aria-label="label || stateText"
@change="onChange"
/>
<span class="base-switch__track" aria-hidden="true">
<span class="base-switch__thumb">
<span v-if="loading" class="base-switch__spinner" />
</span>
</span>
</span>
</label>
</template>
<style scoped>
.base-switch {
display: inline-flex;
flex-direction: column;
gap: 6px;
cursor: pointer;
}
.base-switch--disabled {
cursor: not-allowed;
opacity: 0.72;
}
.base-switch--error .base-switch__track {
box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.18);
}
.base-switch__label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text);
}
.base-switch__control {
display: inline-flex;
align-items: center;
}
.base-switch__input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.base-switch__input:focus-visible + .base-switch__track {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.base-switch__track {
position: relative;
width: 44px;
height: 24px;
border-radius: 999px;
background: #cbd5e1;
transition: background var(--transition-fast);
flex-shrink: 0;
}
.base-switch__input:checked + .base-switch__track {
background: var(--color-success);
}
.base-switch__thumb {
position: absolute;
top: 2px;
right: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
box-shadow: var(--shadow-sm);
transition: transform var(--transition-fast);
display: flex;
align-items: center;
justify-content: center;
}
.base-switch__input:checked + .base-switch__track .base-switch__thumb {
transform: translateX(-20px);
}
.base-switch__spinner {
width: 12px;
height: 12px;
border: 2px solid #e2e8f0;
border-top-color: var(--color-primary);
border-radius: 50%;
animation: base-switch-spin 0.7s linear infinite;
}
@keyframes base-switch-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.base-switch__track,
.base-switch__thumb,
.base-switch__spinner {
transition: none;
animation: none;
}
}
</style>
+45
View File
@@ -0,0 +1,45 @@
<script setup lang="ts">
import BaseModal from '@/components/ui/BaseModal.vue'
import { resolveConfirm, setConfirmLoading, useConfirmState } from '@/composables/useConfirm'
const state = useConfirmState()
function cancel() {
resolveConfirm(false)
}
function confirmAction() {
setConfirmLoading(true)
resolveConfirm(true)
}
</script>
<template>
<BaseModal :open="state.open" :title="state.title" size="md" @close="cancel">
<p class="confirm-dialog__message">{{ state.message }}</p>
<template #footer>
<button type="button" class="admin-btn admin-btn--outline" :disabled="state.loading" @click="cancel">
{{ state.cancelLabel }}
</button>
<button
type="button"
class="admin-btn"
:class="state.tone === 'danger' ? 'admin-btn--danger' : 'admin-btn--primary'"
:disabled="state.loading"
@click="confirmAction"
>
<span v-if="state.loading" class="admin-btn__spinner" aria-hidden="true" />
{{ state.confirmLabel }}
</button>
</template>
</BaseModal>
</template>
<style scoped>
.confirm-dialog__message {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-muted);
line-height: 1.7;
}
</style>
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { OrderStatus } from '@/types'
import { ORDER_STATUS_META } from '@/utils/orderStatus'
defineProps<{
status: OrderStatus | 'active' | 'inactive' | 'pending_check_review' | 'check_approved' | 'check_rejected'
}>()
/** فقط وضعیت‌هایی که سفارش نیستند */
const extraMeta: Record<string, { label: string; tone: string }> = {
active: { label: 'فعال', tone: 'success' },
inactive: { label: 'غیرفعال', tone: 'neutral' },
pending_check_review: { label: 'در انتظار بررسی', tone: 'warning' },
check_approved: { label: 'تأیید شده', tone: 'success' },
check_rejected: { label: 'رد شده', tone: 'danger' },
}
</script>
<template>
<span
class="admin-badge status-badge"
:class="`admin-badge--${(ORDER_STATUS_META[status as OrderStatus] ?? extraMeta[status])?.tone ?? 'neutral'}`"
>
{{ (ORDER_STATUS_META[status as OrderStatus] ?? extraMeta[status])?.label ?? status }}
</span>
</template>
<style scoped>
.status-badge {
letter-spacing: -0.01em;
}
</style>
+171
View File
@@ -0,0 +1,171 @@
<script setup lang="ts">
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { useToast } from '@/composables/useToast'
const { toasts, dismiss } = useToast()
const toneIcons: Record<string, string> = {
success: 'vuesax-linear:tick-circle',
error: 'vuesax-linear:close-circle',
warning: 'vuesax-linear:warning-2',
info: 'vuesax-linear:info-circle',
}
function onDismiss(id: string, event: MouseEvent) {
event.preventDefault()
event.stopPropagation()
dismiss(id)
}
</script>
<template>
<div class="toast-container" aria-live="polite" aria-relevant="additions">
<TransitionGroup name="toast" tag="div" class="toast-container__list">
<div
v-for="toast in toasts"
:key="toast.id"
class="toast"
:class="`toast--${toast.tone}`"
role="status"
>
<span class="toast__icon" aria-hidden="true">
<BasicIcon
:icon="toneIcons[toast.tone] || 'vuesax-linear:info-circle'"
:size="20"
:stroke-width="1.5"
/>
</span>
<p class="toast__message">{{ toast.message }}</p>
<button
type="button"
class="toast__close"
aria-label="بستن"
@click="onDismiss(toast.id, $event)"
>
×
</button>
</div>
</TransitionGroup>
</div>
</template>
<style scoped>
.toast-container {
position: fixed;
top: calc(var(--header-height) + 12px);
left: 16px;
z-index: 1200;
width: min(360px, calc(100vw - 32px));
pointer-events: none;
}
.toast-container__list {
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
}
.toast {
pointer-events: auto;
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
box-shadow: var(--shadow-card);
}
.toast--success {
border-color: var(--color-success);
background: var(--color-success-light);
color: var(--color-success-dark);
}
.toast--error {
border-color: var(--color-danger);
background: var(--color-danger-light);
color: var(--color-danger);
}
.toast--warning {
border-color: var(--color-warning);
background: var(--color-warning-light);
color: var(--color-warning-dark);
}
.toast--info {
border-color: var(--color-info);
background: var(--color-info-light);
color: var(--color-info);
}
.toast__icon {
flex-shrink: 0;
margin-top: 1px;
color: inherit;
}
.toast__message {
margin: 0;
flex: 1;
font-size: 0.8125rem;
font-weight: 600;
line-height: 1.5;
color: inherit;
}
.toast--success .toast__message,
.toast--error .toast__message,
.toast--warning .toast__message,
.toast--info .toast__message {
color: var(--color-text);
}
.toast__close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
margin: -4px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--color-text-muted);
font-size: 1.25rem;
line-height: 1;
padding: 0;
flex-shrink: 0;
cursor: pointer;
pointer-events: auto;
}
.toast__close:hover {
background: var(--color-border-light);
color: var(--color-text);
}
.toast-enter-active,
.toast-leave-active {
transition:
opacity 0.18s ease,
transform 0.18s ease;
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateY(-8px);
}
@media (prefers-reduced-motion: reduce) {
.toast-enter-active,
.toast-leave-active {
transition: none;
}
}
</style>
+56
View File
@@ -0,0 +1,56 @@
import { ref } from 'vue'
import { useToast } from '@/composables/useToast'
export function mockPersistToggle(delayMs = 480): Promise<void> {
return new Promise((resolve) => {
window.setTimeout(resolve, delayMs)
})
}
export function useAsyncToggle(options: {
onPersist: (value: boolean) => Promise<void>
successMessage?: (value: boolean) => string
errorMessage?: string
confirmOff?: () => Promise<boolean>
}) {
const loadingId = ref<string | null>(null)
const errorId = ref<string | null>(null)
const toast = useToast()
async function toggle(id: string, current: boolean): Promise<boolean> {
if (loadingId.value) return current
const next = !current
if (!next && options.confirmOff) {
const confirmed = await options.confirmOff()
if (!confirmed) return current
}
loadingId.value = id
errorId.value = null
const previous = current
try {
await options.onPersist(next)
toast.success(options.successMessage?.(next) ?? (next ? 'وضعیت فعال شد.' : 'وضعیت غیرفعال شد.'))
return next
} catch {
errorId.value = id
toast.error(options.errorMessage ?? 'به‌روزرسانی وضعیت انجام نشد.')
return previous
} finally {
loadingId.value = null
}
}
function isLoading(id: string) {
return loadingId.value === id
}
function hasError(id: string) {
return errorId.value === id
}
return { toggle, isLoading, hasError }
}
+42
View File
@@ -0,0 +1,42 @@
import { computed } from 'vue'
import { useWindowSize } from '@vueuse/core'
export type BreakpointName = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
const BREAKPOINTS = {
xs: 0,
sm: 480,
md: 768,
lg: 1024,
xl: 1280,
'2xl': 1440,
} as const
function getBreakpoint(width: number): BreakpointName {
if (width >= BREAKPOINTS['2xl']) return '2xl'
if (width >= BREAKPOINTS.xl) return 'xl'
if (width >= BREAKPOINTS.lg) return 'lg'
if (width >= BREAKPOINTS.md) return 'md'
if (width >= BREAKPOINTS.sm) return 'sm'
return 'xs'
}
export function useBreakpoint() {
const { width } = useWindowSize()
const breakpoint = computed(() => getBreakpoint(width.value))
const isDesktop = () => breakpoint.value === 'xl' || breakpoint.value === '2xl'
const isTablet = () => breakpoint.value === 'md' || breakpoint.value === 'lg'
const isMobile = () => breakpoint.value === 'xs' || breakpoint.value === 'sm'
const isCompact = () => !isDesktop()
return {
width,
breakpoint,
isDesktop,
isTablet,
isMobile,
isCompact,
BREAKPOINTS,
}
}
+56
View File
@@ -0,0 +1,56 @@
import { reactive } from 'vue'
export interface ConfirmOptions {
title: string
message: string
confirmLabel?: string
cancelLabel?: string
tone?: 'danger' | 'primary'
}
interface ConfirmState extends ConfirmOptions {
open: boolean
loading: boolean
resolve: ((value: boolean) => void) | null
}
const state = reactive<ConfirmState>({
open: false,
loading: false,
title: '',
message: '',
confirmLabel: 'تأیید',
cancelLabel: 'انصراف',
tone: 'primary',
resolve: null,
})
export function useConfirmState() {
return state
}
export function confirm(options: ConfirmOptions): Promise<boolean> {
return new Promise((resolve) => {
Object.assign(state, {
open: true,
loading: false,
title: options.title,
message: options.message,
confirmLabel: options.confirmLabel ?? 'تأیید',
cancelLabel: options.cancelLabel ?? 'انصراف',
tone: options.tone ?? 'primary',
resolve,
})
})
}
export function resolveConfirm(value: boolean) {
state.resolve?.(value)
state.open = false
state.loading = false
state.resolve = null
}
export function setConfirmLoading(loading: boolean) {
state.loading = loading
}
+130
View File
@@ -0,0 +1,130 @@
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { dashboardApi, mapAdminOrder, type DashboardStats } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
import type { CheckDeadline, Order } from '@/types'
export const iconBg: Record<string, string> = {
info: 'var(--color-info-light)',
warning: 'var(--color-warning-light)',
purple: 'var(--color-purple-light)',
success: 'var(--color-success-light)',
}
export const iconColor: Record<string, string> = {
info: 'var(--color-info)',
warning: 'var(--color-warning-dark)',
purple: 'var(--color-purple)',
success: 'var(--color-success)',
}
const emptyStats = (): DashboardStats => ({
newOrders: 0,
pendingReview: 0,
pendingCheck: 0,
finalApproval: 0,
todaySales: 0,
todaySalesGrowth: 0,
monthSales: 0,
})
export function useDashboard() {
const router = useRouter()
const toast = useToast()
const dashboardStats = ref<DashboardStats>(emptyStats())
const recentOrders = ref<Order[]>([])
const salesChartData = ref<number[]>(Array.from({ length: 28 }, () => 0))
const chartAxisLabels = ref(['۱', '۷', '۱۴', '۲۱', '۲۸'])
const checkDeadlines = ref<CheckDeadline[]>([])
const loading = ref(false)
const statCards = computed(() => [
{
label: 'سفارش‌های جدید',
value: dashboardStats.value.newOrders,
icon: 'cart',
tone: 'info',
link: '/orders',
},
{
label: 'در انتظار بررسی',
value: dashboardStats.value.pendingReview,
icon: 'file',
tone: 'warning',
link: '/orders?tab=pending_review',
},
{
label: 'بررسی چک',
value: dashboardStats.value.pendingCheck,
icon: 'wallet',
tone: 'purple',
link: '/orders?tab=pending_check',
},
{
label: 'تأیید نهایی شده',
value: dashboardStats.value.finalApproval,
icon: 'check-circle',
tone: 'success',
link: '/orders?tab=final_approval',
},
] as const)
const chartPoints = computed(() => {
const w = 280
const h = 120
const data = salesChartData.value
if (!data.length) return ''
const max = Math.max(...data, 0.001)
const step = data.length > 1 ? w / (data.length - 1) : w
return data
.map((v, i) => {
const x = i * step
const y = h - (v / max) * (h - 10) - 5
return `${x},${y}`
})
.join(' ')
})
async function loadDashboard() {
loading.value = true
try {
const data = await dashboardApi.get()
dashboardStats.value = data.stats
recentOrders.value = (data.recentOrders ?? []).map((o) => mapAdminOrder(o) as Order)
salesChartData.value = data.salesChartData?.length
? data.salesChartData
: Array.from({ length: 28 }, () => 0)
chartAxisLabels.value = data.chartAxisLabels?.length
? data.chartAxisLabels
: ['۱', '۷', '۱۴', '۲۱', '۲۸']
checkDeadlines.value = data.checkDeadlines ?? []
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری داشبورد انجام نشد.')
} finally {
loading.value = false
}
}
onMounted(() => {
void loadDashboard()
})
function goOrders() {
router.push('/orders')
}
return {
checkDeadlines,
dashboardStats,
recentOrders,
chartPoints,
salesChartData,
chartAxisLabels,
statCards,
loading,
goOrders,
loadDashboard,
}
}
+301
View File
@@ -0,0 +1,301 @@
import { computed, inject, onMounted, provide, reactive, ref, watch, type InjectionKey } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { companiesApi, customersApi, mapAdminOrder, ordersApi } from '@/services/api'
import { ApiError } from '@/services/http'
import type { Company, Customer, Order, RawOrderStatus, SettlementMethod } from '@/types'
import { rawStatusLabel } from '@/utils/orderStatus'
import { useToast } from '@/composables/useToast'
export function useOrdersPage() {
const route = useRoute()
const router = useRouter()
const toast = useToast()
const companies = ref<Company[]>([])
const customers = ref<Customer[]>([])
const orderList = ref<Order[]>([])
const activeTab = ref('all')
const selectedOrderId = ref<string>('')
const searchQuery = ref('')
const showAddModal = ref(false)
const showDetailPanel = ref(false)
const showDetailModal = ref(false)
const formError = ref('')
const saving = ref(false)
const emptyForm = () => ({
customerId: '',
customerName: '',
companyId: companies.value[0]?.id ?? '',
settlementMethod: 'check' as SettlementMethod,
meterage: 1000,
customerPhone: '',
})
const form = reactive(emptyForm())
const isFormValid = computed(
() => form.customerId.length > 0 && form.companyId.length > 0,
)
const activeCustomers = computed(() => {
const list = [...customers.value]
list.sort((a, b) => Number(b.isActive) - Number(a.isActive) || a.name.localeCompare(b.name, 'fa'))
return list
})
function applyCustomer(customerId: string) {
const customer = customers.value.find((c) => c.id === customerId)
form.customerId = customerId
form.customerName = customer?.name ?? ''
form.customerPhone = customer?.phone ?? ''
}
async function loadCustomers() {
try {
customers.value = await customersApi.list()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری مشتریان انجام نشد.')
}
}
const filteredOrders = computed(() => {
let list = orderList.value
if (activeTab.value !== 'all') {
list = list.filter((o) => o.status === activeTab.value)
}
if (searchQuery.value.trim()) {
const q = searchQuery.value.trim()
list = list.filter(
(o) => o.orderNumber.includes(q) || o.customerName.includes(q),
)
}
return list
})
const selectedOrder = computed(() =>
orderList.value.find((o) => o.id === selectedOrderId.value) ?? null,
)
function findOrderByRef(ref: string): Order | undefined {
const key = ref.trim()
if (!key) return undefined
return orderList.value.find(
(o) => o.id === key || o.orderNumber.toUpperCase() === key.toUpperCase(),
)
}
async function applyOrderDeepLink() {
const orderRef = typeof route.query.order === 'string' ? route.query.order : ''
if (!orderRef || !orderList.value.length) return
const order = findOrderByRef(orderRef)
if (!order) {
searchQuery.value = orderRef
return
}
const focus = typeof route.query.focus === 'string' ? route.query.focus : ''
if (focus === 'pricing') {
await router.replace(`/orders/${order.id}/pricing`)
return
}
if (focus === 'checks') {
await router.replace(`/orders/${order.id}/checks`)
return
}
openOrderDetail(order)
searchQuery.value = order.orderNumber
}
async function loadData() {
try {
const [ordersData, companiesData, customersData] = await Promise.all([
ordersApi.list(),
companiesApi.list(),
customersApi.list(),
])
orderList.value = ordersData.map((o) => mapAdminOrder(o) as Order)
companies.value = companiesData
customers.value = customersData
if (!form.companyId && companiesData[0]) {
form.companyId = companiesData[0].id
}
await applyOrderDeepLink()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری سفارش‌ها انجام نشد.')
}
}
onMounted(() => {
void loadData()
})
watch(
() => [route.query.order, route.query.focus] as const,
() => {
void applyOrderDeepLink()
},
)
function selectOrder(order: Order, openDetail = false) {
selectedOrderId.value = order.id
if (openDetail) {
showDetailPanel.value = true
}
}
function openOrderDetail(order: Order) {
selectedOrderId.value = order.id
showDetailModal.value = true
}
function closeDetailPanel() {
showDetailPanel.value = false
}
function closeDetailModal() {
showDetailModal.value = false
}
function settlementLabel(method?: string | null) {
if (!method) return 'انتخاب‌نشده'
return method === 'check' ? 'چکی' : 'نقدی'
}
const statusSaving = ref(false)
async function changeStatus(payload: {
orderId: string
status: RawOrderStatus
note?: string
reason?: string
force?: boolean
}) {
if (statusSaving.value) return null
statusSaving.value = true
try {
const updated = await ordersApi.updateStatus(payload.orderId, {
status: payload.status,
note: payload.note,
reason: payload.reason,
force: payload.force,
})
const mapped = mapAdminOrder(updated) as Order
const index = orderList.value.findIndex((o) => o.id === payload.orderId)
if (index >= 0) orderList.value[index] = { ...orderList.value[index]!, ...mapped }
toast.success(`وضعیت سفارش به «${rawStatusLabel(payload.status)}» تغییر کرد.`)
return mapped
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'تغییر وضعیت انجام نشد.')
return null
} finally {
statusSaving.value = false
}
}
function goPricing(order: Order) {
router.push(`/orders/${order.id}/pricing`)
}
function goChecks(order: Order) {
router.push(`/orders/${order.id}/checks`)
}
function goCashReceipt(order: Order) {
router.push({ path: '/cash-payments', query: { order: order.orderNumber } })
}
function openAddModal() {
Object.assign(form, emptyForm())
formError.value = ''
showAddModal.value = true
void loadCustomers()
}
function closeAddModal() {
showAddModal.value = false
formError.value = ''
}
async function submitAddOrder() {
if (!isFormValid.value) {
formError.value = 'انتخاب مشتری و شرکت الزامی است.'
return
}
saving.value = true
try {
const created = await ordersApi.create({
companyId: form.companyId,
customerId: form.customerId || undefined,
userId: form.customerId || undefined,
customerName: form.customerName.trim(),
customerPhone: form.customerPhone.trim() || undefined,
settlementMethod: form.settlementMethod,
meterage: form.meterage,
})
const mapped = mapAdminOrder(created) as Order
orderList.value.unshift(mapped)
selectedOrderId.value = mapped.id
toast.success('سفارش جدید ثبت شد.')
closeAddModal()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ثبت سفارش انجام نشد.')
} finally {
saving.value = false
}
}
return {
companies,
customers,
activeCustomers,
applyCustomer,
loadCustomers,
orderList,
activeTab,
selectedOrderId,
searchQuery,
showAddModal,
showDetailPanel,
showDetailModal,
formError,
form,
isFormValid,
filteredOrders,
selectedOrder,
selectOrder,
openOrderDetail,
closeDetailPanel,
closeDetailModal,
settlementLabel,
changeStatus,
statusSaving,
goPricing,
goChecks,
goCashReceipt,
openAddModal,
closeAddModal,
submitAddOrder,
saving,
}
}
export type OrdersPageContext = ReturnType<typeof useOrdersPage>
export const ORDERS_PAGE_KEY: InjectionKey<OrdersPageContext> = Symbol('orders-page')
export function provideOrdersPage() {
const ctx = useOrdersPage()
provide(ORDERS_PAGE_KEY, ctx)
return ctx
}
export function useOrdersPageContext() {
const ctx = inject(ORDERS_PAGE_KEY)
if (!ctx) throw new Error('Orders page context is missing')
return ctx
}
+37
View File
@@ -0,0 +1,37 @@
import { reactive, toRef } from 'vue'
export type ToastTone = 'success' | 'error' | 'info' | 'warning'
export interface ToastItem {
id: string
message: string
tone: ToastTone
}
const state = reactive<{ items: ToastItem[] }>({
items: [],
})
function push(message: string, tone: ToastTone) {
const id = crypto.randomUUID()
state.items.push({ id, message, tone })
window.setTimeout(() => dismiss(id), 3200)
}
function dismiss(id: string) {
const index = state.items.findIndex((item) => item.id === id)
if (index !== -1) {
state.items.splice(index, 1)
}
}
export function useToast() {
return {
toasts: toRef(state, 'items'),
dismiss,
success: (message: string) => push(message, 'success'),
error: (message: string) => push(message, 'error'),
info: (message: string) => push(message, 'info'),
warning: (message: string) => push(message, 'warning'),
}
}
+757
View File
@@ -0,0 +1,757 @@
import type {
AdminNotification,
AdminUser,
BankAccount,
CashPayment,
Catalog,
Check,
CheckDeadline,
Company,
Customer,
InternalMessage,
Order,
Product,
Shipment,
} from '@/types'
export const companies: Company[] = [
{
id: 'company-par',
name: 'شرکت پَر',
description: 'تولید و عرضه انواع پارچه',
displayOrder: 1,
isActive: true,
logoUrl: null,
},
{
id: 'company-fam-baft',
name: 'شرکت فام بافت',
description: 'تولید و عرضه انواع پارچه',
displayOrder: 2,
isActive: true,
logoUrl: null,
},
]
export const bankAccounts: BankAccount[] = [
{
id: 'ba-1',
companyId: 'company-par',
bankName: '1',
accountNumber: '1234567890',
accountHolder: 'شرکت پَر',
cardNumber: '6104 3378 0077 1234',
iban: 'IR870120000000012345678901',
isActive: true,
displayOrder: 1,
},
{
id: 'ba-2',
companyId: 'company-par',
bankName: '2',
accountNumber: '9876543210',
accountHolder: 'شرکت پَر',
cardNumber: '6037 9912 3456 7890',
iban: 'IR620190000000098765432101',
isActive: true,
displayOrder: 2,
},
]
export const orders: Order[] = [
{
id: 'ord-1',
orderNumber: 'ORD-1403-1256',
customerName: 'شرکت پَر',
companyName: 'شرکت پَر',
companyId: 'company-par',
settlementMethod: 'check',
totalAmount: 2_450_000_000,
meterage: 4250,
status: 'pending_review',
createdAt: '2024-10-25T10:30:00',
customerPhone: '۰۹۱۲۱۲۳۴۵۶۷',
customerNationalId: '۱۰۱۰۳۴۵۶۷۸۹',
customerType: 'عمده‌فروش',
items: [
{
id: 'oi-1',
catalogCode: '377',
productName: 'پارچه آبی',
color: 'آبی',
requestedMeterage: 2000,
confirmedMeterage: 2000,
supplyPrice: null,
totalAmount: null,
},
{
id: 'oi-2',
catalogCode: '377',
productName: 'پارچه مشکی',
color: 'مشکی',
requestedMeterage: 1500,
confirmedMeterage: 1000,
supplyPrice: null,
totalAmount: null,
},
{
id: 'oi-3',
catalogCode: '378',
productName: 'آبی پشت سفید',
color: 'آبی پشت سفید',
requestedMeterage: 750,
confirmedMeterage: 750,
supplyPrice: null,
totalAmount: null,
},
],
},
{
id: 'ord-2',
orderNumber: 'ORD-1403-1255',
customerName: 'شرکت فام بافت',
companyName: 'شرکت فام بافت',
companyId: 'company-fam-baft',
settlementMethod: 'check',
totalAmount: 1_850_000_000,
meterage: 3200,
status: 'pending_check',
createdAt: '2024-10-24T14:15:00',
},
{
id: 'ord-3',
orderNumber: 'ORD-1403-1254',
customerName: 'پارچه‌سرای نیکو',
companyName: 'شرکت پَر',
companyId: 'company-par',
settlementMethod: 'cash',
totalAmount: 980_000_000,
meterage: 1800,
status: 'final_approval',
createdAt: '2024-10-23T09:00:00',
},
{
id: 'ord-4',
orderNumber: 'ORD-1403-1253',
customerName: 'بافت‌آسان',
companyName: 'شرکت فام بافت',
companyId: 'company-fam-baft',
settlementMethod: 'check',
totalAmount: 3_200_000_000,
meterage: 5100,
status: 'check_correction',
createdAt: '2024-10-22T16:45:00',
},
{
id: 'ord-5',
orderNumber: 'ORD-1403-1252',
customerName: 'شرکت پَر',
companyName: 'شرکت پَر',
companyId: 'company-par',
settlementMethod: 'cash',
totalAmount: 750_000_000,
meterage: 1200,
status: 'delivered',
createdAt: '2024-10-21T11:20:00',
},
{
id: 'ord-6',
orderNumber: 'ORD-1403-1251',
customerName: 'پارچه‌سرای نیکو',
companyName: 'شرکت پَر',
companyId: 'company-par',
settlementMethod: 'check',
totalAmount: 1_600_000_000,
meterage: 2900,
status: 'pending_review',
createdAt: '2024-10-20T08:30:00',
},
{
id: 'ord-7',
orderNumber: 'ORD-1403-1250',
customerName: 'بافت‌آسان',
companyName: 'شرکت فام بافت',
companyId: 'company-fam-baft',
settlementMethod: 'check',
totalAmount: 2_100_000_000,
meterage: 3800,
status: 'pending_check',
createdAt: '2024-10-19T13:00:00',
},
]
export const checks: Check[] = [
{
id: 'chk-1',
orderId: 'ord-1',
row: 1,
amount: 1_000_000_000,
bankName: 'ملت',
sayadId: '12345678901234567',
dueDate: '1403/07/30',
status: 'pending_review',
},
{
id: 'chk-2',
orderId: 'ord-1',
row: 2,
amount: 800_000_000,
bankName: 'ملی',
sayadId: '23456789012345678',
dueDate: '1403/08/05',
status: 'pending_review',
},
{
id: 'chk-3',
orderId: 'ord-1',
row: 3,
amount: 650_000_000,
bankName: 'ملت',
sayadId: '34567890123456789',
dueDate: '1403/08/10',
status: 'pending_review',
},
]
export const checkDeadlines: CheckDeadline[] = [
{ id: 'cd-1', label: 'امروز', tone: 'danger', count: 7, amount: 1_250_000_000 },
{ id: 'cd-2', label: 'فردا', tone: 'warning', count: 5, amount: 820_000_000 },
{ id: 'cd-3', label: '۴ روز دیگر', tone: 'neutral', count: 3, amount: 670_000_000 },
]
export const dashboardStats = {
newOrders: 12,
pendingReview: 18,
pendingCheck: 9,
finalApproval: 24,
todaySales: 2_450_000_000,
todaySalesGrowth: 12,
monthSales: 15_860_000_000,
}
export const salesChartData = [
0.4, 0.6, 0.5, 0.8, 0.7, 1.0, 0.9, 1.2, 1.1, 1.4, 1.3, 1.6, 1.5, 1.8,
1.7, 2.0, 1.9, 1.7, 1.8, 2.0, 1.9, 1.6, 1.8, 2.0, 1.7, 1.9, 1.8, 2.0,
]
export const internalMessages: InternalMessage[] = [
{
id: 'msg-1',
senderName: 'سارا احمدی',
subject: 'هماهنگی ارسال سفارش ORD-1403-1256',
preview: 'لطفاً وضعیت باربری سفارش مشتری پارچه‌سرای نیکو را بررسی کنید.',
sentAt: '2024-10-25T09:15:00',
isRead: false,
orderId: 'ord-1',
link: '/shipping?order=ORD-1403-1256',
},
{
id: 'msg-2',
senderName: 'رضا کریمی',
subject: 'درخواست اصلاح قیمت',
preview: 'مشتری درخواست تخفیف ۵٪ روی متراژ بالای ۳۰۰۰ متر داده است.',
sentAt: '2024-10-25T08:40:00',
isRead: false,
orderId: 'ord-1',
link: '/orders/ord-1/pricing',
},
{
id: 'msg-3',
senderName: 'مریم حسینی',
subject: 'تأیید چک‌های سفارش ۱۲۵۴',
preview: 'دو فقره چک بررسی شد؛ یک مورد نیاز به اصلاح دارد.',
sentAt: '2024-10-24T16:20:00',
isRead: false,
orderId: 'ord-3',
link: '/orders/ord-3/checks',
},
{
id: 'msg-4',
senderName: 'امیر نوری',
subject: 'گزارش فروش هفتگی',
preview: 'گزارش فروش واحد تهران برای هفته جاری آماده است.',
sentAt: '2024-10-24T11:00:00',
isRead: false,
link: '/reports',
},
{
id: 'msg-5',
senderName: 'فاطمه رضایی',
subject: 'ثبت مشتری جدید',
preview: 'مشتری «نساجی آریا» با مدارک کامل ثبت شد.',
sentAt: '2024-10-23T14:30:00',
isRead: false,
link: '/customers',
},
{
id: 'msg-6',
senderName: 'حسین مرادی',
subject: 'یادآوری جلسه',
preview: 'جلسه هماهنگی انبار فردا ساعت ۱۰ برگزار می‌شود.',
sentAt: '2024-10-22T09:00:00',
isRead: true,
},
]
export const adminNotifications: AdminNotification[] = [
{
id: 'ntf-1',
title: 'سفارش جدید ثبت شد',
body: 'سفارش ORD-1403-1260 از مشتری پارچه‌سرای نیکو در انتظار بررسی است.',
sentAt: '2024-10-25T10:05:00',
isRead: false,
tone: 'info',
link: '/orders?order=ORD-1403-1260',
},
{
id: 'ntf-2',
title: 'چک در انتظار تأیید',
body: '۳ فقره چک برای سفارش ORD-1403-1254 نیاز به بررسی دارد.',
sentAt: '2024-10-25T09:50:00',
isRead: false,
tone: 'warning',
orderId: 'ord-3',
link: '/orders/ord-3/checks',
},
{
id: 'ntf-3',
title: 'پیش‌فاکتور ارسال شد',
body: 'پیش‌فاکتور سفارش ORD-1403-1252 برای مشتری ارسال گردید.',
sentAt: '2024-10-25T09:20:00',
isRead: false,
tone: 'success',
orderId: 'ord-5',
link: '/orders/ord-5/pricing',
},
{
id: 'ntf-4',
title: 'سررسید چک نزدیک است',
body: '۷ فقره چک با سررسید امروز — مجموع ۱٬۲۵۰٬۰۰۰٬۰۰۰ تومان.',
sentAt: '2024-10-25T08:00:00',
isRead: false,
tone: 'danger',
link: '/checks',
},
{
id: 'ntf-5',
title: 'درخواست اصلاح سفارش',
body: 'مشتری برای سفارش ORD-1403-1248 درخواست تغییر متراژ داده است.',
sentAt: '2024-10-24T17:30:00',
isRead: false,
tone: 'warning',
link: '/orders?order=ORD-1403-1248&focus=pricing',
},
{
id: 'ntf-6',
title: 'حساب بانکی جدید',
body: 'حساب بانکی جدید برای شرکت فام بافت ثبت شد.',
sentAt: '2024-10-24T15:00:00',
isRead: false,
tone: 'info',
link: '/companies/company-fam-baft/accounts',
},
{
id: 'ntf-7',
title: 'تأیید نهایی سفارش',
body: 'سفارش ORD-1403-1245 آماده ارسال است.',
sentAt: '2024-10-24T12:45:00',
isRead: false,
tone: 'success',
link: '/shipping?order=ORD-1403-1245',
},
{
id: 'ntf-8',
title: 'پیامک ارسال نشد',
body: 'ارسال پیامک برای مشتری ۰۹۱۲۱۲۳۴۵۶۷ با خطا مواجه شد.',
sentAt: '2024-10-24T11:20:00',
isRead: false,
tone: 'danger',
link: '/settings',
},
{
id: 'ntf-9',
title: 'سفارش رد شد',
body: 'سفارش ORD-1403-1240 توسط واحد مالی رد شد.',
sentAt: '2024-10-23T16:00:00',
isRead: false,
tone: 'warning',
link: '/orders?order=ORD-1403-1240',
},
{
id: 'ntf-10',
title: 'گزارش ماهانه آماده است',
body: 'گزارش فروش مهر ۱۴۰۳ قابل مشاهده است.',
sentAt: '2024-10-23T10:00:00',
isRead: false,
tone: 'info',
link: '/reports',
},
{
id: 'ntf-11',
title: 'محصول جدید به کاتالوگ اضافه شد',
body: 'کالیته ۴۰۱ با ۱۲ رنگ جدید ثبت شد.',
sentAt: '2024-10-22T14:00:00',
isRead: false,
tone: 'info',
link: '/catalogs',
},
{
id: 'ntf-12',
title: 'یادآوری پرداخت نقدی',
body: '۲ سفارش نقدی در انتظار تأیید پرداخت هستند.',
sentAt: '2024-10-22T09:30:00',
isRead: false,
tone: 'warning',
link: '/cash-payments',
},
{
id: 'ntf-13',
title: 'پشتیبان‌گیری موفق',
body: 'پشتیبان‌گیری شبانه سامانه با موفقیت انجام شد.',
sentAt: '2024-10-21T06:00:00',
isRead: true,
tone: 'success',
link: '/settings',
},
]
export const customers: Customer[] = [
{
id: 'cust-1',
name: 'پارچه‌سرای نیکو',
phone: '۰۹۱۲۱۲۳۴۵۶۷',
nationalId: '۱۰۱۰۳۴۵۶۷۸۹',
customerType: 'wholesale',
companyName: 'شرکت پَر',
orderCount: 24,
totalMeterage: 18500,
isActive: true,
registeredAt: '2024-03-12T10:00:00',
},
{
id: 'cust-2',
name: 'نساجی آریا',
phone: '۰۹۱۳۵۵۵۱۲۳۴',
nationalId: '۱۰۳۰۵۶۷۸۹۰۱',
customerType: 'wholesale',
companyName: 'شرکت فام بافت',
orderCount: 11,
totalMeterage: 9200,
isActive: true,
registeredAt: '2024-05-20T14:30:00',
},
{
id: 'cust-3',
name: 'فروشگاه پارچه مهر',
phone: '۰۲۱۸۸۷۷۶۶۵۵',
nationalId: '۰۰۱۲۳۴۵۶۷۸',
customerType: 'retail',
companyName: 'شرکت پَر',
orderCount: 6,
totalMeterage: 2100,
isActive: true,
registeredAt: '2024-07-08T09:15:00',
},
{
id: 'cust-4',
name: 'پوشاک سپهر',
phone: '۰۹۳۶۹۸۷۶۵۴۳',
nationalId: '۱۰۵۰۹۸۷۶۵۴۳',
customerType: 'wholesale',
companyName: 'شرکت پَر',
orderCount: 3,
totalMeterage: 4500,
isActive: false,
registeredAt: '2024-08-01T11:00:00',
},
]
export const catalogs: Catalog[] = [
{
id: 'cat-377',
companyId: 'company-par',
companyName: 'شرکت پَر',
code: '377',
name: 'کالیته ۳۷۷',
displayOrder: 1,
productCount: 8,
isActive: true,
},
{
id: 'cat-378',
companyId: 'company-par',
companyName: 'شرکت پَر',
code: '378',
name: 'کالیته ۳۷۸',
displayOrder: 2,
productCount: 12,
isActive: true,
},
{
id: 'cat-379',
companyId: 'company-par',
companyName: 'شرکت پَر',
code: '379',
name: 'کالیته ۳۷۹',
displayOrder: 3,
productCount: 6,
isActive: true,
},
{
id: 'cat-401',
companyId: 'company-fam-baft',
companyName: 'شرکت فام بافت',
code: '401',
name: 'کالیته ۴۰۱',
displayOrder: 1,
productCount: 10,
isActive: true,
},
{
id: 'cat-402',
companyId: 'company-fam-baft',
companyName: 'شرکت فام بافت',
code: '402',
name: 'کالیته ۴۰۲',
displayOrder: 2,
productCount: 4,
isActive: false,
},
]
export const products: Product[] = [
{
id: 'prod-1',
catalogId: 'cat-377',
catalogCode: '377',
companyName: 'شرکت پَر',
name: 'پارچه آبی',
color: 'آبی',
productCode: '377-BLU',
minMeterage: 100,
orderStep: 50,
price: 185000,
internalStock: 12000,
isActive: true,
},
{
id: 'prod-2',
catalogId: 'cat-377',
catalogCode: '377',
companyName: 'شرکت پَر',
name: 'پارچه مشکی',
color: 'مشکی',
productCode: '377-BLK',
minMeterage: 100,
orderStep: 50,
price: 192000,
internalStock: 8500,
isActive: true,
},
{
id: 'prod-3',
catalogId: 'cat-378',
catalogCode: '378',
companyName: 'شرکت پَر',
name: 'آبی پشت سفید',
color: 'آبی / سفید',
productCode: '378-BW',
minMeterage: 200,
orderStep: 100,
price: 210000,
internalStock: 6000,
isActive: true,
},
{
id: 'prod-4',
catalogId: 'cat-378',
catalogCode: '378',
companyName: 'شرکت پَر',
name: 'مشکی پشت سفید',
color: 'مشکی / سفید',
productCode: '378-BLKW',
minMeterage: 200,
orderStep: 100,
price: 215000,
internalStock: 4200,
isActive: true,
},
{
id: 'prod-5',
catalogId: 'cat-401',
catalogCode: '401',
companyName: 'شرکت فام بافت',
name: 'پارچه کرم',
color: 'کرم',
productCode: '401-CRM',
minMeterage: 150,
orderStep: 50,
price: null,
internalStock: 3000,
isActive: true,
},
]
export const cashPayments: CashPayment[] = [
{
id: 'cp-1',
orderId: 'ord-2',
orderNumber: 'ORD-1403-1254',
customerName: 'پارچه‌سرای نیکو',
amount: 1_850_000_000,
paidAmount: 1_850_000_000,
transferDate: '2024-10-25T08:30:00',
trackingCode: '۹۸۷۶۵۴۳۲۱۰',
expiresAt: '2024-10-25T17:00:00',
status: 'pending_review',
},
{
id: 'cp-2',
orderId: 'ord-3',
orderNumber: 'ORD-1403-1248',
customerName: 'نساجی آریا',
amount: 920_000_000,
paidAmount: 900_000_000,
transferDate: '2024-10-24T15:20:00',
trackingCode: '۱۲۳۴۵۶۷۸۹۰',
expiresAt: '2024-10-24T17:00:00',
status: 'pending_review',
},
{
id: 'cp-3',
orderId: 'ord-4',
orderNumber: 'ORD-1403-1240',
customerName: 'فروشگاه پارچه مهر',
amount: 450_000_000,
paidAmount: 450_000_000,
transferDate: '2024-10-23T11:00:00',
trackingCode: '۵۵۵۵۱۲۳۴۵۶',
expiresAt: '2024-10-23T17:00:00',
status: 'approved',
},
{
id: 'cp-4',
orderId: 'ord-5',
orderNumber: 'ORD-1403-1235',
customerName: 'پوشاک سپهر',
amount: 680_000_000,
paidAmount: null,
transferDate: null,
trackingCode: '—',
expiresAt: '2024-10-22T17:00:00',
status: 'expired',
},
]
export const shipments: Shipment[] = [
{
id: 'shp-1',
orderId: 'ord-6',
orderNumber: 'ORD-1403-1220',
customerName: 'پارچه‌سرای نیکو',
carrier: 'باربری تهران-اصفهان',
trackingNumber: 'BR-1403-88901',
meterage: 3200,
status: 'shipped',
shippedAt: '2024-10-24T09:00:00',
deliveredAt: null,
},
{
id: 'shp-2',
orderId: 'ord-7',
orderNumber: 'ORD-1403-1215',
customerName: 'نساجی آریا',
carrier: 'باربری سریع',
trackingNumber: 'BR-1403-88902',
meterage: 1800,
status: 'ready',
shippedAt: null,
deliveredAt: null,
},
{
id: 'shp-3',
orderId: 'ord-8',
orderNumber: 'ORD-1403-1208',
customerName: 'فروشگاه پارچه مهر',
carrier: 'پیک داخلی',
trackingNumber: 'PK-1403-0045',
meterage: 950,
status: 'preparing',
shippedAt: null,
deliveredAt: null,
},
{
id: 'shp-4',
orderId: 'ord-9',
orderNumber: 'ORD-1403-1199',
customerName: 'پارچه‌سرای نیکو',
carrier: 'باربری تهران-مشهد',
trackingNumber: 'BR-1403-88750',
meterage: 4100,
status: 'delivered',
shippedAt: '2024-10-18T10:00:00',
deliveredAt: '2024-10-20T16:30:00',
},
]
export const adminUsers: AdminUser[] = [
{
id: 'usr-1',
fullName: 'علی محمدی',
phone: '۰۹۱۲۱۱۱۲۲۲۲',
email: 'admin@fabrictrade.ir',
role: 'admin',
isActive: true,
lastLoginAt: '2024-10-25T08:00:00',
},
{
id: 'usr-2',
fullName: 'سارا احمدی',
phone: '۰۹۱۳۳۳۳۴۴۴۴',
email: 'sales@fabrictrade.ir',
role: 'sales',
isActive: true,
lastLoginAt: '2024-10-25T07:45:00',
},
{
id: 'usr-3',
fullName: 'رضا کریمی',
phone: '۰۹۱۵۵۵۵۶۶۶۶',
email: 'finance@fabrictrade.ir',
role: 'finance',
isActive: true,
lastLoginAt: '2024-10-24T17:10:00',
},
{
id: 'usr-4',
fullName: 'مریم حسینی',
phone: '۰۹۱۷۷۷۷۸۸۸۸',
email: 'warehouse@fabrictrade.ir',
role: 'warehouse',
isActive: true,
lastLoginAt: '2024-10-24T14:00:00',
},
{
id: 'usr-5',
fullName: 'امیر نوری',
phone: '۰۹۱۹۹۹۹۰۰۰۰',
email: 'amir.n@fabrictrade.ir',
role: 'sales',
isActive: false,
lastLoginAt: '2024-09-30T11:00:00',
},
]
export const reportSalesByCompany = [
{ companyName: 'شرکت پَر', orderCount: 68, totalAmount: 12_450_000_000, meterage: 28500 },
{ companyName: 'شرکت فام بافت', orderCount: 35, totalAmount: 6_820_000_000, meterage: 14200 },
]
export const reportOrdersByStatus = [
{ status: 'در انتظار بررسی', count: 18 },
{ status: 'بررسی چک', count: 9 },
{ status: 'نیاز به اصلاح', count: 6 },
{ status: 'تأیید نهایی', count: 24 },
{ status: 'ارسال شده', count: 12 },
{ status: 'تحویل شده', count: 34 },
]
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import AdminSidebar from '@/components/layout/AdminSidebar.vue'
import AdminHeader from '@/components/layout/AdminHeader.vue'
import AdminMobileDrawer from '@/components/layout/AdminMobileDrawer.vue'
const route = useRoute()
const drawerOpen = ref(false)
function openDrawer() {
drawerOpen.value = true
}
function closeDrawer() {
drawerOpen.value = false
}
watch(
() => route.path,
() => {
drawerOpen.value = false
},
)
</script>
<template>
<div class="admin-shell">
<AdminSidebar class="desktop-only admin-shell__sidebar" />
<AdminMobileDrawer :open="drawerOpen" @close="closeDrawer" />
<div class="admin-shell__main">
<AdminHeader @menu-click="openDrawer" />
<main class="admin-shell__content">
<RouterView />
</main>
</div>
</div>
</template>
<style scoped>
.admin-shell {
display: flex;
height: 100dvh;
overflow: hidden;
}
.admin-shell__main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.admin-shell__content {
flex: 1;
overflow-y: auto;
padding: 24px 28px;
}
</style>
+23
View File
@@ -0,0 +1,23 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { useAuthStore } from '@/stores/auth'
import { useThemeStore } from '@/stores/theme'
import 'virtual:uno.css'
import '@/assets/styles/main.css'
import '@/assets/styles/admin.css'
import '@/assets/styles/responsive.css'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
useThemeStore(pinia)
useAuthStore(pinia).hydrate()
app.mount('#app')
+186
View File
@@ -0,0 +1,186 @@
import { createRouter, createWebHistory } from 'vue-router'
import AdminShell from '@/layouts/AdminShell.vue'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/login',
name: 'login',
component: () => import('@/views/auth/LoginView.vue'),
meta: { public: true, title: 'ورود به پنل مدیریت' },
},
{
path: '/',
component: AdminShell,
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'dashboard',
component: () => import('@/views/dashboard/DashboardView.vue'),
meta: { title: 'داشبورد مدیریتی' },
},
{
path: 'orders',
name: 'orders',
component: () => import('@/views/orders/OrdersView.vue'),
meta: { title: 'سفارش‌ها' },
},
{
path: 'orders/:id/pricing',
name: 'order-pricing',
component: () => import('@/views/orders/OrderPricingView.vue'),
meta: { title: 'قیمت‌گذاری سفارش' },
},
{
path: 'checks',
name: 'checks',
component: () => import('@/views/checks/ChecksListView.vue'),
meta: { title: 'چک‌ها' },
},
{
path: 'orders/:id/checks',
name: 'order-checks',
component: () => import('@/views/checks/CheckManagementView.vue'),
meta: { title: 'مدیریت چک‌های سفارش' },
},
{
/* API links often use /orders/:id — open list + detail via query */
path: 'orders/:id',
name: 'order-detail',
redirect: (to) => ({
name: 'orders',
query: { order: String(to.params.id) },
}),
},
{
path: 'companies',
name: 'companies',
component: () => import('@/views/companies/CompaniesView.vue'),
meta: { title: 'مدیریت شرکت‌ها' },
},
{
path: 'companies/:companyId/accounts',
name: 'bank-accounts',
component: () => import('@/views/companies/BankAccountsView.vue'),
meta: { title: 'حساب‌های بانکی' },
},
{
path: 'customers',
name: 'customers',
component: () => import('@/views/customers/CustomersView.vue'),
meta: { title: 'مشتریان' },
},
{
path: 'products',
name: 'products',
component: () => import('@/views/products/ProductsView.vue'),
meta: { title: 'محصولات' },
},
{
path: 'categories',
name: 'categories',
component: () => import('@/views/categories/CategoriesView.vue'),
meta: { title: 'دسته‌بندی‌ها' },
},
{
path: 'banners',
name: 'banners',
component: () => import('@/views/banners/BannersView.vue'),
meta: { title: 'بنرها' },
},
{
path: 'catalogs',
name: 'catalogs',
component: () => import('@/views/catalogs/CatalogsView.vue'),
meta: { title: 'کالیته‌ها' },
},
{
path: 'catalog-requests',
name: 'catalog-requests',
component: () => import('@/views/catalog-requests/CatalogRequestsView.vue'),
meta: { title: 'درخواست کالیته فیزیکی' },
},
{
path: 'brands',
name: 'brands',
component: () => import('@/views/brands/BrandsView.vue'),
meta: { title: 'برندها' },
},
{
path: 'suggestions',
name: 'suggestions',
component: () => import('@/views/suggestions/SuggestionsView.vue'),
meta: { title: 'پیشنهاد محصول' },
},
{
path: 'stock-alerts',
name: 'stock-alerts',
component: () => import('@/views/stock-alerts/StockAlertsView.vue'),
meta: { title: 'خبر موجودی' },
},
{
path: 'levels',
name: 'levels',
component: () => import('@/views/levels/LevelsView.vue'),
meta: { title: 'سطوح مشتری' },
},
{
path: 'cash-payments',
name: 'cash-payments',
component: () => import('@/views/cash-payments/CashPaymentsView.vue'),
meta: { title: 'پرداخت‌های نقدی' },
},
{
path: 'shipping',
name: 'shipping',
component: () => import('@/views/shipping/ShippingView.vue'),
meta: { title: 'ارسال و باربری' },
},
{
path: 'reports',
name: 'reports',
component: () => import('@/views/reports/ReportsView.vue'),
meta: { title: 'گزارش‌ها' },
},
{
path: 'settings',
name: 'settings',
component: () => import('@/views/settings/SettingsView.vue'),
meta: { title: 'تنظیمات' },
},
{
path: 'users',
name: 'users',
component: () => import('@/views/users/UsersView.vue'),
meta: { title: 'کاربران و دسترسی‌ها' },
},
],
},
],
})
router.beforeEach((to) => {
const auth = useAuthStore()
auth.hydrate()
if (to.meta.public) {
if (auth.isAuthenticated && to.name === 'login') {
return { name: 'dashboard' }
}
return true
}
if (to.matched.some((record) => record.meta.requiresAuth) && !auth.isAuthenticated) {
return {
name: 'login',
query: { redirect: to.fullPath },
}
}
return true
})
export default router
+628
View File
@@ -0,0 +1,628 @@
import { apiRequest } from '@/services/http'
import type {
AdminNotification,
AdminUser,
BankAccount,
CashPayment,
Catalog,
Check,
CheckDeadline,
Company,
Customer,
InternalMessage,
Order,
OrderItem,
OrderStatus,
OrderStatusHistoryItem,
Product,
RawOrderStatus,
SettlementMethod,
Shipment,
ShipmentStatus,
UserRole,
} from '@/types'
/** Normalize admin list order status badges */
export function mapAdminOrder<
T extends {
status: string
adminStatus?: string
allowedTransitions?: string[]
settlementMethod?: string | null
settlementType?: string | null
meterage?: number
totalMeterage?: number
},
>(
order: T,
): T & {
status: OrderStatus
rawStatus: RawOrderStatus
allowedTransitions: RawOrderStatus[]
settlementMethod: SettlementMethod | null
meterage: number
} {
return {
...order,
status: (order.adminStatus ?? order.status) as OrderStatus,
rawStatus: order.status as RawOrderStatus,
allowedTransitions: (order.allowedTransitions ?? []) as RawOrderStatus[],
settlementMethod: (order.settlementMethod ??
order.settlementType ??
null) as SettlementMethod | null,
meterage: order.meterage ?? order.totalMeterage ?? 0,
}
}
export function slugifyCompanyName(name: string): string {
const slug = name
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
return slug || `c-${Date.now()}`
}
/* ── Companies ─────────────────────────────────────────────── */
export type CreateCompanyPayload = {
name: string
slug: string
description?: string
logoUrl?: string | null
displayOrder?: number
isActive?: boolean
}
export type UpdateCompanyPayload = Partial<Omit<CreateCompanyPayload, 'slug'>> & {
slug?: string
}
export const companiesApi = {
list: (activeOnly?: boolean) =>
apiRequest<Company[]>('/companies', { query: { activeOnly: activeOnly ? 'true' : undefined } }),
get: (id: string) => apiRequest<Company>(`/companies/${id}`),
create: (body: CreateCompanyPayload) => apiRequest<Company>('/companies', { body }),
update: (id: string, body: UpdateCompanyPayload) =>
apiRequest<Company>(`/companies/${id}`, { method: 'PATCH', body }),
setActive: (id: string, isActive: boolean) =>
apiRequest<Company>(`/companies/${id}/active`, { method: 'PATCH', body: { isActive } }),
}
/* ── Bank accounts ─────────────────────────────────────────── */
export type CreateBankAccountPayload = {
bankName: string
accountHolder: string
accountNumber: string
cardNumber: string
iban: string
displayOrder?: number
isActive?: boolean
notes?: string
}
export type UpdateBankAccountPayload = Partial<CreateBankAccountPayload>
export const bankAccountsApi = {
listByCompany: (companyId: string) =>
apiRequest<BankAccount[]>(`/companies/${companyId}/bank-accounts`),
create: (companyId: string, body: CreateBankAccountPayload) =>
apiRequest<BankAccount>(`/companies/${companyId}/bank-accounts`, { body }),
update: (id: string, body: UpdateBankAccountPayload) =>
apiRequest<BankAccount>(`/bank-accounts/${id}`, { method: 'PATCH', body }),
setActive: (id: string, isActive: boolean) =>
apiRequest<BankAccount>(`/bank-accounts/${id}/active`, { method: 'PATCH', body: { isActive } }),
}
/* ── Catalogs ──────────────────────────────────────────────── */
export type CreateCatalogPayload = {
companyId: string
name: string
code: string
description?: string
displayOrder?: number
isActive?: boolean
}
export type UpdateCatalogPayload = Partial<Omit<CreateCatalogPayload, 'companyId'>>
export const catalogsApi = {
list: (params?: { companyId?: string; activeOnly?: boolean }) =>
apiRequest<Catalog[]>('/catalogs', {
query: {
companyId: params?.companyId,
activeOnly: params?.activeOnly ? 'true' : undefined,
},
}),
create: (body: CreateCatalogPayload) => apiRequest<Catalog>('/catalogs', { body }),
update: (id: string, body: UpdateCatalogPayload) =>
apiRequest<Catalog>(`/catalogs/${id}`, { method: 'PATCH', body }),
setActive: (id: string, isActive: boolean) =>
apiRequest<Catalog>(`/catalogs/${id}/active`, { method: 'PATCH', body: { isActive } }),
}
/* ── Products ──────────────────────────────────────────────── */
export type CreateProductPayload = {
companyId: string
catalogId: string
name: string
code?: string
productCode?: string
color: string
minMeterage?: number
orderStep?: number
orderMultiplier?: number
price?: number | null
basePrice?: number | null
internalStock?: number | null
stock?: number | null
isActive?: boolean
description?: string
usageCategory?: string
season?: string
composition?: string | null
weaveType?: string | null
subtitle?: string | null
rollMeters?: number
widthCm?: number | null
weightGrams?: number | null
isNew?: boolean
colorsJson?: string[]
imageUrlsJson?: string[]
tagsJson?: string[]
}
export type UpdateProductPayload = Partial<Omit<CreateProductPayload, 'companyId'>>
export const productsApi = {
list: (params?: { catalogId?: string; companyId?: string; activeOnly?: boolean }) =>
apiRequest<Product[]>('/products', {
query: {
catalogId: params?.catalogId,
companyId: params?.companyId,
activeOnly: params?.activeOnly ? 'true' : undefined,
},
}),
create: (body: CreateProductPayload) => apiRequest<Product>('/products', { body }),
update: (id: string, body: UpdateProductPayload) =>
apiRequest<Product>(`/products/${id}`, { method: 'PATCH', body }),
setActive: (id: string, isActive: boolean) =>
apiRequest<Product>(`/products/${id}/active`, { method: 'PATCH', body: { isActive } }),
}
/* ── Customers ─────────────────────────────────────────────── */
export type CreateCustomerPayload = {
name?: string
firstName?: string
lastName?: string
phone: string
nationalId?: string
customerType?: Customer['customerType']
companyId?: string | null
isActive?: boolean
levelDefId?: string | null
assignedAgentId?: string | null
appRole?: 'customer' | 'agent'
agentCode?: string | null
brandIds?: string[]
}
export type UpdateCustomerPayload = Partial<CreateCustomerPayload>
export const customersApi = {
list: (q?: string, appRole?: 'customer' | 'agent') =>
apiRequest<Customer[]>('/admin/customers', { query: { q, appRole } }),
create: (body: CreateCustomerPayload) => apiRequest<Customer>('/admin/customers', { body }),
update: (id: string, body: UpdateCustomerPayload) =>
apiRequest<Customer>(`/admin/customers/${id}`, { method: 'PATCH', body }),
setActive: (id: string, isActive: boolean) =>
apiRequest<Customer>(`/admin/customers/${id}/active`, { method: 'PATCH', body: { isActive } }),
}
/* ── Orders ────────────────────────────────────────────────── */
export type AdminCreateOrderPayload = {
companyId: string
customerId?: string
customerName?: string
customerPhone?: string
userId?: string
settlementMethod?: SettlementMethod
settlementType?: SettlementMethod
meterage?: number
notes?: string
}
export type OrderItemPricingPayload = {
id: string
confirmedMeterage?: number
unitPrice?: number
supplyPrice?: number
}
export type UpdateOrderPricingPayload = {
items: OrderItemPricingPayload[]
shippingCost?: number
note?: string
}
export type ApiOrder = Order & {
adminStatus?: string
settlementType?: SettlementMethod | null
totalMeterage?: number
shippingCost?: number | null
finalAmount?: number | null
checkRassDays?: number | null
checkRassDate?: string
items?: OrderItem[]
checks?: Check[]
statusHistory?: OrderStatusHistoryItem[]
/** وضعیت‌های مجاز بعدی از دید ماشین وضعیت سرور */
allowedTransitions?: string[]
cashReceipt?: { id: string } | null
}
export type UpdateOrderStatusPayload = {
status: string
note?: string
reason?: string
/** گذر خارج از ماشین وضعیت؛ سرور reason را اجباری می‌کند */
force?: boolean
}
export const ordersApi = {
list: (params?: { status?: string; q?: string }) =>
apiRequest<ApiOrder[]>('/admin/orders', { query: { status: params?.status, q: params?.q } }),
get: (id: string) => apiRequest<ApiOrder>(`/admin/orders/${id}`),
create: (body: AdminCreateOrderPayload) => apiRequest<ApiOrder>('/admin/orders', { body }),
updatePricing: (id: string, body: UpdateOrderPricingPayload) =>
apiRequest<ApiOrder>(`/admin/orders/${id}/pricing`, { method: 'PATCH', body }),
updateStatus: (id: string, body: UpdateOrderStatusPayload) =>
apiRequest<ApiOrder>(`/admin/orders/${id}/status`, { method: 'PATCH', body }),
}
/* ── Checks ────────────────────────────────────────────────── */
export const checksApi = {
list: (status?: Check['status']) =>
apiRequest<Check[]>('/admin/checks', { query: { status } }),
listByOrder: (orderId: string) =>
apiRequest<Check[]>(`/admin/orders/${orderId}/checks`),
updateStatus: (id: string, body: { status: Check['status']; note?: string }) =>
apiRequest<Check>(`/admin/checks/${id}/status`, { method: 'PATCH', body }),
}
/* ── Cash payments ─────────────────────────────────────────── */
export const cashPaymentsApi = {
list: (status?: CashPayment['status']) =>
apiRequest<CashPayment[]>('/admin/cash-payments', { query: { status } }),
updateStatus: (id: string, body: { status: CashPayment['status']; note?: string }) =>
apiRequest<CashPayment>(`/admin/cash-payments/${id}/status`, { method: 'PATCH', body }),
}
/* ── Shipments ─────────────────────────────────────────────── */
export type CreateShipmentPayload = {
orderId: string
carrier: string
trackingNumber?: string
meterage?: number
status?: ShipmentStatus
}
export const shipmentsApi = {
list: (status?: ShipmentStatus) =>
apiRequest<Shipment[]>('/admin/shipments', { query: { status } }),
create: (body: CreateShipmentPayload) => apiRequest<Shipment>('/admin/shipments', { body }),
update: (id: string, body: Partial<CreateShipmentPayload>) =>
apiRequest<Shipment>(`/admin/shipments/${id}`, { method: 'PATCH', body }),
advanceStatus: (id: string, body?: { status?: ShipmentStatus; trackingNumber?: string }) =>
apiRequest<Shipment>(`/admin/shipments/${id}/status`, { method: 'PATCH', body: body ?? {} }),
}
/* ── Admin users ───────────────────────────────────────────── */
export type CreateAdminUserPayload = {
fullName: string
phone: string
email?: string
password?: string
role?: UserRole
isActive?: boolean
}
export type UpdateAdminUserPayload = Partial<CreateAdminUserPayload>
export const adminUsersApi = {
list: () => apiRequest<AdminUser[]>('/admin/users'),
create: (body: CreateAdminUserPayload) => apiRequest<AdminUser>('/admin/users', { body }),
update: (id: string, body: UpdateAdminUserPayload) =>
apiRequest<AdminUser>(`/admin/users/${id}`, { method: 'PATCH', body }),
setActive: (id: string, isActive: boolean) =>
apiRequest<AdminUser>(`/admin/users/${id}/active`, { method: 'PATCH', body: { isActive } }),
}
/* ── Dashboard & reports ───────────────────────────────────── */
export type DashboardStats = {
newOrders: number
pendingReview: number
pendingCheck: number
finalApproval: number
todaySales: number
todaySalesGrowth: number
monthSales: number
}
export type DashboardResponse = {
stats: DashboardStats
recentOrders: ApiOrder[]
salesChartData: number[]
chartAxisLabels: string[]
checkDeadlines: CheckDeadline[]
notifications?: AdminNotification[]
messages?: InternalMessage[]
}
export type ReportsResponse = {
reportSalesByCompany: {
companyName: string
orderCount: number
totalAmount: number
meterage: number
}[]
reportOrdersByStatus: { status: string; count: number }[]
reportSalesByMonth?: {
month: string
orderCount: number
totalAmount: number
meterage: number
}[]
reportSalesByProduct?: {
productId: string
productName: string
productCode: string
orderCount: number
meterage: number
totalAmount: number
}[]
reportSalesByCustomer?: {
customerName: string
orderCount: number
totalAmount: number
meterage: number
}[]
reportCustomers?: {
active: number
inactive: number
newLast30Days: number
noPurchase: number
noPurchase90Days: number
withAgent: number
}
}
export const dashboardApi = {
get: () => apiRequest<DashboardResponse>('/admin/dashboard'),
reports: () => apiRequest<ReportsResponse>('/admin/reports'),
}
/* ── System settings ──────────────────────────────────────── */
export type SystemSettings = {
id: string
maxMeteragePerInvoice: number
cashInvoiceValidityHours: number
defaultSettlementCheck: boolean
defaultSettlementCash: boolean
minOrderRollsPerProduct?: number
showCatalogPublicPrice?: boolean
allowAgentPricing?: boolean
agentMinUnitPrice?: number | null
smsOrderRegistered: boolean
smsPricingReady: boolean
smsPaymentReminder: boolean
smsCheckReview: boolean
smsShippingUpdate: boolean
supportPhone: string | null
supportEmail: string | null
categoryImagesJson?: Record<string, string>
updatedAt: string
}
export type UpdateSettingsPayload = Partial<Omit<SystemSettings, 'id' | 'updatedAt'>>
export const settingsApi = {
get: () => apiRequest<SystemSettings>('/admin/settings'),
update: (body: UpdateSettingsPayload) =>
apiRequest<SystemSettings>('/admin/settings', { method: 'PATCH', body }),
}
/* ── Admin notifications ──────────────────────────────────── */
export const notificationsApi = {
markRead: (id: string) =>
apiRequest<{ ok: boolean }>(`/admin/notifications/${id}/read`, { method: 'PATCH' }),
markAllRead: () =>
apiRequest<{ ok: boolean }>('/admin/notifications/read-all', { method: 'PATCH' }),
broadcast: (body: { title: string; body: string; customerIds?: string[]; tone?: string }) =>
apiRequest<{ sent: number }>('/admin/notifications/broadcast', { method: 'POST', body }),
}
/* ── Catalog requests (physical) ──────────────────────────── */
export type CatalogRequestRow = {
id: string
customerId: string
customerName: string
customerPhone: string
status: string
note?: string
createdAt: string
items: {
id: string
productId: string
productName: string
productCode: string
quantity: number
holderType: string
holderCustomerId?: string | null
movements?: {
id: string
fromHolder: string
toHolder: string
note?: string
movedAt: string
}[]
}[]
}
export const catalogRequestsApi = {
list: (status?: string) =>
apiRequest<CatalogRequestRow[]>('/admin/catalog-requests', {
query: { status },
}),
updateStatus: (id: string, status: string) =>
apiRequest<CatalogRequestRow>(`/admin/catalog-requests/${id}/status`, {
method: 'PATCH',
body: { status },
}),
updateHolder: (
itemId: string,
body: { holderType: string; holderCustomerId?: string | null; note?: string },
) =>
apiRequest(`/admin/catalog-request-items/${itemId}/holder`, {
method: 'PATCH',
body,
}),
}
/* ── Brands ───────────────────────────────────────────────── */
export type Brand = {
id: string
name: string
slug?: string
description?: string
isActive: boolean
displayOrder: number
customersCount?: number
}
export type AppBanner = {
id: string
slot: string
title: string
subtitle: string
ctaLabel: string
ctaLink: string
imageUrl: string
displayOrder: number
isActive: boolean
}
export type BannerPayload = {
slot: string
title: string
subtitle?: string | null
ctaLabel?: string | null
ctaLink?: string | null
imageUrl?: string | null
displayOrder?: number
isActive?: boolean
}
export const bannersApi = {
list: (slot?: string) =>
apiRequest<AppBanner[]>('/admin/banners', { query: { slot } }),
create: (body: BannerPayload) =>
apiRequest<AppBanner>('/admin/banners', { body }),
update: (id: string, body: Partial<BannerPayload>) =>
apiRequest<AppBanner>(`/admin/banners/${id}`, { method: 'PATCH', body }),
remove: (id: string) =>
apiRequest<{ ok: boolean }>(`/admin/banners/${id}`, { method: 'DELETE' }),
}
export const brandsApi = {
list: () => apiRequest<Brand[]>('/admin/brands'),
create: (body: Partial<Brand> & { name: string }) =>
apiRequest<Brand>('/admin/brands', { body }),
update: (id: string, body: Partial<Brand>) =>
apiRequest<Brand>(`/admin/brands/${id}`, { method: 'PATCH', body }),
}
/* ── Levels ───────────────────────────────────────────────── */
export type CustomerLevel = {
id: string
code: string
label: string
sortOrder: number
isActive: boolean
customersCount: number
benefits: { id?: string; title: string; description?: string }[]
}
export const levelsApi = {
list: () => apiRequest<CustomerLevel[]>('/admin/levels'),
create: (body: {
code: string
label: string
sortOrder?: number
benefits?: { title: string; description?: string }[]
}) => apiRequest<CustomerLevel>('/admin/levels', { body }),
update: (id: string, body: Partial<CustomerLevel> & { benefits?: { title: string; description?: string }[] }) =>
apiRequest<CustomerLevel>(`/admin/levels/${id}`, { method: 'PATCH', body }),
}
/* ── Suggestions ──────────────────────────────────────────── */
export type ProductSuggestion = {
id: string
customerId: string
customerName: string
productId: string
productName: string
productCode: string
note?: string
isActive: boolean
createdAt: string
}
export const suggestionsApi = {
list: (customerId?: string) =>
apiRequest<ProductSuggestion[]>('/admin/suggestions', { query: { customerId } }),
create: (body: { customerId: string; productId: string; note?: string }) =>
apiRequest<ProductSuggestion>('/admin/suggestions', { body }),
setActive: (id: string, isActive: boolean) =>
apiRequest<ProductSuggestion>(`/admin/suggestions/${id}/active`, {
method: 'PATCH',
body: { isActive },
}),
}
/* ── Stock alerts ─────────────────────────────────────────── */
export type StockAlert = {
id: string
customerName: string
customerPhone: string
productName: string
productCode: string
stock: number | null
active: boolean
createdAt: string
}
export const stockAlertsApi = {
list: () => apiRequest<StockAlert[]>('/admin/stock-alerts'),
notify: (id: string) =>
apiRequest<StockAlert>(`/admin/stock-alerts/${id}/notify`, { method: 'PATCH' }),
}
+135
View File
@@ -0,0 +1,135 @@
import type { AdminLoginPayload, AdminLoginResult, AdminSession } from '@/types/auth'
import { ApiError, apiRequest, getAccessToken, setAccessToken } from '@/services/http'
const STORAGE_KEY = 'fabric-admin-session'
const TOKEN_KEY = 'fabric-admin-token'
const USE_MOCK_AUTH = import.meta.env.VITE_USE_MOCK_AUTH === 'true'
/** حساب موک توسعه — فقط وقتی VITE_USE_MOCK_AUTH=true */
export const MOCK_ADMIN = {
phone: '09128081392',
password: '123456',
session: {
id: 'mock-admin-1',
fullName: 'مدیر سیستم (موک)',
phone: '09128081392',
email: 'admin@parche.ir',
role: 'admin' as const,
} satisfies AdminSession,
}
function readSession(): AdminSession | null {
try {
const raw = localStorage.getItem(STORAGE_KEY) ?? sessionStorage.getItem(STORAGE_KEY)
if (!raw) return null
return JSON.parse(raw) as AdminSession
} catch {
return null
}
}
function writeSession(session: AdminSession, remember: boolean) {
const payload = JSON.stringify(session)
localStorage.removeItem(STORAGE_KEY)
sessionStorage.removeItem(STORAGE_KEY)
if (remember) {
localStorage.setItem(STORAGE_KEY, payload)
} else {
sessionStorage.setItem(STORAGE_KEY, payload)
}
}
function clearSession() {
localStorage.removeItem(STORAGE_KEY)
sessionStorage.removeItem(STORAGE_KEY)
}
function isRememberedToken(): boolean {
return !!localStorage.getItem(TOKEN_KEY)
}
function toSession(user: AdminSession): AdminSession {
return {
id: user.id,
fullName: user.fullName,
phone: user.phone,
email: user.email,
role: user.role,
}
}
export function getStoredSession(): AdminSession | null {
return readSession()
}
export function isMockAuthEnabled() {
return USE_MOCK_AUTH
}
export async function loginAdmin(payload: AdminLoginPayload): Promise<AdminLoginResult> {
const phone = payload.phone.trim()
const password = payload.password
if (!phone || !password) {
return { success: false, message: 'شماره موبایل و رمز عبور الزامی است.' }
}
if (USE_MOCK_AUTH) {
if (phone === MOCK_ADMIN.phone && password === MOCK_ADMIN.password) {
setAccessToken('mock-admin-token', payload.remember)
writeSession(MOCK_ADMIN.session, payload.remember)
return { success: true }
}
return {
success: false,
message: `حالت موک: ${MOCK_ADMIN.phone} / ${MOCK_ADMIN.password}`,
}
}
try {
const data = await apiRequest<{ accessToken: string; user: AdminSession }>(
'/admin/auth/login',
{
method: 'POST',
body: { phone, password },
auth: false,
},
)
setAccessToken(data.accessToken, payload.remember)
writeSession(toSession(data.user), payload.remember)
return { success: true }
} catch (err) {
if (err instanceof ApiError) {
return { success: false, message: err.message }
}
return { success: false, message: 'ورود انجام نشد.' }
}
}
/** Refresh session from GET /auth/me when a token exists. */
export async function refreshAdminSession(): Promise<AdminSession | null> {
const token = getAccessToken()
if (!token) return readSession()
if (USE_MOCK_AUTH || token === 'mock-admin-token') {
const session = readSession() ?? MOCK_ADMIN.session
writeSession(session, isRememberedToken())
return session
}
try {
const user = await apiRequest<AdminSession>('/auth/me')
const session = toSession(user)
writeSession(session, isRememberedToken())
return session
} catch {
return readSession()
}
}
export function logoutAdmin() {
setAccessToken(null)
clearSession()
}
+118
View File
@@ -0,0 +1,118 @@
const TOKEN_KEY = 'fabric-admin-token'
export function getApiBaseUrl(): string {
return (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace(/\/$/, '')
|| 'https://parche.montest.ir/server/api'
}
export function getAccessToken(): string | null {
return localStorage.getItem(TOKEN_KEY) ?? sessionStorage.getItem(TOKEN_KEY)
}
export function setAccessToken(token: string | null, remember = true): void {
localStorage.removeItem(TOKEN_KEY)
sessionStorage.removeItem(TOKEN_KEY)
if (!token) return
if (remember) localStorage.setItem(TOKEN_KEY, token)
else sessionStorage.setItem(TOKEN_KEY, token)
}
export class ApiError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.status = status
}
}
type RequestOptions = {
method?: string
body?: unknown
auth?: boolean
query?: Record<string, string | number | boolean | undefined | null>
}
function buildUrl(path: string, query?: RequestOptions['query']): string {
const base = getApiBaseUrl()
const joined = path.startsWith('http')
? path
: `${base.replace(/\/$/, '')}/${path.replace(/^\//, '')}`
const url = joined.startsWith('http')
? new URL(joined)
: new URL(joined, typeof window !== 'undefined' ? window.location.origin : 'http://127.0.0.1')
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null || value === '') continue
url.searchParams.set(key, String(value))
}
}
return url.toString()
}
function errorMessageFromBody(data: unknown, status: number): string {
if (typeof data === 'object' && data && 'message' in data) {
const message = (data as { message: unknown }).message
if (Array.isArray(message)) return message.join('، ')
return String(message)
}
if (typeof data === 'string') {
if (/upstream error/i.test(data)) {
return 'سرور API در دسترس نیست. کمی بعد دوباره تلاش کنید.'
}
if (data.trim() && !data.trimStart().startsWith('<')) {
return data.trim().slice(0, 200)
}
}
return `خطای سرور (${status})`
}
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const headers: Record<string, string> = {
Accept: 'application/json',
}
if (options.body !== undefined) {
headers['Content-Type'] = 'application/json'
}
if (options.auth !== false) {
const token = getAccessToken()
if (token) headers.Authorization = `Bearer ${token}`
}
let res: Response
try {
res = await fetch(buildUrl(path, options.query), {
method: options.method ?? (options.body !== undefined ? 'POST' : 'GET'),
headers,
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
signal: AbortSignal.timeout(20_000),
})
} catch (err) {
const timedOut =
(err instanceof DOMException && err.name === 'TimeoutError') ||
(err instanceof Error && err.name === 'TimeoutError')
throw new ApiError(
0,
timedOut
? 'پاسخ سرور طول کشید. دوباره تلاش کنید.'
: 'اتصال به سرور برقرار نشد. اینترنت یا آدرس API را بررسی کنید.',
)
}
const text = await res.text()
let data: unknown = null
if (text) {
try {
data = JSON.parse(text)
} catch {
data = text
}
}
if (!res.ok) {
throw new ApiError(res.status, errorMessageFromBody(data, res.status))
}
return data as T
}
+73
View File
@@ -0,0 +1,73 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import type { AdminSession } from '@/types/auth'
import {
getStoredSession,
loginAdmin,
logoutAdmin,
refreshAdminSession,
} from '@/services/authService'
export const useAuthStore = defineStore('auth', () => {
const session = ref<AdminSession | null>(getStoredSession())
const loading = ref(false)
const error = ref<string | null>(null)
const isAuthenticated = computed(() => !!session.value)
const fullName = computed(() => session.value?.fullName ?? '')
const roleLabel = computed(() => {
const map = {
admin: 'مدیر سیستم',
sales: 'واحد فروش',
finance: 'واحد مالی',
warehouse: 'انبار',
} as const
return session.value ? map[session.value.role] : ''
})
function clearError() {
error.value = null
}
function hydrate() {
session.value = getStoredSession()
void refreshAdminSession().then((refreshed) => {
if (refreshed) session.value = refreshed
})
}
async function login(phone: string, password: string, remember: boolean) {
loading.value = true
error.value = null
const result = await loginAdmin({ phone, password, remember })
loading.value = false
if (result.success) {
session.value = getStoredSession()
return true
}
error.value = result.message ?? 'ورود انجام نشد.'
return false
}
function logout() {
logoutAdmin()
session.value = null
error.value = null
}
return {
session,
loading,
error,
isAuthenticated,
fullName,
roleLabel,
clearError,
hydrate,
login,
logout,
}
})
+34
View File
@@ -0,0 +1,34 @@
import { defineStore } from 'pinia'
import { computed, watchEffect } from 'vue'
import { usePreferredDark, useStorage } from '@vueuse/core'
export type ThemePreference = 'light' | 'dark' | 'system'
export const useThemeStore = defineStore('theme', () => {
const preference = useStorage<ThemePreference>('ft_admin_theme', 'system')
const prefersDark = usePreferredDark()
const resolved = computed<'light' | 'dark'>(() => {
if (preference.value === 'system') return prefersDark.value ? 'dark' : 'light'
return preference.value
})
const isDark = computed(() => resolved.value === 'dark')
watchEffect(() => {
const root = document.documentElement
root.classList.toggle('dark', isDark.value)
root.style.colorScheme = resolved.value
})
function setPreference(value: ThemePreference) {
preference.value = value
}
return {
preference,
resolved,
isDark,
setPreference,
}
})
+18
View File
@@ -0,0 +1,18 @@
export interface AdminSession {
id: string
fullName: string
phone: string
email: string
role: 'admin' | 'sales' | 'finance' | 'warehouse'
}
export interface AdminLoginPayload {
phone: string
password: string
remember: boolean
}
export interface AdminLoginResult {
success: boolean
message?: string
}
+276
View File
@@ -0,0 +1,276 @@
/** سطل‌های نمایشی پنل ادمین که سرور از وضعیت واقعی سفارش می‌سازد */
export type OrderStatus =
| 'pending_review'
| 'awaiting_customer'
| 'pending_check'
| 'pending_payment'
| 'needs_correction'
| 'check_correction'
| 'final_approval'
| 'shipped'
| 'delivered'
| 'cancelled'
| 'rejected'
| 'expired'
/** وضعیت واقعی سفارش در ماشین وضعیت سرور */
export type RawOrderStatus =
| 'cart'
| 'submitted'
| 'awaiting_review'
| 'pricing'
| 'awaiting_settlement'
| 'awaiting_settlement_fix'
| 'cash_in_progress'
| 'cash_invoice_issued'
| 'awaiting_cash_payment'
| 'cash_payment_submitted'
| 'awaiting_cash_confirmation'
| 'cash_confirmed'
| 'check_in_progress'
| 'awaiting_check_review'
| 'check_needs_fix'
| 'check_rejected'
| 'checks_approved'
| 'final_approved'
| 'preparing'
| 'ready_to_ship'
| 'shipped'
| 'delivered'
| 'needs_correction'
| 'cancelled'
| 'rejected'
| 'expired'
export type SettlementMethod = 'check' | 'cash'
export type CheckStatus = 'pending_review' | 'approved' | 'rejected'
export interface Company {
id: string
name: string
description: string
displayOrder: number
isActive: boolean
logoUrl: string | null
}
export interface BankAccount {
id: string
companyId: string
bankName: string
accountNumber: string
accountHolder: string
cardNumber: string
iban: string
isActive: boolean
displayOrder: number
}
export interface OrderItem {
id: string
catalogCode: string
productName: string
color: string
requestedMeterage: number
confirmedMeterage: number | null
supplyPrice: number | null
totalAmount: number | null
companyId?: string
companyName?: string
}
export interface Order {
id: string
orderNumber: string
customerName: string
companyName: string
companyId: string
settlementMethod: SettlementMethod | null
totalAmount: number | null
meterage: number
status: OrderStatus
rawStatus?: RawOrderStatus
allowedTransitions?: RawOrderStatus[]
createdAt: string
customerPhone?: string
customerNationalId?: string
customerType?: string
items?: OrderItem[]
}
export interface Check {
id: string
orderId: string
row: number
amount: number
bankName: string
sayadId: string
dueDate: string
status: CheckStatus
checkNumber?: string
ownerNationalId?: string
ownerFirstName?: string
ownerLastName?: string
branchName?: string
frontImage?: string
backImage?: string
notes?: string
mode?: string
createdAt?: string
updatedAt?: string
/** فقط در لیست سراسری چک‌ها */
orderNumber?: string
customerName?: string
orderStatus?: string
settlementType?: SettlementMethod | null
}
export interface OrderStatusHistoryItem {
id: string
fromStatus?: string | null
toStatus: string
changedBy: string
note?: string
reason?: string
changedAt: string
}
export interface CheckDeadline {
id: string
label: string
tone: 'danger' | 'warning' | 'neutral'
count: number
amount: number
}
export interface NavItem {
name: string
label: string
path: string
icon: string
}
export interface InternalMessage {
id: string
senderName: string
subject: string
preview: string
sentAt: string
isRead: boolean
link?: string
orderId?: string
}
export interface AdminNotification {
id: string
title: string
body: string
sentAt: string
isRead: boolean
tone?: 'info' | 'warning' | 'success' | 'danger'
link?: string
orderId?: string
}
export type CustomerType = 'wholesale' | 'retail'
export interface Customer {
id: string
name: string
phone: string
nationalId: string
customerType: CustomerType
companyName: string
companyId?: string | null
orderCount: number
totalMeterage: number
isActive: boolean
registeredAt: string
appRole?: 'customer' | 'agent'
agentCode?: string
levelDefId?: string | null
levelCode?: string
levelLabel?: string
assignedAgentId?: string | null
assignedAgentName?: string
brandIds?: string[]
brands?: { id: string; name: string }[]
}
export interface Catalog {
id: string
companyId: string
companyName: string
code: string
name: string
displayOrder: number
productCount: number
isActive: boolean
}
export interface Product {
id: string
catalogId: string
catalogCode: string
companyName: string
name: string
color: string
productCode: string
minMeterage: number
orderStep: number
price: number | null
internalStock: number
isActive: boolean
usageCategory?: string
season?: string
composition?: string
weaveType?: string
subtitle?: string
rollMeters?: number
isNew?: boolean
colors?: string[]
imageUrls?: string[]
}
export type CashPaymentStatus = 'pending_review' | 'approved' | 'rejected' | 'expired'
export interface CashPayment {
id: string
orderId: string
orderNumber: string
customerName: string
amount: number
paidAmount: number | null
transferDate: string | null
trackingCode: string
expiresAt: string
status: CashPaymentStatus
}
export type ShipmentStatus = 'preparing' | 'ready' | 'shipped' | 'delivered'
export interface Shipment {
id: string
orderId: string
orderNumber: string
customerName: string
carrier: string
trackingNumber: string
meterage: number
status: ShipmentStatus
shippedAt: string | null
deliveredAt: string | null
}
export type UserRole = 'admin' | 'sales' | 'finance' | 'warehouse'
export interface AdminUser {
id: string
fullName: string
phone: string
email: string
role: UserRole
isActive: boolean
lastLoginAt: string | null
}
+41
View File
@@ -0,0 +1,41 @@
import type { CashPaymentStatus, CustomerType, ShipmentStatus, UserRole } from '@/types'
export const CUSTOMER_TYPE_LABELS: Record<CustomerType, string> = {
wholesale: 'عمده‌فروش',
retail: 'خرده‌فروش',
}
export const USER_ROLE_LABELS: Record<UserRole, string> = {
admin: 'مدیر سیستم',
sales: 'واحد فروش',
finance: 'واحد مالی',
warehouse: 'انبار',
}
export const CASH_PAYMENT_STATUS_LABELS: Record<CashPaymentStatus, string> = {
pending_review: 'در انتظار تأیید',
approved: 'تأیید شده',
rejected: 'رد شده',
expired: 'منقضی‌شده',
}
export const CASH_PAYMENT_STATUS_BADGE: Record<CashPaymentStatus, string> = {
pending_review: 'admin-badge--warning',
approved: 'admin-badge--success',
rejected: 'admin-badge--danger',
expired: 'admin-badge--neutral',
}
export const SHIPMENT_STATUS_LABELS: Record<ShipmentStatus, string> = {
preparing: 'در حال آماده‌سازی',
ready: 'آماده ارسال',
shipped: 'ارسال شده',
delivered: 'تحویل شده',
}
export const SHIPMENT_STATUS_BADGE: Record<ShipmentStatus, string> = {
preparing: 'admin-badge--warning',
ready: 'admin-badge--info',
shipped: 'admin-badge--purple',
delivered: 'admin-badge--success',
}
+46
View File
@@ -0,0 +1,46 @@
const MAX_EDGE = 960
const JPEG_QUALITY = 0.82
export async function fileToCatalogImage(file: File): Promise<string> {
if (!file.type.startsWith('image/')) {
throw new Error('فقط فایل تصویری مجاز است.')
}
if (file.size > 8 * 1024 * 1024) {
throw new Error('حجم تصویر نباید بیشتر از ۸ مگابایت باشد.')
}
const dataUrl = await readFileAsDataUrl(file)
return resizeDataUrl(dataUrl)
}
function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result ?? ''))
reader.onerror = () => reject(new Error('خواندن تصویر ناموفق بود.'))
reader.readAsDataURL(file)
})
}
function resizeDataUrl(src: string): Promise<string> {
return new Promise((resolve, reject) => {
const image = new Image()
image.onload = () => {
const scale = Math.min(1, MAX_EDGE / Math.max(image.width, image.height))
const width = Math.max(1, Math.round(image.width * scale))
const height = Math.max(1, Math.round(image.height * scale))
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) {
reject(new Error('پردازش تصویر ناموفق بود.'))
return
}
ctx.drawImage(image, 0, 0, width, height)
resolve(canvas.toDataURL('image/jpeg', JPEG_QUALITY))
}
image.onerror = () => reject(new Error('تصویر نامعتبر است.'))
image.src = src
})
}
+100
View File
@@ -0,0 +1,100 @@
const persianDigits = '۰۱۲۳۴۵۶۷۸۹'
const numberFormatter = new Intl.NumberFormat('fa-IR', { useGrouping: true })
export function normalizeDigits(input: string): string {
return input.replace(/[۰-۹]/g, (digit) => String(persianDigits.indexOf(digit)))
}
export function extractNumericString(input: string): string {
return normalizeDigits(input).replace(/[^\d]/g, '')
}
export function parseFormattedNumber(value: string): number {
const numeric = extractNumericString(value)
return numeric ? Number(numeric) : 0
}
export function formatNumber(value: number): string {
if (!Number.isFinite(value)) return '—'
return numberFormatter.format(value)
}
export function formatCurrency(value: number): string {
return `${formatNumber(value)} تومان`
}
export function formatMeterage(value: number): string {
return `${formatNumber(value)} متر`
}
export function toPersianDigits(input: string | number): string {
return String(input).replace(/\d/g, (d) => persianDigits[Number(d)]!)
}
const persianDateFormatter = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
const persianDateTimeFormatter = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
/** Detect already-Jalali date strings like 1403/07/30 or 1403-07-30 */
function isJalaliDateString(value: string): boolean {
const normalized = normalizeDigits(value.trim()).replace(/-/g, '/')
const match = normalized.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})/)
if (!match) return false
const year = Number(match[1])
return year >= 1300 && year <= 1500
}
function toDateValue(date: string | Date): Date {
if (typeof date !== 'string') return date
const trimmed = date.trim()
// فقط تاریخِ بدون ساعت (YYYY-MM-DD) را به‌صورت تقویم محلی بخوان تا شیفت UTC روز را جابه‌جا نکند.
// رشته‌های دارای ساعت (…T10:30:00 یا … 10:30) باید کامل پارس شوند.
const dateOnly = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})$/)
if (dateOnly && Number(dateOnly[1]) >= 1700) {
return new Date(Number(dateOnly[1]), Number(dateOnly[2]) - 1, Number(dateOnly[3]))
}
const parsed = new Date(trimmed)
if (Number.isNaN(parsed.getTime())) return new Date(NaN)
return parsed
}
export function formatDate(date: string | Date | null | undefined): string {
if (!date) return '—'
if (typeof date === 'string' && isJalaliDateString(date)) {
const normalized = normalizeDigits(date.trim()).replace(/-/g, '/')
const match = normalized.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})/)
if (!match) return toPersianDigits(normalized)
const [, y, m, d] = match
return toPersianDigits(`${y}/${m!.padStart(2, '0')}/${d!.padStart(2, '0')}`)
}
const value = toDateValue(date)
if (Number.isNaN(value.getTime())) return '—'
return persianDateFormatter.format(value)
}
export function formatDateTime(date: string | Date | null | undefined): string {
if (!date) return '—'
if (typeof date === 'string' && isJalaliDateString(date) && !/[T\s]\d/.test(date)) {
return formatDate(date)
}
const value = toDateValue(date)
if (Number.isNaN(value.getTime())) return '—'
return persianDateTimeFormatter.format(value)
}
export function generateOrderNumber(): string {
const year = new Intl.DateTimeFormat('fa-IR-u-ca-persian', { year: 'numeric' }).format(new Date())
const persianYear = year.replace(/[^\d]/g, '') || '1403'
const seq = Math.floor(Math.random() * 9000) + 1000
return `ORD-${persianYear}-${seq}`
}
+112
View File
@@ -0,0 +1,112 @@
/** Resolve admin header message/notification click targets. */
const ORDER_NUMBER_RE = /ORD-?\d{2,4}-?\d{2,6}/i
const BARE_ORDER_PATH_RE = /^\/?(?:admin\/)?orders\/([^/?#]+)\/?$/i
const ORDER_SUBPATH_RE = /^\/?(?:admin\/)?orders\/([^/?#]+)\/(pricing|checks)\/?$/i
export function extractOrderNumber(text: string): string | null {
const match = text.match(ORDER_NUMBER_RE)
if (!match?.[0]) return null
const digits = match[0].toUpperCase().replace(/^ORD-?/, '').replace(/-/g, '')
if (digits.length < 6) return match[0].toUpperCase()
const year = digits.slice(0, 4)
const seq = digits.slice(4)
return `ORD-${year}-${seq}`
}
function hasKeyword(text: string, patterns: RegExp[]): boolean {
return patterns.some((pattern) => pattern.test(text))
}
/** Rewrite API links to routes that exist in the admin app. */
export function normalizeAdminInboxLink(link: string): string {
let path = link.trim()
if (!path) return path
try {
if (/^https?:\/\//i.test(path)) {
const url = new URL(path)
path = `${url.pathname}${url.search}`
}
} catch {
/* keep raw path */
}
path = path.replace(/^\/admin(?=\/)/, '')
if (!path.startsWith('/')) path = `/${path}`
const sub = path.match(ORDER_SUBPATH_RE)
if (sub?.[1] && sub[2]) {
return `/orders/${sub[1]}/${sub[2]}`
}
const bare = path.match(BARE_ORDER_PATH_RE)
if (bare?.[1]) {
return `/orders?order=${encodeURIComponent(bare[1])}`
}
return path
}
export type InboxLinkInput = {
link?: string | null
orderId?: string | null
title?: string | null
body?: string | null
}
/**
* Prefer explicit `link`, then `orderId` + keywords, then order number / keyword heuristics.
*/
export function resolveAdminInboxLink(input: InboxLinkInput): string | null {
const explicit = input.link?.trim()
if (explicit) return normalizeAdminInboxLink(explicit)
const title = input.title?.trim() ?? ''
const body = input.body?.trim() ?? ''
const text = `${title} ${body}`
const orderId = input.orderId?.trim() || null
const orderNumber = extractOrderNumber(text)
const isSms = hasKeyword(text, [/پیامک/])
const isCheck = hasKeyword(text, [/چک/])
const isPricing = hasKeyword(text, [/قیمت/, /تخفیف/, /پیش[‌\s]?فاکتور/, /قیمت‌گذاری/, /اصلاح قیمت/])
const isShipping = !isSms && hasKeyword(text, [/ارسال/, /باربری/, /حمل/, /بارنامه/])
const isCash = hasKeyword(text, [/نقدی/, /پرداخت نقد/, /رسید پرداخت/])
const isReport = hasKeyword(text, [/گزارش/])
const isCustomer = hasKeyword(text, [/مشتری جدید/, /ثبت مشتری/])
const isCatalog = hasKeyword(text, [/کالیته/])
const isProduct = hasKeyword(text, [/محصول/])
const isCompany = hasKeyword(text, [/شرکت/, /حساب بانکی/])
const isOrderish = hasKeyword(text, [/سفارش/])
const isSettings = isSms || hasKeyword(text, [/پشتیبان/, /تنظیمات/])
if (orderId) {
if (isCheck) return `/orders/${orderId}/checks`
if (isPricing) return `/orders/${orderId}/pricing`
if (isShipping) return `/shipping?order=${encodeURIComponent(orderId)}`
if (isCash) return `/cash-payments?order=${encodeURIComponent(orderId)}`
return `/orders?order=${encodeURIComponent(orderId)}`
}
if (orderNumber) {
if (isCheck) return `/orders?order=${encodeURIComponent(orderNumber)}&focus=checks`
if (isPricing) return `/orders?order=${encodeURIComponent(orderNumber)}&focus=pricing`
if (isShipping) return `/shipping?order=${encodeURIComponent(orderNumber)}`
if (isCash) return `/cash-payments?order=${encodeURIComponent(orderNumber)}`
return `/orders?order=${encodeURIComponent(orderNumber)}`
}
if (isCheck) return '/checks'
if (isShipping) return '/shipping'
if (isCash) return '/cash-payments'
if (isReport) return '/reports'
if (isCustomer) return '/customers'
if (isCatalog) return '/catalogs'
if (isProduct) return '/products'
if (isCompany) return '/companies'
if (isSettings) return '/settings'
if (isPricing || isOrderish) return '/orders'
return null
}
+38
View File
@@ -0,0 +1,38 @@
import type { NavItem } from '@/types'
export const ADMIN_NAV_ITEMS: NavItem[] = [
{ name: 'dashboard', label: 'داشبورد', path: '/', icon: 'home' },
{ name: 'orders', label: 'سفارش‌ها', path: '/orders', icon: 'orders' },
{ name: 'customers', label: 'مشتریان', path: '/customers', icon: 'customers' },
{ name: 'products', label: 'محصولات', path: '/products', icon: 'products' },
{ name: 'categories', label: 'دسته‌بندی‌ها', path: '/categories', icon: 'catalogs' },
{ name: 'banners', label: 'بنرها', path: '/banners', icon: 'image' },
{ name: 'catalogs', label: 'کالیته‌ها', path: '/catalogs', icon: 'catalogs' },
{
name: 'catalog-requests',
label: 'درخواست کالیته فیزیکی',
path: '/catalog-requests',
icon: 'file',
},
{ name: 'brands', label: 'برندها', path: '/brands', icon: 'building' },
{ name: 'suggestions', label: 'پیشنهاد محصول', path: '/suggestions', icon: 'cart' },
{ name: 'stock-alerts', label: 'خبر موجودی', path: '/stock-alerts', icon: 'bell' },
{ name: 'levels', label: 'سطوح مشتری', path: '/levels', icon: 'users' },
{ name: 'companies', label: 'شرکت‌ها', path: '/companies', icon: 'companies' },
{ name: 'checks', label: 'چک‌ها', path: '/checks', icon: 'checks' },
{ name: 'cash-payments', label: 'پرداخت‌های نقدی', path: '/cash-payments', icon: 'cash' },
{ name: 'shipping', label: 'ارسال و باربری', path: '/shipping', icon: 'shipping' },
{ name: 'reports', label: 'گزارش‌ها', path: '/reports', icon: 'reports' },
{ name: 'settings', label: 'تنظیمات', path: '/settings', icon: 'settings' },
{ name: 'users', label: 'کاربران و دسترسی‌ها', path: '/users', icon: 'users' },
]
export function isNavActive(path: string, currentPath: string): boolean {
if (path === '/') return currentPath === '/'
// چک‌های یک سفارش زیر منوی «چک‌ها» فعال می‌شود، نه «سفارش‌ها»
if (/^\/orders\/[^/]+\/checks/.test(currentPath)) {
if (path === '/checks') return true
if (path === '/orders') return false
}
return currentPath === path || currentPath.startsWith(`${path}/`)
}
+88
View File
@@ -0,0 +1,88 @@
import type { OrderStatus, RawOrderStatus } from '@/types'
export interface StatusMeta {
label: string
tone: 'warning' | 'purple' | 'success' | 'danger' | 'info' | 'neutral' | 'delivered'
}
export const ORDER_STATUS_META: Record<OrderStatus, StatusMeta> = {
pending_review: { label: 'در انتظار بررسی', tone: 'warning' },
awaiting_customer: { label: 'در انتظار مشتری', tone: 'info' },
pending_check: { label: 'بررسی چک', tone: 'purple' },
pending_payment: { label: 'بررسی رسید پرداخت', tone: 'purple' },
needs_correction: { label: 'نیاز به اصلاح', tone: 'danger' },
check_correction: { label: 'نیاز به اصلاح چک', tone: 'warning' },
final_approval: { label: 'تأیید نهایی', tone: 'success' },
shipped: { label: 'ارسال شده', tone: 'info' },
delivered: { label: 'تحویل شده', tone: 'delivered' },
cancelled: { label: 'لغو شده', tone: 'neutral' },
rejected: { label: 'رد شده', tone: 'danger' },
expired: { label: 'منقضی شده', tone: 'neutral' },
}
/** برچسب وضعیت واقعی سفارش؛ باید با ORDER_STATUS_LABELS سرور یکسان بماند. */
export const RAW_ORDER_STATUS_LABELS: Record<RawOrderStatus, string> = {
cart: 'سبد خرید',
submitted: 'ثبت‌شده',
awaiting_review: 'در انتظار بررسی',
pricing: 'قیمت‌گذاری',
awaiting_settlement: 'در انتظار تسویه',
awaiting_settlement_fix: 'اصلاح تسویه',
cash_in_progress: 'در حال ثبت نقدی',
cash_invoice_issued: 'صدور فاکتور نقدی',
awaiting_cash_payment: 'در انتظار پرداخت نقدی',
cash_payment_submitted: 'رسید نقدی ارسال‌شده',
awaiting_cash_confirmation: 'تأیید پرداخت نقدی',
cash_confirmed: 'پرداخت نقدی تأیید شد',
check_in_progress: 'در حال ثبت چک',
awaiting_check_review: 'بررسی چک',
check_needs_fix: 'اصلاح چک',
check_rejected: 'رد چک',
checks_approved: 'تأیید چک‌ها',
final_approved: 'تأیید نهایی',
preparing: 'آماده‌سازی',
ready_to_ship: 'آماده ارسال',
shipped: 'ارسال‌شده',
delivered: 'تحویل‌شده',
needs_correction: 'نیاز به اصلاح',
cancelled: 'لغو شده',
rejected: 'رد شده',
expired: 'منقضی',
}
export function rawStatusLabel(status?: string): string {
if (!status) return '—'
return RAW_ORDER_STATUS_LABELS[status as RawOrderStatus] ?? status
}
/** وضعیت‌هایی که هنوز فاکتور صادر نشده و قیمت‌گذاری اقدام اصلی است. */
export const PRE_INVOICE_RAW_STATUSES: RawOrderStatus[] = [
'cart',
'submitted',
'awaiting_review',
'pricing',
'needs_correction',
]
const PRE_INVOICE_ADMIN_STATUSES: OrderStatus[] = ['pending_review', 'needs_correction']
export function canPriceOrder(order: {
status: OrderStatus
rawStatus?: RawOrderStatus
}): boolean {
if (order.rawStatus) return PRE_INVOICE_RAW_STATUSES.includes(order.rawStatus)
return PRE_INVOICE_ADMIN_STATUSES.includes(order.status)
}
export const ORDER_TABS = [
{ key: 'all', label: 'همه' },
{ key: 'pending_review', label: 'در انتظار بررسی' },
{ key: 'awaiting_customer', label: 'در انتظار مشتری' },
{ key: 'pending_check', label: 'بررسی چک' },
{ key: 'pending_payment', label: 'بررسی رسید' },
{ key: 'check_correction', label: 'اصلاح چک' },
{ key: 'needs_correction', label: 'نیاز به اصلاح' },
{ key: 'final_approval', label: 'تأیید نهایی' },
{ key: 'shipped', label: 'ارسال شده' },
{ key: 'delivered', label: 'تحویل شده' },
] as const
+301
View File
@@ -0,0 +1,301 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast'
import { isMockAuthEnabled, MOCK_ADMIN } from '@/services/authService'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const toast = useToast()
const phone = ref(isMockAuthEnabled() ? MOCK_ADMIN.phone : '')
const password = ref(isMockAuthEnabled() ? MOCK_ADMIN.password : '')
const remember = ref(true)
const showPassword = ref(false)
const mockAuth = isMockAuthEnabled()
const isFormValid = computed(() => phone.value.trim().length > 0 && password.value.length > 0)
function toAsciiDigits(value: string) {
return value.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d))).replace(/[٠-٩]/g, (d) =>
String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)),
)
}
async function submit() {
if (!isFormValid.value) {
auth.error = 'لطفاً شماره موبایل و رمز عبور را وارد کنید.'
return
}
const ok = await auth.login(toAsciiDigits(phone.value.trim()), password.value, remember.value)
if (!ok) return
toast.success(`خوش آمدید، ${auth.fullName}`)
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
router.replace(redirect)
}
</script>
<template>
<div class="login-page">
<div class="login-page__form-wrap">
<div class="login-page__card admin-card">
<div class="login-page__header">
<h2 class="login-page__title">ورود به پنل مدیریت</h2>
<p class="login-page__subtitle">برای ادامه، اطلاعات حساب کاربری خود را وارد کنید.</p>
<p v-if="mockAuth" class="login-page__mock-hint">
حالت موک فعال است —
<span dir="ltr">{{ MOCK_ADMIN.phone }}</span>
/
<span dir="ltr">{{ MOCK_ADMIN.password }}</span>
</p>
</div>
<form class="admin-form login-page__form" @submit.prevent="submit">
<p v-if="auth.error" class="admin-form__error" role="alert">{{ auth.error }}</p>
<div class="admin-form__field">
<label for="login-phone">شماره موبایل</label>
<div class="login-page__input-wrap">
<span class="login-page__field-icon" aria-hidden="true">
<AdminIcon name="phone" :size="18" />
</span>
<input
id="login-phone"
v-model="phone"
type="tel"
class="admin-input login-page__input"
placeholder="۰۹۱۲۱۱۱۱۲۲۲۲"
autocomplete="username"
inputmode="tel"
dir="ltr"
@input="auth.clearError()"
/>
</div>
</div>
<div class="admin-form__field">
<label for="login-password">رمز عبور</label>
<div class="login-page__input-wrap login-page__input-wrap--password">
<span class="login-page__field-icon" aria-hidden="true">
<AdminIcon name="lock" :size="18" />
</span>
<input
id="login-password"
v-model="password"
:type="showPassword ? 'text' : 'password'"
class="admin-input login-page__input login-page__input--with-toggle"
placeholder="رمز عبور"
autocomplete="current-password"
@input="auth.clearError()"
/>
<button
type="button"
class="login-page__toggle-password"
:aria-label="showPassword ? 'پنهان کردن رمز' : 'نمایش رمز'"
@click="showPassword = !showPassword"
>
<AdminIcon :name="showPassword ? 'eye-off' : 'eye'" :size="18" />
</button>
</div>
</div>
<label class="login-page__remember">
<input v-model="remember" type="checkbox" />
<span>مرا به خاطر بسپار</span>
</label>
<button type="submit" class="admin-btn admin-btn--primary login-page__submit" :disabled="!isFormValid || auth.loading">
<span v-if="auth.loading" class="admin-btn__spinner" aria-hidden="true" />
ورود به پنل
</button>
</form>
</div>
</div>
<div class="login-page__brand">
<div class="login-page__brand-inner">
<p class="login-page__brand-badge">پارچه</p>
<h1 class="login-page__brand-title">پنل مدیریت سفارش‌های عمده</h1>
<p class="login-page__brand-text">
مدیریت سفارش‌ها، مشتریان، قیمت‌گذاری، چک‌ها و گزارش‌های فروش در یک محیط یکپارچه.
</p>
</div>
</div>
</div>
</template>
<style scoped>
.login-page {
min-height: 100dvh;
display: grid;
grid-template-columns: 1.05fr 1fr;
background: var(--color-bg);
}
.login-page__brand {
display: flex;
align-items: center;
justify-content: center;
padding: 48px 40px;
background: linear-gradient(160deg, #062428 0%, #0a353c 55%, #0e454e 100%);
color: #fff;
}
.login-page__brand-inner {
max-width: 420px;
}
.login-page__brand-badge {
display: inline-flex;
margin: 0 0 16px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
font-size: 0.8125rem;
font-weight: 700;
}
.login-page__brand-title {
margin: 0 0 12px;
font-size: clamp(1.5rem, 2.4vw, 2rem);
line-height: 1.35;
}
.login-page__brand-text {
margin: 0;
color: rgba(255, 255, 255, 0.78);
line-height: 1.8;
font-size: 0.9375rem;
}
.login-page__form-wrap {
display: flex;
align-items: center;
justify-content: center;
padding: 32px 24px;
}
.login-page__card {
width: min(100%, 420px);
padding: 28px 24px;
}
.login-page__header {
margin-bottom: 20px;
}
.login-page__title {
margin: 0 0 6px;
font-size: 1.25rem;
font-weight: 700;
}
.login-page__subtitle {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-muted);
line-height: 1.6;
}
.login-page__mock-hint {
margin: 10px 0 0;
padding: 8px 10px;
border-radius: 8px;
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
font-size: 0.8125rem;
line-height: 1.5;
}
.login-page__input-wrap {
position: relative;
color: var(--color-text-muted);
}
.login-page__field-icon {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
display: inline-flex;
pointer-events: none;
z-index: 1;
}
.login-page__input {
padding-right: 42px;
}
.login-page__input--with-toggle {
padding-left: 44px;
}
.login-page__toggle-password {
position: absolute;
left: 6px;
top: 50%;
transform: translateY(-50%);
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: 8px;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
transition: color 0.15s ease, background-color 0.15s ease;
}
.login-page__toggle-password:hover {
color: var(--color-primary);
background: var(--color-bg-muted, rgba(0, 0, 0, 0.04));
}
.login-page__remember {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.login-page__remember input {
width: 16px;
height: 16px;
accent-color: var(--color-primary);
}
.login-page__submit {
width: 100%;
margin-top: 4px;
}
@media (max-width: 900px) {
.login-page {
grid-template-columns: 1fr;
}
.login-page__form-wrap {
order: 2;
}
.login-page__brand {
order: 1;
padding: 28px 20px;
min-height: auto;
}
.login-page__brand-text {
font-size: 0.875rem;
}
}
</style>
+240
View File
@@ -0,0 +1,240 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import BaseModal from '@/components/ui/BaseModal.vue'
import BaseSwitch from '@/components/ui/BaseSwitch.vue'
import BaseEmptyState from '@/components/ui/BaseEmptyState.vue'
import AdminImagePicker from '@/components/ui/AdminImagePicker.vue'
import AdminSelect from '@/components/ui/AdminSelect.vue'
import { bannersApi, type AppBanner } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
const SLOT_OPTIONS = [
{ value: 'home_hero', label: 'خانه — بنر اصلی' },
{ value: 'products_hero', label: 'لیست محصولات' },
{ value: 'categories_hero', label: 'دسته‌بندی‌ها' },
]
const list = ref<AppBanner[]>([])
const loading = ref(true)
const saving = ref(false)
const modalOpen = ref(false)
const editingId = ref<string | null>(null)
const toast = useToast()
const form = reactive({
slot: 'home_hero',
title: '',
subtitle: '',
ctaLabel: '',
ctaLink: '/categories',
imageUrl: null as string | null,
displayOrder: 0,
isActive: true,
})
const modalTitle = computed(() => (editingId.value ? 'ویرایش بنر' : 'افزودن بنر'))
function slotLabel(slot: string) {
return SLOT_OPTIONS.find((item) => item.value === slot)?.label ?? slot
}
async function load() {
loading.value = true
try {
list.value = await bannersApi.list()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری بنرها ناموفق بود.')
} finally {
loading.value = false
}
}
function openAdd() {
editingId.value = null
Object.assign(form, {
slot: 'home_hero',
title: '',
subtitle: '',
ctaLabel: 'مشاهده کالکشن',
ctaLink: '/categories',
imageUrl: null,
displayOrder: list.value.length,
isActive: true,
})
modalOpen.value = true
}
function openEdit(banner: AppBanner) {
editingId.value = banner.id
Object.assign(form, {
slot: banner.slot,
title: banner.title,
subtitle: banner.subtitle,
ctaLabel: banner.ctaLabel,
ctaLink: banner.ctaLink || '/categories',
imageUrl: banner.imageUrl || null,
displayOrder: banner.displayOrder,
isActive: banner.isActive,
})
modalOpen.value = true
}
async function save() {
if (!form.title.trim()) return
saving.value = true
try {
const payload = {
slot: form.slot,
title: form.title.trim(),
subtitle: form.subtitle.trim() || null,
ctaLabel: form.ctaLabel.trim() || null,
ctaLink: form.ctaLink.trim() || null,
imageUrl: form.imageUrl,
displayOrder: form.displayOrder,
isActive: form.isActive,
}
if (editingId.value) await bannersApi.update(editingId.value, payload)
else await bannersApi.create(payload)
modalOpen.value = false
toast.success('بنر ذخیره شد.')
await load()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ذخیره بنر ناموفق بود.')
} finally {
saving.value = false
}
}
async function remove(banner: AppBanner) {
if (!window.confirm(`بنر «${banner.title}» حذف شود؟`)) return
try {
await bannersApi.remove(banner.id)
toast.success('بنر حذف شد.')
await load()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'حذف بنر ناموفق بود.')
}
}
onMounted(() => {
void load()
})
</script>
<template>
<div class="banners-page">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">بنرها</h1>
<p class="admin-page-header__sub">بنر بخش‌های مختلف اپ کاربر را از اینجا مدیریت کنید.</p>
</div>
<button type="button" class="admin-btn admin-btn--primary" @click="openAdd">افزودن بنر</button>
</div>
<AdminPageSkeleton v-if="loading" variant="table" />
<BaseEmptyState v-else-if="!list.length" title="بنری ثبت نشده" description="اولین بنر خانه را اضافه کنید.">
<button type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="openAdd">
افزودن بنر
</button>
</BaseEmptyState>
<div v-else class="admin-card">
<table class="admin-table">
<thead>
<tr>
<th>تصویر</th>
<th>عنوان</th>
<th>بخش</th>
<th>دکمه</th>
<th>فعال</th>
<th class="actions">عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="banner in list" :key="banner.id">
<td>
<span
class="banner-thumb"
:class="{ 'banner-thumb--empty': !banner.imageUrl }"
:style="banner.imageUrl ? { backgroundImage: `url(${banner.imageUrl})` } : undefined"
/>
</td>
<td>
<strong>{{ banner.title }}</strong>
<p v-if="banner.subtitle" class="banner-sub">{{ banner.subtitle }}</p>
</td>
<td>{{ slotLabel(banner.slot) }}</td>
<td>{{ banner.ctaLabel || '—' }}</td>
<td>{{ banner.isActive ? 'بله' : 'خیر' }}</td>
<td class="actions">
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEdit(banner)">
ویرایش
</button>
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="remove(banner)">
حذف
</button>
</td>
</tr>
</tbody>
</table>
</div>
<BaseModal :open="modalOpen" :title="modalTitle" @close="modalOpen = false">
<label class="field">
بخش نمایش
<AdminSelect v-model="form.slot" :options="SLOT_OPTIONS" />
</label>
<label class="field">عنوان<input v-model="form.title" class="admin-input" /></label>
<label class="field">زیرعنوان<input v-model="form.subtitle" class="admin-input" /></label>
<label class="field">متن دکمه<input v-model="form.ctaLabel" class="admin-input" /></label>
<label class="field">
لینک دکمه
<input v-model="form.ctaLink" class="admin-input" dir="ltr" placeholder="/categories" />
</label>
<label class="field">ترتیب نمایش<input v-model.number="form.displayOrder" type="number" min="0" class="admin-input" /></label>
<AdminImagePicker v-model="form.imageUrl" label="تصویر بنر" />
<BaseSwitch v-model="form.isActive" label="فعال" />
<template #footer>
<button type="button" class="admin-btn admin-btn--outline" @click="modalOpen = false">انصراف</button>
<button type="button" class="admin-btn admin-btn--primary" :disabled="!form.title.trim() || saving" @click="save">
{{ saving ? 'در حال ذخیره...' : 'ذخیره' }}
</button>
</template>
</BaseModal>
</div>
</template>
<style scoped>
.admin-page-header__sub {
margin: 4px 0 0;
color: var(--color-text-muted);
font-size: 0.85rem;
}
.banner-thumb {
display: block;
width: 72px;
height: 44px;
border-radius: 8px;
background: #e8eceb center / cover no-repeat;
}
.banner-thumb--empty {
background: #eef1f0;
}
.banner-sub {
margin: 2px 0 0;
color: var(--color-text-muted);
font-size: 0.8rem;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
font-size: 0.875rem;
}
</style>
+130
View File
@@ -0,0 +1,130 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import BaseModal from '@/components/ui/BaseModal.vue'
import BaseSwitch from '@/components/ui/BaseSwitch.vue'
import BaseEmptyState from '@/components/ui/BaseEmptyState.vue'
import { brandsApi, type Brand } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
const list = ref<Brand[]>([])
const loading = ref(true)
const modalOpen = ref(false)
const editingId = ref<string | null>(null)
const toast = useToast()
const form = reactive({ name: '', slug: '', description: '', displayOrder: 0, isActive: true })
const title = computed(() => (editingId.value ? 'ویرایش برند' : 'افزودن برند'))
async function load() {
loading.value = true
try {
list.value = await brandsApi.list()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری برندها ناموفق بود')
} finally {
loading.value = false
}
}
function openAdd() {
editingId.value = null
Object.assign(form, { name: '', slug: '', description: '', displayOrder: list.value.length + 1, isActive: true })
modalOpen.value = true
}
function openEdit(b: Brand) {
editingId.value = b.id
Object.assign(form, {
name: b.name,
slug: b.slug ?? '',
description: b.description ?? '',
displayOrder: b.displayOrder,
isActive: b.isActive,
})
modalOpen.value = true
}
async function save() {
try {
const payload = {
name: form.name.trim(),
slug: form.slug.trim() || undefined,
description: form.description.trim() || undefined,
displayOrder: form.displayOrder,
isActive: form.isActive,
}
if (editingId.value) await brandsApi.update(editingId.value, payload)
else await brandsApi.create(payload)
modalOpen.value = false
toast.success('ذخیره شد')
await load()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ذخیره ناموفق بود')
}
}
onMounted(() => {
void load()
})
</script>
<template>
<div class="admin-page">
<header class="admin-page__head">
<div>
<h1>برندها</h1>
</div>
<button type="button" class="admin-btn admin-btn--primary" @click="openAdd">افزودن برند</button>
</header>
<AdminPageSkeleton v-if="loading" variant="table" />
<BaseEmptyState v-else-if="!list.length" title="برندی ثبت نشده" />
<table v-else class="admin-table">
<thead>
<tr>
<th>نام</th>
<th>شناسه لاتین</th>
<th>مشتریان</th>
<th>فعال</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="b in list" :key="b.id">
<td>{{ b.name }}</td>
<td dir="ltr">{{ b.slug || '—' }}</td>
<td>{{ b.customersCount ?? 0 }}</td>
<td>{{ b.isActive ? 'بله' : 'خیر' }}</td>
<td><button type="button" class="link" @click="openEdit(b)">ویرایش</button></td>
</tr>
</tbody>
</table>
<BaseModal :open="modalOpen" :title="title" @close="modalOpen = false">
<label class="field">نام<input v-model="form.name" class="admin-input" /></label>
<label class="field">شناسه لاتین<input v-model="form.slug" class="admin-input" dir="ltr" placeholder="مثلاً nademi-silk" /></label>
<label class="field">توضیح<textarea v-model="form.description" class="admin-input" rows="3" /></label>
<label class="field">ترتیب<input v-model.number="form.displayOrder" type="number" class="admin-input" /></label>
<BaseSwitch v-model="form.isActive" label="فعال" />
<template #footer>
<button type="button" class="admin-btn admin-btn--primary" :disabled="!form.name.trim()" @click="save">ذخیره</button>
</template>
</BaseModal>
</div>
</template>
<style scoped>
.admin-page__head {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
}
.admin-page__head h1 { margin: 0 0 4px; font-size: 1.25rem; }
.admin-page__head p { margin: 0; color: var(--color-text-muted); font-size: 0.875rem; }
.admin-table { width: 100%; border-collapse: collapse; }
.admin-table th, .admin-table td { text-align: right; padding: 10px 8px; border-bottom: 1px solid var(--color-border); }
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; font-size: 0.875rem; }
.link { background: none; border: none; color: var(--color-primary); cursor: pointer; font-family: inherit; }
</style>
@@ -0,0 +1,250 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import AdminSelect from '@/components/ui/AdminSelect.vue'
import { cashPaymentsApi } from '@/services/api'
import { ApiError } from '@/services/http'
import {
CASH_PAYMENT_STATUS_BADGE,
CASH_PAYMENT_STATUS_LABELS,
} from '@/utils/adminLabels'
import { formatCurrency, formatDateTime } from '@/utils/format'
import { useToast } from '@/composables/useToast'
import type { CashPayment } from '@/types'
const route = useRoute()
const paymentList = ref<CashPayment[]>([])
const searchQuery = ref((route.query.order as string) ?? '')
const statusFilter = ref<'all' | CashPayment['status']>('all')
const loading = ref(true)
const toast = useToast()
const busyId = ref<string | null>(null)
const statusFilterOptions = [
{ value: 'all', label: 'همه وضعیت‌ها' },
{ value: 'pending_review', label: 'در انتظار تأیید' },
{ value: 'approved', label: 'تأیید شده' },
{ value: 'rejected', label: 'رد شده' },
{ value: 'expired', label: 'منقضی‌شده' },
]
const filteredPayments = computed(() => {
let list = paymentList.value
if (statusFilter.value !== 'all') {
list = list.filter((p) => p.status === statusFilter.value)
}
const q = searchQuery.value.trim()
if (q) {
list = list.filter(
(p) => p.orderNumber.includes(q) || p.customerName.includes(q) || p.trackingCode.includes(q),
)
}
return list
})
async function loadPayments() {
loading.value = true
try {
paymentList.value = await cashPaymentsApi.list()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری پرداخت‌ها انجام نشد.')
} finally {
loading.value = false
}
}
onMounted(() => {
void loadPayments()
})
async function approvePayment(payment: CashPayment) {
busyId.value = payment.id
try {
const updated = await cashPaymentsApi.updateStatus(payment.id, { status: 'approved' })
const index = paymentList.value.findIndex((p) => p.id === payment.id)
if (index !== -1) paymentList.value[index] = { ...paymentList.value[index]!, ...updated }
toast.success(`پرداخت سفارش ${payment.orderNumber} تأیید شد.`)
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'تأیید پرداخت انجام نشد.')
} finally {
busyId.value = null
}
}
async function rejectPayment(payment: CashPayment) {
busyId.value = payment.id
try {
const updated = await cashPaymentsApi.updateStatus(payment.id, { status: 'rejected' })
const index = paymentList.value.findIndex((p) => p.id === payment.id)
if (index !== -1) paymentList.value[index] = { ...paymentList.value[index]!, ...updated }
toast.warning(`پرداخت سفارش ${payment.orderNumber} رد شد.`)
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'رد پرداخت انجام نشد.')
} finally {
busyId.value = null
}
}
</script>
<template>
<div class="cash-payments-page">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">پرداخت‌های نقدی</h1>
</div>
</div>
<div class="cash-payments-page__filters admin-card">
<div class="cash-payments-page__search">
<AdminIcon name="search" :size="18" />
<input
v-model="searchQuery"
type="search"
class="cash-payments-page__search-input"
placeholder="جستجو در شماره سفارش، مشتری، پیگیری..."
/>
</div>
<AdminSelect
v-model="statusFilter"
class="cash-payments-page__status-select"
aria-label="فیلتر وضعیت پرداخت"
:options="statusFilterOptions"
/>
</div>
<AdminPageSkeleton v-if="loading" variant="table" />
<template v-else>
<div class="cash-payments-page__table admin-card desktop-only">
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>شماره سفارش</th>
<th>مشتری</th>
<th>مبلغ فاکتور</th>
<th>مبلغ واریزی</th>
<th>تاریخ واریز</th>
<th>شماره پیگیری</th>
<th>پایان اعتبار</th>
<th>وضعیت</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="payment in filteredPayments" :key="payment.id">
<td>{{ payment.orderNumber }}</td>
<td>{{ payment.customerName }}</td>
<td>{{ formatCurrency(payment.amount) }}</td>
<td>{{ payment.paidAmount ? formatCurrency(payment.paidAmount) : '—' }}</td>
<td>{{ payment.transferDate ? formatDateTime(payment.transferDate) : '—' }}</td>
<td dir="ltr">{{ payment.trackingCode }}</td>
<td>{{ formatDateTime(payment.expiresAt) }}</td>
<td>
<span class="admin-badge" :class="CASH_PAYMENT_STATUS_BADGE[payment.status]">
{{ CASH_PAYMENT_STATUS_LABELS[payment.status] }}
</span>
</td>
<td>
<div v-if="payment.status === 'pending_review'" class="admin-action-group">
<button
type="button"
class="admin-btn admin-btn--outline-success admin-btn--xs"
:disabled="busyId === payment.id"
@click="approvePayment(payment)"
>
تأیید
</button>
<button
type="button"
class="admin-btn admin-btn--outline-danger admin-btn--xs"
:disabled="busyId === payment.id"
@click="rejectPayment(payment)"
>
رد
</button>
</div>
<span v-else>—</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="compact-only admin-card-list">
<article v-for="payment in filteredPayments" :key="payment.id" class="admin-card-item">
<div class="admin-card-item__header">
<p class="admin-card-item__title">{{ payment.orderNumber }}</p>
<span class="admin-badge" :class="CASH_PAYMENT_STATUS_BADGE[payment.status]">
{{ CASH_PAYMENT_STATUS_LABELS[payment.status] }}
</span>
</div>
<div class="admin-card-item__meta">
<span>{{ payment.customerName }}</span>
<span>{{ formatCurrency(payment.amount) }}</span>
<span v-if="payment.paidAmount != null">پرداختی: {{ formatCurrency(payment.paidAmount) }}</span>
<span dir="ltr">{{ payment.trackingCode }}</span>
</div>
<div v-if="payment.status === 'pending_review'" class="admin-card-item__actions">
<button
type="button"
class="admin-btn admin-btn--outline-success admin-btn--xs"
:disabled="busyId === payment.id"
@click="approvePayment(payment)"
>
تأیید
</button>
<button
type="button"
class="admin-btn admin-btn--outline-danger admin-btn--xs"
:disabled="busyId === payment.id"
@click="rejectPayment(payment)"
>
رد
</button>
</div>
</article>
</div>
</template>
</div>
</template>
<style scoped>
.cash-payments-page__filters {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 12px 14px;
margin-bottom: 16px;
align-items: center;
}
.cash-payments-page__search {
flex: 1;
min-width: 200px;
display: flex;
align-items: center;
gap: 10px;
color: var(--color-text-muted);
}
.cash-payments-page__search-input {
flex: 1;
border: none;
background: transparent;
font-size: 0.875rem;
outline: none;
color: var(--color-text);
}
.cash-payments-page__status-select {
flex: 0 1 220px;
width: 220px;
}
.cash-payments-page__table {
overflow: hidden;
}
</style>
@@ -0,0 +1,219 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import BaseEmptyState from '@/components/ui/BaseEmptyState.vue'
import AdminSelect from '@/components/ui/AdminSelect.vue'
import { catalogRequestsApi, type CatalogRequestRow } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
import { formatDateTime } from '@/utils/format'
const STATUS_OPTIONS = [
{ value: '', label: 'همه وضعیت‌ها' },
{ value: 'new', label: 'درخواست جدید' },
{ value: 'approved', label: 'تأیید شده' },
{ value: 'ready_to_ship', label: 'آماده ارسال' },
{ value: 'delivered', label: 'تحویل به مشتری' },
{ value: 'returned', label: 'بازگردانده شده' },
{ value: 'cancelled', label: 'لغو شده' },
]
const HOLDER_OPTIONS = [
{ value: 'warehouse', label: 'انبار' },
{ value: 'customer', label: 'مشتری' },
{ value: 'agent', label: 'واسطه' },
]
const STATUS_LABELS: Record<string, string> = Object.fromEntries(
STATUS_OPTIONS.filter((s) => s.value).map((s) => [s.value, s.label]),
)
const HOLDER_LABELS: Record<string, string> = Object.fromEntries(
HOLDER_OPTIONS.map((h) => [h.value, h.label]),
)
const rows = ref<CatalogRequestRow[]>([])
const statusFilter = ref('')
const loading = ref(true)
const updatingHolderId = ref<string | null>(null)
const toast = useToast()
async function load() {
loading.value = true
try {
rows.value = await catalogRequestsApi.list(statusFilter.value || undefined)
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری انجام نشد')
} finally {
loading.value = false
}
}
async function setStatus(id: string, status: string) {
try {
await catalogRequestsApi.updateStatus(id, status)
toast.success('وضعیت به‌روز شد')
await load()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'به‌روزرسانی ناموفق بود')
}
}
async function setHolder(itemId: string, holderType: string) {
if (updatingHolderId.value) return
updatingHolderId.value = itemId
try {
await catalogRequestsApi.updateHolder(itemId, { holderType })
toast.success('محل نگهداری به‌روز شد')
await load()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'به‌روزرسانی محل ناموفق بود')
} finally {
updatingHolderId.value = null
}
}
function holderLabel(value: string): string {
return HOLDER_LABELS[value] ?? value
}
onMounted(() => {
void load()
})
</script>
<template>
<div class="admin-page">
<header class="admin-page__head">
<div>
<h1>درخواست کالیته فیزیکی</h1>
<p>بررسی، گردش وضعیت و محل نگهداری اقلام</p>
</div>
<AdminSelect
v-model="statusFilter"
:options="STATUS_OPTIONS"
@update:model-value="load"
/>
</header>
<AdminPageSkeleton v-if="loading" variant="cards" />
<BaseEmptyState v-else-if="!rows.length" title="درخواستی نیست" />
<template v-else>
<div v-for="row in rows" :key="row.id" class="request-card">
<div class="request-card__top">
<div>
<strong>{{ row.customerName }}</strong>
<span class="muted" dir="ltr">{{ row.customerPhone }}</span>
<span class="badge">{{ STATUS_LABELS[row.status] ?? row.status }}</span>
</div>
<AdminSelect
:model-value="row.status"
:options="STATUS_OPTIONS.filter((s) => s.value)"
@update:model-value="(v) => setStatus(row.id, String(v))"
/>
</div>
<p v-if="row.note" class="muted">{{ row.note }}</p>
<table class="admin-table">
<thead>
<tr>
<th>محصول</th>
<th>کد</th>
<th>تعداد</th>
<th>محل</th>
</tr>
</thead>
<tbody>
<tr v-for="item in row.items" :key="item.id">
<td>{{ item.productName }}</td>
<td dir="ltr">{{ item.productCode }}</td>
<td>{{ item.quantity }}</td>
<td>
<AdminSelect
:model-value="item.holderType"
:options="HOLDER_OPTIONS"
@update:model-value="(v) => setHolder(item.id, String(v))"
/>
<ul v-if="item.movements?.length" class="movements">
<li v-for="move in item.movements" :key="move.id">
{{ holderLabel(move.fromHolder) }} → {{ holderLabel(move.toHolder) }}
<span class="muted">{{ formatDateTime(move.movedAt) }}</span>
</li>
</ul>
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
</template>
<style scoped>
.admin-page__head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 20px;
}
.admin-page__head h1 {
margin: 0 0 4px;
font-size: 1.25rem;
}
.admin-page__head p {
margin: 0;
color: var(--color-text-muted);
font-size: 0.875rem;
}
.request-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
}
.request-card__top {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-bottom: 10px;
}
.request-card__top strong {
margin-left: 8px;
}
.muted {
color: var(--color-text-muted);
font-size: 0.8125rem;
margin-left: 8px;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
background: var(--color-primary-soft);
color: var(--color-primary);
font-size: 0.75rem;
}
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
}
.admin-table th,
.admin-table td {
text-align: right;
padding: 8px 6px;
border-top: 1px solid var(--color-border);
vertical-align: top;
}
.movements {
list-style: none;
margin: 8px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.75rem;
}
</style>
+365
View File
@@ -0,0 +1,365 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import BaseModal from '@/components/ui/BaseModal.vue'
import BaseSwitch from '@/components/ui/BaseSwitch.vue'
import BaseEmptyState from '@/components/ui/BaseEmptyState.vue'
import ActiveSwitchCell from '@/components/ui/ActiveSwitchCell.vue'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import AdminSelect from '@/components/ui/AdminSelect.vue'
import { catalogsApi, companiesApi } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
import type { Catalog, Company } from '@/types'
const catalogList = ref<Catalog[]>([])
const companies = ref<Company[]>([])
const searchQuery = ref('')
const companyFilter = ref('all')
const loading = ref(true)
const modalMode = ref<'add' | 'edit' | null>(null)
const editingId = ref<string | null>(null)
const formError = ref('')
const saving = ref(false)
const toast = useToast()
const companyFilterOptions = computed(() => [
{ value: 'all', label: 'همه شرکت‌ها' },
...companies.value.map((company) => ({ value: company.id, label: company.name })),
])
const companyFormOptions = computed(() =>
companies.value.map((company) => ({ value: company.id, label: company.name })),
)
const emptyForm = () => ({
companyId: companies.value[0]?.id ?? '',
code: '',
name: '',
displayOrder: catalogList.value.length + 1,
isActive: true,
})
const form = reactive(emptyForm())
const isModalOpen = computed(() => modalMode.value !== null)
const modalTitle = computed(() => (modalMode.value === 'edit' ? 'ویرایش کالیته' : 'افزودن کالیته'))
const isFormValid = computed(() => form.code.trim() && form.name.trim() && form.companyId)
const hasActiveFilters = computed(
() => searchQuery.value.trim().length > 0 || companyFilter.value !== 'all',
)
const filteredCatalogs = computed(() => {
let list = catalogList.value
if (companyFilter.value !== 'all') {
list = list.filter((c) => c.companyId === companyFilter.value)
}
const q = searchQuery.value.trim()
if (q) {
list = list.filter((c) => c.code.includes(q) || c.name.includes(q) || c.companyName.includes(q))
}
return list
})
async function loadData() {
loading.value = true
try {
const [catalogsData, companiesData] = await Promise.all([
catalogsApi.list(),
companiesApi.list(),
])
catalogList.value = catalogsData
companies.value = companiesData
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری کالیته‌ها انجام نشد.')
} finally {
loading.value = false
}
}
onMounted(() => {
void loadData()
})
function companyName(companyId: string) {
return companies.value.find((c) => c.id === companyId)?.name ?? '—'
}
function openAddModal() {
modalMode.value = 'add'
editingId.value = null
Object.assign(form, emptyForm())
formError.value = ''
}
function openEditModal(catalog: Catalog) {
modalMode.value = 'edit'
editingId.value = catalog.id
form.companyId = catalog.companyId
form.code = catalog.code
form.name = catalog.name
form.displayOrder = catalog.displayOrder
form.isActive = catalog.isActive
formError.value = ''
}
function closeModal() {
modalMode.value = null
editingId.value = null
formError.value = ''
}
async function submitCatalog() {
if (!isFormValid.value) {
formError.value = 'کد، نام و شرکت الزامی هستند.'
return
}
saving.value = true
try {
if (modalMode.value === 'edit' && editingId.value) {
const updated = await catalogsApi.update(editingId.value, {
code: form.code.trim(),
name: form.name.trim(),
displayOrder: Number(form.displayOrder) || 1,
isActive: form.isActive,
})
const index = catalogList.value.findIndex((c) => c.id === editingId.value)
if (index !== -1) {
catalogList.value[index] = {
...catalogList.value[index]!,
...updated,
companyName: companyName(form.companyId),
}
}
toast.success('اطلاعات کالیته به‌روزرسانی شد.')
} else {
const created = await catalogsApi.create({
companyId: form.companyId,
code: form.code.trim(),
name: form.name.trim(),
displayOrder: Number(form.displayOrder) || 1,
isActive: form.isActive,
})
catalogList.value.push({
...created,
companyName: created.companyName || companyName(form.companyId),
productCount: created.productCount ?? 0,
})
toast.success('کالیته جدید ثبت شد.')
}
closeModal()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ذخیره کالیته انجام نشد.')
} finally {
saving.value = false
}
}
async function persistActive(id: string, isActive: boolean) {
await catalogsApi.setActive(id, isActive)
}
function clearFilters() {
searchQuery.value = ''
companyFilter.value = 'all'
}
</script>
<template>
<div class="catalogs-page">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">کالیته‌ها</h1>
</div>
<button type="button" class="admin-btn admin-btn--primary" @click="openAddModal">
افزودن کالیته
</button>
</div>
<div class="catalogs-page__filters admin-card">
<div class="catalogs-page__search">
<AdminIcon name="search" :size="18" />
<input
v-model="searchQuery"
type="search"
class="catalogs-page__search-input"
placeholder="جستجو بر اساس کد یا نام..."
/>
</div>
<AdminSelect
v-model="companyFilter"
class="catalogs-page__company-filter"
aria-label="فیلتر شرکت"
:options="companyFilterOptions"
/>
</div>
<AdminPageSkeleton v-if="loading" variant="table" />
<BaseEmptyState
v-else-if="!filteredCatalogs.length"
class="admin-card"
icon="catalogs"
:title="hasActiveFilters ? 'نتیجه‌ای یافت نشد' : 'کالیته‌ای ثبت نشده'"
:description="
hasActiveFilters
? 'عبارت جستجو را تغییر دهید یا فیلترها را پاک کنید.'
: 'برای شروع، اولین کالیته را ثبت کنید.'
"
>
<button v-if="hasActiveFilters" type="button" class="admin-btn admin-btn--outline admin-btn--sm" @click="clearFilters">
پاک کردن فیلترها
</button>
<button v-else type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="openAddModal">
افزودن کالیته
</button>
</BaseEmptyState>
<div v-else class="catalogs-page__table admin-card desktop-only">
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>کد</th>
<th>نام کالیته</th>
<th>شرکت</th>
<th class="numeric">ترتیب</th>
<th class="numeric">تعداد محصول</th>
<th class="status">وضعیت</th>
<th class="actions">عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="catalog in filteredCatalogs" :key="catalog.id">
<td>{{ catalog.code }}</td>
<td>{{ catalog.name }}</td>
<td>{{ catalog.companyName }}</td>
<td class="numeric">{{ catalog.displayOrder }}</td>
<td class="numeric">{{ catalog.productCount }}</td>
<td class="status">
<ActiveSwitchCell
:id="catalog.id"
v-model="catalog.isActive"
:entity-name="catalog.name"
entity-type="کالیته"
:persist="persistActive"
/>
</td>
<td class="actions">
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(catalog)">
ویرایش
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="!loading && filteredCatalogs.length" class="compact-only admin-card-list">
<article v-for="catalog in filteredCatalogs" :key="catalog.id" class="admin-card-item">
<div class="admin-card-item__header">
<p class="admin-card-item__title">کالیته {{ catalog.code }} — {{ catalog.name }}</p>
<ActiveSwitchCell
:id="catalog.id"
v-model="catalog.isActive"
:entity-name="catalog.name"
entity-type="کالیته"
:persist="persistActive"
/>
</div>
<div class="admin-card-item__meta">
<span>{{ catalog.companyName }}</span>
<span>{{ catalog.productCount }} محصول</span>
<span>ترتیب: {{ catalog.displayOrder }}</span>
</div>
<div class="admin-card-item__actions">
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(catalog)">
ویرایش
</button>
</div>
</article>
</div>
<BaseModal :open="isModalOpen" :title="modalTitle" size="lg" @close="closeModal">
<form class="admin-form" @submit.prevent="submitCatalog">
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
<div class="admin-form__field">
<label>شرکت <span class="admin-form__required">*</span></label>
<AdminSelect
v-model="form.companyId"
block
aria-label="انتخاب شرکت"
placeholder="انتخاب شرکت"
:options="companyFormOptions"
/>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="catalog-code">کد کالیته <span class="admin-form__required">*</span></label>
<input id="catalog-code" v-model="form.code" type="text" class="admin-input" placeholder="377" />
</div>
<div class="admin-form__field">
<label for="catalog-name">نام کالیته <span class="admin-form__required">*</span></label>
<input id="catalog-name" v-model="form.name" type="text" class="admin-input" />
</div>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="catalog-order">ترتیب نمایش</label>
<input id="catalog-order" v-model.number="form.displayOrder" type="number" min="1" class="admin-input" />
</div>
<div class="admin-form__field">
<BaseSwitch v-model="form.isActive" label="وضعیت" />
</div>
</div>
</form>
<template #footer>
<button type="button" class="admin-btn admin-btn--outline" @click="closeModal">انصراف</button>
<button type="button" class="admin-btn admin-btn--primary" :disabled="!isFormValid || saving" @click="submitCatalog">
{{ modalMode === 'edit' ? 'ذخیره تغییرات' : 'ثبت کالیته' }}
</button>
</template>
</BaseModal>
</div>
</template>
<style scoped>
.catalogs-page__filters {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 12px 14px;
margin-bottom: 16px;
align-items: center;
}
.catalogs-page__search {
flex: 1;
min-width: 200px;
display: flex;
align-items: center;
gap: 10px;
color: var(--color-text-muted);
}
.catalogs-page__search-input {
flex: 1;
border: none;
background: transparent;
font-size: 0.875rem;
outline: none;
}
.catalogs-page__company-filter {
width: min(220px, 100%);
}
.catalogs-page__table {
overflow: hidden;
}
</style>
+139
View File
@@ -0,0 +1,139 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import AdminImagePicker from '@/components/ui/AdminImagePicker.vue'
import { settingsApi } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
const USAGE = [
{ id: 'trousers', title: 'پارچه‌های شلواری' },
{ id: 'suit', title: 'پارچه‌های کت‌شلواری' },
{ id: 'shirt', title: 'پارچه‌های پیراهنی' },
]
const SEASONS = [
{ id: 'summer', title: 'تابستانه' },
{ id: 'winter', title: 'زمستانه' },
{ id: 'all_season', title: 'چهارفصل' },
]
const images = reactive<Record<string, string | null>>({
trousers: null,
suit: null,
shirt: null,
summer: null,
winter: null,
all_season: null,
})
const loading = ref(true)
const saving = ref(false)
const toast = useToast()
async function load() {
loading.value = true
try {
const data = await settingsApi.get()
const stored = data.categoryImagesJson ?? {}
for (const key of Object.keys(images)) {
images[key] = stored[key] ?? null
}
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری تصاویر دسته‌ها ناموفق بود.')
} finally {
loading.value = false
}
}
async function save() {
saving.value = true
try {
const categoryImagesJson: Record<string, string> = {}
for (const [key, value] of Object.entries(images)) {
if (value) categoryImagesJson[key] = value
}
await settingsApi.update({ categoryImagesJson })
toast.success('تصاویر دسته‌بندی ذخیره شد.')
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ذخیره تصاویر ناموفق بود.')
} finally {
saving.value = false
}
}
onMounted(() => {
void load()
})
</script>
<template>
<div class="categories-admin">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">دسته‌بندی‌ها</h1>
<p class="admin-page-header__sub">تصویر هر دسته در اپ کاربر نمایش داده می‌شود.</p>
</div>
<button type="button" class="admin-btn admin-btn--primary" :disabled="saving || loading" @click="save">
{{ saving ? 'در حال ذخیره...' : 'ذخیره تصاویر' }}
</button>
</div>
<AdminPageSkeleton v-if="loading" variant="cards" />
<div v-else class="categories-admin__grid">
<section class="admin-card">
<h2>بر اساس کاربرد</h2>
<div class="categories-admin__list">
<AdminImagePicker
v-for="item in USAGE"
:key="item.id"
:label="item.title"
:model-value="images[item.id]"
@update:model-value="images[item.id] = $event"
/>
</div>
</section>
<section class="admin-card">
<h2>بر اساس فصل</h2>
<div class="categories-admin__list">
<AdminImagePicker
v-for="item in SEASONS"
:key="item.id"
:label="item.title"
:model-value="images[item.id]"
@update:model-value="images[item.id] = $event"
/>
</div>
</section>
</div>
</div>
</template>
<style scoped>
.admin-page-header__sub {
margin: 4px 0 0;
color: var(--color-text-muted);
font-size: 0.85rem;
}
.categories-admin__grid {
display: grid;
gap: 16px;
}
.categories-admin__grid h2 {
margin: 0 0 14px;
font-size: 1rem;
}
.categories-admin__list {
display: grid;
gap: 16px;
}
@media (min-width: 900px) {
.categories-admin__grid {
grid-template-columns: 1fr 1fr;
}
}
</style>
+778
View File
@@ -0,0 +1,778 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { checksApi, mapAdminOrder, ordersApi, type ApiOrder } from '@/services/api'
import { ApiError } from '@/services/http'
import { formatCurrency, formatDate } from '@/utils/format'
import { useToast } from '@/composables/useToast'
import { confirm } from '@/composables/useConfirm'
import { rawStatusLabel } from '@/utils/orderStatus'
import type { Check } from '@/types'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const activeTab = ref<'info' | 'history'>('info')
const order = ref<ApiOrder | null>(null)
const orderChecks = ref<Check[]>([])
const busyId = ref<string | null>(null)
const expandedId = ref<string | null>(null)
const previewImage = ref<string | null>(null)
const displayOrder = computed(() =>
order.value ? mapAdminOrder(order.value) : null,
)
const totalCheckAmount = computed(() =>
orderChecks.value.reduce((sum, c) => sum + c.amount, 0),
)
const pendingChecks = computed(() =>
orderChecks.value.filter((c) => c.status === 'pending_review'),
)
const allChecksApproved = computed(
() =>
orderChecks.value.length > 0 &&
orderChecks.value.every((c) => c.status === 'approved'),
)
const canFinalApprove = computed(() => {
if (!allChecksApproved.value || !order.value) return false
const status = order.value.status
return status === 'checks_approved' || status === 'awaiting_check_review'
})
const finalizing = ref(false)
const reviewHistory = computed(() => {
const items = order.value?.statusHistory ?? []
const checkRelated = new Set([
'check_in_progress',
'awaiting_check_review',
'check_needs_fix',
'check_rejected',
'checks_approved',
])
const filtered = items.filter(
(h) =>
checkRelated.has(h.toStatus) ||
(h.fromStatus && checkRelated.has(h.fromStatus)) ||
/چک/.test(h.note || '') ||
/چک/.test(h.reason || ''),
)
const source = filtered.length ? filtered : items
return [...source].sort(
(a, b) => new Date(b.changedAt).getTime() - new Date(a.changedAt).getTime(),
)
})
const checkReviewNotes = computed(() =>
orderChecks.value
.filter((c) => c.notes?.trim())
.map((c) => ({
id: c.id,
row: c.row,
status: c.status,
notes: c.notes!,
updatedAt: c.updatedAt,
})),
)
async function loadData() {
const orderId = String(route.params.id)
try {
const [orderData, checksData] = await Promise.all([
ordersApi.get(orderId),
checksApi.listByOrder(orderId),
])
order.value = orderData
orderChecks.value = checksData
if (checksData.length === 1) expandedId.value = checksData[0]!.id
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری چک‌ها انجام نشد.')
}
}
onMounted(() => {
void loadData()
})
function goBack() {
router.push('/orders')
}
function checkBadgeStatus(status: Check['status']) {
if (status === 'approved') return 'check_approved'
if (status === 'rejected') return 'check_rejected'
return 'pending_check_review'
}
function actorLabel(changedBy?: string) {
if (!changedBy) return '—'
if (changedBy === 'customer') return 'مشتری'
if (changedBy.startsWith('admin:')) return 'ادمین'
return changedBy
}
function formatDateTime(iso?: string) {
if (!iso) return '—'
try {
return new Intl.DateTimeFormat('fa-IR', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(iso))
} catch {
return iso.slice(0, 16).replace('T', ' ')
}
}
function ownerName(check: Check) {
return [check.ownerFirstName, check.ownerLastName].filter(Boolean).join(' ')
}
function detailFields(check: Check) {
return [
{ label: 'بانک', value: check.bankName },
{ label: 'شعبه', value: check.branchName },
{ label: 'شناسه صیادی', value: check.sayadId, ltr: true },
{ label: 'شماره چک', value: check.checkNumber, ltr: true },
{ label: 'صاحب چک', value: ownerName(check) },
{ label: 'کد ملی', value: check.ownerNationalId, ltr: true },
{ label: 'یادداشت', value: check.notes },
].filter((f) => !!f.value?.trim())
}
function toggleExpand(check: Check) {
expandedId.value = expandedId.value === check.id ? null : check.id
}
async function applyCheckStatus(check: Check, status: 'approved' | 'rejected') {
busyId.value = check.id
try {
const updated = await checksApi.updateStatus(check.id, { status })
const index = orderChecks.value.findIndex((c) => c.id === check.id)
if (index !== -1) orderChecks.value[index] = updated
order.value = await ordersApi.get(String(route.params.id))
return true
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'به‌روزرسانی وضعیت چک انجام نشد.')
return false
} finally {
busyId.value = null
}
}
async function updateCheckStatus(check: Check, status: Check['status']) {
if (status !== 'approved' && status !== 'rejected') return
const amountLabel = formatCurrency(check.amount)
const ok = await confirm(
status === 'approved'
? {
title: 'تأیید چک',
message: `آیا از تأیید چک ردیف ${check.row} به مبلغ ${amountLabel} مطمئن هستید؟`,
tone: 'primary',
confirmLabel: 'تأیید چک',
cancelLabel: 'انصراف',
}
: {
title: 'رد چک',
message: `آیا از رد چک ردیف ${check.row} به مبلغ ${amountLabel} مطمئن هستید؟`,
tone: 'danger',
confirmLabel: 'رد چک',
cancelLabel: 'انصراف',
},
)
if (!ok) return
const success = await applyCheckStatus(check, status)
if (success) {
toast.success(status === 'approved' ? 'چک تأیید شد.' : 'چک رد شد.')
}
}
async function approveAll() {
const pending = pendingChecks.value
if (!pending.length) {
toast.error('چکی برای تأیید وجود ندارد.')
return
}
const ok = await confirm({
title: 'تأیید همه چک‌ها',
message: `آیا از تأیید ${pending.length} چک در انتظار بررسی مطمئن هستید؟`,
tone: 'primary',
confirmLabel: 'تأیید همه',
cancelLabel: 'انصراف',
})
if (!ok) return
for (const check of pending) {
const success = await applyCheckStatus(check, 'approved')
if (!success) return
}
toast.success('همه چک‌های در انتظار تأیید شدند.')
}
async function finalApproveOrder() {
if (!order.value || finalizing.value) return
const ok = await confirm({
title: 'تأیید نهایی سفارش',
message:
'با تأیید نهایی، سفارش از مرحله بررسی چک خارج می‌شود و وارد آماده‌سازی می‌گردد. ادامه می‌دهید؟',
tone: 'primary',
confirmLabel: 'تأیید نهایی',
cancelLabel: 'انصراف',
})
if (!ok) return
finalizing.value = true
try {
const updated = await ordersApi.updateStatus(order.value.id, {
status: 'final_approved',
note: 'تأیید نهایی پس از تأیید همه چک‌ها',
})
order.value = updated
toast.success('سفارش تأیید نهایی شد.')
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'تأیید نهایی انجام نشد.')
} finally {
finalizing.value = false
}
}
</script>
<template>
<div class="checks-page">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">مدیریت چک‌های سفارش</h1>
</div>
<div v-if="displayOrder" class="checks-page__order-meta">
<StatusBadge :status="displayOrder.status" />
</div>
</div>
<div class="checks-page__stats">
<div class="checks-page__stat">
<span>تعداد</span>
<strong>{{ orderChecks.length }}</strong>
</div>
<div class="checks-page__stat">
<span>راس</span>
<strong>{{ order?.checkRassDays ?? '—' }} روز</strong>
</div>
<div class="checks-page__stat">
<span>مجموع چک</span>
<strong>{{ formatCurrency(totalCheckAmount) }}</strong>
</div>
<div class="checks-page__stat">
<span>مبلغ سفارش</span>
<strong>{{ displayOrder?.totalAmount ? formatCurrency(displayOrder.totalAmount) : '—' }}</strong>
</div>
</div>
<div class="admin-tabs">
<button
type="button"
class="admin-tab"
:class="{ active: activeTab === 'info' }"
@click="activeTab = 'info'"
>
چک‌ها ({{ orderChecks.length }})
</button>
<button
type="button"
class="admin-tab"
:class="{ active: activeTab === 'history' }"
@click="activeTab = 'history'"
>
تاریخچه
</button>
</div>
<template v-if="activeTab === 'info'">
<div v-if="!orderChecks.length" class="admin-empty-placeholder admin-card">
چکی برای این سفارش ثبت نشده است.
</div>
<div v-else class="checks-page__list admin-card">
<div
v-for="check in orderChecks"
:key="check.id"
class="check-row"
:class="{ open: expandedId === check.id }"
>
<button type="button" class="check-row__main" @click="toggleExpand(check)">
<span class="check-row__chevron" aria-hidden="true">{{ expandedId === check.id ? '▾' : '◂' }}</span>
<span
v-if="check.frontImage || check.backImage"
class="check-row__thumb"
@click.stop="previewImage = (check.frontImage || check.backImage)!"
>
<img :src="check.frontImage || check.backImage" alt="" />
</span>
<span v-else class="check-row__thumb check-row__thumb--empty">بدون عکس</span>
<span class="check-row__meta">
<strong>چک {{ check.row }}</strong>
<small>
{{ formatCurrency(check.amount) }}
<template v-if="check.bankName"> · {{ check.bankName }}</template>
· {{ formatDate(check.dueDate) }}
</small>
</span>
<StatusBadge :status="checkBadgeStatus(check.status)" />
</button>
<div class="check-row__actions" @click.stop>
<template v-if="check.status === 'pending_review'">
<button
type="button"
class="admin-btn admin-btn--outline-success admin-btn--xs"
:disabled="busyId === check.id"
@click="updateCheckStatus(check, 'approved')"
>
تأیید
</button>
<button
type="button"
class="admin-btn admin-btn--outline-danger admin-btn--xs"
:disabled="busyId === check.id"
@click="updateCheckStatus(check, 'rejected')"
>
رد
</button>
</template>
</div>
<div v-if="expandedId === check.id" class="check-row__panel">
<div v-if="detailFields(check).length" class="check-row__fields">
<div
v-for="field in detailFields(check)"
:key="field.label"
class="check-row__field"
>
<span>{{ field.label }}</span>
<strong :dir="field.ltr ? 'ltr' : undefined">{{ field.value }}</strong>
</div>
</div>
<p v-else class="check-row__hint">جزئیات متنی بیشتری ثبت نشده است.</p>
<div class="check-row__photos">
<button
v-if="check.frontImage"
type="button"
class="check-row__photo"
@click="previewImage = check.frontImage!"
>
<img :src="check.frontImage" alt="روی چک" />
<span>روی</span>
</button>
<button
v-if="check.backImage"
type="button"
class="check-row__photo"
@click="previewImage = check.backImage!"
>
<img :src="check.backImage" alt="پشت چک" />
<span>پشت</span>
</button>
<p v-if="!check.frontImage && !check.backImage" class="check-row__hint">
تصویری ثبت نشده.
</p>
</div>
</div>
</div>
</div>
<div class="checks-page__footer">
<p class="checks-page__note">
<template v-if="canFinalApprove">
همه چک‌ها تأیید شده‌اند. برای ادامه، تأیید نهایی سفارش را ثبت کنید.
</template>
<template v-else-if="order?.checkRassDate">
راس: {{ formatDate(order.checkRassDate) }}
</template>
<template v-else>راس بر اساس تاریخ ثبت سفارش محاسبه می‌شود.</template>
</p>
<div class="admin-action-group">
<button
v-if="canFinalApprove"
type="button"
class="admin-btn admin-btn--success admin-btn--sm"
:disabled="finalizing"
@click="finalApproveOrder"
>
{{ finalizing ? 'در حال ثبت…' : 'تأیید نهایی سفارش' }}
</button>
<button
v-if="pendingChecks.length"
type="button"
class="admin-btn admin-btn--success admin-btn--sm"
@click="approveAll"
>
تأیید همه چک‌ها
</button>
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm" @click="goBack">
بازگشت
</button>
</div>
</div>
</template>
<template v-else>
<div
v-if="!reviewHistory.length && !checkReviewNotes.length"
class="admin-empty-placeholder admin-card"
>
هنوز تاریخچه‌ای ثبت نشده است.
</div>
<div v-else class="checks-page__history">
<article
v-for="item in reviewHistory"
:key="item.id"
class="admin-card checks-page__history-item"
>
<div class="checks-page__history-item-head">
<strong>
{{ item.fromStatus ? rawStatusLabel(item.fromStatus) : '—' }}
→
{{ rawStatusLabel(item.toStatus) }}
</strong>
<span>{{ formatDateTime(item.changedAt) }}</span>
</div>
<p class="checks-page__history-meta">توسط {{ actorLabel(item.changedBy) }}</p>
<p v-if="item.note" class="checks-page__history-note">{{ item.note }}</p>
<p v-if="item.reason" class="checks-page__history-note">دلیل: {{ item.reason }}</p>
</article>
<article
v-for="note in checkReviewNotes"
:key="`note-${note.id}`"
class="admin-card checks-page__history-item"
>
<div class="checks-page__history-item-head">
<strong>یادداشت چک {{ note.row }}</strong>
<span>{{ formatDateTime(note.updatedAt) }}</span>
</div>
<StatusBadge :status="checkBadgeStatus(note.status)" />
<p class="checks-page__history-note">{{ note.notes }}</p>
</article>
</div>
</template>
<Teleport to="body">
<div
v-if="previewImage"
class="checks-page__lightbox"
@click.self="previewImage = null"
>
<button type="button" class="checks-page__lightbox-close" @click="previewImage = null">
بستن
</button>
<img :src="previewImage" alt="پیش‌نمایش" />
</div>
</Teleport>
</div>
</template>
<style scoped>
.checks-page__order-meta {
display: flex;
align-items: center;
gap: 8px;
}
.checks-page__stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin-bottom: 14px;
}
.checks-page__stat {
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
}
.checks-page__stat span {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.checks-page__stat strong {
font-size: 0.875rem;
font-weight: 700;
}
.checks-page__list {
overflow: hidden;
}
.check-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px 12px;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid var(--color-border-light);
}
.check-row:last-child {
border-bottom: 0;
}
.check-row.open {
background: color-mix(in srgb, var(--color-primary-soft) 55%, transparent);
}
.check-row__main {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
border: 0;
background: transparent;
color: inherit;
text-align: start;
cursor: pointer;
padding: 4px 0;
}
.check-row__chevron {
width: 14px;
color: var(--color-text-muted);
font-size: 0.75rem;
flex-shrink: 0;
}
.check-row__thumb {
width: 44px;
height: 32px;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--color-border);
flex-shrink: 0;
padding: 0;
background: #111;
display: grid;
place-items: center;
cursor: zoom-in;
}
.check-row__thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.check-row__thumb--empty {
font-size: 0.625rem;
color: var(--color-text-muted);
cursor: default;
}
.check-row__meta {
min-width: 0;
display: grid;
gap: 2px;
flex: 1;
}
.check-row__meta strong {
font-size: 0.875rem;
font-weight: 700;
}
.check-row__meta small {
font-size: 0.75rem;
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.check-row__actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.check-row__panel {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
gap: 12px 16px;
align-items: flex-start;
padding: 8px 0 4px 24px;
border-top: 1px dashed var(--color-border);
}
.check-row__fields {
display: flex;
flex-wrap: wrap;
gap: 8px;
flex: 1;
min-width: 200px;
}
.check-row__field {
display: inline-flex;
align-items: baseline;
gap: 6px;
padding: 4px 8px;
border-radius: 999px;
background: var(--color-border-light);
font-size: 0.75rem;
}
.check-row__field span {
color: var(--color-text-muted);
}
.check-row__field strong {
font-weight: 600;
}
.check-row__photos {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.check-row__photo {
width: 96px;
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
background: transparent;
color: inherit;
padding: 0;
cursor: zoom-in;
text-align: center;
}
.check-row__photo img {
width: 100%;
height: 60px;
object-fit: cover;
display: block;
background: #111;
}
.check-row__photo span {
display: block;
padding: 4px;
font-size: 0.6875rem;
color: var(--color-text-muted);
}
.check-row__hint {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.checks-page__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 14px;
flex-wrap: wrap;
}
.checks-page__note {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.checks-page__history {
display: grid;
gap: 8px;
}
.checks-page__history-item {
padding: 12px;
display: grid;
gap: 6px;
}
.checks-page__history-item-head {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
font-size: 0.875rem;
}
.checks-page__history-meta,
.checks-page__history-note {
margin: 0;
font-size: 0.8125rem;
line-height: 1.5;
}
.checks-page__history-meta {
color: var(--color-text-muted);
}
.checks-page__lightbox {
position: fixed;
inset: 0;
z-index: 300000;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.84);
padding: 20px;
}
.checks-page__lightbox img {
max-width: min(900px, 100%);
max-height: 88vh;
object-fit: contain;
border-radius: 8px;
}
.checks-page__lightbox-close {
position: absolute;
top: 14px;
inset-inline-end: 14px;
border: 0;
border-radius: 8px;
padding: 8px 12px;
cursor: pointer;
}
@media (max-width: 800px) {
.checks-page__stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.check-row {
grid-template-columns: 1fr;
}
.check-row__actions {
padding-inline-start: 24px;
}
.check-row__panel {
padding-inline-start: 0;
}
}
</style>
+344
View File
@@ -0,0 +1,344 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import AdminSelect from '@/components/ui/AdminSelect.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { checksApi, ordersApi } from '@/services/api'
import { ApiError } from '@/services/http'
import { formatCurrency, formatDateTime } from '@/utils/format'
import { useToast } from '@/composables/useToast'
import type { Check } from '@/types'
const router = useRouter()
const toast = useToast()
const checkList = ref<Check[]>([])
const loading = ref(true)
const searchQuery = ref('')
const statusFilter = ref<'all' | Check['status']>('all')
const busyId = ref<string | null>(null)
const statusFilterOptions = [
{ value: 'all', label: 'همه وضعیت‌ها' },
{ value: 'pending_review', label: 'در انتظار بررسی' },
{ value: 'approved', label: 'تأیید شده' },
{ value: 'rejected', label: 'رد شده' },
]
const filteredChecks = computed(() => {
let list = checkList.value
if (statusFilter.value !== 'all') {
list = list.filter((c) => c.status === statusFilter.value)
}
const q = searchQuery.value.trim()
if (q) {
list = list.filter(
(c) =>
(c.orderNumber ?? '').includes(q) ||
(c.customerName ?? '').includes(q) ||
(c.bankName ?? '').includes(q) ||
(c.sayadId ?? '').includes(q),
)
}
return list
})
const pendingCount = computed(
() => checkList.value.filter((c) => c.status === 'pending_review').length,
)
function checkBadgeStatus(status: Check['status']) {
if (status === 'approved') return 'final_approval'
if (status === 'rejected') return 'needs_correction'
return 'pending_check_review'
}
async function loadChecksFromOrders() {
const orders = await ordersApi.list()
checkList.value = orders.flatMap((order) =>
(order.checks ?? []).map((check) => ({
...check,
orderNumber: order.orderNumber,
customerName: order.customerName,
orderStatus: order.status,
settlementType: order.settlementMethod ?? order.settlementType ?? null,
})),
)
}
async function loadChecks() {
loading.value = true
try {
checkList.value = await checksApi.list()
} catch {
try {
await loadChecksFromOrders()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری چک‌ها انجام نشد.')
}
} finally {
loading.value = false
}
}
onMounted(() => {
void loadChecks()
})
function openOrderChecks(check: Check) {
router.push(`/orders/${check.orderId}/checks`)
}
async function updateCheckStatus(check: Check, status: Check['status']) {
busyId.value = check.id
try {
const updated = await checksApi.updateStatus(check.id, { status })
const index = checkList.value.findIndex((c) => c.id === check.id)
if (index !== -1) {
checkList.value[index] = {
...checkList.value[index]!,
...updated,
orderNumber: check.orderNumber,
customerName: check.customerName,
orderStatus: check.orderStatus,
settlementType: check.settlementType,
}
}
toast.success(status === 'approved' ? 'چک تأیید شد.' : 'چک رد شد.')
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'به‌روزرسانی وضعیت چک انجام نشد.')
} finally {
busyId.value = null
}
}
</script>
<template>
<div class="checks-list-page">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">چک‌ها</h1>
</div>
<div class="checks-list-page__summary">
<span>{{ checkList.length }} چک</span>
<span v-if="pendingCount">{{ pendingCount }} در انتظار بررسی</span>
</div>
</div>
<div class="checks-list-page__filters admin-card">
<div class="checks-list-page__search">
<AdminIcon name="search" :size="18" />
<input
v-model="searchQuery"
type="search"
class="checks-list-page__search-input"
placeholder="جستجو در شماره سفارش، مشتری، بانک، صیاد..."
/>
</div>
<AdminSelect
v-model="statusFilter"
class="checks-list-page__status-select"
aria-label="فیلتر وضعیت چک"
:options="statusFilterOptions"
/>
</div>
<AdminPageSkeleton v-if="loading" variant="table" />
<template v-else>
<div class="checks-list-page__table admin-card desktop-only">
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>شماره سفارش</th>
<th>مشتری</th>
<th>ردیف</th>
<th>مبلغ</th>
<th>بانک</th>
<th>شناسه صیادی</th>
<th>سررسید</th>
<th>وضعیت</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="check in filteredChecks" :key="check.id">
<td>
<button
type="button"
class="checks-list-page__order-link"
@click="openOrderChecks(check)"
>
{{ check.orderNumber || check.orderId }}
</button>
</td>
<td>{{ check.customerName || '—' }}</td>
<td>{{ check.row }}</td>
<td>{{ formatCurrency(check.amount) }}</td>
<td>{{ check.bankName || '—' }}</td>
<td dir="ltr">{{ check.sayadId || '—' }}</td>
<td>{{ check.dueDate.slice(0, 10) }}</td>
<td><StatusBadge :status="checkBadgeStatus(check.status)" /></td>
<td>
<div class="admin-action-group">
<template v-if="check.status === 'pending_review'">
<button
type="button"
class="admin-btn admin-btn--outline-success admin-btn--xs"
:disabled="busyId === check.id"
@click="updateCheckStatus(check, 'approved')"
>
تأیید
</button>
<button
type="button"
class="admin-btn admin-btn--outline-danger admin-btn--xs"
:disabled="busyId === check.id"
@click="updateCheckStatus(check, 'rejected')"
>
رد
</button>
</template>
<button
type="button"
class="admin-btn admin-btn--outline admin-btn--xs"
@click="openOrderChecks(check)"
>
جزئیات سفارش
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="!filteredChecks.length" class="checks-list-page__empty">
چکی با این فیلتر یافت نشد.
</p>
</div>
<div class="compact-only admin-card-list">
<article v-for="check in filteredChecks" :key="check.id" class="admin-card-item">
<div class="admin-card-item__header">
<p class="admin-card-item__title">
{{ check.orderNumber || check.orderId }} — چک {{ check.row }}
</p>
<StatusBadge :status="checkBadgeStatus(check.status)" />
</div>
<div class="admin-card-item__meta">
<span>{{ check.customerName || '—' }}</span>
<span>{{ formatCurrency(check.amount) }}</span>
<span>{{ check.bankName || '—' }}</span>
<span>سررسید {{ check.dueDate.slice(0, 10) }}</span>
<span v-if="check.createdAt">ثبت {{ formatDateTime(check.createdAt) }}</span>
</div>
<div class="admin-card-item__actions">
<template v-if="check.status === 'pending_review'">
<button
type="button"
class="admin-btn admin-btn--outline-success admin-btn--xs"
:disabled="busyId === check.id"
@click="updateCheckStatus(check, 'approved')"
>
تأیید
</button>
<button
type="button"
class="admin-btn admin-btn--outline-danger admin-btn--xs"
:disabled="busyId === check.id"
@click="updateCheckStatus(check, 'rejected')"
>
رد
</button>
</template>
<button
type="button"
class="admin-btn admin-btn--outline admin-btn--xs"
@click="openOrderChecks(check)"
>
جزئیات سفارش
</button>
</div>
</article>
<p v-if="!filteredChecks.length" class="checks-list-page__empty">
چکی با این فیلتر یافت نشد.
</p>
</div>
</template>
</div>
</template>
<style scoped>
.checks-list-page__summary {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.checks-list-page__filters {
display: flex;
gap: 12px;
align-items: center;
padding: 12px 14px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.checks-list-page__search {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 220px;
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-border-light);
color: var(--color-text-muted);
}
.checks-list-page__search-input {
flex: 1;
border: none;
background: transparent;
outline: none;
font-size: 0.875rem;
color: var(--color-text);
}
.checks-list-page__status-select {
flex: 0 1 220px;
width: 220px;
}
.checks-list-page__table {
padding: 0;
overflow: hidden;
}
.checks-list-page__order-link {
border: none;
background: transparent;
color: var(--color-primary);
cursor: pointer;
padding: 0;
font: inherit;
}
.checks-list-page__order-link:hover {
text-decoration: underline;
}
.checks-list-page__empty {
margin: 0;
padding: 24px;
text-align: center;
color: var(--color-text-muted);
font-size: 0.875rem;
}
</style>
+290
View File
@@ -0,0 +1,290 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import BaseModal from '@/components/ui/BaseModal.vue'
import BaseSwitch from '@/components/ui/BaseSwitch.vue'
import BaseEmptyState from '@/components/ui/BaseEmptyState.vue'
import ActiveSwitchCell from '@/components/ui/ActiveSwitchCell.vue'
import { bankAccountsApi, companiesApi } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
import type { BankAccount, Company } from '@/types'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const company = ref<Company | null>(null)
const accountList = ref<BankAccount[]>([])
const showAddModal = ref(false)
const formError = ref('')
const saving = ref(false)
const companyId = computed(() => String(route.params.companyId))
const accounts = computed(() => accountList.value)
const emptyForm = () => ({
bankName: '',
accountNumber: '',
accountHolder: company.value?.name ?? '',
cardNumber: '',
iban: '',
isActive: true,
})
const form = reactive(emptyForm())
const isFormValid = computed(
() =>
form.bankName.trim().length > 0 &&
form.accountNumber.trim().length > 0 &&
form.accountHolder.trim().length > 0,
)
async function loadData() {
try {
const [companyData, accountsData] = await Promise.all([
companiesApi.get(companyId.value),
bankAccountsApi.listByCompany(companyId.value),
])
company.value = companyData
accountList.value = accountsData
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری حساب‌ها انجام نشد.')
}
}
onMounted(() => {
void loadData()
})
function openAddModal() {
Object.assign(form, emptyForm())
formError.value = ''
showAddModal.value = true
}
function closeAddModal() {
showAddModal.value = false
formError.value = ''
}
async function submitAddAccount() {
if (!isFormValid.value) {
formError.value = 'فیلدهای ستاره‌دار الزامی هستند.'
return
}
saving.value = true
try {
const created = await bankAccountsApi.create(companyId.value, {
bankName: form.bankName.trim(),
accountNumber: form.accountNumber.trim(),
accountHolder: form.accountHolder.trim(),
cardNumber: form.cardNumber.trim() || '-',
iban: form.iban.trim() || '-',
isActive: form.isActive,
displayOrder: accountList.value.length + 1,
})
accountList.value.push(created)
toast.success('حساب بانکی جدید ثبت شد.')
closeAddModal()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ثبت حساب انجام نشد.')
} finally {
saving.value = false
}
}
async function persistActive(id: string, isActive: boolean) {
await bankAccountsApi.setActive(id, isActive)
}
function goBack() {
router.push('/companies')
}
</script>
<template>
<div class="accounts-page">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">
مدیریت حساب‌های بانکی — {{ company?.name ?? '…' }}
</h1>
</div>
<div class="admin-action-group">
<button type="button" class="admin-btn admin-btn--primary" @click="openAddModal">
افزودن حساب
</button>
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm" @click="goBack">
بازگشت
</button>
</div>
</div>
<BaseEmptyState
v-if="!accounts.length"
class="admin-card"
icon="wallet"
title="حساب بانکی ثبت نشده"
description="برای این شرکت هنوز حساب بانکی ثبت نشده است."
>
<button type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="openAddModal">
افزودن حساب
</button>
</BaseEmptyState>
<div v-else class="accounts-page__table admin-card desktop-only">
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>بانک</th>
<th>حساب بانکی</th>
<th>نام صاحب</th>
<th>شماره کارت</th>
<th>شماره شبا</th>
<th class="status">وضعیت</th>
</tr>
</thead>
<tbody>
<tr v-for="account in accounts" :key="account.id">
<td>{{ account.bankName }}</td>
<td>{{ account.accountNumber }}</td>
<td>{{ account.accountHolder }}</td>
<td>{{ account.cardNumber || '—' }}</td>
<td>{{ account.iban || '—' }}</td>
<td class="status">
<ActiveSwitchCell
:id="account.id"
v-model="account.isActive"
:entity-name="account.bankName"
entity-type="حساب بانکی"
:persist="persistActive"
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="accounts.length" class="compact-only admin-card-list">
<article v-for="account in accounts" :key="account.id" class="admin-card-item">
<div class="admin-card-item__header">
<p class="admin-card-item__title">بانک {{ account.bankName }}</p>
<ActiveSwitchCell
:id="account.id"
v-model="account.isActive"
:entity-name="account.bankName"
entity-type="حساب بانکی"
:persist="persistActive"
/>
</div>
<div class="admin-card-item__meta">
<span>{{ account.accountHolder }}</span>
<span>حساب: {{ account.accountNumber }}</span>
<span v-if="account.cardNumber">کارت: {{ account.cardNumber }}</span>
<span v-if="account.iban" dir="ltr">{{ account.iban }}</span>
</div>
</article>
</div>
<BaseModal :open="showAddModal" title="افزودن حساب بانکی" size="lg" @close="closeAddModal">
<form class="admin-form" @submit.prevent="submitAddAccount">
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="bank-name">نام بانک <span class="admin-form__required">*</span></label>
<input
id="bank-name"
v-model="form.bankName"
type="text"
class="admin-input"
placeholder="مثال: ملت"
/>
</div>
<div class="admin-form__field">
<label for="account-number">
شماره حساب <span class="admin-form__required">*</span>
</label>
<input
id="account-number"
v-model="form.accountNumber"
type="text"
class="admin-input"
placeholder="1234567890"
inputmode="numeric"
dir="ltr"
/>
</div>
</div>
<div class="admin-form__field">
<label for="account-holder">
نام صاحب حساب <span class="admin-form__required">*</span>
</label>
<input
id="account-holder"
v-model="form.accountHolder"
type="text"
class="admin-input"
/>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="card-number">شماره کارت</label>
<input
id="card-number"
v-model="form.cardNumber"
type="text"
class="admin-input"
placeholder="6104 3378 0077 1234"
inputmode="numeric"
dir="ltr"
/>
</div>
<div class="admin-form__field">
<label for="iban">شماره شبا</label>
<input
id="iban"
v-model="form.iban"
type="text"
class="admin-input"
placeholder="IR..."
dir="ltr"
/>
</div>
</div>
<div class="admin-form__field">
<BaseSwitch v-model="form.isActive" label="وضعیت" />
</div>
</form>
<template #footer>
<button type="button" class="admin-btn admin-btn--outline" @click="closeAddModal">
انصراف
</button>
<button
type="button"
class="admin-btn admin-btn--primary"
:disabled="!isFormValid || saving"
@click="submitAddAccount"
>
ثبت حساب
</button>
</template>
</BaseModal>
</div>
</template>
<style scoped>
.accounts-page__table {
overflow: hidden;
}
</style>
+550
View File
@@ -0,0 +1,550 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import BaseModal from '@/components/ui/BaseModal.vue'
import BaseSwitch from '@/components/ui/BaseSwitch.vue'
import BaseEmptyState from '@/components/ui/BaseEmptyState.vue'
import ActiveSwitchCell from '@/components/ui/ActiveSwitchCell.vue'
import { companiesApi, slugifyCompanyName } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
import { confirm } from '@/composables/useConfirm'
import type { Company } from '@/types'
const MAX_LOGO_SIZE = 2 * 1024 * 1024
const ACCEPTED_LOGO_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']
const router = useRouter()
const toast = useToast()
const companyList = ref<Company[]>([])
const loading = ref(true)
const modalMode = ref<'add' | 'edit' | null>(null)
const editingCompanyId = ref<string | null>(null)
const logoPreview = ref<string | null>(null)
const logoInputRef = ref<HTMLInputElement | null>(null)
const saving = ref(false)
async function loadCompanies() {
loading.value = true
try {
companyList.value = await companiesApi.list()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری شرکت‌ها انجام نشد.')
} finally {
loading.value = false
}
}
onMounted(() => {
void loadCompanies()
})
const emptyForm = () => ({
name: '',
description: '',
displayOrder: companyList.value.length + 1,
isActive: true,
})
const form = reactive(emptyForm())
const formError = ref('')
const isModalOpen = computed(() => modalMode.value !== null)
const modalTitle = computed(() => (modalMode.value === 'edit' ? 'ویرایش شرکت' : 'افزودن شرکت'))
const submitLabel = computed(() => (modalMode.value === 'edit' ? 'ذخیره تغییرات' : 'ثبت شرکت'))
const isFormValid = computed(() => form.name.trim().length > 0 && logoPreview.value !== null)
function resetLogo() {
logoPreview.value = null
if (logoInputRef.value) {
logoInputRef.value.value = ''
}
}
function openAddModal() {
modalMode.value = 'add'
editingCompanyId.value = null
Object.assign(form, emptyForm())
resetLogo()
formError.value = ''
}
function openEditModal(company: Company) {
modalMode.value = 'edit'
editingCompanyId.value = company.id
form.name = company.name
form.description = company.description === '—' ? '' : company.description
form.displayOrder = company.displayOrder
form.isActive = company.isActive
logoPreview.value = company.logoUrl
if (logoInputRef.value) {
logoInputRef.value.value = ''
}
formError.value = ''
}
function closeModal() {
modalMode.value = null
editingCompanyId.value = null
formError.value = ''
resetLogo()
}
function onLogoSelect(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
if (!ACCEPTED_LOGO_TYPES.includes(file.type)) {
formError.value = 'فرمت مجاز: PNG، JPG، WEBP یا SVG'
return
}
if (file.size > MAX_LOGO_SIZE) {
formError.value = 'حداکثر حجم تصویر ۲ مگابایت است.'
return
}
formError.value = ''
const reader = new FileReader()
reader.onload = () => {
logoPreview.value = reader.result as string
}
reader.readAsDataURL(file)
}
async function removeLogo() {
if (!logoPreview.value) return
const ok = await confirm({
title: 'حذف تصویر',
message: 'آیا از حذف لوگوی شرکت مطمئن هستید؟',
confirmLabel: 'حذف',
cancelLabel: 'انصراف',
tone: 'danger',
})
if (!ok) return
resetLogo()
}
async function submitCompany() {
const name = form.name.trim()
if (!name) {
formError.value = 'نام شرکت الزامی است.'
return
}
if (!logoPreview.value) {
formError.value = 'آپلود لوگو الزامی است.'
return
}
const duplicate = companyList.value.some(
(c) => c.name === name && c.id !== editingCompanyId.value,
)
if (duplicate) {
formError.value = 'شرکتی با این نام قبلاً ثبت شده است.'
return
}
saving.value = true
try {
if (modalMode.value === 'edit' && editingCompanyId.value) {
const updated = await companiesApi.update(editingCompanyId.value, {
name,
description: form.description.trim() || undefined,
displayOrder: Number(form.displayOrder) || 1,
isActive: form.isActive,
logoUrl: logoPreview.value,
})
const index = companyList.value.findIndex((c) => c.id === editingCompanyId.value)
if (index !== -1) companyList.value[index] = { ...companyList.value[index]!, ...updated }
toast.success('اطلاعات شرکت به‌روزرسانی شد.')
} else {
const created = await companiesApi.create({
name,
slug: slugifyCompanyName(name),
description: form.description.trim() || undefined,
displayOrder: Number(form.displayOrder) || companyList.value.length + 1,
isActive: form.isActive,
logoUrl: logoPreview.value,
})
companyList.value.push(created)
toast.success('شرکت جدید ثبت شد.')
}
closeModal()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ذخیره شرکت انجام نشد.')
} finally {
saving.value = false
}
}
async function persistActive(id: string, isActive: boolean) {
await companiesApi.setActive(id, isActive)
}
function goAccounts(companyId: string) {
router.push(`/companies/${companyId}/accounts`)
}
function companyInitial(name: string) {
return name.trim().charAt(0) || '؟'
}
</script>
<template>
<div class="companies-page">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">مدیریت شرکت‌ها</h1>
</div>
<button type="button" class="admin-btn admin-btn--primary" @click="openAddModal">
افزودن شرکت
</button>
</div>
<AdminPageSkeleton v-if="loading" variant="table" />
<BaseEmptyState
v-else-if="!companyList.length"
class="admin-card"
icon="companies"
title="شرکتی ثبت نشده"
description="برای شروع، اولین شرکت را ثبت کنید."
>
<button type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="openAddModal">
افزودن شرکت
</button>
</BaseEmptyState>
<div v-else class="companies-page__table admin-card desktop-only">
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>لوگو</th>
<th class="numeric">ترتیب</th>
<th class="status">وضعیت</th>
<th>توضیحات</th>
<th>نام شرکت</th>
<th class="actions">عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="company in companyList" :key="company.id">
<td>
<img
v-if="company.logoUrl"
:src="company.logoUrl"
:alt="`لوگوی ${company.name}`"
class="company-logo"
/>
<div v-else class="company-logo company-logo--fallback">
{{ companyInitial(company.name) }}
</div>
</td>
<td class="numeric">{{ company.displayOrder }}</td>
<td class="status">
<ActiveSwitchCell
:id="company.id"
v-model="company.isActive"
:entity-name="company.name"
entity-type="شرکت"
:persist="persistActive"
/>
</td>
<td>{{ company.description }}</td>
<td>{{ company.name }}</td>
<td class="actions">
<div class="admin-action-group">
<button
type="button"
class="admin-btn admin-btn--ghost admin-btn--xs"
@click="openEditModal(company)"
>
ویرایش
</button>
<button
type="button"
class="admin-btn admin-btn--outline admin-btn--xs"
@click="goAccounts(company.id)"
>
حساب‌ها
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="!loading && companyList.length" class="compact-only admin-card-list">
<article v-for="company in companyList" :key="company.id" class="admin-card-item">
<div class="admin-card-item__header">
<div class="companies-page__card-logo">
<img
v-if="company.logoUrl"
:src="company.logoUrl"
:alt="`لوگوی ${company.name}`"
class="company-logo"
/>
<div v-else class="company-logo company-logo--fallback">
{{ companyInitial(company.name) }}
</div>
<div>
<p class="admin-card-item__title">{{ company.name }}</p>
<p class="companies-page__card-desc">{{ company.description }}</p>
</div>
</div>
<ActiveSwitchCell
:id="company.id"
v-model="company.isActive"
:entity-name="company.name"
entity-type="شرکت"
:persist="persistActive"
/>
</div>
<div class="admin-card-item__meta">
<span>ترتیب: {{ company.displayOrder }}</span>
</div>
<div class="admin-card-item__actions">
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(company)">
ویرایش
</button>
<button type="button" class="admin-btn admin-btn--outline admin-btn--xs" @click="goAccounts(company.id)">
حساب‌ها
</button>
</div>
</article>
</div>
<BaseModal :open="isModalOpen" :title="modalTitle" @close="closeModal">
<form class="admin-form" @submit.prevent="submitCompany">
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
<div class="admin-form__field">
<label>لوگو <span class="admin-form__required">*</span></label>
<div class="logo-upload">
<div v-if="logoPreview" class="logo-upload__preview-wrap">
<img :src="logoPreview" alt="پیش‌نمایش لوگو" class="logo-upload__preview" />
<button type="button" class="logo-upload__remove" @click="removeLogo">
حذف تصویر
</button>
</div>
<label v-else class="logo-upload__dropzone" for="company-logo-file">
<span class="logo-upload__icon">+</span>
<span class="logo-upload__text">انتخاب یا رها کردن تصویر لوگو</span>
<span class="logo-upload__hint">PNG، JPG، WEBP یا SVG — حداکثر ۲ مگابایت</span>
</label>
<input
id="company-logo-file"
ref="logoInputRef"
type="file"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
class="logo-upload__input"
@change="onLogoSelect"
/>
<button
v-if="logoPreview"
type="button"
class="admin-btn admin-btn--outline admin-btn--sm logo-upload__change"
@click="logoInputRef?.click()"
>
تغییر تصویر
</button>
</div>
</div>
<div class="admin-form__field">
<label for="company-name">نام شرکت <span class="admin-form__required">*</span></label>
<input
id="company-name"
v-model="form.name"
type="text"
class="admin-input"
placeholder="مثال: شرکت پَر"
autocomplete="off"
/>
</div>
<div class="admin-form__field">
<label for="company-description">توضیحات</label>
<textarea
id="company-description"
v-model="form.description"
class="admin-form__textarea"
placeholder="تولید و عرضه انواع پارچه"
rows="3"
/>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="company-order">ترتیب نمایش</label>
<input
id="company-order"
v-model.number="form.displayOrder"
type="number"
min="1"
class="admin-input"
/>
</div>
<div class="admin-form__field">
<BaseSwitch v-model="form.isActive" label="وضعیت" />
</div>
</div>
</form>
<template #footer>
<button type="button" class="admin-btn admin-btn--outline" @click="closeModal">
انصراف
</button>
<button
type="button"
class="admin-btn admin-btn--primary"
:disabled="!isFormValid || saving"
@click="submitCompany"
>
{{ submitLabel }}
</button>
</template>
</BaseModal>
</div>
</template>
<style scoped>
.companies-page__table {
overflow: hidden;
}
.companies-page__card-logo {
display: flex;
align-items: center;
gap: 12px;
}
.companies-page__card-desc {
margin: 4px 0 0;
font-size: 0.8125rem;
color: var(--color-text-muted);
font-weight: 400;
}
.company-logo {
width: 40px;
height: 40px;
border-radius: var(--radius-sm);
object-fit: contain;
background: var(--color-bg);
border: 1px solid var(--color-border);
}
.company-logo--fallback {
display: flex;
align-items: center;
justify-content: center;
background: var(--color-primary);
color: #fff;
font-weight: 700;
font-size: 1rem;
}
.logo-upload {
display: flex;
flex-direction: column;
gap: 10px;
}
.logo-upload__input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.logo-upload__dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 28px 16px;
border: 2px dashed var(--color-border);
border-radius: var(--radius-md);
background: var(--color-border-light);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.logo-upload__dropzone:hover {
border-color: var(--color-primary);
background: var(--color-info-light);
}
.logo-upload__icon {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--color-surface);
border: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: var(--color-text-muted);
line-height: 1;
}
.logo-upload__text {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text);
}
.logo-upload__hint {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.logo-upload__preview-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 16px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-border-light);
}
.logo-upload__preview {
width: 80px;
height: 80px;
object-fit: contain;
border-radius: var(--radius-sm);
background: var(--color-surface);
border: 1px solid var(--color-border);
}
.logo-upload__remove {
border: none;
background: none;
color: var(--color-danger);
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
padding: 0;
}
.logo-upload__remove:hover {
text-decoration: underline;
}
.logo-upload__change {
align-self: flex-start;
}
</style>
+498
View File
@@ -0,0 +1,498 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import BaseModal from '@/components/ui/BaseModal.vue'
import BaseSwitch from '@/components/ui/BaseSwitch.vue'
import BaseEmptyState from '@/components/ui/BaseEmptyState.vue'
import ActiveSwitchCell from '@/components/ui/ActiveSwitchCell.vue'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import AdminSelect from '@/components/ui/AdminSelect.vue'
import { brandsApi, customersApi, levelsApi, type Brand, type CustomerLevel } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
import { CUSTOMER_TYPE_LABELS } from '@/utils/adminLabels'
import { formatDateTime, formatMeterage } from '@/utils/format'
import type { Customer, CustomerType } from '@/types'
const customerList = ref<Customer[]>([])
const levels = ref<CustomerLevel[]>([])
const agents = ref<Customer[]>([])
const brands = ref<Brand[]>([])
const searchQuery = ref('')
const loading = ref(true)
const modalMode = ref<'add' | 'edit' | null>(null)
const editingId = ref<string | null>(null)
const formError = ref('')
const saving = ref(false)
const toast = useToast()
function displayCell(value: string | number | null | undefined): string {
if (value == null) return '-'
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : '-'
const text = value.trim()
if (!text || text === '—') return '-'
return text
}
function displayMeterage(value: number | null | undefined): string {
if (value == null || !Number.isFinite(value)) return '-'
return formatMeterage(value)
}
const customerTypeOptions = [
{ value: 'wholesale', label: 'عمده‌فروش' },
{ value: 'retail', label: 'خرده‌فروش' },
]
const appRoleOptions = [
{ value: 'customer', label: 'مشتری' },
{ value: 'agent', label: 'واسطه' },
]
const levelOptions = computed(() => [
{ value: '', label: 'بدون سطح' },
...levels.value.map((level) => ({ value: level.id, label: level.label })),
])
const agentOptions = computed(() => [
{ value: '', label: 'بدون واسطه' },
...agents.value
.filter((agent) => agent.id !== editingId.value)
.map((agent) => ({
value: agent.id,
label: agent.name + (agent.agentCode ? ` (${agent.agentCode})` : ''),
})),
])
const emptyForm = () => ({
name: '',
phone: '',
nationalId: '',
customerType: 'wholesale' as CustomerType,
companyName: '',
isActive: true,
levelDefId: '' as string,
assignedAgentId: '' as string,
brandIds: [] as string[],
appRole: 'customer' as 'customer' | 'agent',
agentCode: '',
})
const form = reactive(emptyForm())
const isModalOpen = computed(() => modalMode.value !== null)
const modalTitle = computed(() => (modalMode.value === 'edit' ? 'ویرایش مشتری' : 'افزودن مشتری'))
const isFormValid = computed(
() => form.name.trim() && form.phone.trim() && form.nationalId.trim(),
)
const filteredCustomers = computed(() => {
const q = searchQuery.value.trim()
if (!q) return customerList.value
return customerList.value.filter(
(c) =>
c.name.includes(q) ||
c.phone.includes(q) ||
c.nationalId.includes(q) ||
c.companyName.includes(q),
)
})
function toggleBrand(brandId: string, checked: boolean) {
if (checked) {
if (!form.brandIds.includes(brandId)) form.brandIds.push(brandId)
} else {
form.brandIds = form.brandIds.filter((id) => id !== brandId)
}
}
async function loadCustomers() {
loading.value = true
try {
customerList.value = await customersApi.list()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری مشتریان انجام نشد.')
} finally {
loading.value = false
}
}
async function loadLookups() {
try {
const [levelsData, agentsData, brandsData] = await Promise.all([
levelsApi.list(),
customersApi.list(undefined, 'agent'),
brandsApi.list(),
])
levels.value = levelsData
agents.value = agentsData
brands.value = brandsData
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری اطلاعات کمکی انجام نشد.')
}
}
onMounted(() => {
void loadCustomers()
void loadLookups()
})
function openAddModal() {
modalMode.value = 'add'
editingId.value = null
Object.assign(form, emptyForm())
formError.value = ''
}
function openEditModal(customer: Customer) {
modalMode.value = 'edit'
editingId.value = customer.id
form.name = customer.name
form.phone = customer.phone
form.nationalId = customer.nationalId
form.customerType = customer.customerType
form.companyName = customer.companyName
form.isActive = customer.isActive
form.levelDefId = customer.levelDefId ?? ''
form.assignedAgentId = customer.assignedAgentId ?? ''
form.brandIds = [...(customer.brandIds ?? [])]
form.appRole = customer.appRole === 'agent' ? 'agent' : 'customer'
form.agentCode = customer.agentCode ?? ''
formError.value = ''
}
function closeModal() {
modalMode.value = null
editingId.value = null
formError.value = ''
}
async function submitCustomer() {
if (!isFormValid.value) {
formError.value = 'نام، تلفن و شناسه ملی الزامی هستند.'
return
}
saving.value = true
const payload = {
name: form.name.trim(),
phone: form.phone.trim(),
nationalId: form.nationalId.trim(),
customerType: form.customerType,
isActive: form.isActive,
levelDefId: form.levelDefId || null,
assignedAgentId: form.assignedAgentId || null,
brandIds: [...form.brandIds],
appRole: form.appRole,
agentCode: form.appRole === 'agent' ? form.agentCode.trim() || null : null,
}
try {
if (modalMode.value === 'edit' && editingId.value) {
const updated = await customersApi.update(editingId.value, payload)
const index = customerList.value.findIndex((c) => c.id === editingId.value)
if (index !== -1) customerList.value[index] = { ...customerList.value[index]!, ...updated }
} else {
const created = await customersApi.create(payload)
customerList.value.unshift(created)
}
toast.success(modalMode.value === 'edit' ? 'اطلاعات مشتری به‌روزرسانی شد.' : 'مشتری جدید ثبت شد.')
closeModal()
if (payload.appRole === 'agent') void loadLookups()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ذخیره مشتری انجام نشد.')
} finally {
saving.value = false
}
}
async function persistActive(id: string, isActive: boolean) {
await customersApi.setActive(id, isActive)
}
function clearSearch() {
searchQuery.value = ''
}
</script>
<template>
<div class="customers-page">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">مشتریان</h1>
</div>
<button type="button" class="admin-btn admin-btn--primary" @click="openAddModal">
افزودن مشتری
</button>
</div>
<div class="customers-page__search admin-card">
<AdminIcon name="search" :size="18" />
<input
v-model="searchQuery"
type="search"
class="customers-page__search-input"
placeholder="جستجو در نام، تلفن، شناسه ملی..."
/>
</div>
<AdminPageSkeleton v-if="loading" variant="table" />
<BaseEmptyState
v-else-if="!filteredCustomers.length"
class="admin-card"
icon="customers"
:title="searchQuery.trim() ? 'نتیجه‌ای یافت نشد' : 'مشتریان ثبت شده'"
:description="
searchQuery.trim()
? 'عبارت جستجو را تغییر دهید یا فیلترها را پاک کنید.'
: 'برای شروع، اولین مشتری را ثبت کنید.'
"
>
<button v-if="searchQuery.trim()" type="button" class="admin-btn admin-btn--outline admin-btn--sm" @click="clearSearch">
پاک کردن جستجو
</button>
<button v-else type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="openAddModal">
افزودن مشتری
</button>
</BaseEmptyState>
<div v-else class="customers-page__table admin-card desktop-only">
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>نام مشتری</th>
<th>تلفن</th>
<th>شناسه ملی</th>
<th>نوع</th>
<th>سطح</th>
<th>واسطه</th>
<th>شرکت</th>
<th class="numeric">تعداد سفارش</th>
<th class="numeric">مجموع متراژ</th>
<th class="status">وضعیت</th>
<th>تاریخ ثبت</th>
<th class="actions">عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="customer in filteredCustomers" :key="customer.id">
<td>{{ displayCell(customer.name) }}</td>
<td>{{ displayCell(customer.phone) }}</td>
<td>{{ displayCell(customer.nationalId) }}</td>
<td>{{ displayCell(CUSTOMER_TYPE_LABELS[customer.customerType]) }}</td>
<td>{{ displayCell(customer.levelLabel) }}</td>
<td>{{ displayCell(customer.assignedAgentName) }}</td>
<td>{{ displayCell(customer.companyName) }}</td>
<td class="numeric">{{ displayCell(customer.orderCount) }}</td>
<td class="numeric">{{ displayMeterage(customer.totalMeterage) }}</td>
<td class="status">
<ActiveSwitchCell
:id="customer.id"
v-model="customer.isActive"
:entity-name="customer.name"
entity-type="مشتری"
:persist="persistActive"
/>
</td>
<td>{{ displayCell(formatDateTime(customer.registeredAt)) }}</td>
<td class="actions">
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(customer)">
ویرایش
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="!loading && filteredCustomers.length" class="compact-only admin-card-list">
<article v-for="customer in filteredCustomers" :key="customer.id" class="admin-card-item">
<div class="admin-card-item__header">
<p class="admin-card-item__title">{{ displayCell(customer.name) }}</p>
<div class="admin-card-item__header-actions">
<ActiveSwitchCell
:id="customer.id"
v-model="customer.isActive"
:entity-name="customer.name"
entity-type="مشتری"
:persist="persistActive"
/>
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(customer)">
ویرایش
</button>
</div>
</div>
<div class="admin-card-item__meta">
<span>{{ displayCell(customer.phone) }}</span>
<span>{{ displayCell(CUSTOMER_TYPE_LABELS[customer.customerType]) }}</span>
<span>{{ displayCell(customer.levelLabel) }}</span>
<span>{{ displayCell(customer.assignedAgentName) }}</span>
<span>{{ displayCell(customer.companyName) }}</span>
<span>
{{ displayCell(customer.orderCount) }} سفارش · {{ displayMeterage(customer.totalMeterage) }}
</span>
</div>
</article>
</div>
<BaseModal :open="isModalOpen" :title="modalTitle" size="lg" @close="closeModal">
<form class="admin-form" @submit.prevent="submitCustomer">
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
<div class="admin-form__field">
<label for="customer-name">نام مشتری <span class="admin-form__required">*</span></label>
<input id="customer-name" v-model="form.name" type="text" class="admin-input" />
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="customer-phone">تلفن <span class="admin-form__required">*</span></label>
<input
id="customer-phone"
v-model="form.phone"
type="tel"
class="admin-input"
inputmode="tel"
dir="ltr"
/>
</div>
<div class="admin-form__field">
<label for="customer-national-id">شناسه ملی <span class="admin-form__required">*</span></label>
<input
id="customer-national-id"
v-model="form.nationalId"
type="text"
class="admin-input"
inputmode="numeric"
dir="ltr"
/>
</div>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label>نوع مشتری</label>
<AdminSelect
v-model="form.customerType"
block
aria-label="نوع مشتری"
:options="customerTypeOptions"
/>
</div>
<div class="admin-form__field">
<label for="customer-company">شرکت مرتبط</label>
<input id="customer-company" v-model="form.companyName" type="text" class="admin-input" />
</div>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label>سطح</label>
<AdminSelect
v-model="form.levelDefId"
block
aria-label="سطح مشتری"
:options="levelOptions"
/>
</div>
<div class="admin-form__field">
<label>واسطه</label>
<AdminSelect
v-model="form.assignedAgentId"
block
aria-label="واسطه اختصاص‌یافته"
:options="agentOptions"
/>
</div>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label>نقش در اپ</label>
<AdminSelect
v-model="form.appRole"
block
aria-label="نقش در اپ"
:options="appRoleOptions"
/>
</div>
<div v-if="form.appRole === 'agent'" class="admin-form__field">
<label for="customer-agent-code">کد واسطه</label>
<input id="customer-agent-code" v-model="form.agentCode" type="text" class="admin-input" dir="ltr" />
</div>
</div>
<div v-if="brands.length" class="admin-form__field">
<label>برندها</label>
<div class="customers-page__brands">
<label v-for="brand in brands" :key="brand.id" class="customers-page__brand">
<input
type="checkbox"
:checked="form.brandIds.includes(brand.id)"
@change="toggleBrand(brand.id, ($event.target as HTMLInputElement).checked)"
/>
<span>{{ brand.name }}</span>
</label>
</div>
</div>
<div class="admin-form__field">
<BaseSwitch v-model="form.isActive" label="وضعیت" />
</div>
</form>
<template #footer>
<button type="button" class="admin-btn admin-btn--outline" @click="closeModal">انصراف</button>
<button type="button" class="admin-btn admin-btn--primary" :disabled="!isFormValid || saving" @click="submitCustomer">
<span v-if="saving" class="admin-btn__spinner" aria-hidden="true" />
{{ modalMode === 'edit' ? 'ذخیره تغییرات' : 'ثبت مشتری' }}
</button>
</template>
</BaseModal>
</div>
</template>
<style scoped>
.customers-page__search {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
margin-bottom: 16px;
color: var(--color-text-muted);
}
.customers-page__search-input {
flex: 1;
border: none;
background: transparent;
font-size: 0.875rem;
outline: none;
}
.customers-page__table {
overflow: hidden;
}
.customers-page__brands {
display: flex;
flex-wrap: wrap;
gap: 10px 16px;
}
.customers-page__brand {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.875rem;
cursor: pointer;
}
.customers-page__brand input {
width: 16px;
height: 16px;
accent-color: var(--color-primary);
}
</style>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import DesktopDashboard from '@/views/dashboard/components/DesktopDashboard.vue'
import CompactDashboard from '@/views/dashboard/components/CompactDashboard.vue'
</script>
<template>
<DesktopDashboard class="desktop-only" />
<CompactDashboard class="compact-only" />
</template>
@@ -0,0 +1,116 @@
<script setup lang="ts">
import AdminIcon from '@/components/icons/AdminIcon.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import SalesAreaChart from '@/components/charts/SalesAreaChart.vue'
import { iconBg, iconColor, useDashboard } from '@/composables/useDashboard'
import { formatCurrency, formatDateTime } from '@/utils/format'
const { checkDeadlines, dashboardStats, recentOrders, salesChartData, chartAxisLabels, goOrders, statCards } =
useDashboard()
</script>
<template>
<div class="compact-dashboard page-shell">
<div class="flex justify-end mb-3">
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm">
<AdminIcon name="calendar" :size="16" />
امروز
</button>
</div>
<div class="compact-stats-grid">
<div v-for="card in statCards" :key="card.label" class="admin-stat-card">
<div
class="admin-stat-card__icon"
:style="{ background: iconBg[card.tone], color: iconColor[card.tone] }"
>
<AdminIcon :name="card.icon" :size="20" />
</div>
<p class="admin-stat-card__label">{{ card.label }}</p>
<p class="admin-stat-card__value">{{ card.value }}</p>
<RouterLink :to="card.link" class="admin-stat-card__link">مشاهده</RouterLink>
</div>
<div class="admin-stat-card col-span-full">
<p class="admin-stat-card__label">فروش امروز</p>
<p class="admin-stat-card__value">{{ formatCurrency(dashboardStats.todaySales) }}</p>
<span class="text-xs font-semibold text-success">
+{{ dashboardStats.todaySalesGrowth }}% نسبت به دیروز
</span>
</div>
</div>
<div class="compact-section-grid">
<section class="compact-section-card admin-panel">
<SalesAreaChart
title="فروش ماه جاری"
:subtitle="formatCurrency(dashboardStats.monthSales)"
:data="salesChartData"
:labels="chartAxisLabels"
unit="میلیارد"
:height="140"
/>
</section>
<section class="compact-section-card admin-panel">
<h2 class="compact-section-card__title">سفارش‌های اخیر</h2>
<div class="admin-card-list">
<RouterLink
v-for="order in recentOrders"
:key="order.id"
to="/orders"
class="admin-card-item block no-underline text-inherit"
>
<div class="admin-card-item__header">
<p class="admin-card-item__title">{{ order.orderNumber }}</p>
<StatusBadge :status="order.status" />
</div>
<div class="admin-card-item__meta">
<span>{{ order.customerName }}</span>
<span>{{ formatDateTime(order.createdAt) }}</span>
</div>
</RouterLink>
</div>
<button
type="button"
class="admin-btn admin-btn--primary admin-btn--sm inline-flex mt-3"
@click="goOrders"
>
مشاهده همه سفارش‌ها
<AdminIcon name="chevron-left" :size="16" />
</button>
</section>
<section class="compact-section-card admin-panel">
<h2 class="compact-section-card__title">سررسید چک‌ها</h2>
<ul class="m-0 p-0 list-none flex flex-col gap-2.5">
<li
v-for="item in checkDeadlines"
:key="item.id"
class="flex flex-wrap items-center gap-2 text-sm"
>
<span
class="admin-badge"
:class="{
'admin-badge--danger': item.tone === 'danger',
'admin-badge--warning': item.tone === 'warning',
'admin-badge--neutral': item.tone === 'neutral',
}"
>
{{ item.label }}
</span>
<span>{{ item.count }} فقره</span>
<strong class="mr-auto text-xs">{{ formatCurrency(item.amount) }}</strong>
</li>
</ul>
<RouterLink
to="/checks"
class="admin-btn admin-btn--outline admin-btn--sm inline-flex mt-3 no-underline"
>
مشاهده همه
<AdminIcon name="chevron-left" :size="16" />
</RouterLink>
</section>
</div>
</div>
</template>
@@ -0,0 +1,257 @@
<script setup lang="ts">
import AdminIcon from '@/components/icons/AdminIcon.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import SalesAreaChart from '@/components/charts/SalesAreaChart.vue'
import { iconBg, iconColor, useDashboard } from '@/composables/useDashboard'
import { formatCurrency, formatDateTime } from '@/utils/format'
const { checkDeadlines, dashboardStats, recentOrders, salesChartData, chartAxisLabels, goOrders, statCards } =
useDashboard()
</script>
<template>
<div class="dashboard">
<div class="dashboard__toolbar">
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm">
<AdminIcon name="calendar" :size="16" />
امروز
</button>
</div>
<div class="dashboard__stats">
<div v-for="card in statCards" :key="card.label" class="admin-stat-card">
<div
class="admin-stat-card__icon"
:style="{ background: iconBg[card.tone], color: iconColor[card.tone] }"
>
<AdminIcon :name="card.icon" :size="20" />
</div>
<p class="admin-stat-card__label">{{ card.label }}</p>
<p class="admin-stat-card__value">{{ card.value }}</p>
<RouterLink :to="card.link" class="admin-stat-card__link">مشاهده سفارش‌ها</RouterLink>
</div>
<div class="dashboard__sales-card admin-stat-card">
<div class="dashboard__sales-header">
<div>
<p class="admin-stat-card__label">فروش امروز</p>
<p class="admin-stat-card__value dashboard__sales-value">
{{ formatCurrency(dashboardStats.todaySales) }}
</p>
<span class="dashboard__sales-growth">
+{{ dashboardStats.todaySalesGrowth }}% نسبت به دیروز
</span>
</div>
<AdminIcon name="calendar" :size="20" class="dashboard__sales-icon" />
</div>
<svg class="dashboard__sparkline" viewBox="0 0 120 32" preserveAspectRatio="none">
<polyline
fill="none"
stroke="#159660"
stroke-width="2"
points="0,24 20,18 40,22 60,12 80,16 100,8 120,14"
/>
</svg>
</div>
</div>
<div class="dashboard__grid">
<div class="dashboard__chart-card admin-card">
<SalesAreaChart
title="فروش ماه جاری"
:subtitle="formatCurrency(dashboardStats.monthSales)"
:data="salesChartData"
:labels="chartAxisLabels"
unit="میلیارد"
:height="168"
/>
</div>
<div class="dashboard__orders-card admin-card">
<div class="dashboard__card-header">
<h2 class="dashboard__card-title">سفارش‌های اخیر</h2>
</div>
<div class="admin-table-wrap">
<table class="admin-table admin-table--compact">
<thead>
<tr>
<th>شماره سفارش</th>
<th>مشتری</th>
<th>شرکت</th>
<th>وضعیت</th>
<th>تاریخ ثبت</th>
</tr>
</thead>
<tbody>
<tr v-for="order in recentOrders" :key="order.id">
<td>{{ order.orderNumber }}</td>
<td>{{ order.customerName }}</td>
<td>{{ order.companyName }}</td>
<td><StatusBadge :status="order.status" /></td>
<td>{{ formatDateTime(order.createdAt) }}</td>
</tr>
</tbody>
</table>
</div>
<div class="dashboard__card-footer">
<button type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="goOrders">
مشاهده همه سفارش‌ها
<AdminIcon name="chevron-left" :size="16" />
</button>
</div>
</div>
<div class="dashboard__checks-card admin-card">
<div class="dashboard__card-header">
<h2 class="dashboard__card-title">سررسید چک‌ها</h2>
</div>
<ul class="dashboard__deadline-list">
<li v-for="item in checkDeadlines" :key="item.id" class="dashboard__deadline-item">
<span
class="admin-badge"
:class="{
'admin-badge--danger': item.tone === 'danger',
'admin-badge--warning': item.tone === 'warning',
'admin-badge--neutral': item.tone === 'neutral',
}"
>
{{ item.label }}
</span>
<span class="dashboard__deadline-count">{{ item.count }} فقره</span>
<span class="dashboard__deadline-amount">{{ formatCurrency(item.amount) }}</span>
</li>
</ul>
<div class="dashboard__card-footer">
<RouterLink to="/checks" class="admin-btn admin-btn--outline admin-btn--sm">
مشاهده همه
<AdminIcon name="chevron-left" :size="16" />
</RouterLink>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.dashboard__toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 16px;
}
.dashboard__stats {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.dashboard__sales-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.dashboard__sales-value {
font-size: 1.25rem !important;
}
.dashboard__sales-growth {
font-size: 0.75rem;
color: var(--color-success);
font-weight: 600;
}
.dashboard__sales-icon {
color: var(--color-text-muted);
}
.dashboard__sparkline {
width: 100%;
height: 32px;
margin-top: 8px;
}
.dashboard__grid {
display: grid;
grid-template-columns: 1fr 1.4fr 0.9fr;
gap: 16px;
}
.dashboard__chart-card,
.dashboard__orders-card,
.dashboard__checks-card {
padding: 20px;
}
.dashboard__chart-card :deep(.sales-chart__header) {
margin-bottom: 0;
}
.dashboard__card-header {
margin-bottom: 16px;
}
.dashboard__card-title {
margin: 0;
font-size: 0.9375rem;
font-weight: 700;
}
.dashboard__card-value {
margin: 4px 0 0;
font-size: 1.125rem;
font-weight: 700;
}
.dashboard__chart {
width: 100%;
height: 140px;
}
.dashboard__chart-axis {
display: flex;
justify-content: space-between;
font-size: 0.6875rem;
color: var(--color-text-muted);
margin-top: 8px;
}
.dashboard__card-footer {
display: flex;
justify-content: flex-start;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--color-border-light);
}
.dashboard__card-footer .admin-btn {
text-decoration: none;
}
.dashboard__deadline-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.dashboard__deadline-item {
display: grid;
grid-template-columns: auto 1fr auto auto;
align-items: center;
gap: 10px;
font-size: 0.8125rem;
}
.dashboard__deadline-count {
color: var(--color-text-muted);
}
.dashboard__deadline-amount {
font-weight: 600;
font-size: 0.75rem;
}
</style>
+146
View File
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import BaseModal from '@/components/ui/BaseModal.vue'
import BaseEmptyState from '@/components/ui/BaseEmptyState.vue'
import { levelsApi, type CustomerLevel } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
const list = ref<CustomerLevel[]>([])
const loading = ref(true)
const modalOpen = ref(false)
const editingId = ref<string | null>(null)
const toast = useToast()
const form = reactive({
code: '',
label: '',
sortOrder: 0,
benefitsText: '',
})
const title = computed(() => (editingId.value ? 'ویرایش سطح' : 'سطح جدید'))
function parseBenefits(text: string) {
return text
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
.map((title) => ({ title }))
}
async function load() {
loading.value = true
try {
list.value = await levelsApi.list()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری سطوح ناموفق بود')
} finally {
loading.value = false
}
}
function openAdd() {
editingId.value = null
Object.assign(form, { code: '', label: '', sortOrder: list.value.length + 1, benefitsText: '' })
modalOpen.value = true
}
function openEdit(row: CustomerLevel) {
editingId.value = row.id
Object.assign(form, {
code: row.code,
label: row.label,
sortOrder: row.sortOrder,
benefitsText: row.benefits.map((b) => b.title).join('\n'),
})
modalOpen.value = true
}
async function save() {
try {
const payload = {
code: form.code.trim(),
label: form.label.trim(),
sortOrder: form.sortOrder,
benefits: parseBenefits(form.benefitsText),
}
if (editingId.value) await levelsApi.update(editingId.value, payload)
else await levelsApi.create(payload)
modalOpen.value = false
toast.success('ذخیره شد')
await load()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ذخیره ناموفق بود')
}
}
onMounted(() => {
void load()
})
</script>
<template>
<div class="admin-page">
<header class="admin-page__head">
<div>
<h1>سطوح و مزایای مشتری</h1>
<p>تعریف Level داخلی CRM و مزایای هر سطح (نمایش فقط در ادمین)</p>
</div>
<button type="button" class="admin-btn admin-btn--primary" @click="openAdd">سطح جدید</button>
</header>
<AdminPageSkeleton v-if="loading" variant="table" />
<BaseEmptyState v-else-if="!list.length" title="سطحی تعریف نشده" />
<table v-else class="admin-table">
<thead>
<tr>
<th>کد</th>
<th>عنوان</th>
<th>مزایا</th>
<th>مشتریان</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="row in list" :key="row.id">
<td dir="ltr">{{ row.code }}</td>
<td>{{ row.label }}</td>
<td>{{ row.benefits.map((b) => b.title).join('، ') || '—' }}</td>
<td>{{ row.customersCount }}</td>
<td><button type="button" class="link" @click="openEdit(row)">ویرایش</button></td>
</tr>
</tbody>
</table>
<BaseModal :open="modalOpen" :title="title" @close="modalOpen = false">
<label class="field">کد<input v-model="form.code" class="admin-input" dir="ltr" /></label>
<label class="field">عنوان<input v-model="form.label" class="admin-input" /></label>
<label class="field">ترتیب<input v-model.number="form.sortOrder" type="number" class="admin-input" /></label>
<label class="field">
مزایا (هر خط یک مورد)
<textarea v-model="form.benefitsText" class="admin-input" rows="4" />
</label>
<template #footer>
<button
type="button"
class="admin-btn admin-btn--primary"
:disabled="!form.code.trim() || !form.label.trim()"
@click="save"
>
ذخیره
</button>
</template>
</BaseModal>
</div>
</template>
<style scoped>
.admin-page__head { display: flex; justify-content: space-between; margin-bottom: 20px; }
.admin-page__head h1 { margin: 0 0 4px; font-size: 1.25rem; }
.admin-page__head p { margin: 0; color: var(--color-text-muted); font-size: 0.875rem; }
.admin-table { width: 100%; border-collapse: collapse; }
.admin-table th, .admin-table td { text-align: right; padding: 10px 8px; border-bottom: 1px solid var(--color-border); }
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
.link { background: none; border: none; color: var(--color-primary); cursor: pointer; font-family: inherit; }
</style>
+580
View File
@@ -0,0 +1,580 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import BaseModal from '@/components/ui/BaseModal.vue'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { mapAdminOrder, ordersApi, type ApiOrder } from '@/services/api'
import { ApiError } from '@/services/http'
import { formatCurrency, formatNumber } from '@/utils/format'
import { confirm } from '@/composables/useConfirm'
import { useToast } from '@/composables/useToast'
import AdminCurrencyInput from '@/components/ui/AdminCurrencyInput.vue'
import { PRE_INVOICE_RAW_STATUSES } from '@/utils/orderStatus'
import type { OrderItem, RawOrderStatus } from '@/types'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const order = ref<ApiOrder | null>(null)
const items = ref<OrderItem[]>([])
const shippingCost = ref(0)
const customerNote = ref('')
const showAddModal = ref(false)
const formError = ref('')
const saving = ref(false)
const sending = ref(false)
const loading = ref(true)
const emptyForm = () => ({
catalogCode: '',
productName: '',
color: '',
requestedMeterage: 100,
confirmedMeterage: 100,
})
const form = reactive(emptyForm())
const isFormValid = computed(
() =>
form.catalogCode.trim().length > 0 &&
form.productName.trim().length > 0 &&
form.requestedMeterage > 0,
)
const displayOrder = computed(() =>
order.value ? mapAdminOrder(order.value) : null,
)
/** تا وقتی سفارش در این وضعیت‌هاست، فاکتور برای مشتری ارسال نشده است. */
const invoiceSent = computed(() =>
order.value
? !PRE_INVOICE_RAW_STATUSES.includes(order.value.status as RawOrderStatus)
: false,
)
const canSendInvoice = computed(() => !!order.value?.finalAmount && !invoiceSent.value)
async function loadOrder() {
loading.value = true
try {
const data = await ordersApi.get(String(route.params.id))
order.value = data
items.value = (data.items ?? []).map((item) => ({
id: item.id,
catalogCode: item.catalogCode,
productName: item.productName,
color: item.color,
requestedMeterage: item.requestedMeterage,
confirmedMeterage: item.confirmedMeterage,
supplyPrice: item.supplyPrice,
totalAmount: item.totalAmount,
companyId: item.companyId,
companyName: item.companyName,
}))
shippingCost.value = data.shippingCost ?? 0
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری سفارش انجام نشد.')
} finally {
loading.value = false
}
}
onMounted(() => {
void loadOrder()
})
function goBack() {
router.push('/orders')
}
async function removeItem(id: string) {
const item = items.value.find((entry) => entry.id === id)
const ok = await confirm({
title: 'حذف محصول',
message: item
? `آیا از حذف «${item.productName}» از سفارش مطمئن هستید؟`
: 'آیا از حذف این محصول از سفارش مطمئن هستید؟',
confirmLabel: 'حذف',
cancelLabel: 'انصراف',
tone: 'danger',
})
if (!ok) return
items.value = items.value.filter((i) => i.id !== id)
}
function openAddModal() {
Object.assign(form, emptyForm())
formError.value = ''
showAddModal.value = true
}
function closeAddModal() {
showAddModal.value = false
formError.value = ''
}
function submitAddProduct() {
if (!isFormValid.value) {
formError.value = 'کالیته، نام محصول و متراژ الزامی هستند.'
return
}
items.value.push({
id: `oi-${crypto.randomUUID().slice(0, 8)}`,
catalogCode: form.catalogCode.trim(),
productName: form.productName.trim(),
color: form.color.trim() || '—',
requestedMeterage: form.requestedMeterage,
confirmedMeterage: form.confirmedMeterage || form.requestedMeterage,
supplyPrice: null,
totalAmount: null,
companyId: order.value?.companyId,
companyName: order.value?.companyName,
})
closeAddModal()
}
async function savePricing() {
if (!order.value) return
const pricedItems = items.value.filter((i) => !i.id.startsWith('oi-'))
if (!pricedItems.length) {
toast.error('اقلام قابل قیمت‌گذاری یافت نشد.')
return
}
saving.value = true
try {
const updated = await ordersApi.updatePricing(order.value.id, {
items: pricedItems.map((item) => ({
id: item.id,
confirmedMeterage: item.confirmedMeterage ?? undefined,
supplyPrice: item.supplyPrice ?? undefined,
})),
shippingCost: shippingCost.value,
note: customerNote.value.trim() || undefined,
})
order.value = updated
items.value = (updated.items ?? []).map((item) => ({
id: item.id,
catalogCode: item.catalogCode,
productName: item.productName,
color: item.color,
requestedMeterage: item.requestedMeterage,
confirmedMeterage: item.confirmedMeterage,
supplyPrice: item.supplyPrice,
totalAmount: item.totalAmount,
companyId: item.companyId,
companyName: item.companyName,
}))
toast.success('قیمت‌گذاری ذخیره شد.')
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ذخیره قیمت‌گذاری انجام نشد.')
} finally {
saving.value = false
}
}
async function sendInvoice() {
if (!order.value) return
if (!order.value.finalAmount) {
toast.error('ابتدا قیمت‌گذاری را ذخیره کنید تا مبلغ فاکتور مشخص شود.')
return
}
const ok = await confirm({
title: 'ارسال فاکتور برای مشتری',
message: `فاکتور ${formatCurrency(order.value.finalAmount)} برای مشتری ارسال می‌شود و او روش تسویه را انتخاب می‌کند. ادامه می‌دهید؟`,
confirmLabel: 'ارسال فاکتور',
cancelLabel: 'انصراف',
})
if (!ok) return
sending.value = true
try {
const updated = await ordersApi.updateStatus(order.value.id, {
status: 'awaiting_settlement',
note: customerNote.value.trim() || undefined,
})
order.value = updated
toast.success('فاکتور برای مشتری ارسال شد.')
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ارسال فاکتور انجام نشد.')
} finally {
sending.value = false
}
}
</script>
<template>
<div class="pricing-page">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">قیمت‌گذاری سفارش</h1>
</div>
<div class="admin-action-group">
<button
type="button"
class="admin-btn admin-btn--success admin-btn--sm"
:disabled="saving || loading"
@click="savePricing"
>
{{ saving ? 'در حال ذخیره…' : 'ذخیره قیمت‌گذاری' }}
</button>
<button
type="button"
class="admin-btn admin-btn--primary admin-btn--sm"
:disabled="!canSendInvoice || sending || saving"
@click="sendInvoice"
>
{{ sending ? 'در حال ارسال…' : 'ارسال فاکتور برای مشتری' }}
</button>
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm" @click="goBack">
بازگشت
</button>
</div>
</div>
<AdminPageSkeleton v-if="loading" variant="detail" />
<template v-else-if="displayOrder">
<div class="pricing-page__summary admin-card">
<div class="pricing-page__order-meta">
<div>
<strong class="pricing-page__order-id">{{ displayOrder.orderNumber }}</strong>
<p class="pricing-page__customer">{{ displayOrder.customerName }}</p>
<StatusBadge :status="displayOrder.status" />
</div>
</div>
<div class="admin-summary-grid">
<div class="admin-summary-item">
<p class="admin-summary-item__label">مجموع مبلغ</p>
<p class="admin-summary-item__value">
{{ displayOrder.totalAmount ? formatCurrency(displayOrder.totalAmount) : '—' }}
</p>
</div>
<div class="admin-summary-item">
<p class="admin-summary-item__label">هزینه حمل</p>
<p class="admin-summary-item__value">
{{ formatCurrency(shippingCost) }}
</p>
</div>
<div class="admin-summary-item">
<p class="admin-summary-item__label">مبلغ نهایی</p>
<p class="admin-summary-item__value">
{{ order?.finalAmount ? formatCurrency(order.finalAmount) : '—' }}
</p>
</div>
</div>
</div>
<div class="pricing-page__table admin-card desktop-only">
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>ردیف</th>
<th>شرکت</th>
<th>کالیته</th>
<th>محصول</th>
<th>رنگ / نوع</th>
<th>متراژ درخواستی</th>
<th>متراژ تأیید شده</th>
<th>قیمت تأمینی (واحد)</th>
<th>مبلغ کل (تومان)</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in items" :key="item.id">
<td>{{ index + 1 }}</td>
<td>{{ item.companyName || order?.companyName || '—' }}</td>
<td>{{ item.catalogCode }}</td>
<td>{{ item.productName }}</td>
<td>{{ item.color }}</td>
<td>{{ formatNumber(item.requestedMeterage) }}</td>
<td>
<input
v-model.number="item.confirmedMeterage"
type="number"
class="admin-input admin-input--sm"
/>
</td>
<td>
<AdminCurrencyInput
v-model="item.supplyPrice"
input-class="admin-input admin-input--sm"
placeholder="—"
/>
</td>
<td>{{ item.totalAmount ? formatCurrency(item.totalAmount) : '—' }}</td>
<td>
<button
type="button"
class="admin-btn admin-btn--outline-danger admin-btn--xs"
@click="removeItem(item.id)"
>
حذف
</button>
</td>
</tr>
</tbody>
</table>
</div>
<button type="button" class="admin-btn admin-btn--ghost pricing-page__add-btn" @click="openAddModal">
<AdminIcon name="products" :size="16" />
افزودن محصول
</button>
</div>
<div class="compact-only admin-card-list pricing-page__compact-items">
<article v-for="(item, index) in items" :key="item.id" class="admin-card-item">
<div class="admin-card-item__header">
<p class="admin-card-item__title">{{ item.productName }}</p>
<span class="pricing-page__item-index">#{{ index + 1 }}</span>
</div>
<div class="admin-card-item__meta">
<span v-if="item.companyName || order?.companyName">{{ item.companyName || order?.companyName }}</span>
<span>کالیته {{ item.catalogCode }}</span>
<span>{{ item.color }}</span>
<span>درخواست: {{ formatNumber(item.requestedMeterage) }} متر</span>
</div>
<div class="pricing-page__compact-fields">
<label>
متراژ تأیید شده
<input v-model.number="item.confirmedMeterage" type="number" class="admin-input" />
</label>
<label>
قیمت تأمینی
<AdminCurrencyInput v-model="item.supplyPrice" placeholder="—" />
</label>
<p v-if="item.totalAmount" class="pricing-page__line-total">
مبلغ کل: {{ formatCurrency(item.totalAmount) }}
</p>
</div>
<div class="admin-card-item__actions">
<button type="button" class="admin-btn admin-btn--outline-danger admin-btn--xs" @click="removeItem(item.id)">
حذف
</button>
</div>
</article>
<button type="button" class="admin-btn admin-btn--ghost" @click="openAddModal">
<AdminIcon name="products" :size="16" />
افزودن محصول
</button>
</div>
<div class="admin-form-grid">
<div class="admin-form-field">
<label>هزینه حمل (تومان)</label>
<AdminCurrencyInput v-model="shippingCost" :allow-empty="false" />
</div>
<div class="admin-form-field">
<label>توضیحات برای مشتری</label>
<textarea v-model="customerNote" placeholder="توضیحات خود را وارد کنید..." />
</div>
</div>
<div class="admin-footer-actions">
<button
type="button"
class="admin-btn admin-btn--success"
:disabled="saving"
@click="savePricing"
>
{{ saving ? 'در حال ذخیره…' : 'ذخیره قیمت‌گذاری' }}
</button>
<button
type="button"
class="admin-btn admin-btn--primary"
:disabled="!canSendInvoice || sending || saving"
@click="sendInvoice"
>
{{ sending ? 'در حال ارسال…' : 'ارسال فاکتور برای مشتری' }}
</button>
</div>
<p class="pricing-page__invoice-hint">
<template v-if="invoiceSent">
فاکتور این سفارش برای مشتری ارسال شده است؛ انتخاب روش تسویه در اختیار اوست.
</template>
<template v-else-if="canSendInvoice">
مبلغ نهایی آماده است. با ارسال فاکتور، مشتری برای انتخاب نقدی یا چک مطلع می‌شود.
</template>
<template v-else>
ابتدا قیمت واحد اقلام و هزینه حمل را ذخیره کنید تا مبلغ نهایی فاکتور ساخته شود.
</template>
</p>
</template>
<BaseModal :open="showAddModal" title="افزودن محصول" size="lg" @close="closeAddModal">
<form class="admin-form" @submit.prevent="submitAddProduct">
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="catalog-code">کالیته <span class="admin-form__required">*</span></label>
<input
id="catalog-code"
v-model="form.catalogCode"
type="text"
class="admin-input"
placeholder="377"
/>
</div>
<div class="admin-form__field">
<label for="product-name">محصول <span class="admin-form__required">*</span></label>
<input
id="product-name"
v-model="form.productName"
type="text"
class="admin-input"
placeholder="پارچه آبی"
/>
</div>
</div>
<div class="admin-form__field">
<label for="product-color">رنگ / نوع</label>
<input
id="product-color"
v-model="form.color"
type="text"
class="admin-input"
placeholder="آبی"
/>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="requested-meterage">
متراژ درخواستی <span class="admin-form__required">*</span>
</label>
<input
id="requested-meterage"
v-model.number="form.requestedMeterage"
type="number"
min="1"
class="admin-input"
/>
</div>
<div class="admin-form__field">
<label for="confirmed-meterage">متراژ تأیید شده</label>
<input
id="confirmed-meterage"
v-model.number="form.confirmedMeterage"
type="number"
min="0"
class="admin-input"
/>
</div>
</div>
</form>
<template #footer>
<button type="button" class="admin-btn admin-btn--outline" @click="closeAddModal">
انصراف
</button>
<button
type="button"
class="admin-btn admin-btn--primary"
:disabled="!isFormValid"
@click="submitAddProduct"
>
افزودن به سفارش
</button>
</template>
</BaseModal>
</div>
</template>
<style scoped>
.pricing-page__summary {
padding: 20px;
margin-bottom: 16px;
}
.pricing-page__order-meta {
margin-bottom: 16px;
}
.pricing-page__order-id {
font-size: 1rem;
display: block;
margin-bottom: 4px;
}
.pricing-page__customer {
margin: 0 0 8px;
font-size: 0.875rem;
color: var(--color-text-muted);
}
.pricing-page__table {
padding: 0;
overflow: hidden;
margin-bottom: 16px;
}
.pricing-page__add-btn {
margin: 12px 16px 16px;
display: inline-flex;
align-items: center;
gap: 6px;
}
.pricing-page__compact-items {
margin-bottom: 16px;
}
.pricing-page__item-index {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.pricing-page__compact-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 12px;
}
.pricing-page__compact-fields label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.75rem;
color: var(--color-text-muted);
min-width: 0;
}
.pricing-page__compact-fields :deep(.admin-input),
.pricing-page__compact-fields :deep(input) {
width: 100%;
min-width: 0;
}
.pricing-page__invoice-hint {
margin: 12px 0 0;
font-size: 0.75rem;
line-height: 1.8;
color: var(--color-text-muted);
}
.pricing-page__line-total {
grid-column: 1 / -1;
margin: 0;
font-size: 0.875rem;
font-weight: 700;
color: var(--color-text);
}
@media (max-width: 479px) {
.pricing-page__compact-fields {
grid-template-columns: 1fr;
}
}
</style>
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { provideOrdersPage } from '@/composables/useOrdersPage'
import DesktopOrdersPage from '@/views/orders/components/DesktopOrdersPage.vue'
import CompactOrdersPage from '@/views/orders/components/CompactOrdersPage.vue'
import OrderAddModal from '@/views/orders/components/OrderAddModal.vue'
provideOrdersPage()
</script>
<template>
<DesktopOrdersPage class="desktop-only" />
<CompactOrdersPage class="compact-only" />
<OrderAddModal />
</template>
@@ -0,0 +1,365 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import BaseModal from '@/components/ui/BaseModal.vue'
import OrderDetailContent from '@/views/orders/components/OrderDetailContent.vue'
import { useOrdersPageContext } from '@/composables/useOrdersPage'
import { ORDER_TABS } from '@/utils/orderStatus'
import { formatCurrency, formatDateTime, formatMeterage } from '@/utils/format'
const isMobile = useMediaQuery('(max-width: 767px)')
/** Matches `.compact-only` visible range (< 1280px) */
const isCompactLayout = useMediaQuery('(max-width: 1279px)')
const {
activeTab,
selectedOrderId,
searchQuery,
filteredOrders,
selectedOrder,
showDetailPanel,
showDetailModal,
selectOrder,
openOrderDetail,
closeDetailPanel,
closeDetailModal,
settlementLabel,
goPricing,
goChecks,
goCashReceipt,
changeStatus,
statusSaving,
openAddModal,
} = useOrdersPageContext()
const showFilterModal = ref(false)
const hasActiveFilter = computed(() => activeTab.value !== 'all')
const activeFilterLabel = computed(
() => ORDER_TABS.find((tab) => tab.key === activeTab.value)?.label ?? 'همه',
)
function openFilters() {
showFilterModal.value = true
}
function closeFilters() {
showFilterModal.value = false
}
function clearFilters() {
activeTab.value = 'all'
showFilterModal.value = false
}
function applyStatusFilter(key: string) {
activeTab.value = key
showFilterModal.value = false
}
function openDetail(order: (typeof filteredOrders.value)[number]) {
if (isMobile.value) {
selectOrder(order, true)
return
}
openOrderDetail(order)
}
function handlePricing() {
if (!selectedOrder.value) return
closeDetailModal()
closeDetailPanel()
goPricing(selectedOrder.value)
}
function handleChecks() {
if (!selectedOrder.value) return
closeDetailModal()
closeDetailPanel()
goChecks(selectedOrder.value)
}
function handleCashReceipt() {
if (!selectedOrder.value) return
closeDetailModal()
closeDetailPanel()
goCashReceipt(selectedOrder.value)
}
</script>
<template>
<div class="compact-orders">
<div class="compact-page-header">
<div class="compact-page-header__row">
<div>
<h2 class="compact-page-header__title">سفارش‌ها</h2>
</div>
<div class="compact-page-header__actions">
<button
type="button"
class="admin-btn admin-btn--outline admin-btn--sm"
:class="{ 'admin-btn--filter-active': hasActiveFilter }"
@click="openFilters"
>
<AdminIcon name="filter" :size="16" />
فیلتر
<span v-if="hasActiveFilter" class="compact-orders__filter-dot" aria-hidden="true" />
</button>
<button type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="openAddModal">
سفارش جدید
</button>
</div>
</div>
</div>
<div class="orders-page__search admin-card">
<AdminIcon name="search" :size="18" />
<input
v-model="searchQuery"
type="search"
placeholder="جستجو در شماره سفارش، مشتری..."
class="orders-page__search-input"
/>
</div>
<!-- Mobile: chip instead of status tabs -->
<div v-if="isMobile && hasActiveFilter" class="compact-orders__filter-chip admin-card">
<span>وضعیت: {{ activeFilterLabel }}</span>
<button type="button" class="compact-orders__filter-clear" @click="clearFilters">پاک کردن</button>
</div>
<!-- Tablet+: status tabs -->
<div v-if="!isMobile" class="admin-tabs compact-orders__tabs">
<button
v-for="tab in ORDER_TABS"
:key="tab.key"
type="button"
class="admin-tab"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.label }}
<template v-if="'count' in tab && tab.count"> ({{ tab.count }})</template>
</button>
</div>
<BaseModal :open="showFilterModal" title="فیلتر سفارش‌ها" @close="closeFilters">
<div class="compact-orders__filter-form" role="listbox" aria-label="وضعیت سفارش">
<button
v-for="tab in ORDER_TABS"
:key="tab.key"
type="button"
class="compact-orders__filter-option"
:class="{ 'is-active': activeTab === tab.key }"
role="option"
:aria-selected="activeTab === tab.key"
@click="applyStatusFilter(tab.key)"
>
<span>{{ tab.label }}</span>
<span v-if="activeTab === tab.key" class="compact-orders__filter-check" aria-hidden="true">✓</span>
</button>
</div>
<template #footer>
<button type="button" class="admin-btn admin-btn--outline" @click="clearFilters">
پاک کردن
</button>
<button type="button" class="admin-btn admin-btn--primary" @click="closeFilters">
بستن
</button>
</template>
</BaseModal>
<div class="admin-card-list">
<button
v-for="order in filteredOrders"
:key="order.id"
type="button"
class="admin-card-item"
:class="{ selected: selectedOrderId === order.id }"
@click="openDetail(order)"
>
<div class="admin-card-item__header">
<p class="admin-card-item__title">{{ order.orderNumber }}</p>
<StatusBadge :status="order.status" />
</div>
<div class="admin-card-item__meta">
<span><strong>مشتری:</strong> {{ order.customerName }}</span>
<span><strong>متراژ:</strong> {{ formatMeterage(order.meterage) }}</span>
<span><strong>مبلغ:</strong> {{ order.totalAmount ? formatCurrency(order.totalAmount) : '—' }}</span>
<span>{{ formatDateTime(order.createdAt) }}</span>
</div>
</button>
</div>
<!-- Mobile: full-screen slide panel -->
<div
class="admin-detail-panel compact-orders__mobile-panel"
:class="{ open: isMobile && showDetailPanel && selectedOrder }"
>
<div v-if="selectedOrder" class="admin-detail-panel__inner">
<div class="admin-detail-panel__header">
<button
type="button"
class="admin-detail-panel__back"
aria-label="بازگشت به لیست"
@click="closeDetailPanel"
>
<AdminIcon name="chevron-back" :size="20" />
</button>
<h2 class="admin-detail-panel__title">جزئیات سفارش</h2>
</div>
<div class="admin-detail-panel__body">
<OrderDetailContent
:order="selectedOrder"
:settlement-label="settlementLabel"
:status-saving="statusSaving"
@pricing="handlePricing"
@checks="handleChecks"
@cash-receipt="handleCashReceipt"
@status="changeStatus"
/>
</div>
</div>
</div>
<!-- Tablet / small desktop: modal -->
<BaseModal
:open="isCompactLayout && !isMobile && showDetailModal && !!selectedOrder"
title="جزئیات سفارش"
size="xl"
@close="closeDetailModal"
>
<template v-if="selectedOrder">
<OrderDetailContent
:order="selectedOrder"
:settlement-label="settlementLabel"
:status-saving="statusSaving"
@pricing="handlePricing"
@checks="handleChecks"
@cash-receipt="handleCashReceipt"
@status="changeStatus"
/>
</template>
</BaseModal>
</div>
</template>
<style scoped>
.compact-orders__tabs {
margin-bottom: 16px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 4px 8px 0;
border-bottom: 1px solid var(--color-border);
overflow: visible;
}
.orders-page__search {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
margin-bottom: 12px;
color: var(--color-text-muted);
}
.orders-page__search-input {
flex: 1;
border: none;
background: transparent;
font-size: 0.875rem;
outline: none;
color: var(--color-text);
}
.admin-btn--filter-active {
border-color: var(--color-primary);
color: var(--color-primary);
}
.compact-orders__filter-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-primary);
}
.compact-orders__filter-chip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
margin-bottom: 12px;
font-size: 0.875rem;
color: var(--color-text);
}
.compact-orders__filter-clear {
border: none;
background: none;
padding: 0;
color: var(--color-primary);
font-family: inherit;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
}
.compact-orders__filter-form {
display: flex;
flex-direction: column;
gap: 6px;
}
.compact-orders__filter-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
min-height: 44px;
padding: 10px 14px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg);
color: var(--color-text);
font-family: inherit;
font-size: 0.875rem;
text-align: right;
cursor: pointer;
}
.compact-orders__filter-option.is-active {
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
color: var(--color-primary);
font-weight: 600;
}
.compact-orders__filter-check {
font-size: 0.875rem;
}
.admin-card-item {
cursor: pointer;
text-align: right;
}
.admin-detail-panel__inner {
display: flex;
flex-direction: column;
height: 100%;
background: var(--color-bg);
}
@media (min-width: 768px) {
.compact-orders__mobile-panel {
display: none !important;
}
}
</style>
@@ -0,0 +1,416 @@
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import BaseModal from '@/components/ui/BaseModal.vue'
import OrderDetailContent from '@/views/orders/components/OrderDetailContent.vue'
import { useOrdersPageContext } from '@/composables/useOrdersPage'
import { ORDER_TABS, canPriceOrder } from '@/utils/orderStatus'
import { formatCurrency, formatDateTime, formatMeterage } from '@/utils/format'
import type { Order } from '@/types'
/** Matches `.desktop-only` in responsive.css (>= 1280px) */
const isDesktopLayout = useMediaQuery('(min-width: 1280px)')
const {
activeTab,
selectedOrderId,
searchQuery,
filteredOrders,
selectedOrder,
showDetailModal,
openOrderDetail,
closeDetailModal,
settlementLabel,
goPricing,
goChecks,
goCashReceipt,
changeStatus,
statusSaving,
openAddModal,
} = useOrdersPageContext()
const menuOrder = ref<Order | null>(null)
const menuStyle = ref<Record<string, string>>({})
function closeMenu() {
menuOrder.value = null
}
async function toggleMenu(order: Order, event: MouseEvent) {
event.stopPropagation()
if (menuOrder.value?.id === order.id) {
closeMenu()
return
}
const btn = event.currentTarget as HTMLElement
const rect = btn.getBoundingClientRect()
menuOrder.value = order
await nextTick()
const menuWidth = 168
const left = Math.min(
Math.max(8, rect.right - menuWidth),
window.innerWidth - menuWidth - 8,
)
menuStyle.value = {
position: 'fixed',
top: `${rect.bottom + 4}px`,
left: `${left}px`,
width: `${menuWidth}px`,
}
}
function onDocumentClick() {
closeMenu()
}
onMounted(() => {
document.addEventListener('click', onDocumentClick)
window.addEventListener('scroll', closeMenu, true)
window.addEventListener('resize', closeMenu)
})
onUnmounted(() => {
document.removeEventListener('click', onDocumentClick)
window.removeEventListener('scroll', closeMenu, true)
window.removeEventListener('resize', closeMenu)
})
function handlePricing() {
if (!selectedOrder.value) return
closeDetailModal()
goPricing(selectedOrder.value)
}
function handleChecks() {
if (!selectedOrder.value) return
closeDetailModal()
goChecks(selectedOrder.value)
}
function handleCashReceipt() {
if (!selectedOrder.value) return
closeDetailModal()
goCashReceipt(selectedOrder.value)
}
function printOrderDetail() {
if (!selectedOrder.value) return
document.documentElement.classList.add('printing-order-detail')
const cleanup = () => {
document.documentElement.classList.remove('printing-order-detail')
window.removeEventListener('afterprint', cleanup)
}
window.addEventListener('afterprint', cleanup)
window.setTimeout(cleanup, 60_000)
window.print()
}
function runMenuAction(action: 'detail' | 'pricing' | 'checks' | 'cash') {
const order = menuOrder.value
if (!order) return
closeMenu()
if (action === 'detail') {
openOrderDetail(order)
return
}
if (action === 'pricing') {
goPricing(order)
return
}
if (action === 'checks') {
goChecks(order)
return
}
goCashReceipt(order)
}
</script>
<template>
<div class="orders-page">
<div class="orders-page__list-panel admin-card">
<div class="orders-page__list-header">
<div>
<h2 class="orders-page__list-title">سفارش‌ها</h2>
</div>
<div class="orders-page__list-actions">
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm">
<AdminIcon name="filter" :size="16" />
فیلتر
</button>
<button type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="openAddModal">
سفارش جدید
</button>
</div>
</div>
<div class="orders-page__search">
<AdminIcon name="search" :size="18" />
<input
v-model="searchQuery"
type="search"
placeholder="جستجو در شماره سفارش، مشتری..."
class="orders-page__search-input"
/>
</div>
<div class="admin-tabs">
<button
v-for="tab in ORDER_TABS"
:key="tab.key"
type="button"
class="admin-tab"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.label }}
<template v-if="'count' in tab && tab.count"> ({{ tab.count }})</template>
</button>
</div>
<div class="admin-table-wrap">
<table class="admin-table admin-table--compact">
<thead>
<tr>
<th>شماره سفارش</th>
<th>مشتری</th>
<th>روش تسویه</th>
<th>مبلغ کل</th>
<th>متراژ</th>
<th>وضعیت</th>
<th>تاریخ ثبت</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
<tr
v-for="order in filteredOrders"
:key="order.id"
:class="{ 'is-selected': selectedOrderId === order.id }"
@click="openOrderDetail(order)"
>
<td>{{ order.orderNumber }}</td>
<td>{{ order.customerName }}</td>
<td>{{ settlementLabel(order.settlementMethod) }}</td>
<td>{{ order.totalAmount ? formatCurrency(order.totalAmount) : '—' }}</td>
<td>{{ formatMeterage(order.meterage) }}</td>
<td><StatusBadge :status="order.status" /></td>
<td>{{ formatDateTime(order.createdAt) }}</td>
<td class="orders-page__actions-cell">
<button
type="button"
class="orders-page__more-btn"
:aria-expanded="menuOrder?.id === order.id"
aria-haspopup="menu"
aria-label="عملیات سفارش"
@click="toggleMenu(order, $event)"
>
<AdminIcon name="more" :size="18" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="admin-pagination">
<span>نمایش ۱ تا ۷ از ۱۰۳ سفارش</span>
<div class="admin-pagination__pages">
<button type="button" class="admin-pagination__page active">۱</button>
<button type="button" class="admin-pagination__page">۲</button>
<button type="button" class="admin-pagination__page">۳</button>
<span>...</span>
<button type="button" class="admin-pagination__page">۱۲</button>
</div>
</div>
</div>
<Teleport to="body">
<div
v-if="menuOrder"
class="orders-page__menu"
role="menu"
:style="menuStyle"
@click.stop
>
<button
type="button"
role="menuitem"
class="orders-page__menu-item"
@click="runMenuAction('detail')"
>
جزئیات سفارش
</button>
<button
v-if="menuOrder && canPriceOrder(menuOrder)"
type="button"
role="menuitem"
class="orders-page__menu-item"
@click="runMenuAction('pricing')"
>
قیمت‌گذاری
</button>
<button
v-if="menuOrder.settlementMethod === 'check'"
type="button"
role="menuitem"
class="orders-page__menu-item"
@click="runMenuAction('checks')"
>
مدیریت چک‌ها
</button>
<button
v-if="menuOrder.settlementMethod === 'cash'"
type="button"
role="menuitem"
class="orders-page__menu-item"
@click="runMenuAction('cash')"
>
رسید پرداخت
</button>
</div>
</Teleport>
<BaseModal
:open="isDesktopLayout && showDetailModal && !!selectedOrder"
title="جزئیات سفارش"
size="xl"
@close="closeDetailModal"
>
<template v-if="selectedOrder">
<OrderDetailContent
:order="selectedOrder"
layout="desktop"
:settlement-label="settlementLabel"
:status-saving="statusSaving"
@pricing="handlePricing"
@checks="handleChecks"
@cash-receipt="handleCashReceipt"
@status="changeStatus"
/>
</template>
<template #footer>
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm">
<AdminIcon name="sms" :size="16" />
ارسال پیامک
</button>
<button
type="button"
class="admin-btn admin-btn--outline admin-btn--sm"
@click="printOrderDetail"
>
<AdminIcon name="print" :size="16" />
چاپ
</button>
</template>
</BaseModal>
</div>
</template>
<style scoped>
.orders-page {
min-height: calc(100dvh - var(--header-height) - 48px);
}
.orders-page__list-panel {
padding: 20px;
min-width: 0;
}
.orders-page__list-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.orders-page__list-title {
margin: 0;
font-size: 1.125rem;
font-weight: 700;
}
.orders-page__list-actions {
display: flex;
gap: 8px;
}
.orders-page__search {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-border-light);
margin-bottom: 16px;
color: var(--color-text-muted);
}
.orders-page__search-input {
flex: 1;
border: none;
background: transparent;
font-size: 0.875rem;
outline: none;
}
.orders-page__actions-cell {
width: 48px;
}
.orders-page__more-btn {
border: none;
background: transparent;
color: var(--color-text-muted);
padding: 4px;
border-radius: var(--radius-sm);
cursor: pointer;
}
.orders-page__more-btn:hover,
.orders-page__more-btn[aria-expanded='true'] {
color: var(--color-text);
background: var(--color-border-light);
}
.admin-table tbody tr {
cursor: pointer;
}
.admin-table tbody tr.is-selected {
background: var(--color-primary-soft);
}
</style>
<style>
.orders-page__menu {
z-index: 300000;
min-width: 160px;
padding: 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
display: grid;
gap: 2px;
}
.orders-page__menu-item {
border: none;
background: transparent;
color: var(--color-text);
text-align: start;
padding: 8px 10px;
border-radius: var(--radius-sm);
font-size: 0.8125rem;
cursor: pointer;
}
.orders-page__menu-item:hover {
background: var(--color-primary-soft);
}
</style>
@@ -0,0 +1,147 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
import BaseModal from '@/components/ui/BaseModal.vue'
import AdminSelect from '@/components/ui/AdminSelect.vue'
import { useOrdersPageContext } from '@/composables/useOrdersPage'
const {
showAddModal,
formError,
form,
isFormValid,
companies,
activeCustomers,
applyCustomer,
closeAddModal,
submitAddOrder,
} = useOrdersPageContext()
const companyOptions = computed(() =>
companies.value.map((company) => ({ value: company.id, label: company.name })),
)
const customerOptions = computed(() =>
activeCustomers.value.map((customer) => {
const phone = customer.phone ? ` — ${customer.phone}` : ''
const inactive = customer.isActive === false ? ' (غیرفعال)' : ''
return {
value: customer.id,
label: `${customer.name}${phone}${inactive}`,
}
}),
)
const settlementOptions = [
{ value: 'check', label: 'چکی' },
{ value: 'cash', label: 'نقدی' },
]
watch(
() => form.customerId,
(id) => {
if (!id) {
form.customerName = ''
form.customerPhone = ''
return
}
applyCustomer(id)
},
)
</script>
<template>
<BaseModal :open="showAddModal" title="سفارش جدید" size="lg" @close="closeAddModal">
<form class="admin-form" @submit.prevent="submitAddOrder">
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
<div class="admin-form__field">
<label id="order-customer-label">
نام مشتری <span class="admin-form__required">*</span>
</label>
<AdminSelect
id="order-customer"
v-model="form.customerId"
block
searchable
aria-label="انتخاب مشتری از لیست"
placeholder="انتخاب مشتری از لیست"
search-placeholder="جستجوی نام یا موبایل…"
:options="customerOptions"
/>
<p v-if="!customerOptions.length" class="admin-form__hint">
مشتری‌ای در سیستم نیست. ابتدا از بخش «مشتریان» یک مشتری ثبت کنید.
</p>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label>شرکت <span class="admin-form__required">*</span></label>
<AdminSelect
v-model="form.companyId"
block
aria-label="انتخاب شرکت"
placeholder="انتخاب شرکت"
:options="companyOptions"
/>
</div>
<div class="admin-form__field">
<label>روش تسویه</label>
<AdminSelect
v-model="form.settlementMethod"
block
aria-label="روش تسویه"
:options="settlementOptions"
/>
</div>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="order-meterage">متراژ (متر)</label>
<input
id="order-meterage"
v-model.number="form.meterage"
type="number"
min="1"
class="admin-input"
/>
</div>
<div class="admin-form__field">
<label for="order-phone">تلفن مشتری</label>
<input
id="order-phone"
v-model="form.customerPhone"
type="tel"
class="admin-input"
placeholder="پس از انتخاب مشتری پر می‌شود"
inputmode="tel"
dir="ltr"
readonly
/>
</div>
</div>
</form>
<template #footer>
<button type="button" class="admin-btn admin-btn--outline" @click="closeAddModal">
انصراف
</button>
<button
type="button"
class="admin-btn admin-btn--primary"
:disabled="!isFormValid"
@click="submitAddOrder"
>
ثبت سفارش
</button>
</template>
</BaseModal>
</template>
<style scoped>
.admin-form__hint {
margin: 6px 0 0;
font-size: 0.75rem;
color: var(--color-text-muted);
}
</style>
@@ -0,0 +1,516 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import AdminSelect from '@/components/ui/AdminSelect.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { formatCurrency, formatMeterage, formatNumber } from '@/utils/format'
import { CUSTOMER_TYPE_LABELS } from '@/utils/adminLabels'
import { ORDER_STATUS_META, RAW_ORDER_STATUS_LABELS, canPriceOrder, rawStatusLabel } from '@/utils/orderStatus'
import type { CustomerType, Order, RawOrderStatus } from '@/types'
const props = withDefaults(
defineProps<{
order: Order
settlementLabel: (method?: string | null) => string
layout?: 'desktop' | 'compact'
statusSaving?: boolean
}>(),
{ layout: 'compact' },
)
const emit = defineEmits<{
pricing: []
checks: []
cashReceipt: []
status: [
payload: {
orderId: string
status: RawOrderStatus
note?: string
reason?: string
force?: boolean
},
]
}>()
const manualMode = ref(false)
const nextStatus = ref<RawOrderStatus | ''>('')
const statusNote = ref('')
const allStatuses = Object.keys(RAW_ORDER_STATUS_LABELS) as RawOrderStatus[]
const transitions = computed(() => props.order.allowedTransitions ?? [])
const statusOptions = computed<RawOrderStatus[]>(() =>
manualMode.value
? allStatuses.filter((s) => s !== props.order.rawStatus)
: [...transitions.value],
)
const statusSelectOptions = computed(() => [
{ value: '', label: 'انتخاب کنید…' },
...statusOptions.value.map((status) => ({
value: status,
label: rawStatusLabel(status),
})),
])
const noteRequired = computed(() => manualMode.value)
const canSubmitStatus = computed(
() => !!nextStatus.value && (!noteRequired.value || statusNote.value.trim().length > 0),
)
const useTable = computed(() => props.layout === 'desktop')
const badgeStatusLabel = computed(
() => ORDER_STATUS_META[props.order.status]?.label ?? props.order.status,
)
/** Avoid duplicating the same Persian label under the badge. */
const distinctRawStatusLabel = computed(() => {
const raw = rawStatusLabel(props.order.rawStatus)
if (!raw || raw === badgeStatusLabel.value) return ''
return raw
})
const customerTypeLabel = computed(() => {
const type = props.order.customerType as CustomerType | undefined
if (!type) return '—'
return CUSTOMER_TYPE_LABELS[type] ?? props.order.customerType ?? '—'
})
const showPricingAction = computed(() => canPriceOrder(props.order))
const showChecksAction = computed(() => props.order.settlementMethod === 'check')
const showCashAction = computed(() => props.order.settlementMethod === 'cash')
/** Primary CTA follows the current workflow stage. */
const primaryAction = computed<'pricing' | 'checks' | 'cash' | null>(() => {
if (showPricingAction.value) return 'pricing'
if (showChecksAction.value) return 'checks'
if (showCashAction.value) return 'cash'
return null
})
watch([() => props.order.id, manualMode], () => {
nextStatus.value = ''
statusNote.value = ''
})
function submitStatus() {
if (!nextStatus.value || !canSubmitStatus.value) return
const note = statusNote.value.trim() || undefined
emit('status', {
orderId: props.order.id,
status: nextStatus.value,
note,
reason: manualMode.value ? note : undefined,
force: manualMode.value || undefined,
})
nextStatus.value = ''
statusNote.value = ''
}
</script>
<template>
<div class="order-detail" :class="`order-detail--${layout}`">
<header class="order-detail__hero">
<div class="order-detail__hero-text">
<div class="order-detail__id">
<strong>{{ order.orderNumber }}</strong>
<StatusBadge :status="order.status" />
</div>
<p v-if="distinctRawStatusLabel" class="order-detail__raw-status">
{{ distinctRawStatusLabel }}
</p>
</div>
<div class="order-detail__actions no-print">
<button
v-if="showPricingAction"
type="button"
class="admin-btn admin-btn--sm"
:class="primaryAction === 'pricing' ? 'admin-btn--primary' : 'admin-btn--outline'"
@click="emit('pricing')"
>
قیمت‌گذاری سفارش
</button>
<button
v-if="showChecksAction"
type="button"
class="admin-btn admin-btn--sm"
:class="primaryAction === 'checks' ? 'admin-btn--primary' : 'admin-btn--outline'"
@click="emit('checks')"
>
مدیریت چک‌ها
</button>
<button
v-if="showCashAction"
type="button"
class="admin-btn admin-btn--sm"
:class="primaryAction === 'cash' ? 'admin-btn--primary' : 'admin-btn--outline'"
@click="emit('cashReceipt')"
>
رسید پرداخت
</button>
</div>
</header>
<div class="order-detail__panels">
<section class="order-detail__section">
<h3 class="order-detail__section-title">اطلاعات مشتری</h3>
<div class="order-detail__info">
<div class="order-detail__info-row">
<AdminIcon name="building" :size="16" />
<span class="order-detail__info-label">مشتری</span>
<span class="order-detail__info-value">{{ order.customerName }}</span>
</div>
<div class="order-detail__info-row">
<AdminIcon name="phone" :size="16" />
<span class="order-detail__info-label">تلفن</span>
<span class="order-detail__info-value order-detail__info-value--phone" dir="ltr">{{ order.customerPhone ?? '—' }}</span>
</div>
<div class="order-detail__info-row">
<AdminIcon name="id-card" :size="16" />
<span class="order-detail__info-label">شناسه ملی</span>
<span class="order-detail__info-value">{{ order.customerNationalId ?? '—' }}</span>
</div>
<div class="order-detail__info-row">
<AdminIcon name="user" :size="16" />
<span class="order-detail__info-label">نوع مشتری</span>
<span class="order-detail__info-value">{{ customerTypeLabel }}</span>
</div>
</div>
</section>
<section class="order-detail__section">
<h3 class="order-detail__section-title">اطلاعات سفارش</h3>
<div class="order-detail__info">
<div class="order-detail__info-row">
<span class="order-detail__info-label">شرکت</span>
<span class="order-detail__info-value">{{ order.companyName }}</span>
</div>
<div class="order-detail__info-row">
<span class="order-detail__info-label">روش تسویه</span>
<span class="order-detail__info-value">{{ settlementLabel(order.settlementMethod) }}</span>
</div>
<div class="order-detail__info-row">
<span class="order-detail__info-label">متراژ کل</span>
<span class="order-detail__info-value">{{ formatMeterage(order.meterage) }}</span>
</div>
<div class="order-detail__info-row">
<span class="order-detail__info-label">مبلغ کل</span>
<span class="order-detail__info-value">
{{ order.totalAmount != null ? formatCurrency(order.totalAmount) : 'پس از قیمت‌گذاری' }}
</span>
</div>
</div>
</section>
</div>
<section v-if="order.items?.length" class="order-detail__section">
<h3 class="order-detail__section-title">محصولات سفارش</h3>
<div v-if="useTable" class="admin-table-wrap order-detail__table">
<table class="admin-table admin-table--compact">
<thead>
<tr>
<th>شرکت</th>
<th>کالیته</th>
<th>محصول</th>
<th>رنگ</th>
<th>متراژ</th>
<th>قیمت</th>
<th>مبلغ</th>
</tr>
</thead>
<tbody>
<tr v-for="item in order.items" :key="item.id">
<td>{{ item.companyName || order.companyName }}</td>
<td>{{ item.catalogCode }}</td>
<td>{{ item.productName }}</td>
<td>{{ item.color }}</td>
<td>{{ formatNumber(item.confirmedMeterage ?? item.requestedMeterage) }}</td>
<td>{{ item.supplyPrice != null ? formatCurrency(item.supplyPrice) : '—' }}</td>
<td>{{ item.totalAmount != null ? formatCurrency(item.totalAmount) : '—' }}</td>
</tr>
</tbody>
</table>
</div>
<ul v-else class="order-detail__items">
<li v-for="item in order.items" :key="item.id" class="order-detail__item">
<div class="order-detail__item-head">
<strong>{{ item.productName }}</strong>
<span>{{ item.catalogCode }}</span>
</div>
<div class="order-detail__item-meta">
<span v-if="item.companyName">{{ item.companyName }}</span>
<span>{{ item.color }}</span>
<span>{{ formatMeterage(item.confirmedMeterage ?? item.requestedMeterage) }}</span>
<span v-if="item.supplyPrice != null">{{ formatCurrency(item.supplyPrice) }}</span>
<span v-if="item.totalAmount != null" class="order-detail__item-total">
{{ formatCurrency(item.totalAmount) }}
</span>
</div>
</li>
</ul>
</section>
<section class="order-detail__section order-detail__status no-print">
<div class="order-detail__status-head">
<h3 class="order-detail__section-title">تغییر وضعیت</h3>
<label class="order-detail__manual">
<input v-model="manualMode" type="checkbox" />
<span>تغییر دستی</span>
</label>
</div>
<p v-if="manualMode" class="order-detail__status-hint">
این گذر خارج از روال استاندارد است؛ ثبت یادداشت اجباری است.
</p>
<p v-else-if="!statusOptions.length" class="order-detail__status-hint">
در این وضعیت گذر خودکاری وجود ندارد. برای تغییر، حالت دستی را فعال کنید.
</p>
<div v-if="statusOptions.length" class="order-detail__status-form">
<div class="admin-form__field">
<label>وضعیت بعدی</label>
<AdminSelect
v-model="nextStatus"
block
aria-label="وضعیت بعدی"
placeholder="انتخاب کنید…"
:options="statusSelectOptions"
/>
</div>
<div class="admin-form__field">
<label for="status-note">
یادداشت{{ noteRequired ? ' (اجباری)' : ' (اختیاری)' }}
</label>
<input
id="status-note"
v-model="statusNote"
type="text"
class="admin-input"
placeholder="دلیل یا توضیح این تغییر"
/>
</div>
<button
type="button"
class="admin-btn admin-btn--primary admin-btn--sm"
:disabled="!canSubmitStatus || statusSaving"
@click="submitStatus"
>
{{ statusSaving ? 'در حال ثبت…' : 'ثبت وضعیت' }}
</button>
</div>
</section>
</div>
</template>
<style scoped>
.order-detail {
display: flex;
flex-direction: column;
gap: 16px;
}
.order-detail__hero {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-elevated, var(--color-bg));
}
.order-detail__id {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
margin-bottom: 0;
font-size: 1rem;
}
.order-detail__id strong {
line-height: 1.3;
}
.order-detail__raw-status {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.order-detail__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.order-detail__actions .admin-btn {
min-height: 40px;
}
.order-detail__panels {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.order-detail__section {
margin: 0;
padding: 14px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
}
.order-detail__section-title {
margin: 0 0 12px;
font-size: 0.8125rem;
font-weight: 700;
color: var(--color-text);
}
.order-detail__info {
display: grid;
gap: 10px;
}
.order-detail__info-row {
display: grid;
grid-template-columns: auto minmax(72px, auto) 1fr;
align-items: center;
gap: 8px;
font-size: 0.8125rem;
}
.order-detail__info-row:not(:has(svg)) {
grid-template-columns: minmax(72px, auto) 1fr;
}
.order-detail__info-label {
color: var(--color-text-muted);
}
.order-detail__info-value {
font-weight: 600;
color: var(--color-text);
word-break: break-word;
}
.order-detail__info-value--phone {
text-align: right;
justify-self: stretch;
}
.order-detail__table {
overflow-x: auto;
}
.order-detail__items {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 8px;
}
.order-detail__item {
padding: 12px;
border-radius: var(--radius-sm);
background: var(--color-elevated, var(--color-bg));
border: 1px solid var(--color-border);
}
.order-detail__item-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
font-size: 0.875rem;
}
.order-detail__item-head span {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.order-detail__item-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.order-detail__item-total {
margin-inline-start: auto;
font-weight: 700;
color: var(--color-text);
}
.order-detail__status-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.order-detail__status-head .order-detail__section-title {
margin: 0;
}
.order-detail__manual {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.8125rem;
color: var(--color-text-muted);
cursor: pointer;
}
.order-detail__manual input {
width: 15px;
height: 15px;
accent-color: var(--color-primary);
}
.order-detail__status-hint {
margin: 0 0 12px;
font-size: 0.75rem;
line-height: 1.7;
color: var(--color-text-muted);
}
.order-detail__status-form {
display: grid;
gap: 12px;
}
@media (max-width: 639px) {
.order-detail__panels {
grid-template-columns: 1fr;
}
.order-detail__hero {
flex-direction: column;
}
.order-detail__actions {
width: 100%;
}
.order-detail__actions .admin-btn {
flex: 1;
}
}
</style>
+623
View File
@@ -0,0 +1,623 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import BaseModal from '@/components/ui/BaseModal.vue'
import BaseSwitch from '@/components/ui/BaseSwitch.vue'
import BaseEmptyState from '@/components/ui/BaseEmptyState.vue'
import ActiveSwitchCell from '@/components/ui/ActiveSwitchCell.vue'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import AdminSelect from '@/components/ui/AdminSelect.vue'
import { catalogsApi, productsApi } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
import { formatCurrency, formatNumber } from '@/utils/format'
import type { Catalog, Product } from '@/types'
import AdminCurrencyInput from '@/components/ui/AdminCurrencyInput.vue'
import { fileToCatalogImage } from '@/utils/catalogImage'
const productList = ref<Product[]>([])
const catalogs = ref<Catalog[]>([])
const searchQuery = ref('')
const catalogFilter = ref('all')
const loading = ref(true)
const modalMode = ref<'add' | 'edit' | null>(null)
const editingId = ref<string | null>(null)
const formError = ref('')
const saving = ref(false)
const toast = useToast()
const catalogFilterOptions = computed(() => [
{ value: 'all', label: 'همه کالیته‌ها' },
...catalogs.value.map((catalog) => ({
value: catalog.id,
label: `${catalog.code} — ${catalog.name}`,
})),
])
const catalogFormOptions = computed(() =>
catalogs.value.map((catalog) => ({
value: catalog.id,
label: `${catalog.code} — ${catalog.name} (${catalog.companyName})`,
})),
)
const emptyForm = () => ({
catalogId: catalogs.value[0]?.id ?? '',
name: '',
color: '',
productCode: '',
minMeterage: 100,
orderStep: 50,
price: null as number | null,
internalStock: 0,
isActive: true,
usageCategory: 'other',
season: 'all_season',
composition: '',
weaveType: '',
subtitle: '',
rollMeters: 50,
isNew: false,
colorsText: '',
imageUrls: [] as string[],
})
const form = reactive(emptyForm())
const isModalOpen = computed(() => modalMode.value !== null)
const modalTitle = computed(() => (modalMode.value === 'edit' ? 'ویرایش محصول' : 'افزودن محصول'))
const isFormValid = computed(() => form.name.trim() && form.catalogId && form.minMeterage > 0)
const hasActiveFilters = computed(
() => searchQuery.value.trim().length > 0 || catalogFilter.value !== 'all',
)
const filteredProducts = computed(() => {
let list = productList.value
if (catalogFilter.value !== 'all') {
list = list.filter((p) => p.catalogId === catalogFilter.value)
}
const q = searchQuery.value.trim()
if (q) {
list = list.filter(
(p) =>
p.name.includes(q) ||
p.color.includes(q) ||
p.productCode.includes(q) ||
p.catalogCode.includes(q),
)
}
return list
})
async function loadData() {
loading.value = true
try {
const [productsData, catalogsData] = await Promise.all([
productsApi.list(),
catalogsApi.list(),
])
productList.value = productsData
catalogs.value = catalogsData
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری محصولات انجام نشد.')
} finally {
loading.value = false
}
}
onMounted(() => {
void loadData()
})
function catalogMeta(catalogId: string) {
return catalogs.value.find((c) => c.id === catalogId)
}
function openAddModal() {
modalMode.value = 'add'
editingId.value = null
Object.assign(form, emptyForm())
formError.value = ''
}
function openEditModal(product: Product) {
modalMode.value = 'edit'
editingId.value = product.id
form.catalogId = product.catalogId
form.name = product.name
form.color = product.color
form.productCode = product.productCode
form.minMeterage = product.minMeterage
form.orderStep = product.orderStep
form.price = product.price
form.internalStock = product.internalStock
form.isActive = product.isActive
form.usageCategory = (product as { usageCategory?: string }).usageCategory ?? 'other'
form.season = (product as { season?: string }).season ?? 'all_season'
form.composition = (product as { composition?: string }).composition ?? ''
form.weaveType = (product as { weaveType?: string }).weaveType ?? ''
form.subtitle = (product as { subtitle?: string }).subtitle ?? ''
form.rollMeters = (product as { rollMeters?: number }).rollMeters ?? 50
form.isNew = (product as { isNew?: boolean }).isNew ?? false
form.colorsText = (product.colors ?? []).join(', ')
form.imageUrls = [...(product.imageUrls ?? [])]
formError.value = ''
}
function parseListText(value: string): string[] {
return value.split(',').map((part) => part.trim()).filter(Boolean)
}
const imageBusy = ref(false)
async function addProductImages(event: Event) {
const input = event.target as HTMLInputElement
const files = [...(input.files ?? [])]
input.value = ''
if (!files.length) return
imageBusy.value = true
formError.value = ''
try {
const next = await Promise.all(files.map((file) => fileToCatalogImage(file)))
form.imageUrls = [...form.imageUrls, ...next]
} catch (e) {
formError.value = e instanceof Error ? e.message : 'بارگذاری تصویر ناموفق بود.'
} finally {
imageBusy.value = false
}
}
function removeProductImage(index: number) {
form.imageUrls.splice(index, 1)
}
function closeModal() {
modalMode.value = null
editingId.value = null
formError.value = ''
}
async function submitProduct() {
if (!isFormValid.value) {
formError.value = 'نام محصول، کالیته و حداقل متراژ الزامی هستند.'
return
}
const cat = catalogMeta(form.catalogId)
if (!cat) {
formError.value = 'کالیته معتبر انتخاب کنید.'
return
}
const catalogPayload = {
usageCategory: form.usageCategory,
season: form.season,
composition: form.composition.trim() || null,
weaveType: form.weaveType.trim() || null,
subtitle: form.subtitle.trim() || null,
rollMeters: form.rollMeters,
isNew: form.isNew,
colorsJson: parseListText(form.colorsText),
imageUrlsJson: form.imageUrls,
}
const productCode = form.productCode.trim() || `${cat.code}-NEW`
saving.value = true
try {
if (modalMode.value === 'edit' && editingId.value) {
const updated = await productsApi.update(editingId.value, {
catalogId: form.catalogId,
name: form.name.trim(),
color: form.color.trim() || '—',
code: productCode,
productCode,
minMeterage: form.minMeterage,
orderStep: form.orderStep,
price: form.price,
internalStock: form.internalStock,
isActive: form.isActive,
...catalogPayload,
})
const index = productList.value.findIndex((p) => p.id === editingId.value)
if (index !== -1) productList.value[index] = { ...productList.value[index]!, ...updated }
toast.success('اطلاعات محصول به‌روزرسانی شد.')
} else {
const created = await productsApi.create({
companyId: cat.companyId,
catalogId: form.catalogId,
name: form.name.trim(),
color: form.color.trim() || '—',
code: productCode,
productCode,
minMeterage: form.minMeterage,
orderStep: form.orderStep,
price: form.price,
internalStock: form.internalStock,
isActive: form.isActive,
...catalogPayload,
})
productList.value.push(created)
toast.success('محصول جدید ثبت شد.')
}
closeModal()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ذخیره محصول انجام نشد.')
} finally {
saving.value = false
}
}
async function persistActive(id: string, isActive: boolean) {
await productsApi.setActive(id, isActive)
}
function clearFilters() {
searchQuery.value = ''
catalogFilter.value = 'all'
}
</script>
<template>
<div class="products-page">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">محصولات</h1>
</div>
<button type="button" class="admin-btn admin-btn--primary" @click="openAddModal">
افزودن محصول
</button>
</div>
<div class="products-page__filters admin-card">
<div class="products-page__search">
<AdminIcon name="search" :size="18" />
<input
v-model="searchQuery"
type="search"
class="products-page__search-input"
placeholder="جستجو در نام، رنگ، کد..."
/>
</div>
<AdminSelect
v-model="catalogFilter"
class="products-page__catalog-filter"
aria-label="فیلتر کالیته"
:options="catalogFilterOptions"
/>
</div>
<AdminPageSkeleton v-if="loading" variant="table" />
<BaseEmptyState
v-else-if="!filteredProducts.length"
class="admin-card"
icon="products"
:title="hasActiveFilters ? 'نتیجه‌ای یافت نشد' : 'محصولی ثبت نشده'"
:description="
hasActiveFilters
? 'عبارت جستجو را تغییر دهید یا فیلترها را پاک کنید.'
: 'برای شروع، اولین محصول را ثبت کنید.'
"
>
<button v-if="hasActiveFilters" type="button" class="admin-btn admin-btn--outline admin-btn--sm" @click="clearFilters">
پاک کردن فیلترها
</button>
<button v-else type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="openAddModal">
افزودن محصول
</button>
</BaseEmptyState>
<div v-else class="products-page__table admin-card desktop-only">
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>تصویر</th>
<th>کد محصول</th>
<th>نام</th>
<th>رنگ</th>
<th>کالیته</th>
<th>شرکت</th>
<th class="numeric">حداقل متراژ</th>
<th class="numeric">ضریب</th>
<th class="numeric">قیمت</th>
<th class="numeric">موجودی</th>
<th class="status">وضعیت</th>
<th class="actions">عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="product in filteredProducts" :key="product.id">
<td>
<span
class="product-thumb"
:class="{ 'product-thumb--empty': !product.imageUrls?.[0] }"
:style="product.imageUrls?.[0] ? { backgroundImage: `url(${product.imageUrls[0]})` } : undefined"
/>
</td>
<td>{{ product.productCode }}</td>
<td>{{ product.name }}</td>
<td>{{ product.color }}</td>
<td>{{ product.catalogCode }}</td>
<td>{{ product.companyName }}</td>
<td class="numeric">{{ formatNumber(product.minMeterage) }} متر</td>
<td class="numeric">{{ formatNumber(product.orderStep) }}</td>
<td class="numeric">{{ product.price ? formatCurrency(product.price) : '—' }}</td>
<td class="numeric">{{ formatNumber(product.internalStock) }} متر</td>
<td class="status">
<ActiveSwitchCell
:id="product.id"
v-model="product.isActive"
:entity-name="product.name"
entity-type="محصول"
:persist="persistActive"
/>
</td>
<td class="actions">
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(product)">
ویرایش
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="!loading && filteredProducts.length" class="compact-only admin-card-list">
<article v-for="product in filteredProducts" :key="product.id" class="admin-card-item">
<div class="admin-card-item__header">
<p class="admin-card-item__title">{{ product.name }}</p>
<div class="admin-card-item__header-actions">
<ActiveSwitchCell
:id="product.id"
v-model="product.isActive"
:entity-name="product.name"
entity-type="محصول"
:persist="persistActive"
/>
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(product)">
ویرایش
</button>
</div>
</div>
<div class="admin-card-item__meta">
<span>{{ product.productCode }}</span>
<span>{{ product.color }}</span>
<span>کالیته {{ product.catalogCode }}</span>
<span>{{ product.price ? formatCurrency(product.price) : 'بدون قیمت' }}</span>
</div>
</article>
</div>
<BaseModal :open="isModalOpen" :title="modalTitle" size="lg" @close="closeModal">
<form class="admin-form" @submit.prevent="submitProduct">
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
<div class="admin-form__field">
<label>کالیته <span class="admin-form__required">*</span></label>
<AdminSelect
v-model="form.catalogId"
block
aria-label="انتخاب کالیته"
placeholder="انتخاب کالیته"
:options="catalogFormOptions"
/>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="product-name">نام محصول <span class="admin-form__required">*</span></label>
<input id="product-name" v-model="form.name" type="text" class="admin-input" />
</div>
<div class="admin-form__field">
<label for="product-color">رنگ / نوع</label>
<input id="product-color" v-model="form.color" type="text" class="admin-input" />
</div>
</div>
<div class="admin-form__field">
<label for="product-code">کد محصول</label>
<input id="product-code" v-model="form.productCode" type="text" class="admin-input" dir="ltr" />
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="product-min">حداقل متراژ</label>
<input id="product-min" v-model.number="form.minMeterage" type="number" min="1" class="admin-input" />
</div>
<div class="admin-form__field">
<label for="product-step">ضریب سفارش</label>
<input id="product-step" v-model.number="form.orderStep" type="number" min="1" class="admin-input" />
</div>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="product-price">قیمت (تومان)</label>
<AdminCurrencyInput id="product-price" v-model="form.price" />
</div>
<div class="admin-form__field">
<label for="product-stock">موجودی داخلی (متر)</label>
<input id="product-stock" v-model.number="form.internalStock" type="number" min="0" class="admin-input" />
</div>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="product-usage">کاربرد</label>
<select id="product-usage" v-model="form.usageCategory" class="admin-input">
<option value="trousers">شلوار</option>
<option value="suit">کت‌وشلوار</option>
<option value="shirt">پیراهن</option>
<option value="other">سایر</option>
</select>
</div>
<div class="admin-form__field">
<label for="product-season">فصل</label>
<select id="product-season" v-model="form.season" class="admin-input">
<option value="summer">تابستان</option>
<option value="winter">زمستان</option>
<option value="all_season">چهارفصل</option>
</select>
</div>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="product-composition">جنس / ترکیب</label>
<input id="product-composition" v-model="form.composition" type="text" class="admin-input" />
</div>
<div class="admin-form__field">
<label for="product-weave">نوع بافت</label>
<input id="product-weave" v-model="form.weaveType" type="text" class="admin-input" />
</div>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="product-subtitle">زیرعنوان</label>
<input id="product-subtitle" v-model="form.subtitle" type="text" class="admin-input" />
</div>
<div class="admin-form__field">
<label for="product-roll">متراژ طاقه</label>
<input id="product-roll" v-model.number="form.rollMeters" type="number" min="1" class="admin-input" />
</div>
</div>
<div class="admin-form__field">
<label for="product-colors">رنگ‌ها (با ویرگول)</label>
<input
id="product-colors"
v-model="form.colorsText"
type="text"
class="admin-input"
placeholder="مثلاً: سرمه‌ای، خاکستری، بژ"
/>
</div>
<div class="admin-form__field">
<label>تصاویر محصول</label>
<div class="product-images">
<div
v-for="(src, index) in form.imageUrls"
:key="`${index}-${src.slice(0, 24)}`"
class="product-images__item"
>
<span class="product-images__thumb" :style="{ backgroundImage: `url(${src})` }" />
<button type="button" class="product-images__remove" @click="removeProductImage(index)">
حذف
</button>
</div>
<label class="product-images__add">
<input type="file" accept="image/*" multiple hidden :disabled="imageBusy" @change="addProductImages" />
{{ imageBusy ? '...' : '+ افزودن تصویر' }}
</label>
</div>
</div>
<div class="admin-form__field">
<BaseSwitch v-model="form.isNew" label="محصول جدید" />
</div>
<div class="admin-form__field">
<BaseSwitch v-model="form.isActive" label="وضعیت" />
</div>
</form>
<template #footer>
<button type="button" class="admin-btn admin-btn--outline" @click="closeModal">انصراف</button>
<button type="button" class="admin-btn admin-btn--primary" :disabled="!isFormValid || saving" @click="submitProduct">
{{ modalMode === 'edit' ? 'ذخیره تغییرات' : 'ثبت محصول' }}
</button>
</template>
</BaseModal>
</div>
</template>
<style scoped>
.products-page__filters {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 12px 14px;
margin-bottom: 16px;
align-items: center;
}
.products-page__search {
flex: 1;
min-width: 200px;
display: flex;
align-items: center;
gap: 10px;
color: var(--color-text-muted);
}
.products-page__search-input {
flex: 1;
border: none;
background: transparent;
font-size: 0.875rem;
outline: none;
}
.products-page__catalog-filter {
width: min(260px, 100%);
}
.products-page__table {
overflow: hidden;
}
.product-thumb {
display: block;
width: 44px;
height: 36px;
border-radius: 8px;
background: #e8eceb center / cover no-repeat;
}
.product-thumb--empty {
background: #eef1f0;
}
.product-images {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.product-images__item {
display: flex;
flex-direction: column;
gap: 6px;
width: 92px;
}
.product-images__thumb {
display: block;
width: 92px;
height: 72px;
border-radius: 10px;
background: #e8eceb center / cover no-repeat;
border: 1px solid var(--color-border);
}
.product-images__remove,
.product-images__add {
border: 1px dashed var(--color-border);
background: transparent;
border-radius: 8px;
padding: 6px 8px;
font-size: 0.75rem;
cursor: pointer;
text-align: center;
}
.product-images__add {
display: grid;
place-items: center;
min-width: 92px;
min-height: 72px;
color: var(--color-text-muted);
}
</style>
+332
View File
@@ -0,0 +1,332 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import { dashboardApi, type DashboardStats, type ReportsResponse } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
import { formatCurrency, formatMeterage, formatNumber } from '@/utils/format'
const toast = useToast()
const loading = ref(true)
const dashboardStats = ref<DashboardStats>({
newOrders: 0,
pendingReview: 0,
pendingCheck: 0,
finalApproval: 0,
todaySales: 0,
todaySalesGrowth: 0,
monthSales: 0,
})
const reportSalesByCompany = ref<ReportsResponse['reportSalesByCompany']>([])
const reportOrdersByStatus = ref<ReportsResponse['reportOrdersByStatus']>([])
const reportSalesByMonth = ref<NonNullable<ReportsResponse['reportSalesByMonth']>>([])
const reportSalesByProduct = ref<NonNullable<ReportsResponse['reportSalesByProduct']>>([])
const reportSalesByCustomer = ref<NonNullable<ReportsResponse['reportSalesByCustomer']>>([])
const reportCustomers = ref<ReportsResponse['reportCustomers'] | null>(null)
onMounted(async () => {
loading.value = true
try {
const [dashboard, reports] = await Promise.all([
dashboardApi.get(),
dashboardApi.reports(),
])
dashboardStats.value = dashboard.stats
reportSalesByCompany.value = reports.reportSalesByCompany
reportOrdersByStatus.value = reports.reportOrdersByStatus
reportSalesByMonth.value = reports.reportSalesByMonth ?? []
reportSalesByProduct.value = reports.reportSalesByProduct ?? []
reportSalesByCustomer.value = reports.reportSalesByCustomer ?? []
reportCustomers.value = reports.reportCustomers ?? null
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری گزارش‌ها انجام نشد.')
} finally {
loading.value = false
}
})
function printReport() {
window.print()
}
</script>
<template>
<div class="reports-page">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">گزارش‌ها</h1>
</div>
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm" @click="printReport">
<AdminIcon name="print" :size="16" />
چاپ گزارش
</button>
</div>
<AdminPageSkeleton v-if="loading" variant="table" />
<template v-else>
<div class="reports-page__stats">
<div class="admin-stat-card admin-card">
<p class="admin-stat-card__label">فروش امروز</p>
<p class="admin-stat-card__value">{{ formatCurrency(dashboardStats.todaySales) }}</p>
<span class="reports-page__growth">+{{ dashboardStats.todaySalesGrowth }}% نسبت به دیروز</span>
</div>
<div class="admin-stat-card admin-card">
<p class="admin-stat-card__label">فروش ماه جاری</p>
<p class="admin-stat-card__value">{{ formatCurrency(dashboardStats.monthSales) }}</p>
</div>
<div class="admin-stat-card admin-card">
<p class="admin-stat-card__label">سفارش‌های جدید</p>
<p class="admin-stat-card__value">{{ formatNumber(dashboardStats.newOrders) }}</p>
</div>
<div class="admin-stat-card admin-card">
<p class="admin-stat-card__label">در انتظار بررسی</p>
<p class="admin-stat-card__value">{{ formatNumber(dashboardStats.pendingReview) }}</p>
</div>
</div>
<div v-if="reportCustomers" class="reports-page__stats">
<div class="admin-stat-card admin-card">
<p class="admin-stat-card__label">مشتریان فعال</p>
<p class="admin-stat-card__value">{{ formatNumber(reportCustomers.active) }}</p>
</div>
<div class="admin-stat-card admin-card">
<p class="admin-stat-card__label">غیرفعال</p>
<p class="admin-stat-card__value">{{ formatNumber(reportCustomers.inactive) }}</p>
</div>
<div class="admin-stat-card admin-card">
<p class="admin-stat-card__label">جدید ۳۰ روز</p>
<p class="admin-stat-card__value">{{ formatNumber(reportCustomers.newLast30Days) }}</p>
</div>
<div class="admin-stat-card admin-card">
<p class="admin-stat-card__label">بدون خرید</p>
<p class="admin-stat-card__value">{{ formatNumber(reportCustomers.noPurchase) }}</p>
</div>
<div class="admin-stat-card admin-card">
<p class="admin-stat-card__label">بدون خرید ۹۰ روز</p>
<p class="admin-stat-card__value">{{ formatNumber(reportCustomers.noPurchase90Days) }}</p>
</div>
<div class="admin-stat-card admin-card">
<p class="admin-stat-card__label">دارای واسطه</p>
<p class="admin-stat-card__value">{{ formatNumber(reportCustomers.withAgent) }}</p>
</div>
</div>
<div class="reports-page__grid">
<section class="admin-card reports-page__section">
<h2 class="reports-page__section-title">فروش به تفکیک شرکت</h2>
<div class="admin-table-wrap desktop-only">
<table class="admin-table">
<thead>
<tr>
<th>شرکت</th>
<th>تعداد سفارش</th>
<th>مجموع متراژ</th>
<th>مجموع مبلغ</th>
</tr>
</thead>
<tbody>
<tr v-for="row in reportSalesByCompany" :key="row.companyName">
<td>{{ row.companyName }}</td>
<td>{{ formatNumber(row.orderCount) }}</td>
<td>{{ formatMeterage(row.meterage) }}</td>
<td>{{ formatCurrency(row.totalAmount) }}</td>
</tr>
</tbody>
</table>
</div>
<div class="compact-only admin-card-list">
<article v-for="row in reportSalesByCompany" :key="row.companyName" class="admin-card-item">
<p class="admin-card-item__title">{{ row.companyName }}</p>
<div class="admin-card-item__meta">
<span>{{ row.orderCount }} سفارش</span>
<span>{{ formatMeterage(row.meterage) }}</span>
<span>{{ formatCurrency(row.totalAmount) }}</span>
</div>
</article>
</div>
</section>
<section class="admin-card reports-page__section">
<h2 class="reports-page__section-title">سفارش‌ها به تفکیک وضعیت</h2>
<div class="admin-table-wrap desktop-only">
<table class="admin-table">
<thead>
<tr>
<th>وضعیت</th>
<th>تعداد</th>
</tr>
</thead>
<tbody>
<tr v-for="row in reportOrdersByStatus" :key="row.status">
<td>{{ row.status }}</td>
<td>{{ formatNumber(row.count) }}</td>
</tr>
</tbody>
</table>
</div>
<ul class="compact-only reports-page__status-list">
<li v-for="row in reportOrdersByStatus" :key="row.status">
<span>{{ row.status }}</span>
<strong>{{ formatNumber(row.count) }}</strong>
</li>
</ul>
</section>
</div>
<div v-if="reportSalesByMonth.length" class="reports-page__grid reports-page__grid--full">
<section class="admin-card reports-page__section">
<h2 class="reports-page__section-title">فروش ماهانه</h2>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>ماه</th>
<th>تعداد سفارش</th>
<th>مجموع متراژ</th>
<th>مجموع مبلغ</th>
</tr>
</thead>
<tbody>
<tr v-for="row in reportSalesByMonth" :key="row.month">
<td>{{ row.month }}</td>
<td>{{ formatNumber(row.orderCount) }}</td>
<td>{{ formatMeterage(row.meterage) }}</td>
<td>{{ formatCurrency(row.totalAmount) }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
<div
v-if="reportSalesByProduct.length || reportSalesByCustomer.length"
class="reports-page__grid"
>
<section v-if="reportSalesByProduct.length" class="admin-card reports-page__section">
<h2 class="reports-page__section-title">فروش به تفکیک محصول</h2>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>محصول</th>
<th>کد</th>
<th>تعداد سفارش</th>
<th>متراژ</th>
<th>مبلغ</th>
</tr>
</thead>
<tbody>
<tr v-for="row in reportSalesByProduct" :key="row.productId">
<td>{{ row.productName }}</td>
<td dir="ltr">{{ row.productCode }}</td>
<td>{{ formatNumber(row.orderCount) }}</td>
<td>{{ formatMeterage(row.meterage) }}</td>
<td>{{ formatCurrency(row.totalAmount) }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<section v-if="reportSalesByCustomer.length" class="admin-card reports-page__section">
<h2 class="reports-page__section-title">فروش به تفکیک مشتری</h2>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>مشتری</th>
<th>تعداد سفارش</th>
<th>متراژ</th>
<th>مبلغ</th>
</tr>
</thead>
<tbody>
<tr v-for="row in reportSalesByCustomer" :key="row.customerName">
<td>{{ row.customerName }}</td>
<td>{{ formatNumber(row.orderCount) }}</td>
<td>{{ formatMeterage(row.meterage) }}</td>
<td>{{ formatCurrency(row.totalAmount) }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</template>
</div>
</template>
<style scoped>
.reports-page__stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.reports-page__growth {
font-size: 0.75rem;
color: var(--color-success-dark);
margin-top: 6px;
display: block;
}
.reports-page__grid {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.reports-page__grid--full {
grid-template-columns: 1fr;
}
.reports-page__section {
padding: 20px;
overflow: hidden;
}
.reports-page__section-title {
margin: 0 0 16px;
font-size: 1rem;
font-weight: 700;
}
.reports-page__status-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.reports-page__status-list li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: var(--color-border-light);
border-radius: var(--radius-sm);
font-size: 0.875rem;
}
@media (max-width: 1279px) {
.reports-page__stats {
grid-template-columns: repeat(2, 1fr);
}
.reports-page__grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 767px) {
.reports-page__stats {
grid-template-columns: 1fr;
}
}
</style>
+345
View File
@@ -0,0 +1,345 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import { useToast } from '@/composables/useToast'
import {
customersApi,
settingsApi,
notificationsApi,
type UpdateSettingsPayload,
} from '@/services/api'
import type { Customer } from '@/types'
const toast = useToast()
const settings = reactive<UpdateSettingsPayload>({
maxMeteragePerInvoice: 5000,
cashInvoiceValidityHours: 8,
smsOrderRegistered: true,
smsPricingReady: true,
smsPaymentReminder: true,
smsCheckReview: true,
smsShippingUpdate: true,
defaultSettlementCheck: true,
defaultSettlementCash: true,
minOrderRollsPerProduct: 1,
showCatalogPublicPrice: false,
allowAgentPricing: false,
agentMinUnitPrice: null,
supportPhone: '',
supportEmail: '',
})
const broadcast = reactive({ title: '', body: '' })
const broadcasting = ref(false)
const customers = ref<Customer[]>([])
const selectedCustomerIds = ref<string[]>([])
const loading = ref(true)
const saving = ref(false)
const broadcastButtonLabel = computed(() => {
if (broadcasting.value) return 'در حال ارسال…'
if (selectedCustomerIds.value.length) {
return `ارسال به ${selectedCustomerIds.value.length} مشتری انتخاب‌شده`
}
return 'ارسال به همه مشتریان فعال'
})
function toggleCustomer(id: string, checked: boolean) {
if (checked) {
if (!selectedCustomerIds.value.includes(id)) selectedCustomerIds.value.push(id)
} else {
selectedCustomerIds.value = selectedCustomerIds.value.filter((cid) => cid !== id)
}
}
onMounted(async () => {
loading.value = true
try {
const [data, customerList] = await Promise.all([settingsApi.get(), customersApi.list()])
Object.assign(settings, {
maxMeteragePerInvoice: data.maxMeteragePerInvoice,
cashInvoiceValidityHours: data.cashInvoiceValidityHours,
smsOrderRegistered: data.smsOrderRegistered,
smsPricingReady: data.smsPricingReady,
smsPaymentReminder: data.smsPaymentReminder,
smsCheckReview: data.smsCheckReview,
smsShippingUpdate: data.smsShippingUpdate,
defaultSettlementCheck: data.defaultSettlementCheck,
defaultSettlementCash: data.defaultSettlementCash,
minOrderRollsPerProduct: data.minOrderRollsPerProduct ?? 1,
showCatalogPublicPrice: data.showCatalogPublicPrice ?? false,
allowAgentPricing: data.allowAgentPricing ?? false,
agentMinUnitPrice: data.agentMinUnitPrice ?? null,
supportPhone: data.supportPhone ?? '',
supportEmail: data.supportEmail ?? '',
})
customers.value = customerList
} catch (e) {
toast.error(e instanceof Error ? e.message : 'دریافت تنظیمات ناموفق بود.')
} finally {
loading.value = false
}
})
async function saveSettings() {
if (saving.value) return
saving.value = true
try {
await settingsApi.update({ ...settings })
toast.success('تنظیمات با موفقیت ذخیره شد.')
} catch (e) {
toast.error(e instanceof Error ? e.message : 'ذخیره تنظیمات ناموفق بود.')
} finally {
saving.value = false
}
}
async function sendBroadcast() {
if (!broadcast.title.trim() || !broadcast.body.trim() || broadcasting.value) return
broadcasting.value = true
try {
const payload: { title: string; body: string; customerIds?: string[] } = {
title: broadcast.title.trim(),
body: broadcast.body.trim(),
}
if (selectedCustomerIds.value.length) {
payload.customerIds = [...selectedCustomerIds.value]
}
const res = await notificationsApi.broadcast(payload)
toast.success(`اعلان برای ${res.sent} مشتری ارسال شد.`)
broadcast.title = ''
broadcast.body = ''
selectedCustomerIds.value = []
} catch (e) {
toast.error(e instanceof Error ? e.message : 'ارسال اعلان ناموفق بود.')
} finally {
broadcasting.value = false
}
}
</script>
<template>
<div class="settings-page">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">تنظیمات</h1>
</div>
<button
type="button"
class="admin-btn admin-btn--primary"
:disabled="loading || saving"
@click="saveSettings"
>
{{ saving ? 'در حال ذخیره…' : 'ذخیره تنظیمات' }}
</button>
</div>
<AdminPageSkeleton v-if="loading" variant="form" :rows="8" />
<div v-else class="settings-page__grid">
<section class="admin-card settings-page__section">
<h2 class="settings-page__title">تنظیمات عمومی سفارش</h2>
<div class="admin-form__field">
<label for="max-meterage">حداکثر متراژ هر فاکتور (متر)</label>
<input
id="max-meterage"
v-model.number="settings.maxMeteragePerInvoice"
type="number"
min="1000"
class="admin-input"
/>
</div>
<div class="admin-form__field">
<label for="validity-hours">اعتبار فاکتور نقدی (ساعت)</label>
<input
id="validity-hours"
v-model.number="settings.cashInvoiceValidityHours"
type="number"
min="1"
class="admin-input"
/>
</div>
<div class="admin-form__field">
<label for="min-rolls">حداقل طاقه از هر محصول</label>
<input
id="min-rolls"
v-model.number="settings.minOrderRollsPerProduct"
type="number"
min="1"
class="admin-input"
/>
</div>
<label class="settings-page__check">
<input v-model="settings.showCatalogPublicPrice" type="checkbox" />
نمایش قیمت عمومی در کاتالوگ
</label>
<label class="settings-page__check">
<input v-model="settings.allowAgentPricing" type="checkbox" />
اجازه قیمت‌گذاری توسط واسطه
</label>
<div class="admin-form__field">
<label for="agent-min-price">حداقل قیمت واحد واسطه (تومان)</label>
<input
id="agent-min-price"
v-model.number="settings.agentMinUnitPrice"
type="number"
min="0"
class="admin-input"
:disabled="!settings.allowAgentPricing"
/>
</div>
<div class="settings-page__checks">
<label class="settings-page__check">
<input v-model="settings.defaultSettlementCheck" type="checkbox" />
<span>فعال‌سازی تسویه چکی</span>
</label>
<label class="settings-page__check">
<input v-model="settings.defaultSettlementCash" type="checkbox" />
<span>فعال‌سازی تسویه نقدی</span>
</label>
</div>
</section>
<section class="admin-card settings-page__section">
<h2 class="settings-page__title">پیامک و اعلان</h2>
<div class="settings-page__checks">
<label class="settings-page__check">
<input v-model="settings.smsOrderRegistered" type="checkbox" />
<span>پیامک ثبت سفارش</span>
</label>
<label class="settings-page__check">
<input v-model="settings.smsPricingReady" type="checkbox" />
<span>پیامک صدور فاکتور و آماده‌بودن قیمت</span>
</label>
<label class="settings-page__check">
<input v-model="settings.smsPaymentReminder" type="checkbox" />
<span>یادآوری پایان اعتبار فاکتور</span>
</label>
<label class="settings-page__check">
<input v-model="settings.smsCheckReview" type="checkbox" />
<span>اعلان بررسی چک</span>
</label>
<label class="settings-page__check">
<input v-model="settings.smsShippingUpdate" type="checkbox" />
<span>اعلان وضعیت ارسال</span>
</label>
</div>
</section>
<section class="admin-card settings-page__section">
<h2 class="settings-page__title">اطلاعات تماس پشتیبانی</h2>
<div class="admin-form__field">
<label for="support-phone">تلفن پشتیبانی</label>
<input id="support-phone" v-model="settings.supportPhone" type="text" class="admin-input" />
</div>
<div class="admin-form__field">
<label for="support-email">ایمیل پشتیبانی</label>
<input id="support-email" v-model="settings.supportEmail" type="email" class="admin-input" dir="ltr" />
</div>
</section>
<section class="admin-card settings-page__section">
<h2 class="settings-page__title">اعلان هدفمند</h2>
<div class="admin-form__field">
<label for="broadcast-title">عنوان</label>
<input id="broadcast-title" v-model="broadcast.title" type="text" class="admin-input" />
</div>
<div class="admin-form__field">
<label for="broadcast-body">متن</label>
<textarea id="broadcast-body" v-model="broadcast.body" class="admin-input" rows="4" />
</div>
<div v-if="customers.length" class="admin-form__field">
<label>گیرندگان (بدون انتخاب = همه)</label>
<div class="settings-page__customers">
<label v-for="customer in customers" :key="customer.id" class="settings-page__check">
<input
type="checkbox"
:checked="selectedCustomerIds.includes(customer.id)"
@change="toggleCustomer(customer.id, ($event.target as HTMLInputElement).checked)"
/>
<span>{{ customer.name }} <span class="settings-page__phone" dir="ltr">{{ customer.phone }}</span></span>
</label>
</div>
</div>
<button
type="button"
class="admin-btn admin-btn--outline"
:disabled="broadcasting || !broadcast.title.trim() || !broadcast.body.trim()"
@click="sendBroadcast"
>
{{ broadcastButtonLabel }}
</button>
</section>
</div>
</div>
</template>
<style scoped>
.settings-page__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.settings-page__section {
padding: 20px;
}
.settings-page__title {
margin: 0 0 16px;
font-size: 1rem;
font-weight: 700;
}
.settings-page__checks {
display: flex;
flex-direction: column;
gap: 12px;
}
.settings-page__check {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.875rem;
cursor: pointer;
}
.settings-page__check input {
width: 16px;
height: 16px;
accent-color: var(--color-primary);
}
.settings-page__customers {
max-height: 220px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0;
}
.settings-page__phone {
color: var(--color-text-muted);
font-size: 0.8125rem;
margin-right: 6px;
}
@media (max-width: 1279px) {
.settings-page__grid {
grid-template-columns: 1fr;
}
}
</style>
+361
View File
@@ -0,0 +1,361 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import BaseModal from '@/components/ui/BaseModal.vue'
import AdminSelect from '@/components/ui/AdminSelect.vue'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import { mapAdminOrder, ordersApi, shipmentsApi } from '@/services/api'
import { ApiError } from '@/services/http'
import { SHIPMENT_STATUS_BADGE, SHIPMENT_STATUS_LABELS } from '@/utils/adminLabels'
import { formatDateTime, formatMeterage } from '@/utils/format'
import { useToast } from '@/composables/useToast'
import type { Order, Shipment, ShipmentStatus } from '@/types'
const route = useRoute()
const shipmentList = ref<Shipment[]>([])
const orders = ref<Order[]>([])
const searchQuery = ref(typeof route.query.order === 'string' ? route.query.order : '')
const statusFilter = ref<'all' | ShipmentStatus>('all')
const loading = ref(true)
const showModal = ref(false)
const formError = ref('')
const saving = ref(false)
const toast = useToast()
const statusFilterOptions = [
{ value: 'all', label: 'همه وضعیت‌ها' },
{ value: 'preparing', label: 'در حال آماده‌سازی' },
{ value: 'ready', label: 'آماده ارسال' },
{ value: 'shipped', label: 'ارسال شده' },
{ value: 'delivered', label: 'تحویل شده' },
]
const shipmentStatusOptions = [
{ value: 'preparing', label: 'در حال آماده‌سازی' },
{ value: 'ready', label: 'آماده ارسال' },
{ value: 'shipped', label: 'ارسال شده' },
{ value: 'delivered', label: 'تحویل شده' },
]
const emptyForm = () => ({
orderId: '',
carrier: '',
trackingNumber: '',
meterage: 1000,
status: 'preparing' as ShipmentStatus,
})
const form = reactive(emptyForm())
const filteredShipments = computed(() => {
let list = shipmentList.value
if (statusFilter.value !== 'all') {
list = list.filter((s) => s.status === statusFilter.value)
}
const q = searchQuery.value.trim()
if (q) {
list = list.filter(
(s) =>
s.orderNumber.includes(q) ||
s.customerName.includes(q) ||
s.trackingNumber.includes(q) ||
s.carrier.includes(q),
)
}
return list
})
const isFormValid = computed(() => form.orderId.trim() && form.carrier.trim())
const availableOrders = computed(() => {
const shippedIds = new Set(shipmentList.value.map((s) => s.orderId))
return orders.value.filter((o) => !shippedIds.has(o.id))
})
const orderSelectOptions = computed(() =>
availableOrders.value.map((order) => ({
value: order.id,
label: `${order.orderNumber} — ${order.customerName}`,
})),
)
async function loadData() {
loading.value = true
try {
const [shipmentsData, ordersData] = await Promise.all([
shipmentsApi.list(),
ordersApi.list(),
])
shipmentList.value = shipmentsData
orders.value = ordersData.map((o) => mapAdminOrder(o) as Order)
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری ارسال‌ها انجام نشد.')
} finally {
loading.value = false
}
}
onMounted(() => {
void loadData()
})
watch(
() => route.query.order,
(orderRef) => {
if (typeof orderRef === 'string' && orderRef.trim()) {
searchQuery.value = orderRef.trim()
}
},
)
function onOrderChange() {
const order = orders.value.find((o) => o.id === form.orderId)
if (order) form.meterage = order.meterage
}
function openAddModal() {
Object.assign(form, emptyForm())
formError.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
formError.value = ''
}
async function submitShipment() {
if (!isFormValid.value) {
formError.value = 'سفارش و باربری الزامی هستند.'
return
}
saving.value = true
try {
const created = await shipmentsApi.create({
orderId: form.orderId,
carrier: form.carrier.trim(),
trackingNumber: form.trackingNumber.trim() || undefined,
meterage: form.meterage,
status: form.status,
})
shipmentList.value.unshift(created)
toast.success('بارنامه ثبت شد.')
closeModal()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ثبت بارنامه انجام نشد.')
} finally {
saving.value = false
}
}
async function advanceStatus(shipment: Shipment) {
try {
const updated = await shipmentsApi.advanceStatus(shipment.id)
const index = shipmentList.value.findIndex((s) => s.id === shipment.id)
if (index !== -1) shipmentList.value[index] = updated
toast.success('وضعیت ارسال به‌روزرسانی شد.')
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'به‌روزرسانی وضعیت انجام نشد.')
}
}
</script>
<template>
<div class="shipping-page">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">ارسال و باربری</h1>
</div>
<button type="button" class="admin-btn admin-btn--primary" @click="openAddModal">
ثبت بارنامه
</button>
</div>
<div class="shipping-page__filters admin-card">
<div class="shipping-page__search">
<AdminIcon name="search" :size="18" />
<input
v-model="searchQuery"
type="search"
class="shipping-page__search-input"
placeholder="جستجو در سفارش، مشتری، باربری..."
/>
</div>
<AdminSelect
v-model="statusFilter"
class="shipping-page__status-select"
aria-label="فیلتر وضعیت ارسال"
:options="statusFilterOptions"
/>
</div>
<AdminPageSkeleton v-if="loading" variant="table" />
<template v-else>
<div class="shipping-page__table admin-card desktop-only">
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>شماره سفارش</th>
<th>مشتری</th>
<th>باربری</th>
<th>شماره بارنامه</th>
<th>متراژ</th>
<th>وضعیت</th>
<th>تاریخ ارسال</th>
<th>تاریخ تحویل</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="shipment in filteredShipments" :key="shipment.id">
<td>{{ shipment.orderNumber }}</td>
<td>{{ shipment.customerName }}</td>
<td>{{ shipment.carrier }}</td>
<td dir="ltr">{{ shipment.trackingNumber }}</td>
<td>{{ formatMeterage(shipment.meterage) }}</td>
<td>
<span class="admin-badge" :class="SHIPMENT_STATUS_BADGE[shipment.status]">
{{ SHIPMENT_STATUS_LABELS[shipment.status] }}
</span>
</td>
<td>{{ shipment.shippedAt ? formatDateTime(shipment.shippedAt) : '—' }}</td>
<td>{{ shipment.deliveredAt ? formatDateTime(shipment.deliveredAt) : '—' }}</td>
<td>
<button
v-if="shipment.status !== 'delivered'"
type="button"
class="admin-btn admin-btn--outline admin-btn--xs"
@click="advanceStatus(shipment)"
>
مرحله بعد
</button>
<span v-else>—</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="compact-only admin-card-list">
<article v-for="shipment in filteredShipments" :key="shipment.id" class="admin-card-item">
<div class="admin-card-item__header">
<p class="admin-card-item__title">{{ shipment.orderNumber }}</p>
<span class="admin-badge" :class="SHIPMENT_STATUS_BADGE[shipment.status]">
{{ SHIPMENT_STATUS_LABELS[shipment.status] }}
</span>
</div>
<div class="admin-card-item__meta">
<span>{{ shipment.customerName }}</span>
<span>{{ shipment.carrier }}</span>
<span>{{ formatMeterage(shipment.meterage) }}</span>
<span dir="ltr">{{ shipment.trackingNumber }}</span>
</div>
<div v-if="shipment.status !== 'delivered'" class="admin-card-item__actions">
<button type="button" class="admin-btn admin-btn--outline admin-btn--xs" @click="advanceStatus(shipment)">
مرحله بعد
</button>
</div>
</article>
</div>
</template>
<BaseModal :open="showModal" title="ثبت بارنامه" size="lg" @close="closeModal">
<form class="admin-form" @submit.prevent="submitShipment">
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
<div class="admin-form__field">
<label id="ship-order-label">سفارش <span class="admin-form__required">*</span></label>
<AdminSelect
v-model="form.orderId"
block
aria-label="انتخاب سفارش"
placeholder="انتخاب سفارش"
:options="orderSelectOptions"
@update:model-value="onOrderChange"
/>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="ship-carrier">باربری <span class="admin-form__required">*</span></label>
<input id="ship-carrier" v-model="form.carrier" type="text" class="admin-input" />
</div>
<div class="admin-form__field">
<label for="ship-tracking">شماره بارنامه</label>
<input id="ship-tracking" v-model="form.trackingNumber" type="text" class="admin-input" dir="ltr" />
</div>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="ship-meterage">متراژ</label>
<input id="ship-meterage" v-model.number="form.meterage" type="number" min="1" class="admin-input" />
</div>
<div class="admin-form__field">
<label id="ship-status-label">وضعیت</label>
<AdminSelect
v-model="form.status"
block
aria-label="وضعیت ارسال"
:options="shipmentStatusOptions"
/>
</div>
</div>
</form>
<template #footer>
<button type="button" class="admin-btn admin-btn--outline" @click="closeModal">انصراف</button>
<button
type="button"
class="admin-btn admin-btn--primary"
:disabled="!isFormValid || saving"
@click="submitShipment"
>
ثبت بارنامه
</button>
</template>
</BaseModal>
</div>
</template>
<style scoped>
.shipping-page__filters {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 12px 14px;
margin-bottom: 16px;
align-items: center;
}
.shipping-page__search {
flex: 1;
min-width: 200px;
display: flex;
align-items: center;
gap: 10px;
color: var(--color-text-muted);
}
.shipping-page__search-input {
flex: 1;
border: none;
background: transparent;
font-size: 0.875rem;
outline: none;
color: var(--color-text);
}
.shipping-page__status-select {
flex: 0 1 220px;
width: 220px;
}
.shipping-page__table {
overflow: hidden;
}
</style>
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import BaseEmptyState from '@/components/ui/BaseEmptyState.vue'
import { stockAlertsApi, type StockAlert } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
const list = ref<StockAlert[]>([])
const loading = ref(true)
const toast = useToast()
async function load() {
loading.value = true
try {
list.value = await stockAlertsApi.list()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری ناموفق بود')
} finally {
loading.value = false
}
}
async function notify(id: string) {
try {
await stockAlertsApi.notify(id)
toast.success('اعلان موجودی ارسال شد')
await load()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ارسال ناموفق بود')
}
}
onMounted(() => {
void load()
})
</script>
<template>
<div class="admin-page">
<header class="admin-page__head">
<div>
<h1>درخواست خبر موجودی</h1>
<p>مشتریانی که برای محصول ناموجود درخواست اطلاع داده‌اند</p>
</div>
</header>
<AdminPageSkeleton v-if="loading" variant="table" />
<BaseEmptyState v-else-if="!list.length" title="درخواستی فعال نیست" />
<table v-else class="admin-table">
<thead>
<tr>
<th>مشتری</th>
<th>محصول</th>
<th>موجودی فعلی</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="row in list" :key="row.id">
<td>
{{ row.customerName }}
<span class="muted" dir="ltr">{{ row.customerPhone }}</span>
</td>
<td>{{ row.productName }} <span class="muted" dir="ltr">({{ row.productCode }})</span></td>
<td>{{ row.stock ?? '—' }}</td>
<td>
<button type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="notify(row.id)">ارسال اعلان</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
.admin-page__head { margin-bottom: 20px; }
.admin-page__head h1 { margin: 0 0 4px; font-size: 1.25rem; }
.admin-page__head p { margin: 0; color: var(--color-text-muted); font-size: 0.875rem; }
.admin-table { width: 100%; border-collapse: collapse; }
.admin-table th, .admin-table td { text-align: right; padding: 10px 8px; border-bottom: 1px solid var(--color-border); }
.muted { color: var(--color-text-muted); font-size: 0.75rem; margin-right: 6px; }
</style>
+147
View File
@@ -0,0 +1,147 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import BaseModal from '@/components/ui/BaseModal.vue'
import BaseEmptyState from '@/components/ui/BaseEmptyState.vue'
import AdminSelect from '@/components/ui/AdminSelect.vue'
import { customersApi, productsApi, suggestionsApi, type ProductSuggestion } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
import type { Customer, Product } from '@/types'
const list = ref<ProductSuggestion[]>([])
const loading = ref(true)
const customers = ref<Customer[]>([])
const products = ref<Product[]>([])
const modalOpen = ref(false)
const toast = useToast()
const form = reactive({ customerId: '', productId: '', note: '' })
const customerOptions = computed(() =>
customers.value.map((c) => ({
value: c.id,
label: `${c.firstName} ${c.lastName}`.trim() || c.phone,
})),
)
const productOptions = computed(() =>
products.value.map((p) => ({ value: p.id, label: `${p.name} (${p.productCode || p.code})` })),
)
async function load() {
loading.value = true
try {
const [suggestions, customerRows, productRows] = await Promise.all([
suggestionsApi.list(),
customersApi.list(),
productsApi.list(),
])
list.value = suggestions
customers.value = customerRows
products.value = productRows
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری ناموفق بود')
} finally {
loading.value = false
}
}
async function save() {
try {
await suggestionsApi.create({
customerId: form.customerId,
productId: form.productId,
note: form.note.trim() || undefined,
})
modalOpen.value = false
form.note = ''
toast.success('پیشنهاد ثبت شد')
await load()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ثبت ناموفق بود')
}
}
async function toggle(row: ProductSuggestion) {
try {
await suggestionsApi.setActive(row.id, !row.isActive)
await load()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'به‌روزرسانی ناموفق بود')
}
}
onMounted(() => {
void load()
})
</script>
<template>
<div class="admin-page">
<header class="admin-page__head">
<div>
<h1>پیشنهاد محصول</h1>
<p>پیشنهاد دستی محصول به مشتری</p>
</div>
<button type="button" class="admin-btn admin-btn--primary" @click="modalOpen = true">پیشنهاد جدید</button>
</header>
<AdminPageSkeleton v-if="loading" variant="table" />
<BaseEmptyState v-else-if="!list.length" title="پیشنهادی ثبت نشده" />
<table v-else class="admin-table">
<thead>
<tr>
<th>مشتری</th>
<th>محصول</th>
<th>یادداشت</th>
<th>وضعیت</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="row in list" :key="row.id">
<td>{{ row.customerName }}</td>
<td>{{ row.productName }} <span class="muted" dir="ltr">({{ row.productCode }})</span></td>
<td>{{ row.note || '—' }}</td>
<td>{{ row.isActive ? 'فعال' : 'غیرفعال' }}</td>
<td>
<button type="button" class="link" @click="toggle(row)">
{{ row.isActive ? 'غیرفعال' : 'فعال' }}
</button>
</td>
</tr>
</tbody>
</table>
<BaseModal :open="modalOpen" title="پیشنهاد محصول" @close="modalOpen = false">
<label class="field">مشتری
<AdminSelect v-model="form.customerId" :options="customerOptions" />
</label>
<label class="field">محصول
<AdminSelect v-model="form.productId" :options="productOptions" />
</label>
<label class="field">یادداشت<textarea v-model="form.note" class="admin-input" rows="3" /></label>
<template #footer>
<button
type="button"
class="admin-btn admin-btn--primary"
:disabled="!form.customerId || !form.productId"
@click="save"
>
ثبت
</button>
</template>
</BaseModal>
</div>
</template>
<style scoped>
.admin-page__head { display: flex; justify-content: space-between; margin-bottom: 20px; }
.admin-page__head h1 { margin: 0 0 4px; font-size: 1.25rem; }
.admin-page__head p { margin: 0; color: var(--color-text-muted); font-size: 0.875rem; }
.admin-table { width: 100%; border-collapse: collapse; }
.admin-table th, .admin-table td { text-align: right; padding: 10px 8px; border-bottom: 1px solid var(--color-border); }
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
.muted { color: var(--color-text-muted); font-size: 0.75rem; }
.link { background: none; border: none; color: var(--color-primary); cursor: pointer; font-family: inherit; }
</style>
+321
View File
@@ -0,0 +1,321 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import BaseModal from '@/components/ui/BaseModal.vue'
import BaseSwitch from '@/components/ui/BaseSwitch.vue'
import BaseEmptyState from '@/components/ui/BaseEmptyState.vue'
import ActiveSwitchCell from '@/components/ui/ActiveSwitchCell.vue'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import AdminSelect from '@/components/ui/AdminSelect.vue'
import { adminUsersApi } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
import { USER_ROLE_LABELS } from '@/utils/adminLabels'
import { formatDateTime } from '@/utils/format'
import type { AdminUser, UserRole } from '@/types'
const userList = ref<AdminUser[]>([])
const searchQuery = ref('')
const loading = ref(true)
const modalMode = ref<'add' | 'edit' | null>(null)
const editingId = ref<string | null>(null)
const formError = ref('')
const saving = ref(false)
const toast = useToast()
const roleOptions = [
{ value: 'admin', label: 'مدیر سیستم' },
{ value: 'sales', label: 'واحد فروش' },
{ value: 'finance', label: 'واحد مالی' },
{ value: 'warehouse', label: 'انبار' },
]
const emptyForm = () => ({
fullName: '',
phone: '',
email: '',
role: 'sales' as UserRole,
isActive: true,
})
const form = reactive(emptyForm())
const isModalOpen = computed(() => modalMode.value !== null)
const modalTitle = computed(() => (modalMode.value === 'edit' ? 'ویرایش کاربر' : 'افزودن کاربر'))
const isFormValid = computed(() => form.fullName.trim() && form.phone.trim())
const filteredUsers = computed(() => {
const q = searchQuery.value.trim()
if (!q) return userList.value
return userList.value.filter(
(u) =>
u.fullName.includes(q) ||
u.phone.includes(q) ||
u.email.includes(q) ||
USER_ROLE_LABELS[u.role].includes(q),
)
})
async function loadUsers() {
loading.value = true
try {
userList.value = await adminUsersApi.list()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری کاربران انجام نشد.')
} finally {
loading.value = false
}
}
onMounted(() => {
void loadUsers()
})
function openAddModal() {
modalMode.value = 'add'
editingId.value = null
Object.assign(form, emptyForm())
formError.value = ''
}
function openEditModal(user: AdminUser) {
modalMode.value = 'edit'
editingId.value = user.id
form.fullName = user.fullName
form.phone = user.phone
form.email = user.email
form.role = user.role
form.isActive = user.isActive
formError.value = ''
}
function closeModal() {
modalMode.value = null
editingId.value = null
formError.value = ''
}
async function submitUser() {
if (!isFormValid.value) {
formError.value = 'نام و تلفن الزامی هستند.'
return
}
saving.value = true
const payload = {
fullName: form.fullName.trim(),
phone: form.phone.trim(),
email: form.email.trim() || undefined,
role: form.role,
isActive: form.isActive,
}
try {
if (modalMode.value === 'edit' && editingId.value) {
const updated = await adminUsersApi.update(editingId.value, payload)
const index = userList.value.findIndex((u) => u.id === editingId.value)
if (index !== -1) userList.value[index] = { ...userList.value[index]!, ...updated }
} else {
const created = await adminUsersApi.create(payload)
userList.value.push(created)
}
toast.success(modalMode.value === 'edit' ? 'اطلاعات کاربر به‌روزرسانی شد.' : 'کاربر جدید ثبت شد.')
closeModal()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ذخیره کاربر انجام نشد.')
} finally {
saving.value = false
}
}
async function persistActive(id: string, isActive: boolean) {
await adminUsersApi.setActive(id, isActive)
}
function clearSearch() {
searchQuery.value = ''
}
</script>
<template>
<div class="users-page">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">کاربران و دسترسی‌ها</h1>
</div>
<button type="button" class="admin-btn admin-btn--primary" @click="openAddModal">
افزودن کاربر
</button>
</div>
<div class="users-page__search admin-card">
<AdminIcon name="search" :size="18" />
<input
v-model="searchQuery"
type="search"
class="users-page__search-input"
placeholder="جستجو در نام، تلفن، ایمیل..."
/>
</div>
<AdminPageSkeleton v-if="loading" variant="table" />
<BaseEmptyState
v-else-if="!filteredUsers.length"
class="admin-card"
icon="users"
:title="searchQuery.trim() ? 'نتیجه‌ای یافت نشد' : 'کاربری ثبت نشده'"
:description="
searchQuery.trim()
? 'عبارت جستجو را تغییر دهید یا فیلترها را پاک کنید.'
: 'برای شروع، اولین کاربر را ثبت کنید.'
"
>
<button v-if="searchQuery.trim()" type="button" class="admin-btn admin-btn--outline admin-btn--sm" @click="clearSearch">
پاک کردن جستجو
</button>
<button v-else type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="openAddModal">
افزودن کاربر
</button>
</BaseEmptyState>
<div v-else class="users-page__table admin-card desktop-only">
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>نام</th>
<th>تلفن</th>
<th>ایمیل</th>
<th>نقش</th>
<th class="status">وضعیت</th>
<th>آخرین ورود</th>
<th class="actions">عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="user in filteredUsers" :key="user.id">
<td>{{ user.fullName }}</td>
<td>{{ user.phone }}</td>
<td dir="ltr">{{ user.email }}</td>
<td>{{ USER_ROLE_LABELS[user.role] }}</td>
<td class="status">
<ActiveSwitchCell
:id="user.id"
v-model="user.isActive"
:entity-name="user.fullName"
entity-type="کاربر"
:persist="persistActive"
/>
</td>
<td>{{ user.lastLoginAt ? formatDateTime(user.lastLoginAt) : '—' }}</td>
<td class="actions">
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(user)">
ویرایش
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="!loading && filteredUsers.length" class="compact-only admin-card-list">
<article v-for="user in filteredUsers" :key="user.id" class="admin-card-item">
<div class="admin-card-item__header">
<p class="admin-card-item__title">{{ user.fullName }}</p>
<ActiveSwitchCell
:id="user.id"
v-model="user.isActive"
:entity-name="user.fullName"
entity-type="کاربر"
:persist="persistActive"
/>
</div>
<div class="admin-card-item__meta">
<span>{{ USER_ROLE_LABELS[user.role] }}</span>
<span>{{ user.phone }}</span>
<span dir="ltr">{{ user.email }}</span>
</div>
<div class="admin-card-item__actions">
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(user)">
ویرایش
</button>
</div>
</article>
</div>
<BaseModal :open="isModalOpen" :title="modalTitle" size="lg" @close="closeModal">
<form class="admin-form" @submit.prevent="submitUser">
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
<div class="admin-form__field">
<label for="user-name">نام کامل <span class="admin-form__required">*</span></label>
<input id="user-name" v-model="form.fullName" type="text" class="admin-input" />
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="user-phone">تلفن <span class="admin-form__required">*</span></label>
<input
id="user-phone"
v-model="form.phone"
type="tel"
class="admin-input"
inputmode="tel"
dir="ltr"
/>
</div>
<div class="admin-form__field">
<label for="user-email">ایمیل</label>
<input id="user-email" v-model="form.email" type="email" class="admin-input" dir="ltr" />
</div>
</div>
<div class="admin-form__field">
<label>نقش</label>
<AdminSelect
v-model="form.role"
block
aria-label="نقش کاربر"
:options="roleOptions"
/>
</div>
<div class="admin-form__field">
<BaseSwitch v-model="form.isActive" label="وضعیت" />
</div>
</form>
<template #footer>
<button type="button" class="admin-btn admin-btn--outline" @click="closeModal">انصراف</button>
<button type="button" class="admin-btn admin-btn--primary" :disabled="!isFormValid || saving" @click="submitUser">
<span v-if="saving" class="admin-btn__spinner" aria-hidden="true" />
{{ modalMode === 'edit' ? 'ذخیره تغییرات' : 'ثبت کاربر' }}
</button>
</template>
</BaseModal>
</div>
</template>
<style scoped>
.users-page__search {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
margin-bottom: 16px;
color: var(--color-text-muted);
}
.users-page__search-input {
flex: 1;
border: none;
background: transparent;
font-size: 0.875rem;
outline: none;
}
.users-page__table {
overflow: hidden;
}
</style>
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
// Extra safety for array and object lookups, but may have false positives.
"noUncheckedIndexedAccess": true,
// Path mapping for cleaner imports.
"paths": {
"@/*": ["./src/*"]
},
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

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