Compare commits

...
32 Commits
Author SHA1 Message Date
kavehhn 195d0300a0 fix(gallery): resolve image zoom stretching and add rich viewer features
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-30 11:25:31 +03:30
kavehhn 7c56d2e66a fix(dashboard): resolve dark mode styling and text contrast for quick action buttons
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-30 11:09:24 +03:30
kavehhn 712b654d68 feat(map): improve co-located properties with smart grouping, layered pin badges, popup navigation, and spread mode
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-30 10:59:41 +03:30
kavehhn b6d5bff088 feat(property): add 'فاقد کاربری' to zoningType dropdown options
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-30 10:16:34 +03:30
kavehhn 9d16b90e18 build(docker): use liara npm mirror registry
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-30 09:42:14 +03:30
kavehhn 9ec5e22138 feat(dashboard): fix logout flow and redesign executive dashboard with real live data, charts and feeds
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-30 09:29:26 +03:30
kavehhn b0980f1448 feat(property): replace polling autosave with change-driven debounce and compact status indicator
deploy-chabokan / deploy (push) Canceled after 0s
Autosave previously ran on a fixed setInterval regardless of activity. It now
debounces a save shortly after the user stops editing, with a max-wait safety
net for long uninterrupted typing sessions, so it only does work when there
are actual changes. Also replaces the bulky bottom-right status popup with a
very small fixed pill (idle/pending/saving/saved/error) that stays visible
while scrolling.
2026-08-19 08:12:25 +03:30
kavehhn a155cdd481 fix(property): improve property save payload and error handling
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 07:46:20 +03:30
kavehhn 30c8149008 feat(property): add save toasts, background autosave with bottom-right status popup, and configurable autosave interval in settings
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 07:27:09 +03:30
kavehhn 0503845786 fix(property): fix persian months dropdown labels and values in registration date
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 07:17:42 +03:30
kavehhn 15dc77a76b fix(property): fix edit property loading, map event recursion, and category sync
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 07:13:32 +03:30
kavehhn 3723f801e7 fix(dashboard): fix properties freeze, category/feature list views, and auth interceptor
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 07:08:43 +03:30
kavehhn b81a85f672 feat(settings): add database seeder section with one-time execution and lock mechanism
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 06:50:01 +03:30
kavehhn 001ab07485 feat(property): add Iranian date fields with Date conversion and fix edit coordinates/map loading
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 06:45:55 +03:30
kavehhn e2aa60c54c fix(property): load signed urls for image gallery and strip query params on save
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 06:30:07 +03:30
kavehhn b6fde3e541 feat: add loading screens, skeleton states, and table loaders across all list pages
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 06:02:20 +03:30
kavehhn 859a86f1a5 feat: add search settings page in dashboard, sidebar menu, and MongoDB search integration
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 05:50:18 +03:30
kavehhn cf7e1ee45c fix(uploader): register FilePond component at module level and restore proper dropzone styling
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 05:14:03 +03:30
kavehhn ba54647837 fix(uploader): upload to temp bucket and fix initialFiles reactivity to prevent file vanishing
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 04:58:19 +03:30
kavehhn 36f2c200a1 fix(gallery): add ImageGallery to property create/edit page and modernize gallery UI
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 04:49:46 +03:30
kavehhn 119a6950f0 fix(uploader): resolve upload endpoint URL and update ImageUploader props in CreateProperty
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 04:35:49 +03:30
kavehhn a6ece05199 feat: add interactive PropertyMap component and Airbnb-style split view to ListProperties
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 04:23:18 +03:30
kavehhn 7c66377e38 feat: add user management and role/permission management pages
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-18 00:09:15 +03:30
kavehhn d801a81660 Add mbmanager.ir and .mbmanager.ir as allowed hosts in vite config 2026-08-17 23:01:57 +03:30
kavehhn edd63cbdfc Add healthcheck endpoint for dashboard 2026-08-17 22:50:52 +03:30
kavehhn 395b895686 ADD: Production multi-stage Dockerfile with Nginx and .dockerignore 2026-08-17 12:42:42 +03:30
kavehhn 79da0d24ab REFACTOR: Belonging → Property system migration + dashboard + map improvements
Property system:
- Add PropertyService.js and PropertyStatusService.js
- Add Property pages: CreateProperty.vue, ListProperties.vue, TransactionDialog.vue
- Remove legacy BelongingService.js and Belonging pages (CreateBelonging, ListBelongings, TransactionDialog)

Dashboard:
- Update RecentSalesWidget.vue
- Update RevenueStreamWidget.vue
- Update StatsWidget.vue

UI & config:
- Major MapComponent.vue improvements (extended functionality)
- Update ListCategories.vue
- Update ListTransactions.vue
- Update vite.config.mjs
2026-08-11 20:36:02 +03:30
kavehhn c01fed2622 ADD: Standalone transaction creation page with sidebar menu entry
- Add CreateWarehouseTransaction.vue — full-page form to register IN/OUT transactions
  with item/warehouse dropdowns (searchable), quantity input, reason presets, date picker,
  live stock preview showing current balance for the selected item+warehouse combo,
  and input validation with inline error messages
- Add /warehouse-items/create-transaction route
- Add 'ثبت تراکنش' as first item in 'مدیریت انبار' sidebar group
2026-08-11 20:12:34 +03:30
kavehhn 703df418ce ADD: Warehouse inventory management dashboard
- Add WarehouseItemService.js — API client for all warehouse item endpoints (items CRUD, transactions, stock queries)
- Add WarehouseService.js — API client for warehouse location management
- Add ListWarehouseItems.vue — item catalogue table with inline create/edit dialog and bilingual unit picker
- Add WarehouseItemStockView.vue — per-item stock dashboard showing total + per-warehouse breakdown, IN/OUT transaction dialog with reason presets, inline transaction history with delete
- Add ListWarehouseItemTransactions.vue — full transaction history with filter panel (item, warehouse, type, date range)
- Add ListWarehouses.vue — warehouse location management with create/edit dialogs
- Update AppMenu.vue — add 'مدیریت انبار' menu group with 3 items
- Update router/index.js — register 4 new warehouse management routes
2026-08-11 20:09:31 +03:30
kavehhn 7421896805 FIX: Image Upload Bug
FIX: Minor Bugs
2025-06-04 16:05:54 +03:30
kavehhn 3b06bc50a5 CHANGE: Token Domain 2025-06-04 14:58:37 +03:30
kavehhn 8bae1d064a ADD: Utm Convert
FIX: Features data not saving and not loading
2025-05-10 20:48:03 +03:30
62 changed files with 12350 additions and 1676 deletions
+16
View File
@@ -0,0 +1,16 @@
node_modules
dist
npm-debug.log
.git
.gitignore
.github
.editorconfig
.idea
.vscode
.eslintcache
.env
.env.*
Dockerfile*
.dockerignore
README.md
CHANGELOG.md
+44
View File
@@ -0,0 +1,44 @@
# Stage 1: Build the Vue SPA application
FROM node:20-alpine AS builder
WORKDIR /app
# Copy dependency definitions
COPY package*.json ./
# Configure Liara npm mirror registry
RUN npm config set registry https://package-mirror.liara.ir/repository/npm/ --global
# Install all dependencies (including devDependencies required for Vite build)
RUN npm ci
# Pass build arguments for environment variables
ARG VITE_API_URL=""
ARG VITE_ENV=production
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_ENV=$VITE_ENV
# Copy application source files
COPY . .
# Build production static bundle
RUN npm run build
# Stage 2: Serve application with Nginx
FROM nginx:alpine AS runner
# Remove default nginx static assets
RUN rm -rf /usr/share/nginx/html/*
# Copy custom nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy compiled assets from the builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Expose web server port
EXPOSE 80
# Run nginx in foreground
CMD ["nginx", "-g", "daemon off;"]
+58
View File
@@ -0,0 +1,58 @@
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip Compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/x-javascript
application/xml
application/xml+rss
image/svg+xml;
# Security Headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Cache static assets (Vite generates unique hashes for files in /assets)
location /assets/ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
# Health check endpoint
location ~ ^/(health|healthz)$ {
access_log off;
default_type application/json;
return 200 '{"status":"healthy","service":"dashboard"}';
}
# SPA routing - route all queries to index.html
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Error handling
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
+24
View File
@@ -29,7 +29,10 @@
"photoswipe": "^5.4.4",
"primeicons": "^7.0.0",
"primevue": "^4.2.4",
"usng.js": "^0.4.5",
"utm-latlng": "^1.0.8",
"v-mapbox": "^5.1.0",
"viewerjs": "^1.12.0",
"vue": "^3.4.34",
"vue-cookies": "^1.8.3",
"vue-filepond": "^7.0.4",
@@ -4794,12 +4797,27 @@
"punycode": "^2.1.0"
}
},
"node_modules/usng.js": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/usng.js/-/usng.js-0.4.5.tgz",
"integrity": "sha512-JTJcFFDy/JqA5iiU8DqMOV5gJiL3ZdXH0hCKYaRMDbbredhFna5Ok4C1YDFU07mTGAgm+5FzHaaOzQnO/3BzWQ==",
"license": "MIT",
"bin": {
"usng-cli": "bin/cli.js"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true
},
"node_modules/utm-latlng": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/utm-latlng/-/utm-latlng-1.0.8.tgz",
"integrity": "sha512-jQ5l1BNTzNgas7jXOIh0jU0m5PU5LtmjYvRiLwR4iymYYAhEhQ6N0MGCnLjv5iO958b9tLMMSmzdbIIhLtXqCg==",
"license": "MIT"
},
"node_modules/v-mapbox": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/v-mapbox/-/v-mapbox-5.1.0.tgz",
@@ -4823,6 +4841,12 @@
"resolved": "https://registry.npmjs.org/vazirmatn/-/vazirmatn-33.0.3.tgz",
"integrity": "sha512-fbjNc0CMjazZpIegWzz9OHGzI1APFboT+x7ZecXlUCtDe/nkyx7gtCTZnWS/+eeXG7fbfXymCPKJI8EsnM3iqw=="
},
"node_modules/viewerjs": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/viewerjs/-/viewerjs-1.12.0.tgz",
"integrity": "sha512-eQrV7FvqXj2Ysavd9LINI6bFAvXtw5YbdlMtdIo6Bmrv25hEOBeXoOX9E7cJwYuFAOPFzmppFvDn02QNHB120w==",
"license": "MIT"
},
"node_modules/vite": {
"version": "5.4.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz",
+3
View File
@@ -29,7 +29,10 @@
"photoswipe": "^5.4.4",
"primeicons": "^7.0.0",
"primevue": "^4.2.4",
"usng.js": "^0.4.5",
"utm-latlng": "^1.0.8",
"v-mapbox": "^5.1.0",
"viewerjs": "^1.12.0",
"vue": "^3.4.34",
"vue-cookies": "^1.8.3",
"vue-filepond": "^7.0.4",
+1
View File
@@ -0,0 +1 @@
{"status":"healthy","service":"dashboard"}
+32 -2
View File
@@ -1,6 +1,36 @@
<script setup>
import axios from 'axios'
axios.defaults.baseURL = import.meta.env.VITE_API_URL
import axios from 'axios';
import Cookies from 'js-cookie';
axios.defaults.baseURL = import.meta.env.VITE_API_URL;
axios.defaults.withCredentials = true;
// Global Axios Request Interceptor for Authentication
axios.interceptors.request.use(
(config) => {
config.withCredentials = true;
// Resolve token from Cookies, localStorage, or document.cookie
let token = Cookies.get('bm-token') || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
if (!token && typeof document !== 'undefined') {
token = document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1];
}
if (token && token !== 'undefined' && token !== 'null' && token.trim() !== '') {
config.headers = config.headers || {};
config.headers.Authorization = `Bearer ${token.trim()}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
</script>
<template>
+114
View File
@@ -0,0 +1,114 @@
<script setup>
defineProps({
type: {
type: String,
default: 'table' // 'table' | 'cards' | 'split' | 'spinner'
},
rows: {
type: Number,
default: 6
},
hasImage: {
type: Boolean,
default: false
},
showHeader: {
type: Boolean,
default: false
},
message: {
type: String,
default: 'در حال بارگذاری اطلاعات...'
}
});
</script>
<template>
<div class="list-loading-container w-full animate-pulse">
<!-- Optional Top Header Shimmer -->
<div v-if="showHeader" class="flex flex-wrap items-center justify-between gap-4 mb-4">
<Skeleton width="12rem" height="2rem" class="rounded-xl" />
<Skeleton width="16rem" height="2.5rem" class="rounded-xl" />
</div>
<!-- ── TABLE SKELETON ── -->
<div v-if="type === 'table'" class="rounded-2xl border border-surface-200 dark:border-surface-700 overflow-hidden bg-surface-0 dark:bg-surface-900 shadow-sm">
<!-- Table Header Bar -->
<div class="p-4 bg-surface-50 dark:bg-surface-800/60 border-b border-surface-200 dark:border-surface-700 flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-3">
<Skeleton width="2rem" height="2rem" class="rounded-lg" />
<Skeleton width="10rem" height="1.5rem" class="rounded-lg" />
</div>
<Skeleton width="14rem" height="2.25rem" class="rounded-xl" />
</div>
<!-- Table Rows -->
<div class="divide-y divide-surface-100 dark:divide-surface-800">
<div v-for="r in rows" :key="r" class="p-4 flex items-center gap-4">
<Skeleton width="1.75rem" height="1.75rem" class="rounded-md shrink-0" />
<Skeleton v-if="hasImage" width="3.5rem" height="3.5rem" class="rounded-xl shrink-0" />
<div class="flex-1 grid grid-cols-2 sm:grid-cols-4 gap-4 items-center">
<Skeleton width="75%" height="1.25rem" class="rounded-md" />
<Skeleton width="55%" height="1.25rem" class="rounded-md" />
<Skeleton width="65%" height="1.25rem" class="rounded-md hidden sm:block" />
<Skeleton width="45%" height="1.25rem" class="rounded-md hidden sm:block" />
</div>
<div class="flex items-center gap-2 shrink-0">
<Skeleton width="2.25rem" height="2.25rem" class="rounded-full" />
<Skeleton width="2.25rem" height="2.25rem" class="rounded-full" />
</div>
</div>
</div>
</div>
<!-- ── CARDS GRID SKELETON ── -->
<div v-else-if="type === 'cards'" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<div v-for="c in rows" :key="c" class="card p-4 rounded-2xl border border-surface-200 dark:border-surface-700 bg-surface-0 dark:bg-surface-900 shadow-sm flex flex-col gap-3">
<Skeleton width="100%" height="12rem" class="rounded-xl" />
<div class="flex items-center justify-between gap-2 mt-1">
<Skeleton width="60%" height="1.5rem" class="rounded-md" />
<Skeleton width="25%" height="1.25rem" class="rounded-full" />
</div>
<Skeleton width="40%" height="1rem" class="rounded-md" />
<div class="flex items-center justify-between pt-3 border-t border-surface-100 dark:border-surface-800 mt-auto">
<Skeleton width="30%" height="1.25rem" class="rounded-md" />
<Skeleton width="2rem" height="2rem" class="rounded-lg" />
</div>
</div>
</div>
<!-- ── SPLIT VIEW SKELETON (Cards + Map Placeholder) ── -->
<div v-else-if="type === 'split'" class="grid grid-cols-1 lg:grid-cols-12 gap-6 h-[750px]">
<!-- Left Cards list skeleton -->
<div class="lg:col-span-7 flex flex-col gap-4 overflow-hidden">
<div class="flex items-center justify-between mb-2">
<Skeleton width="10rem" height="1.5rem" class="rounded-md" />
<Skeleton width="6rem" height="1.25rem" class="rounded-md" />
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div v-for="i in 4" :key="i" class="p-4 rounded-2xl border border-surface-200 dark:border-surface-700 bg-surface-0 dark:bg-surface-900 shadow-sm flex flex-col gap-3">
<Skeleton width="100%" height="10rem" class="rounded-xl" />
<Skeleton width="70%" height="1.25rem" class="rounded-md" />
<Skeleton width="45%" height="1rem" class="rounded-md" />
<div class="flex justify-between items-center pt-2 border-t border-surface-100 dark:border-surface-800">
<Skeleton width="35%" height="1rem" class="rounded-md" />
<Skeleton width="2rem" height="2rem" class="rounded-lg" />
</div>
</div>
</div>
</div>
<!-- Right Map skeleton -->
<div class="lg:col-span-5 h-full rounded-2xl border border-surface-200 dark:border-surface-700 overflow-hidden bg-surface-100 dark:bg-surface-800 flex flex-col items-center justify-center p-8 text-center">
<i class="pi pi-map text-5xl text-surface-400 dark:text-surface-600 mb-4 animate-bounce"></i>
<Skeleton width="12rem" height="1.5rem" class="rounded-md mb-2" />
<Skeleton width="8rem" height="1rem" class="rounded-md" />
</div>
</div>
<!-- ── SPINNER / OVERLAY SKELETON ── -->
<div v-else class="flex flex-col items-center justify-center py-16 gap-4">
<ProgressSpinner style="width: 48px; height: 48px" strokeWidth="4" />
<span class="text-sm font-medium text-surface-500 dark:text-surface-400">{{ message }}</span>
</div>
</div>
</template>
+53 -74
View File
@@ -1,93 +1,72 @@
<!-- LoadingCheck.vue -->
<template>
<div class="inline-flex items-center justify-center">
<svg
:width="size"
:height="size"
viewBox="0 0 24 24"
class="transform transition-transform duration-500"
>
<circle
cx="12"
cy="12"
r="8"
fill="none"
stroke="currentColor"
stroke-width="2"
class="text-blue-500"
:style="circleStyle"
/>
<path
d="M8 12l3 3l5-5"
fill="none"
stroke="currentColor"
stroke-width="2"
class="text-green-500"
:style="checkStyle"
/>
</svg>
</div>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
const props = defineProps({
isComplete: {
type: Boolean,
default: false
},
size: {
type: Number,
default: 24
}
})
isComplete: {
type: Boolean,
default: false
},
size: {
type: Number,
default: 24
}
});
const dashOffset = ref(0)
let animationFrame = null
const dashOffset = ref(0);
let animationFrame = null;
const circleStyle = computed(() => ({
strokeDasharray: '60 60',
strokeDashoffset: props.isComplete ? '60' : String(dashOffset.value),
transition: props.isComplete ? 'all 0.2s ease-in-out' : 'none',
transformOrigin: 'center',
transform: `rotate(${props.isComplete ? -90 : 0}deg)`
}))
strokeDasharray: '60 60',
strokeDashoffset: props.isComplete ? '60' : String(dashOffset.value),
transition: props.isComplete ? 'all 0.2s ease-in-out' : 'none',
transformOrigin: 'center',
transform: `rotate(${props.isComplete ? -90 : 0}deg)`
}));
const checkStyle = computed(() => ({
opacity: props.isComplete ? 1 : 0,
strokeDasharray: 20,
strokeDashoffset: props.isComplete ? 0 : 20,
transition: 'all 0.3s ease-in-out',
transitionDelay: '0.2s'
}))
opacity: props.isComplete ? 1 : 0,
strokeDasharray: 20,
strokeDashoffset: props.isComplete ? 0 : 20,
transition: 'all 0.3s ease-in-out',
transitionDelay: '0.2s'
}));
let startTime = null
let startTime = null;
const animate = (timestamp) => {
if (!startTime) startTime = timestamp
dashOffset.value = (timestamp - startTime) / 10
animationFrame = requestAnimationFrame(animate)
}
if (!startTime) startTime = timestamp;
dashOffset.value = (timestamp - startTime) / 10;
animationFrame = requestAnimationFrame(animate);
};
watch(
() => props.isComplete,
(newValue) => {
if (newValue && animationFrame) {
cancelAnimationFrame(animationFrame)
animationFrame = null
() => props.isComplete,
(newValue) => {
if (newValue && animationFrame) {
cancelAnimationFrame(animationFrame);
animationFrame = null;
}
}
}
)
);
onMounted(() => {
if (!props.isComplete) {
animationFrame = requestAnimationFrame(animate)
}
})
if (!props.isComplete) {
animationFrame = requestAnimationFrame(animate);
}
});
onUnmounted(() => {
if (animationFrame) {
cancelAnimationFrame(animationFrame)
}
})
if (animationFrame) {
cancelAnimationFrame(animationFrame);
}
});
</script>
<template>
<div class="inline-flex items-center justify-center">
<svg :width="size" :height="size" viewBox="0 0 24 24" class="transform transition-transform duration-500">
<circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" stroke-width="2" class="text-blue-500" :style="circleStyle" />
<path d="M8 12l3 3l5-5" fill="none" stroke="currentColor" stroke-width="2" class="text-green-500" :style="checkStyle" />
</svg>
</div>
</template>
@@ -0,0 +1,121 @@
<script setup>
import { useLayout } from '@/layout/composables/layout';
import { computed, onMounted, ref, watch } from 'vue';
const props = defineProps({
statusStats: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
}
});
const { isDarkTheme } = useLayout();
const chartData = ref(null);
const chartOptions = ref(null);
const defaultColors = ['#10b981', '#3b82f6', '#f59e0b', '#8b5cf6', '#ec4899', '#06b6d4', '#64748b'];
function updateChart() {
const statuses = props.statusStats || [];
const labels = statuses.length > 0 ? statuses.map((s) => s.name) : ['نامشخص'];
const data = statuses.length > 0 ? statuses.map((s) => s.count) : [0];
const colors = statuses.map((s, i) => s.color || defaultColors[i % defaultColors.length]);
chartData.value = {
labels: labels,
datasets: [
{
data: data,
backgroundColor: colors,
borderWidth: 2,
borderColor: isDarkTheme.value ? '#18181b' : '#ffffff'
}
]
};
chartOptions.value = {
maintainAspectRatio: false,
responsive: true,
plugins: {
legend: {
position: 'bottom',
rtl: true,
labels: {
usePointStyle: true,
pointStyle: 'circle',
padding: 16,
font: {
family: 'Estedad',
size: 12
}
}
},
tooltip: {
rtl: true,
titleFont: { family: 'Estedad', size: 13 },
bodyFont: { family: 'Estedad', size: 12 },
callbacks: {
label: function (context) {
const count = context.parsed;
const statusItem = statuses[context.dataIndex];
const pct = statusItem?.percentage !== undefined ? ` (${statusItem.percentage}٪)` : '';
return `${context.label}: ${Number(count).toLocaleString('fa-IR')} ملک${pct}`;
}
}
}
},
cutout: '65%'
};
}
watch(
() => props.statusStats,
() => {
updateChart();
},
{ deep: true }
);
watch(isDarkTheme, () => {
updateChart();
});
onMounted(() => {
updateChart();
});
</script>
<template>
<div class="card h-full p-5 border border-surface-200 dark:border-surface-800 rounded-2xl bg-surface-0 dark:bg-surface-900">
<div class="flex justify-between items-center mb-4">
<div>
<h3 class="font-bold text-lg text-surface-900 dark:text-surface-0 mb-1">وضعیت اسناد و املاک</h3>
<p class="text-xs text-muted-color">تفکیک املاک بر اساس وضعیت سند و مالکیت</p>
</div>
<div class="flex items-center gap-2">
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-emerald-50 dark:bg-emerald-950/40 text-emerald-600 dark:text-emerald-400">
<i class="pi pi-check-circle text-[10px]"></i>
وضعیت‌ها
</span>
</div>
</div>
<div v-if="loading" class="h-72 flex items-center justify-center">
<i class="pi pi-spin pi-spinner text-primary text-3xl"></i>
</div>
<div v-else-if="!statusStats || statusStats.length === 0" class="h-72 flex flex-col items-center justify-center text-muted-color">
<i class="pi pi-chart-pie text-4xl mb-2 opacity-40"></i>
<span class="text-sm">اطلاعات وضعیت املاک ثبت نشده است</span>
</div>
<div v-else class="h-72">
<Chart type="doughnut" :data="chartData" :options="chartOptions" class="h-full w-full" />
</div>
</div>
</template>
@@ -0,0 +1,213 @@
<script setup>
import { useRouter } from 'vue-router';
const router = useRouter();
const actions = [
{
title: 'ثبت ملک جدید',
description: 'افزودن زمین، ساختمان یا آپارتمان',
icon: 'pi pi-file-plus',
colorClass: 'icon-blue',
route: '/property/create-property'
},
{
title: 'ثبت تراکنش انبار',
description: 'ثبت ورود یا خروج مصالح و اقلام',
icon: 'pi pi-plus-circle',
colorClass: 'icon-emerald',
route: '/warehouse-items/create-transaction'
},
{
title: 'فهرست املاک',
description: 'جستجو، فیلتر و مدیریت اسناد',
icon: 'pi pi-list',
colorClass: 'icon-purple',
route: '/property/list-properties'
},
{
title: 'اقلام و موجودی انبار',
description: 'مشاهده کاردکس و موجودی انبارها',
icon: 'pi pi-box',
colorClass: 'icon-amber',
route: '/warehouse-items/list'
}
];
function navigate(route) {
if (route) {
router.push(route);
}
}
</script>
<template>
<div class="card p-5 border border-surface-200 dark:border-surface-800 rounded-2xl bg-surface-0 dark:bg-surface-900">
<div class="flex justify-between items-center mb-4">
<div>
<h3 class="font-bold text-lg text-surface-900 dark:text-surface-0 mb-1">دسترسی و عملیات سریع</h3>
<p class="text-xs text-muted-color">میانبرهای پرکاربرد برای تسهیل امور مدیریت و ثبت</p>
</div>
<div class="flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-950/40 px-2.5 py-1 rounded-full font-medium">
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
سیستم آنلاین
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3.5">
<div v-for="item in actions" :key="item.title" @click="navigate(item.route)" class="quick-action-card group">
<div :class="['action-icon-box', item.colorClass]">
<i :class="[item.icon, 'text-lg']"></i>
</div>
<div class="action-text-wrap truncate">
<h4 class="action-title">
{{ item.title }}
</h4>
<p class="action-desc">
{{ item.description }}
</p>
</div>
<div class="action-arrow">
<i class="pi pi-arrow-left text-xs"></i>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.quick-action-card {
position: relative;
padding: 0.95rem 1rem;
border-radius: 14px;
border: 1px solid #e2e8f0;
background: #ffffff;
display: flex;
align-items: center;
gap: 0.875rem;
cursor: pointer;
transition: all 0.22s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
font-family: 'Estedad', sans-serif !important;
}
.quick-action-card:hover {
transform: translateY(-2px);
border-color: #6366f1;
background: #f8fafc;
box-shadow: 0 6px 18px rgba(99, 102, 241, 0.12);
}
.action-icon-box {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.08);
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.quick-action-card:hover .action-icon-box {
transform: scale(1.08);
}
.action-text-wrap {
flex: 1;
min-width: 0;
}
.action-title {
font-size: 13.5px;
font-weight: 700;
color: #0f172a;
margin: 0;
line-height: 1.4;
transition: color 0.2s ease;
}
.quick-action-card:hover .action-title {
color: #4f46e5;
}
.action-desc {
font-size: 11px;
color: #64748b;
margin-top: 2px;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.action-arrow {
opacity: 0;
transform: translateX(4px);
color: #94a3b8;
transition: all 0.2s ease;
}
.quick-action-card:hover .action-arrow {
opacity: 1;
transform: translateX(0);
color: #4f46e5;
}
/* ── Gradient Icon Box Styles ── */
.icon-blue {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: #ffffff;
}
.icon-emerald {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: #ffffff;
}
.icon-purple {
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
color: #ffffff;
}
.icon-amber {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
color: #ffffff;
}
/* ── Dark Mode Styles (via .app-dark selector) ── */
:global(.app-dark) .quick-action-card {
background: #18181b;
border-color: #27272a;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}
:global(.app-dark) .quick-action-card:hover {
background: #27272a;
border-color: #6366f1;
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.45),
0 0 0 1px rgba(99, 102, 241, 0.3);
}
:global(.app-dark) .action-title {
color: #f8fafc;
}
:global(.app-dark) .quick-action-card:hover .action-title {
color: #a5b4fc;
}
:global(.app-dark) .action-desc {
color: #94a3b8;
}
:global(.app-dark) .action-arrow {
color: #64748b;
}
:global(.app-dark) .quick-action-card:hover .action-arrow {
color: #a5b4fc;
}
</style>
+113 -33
View File
@@ -1,45 +1,125 @@
<script setup>
import { ProductService } from '@/service/ProductService';
import { onMounted, ref } from 'vue';
import { BelongingService } from '@/service/BelongingService';
import { useRouter } from 'vue-router';
import { parseDateToJalali } from '@/utils/jalali';
const products = ref(null);
function formatCurrency(value) {
return value.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
}
onMounted(() => {
BelongingService.getAllBelongings().then((data) => (products.value = data));
const props = defineProps({
properties: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
}
});
const router = useRouter();
function editBelonging(belongingId) {
router.push('/belonging/edit-belonging/' + belongingId);
function editProperty(propertyId) {
if (propertyId) {
router.push('/property/edit-property/' + propertyId);
}
}
function viewAllProperties() {
router.push('/property/list-properties');
}
function formatJalaliDate(dateVal) {
if (!dateVal) return '—';
const j = parseDateToJalali(dateVal);
if (!j) return '—';
return `${j.jy}/${String(j.jm).padStart(2, '0')}/${String(j.jd).padStart(2, '0')}`;
}
const formatNumber = (num) => {
if (num === null || num === undefined) return '۰';
return Number(num).toLocaleString('fa-IR');
};
</script>
<template>
<div class="card">
<div class="font-semibold text-xl mb-4">آخرین دارایی های افزوده شده</div>
<DataTable :value="products" :rows="5" :paginator="true" responsiveLayout="scroll">
<Column style="min-width: 15%" header="Image">
<template #body="slotProps">
<img :src="`${slotProps.data.mainImageUrl}`" :alt="slotProps.data.name" width="50" class="shadow" />
</template>
</Column>
<Column field="name" header="نام" :sortable="true" style="min-width: 35%"></Column>
<Column field="price" header="دسته بندی" :sortable="true" style="min-width: 30%">
<template #body="slotProps">
{{ slotProps.data.category?.nativeName }}
</template>
</Column>
<Column style="min-width: 15%" header="نمایش">
<template #body="slotProps">
<Button icon="pi pi-search" type="button" class="p-button-text" @click="editBelonging(slotProps.data._id)"></Button>
</template>
</Column>
</DataTable>
<div class="card h-full p-5 border border-surface-200 dark:border-surface-800 rounded-2xl bg-surface-0 dark:bg-surface-900">
<div class="flex justify-between items-center mb-4">
<div>
<h3 class="font-bold text-lg text-surface-900 dark:text-surface-0 mb-1">آخرین املاک ثبت شده</h3>
<p class="text-xs text-muted-color">جدیدترین املاک و دارایی‌های ثبت شده در سیستم</p>
</div>
<Button label="مشاهده همه" icon="pi pi-arrow-left" iconPos="right" size="small" text class="text-xs" @click="viewAllProperties" />
</div>
<div v-if="loading" class="py-12 flex items-center justify-center">
<i class="pi pi-spin pi-spinner text-primary text-2xl"></i>
</div>
<div v-else-if="!properties || properties.length === 0" class="py-12 flex flex-col items-center justify-center text-muted-color">
<i class="pi pi-building text-4xl mb-2 opacity-40"></i>
<span class="text-sm">ملکی ثبت نشده است</span>
<router-link to="/property/create-property" class="mt-3">
<Button label="ثبت ملک جدید" icon="pi pi-plus" size="small" />
</router-link>
</div>
<div v-else class="overflow-x-auto">
<DataTable :value="properties" responsiveLayout="scroll" class="p-datatable-sm">
<!-- Image -->
<Column header="تصویر" style="width: 60px">
<template #body="slotProps">
<div class="w-11 h-11 rounded-lg overflow-hidden border border-surface-200 dark:border-surface-700 bg-surface-100 dark:bg-surface-800 flex items-center justify-center">
<img :src="slotProps.data.imageUrl" :alt="slotProps.data.name" class="w-full h-full object-cover" @error="(e) => (e.target.src = 'https://cloud.mbmanager.ir/images/item-no-image.png')" />
</div>
</template>
</Column>
<!-- Code & Name -->
<Column header="نام و کد ملک" style="min-width: 160px">
<template #body="slotProps">
<div class="flex flex-col">
<span class="font-semibold text-surface-900 dark:text-surface-100 text-sm hover:text-primary cursor-pointer" @click="editProperty(slotProps.data._id)">
{{ slotProps.data.name }}
</span>
<span class="text-[11px] text-muted-color font-mono"> کد: {{ slotProps.data.propertyCode }} </span>
</div>
</template>
</Column>
<!-- Category -->
<Column header="دسته‌بندی" style="min-width: 110px">
<template #body="slotProps">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface-100 dark:bg-surface-800 text-surface-700 dark:text-surface-300">
{{ slotProps.data.category }}
</span>
</template>
</Column>
<!-- Status / Owner -->
<Column header="مشخصات" style="min-width: 130px">
<template #body="slotProps">
<div class="flex flex-col text-xs gap-0.5">
<span class="text-surface-600 dark:text-surface-400"
>مالک: <b class="text-surface-800 dark:text-surface-200">{{ slotProps.data.ownerName }}</b></span
>
<span class="text-muted-color text-[11px]">مساحت: {{ formatNumber(slotProps.data.area) }} م² ({{ slotProps.data.dong }} دانگ)</span>
</div>
</template>
</Column>
<!-- Date -->
<Column header="تاریخ ثبت" style="min-width: 95px">
<template #body="slotProps">
<span class="text-xs text-muted-color font-mono">
{{ formatJalaliDate(slotProps.data.createdAt) }}
</span>
</template>
</Column>
<!-- Action -->
<Column header="عملیات" style="width: 50px">
<template #body="slotProps">
<Button icon="pi pi-arrow-left" class="p-button-rounded p-button-text p-button-sm !w-8 !h-8" v-tooltip.top="'مشاهده و ویرایش'" @click="editProperty(slotProps.data._id)" />
</template>
</Column>
</DataTable>
</div>
</div>
</template>
@@ -1,95 +1,146 @@
<script setup>
import { useLayout } from '@/layout/composables/layout';
import { onMounted, ref, watch } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
const props = defineProps({
categoryStats: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
}
});
const { getPrimary, getSurface, isDarkTheme } = useLayout();
const chartData = ref(null);
const chartOptions = ref(null);
function setChartData() {
const documentStyle = getComputedStyle(document.documentElement);
const palette = ['#3b82f6', '#10b981', '#f59e0b', '#ec4899', '#8b5cf6', '#06b6d4', '#f97316', '#6366f1', '#14b8a6', '#84cc16'];
return {
labels: ['زمین', 'ساختمان', 'خودرو', 'ارز'],
function updateChart() {
const documentStyle = getComputedStyle(document.documentElement);
const textMutedColor = documentStyle.getPropertyValue('--text-color-secondary') || '#6b7280';
const borderColor = documentStyle.getPropertyValue('--surface-border') || '#e5e7eb';
const categories = props.categoryStats || [];
const labels = categories.length > 0 ? categories.map((c) => c.name) : ['بدون دسته‌بندی'];
const data = categories.length > 0 ? categories.map((c) => c.count) : [0];
const backgroundColors = labels.map((_, i) => palette[i % palette.length]);
chartData.value = {
labels: labels,
datasets: [
{
type: 'bar',
label: 'تعداد دارایی ها',
backgroundColor: documentStyle.getPropertyValue('--p-primary-400'),
font: {
family: 'Estedad', // Set custom font for y-axis labels
size: 14 // Set font size
},
data: [18, 13, 5, 11],
barThickness: 32
label: 'تعداد املاک',
data: data,
backgroundColor: backgroundColors,
borderRadius: 8,
barThickness: 28,
maxBarThickness: 36
}
]
};
}
function setChartOptions() {
const documentStyle = getComputedStyle(document.documentElement);
const borderColor = documentStyle.getPropertyValue('--surface-border');
const textMutedColor = documentStyle.getPropertyValue('--text-color-secondary');
return {
chartOptions.value = {
maintainAspectRatio: false,
aspectRatio: 0.8,
responsive: true,
plugins: {
legend: {
display: false
},
tooltip: {
rtl: true,
titleFont: { family: 'Estedad', size: 13 },
bodyFont: { family: 'Estedad', size: 12 },
padding: 10,
callbacks: {
label: function (context) {
const count = context.parsed.y !== undefined ? context.parsed.y : context.parsed;
const cat = categories[context.dataIndex];
const areaText = cat?.totalArea ? ` (${Number(cat.totalArea).toLocaleString('fa-IR')} م²)` : '';
return `تعداد: ${Number(count).toLocaleString('fa-IR')} ملک${areaText}`;
}
}
}
},
scales: {
x: {
stacked: true,
ticks: {
color: textMutedColor,
font: {
family: 'Estedad', // Set custom font for y-axis labels
size: 14 // Set font size
family: 'Estedad',
size: 13
}
},
grid: {
color: 'transparent',
borderColor: 'transparent'
display: false
}
},
y: {
stacked: true,
beginAtZero: true,
ticks: {
precision: 0,
color: textMutedColor,
font: {
family: 'Estedad', // Set custom font for y-axis labels
size: 14 // Set font size
family: 'Estedad',
size: 12
}
},
grid: {
color: borderColor,
borderColor: 'transparent',
drawTicks: false
drawBorder: false
}
}
}
};
}
watch(
() => props.categoryStats,
() => {
updateChart();
},
{ deep: true }
);
watch([getPrimary, getSurface, isDarkTheme], () => {
chartData.value = setChartData();
chartOptions.value = setChartOptions();
updateChart();
});
onMounted(() => {
chartData.value = setChartData();
chartOptions.value = setChartOptions();
updateChart();
});
</script>
<template>
<div class="card">
<div class="font-semibold text-xl mb-4">تقسیم بندی دارایی ها</div>
<Chart type="bar" :data="chartData" :options="chartOptions" class="h-80" />
<div class="card h-full p-5 border border-surface-200 dark:border-surface-800 rounded-2xl bg-surface-0 dark:bg-surface-900">
<div class="flex justify-between items-center mb-4">
<div>
<h3 class="font-bold text-lg text-surface-900 dark:text-surface-0 mb-1">تفکیک املاک بر اساس دسته‌بندی</h3>
<p class="text-xs text-muted-color">توزیع و تعداد املاک ثبت شده در دسته‌بندی‌های مختلف سامانه</p>
</div>
<div class="flex items-center gap-2">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-primary-50 dark:bg-primary-950/40 text-primary">
<i class="pi pi-th-large text-[10px]"></i>
{{ categoryStats?.length || 0 }} دسته‌بندی
</span>
</div>
</div>
<div v-if="loading" class="h-72 flex items-center justify-center">
<i class="pi pi-spin pi-spinner text-primary text-3xl"></i>
</div>
<div v-else-if="!categoryStats || categoryStats.length === 0" class="h-72 flex flex-col items-center justify-center text-muted-color">
<i class="pi pi-chart-bar text-4xl mb-2 opacity-40"></i>
<span class="text-sm">اطلاعاتی برای نمایش وجود ندارد</span>
</div>
<div v-else class="h-72">
<Chart type="bar" :data="chartData" :options="chartOptions" class="h-full w-full" />
</div>
</div>
</template>
<style>
.card {
font-family: 'Estedad', sans-serif !important;
}
</style>
+108 -21
View File
@@ -1,41 +1,128 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
stats: {
type: Object,
default: () => ({})
},
loading: {
type: Boolean,
default: false
}
});
const formatNumber = (num) => {
if (num === null || num === undefined) return '۰';
return Number(num).toLocaleString('fa-IR');
};
const overview = computed(() => props.stats?.overview || {});
const warehouseFlow = computed(() => props.stats?.warehouseFlow || {});
</script>
<template>
<div class="col-span-12 lg:col-span-6 xl:col-span-3">
<div class="card mb-0">
<div class="flex justify-between mb-4">
<!-- Total Properties Card -->
<div class="col-span-12 sm:col-span-6 lg:col-span-6 xl:col-span-3">
<div
class="card mb-0 h-full p-5 border border-surface-200 dark:border-surface-800 hover:shadow-lg transition-all duration-300 rounded-2xl relative overflow-hidden bg-gradient-to-br from-surface-0 to-surface-50 dark:from-surface-900 dark:to-surface-950"
>
<div class="absolute -right-6 -bottom-6 w-24 h-24 bg-blue-500/5 dark:bg-blue-500/10 rounded-full blur-xl pointer-events-none"></div>
<div class="flex justify-between items-start mb-3">
<div>
<span class="block text-muted-color font-medium mb-4">تعداد دارایی ها</span>
<div class="text-surface-900 dark:text-surface-0 font-medium text-xl">100</div>
<span class="block text-muted-color font-medium text-sm mb-1">املاک و مستغلات</span>
<div v-if="loading" class="h-8 w-20 bg-surface-200 dark:bg-surface-700 animate-pulse rounded my-1"></div>
<div v-else class="text-surface-900 dark:text-surface-0 font-bold text-2xl tracking-tight">{{ formatNumber(overview.totalProperties) }} <span class="text-xs font-normal text-muted-color">ملک</span></div>
</div>
<div class="flex items-center justify-center bg-blue-100 dark:bg-blue-400/10 rounded-border" style="width: 2.5rem; height: 2.5rem">
<i class="pi pi-shopping-cart text-blue-500 !text-xl"></i>
<div class="flex items-center justify-center bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400 rounded-xl shadow-sm" style="width: 3rem; height: 3rem">
<i class="pi pi-building text-xl"></i>
</div>
</div>
<div class="flex items-center gap-2 text-xs font-medium text-surface-600 dark:text-surface-400 pt-2 border-t border-surface-100 dark:border-surface-800">
<i class="pi pi-map text-blue-500"></i>
<span
>مساحت کل: <b class="text-surface-900 dark:text-surface-100">{{ formatNumber(overview.totalArea) }}</b> م²</span
>
</div>
</div>
</div>
<div class="col-span-12 lg:col-span-6 xl:col-span-3">
<div class="card mb-0">
<div class="flex justify-between mb-4">
<!-- Warehouse Items Card -->
<div class="col-span-12 sm:col-span-6 lg:col-span-6 xl:col-span-3">
<div
class="card mb-0 h-full p-5 border border-surface-200 dark:border-surface-800 hover:shadow-lg transition-all duration-300 rounded-2xl relative overflow-hidden bg-gradient-to-br from-surface-0 to-surface-50 dark:from-surface-900 dark:to-surface-950"
>
<div class="absolute -right-6 -bottom-6 w-24 h-24 bg-amber-500/5 dark:bg-amber-500/10 rounded-full blur-xl pointer-events-none"></div>
<div class="flex justify-between items-start mb-3">
<div>
<span class="block text-muted-color font-medium mb-4">ارز ها</span>
<div class="text-surface-900 dark:text-surface-0 font-medium text-xl">2800 دلار</div>
<span class="block text-muted-color font-medium text-sm mb-1">اقلام انبار</span>
<div v-if="loading" class="h-8 w-20 bg-surface-200 dark:bg-surface-700 animate-pulse rounded my-1"></div>
<div v-else class="text-surface-900 dark:text-surface-0 font-bold text-2xl tracking-tight">{{ formatNumber(overview.totalWarehouseItems) }} <span class="text-xs font-normal text-muted-color">قلم کالا</span></div>
</div>
<div class="flex items-center justify-center bg-orange-100 dark:bg-orange-400/10 rounded-border" style="width: 2.5rem; height: 2.5rem">
<i class="pi pi-dollar text-orange-500 !text-xl"></i>
<div class="flex items-center justify-center bg-amber-100 dark:bg-amber-500/20 text-amber-600 dark:text-amber-400 rounded-xl shadow-sm" style="width: 3rem; height: 3rem">
<i class="pi pi-box text-xl"></i>
</div>
</div>
<div class="flex items-center gap-2 text-xs font-medium text-surface-600 dark:text-surface-400 pt-2 border-t border-surface-100 dark:border-surface-800">
<i class="pi pi-warehouse text-amber-500"></i>
<span
>مستقر در <b class="text-surface-900 dark:text-surface-100">{{ formatNumber(overview.totalWarehouses) }}</b> انبار فعال</span
>
</div>
</div>
</div>
<div class="col-span-12 lg:col-span-6 xl:col-span-3">
<div class="card mb-0">
<div class="flex justify-between mb-4">
<!-- Warehouse Transactions Card -->
<div class="col-span-12 sm:col-span-6 lg:col-span-6 xl:col-span-3">
<div
class="card mb-0 h-full p-5 border border-surface-200 dark:border-surface-800 hover:shadow-lg transition-all duration-300 rounded-2xl relative overflow-hidden bg-gradient-to-br from-surface-0 to-surface-50 dark:from-surface-900 dark:to-surface-950"
>
<div class="absolute -right-6 -bottom-6 w-24 h-24 bg-emerald-500/5 dark:bg-emerald-500/10 rounded-full blur-xl pointer-events-none"></div>
<div class="flex justify-between items-start mb-3">
<div>
<span class="block text-muted-color font-medium mb-4">تعداد کاربران</span>
<div class="text-surface-900 dark:text-surface-0 font-medium text-xl">3 عدد</div>
<span class="block text-muted-color font-medium text-sm mb-1">گردش انبارداری</span>
<div v-if="loading" class="h-8 w-20 bg-surface-200 dark:bg-surface-700 animate-pulse rounded my-1"></div>
<div v-else class="text-surface-900 dark:text-surface-0 font-bold text-2xl tracking-tight">{{ formatNumber(overview.totalWarehouseTransactions) }} <span class="text-xs font-normal text-muted-color">تراکنش</span></div>
</div>
<div class="flex items-center justify-center bg-cyan-100 dark:bg-cyan-400/10 rounded-border" style="width: 2.5rem; height: 2.5rem">
<i class="pi pi-users text-cyan-500 !text-xl"></i>
<div class="flex items-center justify-center bg-emerald-100 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 rounded-xl shadow-sm" style="width: 3rem; height: 3rem">
<i class="pi pi-sync text-xl"></i>
</div>
</div>
<div class="flex items-center justify-between text-xs font-medium text-surface-600 dark:text-surface-400 pt-2 border-t border-surface-100 dark:border-surface-800">
<span class="text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
<i class="pi pi-arrow-down-left"></i>
{{ formatNumber(warehouseFlow.inCount) }} ورود
</span>
<span class="text-rose-600 dark:text-rose-400 flex items-center gap-1">
<i class="pi pi-arrow-up-right"></i>
{{ formatNumber(warehouseFlow.outCount) }} خروج
</span>
</div>
</div>
</div>
<!-- Active Users & Roles Card -->
<div class="col-span-12 sm:col-span-6 lg:col-span-6 xl:col-span-3">
<div
class="card mb-0 h-full p-5 border border-surface-200 dark:border-surface-800 hover:shadow-lg transition-all duration-300 rounded-2xl relative overflow-hidden bg-gradient-to-br from-surface-0 to-surface-50 dark:from-surface-900 dark:to-surface-950"
>
<div class="absolute -right-6 -bottom-6 w-24 h-24 bg-purple-500/5 dark:bg-purple-500/10 rounded-full blur-xl pointer-events-none"></div>
<div class="flex justify-between items-start mb-3">
<div>
<span class="block text-muted-color font-medium text-sm mb-1">کاربران سیستم</span>
<div v-if="loading" class="h-8 w-20 bg-surface-200 dark:bg-surface-700 animate-pulse rounded my-1"></div>
<div v-else class="text-surface-900 dark:text-surface-0 font-bold text-2xl tracking-tight">{{ formatNumber(overview.totalUsers) }} <span class="text-xs font-normal text-muted-color">کاربر</span></div>
</div>
<div class="flex items-center justify-center bg-purple-100 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400 rounded-xl shadow-sm" style="width: 3rem; height: 3rem">
<i class="pi pi-users text-xl"></i>
</div>
</div>
<div class="flex items-center gap-2 text-xs font-medium text-surface-600 dark:text-surface-400 pt-2 border-t border-surface-100 dark:border-surface-800">
<i class="pi pi-shield text-purple-500"></i>
<span
>تعریف شده در <b class="text-surface-900 dark:text-surface-100">{{ formatNumber(overview.totalRoles) }}</b> نقش سازمانی</span
>
</div>
</div>
</div>
</template>
@@ -0,0 +1,142 @@
<script setup>
import { useRouter } from 'vue-router';
import { parseDateToJalali } from '@/utils/jalali';
const props = defineProps({
transactions: {
type: Array,
default: () => []
},
warehouseFlow: {
type: Object,
default: () => ({ inCount: 0, outCount: 0, inQuantity: 0, outQuantity: 0 })
},
loading: {
type: Boolean,
default: false
}
});
const router = useRouter();
function viewAllTransactions() {
router.push('/warehouse-items/transactions');
}
function createTransaction() {
router.push('/warehouse-items/create-transaction');
}
function formatJalaliDate(dateVal) {
if (!dateVal) return '—';
const j = parseDateToJalali(dateVal);
if (!j) return '—';
return `${j.jy}/${String(j.jm).padStart(2, '0')}/${String(j.jd).padStart(2, '0')}`;
}
const formatNumber = (num) => {
if (num === null || num === undefined) return '۰';
return Number(num).toLocaleString('fa-IR');
};
</script>
<template>
<div class="card h-full p-5 border border-surface-200 dark:border-surface-800 rounded-2xl bg-surface-0 dark:bg-surface-900 flex flex-col justify-between">
<div>
<div class="flex justify-between items-center mb-4">
<div>
<h3 class="font-bold text-lg text-surface-900 dark:text-surface-0 mb-1">آخرین گردش‌های انبار</h3>
<p class="text-xs text-muted-color">گزارش لحظه‌ای ثبت ورود و خروج کالا در انبارها</p>
</div>
<Button label="مشاهده همه" icon="pi pi-arrow-left" iconPos="right" size="small" text class="text-xs" @click="viewAllTransactions" />
</div>
<!-- Flow stats summary banner -->
<div class="grid grid-cols-2 gap-3 mb-4">
<div class="p-3 rounded-xl bg-emerald-50/60 dark:bg-emerald-950/20 border border-emerald-200/50 dark:border-emerald-800/30 flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-lg bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 flex items-center justify-center">
<i class="pi pi-arrow-down-left text-sm"></i>
</div>
<div>
<span class="text-[11px] text-muted-color block">مجموع ورودی</span>
<span class="text-xs font-bold text-emerald-700 dark:text-emerald-400"> {{ formatNumber(warehouseFlow.inQuantity) }} واحد </span>
</div>
</div>
<span class="text-xs font-medium text-emerald-600 dark:text-emerald-400"> {{ formatNumber(warehouseFlow.inCount) }} تراکنش </span>
</div>
<div class="p-3 rounded-xl bg-rose-50/60 dark:bg-rose-950/20 border border-rose-200/50 dark:border-rose-800/30 flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-lg bg-rose-500/10 text-rose-600 dark:text-rose-400 flex items-center justify-center">
<i class="pi pi-arrow-up-right text-sm"></i>
</div>
<div>
<span class="text-[11px] text-muted-color block">مجموع خروجی</span>
<span class="text-xs font-bold text-rose-700 dark:text-rose-400"> {{ formatNumber(warehouseFlow.outQuantity) }} واحد </span>
</div>
</div>
<span class="text-xs font-medium text-rose-600 dark:text-rose-400"> {{ formatNumber(warehouseFlow.outCount) }} تراکنش </span>
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="py-10 flex items-center justify-center">
<i class="pi pi-spin pi-spinner text-primary text-2xl"></i>
</div>
<!-- Empty -->
<div v-else-if="!transactions || transactions.length === 0" class="py-10 flex flex-col items-center justify-center text-muted-color">
<i class="pi pi-history text-4xl mb-2 opacity-40"></i>
<span class="text-sm">تراکنش انباری ثبت نشده است</span>
<Button label="ثبت اولین تراکنش" icon="pi pi-plus" size="small" class="mt-3" @click="createTransaction" />
</div>
<!-- Transactions List -->
<ul v-else class="list-none p-0 m-0 divide-y divide-surface-100 dark:divide-surface-800">
<li v-for="tx in transactions" :key="tx._id" class="py-3 flex items-center justify-between gap-3 hover:bg-surface-50 dark:hover:bg-surface-800/50 px-2 rounded-xl transition-colors duration-150">
<div class="flex items-center gap-3 min-w-0">
<div
:class="[
'w-9 h-9 rounded-xl flex items-center justify-center shrink-0 font-bold text-xs',
tx.type === 'IN' ? 'bg-emerald-100 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400' : 'bg-rose-100 dark:bg-rose-500/20 text-rose-600 dark:text-rose-400'
]"
>
<i :class="['pi text-xs', tx.type === 'IN' ? 'pi-arrow-down' : 'pi-arrow-up']"></i>
</div>
<div class="truncate">
<div class="flex items-center gap-2">
<span class="font-semibold text-surface-900 dark:text-surface-100 text-sm truncate">
{{ tx.itemName }}
</span>
<span
:class="[
'px-1.5 py-0.2 rounded text-[10px] font-bold',
tx.type === 'IN' ? 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300' : 'bg-rose-100 dark:bg-rose-900/40 text-rose-700 dark:text-rose-300'
]"
>
{{ tx.type === 'IN' ? 'ورود' : 'خروج' }}
</span>
</div>
<div class="text-[11px] text-muted-color flex items-center gap-2 mt-0.5">
<span>انبار: {{ tx.warehouseName }}</span>
<span>•</span>
<span>توسط: {{ tx.actorName }}</span>
</div>
</div>
</div>
<div class="text-left shrink-0">
<div :class="['font-bold text-sm font-mono', tx.type === 'IN' ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400']">
{{ tx.type === 'IN' ? '+' : '-' }}{{ formatNumber(tx.quantity) }} {{ tx.unit }}
</div>
<span class="text-[10px] text-muted-color font-mono block">
{{ formatJalaliDate(tx.date) }}
</span>
</div>
</li>
</ul>
</div>
</div>
</template>
+24 -5
View File
@@ -9,19 +9,38 @@ const model = ref([
items: [{ label: 'داشبورد', icon: 'pi pi-fw pi-home', to: '/' }]
},
{
label: 'مدیریت اموال',
label: 'مدیریت املاک',
items: [
{ label: 'ایجاد اموال', icon: 'pi pi-fw pi-file-plus', to: '/belonging/create-belonging' },
{ label: 'لیست اموال', icon: 'pi pi-fw pi-file', to: '/belonging/list-belongings' },
{ label: 'لیست تراکنش اموال', icon: 'pi pi-list-check', to: '/transactions/list-transactions' }
{ label: 'ایجاد ملک', icon: 'pi pi-fw pi-file-plus', to: '/property/create-property' },
{ label: 'لیست املاک', icon: 'pi pi-fw pi-file', to: '/property/list-properties' }
]
},
{
label: 'دسته بندی اموال',
label: 'دسته بندی املاک',
items: [
{ label: 'لیست دسته بندی ها', icon: 'pi pi-fw pi-th-large', to: '/categories/list-categories' },
{ label: 'ویژگی دسته بندی ها', icon: 'pi pi-fw pi-sitemap', to: '/features/list-features' }
]
},
{
label: 'مدیریت انبار',
items: [
{ label: 'ثبت تراکنش', icon: 'pi pi-fw pi-plus-circle', to: '/warehouse-items/create-transaction' },
{ label: 'اقلام انبار', icon: 'pi pi-fw pi-box', to: '/warehouse-items/list' },
{ label: 'تراکنش‌های انبار', icon: 'pi pi-fw pi-history', to: '/warehouse-items/transactions' },
{ label: 'مکان‌های انبار', icon: 'pi pi-fw pi-map', to: '/warehouse-items/warehouses' }
]
},
{
label: 'مدیریت سیستم',
items: [
{ label: 'کاربران', icon: 'pi pi-fw pi-users', to: '/users/list-users' },
{ label: 'نقش‌ها و دسترسی‌ها', icon: 'pi pi-fw pi-shield', to: '/roles/list-roles' }
]
},
{
label: 'تنظیمات سیستم',
items: [{ label: 'تنظیمات جستجو', icon: 'pi pi-fw pi-sliders-h', to: '/settings/search-settings' }]
}
]);
</script>
+61 -21
View File
@@ -2,12 +2,54 @@
import { useLayout } from '@/layout/composables/layout';
import AppConfigurator from './AppConfigurator.vue';
import Cookies from 'js-cookie';
import axios from 'axios';
import { useRouter } from 'vue-router';
const router = useRouter()
const signOut = () => {
Cookies.set('bm-token', '')
router.push('/auth/login')
}
const router = useRouter();
const signOut = async () => {
try {
await axios.post('/api/users/logout', {}, { withCredentials: true });
} catch (e) {
console.warn('Logout API error:', e?.message || e);
}
// Comprehensive cookie cleaning
Cookies.remove('bm-token');
Cookies.remove('bm-token', { domain: '.mbmanager.ir', path: '/' });
Cookies.remove('bm-token', { path: '/' });
Cookies.remove('belonging_token');
Cookies.remove('belonging_token', { domain: '.mbmanager.ir', path: '/' });
Cookies.remove('belonging_token', { path: '/' });
Cookies.remove('jwt');
Cookies.remove('jwt', { domain: '.mbmanager.ir', path: '/' });
Cookies.remove('jwt', { path: '/' });
// Force expire raw cookies in document.cookie
if (typeof document !== 'undefined') {
const cookiesToClear = ['bm-token', 'belonging_token', 'jwt'];
const domains = ['', '; domain=.mbmanager.ir', '; domain=' + window.location.hostname];
cookiesToClear.forEach((name) => {
domains.forEach((dom) => {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/${dom}`;
});
});
}
// Clear local & session storage
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('bm-token');
localStorage.removeItem('belonging_token');
localStorage.removeItem('jwt');
}
if (typeof sessionStorage !== 'undefined') {
sessionStorage.clear();
}
delete axios.defaults.headers.common['Authorization'];
await router.replace('/auth/login');
};
const { toggleMenu, toggleDarkMode, isDarkTheme } = useLayout();
</script>
@@ -15,7 +57,7 @@ const { toggleMenu, toggleDarkMode, isDarkTheme } = useLayout();
<template>
<div class="layout-topbar">
<div class="layout-topbar-logo-container">
<button class="layout-menu-button layout-topbar-action" @click="toggleMenu">
<button class="layout-menu-button layout-topbar-action" @click="toggleMenu" title="منو">
<i class="pi pi-bars"></i>
</button>
<router-link to="/" class="layout-topbar-logo">
@@ -43,7 +85,7 @@ const { toggleMenu, toggleDarkMode, isDarkTheme } = useLayout();
<div class="layout-topbar-actions">
<div class="layout-config-menu">
<button type="button" class="layout-topbar-action" @click="toggleDarkMode">
<button type="button" class="layout-topbar-action" @click="toggleDarkMode" title="تغییر حالت شب/روز">
<i :class="['pi', { 'pi-moon': isDarkTheme, 'pi-sun': !isDarkTheme }]"></i>
</button>
<div class="relative">
@@ -51,6 +93,7 @@ const { toggleMenu, toggleDarkMode, isDarkTheme } = useLayout();
v-styleclass="{ selector: '@next', enterFromClass: 'hidden', enterActiveClass: 'animate-scalein', leaveToClass: 'hidden', leaveActiveClass: 'animate-fadeout', hideOnOutsideClick: true }"
type="button"
class="layout-topbar-action layout-topbar-action-highlight"
title="تنظیمات تم"
>
<i class="pi pi-palette"></i>
</button>
@@ -61,27 +104,24 @@ const { toggleMenu, toggleDarkMode, isDarkTheme } = useLayout();
<button
class="layout-topbar-menu-button layout-topbar-action"
v-styleclass="{ selector: '@next', enterFromClass: 'hidden', enterActiveClass: 'animate-scalein', leaveToClass: 'hidden', leaveActiveClass: 'animate-fadeout', hideOnOutsideClick: true }"
title="منوی کاربری"
>
<i class="pi pi-ellipsis-v"></i>
</button>
<div class="layout-topbar-menu hidden lg:block">
<div class="layout-topbar-menu-content">
<button type="button" class="layout-topbar-action">
<i class="pi pi-calendar"></i>
<span>Calendar</span>
</button>
<button type="button" class="layout-topbar-action">
<i class="pi pi-inbox"></i>
<span>Messages</span>
</button>
<button type="button" class="layout-topbar-action">
<router-link to="/property/create-property" class="layout-topbar-action" title="ثبت ملک جدید">
<i class="pi pi-plus-circle"></i>
<span>ثبت ملک</span>
</router-link>
<router-link to="/users/list-users" class="layout-topbar-action" title="مدیریت کاربران">
<i class="pi pi-user"></i>
<span>Profile</span>
</button>
<button type="button" class="layout-topbar-action" @click="signOut">
<i class="pi pi-power-off"></i>
<span>Profile</span>
<span>کاربران</span>
</router-link>
<button type="button" class="layout-topbar-action text-red-500 hover:text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30" title="خروج از حساب کاربری" @click="signOut">
<i class="pi pi-power-off text-red-500"></i>
<span class="text-red-500">خروج</span>
</button>
</div>
</div>
+1 -1
View File
@@ -15,7 +15,7 @@ const layoutState = reactive({
configSidebarVisible: false,
staticMenuMobileActive: false,
menuHoverActive: false,
activeMenuItem: null,
activeMenuItem: null
});
export function useLayout() {
+68 -4
View File
@@ -4,7 +4,7 @@ import axios from 'axios';
import Cookies from 'js-cookie';
const loginGuard = async (to, from, next) => {
const token = Cookies.get('bm-token');
const token = Cookies.get('bm-token') || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
if (!token) {
next('/auth/login'); // Redirect to log in if no token
@@ -38,20 +38,36 @@ const router = createRouter({
name: 'dashboard',
component: () => import('@/views/Dashboard.vue')
},
{
path: '/property/create-property',
name: 'create-property',
component: () => import('@/views/pages/Property/CreateProperty.vue')
},
{
path: '/property/edit-property/:propertyId',
name: 'edit-property',
component: () => import('@/views/pages/Property/CreateProperty.vue')
},
{
path: '/property/list-properties',
name: 'list-properties',
component: () => import('@/views/pages/Property/ListProperties.vue')
},
// Aliases for backward compatibility
{
path: '/belonging/create-belonging',
name: 'create-belonging',
component: () => import('@/views/pages/Belonging/CreateBelonging.vue')
component: () => import('@/views/pages/Property/CreateProperty.vue')
},
{
path: '/belonging/edit-belonging/:belongingId',
name: 'edit-belonging',
component: () => import('@/views/pages/Belonging/CreateBelonging.vue')
component: () => import('@/views/pages/Property/CreateProperty.vue')
},
{
path: '/belonging/list-belongings',
name: 'list-belongings',
component: () => import('@/views/pages/Belonging/ListBelongings.vue')
component: () => import('@/views/pages/Property/ListProperties.vue')
},
{
path: '/categories/list-categories',
@@ -68,6 +84,54 @@ const router = createRouter({
name: 'list-transactions',
component: () => import('@/views/pages/Transaction/ListTransactions.vue')
},
// ─── Warehouse Items ──────────────────────────────────────────
{
path: '/warehouse-items/list',
name: 'list-warehouse-items',
component: () => import('@/views/pages/WarehouseItems/ListWarehouseItems.vue')
},
{
path: '/warehouse-items/stock/:itemId',
name: 'warehouse-item-stock',
component: () => import('@/views/pages/WarehouseItems/WarehouseItemStockView.vue')
},
{
path: '/warehouse-items/create-transaction',
name: 'create-warehouse-transaction',
component: () => import('@/views/pages/WarehouseItems/CreateWarehouseTransaction.vue')
},
{
path: '/warehouse-items/transactions',
name: 'warehouse-item-transactions',
component: () => import('@/views/pages/WarehouseItems/ListWarehouseItemTransactions.vue')
},
{
path: '/warehouse-items/warehouses',
name: 'list-warehouses',
component: () => import('@/views/pages/WarehouseItems/ListWarehouses.vue')
},
// ─── Users & Roles ───────────────────────────────────────────
{
path: '/users/list-users',
name: 'list-users',
component: () => import('@/views/pages/Users/ListUsers.vue')
},
{
path: '/roles/list-roles',
name: 'list-roles',
component: () => import('@/views/pages/Roles/ListRoles.vue')
},
// ─── Settings ────────────────────────────────────────────────
{
path: '/settings/search-settings',
name: 'search-settings',
component: () => import('@/views/pages/Settings/SearchSettings.vue')
},
{
path: '/settings',
redirect: '/settings/search-settings'
},
{
path: '/uikit/formlayout',
name: 'formlayout',
-110
View File
@@ -1,110 +0,0 @@
import axios from 'axios';
export const BelongingService = {
// Helper function to get token from cookies
_getAuthHeader: () => {
const bmToken = document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1];
return {
Authorization: `Bearer ${bmToken}`
};
},
getBelonging: async (belongingId) => {
const config = {
method: 'get',
url: '/api/belongings/admin/get-one/' + belongingId,
withCredentials: true,
headers: BelongingService._getAuthHeader()
};
try {
const response = await axios(config);
return response.data.data;
} catch (err) {
console.log(err);
}
},
getAllBelongings: async () => {
const config = {
method: 'get',
url: '/api/belongings/admin/get-all',
withCredentials: true,
headers: BelongingService._getAuthHeader()
};
try {
const response = await axios(config);
return response.data.data;
} catch (err) {
console.log(err);
}
},
searchBelongings: async (query) => {
const config = {
method: 'get',
url: '/api/belongings/admin/search',
withCredentials: true,
params: query,
headers: BelongingService._getAuthHeader()
};
try {
const response = await axios(config);
return response.data;
} catch (err) {
console.log(err);
}
},
indexBelongings: async () => {
const config = {
method: 'post',
url: '/api/belongings/admin/index-all',
withCredentials: true,
headers: BelongingService._getAuthHeader()
};
try {
const response = await axios(config);
return response.data;
} catch (err) {
console.log(err);
}
},
createBelonging: async (data) => {
const config = {
method: 'post',
url: '/api/belongings/admin/create',
withCredentials: true,
data,
headers: BelongingService._getAuthHeader()
};
try {
const response = await axios(config);
return 'success';
} catch (err) {
console.log(err);
return err.response.data.message;
}
},
updateBelonging: async (belongingId, data) => {
const config = {
method: 'put',
url: '/api/belongings/admin/update/' + belongingId,
withCredentials: true,
data,
headers: BelongingService._getAuthHeader()
};
try {
const response = await axios(config);
return 'success';
} catch (err) {
console.log(err);
return err.response.data.message;
}
}
};
+16 -26
View File
@@ -2,46 +2,36 @@ import axios from 'axios';
export const CategoryService = {
getAllCategories: async () => {
const config = {
method: 'get',
url: '/api/categories/admin/get-all',
withCredentials: true
};
try {
const response = await axios(config);
return response.data.data;
const response = await axios.get('/api/categories/admin/get-all');
return response.data?.data || [];
} catch (err) {
console.log(err);
console.error('Error in getAllCategories:', err);
return [];
}
},
createCategory: async (data) => {
const config = {
method: 'post',
url: '/api/categories/admin/create',
data,
withCredentials: true
};
try {
const response = await axios(config);
const response = await axios.post('/api/categories/admin/create', data);
return 'success';
} catch (err) {
return err.response.data.message;
return err.response?.data?.message || 'خطا در ساخت دسته‌بندی';
}
},
updateCategory: async (categoryId, data) => {
const config = {
method: 'put',
url: '/api/categories/admin/update/' + categoryId,
data,
withCredentials: true
};
try {
const response = await axios(config);
const response = await axios.patch('/api/categories/admin/update/' + categoryId, data);
return 'success';
} catch (err) {
return err.response.data.message;
return err.response?.data?.message || 'خطا در ویرایش دسته‌بندی';
}
},
deleteCategory: async (categoryId) => {
try {
await axios.delete('/api/categories/admin/delete/' + categoryId);
return 'success';
} catch (err) {
return err.response?.data?.message || 'خطا در حذف دسته‌بندی';
}
}
};
+27
View File
@@ -0,0 +1,27 @@
import axios from 'axios';
const getAuthHeader = () => {
const bmToken =
(typeof document !== 'undefined'
? document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1]
: null) || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
return bmToken && bmToken !== 'undefined' && bmToken !== 'null' ? { Authorization: `Bearer ${bmToken}` } : {};
};
export const DashboardService = {
/**
* Fetch aggregated live statistics for the dashboard
* @returns {Promise<Object>}
*/
getDashboardStats: async () => {
const response = await axios.get('/api/dashboard/stats', {
withCredentials: true,
headers: getAuthHeader()
});
return response.data?.data || response.data;
}
};
+25 -26
View File
@@ -2,46 +2,45 @@ import axios from 'axios';
export const FeatureService = {
getAllFeatures: async () => {
const config = {
method: 'get',
url: '/api/categories/admin/get-all',
withCredentials: true
};
try {
const response = await axios(config);
return response.data.data;
const response = await axios.get('/api/features/admin/get-all');
return response.data?.data || [];
} catch (err) {
console.log(err);
console.error('Error in getAllFeatures:', err);
return [];
}
},
getFeaturesByCategory: async (categoryId) => {
try {
const response = await axios.get(`/api/features/admin/get-by-category/${categoryId}`);
return response.data?.data || [];
} catch (err) {
console.error('Error in getFeaturesByCategory:', err);
return [];
}
},
createFeature: async (data) => {
const config = {
method: 'post',
url: '/api/features/admin/create',
data,
withCredentials: true
};
try {
const response = await axios(config);
await axios.post('/api/features/admin/create', data);
return 'success';
} catch (err) {
return err.response.data.message;
return err.response?.data?.message || 'خطا در ساخت ویژگی';
}
},
updateFeature: async (featureId, data) => {
const config = {
method: 'put',
url: '/api/features/admin/update/' + featureId,
data,
withCredentials: true
};
try {
const response = await axios(config);
await axios.put('/api/features/admin/update/' + featureId, data);
return 'success';
} catch (err) {
return err.response.data.message;
return err.response?.data?.message || 'خطا در ویرایش ویژگی';
}
},
deleteFeature: async (featureId) => {
try {
await axios.delete('/api/features/admin/delete/' + featureId);
return 'success';
} catch (err) {
return err.response?.data?.message || 'خطا در حذف ویژگی';
}
}
};
+20
View File
@@ -0,0 +1,20 @@
import axios from 'axios';
const getAuthHeader = () => {
const bmToken =
document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1] || localStorage.getItem('bm-token');
return bmToken ? { Authorization: `Bearer ${bmToken}` } : {};
};
export const PermissionService = {
listPermissions: async () => {
const response = await axios.get('/api/permissions', {
withCredentials: true,
headers: getAuthHeader()
});
return response.data.data;
}
};
+138
View File
@@ -0,0 +1,138 @@
import axios from 'axios';
export const PropertyService = {
_getAuthHeader: () => {
const bmToken =
(typeof document !== 'undefined'
? document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1]
: null) || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
return bmToken && bmToken !== 'undefined' && bmToken !== 'null' ? { Authorization: `Bearer ${bmToken}` } : {};
},
getProperty: async (propertyId) => {
const config = {
method: 'get',
url: '/api/properties/admin/get-one/' + propertyId,
withCredentials: true,
headers: PropertyService._getAuthHeader()
};
try {
const response = await axios(config);
return response.data.data;
} catch (err) {
console.log(err);
}
},
getAllProperties: async () => {
const config = {
method: 'get',
url: '/api/properties/admin/get-all',
withCredentials: true,
headers: PropertyService._getAuthHeader()
};
try {
const response = await axios(config);
return response.data.data;
} catch (err) {
console.log(err);
}
},
searchProperties: async (query) => {
const config = {
method: 'get',
url: '/api/properties/admin/search',
withCredentials: true,
params: query,
headers: PropertyService._getAuthHeader()
};
try {
const response = await axios(config);
return response.data;
} catch (err) {
console.log(err);
throw err;
}
},
searchPropertiesMongo: async (query) => {
const config = {
method: 'get',
url: '/api/properties/admin/search-mongo',
withCredentials: true,
params: query,
headers: PropertyService._getAuthHeader()
};
try {
const response = await axios(config);
return response.data;
} catch (err) {
console.log(err);
throw err;
}
},
indexProperties: async () => {
const config = {
method: 'post',
url: '/api/properties/admin/index-all',
withCredentials: true,
headers: PropertyService._getAuthHeader()
};
try {
const response = await axios(config);
return response.data;
} catch (err) {
console.log(err);
}
},
createProperty: async (data) => {
const config = {
method: 'post',
url: '/api/properties/admin/create',
withCredentials: true,
data,
headers: PropertyService._getAuthHeader()
};
try {
const response = await axios(config);
return 'success';
} catch (err) {
console.error('createProperty error:', err);
return err?.response?.data?.message || err?.message || 'خطا در ثبت ملک';
}
},
updateProperty: async (propertyId, data) => {
const config = {
method: 'put',
url: '/api/properties/admin/update/' + propertyId,
withCredentials: true,
data,
headers: PropertyService._getAuthHeader()
};
try {
const response = await axios(config);
return 'success';
} catch (err) {
console.error('updateProperty error:', err);
return err?.response?.data?.message || err?.message || 'خطا در بروزرسانی ملک';
}
},
// Backward compatibility aliases
getBelonging: (id) => PropertyService.getProperty(id),
getAllBelongings: () => PropertyService.getAllProperties(),
searchBelongings: (q) => PropertyService.searchProperties(q),
indexBelongings: () => PropertyService.indexProperties(),
createBelonging: (data) => PropertyService.createProperty(data),
updateBelonging: (id, data) => PropertyService.updateProperty(id, data)
};
export const BelongingService = PropertyService;
+22
View File
@@ -0,0 +1,22 @@
import axios from 'axios';
import { PropertyService } from './PropertyService';
export const PropertyStatusService = {
getAllStatuses: async () => {
const config = {
method: 'get',
url: '/api/property-statuses/admin/get-all',
withCredentials: true,
headers: PropertyService._getAuthHeader()
};
try {
const response = await axios(config);
return response.data.data;
} catch (err) {
console.error('Error fetching property statuses:', err);
return [];
}
}
};
export const BelongingStatusService = PropertyStatusService;
+53
View File
@@ -0,0 +1,53 @@
import axios from 'axios';
const getAuthHeader = () => {
const bmToken =
document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1] || localStorage.getItem('bm-token');
return bmToken ? { Authorization: `Bearer ${bmToken}` } : {};
};
export const RoleService = {
listRoles: async (params = {}) => {
const response = await axios.get('/api/roles/admin/get-all', {
params,
withCredentials: true,
headers: getAuthHeader()
});
return response.data.data;
},
getRole: async (roleId) => {
const response = await axios.get(`/api/roles/admin/get-one/${roleId}`, {
withCredentials: true,
headers: getAuthHeader()
});
return response.data.data;
},
createRole: async (data) => {
const response = await axios.post('/api/roles/admin/create', data, {
withCredentials: true,
headers: getAuthHeader()
});
return response.data.data;
},
updateRole: async (roleId, data) => {
const response = await axios.patch(`/api/roles/admin/update/${roleId}`, data, {
withCredentials: true,
headers: getAuthHeader()
});
return response.data.data;
},
deleteRole: async (roleId) => {
const response = await axios.delete(`/api/roles/admin/delete/${roleId}`, {
withCredentials: true,
headers: getAuthHeader()
});
return response.data;
}
};
+203
View File
@@ -0,0 +1,203 @@
import axios from 'axios';
export const SettingService = {
_getAuthHeader: () => {
const bmToken =
(typeof document !== 'undefined'
? document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1]
: null) || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
return bmToken && bmToken !== 'undefined' && bmToken !== 'null' ? { Authorization: `Bearer ${bmToken}` } : {};
},
/**
* Get search settings for all modules
* @returns {Promise<Array>}
*/
getSearchSettings: async () => {
const config = {
method: 'get',
url: '/api/settings/search-settings',
withCredentials: true,
headers: SettingService._getAuthHeader()
};
try {
const response = await axios(config);
return response.data.data;
} catch (err) {
console.error('Error fetching search settings:', err);
throw err;
}
},
/**
* Get search setting for a specific page key
* @param {string} pageKey
* @returns {Promise<Object>}
*/
getSearchSettingByPage: async (pageKey) => {
const config = {
method: 'get',
url: `/api/settings/search-settings/${pageKey}`,
withCredentials: true,
headers: SettingService._getAuthHeader()
};
try {
const response = await axios(config);
return response.data.data;
} catch (err) {
console.error(`Error fetching search setting for ${pageKey}:`, err);
throw err;
}
},
/**
* Update search method for a specific page key
* @param {string} pageKey
* @param {string} searchMethod - 'typesense' | 'mongo'
* @param {Object} extraConfig
* @returns {Promise<Object>}
*/
updateSearchSetting: async (pageKey, searchMethod, extraConfig = {}) => {
const config = {
method: 'put',
url: `/api/settings/search-settings/${pageKey}`,
withCredentials: true,
data: { searchMethod, extraConfig },
headers: SettingService._getAuthHeader()
};
try {
const response = await axios(config);
return response.data.data;
} catch (err) {
console.error(`Error updating search setting for ${pageKey}:`, err);
throw err;
}
},
/**
* Bulk update search settings
* @param {Array<{pageKey: string, searchMethod: string}>} settings
* @returns {Promise<Array>}
*/
bulkUpdateSearchSettings: async (settings) => {
const config = {
method: 'put',
url: '/api/settings/search-settings',
withCredentials: true,
data: { settings },
headers: SettingService._getAuthHeader()
};
try {
const response = await axios(config);
return response.data.data;
} catch (err) {
console.error('Error bulk updating search settings:', err);
throw err;
}
},
/**
* Get database seeding status
* @returns {Promise<{isSeeded: boolean, seededAt: string|null, seededBy: string|null}>}
*/
getSeedStatus: async () => {
const config = {
method: 'get',
url: '/api/settings/seed-status',
withCredentials: true,
headers: SettingService._getAuthHeader()
};
try {
const response = await axios(config);
return response.data.data;
} catch (err) {
console.error('Error fetching seed status:', err);
throw err;
}
},
/**
* Execute one-time database seeding
* @returns {Promise<Object>}
*/
seedDatabase: async () => {
const config = {
method: 'post',
url: '/api/settings/seed-database',
withCredentials: true,
headers: SettingService._getAuthHeader()
};
try {
const response = await axios(config);
return response.data.data;
} catch (err) {
console.error('Error seeding database:', err);
throw err;
}
},
/**
* Get auto-save configuration
* @returns {Promise<{enabled: boolean, intervalSeconds: number}>}
*/
getAutoSaveSetting: async () => {
const config = {
method: 'get',
url: '/api/settings/autosave',
withCredentials: true,
headers: SettingService._getAuthHeader()
};
try {
const response = await axios(config);
if (response.data?.data) {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('bm-autosave-settings', JSON.stringify(response.data.data));
}
return response.data.data;
}
} catch (err) {
console.warn('Error fetching autosave setting from server, using local fallback:', err);
}
if (typeof localStorage !== 'undefined') {
const cached = localStorage.getItem('bm-autosave-settings');
if (cached) {
try {
return JSON.parse(cached);
} catch (e) {}
}
}
return { enabled: true, intervalSeconds: 60 };
},
/**
* Update auto-save configuration
* @param {{enabled: boolean, intervalSeconds: number}} data
* @returns {Promise<{enabled: boolean, intervalSeconds: number}>}
*/
updateAutoSaveSetting: async (data) => {
const config = {
method: 'put',
url: '/api/settings/autosave',
withCredentials: true,
data,
headers: SettingService._getAuthHeader()
};
try {
const response = await axios(config);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('bm-autosave-settings', JSON.stringify(response.data.data));
}
return response.data.data;
} catch (err) {
console.error('Error updating autosave setting:', err);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('bm-autosave-settings', JSON.stringify(data));
}
return data;
}
}
};
+26 -7
View File
@@ -2,14 +2,15 @@ import axios from 'axios';
export const TransactionService = {
_getAuthHeader: () => {
const bmToken = document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1];
const bmToken =
(typeof document !== 'undefined'
? document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1]
: null) || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
return {
Authorization: `Bearer ${bmToken}`
};
return bmToken && bmToken !== 'undefined' && bmToken !== 'null' ? { Authorization: `Bearer ${bmToken}` } : {};
},
/**
* Get a single transaction by ID
@@ -168,6 +169,24 @@ export const TransactionService = {
return response.data;
} catch (err) {
console.log(err);
throw err;
}
},
searchTransactionsMongo: async (query) => {
const config = {
method: 'get',
url: '/api/transactions/admin/search-mongo',
withCredentials: true,
params: query,
headers: TransactionService._getAuthHeader()
};
try {
const response = await axios(config);
return response.data;
} catch (err) {
console.log(err);
throw err;
}
},
+53
View File
@@ -0,0 +1,53 @@
import axios from 'axios';
const getAuthHeader = () => {
const bmToken =
document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1] || localStorage.getItem('bm-token');
return bmToken ? { Authorization: `Bearer ${bmToken}` } : {};
};
export const UserService = {
listUsers: async (params = {}) => {
const response = await axios.get('/api/users', {
params,
withCredentials: true,
headers: getAuthHeader()
});
return response.data.data;
},
getUser: async (userId) => {
const response = await axios.get(`/api/users/${userId}`, {
withCredentials: true,
headers: getAuthHeader()
});
return response.data.data;
},
createUser: async (data) => {
const response = await axios.post('/api/users', data, {
withCredentials: true,
headers: getAuthHeader()
});
return response.data.data;
},
updateUser: async (userId, data) => {
const response = await axios.patch(`/api/users/${userId}`, data, {
withCredentials: true,
headers: getAuthHeader()
});
return response.data.data;
},
deleteUser: async (userId) => {
const response = await axios.delete(`/api/users/${userId}`, {
withCredentials: true,
headers: getAuthHeader()
});
return response.data;
}
};
+105
View File
@@ -0,0 +1,105 @@
import axios from 'axios';
const getAuthHeader = () => {
const bmToken =
(typeof document !== 'undefined'
? document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1]
: null) || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
return bmToken && bmToken !== 'undefined' && bmToken !== 'null' ? { Authorization: `Bearer ${bmToken}` } : {};
};
export const WarehouseItemService = {
// ─── Items ────────────────────────────────────────────────────────────────
listItems: async (params = {}) => {
const response = await axios.get('/api/warehouse-items/admin/items', {
params,
withCredentials: true,
headers: getAuthHeader()
});
return response.data;
},
createItem: async (data) => {
const response = await axios.post('/api/warehouse-items/admin/items', data, {
withCredentials: true,
headers: getAuthHeader()
});
return response.data.data;
},
getItem: async (itemId) => {
const response = await axios.get(`/api/warehouse-items/admin/items/${itemId}`, {
withCredentials: true,
headers: getAuthHeader()
});
return response.data.data;
},
updateItem: async (itemId, data) => {
const response = await axios.patch(`/api/warehouse-items/admin/items/${itemId}`, data, {
withCredentials: true,
headers: getAuthHeader()
});
return response.data.data;
},
deleteItem: async (itemId) => {
await axios.delete(`/api/warehouse-items/admin/items/${itemId}`, {
withCredentials: true,
headers: getAuthHeader()
});
},
// ─── Transactions ─────────────────────────────────────────────────────────
listTransactions: async (params = {}) => {
const response = await axios.get('/api/warehouse-items/admin/transactions', {
params,
withCredentials: true,
headers: getAuthHeader()
});
return response.data;
},
createTransaction: async (data) => {
const response = await axios.post('/api/warehouse-items/admin/transactions', data, {
withCredentials: true,
headers: getAuthHeader()
});
return response.data.data;
},
deleteTransaction: async (transactionId) => {
await axios.delete(`/api/warehouse-items/admin/transactions/${transactionId}`, {
withCredentials: true,
headers: getAuthHeader()
});
},
// ─── Stock ────────────────────────────────────────────────────────────────
getTotalStock: async () => {
const response = await axios.get('/api/warehouse-items/admin/stock/total', {
withCredentials: true,
headers: getAuthHeader()
});
return response.data.data;
},
getStockByItem: async (itemId) => {
const response = await axios.get(`/api/warehouse-items/admin/stock/by-item/${itemId}`, {
withCredentials: true,
headers: getAuthHeader()
});
return response.data.data;
},
getStockByWarehouse: async (warehouseId) => {
const response = await axios.get(`/api/warehouse-items/admin/stock/by-warehouse/${warehouseId}`, {
withCredentials: true,
headers: getAuthHeader()
});
return response.data.data;
}
};
+54
View File
@@ -0,0 +1,54 @@
import axios from 'axios';
const getAuthHeader = () => {
const bmToken =
(typeof document !== 'undefined'
? document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1]
: null) || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
return bmToken && bmToken !== 'undefined' && bmToken !== 'null' ? { Authorization: `Bearer ${bmToken}` } : {};
};
export const WarehouseService = {
listWarehouses: async (params = {}) => {
const response = await axios.get('/api/warehouses/admin/get-all', {
params,
withCredentials: true,
headers: getAuthHeader()
});
return response.data.data;
},
createWarehouse: async (data) => {
const response = await axios.post('/api/warehouses/admin/create', data, {
withCredentials: true,
headers: getAuthHeader()
});
return response.data.data;
},
getWarehouse: async (warehouseId) => {
const response = await axios.get(`/api/warehouses/admin/get-one/${warehouseId}`, {
withCredentials: true,
headers: getAuthHeader()
});
return response.data.data;
},
updateWarehouse: async (warehouseId, data) => {
const response = await axios.patch(`/api/warehouses/admin/update/${warehouseId}`, data, {
withCredentials: true,
headers: getAuthHeader()
});
return response.data.data;
},
deleteWarehouse: async (warehouseId) => {
await axios.delete(`/api/warehouses/admin/delete/${warehouseId}`, {
withCredentials: true,
headers: getAuthHeader()
});
}
};
+151
View File
@@ -0,0 +1,151 @@
/**
* Jalali (Solar Hijri / Shamsi) calendar conversion utilities
*/
export const persianMonths = [
{ value: 1, id: 1, label: 'فروردین', name: 'فروردین' },
{ value: 2, id: 2, label: 'اردیبهشت', name: 'اردیبهشت' },
{ value: 3, id: 3, label: 'خرداد', name: 'خرداد' },
{ value: 4, id: 4, label: 'تیر', name: 'تیر' },
{ value: 5, id: 5, label: 'مرداد', name: 'مرداد' },
{ value: 6, id: 6, label: 'شهریور', name: 'شهریور' },
{ value: 7, id: 7, label: 'مهر', name: 'مهر' },
{ value: 8, id: 8, label: 'آبان', name: 'آبان' },
{ value: 9, id: 9, label: 'آذر', name: 'آذر' },
{ value: 10, id: 10, label: 'دی', name: 'دی' },
{ value: 11, id: 11, label: 'بهمن', name: 'بهمن' },
{ value: 12, id: 12, label: 'اسفند', name: 'اسفند' }
];
/**
* Converts Persian and Arabic digits in a string to English digits
*/
export function toEnglishDigits(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d))
.replace(/[٠-٩]/g, (d) => '٠١٢٣٤٥٦٧٨٩'.indexOf(d));
}
/**
* Converts Jalali date (year, month, day) to Gregorian date (gy, gm, gd)
*/
export function jalaliToGregorian(jy, jm, jd) {
jy = Number(jy);
jm = Number(jm);
jd = Number(jd);
let gy = jy > 979 ? 1600 : 621;
jy = jy > 979 ? jy - 979 : jy;
let days = 365 * jy + Math.floor(jy / 33) * 8 + Math.floor(((jy % 33) + 3) / 4) + 78 + jd + (jm < 7 ? (jm - 1) * 31 : (jm - 7) * 30 + 186);
gy += 400 * Math.floor(days / 146097);
days %= 146097;
if (days > 36524) {
gy += 100 * Math.floor(--days / 36524);
days %= 36524;
if (days >= 365) days++;
}
gy += 4 * Math.floor(days / 1461);
days %= 1461;
if (days > 365) {
gy += Math.floor((days - 1) / 365);
days = (days - 1) % 365;
}
let gd = days + 1;
const isLeap = (gy % 4 === 0 && gy % 100 !== 0) || gy % 400 === 0;
const sal_a = [0, 31, isLeap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let gm;
for (gm = 0; gm < 13; gm++) {
const v = sal_a[gm];
if (gd <= v) break;
gd -= v;
}
return { gy, gm, gd };
}
/**
* Converts Gregorian date (gy, gm, gd) to Jalali date (jy, jm, jd)
*/
export function gregorianToJalali(gy, gm, gd) {
gy = Number(gy);
gm = Number(gm);
gd = Number(gd);
const g_d_m = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
let jy = gy > 1600 ? 979 : 0;
gy = gy > 1600 ? gy - 1600 : gy - 621;
const gy2 = gm > 2 ? gy + 1 : gy;
let days = 365 * gy + Math.floor((gy2 + 3) / 4) - Math.floor((gy2 + 99) / 100) + Math.floor((gy2 + 399) / 400) - 80 + gd + g_d_m[gm - 1];
jy += 33 * Math.floor(days / 12053);
days %= 12053;
jy += 4 * Math.floor(days / 1461);
days %= 1461;
if (days > 365) {
jy += Math.floor((days - 1) / 365);
days = (days - 1) % 365;
}
const jm = days < 186 ? 1 + Math.floor(days / 31) : 7 + Math.floor((days - 186) / 30);
const jd = 1 + (days < 186 ? days % 31 : (days - 186) % 30);
return { jy, jm, jd };
}
/**
* Converts Jalali year, month, day to a standard JS Date object at 12:00 UTC
*/
export function jalaliToDate(jy, jm, jd) {
const y = parseInt(toEnglishDigits(jy), 10);
const m = parseInt(toEnglishDigits(jm), 10);
const d = parseInt(toEnglishDigits(jd), 10);
if (isNaN(y) || isNaN(m) || isNaN(d) || y <= 0 || m < 1 || m > 12 || d < 1 || d > 31) {
return null;
}
const { gy, gm, gd } = jalaliToGregorian(y, m, d);
return new Date(Date.UTC(gy, gm - 1, gd, 12, 0, 0));
}
/**
* Converts a JS Date object, ISO string, or legacy Jalali string (YYYY/MM/DD) to { jy, jm, jd }
*/
export function parseDateToJalali(dateVal) {
if (!dateVal) return null;
// Check if it's already a JS Date or ISO string
if (dateVal instanceof Date || (typeof dateVal === 'string' && (dateVal.includes('T') || dateVal.includes('-')))) {
const d = new Date(dateVal);
if (!isNaN(d.getTime())) {
return gregorianToJalali(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate());
}
}
// Check if it's a legacy string format e.g. "1399/04/15" or "۱۳۹۹/۰۴/۱۵"
if (typeof dateVal === 'string' && dateVal.includes('/')) {
const clean = toEnglishDigits(dateVal.trim());
const parts = clean.split('/');
if (parts.length === 3) {
const jy = parseInt(parts[0], 10);
const jm = parseInt(parts[1], 10);
const jd = parseInt(parts[2], 10);
if (!isNaN(jy) && !isNaN(jm) && !isNaN(jd)) {
return { jy, jm, jd };
}
}
}
return null;
}
+78
View File
@@ -0,0 +1,78 @@
// Converts UTM coordinates to latitude and longitude
const a = 6378137.0; // WGS84 Major axis
const eccSquared = 0.00669438; // WGS84 eccentricity squared
const k0 = 0.9996; // Scale factor
function UTMtoLatLon(UTMEasting, UTMNorthing, UTMZoneNumber, Hemisphere) {
const e1 = (1 - Math.sqrt(1 - eccSquared)) / (1 + Math.sqrt(1 - eccSquared));
let x = UTMEasting - 500000.0;
let y = UTMNorthing;
if (Hemisphere === 'S') {
y -= 10000000.0;
}
const LongOrigin = (UTMZoneNumber - 1) * 6 - 180 + 3;
const eccPrimeSquared = eccSquared / (1 - eccSquared);
const M = y / k0;
const mu = M / (a * (1 - eccSquared / 4 - (3 * eccSquared ** 2) / 64 - (5 * eccSquared ** 3) / 256));
let phi1Rad = mu + ((3 * e1) / 2 - (27 * e1 ** 3) / 32) * Math.sin(2 * mu) + ((21 * e1 ** 2) / 16 - (55 * e1 ** 4) / 32) * Math.sin(4 * mu) + ((151 * e1 ** 3) / 96) * Math.sin(6 * mu);
const N1 = a / Math.sqrt(1 - eccSquared * Math.sin(phi1Rad) ** 2);
const T1 = Math.tan(phi1Rad) ** 2;
const C1 = eccPrimeSquared * Math.cos(phi1Rad) ** 2;
const R1 = (a * (1 - eccSquared)) / Math.pow(1 - eccSquared * Math.sin(phi1Rad) ** 2, 1.5);
const D = x / (N1 * k0);
let lat = phi1Rad - ((N1 * Math.tan(phi1Rad)) / R1) * (D ** 2 / 2 - ((5 + 3 * T1 + 10 * C1 - 4 * C1 ** 2 - 9 * eccPrimeSquared) * D ** 4) / 24 + ((61 + 90 * T1 + 298 * C1 + 45 * T1 ** 2 - 252 * eccPrimeSquared - 3 * C1 ** 2) * D ** 6) / 720);
lat *= 180 / Math.PI;
let lon = (D - ((1 + 2 * T1 + C1) * D ** 3) / 6 + ((5 - 2 * C1 + 28 * T1 - 3 * C1 ** 2 + 8 * eccPrimeSquared + 24 * T1 ** 2) * D ** 5) / 120) / Math.cos(phi1Rad);
lon = LongOrigin + (lon * 180) / Math.PI;
return { latitude: lat, longitude: lon };
}
// Converts latitude and longitude to UTM coordinates
function LatLontoUTM(lat, lon) {
const LatRad = (lat * Math.PI) / 180.0;
const LongRad = (lon * Math.PI) / 180.0;
const ZoneNumber = Math.floor((lon + 180) / 6) + 1;
const LongOrigin = (ZoneNumber - 1) * 6 - 180 + 3;
const LongOriginRad = (LongOrigin * Math.PI) / 180.0;
const eccPrimeSquared = eccSquared / (1 - eccSquared);
const N = a / Math.sqrt(1 - eccSquared * Math.sin(LatRad) ** 2);
const T = Math.tan(LatRad) ** 2;
const C = eccPrimeSquared * Math.cos(LatRad) ** 2;
const A = Math.cos(LatRad) * (LongRad - LongOriginRad);
const M =
a *
((1 - eccSquared / 4 - (3 * eccSquared ** 2) / 64 - (5 * eccSquared ** 3) / 256) * LatRad -
((3 * eccSquared) / 8 + (3 * eccSquared ** 2) / 32 + (45 * eccSquared ** 3) / 1024) * Math.sin(2 * LatRad) +
((15 * eccSquared ** 2) / 256 + (45 * eccSquared ** 3) / 1024) * Math.sin(4 * LatRad) -
((35 * eccSquared ** 3) / 3072) * Math.sin(6 * LatRad));
let UTMEasting = k0 * N * (A + ((1 - T + C) * A ** 3) / 6 + ((5 - 18 * T + T ** 2 + 72 * C - 58 * eccPrimeSquared) * A ** 5) / 120) + 500000.0;
let UTMNorthing = k0 * (M + N * Math.tan(LatRad) * (A ** 2 / 2 + ((5 - T + 9 * C + 4 * C ** 2) * A ** 4) / 24 + ((61 - 58 * T + T ** 2 + 600 * C - 330 * eccPrimeSquared) * A ** 6) / 720));
const Hemisphere = lat < 0 ? 'S' : 'N';
if (lat < 0) {
UTMNorthing += 10000000.0;
}
return {
easting: UTMEasting,
northing: UTMNorthing,
zoneNumber: ZoneNumber,
hemisphere: Hemisphere
};
}
// Exporting functions as a module
export { UTMtoLatLon, LatLontoUTM };
+111 -12
View File
@@ -1,22 +1,121 @@
<script setup>
import BestSellingWidget from '@/components/dashboard/BestSellingWidget.vue';
import NotificationsWidget from '@/components/dashboard/NotificationsWidget.vue';
import RecentSalesWidget from '@/components/dashboard/RecentSalesWidget.vue';
import RevenueStreamWidget from '@/components/dashboard/RevenueStreamWidget.vue';
import { onMounted, ref } from 'vue';
import StatsWidget from '@/components/dashboard/StatsWidget.vue';
import RevenueStreamWidget from '@/components/dashboard/RevenueStreamWidget.vue';
import PropertyStatusWidget from '@/components/dashboard/PropertyStatusWidget.vue';
import RecentSalesWidget from '@/components/dashboard/RecentSalesWidget.vue';
import WarehouseActivityWidget from '@/components/dashboard/WarehouseActivityWidget.vue';
import QuickActionsWidget from '@/components/dashboard/QuickActionsWidget.vue';
import { DashboardService } from '@/service/DashboardService';
import { gregorianToJalali, persianMonths } from '@/utils/jalali';
import { useRouter } from 'vue-router';
const router = useRouter();
const stats = ref(null);
const loading = ref(true);
const refreshing = ref(false);
const dayNames = ['یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنج‌شنبه', 'جمعه', 'شنبه'];
const getTodayPersianDate = () => {
const now = new Date();
const j = gregorianToJalali(now.getFullYear(), now.getMonth() + 1, now.getDate());
const dayName = dayNames[now.getDay()];
const monthName = persianMonths.find((m) => m.value === j.jm)?.name || '';
return `${dayName} ${j.jd} ${monthName} ${j.jy}`;
};
const todayFormatted = getTodayPersianDate();
const fetchDashboardStats = async (isManual = false) => {
try {
if (isManual) refreshing.value = true;
else loading.value = true;
const data = await DashboardService.getDashboardStats();
stats.value = data;
} catch (err) {
console.error('Failed to load dashboard statistics:', err);
} finally {
loading.value = false;
refreshing.value = false;
}
};
onMounted(() => {
fetchDashboardStats();
});
</script>
<template>
<div class="grid grid-cols-12 gap-8">
<StatsWidget />
<div class="flex flex-col gap-6">
<!-- Dashboard Header Welcome Banner -->
<div
class="card p-6 border border-surface-200 dark:border-surface-800 rounded-2xl bg-gradient-to-l from-primary-500/10 via-surface-0 to-surface-0 dark:from-primary-500/15 dark:via-surface-900 dark:to-surface-900 shadow-sm relative overflow-hidden"
>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<div class="flex items-center gap-2 mb-1.5">
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-primary-100 dark:bg-primary-900/40 text-primary">
<i class="pi pi-compass text-[10px]"></i>
مرکز مدیریت و پایش
</span>
<span class="text-xs text-muted-color">|</span>
<span class="text-xs font-medium text-surface-600 dark:text-surface-400">
{{ todayFormatted }}
</span>
</div>
<h1 class="text-2xl font-black text-surface-900 dark:text-surface-0 tracking-tight">داشبورد جامع مدیریت اموال و انبارداری</h1>
<p class="text-sm text-muted-color mt-1">خلاصه آماری دارایی‌ها، املاک ثبتی و گزارش لحظه‌ای گردش کالاهای انبار</p>
</div>
<div class="col-span-12 xl:col-span-6">
<RecentSalesWidget />
<!-- <BestSellingWidget />-->
<div class="flex items-center gap-3 shrink-0">
<Button icon="pi pi-refresh" :class="['p-button-outlined p-button-secondary !w-10 !h-10 p-button-rounded', { 'pi-spin': refreshing }]" v-tooltip.bottom="'بروزرسانی داده‌ها'" @click="fetchDashboardStats(true)" />
<router-link to="/property/create-property">
<Button label="ثبت ملک جدید" icon="pi pi-plus" class="shadow-sm" />
</router-link>
</div>
</div>
</div>
<div class="col-span-12 xl:col-span-6">
<RevenueStreamWidget />
<!-- <NotificationsWidget />-->
<!-- 4 KPI Stats Cards Row -->
<div class="grid grid-cols-12 gap-5">
<StatsWidget :stats="stats" :loading="loading" />
</div>
<!-- Quick Actions Shortcuts -->
<QuickActionsWidget />
<!-- Real Data Visual Charts Row -->
<div class="grid grid-cols-12 gap-6">
<!-- Property Category Distribution Chart -->
<div class="col-span-12 xl:col-span-7">
<RevenueStreamWidget :category-stats="stats?.propertiesByCategory || []" :loading="loading" />
</div>
<!-- Property Status & Deeds Breakdown Chart -->
<div class="col-span-12 xl:col-span-5">
<PropertyStatusWidget :status-stats="stats?.propertiesByStatus || []" :loading="loading" />
</div>
</div>
<!-- Live Feeds: Recent Properties & Recent Warehouse Transactions Row -->
<div class="grid grid-cols-12 gap-6">
<!-- Recent Properties Table -->
<div class="col-span-12 xl:col-span-7">
<RecentSalesWidget :properties="stats?.recentProperties || []" :loading="loading" />
</div>
<!-- Recent Warehouse Transactions Feed -->
<div class="col-span-12 xl:col-span-5">
<WarehouseActivityWidget :transactions="stats?.recentWarehouseTransactions || []" :warehouse-flow="stats?.warehouseFlow || { inCount: 0, outCount: 0, inQuantity: 0, outQuantity: 0 }" :loading="loading" />
</div>
</div>
</div>
</template>
<style scoped>
.card {
font-family: 'Estedad', sans-serif !important;
}
</style>
@@ -1,466 +0,0 @@
<script setup>
import { Listbox, InputText } from 'primevue';
import { onBeforeMount, ref, watch } from 'vue';
import { CategoryService } from '@/service/CategoryService';
import ImageUploader from '@/views/uikit/ImageUploader.vue';
import ImageGallery from '@/views/uikit/ImageGallery.vue';
import KiwiChips from '@/views/uikit/KiwiChips.vue';
import { useToast } from 'primevue/usetoast';
import { BelongingService } from '@/service/BelongingService';
import { useRoute, useRouter } from 'vue-router';
import MapComponent from '@/views/uikit/MapComponent.vue';
import TransactionDialog from './TransactionDialog.vue';
import { TransactionService } from '@/service/TransactionService';
import { FilterMatchMode, FilterOperator } from '@primevue/core/api';
import Popover from 'primevue/popover';
// For the single popover approach
const op = ref();
const activeDescription = ref('');
const toggle = (event, data) => {
activeDescription.value = data;
op.value.toggle(event);
};
const toast = useToast();
const categories = ref([]);
const selectedCategory = ref();
const features = ref([]);
const featuresValues = ref({});
const documentData = ref([]);
const mainImageUrlsWithData = ref([]);
const uploadedMainImageUrls = ref([]);
const documentUrlsWithData = ref([]);
const uploadedDocumentUrls = ref([]);
const editMode = ref(false);
const belonging = ref({
location: {
type: 'Point',
coordinates: [0, 0]
}
});
const tags = ref([]);
const signedImageUrls = ref([]);
const signedDocumentsUrls = ref([]);
const route = useRoute();
const router = useRouter();
const mapKey = 'web.79abbab4970e401781f434da3e41cfa6';
const serviceKey = 'service.11d860f52c434bd29e5f84d0a0a41108';
const mapRef = ref();
const transactions = ref([]);
const stockData = ref({});
const handleDocumentUpload = (newDocumentUrls) => {
uploadedDocumentUrls.value = newDocumentUrls.value;
const tempConcatImages = [...belonging.value?.documentUrls, ...newDocumentUrls.value];
documentUrlsWithData.value = fillImagesArray(tempConcatImages);
};
const handleDocumentDelete = (newDocumentUrls) => {
uploadedDocumentUrls.value = newDocumentUrls;
const tempConcatImages = [...belonging.value?.documentUrls, ...newDocumentUrls.value];
documentUrlsWithData.value = fillImagesArray(tempConcatImages);
};
const handleDocumentGalleryDelete = (deletedDocumentUrl, index) => {
console.log('deletedImageUrl', deletedDocumentUrl, 'at index', index);
// Find which array contains the image to delete
if (index < belonging.value.documentUrls.length) {
// Remove only at specific index from belonging.value.mainImageNames
belonging.value.documentUrls.splice(index, 1);
} else {
// Adjust index for uploadedMainImageNames array
const adjustedIndex = index - belonging.value?.documentUrls.length;
uploadedDocumentUrls.value.splice(adjustedIndex, 1);
}
// Recombine arrays
const tempConcatImages = [...belonging.value?.documentUrls, ...uploadedDocumentUrls.value];
documentUrlsWithData.value = fillImagesArray(tempConcatImages);
};
const handleImageUpload = (newImageUrls) => {
uploadedMainImageUrls.value = newImageUrls.value;
const tempConcatImages = [...belonging.value?.mainImageUrls, ...newImageUrls.value];
mainImageUrlsWithData.value = fillImagesArray(tempConcatImages);
};
const handleImageDelete = (newImageUrls) => {
uploadedMainImageUrls.value = newImageUrls;
const tempConcatImages = [...belonging.value?.mainImageUrls.value, ...newImageUrls.value];
mainImageUrlsWithData.value = fillImagesArray(tempConcatImages);
};
const handleImageGalleryDelete = (deletedImageUrl, index) => {
console.log('deletedImageUrl', deletedImageUrl, 'at index', index);
// Find which array contains the image to delete
if (index < belonging.value?.mainImageUrls.length) {
// Remove only at specific index from belonging.value.mainImageNames
belonging.value?.mainImageUrls.splice(index, 1);
} else {
// Adjust index for uploadedMainImageNames array
const adjustedIndex = index - belonging.value?.mainImageUrls.length;
uploadedMainImageUrls.value?.splice(adjustedIndex, 1);
}
// Recombine arrays
const tempConcatImages = [...belonging.value?.mainImageUrls, ...uploadedMainImageUrls.value];
mainImageUrlsWithData.value = fillImagesArray(tempConcatImages);
};
onBeforeMount(async () => {
categories.value = await CategoryService.getAllCategories();
console.log(route.name);
if (route.name === 'edit-belonging') {
editMode.value = true;
belonging.value = await BelongingService.getBelonging(route.params.belongingId);
transactions.value = await TransactionService.getTransactionsByBelongingId(route.params.belongingId);
stockData.value = await TransactionService.getStockByBelongingId(route.params.belongingId);
mainImageUrlsWithData.value = fillImagesArray(belonging.value?.mainImageUrls || []);
documentUrlsWithData.value = fillImagesArray(belonging.value?.documentUrls || []);
}
});
const showTransactionDialog = ref(false);
const fillImagesArray = (imageUrls) => {
const images = [];
for (let url of imageUrls) {
images.push({ url: url });
}
return images;
};
const openTransactionDialog = () => {
showTransactionDialog.value = true;
};
const onTransactionSaved = async () => {
if (editMode.value && belonging.value?._id) {
belonging.value = await BelongingService.getBelonging(belonging.value._id);
}
// toast.add({ severity: 'success', summary: 'موفق', detail: 'تراکنش با موفقیت ثبت شد', life: 3000 });
};
const handleSelectCategory = ($event) => {
if ($event.value) {
belonging.value.category = $event.value;
selectedCategory.value = categories.value.find((x) => x._id === $event.value);
features.value = selectedCategory.value.features;
} else {
belonging.value = {
location: {
type: 'Point',
coordinates: [0, 0]
}
};
selectedCategory.value = {};
}
};
const saveBelonging = async () => {
let status = 'failed';
console.log(belonging);
if (belonging?.value._id?.trim()) {
belonging.value.mainImageUrls = mainImageUrlsWithData.value.map((x) => x.url);
belonging.value.documentUrls = documentUrlsWithData.value.map((x) => x.url);
status = await BelongingService.updateBelonging(belonging.value._id, belonging.value);
} else {
status = await BelongingService.createBelonging(belonging.value);
}
if (status === 'success') {
toast.add({ severity: 'success', summary: 'موفق', detail: 'ویژگی جدید ساخته شد.', life: 3000 });
} else {
toast.add({ severity: 'error', summary: 'ناموفق', detail: status, life: 3000 });
}
await router.push('/belonging/list-belongings');
};
watch(
() => mapRef.value?.state.map,
(nv, ol) => {
if (ol) return;
setTimeout(() => {
mapRef.value?.state.map?.setMapType('standard-night');
if (mapRef.value?.state.mapType) mapRef.value.state.mapType = 'standard-night';
}, 2000);
setTimeout(() => {
mapRef.value?.search({
term: 'ولی عصر',
coords: [51.36281969540723, 35.69672648316882]
});
}, 3000);
}
);
const filters = ref({
global: { value: null, matchMode: FilterMatchMode.CONTAINS },
type: { value: null, matchMode: FilterMatchMode.EQUALS },
status: { value: null, matchMode: FilterMatchMode.EQUALS },
value: { value: null, matchMode: FilterMatchMode.BETWEEN },
date: { value: null, matchMode: FilterMatchMode.DATE_IS }
});
// Options for filters
const typeOptions = [
{ label: 'واریز', value: 'deposit' },
{ label: 'برداشت', value: 'withdraw' }
];
const statusOptions = [
{ label: 'ناموفق', value: 'failed' },
{ label: 'در انتظار', value: 'pending' },
{ label: 'تکمیل شده', value: 'finished' }
];
// Helper functions for translations and formatting
const getTypeTranslation = (type) => {
switch (type) {
case 'deposit':
return 'واریز';
case 'withdraw':
return 'برداشت';
default:
return type;
}
};
const getTypeSeverity = (type) => {
switch (type) {
case 'deposit':
return 'success';
case 'withdraw':
return 'danger';
default:
return null;
}
};
const getStatusTranslation = (status) => {
switch (status) {
case 'failed':
return 'ناموفق';
case 'pending':
return 'در انتظار';
case 'finished':
return 'تکمیل شده';
default:
return status;
}
};
const getStatusSeverity = (status) => {
switch (status) {
case 'failed':
return 'danger';
case 'pending':
return 'warn';
case 'finished':
return 'success';
default:
return null;
}
};
const formatCurrency = (value) => {
return new Intl.NumberFormat('fa-IR', {
style: 'currency',
currency: 'IRR',
minimumFractionDigits: 0
}).format(Math.abs(value));
};
const formatDate = (dateString) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat('fa-IR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(date);
};
</script>
<template>
<TransactionDialog v-model:visible="showTransactionDialog" :belongingId="belonging._id" @saved="onTransactionSaved" />
<div className="card lg:p-5 px-3">
<div class="mb-4" v-if="editMode === true">
<Button @click="openTransactionDialog">افزایش یا کاهش موجودی</Button>
</div>
<div class="font-semibold text-xl mb-4" v-if="editMode === false">ایجاد اموال</div>
<p v-if="editMode === false">از طریق این صفحه می توانید اموال جدید را به سوابق اضافه کنید.</p>
<div class="font-semibold text-xl mb-4" v-if="editMode === true">
در حال ویرایش
<span class="text-primary">{{ belonging.name }}</span>
</div>
<div class="lg:flex my-6">
<div class="lg:w-3/4 w-full ml-3">
<div class="">
<label class="text-lg"> نام دارایی </label>
<InputText v-model="belonging.name" class="w-full my-2" placeholder="مثلا: 100 عدد کولر گازی" />
</div>
<div class="my-6">
<label class="text-lg"> توضیحات </label>
<Textarea v-model="belonging.description" class="w-full my-2" placeholder="مثلا: در تاریخ 1403/09/11 خریداری شد." rows="6" />
</div>
<div class="my-6">
<label class="text-lg"> تصاویر، فایل ها و مدارک </label>
<ImageUploader
v-model="uploadedDocumentUrls"
:max-files="50"
mode="private"
delete-endpoint="/api/files/bucket/delete-file/belonging-documents"
upload-endpoint="/api/files/bucket/upload-multi-filepond/belonging-documents"
@upload="handleDocumentUpload"
@delete="handleDocumentDelete"
/>
<div v-if="documentUrlsWithData.length > 0">
<ImageGallery v-model="documentUrlsWithData" gallery-id="documents" @delete-image="handleDocumentGalleryDelete" />
</div>
</div>
<div class="my-6" v-if="signedDocumentsUrls.length > 0">
<label class="text-lg"> فایل ها و مدارک </label>
</div>
<div class="my-6 max-h-96 overflow-y-auto" v-if="features.length > 0">
<label class="text-lg"> اطلاعات دارایی </label>
<div class="my-6 mr-10" v-for="feature of features">
<label>{{ feature.nativeName }}</label>
<InputText v-model="featuresValues[feature._id]" class="w-1/3 my-2 block" placeholder="" />
</div>
</div>
<div class="my-10">
<label class="text-lg"> موقعیت دارایی </label>
<MapComponent v-model="belonging.location.coordinates" />
<!-- <MapIrComponent />-->
</div>
<div class="my-10" v-if="editMode">
<label class="text-lg"> موجودی </label>
<p class="mx-3 text-blue-400">موجودی فعلی : {{ stockData.finishedInventory }}</p>
<p class="mx-3 text-green-400">موجودی در حال اضافه شدن : {{ stockData.pendingDepositsInventory }}</p>
<p class="mx-3 text-red-400">
موجودی در حال کم شدن : <span dir="ltr">{{ stockData.pendingWithdrawsInventory }}</span>
</p>
<div class="mx-6 py-4">
<label> تراکنش ها </label>
<div class="card lg:p-5 px-3">
<DataTable
:value="transactions"
paginator
:rows="20"
:rowsPerPageOptions="[20, 50, 100]"
tableStyle="min-width: 50rem"
stripedRows
v-model:filters="filters"
filterDisplay="menu"
:globalFilterFields="['type', 'status', 'value', 'date', 'description']"
>
<template #header>
<div class="lg:flex lg:justify-content-between">
<span class="p-input-icon-left">
<!-- <i class="pi pi-search" />-->
<InputText v-model="filters['global'].value" placeholder="جستجو..." class="w-full" />
</span>
</div>
</template>
<Column field="id" header="شناسه" sortable style="width: 25%"></Column>
<Column field="type" header="نوع" sortable style="width: 15%">
<template #body="{ data }">
<Tag :severity="getTypeSeverity(data.type)" :value="getTypeTranslation(data.type)" />
</template>
<template #filter="{ filterModel, filterCallback }">
<Dropdown v-model="filterModel.value" @change="filterCallback()" :options="typeOptions" optionLabel="label" optionValue="value" placeholder="انتخاب نوع" class="p-column-filter" showClear />
</template>
</Column>
<Column field="status" header="وضعیت" sortable style="width: 15%">
<template #body="{ data }">
<Tag :severity="getStatusSeverity(data.status)" :value="getStatusTranslation(data.status)" />
</template>
<template #filter="{ filterModel, filterCallback }">
<Dropdown v-model="filterModel.value" @change="filterCallback()" :options="statusOptions" optionLabel="label" optionValue="value" placeholder="انتخاب وضعیت" class="p-column-filter" showClear />
</template>
</Column>
<Column field="value" header="تعداد" sortable style="width: 15%">
<template #body="{ data }">
<span :class="data.value >= 0 ? 'text-green-500' : 'text-red-500'" dir="ltr"> {{ data.value }} </span>
</template>
</Column>
<Column field="date" header="تاریخ" sortable style="width: 20%">
<template #body="{ data }">
{{ formatDate(data.date) }}
</template>
</Column>
<Column field="description" header="توضیحات" style="width: 10%">
<template #body="{ data }">
<Button v-if="data.description" icon="pi pi-info-circle" text rounded aria-label="توضیحات" @click="toggle($event, data.description)" />
</template>
</Column>
<Popover ref="op" :showCloseIcon="true" :autoHide="false">
<div class="p-2">{{ activeDescription }}</div>
</Popover>
</DataTable>
</div>
</div>
</div>
</div>
<div class="lg:w-1/4 w-full">
<div>
<label class="text-lg"> دسته بندی </label>
<Listbox @change="handleSelectCategory($event)" v-model="belonging.category" :options="categories" optionLabel="nativeName" optionValue="_id" checkmark :highlightOnSelect="false" class="w-full my-2" />
</div>
<div class="my-6">
<label class="text-lg"> تصویر اصلی </label>
<ImageUploader
v-model="uploadedMainImageUrls"
accepted-files="image/*"
:max-files="1"
mode="private"
delete-endpoint="/api/files/bucket/delete/belonging-images"
upload-endpoint="/api/files/bucket/upload-multi-filepond/belonging-images"
@upload="handleImageUpload"
@delete="handleImageDelete"
/>
<div class="my-10 border rounded-lg p-3 border-surface-700" v-if="mainImageUrlsWithData.length > 0">
<label for="ImageGallery">گالری تصاویر</label>
<div class="">
<ImageGallery v-model="mainImageUrlsWithData" gallery-id="productImages" @delete-image="handleImageGalleryDelete" />
</div>
</div>
</div>
<div class="my-8">
<label class="text-lg"> واحد </label>
<InputText v-model="belonging.unit" class="w-full" placeholder="مثلا: عدد، متر، و..." />
</div>
<div class="my-8">
<label class="text-lg"> برچسب ها </label>
<KiwiChips v-model="belonging.tags" class="w-full" placeholder="مثلا: زمین، ملک، کیانپارس..." />
</div>
</div>
</div>
<div class="flex justify-center">
<Button @click="saveBelonging">ذخیره</Button>
</div>
</div>
</template>
<style>
@import url('leaflet/dist/leaflet.css');
</style>
<style scoped>
label {
@apply text-primary;
}
</style>
@@ -1,249 +0,0 @@
<script setup>
import { ProductService } from '@/service/ProductService';
import { FilterMatchMode } from '@primevue/core/api';
import { useToast } from 'primevue/usetoast';
import { onMounted, ref, watch } from 'vue';
import { BelongingService } from '@/service/BelongingService';
import { useRouter, useRoute } from 'vue-router';
import Chip from 'primevue/chip';
import InputIcon from 'primevue/inputicon';
import IconField from 'primevue/iconfield';
import LoadingCheck from '@/components/LoadingCheck.vue';
onMounted(() => {
loadData();
});
const toast = useToast();
const dt = ref();
const belongings = ref();
const productDialog = ref(false);
const deleteProductDialog = ref(false);
const deleteProductsDialog = ref(false);
const belonging = ref({});
const selectedBelongings = ref();
const filters = ref({
global: { value: null, matchMode: FilterMatchMode.CONTAINS }
});
const submitted = ref(false);
function hideDialog() {
productDialog.value = false;
submitted.value = false;
}
const router = useRouter();
function editBelonging(belongingId) {
router.push('/belonging/edit-belonging/' + belongingId);
}
const route = useRoute();
const searchPhrase = ref(route.query.query);
const page = ref(Number(route.query.page) || 1);
const per_page = ref(Number(route.query.per_page) || 20);
const totalCount = ref(0);
const pagesCount = ref(0);
let searchTimeout;
// Update your search watcher
watch(searchPhrase, (newPhrase, oldPhrase) => {
if (newPhrase !== oldPhrase) {
// Debounce search to avoid too many requests
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
router.push({
query: {
...route.query,
query: newPhrase || undefined,
page: 1 // Reset to first page on new search
}
});
}, 300);
}
});
watch(
() => route.query,
() => {
page.value = Number(route.query.page) || 1;
per_page.value = Number(route.query.per_page) || 20;
searchPhrase.value = route.query.query || '';
loadData();
},
{ deep: true }
);
const handleRemoveSearch = () => {
removeFromQuery('searchPhrase'); // Ensure 'searchPhrase' is the correct key
searchPhrase.value = '';
};
const removeFromQuery = (queryToRemove) => {
const newQuery = { ...route.query };
delete newQuery[queryToRemove];
router.replace({ query: newQuery }).catch((err) => {
if (err.name !== 'NavigationDuplicated') {
console.error(err);
}
});
};
const onPageChange = (event) => {
const newPage = event.page + 1;
const newPerPage = event.rows;
if (page.value !== newPage || per_page.value !== newPerPage) {
router.push({
query: {
...route.query,
page: newPage,
per_page: newPerPage
}
});
}
};
const loadData = () => {
BelongingService.searchBelongings(route.query)
.then((response) => {
console.log('response', response);
belongings.value = response.belongings || response;
totalCount.value = response.totalCount || response.length || 0;
pagesCount.value = Math.ceil(totalCount.value / per_page.value);
})
.catch((error) => {
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to load data', life: 3000 });
console.error('Error loading data:', error);
});
};
const loading = ref(false);
const indexingFinished = ref(false);
const indexingMessage = ref('در حال ایندکس...');
const animationKey = ref(0);
// Function to handle indexing
const indexBelongings = async () => {
loading.value = true;
indexingFinished.value = false;
try {
// Your indexing logic here
// await new Promise((resolve) => setTimeout(resolve, 2000)); // Simulate API call
await BelongingService.indexBelongings();
// When indexing completes
indexingFinished.value = true;
indexingMessage.value = 'ایندکس با موفقیت انجام شد';
} catch (error) {
indexingMessage.value = 'خطا در ایندکس';
} finally {
// Reset after some time
setTimeout(() => {
loading.value = false;
animationKey.value++; // Force re-render of animation
}, 3000);
}
};
</script>
<template>
<div>
<div class="card">
<Toolbar class="mb-6">
<template #start>
<Button label="ساخت دارایی جدید" icon="pi pi-plus" severity="secondary" class="mr-2 mx-3" @click="router.push('/belonging/create-belonging')" />
<Button @click="indexBelongings" :disabled="loading" severity="secondary mx-3" class="indexing-button">
<div class="button-content">
<div v-if="!loading">ایندکس کردن محصولات</div>
<div v-else class="loading-content flex">
<LoadingCheck :key="animationKey" :is-complete="indexingFinished" />
<Transition enter-active-class="animate__animated animate__fadeIn animate__fast" leave-active-class="animate__animated animate__fadeOut animate__fast" mode="out-in">
<div :key="indexingMessage">{{ indexingMessage }}</div>
</Transition>
</div>
</div>
</Button>
</template>
<template #end> </template>
</Toolbar>
<DataTable
ref="dt"
v-model:selection="selectedBelongings"
:value="belongings"
dataKey="id"
:paginator="true"
:rows="per_page"
:filters="filters"
:total-records="totalCount"
:current-page="page - 1"
@page="onPageChange($event)"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
:rowsPerPageOptions="[20, 40, 80]"
currentPageReportTemplate="نمایش {first} تا {last} از {totalRecords} دارایی"
>
<template #header>
<div class="flex flex-wrap gap-2 items-center justify-start">
<h4 class="m-0">مدیریت اموال</h4>
<IconField>
<InputIcon>
<i class="pi pi-search" />
</InputIcon>
<InputText v-model="searchPhrase" placeholder="جستجو..." />
</IconField>
</div>
<Chip v-if="searchPhrase" class="mx-2" :label="searchPhrase" removable @remove="handleRemoveSearch" />
</template>
<Column selectionMode="multiple" style="width: 3rem" :exportable="false"></Column>
<Column header="تصویر">
<template #body="slotProps">
<img :src="`${slotProps.data.imageUrl}`" :alt="slotProps.data.name" class="rounded" style="max-width: 64px" crossorigin="use-credentials" />
</template>
</Column>
<Column field="belongingCode" header="کد" style="min-width: 4rem"></Column>
<Column field="name" header="نام" sortable style="min-width: 16rem"></Column>
<Column field="category" header="دسته بندی" sortable style="min-width: 6rem">
<template #body="slotProps">
{{ slotProps.data.category?.nativeName || slotProps.data.nativeCategory }}
</template>
</Column>
<Column :exportable="false" style="min-width: 12rem">
<template #body="slotProps">
<Button icon="pi pi-pencil" outlined rounded class="mr-2 mx-3" @click="editBelonging(slotProps.data._id)" />
<Button icon="pi pi-trash" outlined rounded severity="danger" class="mx-3" @click="confirmDeleteProduct(slotProps.data)" />
</template>
</Column>
</DataTable>
</div>
<Dialog v-model:visible="deleteProductDialog" :style="{ width: '450px' }" header="Confirm" :modal="true">
<div class="flex items-center gap-4">
<i class="pi pi-exclamation-triangle !text-3xl" />
<span v-if="product"
>Are you sure you want to delete <b>{{ product.name }}</b
>?</span
>
</div>
<template #footer>
<Button label="خیر" icon="pi pi-times" text @click="deleteProductDialog = false" />
<Button label="بله" icon="pi pi-check" @click="deleteProduct" />
</template>
</Dialog>
<Dialog v-model:visible="deleteProductsDialog" :style="{ width: '450px' }" header="Confirm" :modal="true">
<div class="flex items-center gap-4">
<i class="pi pi-exclamation-triangle !text-3xl" />
<span v-if="product">Are you sure you want to delete the selected products?</span>
</div>
<template #footer>
<Button label="No" icon="pi pi-times" text @click="deleteProductsDialog = false" />
<Button label="Yes" icon="pi pi-check" text @click="deleteselectedBelongings" />
</template>
</Dialog>
</div>
</template>
+104 -60
View File
@@ -3,24 +3,39 @@ import { FilterMatchMode } from '@primevue/core/api';
import { onMounted, ref } from 'vue';
import { CategoryService } from '@/service/CategoryService';
import { useToast } from 'primevue/usetoast';
const toast = useToast();
import axios from 'axios';
onMounted(() => {
CategoryService.getAllCategories().then((data) => (categories.value = data));
});
const toast = useToast();
const dataLoading = ref(true);
const dt = ref();
const categories = ref();
const categories = ref([]);
const categoryDialog = ref(false);
const deleteCategoryDialog = ref(false);
const deleteProductsDialog = ref(false);
const deleteSelectedDialog = ref(false);
const category = ref({});
const selectedCategories = ref();
const selectedCategories = ref([]);
const filters = ref({
global: { value: null, matchMode: FilterMatchMode.CONTAINS }
});
const submitted = ref(false);
const loadData = async () => {
dataLoading.value = true;
try {
const data = await CategoryService.getAllCategories();
categories.value = Array.isArray(data) ? data : [];
} catch (err) {
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در دریافت اطلاعات دسته‌بندی‌ها', life: 3000 });
categories.value = [];
} finally {
dataLoading.value = false;
}
};
onMounted(() => {
loadData();
});
function openNew() {
category.value = {};
submitted.value = false;
@@ -34,24 +49,25 @@ function hideDialog() {
async function saveCategory() {
submitted.value = true;
let status = 'failed';
if (!category.value?.nativeName || !category.value?.englishName) {
return;
}
if (category?.value._id?.trim()) {
let status = 'failed';
if (category.value._id?.trim()) {
status = await CategoryService.updateCategory(category.value._id, category.value);
} else {
status = await CategoryService.createCategory(category.value);
}
if (status === 'success') {
toast.add({ severity: 'success', summary: 'موفق', detail: 'دسته بندی جدید ساخته شد.', life: 3000 });
toast.add({ severity: 'success', summary: 'موفق', detail: 'دسته‌بندی با موفقیت ذخیره شد.', life: 3000 });
categoryDialog.value = false;
category.value = {};
await loadData();
} else {
toast.add({ severity: 'error', summary: 'ناموفق', detail: status, life: 3000 });
}
categoryDialog.value = false;
category.value = {};
CategoryService.getAllCategories().then((data) => (categories.value = data));
}
function editCategory(cat) {
@@ -59,42 +75,47 @@ function editCategory(cat) {
categoryDialog.value = true;
}
function confirmDeleteCategory(prod) {
category.value = prod;
function confirmDeleteCategory(cat) {
category.value = cat;
deleteCategoryDialog.value = true;
}
async function deleteCategory() {
const config = {
method: 'delete',
url: '/api/categories/admin/delete/' + category.value._id,
data: category.value,
withCredentials: true
};
try {
const response = await axios(config);
toast.add({ severity: 'success', summary: 'موفق', detail: 'دسته بندی جدید حذف شد.', life: 3000 });
} catch (err) {
toast.add({ severity: 'error', summary: 'ناموفق', detail: err.response.data.message, life: 3000 });
if (!category.value?._id) return;
const status = await CategoryService.deleteCategory(category.value._id);
if (status === 'success') {
toast.add({ severity: 'success', summary: 'موفقیت‌آمیز', detail: 'دسته‌بندی با موفقیت حذف شد.', life: 3000 });
deleteCategoryDialog.value = false;
category.value = {};
await loadData();
} else {
toast.add({ severity: 'error', summary: 'ناموفق', detail: status, life: 3000 });
}
CategoryService.getAllCategories().then((data) => (categories.value = data));
deleteCategoryDialog.value = false;
category.value = {};
}
function exportCSV() {
dt.value.exportCSV();
}
function confirmDeleteSelected() {
deleteProductsDialog.value = true;
deleteSelectedDialog.value = true;
}
function deleteSelectedCategories() {
categories.value = categories.value.filter((val) => !selectedCategories.value.includes(val));
deleteProductsDialog.value = false;
selectedCategories.value = null;
toast.add({ severity: 'success', summary: 'موفقیت آمیز', detail: 'دسته بندی مورد نظر حذف شد.', life: 3000 });
async function deleteSelectedCategories() {
if (!selectedCategories.value || !selectedCategories.value.length) return;
let successCount = 0;
for (const cat of selectedCategories.value) {
if (cat._id) {
const res = await CategoryService.deleteCategory(cat._id);
if (res === 'success') successCount++;
}
}
toast.add({ severity: 'success', summary: 'موفقیت‌آمیز', detail: `${successCount} دسته‌بندی حذف شد.`, life: 3000 });
deleteSelectedDialog.value = false;
selectedCategories.value = [];
await loadData();
}
function exportCSV() {
dt.value?.exportCSV();
}
</script>
@@ -104,28 +125,44 @@ function deleteSelectedCategories() {
<div class="card">
<Toolbar class="mb-6">
<template #start>
<Button label="ساخت دسته بندی جدید" icon="pi pi-plus" severity="secondary" class="mr-2 mx-3" @click="openNew" />
<Button label="حذف دسته بندی" icon="pi pi-trash" severity="secondary" @click="confirmDeleteSelected" :disabled="!selectedCategories || !selectedCategories.length" />
<Button label="ساخت دسته بندی جدید" icon="pi pi-plus" severity="primary" class="mr-2 mx-3" @click="openNew" />
<Button label="حذف موارد انتخابی" icon="pi pi-trash" severity="danger" outlined @click="confirmDeleteSelected" :disabled="!selectedCategories || !selectedCategories.length" />
</template>
<template #end> </template>
<template #end>
<Button label="خروجی CSV" icon="pi pi-upload" severity="secondary" @click="exportCSV" />
</template>
</Toolbar>
<DataTable
ref="dt"
v-model:selection="selectedCategories"
:value="categories"
dataKey="id"
:loading="dataLoading"
dataKey="_id"
:paginator="true"
:rows="20"
:filters="filters"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
:rowsPerPageOptions="[20, 40, 80]"
currentPageReportTemplate="نمایش {first} تا {last} از {totalRecords} دارایی"
currentPageReportTemplate="نمایش {first} تا {last} از {totalRecords} دسته‌بندی"
>
<template #loading>
<div class="flex flex-col items-center justify-center p-8 gap-3">
<ProgressSpinner style="width: 42px; height: 42px" strokeWidth="4" />
<span class="text-sm font-medium text-surface-500">در حال بارگذاری دسته‌بندی‌ها...</span>
</div>
</template>
<template #empty>
<div v-if="!dataLoading" class="p-8 text-center text-surface-500">
<i class="pi pi-inbox text-4xl mb-2 text-surface-400"></i>
<p>هیچ دسته‌بندی یافت نشد.</p>
</div>
</template>
<template #header>
<div class="flex flex-wrap gap-2 items-center justify-between">
<h4 class="m-0">مدیریت دسته بندی ها</h4>
<h4 class="m-0 font-bold">مدیریت دسته بندی ها ({{ categories.length }} مورد)</h4>
<IconField>
<InputIcon>
<i class="pi pi-search" />
@@ -138,10 +175,15 @@ function deleteSelectedCategories() {
<Column selectionMode="multiple" style="width: 3rem" :exportable="false"></Column>
<Column field="nativeName" header="نام فارسی" sortable style="min-width: 16rem"></Column>
<Column field="englishName" header="نام انگلیسی" sortable style="min-width: 16rem"></Column>
<Column header="تعداد ویژگی‌ها" style="min-width: 10rem">
<template #body="slotProps">
<span class="px-2.5 py-1 text-xs rounded-full bg-indigo-50 dark:bg-indigo-950/60 text-indigo-700 dark:text-indigo-300 font-medium"> {{ slotProps.data.features?.length || 0 }} ویژگی </span>
</template>
</Column>
<Column :exportable="false" style="min-width: 12rem">
<template #body="slotProps">
<Button icon="pi pi-pencil" outlined rounded class="mr-2 mx-3" @click="editCategory(slotProps.data)" />
<Button icon="pi pi-trash" class="mx-3" outlined rounded severity="danger" @click="confirmDeleteCategory(slotProps.data)" />
<Button icon="pi pi-pencil" outlined rounded class="mr-2 mx-2" severity="info" title="ویرایش" @click="editCategory(slotProps.data)" />
<Button icon="pi pi-trash" class="mx-2" outlined rounded severity="danger" title="حذف" @click="confirmDeleteCategory(slotProps.data)" />
</template>
</Column>
</DataTable>
@@ -156,7 +198,7 @@ function deleteSelectedCategories() {
</div>
<div>
<label for="englishName" class="block font-bold mb-3">نام انگلیسی</label>
<InputText id="englishName" v-model.trim="category.englishName" required="true" autofocus :invalid="submitted && !category.englishName" fluid />
<InputText id="englishName" v-model.trim="category.englishName" required="true" :invalid="submitted && !category.englishName" fluid />
<small v-if="submitted && !category.englishName" class="text-red-500">ورود نام انگلیسی الزامی است.</small>
</div>
</div>
@@ -167,27 +209,29 @@ function deleteSelectedCategories() {
</template>
</Dialog>
<Dialog v-model:visible="deleteCategoryDialog" :style="{ width: '450px' }" header="تایید نهایی" :modal="true">
<Dialog v-model:visible="deleteCategoryDialog" :style="{ width: '450px' }" header="تایید حذف" :modal="true">
<div class="flex items-center gap-4">
<i class="pi pi-exclamation-triangle !text-3xl" />
<i class="pi pi-exclamation-triangle !text-3xl text-red-500" />
<span v-if="category"
>آیا از حذف دسته بندی <b>{{ category.nativeName }} </b> مطمئن هستید؟</span
>آیا از حذف دسته بندی <b>{{ category.nativeName }}</b> مطمئن هستید؟</span
>
</div>
<template #footer>
<Button label="خیر" icon="pi pi-times" text @click="deleteCategoryDialog = false" />
<Button label="بله" icon="pi pi-check" @click="deleteCategory" />
<Button label="بله، حذف شود" icon="pi pi-check" severity="danger" @click="deleteCategory" />
</template>
</Dialog>
<Dialog v-model:visible="deleteProductsDialog" :style="{ width: '450px' }" header="Confirm" :modal="true">
<Dialog v-model:visible="deleteSelectedDialog" :style="{ width: '450px' }" header="تایید حذف گروهی" :modal="true">
<div class="flex items-center gap-4">
<i class="pi pi-exclamation-triangle !text-3xl" />
<span v-if="product">Are you sure you want to delete the selected products?</span>
<i class="pi pi-exclamation-triangle !text-3xl text-red-500" />
<span
>آیا از حذف <b>{{ selectedCategories?.length || 0 }}</b> دسته‌بندی انتخاب شده اطمینان دارید؟</span
>
</div>
<template #footer>
<Button label="No" icon="pi pi-times" text @click="deleteProductsDialog = false" />
<Button label="Yes" icon="pi pi-check" text @click="deleteSelectedCategories" />
<Button label="خیر" icon="pi pi-times" text @click="deleteSelectedDialog = false" />
<Button label="بله، حذف شوند" icon="pi pi-check" severity="danger" @click="deleteSelectedCategories" />
</template>
</Dialog>
</div>
+212 -67
View File
@@ -1,124 +1,269 @@
<script setup>
import { onBeforeMount, onMounted, ref } from 'vue';
import { onMounted, ref, computed } from 'vue';
import { CategoryService } from '@/service/CategoryService';
import { Tabs, TabList, Tab } from 'primevue';
import { useToast } from 'primevue/usetoast';
import axios from 'axios';
import { FeatureService } from '@/service/FeatureService';
import { Tabs, TabList, Tab, TabPanels, TabPanel } from 'primevue';
import { useToast } from 'primevue/usetoast';
const toast = useToast();
const selectedTab = ref('');
const selectedFeature = ref();
const submitted = ref(false);
const feature = ref({});
const categories = ref([]);
const dataLoading = ref(true);
const featureDialog = ref(false);
onMounted(async () => {
categories.value = await CategoryService.getAllCategories();
const deleteFeatureDialog = ref(false);
const featureToDelete = ref(null);
const featureTypes = [
{ value: 'string', name: 'متن (String)' },
{ value: 'number', name: 'عدد (Number)' },
{ value: 'boolean', name: 'بله یا خیر (Boolean)' },
{ value: 'date', name: 'تاریخ (Date)' }
];
const loadCategories = async () => {
dataLoading.value = true;
try {
const data = await CategoryService.getAllCategories();
categories.value = Array.isArray(data) ? data : [];
if (categories.value.length > 0 && (!selectedTab.value || !categories.value.some((c) => c._id === selectedTab.value))) {
selectedTab.value = categories.value[0]._id;
}
} catch (e) {
console.error('Error loading categories:', e);
categories.value = [];
} finally {
dataLoading.value = false;
}
};
onMounted(() => {
loadCategories();
});
const currentCategory = computed(() => {
return categories.value.find((c) => c._id === selectedTab.value) || null;
});
const featureType = [
{
value: 'string',
name: 'متن'
},
{
value: 'boolean',
name: 'بله یا خیر'
},
{
value: 'number',
name: 'عدد'
},
{
value: 'date',
name: 'تاریخ'
}
];
function hideDialog() {
featureDialog.value = false;
submitted.value = false;
feature.value = {};
}
function openNew(categoryId) {
feature.value = {};
feature.value.category = categoryId;
feature.value = {
category: categoryId || selectedTab.value,
featureType: 'string',
isFilterable: true
};
submitted.value = false;
featureDialog.value = true;
}
const editFeature = (event, categoryId) => {
feature.value = event.value;
const editFeature = (feat, categoryId) => {
feature.value = { ...feat, category: categoryId || selectedTab.value };
submitted.value = false;
featureDialog.value = true;
};
const confirmDelete = (feat) => {
featureToDelete.value = feat;
deleteFeatureDialog.value = true;
};
const deleteFeature = async () => {
if (!featureToDelete.value?._id) return;
try {
const status = await FeatureService.deleteFeature(featureToDelete.value._id);
if (status === 'success') {
toast.add({ severity: 'success', summary: 'موفق', detail: 'ویژگی با موفقیت حذف شد.', life: 3000 });
deleteFeatureDialog.value = false;
featureToDelete.value = null;
await loadCategories();
} else {
toast.add({ severity: 'error', summary: 'ناموفق', detail: status, life: 3000 });
}
} catch (e) {
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در حذف ویژگی', life: 3000 });
}
};
const saveFeature = async () => {
submitted.value = true;
if (!feature.value.nativeName || !feature.value.englishName || !feature.value.featureType) {
return;
}
let status = 'failed';
if (feature?.value._id?.trim()) {
status = await FeatureService.updateFeature(feature.value._id, feature.value);
} else {
status = await FeatureService.createFeature(feature.value);
try {
if (feature.value._id?.trim()) {
status = await FeatureService.updateFeature(feature.value._id, feature.value);
} else {
status = await FeatureService.createFeature(feature.value);
}
if (status === 'success') {
toast.add({ severity: 'success', summary: 'موفق', detail: 'ویژگی با موفقیت ذخیره شد.', life: 3000 });
featureDialog.value = false;
feature.value = {};
await loadCategories();
} else {
toast.add({ severity: 'error', summary: 'ناموفق', detail: status, life: 3000 });
}
} catch (e) {
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در ذخیره ویژگی', life: 3000 });
}
};
if (status === 'success') {
toast.add({ severity: 'success', summary: 'موفق', detail: 'ویژگی جدید ساخته شد.', life: 3000 });
} else {
toast.add({ severity: 'error', summary: 'ناموفق', detail: status, life: 3000 });
}
featureDialog.value = false;
feature.value = {};
CategoryService.getAllCategories().then((data) => (categories.value = data));
const getFeatureTypeLabel = (type) => {
const found = featureTypes.find((t) => t.value === type);
return found ? found.name : type || '-';
};
</script>
<template>
<div className="card">
<div class="font-semibold text-xl mb-4">ویژگی دسته بندی ها</div>
<Tabs :value="selectedTab" :pt="{}">
<TabList>
<Tab v-for="category in categories" :key="category._id" :value="category._id">
{{ category.nativeName }}
<div class="card p-4">
<Toast></Toast>
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<div>
<h3 class="text-xl font-bold m-0 text-slate-800 dark:text-slate-100">ویژگی دسته‌بندی‌ها</h3>
<p class="text-xs text-slate-500 mt-1 mb-0">تعریف و مدیریت فیلدهای اختصاصی برای هر دسته‌بندی</p>
</div>
<Button v-if="selectedTab" label="افزودن ویژگی جدید" icon="pi pi-plus" severity="primary" @click="openNew(selectedTab)" />
</div>
<!-- Skeleton Loading -->
<div v-if="dataLoading" class="space-y-4">
<div class="flex gap-2">
<Skeleton v-for="i in 4" :key="i" width="8rem" height="2.5rem" class="rounded-xl" />
</div>
<div class="w-full my-4 space-y-3">
<Skeleton width="100%" height="4rem" class="rounded-xl" />
<Skeleton width="100%" height="4rem" class="rounded-xl" />
<Skeleton width="100%" height="4rem" class="rounded-xl" />
</div>
</div>
<!-- Empty state if no categories exist -->
<div v-else-if="!categories.length" class="p-8 text-center text-slate-500">
<i class="pi pi-inbox text-4xl mb-2 text-surface-400"></i>
<p>هیچ دسته‌بندی‌ای یافت نشد. ابتدا یک دسته‌بندی ایجاد کنید.</p>
</div>
<!-- Category Tabs -->
<Tabs v-else :value="selectedTab" @update:value="selectedTab = $event">
<TabList class="mb-4">
<Tab v-for="cat in categories" :key="cat._id" :value="cat._id">
<div class="flex items-center gap-2">
<i class="pi pi-tag text-xs"></i>
<span>{{ cat.nativeName }}</span>
<span class="text-xs px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300">
{{ cat.features?.length || 0 }}
</span>
</div>
</Tab>
</TabList>
<TabPanels>
<TabPanel v-for="category in categories" :key="category._id + '_content'" :value="category._id">
<p>لیست ویژگی های {{ category.nativeName }}</p>
<div class="w-1/3 my-3">
<Listbox filter :options="category.features" option-value="_id" option-label="nativeName" @item-dblclick="editFeature($event, category._id)" emptyMessage="موردی برای نمایش وجود ندارد."></Listbox>
<Button class="my-3" :categoryId="category._id" @click="openNew(category._id)">افزودن</Button>
<TabPanel v-for="cat in categories" :key="cat._id + '_content'" :value="cat._id">
<div class="flex items-center justify-between mb-4">
<span class="text-sm font-semibold text-slate-700 dark:text-slate-200"> لیست ویژگی‌های «{{ cat.nativeName }}» ({{ cat.features?.length || 0 }} مورد) </span>
<Button label="افزودن ویژگی" icon="pi pi-plus" size="small" severity="secondary" outlined @click="openNew(cat._id)" />
</div>
<!-- Features Table / List -->
<div v-if="cat.features && cat.features.length > 0" class="overflow-x-auto rounded-xl border border-surface-200 dark:border-surface-700">
<table class="w-full text-right text-sm">
<thead class="bg-surface-50 dark:bg-surface-800/60 text-surface-700 dark:text-surface-200 font-semibold border-b border-surface-200 dark:border-surface-700">
<tr>
<th class="p-3">#</th>
<th class="p-3">نام فارسی</th>
<th class="p-3">نام انگلیسی</th>
<th class="p-3">نوع ویژگی</th>
<th class="p-3">واحد</th>
<th class="p-3">تگ دسته‌بندی</th>
<th class="p-3 text-center">عملیات</th>
</tr>
</thead>
<tbody class="divide-y divide-surface-100 dark:divide-surface-800">
<tr v-for="(feat, idx) in cat.features" :key="feat._id || idx" class="hover:bg-surface-50/50 dark:hover:bg-surface-800/40 transition-colors">
<td class="p-3 font-mono text-xs text-slate-400">{{ idx + 1 }}</td>
<td class="p-3 font-semibold text-slate-800 dark:text-slate-100">{{ feat.nativeName }}</td>
<td class="p-3 font-mono text-xs text-slate-600 dark:text-slate-300">{{ feat.englishName }}</td>
<td class="p-3">
<span class="px-2 py-0.5 text-xs rounded-full bg-blue-50 dark:bg-blue-950/60 text-blue-700 dark:text-blue-300 font-medium">
{{ getFeatureTypeLabel(feat.featureType) }}
</span>
</td>
<td class="p-3 text-xs text-slate-500">{{ feat.unit || '-' }}</td>
<td class="p-3 text-xs text-slate-500">{{ feat.tag || '-' }}</td>
<td class="p-3 text-center">
<div class="flex items-center justify-center gap-2">
<Button icon="pi pi-pencil" size="small" outlined rounded severity="info" title="ویرایش" @click="editFeature(feat, cat._id)" />
<Button icon="pi pi-trash" size="small" outlined rounded severity="danger" title="حذف" @click="confirmDelete(feat)" />
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="p-8 text-center text-slate-400 bg-surface-50 dark:bg-surface-800/30 rounded-xl border border-dashed border-surface-200 dark:border-surface-700">
<i class="pi pi-tags text-3xl mb-2 text-slate-300 dark:text-slate-600"></i>
<p class="mb-3 text-sm">هیچ ویژگی‌ای برای این دسته‌بندی تعریف نشده است.</p>
<Button label="ساخت اولین ویژگی" icon="pi pi-plus" size="small" @click="openNew(cat._id)" />
</div>
</TabPanel>
</TabPanels>
</Tabs>
<Dialog v-model:visible="featureDialog" :style="{ width: '450px' }" header="ساخت ویژگی" :modal="true">
<div class="flex flex-col gap-6">
<!-- Feature Create / Edit Dialog -->
<Dialog v-model:visible="featureDialog" :style="{ width: '480px' }" :header="feature._id ? 'ویرایش ویژگی' : 'ساخت ویژگی جدید'" :modal="true">
<div class="flex flex-col gap-4">
<div>
<label for="nativeName" class="block font-bold mb-3">نام فارسی</label>
<InputText id="nativeName" v-model.trim="feature.nativeName" required="true" autofocus :invalid="submitted && !feature.nativeName" fluid />
<label for="nativeName" class="block font-semibold text-sm mb-1.5">نام فارسی <span class="text-red-500">*</span></label>
<InputText id="nativeName" v-model.trim="feature.nativeName" placeholder="مثلا: متراژ، تعداد خواب" required autofocus :invalid="submitted && !feature.nativeName" fluid />
<small v-if="submitted && !feature.nativeName" class="text-red-500">ورود نام فارسی الزامی است.</small>
</div>
<div>
<label for="englishName" class="block font-bold mb-3">نام انگلیسی</label>
<InputText id="englishName" v-model.trim="feature.englishName" required="true" autofocus :invalid="submitted && !feature.englishName" fluid />
<label for="englishName" class="block font-semibold text-sm mb-1.5">نام انگلیسی <span class="text-red-500">*</span></label>
<InputText id="englishName" v-model.trim="feature.englishName" placeholder="مثلا: area, roomCount" required :invalid="submitted && !feature.englishName" fluid />
<small v-if="submitted && !feature.englishName" class="text-red-500">ورود نام انگلیسی الزامی است.</small>
</div>
<div>
<label for="unit" class="block font-bold mb-3">واحد</label>
<InputText id="unit" v-model.trim="feature.unit" required="true" autofocus :invalid="submitted && !feature.unit" fluid />
</div>
<div class="mb-6">
<label for="featureType" class="block font-bold mb-3">نوع ویژگی</label>
<Select id="featureType" option-label="name" option-value="value" :options="featureType" v-model.trim="feature.featureType" autofocus :invalid="submitted && !feature.featureType" fluid />
<label for="featureType" class="block font-semibold text-sm mb-1.5">نوع داده‌ای <span class="text-red-500">*</span></label>
<Select id="featureType" option-label="name" option-value="value" :options="featureTypes" v-model="feature.featureType" :invalid="submitted && !feature.featureType" fluid />
<small v-if="submitted && !feature.featureType" class="text-red-500">ورود نوع ویژگی الزامی است.</small>
</div>
<div>
<label for="unit" class="block font-semibold text-sm mb-1.5">واحد اندازه‌گیری (اختیاری)</label>
<InputText id="unit" v-model.trim="feature.unit" placeholder="مثلا: متر مربع، دستگاه" fluid />
</div>
<div>
<label for="tag" class="block font-semibold text-sm mb-1.5">تگ گروهی (اختیاری)</label>
<InputText id="tag" v-model.trim="feature.tag" placeholder="مثلا: مشخصات کلی، امکانات" fluid />
</div>
</div>
<template #footer>
<Button label="لغو" icon="pi pi-times" text @click="hideDialog" />
<Button label="ذخیره" icon="pi pi-check" @click="saveFeature" />
<Button label="ذخیره" icon="pi pi-check" severity="primary" @click="saveFeature" />
</template>
</Dialog>
<!-- Delete Confirmation Dialog -->
<Dialog v-model:visible="deleteFeatureDialog" :style="{ width: '420px' }" header="تایید حذف ویژگی" :modal="true">
<div class="flex items-center gap-3">
<i class="pi pi-exclamation-triangle text-2xl text-red-500" />
<span v-if="featureToDelete">
آیا از حذف ویژگی <b>«{{ featureToDelete.nativeName }}»</b> مطمئن هستید؟
</span>
</div>
<template #footer>
<Button label="انصراف" icon="pi pi-times" text @click="deleteFeatureDialog = false" />
<Button label="حذف شود" icon="pi pi-trash" severity="danger" @click="deleteFeature" />
</template>
</Dialog>
</div>
File diff suppressed because it is too large Load Diff
+735
View File
@@ -0,0 +1,735 @@
<script setup>
import { FilterMatchMode } from '@primevue/core/api';
import { useToast } from 'primevue/usetoast';
import { onMounted, ref, watch, computed, nextTick } from 'vue';
import { PropertyService } from '@/service/PropertyService';
import { SettingService } from '@/service/SettingService';
import { useRouter, useRoute } from 'vue-router';
import Chip from 'primevue/chip';
import Tag from 'primevue/tag';
import InputIcon from 'primevue/inputicon';
import IconField from 'primevue/iconfield';
import SelectButton from 'primevue/selectbutton';
import LoadingCheck from '@/components/LoadingCheck.vue';
import { useConfirm } from 'primevue/useconfirm';
import ConfirmPopup from 'primevue/confirmpopup';
import PropertyMap from './PropertyMap.vue';
import axios from 'axios';
const deleteConfirm = useConfirm();
const toast = useToast();
const router = useRouter();
const route = useRoute();
// View Mode
const viewMode = ref('split'); // 'table' | 'split' | 'map'
const viewModeOptions = ref([
{ label: 'جدول', value: 'table', icon: 'pi pi-table' },
{ label: 'نمای ترکیبی (Airbnb)', value: 'split', icon: 'pi pi-th-large' },
{ label: 'نقشه کامل', value: 'map', icon: 'pi pi-map' }
]);
const dt = ref();
const properties = ref([]);
const selectedProperties = ref();
const selectedPropertyId = ref(null);
const hoveredPropertyId = ref(null);
const mapRef = ref(null);
const splitMapRef = ref(null);
const propertyCardRefs = ref({});
const searchMethod = ref(null); // 'typesense' | 'mongo'
const filters = ref({
global: { value: null, matchMode: FilterMatchMode.CONTAINS }
});
const searchPhrase = ref(route.query.query || '');
const page = ref(Number(route.query.page) || 1);
const per_page = ref(Number(route.query.per_page) || 20);
const totalCount = ref(0);
const pagesCount = ref(0);
const DEFAULT_IMAGE = 'https://cloud.mbmanager.ir/images/item-no-image.png';
const loadSearchSetting = async () => {
try {
const setting = await SettingService.getSearchSettingByPage('properties');
searchMethod.value = setting?.searchMethod || 'typesense';
} catch (e) {
console.warn('Failed to load search setting, defaulting to typesense', e);
searchMethod.value = 'typesense';
}
};
onMounted(async () => {
await loadSearchSetting();
loadData();
});
let searchTimeout;
watch(searchPhrase, (newPhrase, oldPhrase) => {
if (newPhrase !== oldPhrase) {
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
router.push({
query: {
...route.query,
query: newPhrase || undefined,
page: 1
}
});
}, 300);
}
});
watch(
() => route.query,
() => {
page.value = Number(route.query.page) || 1;
per_page.value = Number(route.query.per_page) || 20;
searchPhrase.value = route.query.query || '';
loadData();
},
{ deep: true }
);
const handleRemoveSearch = () => {
removeFromQuery('query');
searchPhrase.value = '';
};
const removeFromQuery = (queryToRemove) => {
const newQuery = { ...route.query };
delete newQuery[queryToRemove];
router.replace({ query: newQuery }).catch((err) => {
if (err?.name !== 'NavigationDuplicated') {
console.error(err);
}
});
};
const onPageChange = (event) => {
const newPage = event.page + 1;
const newPerPage = event.rows;
if (page.value !== newPage || per_page.value !== newPerPage) {
router.push({
query: {
...route.query,
page: newPage,
per_page: newPerPage
}
});
}
};
const dataLoading = ref(true);
const loadData = async () => {
dataLoading.value = true;
try {
if (searchMethod.value === null) {
await loadSearchSetting();
}
let response;
if (searchMethod.value === 'mongo') {
response = await PropertyService.searchPropertiesMongo(route.query);
} else {
response = await PropertyService.searchProperties(route.query);
}
properties.value = response.properties || response.belongings || response || [];
totalCount.value = response.totalCount || (Array.isArray(response) ? response.length : 0);
pagesCount.value = Math.ceil(totalCount.value / per_page.value);
} catch (error) {
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در بازیابی اطلاعات املاک', life: 3000 });
console.error('Error loading data:', error);
} finally {
dataLoading.value = false;
}
};
const loading = ref(false);
const indexingFinished = ref(false);
const indexingMessage = ref('در حال ایندکس...');
const animationKey = ref(0);
const indexProperties = async () => {
loading.value = true;
indexingFinished.value = false;
try {
await PropertyService.indexProperties();
await loadData();
indexingFinished.value = true;
indexingMessage.value = 'ایندکس با موفقیت انجام شد';
} catch (error) {
indexingMessage.value = 'خطا در ایندکس';
} finally {
setTimeout(() => {
loading.value = false;
animationKey.value++;
}, 3000);
}
};
function editProperty(propertyId) {
router.push('/property/edit-property/' + propertyId);
}
function openInNewTab(propertyId) {
window.open('/property/edit-property/' + propertyId, '_blank');
}
const confirmDeleteProperty = (id, event) => {
deleteConfirm.require({
target: event.currentTarget,
message: `آیا از حذف این ملک اطمینان دارید؟`,
icon: 'pi pi-exclamation-triangle',
rejectProps: {
label: 'خیر',
severity: 'secondary',
outlined: true
},
acceptProps: {
label: 'بله'
},
accept: async () => {
try {
const url = '/api/properties/admin/delete/' + id;
await axios.delete(url, { withCredentials: true });
toast.add({ severity: 'success', summary: 'موفقیت آمیز', detail: 'حذف ملک با موفقیت انجام شد', life: 3000 });
await indexProperties();
await loadData();
} catch (error) {
toast.add({ severity: 'error', summary: 'خطا در انجام عملیات', detail: error.response?.data?.message || 'خطایی در حذف ملک رخ داد', life: 3000 });
console.error(error);
}
}
});
};
const getPropertyImage = (prop) => {
return prop.imageUrl || prop.mainImageUrls?.[0] || prop.signedMainImageUrls?.[0] || DEFAULT_IMAGE;
};
const handleImageError = (e) => {
e.target.src = DEFAULT_IMAGE;
};
const getCategoryName = (prop) => {
if (prop.nativeCategory) return prop.nativeCategory;
if (typeof prop.category === 'object' && prop.category?.nativeName) return prop.category.nativeName;
if (typeof prop.category === 'string') return prop.category;
return '';
};
// Selection sync between Map & Cards
const onSelectProperty = (propertyId) => {
selectedPropertyId.value = propertyId;
// Scroll to the selected property card in split view
nextTick(() => {
const cardEl = document.getElementById(`property-card-${propertyId}`);
if (cardEl) {
cardEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
});
};
const onCardHover = (propertyId) => {
hoveredPropertyId.value = propertyId;
};
const onCardLeave = () => {
hoveredPropertyId.value = null;
};
</script>
<template>
<ConfirmPopup></ConfirmPopup>
<div class="properties-page">
<div class="card p-4">
<!-- ── Top Toolbar ── -->
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<!-- Start: Actions -->
<div class="flex flex-wrap items-center gap-3">
<Button label="ساخت ملک جدید" icon="pi pi-plus" severity="primary" @click="router.push('/property/create-property')" />
<Button @click="indexProperties" :disabled="loading" severity="secondary" class="indexing-button">
<div class="button-content">
<div v-if="!loading" class="flex items-center gap-2">
<i class="pi pi-refresh"></i>
<span>ایندکس کردن املاک</span>
</div>
<div v-else class="loading-content flex items-center gap-2">
<LoadingCheck :key="animationKey" :is-complete="indexingFinished" />
<Transition enter-active-class="animate__animated animate__fadeIn animate__fast" leave-active-class="animate__animated animate__fadeOut animate__fast" mode="out-in">
<span :key="indexingMessage">{{ indexingMessage }}</span>
</Transition>
</div>
</div>
</Button>
</div>
<!-- Center/End: Search & View Switcher -->
<div class="flex flex-wrap items-center gap-3">
<!-- Search Input -->
<IconField>
<InputIcon>
<i class="pi pi-search" />
</InputIcon>
<InputText v-model="searchPhrase" placeholder="جستجوی املاک..." class="w-64" />
</IconField>
<Chip v-if="searchPhrase" class="mx-1" :label="searchPhrase" removable @remove="handleRemoveSearch" />
<!-- Search Engine Mode Tag -->
<Tag
v-if="searchMethod"
:value="searchMethod === 'mongo' ? 'جستجو: MongoDB' : 'جستجو: Typesense'"
:severity="searchMethod === 'mongo' ? 'success' : 'info'"
:icon="searchMethod === 'mongo' ? 'pi pi-database' : 'pi pi-bolt'"
class="cursor-pointer text-xs"
v-tooltip.bottom="'کلیک برای تغییر موتور جستجو در تنظیمات'"
@click="router.push('/settings/search-settings')"
/>
<!-- View Mode Selector -->
<SelectButton v-model="viewMode" :options="viewModeOptions" optionLabel="label" optionValue="value" :allowEmpty="false" class="view-mode-selector">
<template #option="slotProps">
<div class="flex items-center gap-2 px-2 py-1">
<i :class="slotProps.option.icon"></i>
<span class="hidden sm:inline">{{ slotProps.option.label }}</span>
</div>
</template>
</SelectButton>
</div>
</div>
<!-- ── VIEW 1: DATA TABLE VIEW ── -->
<div v-if="viewMode === 'table'">
<DataTable
ref="dt"
v-model:selection="selectedProperties"
:value="properties"
:loading="dataLoading"
dataKey="_id"
:paginator="true"
:rows="per_page"
:filters="filters"
:total-records="totalCount"
:current-page="page - 1"
@page="onPageChange($event)"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
:rowsPerPageOptions="[20, 40, 80]"
currentPageReportTemplate="نمایش {first} تا {last} از {totalRecords} ملک"
stripedRows
responsiveLayout="scroll"
>
<template #loading>
<div class="flex flex-col items-center justify-center p-8 gap-3">
<ProgressSpinner style="width: 42px; height: 42px" strokeWidth="4" />
<span class="text-sm font-medium text-surface-500">در حال بارگذاری اطلاعات املاک...</span>
</div>
</template>
<template #empty>
<div v-if="!dataLoading" class="p-8 text-center text-surface-500">
<i class="pi pi-inbox text-4xl mb-2 text-surface-400"></i>
<p>هیچ ملکی یافت نشد.</p>
</div>
</template>
<template #header>
<div class="flex flex-wrap gap-2 items-center justify-between">
<h4 class="m-0 text-lg font-bold">لیست املاک ({{ totalCount }} مورد)</h4>
</div>
</template>
<Column selectionMode="multiple" style="width: 3rem" :exportable="false"></Column>
<Column header="تصویر" style="width: 5rem">
<template #body="slotProps">
<img :src="getPropertyImage(slotProps.data)" :alt="slotProps.data.name" class="rounded-lg object-cover w-14 h-14 shadow-sm" @error="handleImageError" />
</template>
</Column>
<Column field="propertyCode" header="کد" style="min-width: 5rem">
<template #body="slotProps">
<span class="font-mono text-sm font-semibold text-indigo-600 dark:text-indigo-400">
{{ slotProps.data.propertyCode || '-' }}
</span>
</template>
</Column>
<Column field="nationalGeoCode" header="شناسه ملی جغرافیایی" style="min-width: 11rem">
<template #body="slotProps">
<span class="font-mono text-xs text-slate-600 dark:text-slate-300">
{{ slotProps.data.nationalGeoCode || '-' }}
</span>
</template>
</Column>
<Column field="name" header="نام ملک" sortable style="min-width: 14rem">
<template #body="slotProps">
<span class="font-semibold text-slate-800 dark:text-slate-100">
{{ slotProps.data.name || 'بدون نام' }}
</span>
</template>
</Column>
<Column field="category" header="دسته‌بندی" sortable style="min-width: 8rem">
<template #body="slotProps">
<span class="px-2.5 py-1 text-xs rounded-full bg-indigo-50 dark:bg-indigo-950/60 text-indigo-700 dark:text-indigo-300 font-medium">
{{ getCategoryName(slotProps.data) || '-' }}
</span>
</template>
</Column>
<Column :exportable="false" style="min-width: 12rem">
<template #body="slotProps">
<div class="flex items-center gap-2">
<Button icon="pi pi-external-link" outlined rounded severity="secondary" title="باز کردن در تب جدید" @click="openInNewTab(slotProps.data._id || slotProps.data.id)" />
<Button icon="pi pi-pencil" outlined rounded severity="info" title="ویرایش ملک" @click="editProperty(slotProps.data._id || slotProps.data.id)" />
<Button icon="pi pi-trash" outlined rounded severity="danger" title="حذف ملک" @click="confirmDeleteProperty(slotProps.data._id || slotProps.data.id, $event)" />
</div>
</template>
</Column>
</DataTable>
</div>
<!-- ── VIEW 2: AIRBNB SPLIT VIEW (Cards + Map) ── -->
<div v-else-if="viewMode === 'split'" class="airbnb-split-container">
<!-- Left/Main: Property Cards Scrollable Column -->
<div class="properties-cards-column">
<div class="cards-header">
<span class="text-sm font-semibold text-slate-700 dark:text-slate-200"> {{ totalCount }} ملک یافت شد (صفحه {{ page }} از {{ pagesCount || 1 }}) </span>
</div>
<!-- Loading Skeleton in Split View -->
<div v-if="dataLoading" class="cards-grid">
<div v-for="i in 6" :key="i" class="p-4 rounded-2xl border border-surface-200 dark:border-surface-700 bg-surface-0 dark:bg-surface-900 shadow-sm flex flex-col gap-3 animate-pulse">
<Skeleton width="100%" height="11rem" class="rounded-xl" />
<Skeleton width="70%" height="1.25rem" class="rounded-md" />
<Skeleton width="45%" height="1rem" class="rounded-md" />
<div class="flex justify-between items-center pt-2 border-t border-surface-100 dark:border-surface-800">
<Skeleton width="35%" height="1rem" class="rounded-md" />
<Skeleton width="2rem" height="2rem" class="rounded-lg" />
</div>
</div>
</div>
<!-- Cards Grid -->
<div v-else-if="properties.length > 0" class="cards-grid">
<div
v-for="prop in properties"
:key="prop._id || prop.id"
:id="`property-card-${prop._id || prop.id}`"
class="property-card"
:class="{
'card-selected': selectedPropertyId === (prop._id || prop.id),
'card-hovered': hoveredPropertyId === (prop._id || prop.id)
}"
@mouseenter="onCardHover(prop._id || prop.id)"
@mouseleave="onCardLeave"
@click="onSelectProperty(prop._id || prop.id)"
>
<!-- Card Image -->
<div class="card-img-wrap">
<img :src="getPropertyImage(prop)" :alt="prop.name" class="card-img" @error="handleImageError" />
<span v-if="getCategoryName(prop)" class="card-badge-category">
{{ getCategoryName(prop) }}
</span>
<span v-if="prop.propertyCode" class="card-badge-code"> #{{ prop.propertyCode }} </span>
</div>
<!-- Card Body -->
<div class="card-body">
<h4 class="card-title" :title="prop.name">
{{ prop.name || 'ملک بدون نام' }}
</h4>
<div class="card-details">
<div v-if="prop.nationalGeoCode" class="detail-row">
<i class="pi pi-id-card text-xs text-slate-400"></i>
<span class="font-mono text-xs">{{ prop.nationalGeoCode }}</span>
</div>
<div v-if="prop.dong" class="detail-row">
<i class="pi pi-chart-pie text-xs text-slate-400"></i>
<span class="text-xs">{{ prop.dong }} دانگ</span>
</div>
<div v-if="prop.address" class="detail-row address-row">
<i class="pi pi-map-marker text-xs text-slate-400"></i>
<span class="text-xs truncate">{{ prop.address }}</span>
</div>
</div>
<!-- Card Actions -->
<div class="card-actions" @click.stop>
<a :href="'/property/edit-property/' + (prop._id || prop.id)" target="_blank" rel="noopener noreferrer" class="card-btn card-btn-newtab" title="باز کردن در تب جدید">
<i class="pi pi-external-link"></i>
<span>تب جدید</span>
</a>
<Button icon="pi pi-pencil" label="ویرایش" size="small" severity="secondary" outlined class="flex-1" @click="editProperty(prop._id || prop.id)" />
<Button icon="pi pi-trash" size="small" severity="danger" outlined @click="confirmDeleteProperty(prop._id || prop.id, $event)" />
</div>
</div>
</div>
</div>
<div v-else class="p-8 text-center text-slate-500">
<i class="pi pi-inbox text-4xl mb-2 text-surface-400"></i>
<p>ملکی یافت نشد.</p>
</div>
<!-- Pagination -->
<Paginator :rows="per_page" :totalRecords="totalCount" :first="(page - 1) * per_page" :rowsPerPageOptions="[20, 40, 80]" @page="onPageChange($event)" class="mt-4" />
</div>
<!-- Right/Side: Sticky Interactive Map -->
<div class="properties-map-column">
<PropertyMap ref="splitMapRef" :properties="properties" :selectedPropertyId="selectedPropertyId" :hoveredPropertyId="hoveredPropertyId" @select-property="onSelectProperty" @edit-property="editProperty" />
</div>
</div>
<!-- ── VIEW 3: FULL MAP VIEW ── -->
<div v-else-if="viewMode === 'map'" class="full-map-container">
<PropertyMap ref="mapRef" :properties="properties" :selectedPropertyId="selectedPropertyId" :hoveredPropertyId="hoveredPropertyId" @select-property="onSelectProperty" @edit-property="editProperty" />
</div>
</div>
</div>
</template>
<style scoped>
.properties-page {
font-family: 'Estedad', sans-serif;
}
/* ── Split View Layout (Airbnb style) ── */
.airbnb-split-container {
display: grid;
grid-template-columns: 1.15fr 1fr;
gap: 20px;
min-height: calc(100vh - 220px);
}
@media (max-width: 1024px) {
.airbnb-split-container {
grid-template-columns: 1fr;
}
}
.properties-cards-column {
display: flex;
flex-direction: column;
gap: 16px;
max-height: calc(100vh - 220px);
overflow-y: auto;
padding-right: 4px;
}
.cards-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 8px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
}
/* Property Card */
.property-card {
background: #ffffff;
border-radius: 14px;
border: 1px solid #e2e8f0;
overflow: hidden;
display: flex;
flex-direction: column;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.property-card:hover {
transform: translateY(-4px);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08);
border-color: #cbd5e1;
}
.property-card.card-selected {
border-color: #6366f1;
box-shadow:
0 0 0 2px #6366f1,
0 12px 28px rgba(99, 102, 241, 0.18);
transform: translateY(-4px);
}
.card-img-wrap {
position: relative;
width: 100%;
height: 150px;
background: #f8fafc;
overflow: hidden;
}
.card-img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.property-card:hover .card-img {
transform: scale(1.05);
}
.card-badge-category {
position: absolute;
top: 8px;
right: 8px;
background: rgba(79, 70, 229, 0.9);
backdrop-filter: blur(4px);
color: #ffffff;
font-size: 11px;
font-weight: 600;
padding: 3px 8px;
border-radius: 20px;
}
.card-badge-code {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(15, 23, 42, 0.82);
backdrop-filter: blur(4px);
color: #ffffff;
font-size: 11px;
font-weight: 600;
padding: 2px 6px;
border-radius: 6px;
font-family: 'Courier New', monospace;
}
.card-body {
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.card-title {
font-size: 14px;
font-weight: 700;
color: #1e293b;
margin: 0;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-details {
display: flex;
flex-direction: column;
gap: 4px;
color: #64748b;
}
.detail-row {
display: flex;
align-items: center;
gap: 6px;
}
.detail-row.address-row {
max-width: 100%;
}
.card-actions {
display: flex;
align-items: center;
gap: 6px;
margin-top: auto;
padding-top: 8px;
border-top: 1px solid #f1f5f9;
}
.card-btn-newtab {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
color: #4f46e5;
background: #eef2ff;
border: 1px solid #e0e7ff;
text-decoration: none;
transition: all 0.15s ease;
}
.card-btn-newtab:hover {
background: #e0e7ff;
color: #4338ca;
text-decoration: none;
}
.properties-map-column {
position: sticky;
top: 20px;
height: calc(100vh - 220px);
min-height: 500px;
border-radius: 16px;
overflow: hidden;
}
/* Full Map Container */
.full-map-container {
height: calc(100vh - 220px);
min-height: 550px;
border-radius: 16px;
overflow: hidden;
}
/* ── Dark Mode Adjustments ── */
.app-dark .property-card {
background: #1e293b;
border-color: #334155;
}
.app-dark .property-card:hover {
border-color: #475569;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.4);
}
.app-dark .property-card.card-selected {
border-color: #6366f1;
box-shadow:
0 0 0 2px #6366f1,
0 12px 28px rgba(99, 102, 241, 0.35);
}
.app-dark .card-title {
color: #f8fafc;
}
.app-dark .card-img-wrap {
background: #0f172a;
}
.app-dark .card-actions {
border-top-color: #334155;
}
.app-dark .card-btn-newtab {
background: #1e1b4b;
border-color: #312e81;
color: #a5b4fc;
}
.app-dark .card-btn-newtab:hover {
background: #312e81;
color: #c7d2fe;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
<script setup>
import { ref, reactive } from 'vue';
import { ref, reactive, watch, onMounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import { TransactionService } from '@/service/TransactionService';
@@ -11,6 +11,14 @@ const props = defineProps({
belongingId: {
type: String,
required: true
},
editMode: {
type: Boolean,
default: false
},
transactionData: {
type: Object,
default: null
}
});
@@ -23,7 +31,8 @@ const transaction = reactive({
status: 'pending',
value: null,
description: '',
date: new Date().toISOString()
date: new Date().toISOString(),
_id: null
});
const typeOptions = [
@@ -43,6 +52,43 @@ const errors = reactive({
value: ''
});
// Watch for changes in editMode and transactionData
watch(
() => props.transactionData,
(newValue) => {
if (newValue && props.editMode) {
// Load transaction data for editing
Object.assign(transaction, {
type: newValue.type || 'deposit',
status: newValue.status || 'pending',
value: newValue.value || null,
description: newValue.description || '',
date: newValue.date || new Date().toISOString(),
_id: newValue._id || null
});
}
},
{ immediate: true }
);
// Reset form when dialog opens
watch(
() => props.visible,
(isVisible) => {
if (isVisible && !props.editMode) {
// Reset form for create mode
Object.assign(transaction, {
type: 'deposit',
status: 'pending',
value: null,
description: '',
date: new Date().toISOString(),
_id: null
});
}
}
);
const validateForm = () => {
let isValid = true;
@@ -78,9 +124,19 @@ const saveTransaction = async () => {
try {
transaction.belongingId = props.belongingId;
const result = await TransactionService.createTransaction(transaction);
let result;
if (props.editMode && transaction._id) {
// Update existing transaction
result = await TransactionService.updateTransaction(transaction._id, transaction);
} else {
// Create new transaction
result = await TransactionService.createTransaction(transaction);
}
if (result === 'success') {
toast.add({ severity: 'success', summary: 'موفق', detail: 'تراکنش با موفقیت ثبت شد', life: 3000 });
const message = props.editMode ? 'تراکنش با موفقیت بروزرسانی شد' : 'تراکنش با موفقیت ثبت شد';
toast.add({ severity: 'success', summary: 'موفق', detail: message, life: 3000 });
emit('saved');
closeDialog();
} else {
@@ -98,7 +154,7 @@ const closeDialog = () => {
</script>
<template>
<Dialog v-model:visible="props.visible" :modal="true" :closable="true" :style="{ width: '450px' }" header="افزایش یا کاهش موجودی" @update:visible="closeDialog">
<Dialog v-model:visible="props.visible" :modal="true" :closable="true" :style="{ width: '450px' }" :header="props.editMode ? 'ویرایش تراکنش' : 'افزایش یا کاهش موجودی'" @update:visible="closeDialog">
<div class="p-fluid">
<div class="field mb-4">
<label class="font-bold mb-2 block">نوع تراکنش</label>
@@ -127,7 +183,7 @@ const closeDialog = () => {
<template #footer>
<div class="flex justify-end gap-2">
<Button label="انصراف" icon="pi pi-times" class="p-button-text" @click="closeDialog" />
<Button label="ذخیره" icon="pi pi-check" @click="saveTransaction" />
<Button :label="props.editMode ? 'بروزرسانی' : 'ذخیره'" icon="pi pi-check" @click="saveTransaction" />
</div>
</template>
</Dialog>
+685
View File
@@ -0,0 +1,685 @@
<script setup>
import { onMounted, ref, computed } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import ConfirmPopup from 'primevue/confirmpopup';
import { RoleService } from '@/service/RoleService';
import { PermissionService } from '@/service/PermissionService';
const toast = useToast();
const confirm = useConfirm();
const roles = ref([]);
const allPermissions = ref([]);
const loading = ref(true);
const showDialog = ref(false);
const editMode = ref(false);
const submitting = ref(false);
const selectedRole = ref(null);
const searchPhrase = ref('');
const permissionFilter = ref('');
const form = ref({
englishName: '',
nativeName: '',
permissions: []
});
onMounted(async () => {
await Promise.all([loadRoles(), loadPermissions()]);
});
const loadRoles = async () => {
loading.value = true;
try {
roles.value = await RoleService.listRoles();
} catch (e) {
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در بارگذاری لیست نقش‌ها', life: 3000 });
} finally {
loading.value = false;
}
};
const loadPermissions = async () => {
try {
allPermissions.value = await PermissionService.listPermissions();
} catch (e) {
console.error('Error loading permissions:', e);
}
};
const filteredRoles = computed(() => {
if (!searchPhrase.value) return roles.value;
const q = searchPhrase.value.toLowerCase().trim();
return roles.value.filter((r) => {
const en = (r.englishName || '').toLowerCase();
const fa = (r.nativeName || '').toLowerCase();
return en.includes(q) || fa.includes(q);
});
});
// Group permissions by category
const categorizedPermissions = computed(() => {
const filter = permissionFilter.value.toLowerCase().trim();
const groups = {};
allPermissions.value.forEach((p) => {
if (filter) {
const matchesFa = (p.nativeName || '').toLowerCase().includes(filter);
const matchesEn = (p.englishName || '').toLowerCase().includes(filter);
const matchesKey = (p.key || '').toLowerCase().includes(filter);
const matchesCat = (p.category || '').toLowerCase().includes(filter);
if (!matchesFa && !matchesEn && !matchesKey && !matchesCat) return;
}
const cat = p.category || 'سایر دسترسی‌ها';
if (!groups[cat]) {
groups[cat] = [];
}
groups[cat].push(p);
});
return groups;
});
const openCreate = () => {
editMode.value = false;
selectedRole.value = null;
permissionFilter.value = '';
form.value = {
englishName: '',
nativeName: '',
permissions: []
};
showDialog.value = true;
};
const openEdit = (r) => {
editMode.value = true;
selectedRole.value = r;
permissionFilter.value = '';
const permIds = (r.permissions || []).map((p) => (p._id || p).toString());
form.value = {
englishName: r.englishName || '',
nativeName: r.nativeName || '',
permissions: permIds
};
showDialog.value = true;
};
const togglePermission = (permId) => {
const id = permId.toString();
const idx = form.value.permissions.indexOf(id);
if (idx === -1) {
form.value.permissions.push(id);
} else {
form.value.permissions.splice(idx, 1);
}
};
const isPermissionSelected = (permId) => {
return form.value.permissions.includes(permId.toString());
};
const toggleCategory = (categoryName) => {
const catPerms = categorizedPermissions.value[categoryName] || [];
const catIds = catPerms.map((p) => p._id.toString());
const allSelected = catIds.every((id) => form.value.permissions.includes(id));
if (allSelected) {
// Deselect all in category
form.value.permissions = form.value.permissions.filter((id) => !catIds.includes(id));
} else {
// Select all in category
catIds.forEach((id) => {
if (!form.value.permissions.includes(id)) {
form.value.permissions.push(id);
}
});
}
};
const isCategoryAllSelected = (categoryName) => {
const catPerms = categorizedPermissions.value[categoryName] || [];
if (catPerms.length === 0) return false;
return catPerms.every((p) => form.value.permissions.includes(p._id.toString()));
};
const selectAllPermissions = () => {
form.value.permissions = allPermissions.value.map((p) => p._id.toString());
};
const deselectAllPermissions = () => {
form.value.permissions = [];
};
const submitForm = async () => {
if (!form.value.nativeName) {
toast.add({ severity: 'warn', summary: 'توجه', detail: 'نام فارسی نقش الزامی است', life: 3000 });
return;
}
if (!form.value.englishName) {
toast.add({ severity: 'warn', summary: 'توجه', detail: 'نام انگلیسی نقش الزامی است', life: 3000 });
return;
}
submitting.value = true;
try {
const payload = {
englishName: form.value.englishName.trim(),
nativeName: form.value.nativeName.trim(),
permissions: form.value.permissions
};
if (editMode.value) {
await RoleService.updateRole(selectedRole.value._id, payload);
toast.add({ severity: 'success', summary: 'موفق', detail: 'نقش با موفقیت ویرایش شد', life: 3000 });
} else {
await RoleService.createRole(payload);
toast.add({ severity: 'success', summary: 'موفق', detail: 'نقش جدید با موفقیت ایجاد شد', life: 3000 });
}
showDialog.value = false;
await loadRoles();
} catch (e) {
toast.add({
severity: 'error',
summary: 'خطا',
detail: e.response?.data?.message || 'عملیات ناموفق بود',
life: 4000
});
} finally {
submitting.value = false;
}
};
const confirmDelete = (r, event) => {
if (r.englishName === 'SuperAdmin') {
toast.add({ severity: 'warn', summary: 'غیرمجاز', detail: 'نقش مدیر اصلی سیستم قابل حذف نیست', life: 3000 });
return;
}
confirm.require({
target: event.currentTarget,
message: `آیا از حذف نقش "${r.nativeName}" اطمینان دارید؟`,
icon: 'pi pi-exclamation-triangle',
rejectProps: { label: 'خیر', severity: 'secondary', outlined: true },
acceptProps: { label: 'بله', severity: 'danger' },
accept: async () => {
try {
await RoleService.deleteRole(r._id);
toast.add({ severity: 'success', summary: 'موفق', detail: 'نقش حذف شد', life: 3000 });
await loadRoles();
} catch (e) {
toast.add({ severity: 'error', summary: 'خطا', detail: 'عملیات ناموفق بود', life: 3000 });
}
}
});
};
</script>
<template>
<ConfirmPopup />
<div class="roles-page">
<!-- Header -->
<div class="page-header">
<div class="header-content">
<div class="header-icon">
<i class="pi pi-shield"></i>
</div>
<div>
<h1 class="page-title">مدیریت نقش‌ها و دسترسی‌ها</h1>
<p class="page-subtitle">تعریف سطوح دسترسی و گروه‌بندی مجوزهای کاربران</p>
</div>
</div>
<Button label="افزودن نقش جدید" icon="pi pi-plus" class="create-btn" @click="openCreate" />
</div>
<!-- Table Card -->
<div class="card main-card">
<DataTable :value="filteredRoles" :loading="loading" dataKey="_id" stripedRows responsiveLayout="scroll">
<template #header>
<div class="table-header">
<span class="table-title">لیست نقش‌های تعریف شده ({{ filteredRoles.length }})</span>
<IconField>
<InputIcon><i class="pi pi-search" /></InputIcon>
<InputText v-model="searchPhrase" placeholder="جستجوی نام نقش..." class="search-input" />
</IconField>
</div>
</template>
<template #loading>
<div class="flex flex-col items-center justify-center p-8 gap-3">
<ProgressSpinner style="width: 42px; height: 42px" strokeWidth="4" />
<span class="text-sm font-medium text-surface-500">در حال بارگذاری نقش‌ها و دسترسی‌ها...</span>
</div>
</template>
<template #empty>
<div v-if="!loading" class="empty-state">
<i class="pi pi-shield empty-icon"></i>
<p>هیچ نقشی تعریف نشده است</p>
<Button label="افزودن اولین نقش" icon="pi pi-plus" @click="openCreate" />
</div>
</template>
<!-- Native Name -->
<Column field="nativeName" header="نام فارسی نقش" style="min-width: 180px">
<template #body="{ data }">
<div class="role-name-cell">
<span class="role-native-name">{{ data.nativeName }}</span>
<span v-if="data.englishName === 'SuperAdmin'" class="super-badge"> <i class="pi pi-bolt"></i> مدیر کل سیستم </span>
</div>
</template>
</Column>
<!-- English Name -->
<Column field="englishName" header="شناسه لاتین" style="min-width: 150px">
<template #body="{ data }">
<span class="code-badge">{{ data.englishName }}</span>
</template>
</Column>
<!-- Permissions Count -->
<Column header="تعداد دسترسی‌ها" style="width: 180px">
<template #body="{ data }">
<span v-if="data.englishName === 'SuperAdmin'" class="perm-count-badge perm-full"> <i class="pi pi-check-circle ml-1"></i> تمام دسترسی‌ها (نامحدود) </span>
<span v-else class="perm-count-badge"> <i class="pi pi-key ml-1"></i> {{ (data.permissions || []).length }} دسترسی فعال </span>
</template>
</Column>
<!-- Status Column -->
<Column header="وضعیت" style="width: 100px">
<template #body="{ data }">
<Tag :severity="data.isActive !== false ? 'success' : 'danger'" :value="data.isActive !== false ? 'فعال' : 'غیرفعال'" />
</template>
</Column>
<!-- Actions -->
<Column header="عملیات" style="width: 110px">
<template #body="{ data }">
<div class="action-buttons">
<Button icon="pi pi-pencil" outlined rounded size="small" v-tooltip.top="'ویرایش دسترسی‌ها'" @click="openEdit(data)" />
<Button v-if="data.englishName !== 'SuperAdmin'" icon="pi pi-trash" outlined rounded severity="danger" size="small" v-tooltip.top="'حذف نقش'" @click="confirmDelete(data, $event)" />
</div>
</template>
</Column>
</DataTable>
</div>
<!-- Create / Edit Dialog -->
<Dialog v-model:visible="showDialog" :header="editMode ? 'ویرایش نقش و دسترسی‌ها' : 'افزودن نقش جدید'" :style="{ width: '820px' }" modal dir="rtl">
<div class="dialog-content">
<!-- General Info -->
<div class="form-row">
<div class="form-field">
<label>نام فارسی نقش <span class="required">*</span></label>
<InputText v-model="form.nativeName" placeholder="مثال: مدیر مالی" class="w-full" />
</div>
<div class="form-field">
<label>نام انگلیسی (شناسه) <span class="required">*</span></label>
<InputText v-model="form.englishName" placeholder="مثال: FinanceManager" class="w-full" dir="ltr" />
</div>
</div>
<!-- Permissions Matrix Section -->
<div class="permissions-section">
<div class="section-header">
<div class="section-title">
<i class="pi pi-key"></i>
<span>تخصیص دسترسی‌های این نقش ({{ form.permissions.length }} انتخاب شده)</span>
</div>
<div class="section-actions">
<Button label="انتخاب همه" size="small" outlined severity="secondary" @click="selectAllPermissions" />
<Button label="لغو همه" size="small" text severity="danger" @click="deselectAllPermissions" />
</div>
</div>
<!-- Search within permissions -->
<div class="perm-search-box">
<IconField class="w-full">
<InputIcon><i class="pi pi-search" /></InputIcon>
<InputText v-model="permissionFilter" placeholder="فیلتر دسترسی‌ها (مثلا: انبار، کاربر، ایجاد...)" class="w-full search-box-input" />
</IconField>
</div>
<!-- Categories Matrix -->
<div class="categories-container">
<div v-for="(perms, catName) in categorizedPermissions" :key="catName" class="category-card">
<div class="category-header">
<span class="category-name">
<i class="pi pi-folder ml-1"></i>
{{ catName }} ({{ perms.length }})
</span>
<Button
:label="isCategoryAllSelected(catName) ? 'لغو انتخاب دسته' : 'انتخاب همه دسته'"
size="small"
text
:severity="isCategoryAllSelected(catName) ? 'danger' : 'info'"
class="cat-toggle-btn"
@click="toggleCategory(catName)"
/>
</div>
<div class="permissions-grid">
<div v-for="p in perms" :key="p._id" class="permission-item" :class="{ 'permission-active': isPermissionSelected(p._id) }" @click="togglePermission(p._id)">
<div class="perm-checkbox">
<i v-if="isPermissionSelected(p._id)" class="pi pi-check text-xs"></i>
</div>
<div class="perm-info">
<span class="perm-native">{{ p.nativeName }}</span>
<span class="perm-key">{{ p.key }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<template #footer>
<Button label="انصراف" icon="pi pi-times" text @click="showDialog = false" />
<Button :label="editMode ? 'ذخیره تغییرات' : 'ایجاد نقش'" icon="pi pi-check" :loading="submitting" @click="submitForm" />
</template>
</Dialog>
</div>
</template>
<style scoped>
.roles-page {
padding: 1.5rem;
direction: rtl;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.header-content {
display: flex;
align-items: center;
gap: 1rem;
}
.header-icon {
width: 52px;
height: 52px;
border-radius: 14px;
background: linear-gradient(135deg, #8b5cf6, #d946ef);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: #fff;
box-shadow: 0 4px 15px rgba(139, 92, 246, 0.35);
}
.page-title {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
}
.page-subtitle {
margin: 0;
color: var(--text-color-secondary);
font-size: 0.875rem;
}
.create-btn {
background: linear-gradient(135deg, #8b5cf6, #d946ef) !important;
border: none !important;
padding: 0.65rem 1.5rem !important;
font-weight: 600 !important;
}
.main-card {
border-radius: 16px;
}
.table-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0;
}
.table-title {
font-size: 1rem;
font-weight: 600;
}
.search-input {
width: 260px;
}
.empty-state {
text-align: center;
padding: 3rem;
color: var(--text-color-secondary);
}
.empty-icon {
font-size: 3rem;
display: block;
margin-bottom: 1rem;
opacity: 0.35;
}
.role-name-cell {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.role-native-name {
font-weight: 600;
font-size: 0.95rem;
}
.super-badge {
font-size: 0.75rem;
color: #eab308;
display: inline-flex;
align-items: center;
gap: 0.2rem;
}
.code-badge {
font-family: monospace;
background: var(--surface-100);
padding: 3px 8px;
border-radius: 6px;
font-size: 0.82rem;
font-weight: 600;
}
.perm-count-badge {
display: inline-flex;
align-items: center;
font-size: 0.85rem;
background: rgba(99, 102, 241, 0.1);
color: #6366f1;
padding: 3px 10px;
border-radius: 8px;
font-weight: 600;
}
.perm-full {
background: rgba(34, 197, 94, 0.1);
color: #16a34a;
}
.action-buttons {
display: flex;
gap: 0.4rem;
}
.dialog-content {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.form-field label {
font-weight: 600;
font-size: 0.9rem;
}
.required {
color: #ef4444;
}
.permissions-section {
border-top: 1px solid var(--surface-border);
padding-top: 1.25rem;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 700;
font-size: 0.95rem;
}
.section-actions {
display: flex;
gap: 0.5rem;
}
.perm-search-box {
margin-bottom: 1rem;
}
.search-box-input {
border-radius: 10px;
}
.categories-container {
display: flex;
flex-direction: column;
gap: 1.25rem;
max-height: 420px;
overflow-y: auto;
padding-left: 0.5rem;
}
.category-card {
background: var(--surface-50);
border: 1px solid var(--surface-border);
border-radius: 12px;
padding: 1rem;
}
.category-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px dashed var(--surface-border);
}
.category-name {
font-weight: 700;
font-size: 0.9rem;
color: var(--primary-color);
}
.cat-toggle-btn {
font-size: 0.8rem !important;
padding: 0.2rem 0.5rem !important;
}
.permissions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.6rem;
}
.permission-item {
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: 10px;
padding: 0.6rem 0.75rem;
display: flex;
align-items: center;
gap: 0.65rem;
cursor: pointer;
transition: all 0.15s ease;
user-select: none;
}
.permission-item:hover {
border-color: var(--primary-color);
background: var(--surface-100);
}
.permission-active {
background: rgba(59, 130, 246, 0.08);
border-color: #3b82f6;
}
.perm-checkbox {
width: 20px;
height: 20px;
border-radius: 6px;
border: 2px solid var(--surface-400);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.15s;
}
.permission-active .perm-checkbox {
background: #3b82f6;
border-color: #3b82f6;
color: #fff;
}
.perm-info {
display: flex;
flex-direction: column;
overflow: hidden;
}
.perm-native {
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.perm-key {
font-size: 0.72rem;
color: var(--text-color-secondary);
font-family: monospace;
direction: ltr;
text-align: right;
}
</style>
+453
View File
@@ -0,0 +1,453 @@
<script setup>
import { onMounted, ref, computed } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { SettingService } from '@/service/SettingService';
import { useRouter } from 'vue-router';
const toast = useToast();
const confirm = useConfirm();
const router = useRouter();
const loading = ref(false);
const saving = ref(false);
const settings = ref([]);
// Seeder State
const seedStatus = ref({ isSeeded: false, seededAt: null });
const isSeeding = ref(false);
const seedLoading = ref(false);
// Auto-Save Settings State
const autoSaveSettings = ref({ enabled: true, intervalSeconds: 60 });
const autoSaveLoading = ref(false);
const autoSaveSaving = ref(false);
const autoSaveIntervalOptions = [
{ label: '۳۰ ثانیه (سریع)', value: 30 },
{ label: '۱ دقیقه (پیش‌فرض)', value: 60 },
{ label: '۲ دقیقه', value: 120 },
{ label: '۳ دقیقه', value: 180 },
{ label: '۵ دقیقه', value: 300 },
{ label: '۱۰ دقیقه', value: 600 }
];
const methodOptions = [
{ label: 'Typesense', value: 'typesense', icon: 'pi pi-bolt', class: 'text-amber-500' },
{ label: 'MongoDB', value: 'mongo', icon: 'pi pi-database', class: 'text-emerald-500' }
];
onMounted(async () => {
await Promise.all([loadSettings(), loadSeedStatus(), loadAutoSaveSettings()]);
});
const loadAutoSaveSettings = async () => {
autoSaveLoading.value = true;
try {
const data = await SettingService.getAutoSaveSetting();
if (data) {
autoSaveSettings.value = {
enabled: data.enabled !== undefined ? data.enabled : true,
intervalSeconds: Number(data.intervalSeconds) || 60
};
}
} catch (e) {
console.warn('Failed to load autosave settings:', e);
} finally {
autoSaveLoading.value = false;
}
};
const saveAutoSaveSettings = async () => {
autoSaveSaving.value = true;
try {
await SettingService.updateAutoSaveSetting(autoSaveSettings.value);
toast.add({
severity: 'success',
summary: 'موفقیت‌آمیز',
detail: 'تنظیمات ذخیره خودکار با موفقیت ذخیره شد.',
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: 'خطا',
detail: 'خطا در ذخیره تنظیمات ذخیره خودکار',
life: 3000
});
} finally {
autoSaveSaving.value = false;
}
};
const loadSeedStatus = async () => {
seedLoading.value = true;
try {
const data = await SettingService.getSeedStatus();
seedStatus.value = data || { isSeeded: false, seededAt: null };
} catch (e) {
console.warn('Failed to load seed status:', e);
} finally {
seedLoading.value = false;
}
};
const handleSeedDatabase = () => {
if (seedStatus.value.isSeeded) return;
confirm.require({
message:
'توجه: این عملیات داده‌های اولیه سیستم (کاربران پیش‌فرض، نقش‌ها، دسته‌بندی‌ها، ویژگی‌های تکمیلی و وضعیت‌های املاک) را در پایگاه داده ایجاد می‌کند. این عملیات فقط یک‌بار قابل اجراست و پس از انجام دکمه برای همیشه قفل خواهد شد. آیا ادامه می‌دهید؟',
header: 'تایید مقداردهی اولیه پایگاه داده (Seed Database)',
icon: 'pi pi-exclamation-triangle',
rejectProps: { label: 'انصراف', severity: 'secondary', outlined: true },
acceptProps: { label: 'تایید و شروع مقداردهی', severity: 'primary' },
accept: async () => {
isSeeding.value = true;
try {
const res = await SettingService.seedDatabase();
seedStatus.value = {
isSeeded: true,
seededAt: res.seededAt || new Date()
};
toast.add({
severity: 'success',
summary: 'موفقیت‌آمیز',
detail: res.message || 'پایگاه داده با موفقیت مقداردهی شد و قفل گردید.',
life: 5000
});
await loadSettings();
} catch (err) {
const errMsg = err?.response?.data?.message || err.message || 'خطا در مقداردهی پایگاه داده';
toast.add({
severity: 'error',
summary: 'خطا در مقداردهی',
detail: errMsg,
life: 4000
});
await loadSeedStatus();
} finally {
isSeeding.value = false;
}
}
});
};
const loadSettings = async () => {
loading.value = true;
try {
const data = await SettingService.getSearchSettings();
settings.value = data;
} catch (e) {
toast.add({
severity: 'error',
summary: 'خطا',
detail: 'خطا در بارگذاری تنظیمات جستجو',
life: 3000
});
} finally {
loading.value = false;
}
};
const stats = computed(() => {
const total = settings.value.length;
const mongoCount = settings.value.filter((s) => s.searchMethod === 'mongo').length;
const typesenseCount = settings.value.filter((s) => s.searchMethod === 'typesense').length;
const persistedCount = settings.value.filter((s) => s.isPersisted).length;
return { total, mongoCount, typesenseCount, persistedCount };
});
const updateMethod = async (pageKey, newMethod) => {
try {
const updated = await SettingService.updateSearchSetting(pageKey, newMethod);
const index = settings.value.findIndex((s) => s.pageKey === pageKey);
if (index !== -1) {
settings.value[index] = { ...settings.value[index], ...updated };
}
toast.add({
severity: 'success',
summary: 'بروزرسانی شد',
detail: `روش جستجوی "${updated.title}" به ${newMethod === 'mongo' ? 'MongoDB' : 'Typesense'} تغییر یافت.`,
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: 'خطا',
detail: 'خطا در ذخیره‌سازی تنظیمات در پایگاه داده',
life: 3000
});
}
};
const setAllToMethod = async (method) => {
confirm.require({
message: `آیا از تغییر روش جستجوی تمامی بخش‌ها به ${method === 'mongo' ? 'MongoDB' : 'Typesense'} اطمینان دارید؟`,
header: 'تایید تغییر همگانی',
icon: 'pi pi-exclamation-triangle',
rejectProps: { label: 'انصراف', severity: 'secondary', outlined: true },
acceptProps: { label: 'تایید و اعمال', severity: method === 'mongo' ? 'success' : 'warn' },
accept: async () => {
saving.value = true;
try {
const payload = settings.value.map((s) => ({
pageKey: s.pageKey,
searchMethod: method
}));
const updatedList = await SettingService.bulkUpdateSearchSettings(payload);
settings.value = updatedList;
toast.add({
severity: 'success',
summary: 'موفقیت‌آمیز',
detail: `تمامی بخش‌ها با موفقیت به ${method === 'mongo' ? 'MongoDB' : 'Typesense'} تغییر یافتند.`,
life: 4000
});
} catch (e) {
toast.add({
severity: 'error',
summary: 'خطا',
detail: 'خطا در بروزرسانی همگانی تنظیمات',
life: 3000
});
} finally {
saving.value = false;
}
}
});
};
const navigateToModule = (routePath) => {
if (routePath) {
router.push(routePath);
}
};
</script>
<template>
<div class="search-settings-page">
<Toast />
<ConfirmDialog />
<!-- ── Header Banner ── -->
<div class="card p-6 mb-6 shadow-sm border border-surface-200 dark:border-surface-700 bg-surface-0 dark:bg-surface-900 rounded-2xl">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-2xl bg-primary-100 dark:bg-primary-950 flex items-center justify-center text-primary-600 dark:text-primary-400">
<i class="pi pi-sliders-h text-2xl"></i>
</div>
<div>
<h2 class="text-2xl font-bold m-0 text-surface-900 dark:text-surface-0">تنظیمات موتور جستجو</h2>
<p class="text-surface-500 dark:text-surface-400 m-0 mt-1 text-sm">انتخاب روش جستجو (MongoDB یا Typesense) برای هر بخش از سیستم به صورت مجزا</p>
</div>
</div>
<!-- Stats Badges -->
<div class="flex flex-wrap items-center gap-2">
<div class="flex items-center gap-2 px-3 py-2 rounded-xl bg-surface-100 dark:bg-surface-800 text-surface-700 dark:text-surface-300 text-xs font-semibold">
<i class="pi pi-list text-primary-500"></i>
<span>کل بخش‌ها: {{ stats.total }}</span>
</div>
<div class="flex items-center gap-2 px-3 py-2 rounded-xl bg-emerald-50 dark:bg-emerald-950 text-emerald-700 dark:text-emerald-300 text-xs font-semibold">
<i class="pi pi-database text-emerald-500"></i>
<span>MongoDB: {{ stats.mongoCount }}</span>
</div>
<div class="flex items-center gap-2 px-3 py-2 rounded-xl bg-amber-50 dark:bg-amber-950 text-amber-700 dark:text-amber-300 text-xs font-semibold">
<i class="pi pi-bolt text-amber-500"></i>
<span>Typesense: {{ stats.typesenseCount }}</span>
</div>
</div>
</div>
</div>
<!-- ── Database Seeder Section (One-Time Execution) ── -->
<div class="card p-6 mb-6 shadow-sm border border-surface-200 dark:border-surface-700 bg-surface-0 dark:bg-surface-900 rounded-2xl">
<div class="flex flex-col lg:flex-row lg:items-center justify-between gap-6">
<div class="flex items-start gap-4">
<div
class="w-14 h-14 rounded-2xl flex items-center justify-center text-2xl flex-shrink-0 transition-colors"
:class="seedStatus.isSeeded ? 'bg-surface-100 text-surface-500 dark:bg-surface-800 dark:text-surface-400' : 'bg-indigo-100 text-indigo-600 dark:bg-indigo-950 dark:text-indigo-400 shadow-sm'"
>
<i :class="seedStatus.isSeeded ? 'pi pi-lock' : 'pi pi-database'"></i>
</div>
<div>
<div class="flex items-center gap-2.5 flex-wrap">
<h3 class="text-xl font-bold m-0 text-surface-900 dark:text-surface-0">مقداردهی اولیه پایگاه داده (Seed Database)</h3>
<Tag v-if="seedStatus.isSeeded" value="مقداردهی شده و قفل است" severity="success" icon="pi pi-lock" rounded class="px-2.5 text-xs font-bold" />
<Tag v-else value="آماده مقداردهی (یک‌بار مصرف)" severity="warn" icon="pi pi-exclamation-circle" rounded class="px-2.5 text-xs font-bold" />
</div>
<p class="text-surface-500 dark:text-surface-400 m-0 mt-1.5 text-sm leading-relaxed max-w-2xl">
ایجاد خودکار کاربران اولیه، نقش‌ها و سطوح دسترسی، دسته‌بندی‌های املاک، ویژگی‌های تکمیلی و وضعیت‌های املاک در دیتابیس. این عملیات صرفاً یک‌بار قابل اجراست و پس از انجام دکمه برای همیشه قفل می‌شود.
</p>
<div v-if="seedStatus.isSeeded && seedStatus.seededAt" class="mt-2 text-xs text-surface-400 flex items-center gap-1.5 font-mono">
<i class="pi pi-calendar text-xs"></i>
<span>تاریخ اجرا: {{ new Date(seedStatus.seededAt).toLocaleString('fa-IR') }}</span>
</div>
</div>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<Button v-if="!seedStatus.isSeeded" label="اجرای مقداردهی اولیه داده‌ها" icon="pi pi-database" severity="primary" rounded :loading="isSeeding" @click="handleSeedDatabase" class="px-5 font-semibold" />
<Button v-else label="پایگاه داده مقداردهی شده است" icon="pi pi-lock" severity="secondary" rounded disabled class="px-5 font-semibold opacity-70 cursor-not-allowed" />
</div>
</div>
</div>
<!-- ── Auto-Save Settings Card ── -->
<div class="card p-6 mb-6 shadow-sm border border-surface-200 dark:border-surface-700 bg-surface-0 dark:bg-surface-900 rounded-2xl">
<div class="flex flex-col lg:flex-row lg:items-center justify-between gap-6">
<div class="flex items-start gap-4">
<div class="w-14 h-14 rounded-2xl flex items-center justify-center text-2xl flex-shrink-0 bg-emerald-100 text-emerald-600 dark:bg-emerald-950 dark:text-emerald-400 shadow-sm">
<i class="pi pi-save"></i>
</div>
<div>
<div class="flex items-center gap-2.5 flex-wrap">
<h3 class="text-xl font-bold m-0 text-surface-900 dark:text-surface-0">تنظیمات ذخیره خودکار (Auto-Save)</h3>
<Tag :value="autoSaveSettings.enabled ? 'فعال' : 'غیرفعال'" :severity="autoSaveSettings.enabled ? 'success' : 'secondary'" rounded class="px-2.5 text-xs font-bold" />
</div>
<p class="text-surface-500 dark:text-surface-400 m-0 mt-1.5 text-sm leading-relaxed max-w-2xl">
پیکربندی ذخیره پس‌زمینه خودکار فرم‌ها و املاک. در صورت فعال بودن، تغییرات شما بدون نیاز به فشردن دستی دکمه ذخیره، در بازه‌های مشخص ذخیره شده و اعلان آن در گوشه صفحه نمایش داده می‌شود.
</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-4 flex-shrink-0">
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-surface-600 dark:text-surface-300">فعال بودن:</span>
<ToggleSwitch v-model="autoSaveSettings.enabled" />
</div>
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-surface-600 dark:text-surface-300">بازه زمانی:</span>
<Dropdown v-model="autoSaveSettings.intervalSeconds" :options="autoSaveIntervalOptions" optionLabel="label" optionValue="value" :disabled="!autoSaveSettings.enabled" class="!rounded-xl text-xs w-44" />
</div>
<Button label="ذخیره تنظیمات" icon="pi pi-check" severity="primary" rounded size="small" :loading="autoSaveSaving" @click="saveAutoSaveSettings" class="px-4 font-semibold" />
</div>
</div>
</div>
<!-- ── Quick Actions Toolbar ── -->
<div class="card p-4 mb-6 flex flex-wrap items-center justify-between gap-4">
<div class="flex flex-wrap items-center gap-3">
<Button label="تنظیم همه روی MongoDB" icon="pi pi-database" severity="success" outlined size="small" :disabled="loading || saving" @click="setAllToMethod('mongo')" />
<Button label="تنظیم همه روی Typesense" icon="pi pi-bolt" severity="warn" outlined size="small" :disabled="loading || saving" @click="setAllToMethod('typesense')" />
</div>
<div class="flex items-center gap-2">
<Button icon="pi pi-refresh" severity="secondary" text rounded :loading="loading" v-tooltip.top="'بارگذاری مجدد'" @click="loadSettings" />
</div>
</div>
<!-- ── Settings Cards Grid ── -->
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div v-for="i in 6" :key="i" class="card p-6">
<Skeleton width="40%" height="1.5rem" class="mb-3" />
<Skeleton width="80%" height="1rem" class="mb-4" />
<Skeleton width="100%" height="2.5rem" />
</div>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div
v-for="item in settings"
:key="item.pageKey"
class="card p-6 rounded-2xl border transition-all duration-200 hover:shadow-md flex flex-col justify-between"
:class="[item.searchMethod === 'mongo' ? 'border-emerald-200 dark:border-emerald-900 bg-emerald-50/10' : 'border-amber-200 dark:border-amber-900 bg-amber-50/10']"
>
<div>
<!-- Card Top Header -->
<div class="flex items-start justify-between gap-3 mb-3">
<div class="flex items-center gap-3">
<div
class="w-10 h-10 rounded-xl flex items-center justify-center text-lg"
:class="[item.searchMethod === 'mongo' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300' : 'bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-300']"
>
<i :class="item.icon || 'pi pi-file'"></i>
</div>
<div>
<h3 class="text-lg font-bold m-0 text-surface-900 dark:text-surface-0">{{ item.title }}</h3>
<span class="text-xs text-surface-500 font-mono">{{ item.pageKey }}</span>
</div>
</div>
<!-- Persistence Badge -->
<Tag :value="item.isPersisted ? 'ذخیره در دیتابیس' : 'پیش‌فرض'" :severity="item.isPersisted ? 'success' : 'secondary'" class="text-xs" />
</div>
<!-- Description -->
<p class="text-sm text-surface-600 dark:text-surface-300 mb-4 leading-relaxed">
{{ item.description }}
</p>
<!-- Endpoints Information -->
<div class="p-3 mb-4 rounded-xl bg-surface-100 dark:bg-surface-800/80 text-xs space-y-1 font-mono">
<div class="flex items-center justify-between">
<span class="text-surface-500">اندپوینت فعال:</span>
<span class="font-bold" :class="item.searchMethod === 'mongo' ? 'text-emerald-600 dark:text-emerald-400' : 'text-amber-600 dark:text-amber-400'">
{{ item.searchMethod === 'mongo' ? item.endpointMongo : item.endpointTypesense }}
</span>
</div>
</div>
</div>
<!-- Bottom Selector & Actions -->
<div class="pt-4 border-t border-surface-200 dark:border-surface-700 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2">
<span class="text-xs font-semibold text-surface-600 dark:text-surface-400">موتور جستجو:</span>
<SelectButton :modelValue="item.searchMethod" :options="methodOptions" optionLabel="label" optionValue="value" :allowEmpty="false" size="small" @update:modelValue="(val) => updateMethod(item.pageKey, val)">
<template #option="slotProps">
<div class="flex items-center gap-1.5 px-1 py-0.5 text-xs font-bold">
<i :class="slotProps.option.icon"></i>
<span>{{ slotProps.option.label }}</span>
</div>
</template>
</SelectButton>
</div>
<Button v-if="item.routePath" label="مشاهده صفحه" icon="pi pi-arrow-left" iconPos="right" size="small" severity="secondary" text @click="navigateToModule(item.routePath)" />
</div>
</div>
</div>
<!-- ── Information Guide ── -->
<div class="card p-6 mt-8 rounded-2xl bg-surface-50 dark:bg-surface-800/50 border border-surface-200 dark:border-surface-700">
<h4 class="text-base font-bold mb-3 flex items-center gap-2 text-surface-800 dark:text-surface-100">
<i class="pi pi-info-circle text-primary-500"></i>
<span>راهنمای روش‌های جستجو</span>
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs leading-relaxed text-surface-600 dark:text-surface-300">
<div class="p-4 rounded-xl bg-surface-0 dark:bg-surface-900 border border-amber-200 dark:border-amber-900/50">
<div class="font-bold text-amber-700 dark:text-amber-400 flex items-center gap-2 mb-1">
<i class="pi pi-bolt"></i>
<span>موتور Typesense</span>
</div>
<p class="m-0">موتور جستجوی ایندکس‌شده با سرعت فوق‌العاده بالا، تحمل خطای تایپی (Typo Tolerance) و سیستم Faceting پیشرفته. نیازمند فرآیند ایندکس‌سازی داده‌ها است.</p>
</div>
<div class="p-4 rounded-xl bg-surface-0 dark:bg-surface-900 border border-emerald-200 dark:border-emerald-900/50">
<div class="font-bold text-emerald-700 dark:text-emerald-400 flex items-center gap-2 mb-1">
<i class="pi pi-database"></i>
<span>موتور MongoDB</span>
</div>
<p class="m-0">جستجوی زنده مستقیم روی پایگاه داده MongoDB با الگوهای عبارت منظم (Regex) و فیلترهای دقیق. بدون نیاز به ایندکس جداگانه و همیشه هماهنگ با آخرین تغییرات پایگاه داده.</p>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.search-settings-page {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
@@ -3,13 +3,29 @@ import { FilterMatchMode } from '@primevue/core/api';
import { useToast } from 'primevue/usetoast';
import { onMounted, ref, watch } from 'vue';
import { TransactionService } from '@/service/TransactionService';
import { SettingService } from '@/service/SettingService';
import { useRouter, useRoute } from 'vue-router';
import Chip from 'primevue/chip';
import Tag from 'primevue/tag';
import InputIcon from 'primevue/inputicon';
import IconField from 'primevue/iconfield';
import LoadingCheck from '@/components/LoadingCheck.vue';
import Popover from 'primevue/popover';
onMounted(() => {
const searchMethod = ref(null);
const loadSearchSetting = async () => {
try {
const setting = await SettingService.getSearchSettingByPage('transactions');
searchMethod.value = setting?.searchMethod || 'typesense';
} catch (e) {
console.warn('Failed to load search setting for transactions, defaulting to typesense', e);
searchMethod.value = 'typesense';
}
};
onMounted(async () => {
await loadSearchSetting();
loadData();
});
@@ -74,7 +90,7 @@ watch(
{ deep: true }
);
const handleRemoveSearch = () => {
removeFromQuery('searchPhrase'); // Ensure 'searchPhrase' is the correct key
removeFromQuery('query');
searchPhrase.value = '';
};
@@ -105,18 +121,29 @@ const onPageChange = (event) => {
}
};
const loadData = () => {
TransactionService.searchTransactions(route.query)
.then((response) => {
console.log('response', response);
transactions.value = response.transactions || response;
totalCount.value = response.totalCount || response.length || 0;
pagesCount.value = Math.ceil(totalCount.value / per_page.value);
})
.catch((error) => {
toast.add({ severity: 'error', summary: 'Error', detail: 'خطا در دریافت اطلاعات', life: 3000 });
console.error('Error loading data:', error);
});
const dataLoading = ref(true);
const loadData = async () => {
dataLoading.value = true;
try {
if (searchMethod.value === null) {
await loadSearchSetting();
}
let response;
if (searchMethod.value === 'mongo') {
response = await TransactionService.searchTransactionsMongo(route.query);
} else {
response = await TransactionService.searchTransactions(route.query);
}
transactions.value = response.transactions || response;
totalCount.value = response.totalCount || response.length || 0;
pagesCount.value = Math.ceil(totalCount.value / per_page.value);
} catch (error) {
toast.add({ severity: 'error', summary: 'Error', detail: 'خطا در دریافت اطلاعات', life: 3000 });
console.error('Error loading data:', error);
} finally {
dataLoading.value = false;
}
};
const loading = ref(false);
@@ -254,6 +281,7 @@ const toggle = (event, data) => {
ref="dt"
v-model:selection="selectedTransactions"
:value="transactions"
:loading="dataLoading"
dataKey="id"
:paginator="true"
:rows="per_page"
@@ -269,18 +297,42 @@ const toggle = (event, data) => {
filterDisplay="menu"
:globalFilterFields="['type', 'status', 'belongingName', 'createdAt', 'description']"
>
<template #loading>
<div class="flex flex-col items-center justify-center p-8 gap-3">
<ProgressSpinner style="width: 42px; height: 42px" strokeWidth="4" />
<span class="text-sm font-medium text-surface-500">در حال بارگذاری تراکنش‌ها...</span>
</div>
</template>
<template #empty>
<div v-if="!dataLoading" class="p-8 text-center text-surface-500">
<i class="pi pi-inbox text-4xl mb-2 text-surface-400"></i>
<p>هیچ تراکنشی یافت نشد.</p>
</div>
</template>
<template #header>
<div class="">
<h4 class="m-0">مدیریت تراکنش‌ها</h4>
<div class="p-input-icon-right w-1/3 mt-2 lg:mt-0">
<InputText v-model="searchPhrase" placeholder="جستجو..." class="w-full" />
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<h4 class="m-0 font-bold">مدیریت تراکنش‌ها</h4>
<Tag
v-if="searchMethod"
:value="searchMethod === 'mongo' ? 'جستجو: MongoDB' : 'جستجو: Typesense'"
:severity="searchMethod === 'mongo' ? 'success' : 'info'"
:icon="searchMethod === 'mongo' ? 'pi pi-database' : 'pi pi-bolt'"
class="cursor-pointer text-xs"
v-tooltip.bottom="'کلیک برای تغییر موتور جستجو در تنظیمات'"
@click="router.push('/settings/search-settings')"
/>
</div>
<div class="p-input-icon-right w-full sm:w-72 mt-2 lg:mt-0">
<InputText v-model="searchPhrase" placeholder="جستجو در تراکنش‌ها..." class="w-full" />
</div>
</div>
<Chip v-if="searchPhrase" class="mx-2 mt-2" :label="searchPhrase" removable @remove="handleRemoveSearch" />
</template>
<Column field="_id" header="شناسه" sortable style="width: 10%"></Column>
<Column field="belongingName" header="دارایی" sortable style="width: 15%"></Column>
<Column field="belongingName" header="ملک" sortable style="width: 15%"></Column>
<Column field="type" header="نوع تراکنش" sortable style="width: 15%">
<template #body="{ data }">
+625
View File
@@ -0,0 +1,625 @@
<script setup>
import { onMounted, ref, computed } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import ConfirmPopup from 'primevue/confirmpopup';
import { UserService } from '@/service/UserService';
import { RoleService } from '@/service/RoleService';
const toast = useToast();
const confirm = useConfirm();
const users = ref([]);
const roles = ref([]);
const loading = ref(true);
const showDialog = ref(false);
const editMode = ref(false);
const submitting = ref(false);
const selectedUser = ref(null);
const searchPhrase = ref('');
const form = ref({
name: '',
email: '',
password: '',
role: null,
isAdmin: false,
isActive: true
});
onMounted(async () => {
await Promise.all([loadUsers(), loadRoles()]);
});
const loadUsers = async () => {
loading.value = true;
try {
users.value = await UserService.listUsers();
} catch (e) {
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در بارگذاری لیست کاربران', life: 3000 });
} finally {
loading.value = false;
}
};
const loadRoles = async () => {
try {
roles.value = await RoleService.listRoles();
} catch (e) {
console.error('Error loading roles:', e);
}
};
const roleOptions = computed(() => {
return roles.value.map((r) => ({
label: `${r.nativeName} (${r.englishName})`,
value: r._id
}));
});
const filteredUsers = computed(() => {
if (!searchPhrase.value) return users.value;
const q = searchPhrase.value.toLowerCase().trim();
return users.value.filter((u) => {
const name = (u.name || '').toLowerCase();
const email = (u.email || '').toLowerCase();
const roleNative = (u.role?.nativeName || '').toLowerCase();
const roleEn = (u.role?.englishName || '').toLowerCase();
return name.includes(q) || email.includes(q) || roleNative.includes(q) || roleEn.includes(q);
});
});
const stats = computed(() => {
const total = users.value.length;
const active = users.value.filter((u) => u.isActive !== false).length;
const admins = users.value.filter((u) => u.isAdmin || u.role?.englishName === 'SuperAdmin').length;
return { total, active, admins };
});
const openCreate = () => {
editMode.value = false;
selectedUser.value = null;
form.value = {
name: '',
email: '',
password: '',
role: roles.value.length > 0 ? roles.value[0]._id : null,
isAdmin: false,
isActive: true
};
showDialog.value = true;
};
const openEdit = (u) => {
editMode.value = true;
selectedUser.value = u;
form.value = {
name: u.name || '',
email: u.email || '',
password: '',
role: u.role?._id || u.role,
isAdmin: u.isAdmin || false,
isActive: u.isActive !== false
};
showDialog.value = true;
};
const submitForm = async () => {
if (!form.value.email) {
toast.add({ severity: 'warn', summary: 'توجه', detail: 'آدرس ایمیل الزامی است', life: 3000 });
return;
}
if (!editMode.value && !form.value.password) {
toast.add({ severity: 'warn', summary: 'توجه', detail: 'رمز عبور الزامی است', life: 3000 });
return;
}
if (!form.value.role) {
toast.add({ severity: 'warn', summary: 'توجه', detail: 'انتخاب نقش کاربری الزامی است', life: 3000 });
return;
}
submitting.value = true;
try {
const payload = {
name: form.value.name,
email: form.value.email,
role: form.value.role,
isAdmin: form.value.isAdmin,
isActive: form.value.isActive
};
if (form.value.password && form.value.password.trim().length > 0) {
payload.password = form.value.password;
}
if (editMode.value) {
await UserService.updateUser(selectedUser.value._id, payload);
toast.add({ severity: 'success', summary: 'موفق', detail: 'کاربر با موفقیت ویرایش شد', life: 3000 });
} else {
await UserService.createUser(payload);
toast.add({ severity: 'success', summary: 'موفق', detail: 'کاربر جدید با موفقیت ایجاد شد', life: 3000 });
}
showDialog.value = false;
await loadUsers();
} catch (e) {
toast.add({
severity: 'error',
summary: 'خطا',
detail: e.response?.data?.message || 'عملیات ناموفق بود',
life: 4000
});
} finally {
submitting.value = false;
}
};
const confirmDelete = (u, event) => {
confirm.require({
target: event.currentTarget,
message: `آیا از غیرفعال‌سازی کاربر "${u.name || u.email}" اطمینان دارید؟`,
icon: 'pi pi-exclamation-triangle',
rejectProps: { label: 'خیر', severity: 'secondary', outlined: true },
acceptProps: { label: 'بله', severity: 'danger' },
accept: async () => {
try {
await UserService.deleteUser(u._id);
toast.add({ severity: 'success', summary: 'موفق', detail: 'کاربر غیرفعال شد', life: 3000 });
await loadUsers();
} catch (e) {
toast.add({ severity: 'error', summary: 'خطا', detail: 'عملیات ناموفق بود', life: 3000 });
}
}
});
};
const formatDate = (dateStr) => {
if (!dateStr) return '—';
try {
return new Date(dateStr).toLocaleDateString('fa-IR', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
} catch {
return dateStr;
}
};
const getInitials = (name, email) => {
if (name && name.trim().length > 0) {
return name.trim().charAt(0).toUpperCase();
}
if (email) {
return email.charAt(0).toUpperCase();
}
return 'U';
};
</script>
<template>
<ConfirmPopup />
<div class="users-page">
<!-- Header -->
<div class="page-header">
<div class="header-content">
<div class="header-icon">
<i class="pi pi-users"></i>
</div>
<div>
<h1 class="page-title">مدیریت کاربران</h1>
<p class="page-subtitle">تعریف حساب‌های کاربری، تعیین نقش‌ها و رمز عبور</p>
</div>
</div>
<Button label="افزودن کاربر جدید" icon="pi pi-user-plus" class="create-btn" @click="openCreate" />
</div>
<!-- Quick Stats -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon stat-icon-blue">
<i class="pi pi-users"></i>
</div>
<div class="stat-data">
<span class="stat-value">{{ stats.total }}</span>
<span class="stat-label">کل کاربران</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon stat-icon-green">
<i class="pi pi-check-circle"></i>
</div>
<div class="stat-data">
<span class="stat-value">{{ stats.active }}</span>
<span class="stat-label">کاربران فعال</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon stat-icon-purple">
<i class="pi pi-shield"></i>
</div>
<div class="stat-data">
<span class="stat-value">{{ stats.admins }}</span>
<span class="stat-label">مدیران سیستم</span>
</div>
</div>
</div>
<!-- Table Card -->
<div class="card main-card">
<DataTable :value="filteredUsers" :loading="loading" dataKey="_id" stripedRows responsiveLayout="scroll" paginator :rows="10" :rowsPerPageOptions="[10, 20, 50]">
<template #header>
<div class="table-header">
<span class="table-title">لیست کاربران سیستم ({{ filteredUsers.length }})</span>
<IconField>
<InputIcon><i class="pi pi-search" /></InputIcon>
<InputText v-model="searchPhrase" placeholder="جستجوی کاربر، ایمیل، نقش..." class="search-input" />
</IconField>
</div>
</template>
<template #loading>
<div class="flex flex-col items-center justify-center p-8 gap-3">
<ProgressSpinner style="width: 42px; height: 42px" strokeWidth="4" />
<span class="text-sm font-medium text-surface-500">در حال بارگذاری کاربران...</span>
</div>
</template>
<template #empty>
<div v-if="!loading" class="empty-state">
<i class="pi pi-user-minus empty-icon"></i>
<p>هیچ کاربری یافت نشد</p>
<Button label="افزودن اولین کاربر" icon="pi pi-plus" @click="openCreate" />
</div>
</template>
<!-- User Column -->
<Column header="کاربر" style="min-width: 200px">
<template #body="{ data }">
<div class="user-cell">
<div class="user-avatar">
{{ getInitials(data.name, data.email) }}
</div>
<div class="user-info">
<span class="user-name">{{ data.name || 'بدون نام' }}</span>
<span class="user-email">{{ data.email }}</span>
</div>
</div>
</template>
</Column>
<!-- Role Column -->
<Column header="نقش کاربری" style="min-width: 150px">
<template #body="{ data }">
<span v-if="data.role" class="role-badge" :class="{ 'role-super': data.role?.englishName === 'SuperAdmin' }">
<i class="pi pi-shield ml-1"></i>
{{ data.role?.nativeName || data.role?.englishName }}
</span>
<span v-else class="text-muted">—</span>
</template>
</Column>
<!-- Admin Column -->
<Column header="دسترسی ادمین" style="width: 120px; text-align: center">
<template #body="{ data }">
<Tag v-if="data.isAdmin || data.role?.englishName === 'SuperAdmin'" severity="warn" value="مدیر" icon="pi pi-star-fill" />
<span v-else class="text-muted">عادی</span>
</template>
</Column>
<!-- Status Column -->
<Column header="وضعیت" style="width: 100px">
<template #body="{ data }">
<Tag :severity="data.isActive !== false ? 'success' : 'danger'" :value="data.isActive !== false ? 'فعال' : 'غیرفعال'" />
</template>
</Column>
<!-- Created Date -->
<Column header="تاریخ عضویت" style="width: 140px">
<template #body="{ data }">
<span class="date-text">{{ formatDate(data.createdAt) }}</span>
</template>
</Column>
<!-- Actions -->
<Column header="عملیات" style="width: 110px">
<template #body="{ data }">
<div class="action-buttons">
<Button icon="pi pi-pencil" outlined rounded size="small" v-tooltip.top="'ویرایش'" @click="openEdit(data)" />
<Button icon="pi pi-ban" outlined rounded severity="danger" size="small" v-tooltip.top="'غیرفعال‌سازی'" @click="confirmDelete(data, $event)" />
</div>
</template>
</Column>
</DataTable>
</div>
<!-- Create / Edit Dialog -->
<Dialog v-model:visible="showDialog" :header="editMode ? 'ویرایش کاربر' : 'افزودن کاربر جدید'" :style="{ width: '520px' }" modal dir="rtl">
<div class="form-grid">
<div class="form-field">
<label>نام و نام خانوادگی</label>
<InputText v-model="form.name" placeholder="مثال: علی رضایی" class="w-full" />
</div>
<div class="form-field">
<label>آدرس ایمیل <span class="required">*</span></label>
<InputText v-model="form.email" type="email" placeholder="example@domain.com" class="w-full" dir="ltr" />
</div>
<div class="form-field">
<label>
رمز عبور
<span v-if="!editMode" class="required">*</span>
<span v-else class="text-xs text-muted-color">(در صورت تمایل به تغییر وارد کنید)</span>
</label>
<Password v-model="form.password" :placeholder="editMode ? 'رمز جدید (اختیاری)' : 'حداقل ۸ کاراکتر'" :toggleMask="true" class="w-full" fluid :feedback="false" dir="ltr" />
</div>
<div class="form-field">
<label>نقش کاربری <span class="required">*</span></label>
<Dropdown v-model="form.role" :options="roleOptions" optionLabel="label" optionValue="value" placeholder="انتخاب نقش" class="w-full" />
</div>
<div class="checkbox-row">
<div class="checkbox-field">
<Checkbox v-model="form.isAdmin" :binary="true" inputId="isAdminCheck" />
<label for="isAdminCheck" class="cursor-pointer">دسترسی مدیر کل (Admin)</label>
</div>
<div class="checkbox-field">
<Checkbox v-model="form.isActive" :binary="true" inputId="isActiveCheck" />
<label for="isActiveCheck" class="cursor-pointer">حساب کاربری فعال است</label>
</div>
</div>
</div>
<template #footer>
<Button label="انصراف" icon="pi pi-times" text @click="showDialog = false" />
<Button :label="editMode ? 'ذخیره تغییرات' : 'ایجاد کاربر'" icon="pi pi-check" :loading="submitting" @click="submitForm" />
</template>
</Dialog>
</div>
</template>
<style scoped>
.users-page {
padding: 1.5rem;
direction: rtl;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.header-content {
display: flex;
align-items: center;
gap: 1rem;
}
.header-icon {
width: 52px;
height: 52px;
border-radius: 14px;
background: linear-gradient(135deg, #3b82f6, #6366f1);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: #fff;
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.35);
}
.page-title {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
}
.page-subtitle {
margin: 0;
color: var(--text-color-secondary);
font-size: 0.875rem;
}
.create-btn {
background: linear-gradient(135deg, #3b82f6, #6366f1) !important;
border: none !important;
padding: 0.65rem 1.5rem !important;
font-weight: 600 !important;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: 14px;
padding: 1rem 1.25rem;
display: flex;
align-items: center;
gap: 1rem;
}
.stat-icon {
width: 44px;
height: 44px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
}
.stat-icon-blue {
background: rgba(59, 130, 246, 0.12);
color: #3b82f6;
}
.stat-icon-green {
background: rgba(34, 197, 94, 0.12);
color: #22c55e;
}
.stat-icon-purple {
background: rgba(168, 85, 247, 0.12);
color: #a855f7;
}
.stat-data {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 1.4rem;
font-weight: 700;
}
.stat-label {
font-size: 0.8rem;
color: var(--text-color-secondary);
}
.main-card {
border-radius: 16px;
}
.table-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0;
}
.table-title {
font-size: 1rem;
font-weight: 600;
}
.search-input {
width: 280px;
}
.empty-state {
text-align: center;
padding: 3rem;
color: var(--text-color-secondary);
}
.empty-icon {
font-size: 3rem;
display: block;
margin-bottom: 1rem;
opacity: 0.35;
}
.user-cell {
display: flex;
align-items: center;
gap: 0.75rem;
}
.user-avatar {
width: 38px;
height: 38px;
border-radius: 50%;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.95rem;
flex-shrink: 0;
}
.user-info {
display: flex;
flex-direction: column;
}
.user-name {
font-weight: 600;
font-size: 0.95rem;
}
.user-email {
font-size: 0.8rem;
color: var(--text-color-secondary);
direction: ltr;
text-align: right;
}
.role-badge {
display: inline-flex;
align-items: center;
background: var(--surface-100);
color: var(--text-color);
padding: 3px 10px;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 600;
}
.role-super {
background: rgba(234, 179, 8, 0.15);
color: #ca8a04;
}
.date-text {
font-size: 0.85rem;
color: var(--text-color-secondary);
}
.text-muted {
opacity: 0.4;
}
.action-buttons {
display: flex;
gap: 0.4rem;
}
.form-grid {
display: flex;
flex-direction: column;
gap: 1.1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.form-field label {
font-weight: 600;
font-size: 0.9rem;
}
.required {
color: #ef4444;
}
.checkbox-row {
display: flex;
gap: 1.5rem;
margin-top: 0.5rem;
}
.checkbox-field {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
</style>
@@ -0,0 +1,686 @@
<script setup>
import { onMounted, ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { WarehouseItemService } from '@/service/WarehouseItemService';
import { WarehouseService } from '@/service/WarehouseService';
const router = useRouter();
const toast = useToast();
const items = ref([]);
const warehouses = ref([]);
const submitting = ref(false);
const submitted = ref(false);
const form = ref({
itemId: '',
warehouseId: '',
type: 'IN',
quantity: null,
reason: '',
note: '',
date: ''
});
// Stock preview for the selected item+warehouse combo
const stockPreview = ref(null);
const stockLoading = ref(false);
const IN_REASONS = ['خرید از تامین‌کننده', 'برگشت از کارگاه', 'انتقال از انبار دیگر', 'اهدا / دریافت رایگان', 'موجودی اولیه', 'سایر'];
const OUT_REASONS = ['مصرف در پروژه', 'انتقال به انبار دیگر', 'فروش / واگذاری', 'ضایعات', 'مفقودی', 'سایر'];
const reasons = computed(() => (form.value.type === 'IN' ? IN_REASONS : OUT_REASONS));
const selectedItem = computed(() => items.value.find((i) => i._id === form.value.itemId));
onMounted(async () => {
await Promise.all([loadItems(), loadWarehouses()]);
});
const loadItems = async () => {
try {
const res = await WarehouseItemService.listItems({ limit: 500, isActive: 'true' });
items.value = res.items || [];
} catch (e) {
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در بارگذاری اقلام', life: 3000 });
}
};
const loadWarehouses = async () => {
try {
warehouses.value = await WarehouseService.listWarehouses({ isActive: 'true' });
} catch (e) {
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در بارگذاری انبارها', life: 3000 });
}
};
// Load current stock when both item and warehouse are selected
const onItemOrWarehouseChange = async () => {
if (!form.value.itemId || !form.value.warehouseId) {
stockPreview.value = null;
return;
}
stockLoading.value = true;
try {
const data = await WarehouseItemService.getStockByItem(form.value.itemId);
const warehouseStock = data.stocks.find((s) => s.warehouse?._id === form.value.warehouseId);
stockPreview.value = {
quantity: warehouseStock ? warehouseStock.quantity : 0,
unit: data.unit,
unitEn: data.unitEn
};
} catch (e) {
stockPreview.value = null;
} finally {
stockLoading.value = false;
}
};
const onTypeChange = () => {
form.value.reason = '';
};
const isValid = computed(() => {
return form.value.itemId && form.value.warehouseId && form.value.quantity > 0 && form.value.reason;
});
const submitTransaction = async () => {
submitted.value = true;
if (!isValid.value) {
toast.add({ severity: 'warn', summary: 'توجه', detail: 'لطفاً تمام فیلدهای ضروری را پر کنید', life: 3000 });
return;
}
submitting.value = true;
try {
await WarehouseItemService.createTransaction({
itemId: form.value.itemId,
warehouseId: form.value.warehouseId,
type: form.value.type,
quantity: form.value.quantity,
reason: form.value.reason,
note: form.value.note,
date: form.value.date || undefined
});
toast.add({
severity: 'success',
summary: 'موفق',
detail: form.value.type === 'IN' ? 'موجودی با موفقیت به انبار افزوده شد' : 'موجودی با موفقیت از انبار کسر شد',
life: 4000
});
// Reset form
form.value = { itemId: '', warehouseId: '', type: 'IN', quantity: null, reason: '', note: '', date: '' };
submitted.value = false;
stockPreview.value = null;
} catch (e) {
toast.add({
severity: 'error',
summary: 'خطا',
detail: e.response?.data?.message || 'عملیات ناموفق بود',
life: 5000
});
} finally {
submitting.value = false;
}
};
const goToStockView = () => {
if (form.value.itemId) {
router.push(`/warehouse-items/stock/${form.value.itemId}`);
}
};
</script>
<template>
<div class="create-transaction-page">
<!-- Header -->
<div class="page-header">
<div class="header-left">
<div class="header-icon" :class="form.type === 'IN' ? 'icon-in' : 'icon-out'">
<i :class="form.type === 'IN' ? 'pi pi-arrow-circle-down' : 'pi pi-arrow-circle-up'"></i>
</div>
<div>
<h1 class="page-title">ثبت تراکنش انبار</h1>
<p class="page-subtitle">ورود یا خروج اقلام از انبار</p>
</div>
</div>
<div class="header-actions">
<Button icon="pi pi-history" label="تاریخچه تراکنش‌ها" severity="secondary" outlined @click="router.push('/warehouse-items/transactions')" />
<Button v-if="form.itemId" icon="pi pi-chart-bar" label="مشاهده موجودی قلم" severity="info" outlined @click="goToStockView" />
</div>
</div>
<div class="form-layout">
<!-- Main form card -->
<div class="card form-card">
<!-- Type selector -->
<div class="type-selector">
<button
id="type-in"
class="type-card"
:class="{ active: form.type === 'IN' }"
type="button"
@click="
form.type = 'IN';
onTypeChange();
"
>
<div class="type-icon in-icon"><i class="pi pi-arrow-down"></i></div>
<div class="type-text">
<span class="type-label">ورود به انبار</span>
<span class="type-desc">افزودن موجودی</span>
</div>
</button>
<button
id="type-out"
class="type-card"
:class="{ active: form.type === 'OUT' }"
type="button"
@click="
form.type = 'OUT';
onTypeChange();
"
>
<div class="type-icon out-icon"><i class="pi pi-arrow-up"></i></div>
<div class="type-text">
<span class="type-label">خروج از انبار</span>
<span class="type-desc">کسر موجودی</span>
</div>
</button>
</div>
<Divider />
<!-- Item & Warehouse row -->
<div class="fields-grid">
<div class="form-field" :class="{ 'field-error': submitted && !form.itemId }">
<label for="item-select">قلم انبار <span class="required">*</span></label>
<Select id="item-select" v-model="form.itemId" :options="items" optionLabel="nameFa" optionValue="_id" placeholder="انتخاب قلم..." class="w-full" filter filterPlaceholder="جستجو..." @change="onItemOrWarehouseChange">
<template #option="{ option }">
<div class="item-option">
<span class="option-fa">{{ option.nameFa }}</span>
<span class="option-en">{{ option.nameEn }}</span>
<span class="option-unit">{{ option.unitFa }}</span>
</div>
</template>
</Select>
<small class="error-msg" v-if="submitted && !form.itemId">قلم انبار ضروری است</small>
</div>
<div class="form-field" :class="{ 'field-error': submitted && !form.warehouseId }">
<label for="warehouse-select">انبار <span class="required">*</span></label>
<Select id="warehouse-select" v-model="form.warehouseId" :options="warehouses" optionLabel="name" optionValue="_id" placeholder="انتخاب انبار..." class="w-full" @change="onItemOrWarehouseChange">
<template #option="{ option }">
<div class="warehouse-option">
<span>{{ option.name }}</span>
<span v-if="option.location?.address" class="option-addr">{{ option.location.address }}</span>
</div>
</template>
</Select>
<small class="error-msg" v-if="submitted && !form.warehouseId">انبار ضروری است</small>
</div>
</div>
<!-- Stock preview banner -->
<Transition name="fade">
<div v-if="stockPreview !== null" class="stock-preview" :class="{ 'stock-zero': stockPreview.quantity <= 0 }">
<i class="pi pi-database"></i>
<span>
موجودی فعلی در این انبار:
<strong>{{ stockPreview.quantity.toLocaleString('fa-IR') }} {{ stockPreview.unit }}</strong>
<span class="unit-en-small">({{ stockPreview.unitEn }})</span>
</span>
<span v-if="stockPreview.quantity <= 0 && form.type === 'OUT'" class="stock-alert"> <i class="pi pi-exclamation-triangle"></i> موجودی صفر است </span>
<ProgressSpinner v-if="stockLoading" style="width: 20px; height: 20px" strokeWidth="5" />
</div>
</Transition>
<!-- Quantity & Date row -->
<div class="fields-grid">
<div class="form-field" :class="{ 'field-error': submitted && !form.quantity }">
<label for="quantity-input">
مقدار
<span v-if="selectedItem" class="unit-hint">({{ selectedItem.unitFa }} / {{ selectedItem.unitEn }})</span>
<span class="required">*</span>
</label>
<InputNumber
id="quantity-input"
v-model="form.quantity"
:min="0.001"
:minFractionDigits="0"
:maxFractionDigits="3"
class="w-full"
placeholder="مقدار را وارد کنید"
:pt="{ input: { dir: 'ltr', style: 'font-size:1.15rem; font-weight:600;' } }"
/>
<small class="error-msg" v-if="submitted && !form.quantity">مقدار ضروری است</small>
</div>
<div class="form-field">
<label for="date-input">تاریخ تراکنش <span class="optional">(اختیاری — پیش‌فرض: اکنون)</span></label>
<InputText id="date-input" v-model="form.date" type="datetime-local" class="w-full" dir="ltr" />
</div>
</div>
<!-- Reason -->
<div class="form-field" :class="{ 'field-error': submitted && !form.reason }">
<label>دلیل تراکنش <span class="required">*</span></label>
<div class="reason-chips">
<button v-for="r in reasons" :key="r" class="reason-chip" :class="{ active: form.reason === r }" type="button" @click="form.reason = r">{{ r }}</button>
</div>
<InputText v-model="form.reason" placeholder="یا دلیل سفارشی وارد کنید..." class="w-full mt-2" :class="{ 'p-invalid': submitted && !form.reason }" />
<small class="error-msg" v-if="submitted && !form.reason">دلیل تراکنش ضروری است</small>
</div>
<!-- Note -->
<div class="form-field">
<label for="note-input">یادداشت <span class="optional">(اختیاری)</span></label>
<Textarea id="note-input" v-model="form.note" rows="2" class="w-full" autoResize placeholder="توضیحات تکمیلی..." />
</div>
<!-- Submit -->
<div class="submit-row">
<Button
label="انصراف و پاک‌کردن فرم"
icon="pi pi-times"
severity="secondary"
outlined
@click="
() => {
form = { itemId: '', warehouseId: '', type: 'IN', quantity: null, reason: '', note: '', date: '' };
submitted = false;
stockPreview = null;
}
"
/>
<Button
:label="form.type === 'IN' ? 'ثبت ورود به انبار' : 'ثبت خروج از انبار'"
:icon="form.type === 'IN' ? 'pi pi-plus-circle' : 'pi pi-minus-circle'"
:severity="form.type === 'IN' ? 'success' : 'danger'"
:loading="submitting"
size="large"
class="submit-btn"
@click="submitTransaction"
/>
</div>
</div>
<!-- Side info card -->
<div class="side-panel">
<div class="card info-card">
<h4 class="info-title"><i class="pi pi-info-circle"></i> راهنما</h4>
<ul class="info-list">
<li>
<i class="pi pi-arrow-down in-dot"></i>
<span><strong>ورود:</strong> کالایی وارد انبار می‌شود (خرید، برگشت کالا، انتقال از جای دیگر)</span>
</li>
<li>
<i class="pi pi-arrow-up out-dot"></i>
<span><strong>خروج:</strong> کالایی از انبار برداشت می‌شود (مصرف، فروش، ضایعات)</span>
</li>
<li>
<i class="pi pi-lock"></i>
<span>اگر موجودی کافی نباشد، تراکنش خروج ثبت نمی‌شود</span>
</li>
<li>
<i class="pi pi-user"></i>
<span>کاربر انجام‌دهنده به صورت خودکار ثبت می‌شود</span>
</li>
</ul>
</div>
<div class="card quick-links-card" v-if="form.itemId">
<h4 class="info-title"><i class="pi pi-link"></i> دسترسی سریع</h4>
<Button icon="pi pi-chart-bar" label="موجودی این قلم در همه انبارها" class="w-full mb-2" severity="info" outlined @click="goToStockView" />
<Button icon="pi pi-box" label="لیست همه اقلام" class="w-full" severity="secondary" outlined @click="router.push('/warehouse-items/list')" />
</div>
</div>
</div>
</div>
</template>
<style scoped>
.create-transaction-page {
padding: 1.5rem;
direction: rtl;
}
/* Header */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.header-icon {
width: 56px;
height: 56px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.6rem;
color: #fff;
transition: all 0.3s;
}
.icon-in {
background: linear-gradient(135deg, #22c55e, #16a34a);
box-shadow: 0 4px 15px rgba(34, 197, 94, 0.35);
}
.icon-out {
background: linear-gradient(135deg, #ef4444, #dc2626);
box-shadow: 0 4px 15px rgba(239, 68, 68, 0.35);
}
.page-title {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
}
.page-subtitle {
margin: 0;
font-size: 0.875rem;
color: var(--text-color-secondary);
}
.header-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
/* Layout */
.form-layout {
display: grid;
grid-template-columns: 1fr 300px;
gap: 1.5rem;
align-items: start;
}
@media (max-width: 900px) {
.form-layout {
grid-template-columns: 1fr;
}
}
/* Form card */
.form-card {
border-radius: 16px;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* Type selector */
.type-selector {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.type-card {
border: 2px solid var(--surface-border);
background: var(--surface-50);
border-radius: 14px;
padding: 1.25rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 1rem;
transition: all 0.25s;
text-align: right;
color: var(--text-color);
}
.type-card:hover {
border-color: var(--primary-color);
transform: translateY(-1px);
}
.type-card.active {
transform: translateY(-2px);
box-shadow: 0 6px 20px -5px currentColor;
}
.type-card:nth-child(1).active {
border-color: #22c55e;
background: rgba(34, 197, 94, 0.07);
box-shadow: 0 6px 20px -5px rgba(34, 197, 94, 0.4);
}
.type-card:nth-child(2).active {
border-color: #ef4444;
background: rgba(239, 68, 68, 0.07);
box-shadow: 0 6px 20px -5px rgba(239, 68, 68, 0.4);
}
.type-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.3rem;
color: #fff;
flex-shrink: 0;
}
.in-icon {
background: linear-gradient(135deg, #22c55e, #16a34a);
}
.out-icon {
background: linear-gradient(135deg, #ef4444, #dc2626);
}
.type-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.type-label {
font-weight: 700;
font-size: 1rem;
}
.type-desc {
font-size: 0.8rem;
color: var(--text-color-secondary);
}
/* Fields */
.fields-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 600px) {
.fields-grid {
grid-template-columns: 1fr;
}
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.form-field label {
font-weight: 600;
font-size: 0.9rem;
}
.required {
color: #ef4444;
}
.optional {
font-size: 0.75rem;
color: var(--text-color-secondary);
font-weight: 400;
}
.unit-hint {
font-size: 0.8rem;
color: #6366f1;
font-weight: 600;
margin-right: 0.25rem;
}
.error-msg {
color: #ef4444;
font-size: 0.8rem;
}
.field-error label {
color: #ef4444;
}
/* Stock preview */
.stock-preview {
display: flex;
align-items: center;
gap: 0.75rem;
background: rgba(99, 102, 241, 0.08);
border: 1px solid rgba(99, 102, 241, 0.25);
border-radius: 10px;
padding: 0.75rem 1rem;
font-size: 0.9rem;
}
.stock-preview.stock-zero {
background: rgba(239, 68, 68, 0.08);
border-color: rgba(239, 68, 68, 0.3);
color: #dc2626;
}
.stock-alert {
margin-right: auto;
font-size: 0.8rem;
font-weight: 600;
color: #ef4444;
}
.unit-en-small {
font-size: 0.8rem;
color: var(--text-color-secondary);
margin-right: 0.25rem;
}
/* Reason chips */
.reason-chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.reason-chip {
border: 1.5px solid var(--surface-border);
background: var(--surface-50);
border-radius: 20px;
padding: 5px 14px;
font-size: 0.82rem;
cursor: pointer;
transition: all 0.2s;
color: var(--text-color);
}
.reason-chip:hover {
border-color: var(--primary-color);
color: var(--primary-color);
}
.reason-chip.active {
background: rgba(99, 102, 241, 0.1);
border-color: #6366f1;
color: #6366f1;
font-weight: 600;
}
/* Submit row */
.submit-row {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 0.5rem;
gap: 1rem;
}
.submit-btn {
flex: 0 0 auto;
}
/* Dropdown option templates */
.item-option {
display: flex;
align-items: center;
gap: 0.5rem;
}
.option-fa {
font-weight: 600;
}
.option-en {
font-size: 0.8rem;
color: var(--text-color-secondary);
direction: ltr;
}
.option-unit {
margin-right: auto;
font-size: 0.75rem;
background: rgba(99, 102, 241, 0.1);
color: #6366f1;
padding: 2px 8px;
border-radius: 10px;
}
.warehouse-option {
display: flex;
flex-direction: column;
}
.option-addr {
font-size: 0.75rem;
color: var(--text-color-secondary);
}
/* Side panel */
.side-panel {
display: flex;
flex-direction: column;
gap: 1rem;
}
.info-card,
.quick-links-card {
border-radius: 14px;
}
.info-title {
margin: 0 0 1rem 0;
font-size: 0.95rem;
font-weight: 700;
display: flex;
align-items: center;
gap: 0.5rem;
}
.info-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.info-list li {
display: flex;
align-items: flex-start;
gap: 0.6rem;
font-size: 0.85rem;
line-height: 1.5;
}
.in-dot {
color: #22c55e;
margin-top: 2px;
}
.out-dot {
color: #ef4444;
margin-top: 2px;
}
/* Transition */
.fade-enter-active,
.fade-leave-active {
transition: all 0.25s ease;
}
.fade-enter-from {
opacity: 0;
transform: translateY(-6px);
}
.fade-leave-to {
opacity: 0;
transform: translateY(-6px);
}
</style>
@@ -0,0 +1,427 @@
<script setup>
import { onMounted, ref, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { WarehouseItemService } from '@/service/WarehouseItemService';
import { WarehouseService } from '@/service/WarehouseService';
const router = useRouter();
const route = useRoute();
const toast = useToast();
const transactions = ref([]);
const totalCount = ref(0);
const loading = ref(true);
const page = ref(1);
const perPage = ref(30);
// Filters
const filters = ref({
itemId: '',
warehouseId: '',
type: '',
dateFrom: '',
dateTo: ''
});
const items = ref([]);
const warehouses = ref([]);
const typeOptions = [
{ label: 'ورود', value: 'IN' },
{ label: 'خروج', value: 'OUT' }
];
onMounted(async () => {
await Promise.all([loadLookups(), loadData()]);
});
const loadLookups = async () => {
try {
const [wi, wh] = await Promise.all([WarehouseItemService.listItems({ limit: 200 }), WarehouseService.listWarehouses()]);
items.value = wi.items || [];
warehouses.value = wh;
} catch (e) {
console.error(e);
}
};
const loadData = async () => {
loading.value = true;
try {
const params = {
page: page.value,
limit: perPage.value
};
if (filters.value.itemId) params.itemId = filters.value.itemId;
if (filters.value.warehouseId) params.warehouseId = filters.value.warehouseId;
if (filters.value.type) params.type = filters.value.type;
if (filters.value.dateFrom) params.dateFrom = filters.value.dateFrom;
if (filters.value.dateTo) params.dateTo = filters.value.dateTo;
const result = await WarehouseItemService.listTransactions(params);
transactions.value = result.transactions || [];
totalCount.value = result.total || 0;
} catch (e) {
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در بارگذاری تراکنش‌ها', life: 3000 });
} finally {
loading.value = false;
}
};
const applyFilters = () => {
page.value = 1;
loadData();
};
const clearFilters = () => {
filters.value = { itemId: '', warehouseId: '', type: '', dateFrom: '', dateTo: '' };
page.value = 1;
loadData();
};
const onPageChange = (event) => {
page.value = event.page + 1;
perPage.value = event.rows;
loadData();
};
const formatDate = (d) => {
if (!d) return '—';
return new Intl.DateTimeFormat('fa-IR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(d));
};
</script>
<template>
<div class="transactions-page">
<!-- Header -->
<div class="page-header">
<div class="header-content">
<div class="header-icon">
<i class="pi pi-history"></i>
</div>
<div>
<h1 class="page-title">تراکنش‌های انبار</h1>
<p class="page-subtitle">تاریخچه کامل ورود و خروج اقلام</p>
</div>
</div>
</div>
<!-- Filters -->
<div class="card filters-card">
<div class="filters-header">
<span class="filters-title"><i class="pi pi-filter"></i> فیلترها</span>
<Button label="پاک‌کردن فیلترها" icon="pi pi-times" text size="small" @click="clearFilters" />
</div>
<div class="filters-grid">
<div class="filter-field">
<label>قلم انبار</label>
<Select v-model="filters.itemId" :options="items" optionLabel="nameFa" optionValue="_id" placeholder="همه اقلام" showClear class="w-full" />
</div>
<div class="filter-field">
<label>انبار</label>
<Select v-model="filters.warehouseId" :options="warehouses" optionLabel="name" optionValue="_id" placeholder="همه انبارها" showClear class="w-full" />
</div>
<div class="filter-field">
<label>نوع تراکنش</label>
<Select v-model="filters.type" :options="typeOptions" optionLabel="label" optionValue="value" placeholder="همه" showClear class="w-full" />
</div>
<div class="filter-field">
<label>از تاریخ</label>
<InputText v-model="filters.dateFrom" type="date" class="w-full" dir="ltr" />
</div>
<div class="filter-field">
<label>تا تاریخ</label>
<InputText v-model="filters.dateTo" type="date" class="w-full" dir="ltr" />
</div>
<div class="filter-field filter-action">
<Button label="اعمال فیلتر" icon="pi pi-search" class="apply-btn" @click="applyFilters" />
</div>
</div>
</div>
<!-- Table -->
<div class="card main-card">
<DataTable
:value="transactions"
:loading="loading"
dataKey="_id"
:paginator="true"
:rows="perPage"
:total-records="totalCount"
lazy
@page="onPageChange($event)"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
:rowsPerPageOptions="[20, 30, 50]"
currentPageReportTemplate="نمایش {first} تا {last} از {totalRecords} تراکنش"
stripedRows
responsiveLayout="scroll"
>
<template #header>
<div class="flex justify-between items-center">
<span class="table-title">
لیست تراکنش‌ها
<Tag severity="secondary" :value="`${totalCount.toLocaleString('fa-IR')} تراکنش`" />
</span>
</div>
</template>
<template #loading>
<div class="flex flex-col items-center justify-center p-8 gap-3">
<ProgressSpinner style="width: 42px; height: 42px" strokeWidth="4" />
<span class="text-sm font-medium text-surface-500">در حال بارگذاری تراکنش‌های انبار...</span>
</div>
</template>
<template #empty>
<div v-if="!loading" class="empty-state">
<i class="pi pi-inbox"></i>
<p>هیچ تراکنشی یافت نشد</p>
</div>
</template>
<Column header="نوع" style="width: 7%">
<template #body="{ data }">
<Tag :severity="data.type === 'IN' ? 'success' : 'danger'" :value="data.type === 'IN' ? 'ورود' : 'خروج'" class="type-tag" />
</template>
</Column>
<Column header="قلم انبار" style="width: 16%">
<template #body="{ data }">
<div class="item-cell">
<span class="item-name-fa">{{ data.item?.nameFa || '—' }}</span>
<span class="item-name-en">{{ data.item?.nameEn }}</span>
</div>
</template>
</Column>
<Column header="مقدار" style="width: 12%">
<template #body="{ data }">
<span class="qty-display" :class="data.type === 'IN' ? 'qty-in' : 'qty-out'">
{{ data.type === 'OUT' ? '−' : '+' }}{{ data.quantity.toLocaleString('fa-IR') }}
<small>{{ data.unit }}</small>
</span>
</template>
</Column>
<Column header="انبار" style="width: 13%">
<template #body="{ data }">
<div class="warehouse-cell">
<i class="pi pi-map-marker" style="font-size: 0.75rem; opacity: 0.5"></i>
{{ data.warehouse?.name || '—' }}
</div>
</template>
</Column>
<Column header="دلیل" style="width: 18%">
<template #body="{ data }">
<div>
<span class="reason-text">{{ data.reason }}</span>
<div class="note-text" v-if="data.note">{{ data.note }}</div>
</div>
</template>
</Column>
<Column header="کاربر" style="width: 12%">
<template #body="{ data }">
<div class="actor-cell">
<i class="pi pi-user" style="font-size: 0.75rem; opacity: 0.5"></i>
{{ data.actorName || data.actor?.name || '—' }}
</div>
</template>
</Column>
<Column header="تاریخ" style="width: 14%">
<template #body="{ data }">
<span class="date-text">{{ formatDate(data.date || data.createdAt) }}</span>
</template>
</Column>
<Column style="width: 8%">
<template #body="{ data }">
<Button icon="pi pi-eye" text rounded severity="secondary" size="small" v-tooltip.top="'مشاهده موجودی قلم'" @click="router.push(`/warehouse-items/stock/${data.item?._id}`)" />
</template>
</Column>
</DataTable>
</div>
</div>
</template>
<style scoped>
.transactions-page {
padding: 1.5rem;
direction: rtl;
}
/* Header */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.header-content {
display: flex;
align-items: center;
gap: 1rem;
}
.header-icon {
width: 52px;
height: 52px;
border-radius: 14px;
background: linear-gradient(135deg, #0ea5e9, #6366f1);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: #fff;
box-shadow: 0 4px 15px rgba(14, 165, 233, 0.35);
}
.page-title {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
}
.page-subtitle {
margin: 0;
color: var(--text-color-secondary);
font-size: 0.875rem;
}
/* Filters */
.filters-card {
border-radius: 16px;
margin-bottom: 1.25rem;
}
.filters-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.filters-title {
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.filters-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1rem;
align-items: end;
}
.filter-field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.filter-field label {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-color-secondary);
}
.filter-action {
display: flex;
align-items: flex-end;
}
.apply-btn {
background: linear-gradient(135deg, #6366f1, #8b5cf6) !important;
border: none !important;
width: 100%;
}
/* Table */
.main-card {
border-radius: 16px;
}
.table-title {
display: flex;
align-items: center;
gap: 0.75rem;
font-weight: 600;
}
.empty-state {
text-align: center;
padding: 3rem;
color: var(--text-color-secondary);
}
.empty-state i {
font-size: 2.5rem;
display: block;
margin-bottom: 0.75rem;
opacity: 0.3;
}
/* Cells */
.type-tag {
font-weight: 700;
}
.item-cell {
display: flex;
flex-direction: column;
}
.item-name-fa {
font-weight: 600;
font-size: 0.9rem;
}
.item-name-en {
font-size: 0.75rem;
color: var(--text-color-secondary);
direction: ltr;
display: inline-block;
}
.qty-display {
font-weight: 700;
font-size: 1rem;
}
.qty-in {
color: #16a34a;
}
.qty-out {
color: #dc2626;
}
.qty-display small {
font-size: 0.75rem;
font-weight: 400;
margin-right: 0.25rem;
color: var(--text-color-secondary);
}
.warehouse-cell {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.875rem;
}
.actor-cell {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.875rem;
}
.reason-text {
font-size: 0.875rem;
}
.note-text {
font-size: 0.775rem;
color: var(--text-color-secondary);
margin-top: 2px;
}
.date-text {
font-size: 0.8rem;
color: var(--text-color-secondary);
}
</style>
@@ -0,0 +1,533 @@
<script setup>
import { onMounted, ref, watch } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useRouter, useRoute } from 'vue-router';
import { useConfirm } from 'primevue/useconfirm';
import ConfirmPopup from 'primevue/confirmpopup';
import { WarehouseItemService } from '@/service/WarehouseItemService';
const toast = useToast();
const router = useRouter();
const route = useRoute();
const confirm = useConfirm();
const items = ref([]);
const totalCount = ref(0);
const page = ref(Number(route.query.page) || 1);
const perPage = ref(Number(route.query.per_page) || 20);
const searchPhrase = ref(route.query.search || '');
const loading = ref(true);
// Create/Edit dialog
const showDialog = ref(false);
const editMode = ref(false);
const submitting = ref(false);
const selectedItem = ref(null);
const form = ref({
nameFa: '',
nameEn: '',
code: '',
unitFa: '',
unitEn: '',
category: '',
description: ''
});
// ─── Common units ─────────────────────────────────────────────────────────────
const UNITS = [
{ fa: 'عدد', en: 'Piece' },
{ fa: 'تن', en: 'Ton' },
{ fa: 'کیلوگرم', en: 'Kilogram' },
{ fa: 'متر', en: 'Meter' },
{ fa: 'متر مربع', en: 'Square Meter' },
{ fa: 'متر مکعب', en: 'Cubic Meter' },
{ fa: 'لیتر', en: 'Liter' },
{ fa: 'بسته', en: 'Package' },
{ fa: 'رول', en: 'Roll' },
{ fa: 'جعبه', en: 'Box' },
{ fa: 'کیسه', en: 'Bag' },
{ fa: 'پالت', en: 'Pallet' }
];
let searchTimeout = null;
onMounted(() => {
loadData();
});
watch(searchPhrase, (val) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
router.push({ query: { ...route.query, search: val || undefined, page: 1 } });
}, 400);
});
watch(
() => route.query,
() => {
page.value = Number(route.query.page) || 1;
perPage.value = Number(route.query.per_page) || 20;
searchPhrase.value = route.query.search || '';
loadData();
},
{ deep: true }
);
const loadData = async () => {
loading.value = true;
try {
const result = await WarehouseItemService.listItems({
page: page.value,
limit: perPage.value,
search: searchPhrase.value || undefined
});
items.value = result.items || [];
totalCount.value = result.total || 0;
} catch (e) {
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در بارگذاری اطلاعات', life: 3000 });
} finally {
loading.value = false;
}
};
const onPageChange = (event) => {
router.push({ query: { ...route.query, page: event.page + 1, per_page: event.rows } });
};
const openCreateDialog = () => {
editMode.value = false;
selectedItem.value = null;
form.value = { nameFa: '', nameEn: '', code: '', unitFa: '', unitEn: '', category: '', description: '' };
showDialog.value = true;
};
const openEditDialog = (item) => {
editMode.value = true;
selectedItem.value = item;
form.value = {
nameFa: item.nameFa,
nameEn: item.nameEn,
code: item.code || '',
unitFa: item.unitFa,
unitEn: item.unitEn,
category: item.category || '',
description: item.description || ''
};
showDialog.value = true;
};
const onUnitSelect = (unit) => {
form.value.unitFa = unit.fa;
form.value.unitEn = unit.en;
};
const submitForm = async () => {
if (!form.value.nameFa || !form.value.nameEn || !form.value.unitFa || !form.value.unitEn) {
toast.add({ severity: 'warn', summary: 'توجه', detail: 'لطفاً تمام فیلدهای ضروری را پر کنید', life: 3000 });
return;
}
submitting.value = true;
try {
if (editMode.value) {
await WarehouseItemService.updateItem(selectedItem.value._id, form.value);
toast.add({ severity: 'success', summary: 'موفق', detail: 'آیتم با موفقیت ویرایش شد', life: 3000 });
} else {
await WarehouseItemService.createItem(form.value);
toast.add({ severity: 'success', summary: 'موفق', detail: 'آیتم با موفقیت ایجاد شد', life: 3000 });
}
showDialog.value = false;
loadData();
} catch (e) {
toast.add({ severity: 'error', summary: 'خطا', detail: e.response?.data?.message || 'عملیات ناموفق بود', life: 4000 });
} finally {
submitting.value = false;
}
};
const confirmDelete = (item, event) => {
confirm.require({
target: event.currentTarget,
message: `آیا از غیرفعال‌سازی "${item.nameFa}" اطمینان دارید؟`,
icon: 'pi pi-exclamation-triangle',
rejectProps: { label: 'خیر', severity: 'secondary', outlined: true },
acceptProps: { label: 'بله', severity: 'danger' },
accept: async () => {
try {
await WarehouseItemService.deleteItem(item._id);
toast.add({ severity: 'success', summary: 'موفق', detail: 'آیتم غیرفعال شد', life: 3000 });
loadData();
} catch (e) {
toast.add({ severity: 'error', summary: 'خطا', detail: 'عملیات ناموفق بود', life: 3000 });
}
}
});
};
const viewStock = (itemId) => {
router.push(`/warehouse-items/stock/${itemId}`);
};
</script>
<template>
<ConfirmPopup />
<div class="warehouse-items-page">
<!-- ─── Header ─────────────────────────────────────────────────── -->
<div class="page-header">
<div class="header-content">
<div class="header-icon">
<i class="pi pi-box"></i>
</div>
<div>
<h1 class="page-title">اقلام انبار</h1>
<p class="page-subtitle">مدیریت محصولات و مصالح انبار</p>
</div>
</div>
<div class="header-actions">
<Button label="افزودن قلم جدید" icon="pi pi-plus" class="create-btn" @click="openCreateDialog" />
</div>
</div>
<!-- ─── Main card ───────────────────────────────────────────────── -->
<div class="card main-card">
<DataTable
:value="items"
:loading="loading"
dataKey="_id"
:paginator="true"
:rows="perPage"
:total-records="totalCount"
lazy
@page="onPageChange($event)"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
:rowsPerPageOptions="[20, 40, 80]"
currentPageReportTemplate="نمایش {first} تا {last} از {totalRecords} قلم"
stripedRows
responsiveLayout="scroll"
>
<template #header>
<div class="table-header">
<span class="table-title">لیست اقلام</span>
<IconField>
<InputIcon><i class="pi pi-search" /></InputIcon>
<InputText v-model="searchPhrase" placeholder="جستجو..." class="search-input" />
</IconField>
</div>
</template>
<template #loading>
<div class="flex flex-col items-center justify-center p-8 gap-3">
<ProgressSpinner style="width: 42px; height: 42px" strokeWidth="4" />
<span class="text-sm font-medium text-surface-500">در حال بارگذاری اقلام انبار...</span>
</div>
</template>
<template #empty>
<div v-if="!loading" class="empty-state">
<i class="pi pi-inbox empty-icon"></i>
<p>هیچ قلمی یافت نشد</p>
<Button label="افزودن اولین قلم" icon="pi pi-plus" @click="openCreateDialog" />
</div>
</template>
<Column field="code" header="کد" style="width: 8%">
<template #body="{ data }">
<span class="code-badge" v-if="data.code">{{ data.code }}</span>
<span class="text-muted" v-else>—</span>
</template>
</Column>
<Column field="nameFa" header="نام فارسی" sortable style="width: 18%">
<template #body="{ data }">
<div class="item-name">
<span class="name-fa">{{ data.nameFa }}</span>
</div>
</template>
</Column>
<Column field="nameEn" header="نام انگلیسی" sortable style="width: 18%">
<template #body="{ data }">
<span class="name-en">{{ data.nameEn }}</span>
</template>
</Column>
<Column header="واحد" style="width: 12%">
<template #body="{ data }">
<span class="unit-badge">{{ data.unitFa }} / {{ data.unitEn }}</span>
</template>
</Column>
<Column field="category" header="دسته‌بندی" style="width: 14%">
<template #body="{ data }">
<span v-if="data.category" class="category-chip">{{ data.category }}</span>
<span v-else class="text-muted">—</span>
</template>
</Column>
<Column field="isActive" header="وضعیت" style="width: 9%">
<template #body="{ data }">
<Tag :severity="data.isActive ? 'success' : 'danger'" :value="data.isActive ? 'فعال' : 'غیرفعال'" />
</template>
</Column>
<Column header="عملیات" style="width: 21%">
<template #body="{ data }">
<div class="action-buttons">
<Button icon="pi pi-chart-bar" outlined rounded severity="info" v-tooltip.top="'مشاهده موجودی'" @click="viewStock(data._id)" />
<Button icon="pi pi-pencil" outlined rounded v-tooltip.top="'ویرایش'" @click="openEditDialog(data)" />
<Button icon="pi pi-ban" outlined rounded severity="danger" v-tooltip.top="'غیرفعال‌سازی'" @click="confirmDelete(data, $event)" />
</div>
</template>
</Column>
</DataTable>
</div>
<!-- ─── Create/Edit Dialog ──────────────────────────────────────── -->
<Dialog v-model:visible="showDialog" :header="editMode ? 'ویرایش قلم انبار' : 'افزودن قلم جدید'" :style="{ width: '580px' }" modal :dir="'rtl'">
<div class="form-grid">
<div class="form-row">
<div class="form-field">
<label>نام فارسی <span class="required">*</span></label>
<InputText v-model="form.nameFa" placeholder="مثال: سیمان" class="w-full" />
</div>
<div class="form-field">
<label>نام انگلیسی <span class="required">*</span></label>
<InputText v-model="form.nameEn" placeholder="e.g. Cement" class="w-full" dir="ltr" />
</div>
</div>
<div class="form-row">
<div class="form-field">
<label>کد (اختیاری)</label>
<InputText v-model="form.code" placeholder="مثال: CEM" class="w-full" dir="ltr" />
</div>
<div class="form-field">
<label>دسته‌بندی</label>
<InputText v-model="form.category" placeholder="مثال: مصالح ساختمانی" class="w-full" />
</div>
</div>
<!-- Unit picker -->
<div class="form-field full-width">
<label>واحد شمارش <span class="required">*</span></label>
<div class="unit-picker">
<button v-for="u in UNITS" :key="u.en" class="unit-chip" :class="{ active: form.unitFa === u.fa }" type="button" @click="onUnitSelect(u)">
{{ u.fa }}
<span class="unit-en">{{ u.en }}</span>
</button>
</div>
<div class="unit-custom-row">
<InputText v-model="form.unitFa" placeholder="واحد فارسی سفارشی" class="w-1/2" />
<InputText v-model="form.unitEn" placeholder="Custom unit (EN)" class="w-1/2" dir="ltr" />
</div>
</div>
<div class="form-field full-width">
<label>توضیحات</label>
<Textarea v-model="form.description" rows="2" class="w-full" placeholder="توضیحات اختیاری..." autoResize />
</div>
</div>
<template #footer>
<Button label="انصراف" icon="pi pi-times" text @click="showDialog = false" />
<Button :label="editMode ? 'ذخیره تغییرات' : 'ایجاد قلم'" icon="pi pi-check" :loading="submitting" @click="submitForm" />
</template>
</Dialog>
</div>
</template>
<style scoped>
.warehouse-items-page {
padding: 1.5rem;
direction: rtl;
}
/* Header */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.header-content {
display: flex;
align-items: center;
gap: 1rem;
}
.header-icon {
width: 52px;
height: 52px;
border-radius: 14px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: #fff;
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.35);
}
.page-title {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
}
.page-subtitle {
margin: 0;
color: var(--text-color-secondary);
font-size: 0.875rem;
}
.create-btn {
background: linear-gradient(135deg, #6366f1, #8b5cf6) !important;
border: none !important;
padding: 0.65rem 1.5rem !important;
font-weight: 600 !important;
}
/* Card */
.main-card {
border-radius: 16px;
overflow: hidden;
}
/* Table header */
.table-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0;
}
.table-title {
font-size: 1rem;
font-weight: 600;
}
.search-input {
width: 260px;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 3rem;
color: var(--text-color-secondary);
}
.empty-icon {
font-size: 3rem;
display: block;
margin-bottom: 1rem;
opacity: 0.4;
}
/* Badges / chips */
.code-badge {
font-family: monospace;
background: var(--surface-100);
padding: 2px 8px;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 600;
}
.name-fa {
font-weight: 600;
}
.name-en {
color: var(--text-color-secondary);
font-size: 0.875rem;
direction: ltr;
display: inline-block;
}
.unit-badge {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.1), rgba(139, 92, 246, 0.1));
color: #6366f1;
padding: 3px 10px;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
}
.category-chip {
background: var(--surface-200);
padding: 3px 10px;
border-radius: 20px;
font-size: 0.8rem;
}
.text-muted {
color: var(--text-color-secondary);
opacity: 0.5;
}
/* Actions */
.action-buttons {
display: flex;
gap: 0.4rem;
align-items: center;
}
/* Form */
.form-grid {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.form-field.full-width {
grid-column: span 2;
}
.form-field label {
font-weight: 600;
font-size: 0.9rem;
}
.required {
color: #ef4444;
}
/* Unit picker */
.unit-picker {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.unit-chip {
border: 1.5px solid var(--surface-border);
background: var(--surface-50);
border-radius: 20px;
padding: 5px 14px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.4rem;
transition: all 0.2s;
color: var(--text-color);
}
.unit-chip:hover {
border-color: #6366f1;
color: #6366f1;
background: rgba(99, 102, 241, 0.06);
}
.unit-chip.active {
border-color: #6366f1;
background: linear-gradient(135deg, rgba(99, 102, 241, 0.15), rgba(139, 92, 246, 0.15));
color: #6366f1;
}
.unit-en {
font-size: 0.75rem;
opacity: 0.7;
direction: ltr;
display: inline-block;
}
.unit-custom-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
</style>
@@ -0,0 +1,360 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import ConfirmPopup from 'primevue/confirmpopup';
import { WarehouseService } from '@/service/WarehouseService';
const toast = useToast();
const confirm = useConfirm();
const warehouses = ref([]);
const loading = ref(true);
const showDialog = ref(false);
const editMode = ref(false);
const submitting = ref(false);
const selectedWarehouse = ref(null);
const searchPhrase = ref('');
const form = ref({
name: '',
code: '',
description: '',
location: { address: '' }
});
onMounted(() => loadData());
const loadData = async () => {
loading.value = true;
try {
warehouses.value = await WarehouseService.listWarehouses({
search: searchPhrase.value || undefined
});
} catch (e) {
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در بارگذاری انبارها', life: 3000 });
} finally {
loading.value = false;
}
};
const openCreate = () => {
editMode.value = false;
selectedWarehouse.value = null;
form.value = { name: '', code: '', description: '', location: { address: '' } };
showDialog.value = true;
};
const openEdit = (wh) => {
editMode.value = true;
selectedWarehouse.value = wh;
form.value = {
name: wh.name,
code: wh.code || '',
description: wh.description || '',
location: { address: wh.location?.address || '' }
};
showDialog.value = true;
};
const submitForm = async () => {
if (!form.value.name) {
toast.add({ severity: 'warn', summary: 'توجه', detail: 'نام انبار ضروری است', life: 3000 });
return;
}
submitting.value = true;
try {
if (editMode.value) {
await WarehouseService.updateWarehouse(selectedWarehouse.value._id, form.value);
toast.add({ severity: 'success', summary: 'موفق', detail: 'انبار با موفقیت ویرایش شد', life: 3000 });
} else {
await WarehouseService.createWarehouse(form.value);
toast.add({ severity: 'success', summary: 'موفق', detail: 'انبار با موفقیت ایجاد شد', life: 3000 });
}
showDialog.value = false;
loadData();
} catch (e) {
toast.add({ severity: 'error', summary: 'خطا', detail: e.response?.data?.message || 'عملیات ناموفق', life: 4000 });
} finally {
submitting.value = false;
}
};
const confirmDelete = (wh, event) => {
confirm.require({
target: event.currentTarget,
message: `آیا از غیرفعال‌سازی انبار "${wh.name}" اطمینان دارید؟`,
icon: 'pi pi-exclamation-triangle',
rejectProps: { label: 'خیر', severity: 'secondary', outlined: true },
acceptProps: { label: 'بله', severity: 'danger' },
accept: async () => {
try {
await WarehouseService.deleteWarehouse(wh._id);
toast.add({ severity: 'success', summary: 'موفق', detail: 'انبار غیرفعال شد', life: 3000 });
loadData();
} catch (e) {
toast.add({ severity: 'error', summary: 'خطا', detail: 'عملیات ناموفق', life: 3000 });
}
}
});
};
let searchTimeout;
const onSearch = () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(loadData, 400);
};
</script>
<template>
<ConfirmPopup />
<div class="warehouses-page">
<!-- Header -->
<div class="page-header">
<div class="header-content">
<div class="header-icon">
<i class="pi pi-map"></i>
</div>
<div>
<h1 class="page-title">مدیریت انبارها</h1>
<p class="page-subtitle">مکان‌های نگهداری اقلام</p>
</div>
</div>
<Button label="افزودن انبار جدید" icon="pi pi-plus" class="create-btn" @click="openCreate" />
</div>
<!-- Table -->
<div class="card main-card">
<DataTable :value="warehouses" :loading="loading" dataKey="_id" stripedRows responsiveLayout="scroll">
<template #header>
<div class="table-header">
<span class="table-title">لیست انبارها ({{ warehouses.length }})</span>
<IconField>
<InputIcon><i class="pi pi-search" /></InputIcon>
<InputText v-model="searchPhrase" placeholder="جستجو..." class="search-input" @input="onSearch" />
</IconField>
</div>
</template>
<template #loading>
<div class="flex flex-col items-center justify-center p-8 gap-3">
<ProgressSpinner style="width: 42px; height: 42px" strokeWidth="4" />
<span class="text-sm font-medium text-surface-500">در حال بارگذاری انبارها...</span>
</div>
</template>
<template #empty>
<div v-if="!loading" class="empty-state">
<i class="pi pi-map empty-icon"></i>
<p>هیچ انباری تعریف نشده است</p>
<Button label="افزودن اولین انبار" icon="pi pi-plus" @click="openCreate" />
</div>
</template>
<Column field="code" header="کد" style="width: 8%">
<template #body="{ data }">
<span v-if="data.code" class="code-badge">{{ data.code }}</span>
<span v-else class="text-muted">—</span>
</template>
</Column>
<Column field="name" header="نام انبار" sortable style="width: 20%">
<template #body="{ data }">
<span class="warehouse-name">{{ data.name }}</span>
</template>
</Column>
<Column header="آدرس" style="width: 28%">
<template #body="{ data }">
<span class="address-text">{{ data.location?.address || '—' }}</span>
</template>
</Column>
<Column header="مدیر انبار" style="width: 16%">
<template #body="{ data }">
{{ data.manager?.name || '—' }}
</template>
</Column>
<Column field="isActive" header="وضعیت" style="width: 9%">
<template #body="{ data }">
<Tag :severity="data.isActive ? 'success' : 'danger'" :value="data.isActive ? 'فعال' : 'غیرفعال'" />
</template>
</Column>
<Column header="توضیحات" style="width: 12%">
<template #body="{ data }">
<span class="desc-text">{{ data.description || '—' }}</span>
</template>
</Column>
<Column header="عملیات" style="width: 7%">
<template #body="{ data }">
<div class="action-buttons">
<Button icon="pi pi-pencil" outlined rounded size="small" v-tooltip.top="'ویرایش'" @click="openEdit(data)" />
<Button icon="pi pi-ban" outlined rounded severity="danger" size="small" v-tooltip.top="'غیرفعال‌سازی'" @click="confirmDelete(data, $event)" />
</div>
</template>
</Column>
</DataTable>
</div>
<!-- Dialog -->
<Dialog v-model:visible="showDialog" :header="editMode ? 'ویرایش انبار' : 'افزودن انبار جدید'" :style="{ width: '480px' }" modal dir="rtl">
<div class="form-grid">
<div class="form-row">
<div class="form-field">
<label>نام انبار <span class="required">*</span></label>
<InputText v-model="form.name" placeholder="مثال: انبار تهران" class="w-full" />
</div>
<div class="form-field">
<label>کد (اختیاری)</label>
<InputText v-model="form.code" placeholder="مثال: WHT" class="w-full" dir="ltr" />
</div>
</div>
<div class="form-field full-width">
<label>آدرس</label>
<InputText v-model="form.location.address" placeholder="آدرس کامل انبار" class="w-full" />
</div>
<div class="form-field full-width">
<label>توضیحات</label>
<Textarea v-model="form.description" rows="2" class="w-full" autoResize placeholder="توضیحات اختیاری..." />
</div>
</div>
<template #footer>
<Button label="انصراف" icon="pi pi-times" text @click="showDialog = false" />
<Button :label="editMode ? 'ذخیره تغییرات' : 'ایجاد انبار'" icon="pi pi-check" :loading="submitting" @click="submitForm" />
</template>
</Dialog>
</div>
</template>
<style scoped>
.warehouses-page {
padding: 1.5rem;
direction: rtl;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.header-content {
display: flex;
align-items: center;
gap: 1rem;
}
.header-icon {
width: 52px;
height: 52px;
border-radius: 14px;
background: linear-gradient(135deg, #0891b2, #0ea5e9);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: #fff;
box-shadow: 0 4px 15px rgba(8, 145, 178, 0.35);
}
.page-title {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
}
.page-subtitle {
margin: 0;
color: var(--text-color-secondary);
font-size: 0.875rem;
}
.create-btn {
background: linear-gradient(135deg, #0891b2, #0ea5e9) !important;
border: none !important;
padding: 0.65rem 1.5rem !important;
font-weight: 600 !important;
}
.main-card {
border-radius: 16px;
}
.table-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0;
}
.table-title {
font-size: 1rem;
font-weight: 600;
}
.search-input {
width: 240px;
}
.empty-state {
text-align: center;
padding: 3rem;
color: var(--text-color-secondary);
}
.empty-icon {
font-size: 3rem;
display: block;
margin-bottom: 1rem;
opacity: 0.35;
}
.code-badge {
font-family: monospace;
background: var(--surface-100);
padding: 2px 8px;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 600;
}
.warehouse-name {
font-weight: 600;
}
.address-text {
font-size: 0.875rem;
color: var(--text-color-secondary);
}
.desc-text {
font-size: 0.8rem;
color: var(--text-color-secondary);
}
.text-muted {
opacity: 0.4;
}
.action-buttons {
display: flex;
gap: 0.4rem;
}
.form-grid {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.form-field.full-width {
}
.form-field label {
font-weight: 600;
font-size: 0.9rem;
}
.required {
color: #ef4444;
}
</style>
@@ -0,0 +1,696 @@
<script setup>
import { onMounted, ref, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import ConfirmPopup from 'primevue/confirmpopup';
import { WarehouseItemService } from '@/service/WarehouseItemService';
import { WarehouseService } from '@/service/WarehouseService';
const route = useRoute();
const router = useRouter();
const toast = useToast();
const confirm = useConfirm();
const itemId = computed(() => route.params.itemId);
const stockData = ref(null);
const warehouses = ref([]);
const loading = ref(false);
const showTransactionDialog = ref(false);
const showTransactionHistory = ref(false);
const submitting = ref(false);
const transactions = ref([]);
const transactionsLoading = ref(false);
const totalTransactions = ref(0);
const txForm = ref({
type: 'IN',
warehouseId: '',
quantity: null,
reason: '',
note: ''
});
const IN_REASONS = ['خرید از تامین‌کننده', 'برگشت از کارگاه', 'انتقال از انبار دیگر', 'اهدا / دریافت رایگان', 'موجودی اولیه', 'سایر'];
const OUT_REASONS = ['مصرف در پروژه', 'انتقال به انبار دیگر', 'فروش / واگذاری', 'ضایعات', 'مفقودی', 'سایر'];
const reasons = computed(() => (txForm.value.type === 'IN' ? IN_REASONS : OUT_REASONS));
onMounted(async () => {
await Promise.all([loadStock(), loadWarehouses()]);
});
const loadStock = async () => {
loading.value = true;
try {
stockData.value = await WarehouseItemService.getStockByItem(itemId.value);
} catch (e) {
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در بارگذاری موجودی', life: 3000 });
} finally {
loading.value = false;
}
};
const loadWarehouses = async () => {
try {
warehouses.value = await WarehouseService.listWarehouses({ isActive: 'true' });
} catch (e) {
console.error('Failed to load warehouses', e);
}
};
const loadTransactions = async () => {
transactionsLoading.value = true;
try {
const result = await WarehouseItemService.listTransactions({ itemId: itemId.value, limit: 50 });
transactions.value = result.transactions || [];
totalTransactions.value = result.total || 0;
} catch (e) {
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در بارگذاری تراکنش‌ها', life: 3000 });
} finally {
transactionsLoading.value = false;
}
};
const openTransactionDialog = (type = 'IN') => {
txForm.value = { type, warehouseId: '', quantity: null, reason: '', note: '' };
showTransactionDialog.value = true;
};
const submitTransaction = async () => {
if (!txForm.value.warehouseId || !txForm.value.quantity || !txForm.value.reason) {
toast.add({ severity: 'warn', summary: 'توجه', detail: 'لطفاً تمام فیلدهای ضروری را پر کنید', life: 3000 });
return;
}
submitting.value = true;
try {
await WarehouseItemService.createTransaction({
itemId: itemId.value,
warehouseId: txForm.value.warehouseId,
type: txForm.value.type,
quantity: txForm.value.quantity,
reason: txForm.value.reason,
note: txForm.value.note
});
toast.add({
severity: 'success',
summary: 'موفق',
detail: txForm.value.type === 'IN' ? 'موجودی با موفقیت افزوده شد' : 'موجودی با موفقیت کسر شد',
life: 3000
});
showTransactionDialog.value = false;
await loadStock();
if (showTransactionHistory.value) await loadTransactions();
} catch (e) {
toast.add({
severity: 'error',
summary: 'خطا',
detail: e.response?.data?.message || 'عملیات ناموفق بود',
life: 5000
});
} finally {
submitting.value = false;
}
};
const toggleHistory = async () => {
showTransactionHistory.value = !showTransactionHistory.value;
if (showTransactionHistory.value && transactions.value.length === 0) {
await loadTransactions();
}
};
const confirmDeleteTransaction = (tx, event) => {
confirm.require({
target: event.currentTarget,
message: 'حذف این تراکنش موجودی انبار را برگشت می‌دهد. مطمئنید؟',
icon: 'pi pi-exclamation-triangle',
rejectProps: { label: 'خیر', severity: 'secondary', outlined: true },
acceptProps: { label: 'بله، حذف شود', severity: 'danger' },
accept: async () => {
try {
await WarehouseItemService.deleteTransaction(tx._id);
toast.add({ severity: 'success', summary: 'موفق', detail: 'تراکنش حذف شد', life: 3000 });
await Promise.all([loadStock(), loadTransactions()]);
} catch (e) {
toast.add({ severity: 'error', summary: 'خطا', detail: e.response?.data?.message || 'خطا در حذف', life: 4000 });
}
}
});
};
const formatDate = (d) => {
if (!d) return '—';
return new Intl.DateTimeFormat('fa-IR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(d));
};
const getWarehouseName = (id) => {
const w = warehouses.value.find((w) => w._id === id);
return w ? w.name : id;
};
</script>
<template>
<ConfirmPopup />
<div class="stock-view-page">
<!-- ─── Back + Title ──────────────────────────────────────────── -->
<div class="page-header">
<Button icon="pi pi-arrow-right" text rounded @click="router.back()" />
<div class="header-text" v-if="stockData">
<h1 class="page-title">
<span class="item-name-fa">{{ stockData.item.nameFa }}</span>
<span class="item-name-en">{{ stockData.item.nameEn }}</span>
</h1>
<p class="page-subtitle">مدیریت موجودی انبار</p>
</div>
<div class="header-actions" v-if="stockData">
<Button icon="pi pi-plus" label="افزودن موجودی" severity="success" @click="openTransactionDialog('IN')" />
<Button icon="pi pi-minus" label="کسر موجودی" severity="danger" outlined @click="openTransactionDialog('OUT')" />
<Button :icon="showTransactionHistory ? 'pi pi-times' : 'pi pi-history'" :label="showTransactionHistory ? 'بستن تاریخچه' : 'تاریخچه تراکنش‌ها'" severity="secondary" outlined @click="toggleHistory" />
</div>
</div>
<div v-if="loading" class="loading-container">
<ProgressSpinner />
</div>
<div v-else-if="stockData" class="content-grid">
<!-- ─── Total Summary Card ────────────────────────────────── -->
<div class="summary-card">
<div class="summary-icon">
<i class="pi pi-database"></i>
</div>
<div class="summary-details">
<span class="summary-label">موجودی کل (تمام انبارها)</span>
<div class="summary-value">
<span class="qty">{{ stockData.totalQuantity.toLocaleString('fa-IR') }}</span>
<span class="unit">{{ stockData.unit }}</span>
</div>
<span class="unit-en-label">{{ stockData.unitEn }}</span>
</div>
<div class="summary-locations">
<i class="pi pi-map-marker"></i>
{{ stockData.stocks.length }} انبار فعال
</div>
</div>
<!-- ─── Per-warehouse stock table ─────────────────────────── -->
<div class="card stock-table-card">
<h3 class="card-title">
<i class="pi pi-warehouse me-2"></i>
موجودی به تفکیک انبار
</h3>
<DataTable :value="stockData.stocks" stripedRows responsiveLayout="scroll">
<template #empty>
<div class="empty-state">
<i class="pi pi-inbox"></i>
<p>هنوز هیچ موجودی ثبت نشده است</p>
</div>
</template>
<Column header="انبار">
<template #body="{ data }">
<div class="warehouse-info">
<div class="warehouse-name">{{ data.warehouse?.name || '—' }}</div>
<div class="warehouse-code" v-if="data.warehouse?.code">{{ data.warehouse.code }}</div>
</div>
</template>
</Column>
<Column header="آدرس">
<template #body="{ data }">
<span class="warehouse-address">{{ data.warehouse?.location?.address || '—' }}</span>
</template>
</Column>
<Column header="موجودی فعلی">
<template #body="{ data }">
<div class="stock-quantity" :class="{ 'zero-stock': data.quantity <= 0, 'low-stock': data.quantity > 0 && data.quantity < 5 }">
<span class="qty-value">{{ data.quantity.toLocaleString('fa-IR') }}</span>
<span class="qty-unit">{{ data.unit }}</span>
</div>
</template>
</Column>
<Column header="عملیات" style="width: 12rem">
<template #body="{ data }">
<div class="row-actions">
<Button
icon="pi pi-plus"
size="small"
severity="success"
text
rounded
v-tooltip.top="'افزودن موجودی'"
@click="
() => {
txForm.warehouseId = data.warehouse?._id;
openTransactionDialog('IN');
txForm.warehouseId = data.warehouse?._id;
}
"
/>
<Button
icon="pi pi-minus"
size="small"
severity="danger"
text
rounded
v-tooltip.top="'کسر موجودی'"
@click="
() => {
txForm.warehouseId = data.warehouse?._id;
openTransactionDialog('OUT');
txForm.warehouseId = data.warehouse?._id;
}
"
/>
</div>
</template>
</Column>
</DataTable>
</div>
<!-- ─── Transaction History ────────────────────────────────── -->
<Transition name="slide-fade">
<div class="card history-card" v-if="showTransactionHistory">
<h3 class="card-title">
<i class="pi pi-history me-2"></i>
تاریخچه تراکنش‌ها
<span class="history-count">({{ totalTransactions }} تراکنش)</span>
</h3>
<DataTable :value="transactions" :loading="transactionsLoading" stripedRows responsiveLayout="scroll">
<template #empty>
<div class="empty-state"><p>هیچ تراکنشی یافت نشد</p></div>
</template>
<Column header="نوع" style="width: 8%">
<template #body="{ data }">
<Tag :severity="data.type === 'IN' ? 'success' : 'danger'" :value="data.type === 'IN' ? 'ورود' : 'خروج'" />
</template>
</Column>
<Column header="مقدار">
<template #body="{ data }">
<span class="qty-value" :class="data.type === 'IN' ? 'in-qty' : 'out-qty'"> {{ data.type === 'OUT' ? '-' : '+' }}{{ data.quantity.toLocaleString('fa-IR') }} {{ data.unit }} </span>
</template>
</Column>
<Column header="انبار">
<template #body="{ data }">{{ data.warehouse?.name || '—' }}</template>
</Column>
<Column header="دلیل">
<template #body="{ data }">
<span class="reason-text">{{ data.reason }}</span>
<small class="note-text" v-if="data.note"> — {{ data.note }}</small>
</template>
</Column>
<Column header="کاربر">
<template #body="{ data }">{{ data.actorName || data.actor?.name || '—' }}</template>
</Column>
<Column header="تاریخ">
<template #body="{ data }">{{ formatDate(data.date) }}</template>
</Column>
<Column style="width: 5%">
<template #body="{ data }">
<Button icon="pi pi-trash" text rounded severity="danger" size="small" v-tooltip.top="'حذف تراکنش'" @click="confirmDeleteTransaction(data, $event)" />
</template>
</Column>
</DataTable>
</div>
</Transition>
</div>
<!-- ─── Transaction Dialog ────────────────────────────────────── -->
<Dialog v-model:visible="showTransactionDialog" :header="txForm.type === 'IN' ? 'افزودن موجودی' : 'کسر موجودی'" :style="{ width: '500px' }" modal dir="rtl">
<div class="tx-form">
<!-- Type toggle -->
<div class="type-toggle">
<button class="type-btn" :class="{ active: txForm.type === 'IN', 'in-type': true }" @click="txForm.type = 'IN'" type="button"><i class="pi pi-arrow-down"></i> ورود به انبار</button>
<button class="type-btn" :class="{ active: txForm.type === 'OUT', 'out-type': true }" @click="txForm.type = 'OUT'" type="button"><i class="pi pi-arrow-up"></i> خروج از انبار</button>
</div>
<div class="form-field">
<label>انبار <span class="required">*</span></label>
<Select v-model="txForm.warehouseId" :options="warehouses" optionLabel="name" optionValue="_id" placeholder="انتخاب انبار..." class="w-full" />
</div>
<div class="form-field">
<label>مقدار ({{ stockData?.unit }}) <span class="required">*</span></label>
<InputNumber v-model="txForm.quantity" :min="0.001" :minFractionDigits="0" :maxFractionDigits="3" class="w-full" placeholder="مقدار را وارد کنید" :pt="{ input: { dir: 'ltr' } }" />
</div>
<div class="form-field">
<label>دلیل <span class="required">*</span></label>
<div class="reason-chips">
<button v-for="r in reasons" :key="r" class="reason-chip" :class="{ active: txForm.reason === r }" type="button" @click="txForm.reason = r">{{ r }}</button>
</div>
<InputText v-model="txForm.reason" placeholder="یا دلیل سفارشی وارد کنید..." class="w-full mt-2" />
</div>
<div class="form-field">
<label>یادداشت (اختیاری)</label>
<Textarea v-model="txForm.note" rows="2" class="w-full" autoResize placeholder="توضیحات تکمیلی..." />
</div>
<!-- Stock preview for OUT -->
<div class="stock-warning" v-if="txForm.type === 'OUT' && txForm.warehouseId">
<i class="pi pi-info-circle"></i>
<span>
موجودی فعلی در این انبار:
<strong> {{ (stockData.stocks.find((s) => s.warehouse?._id === txForm.warehouseId)?.quantity || 0).toLocaleString('fa-IR') }} {{ stockData?.unit }} </strong>
</span>
</div>
</div>
<template #footer>
<Button label="انصراف" icon="pi pi-times" text @click="showTransactionDialog = false" />
<Button
:label="txForm.type === 'IN' ? 'افزودن موجودی' : 'کسر موجودی'"
:icon="txForm.type === 'IN' ? 'pi pi-plus' : 'pi pi-minus'"
:severity="txForm.type === 'IN' ? 'success' : 'danger'"
:loading="submitting"
@click="submitTransaction"
/>
</template>
</Dialog>
</div>
</template>
<style scoped>
.stock-view-page {
padding: 1.5rem;
direction: rtl;
}
/* Header */
.page-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.header-text {
flex: 1;
}
.page-title {
margin: 0;
font-size: 1.4rem;
font-weight: 700;
display: flex;
align-items: center;
gap: 0.75rem;
}
.item-name-fa {
color: var(--text-color);
}
.item-name-en {
font-size: 0.95rem;
color: var(--text-color-secondary);
direction: ltr;
display: inline-block;
font-weight: 400;
}
.page-subtitle {
margin: 0;
color: var(--text-color-secondary);
font-size: 0.875rem;
}
.header-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.loading-container {
display: flex;
justify-content: center;
padding: 4rem;
}
/* Content grid */
.content-grid {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* Summary card */
.summary-card {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
border-radius: 16px;
padding: 1.75rem 2rem;
color: #fff;
display: flex;
align-items: center;
gap: 1.5rem;
box-shadow: 0 8px 30px rgba(99, 102, 241, 0.35);
}
.summary-icon {
width: 64px;
height: 64px;
background: rgba(255, 255, 255, 0.2);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.8rem;
flex-shrink: 0;
}
.summary-details {
flex: 1;
}
.summary-label {
font-size: 0.875rem;
opacity: 0.85;
display: block;
margin-bottom: 0.3rem;
}
.summary-value {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.summary-value .qty {
font-size: 2.5rem;
font-weight: 700;
line-height: 1;
}
.summary-value .unit {
font-size: 1.1rem;
opacity: 0.9;
}
.unit-en-label {
font-size: 0.8rem;
opacity: 0.7;
}
.summary-locations {
font-size: 0.875rem;
opacity: 0.85;
display: flex;
align-items: center;
gap: 0.4rem;
}
/* Table card */
.stock-table-card,
.history-card {
border-radius: 16px;
}
.card-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 1.25rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.history-count {
font-size: 0.8rem;
color: var(--text-color-secondary);
font-weight: 400;
}
/* Warehouse cells */
.warehouse-name {
font-weight: 600;
}
.warehouse-code {
font-size: 0.75rem;
color: var(--text-color-secondary);
font-family: monospace;
}
.warehouse-address {
font-size: 0.85rem;
color: var(--text-color-secondary);
}
/* Stock quantity */
.stock-quantity {
display: flex;
align-items: baseline;
gap: 0.4rem;
}
.qty-value {
font-size: 1.2rem;
font-weight: 700;
}
.qty-unit {
font-size: 0.8rem;
color: var(--text-color-secondary);
}
.zero-stock .qty-value {
color: #ef4444;
}
.low-stock .qty-value {
color: #f59e0b;
}
.in-qty {
color: #22c55e;
font-weight: 700;
}
.out-qty {
color: #ef4444;
font-weight: 700;
}
.row-actions {
display: flex;
gap: 0.25rem;
}
.reason-text {
font-size: 0.875rem;
}
.note-text {
font-size: 0.8rem;
color: var(--text-color-secondary);
}
.empty-state {
text-align: center;
padding: 2rem;
color: var(--text-color-secondary);
}
/* Slide animation */
.slide-fade-enter-active,
.slide-fade-leave-active {
transition: all 0.3s ease;
}
.slide-fade-enter-from {
opacity: 0;
transform: translateY(-12px);
}
.slide-fade-leave-to {
opacity: 0;
transform: translateY(-12px);
}
/* TX form */
.tx-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.form-field label {
font-weight: 600;
font-size: 0.9rem;
}
.required {
color: #ef4444;
}
.type-toggle {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
border-radius: 12px;
overflow: hidden;
}
.type-btn {
padding: 0.75rem;
border: 2px solid var(--surface-border);
background: var(--surface-50);
border-radius: 10px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
transition: all 0.2s;
color: var(--text-color);
}
.type-btn.in-type.active {
background: #dcfce7;
border-color: #22c55e;
color: #16a34a;
}
.type-btn.out-type.active {
background: #fee2e2;
border-color: #ef4444;
color: #dc2626;
}
.type-btn:hover {
border-color: var(--primary-color);
}
.reason-chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.reason-chip {
border: 1.5px solid var(--surface-border);
background: var(--surface-50);
border-radius: 20px;
padding: 4px 12px;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s;
color: var(--text-color);
}
.reason-chip:hover {
border-color: var(--primary-color);
color: var(--primary-color);
}
.reason-chip.active {
background: var(--primary-50);
border-color: var(--primary-color);
color: var(--primary-color);
font-weight: 600;
}
.stock-warning {
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 8px;
padding: 0.75rem 1rem;
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 0.5rem;
color: #92400e;
}
</style>
+13 -13
View File
@@ -16,11 +16,12 @@ const login = async () => {
email: email.value,
password: password.value
});
const isSecure = typeof window !== 'undefined' && window.location.protocol === 'https:';
const env = import.meta.env.VITE_ENV;
if (env === 'production') {
Cookies.set('bm-token', response.data.data.token, { sameSite: 'none', secure: true, expires: 1000, domain: '.chbk.app' });
if (env === 'production' && isSecure) {
Cookies.set('bm-token', response.data.data.token, { sameSite: 'none', secure: true, expires: 1000, domain: '.mbmanager.ir' });
} else {
Cookies.set('bm-token', response.data.data.token, { sameSite: 'none', secure: true, expires: 1000 });
Cookies.set('bm-token', response.data.data.token, { expires: 1000, secure: isSecure, sameSite: isSecure ? 'none' : 'lax' });
}
localStorage.setItem('bm-token', response.data.data.token);
await router.push('/');
@@ -31,17 +32,16 @@ const login = async () => {
};
onBeforeMount(async () => {
const token = Cookies.get('bm-token');
try {
const response = await axios.get('/api/users/user/get-self', {
headers: {
Authorization: `Bearer ${token}`
const token = Cookies.get('bm-token') || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
if (token) {
try {
const response = await axios.get('/api/users/user/get-self');
if (response.data?.data?.user) {
await router.push('/');
}
});
console.log(response);
await router.push('/');
} catch (error) {
console.log(error);
} catch (error) {
console.log(error);
}
}
});
</script>
+57 -86
View File
@@ -1,95 +1,66 @@
<template>
<div
:id="galleryId"
class="pswp-gallery pswp-gallery--single-column my-3 flex min-w-0 flex-row flex-nowrap overflow-x-auto rounded-md bg-brand-dark-3 p-3"
>
<div v-for="(image, key) in imagesData" :key="key" class="relative">
<a
:href="image.url"
:data-pswp-width="image.width"
:data-pswp-height="image.height"
target="_blank"
rel="noreferrer"
class="mx-3 block flex-shrink-0"
>
<img :src="image.url" alt="" class="max-h-60 rounded-md" :style="{ height: '150px' }" />
</a>
<button
class="absolute right-4 top-2 flex h-6 w-6 items-center justify-center rounded-full text-brand-danger"
@click="removeImage(key)"
>
<font-awesome-icon icon="fa-solid fa-x" />
</button>
</div>
</div>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import PhotoSwipeLightbox from 'photoswipe/lightbox'
import 'photoswipe/style.css'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { ref, watch } from 'vue';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import ImageViewerModal from '@/views/uikit/ImageViewerModal.vue';
const props = defineProps({
galleryId: {
type: String,
required: true
},
modelValue: {
type: Array,
required: true
}
});
const emit = defineEmits(['update:modelValue']);
const imagesData = ref([]);
const viewerVisible = ref(false);
const viewerInitialIndex = ref(0);
function updateImagesData(images) {
for (let i = 0; i < images.length; i++) {
const img = new Image()
img.src = images[i].url
img.onload = function () {
images[i].height = img.naturalHeight
images[i].width = img.naturalWidth
}
}
return images
if (!images || !Array.isArray(images)) return [];
return images.map((item) => {
if (typeof item === 'string') {
return { url: item, name: item.split('?')[0].split('/').pop() || 'image' };
}
return { ...item };
});
}
// Props
const props = defineProps({
galleryId: {
type: String,
required: true
},
modelValue: {
type: Array,
required: true
}
})
function openViewer(index) {
viewerInitialIndex.value = index;
viewerVisible.value = true;
}
const imagesData = ref([])
let lightbox = null
// Lifecycle hook: mounted
onMounted(() => {
if (!lightbox) {
console.log('Launching Gallery')
console.log({
gallery: '#' + props.galleryId,
children: 'a'
})
lightbox = new PhotoSwipeLightbox({
gallery: '#' + props.galleryId,
children: 'a',
pswpModule: () => import('photoswipe')
})
lightbox.init()
}
imagesData.value = updateImagesData(props.modelValue)
})
function removeImage(index) {
const newImages = [...props.modelValue];
newImages.splice(index, 1);
emit('update:modelValue', newImages);
}
watch(
() => props.modelValue,
(newImages) => {
console.log('propsimages', newImages)
imagesData.value = updateImagesData(newImages)
},
{ deep: true, immediate: true }
)
const emit = defineEmits(['update:modelValue'])
function removeImage(index) {
const newImages = [...props.modelValue]
newImages.splice(index, 1)
emit('update:modelValue', newImages)
}
() => props.modelValue,
(newImages) => {
imagesData.value = updateImagesData(newImages);
},
{ deep: true, immediate: true }
);
</script>
<template>
<div :id="galleryId" class="my-3 flex min-w-0 flex-row flex-nowrap overflow-x-auto rounded-md bg-brand-dark-3 p-3 gap-3">
<div v-for="(image, key) in imagesData" :key="key" class="relative group flex-shrink-0">
<div class="cursor-pointer overflow-hidden rounded-md" @click="openViewer(key)">
<img :src="image.url" alt="" class="h-[150px] object-cover rounded-md transition-transform duration-200 group-hover:scale-105" />
</div>
<button type="button" class="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-white shadow-md hover:bg-red-600 focus:outline-none" @click.stop="removeImage(key)">
<font-awesome-icon icon="fa-solid fa-x" class="text-xs" />
</button>
</div>
<ImageViewerModal v-model:visible="viewerVisible" :images="imagesData" :initial-index="viewerInitialIndex" />
</div>
</template>
+128 -121
View File
@@ -1,86 +1,60 @@
<template>
<div :id="galleryId" class="pswp-gallery pswp-gallery--single-column my-3 flex min-w-0 flex-row flex-nowrap overflow-x-auto rounded-md bg-brand-dark-3 p-3">
<div v-for="(item, key) in itemsData" :key="key" class="relative mx-3 flex-shrink-0" draggable="true" @dragstart="startDrag($event, key)" @dragover.prevent @dragenter.prevent @drop="onDrop($event, key)">
<!-- Image Display -->
<template v-if="isImageFile(item.url)">
<a :href="item.url" :data-pswp-width="item.width" :data-pswp-height="item.height" target="_blank" rel="noreferrer" class="block">
<img :src="item.url" alt="" class="max-h-60 rounded-md" :style="{ height: '150px' }" />
</a>
</template>
<!-- File Display -->
<template v-else>
<div class="flex h-[150px] w-[200px] flex-col items-center justify-center rounded-md border-2 border-gray-200 bg-white p-4">
<!-- File Icon -->
<font-awesome-icon :icon="getFileIcon(item.url)" class="mb-2 text-4xl" :class="getIconColor(item.url)" />
<!-- File Name -->
<p class="mb-2 max-w-full truncate text-sm font-medium text-gray-700">
{{ getFileName(item.url) }}
</p>
<!-- Download Button -->
<Button icon="pi pi-download" severity="secondary" size="small" @click="downloadFile(item.url)" class="p-button-sm"> دانلود </Button>
</div>
</template>
<!-- Remove Button -->
<button class="absolute right-4 top-2 flex h-6 w-6 items-center justify-center rounded-full text-red-500" @click="removeItem(key, item.url)">
<font-awesome-icon icon="fa-solid fa-x" />
</button>
</div>
</div>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue';
import PhotoSwipeLightbox from 'photoswipe/lightbox';
import 'photoswipe/style.css';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { Button } from 'primevue';
import { ref, computed, watch } from 'vue';
import ImageViewerModal from '@/views/uikit/ImageViewerModal.vue';
const props = defineProps({
galleryId: {
type: String,
required: true
},
modelValue: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['update:modelValue', 'delete-image']);
const itemsData = ref([]);
const viewerVisible = ref(false);
const viewerInitialIndex = ref(0);
// File type checking functions
function isImageFile(url) {
const extension = url.toLowerCase().split('.').pop();
return ['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(extension);
if (!url || typeof url !== 'string') return false;
const cleanUrl = url.split('?')[0];
const ext = cleanUrl.toLowerCase().split('.').pop();
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'avif'].includes(ext) || cleanUrl.startsWith('blob:') || cleanUrl.startsWith('data:image/');
}
function getFileIcon(url) {
const extension = url.toLowerCase().split('.').pop();
const iconMap = {
pdf: 'fa-solid fa-file-pdf',
doc: 'fa-solid fa-file-word',
docx: 'fa-solid fa-file-word',
xls: 'fa-solid fa-file-excel',
xlsx: 'fa-solid fa-file-excel',
txt: 'fa-solid fa-file-lines',
zip: 'fa-solid fa-file-zipper'
// Add more file types as needed
};
return iconMap[extension] || 'fa-solid fa-file';
function getFileIconClass(url) {
if (!url || typeof url !== 'string') return 'pi pi-file';
const cleanUrl = url.split('?')[0];
const ext = cleanUrl.toLowerCase().split('.').pop();
if (ext === 'pdf') return 'pi pi-file-pdf';
if (['doc', 'docx'].includes(ext)) return 'pi pi-file-word';
if (['xls', 'xlsx', 'csv'].includes(ext)) return 'pi pi-file-excel';
return 'pi pi-file';
}
function getIconColor(url) {
const extension = url.toLowerCase().split('.').pop();
const colorMap = {
pdf: 'text-red-500',
doc: 'text-blue-500',
docx: 'text-blue-500',
xls: 'text-green-500',
xlsx: 'text-green-500',
txt: 'text-gray-500',
zip: 'text-yellow-600'
// Add more file types as needed
};
return colorMap[extension] || 'text-gray-400';
function getFileIconColor(url) {
if (!url || typeof url !== 'string') return 'text-surface-500';
const cleanUrl = url.split('?')[0];
const ext = cleanUrl.toLowerCase().split('.').pop();
if (ext === 'pdf') return 'text-red-500';
if (['doc', 'docx'].includes(ext)) return 'text-blue-500';
if (['xls', 'xlsx', 'csv'].includes(ext)) return 'text-emerald-500';
return 'text-primary-500';
}
function getFileName(url) {
return url.split('/').pop();
}
function downloadFile(url) {
window.open(url, '_blank');
if (!url || typeof url !== 'string') return 'file';
const cleanUrl = url.split('?')[0];
const raw = cleanUrl.split('/').pop() || 'file';
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
}
function updateItemsData(items) {
@@ -89,44 +63,26 @@ function updateItemsData(items) {
}
return items.map((item) => {
if (item && item.url && isImageFile(item.url)) {
// Handle images
const img = new Image();
img.src = item.url;
img.onload = function () {
item.height = img.naturalHeight;
item.width = img.naturalWidth;
if (typeof item === 'string') {
return {
url: item,
name: item.split('?')[0].split('/').pop() || 'file'
};
}
return item;
return { ...item };
});
}
// Props
const props = defineProps({
galleryId: {
type: String,
required: true
},
modelValue: {
type: Array,
required: true
}
// Extract only images for the lightbox viewer
const imageItems = computed(() => {
return itemsData.value.filter((item) => isImageFile(item.url));
});
const itemsData = ref([]);
let lightbox = null;
onMounted(() => {
if (!lightbox) {
lightbox = new PhotoSwipeLightbox({
gallery: '#' + props.galleryId,
children: 'a',
pswpModule: () => import('photoswipe')
});
lightbox.init();
}
itemsData.value = updateItemsData(props.modelValue);
});
function openViewer(clickedItem) {
const idx = imageItems.value.findIndex((img) => img.url === clickedItem.url);
viewerInitialIndex.value = idx >= 0 ? idx : 0;
viewerVisible.value = true;
}
watch(
() => props.modelValue,
@@ -136,8 +92,6 @@ watch(
{ deep: true, immediate: true }
);
const emit = defineEmits(['update:modelValue', 'delete-image']);
function removeItem(index, url) {
const newItems = [...props.modelValue];
newItems.splice(index, 1);
@@ -148,24 +102,77 @@ function removeItem(index, url) {
function startDrag(event, index) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.dropEffect = 'move';
event.dataTransfer.setData('imageIndex', index);
event.dataTransfer.setData('imageIndex', index.toString());
}
function onDrop(event, dropIndex) {
const dragIndex = event.dataTransfer.getData('imageIndex');
const dragIndexStr = event.dataTransfer.getData('imageIndex');
const dragIndex = parseInt(dragIndexStr, 10);
if (isNaN(dragIndex) || dragIndex === dropIndex) return;
if (dragIndex === dropIndex.toString()) return;
const newItems = [...props.modelValue];
const draggedItem = newItems.splice(dragIndex, 1)[0];
newItems.splice(dropIndex, 0, draggedItem);
// Create a copy of the images array
const newImages = [...props.modelValue];
// Remove the dragged item
const draggedItem = newImages.splice(dragIndex, 1)[0];
// Insert it at the new position
newImages.splice(dropIndex, 0, draggedItem);
// Update the model
emit('update:modelValue', newImages);
emit('update:modelValue', newItems);
}
</script>
<template>
<div :id="galleryId" class="flex flex-wrap gap-3 p-3 rounded-xl bg-surface-50 dark:bg-surface-800/60 border border-surface-200 dark:border-surface-700/60 min-h-[90px] items-center">
<div
v-for="(item, key) in itemsData"
:key="key"
class="group relative flex flex-col items-center justify-between rounded-xl border border-surface-200 dark:border-surface-700 bg-surface-0 dark:bg-surface-900 p-2 shadow-sm transition-all hover:shadow-md overflow-hidden"
:class="isImageFile(item.url) ? 'w-36 h-36 sm:w-40 sm:h-40' : 'w-44 h-36'"
draggable="true"
@dragstart="startDrag($event, key)"
@dragover.prevent
@dragenter.prevent
@drop="onDrop($event, key)"
>
<!-- Image Thumbnail -->
<template v-if="isImageFile(item.url)">
<div class="w-full h-full flex items-center justify-center overflow-hidden rounded-lg relative cursor-pointer group" @click="openViewer(item)">
<img :src="item.url" :alt="item.name || 'Image'" class="w-full h-full object-cover rounded-lg transition-transform duration-300 group-hover:scale-105 select-none pointer-events-none" crossorigin="anonymous" loading="lazy" />
<div class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-1 text-white">
<i class="pi pi-search-plus text-2xl drop-shadow-md"></i>
<span class="text-[11px] font-medium drop-shadow-md">مشاهده و بزرگ‌نمایی</span>
</div>
</div>
</template>
<!-- Non-Image File (PDF, DOC, etc.) -->
<template v-else>
<div class="w-full h-full flex flex-col items-center justify-center p-2 text-center">
<i :class="[getFileIconClass(item.url), getFileIconColor(item.url), 'text-3xl mb-2']"></i>
<p class="text-xs font-medium text-surface-700 dark:text-surface-200 truncate max-w-full px-1 mb-2" :title="getFileName(item.url)">
{{ getFileName(item.url) }}
</p>
<a
:href="item.url"
target="_blank"
download
class="inline-flex items-center gap-1 text-[11px] px-2.5 py-1 rounded-lg bg-surface-100 dark:bg-surface-800 hover:bg-surface-200 dark:hover:bg-surface-700 text-surface-700 dark:text-surface-200 transition-colors"
>
<i class="pi pi-download text-[10px]"></i>
<span>دانلود</span>
</a>
</div>
</template>
<!-- Remove Button -->
<button
type="button"
class="absolute top-1.5 right-1.5 z-10 w-6 h-6 rounded-full bg-red-500 hover:bg-red-600 text-white flex items-center justify-center shadow-md opacity-90 hover:opacity-100 transition-all cursor-pointer focus:outline-none"
title="حذف"
@click.stop="removeItem(key, item.url)"
>
<i class="pi pi-times text-[10px]"></i>
</button>
</div>
<!-- High-Performance Image Viewer Lightbox Modal -->
<ImageViewerModal v-model:visible="viewerVisible" :images="imageItems" :initial-index="viewerInitialIndex" />
</div>
</template>
+201 -99
View File
@@ -1,62 +1,52 @@
<template>
<div id="app">
<FilePond
ref="pond"
name="filepond-image"
:max-files="maxFiles"
store-as-file="true"
:server="{
process: {
url: API_URL + uploadEndpoint,
method: 'POST',
withCredentials: true,
headers: {
authorization: 'Bearer ' + Cookies.get('bm-token')
},
onload: (response) => handleProcessResponse(response)
},
revert: {
url: API_URL + deleteEndpoint,
method: 'DELETE',
withCredentials: true,
headers: {
authorization: 'Bearer ' + Cookies.get('bm-token')
},
onload: (response) => handleRevertResponse(response)
}
}"
label-idle="برای آپلود کلیک کنید"
label-tap-to-undo=" برای حذف کلیک کنید "
label-file-processing=" در حال آپلود "
label-file-processing-complete=" آپلود فایل تکمیل شد "
:allow-multiple="true"
v-model="uploadedFiles"
/>
</div>
</template>
<script setup>
import { ref, watch, defineProps, computed } from 'vue';
import Cookies from 'js-cookie';
<script>
import vueFilePond from 'vue-filepond';
import 'filepond/dist/filepond.min.css';
import 'filepond-plugin-image-preview/dist/filepond-plugin-image-preview.min.css';
import FilePondPluginFileValidateType from 'filepond-plugin-file-validate-type';
import FilePondPluginImagePreview from 'filepond-plugin-image-preview';
// Define props
// Create FilePond component at module level
const FilePond = vueFilePond(FilePondPluginFileValidateType, FilePondPluginImagePreview);
export default {
components: {
FilePond
}
};
</script>
<script setup>
import { ref, computed } from 'vue';
import Cookies from 'js-cookie';
const props = defineProps({
bucketName: {
type: String,
default: 'temp'
},
uploadEndpoint: {
type: String,
required: true
default: ''
},
uploadUrl: {
type: String,
default: ''
},
deleteEndpoint: {
type: String,
required: true
default: ''
},
deleteUrl: {
type: String,
default: ''
},
maxFiles: {
type: Number,
default: 1
default: 10
},
allowMultiple: {
type: Boolean,
default: true
},
acceptedFiles: {
type: String,
@@ -64,95 +54,207 @@ const props = defineProps({
},
mode: {
type: String,
required: true,
default: 'public'
},
modelValue: {
type: Array,
required: false,
default: () => []
}
});
// Setup reactive variables
const API_URL = import.meta.env.VITE_API_URL;
const uploadedFiles = ref([...props.modelValue]);
const emit = defineEmits(['update:modelValue', 'upload', 'uploaded', 'delete']);
const pond = ref(null);
// Define FilePond component
const FilePond = vueFilePond(FilePondPluginFileValidateType, FilePondPluginImagePreview);
const emit = defineEmits(['update:modelValue', 'upload', 'delete']);
const API_URL = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '');
const authHeaders = computed(() => {
const token = Cookies.get('bm-token') || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : '') || '';
return token ? { authorization: `Bearer ${token}` } : {};
});
const resolvedUploadUrl = computed(() => {
let endpoint = props.uploadUrl || props.uploadEndpoint;
if (!endpoint || endpoint === '/api/files/admin/upload-file') {
endpoint = `/api/files/bucket/upload-single-filepond/${props.bucketName || 'temp'}`;
}
if (endpoint.startsWith('http://') || endpoint.startsWith('https://')) {
return endpoint;
}
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
return `${API_URL}${cleanEndpoint}`;
});
const resolvedDeleteUrl = computed(() => {
let endpoint = props.deleteUrl || props.deleteEndpoint;
if (!endpoint) {
endpoint = `/api/files/bucket/delete-file/${props.bucketName || 'temp'}`;
}
if (endpoint.startsWith('http://') || endpoint.startsWith('https://')) {
return endpoint;
}
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
return `${API_URL}${cleanEndpoint}`;
});
// Handle server responses for file processing
function handleProcessResponse(response) {
if (props.mode === 'public') {
const data = JSON.parse(response);
const publicUrl = data.data[0].fileRecord.publicUrl;
const fileName = data.data[0].fileRecord.fileName;
uploadedFiles.value.push(publicUrl);
emit('update:modelValue', uploadedFiles);
emit('upload', uploadedFiles);
return publicUrl;
} else {
const data = JSON.parse(response);
const privateUrl = data.data[0].fileRecord.privateUrl;
const fileName = data.data[0].fileRecord.fileName;
uploadedFiles.value.push(privateUrl);
emit('update:modelValue', uploadedFiles);
emit('upload', uploadedFiles);
return privateUrl;
let parsed;
try {
parsed = typeof response === 'string' ? JSON.parse(response) : response;
} catch (e) {
console.error('Failed to parse file upload response:', e);
return '';
}
let fileRecord = null;
if (Array.isArray(parsed?.data)) {
fileRecord = parsed.data[0]?.fileRecord || parsed.data[0];
} else if (parsed?.data?.fileRecord) {
fileRecord = parsed.data.fileRecord;
} else if (parsed?.data) {
fileRecord = parsed.data;
} else {
fileRecord = parsed;
}
const fileUrl = props.mode === 'private' ? fileRecord?.privateUrl || fileRecord?.fileURL || fileRecord?.url || '' : fileRecord?.publicUrl || fileRecord?.fileURL || fileRecord?.url || '';
const fileName = fileRecord?.fileName || fileRecord?.name || (fileUrl ? fileUrl.split('/').pop() : 'file');
const newItem = {
name: fileName,
url: fileUrl,
progress: 100,
bucketName: props.bucketName
};
const currentList = Array.isArray(props.modelValue) ? [...props.modelValue] : [];
const isStringArray = currentList.length > 0 && typeof currentList[0] === 'string';
currentList.push(isStringArray ? fileUrl : newItem);
emit('update:modelValue', currentList);
emit('upload', newItem);
emit('uploaded', newItem);
return fileName || fileUrl;
}
// Handle server responses for file revert
function handleRevertResponse(response) {
const data = JSON.parse(response);
const fileName = data.data;
// Find and remove the file name from uploadedFiles
const fileIndex = uploadedFiles.value.findIndex((name) => name === fileName);
if (fileIndex !== -1) {
uploadedFiles.value.splice(fileIndex, 1);
emit('update:modelValue', uploadedFiles);
emit('delete', uploadedFiles);
let deletedIdentifier = '';
try {
const parsed = typeof response === 'string' ? JSON.parse(response) : response;
deletedIdentifier = parsed?.data || parsed?.fileName || response;
} catch (e) {
deletedIdentifier = response;
}
return fileName;
removeFromFileList(deletedIdentifier);
emit('delete', deletedIdentifier);
return deletedIdentifier;
}
function handleRemoveFile(error, fileItem) {
if (error) return;
const identifier = fileItem?.serverId || fileItem?.source || fileItem?.filename || '';
if (identifier) {
removeFromFileList(identifier);
}
}
function removeFromFileList(identifier) {
if (!Array.isArray(props.modelValue) || !identifier) return;
const updated = props.modelValue.filter((item) => {
if (typeof item === 'string') {
return item !== identifier && !item.includes(identifier);
}
if (typeof item === 'object' && item !== null) {
const u = item.url || item.source || item.name || '';
return u !== identifier && !u.includes(identifier);
}
return true;
});
emit('update:modelValue', updated);
}
</script>
<template>
<div class="image-uploader-wrapper">
<FilePond
ref="pond"
name="filepond-image"
:max-files="maxFiles"
:allow-multiple="allowMultiple"
store-as-file="true"
:server="{
process: {
url: resolvedUploadUrl,
method: 'POST',
withCredentials: true,
headers: authHeaders,
onload: (response) => handleProcessResponse(response)
},
revert: {
url: resolvedDeleteUrl,
method: 'DELETE',
withCredentials: true,
headers: authHeaders,
onload: (response) => handleRevertResponse(response)
}
}"
:label-idle="'برای آپلود کلیک کنید یا فایل را بکشید و رها کنید <span class=\'filepond--label-action\'>انتخاب فایل</span>'"
label-tap-to-undo="برای حذف کلیک کنید"
label-file-processing="در حال آپلود..."
label-file-processing-complete="آپلود فایل تکمیل شد"
@removefile="handleRemoveFile"
/>
</div>
</template>
<style>
/* Apply dark mode styles to FilePond component */
/* FilePond Container & Theme Styling */
.image-uploader-wrapper {
width: 100%;
}
.filepond--root {
font-family: 'Estedad FD', sans-serif;
background-color: var(--surface-ground);
color: var(--text-color);
border-radius: 8px;
font-family: inherit;
margin-bottom: 0 !important;
}
.app-dark .filepond--root {
background-color: var(--code-background);
border-radius: 8px;
.filepond--panel-root {
border-radius: 0.75rem !important;
background-color: var(--surface-100, #f1f5f9) !important;
border: 2px dashed var(--surface-300, #cbd5e1) !important;
transition: all 0.2s ease;
}
.filepond--label {
color: var(--text-color-secondary);
.app-dark .filepond--panel-root,
.dark .filepond--panel-root {
background-color: var(--surface-800, #1e293b) !important;
border-color: var(--surface-700, #334155) !important;
}
.app-dark .filepond--label {
color: var(--code-color);
.filepond--drop-label {
min-height: 5.5rem !important;
color: var(--text-color-secondary, #64748b) !important;
cursor: pointer;
font-size: 0.875rem !important;
}
.app-dark .filepond--drop-label {
background-color: var(--code-background);
color: var(--code-color);
border: 1px solid var(--surface-border);
border-radius: 8px;
.app-dark .filepond--drop-label,
.dark .filepond--drop-label {
color: var(--text-color-secondary, #94a3b8) !important;
}
.filepond--label-action {
color: var(--primary-color, #10b981) !important;
font-weight: 600;
text-decoration: underline;
text-underline-offset: 3px;
}
.filepond--item-panel {
background-color: var(--surface-card);
}
.app-dark .filepond--item-panel {
background-color: var(--code-background);
background-color: var(--primary-color, #10b981) !important;
}
</style>
+636
View File
@@ -0,0 +1,636 @@
<script setup>
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
const props = defineProps({
visible: {
type: Boolean,
default: false
},
images: {
type: Array,
default: () => []
},
initialIndex: {
type: Number,
default: 0
}
});
const emit = defineEmits(['update:visible', 'change', 'close']);
// Viewport and container refs
const modalContainerRef = ref(null);
const viewportRef = ref(null);
const imageRef = ref(null);
// Active state
const currentIndex = ref(0);
const loading = ref(true);
const isFullscreen = ref(false);
const showThumbnails = ref(true);
// Zoom and Transform states
const scale = ref(1);
const minScale = 0.2;
const maxScale = 8.0;
const translateX = ref(0);
const translateY = ref(0);
const rotation = ref(0);
const flipH = ref(1);
const flipV = ref(1);
// Drag & Pan state
const isDragging = ref(false);
const dragStartX = ref(0);
const dragStartY = ref(0);
const initialTranslateX = ref(0);
const initialTranslateY = ref(0);
// Touch tracking for pinch-to-zoom
let initialTouchDistance = 0;
let initialTouchScale = 1;
// Filter only valid image items
function isImageItem(item) {
const url = getImageUrl(item);
if (!url || typeof url !== 'string') return false;
const clean = url.split('?')[0].toLowerCase();
const ext = clean.split('.').pop();
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'avif'].includes(ext) || clean.startsWith('blob:') || clean.startsWith('data:image/');
}
const validImages = computed(() => {
if (!props.images || !Array.isArray(props.images)) return [];
return props.images.filter(isImageItem);
});
const currentImage = computed(() => {
return validImages.value[currentIndex.value] || null;
});
const currentImageUrl = computed(() => {
return getImageUrl(currentImage.value);
});
const currentImageName = computed(() => {
return getImageName(currentImage.value);
});
const isActualSize = computed(() => {
return Math.abs(scale.value - 1) < 0.05 && translateX.value === 0 && translateY.value === 0;
});
function getImageUrl(item) {
if (!item) return '';
if (typeof item === 'string') return item;
return item.url || item.source || item.src || '';
}
function getImageName(item) {
if (!item) return 'تصویر';
if (typeof item === 'string') {
const clean = item.split('?')[0];
try {
return decodeURIComponent(clean.split('/').pop() || 'تصویر');
} catch {
return clean.split('/').pop() || 'تصویر';
}
}
const name = item.fileName || item.name || '';
if (name) return name;
const url = item.url || item.source || '';
const clean = url.split('?')[0];
try {
return decodeURIComponent(clean.split('/').pop() || 'تصویر');
} catch {
return clean.split('/').pop() || 'تصویر';
}
}
function formatPersianNumber(num) {
if (num === null || num === undefined) return '';
return num.toLocaleString('fa-IR');
}
// Transform matrix
const transformStyle = computed(() => {
return {
transform: `translate3d(${translateX.value}px, ${translateY.value}px, 0) scale(${scale.value}) rotate(${rotation.value}deg) scaleX(${flipH.value}) scaleY(${flipV.value})`,
transition: isDragging.value ? 'none' : 'transform 0.18s cubic-bezier(0.2, 0, 0.2, 1)'
};
});
// Watch visibility
watch(
() => props.visible,
(val) => {
if (val) {
currentIndex.value = Math.max(0, Math.min(props.initialIndex, validImages.value.length - 1));
resetTransform();
loading.value = true;
nextTick(() => {
modalContainerRef.value?.focus();
});
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
}
}
},
{ immediate: true }
);
watch(
() => props.initialIndex,
(idx) => {
if (props.visible) {
currentIndex.value = Math.max(0, Math.min(idx, validImages.value.length - 1));
resetTransform();
loading.value = true;
}
}
);
function selectImage(idx) {
if (idx >= 0 && idx < validImages.value.length) {
currentIndex.value = idx;
resetTransform();
loading.value = true;
emit('change', idx);
}
}
function prevImage() {
if (validImages.value.length <= 1) return;
const nextIdx = (currentIndex.value - 1 + validImages.value.length) % validImages.value.length;
selectImage(nextIdx);
}
function nextImage() {
if (validImages.value.length <= 1) return;
const nextIdx = (currentIndex.value + 1) % validImages.value.length;
selectImage(nextIdx);
}
function onImageLoaded() {
loading.value = false;
}
function onImageError() {
loading.value = false;
}
// Zoom operations
function zoomIn() {
const newScale = Math.min(scale.value * 1.25, maxScale);
scale.value = Number(newScale.toFixed(2));
}
function zoomOut() {
const newScale = Math.max(scale.value / 1.25, minScale);
scale.value = Number(newScale.toFixed(2));
if (scale.value <= 1) {
translateX.value = 0;
translateY.value = 0;
}
}
function toggleActualSize() {
if (Math.abs(scale.value - 1) < 0.05) {
scale.value = 2.0;
} else {
resetTransform();
}
}
function rotateRight() {
rotation.value = (rotation.value + 90) % 360;
}
function rotateLeft() {
rotation.value = (rotation.value - 90 + 360) % 360;
}
function toggleFlipH() {
flipH.value = flipH.value === 1 ? -1 : 1;
}
function toggleFlipV() {
flipV.value = flipV.value === 1 ? -1 : 1;
}
function resetTransform() {
scale.value = 1;
translateX.value = 0;
translateY.value = 0;
rotation.value = 0;
flipH.value = 1;
flipV.value = 1;
}
// Mouse Wheel Zoom
function handleWheel(e) {
const zoomFactor = e.deltaY < 0 ? 1.15 : 0.87;
const newScale = Math.min(Math.max(scale.value * zoomFactor, minScale), maxScale);
if (newScale === scale.value) return;
if (viewportRef.value) {
const rect = viewportRef.value.getBoundingClientRect();
const mouseX = e.clientX - (rect.left + rect.width / 2);
const mouseY = e.clientY - (rect.top + rect.height / 2);
// Calculate offset zoom
translateX.value = mouseX - (mouseX - translateX.value) * (newScale / scale.value);
translateY.value = mouseY - (mouseY - translateY.value) * (newScale / scale.value);
}
scale.value = Number(newScale.toFixed(2));
if (scale.value <= 1) {
translateX.value = 0;
translateY.value = 0;
}
}
// Double Click Zoom
function onDoubleClick(e) {
if (scale.value <= 1.1) {
if (viewportRef.value) {
const rect = viewportRef.value.getBoundingClientRect();
const mouseX = e.clientX - (rect.left + rect.width / 2);
const mouseY = e.clientY - (rect.top + rect.height / 2);
translateX.value = -mouseX * 1.5;
translateY.value = -mouseY * 1.5;
}
scale.value = 2.5;
} else {
resetTransform();
}
}
// Mouse Drag / Pan
function onMouseDown(e) {
if (e.button !== 0) return; // Only left click
isDragging.value = true;
dragStartX.value = e.clientX;
dragStartY.value = e.clientY;
initialTranslateX.value = translateX.value;
initialTranslateY.value = translateY.value;
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}
function onMouseMove(e) {
if (!isDragging.value) return;
const dx = e.clientX - dragStartX.value;
const dy = e.clientY - dragStartY.value;
translateX.value = initialTranslateX.value + dx;
translateY.value = initialTranslateY.value + dy;
}
function onMouseUp() {
isDragging.value = false;
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
}
// Touch Handling for Mobile / Tablets
function onTouchStart(e) {
if (e.touches.length === 2) {
// Pinch zoom
const t1 = e.touches[0];
const t2 = e.touches[1];
initialTouchDistance = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
initialTouchScale = scale.value;
} else if (e.touches.length === 1) {
// Pan
isDragging.value = true;
dragStartX.value = e.touches[0].clientX;
dragStartY.value = e.touches[0].clientY;
initialTranslateX.value = translateX.value;
initialTranslateY.value = translateY.value;
}
}
function onTouchMove(e) {
if (e.touches.length === 2 && initialTouchDistance > 0) {
const t1 = e.touches[0];
const t2 = e.touches[1];
const currentDistance = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
const factor = currentDistance / initialTouchDistance;
scale.value = Math.min(Math.max(initialTouchScale * factor, minScale), maxScale);
} else if (e.touches.length === 1 && isDragging.value) {
const dx = e.touches[0].clientX - dragStartX.value;
const dy = e.touches[0].clientY - dragStartY.value;
translateX.value = initialTranslateX.value + dx;
translateY.value = initialTranslateY.value + dy;
}
}
function onTouchEnd() {
isDragging.value = false;
initialTouchDistance = 0;
}
// Fullscreen
function toggleFullscreen() {
if (!document.fullscreenElement) {
modalContainerRef.value
?.requestFullscreen?.()
.then(() => {
isFullscreen.value = true;
})
.catch(() => {});
} else {
document
.exitFullscreen?.()
.then(() => {
isFullscreen.value = false;
})
.catch(() => {});
}
}
// Download
async function downloadCurrentImage() {
const url = currentImageUrl.value;
if (!url) return;
const filename = currentImageName.value || 'image.jpg';
try {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
} catch {
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.target = '_blank';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
}
// Close
function closeViewer() {
emit('update:visible', false);
emit('close');
}
// Keyboard shortcuts
function handleKeydown(e) {
if (!props.visible) return;
switch (e.key) {
case 'Escape':
closeViewer();
break;
case 'ArrowRight':
prevImage();
break;
case 'ArrowLeft':
nextImage();
break;
case '+':
case '=':
zoomIn();
break;
case '-':
case '_':
zoomOut();
break;
case '0':
resetTransform();
break;
case 'r':
case 'R':
case 'ق': // Persian layout mapping
rotateRight();
break;
case 'f':
case 'F':
case 'ب':
toggleFullscreen();
break;
}
}
function onFullscreenChange() {
isFullscreen.value = !!document.fullscreenElement;
}
onMounted(() => {
document.addEventListener('fullscreenchange', onFullscreenChange);
});
onBeforeUnmount(() => {
document.removeEventListener('fullscreenchange', onFullscreenChange);
document.body.style.overflow = '';
});
</script>
<template>
<Teleport to="body">
<Transition name="iv-fade">
<div v-if="visible" ref="modalContainerRef" class="fixed inset-0 z-[99999] flex flex-col bg-black/92 backdrop-blur-xl text-white select-none overflow-hidden font-sans outline-none" tabindex="-1" @keydown="handleKeydown">
<!-- TOP HEADER BAR -->
<div class="relative z-20 flex items-center justify-between px-4 py-3 bg-gradient-to-b from-black/80 via-black/40 to-transparent">
<!-- Left / Image Counter & Name -->
<div class="flex items-center gap-3 max-w-[60%] truncate">
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-white/10 text-white/90 backdrop-blur-md border border-white/10 tracking-wide">
{{ formatPersianNumber(currentIndex + 1) }} / {{ formatPersianNumber(validImages.length) }}
</span>
<h3 class="text-sm font-medium text-white/80 truncate" :title="currentImageName">
{{ currentImageName }}
</h3>
</div>
<!-- Right / Top Action Controls -->
<div class="flex items-center gap-2">
<!-- Fullscreen -->
<button type="button" class="iv-btn" :title="isFullscreen ? 'خروج از تمام صفحه (F)' : 'تمام صفحه (F)'" @click="toggleFullscreen">
<i :class="isFullscreen ? 'pi pi-window-minimize' : 'pi pi-window-maximize'" class="text-sm"></i>
</button>
<!-- Download -->
<button type="button" class="iv-btn" title="دانلود تصویر" @click="downloadCurrentImage">
<i class="pi pi-download text-sm"></i>
</button>
<!-- Close Button -->
<button type="button" class="iv-btn iv-btn-close" title="بستن (Esc)" @click="closeViewer">
<i class="pi pi-times text-base font-bold"></i>
</button>
</div>
</div>
<!-- MAIN IMAGE VIEWPORT -->
<div
ref="viewportRef"
class="relative flex-1 w-full h-full flex items-center justify-center overflow-hidden cursor-grab active:cursor-grabbing touch-none"
@wheel.prevent="handleWheel"
@mousedown="onMouseDown"
@dblclick="onDoubleClick"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
>
<!-- Loading Spinner -->
<div v-if="loading" class="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<i class="pi pi-spin pi-spinner text-4xl text-primary-400"></i>
</div>
<!-- Image Container with Hardware Accelerated Transform -->
<div v-show="currentImageUrl" class="relative will-change-transform flex items-center justify-center max-w-full max-h-full" :style="transformStyle">
<img
ref="imageRef"
:src="currentImageUrl"
:alt="currentImageName"
class="max-w-[85vw] max-h-[75vh] object-contain pointer-events-none select-none drop-shadow-2xl transition-opacity duration-200"
:class="{ 'opacity-0': loading, 'opacity-100': !loading }"
crossorigin="anonymous"
@load="onImageLoaded"
@error="onImageError"
/>
</div>
<!-- Navigation Arrow: Previous (Right side in RTL, left side in LTR) -->
<button v-if="validImages.length > 1" type="button" class="iv-nav-btn absolute right-4 top-1/2 -translate-y-1/2 z-20" title="تصویر قبلی (کلید جهت‌نما)" @click.stop="prevImage">
<i class="pi pi-chevron-right text-xl"></i>
</button>
<!-- Navigation Arrow: Next (Left side in RTL, right side in LTR) -->
<button v-if="validImages.length > 1" type="button" class="iv-nav-btn absolute left-4 top-1/2 -translate-y-1/2 z-20" title="تصویر بعدی (کلید جهت‌نما)" @click.stop="nextImage">
<i class="pi pi-chevron-left text-xl"></i>
</button>
</div>
<!-- FLOATING BOTTOM TOOLBAR -->
<div class="relative z-20 flex flex-col items-center gap-2 pb-3 px-4 pointer-events-none">
<div class="pointer-events-auto flex items-center gap-1.5 p-1.5 rounded-2xl bg-surface-900/80 dark:bg-surface-950/80 backdrop-blur-xl border border-white/10 shadow-2xl">
<!-- Zoom Out -->
<button type="button" class="iv-tool-btn" title="کوچک‌نمایی (-)" :disabled="scale <= minScale" @click="zoomOut">
<i class="pi pi-search-minus text-sm"></i>
</button>
<!-- Zoom Percentage / Reset Zoom -->
<button type="button" class="iv-tool-btn px-3 font-mono text-xs font-semibold text-white/90 min-w-[54px] text-center" title="بازنشانی بزرگ‌نمایی (0)" @click="resetTransform">{{ Math.round(scale * 100) }}%</button>
<!-- Zoom In -->
<button type="button" class="iv-tool-btn" title="بزرگ‌نمایی (+)" :disabled="scale >= maxScale" @click="zoomIn">
<i class="pi pi-search-plus text-sm"></i>
</button>
<div class="w-[1px] h-5 bg-white/15 mx-1"></div>
<!-- 1:1 Actual Size -->
<button type="button" class="iv-tool-btn" :class="{ 'text-primary-400 bg-white/10': isActualSize }" title="اندازه واقعی (۱:۱)" @click="toggleActualSize">
<span class="text-xs font-bold font-mono">1:1</span>
</button>
<!-- Rotate Counter-Clockwise -->
<button type="button" class="iv-tool-btn" title="چرخش ۹۰ درجه پادساعت‌گرد" @click="rotateLeft">
<i class="pi pi-replay text-sm"></i>
</button>
<!-- Rotate Clockwise -->
<button type="button" class="iv-tool-btn" title="چرخش ۹۰ درجه ساعت‌گرد (R)" @click="rotateRight">
<i class="pi pi-refresh text-sm"></i>
</button>
<!-- Flip Horizontal -->
<button type="button" class="iv-tool-btn" :class="{ 'text-primary-400 bg-white/10': flipH === -1 }" title="قرینه افقی" @click="toggleFlipH">
<i class="pi pi-arrows-h text-sm"></i>
</button>
<!-- Flip Vertical -->
<button type="button" class="iv-tool-btn" :class="{ 'text-primary-400 bg-white/10': flipV === -1 }" title="قرینه عمودی" @click="toggleFlipV">
<i class="pi pi-arrows-v text-sm"></i>
</button>
<div class="w-[1px] h-5 bg-white/15 mx-1"></div>
<!-- Reset Everything -->
<button type="button" class="iv-tool-btn" title="تنظیم مجدد موقعیت و چرخش" @click="resetTransform">
<i class="pi pi-undo text-sm"></i>
</button>
<!-- Toggle Thumbnail Strip -->
<button v-if="validImages.length > 1" type="button" class="iv-tool-btn" :class="{ 'text-primary-400 bg-white/10': showThumbnails }" title="نمایش/مخفی‌سازی گالری تصاویر" @click="showThumbnails = !showThumbnails">
<i class="pi pi-images text-sm"></i>
</button>
</div>
<!-- THUMBNAIL STRIP CAROUSEL -->
<Transition name="iv-slide-up">
<div
v-if="showThumbnails && validImages.length > 1"
class="pointer-events-auto flex items-center gap-2 p-2 rounded-2xl bg-black/60 backdrop-blur-xl border border-white/10 max-w-[90vw] overflow-x-auto scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent"
>
<button
v-for="(img, idx) in validImages"
:key="idx"
type="button"
class="relative flex-shrink-0 w-14 h-14 rounded-xl overflow-hidden border-2 transition-all duration-200 cursor-pointer focus:outline-none"
:class="idx === currentIndex ? 'border-primary-400 ring-2 ring-primary-400/40 scale-105 opacity-100 shadow-lg' : 'border-white/20 opacity-60 hover:opacity-100 hover:border-white/50'"
@click="selectImage(idx)"
>
<img :src="getImageUrl(img)" :alt="getImageName(img)" class="w-full h-full object-cover" crossorigin="anonymous" />
</button>
</div>
</Transition>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.iv-btn {
@apply flex items-center justify-center w-9 h-9 rounded-full bg-white/10 hover:bg-white/20 active:bg-white/30 text-white/90 transition-all backdrop-blur-md border border-white/10 cursor-pointer shadow-md focus:outline-none;
}
.iv-btn-close {
@apply bg-red-500/80 hover:bg-red-600 text-white border-red-400/30;
}
.iv-nav-btn {
@apply flex items-center justify-center w-12 h-12 rounded-full bg-surface-900/70 hover:bg-surface-900 text-white/80 hover:text-white transition-all backdrop-blur-xl border border-white/15 shadow-2xl cursor-pointer hover:scale-110 active:scale-95 focus:outline-none;
}
.iv-tool-btn {
@apply flex items-center justify-center h-8 px-2.5 rounded-xl text-white/80 hover:text-white hover:bg-white/10 active:bg-white/20 transition-all cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed focus:outline-none;
}
/* Animations */
.iv-fade-enter-active,
.iv-fade-leave-active {
transition: opacity 0.25s ease;
}
.iv-fade-enter-from,
.iv-fade-leave-to {
opacity: 0;
}
.iv-slide-up-enter-active,
.iv-slide-up-leave-active {
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
.iv-slide-up-enter-from,
.iv-slide-up-leave-to {
opacity: 0;
transform: translateY(12px);
}
</style>
+8 -8
View File
@@ -1,11 +1,3 @@
<template>
<!-- Outer container that mimics a text field with a green border -->
<div class="flex flex-wrap items-center gap-2 rounded p-2 cursor-text" @click="focusInput">
<InputText v-model="inputValue" @keyup.enter="addChip" :placeholder="placeholder" class="w-full min-w-[100px]" />
<Chip v-for="(tag, index) in localTags" :key="tag" :label="tag" removable @remove="removeChip(index)" class="text-sm" />
</div>
</template>
<script setup>
import { ref, watch } from 'vue';
import Chip from 'primevue/chip';
@@ -51,6 +43,14 @@ function removeChip(index) {
}
</script>
<template>
<!-- Outer container that mimics a text field with a green border -->
<div class="flex flex-wrap items-center gap-2 rounded p-2 cursor-text" @click="focusInput">
<InputText v-model="inputValue" @keyup.enter="addChip" :placeholder="placeholder" class="w-full min-w-[100px]" />
<Chip v-for="(tag, index) in localTags" :key="tag" :label="tag" removable @remove="removeChip(index)" class="text-sm" />
</div>
</template>
<!--
No <style> block is needed if you're using Tailwind CSS utility classes.
However, if you need to override or add custom CSS, do it here or in a global stylesheet.
+7 -7
View File
@@ -1,10 +1,3 @@
<template>
<FloatLabel>
<ToggleSwitch :id="id" v-model="computedValue" :class="inputClass" @change="handleChange" @keyup="handleKeyUp" />
<label :for="id">label</label>
</FloatLabel>
</template>
<script setup>
import ToggleSwitch from 'primevue/toggleswitch';
import { computed } from 'vue';
@@ -46,3 +39,10 @@ const handleKeyUp = (event) => {
emit('key-up-event', event);
};
</script>
<template>
<FloatLabel>
<ToggleSwitch :id="id" v-model="computedValue" :class="inputClass" @change="handleChange" @keyup="handleKeyUp" />
<label :for="id">label</label>
</FloatLabel>
</template>
+577 -86
View File
@@ -1,122 +1,613 @@
<template>
<div class="map-container">
<!-- Search box -->
<div class="search-container">
<input v-model="searchQuery" @keyup.enter="handleSearch" placeholder="جستجو" class="search-input" />
</div>
<l-map ref="map" v-model:zoom="zoom" :center="center" :use-global-leaflet="false" @click="handleClick($event)">
<!-- Base layers -->
<l-control-layers position="topright" />
<!-- OpenStreetMap layer (default) -->
<l-tile-layer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" layer-type="base" name="نمای نقشه ای" :attribution="attribution" />
<!-- Satellite layer (optional) -->
<l-tile-layer
:visible="false"
url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
layer-type="base"
name="نمای ماهواره ای"
attribution="Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community"
/>
<!-- Marker -->
<l-marker v-if="marker.length > 0" :lat-lng="marker">
<l-popup> نقطه انتخاب شده </l-popup>
</l-marker>
</l-map>
</div>
</template>
<script setup>
import { ref, watch } from 'vue';
import { ref, watch, nextTick, onBeforeUnmount } from 'vue';
import 'leaflet/dist/leaflet.css';
import { LMap, LTileLayer, LMarker, LPopup, LControlLayers } from '@vue-leaflet/vue-leaflet';
import { LMap, LTileLayer, LMarker, LPopup, LControlLayers, LControlScale } from '@vue-leaflet/vue-leaflet';
import axios from 'axios';
// Props and emits
// ── Props & Emits ──────────────────────────────────────────────────────────
const props = defineProps({
modelValue: {
type: Array,
default: () => []
modelValue: { type: Array, default: () => [] },
lon: { type: Number },
lat: { type: Number },
locked: { type: Boolean, default: false }
});
const emit = defineEmits(['update:modelValue', 'marker-updated', 'cleared']);
// ── State ──────────────────────────────────────────────────────────────────
const zoom = ref(12);
const center = ref([31.3183, 48.6706]);
const attribution = ref('© OpenStreetMap contributors');
const marker = ref([...(props.modelValue || [])]);
const map = ref(null);
const mapContainer = ref(null);
const searchQuery = ref('');
const hoverCoords = ref(null);
const copied = ref(false);
const isLocating = ref(false);
const isFullscreen = ref(false);
// ── Core: set marker + flyTo ───────────────────────────────────────────────
const setMarker = (latVal, lonVal) => {
if (latVal !== null && lonVal !== null && !isNaN(latVal) && !isNaN(lonVal)) {
const numLat = parseFloat(latVal);
const numLon = parseFloat(lonVal);
if (marker.value && marker.value.length === 2 && marker.value[0] === numLat && marker.value[1] === numLon) {
return;
}
const newCoords = [numLat, numLon];
marker.value = newCoords;
center.value = newCoords;
zoom.value = 16;
emit('update:modelValue', newCoords);
emit('marker-updated', newCoords);
nextTick(() => {
if (map.value?.leafletObject) {
map.value.leafletObject.flyTo(newCoords, 16, { animate: true, duration: 0.8 });
}
});
}
};
const clearMarker = () => {
marker.value = [];
emit('update:modelValue', []);
emit('marker-updated', []);
emit('cleared'); // notify parent to also reset nationalGeoCode
};
// ── Fullscreen ─────────────────────────────────────────────────────────────
const toggleFullscreen = () => {
if (!isFullscreen.value) {
if (mapContainer.value?.requestFullscreen) {
mapContainer.value.requestFullscreen();
} else if (mapContainer.value?.webkitRequestFullscreen) {
mapContainer.value.webkitRequestFullscreen();
}
} else {
if (document.exitFullscreen) document.exitFullscreen();
else if (document.webkitExitFullscreen) document.webkitExitFullscreen();
}
};
const onFullscreenChange = () => {
isFullscreen.value = !!document.fullscreenElement;
nextTick(() => map.value?.leafletObject?.invalidateSize());
};
if (typeof document !== 'undefined') {
document.addEventListener('fullscreenchange', onFullscreenChange);
}
onBeforeUnmount(() => {
if (typeof document !== 'undefined') {
document.removeEventListener('fullscreenchange', onFullscreenChange);
}
});
const emit = defineEmits(['update:modelValue']);
// ── Locate Me ─────────────────────────────────────────────────────────────
const locateMe = () => {
if (!navigator.geolocation) return;
isLocating.value = true;
navigator.geolocation.getCurrentPosition(
(pos) => {
isLocating.value = false;
setMarker(pos.coords.latitude, pos.coords.longitude);
},
() => {
isLocating.value = false;
},
{ timeout: 10000, enableHighAccuracy: true }
);
};
// Map configuration
const zoom = ref(13);
const center = ref([31.3183, 48.6706]);
const attribution = ref('© OpenStreetMap contributors');
const marker = ref([...props.modelValue]);
const map = ref(null);
// Search functionality
const searchQuery = ref('');
const handleSearch = async () => {
if (!searchQuery.value) return;
try {
const response = await axios.get(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(searchQuery.value)}`);
if (response.data && response.data.length > 0) {
const { lat, lon } = response.data[0];
center.value = [parseFloat(lat), parseFloat(lon)];
zoom.value = 16;
marker.value = [parseFloat(lat), parseFloat(lon)];
emit('update:modelValue', marker.value);
}
} catch (error) {
console.error('Search failed:', error);
// ── Mouse hover coords ─────────────────────────────────────────────────────
const handleMouseMove = (event) => {
if (event?.latlng) {
hoverCoords.value = { lat: event.latlng.lat, lon: event.latlng.lng };
}
};
// Watch for changes in modelValue and sync with marker
// ── Copy coords ────────────────────────────────────────────────────────────
const copyCoords = () => {
if (!marker.value.length) return;
const text = `${marker.value[0].toFixed(6)}, ${marker.value[1].toFixed(6)}`;
navigator.clipboard?.writeText(text).then(() => {
copied.value = true;
setTimeout(() => (copied.value = false), 2000);
});
};
// ── Search ─────────────────────────────────────────────────────────────────
const searchLocation = async (query) => {
if (!query || typeof query !== 'string') return;
const trimmed = query.trim();
if (!trimmed) return;
// Direct lat,lon pair
const coordMatch = trimmed.match(/^([+-]?\d+(?:\.\d+)?)\s*[\s,]\s*([+-]?\d+(?:\.\d+)?)$/);
if (coordMatch) {
setMarker(parseFloat(coordMatch[1]), parseFloat(coordMatch[2]));
return;
}
// Geocoding
try {
const res = await axios.get(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(trimmed)}&limit=1`);
if (res.data?.length > 0) {
const { lat, lon } = res.data[0];
setMarker(parseFloat(lat), parseFloat(lon));
searchQuery.value = '';
}
} catch (e) {
console.error('Geo search failed:', e);
}
};
const handleSearch = () => searchLocation(searchQuery.value);
// ── Click on map ───────────────────────────────────────────────────────────
const handleClick = (event) => {
// When locked (nationalGeoCode is set), ignore map clicks — use trash to unlock
if (props.locked) return;
const coords = [event.latlng.lat, event.latlng.lng];
marker.value = coords;
emit('update:modelValue', coords);
emit('marker-updated', coords);
};
// ── Ready handler ──────────────────────────────────────────────────────────
const onMapReady = () => {
if (Array.isArray(marker.value) && marker.value.length === 2 && marker.value[0] && marker.value[1]) {
center.value = [...marker.value];
zoom.value = 16;
nextTick(() => {
if (map.value?.leafletObject) {
map.value.leafletObject.setView(marker.value, 16);
map.value.leafletObject.invalidateSize();
}
});
}
};
// ── Sync modelValue prop → marker ──────────────────────────────────────────
watch(
() => props.modelValue,
(newValue) => {
marker.value = [...newValue];
(val) => {
if (Array.isArray(val) && val.length === 2 && val[0] !== undefined && val[1] !== undefined && !isNaN(val[0]) && !isNaN(val[1])) {
const newCoords = [parseFloat(val[0]), parseFloat(val[1])];
if (marker.value && marker.value.length === 2 && marker.value[0] === newCoords[0] && marker.value[1] === newCoords[1]) {
return;
}
marker.value = newCoords;
center.value = newCoords;
zoom.value = 16;
nextTick(() => {
if (map.value?.leafletObject) {
map.value.leafletObject.flyTo(newCoords, 16, { animate: true, duration: 0.8 });
map.value.leafletObject.invalidateSize();
}
});
} else if (Array.isArray(val) && val.length === 0) {
marker.value = [];
}
},
{ immediate: true }
{ immediate: true, deep: true }
);
// Update marker and emit changes to modelValue
const handleClick = (event) => {
marker.value = [event.latlng.lat, event.latlng.lng];
emit('update:modelValue', marker.value);
};
// ── Expose to parent ───────────────────────────────────────────────────────
defineExpose({ searchByCoordinates: setMarker, setCenterAndMarker: setMarker, searchLocation, onMapReady });
</script>
<template>
<div ref="mapContainer" class="map-container" :class="{ 'map-fullscreen': isFullscreen }">
<!-- ── Top-left: Search bar ── -->
<div class="map-overlay search-bar-wrap">
<span class="search-icon">🔍</span>
<input v-model="searchQuery" @keyup.enter="handleSearch" placeholder="جستجو روی نقشه..." class="search-input" />
<button v-if="searchQuery" class="search-clear" @click="searchQuery = ''" title="پاک کردن">✕</button>
</div>
<!-- ── Top-right: Action buttons ── -->
<div class="map-overlay action-buttons">
<!-- Fullscreen toggle -->
<button class="map-btn" @click="toggleFullscreen" :title="isFullscreen ? 'خروج از تمام‌صفحه' : 'تمام‌صفحه'">
<!-- Exit fullscreen icon -->
<svg v-if="isFullscreen" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 3v3a2 2 0 0 1-2 2H3" />
<path d="M21 8h-3a2 2 0 0 1-2-2V3" />
<path d="M3 16h3a2 2 0 0 1 2 2v3" />
<path d="M16 21v-3a2 2 0 0 1 2-2h3" />
</svg>
<!-- Enter fullscreen icon -->
<svg v-else xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 7V3h4" />
<path d="M21 7V3h-4" />
<path d="M3 17v4h4" />
<path d="M21 17v4h-4" />
</svg>
</button>
<!-- Locate me -->
<button class="map-btn map-btn-locate" @click="locateMe" title="موقعیت من" :class="{ locating: isLocating }">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
<circle cx="12" cy="12" r="7" opacity="0.3" />
</svg>
</button>
<!-- Clear marker -->
<button v-if="marker.length > 0" class="map-btn map-btn-danger" @click="clearMarker" title="حذف نشانگر">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4h6v2" />
</svg>
</button>
</div>
<!-- ── Bottom-left: Coordinate readout ── -->
<div v-if="hoverCoords" class="map-overlay coords-readout">
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="opacity: 0.7; flex-shrink: 0">
<circle cx="12" cy="12" r="3" />
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
</svg>
<span>{{ hoverCoords.lat.toFixed(6) }}, {{ hoverCoords.lon.toFixed(6) }}</span>
</div>
<!-- ── Bottom-right: Marker info pill ── -->
<div v-if="marker.length > 0" class="map-overlay marker-info">
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="#6366f1" stroke="#6366f1" stroke-width="1">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z" />
</svg>
<span class="marker-coords">{{ marker[0].toFixed(5) }}, {{ marker[1].toFixed(5) }}</span>
<button class="copy-btn" @click="copyCoords" :title="copied ? 'کپی شد!' : 'کپی مختصات'">
<svg v-if="!copied" xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2.5">
<polyline points="20 6 9 17 4 12" />
</svg>
</button>
</div>
<!-- Lock Indicator -->
<div v-if="locked" class="map-overlay lock-indicator">🔒 قفل شده</div>
<!-- ── Leaflet Map ── -->
<l-map ref="map" v-model:zoom="zoom" :center="center" :use-global-leaflet="false" style="height: 100%; width: 100%" @click="handleClick($event)" @mousemove="handleMouseMove($event)" @mouseout="hoverCoords = null" @ready="onMapReady">
<!-- Layer switcher -->
<l-control-layers position="bottomright" />
<!-- OpenStreetMap -->
<l-tile-layer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" layer-type="base" name="نمای نقشه‌ای" :attribution="attribution" />
<!-- Satellite -->
<l-tile-layer :visible="false" url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" layer-type="base" name="نمای ماهواره‌ای" attribution="Tiles &copy; Esri" />
<!-- Scale control -->
<l-control-scale position="bottomleft" :imperial="false" :maxWidth="150" />
<!-- Marker with rich popup -->
<l-marker v-if="marker.length > 0" :lat-lng="marker">
<l-popup>
<div class="popup-content">
<strong>📍 نقطه انتخاب‌شده</strong><br />
<span>عرض: {{ marker[0].toFixed(6) }}</span
><br />
<span>طول: {{ marker[1].toFixed(6) }}</span>
</div>
</l-popup>
</l-marker>
</l-map>
<!-- Locating spinner overlay -->
<div v-if="isLocating" class="locating-overlay">
<div class="locating-spinner"></div>
<span>در حال تعیین موقعیت...</span>
</div>
</div>
</template>
<style scoped>
/* ── Container ──────────────────────────────────────────────────────────── */
.map-container {
font-family: 'Estedad', sans-serif !important;
height: 500px;
height: 100%;
width: 100%;
position: relative;
border-radius: inherit;
}
.leaflet-container {
font-family: 'Estedad', serif !important;
.map-fullscreen {
border-radius: 0 !important;
}
.search-container {
:deep(.leaflet-container) {
font-family: 'Estedad', sans-serif !important;
border-radius: inherit;
}
/* ── Shared overlay pill style ──────────────────────────────────────────── */
.map-overlay {
position: absolute;
top: 10px;
left: 10px;
z-index: 1000;
background: white;
padding: 5px;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
pointer-events: auto;
}
/* ── Search bar ─────────────────────────────────────────────────────────── */
.search-bar-wrap {
top: 12px;
left: 12px;
display: flex;
align-items: center;
gap: 6px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-radius: 12px;
padding: 6px 10px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
border: 1px solid rgba(255, 255, 255, 0.6);
max-width: 280px;
}
.search-icon {
font-size: 13px;
opacity: 0.5;
}
.search-input {
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
width: 250px;
border: none;
outline: none;
background: transparent;
font-family: 'Estedad', sans-serif;
font-size: 13px;
width: 210px;
color: #1a1a2e;
direction: rtl;
}
.search-input::placeholder {
color: #9ca3af;
}
.search-clear {
background: none;
border: none;
cursor: pointer;
color: #9ca3af;
font-size: 12px;
padding: 0 2px;
line-height: 1;
transition: color 0.15s;
}
.search-clear:hover {
color: #ef4444;
}
/* ── Action buttons (top-right) ─────────────────────────────────────────── */
.action-buttons {
top: 12px;
right: 12px; /* layer control is now at bottomright, so no offset needed */
display: flex;
flex-direction: column;
gap: 6px;
}
.map-btn {
width: 36px;
height: 36px;
border-radius: 10px;
border: none;
background: #6366f1;
color: #fff;
box-shadow: 0 3px 10px rgba(99, 102, 241, 0.35);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition:
background 0.15s,
transform 0.1s,
box-shadow 0.15s;
}
.map-btn:hover {
background: #4f46e5;
box-shadow: 0 6px 18px rgba(79, 70, 229, 0.45);
transform: scale(1.06);
}
.map-btn:active {
transform: scale(0.95);
}
.map-btn-locate {
background: #0ea5e9;
box-shadow: 0 3px 10px rgba(14, 165, 233, 0.35);
}
.map-btn-locate:hover {
background: #0284c7;
box-shadow: 0 6px 18px rgba(2, 132, 199, 0.45);
}
.map-btn.locating {
animation: pulse-btn 1s infinite;
}
.map-btn-danger {
background: #ef4444;
box-shadow: 0 3px 10px rgba(239, 68, 68, 0.35);
}
.map-btn-danger:hover {
background: #dc2626;
box-shadow: 0 6px 18px rgba(220, 38, 38, 0.45);
}
@keyframes pulse-btn {
0%,
100% {
box-shadow: 0 0 0 0 rgba(14, 165, 233, 0.5);
}
50% {
box-shadow: 0 0 0 8px rgba(14, 165, 233, 0);
}
}
/* ── Coordinate readout (bottom-left) ────────────────────────────────────────────── */
.coords-readout {
bottom: 62px; /* above the Leaflet scale bar + attribution */
left: 12px;
display: flex;
align-items: center;
gap: 5px;
background: rgba(15, 15, 35, 0.8);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
color: #c7d2fe;
font-size: 11px;
font-family: 'Courier New', monospace;
padding: 5px 10px;
border-radius: 8px;
letter-spacing: 0.04em;
pointer-events: none;
}
/* ── Marker info pill (bottom-right) ─────────────────────────────────────────────── */
.marker-info {
bottom: 62px; /* above the Leaflet layer-switcher control */
right: 12px;
display: flex;
align-items: center;
gap: 6px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-radius: 20px;
padding: 5px 12px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.14);
border: 1px solid rgba(255, 255, 255, 0.6);
font-size: 12px;
color: #1a1a2e;
max-width: 260px;
}
.marker-pin-icon {
font-size: 14px;
}
.marker-coords {
font-family: 'Courier New', monospace;
font-size: 11px;
}
.copy-btn {
background: none;
border: none;
cursor: pointer;
font-size: 14px;
color: #6366f1;
padding: 0;
transition: transform 0.1s;
}
.copy-btn:hover {
transform: scale(1.2);
}
/* ── Locating overlay ───────────────────────────────────────────────────── */
.locating-overlay {
position: absolute;
inset: 0;
z-index: 1100;
background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(4px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
font-size: 14px;
color: #4f46e5;
font-weight: 600;
border-radius: inherit;
}
.locating-spinner {
width: 36px;
height: 36px;
border: 3px solid rgba(99, 102, 241, 0.2);
border-top-color: #6366f1;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ── Lock badge ──────────────────────────────────────────────────────────── */
.lock-badge {
top: 12px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 5px;
background: rgba(99, 102, 241, 0.15);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(99, 102, 241, 0.35);
border-radius: 20px;
padding: 4px 12px;
font-size: 11px;
color: #6366f1;
font-weight: 600;
pointer-events: none;
white-space: nowrap;
}
</style>
<!-- Dark mode overrides (app-dark class on <html>) -->
<style>
/* Search bar */
.app-dark .search-bar-wrap {
background: rgba(30, 30, 50, 0.92) !important;
border-color: rgba(99, 102, 241, 0.25) !important;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4) !important;
}
.app-dark .search-input {
color: #e2e8f0 !important;
}
.app-dark .search-input::placeholder {
color: #6b7280 !important;
}
.app-dark .search-icon {
filter: invert(1) brightness(0.7);
}
/* Marker info pill */
.app-dark .marker-info {
background: rgba(30, 30, 50, 0.92) !important;
border-color: rgba(99, 102, 241, 0.25) !important;
color: #e2e8f0 !important;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.4) !important;
}
/* Locating overlay */
.app-dark .locating-overlay {
background: rgba(15, 15, 30, 0.7) !important;
color: #a5b4fc !important;
}
.app-dark .locating-spinner {
border-color: rgba(99, 102, 241, 0.15) !important;
border-top-color: #818cf8 !important;
}
</style>
+16 -1
View File
@@ -3,8 +3,17 @@ import { PhotoService } from '@/service/PhotoService';
import { ProductService } from '@/service/ProductService';
import { onMounted, ref } from 'vue';
import ImageGallery from '@/views/uikit/ImageGallery.vue';
const products = ref([]);
const images = ref([]);
const sampleGalleryImages = ref([
{ url: 'https://primefaces.org/cdn/primevue/images/galleria/galleria10.jpg', name: 'تصویر منظره شماره ۱' },
{ url: 'https://primefaces.org/cdn/primevue/images/galleria/galleria11.jpg', name: 'تصویر منظره شماره ۲' },
{ url: 'https://primefaces.org/cdn/primevue/images/galleria/galleria12.jpg', name: 'تصویر سند / عمودی' },
{ url: 'https://primefaces.org/cdn/primevue/images/galleria/galleria13.jpg', name: 'تصویر شماره ۴' },
{ url: 'https://example.com/sample-document.pdf', name: 'سند_مالکیت.pdf' }
]);
const galleriaResponsiveOptions = ref([
{
breakpoint: '1024px',
@@ -91,7 +100,13 @@ function getSeverity(status) {
</div>
<div class="card">
<div class="font-semibold text-xl mb-4">Image</div>
<div class="font-semibold text-xl mb-2">Image Gallery with Aspect-Preserving Zoom & Rotate</div>
<p class="text-surface-500 mb-4">گالری پیشرفته تصاویر با بزرگ‌نمایی بدون تغییر نسبت تصویر (بدون کشیدگی)، قابلیت چرخش ۹۰ درجه، قرینه، تمام صفحه و نوار پیش‌نمایش بندانگشتی.</p>
<ImageGallery v-model="sampleGalleryImages" galleryId="demoMediaGallery" />
</div>
<div class="card">
<div class="font-semibold text-xl mb-4">PrimeVue Image</div>
<Image src="https://primefaces.org/cdn/primevue/images/galleria/galleria10.jpg" alt="Image" width="250" />
</div>
+25 -1
View File
@@ -5,6 +5,24 @@ import vue from '@vitejs/plugin-vue';
import Components from 'unplugin-vue-components/vite';
import { defineConfig } from 'vite';
function healthCheckPlugin() {
return {
name: 'vite-plugin-healthcheck',
configureServer(server) {
server.middlewares.use((req, res, next) => {
const url = req.url ? req.url.split('?')[0] : '';
if (url === '/health' || url === '/healthz') {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ status: 'healthy', service: 'dashboard' }));
return;
}
next();
});
}
};
}
// https://vitejs.dev/config/
export default defineConfig({
optimizeDeps: {
@@ -14,11 +32,17 @@ export default defineConfig({
vue(),
Components({
resolvers: [PrimeVueResolver()]
})
}),
healthCheckPlugin()
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
port: 5173,
host: true,
allowedHosts: ['mbmanager.ir', '.mbmanager.ir']
}
});