Compare commits
10
Commits
abddf06a28
...
7c66377e38
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
# Stage 1: Build the Vue SPA application
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy dependency definitions
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# 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
+17
@@ -29,6 +29,8 @@
|
|||||||
"photoswipe": "^5.4.4",
|
"photoswipe": "^5.4.4",
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primevue": "^4.2.4",
|
"primevue": "^4.2.4",
|
||||||
|
"usng.js": "^0.4.5",
|
||||||
|
"utm-latlng": "^1.0.8",
|
||||||
"v-mapbox": "^5.1.0",
|
"v-mapbox": "^5.1.0",
|
||||||
"vue": "^3.4.34",
|
"vue": "^3.4.34",
|
||||||
"vue-cookies": "^1.8.3",
|
"vue-cookies": "^1.8.3",
|
||||||
@@ -4794,12 +4796,27 @@
|
|||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/v-mapbox": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/v-mapbox/-/v-mapbox-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/v-mapbox/-/v-mapbox-5.1.0.tgz",
|
||||||
|
|||||||
@@ -29,6 +29,8 @@
|
|||||||
"photoswipe": "^5.4.4",
|
"photoswipe": "^5.4.4",
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primevue": "^4.2.4",
|
"primevue": "^4.2.4",
|
||||||
|
"usng.js": "^0.4.5",
|
||||||
|
"utm-latlng": "^1.0.8",
|
||||||
"v-mapbox": "^5.1.0",
|
"v-mapbox": "^5.1.0",
|
||||||
"vue": "^3.4.34",
|
"vue": "^3.4.34",
|
||||||
"vue-cookies": "^1.8.3",
|
"vue-cookies": "^1.8.3",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"status":"healthy","service":"dashboard"}
|
||||||
@@ -1,43 +1,38 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ProductService } from '@/service/ProductService';
|
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
import { BelongingService } from '@/service/BelongingService';
|
import { PropertyService } from '@/service/PropertyService';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
const products = ref(null);
|
const properties = ref(null);
|
||||||
|
|
||||||
function formatCurrency(value) {
|
|
||||||
return value.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
BelongingService.getAllBelongings().then((data) => (products.value = data));
|
PropertyService.getAllProperties().then((data) => (properties.value = data));
|
||||||
});
|
});
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
function editBelonging(belongingId) {
|
function editProperty(propertyId) {
|
||||||
router.push('/belonging/edit-belonging/' + belongingId);
|
router.push('/property/edit-property/' + propertyId);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="font-semibold text-xl mb-4">آخرین دارایی های افزوده شده</div>
|
<div class="font-semibold text-xl mb-4">آخرین املاک افزوده شده</div>
|
||||||
<DataTable :value="products" :rows="5" :paginator="true" responsiveLayout="scroll">
|
<DataTable :value="properties" :rows="5" :paginator="true" responsiveLayout="scroll">
|
||||||
<Column style="min-width: 15%" header="Image">
|
<Column style="min-width: 15%" header="تصویر">
|
||||||
<template #body="slotProps">
|
<template #body="slotProps">
|
||||||
<img :src="`${slotProps.data.mainImageUrl}`" :alt="slotProps.data.name" width="50" class="shadow" />
|
<img :src="`${slotProps.data.mainImageUrl || slotProps.data.mainImageUrls?.[0]}`" :alt="slotProps.data.name" width="50" class="shadow" />
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column field="name" header="نام" :sortable="true" style="min-width: 35%"></Column>
|
<Column field="name" header="نام" :sortable="true" style="min-width: 35%"></Column>
|
||||||
<Column field="price" header="دسته بندی" :sortable="true" style="min-width: 30%">
|
<Column field="category" header="دسته بندی" :sortable="true" style="min-width: 30%">
|
||||||
<template #body="slotProps">
|
<template #body="slotProps">
|
||||||
{{ slotProps.data.category?.nativeName }}
|
{{ slotProps.data.category?.nativeName }}
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column style="min-width: 15%" header="نمایش">
|
<Column style="min-width: 15%" header="نمایش">
|
||||||
<template #body="slotProps">
|
<template #body="slotProps">
|
||||||
<Button icon="pi pi-search" type="button" class="p-button-text" @click="editBelonging(slotProps.data._id)"></Button>
|
<Button icon="pi pi-search" type="button" class="p-button-text" @click="editProperty(slotProps.data._id)"></Button>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
|||||||
@@ -11,17 +11,17 @@ function setChartData() {
|
|||||||
const documentStyle = getComputedStyle(document.documentElement);
|
const documentStyle = getComputedStyle(document.documentElement);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
labels: ['زمین', 'ساختمان', 'خودرو', 'ارز'],
|
labels: ['زمین', 'ساختمان'],
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
label: 'تعداد دارایی ها',
|
label: 'تعداد املاک',
|
||||||
backgroundColor: documentStyle.getPropertyValue('--p-primary-400'),
|
backgroundColor: documentStyle.getPropertyValue('--p-primary-400'),
|
||||||
font: {
|
font: {
|
||||||
family: 'Estedad', // Set custom font for y-axis labels
|
family: 'Estedad',
|
||||||
size: 14 // Set font size
|
size: 14
|
||||||
},
|
},
|
||||||
data: [18, 13, 5, 11],
|
data: [18, 13],
|
||||||
barThickness: 32
|
barThickness: 32
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -42,8 +42,8 @@ function setChartOptions() {
|
|||||||
ticks: {
|
ticks: {
|
||||||
color: textMutedColor,
|
color: textMutedColor,
|
||||||
font: {
|
font: {
|
||||||
family: 'Estedad', // Set custom font for y-axis labels
|
family: 'Estedad',
|
||||||
size: 14 // Set font size
|
size: 14
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
@@ -56,8 +56,8 @@ function setChartOptions() {
|
|||||||
ticks: {
|
ticks: {
|
||||||
color: textMutedColor,
|
color: textMutedColor,
|
||||||
font: {
|
font: {
|
||||||
family: 'Estedad', // Set custom font for y-axis labels
|
family: 'Estedad',
|
||||||
size: 14 // Set font size
|
size: 14
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
@@ -83,7 +83,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="font-semibold text-xl mb-4">تقسیم بندی دارایی ها</div>
|
<div class="font-semibold text-xl mb-4">تقسیم بندی املاک</div>
|
||||||
<Chart type="bar" :data="chartData" :options="chartOptions" class="h-80" />
|
<Chart type="bar" :data="chartData" :options="chartOptions" class="h-80" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="card mb-0">
|
<div class="card mb-0">
|
||||||
<div class="flex justify-between mb-4">
|
<div class="flex justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<span class="block text-muted-color font-medium mb-4">تعداد دارایی ها</span>
|
<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>
|
<div class="text-surface-900 dark:text-surface-0 font-medium text-xl">100</div>
|
||||||
</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">
|
<div class="flex items-center justify-center bg-blue-100 dark:bg-blue-400/10 rounded-border" style="width: 2.5rem; height: 2.5rem">
|
||||||
|
|||||||
+20
-5
@@ -9,19 +9,34 @@ const model = ref([
|
|||||||
items: [{ label: 'داشبورد', icon: 'pi pi-fw pi-home', to: '/' }]
|
items: [{ label: 'داشبورد', icon: 'pi pi-fw pi-home', to: '/' }]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'مدیریت اموال',
|
label: 'مدیریت املاک',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'ایجاد اموال', icon: 'pi pi-fw pi-file-plus', to: '/belonging/create-belonging' },
|
{ label: 'ایجاد ملک', icon: 'pi pi-fw pi-file-plus', to: '/property/create-property' },
|
||||||
{ label: 'لیست اموال', icon: 'pi pi-fw pi-file', to: '/belonging/list-belongings' },
|
{ label: 'لیست املاک', icon: 'pi pi-fw pi-file', to: '/property/list-properties' }
|
||||||
{ label: 'لیست تراکنش اموال', icon: 'pi pi-list-check', to: '/transactions/list-transactions' }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'دسته بندی اموال',
|
label: 'دسته بندی املاک',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'لیست دسته بندی ها', icon: 'pi pi-fw pi-th-large', to: '/categories/list-categories' },
|
{ label: 'لیست دسته بندی ها', icon: 'pi pi-fw pi-th-large', to: '/categories/list-categories' },
|
||||||
{ label: 'ویژگی دسته بندی ها', icon: 'pi pi-fw pi-sitemap', to: '/features/list-features' }
|
{ 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' }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+61
-3
@@ -38,20 +38,41 @@ const router = createRouter({
|
|||||||
name: 'dashboard',
|
name: 'dashboard',
|
||||||
component: () => import('@/views/Dashboard.vue')
|
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/CreateProperty.vue')
|
||||||
|
},
|
||||||
|
// Aliases for backward compatibility
|
||||||
{
|
{
|
||||||
path: '/belonging/create-belonging',
|
path: '/belonging/create-belonging',
|
||||||
name: 'create-belonging',
|
name: 'create-belonging',
|
||||||
component: () => import('@/views/pages/Belonging/CreateBelonging.vue')
|
component: () => import('@/views/pages/Property/CreateProperty.vue')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/belonging/edit-belonging/:belongingId',
|
path: '/belonging/edit-belonging/:belongingId',
|
||||||
name: 'edit-belonging',
|
name: 'edit-belonging',
|
||||||
component: () => import('@/views/pages/Belonging/CreateBelonging.vue')
|
component: () => import('@/views/pages/Property/CreateProperty.vue')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/belonging/list-belongings',
|
path: '/belonging/list-belongings',
|
||||||
name: 'list-belongings',
|
name: 'list-belongings',
|
||||||
component: () => import('@/views/pages/Belonging/ListBelongings.vue')
|
component: () => import('@/views/pages/Property/ListProperties.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/property/list-properties',
|
||||||
|
name: 'list-properties-main',
|
||||||
|
component: () => import('@/views/pages/Property/ListProperties.vue')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/categories/list-categories',
|
path: '/categories/list-categories',
|
||||||
@@ -68,6 +89,43 @@ const router = createRouter({
|
|||||||
name: 'list-transactions',
|
name: 'list-transactions',
|
||||||
component: () => import('@/views/pages/Transaction/ListTransactions.vue')
|
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')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/uikit/formlayout',
|
path: '/uikit/formlayout',
|
||||||
name: 'formlayout',
|
name: 'formlayout',
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
export const BelongingService = {
|
export const PropertyService = {
|
||||||
// Helper function to get token from cookies
|
|
||||||
_getAuthHeader: () => {
|
_getAuthHeader: () => {
|
||||||
const bmToken = document.cookie
|
const bmToken = document.cookie
|
||||||
.split('; ')
|
.split('; ')
|
||||||
@@ -13,12 +12,12 @@ export const BelongingService = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
getBelonging: async (belongingId) => {
|
getProperty: async (propertyId) => {
|
||||||
const config = {
|
const config = {
|
||||||
method: 'get',
|
method: 'get',
|
||||||
url: '/api/belongings/admin/get-one/' + belongingId,
|
url: '/api/properties/admin/get-one/' + propertyId,
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
headers: BelongingService._getAuthHeader()
|
headers: PropertyService._getAuthHeader()
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const response = await axios(config);
|
const response = await axios(config);
|
||||||
@@ -28,12 +27,12 @@ export const BelongingService = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
getAllBelongings: async () => {
|
getAllProperties: async () => {
|
||||||
const config = {
|
const config = {
|
||||||
method: 'get',
|
method: 'get',
|
||||||
url: '/api/belongings/admin/get-all',
|
url: '/api/properties/admin/get-all',
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
headers: BelongingService._getAuthHeader()
|
headers: PropertyService._getAuthHeader()
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const response = await axios(config);
|
const response = await axios(config);
|
||||||
@@ -43,13 +42,13 @@ export const BelongingService = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
searchBelongings: async (query) => {
|
searchProperties: async (query) => {
|
||||||
const config = {
|
const config = {
|
||||||
method: 'get',
|
method: 'get',
|
||||||
url: '/api/belongings/admin/search',
|
url: '/api/properties/admin/search',
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
params: query,
|
params: query,
|
||||||
headers: BelongingService._getAuthHeader()
|
headers: PropertyService._getAuthHeader()
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const response = await axios(config);
|
const response = await axios(config);
|
||||||
@@ -59,12 +58,12 @@ export const BelongingService = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
indexBelongings: async () => {
|
indexProperties: async () => {
|
||||||
const config = {
|
const config = {
|
||||||
method: 'post',
|
method: 'post',
|
||||||
url: '/api/belongings/admin/index-all',
|
url: '/api/properties/admin/index-all',
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
headers: BelongingService._getAuthHeader()
|
headers: PropertyService._getAuthHeader()
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const response = await axios(config);
|
const response = await axios(config);
|
||||||
@@ -74,13 +73,13 @@ export const BelongingService = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
createBelonging: async (data) => {
|
createProperty: async (data) => {
|
||||||
const config = {
|
const config = {
|
||||||
method: 'post',
|
method: 'post',
|
||||||
url: '/api/belongings/admin/create',
|
url: '/api/properties/admin/create',
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
data,
|
data,
|
||||||
headers: BelongingService._getAuthHeader()
|
headers: PropertyService._getAuthHeader()
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const response = await axios(config);
|
const response = await axios(config);
|
||||||
@@ -91,13 +90,13 @@ export const BelongingService = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
updateBelonging: async (belongingId, data) => {
|
updateProperty: async (propertyId, data) => {
|
||||||
const config = {
|
const config = {
|
||||||
method: 'put',
|
method: 'put',
|
||||||
url: '/api/belongings/admin/update/' + belongingId,
|
url: '/api/properties/admin/update/' + propertyId,
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
data,
|
data,
|
||||||
headers: BelongingService._getAuthHeader()
|
headers: PropertyService._getAuthHeader()
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const response = await axios(config);
|
const response = await axios(config);
|
||||||
@@ -106,5 +105,15 @@ export const BelongingService = {
|
|||||||
console.log(err);
|
console.log(err);
|
||||||
return err.response.data.message;
|
return err.response.data.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,52 @@
|
|||||||
|
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,52 @@
|
|||||||
|
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,102 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const getAuthHeader = () => {
|
||||||
|
const bmToken = document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find((row) => row.startsWith('bm-token='))
|
||||||
|
?.split('=')[1];
|
||||||
|
return { 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,51 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const getAuthHeader = () => {
|
||||||
|
const bmToken = document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find((row) => row.startsWith('bm-token='))
|
||||||
|
?.split('=')[1];
|
||||||
|
return { 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()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
+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 };
|
||||||
@@ -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>
|
|
||||||
@@ -121,7 +121,7 @@ function deleteSelectedCategories() {
|
|||||||
:filters="filters"
|
:filters="filters"
|
||||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||||
:rowsPerPageOptions="[20, 40, 80]"
|
:rowsPerPageOptions="[20, 40, 80]"
|
||||||
currentPageReportTemplate="نمایش {first} تا {last} از {totalRecords} دارایی"
|
currentPageReportTemplate="نمایش {first} تا {last} از {totalRecords} دستهبندی"
|
||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex flex-wrap gap-2 items-center justify-between">
|
<div class="flex flex-wrap gap-2 items-center justify-between">
|
||||||
|
|||||||
@@ -0,0 +1,909 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onBeforeMount, ref, watch, computed } from 'vue';
|
||||||
|
import { useToast } from 'primevue/usetoast';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { CategoryService } from '@/service/CategoryService';
|
||||||
|
import { PropertyService } from '@/service/PropertyService';
|
||||||
|
import { PropertyStatusService } from '@/service/PropertyStatusService';
|
||||||
|
import ImageUploader from '@/views/uikit/ImageUploader.vue';
|
||||||
|
import KiwiChips from '@/views/uikit/KiwiChips.vue';
|
||||||
|
import MapComponent from '@/views/uikit/MapComponent.vue';
|
||||||
|
import { UTMtoLatLon } from '@/utm';
|
||||||
|
|
||||||
|
// PrimeVue Components
|
||||||
|
import Dropdown from 'primevue/dropdown';
|
||||||
|
import MultiSelect from 'primevue/multiselect';
|
||||||
|
import InputText from 'primevue/inputtext';
|
||||||
|
import InputNumber from 'primevue/inputnumber';
|
||||||
|
import Textarea from 'primevue/textarea';
|
||||||
|
import ToggleSwitch from 'primevue/toggleswitch';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import Tag from 'primevue/tag';
|
||||||
|
import SelectButton from 'primevue/selectbutton';
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// State
|
||||||
|
const categories = ref([]);
|
||||||
|
const statuses = ref([]);
|
||||||
|
const selectedCategory = ref(null);
|
||||||
|
const features = ref([]);
|
||||||
|
const featuresValues = ref({});
|
||||||
|
const mainImageUrlsWithData = ref([]);
|
||||||
|
const documentUrlsWithData = ref([]);
|
||||||
|
const editMode = ref(false);
|
||||||
|
const isSaving = ref(false);
|
||||||
|
|
||||||
|
const propertyTypes = ref([
|
||||||
|
{ label: 'زمین', value: 'Land' },
|
||||||
|
{ label: 'ساختمان', value: 'Building' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const documentTypeOptions = ref(['سند تکبرگ', 'منگولهدار', 'وقفی', 'مشاع', 'بنچاق', 'قولنامهای']);
|
||||||
|
const specialStatusOptions = ref(['عادی', 'توقیف', 'رهن', 'وراثتی', 'اوقافی', 'دولتی', 'در دست ساخت', 'مسدود']);
|
||||||
|
const zoningOptions = ref(['مسکونی', 'تجاری', 'کشاورزی', 'صنعتی', 'ویلایی', 'باغی', 'آموزشی', 'اداری']);
|
||||||
|
const structureTypeOptions = ref(['اسکلت بتنی', 'اسکلت فلزی', 'دیوار باربر', 'سازه پیشساخته']);
|
||||||
|
const flooringTypeOptions = ref(['سرامیک', 'سنگ', 'پارکت', 'لمینت', 'موزاییک']);
|
||||||
|
const sideDirectionOptions = ref(['شمالی', 'جنوبی', 'شرقی', 'غربی', 'شمال شرقی', 'شمال غربی', 'جنوب شرقی', 'جنوب غربی']);
|
||||||
|
|
||||||
|
const property = ref({
|
||||||
|
name: '',
|
||||||
|
propertyType: 'Land',
|
||||||
|
category: null,
|
||||||
|
status: null,
|
||||||
|
description: '',
|
||||||
|
tags: [],
|
||||||
|
nationalGeoCode: '',
|
||||||
|
uniqueId: '',
|
||||||
|
serialNumber: '',
|
||||||
|
provinceRegistry: '',
|
||||||
|
localRegistryOffice: '',
|
||||||
|
registrationDate: '',
|
||||||
|
documentUrls: [],
|
||||||
|
mainImageUrls: [],
|
||||||
|
location: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [0, 0]
|
||||||
|
},
|
||||||
|
|
||||||
|
// 1. مشخصات مالک
|
||||||
|
ownerName: '',
|
||||||
|
fatherName: '',
|
||||||
|
idCardNumber: '',
|
||||||
|
nationalId: '',
|
||||||
|
issuePlace: '',
|
||||||
|
birthDate: '',
|
||||||
|
nationality: 'ایران',
|
||||||
|
dong: 6,
|
||||||
|
ownershipDocs: '',
|
||||||
|
|
||||||
|
// 2. مشخصات ملک
|
||||||
|
subNumber: '',
|
||||||
|
mainNumber: '',
|
||||||
|
separatedFrom: '',
|
||||||
|
plotNumber: '',
|
||||||
|
registrationSection: '',
|
||||||
|
district: '',
|
||||||
|
area: null,
|
||||||
|
blockNumber: '',
|
||||||
|
sideDirection: '',
|
||||||
|
floor: '',
|
||||||
|
specialStatus: 'عادی',
|
||||||
|
zoningType: 'مسکونی',
|
||||||
|
documentType: 'سند تکبرگ',
|
||||||
|
address: '',
|
||||||
|
postalCode: '',
|
||||||
|
propertyBoundaries: '',
|
||||||
|
restrictions: '',
|
||||||
|
|
||||||
|
// Technical Features
|
||||||
|
landArea: null,
|
||||||
|
streetWidth: null,
|
||||||
|
facadeCount: null,
|
||||||
|
landLength: null,
|
||||||
|
landWidth: null,
|
||||||
|
builtArea: null,
|
||||||
|
floorsCount: null,
|
||||||
|
unitsCount: null,
|
||||||
|
constructionYear: null,
|
||||||
|
elevator: false,
|
||||||
|
parking: false,
|
||||||
|
warehouse: false,
|
||||||
|
heatingSystem: '',
|
||||||
|
coolingSystem: '',
|
||||||
|
structureType: '',
|
||||||
|
flooringType: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// Map state
|
||||||
|
const mapRef = ref(null);
|
||||||
|
const easting = ref();
|
||||||
|
const northing = ref();
|
||||||
|
const zone = ref();
|
||||||
|
const lat = ref();
|
||||||
|
const long = ref();
|
||||||
|
|
||||||
|
// Auto-generated property name: ownerName + شماره اصلی/شماره فرعی
|
||||||
|
const computedPropertyName = computed(() => {
|
||||||
|
const owner = property.value.ownerName?.trim() || '';
|
||||||
|
const main = property.value.mainNumber?.trim() || '';
|
||||||
|
const sub = property.value.subNumber?.trim() || '';
|
||||||
|
const regNum = main && sub ? `${main}/${sub}` : (main || sub || '');
|
||||||
|
if (owner && regNum) return `${owner} - ${regNum}`;
|
||||||
|
if (owner) return owner;
|
||||||
|
if (regNum) return regNum;
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dong options with fractional description
|
||||||
|
const dongOptions = [
|
||||||
|
{ value: 1, label: '۱ دانگ', fraction: '۱⁏۶' },
|
||||||
|
{ value: 2, label: '۲ دانگ', fraction: '۱⁏۳ (یک سوم)' },
|
||||||
|
{ value: 3, label: '۳ دانگ', fraction: '۱⁏۲ (نیم)' },
|
||||||
|
{ value: 4, label: '۴ دانگ', fraction: '۲⁏۳ (دو سوم)' },
|
||||||
|
{ value: 5, label: '۵ دانگ', fraction: '۵⁏۶' },
|
||||||
|
{ value: 6, label: '۶ دانگ', fraction: 'تمام (مالکیت کامل)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const convertUTMtoLatLng = () => {
|
||||||
|
if (easting.value && northing.value && zone.value) {
|
||||||
|
try {
|
||||||
|
const result = UTMtoLatLon(
|
||||||
|
Number(easting.value),
|
||||||
|
Number(northing.value),
|
||||||
|
Number(zone.value),
|
||||||
|
'N'
|
||||||
|
);
|
||||||
|
lat.value = result.latitude;
|
||||||
|
long.value = result.longitude;
|
||||||
|
searchLocation(lat.value, long.value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error converting UTM to Lat/Lng:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkerUpdated = (coords) => {
|
||||||
|
if (coords && Array.isArray(coords)) {
|
||||||
|
property.value.location = {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [coords[1], coords[0]]
|
||||||
|
};
|
||||||
|
lat.value = coords[0];
|
||||||
|
long.value = coords[1];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchLocation = (latitude, longitude) => {
|
||||||
|
if (mapRef.value && latitude && longitude) {
|
||||||
|
mapRef.value.setCenterAndMarker(Number(latitude), Number(longitude));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let geoCodeDebounceTimeout = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Iran's شناسه ملی جغرافیایی (JAM code) which is a concatenated UTM string:
|
||||||
|
* - First 6 digits → Easting (e.g. 538136)
|
||||||
|
* - Next 7 digits → Northing (e.g. 3950135)
|
||||||
|
* - Last 2 digits → UTM Zone (e.g. 39 for most of Iran)
|
||||||
|
* If the code doesn't match the 15-digit pattern, falls back to zone 39.
|
||||||
|
*/
|
||||||
|
const parseNationalGeoCode = (code) => {
|
||||||
|
const digits = code.replace(/\D/g, ''); // strip non-digits
|
||||||
|
if (digits.length === 15) {
|
||||||
|
// Standard JAM: 6 easting + 7 northing + 2 zone
|
||||||
|
const e = parseInt(digits.slice(0, 6), 10);
|
||||||
|
const n = parseInt(digits.slice(6, 13), 10);
|
||||||
|
const z = parseInt(digits.slice(13, 15), 10);
|
||||||
|
return { easting: e, northing: n, zone: z };
|
||||||
|
}
|
||||||
|
if (digits.length === 13) {
|
||||||
|
// Some codes omit the zone (older format) — assume zone 39 for Iran
|
||||||
|
const e = parseInt(digits.slice(0, 6), 10);
|
||||||
|
const n = parseInt(digits.slice(6, 13), 10);
|
||||||
|
return { easting: e, northing: n, zone: 39 };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyNationalGeoCode = (val) => {
|
||||||
|
// 1. Try parsing as a JAM UTM code (15-digit or 13-digit)
|
||||||
|
const parsed = parseNationalGeoCode(val);
|
||||||
|
if (parsed) {
|
||||||
|
try {
|
||||||
|
const result = UTMtoLatLon(parsed.easting, parsed.northing, parsed.zone, 'N');
|
||||||
|
lat.value = result.latitude;
|
||||||
|
long.value = result.longitude;
|
||||||
|
easting.value = parsed.easting;
|
||||||
|
northing.value = parsed.northing;
|
||||||
|
zone.value = parsed.zone;
|
||||||
|
searchLocation(result.latitude, result.longitude);
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('UTM conversion failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. Fallback: treat as lat,lon text or geocode search via map
|
||||||
|
if (mapRef.value && typeof mapRef.value.searchLocation === 'function') {
|
||||||
|
mapRef.value.searchLocation(val);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNationalGeoCodeChange = () => {
|
||||||
|
const val = property.value.nationalGeoCode?.trim();
|
||||||
|
if (!val) return;
|
||||||
|
|
||||||
|
const digits = val.replace(/\D/g, '');
|
||||||
|
|
||||||
|
// Fire immediately once a full 15-digit JAM code is complete — no waiting
|
||||||
|
if (digits.length === 15) {
|
||||||
|
if (geoCodeDebounceTimeout) clearTimeout(geoCodeDebounceTimeout);
|
||||||
|
applyNationalGeoCode(val);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For partial / non-standard input, debounce to avoid firing on every keystroke
|
||||||
|
if (geoCodeDebounceTimeout) clearTimeout(geoCodeDebounceTimeout);
|
||||||
|
geoCodeDebounceTimeout = setTimeout(() => applyNationalGeoCode(val), 600);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto sync category when propertyType changes
|
||||||
|
watch(() => property.value.propertyType, (newType) => {
|
||||||
|
if (!categories.value || categories.value.length === 0) return;
|
||||||
|
let targetCatName = newType === 'Land' ? 'Lot' : 'Building';
|
||||||
|
let matchedCategory = categories.value.find(c => c.englishName === targetCatName || c.nativeName === (newType === 'Land' ? 'زمین' : 'ساختمان'));
|
||||||
|
if (matchedCategory) {
|
||||||
|
property.value.category = matchedCategory._id;
|
||||||
|
handleSelectCategory(matchedCategory._id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const initializeFeatureDefaultValues = () => {
|
||||||
|
featuresValues.value = {};
|
||||||
|
if (features.value && Array.isArray(features.value)) {
|
||||||
|
features.value.forEach((feat) => {
|
||||||
|
const definition = feat.definition || feat;
|
||||||
|
if (definition && definition._id) {
|
||||||
|
if (definition.type === 'boolean') {
|
||||||
|
featuresValues.value[definition._id] = false;
|
||||||
|
} else if (definition.type === 'multiselect') {
|
||||||
|
featuresValues.value[definition._id] = [];
|
||||||
|
} else {
|
||||||
|
featuresValues.value[definition._id] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractFeaturesValues = (featuresArray) => {
|
||||||
|
const values = {};
|
||||||
|
if (Array.isArray(featuresArray)) {
|
||||||
|
featuresArray.forEach((item) => {
|
||||||
|
const defId = item.definition?._id || item.definition;
|
||||||
|
if (defId) {
|
||||||
|
values[defId] = item.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
};
|
||||||
|
|
||||||
|
const serializeFeaturesPayload = (valuesObject) => {
|
||||||
|
const result = [];
|
||||||
|
if (valuesObject && typeof valuesObject === 'object') {
|
||||||
|
Object.entries(valuesObject).forEach(([definitionId, val]) => {
|
||||||
|
if (val !== null && val !== undefined && val !== '') {
|
||||||
|
result.push({
|
||||||
|
definition: definitionId,
|
||||||
|
value: val
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupedFeatures = computed(() => {
|
||||||
|
if (!features.value || !Array.isArray(features.value)) return {};
|
||||||
|
const groups = {};
|
||||||
|
features.value.forEach((feat) => {
|
||||||
|
const def = feat.definition || feat;
|
||||||
|
const tag = def.tag || 'سایر ویژگیها';
|
||||||
|
if (!groups[tag]) groups[tag] = [];
|
||||||
|
groups[tag].push(def);
|
||||||
|
});
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
const fillImagesArray = (urlsArray) => {
|
||||||
|
if (!Array.isArray(urlsArray)) return [];
|
||||||
|
return urlsArray.map((url) => ({
|
||||||
|
name: url.split('/').pop() || 'file',
|
||||||
|
url: url,
|
||||||
|
progress: 100
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectCategory = (catId) => {
|
||||||
|
selectedCategory.value = categories.value.find((c) => c._id === catId);
|
||||||
|
if (selectedCategory.value) {
|
||||||
|
features.value = selectedCategory.value.features || [];
|
||||||
|
initializeFeatureDefaultValues();
|
||||||
|
} else {
|
||||||
|
features.value = [];
|
||||||
|
featuresValues.value = {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMainImageUploaded = (res) => {
|
||||||
|
if (res && res.url) {
|
||||||
|
mainImageUrlsWithData.value.push({
|
||||||
|
name: res.originalName || 'image',
|
||||||
|
url: res.url,
|
||||||
|
progress: 100
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDocumentUploaded = (res) => {
|
||||||
|
if (res && res.url) {
|
||||||
|
documentUrlsWithData.value.push({
|
||||||
|
name: res.originalName || 'document',
|
||||||
|
url: res.url,
|
||||||
|
progress: 100
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusSeverity = (st) => {
|
||||||
|
if (!st) return 'secondary';
|
||||||
|
// Use the color field from the API; fall back to secondary
|
||||||
|
const valid = ['success', 'info', 'warn', 'danger', 'secondary', 'contrast'];
|
||||||
|
return valid.includes(st.color) ? st.color : 'secondary';
|
||||||
|
};
|
||||||
|
|
||||||
|
onBeforeMount(async () => {
|
||||||
|
try {
|
||||||
|
const [fetchedCategories, fetchedStatuses] = await Promise.all([
|
||||||
|
CategoryService.getAllCategories(),
|
||||||
|
PropertyStatusService.getAllStatuses()
|
||||||
|
]);
|
||||||
|
categories.value = fetchedCategories || [];
|
||||||
|
statuses.value = fetchedStatuses || [];
|
||||||
|
|
||||||
|
const propId = route.params.propertyId || route.params.belongingId;
|
||||||
|
if (propId) {
|
||||||
|
editMode.value = true;
|
||||||
|
const data = await PropertyService.getProperty(propId);
|
||||||
|
if (data) {
|
||||||
|
property.value = { ...property.value, ...data };
|
||||||
|
featuresValues.value = extractFeaturesValues(data.features);
|
||||||
|
|
||||||
|
mainImageUrlsWithData.value = fillImagesArray(data.mainImageUrls || []);
|
||||||
|
documentUrlsWithData.value = fillImagesArray(data.documentUrls || []);
|
||||||
|
|
||||||
|
if (data.category) {
|
||||||
|
const catId = typeof data.category === 'object' ? data.category._id : data.category;
|
||||||
|
selectedCategory.value = categories.value.find((x) => x._id === catId);
|
||||||
|
if (selectedCategory.value) {
|
||||||
|
features.value = selectedCategory.value.features || [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let matchedCat = categories.value.find(c => c.englishName === 'Lot' || c.nativeName === 'زمین');
|
||||||
|
if (matchedCat) {
|
||||||
|
property.value.category = matchedCat._id;
|
||||||
|
handleSelectCategory(matchedCat._id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Initialization error:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveProperty = async () => {
|
||||||
|
// Auto-set name from ownerName + mainNumber/subNumber
|
||||||
|
property.value.name = computedPropertyName.value || 'ملک جدید';
|
||||||
|
|
||||||
|
if (!property.value.nationalGeoCode?.trim()) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'هشدار', detail: 'شناسه ملی جغرافیایی الزامی است.', life: 3000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSaving.value = true;
|
||||||
|
try {
|
||||||
|
property.value.mainImageUrls = mainImageUrlsWithData.value.map((x) => x.url);
|
||||||
|
property.value.documentUrls = documentUrlsWithData.value.map((x) => x.url);
|
||||||
|
property.value.features = serializeFeaturesPayload(featuresValues.value);
|
||||||
|
|
||||||
|
let status = 'failed';
|
||||||
|
if (property.value._id?.trim()) {
|
||||||
|
status = await PropertyService.updateProperty(property.value._id, property.value);
|
||||||
|
} else {
|
||||||
|
status = await PropertyService.createProperty(property.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'success') {
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'موفقیتآمیز',
|
||||||
|
detail: editMode.value ? 'اطلاعات ملک با موفقیت بروزرسانی شد.' : 'ملک جدید با موفقیت ایجاد شد.',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
setTimeout(async () => {
|
||||||
|
await router.push('/property/list-properties');
|
||||||
|
}, 800);
|
||||||
|
} else {
|
||||||
|
toast.add({ severity: 'error', summary: 'خطا در ثبت', detail: status, life: 4000 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save error:', error);
|
||||||
|
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطایی در ارتباط با سرور رخ داد.', life: 4000 });
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6 dir-rtl" style="direction: rtl;">
|
||||||
|
<!-- Header Bar -->
|
||||||
|
<div class="card p-4 bg-surface-0 dark:bg-surface-900 shadow-sm border border-surface-200 dark:border-surface-800 rounded-2xl flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="p-3 bg-primary-50 dark:bg-primary-950/50 rounded-xl text-primary">
|
||||||
|
<i class="pi pi-building text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<h1 class="text-xl font-bold text-surface-900 dark:text-surface-0">
|
||||||
|
{{ editMode ? 'ویرایش ملک' : 'ایجاد ملک جدید' }}
|
||||||
|
</h1>
|
||||||
|
<Tag :value="editMode ? 'حالت ویرایش' : 'حالت ایجاد'" :severity="editMode ? 'warn' : 'success'" rounded class="px-2.5 text-xs font-semibold" />
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-surface-500 dark:text-surface-400 mt-0.5">
|
||||||
|
{{ editMode ? property.name || 'در حال بروزرسانی اطلاعات ملک' : 'مشخصات ملک (زمین یا ساختمان)، ویژگیها و شناسه ملی جغرافیایی را ثبت نمایید' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 flex-wrap justify-end">
|
||||||
|
<Button label="بازگشت" icon="pi pi-arrow-right" severity="secondary" text rounded @click="router.push('/property/list-properties')" />
|
||||||
|
<Button
|
||||||
|
:label="editMode ? 'بروزرسانی ملک' : 'ذخیره ملک'"
|
||||||
|
icon="pi pi-check"
|
||||||
|
severity="primary"
|
||||||
|
rounded
|
||||||
|
:loading="isSaving"
|
||||||
|
@click="saveProperty"
|
||||||
|
class="px-5 font-semibold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Layout Grid -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
|
||||||
|
<!-- Main Content Area -->
|
||||||
|
<div class="lg:col-span-8 space-y-6 w-full">
|
||||||
|
<!-- 1. General Identification & Registry Card (شناسه و اداره ثبت) -->
|
||||||
|
<div class="card p-4 sm:p-6 bg-surface-0 dark:bg-surface-900 shadow-sm border border-surface-200 dark:border-surface-800 rounded-2xl space-y-5">
|
||||||
|
<div class="flex items-center gap-2 text-primary font-bold text-lg border-b border-surface-100 dark:border-surface-800 pb-3">
|
||||||
|
<i class="pi pi-file-edit text-xl"></i>
|
||||||
|
<span>شناسه و اداره ثبت اسناد</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<!-- 1. شناسه یکتا -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">
|
||||||
|
شناسه یکتا ثبت ملک (۱۸ رقم)
|
||||||
|
</label>
|
||||||
|
<InputText
|
||||||
|
v-model="property.uniqueId"
|
||||||
|
placeholder="مثلاً: ۱۳۹۹۲۱۵۸۰۳۰۱۰۰۰۱۲۳"
|
||||||
|
class="w-full !rounded-xl"
|
||||||
|
fluid
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- شناسه ملی جغرافیایی (جام) -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">
|
||||||
|
شناسه ملی جغرافیایی (جام) <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<InputText
|
||||||
|
v-model="property.nationalGeoCode"
|
||||||
|
placeholder="کد ۱۵ رقمی جام (UTM)"
|
||||||
|
class="w-full !rounded-xl"
|
||||||
|
fluid
|
||||||
|
@input="handleNationalGeoCodeChange"
|
||||||
|
@change="handleNationalGeoCodeChange"
|
||||||
|
@keyup.enter="handleNationalGeoCodeChange"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-map-marker"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
rounded
|
||||||
|
title="یافتن نقطه روی نقشه"
|
||||||
|
@click="handleNationalGeoCodeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. شماره سریال -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">شماره سریال سند</label>
|
||||||
|
<InputText v-model="property.serialNumber" placeholder="مثلاً: الف/۹۹ ۱۲۳۴۵۶" class="w-full !rounded-xl" fluid />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. اداره کل استان -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">اداره کل استان</label>
|
||||||
|
<InputText v-model="property.provinceRegistry" placeholder="مثلاً: خوزستان" class="w-full !rounded-xl" fluid />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 4. اداره ثبت اسناد و املاک -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">اداره ثبت اسناد و املاک</label>
|
||||||
|
<InputText v-model="property.localRegistryOffice" placeholder="مثلاً: کرج - ناحیه ۱" class="w-full !rounded-xl" fluid />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 5. تاریخ ثبت -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">تاریخ ثبت</label>
|
||||||
|
<InputText v-model="property.registrationDate" placeholder="مثلاً: ۱۳۹۹/۰۴/۱۵" class="w-full !rounded-xl" fluid />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto Name Preview -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">
|
||||||
|
نام ملک
|
||||||
|
<span class="text-xs text-surface-400 font-normal mr-1">(خودکار تولید میشود)</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-surface-100 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 min-h-[42px]">
|
||||||
|
<i class="pi pi-tag text-primary text-sm"></i>
|
||||||
|
<span v-if="computedPropertyName" class="text-sm font-semibold text-surface-800 dark:text-surface-100">
|
||||||
|
{{ computedPropertyName }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-sm text-surface-400 italic">
|
||||||
|
پس از وارد کردن نام مالک و شماره ثبتی نمایش داده میشود
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Category Selector & Status -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">دستهبندی اصلی</label>
|
||||||
|
<Dropdown
|
||||||
|
v-model="property.category"
|
||||||
|
:options="categories"
|
||||||
|
optionLabel="nativeName"
|
||||||
|
optionValue="_id"
|
||||||
|
placeholder="انتخاب دستهبندی"
|
||||||
|
class="w-full !rounded-xl"
|
||||||
|
@change="handleSelectCategory($event.value)"
|
||||||
|
showClear
|
||||||
|
fluid
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">وضعیت ملک</label>
|
||||||
|
<Dropdown
|
||||||
|
v-model="property.status"
|
||||||
|
:options="statuses"
|
||||||
|
optionLabel="nativeName"
|
||||||
|
optionValue="_id"
|
||||||
|
placeholder="انتخاب وضعیت"
|
||||||
|
class="w-full !rounded-xl"
|
||||||
|
showClear
|
||||||
|
fluid
|
||||||
|
>
|
||||||
|
<template #option="slotProps">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Tag :value="slotProps.option.nativeName" :severity="getStatusSeverity(slotProps.option)" rounded class="text-xs" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. Owner Specifications Card (مشخصات مالک) -->
|
||||||
|
<div class="card p-4 sm:p-6 bg-surface-0 dark:bg-surface-900 shadow-sm border border-surface-200 dark:border-surface-800 rounded-2xl space-y-5">
|
||||||
|
<div class="flex items-center gap-2 text-primary font-bold text-lg border-b border-surface-100 dark:border-surface-800 pb-3">
|
||||||
|
<i class="pi pi-user text-xl"></i>
|
||||||
|
<span>مشخصات مالک</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<!-- 1. نام و نام خانوادگی مالک -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">نام و نام خانوادگی مالک</label>
|
||||||
|
<InputText v-model="property.ownerName" placeholder="نام و نام خانوادگی" class="w-full !rounded-xl" fluid />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. نام پدر مالک -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">نام پدر مالک</label>
|
||||||
|
<InputText v-model="property.fatherName" placeholder="نام پدر" class="w-full !rounded-xl" fluid />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. شماره شناسنامه مالک -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">شماره شناسنامه مالک</label>
|
||||||
|
<InputText v-model="property.idCardNumber" placeholder="شماره شناسنامه" class="w-full !rounded-xl" fluid />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 4. شماره ملی مالک یا شناسه ملی شرکت -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">شماره ملی مالک یا شناسه ملی شرکت</label>
|
||||||
|
<InputText v-model="property.nationalId" placeholder="شماره ملی (۱۰ رقم) / شناسه شرکت (۱۱ رقم)" class="w-full !rounded-xl" fluid />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 5. محل صدور شناسنامه مالک یا محل ثبت شرکت -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">محل صدور شناسنامه / محل ثبت شرکت</label>
|
||||||
|
<InputText v-model="property.issuePlace" placeholder="محل صدور یا ثبت" class="w-full !rounded-xl" fluid />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 6. تاریخ تولد مالک یا تاریخ ثبت شرکت -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">تاریخ تولد مالک / تاریخ ثبت شرکت</label>
|
||||||
|
<InputText v-model="property.birthDate" placeholder="مثلاً: ۱۳۶۵/۰۵/۱۲" class="w-full !rounded-xl" fluid />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 7. تابعیت -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">تابعیت</label>
|
||||||
|
<InputText v-model="property.nationality" placeholder="مثلاً: ایران" class="w-full !rounded-xl" fluid />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 8. میزان مالکیت (دانگ) -->
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-2">
|
||||||
|
میزان مالکیت (دانگ)
|
||||||
|
<span class="text-xs text-surface-400 font-normal mr-2">
|
||||||
|
(هر ملک از ۶ دانگ تشکیل شده)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex gap-2 flex-wrap items-center">
|
||||||
|
<button
|
||||||
|
v-for="opt in dongOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
type="button"
|
||||||
|
@click="property.dong = opt.value"
|
||||||
|
class="flex flex-col items-center justify-center px-3 py-1.5 rounded-xl border transition-all cursor-pointer min-w-[64px] text-xs"
|
||||||
|
:class="property.dong === opt.value
|
||||||
|
? 'bg-primary text-primary-contrast border-primary shadow-md shadow-primary/25 font-bold scale-[1.02]'
|
||||||
|
: 'bg-surface-0 dark:bg-surface-900 text-surface-700 dark:text-surface-200 border-surface-200 dark:border-surface-700 hover:border-primary/50 hover:bg-surface-50 dark:hover:bg-surface-800'"
|
||||||
|
>
|
||||||
|
<span class="text-xs font-bold leading-tight">{{ opt.label }}</span>
|
||||||
|
<span class="text-[10px] opacity-70 leading-tight mt-0.5">{{ opt.fraction }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 9. مستندات مالکیت -->
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">مستندات مالکیت</label>
|
||||||
|
<Textarea v-model="property.ownershipDocs" placeholder="شرح مستندات، شماره بنچاق، مبایعهنامه و انتقال..." rows="2" class="w-full !rounded-xl" fluid />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. Property Specifications Card (مشخصات ملک) -->
|
||||||
|
<div class="card p-4 sm:p-6 bg-surface-0 dark:bg-surface-900 shadow-sm border border-surface-200 dark:border-surface-800 rounded-2xl space-y-5">
|
||||||
|
<div class="flex items-center gap-2 text-primary font-bold text-lg border-b border-surface-100 dark:border-surface-800 pb-3">
|
||||||
|
<i class="pi pi-building text-xl"></i>
|
||||||
|
<span>مشخصات ملک</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<!-- 1. شماره فرعی -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">شماره فرعی</label>
|
||||||
|
<InputText v-model="property.subNumber" placeholder="شماره فرعی ثبت" class="w-full !rounded-xl" fluid />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. شماره اصلی -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">شماره اصلی</label>
|
||||||
|
<InputText v-model="property.mainNumber" placeholder="شماره اصلی ثبت" class="w-full !rounded-xl" fluid />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. مفروز و مجزی از -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">مفروز و مجزی از</label>
|
||||||
|
<InputText v-model="property.separatedFrom" placeholder="شماره پلاک یا بخش قبلی" class="w-full !rounded-xl" fluid />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 4. قطعه -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">قطعه</label>
|
||||||
|
<InputText v-model="property.plotNumber" placeholder="شماره قطعه" class="w-full !rounded-xl" fluid />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 5. بخش ثبتی -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">بخش ثبتی</label>
|
||||||
|
<InputText v-model="property.registrationSection" placeholder="مثلاً: بخش ۱ اهواز" class="w-full !rounded-xl" fluid />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 6. ناحیه -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">ناحیه</label>
|
||||||
|
<InputText v-model="property.district" placeholder="مثلاً: ناحیه ۲" class="w-full !rounded-xl" fluid />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 7. مساحت به متر مربع -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">مساحت به متر مربع</label>
|
||||||
|
<InputNumber v-model="property.area" placeholder="مثلاً: ۳۵۰.۵" class="w-full !rounded-xl" fluid />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 8. بلوک -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">بلوک</label>
|
||||||
|
<InputText v-model="property.blockNumber" placeholder="شماره بلوک" class="w-full !rounded-xl" fluid />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 9. سمت -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">سمت</label>
|
||||||
|
<Dropdown v-model="property.sideDirection" :options="sideDirectionOptions" placeholder="انتخاب سمت" class="w-full !rounded-xl" fluid showClear />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 10. طبقه -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">طبقه</label>
|
||||||
|
<InputText v-model="property.floor" placeholder="مثلاً: طبقه اول / همکف" class="w-full !rounded-xl" fluid />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 11. نوع ملک -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">
|
||||||
|
نوع ملک <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<SelectButton v-model="property.propertyType" :options="propertyTypes" optionLabel="label" optionValue="value" class="w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 12. وضعیت خاص -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">وضعیت خاص</label>
|
||||||
|
<Dropdown v-model="property.specialStatus" :options="specialStatusOptions" placeholder="انتخاب وضعیت خاص" class="w-full !rounded-xl" fluid />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 13. کاربری -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">کاربری</label>
|
||||||
|
<Dropdown v-model="property.zoningType" :options="zoningOptions" placeholder="انتخاب کاربری" class="w-full !rounded-xl" fluid />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 14. نوع سند -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">نوع سند</label>
|
||||||
|
<Dropdown v-model="property.documentType" :options="documentTypeOptions" placeholder="انتخاب نوع سند" class="w-full !rounded-xl" fluid />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 15. نشانی ملک -->
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">نشانی ملک</label>
|
||||||
|
<Textarea v-model="property.address" placeholder="نشانی کامل و دقیق ملک..." rows="2" class="w-full !rounded-xl" fluid />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 16. کدپستی -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">کدپستی ۱۰ رقمی</label>
|
||||||
|
<InputText v-model="property.postalCode" placeholder="کدپستی ملک" class="w-full !rounded-xl" fluid />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 17. حدود مفروزات -->
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">حدود مفروزات</label>
|
||||||
|
<Textarea v-model="property.propertyBoundaries" placeholder="شرح تفصیلی حدود مفروزات و مجاورین (شمال، جنوب، شرق، غرب)..." rows="3" class="w-full !rounded-xl" fluid />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 18. محدودیت ها و نقل انتقالات -->
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">محدودیتها و نقل و انتقالات</label>
|
||||||
|
<Textarea v-model="property.restrictions" placeholder="شرح حقوق ارتفاقی، بازداشت، رهن و نقل و انتقالات ثبتشده..." rows="3" class="w-full !rounded-xl" fluid />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 5. Category Dynamic Features Card (if present) -->
|
||||||
|
<div v-if="features && features.length > 0" class="card p-4 sm:p-6 bg-surface-0 dark:bg-surface-900 shadow-sm border border-surface-200 dark:border-surface-800 rounded-2xl space-y-5">
|
||||||
|
<div class="flex items-center gap-2 text-primary font-bold text-lg border-b border-surface-100 dark:border-surface-800 pb-3">
|
||||||
|
<i class="pi pi-cog text-xl"></i>
|
||||||
|
<span>ویژگیهای تکمیلی دستهبندی</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="(groupFeats, tagName) in groupedFeatures" :key="tagName" class="space-y-3">
|
||||||
|
<h3 class="text-sm font-bold text-surface-600 dark:text-surface-400 border-b border-surface-100 dark:border-surface-800 pb-1">
|
||||||
|
{{ tagName }}
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div v-for="feat in groupFeats" :key="feat._id" class="space-y-1">
|
||||||
|
<label class="block text-xs font-medium text-surface-600 dark:text-surface-300">
|
||||||
|
{{ feat.nativeName }}
|
||||||
|
</label>
|
||||||
|
<Dropdown
|
||||||
|
v-if="feat.predefinedValues && feat.predefinedValues.length > 0"
|
||||||
|
v-model="featuresValues[feat._id]"
|
||||||
|
:options="feat.predefinedValues"
|
||||||
|
placeholder="انتخاب کنید"
|
||||||
|
class="w-full !rounded-xl"
|
||||||
|
showClear
|
||||||
|
fluid
|
||||||
|
/>
|
||||||
|
<InputText v-else-if="feat.featureType === 'string' || feat.featureType === 'text' || feat.type === 'string'" v-model="featuresValues[feat._id]" class="w-full !rounded-xl" fluid />
|
||||||
|
<InputNumber v-else-if="feat.featureType === 'number' || feat.type === 'number'" v-model="featuresValues[feat._id]" class="w-full !rounded-xl" fluid />
|
||||||
|
<ToggleSwitch v-else-if="feat.featureType === 'boolean' || feat.type === 'boolean'" v-model="featuresValues[feat._id]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 6. Documents Section -->
|
||||||
|
<div class="card p-4 sm:p-6 bg-surface-0 dark:bg-surface-900 shadow-sm border border-surface-200 dark:border-surface-800 rounded-2xl space-y-5">
|
||||||
|
<div class="flex items-center gap-2 text-primary font-bold text-lg border-b border-surface-100 dark:border-surface-800 pb-3">
|
||||||
|
<i class="pi pi-file-pdf text-xl"></i>
|
||||||
|
<span>اسناد و مدارک تصویری / پیدیاف ملک</span>
|
||||||
|
</div>
|
||||||
|
<ImageUploader v-model="documentUrlsWithData" uploadUrl="/api/files/admin/upload-file" @uploaded="onDocumentUploaded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar Area -->
|
||||||
|
<div class="lg:col-span-4 space-y-6 w-full">
|
||||||
|
<!-- Location Map Card -->
|
||||||
|
<div class="card p-4 sm:p-6 bg-surface-0 dark:bg-surface-900 shadow-sm border border-surface-200 dark:border-surface-800 rounded-2xl space-y-4">
|
||||||
|
<div class="flex items-center gap-2 text-primary font-bold text-lg border-b border-surface-100 dark:border-surface-800 pb-3">
|
||||||
|
<i class="pi pi-map-marker text-xl"></i>
|
||||||
|
<span>موقعیت روی نقشه</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="grid grid-cols-3 gap-2">
|
||||||
|
<InputText v-model="easting" placeholder="Easting" class="!rounded-xl text-xs" />
|
||||||
|
<InputText v-model="northing" placeholder="Northing" class="!rounded-xl text-xs" />
|
||||||
|
<InputText v-model="zone" placeholder="Zone" class="!rounded-xl text-xs" />
|
||||||
|
</div>
|
||||||
|
<Button label="تبدیل UTM" size="small" severity="secondary" rounded @click="convertUTMtoLatLng" class="w-full" />
|
||||||
|
|
||||||
|
<div class="h-[600px] rounded-xl overflow-hidden border border-surface-200 dark:border-surface-800">
|
||||||
|
<MapComponent
|
||||||
|
ref="mapRef"
|
||||||
|
:locked="!!property.nationalGeoCode?.trim()"
|
||||||
|
@marker-updated="handleMarkerUpdated"
|
||||||
|
@cleared="property.nationalGeoCode = ''"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Image Card -->
|
||||||
|
<div class="card p-4 sm:p-6 bg-surface-0 dark:bg-surface-900 shadow-sm border border-surface-200 dark:border-surface-800 rounded-2xl space-y-4">
|
||||||
|
<div class="flex items-center gap-2 text-primary font-bold text-lg border-b border-surface-100 dark:border-surface-800 pb-3">
|
||||||
|
<i class="pi pi-image text-xl"></i>
|
||||||
|
<span>تصاویر ملک</span>
|
||||||
|
</div>
|
||||||
|
<ImageUploader v-model="mainImageUrlsWithData" uploadUrl="/api/files/admin/upload-file" @uploaded="onMainImageUploaded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dir-rtl {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+71
-61
@@ -1,27 +1,30 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ProductService } from '@/service/ProductService';
|
|
||||||
import { FilterMatchMode } from '@primevue/core/api';
|
import { FilterMatchMode } from '@primevue/core/api';
|
||||||
import { useToast } from 'primevue/usetoast';
|
import { useToast } from 'primevue/usetoast';
|
||||||
import { onMounted, ref, watch } from 'vue';
|
import { onMounted, ref, watch } from 'vue';
|
||||||
import { BelongingService } from '@/service/BelongingService';
|
import { PropertyService } from '@/service/PropertyService';
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
import Chip from 'primevue/chip';
|
import Chip from 'primevue/chip';
|
||||||
import InputIcon from 'primevue/inputicon';
|
import InputIcon from 'primevue/inputicon';
|
||||||
import IconField from 'primevue/iconfield';
|
import IconField from 'primevue/iconfield';
|
||||||
import LoadingCheck from '@/components/LoadingCheck.vue';
|
import LoadingCheck from '@/components/LoadingCheck.vue';
|
||||||
|
import { useConfirm } from 'primevue/useconfirm';
|
||||||
|
import ConfirmPopup from 'primevue/confirmpopup';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const deleteConfirm = useConfirm();
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadData();
|
loadData();
|
||||||
});
|
});
|
||||||
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const dt = ref();
|
const dt = ref();
|
||||||
const belongings = ref();
|
const properties = ref();
|
||||||
const productDialog = ref(false);
|
const productDialog = ref(false);
|
||||||
const deleteProductDialog = ref(false);
|
const deleteProductDialog = ref(false);
|
||||||
const deleteProductsDialog = ref(false);
|
const deleteProductsDialog = ref(false);
|
||||||
const belonging = ref({});
|
const property = ref({});
|
||||||
const selectedBelongings = ref();
|
const selectedProperties = ref();
|
||||||
const filters = ref({
|
const filters = ref({
|
||||||
global: { value: null, matchMode: FilterMatchMode.CONTAINS }
|
global: { value: null, matchMode: FilterMatchMode.CONTAINS }
|
||||||
});
|
});
|
||||||
@@ -34,8 +37,8 @@ function hideDialog() {
|
|||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
function editBelonging(belongingId) {
|
function editProperty(propertyId) {
|
||||||
router.push('/belonging/edit-belonging/' + belongingId);
|
router.push('/property/edit-property/' + propertyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -47,17 +50,15 @@ const pagesCount = ref(0);
|
|||||||
|
|
||||||
let searchTimeout;
|
let searchTimeout;
|
||||||
|
|
||||||
// Update your search watcher
|
|
||||||
watch(searchPhrase, (newPhrase, oldPhrase) => {
|
watch(searchPhrase, (newPhrase, oldPhrase) => {
|
||||||
if (newPhrase !== oldPhrase) {
|
if (newPhrase !== oldPhrase) {
|
||||||
// Debounce search to avoid too many requests
|
|
||||||
if (searchTimeout) clearTimeout(searchTimeout);
|
if (searchTimeout) clearTimeout(searchTimeout);
|
||||||
searchTimeout = setTimeout(() => {
|
searchTimeout = setTimeout(() => {
|
||||||
router.push({
|
router.push({
|
||||||
query: {
|
query: {
|
||||||
...route.query,
|
...route.query,
|
||||||
query: newPhrase || undefined,
|
query: newPhrase || undefined,
|
||||||
page: 1 // Reset to first page on new search
|
page: 1
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, 300);
|
}, 300);
|
||||||
@@ -74,8 +75,9 @@ watch(
|
|||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRemoveSearch = () => {
|
const handleRemoveSearch = () => {
|
||||||
removeFromQuery('searchPhrase'); // Ensure 'searchPhrase' is the correct key
|
removeFromQuery('searchPhrase');
|
||||||
searchPhrase.value = '';
|
searchPhrase.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -106,16 +108,15 @@ const onPageChange = (event) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadData = () => {
|
const loadData = async () => {
|
||||||
BelongingService.searchBelongings(route.query)
|
await PropertyService.searchProperties(route.query)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
console.log('response', response);
|
properties.value = response.properties || response.belongings || response;
|
||||||
belongings.value = response.belongings || response;
|
|
||||||
totalCount.value = response.totalCount || response.length || 0;
|
totalCount.value = response.totalCount || response.length || 0;
|
||||||
pagesCount.value = Math.ceil(totalCount.value / per_page.value);
|
pagesCount.value = Math.ceil(totalCount.value / per_page.value);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to load data', life: 3000 });
|
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در بازیابی اطلاعات', life: 3000 });
|
||||||
console.error('Error loading data:', error);
|
console.error('Error loading data:', error);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -125,39 +126,72 @@ const indexingFinished = ref(false);
|
|||||||
const indexingMessage = ref('در حال ایندکس...');
|
const indexingMessage = ref('در حال ایندکس...');
|
||||||
const animationKey = ref(0);
|
const animationKey = ref(0);
|
||||||
|
|
||||||
// Function to handle indexing
|
const indexProperties = async () => {
|
||||||
const indexBelongings = async () => {
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
indexingFinished.value = false;
|
indexingFinished.value = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Your indexing logic here
|
await PropertyService.indexProperties();
|
||||||
// await new Promise((resolve) => setTimeout(resolve, 2000)); // Simulate API call
|
loadData();
|
||||||
await BelongingService.indexBelongings();
|
|
||||||
// When indexing completes
|
|
||||||
indexingFinished.value = true;
|
indexingFinished.value = true;
|
||||||
indexingMessage.value = 'ایندکس با موفقیت انجام شد';
|
indexingMessage.value = 'ایندکس با موفقیت انجام شد';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
indexingMessage.value = 'خطا در ایندکس';
|
indexingMessage.value = 'خطا در ایندکس';
|
||||||
} finally {
|
} finally {
|
||||||
// Reset after some time
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
animationKey.value++; // Force re-render of animation
|
animationKey.value++;
|
||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 {
|
||||||
|
let url = '/api/properties/admin/delete/' + id;
|
||||||
|
const method = 'delete';
|
||||||
|
const config = {
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
withCredentials: true
|
||||||
|
};
|
||||||
|
const response = await axios(config);
|
||||||
|
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.log(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reject: () => {}
|
||||||
|
});
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<ConfirmPopup></ConfirmPopup>
|
||||||
<div>
|
<div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<Toolbar class="mb-6">
|
<Toolbar class="mb-6">
|
||||||
<template #start>
|
<template #start>
|
||||||
<Button label="ساخت دارایی جدید" icon="pi pi-plus" severity="secondary" class="mr-2 mx-3" @click="router.push('/belonging/create-belonging')" />
|
<Button label="ساخت ملک جدید" icon="pi pi-plus" severity="secondary" class="mr-2 mx-3" @click="router.push('/property/create-property')" />
|
||||||
<Button @click="indexBelongings" :disabled="loading" severity="secondary mx-3" class="indexing-button">
|
<Button @click="indexProperties" :disabled="loading" severity="secondary mx-3" class="indexing-button">
|
||||||
<div class="button-content">
|
<div class="button-content">
|
||||||
<div v-if="!loading">ایندکس کردن محصولات</div>
|
<div v-if="!loading">ایندکس کردن املاک</div>
|
||||||
<div v-else class="loading-content flex">
|
<div v-else class="loading-content flex">
|
||||||
<LoadingCheck :key="animationKey" :is-complete="indexingFinished" />
|
<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">
|
<Transition enter-active-class="animate__animated animate__fadeIn animate__fast" leave-active-class="animate__animated animate__fadeOut animate__fast" mode="out-in">
|
||||||
@@ -173,8 +207,8 @@ const indexBelongings = async () => {
|
|||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
ref="dt"
|
ref="dt"
|
||||||
v-model:selection="selectedBelongings"
|
v-model:selection="selectedProperties"
|
||||||
:value="belongings"
|
:value="properties"
|
||||||
dataKey="id"
|
dataKey="id"
|
||||||
:paginator="true"
|
:paginator="true"
|
||||||
:rows="per_page"
|
:rows="per_page"
|
||||||
@@ -184,11 +218,11 @@ const indexBelongings = async () => {
|
|||||||
@page="onPageChange($event)"
|
@page="onPageChange($event)"
|
||||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||||
:rowsPerPageOptions="[20, 40, 80]"
|
:rowsPerPageOptions="[20, 40, 80]"
|
||||||
currentPageReportTemplate="نمایش {first} تا {last} از {totalRecords} دارایی"
|
currentPageReportTemplate="نمایش {first} تا {last} از {totalRecords} ملک"
|
||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex flex-wrap gap-2 items-center justify-start">
|
<div class="flex flex-wrap gap-2 items-center justify-start">
|
||||||
<h4 class="m-0">مدیریت اموال</h4>
|
<h4 class="m-0">مدیریت املاک</h4>
|
||||||
<IconField>
|
<IconField>
|
||||||
<InputIcon>
|
<InputIcon>
|
||||||
<i class="pi pi-search" />
|
<i class="pi pi-search" />
|
||||||
@@ -202,11 +236,12 @@ const indexBelongings = async () => {
|
|||||||
<Column selectionMode="multiple" style="width: 3rem" :exportable="false"></Column>
|
<Column selectionMode="multiple" style="width: 3rem" :exportable="false"></Column>
|
||||||
<Column header="تصویر">
|
<Column header="تصویر">
|
||||||
<template #body="slotProps">
|
<template #body="slotProps">
|
||||||
<img :src="`${slotProps.data.imageUrl}`" :alt="slotProps.data.name" class="rounded" style="max-width: 64px" crossorigin="use-credentials" />
|
<img :src="`${slotProps.data.imageUrl || slotProps.data.mainImageUrls?.[0]}`" :alt="slotProps.data.name" class="rounded" style="max-width: 64px" crossorigin="use-credentials" />
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column field="belongingCode" header="کد" style="min-width: 4rem"></Column>
|
<Column field="propertyCode" header="کد" style="min-width: 4rem"></Column>
|
||||||
<Column field="name" header="نام" sortable style="min-width: 16rem"></Column>
|
<Column field="nationalGeoCode" header="شناسه ملی جغرافیایی" style="min-width: 10rem"></Column>
|
||||||
|
<Column field="name" header="نام" sortable style="min-width: 14rem"></Column>
|
||||||
<Column field="category" header="دسته بندی" sortable style="min-width: 6rem">
|
<Column field="category" header="دسته بندی" sortable style="min-width: 6rem">
|
||||||
<template #body="slotProps">
|
<template #body="slotProps">
|
||||||
{{ slotProps.data.category?.nativeName || slotProps.data.nativeCategory }}
|
{{ slotProps.data.category?.nativeName || slotProps.data.nativeCategory }}
|
||||||
@@ -214,36 +249,11 @@ const indexBelongings = async () => {
|
|||||||
</Column>
|
</Column>
|
||||||
<Column :exportable="false" style="min-width: 12rem">
|
<Column :exportable="false" style="min-width: 12rem">
|
||||||
<template #body="slotProps">
|
<template #body="slotProps">
|
||||||
<Button icon="pi pi-pencil" outlined rounded class="mr-2 mx-3" @click="editBelonging(slotProps.data._id)" />
|
<Button icon="pi pi-pencil" outlined rounded class="mr-2 mx-3" @click="editProperty(slotProps.data._id)" />
|
||||||
<Button icon="pi pi-trash" outlined rounded severity="danger" class="mx-3" @click="confirmDeleteProduct(slotProps.data)" />
|
<Button icon="pi pi-trash" outlined rounded severity="danger" class="mx-3" @click="confirmDeleteProperty(slotProps.data._id, $event)" />
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
+62
-6
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive } from 'vue';
|
import { ref, reactive, watch, onMounted } from 'vue';
|
||||||
import { useToast } from 'primevue/usetoast';
|
import { useToast } from 'primevue/usetoast';
|
||||||
import { TransactionService } from '@/service/TransactionService';
|
import { TransactionService } from '@/service/TransactionService';
|
||||||
|
|
||||||
@@ -11,6 +11,14 @@ const props = defineProps({
|
|||||||
belongingId: {
|
belongingId: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
editMode: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
transactionData: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -23,7 +31,8 @@ const transaction = reactive({
|
|||||||
status: 'pending',
|
status: 'pending',
|
||||||
value: null,
|
value: null,
|
||||||
description: '',
|
description: '',
|
||||||
date: new Date().toISOString()
|
date: new Date().toISOString(),
|
||||||
|
_id: null
|
||||||
});
|
});
|
||||||
|
|
||||||
const typeOptions = [
|
const typeOptions = [
|
||||||
@@ -43,6 +52,43 @@ const errors = reactive({
|
|||||||
value: ''
|
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 = () => {
|
const validateForm = () => {
|
||||||
let isValid = true;
|
let isValid = true;
|
||||||
|
|
||||||
@@ -78,9 +124,19 @@ const saveTransaction = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
transaction.belongingId = props.belongingId;
|
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') {
|
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');
|
emit('saved');
|
||||||
closeDialog();
|
closeDialog();
|
||||||
} else {
|
} else {
|
||||||
@@ -98,7 +154,7 @@ const closeDialog = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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="p-fluid">
|
||||||
<div class="field mb-4">
|
<div class="field mb-4">
|
||||||
<label class="font-bold mb-2 block">نوع تراکنش</label>
|
<label class="font-bold mb-2 block">نوع تراکنش</label>
|
||||||
@@ -127,7 +183,7 @@ const closeDialog = () => {
|
|||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<Button label="انصراف" icon="pi pi-times" class="p-button-text" @click="closeDialog" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -0,0 +1,718 @@
|
|||||||
|
<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(false);
|
||||||
|
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 #empty>
|
||||||
|
<div 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>
|
||||||
@@ -280,7 +280,7 @@ const toggle = (event, data) => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<Column field="_id" header="شناسه" sortable style="width: 10%"></Column>
|
<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%">
|
<Column field="type" header="نوع تراکنش" sortable style="width: 15%">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
|
|||||||
@@ -0,0 +1,668 @@
|
|||||||
|
<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(false);
|
||||||
|
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 #empty>
|
||||||
|
<div 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,572 @@
|
|||||||
|
<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,353 @@
|
|||||||
|
<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(false);
|
||||||
|
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 #empty>
|
||||||
|
<div 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,537 @@
|
|||||||
|
<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(false);
|
||||||
|
|
||||||
|
// 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 #empty>
|
||||||
|
<div 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,295 @@
|
|||||||
|
<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(false);
|
||||||
|
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 #empty>
|
||||||
|
<div 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,638 @@
|
|||||||
|
<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>
|
||||||
@@ -18,7 +18,7 @@ const login = async () => {
|
|||||||
});
|
});
|
||||||
const env = import.meta.env.VITE_ENV;
|
const env = import.meta.env.VITE_ENV;
|
||||||
if (env === 'production') {
|
if (env === 'production') {
|
||||||
Cookies.set('bm-token', response.data.data.token, { sameSite: 'none', secure: true, expires: 1000, domain: '.chbk.app' });
|
Cookies.set('bm-token', response.data.data.token, { sameSite: 'none', secure: true, expires: 1000, domain: '.mbmanager.ir' });
|
||||||
} else {
|
} else {
|
||||||
Cookies.set('bm-token', response.data.data.token, { sameSite: 'none', secure: true, expires: 1000 });
|
Cookies.set('bm-token', response.data.data.token, { sameSite: 'none', secure: true, expires: 1000 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,122 +1,564 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="map-container">
|
<div ref="mapContainer" class="map-container" :class="{ 'map-fullscreen': isFullscreen }">
|
||||||
<!-- Search box -->
|
|
||||||
<div class="search-container">
|
<!-- ── Top-left: Search bar ── -->
|
||||||
<input v-model="searchQuery" @keyup.enter="handleSearch" placeholder="جستجو" class="search-input" />
|
<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>
|
</div>
|
||||||
|
|
||||||
<l-map ref="map" v-model:zoom="zoom" :center="center" :use-global-leaflet="false" @click="handleClick($event)">
|
<!-- ── Top-right: Action buttons ── -->
|
||||||
<!-- Base layers -->
|
<div class="map-overlay action-buttons">
|
||||||
<l-control-layers position="topright" />
|
<!-- 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>
|
||||||
|
|
||||||
<!-- OpenStreetMap layer (default) -->
|
<!-- ── Bottom-left: Coordinate readout ── -->
|
||||||
<l-tile-layer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" layer-type="base" name="نمای نقشه ای" :attribution="attribution" />
|
<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>
|
||||||
|
|
||||||
<!-- Satellite layer (optional) -->
|
<!-- ── 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"
|
||||||
|
>
|
||||||
|
<!-- 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
|
<l-tile-layer
|
||||||
:visible="false"
|
:visible="false"
|
||||||
url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
|
url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
|
||||||
layer-type="base"
|
layer-type="base"
|
||||||
name="نمای ماهواره ای"
|
name="نمای ماهوارهای"
|
||||||
attribution="Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community"
|
attribution="Tiles © Esri"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Marker -->
|
<!-- 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-marker v-if="marker.length > 0" :lat-lng="marker">
|
||||||
<l-popup> نقطه انتخاب شده </l-popup>
|
<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-marker>
|
||||||
</l-map>
|
</l-map>
|
||||||
|
|
||||||
|
<!-- Locating spinner overlay -->
|
||||||
|
<div v-if="isLocating" class="locating-overlay">
|
||||||
|
<div class="locating-spinner"></div>
|
||||||
|
<span>در حال تعیین موقعیت...</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue';
|
import { ref, watch, nextTick } from 'vue';
|
||||||
import 'leaflet/dist/leaflet.css';
|
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';
|
import axios from 'axios';
|
||||||
|
|
||||||
// Props and emits
|
// ── Props & Emits ──────────────────────────────────────────────────────────
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: { type: Array, default: () => [] },
|
||||||
type: Array,
|
lon: { type: Number },
|
||||||
default: () => []
|
lat: { type: Number },
|
||||||
}
|
locked: { type: Boolean, default: false }
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue']);
|
const emit = defineEmits(['update:modelValue', 'marker-updated', 'cleared']);
|
||||||
|
|
||||||
// Map configuration
|
// ── State ──────────────────────────────────────────────────────────────────
|
||||||
const zoom = ref(13);
|
const zoom = ref(12);
|
||||||
const center = ref([31.3183, 48.6706]);
|
const center = ref([31.3183, 48.6706]);
|
||||||
const attribution = ref('© OpenStreetMap contributors');
|
const attribution = ref('© OpenStreetMap contributors');
|
||||||
const marker = ref([...props.modelValue]);
|
const marker = ref([...props.modelValue]);
|
||||||
const map = ref(null);
|
const map = ref(null);
|
||||||
|
const mapContainer = ref(null);
|
||||||
|
|
||||||
// Search functionality
|
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
|
const hoverCoords = ref(null);
|
||||||
|
const copied = ref(false);
|
||||||
|
const isLocating = ref(false);
|
||||||
|
const isFullscreen = ref(false);
|
||||||
|
|
||||||
const handleSearch = async () => {
|
// ── Core: set marker + flyTo ───────────────────────────────────────────────
|
||||||
if (!searchQuery.value) return;
|
const setMarker = (latVal, lonVal) => {
|
||||||
|
if (latVal !== null && lonVal !== null && !isNaN(latVal) && !isNaN(lonVal)) {
|
||||||
try {
|
const newCoords = [parseFloat(latVal), parseFloat(lonVal)];
|
||||||
const response = await axios.get(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(searchQuery.value)}`);
|
marker.value = newCoords;
|
||||||
|
center.value = newCoords;
|
||||||
if (response.data && response.data.length > 0) {
|
zoom.value = 16;
|
||||||
const { lat, lon } = response.data[0];
|
emit('update:modelValue', newCoords);
|
||||||
center.value = [parseFloat(lat), parseFloat(lon)];
|
emit('marker-updated', newCoords);
|
||||||
zoom.value = 16;
|
nextTick(() => {
|
||||||
marker.value = [parseFloat(lat), parseFloat(lon)];
|
if (map.value?.leafletObject) {
|
||||||
emit('update:modelValue', marker.value);
|
map.value.leafletObject.flyTo(newCoords, 16, { animate: true, duration: 0.8 });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
});
|
||||||
console.error('Search failed:', error);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Watch for changes in modelValue and sync with marker
|
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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('fullscreenchange', () => {
|
||||||
|
isFullscreen.value = !!document.fullscreenElement;
|
||||||
|
// Invalidate map size so Leaflet redraws correctly after fullscreen
|
||||||
|
nextTick(() => map.value?.leafletObject?.invalidateSize());
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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 }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Mouse hover coords ─────────────────────────────────────────────────────
|
||||||
|
const handleMouseMove = (event) => {
|
||||||
|
if (event?.latlng) {
|
||||||
|
hoverCoords.value = { lat: event.latlng.lat, lon: event.latlng.lng };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Sync modelValue prop → marker ──────────────────────────────────────────
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(newValue) => {
|
(val) => {
|
||||||
marker.value = [...newValue];
|
if (Array.isArray(val) && val.length === 2) {
|
||||||
|
marker.value = [...val];
|
||||||
|
center.value = [...val];
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update marker and emit changes to modelValue
|
// ── Expose to parent ───────────────────────────────────────────────────────
|
||||||
const handleClick = (event) => {
|
defineExpose({ searchByCoordinates: setMarker, setCenterAndMarker: setMarker, searchLocation });
|
||||||
marker.value = [event.latlng.lat, event.latlng.lng];
|
|
||||||
emit('update:modelValue', marker.value);
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* ── Container ──────────────────────────────────────────────────────────── */
|
||||||
.map-container {
|
.map-container {
|
||||||
font-family: 'Estedad', sans-serif !important;
|
font-family: 'Estedad', sans-serif !important;
|
||||||
height: 500px;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
border-radius: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-container {
|
.map-fullscreen {
|
||||||
font-family: 'Estedad', serif !important;
|
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;
|
position: absolute;
|
||||||
top: 10px;
|
|
||||||
left: 10px;
|
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
background: white;
|
pointer-events: auto;
|
||||||
padding: 5px;
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 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 {
|
.search-input {
|
||||||
padding: 8px;
|
border: none;
|
||||||
border: 1px solid #ccc;
|
outline: none;
|
||||||
border-radius: 4px;
|
background: transparent;
|
||||||
width: 250px;
|
|
||||||
font-family: 'Estedad', sans-serif;
|
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>
|
</style>
|
||||||
|
|||||||
+25
-1
@@ -5,6 +5,24 @@ import vue from '@vitejs/plugin-vue';
|
|||||||
import Components from 'unplugin-vue-components/vite';
|
import Components from 'unplugin-vue-components/vite';
|
||||||
import { defineConfig } from '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/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
@@ -14,11 +32,17 @@ export default defineConfig({
|
|||||||
vue(),
|
vue(),
|
||||||
Components({
|
Components({
|
||||||
resolvers: [PrimeVueResolver()]
|
resolvers: [PrimeVueResolver()]
|
||||||
})
|
}),
|
||||||
|
healthCheckPlugin()
|
||||||
],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
host: true,
|
||||||
|
allowedHosts: ['mbmanager.ir', '.mbmanager.ir']
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user