Compare commits
32
Commits
abddf06a28
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
195d0300a0 | ||
|
|
7c56d2e66a | ||
|
|
712b654d68 | ||
|
|
b6d5bff088 | ||
|
|
9d16b90e18 | ||
|
|
9ec5e22138 | ||
|
|
b0980f1448 | ||
|
|
a155cdd481 | ||
|
|
30c8149008 | ||
|
|
0503845786 | ||
|
|
15dc77a76b | ||
|
|
3723f801e7 | ||
|
|
b81a85f672 | ||
|
|
001ab07485 | ||
|
|
e2aa60c54c | ||
|
|
b6fde3e541 | ||
|
|
859a86f1a5 | ||
|
|
cf7e1ee45c | ||
|
|
ba54647837 | ||
|
|
36f2c200a1 | ||
|
|
119a6950f0 | ||
|
|
a6ece05199 | ||
|
|
7c66377e38 | ||
|
|
d801a81660 | ||
|
|
edd63cbdfc | ||
|
|
395b895686 | ||
|
|
79da0d24ab | ||
|
|
c01fed2622 | ||
|
|
703df418ce | ||
|
|
7421896805 | ||
|
|
3b06bc50a5 | ||
|
|
8bae1d064a |
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+24
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"status":"healthy","service":"dashboard"}
|
||||
+32
-2
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -15,7 +15,7 @@ const layoutState = reactive({
|
||||
configSidebarVisible: false,
|
||||
staticMenuMobileActive: false,
|
||||
menuHoverActive: false,
|
||||
activeMenuItem: null,
|
||||
activeMenuItem: null
|
||||
});
|
||||
|
||||
export function useLayout() {
|
||||
|
||||
+68
-4
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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 || 'خطا در حذف دستهبندی';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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 || 'خطا در حذف ویژگی';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
+62
-6
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }">
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 © Esri — 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 © 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>
|
||||
|
||||
@@ -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
@@ -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']
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user