Initialize the admin app with banner management for app sections.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=https://parche.montest.ir/server/api
|
||||
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
+44
@@ -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
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue"],
|
||||
"env": {
|
||||
"browser": true
|
||||
},
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"EditorConfig.EditorConfig",
|
||||
"oxc.oxc-vscode",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="unocss" />
|
||||
@@ -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
@@ -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>
|
||||
Generated
+6297
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'),
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
]
|
||||
@@ -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
@@ -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')
|
||||
@@ -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
|
||||
@@ -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' }),
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}/`)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user