fix(gallery): resolve image zoom stretching and add rich viewer features
deploy-chabokan / deploy (push) Canceled after 0s

This commit is contained in:
2026-08-30 11:25:31 +03:30
parent 7c56d2e66a
commit 195d0300a0
48 changed files with 2448 additions and 2163 deletions
+7
View File
@@ -32,6 +32,7 @@
"usng.js": "^0.4.5",
"utm-latlng": "^1.0.8",
"v-mapbox": "^5.1.0",
"viewerjs": "^1.12.0",
"vue": "^3.4.34",
"vue-cookies": "^1.8.3",
"vue-filepond": "^7.0.4",
@@ -4840,6 +4841,12 @@
"resolved": "https://registry.npmjs.org/vazirmatn/-/vazirmatn-33.0.3.tgz",
"integrity": "sha512-fbjNc0CMjazZpIegWzz9OHGzI1APFboT+x7ZecXlUCtDe/nkyx7gtCTZnWS/+eeXG7fbfXymCPKJI8EsnM3iqw=="
},
"node_modules/viewerjs": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/viewerjs/-/viewerjs-1.12.0.tgz",
"integrity": "sha512-eQrV7FvqXj2Ysavd9LINI6bFAvXtw5YbdlMtdIo6Bmrv25hEOBeXoOX9E7cJwYuFAOPFzmppFvDn02QNHB120w==",
"license": "MIT"
},
"node_modules/vite": {
"version": "5.4.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz",
+1
View File
@@ -32,6 +32,7 @@
"usng.js": "^0.4.5",
"utm-latlng": "^1.0.8",
"v-mapbox": "^5.1.0",
"viewerjs": "^1.12.0",
"vue": "^3.4.34",
"vue-cookies": "^1.8.3",
"vue-filepond": "^7.0.4",
+22 -19
View File
@@ -6,28 +6,31 @@ axios.defaults.baseURL = import.meta.env.VITE_API_URL;
axios.defaults.withCredentials = true;
// Global Axios Request Interceptor for Authentication
axios.interceptors.request.use((config) => {
config.withCredentials = true;
axios.interceptors.request.use(
(config) => {
config.withCredentials = true;
// Resolve token from Cookies, localStorage, or document.cookie
let token = Cookies.get('bm-token') || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
// Resolve token from Cookies, localStorage, or document.cookie
let token = Cookies.get('bm-token') || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
if (!token && typeof document !== 'undefined') {
token = document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1];
if (!token && typeof document !== 'undefined') {
token = document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1];
}
if (token && token !== 'undefined' && token !== 'null' && token.trim() !== '') {
config.headers = config.headers || {};
config.headers.Authorization = `Bearer ${token.trim()}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
if (token && token !== 'undefined' && token !== 'null' && token.trim() !== '') {
config.headers = config.headers || {};
config.headers.Authorization = `Bearer ${token.trim()}`;
}
return config;
}, (error) => {
return Promise.reject(error);
});
);
</script>
<template>
+3 -14
View File
@@ -32,10 +32,7 @@ defineProps({
</div>
<!-- ── TABLE SKELETON ── -->
<div
v-if="type === 'table'"
class="rounded-2xl border border-surface-200 dark:border-surface-700 overflow-hidden bg-surface-0 dark:bg-surface-900 shadow-sm"
>
<div v-if="type === 'table'" class="rounded-2xl border border-surface-200 dark:border-surface-700 overflow-hidden bg-surface-0 dark:bg-surface-900 shadow-sm">
<!-- Table Header Bar -->
<div class="p-4 bg-surface-50 dark:bg-surface-800/60 border-b border-surface-200 dark:border-surface-700 flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-3">
@@ -66,11 +63,7 @@ defineProps({
<!-- ── CARDS GRID SKELETON ── -->
<div v-else-if="type === 'cards'" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<div
v-for="c in rows"
:key="c"
class="card p-4 rounded-2xl border border-surface-200 dark:border-surface-700 bg-surface-0 dark:bg-surface-900 shadow-sm flex flex-col gap-3"
>
<div v-for="c in rows" :key="c" class="card p-4 rounded-2xl border border-surface-200 dark:border-surface-700 bg-surface-0 dark:bg-surface-900 shadow-sm flex flex-col gap-3">
<Skeleton width="100%" height="12rem" class="rounded-xl" />
<div class="flex items-center justify-between gap-2 mt-1">
<Skeleton width="60%" height="1.5rem" class="rounded-md" />
@@ -93,11 +86,7 @@ defineProps({
<Skeleton width="6rem" height="1.25rem" class="rounded-md" />
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div
v-for="i in 4"
:key="i"
class="p-4 rounded-2xl border border-surface-200 dark:border-surface-700 bg-surface-0 dark:bg-surface-900 shadow-sm flex flex-col gap-3"
>
<div v-for="i in 4" :key="i" class="p-4 rounded-2xl border border-surface-200 dark:border-surface-700 bg-surface-0 dark:bg-surface-900 shadow-sm flex flex-col gap-3">
<Skeleton width="100%" height="10rem" class="rounded-xl" />
<Skeleton width="70%" height="1.25rem" class="rounded-md" />
<Skeleton width="45%" height="1rem" class="rounded-md" />
+53 -74
View File
@@ -1,93 +1,72 @@
<!-- LoadingCheck.vue -->
<template>
<div class="inline-flex items-center justify-center">
<svg
:width="size"
:height="size"
viewBox="0 0 24 24"
class="transform transition-transform duration-500"
>
<circle
cx="12"
cy="12"
r="8"
fill="none"
stroke="currentColor"
stroke-width="2"
class="text-blue-500"
:style="circleStyle"
/>
<path
d="M8 12l3 3l5-5"
fill="none"
stroke="currentColor"
stroke-width="2"
class="text-green-500"
:style="checkStyle"
/>
</svg>
</div>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
const props = defineProps({
isComplete: {
type: Boolean,
default: false
},
size: {
type: Number,
default: 24
}
})
isComplete: {
type: Boolean,
default: false
},
size: {
type: Number,
default: 24
}
});
const dashOffset = ref(0)
let animationFrame = null
const dashOffset = ref(0);
let animationFrame = null;
const circleStyle = computed(() => ({
strokeDasharray: '60 60',
strokeDashoffset: props.isComplete ? '60' : String(dashOffset.value),
transition: props.isComplete ? 'all 0.2s ease-in-out' : 'none',
transformOrigin: 'center',
transform: `rotate(${props.isComplete ? -90 : 0}deg)`
}))
strokeDasharray: '60 60',
strokeDashoffset: props.isComplete ? '60' : String(dashOffset.value),
transition: props.isComplete ? 'all 0.2s ease-in-out' : 'none',
transformOrigin: 'center',
transform: `rotate(${props.isComplete ? -90 : 0}deg)`
}));
const checkStyle = computed(() => ({
opacity: props.isComplete ? 1 : 0,
strokeDasharray: 20,
strokeDashoffset: props.isComplete ? 0 : 20,
transition: 'all 0.3s ease-in-out',
transitionDelay: '0.2s'
}))
opacity: props.isComplete ? 1 : 0,
strokeDasharray: 20,
strokeDashoffset: props.isComplete ? 0 : 20,
transition: 'all 0.3s ease-in-out',
transitionDelay: '0.2s'
}));
let startTime = null
let startTime = null;
const animate = (timestamp) => {
if (!startTime) startTime = timestamp
dashOffset.value = (timestamp - startTime) / 10
animationFrame = requestAnimationFrame(animate)
}
if (!startTime) startTime = timestamp;
dashOffset.value = (timestamp - startTime) / 10;
animationFrame = requestAnimationFrame(animate);
};
watch(
() => props.isComplete,
(newValue) => {
if (newValue && animationFrame) {
cancelAnimationFrame(animationFrame)
animationFrame = null
() => props.isComplete,
(newValue) => {
if (newValue && animationFrame) {
cancelAnimationFrame(animationFrame);
animationFrame = null;
}
}
}
)
);
onMounted(() => {
if (!props.isComplete) {
animationFrame = requestAnimationFrame(animate)
}
})
if (!props.isComplete) {
animationFrame = requestAnimationFrame(animate);
}
});
onUnmounted(() => {
if (animationFrame) {
cancelAnimationFrame(animationFrame)
}
})
if (animationFrame) {
cancelAnimationFrame(animationFrame);
}
});
</script>
<template>
<div class="inline-flex items-center justify-center">
<svg :width="size" :height="size" viewBox="0 0 24 24" class="transform transition-transform duration-500">
<circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" stroke-width="2" class="text-blue-500" :style="circleStyle" />
<path d="M8 12l3 3l5-5" fill="none" stroke="currentColor" stroke-width="2" class="text-green-500" :style="checkStyle" />
</svg>
</div>
</template>
@@ -73,9 +73,13 @@ function updateChart() {
};
}
watch(() => props.statusStats, () => {
updateChart();
}, { deep: true });
watch(
() => props.statusStats,
() => {
updateChart();
},
{ deep: true }
);
watch(isDarkTheme, () => {
updateChart();
@@ -55,12 +55,7 @@ function navigate(route) {
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3.5">
<div
v-for="item in actions"
:key="item.title"
@click="navigate(item.route)"
class="quick-action-card group"
>
<div v-for="item in actions" :key="item.title" @click="navigate(item.route)" class="quick-action-card group">
<div :class="['action-icon-box', item.colorClass]">
<i :class="[item.icon, 'text-lg']"></i>
</div>
@@ -191,7 +186,9 @@ function navigate(route) {
:global(.app-dark) .quick-action-card:hover {
background: #27272a;
border-color: #6366f1;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(99, 102, 241, 0.3);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.45),
0 0 0 1px rgba(99, 102, 241, 0.3);
}
:global(.app-dark) .action-title {
+7 -25
View File
@@ -45,15 +45,7 @@ const formatNumber = (num) => {
<h3 class="font-bold text-lg text-surface-900 dark:text-surface-0 mb-1">آخرین املاک ثبت شده</h3>
<p class="text-xs text-muted-color">جدیدترین املاک و دارایی‌های ثبت شده در سیستم</p>
</div>
<Button
label="مشاهده همه"
icon="pi pi-arrow-left"
iconPos="right"
size="small"
text
class="text-xs"
@click="viewAllProperties"
/>
<Button label="مشاهده همه" icon="pi pi-arrow-left" iconPos="right" size="small" text class="text-xs" @click="viewAllProperties" />
</div>
<div v-if="loading" class="py-12 flex items-center justify-center">
@@ -74,12 +66,7 @@ const formatNumber = (num) => {
<Column header="تصویر" style="width: 60px">
<template #body="slotProps">
<div class="w-11 h-11 rounded-lg overflow-hidden border border-surface-200 dark:border-surface-700 bg-surface-100 dark:bg-surface-800 flex items-center justify-center">
<img
:src="slotProps.data.imageUrl"
:alt="slotProps.data.name"
class="w-full h-full object-cover"
@error="(e) => e.target.src = 'https://cloud.mbmanager.ir/images/item-no-image.png'"
/>
<img :src="slotProps.data.imageUrl" :alt="slotProps.data.name" class="w-full h-full object-cover" @error="(e) => (e.target.src = 'https://cloud.mbmanager.ir/images/item-no-image.png')" />
</div>
</template>
</Column>
@@ -91,9 +78,7 @@ const formatNumber = (num) => {
<span class="font-semibold text-surface-900 dark:text-surface-100 text-sm hover:text-primary cursor-pointer" @click="editProperty(slotProps.data._id)">
{{ slotProps.data.name }}
</span>
<span class="text-[11px] text-muted-color font-mono">
کد: {{ slotProps.data.propertyCode }}
</span>
<span class="text-[11px] text-muted-color font-mono"> کد: {{ slotProps.data.propertyCode }} </span>
</div>
</template>
</Column>
@@ -111,7 +96,9 @@ const formatNumber = (num) => {
<Column header="مشخصات" style="min-width: 130px">
<template #body="slotProps">
<div class="flex flex-col text-xs gap-0.5">
<span class="text-surface-600 dark:text-surface-400">مالک: <b class="text-surface-800 dark:text-surface-200">{{ slotProps.data.ownerName }}</b></span>
<span class="text-surface-600 dark:text-surface-400"
>مالک: <b class="text-surface-800 dark:text-surface-200">{{ slotProps.data.ownerName }}</b></span
>
<span class="text-muted-color text-[11px]">مساحت: {{ formatNumber(slotProps.data.area) }} م² ({{ slotProps.data.dong }} دانگ)</span>
</div>
</template>
@@ -129,12 +116,7 @@ const formatNumber = (num) => {
<!-- Action -->
<Column header="عملیات" style="width: 50px">
<template #body="slotProps">
<Button
icon="pi pi-arrow-left"
class="p-button-rounded p-button-text p-button-sm !w-8 !h-8"
v-tooltip.top="'مشاهده و ویرایش'"
@click="editProperty(slotProps.data._id)"
/>
<Button icon="pi pi-arrow-left" class="p-button-rounded p-button-text p-button-sm !w-8 !h-8" v-tooltip.top="'مشاهده و ویرایش'" @click="editProperty(slotProps.data._id)" />
</template>
</Column>
</DataTable>
@@ -18,10 +18,7 @@ const { getPrimary, getSurface, isDarkTheme } = useLayout();
const chartData = ref(null);
const chartOptions = ref(null);
const palette = [
'#3b82f6', '#10b981', '#f59e0b', '#ec4899', '#8b5cf6',
'#06b6d4', '#f97316', '#6366f1', '#14b8a6', '#84cc16'
];
const palette = ['#3b82f6', '#10b981', '#f59e0b', '#ec4899', '#8b5cf6', '#06b6d4', '#f97316', '#6366f1', '#14b8a6', '#84cc16'];
function updateChart() {
const documentStyle = getComputedStyle(document.documentElement);
@@ -101,9 +98,13 @@ function updateChart() {
};
}
watch(() => props.categoryStats, () => {
updateChart();
}, { deep: true });
watch(
() => props.categoryStats,
() => {
updateChart();
},
{ deep: true }
);
watch([getPrimary, getSurface, isDarkTheme], () => {
updateChart();
+25 -19
View File
@@ -24,15 +24,15 @@ const warehouseFlow = computed(() => props.stats?.warehouseFlow || {});
<template>
<!-- Total Properties Card -->
<div class="col-span-12 sm:col-span-6 lg:col-span-6 xl:col-span-3">
<div class="card mb-0 h-full p-5 border border-surface-200 dark:border-surface-800 hover:shadow-lg transition-all duration-300 rounded-2xl relative overflow-hidden bg-gradient-to-br from-surface-0 to-surface-50 dark:from-surface-900 dark:to-surface-950">
<div
class="card mb-0 h-full p-5 border border-surface-200 dark:border-surface-800 hover:shadow-lg transition-all duration-300 rounded-2xl relative overflow-hidden bg-gradient-to-br from-surface-0 to-surface-50 dark:from-surface-900 dark:to-surface-950"
>
<div class="absolute -right-6 -bottom-6 w-24 h-24 bg-blue-500/5 dark:bg-blue-500/10 rounded-full blur-xl pointer-events-none"></div>
<div class="flex justify-between items-start mb-3">
<div>
<span class="block text-muted-color font-medium text-sm mb-1">املاک و مستغلات</span>
<div v-if="loading" class="h-8 w-20 bg-surface-200 dark:bg-surface-700 animate-pulse rounded my-1"></div>
<div v-else class="text-surface-900 dark:text-surface-0 font-bold text-2xl tracking-tight">
{{ formatNumber(overview.totalProperties) }} <span class="text-xs font-normal text-muted-color">ملک</span>
</div>
<div v-else class="text-surface-900 dark:text-surface-0 font-bold text-2xl tracking-tight">{{ formatNumber(overview.totalProperties) }} <span class="text-xs font-normal text-muted-color">ملک</span></div>
</div>
<div class="flex items-center justify-center bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400 rounded-xl shadow-sm" style="width: 3rem; height: 3rem">
<i class="pi pi-building text-xl"></i>
@@ -40,22 +40,24 @@ const warehouseFlow = computed(() => props.stats?.warehouseFlow || {});
</div>
<div class="flex items-center gap-2 text-xs font-medium text-surface-600 dark:text-surface-400 pt-2 border-t border-surface-100 dark:border-surface-800">
<i class="pi pi-map text-blue-500"></i>
<span>مساحت کل: <b class="text-surface-900 dark:text-surface-100">{{ formatNumber(overview.totalArea) }}</b> م²</span>
<span
>مساحت کل: <b class="text-surface-900 dark:text-surface-100">{{ formatNumber(overview.totalArea) }}</b> م²</span
>
</div>
</div>
</div>
<!-- Warehouse Items Card -->
<div class="col-span-12 sm:col-span-6 lg:col-span-6 xl:col-span-3">
<div class="card mb-0 h-full p-5 border border-surface-200 dark:border-surface-800 hover:shadow-lg transition-all duration-300 rounded-2xl relative overflow-hidden bg-gradient-to-br from-surface-0 to-surface-50 dark:from-surface-900 dark:to-surface-950">
<div
class="card mb-0 h-full p-5 border border-surface-200 dark:border-surface-800 hover:shadow-lg transition-all duration-300 rounded-2xl relative overflow-hidden bg-gradient-to-br from-surface-0 to-surface-50 dark:from-surface-900 dark:to-surface-950"
>
<div class="absolute -right-6 -bottom-6 w-24 h-24 bg-amber-500/5 dark:bg-amber-500/10 rounded-full blur-xl pointer-events-none"></div>
<div class="flex justify-between items-start mb-3">
<div>
<span class="block text-muted-color font-medium text-sm mb-1">اقلام انبار</span>
<div v-if="loading" class="h-8 w-20 bg-surface-200 dark:bg-surface-700 animate-pulse rounded my-1"></div>
<div v-else class="text-surface-900 dark:text-surface-0 font-bold text-2xl tracking-tight">
{{ formatNumber(overview.totalWarehouseItems) }} <span class="text-xs font-normal text-muted-color">قلم کالا</span>
</div>
<div v-else class="text-surface-900 dark:text-surface-0 font-bold text-2xl tracking-tight">{{ formatNumber(overview.totalWarehouseItems) }} <span class="text-xs font-normal text-muted-color">قلم کالا</span></div>
</div>
<div class="flex items-center justify-center bg-amber-100 dark:bg-amber-500/20 text-amber-600 dark:text-amber-400 rounded-xl shadow-sm" style="width: 3rem; height: 3rem">
<i class="pi pi-box text-xl"></i>
@@ -63,22 +65,24 @@ const warehouseFlow = computed(() => props.stats?.warehouseFlow || {});
</div>
<div class="flex items-center gap-2 text-xs font-medium text-surface-600 dark:text-surface-400 pt-2 border-t border-surface-100 dark:border-surface-800">
<i class="pi pi-warehouse text-amber-500"></i>
<span>مستقر در <b class="text-surface-900 dark:text-surface-100">{{ formatNumber(overview.totalWarehouses) }}</b> انبار فعال</span>
<span
>مستقر در <b class="text-surface-900 dark:text-surface-100">{{ formatNumber(overview.totalWarehouses) }}</b> انبار فعال</span
>
</div>
</div>
</div>
<!-- Warehouse Transactions Card -->
<div class="col-span-12 sm:col-span-6 lg:col-span-6 xl:col-span-3">
<div class="card mb-0 h-full p-5 border border-surface-200 dark:border-surface-800 hover:shadow-lg transition-all duration-300 rounded-2xl relative overflow-hidden bg-gradient-to-br from-surface-0 to-surface-50 dark:from-surface-900 dark:to-surface-950">
<div
class="card mb-0 h-full p-5 border border-surface-200 dark:border-surface-800 hover:shadow-lg transition-all duration-300 rounded-2xl relative overflow-hidden bg-gradient-to-br from-surface-0 to-surface-50 dark:from-surface-900 dark:to-surface-950"
>
<div class="absolute -right-6 -bottom-6 w-24 h-24 bg-emerald-500/5 dark:bg-emerald-500/10 rounded-full blur-xl pointer-events-none"></div>
<div class="flex justify-between items-start mb-3">
<div>
<span class="block text-muted-color font-medium text-sm mb-1">گردش انبارداری</span>
<div v-if="loading" class="h-8 w-20 bg-surface-200 dark:bg-surface-700 animate-pulse rounded my-1"></div>
<div v-else class="text-surface-900 dark:text-surface-0 font-bold text-2xl tracking-tight">
{{ formatNumber(overview.totalWarehouseTransactions) }} <span class="text-xs font-normal text-muted-color">تراکنش</span>
</div>
<div v-else class="text-surface-900 dark:text-surface-0 font-bold text-2xl tracking-tight">{{ formatNumber(overview.totalWarehouseTransactions) }} <span class="text-xs font-normal text-muted-color">تراکنش</span></div>
</div>
<div class="flex items-center justify-center bg-emerald-100 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 rounded-xl shadow-sm" style="width: 3rem; height: 3rem">
<i class="pi pi-sync text-xl"></i>
@@ -99,15 +103,15 @@ const warehouseFlow = computed(() => props.stats?.warehouseFlow || {});
<!-- Active Users & Roles Card -->
<div class="col-span-12 sm:col-span-6 lg:col-span-6 xl:col-span-3">
<div class="card mb-0 h-full p-5 border border-surface-200 dark:border-surface-800 hover:shadow-lg transition-all duration-300 rounded-2xl relative overflow-hidden bg-gradient-to-br from-surface-0 to-surface-50 dark:from-surface-900 dark:to-surface-950">
<div
class="card mb-0 h-full p-5 border border-surface-200 dark:border-surface-800 hover:shadow-lg transition-all duration-300 rounded-2xl relative overflow-hidden bg-gradient-to-br from-surface-0 to-surface-50 dark:from-surface-900 dark:to-surface-950"
>
<div class="absolute -right-6 -bottom-6 w-24 h-24 bg-purple-500/5 dark:bg-purple-500/10 rounded-full blur-xl pointer-events-none"></div>
<div class="flex justify-between items-start mb-3">
<div>
<span class="block text-muted-color font-medium text-sm mb-1">کاربران سیستم</span>
<div v-if="loading" class="h-8 w-20 bg-surface-200 dark:bg-surface-700 animate-pulse rounded my-1"></div>
<div v-else class="text-surface-900 dark:text-surface-0 font-bold text-2xl tracking-tight">
{{ formatNumber(overview.totalUsers) }} <span class="text-xs font-normal text-muted-color">کاربر</span>
</div>
<div v-else class="text-surface-900 dark:text-surface-0 font-bold text-2xl tracking-tight">{{ formatNumber(overview.totalUsers) }} <span class="text-xs font-normal text-muted-color">کاربر</span></div>
</div>
<div class="flex items-center justify-center bg-purple-100 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400 rounded-xl shadow-sm" style="width: 3rem; height: 3rem">
<i class="pi pi-users text-xl"></i>
@@ -115,7 +119,9 @@ const warehouseFlow = computed(() => props.stats?.warehouseFlow || {});
</div>
<div class="flex items-center gap-2 text-xs font-medium text-surface-600 dark:text-surface-400 pt-2 border-t border-surface-100 dark:border-surface-800">
<i class="pi pi-shield text-purple-500"></i>
<span>تعریف شده در <b class="text-surface-900 dark:text-surface-100">{{ formatNumber(overview.totalRoles) }}</b> نقش سازمانی</span>
<span
>تعریف شده در <b class="text-surface-900 dark:text-surface-100">{{ formatNumber(overview.totalRoles) }}</b> نقش سازمانی</span
>
</div>
</div>
</div>
@@ -48,15 +48,7 @@ const formatNumber = (num) => {
<h3 class="font-bold text-lg text-surface-900 dark:text-surface-0 mb-1">آخرین گردش‌های انبار</h3>
<p class="text-xs text-muted-color">گزارش لحظه‌ای ثبت ورود و خروج کالا در انبارها</p>
</div>
<Button
label="مشاهده همه"
icon="pi pi-arrow-left"
iconPos="right"
size="small"
text
class="text-xs"
@click="viewAllTransactions"
/>
<Button label="مشاهده همه" icon="pi pi-arrow-left" iconPos="right" size="small" text class="text-xs" @click="viewAllTransactions" />
</div>
<!-- Flow stats summary banner -->
@@ -68,14 +60,10 @@ const formatNumber = (num) => {
</div>
<div>
<span class="text-[11px] text-muted-color block">مجموع ورودی</span>
<span class="text-xs font-bold text-emerald-700 dark:text-emerald-400">
{{ formatNumber(warehouseFlow.inQuantity) }} واحد
</span>
<span class="text-xs font-bold text-emerald-700 dark:text-emerald-400"> {{ formatNumber(warehouseFlow.inQuantity) }} واحد </span>
</div>
</div>
<span class="text-xs font-medium text-emerald-600 dark:text-emerald-400">
{{ formatNumber(warehouseFlow.inCount) }} تراکنش
</span>
<span class="text-xs font-medium text-emerald-600 dark:text-emerald-400"> {{ formatNumber(warehouseFlow.inCount) }} تراکنش </span>
</div>
<div class="p-3 rounded-xl bg-rose-50/60 dark:bg-rose-950/20 border border-rose-200/50 dark:border-rose-800/30 flex items-center justify-between">
@@ -85,14 +73,10 @@ const formatNumber = (num) => {
</div>
<div>
<span class="text-[11px] text-muted-color block">مجموع خروجی</span>
<span class="text-xs font-bold text-rose-700 dark:text-rose-400">
{{ formatNumber(warehouseFlow.outQuantity) }} واحد
</span>
<span class="text-xs font-bold text-rose-700 dark:text-rose-400"> {{ formatNumber(warehouseFlow.outQuantity) }} واحد </span>
</div>
</div>
<span class="text-xs font-medium text-rose-600 dark:text-rose-400">
{{ formatNumber(warehouseFlow.outCount) }} تراکنش
</span>
<span class="text-xs font-medium text-rose-600 dark:text-rose-400"> {{ formatNumber(warehouseFlow.outCount) }} تراکنش </span>
</div>
</div>
@@ -110,18 +94,12 @@ const formatNumber = (num) => {
<!-- Transactions List -->
<ul v-else class="list-none p-0 m-0 divide-y divide-surface-100 dark:divide-surface-800">
<li
v-for="tx in transactions"
:key="tx._id"
class="py-3 flex items-center justify-between gap-3 hover:bg-surface-50 dark:hover:bg-surface-800/50 px-2 rounded-xl transition-colors duration-150"
>
<li v-for="tx in transactions" :key="tx._id" class="py-3 flex items-center justify-between gap-3 hover:bg-surface-50 dark:hover:bg-surface-800/50 px-2 rounded-xl transition-colors duration-150">
<div class="flex items-center gap-3 min-w-0">
<div
:class="[
'w-9 h-9 rounded-xl flex items-center justify-center shrink-0 font-bold text-xs',
tx.type === 'IN'
? 'bg-emerald-100 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400'
: 'bg-rose-100 dark:bg-rose-500/20 text-rose-600 dark:text-rose-400'
tx.type === 'IN' ? 'bg-emerald-100 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400' : 'bg-rose-100 dark:bg-rose-500/20 text-rose-600 dark:text-rose-400'
]"
>
<i :class="['pi text-xs', tx.type === 'IN' ? 'pi-arrow-down' : 'pi-arrow-up']"></i>
@@ -135,9 +113,7 @@ const formatNumber = (num) => {
<span
:class="[
'px-1.5 py-0.2 rounded text-[10px] font-bold',
tx.type === 'IN'
? 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300'
: 'bg-rose-100 dark:bg-rose-900/40 text-rose-700 dark:text-rose-300'
tx.type === 'IN' ? 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300' : 'bg-rose-100 dark:bg-rose-900/40 text-rose-700 dark:text-rose-300'
]"
>
{{ tx.type === 'IN' ? 'ورود' : 'خروج' }}
@@ -152,12 +128,7 @@ const formatNumber = (num) => {
</div>
<div class="text-left shrink-0">
<div
:class="[
'font-bold text-sm font-mono',
tx.type === 'IN' ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400'
]"
>
<div :class="['font-bold text-sm font-mono', tx.type === 'IN' ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400']">
{{ tx.type === 'IN' ? '+' : '-' }}{{ formatNumber(tx.quantity) }} {{ tx.unit }}
</div>
<span class="text-[10px] text-muted-color font-mono block">
+1 -4
View File
@@ -40,12 +40,9 @@ const model = ref([
},
{
label: 'تنظیمات سیستم',
items: [
{ label: 'تنظیمات جستجو', icon: 'pi pi-fw pi-sliders-h', to: '/settings/search-settings' }
]
items: [{ label: 'تنظیمات جستجو', icon: 'pi pi-fw pi-sliders-h', to: '/settings/search-settings' }]
}
]);
</script>
<template>
+1 -6
View File
@@ -119,12 +119,7 @@ const { toggleMenu, toggleDarkMode, isDarkTheme } = useLayout();
<i class="pi pi-user"></i>
<span>کاربران</span>
</router-link>
<button
type="button"
class="layout-topbar-action text-red-500 hover:text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30"
title="خروج از حساب کاربری"
@click="signOut"
>
<button type="button" class="layout-topbar-action text-red-500 hover:text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30" title="خروج از حساب کاربری" @click="signOut">
<i class="pi pi-power-off text-red-500"></i>
<span class="text-red-500">خروج</span>
</button>
+1 -1
View File
@@ -15,7 +15,7 @@ const layoutState = reactive({
configSidebarVisible: false,
staticMenuMobileActive: false,
menuHoverActive: false,
activeMenuItem: null,
activeMenuItem: null
});
export function useLayout() {
-1
View File
@@ -35,4 +35,3 @@ export const CategoryService = {
}
}
};
-1
View File
@@ -44,4 +44,3 @@ export const FeatureService = {
}
}
};
+5 -4
View File
@@ -1,10 +1,11 @@
import axios from 'axios';
const getAuthHeader = () => {
const bmToken = document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1] || localStorage.getItem('bm-token');
const bmToken =
document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1] || localStorage.getItem('bm-token');
return bmToken ? { Authorization: `Bearer ${bmToken}` } : {};
};
+8 -7
View File
@@ -2,13 +2,15 @@ import axios from 'axios';
export const PropertyService = {
_getAuthHeader: () => {
const bmToken = (typeof document !== 'undefined'
? document.cookie.split('; ').find((row) => row.startsWith('bm-token='))?.split('=')[1]
: null) || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
const bmToken =
(typeof document !== 'undefined'
? document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1]
: null) || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
return bmToken && bmToken !== 'undefined' && bmToken !== 'null'
? { Authorization: `Bearer ${bmToken}` }
: {};
return bmToken && bmToken !== 'undefined' && bmToken !== 'null' ? { Authorization: `Bearer ${bmToken}` } : {};
},
getProperty: async (propertyId) => {
@@ -75,7 +77,6 @@ export const PropertyService = {
}
},
indexProperties: async () => {
const config = {
method: 'post',
+5 -4
View File
@@ -1,10 +1,11 @@
import axios from 'axios';
const getAuthHeader = () => {
const bmToken = document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1] || localStorage.getItem('bm-token');
const bmToken =
document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1] || localStorage.getItem('bm-token');
return bmToken ? { Authorization: `Bearer ${bmToken}` } : {};
};
+8 -6
View File
@@ -2,13 +2,15 @@ import axios from 'axios';
export const SettingService = {
_getAuthHeader: () => {
const bmToken = (typeof document !== 'undefined'
? document.cookie.split('; ').find((row) => row.startsWith('bm-token='))?.split('=')[1]
: null) || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
const bmToken =
(typeof document !== 'undefined'
? document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1]
: null) || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
return bmToken && bmToken !== 'undefined' && bmToken !== 'null'
? { Authorization: `Bearer ${bmToken}` }
: {};
return bmToken && bmToken !== 'undefined' && bmToken !== 'null' ? { Authorization: `Bearer ${bmToken}` } : {};
},
/**
+8 -7
View File
@@ -2,13 +2,15 @@ import axios from 'axios';
export const TransactionService = {
_getAuthHeader: () => {
const bmToken = (typeof document !== 'undefined'
? document.cookie.split('; ').find((row) => row.startsWith('bm-token='))?.split('=')[1]
: null) || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
const bmToken =
(typeof document !== 'undefined'
? document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1]
: null) || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
return bmToken && bmToken !== 'undefined' && bmToken !== 'null'
? { Authorization: `Bearer ${bmToken}` }
: {};
return bmToken && bmToken !== 'undefined' && bmToken !== 'null' ? { Authorization: `Bearer ${bmToken}` } : {};
},
/**
* Get a single transaction by ID
@@ -188,7 +190,6 @@ export const TransactionService = {
}
},
indexTransactions: async () => {
const config = {
method: 'post',
+5 -4
View File
@@ -1,10 +1,11 @@
import axios from 'axios';
const getAuthHeader = () => {
const bmToken = document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1] || localStorage.getItem('bm-token');
const bmToken =
document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1] || localStorage.getItem('bm-token');
return bmToken ? { Authorization: `Bearer ${bmToken}` } : {};
};
+7 -3
View File
@@ -1,9 +1,13 @@
import axios from 'axios';
const getAuthHeader = () => {
const bmToken = (typeof document !== 'undefined'
? document.cookie.split('; ').find((row) => row.startsWith('bm-token='))?.split('=')[1]
: null) || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
const bmToken =
(typeof document !== 'undefined'
? document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1]
: null) || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
return bmToken && bmToken !== 'undefined' && bmToken !== 'null' ? { Authorization: `Bearer ${bmToken}` } : {};
};
+7 -3
View File
@@ -1,9 +1,13 @@
import axios from 'axios';
const getAuthHeader = () => {
const bmToken = (typeof document !== 'undefined'
? document.cookie.split('; ').find((row) => row.startsWith('bm-token='))?.split('=')[1]
: null) || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
const bmToken =
(typeof document !== 'undefined'
? document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1]
: null) || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
return bmToken && bmToken !== 'undefined' && bmToken !== 'null' ? { Authorization: `Bearer ${bmToken}` } : {};
};
+2 -15
View File
@@ -38,13 +38,7 @@ export function jalaliToGregorian(jy, jm, jd) {
let gy = jy > 979 ? 1600 : 621;
jy = jy > 979 ? jy - 979 : jy;
let days =
365 * jy +
Math.floor(jy / 33) * 8 +
Math.floor(((jy % 33) + 3) / 4) +
78 +
jd +
(jm < 7 ? (jm - 1) * 31 : (jm - 7) * 30 + 186);
let days = 365 * jy + Math.floor(jy / 33) * 8 + Math.floor(((jy % 33) + 3) / 4) + 78 + jd + (jm < 7 ? (jm - 1) * 31 : (jm - 7) * 30 + 186);
gy += 400 * Math.floor(days / 146097);
days %= 146097;
@@ -90,14 +84,7 @@ export function gregorianToJalali(gy, gm, gd) {
gy = gy > 1600 ? gy - 1600 : gy - 621;
const gy2 = gm > 2 ? gy + 1 : gy;
let days =
365 * gy +
Math.floor((gy2 + 3) / 4) -
Math.floor((gy2 + 99) / 100) +
Math.floor((gy2 + 399) / 400) -
80 +
gd +
g_d_m[gm - 1];
let days = 365 * gy + Math.floor((gy2 + 3) / 4) - Math.floor((gy2 + 99) / 100) + Math.floor((gy2 + 399) / 400) - 80 + gd + g_d_m[gm - 1];
jy += 33 * Math.floor(days / 12053);
days %= 12053;
+7 -18
View File
@@ -50,7 +50,9 @@ onMounted(() => {
<template>
<div class="flex flex-col gap-6">
<!-- Dashboard Header Welcome Banner -->
<div class="card p-6 border border-surface-200 dark:border-surface-800 rounded-2xl bg-gradient-to-l from-primary-500/10 via-surface-0 to-surface-0 dark:from-primary-500/15 dark:via-surface-900 dark:to-surface-900 shadow-sm relative overflow-hidden">
<div
class="card p-6 border border-surface-200 dark:border-surface-800 rounded-2xl bg-gradient-to-l from-primary-500/10 via-surface-0 to-surface-0 dark:from-primary-500/15 dark:via-surface-900 dark:to-surface-900 shadow-sm relative overflow-hidden"
>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<div class="flex items-center gap-2 mb-1.5">
@@ -63,21 +65,12 @@ onMounted(() => {
{{ todayFormatted }}
</span>
</div>
<h1 class="text-2xl font-black text-surface-900 dark:text-surface-0 tracking-tight">
داشبورد جامع مدیریت اموال و انبارداری
</h1>
<p class="text-sm text-muted-color mt-1">
خلاصه آماری دارایی‌ها، املاک ثبتی و گزارش لحظه‌ای گردش کالاهای انبار
</p>
<h1 class="text-2xl font-black text-surface-900 dark:text-surface-0 tracking-tight">داشبورد جامع مدیریت اموال و انبارداری</h1>
<p class="text-sm text-muted-color mt-1">خلاصه آماری دارایی‌ها، املاک ثبتی و گزارش لحظه‌ای گردش کالاهای انبار</p>
</div>
<div class="flex items-center gap-3 shrink-0">
<Button
icon="pi pi-refresh"
:class="['p-button-outlined p-button-secondary !w-10 !h-10 p-button-rounded', { 'pi-spin': refreshing }]"
v-tooltip.bottom="'بروزرسانی داده‌ها'"
@click="fetchDashboardStats(true)"
/>
<Button icon="pi pi-refresh" :class="['p-button-outlined p-button-secondary !w-10 !h-10 p-button-rounded', { 'pi-spin': refreshing }]" v-tooltip.bottom="'بروزرسانی داده‌ها'" @click="fetchDashboardStats(true)" />
<router-link to="/property/create-property">
<Button label="ثبت ملک جدید" icon="pi pi-plus" class="shadow-sm" />
</router-link>
@@ -115,11 +108,7 @@ onMounted(() => {
<!-- Recent Warehouse Transactions Feed -->
<div class="col-span-12 xl:col-span-5">
<WarehouseActivityWidget
:transactions="stats?.recentWarehouseTransactions || []"
:warehouse-flow="stats?.warehouseFlow || { inCount: 0, outCount: 0, inQuantity: 0, outQuantity: 0 }"
:loading="loading"
/>
<WarehouseActivityWidget :transactions="stats?.recentWarehouseTransactions || []" :warehouse-flow="stats?.warehouseFlow || { inCount: 0, outCount: 0, inQuantity: 0, outQuantity: 0 }" :loading="loading" />
</div>
</div>
</div>
+4 -4
View File
@@ -177,9 +177,7 @@ function exportCSV() {
<Column field="englishName" header="نام انگلیسی" sortable style="min-width: 16rem"></Column>
<Column header="تعداد ویژگی‌ها" style="min-width: 10rem">
<template #body="slotProps">
<span class="px-2.5 py-1 text-xs rounded-full bg-indigo-50 dark:bg-indigo-950/60 text-indigo-700 dark:text-indigo-300 font-medium">
{{ slotProps.data.features?.length || 0 }} ویژگی
</span>
<span class="px-2.5 py-1 text-xs rounded-full bg-indigo-50 dark:bg-indigo-950/60 text-indigo-700 dark:text-indigo-300 font-medium"> {{ slotProps.data.features?.length || 0 }} ویژگی </span>
</template>
</Column>
<Column :exportable="false" style="min-width: 12rem">
@@ -227,7 +225,9 @@ function exportCSV() {
<Dialog v-model:visible="deleteSelectedDialog" :style="{ width: '450px' }" header="تایید حذف گروهی" :modal="true">
<div class="flex items-center gap-4">
<i class="pi pi-exclamation-triangle !text-3xl text-red-500" />
<span>آیا از حذف <b>{{ selectedCategories?.length || 0 }}</b> دسته‌بندی انتخاب شده اطمینان دارید؟</span>
<span
>آیا از حذف <b>{{ selectedCategories?.length || 0 }}</b> دسته‌بندی انتخاب شده اطمینان دارید؟</span
>
</div>
<template #footer>
<Button label="خیر" icon="pi pi-times" text @click="deleteSelectedDialog = false" />
+7 -38
View File
@@ -27,7 +27,7 @@ const loadCategories = async () => {
try {
const data = await CategoryService.getAllCategories();
categories.value = Array.isArray(data) ? data : [];
if (categories.value.length > 0 && (!selectedTab.value || !categories.value.some(c => c._id === selectedTab.value))) {
if (categories.value.length > 0 && (!selectedTab.value || !categories.value.some((c) => c._id === selectedTab.value))) {
selectedTab.value = categories.value[0]._id;
}
} catch (e) {
@@ -119,7 +119,7 @@ const saveFeature = async () => {
const getFeatureTypeLabel = (type) => {
const found = featureTypes.find((t) => t.value === type);
return found ? found.name : (type || '-');
return found ? found.name : type || '-';
};
</script>
@@ -131,13 +131,7 @@ const getFeatureTypeLabel = (type) => {
<h3 class="text-xl font-bold m-0 text-slate-800 dark:text-slate-100">ویژگی دسته‌بندی‌ها</h3>
<p class="text-xs text-slate-500 mt-1 mb-0">تعریف و مدیریت فیلدهای اختصاصی برای هر دسته‌بندی</p>
</div>
<Button
v-if="selectedTab"
label="افزودن ویژگی جدید"
icon="pi pi-plus"
severity="primary"
@click="openNew(selectedTab)"
/>
<Button v-if="selectedTab" label="افزودن ویژگی جدید" icon="pi pi-plus" severity="primary" @click="openNew(selectedTab)" />
</div>
<!-- Skeleton Loading -->
@@ -175,17 +169,8 @@ const getFeatureTypeLabel = (type) => {
<TabPanels>
<TabPanel v-for="cat in categories" :key="cat._id + '_content'" :value="cat._id">
<div class="flex items-center justify-between mb-4">
<span class="text-sm font-semibold text-slate-700 dark:text-slate-200">
لیست ویژگی‌های «{{ cat.nativeName }}» ({{ cat.features?.length || 0 }} مورد)
</span>
<Button
label="افزودن ویژگی"
icon="pi pi-plus"
size="small"
severity="secondary"
outlined
@click="openNew(cat._id)"
/>
<span class="text-sm font-semibold text-slate-700 dark:text-slate-200"> لیست ویژگی‌های «{{ cat.nativeName }}» ({{ cat.features?.length || 0 }} مورد) </span>
<Button label="افزودن ویژگی" icon="pi pi-plus" size="small" severity="secondary" outlined @click="openNew(cat._id)" />
</div>
<!-- Features Table / List -->
@@ -216,24 +201,8 @@ const getFeatureTypeLabel = (type) => {
<td class="p-3 text-xs text-slate-500">{{ feat.tag || '-' }}</td>
<td class="p-3 text-center">
<div class="flex items-center justify-center gap-2">
<Button
icon="pi pi-pencil"
size="small"
outlined
rounded
severity="info"
title="ویرایش"
@click="editFeature(feat, cat._id)"
/>
<Button
icon="pi pi-trash"
size="small"
outlined
rounded
severity="danger"
title="حذف"
@click="confirmDelete(feat)"
/>
<Button icon="pi pi-pencil" size="small" outlined rounded severity="info" title="ویرایش" @click="editFeature(feat, cat._id)" />
<Button icon="pi pi-trash" size="small" outlined rounded severity="danger" title="حذف" @click="confirmDelete(feat)" />
</div>
</td>
</tr>
+46 -135
View File
@@ -156,11 +156,11 @@ const long = ref(null);
// 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 || '');
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 (owner) return owner;
if (regNum) return regNum;
return '';
});
@@ -206,7 +206,7 @@ const dongOptions = [
{ value: 3, label: '۳ دانگ', fraction: '۱⁏۲ (نیم)' },
{ value: 4, label: '۴ دانگ', fraction: '۲⁏۳ (دو سوم)' },
{ value: 5, label: '۵ دانگ', fraction: '۵⁏۶' },
{ value: 6, label: '۶ دانگ', fraction: 'تمام (مالکیت کامل)' },
{ value: 6, label: '۶ دانگ', fraction: 'تمام (مالکیت کامل)' }
];
/**
@@ -356,28 +356,23 @@ const handleNationalGeoCodeChange = () => {
};
// Auto sync category when propertyType changes
watch(() => property.value.propertyType, (newType) => {
if (pageLoading.value || editMode.value) return;
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) {
property.value.category = matchedCategory._id;
handleSelectCategory(matchedCategory._id);
watch(
() => property.value.propertyType,
(newType) => {
if (pageLoading.value || editMode.value) return;
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) {
property.value.category = matchedCategory._id;
handleSelectCategory(matchedCategory._id);
}
}
});
);
// Watch for form modifications to mark dirty for auto-save
watch(
[
() => property.value,
() => featuresValues.value,
() => regYear.value,
() => regMonth.value,
() => regDay.value,
() => mainImageUrlsWithData.value,
() => documentUrlsWithData.value
],
[() => property.value, () => featuresValues.value, () => regYear.value, () => regMonth.value, () => regDay.value, () => mainImageUrlsWithData.value, () => documentUrlsWithData.value],
() => {
if (!pageLoading.value) {
isDirty.value = true;
@@ -531,13 +526,13 @@ const performAutoSave = async () => {
}
payload.mainImageUrls = (mainImageUrlsWithData.value || [])
.map((x) => {
const raw = typeof x === 'string' ? x : (x.url || x.source || '');
const raw = typeof x === 'string' ? x : x.url || x.source || '';
return raw.split('?')[0];
})
.filter(Boolean);
payload.documentUrls = (documentUrlsWithData.value || [])
.map((x) => {
const raw = typeof x === 'string' ? x : (x.url || x.source || '');
const raw = typeof x === 'string' ? x : x.url || x.source || '';
return raw.split('?')[0];
})
.filter(Boolean);
@@ -656,12 +651,8 @@ onBeforeMount(async () => {
}
}
const mainImages = (data.signedMainImageUrls && data.signedMainImageUrls.length > 0)
? data.signedMainImageUrls
: (data.mainImageUrls || []);
const docs = (data.signedDocumentUrls && data.signedDocumentUrls.length > 0)
? data.signedDocumentUrls
: (data.documentUrls || []);
const mainImages = data.signedMainImageUrls && data.signedMainImageUrls.length > 0 ? data.signedMainImageUrls : data.mainImageUrls || [];
const docs = data.signedDocumentUrls && data.signedDocumentUrls.length > 0 ? data.signedDocumentUrls : data.documentUrls || [];
mainImageUrlsWithData.value = fillImagesArray(mainImages);
documentUrlsWithData.value = fillImagesArray(docs);
@@ -669,7 +660,7 @@ onBeforeMount(async () => {
toast.add({ severity: 'error', summary: 'خطا', detail: 'ملک مورد نظر یافت نشد.', life: 4000 });
}
} else {
let matchedCat = categories.value.find(c => c.englishName === 'Lot' || c.nativeName === 'زمین');
let matchedCat = categories.value.find((c) => c.englishName === 'Lot' || c.nativeName === 'زمین');
if (matchedCat) {
property.value.category = matchedCat._id;
handleSelectCategory(matchedCat._id);
@@ -723,13 +714,13 @@ const saveProperty = async () => {
payload.mainImageUrls = (mainImageUrlsWithData.value || [])
.map((x) => {
const raw = typeof x === 'string' ? x : (x.url || x.source || '');
const raw = typeof x === 'string' ? x : x.url || x.source || '';
return raw.split('?')[0];
})
.filter(Boolean);
payload.documentUrls = (documentUrlsWithData.value || [])
.map((x) => {
const raw = typeof x === 'string' ? x : (x.url || x.source || '');
const raw = typeof x === 'string' ? x : x.url || x.source || '';
return raw.split('?')[0];
})
.filter(Boolean);
@@ -769,7 +760,7 @@ const saveProperty = async () => {
</script>
<template>
<div class="space-y-6 dir-rtl relative" style="direction: rtl;">
<div class="space-y-6 dir-rtl relative" style="direction: rtl">
<!-- Toast Notification Container -->
<Toast></Toast>
@@ -794,16 +785,7 @@ const saveProperty = async () => {
<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"
:disabled="pageLoading"
@click="saveProperty"
class="px-5 font-semibold"
/>
<Button :label="editMode ? 'بروزرسانی ملک' : 'ذخیره ملک'" icon="pi pi-check" severity="primary" rounded :loading="isSaving" :disabled="pageLoading" @click="saveProperty" class="px-5 font-semibold" />
</div>
</div>
@@ -827,40 +809,23 @@ const saveProperty = async () => {
<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
/>
<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>
<!-- 2. شناسه ملی جغرافیایی (JAM) -->
<div>
<div class="flex items-center justify-between mb-1.5">
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200">
شناسه ملی جغرافیایی (کد جام) <span class="text-red-500">*</span>
</label>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200"> شناسه ملی جغرافیایی (کد جام) <span class="text-red-500">*</span> </label>
<span class="text-xs text-primary font-medium">الزامی</span>
</div>
<div class="relative">
<InputText
v-model="property.nationalGeoCode"
@input="handleNationalGeoCodeChange"
placeholder="مثلاً: ۵۳۸۱۳۶۳۹۵۰۱۳۵۳۹ (۱۵ رقم)"
class="w-full !rounded-xl pl-9"
fluid
/>
<InputText v-model="property.nationalGeoCode" @input="handleNationalGeoCodeChange" placeholder="مثلاً: ۵۳۸۱۳۶۳۹۵۰۱۳۵۳۹ (۱۵ رقم)" class="w-full !rounded-xl pl-9" fluid />
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-surface-400">
<i class="pi pi-map-marker text-sm"></i>
</span>
</div>
<small class="text-xs text-surface-500 mt-1 block">
شامل ۶ رقم طول + ۷ رقم عرض + ۲ رقم زون UTM
</small>
<small class="text-xs text-surface-500 mt-1 block"> شامل ۶ رقم طول + ۷ رقم عرض + ۲ رقم زون UTM </small>
</div>
<!-- 3. شماره سریال سند -->
@@ -883,30 +848,11 @@ const saveProperty = async () => {
<!-- 6. تاریخ ثبت سند (شمسی) -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">
تاریخ ثبت سند
</label>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5"> تاریخ ثبت سند </label>
<div class="grid grid-cols-3 gap-2">
<InputText
v-model="regDay"
placeholder="روز (۰۱)"
maxlength="2"
class="!rounded-xl text-center text-xs"
/>
<Dropdown
v-model="regMonth"
:options="persianMonths"
optionLabel="label"
optionValue="value"
placeholder="ماه"
class="!rounded-xl text-xs"
/>
<InputText
v-model="regYear"
placeholder="سال (۱۴۰۲)"
maxlength="4"
class="!rounded-xl text-center text-xs"
/>
<InputText v-model="regDay" placeholder="روز (۰۱)" maxlength="2" class="!rounded-xl text-center text-xs" />
<Dropdown v-model="regMonth" :options="persianMonths" optionLabel="label" optionValue="value" placeholder="ماه" class="!rounded-xl text-xs" />
<InputText v-model="regYear" placeholder="سال (۱۴۰۲)" maxlength="4" class="!rounded-xl text-center text-xs" />
</div>
</div>
</div>
@@ -922,17 +868,13 @@ const saveProperty = async () => {
<div class="space-y-4">
<!-- Auto-generated Display Name Preview -->
<div class="p-3 bg-surface-50 dark:bg-surface-800/50 rounded-xl border border-surface-200 dark:border-surface-700">
<label class="block text-xs font-semibold text-surface-500 dark:text-surface-400 mb-1">
نام ثبت‌شده ملک (تولید خودکار بر اساس مالک و شماره ثبتی)
</label>
<label class="block text-xs font-semibold text-surface-500 dark:text-surface-400 mb-1"> نام ثبت‌شده ملک (تولید خودکار بر اساس مالک و شماره ثبتی) </label>
<div class="flex items-center gap-2">
<i class="pi pi-building text-primary text-sm"></i>
<span v-if="computedPropertyName" class="text-sm font-bold text-surface-900 dark:text-surface-100 font-mono">
{{ computedPropertyName }}
</span>
<span v-else class="text-sm text-surface-400 italic">
پس از وارد کردن نام مالک و شماره ثبتی نمایش داده می‌شود
</span>
<span v-else class="text-sm text-surface-400 italic"> پس از وارد کردن نام مالک و شماره ثبتی نمایش داده می‌شود </span>
</div>
</div>
@@ -955,16 +897,7 @@ const saveProperty = async () => {
<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
>
<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" />
@@ -1024,18 +957,8 @@ const saveProperty = async () => {
<InputText v-model="property.nationality" 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">
میزان سهم مالکیت (دانگ)
</label>
<Dropdown
v-model="property.dong"
:options="dongOptions"
optionLabel="label"
optionValue="value"
placeholder="انتخاب دانگ"
class="w-full !rounded-xl"
fluid
>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5"> میزان سهم مالکیت (دانگ) </label>
<Dropdown v-model="property.dong" :options="dongOptions" optionLabel="label" optionValue="value" placeholder="انتخاب دانگ" class="w-full !rounded-xl" fluid>
<template #option="slotProps">
<div class="flex items-center justify-between w-full">
<span class="font-semibold">{{ slotProps.option.label }}</span>
@@ -1150,15 +1073,7 @@ const saveProperty = async () => {
<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
/>
<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]" />
@@ -1199,13 +1114,7 @@ const saveProperty = async () => {
<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"
v-model="mapMarker"
:locked="!!property.nationalGeoCode?.trim()"
@marker-updated="handleMarkerUpdated"
@cleared="handleMapCleared"
/>
<MapComponent ref="mapRef" v-model="mapMarker" :locked="!!property.nationalGeoCode?.trim()" @marker-updated="handleMarkerUpdated" @cleared="handleMapCleared" />
</div>
</div>
</div>
@@ -1243,7 +1152,9 @@ const saveProperty = async () => {
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
transition:
opacity 0.3s ease,
transform 0.3s ease;
}
.fade-enter-from,
+23 -121
View File
@@ -150,8 +150,6 @@ const loadData = async () => {
}
};
const loading = ref(false);
const indexingFinished = ref(false);
const indexingMessage = ref('در حال ایندکس...');
@@ -258,18 +256,8 @@ const onCardLeave = () => {
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<!-- Start: Actions -->
<div class="flex flex-wrap items-center gap-3">
<Button
label="ساخت ملک جدید"
icon="pi pi-plus"
severity="primary"
@click="router.push('/property/create-property')"
/>
<Button
@click="indexProperties"
:disabled="loading"
severity="secondary"
class="indexing-button"
>
<Button label="ساخت ملک جدید" icon="pi pi-plus" severity="primary" @click="router.push('/property/create-property')" />
<Button @click="indexProperties" :disabled="loading" severity="secondary" class="indexing-button">
<div class="button-content">
<div v-if="!loading" class="flex items-center gap-2">
<i class="pi pi-refresh"></i>
@@ -307,16 +295,8 @@ const onCardLeave = () => {
@click="router.push('/settings/search-settings')"
/>
<!-- View Mode Selector -->
<SelectButton
v-model="viewMode"
:options="viewModeOptions"
optionLabel="label"
optionValue="value"
:allowEmpty="false"
class="view-mode-selector"
>
<SelectButton v-model="viewMode" :options="viewModeOptions" optionLabel="label" optionValue="value" :allowEmpty="false" class="view-mode-selector">
<template #option="slotProps">
<div class="flex items-center gap-2 px-2 py-1">
<i :class="slotProps.option.icon"></i>
@@ -365,16 +345,10 @@ const onCardLeave = () => {
</div>
</template>
<Column selectionMode="multiple" style="width: 3rem" :exportable="false"></Column>
<Column header="تصویر" style="width: 5rem">
<template #body="slotProps">
<img
:src="getPropertyImage(slotProps.data)"
:alt="slotProps.data.name"
class="rounded-lg object-cover w-14 h-14 shadow-sm"
@error="handleImageError"
/>
<img :src="getPropertyImage(slotProps.data)" :alt="slotProps.data.name" class="rounded-lg object-cover w-14 h-14 shadow-sm" @error="handleImageError" />
</template>
</Column>
<Column field="propertyCode" header="کد" style="min-width: 5rem">
@@ -408,30 +382,9 @@ const onCardLeave = () => {
<Column :exportable="false" style="min-width: 12rem">
<template #body="slotProps">
<div class="flex items-center gap-2">
<Button
icon="pi pi-external-link"
outlined
rounded
severity="secondary"
title="باز کردن در تب جدید"
@click="openInNewTab(slotProps.data._id || slotProps.data.id)"
/>
<Button
icon="pi pi-pencil"
outlined
rounded
severity="info"
title="ویرایش ملک"
@click="editProperty(slotProps.data._id || slotProps.data.id)"
/>
<Button
icon="pi pi-trash"
outlined
rounded
severity="danger"
title="حذف ملک"
@click="confirmDeleteProperty(slotProps.data._id || slotProps.data.id, $event)"
/>
<Button icon="pi pi-external-link" outlined rounded severity="secondary" title="باز کردن در تب جدید" @click="openInNewTab(slotProps.data._id || slotProps.data.id)" />
<Button icon="pi pi-pencil" outlined rounded severity="info" title="ویرایش ملک" @click="editProperty(slotProps.data._id || slotProps.data.id)" />
<Button icon="pi pi-trash" outlined rounded severity="danger" title="حذف ملک" @click="confirmDeleteProperty(slotProps.data._id || slotProps.data.id, $event)" />
</div>
</template>
</Column>
@@ -443,18 +396,12 @@ const onCardLeave = () => {
<!-- Left/Main: Property Cards Scrollable Column -->
<div class="properties-cards-column">
<div class="cards-header">
<span class="text-sm font-semibold text-slate-700 dark:text-slate-200">
{{ totalCount }} ملک یافت شد (صفحه {{ page }} از {{ pagesCount || 1 }})
</span>
<span class="text-sm font-semibold text-slate-700 dark:text-slate-200"> {{ totalCount }} ملک یافت شد (صفحه {{ page }} از {{ pagesCount || 1 }}) </span>
</div>
<!-- Loading Skeleton in Split View -->
<div v-if="dataLoading" class="cards-grid">
<div
v-for="i in 6"
:key="i"
class="p-4 rounded-2xl border border-surface-200 dark:border-surface-700 bg-surface-0 dark:bg-surface-900 shadow-sm flex flex-col gap-3 animate-pulse"
>
<div v-for="i in 6" :key="i" class="p-4 rounded-2xl border border-surface-200 dark:border-surface-700 bg-surface-0 dark:bg-surface-900 shadow-sm flex flex-col gap-3 animate-pulse">
<Skeleton width="100%" height="11rem" class="rounded-xl" />
<Skeleton width="70%" height="1.25rem" class="rounded-md" />
<Skeleton width="45%" height="1rem" class="rounded-md" />
@@ -482,18 +429,11 @@ const onCardLeave = () => {
>
<!-- Card Image -->
<div class="card-img-wrap">
<img
:src="getPropertyImage(prop)"
:alt="prop.name"
class="card-img"
@error="handleImageError"
/>
<img :src="getPropertyImage(prop)" :alt="prop.name" class="card-img" @error="handleImageError" />
<span v-if="getCategoryName(prop)" class="card-badge-category">
{{ getCategoryName(prop) }}
</span>
<span v-if="prop.propertyCode" class="card-badge-code">
#{{ prop.propertyCode }}
</span>
<span v-if="prop.propertyCode" class="card-badge-code"> #{{ prop.propertyCode }} </span>
</div>
<!-- Card Body -->
@@ -519,32 +459,12 @@ const onCardLeave = () => {
<!-- Card Actions -->
<div class="card-actions" @click.stop>
<a
:href="'/property/edit-property/' + (prop._id || prop.id)"
target="_blank"
rel="noopener noreferrer"
class="card-btn card-btn-newtab"
title="باز کردن در تب جدید"
>
<a :href="'/property/edit-property/' + (prop._id || prop.id)" target="_blank" rel="noopener noreferrer" class="card-btn card-btn-newtab" title="باز کردن در تب جدید">
<i class="pi pi-external-link"></i>
<span>تب جدید</span>
</a>
<Button
icon="pi pi-pencil"
label="ویرایش"
size="small"
severity="secondary"
outlined
class="flex-1"
@click="editProperty(prop._id || prop.id)"
/>
<Button
icon="pi pi-trash"
size="small"
severity="danger"
outlined
@click="confirmDeleteProperty(prop._id || prop.id, $event)"
/>
<Button icon="pi pi-pencil" label="ویرایش" size="small" severity="secondary" outlined class="flex-1" @click="editProperty(prop._id || prop.id)" />
<Button icon="pi pi-trash" size="small" severity="danger" outlined @click="confirmDeleteProperty(prop._id || prop.id, $event)" />
</div>
</div>
</div>
@@ -555,41 +475,19 @@ const onCardLeave = () => {
<p>ملکی یافت نشد.</p>
</div>
<!-- Pagination -->
<Paginator
:rows="per_page"
:totalRecords="totalCount"
:first="(page - 1) * per_page"
:rowsPerPageOptions="[20, 40, 80]"
@page="onPageChange($event)"
class="mt-4"
/>
<Paginator :rows="per_page" :totalRecords="totalCount" :first="(page - 1) * per_page" :rowsPerPageOptions="[20, 40, 80]" @page="onPageChange($event)" class="mt-4" />
</div>
<!-- Right/Side: Sticky Interactive Map -->
<div class="properties-map-column">
<PropertyMap
ref="splitMapRef"
:properties="properties"
:selectedPropertyId="selectedPropertyId"
:hoveredPropertyId="hoveredPropertyId"
@select-property="onSelectProperty"
@edit-property="editProperty"
/>
<PropertyMap ref="splitMapRef" :properties="properties" :selectedPropertyId="selectedPropertyId" :hoveredPropertyId="hoveredPropertyId" @select-property="onSelectProperty" @edit-property="editProperty" />
</div>
</div>
<!-- ── VIEW 3: FULL MAP VIEW ── -->
<div v-else-if="viewMode === 'map'" class="full-map-container">
<PropertyMap
ref="mapRef"
:properties="properties"
:selectedPropertyId="selectedPropertyId"
:hoveredPropertyId="hoveredPropertyId"
@select-property="onSelectProperty"
@edit-property="editProperty"
/>
<PropertyMap ref="mapRef" :properties="properties" :selectedPropertyId="selectedPropertyId" :hoveredPropertyId="hoveredPropertyId" @select-property="onSelectProperty" @edit-property="editProperty" />
</div>
</div>
</div>
@@ -658,7 +556,9 @@ const onCardLeave = () => {
.property-card.card-selected {
border-color: #6366f1;
box-shadow: 0 0 0 2px #6366f1, 0 12px 28px rgba(99, 102, 241, 0.18);
box-shadow:
0 0 0 2px #6366f1,
0 12px 28px rgba(99, 102, 241, 0.18);
transform: translateY(-4px);
}
@@ -805,7 +705,9 @@ const onCardLeave = () => {
.app-dark .property-card.card-selected {
border-color: #6366f1;
box-shadow: 0 0 0 2px #6366f1, 0 12px 28px rgba(99, 102, 241, 0.35);
box-shadow:
0 0 0 2px #6366f1,
0 12px 28px rgba(99, 102, 241, 0.35);
}
.app-dark .card-title {
+263 -340
View File
@@ -1,337 +1,3 @@
<template>
<div ref="mapContainer" class="property-map-container" :class="{ 'map-fullscreen': isFullscreen }">
<!-- ── Top Action Buttons ── -->
<div class="map-overlay action-buttons">
<!-- Fullscreen toggle -->
<button class="map-btn" @click="toggleFullscreen" :title="isFullscreen ? 'خروج از تمام‌صفحه' : 'تمام‌صفحه'">
<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>
<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>
<!-- Fit all markers -->
<button class="map-btn map-btn-fit" @click="fitBounds" title="نمایش همه املاک روی نقشه" :disabled="propertiesWithCoords.length === 0">
<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">
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>
</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>
<!-- Spread / Group Mode Toggle (when co-located properties exist) -->
<button
v-if="hasCoLocatedProperties"
class="map-btn map-btn-spread"
:class="{ 'is-active': isSpreadMode }"
@click="toggleSpreadMode"
:title="isSpreadMode ? 'نمایش تجمیعی املاک هم‌مکان' : 'تفکیک و نمایش جداگانه املاک هم‌مکان'"
>
<svg v-if="isSpreadMode" 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">
<!-- Collapse / Group icon -->
<rect x="4" y="4" width="7" height="7" rx="1.5"/>
<rect x="13" y="13" width="7" height="7" rx="1.5"/>
<path d="M11 4v4h-4M13 20v-4h4"/>
</svg>
<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">
<!-- Spread / Fan-out icon -->
<circle cx="12" cy="12" r="3"/>
<circle cx="6" cy="6" r="2.5"/>
<circle cx="18" cy="6" r="2.5"/>
<circle cx="6" cy="18" r="2.5"/>
<circle cx="18" cy="18" r="2.5"/>
<path d="M8 8l2.5 2.5M16 8l-2.5 2.5M8 16l2.5-2.5M16 16l-2.5-2.5" opacity="0.5"/>
</svg>
</button>
</div>
<!-- ── Top-Left: Properties count badge ── -->
<div class="map-overlay count-badge">
<i class="pi pi-map-marker text-indigo-500"></i>
<span>{{ propertiesWithCoords.length }} ملک</span>
<span v-if="hasCoLocatedProperties && !isSpreadMode" class="badge-subcount">
({{ locationGroups.length }} موقعیت)
</span>
</div>
<!-- ── Bottom-Left: Coordinate readout ── -->
<div v-if="hoverCoords" class="map-overlay coords-readout">
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="opacity:0.7;flex-shrink:0">
<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3"/>
</svg>
<span>{{ hoverCoords.lat.toFixed(5) }}, {{ hoverCoords.lon.toFixed(5) }}</span>
</div>
<!-- ── Leaflet Map ── -->
<l-map
ref="map"
v-model:zoom="zoom"
:center="center"
:use-global-leaflet="false"
style="height: 100%; width: 100%;"
@mousemove="handleMouseMove($event)"
@mouseout="hoverCoords = null"
@ready="onMapReady"
>
<!-- Layer switcher -->
<l-control-layers position="bottomright" />
<!-- OpenStreetMap -->
<l-tile-layer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
layer-type="base"
name="نمای نقشه‌ای"
attribution="&copy; OpenStreetMap contributors"
/>
<!-- Satellite -->
<l-tile-layer
:visible="false"
url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
layer-type="base"
name="نمای ماهواره‌ای"
attribution="Tiles &copy; Esri"
/>
<!-- Scale control -->
<l-control-scale position="bottomleft" :imperial="false" :maxWidth="150" />
<!-- ── VIEW A: SPREAD MODE MARKERS (Separated individual pins) ── -->
<template v-if="isSpreadMode">
<l-marker
v-for="item in spreadMarkersList"
:key="item.prop._id || item.prop.id"
:lat-lng="item.coords"
:icon="getIndividualMarkerIcon(item.prop, item.isOffset)"
@click="onMarkerClick(item.prop)"
>
<l-popup :options="{ maxWidth: 330, minWidth: 280, className: 'airbnb-custom-popup' }">
<div class="airbnb-popup-card">
<!-- Image / Header -->
<div class="popup-image-container">
<img
:src="getPropertyImage(item.prop)"
:alt="item.prop.name"
class="popup-image"
@error="handleImageError"
/>
<span v-if="getCategoryName(item.prop)" class="popup-category-badge">
{{ getCategoryName(item.prop) }}
</span>
<span v-if="item.prop.propertyCode" class="popup-code-badge">
کد: {{ item.prop.propertyCode }}
</span>
</div>
<!-- Card Body -->
<div class="popup-content-body">
<h4 class="popup-title">{{ item.prop.name || 'ملک بدون نام' }}</h4>
<div class="popup-info-grid">
<div v-if="item.prop.nationalGeoCode" class="popup-info-item">
<span class="info-label">شناسه ملی جغرافیایی:</span>
<span class="info-value text-mono">{{ item.prop.nationalGeoCode }}</span>
</div>
<div v-if="item.prop.dong" class="popup-info-item">
<span class="info-label">سهم دانگ:</span>
<span class="info-value text-indigo-600 font-bold">{{ item.prop.dong }} دانگ</span>
</div>
<div v-if="item.prop.zoningType" class="popup-info-item">
<span class="info-label">کاربری:</span>
<span class="info-value">{{ item.prop.zoningType }}</span>
</div>
<div v-if="item.prop.address" class="popup-info-item full-width">
<span class="info-label">آدرس:</span>
<span class="info-value">{{ item.prop.address }}</span>
</div>
</div>
<!-- Action Buttons -->
<div class="popup-actions">
<a
:href="'/property/edit-property/' + (item.prop._id || item.prop.id)"
target="_blank"
rel="noopener noreferrer"
class="popup-btn popup-btn-external"
title="باز کردن در برگه جدید"
>
<i class="pi pi-external-link"></i>
<span>باز کردن در تب جدید</span>
</a>
<button
type="button"
class="popup-btn popup-btn-primary"
@click="onEditClick(item.prop)"
title="ویرایش ملک"
>
<i class="pi pi-pencil"></i>
<span>ویرایش</span>
</button>
</div>
</div>
</div>
</l-popup>
</l-marker>
</template>
<!-- ── VIEW B: GROUPED MODE MARKERS (Default Smart Grouping & Multi-Property Card) ── -->
<template v-else>
<l-marker
v-for="group in locationGroups"
:key="group.id"
:lat-lng="group.coords"
:icon="getGroupMarkerIcon(group)"
@click="onGroupMarkerClick(group)"
>
<l-popup :options="{ maxWidth: 340, minWidth: 290, className: 'airbnb-custom-popup' }">
<div class="airbnb-popup-card">
<!-- Multi-Property Group Header & Switcher (If multiple properties share this location) -->
<div v-if="group.isGroup" class="popup-group-header">
<div class="group-header-top">
<div class="group-header-title">
<span class="group-header-icon">🏢</span>
<span>موقعیت مشترک: {{ group.count }} سهم / ملک</span>
</div>
<div class="group-nav-controls">
<button
type="button"
class="group-nav-btn"
@click.stop="prevGroupProperty(group)"
title="سهم قبلی"
>
<i class="pi pi-chevron-right"></i>
</button>
<span class="group-step-indicator">
{{ getActiveGroupIndex(group) + 1 }} از {{ group.count }}
</span>
<button
type="button"
class="group-nav-btn"
@click.stop="nextGroupProperty(group)"
title="سهم بعدی"
>
<i class="pi pi-chevron-left"></i>
</button>
</div>
</div>
<!-- Property Tabs Chips -->
<div class="group-tabs-bar">
<button
v-for="(p, idx) in group.properties"
:key="p._id || p.id"
type="button"
class="group-tab-chip"
:class="{ 'is-active': getActiveGroupIndex(group) === idx }"
@click.stop="setActiveGroupIndex(group, idx)"
>
<span class="tab-chip-code">
{{ p.propertyCode ? `#${p.propertyCode}` : `سهم ${idx + 1}` }}
</span>
<span v-if="p.dong" class="tab-chip-dong">
{{ p.dong }} دانگ
</span>
</button>
</div>
</div>
<!-- Currently Active Property in this Group -->
<template v-if="getActiveProperty(group)">
<!-- Image / Header -->
<div class="popup-image-container">
<img
:src="getPropertyImage(getActiveProperty(group))"
:alt="getActiveProperty(group).name"
class="popup-image"
@error="handleImageError"
/>
<span v-if="getCategoryName(getActiveProperty(group))" class="popup-category-badge">
{{ getCategoryName(getActiveProperty(group)) }}
</span>
<span v-if="getActiveProperty(group).propertyCode" class="popup-code-badge">
کد: {{ getActiveProperty(group).propertyCode }}
</span>
<span v-if="group.isGroup && getActiveProperty(group).dong" class="popup-dong-badge">
سهم: {{ getActiveProperty(group).dong }} دانگ
</span>
</div>
<!-- Card Body -->
<div class="popup-content-body">
<h4 class="popup-title">
{{ getActiveProperty(group).name || 'ملک بدون نام' }}
</h4>
<div class="popup-info-grid">
<div v-if="getActiveProperty(group).nationalGeoCode" class="popup-info-item">
<span class="info-label">شناسه ملی جغرافیایی:</span>
<span class="info-value text-mono">{{ getActiveProperty(group).nationalGeoCode }}</span>
</div>
<div v-if="getActiveProperty(group).dong" class="popup-info-item">
<span class="info-label">میزان مالکیت:</span>
<span class="info-value text-indigo-600 font-bold">
{{ getActiveProperty(group).dong }} دانگ ({{ (parseFloat(getActiveProperty(group).dong) / 6 * 100).toFixed(0) }}٪)
</span>
</div>
<div v-if="getActiveProperty(group).zoningType" class="popup-info-item">
<span class="info-label">کاربری:</span>
<span class="info-value">{{ getActiveProperty(group).zoningType }}</span>
</div>
<div v-if="getActiveProperty(group).address" class="popup-info-item full-width">
<span class="info-label">آدرس:</span>
<span class="info-value">{{ getActiveProperty(group).address }}</span>
</div>
</div>
<!-- Action Buttons -->
<div class="popup-actions">
<a
:href="'/property/edit-property/' + (getActiveProperty(group)._id || getActiveProperty(group).id)"
target="_blank"
rel="noopener noreferrer"
class="popup-btn popup-btn-external"
title="باز کردن در برگه جدید"
>
<i class="pi pi-external-link"></i>
<span>باز کردن در تب جدید</span>
</a>
<button
type="button"
class="popup-btn popup-btn-primary"
@click="onEditClick(getActiveProperty(group))"
title="ویرایش ملک"
>
<i class="pi pi-pencil"></i>
<span>ویرایش</span>
</button>
</div>
</div>
</template>
</div>
</l-popup>
</l-marker>
</template>
</l-map>
<!-- Locating overlay -->
<div v-if="isLocating" class="locating-overlay">
<div class="locating-spinner"></div>
<span>در حال تعیین موقعیت...</span>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue';
import 'leaflet/dist/leaflet.css';
@@ -845,6 +511,250 @@ defineExpose({
});
</script>
<template>
<div ref="mapContainer" class="property-map-container" :class="{ 'map-fullscreen': isFullscreen }">
<!-- ── Top Action Buttons ── -->
<div class="map-overlay action-buttons">
<!-- Fullscreen toggle -->
<button class="map-btn" @click="toggleFullscreen" :title="isFullscreen ? 'خروج از تمام‌صفحه' : 'تمام‌صفحه'">
<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>
<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>
<!-- Fit all markers -->
<button class="map-btn map-btn-fit" @click="fitBounds" title="نمایش همه املاک روی نقشه" :disabled="propertiesWithCoords.length === 0">
<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">
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
</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>
<!-- Spread / Group Mode Toggle (when co-located properties exist) -->
<button v-if="hasCoLocatedProperties" class="map-btn map-btn-spread" :class="{ 'is-active': isSpreadMode }" @click="toggleSpreadMode" :title="isSpreadMode ? 'نمایش تجمیعی املاک هم‌مکان' : 'تفکیک و نمایش جداگانه املاک هم‌مکان'">
<svg v-if="isSpreadMode" 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">
<!-- Collapse / Group icon -->
<rect x="4" y="4" width="7" height="7" rx="1.5" />
<rect x="13" y="13" width="7" height="7" rx="1.5" />
<path d="M11 4v4h-4M13 20v-4h4" />
</svg>
<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">
<!-- Spread / Fan-out icon -->
<circle cx="12" cy="12" r="3" />
<circle cx="6" cy="6" r="2.5" />
<circle cx="18" cy="6" r="2.5" />
<circle cx="6" cy="18" r="2.5" />
<circle cx="18" cy="18" r="2.5" />
<path d="M8 8l2.5 2.5M16 8l-2.5 2.5M8 16l2.5-2.5M16 16l-2.5-2.5" opacity="0.5" />
</svg>
</button>
</div>
<!-- ── Top-Left: Properties count badge ── -->
<div class="map-overlay count-badge">
<i class="pi pi-map-marker text-indigo-500"></i>
<span>{{ propertiesWithCoords.length }} ملک</span>
<span v-if="hasCoLocatedProperties && !isSpreadMode" class="badge-subcount"> ({{ locationGroups.length }} موقعیت) </span>
</div>
<!-- ── Bottom-Left: Coordinate readout ── -->
<div v-if="hoverCoords" class="map-overlay coords-readout">
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="opacity: 0.7; flex-shrink: 0">
<circle cx="12" cy="12" r="3" />
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
</svg>
<span>{{ hoverCoords.lat.toFixed(5) }}, {{ hoverCoords.lon.toFixed(5) }}</span>
</div>
<!-- ── Leaflet Map ── -->
<l-map ref="map" v-model:zoom="zoom" :center="center" :use-global-leaflet="false" style="height: 100%; width: 100%" @mousemove="handleMouseMove($event)" @mouseout="hoverCoords = null" @ready="onMapReady">
<!-- Layer switcher -->
<l-control-layers position="bottomright" />
<!-- OpenStreetMap -->
<l-tile-layer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" layer-type="base" name="نمای نقشه‌ای" attribution="&copy; OpenStreetMap contributors" />
<!-- Satellite -->
<l-tile-layer :visible="false" url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" layer-type="base" name="نمای ماهواره‌ای" attribution="Tiles &copy; Esri" />
<!-- Scale control -->
<l-control-scale position="bottomleft" :imperial="false" :maxWidth="150" />
<!-- ── VIEW A: SPREAD MODE MARKERS (Separated individual pins) ── -->
<template v-if="isSpreadMode">
<l-marker v-for="item in spreadMarkersList" :key="item.prop._id || item.prop.id" :lat-lng="item.coords" :icon="getIndividualMarkerIcon(item.prop, item.isOffset)" @click="onMarkerClick(item.prop)">
<l-popup :options="{ maxWidth: 330, minWidth: 280, className: 'airbnb-custom-popup' }">
<div class="airbnb-popup-card">
<!-- Image / Header -->
<div class="popup-image-container">
<img :src="getPropertyImage(item.prop)" :alt="item.prop.name" class="popup-image" @error="handleImageError" />
<span v-if="getCategoryName(item.prop)" class="popup-category-badge">
{{ getCategoryName(item.prop) }}
</span>
<span v-if="item.prop.propertyCode" class="popup-code-badge"> کد: {{ item.prop.propertyCode }} </span>
</div>
<!-- Card Body -->
<div class="popup-content-body">
<h4 class="popup-title">{{ item.prop.name || 'ملک بدون نام' }}</h4>
<div class="popup-info-grid">
<div v-if="item.prop.nationalGeoCode" class="popup-info-item">
<span class="info-label">شناسه ملی جغرافیایی:</span>
<span class="info-value text-mono">{{ item.prop.nationalGeoCode }}</span>
</div>
<div v-if="item.prop.dong" class="popup-info-item">
<span class="info-label">سهم دانگ:</span>
<span class="info-value text-indigo-600 font-bold">{{ item.prop.dong }} دانگ</span>
</div>
<div v-if="item.prop.zoningType" class="popup-info-item">
<span class="info-label">کاربری:</span>
<span class="info-value">{{ item.prop.zoningType }}</span>
</div>
<div v-if="item.prop.address" class="popup-info-item full-width">
<span class="info-label">آدرس:</span>
<span class="info-value">{{ item.prop.address }}</span>
</div>
</div>
<!-- Action Buttons -->
<div class="popup-actions">
<a :href="'/property/edit-property/' + (item.prop._id || item.prop.id)" target="_blank" rel="noopener noreferrer" class="popup-btn popup-btn-external" title="باز کردن در برگه جدید">
<i class="pi pi-external-link"></i>
<span>باز کردن در تب جدید</span>
</a>
<button type="button" class="popup-btn popup-btn-primary" @click="onEditClick(item.prop)" title="ویرایش ملک">
<i class="pi pi-pencil"></i>
<span>ویرایش</span>
</button>
</div>
</div>
</div>
</l-popup>
</l-marker>
</template>
<!-- ── VIEW B: GROUPED MODE MARKERS (Default Smart Grouping & Multi-Property Card) ── -->
<template v-else>
<l-marker v-for="group in locationGroups" :key="group.id" :lat-lng="group.coords" :icon="getGroupMarkerIcon(group)" @click="onGroupMarkerClick(group)">
<l-popup :options="{ maxWidth: 340, minWidth: 290, className: 'airbnb-custom-popup' }">
<div class="airbnb-popup-card">
<!-- Multi-Property Group Header & Switcher (If multiple properties share this location) -->
<div v-if="group.isGroup" class="popup-group-header">
<div class="group-header-top">
<div class="group-header-title">
<span class="group-header-icon">🏢</span>
<span>موقعیت مشترک: {{ group.count }} سهم / ملک</span>
</div>
<div class="group-nav-controls">
<button type="button" class="group-nav-btn" @click.stop="prevGroupProperty(group)" title="سهم قبلی">
<i class="pi pi-chevron-right"></i>
</button>
<span class="group-step-indicator"> {{ getActiveGroupIndex(group) + 1 }} از {{ group.count }} </span>
<button type="button" class="group-nav-btn" @click.stop="nextGroupProperty(group)" title="سهم بعدی">
<i class="pi pi-chevron-left"></i>
</button>
</div>
</div>
<!-- Property Tabs Chips -->
<div class="group-tabs-bar">
<button v-for="(p, idx) in group.properties" :key="p._id || p.id" type="button" class="group-tab-chip" :class="{ 'is-active': getActiveGroupIndex(group) === idx }" @click.stop="setActiveGroupIndex(group, idx)">
<span class="tab-chip-code">
{{ p.propertyCode ? `#${p.propertyCode}` : `سهم ${idx + 1}` }}
</span>
<span v-if="p.dong" class="tab-chip-dong"> {{ p.dong }} دانگ </span>
</button>
</div>
</div>
<!-- Currently Active Property in this Group -->
<template v-if="getActiveProperty(group)">
<!-- Image / Header -->
<div class="popup-image-container">
<img :src="getPropertyImage(getActiveProperty(group))" :alt="getActiveProperty(group).name" class="popup-image" @error="handleImageError" />
<span v-if="getCategoryName(getActiveProperty(group))" class="popup-category-badge">
{{ getCategoryName(getActiveProperty(group)) }}
</span>
<span v-if="getActiveProperty(group).propertyCode" class="popup-code-badge"> کد: {{ getActiveProperty(group).propertyCode }} </span>
<span v-if="group.isGroup && getActiveProperty(group).dong" class="popup-dong-badge"> سهم: {{ getActiveProperty(group).dong }} دانگ </span>
</div>
<!-- Card Body -->
<div class="popup-content-body">
<h4 class="popup-title">
{{ getActiveProperty(group).name || 'ملک بدون نام' }}
</h4>
<div class="popup-info-grid">
<div v-if="getActiveProperty(group).nationalGeoCode" class="popup-info-item">
<span class="info-label">شناسه ملی جغرافیایی:</span>
<span class="info-value text-mono">{{ getActiveProperty(group).nationalGeoCode }}</span>
</div>
<div v-if="getActiveProperty(group).dong" class="popup-info-item">
<span class="info-label">میزان مالکیت:</span>
<span class="info-value text-indigo-600 font-bold"> {{ getActiveProperty(group).dong }} دانگ ({{ ((parseFloat(getActiveProperty(group).dong) / 6) * 100).toFixed(0) }}٪) </span>
</div>
<div v-if="getActiveProperty(group).zoningType" class="popup-info-item">
<span class="info-label">کاربری:</span>
<span class="info-value">{{ getActiveProperty(group).zoningType }}</span>
</div>
<div v-if="getActiveProperty(group).address" class="popup-info-item full-width">
<span class="info-label">آدرس:</span>
<span class="info-value">{{ getActiveProperty(group).address }}</span>
</div>
</div>
<!-- Action Buttons -->
<div class="popup-actions">
<a
:href="'/property/edit-property/' + (getActiveProperty(group)._id || getActiveProperty(group).id)"
target="_blank"
rel="noopener noreferrer"
class="popup-btn popup-btn-external"
title="باز کردن در برگه جدید"
>
<i class="pi pi-external-link"></i>
<span>باز کردن در تب جدید</span>
</a>
<button type="button" class="popup-btn popup-btn-primary" @click="onEditClick(getActiveProperty(group))" title="ویرایش ملک">
<i class="pi pi-pencil"></i>
<span>ویرایش</span>
</button>
</div>
</div>
</template>
</div>
</l-popup>
</l-marker>
</template>
</l-map>
<!-- Locating overlay -->
<div v-if="isLocating" class="locating-overlay">
<div class="locating-spinner"></div>
<span>در حال تعیین موقعیت...</span>
</div>
</div>
</template>
<style scoped>
.property-map-container {
position: relative;
@@ -972,8 +882,13 @@ defineExpose({
}
@keyframes pulse-btn {
0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.5); }
50% { box-shadow: 0 0 0 10px rgba(16, 185, 129, 0); }
0%,
100% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.5);
}
50% {
box-shadow: 0 0 0 10px rgba(16, 185, 129, 0);
}
}
.coords-readout {
@@ -1020,7 +935,9 @@ defineExpose({
}
@keyframes spin {
to { transform: rotate(360deg); }
to {
transform: rotate(360deg);
}
}
</style>
@@ -1102,8 +1019,12 @@ defineExpose({
}
@keyframes badge-pop {
0% { transform: scale(0); }
100% { transform: scale(1); }
0% {
transform: scale(0);
}
100% {
transform: scale(1);
}
}
.airbnb-pin.is-multi-pin:hover .pin-content,
@@ -1138,7 +1059,9 @@ defineExpose({
background: #4f46e5;
color: #ffffff;
border-color: #ffffff;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.35), 0 8px 24px rgba(79, 70, 229, 0.5);
box-shadow:
0 0 0 3px rgba(79, 70, 229, 0.35),
0 8px 24px rgba(79, 70, 229, 0.5);
}
.pin-icon {
+9 -51
View File
@@ -237,13 +237,7 @@ const confirmDelete = (r, event) => {
<!-- Table Card -->
<div class="card main-card">
<DataTable
:value="filteredRoles"
:loading="loading"
dataKey="_id"
stripedRows
responsiveLayout="scroll"
>
<DataTable :value="filteredRoles" :loading="loading" dataKey="_id" stripedRows responsiveLayout="scroll">
<template #header>
<div class="table-header">
<span class="table-title">لیست نقش‌های تعریف شده ({{ filteredRoles.length }})</span>
@@ -269,15 +263,12 @@ const confirmDelete = (r, event) => {
</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>
<span v-if="data.englishName === 'SuperAdmin'" class="super-badge"> <i class="pi pi-bolt"></i> مدیر کل سیستم </span>
</div>
</template>
</Column>
@@ -292,22 +283,15 @@ const confirmDelete = (r, event) => {
<!-- 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>
<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 ? 'فعال' : 'غیرفعال'"
/>
<Tag :severity="data.isActive !== false ? 'success' : 'danger'" :value="data.isActive !== false ? 'فعال' : 'غیرفعال'" />
</template>
</Column>
@@ -316,16 +300,7 @@ const confirmDelete = (r, event) => {
<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)"
/>
<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>
@@ -333,13 +308,7 @@ const confirmDelete = (r, event) => {
</div>
<!-- Create / Edit Dialog -->
<Dialog
v-model:visible="showDialog"
:header="editMode ? 'ویرایش نقش و دسترسی‌ها' : 'افزودن نقش جدید'"
:style="{ width: '820px' }"
modal
dir="rtl"
>
<Dialog v-model:visible="showDialog" :header="editMode ? 'ویرایش نقش و دسترسی‌ها' : 'افزودن نقش جدید'" :style="{ width: '820px' }" modal dir="rtl">
<div class="dialog-content">
<!-- General Info -->
<div class="form-row">
@@ -393,13 +362,7 @@ const confirmDelete = (r, event) => {
</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 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>
@@ -416,12 +379,7 @@ const confirmDelete = (r, event) => {
<template #footer>
<Button label="انصراف" icon="pi pi-times" text @click="showDialog = false" />
<Button
:label="editMode ? 'ذخیره تغییرات' : 'ایجاد نقش'"
icon="pi pi-check"
:loading="submitting"
@click="submitForm"
/>
<Button :label="editMode ? 'ذخیره تغییرات' : 'ایجاد نقش'" icon="pi pi-check" :loading="submitting" @click="submitForm" />
</template>
</Dialog>
</div>
+23 -139
View File
@@ -96,7 +96,8 @@ const handleSeedDatabase = () => {
if (seedStatus.value.isSeeded) return;
confirm.require({
message: 'توجه: این عملیات داده‌های اولیه سیستم (کاربران پیش‌فرض، نقش‌ها، دسته‌بندی‌ها، ویژگی‌های تکمیلی و وضعیت‌های املاک) را در پایگاه داده ایجاد می‌کند. این عملیات فقط یک‌بار قابل اجراست و پس از انجام دکمه برای همیشه قفل خواهد شد. آیا ادامه می‌دهید؟',
message:
'توجه: این عملیات داده‌های اولیه سیستم (کاربران پیش‌فرض، نقش‌ها، دسته‌بندی‌ها، ویژگی‌های تکمیلی و وضعیت‌های املاک) را در پایگاه داده ایجاد می‌کند. این عملیات فقط یک‌بار قابل اجراست و پس از انجام دکمه برای همیشه قفل خواهد شد. آیا ادامه می‌دهید؟',
header: 'تایید مقداردهی اولیه پایگاه داده (Seed Database)',
icon: 'pi pi-exclamation-triangle',
rejectProps: { label: 'انصراف', severity: 'secondary', outlined: true },
@@ -237,9 +238,7 @@ const navigateToModule = (routePath) => {
</div>
<div>
<h2 class="text-2xl font-bold m-0 text-surface-900 dark:text-surface-0">تنظیمات موتور جستجو</h2>
<p class="text-surface-500 dark:text-surface-400 m-0 mt-1 text-sm">
انتخاب روش جستجو (MongoDB یا Typesense) برای هر بخش از سیستم به صورت مجزا
</p>
<p class="text-surface-500 dark:text-surface-400 m-0 mt-1 text-sm">انتخاب روش جستجو (MongoDB یا Typesense) برای هر بخش از سیستم به صورت مجزا</p>
</div>
</div>
@@ -267,33 +266,15 @@ const navigateToModule = (routePath) => {
<div class="flex items-start gap-4">
<div
class="w-14 h-14 rounded-2xl flex items-center justify-center text-2xl flex-shrink-0 transition-colors"
:class="seedStatus.isSeeded
? 'bg-surface-100 text-surface-500 dark:bg-surface-800 dark:text-surface-400'
: 'bg-indigo-100 text-indigo-600 dark:bg-indigo-950 dark:text-indigo-400 shadow-sm'"
:class="seedStatus.isSeeded ? 'bg-surface-100 text-surface-500 dark:bg-surface-800 dark:text-surface-400' : 'bg-indigo-100 text-indigo-600 dark:bg-indigo-950 dark:text-indigo-400 shadow-sm'"
>
<i :class="seedStatus.isSeeded ? 'pi pi-lock' : 'pi pi-database'"></i>
</div>
<div>
<div class="flex items-center gap-2.5 flex-wrap">
<h3 class="text-xl font-bold m-0 text-surface-900 dark:text-surface-0">
مقداردهی اولیه پایگاه داده (Seed Database)
</h3>
<Tag
v-if="seedStatus.isSeeded"
value="مقداردهی شده و قفل است"
severity="success"
icon="pi pi-lock"
rounded
class="px-2.5 text-xs font-bold"
/>
<Tag
v-else
value="آماده مقداردهی (یک‌بار مصرف)"
severity="warn"
icon="pi pi-exclamation-circle"
rounded
class="px-2.5 text-xs font-bold"
/>
<h3 class="text-xl font-bold m-0 text-surface-900 dark:text-surface-0">مقداردهی اولیه پایگاه داده (Seed Database)</h3>
<Tag v-if="seedStatus.isSeeded" value="مقداردهی شده و قفل است" severity="success" icon="pi pi-lock" rounded class="px-2.5 text-xs font-bold" />
<Tag v-else value="آماده مقداردهی (یک‌بار مصرف)" severity="warn" icon="pi pi-exclamation-circle" rounded class="px-2.5 text-xs font-bold" />
</div>
<p class="text-surface-500 dark:text-surface-400 m-0 mt-1.5 text-sm leading-relaxed max-w-2xl">
ایجاد خودکار کاربران اولیه، نقش‌ها و سطوح دسترسی، دسته‌بندی‌های املاک، ویژگی‌های تکمیلی و وضعیت‌های املاک در دیتابیس. این عملیات صرفاً یک‌بار قابل اجراست و پس از انجام دکمه برای همیشه قفل می‌شود.
@@ -306,25 +287,8 @@ const navigateToModule = (routePath) => {
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<Button
v-if="!seedStatus.isSeeded"
label="اجرای مقداردهی اولیه داده‌ها"
icon="pi pi-database"
severity="primary"
rounded
:loading="isSeeding"
@click="handleSeedDatabase"
class="px-5 font-semibold"
/>
<Button
v-else
label="پایگاه داده مقداردهی شده است"
icon="pi pi-lock"
severity="secondary"
rounded
disabled
class="px-5 font-semibold opacity-70 cursor-not-allowed"
/>
<Button v-if="!seedStatus.isSeeded" label="اجرای مقداردهی اولیه داده‌ها" icon="pi pi-database" severity="primary" rounded :loading="isSeeding" @click="handleSeedDatabase" class="px-5 font-semibold" />
<Button v-else label="پایگاه داده مقداردهی شده است" icon="pi pi-lock" severity="secondary" rounded disabled class="px-5 font-semibold opacity-70 cursor-not-allowed" />
</div>
</div>
</div>
@@ -338,15 +302,8 @@ const navigateToModule = (routePath) => {
</div>
<div>
<div class="flex items-center gap-2.5 flex-wrap">
<h3 class="text-xl font-bold m-0 text-surface-900 dark:text-surface-0">
تنظیمات ذخیره خودکار (Auto-Save)
</h3>
<Tag
:value="autoSaveSettings.enabled ? 'فعال' : 'غیرفعال'"
:severity="autoSaveSettings.enabled ? 'success' : 'secondary'"
rounded
class="px-2.5 text-xs font-bold"
/>
<h3 class="text-xl font-bold m-0 text-surface-900 dark:text-surface-0">تنظیمات ذخیره خودکار (Auto-Save)</h3>
<Tag :value="autoSaveSettings.enabled ? 'فعال' : 'غیرفعال'" :severity="autoSaveSettings.enabled ? 'success' : 'secondary'" rounded class="px-2.5 text-xs font-bold" />
</div>
<p class="text-surface-500 dark:text-surface-400 m-0 mt-1.5 text-sm leading-relaxed max-w-2xl">
پیکربندی ذخیره پس‌زمینه خودکار فرم‌ها و املاک. در صورت فعال بودن، تغییرات شما بدون نیاز به فشردن دستی دکمه ذخیره، در بازه‌های مشخص ذخیره شده و اعلان آن در گوشه صفحه نمایش داده می‌شود.
@@ -362,26 +319,10 @@ const navigateToModule = (routePath) => {
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-surface-600 dark:text-surface-300">بازه زمانی:</span>
<Dropdown
v-model="autoSaveSettings.intervalSeconds"
:options="autoSaveIntervalOptions"
optionLabel="label"
optionValue="value"
:disabled="!autoSaveSettings.enabled"
class="!rounded-xl text-xs w-44"
/>
<Dropdown v-model="autoSaveSettings.intervalSeconds" :options="autoSaveIntervalOptions" optionLabel="label" optionValue="value" :disabled="!autoSaveSettings.enabled" class="!rounded-xl text-xs w-44" />
</div>
<Button
label="ذخیره تنظیمات"
icon="pi pi-check"
severity="primary"
rounded
size="small"
:loading="autoSaveSaving"
@click="saveAutoSaveSettings"
class="px-4 font-semibold"
/>
<Button label="ذخیره تنظیمات" icon="pi pi-check" severity="primary" rounded size="small" :loading="autoSaveSaving" @click="saveAutoSaveSettings" class="px-4 font-semibold" />
</div>
</div>
</div>
@@ -389,35 +330,11 @@ const navigateToModule = (routePath) => {
<!-- ── Quick Actions Toolbar ── -->
<div class="card p-4 mb-6 flex flex-wrap items-center justify-between gap-4">
<div class="flex flex-wrap items-center gap-3">
<Button
label="تنظیم همه روی MongoDB"
icon="pi pi-database"
severity="success"
outlined
size="small"
:disabled="loading || saving"
@click="setAllToMethod('mongo')"
/>
<Button
label="تنظیم همه روی Typesense"
icon="pi pi-bolt"
severity="warn"
outlined
size="small"
:disabled="loading || saving"
@click="setAllToMethod('typesense')"
/>
<Button label="تنظیم همه روی MongoDB" icon="pi pi-database" severity="success" outlined size="small" :disabled="loading || saving" @click="setAllToMethod('mongo')" />
<Button label="تنظیم همه روی Typesense" icon="pi pi-bolt" severity="warn" outlined size="small" :disabled="loading || saving" @click="setAllToMethod('typesense')" />
</div>
<div class="flex items-center gap-2">
<Button
icon="pi pi-refresh"
severity="secondary"
text
rounded
:loading="loading"
v-tooltip.top="'بارگذاری مجدد'"
@click="loadSettings"
/>
<Button icon="pi pi-refresh" severity="secondary" text rounded :loading="loading" v-tooltip.top="'بارگذاری مجدد'" @click="loadSettings" />
</div>
</div>
@@ -435,11 +352,7 @@ const navigateToModule = (routePath) => {
v-for="item in settings"
:key="item.pageKey"
class="card p-6 rounded-2xl border transition-all duration-200 hover:shadow-md flex flex-col justify-between"
:class="[
item.searchMethod === 'mongo'
? 'border-emerald-200 dark:border-emerald-900 bg-emerald-50/10'
: 'border-amber-200 dark:border-amber-900 bg-amber-50/10'
]"
:class="[item.searchMethod === 'mongo' ? 'border-emerald-200 dark:border-emerald-900 bg-emerald-50/10' : 'border-amber-200 dark:border-amber-900 bg-amber-50/10']"
>
<div>
<!-- Card Top Header -->
@@ -447,11 +360,7 @@ const navigateToModule = (routePath) => {
<div class="flex items-center gap-3">
<div
class="w-10 h-10 rounded-xl flex items-center justify-center text-lg"
:class="[
item.searchMethod === 'mongo'
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300'
: 'bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-300'
]"
:class="[item.searchMethod === 'mongo' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300' : 'bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-300']"
>
<i :class="item.icon || 'pi pi-file'"></i>
</div>
@@ -462,11 +371,7 @@ const navigateToModule = (routePath) => {
</div>
<!-- Persistence Badge -->
<Tag
:value="item.isPersisted ? 'ذخیره در دیتابیس' : 'پیش‌فرض'"
:severity="item.isPersisted ? 'success' : 'secondary'"
class="text-xs"
/>
<Tag :value="item.isPersisted ? 'ذخیره در دیتابیس' : 'پیش‌فرض'" :severity="item.isPersisted ? 'success' : 'secondary'" class="text-xs" />
</div>
<!-- Description -->
@@ -489,15 +394,7 @@ const navigateToModule = (routePath) => {
<div class="pt-4 border-t border-surface-200 dark:border-surface-700 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2">
<span class="text-xs font-semibold text-surface-600 dark:text-surface-400">موتور جستجو:</span>
<SelectButton
:modelValue="item.searchMethod"
:options="methodOptions"
optionLabel="label"
optionValue="value"
:allowEmpty="false"
size="small"
@update:modelValue="(val) => updateMethod(item.pageKey, val)"
>
<SelectButton :modelValue="item.searchMethod" :options="methodOptions" optionLabel="label" optionValue="value" :allowEmpty="false" size="small" @update:modelValue="(val) => updateMethod(item.pageKey, val)">
<template #option="slotProps">
<div class="flex items-center gap-1.5 px-1 py-0.5 text-xs font-bold">
<i :class="slotProps.option.icon"></i>
@@ -507,16 +404,7 @@ const navigateToModule = (routePath) => {
</SelectButton>
</div>
<Button
v-if="item.routePath"
label="مشاهده صفحه"
icon="pi pi-arrow-left"
iconPos="right"
size="small"
severity="secondary"
text
@click="navigateToModule(item.routePath)"
/>
<Button v-if="item.routePath" label="مشاهده صفحه" icon="pi pi-arrow-left" iconPos="right" size="small" severity="secondary" text @click="navigateToModule(item.routePath)" />
</div>
</div>
</div>
@@ -533,18 +421,14 @@ const navigateToModule = (routePath) => {
<i class="pi pi-bolt"></i>
<span>موتور Typesense</span>
</div>
<p class="m-0">
موتور جستجوی ایندکس‌شده با سرعت فوق‌العاده بالا، تحمل خطای تایپی (Typo Tolerance) و سیستم Faceting پیشرفته. نیازمند فرآیند ایندکس‌سازی داده‌ها است.
</p>
<p class="m-0">موتور جستجوی ایندکس‌شده با سرعت فوق‌العاده بالا، تحمل خطای تایپی (Typo Tolerance) و سیستم Faceting پیشرفته. نیازمند فرآیند ایندکس‌سازی داده‌ها است.</p>
</div>
<div class="p-4 rounded-xl bg-surface-0 dark:bg-surface-900 border border-emerald-200 dark:border-emerald-900/50">
<div class="font-bold text-emerald-700 dark:text-emerald-400 flex items-center gap-2 mb-1">
<i class="pi pi-database"></i>
<span>موتور MongoDB</span>
</div>
<p class="m-0">
جستجوی زنده مستقیم روی پایگاه داده MongoDB با الگوهای عبارت منظم (Regex) و فیلترهای دقیق. بدون نیاز به ایندکس جداگانه و همیشه هماهنگ با آخرین تغییرات پایگاه داده.
</p>
<p class="m-0">جستجوی زنده مستقیم روی پایگاه داده MongoDB با الگوهای عبارت منظم (Regex) و فیلترهای دقیق. بدون نیاز به ایندکس جداگانه و همیشه هماهنگ با آخرین تغییرات پایگاه داده.</p>
</div>
</div>
</div>
@@ -146,8 +146,6 @@ const loadData = async () => {
}
};
const loading = ref(false);
const indexingFinished = ref(false);
const indexingMessage = ref('در حال ایندکس...');
@@ -333,7 +331,6 @@ const toggle = (event, data) => {
<Chip v-if="searchPhrase" class="mx-2 mt-2" :label="searchPhrase" removable @remove="handleRemoveSearch" />
</template>
<Column field="_id" header="شناسه" sortable style="width: 10%"></Column>
<Column field="belongingName" header="ملک" sortable style="width: 15%"></Column>
+8 -60
View File
@@ -247,16 +247,7 @@ const getInitials = (name, email) => {
<!-- Table Card -->
<div class="card main-card">
<DataTable
:value="filteredUsers"
:loading="loading"
dataKey="_id"
stripedRows
responsiveLayout="scroll"
paginator
:rows="10"
:rowsPerPageOptions="[10, 20, 50]"
>
<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>
@@ -282,7 +273,6 @@ const getInitials = (name, email) => {
</div>
</template>
<!-- User Column -->
<Column header="کاربر" style="min-width: 200px">
<template #body="{ data }">
@@ -312,12 +302,7 @@ const getInitials = (name, email) => {
<!-- 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"
/>
<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>
@@ -325,10 +310,7 @@ const getInitials = (name, email) => {
<!-- Status Column -->
<Column header="وضعیت" style="width: 100px">
<template #body="{ data }">
<Tag
:severity="data.isActive !== false ? 'success' : 'danger'"
:value="data.isActive !== false ? 'فعال' : 'غیرفعال'"
/>
<Tag :severity="data.isActive !== false ? 'success' : 'danger'" :value="data.isActive !== false ? 'فعال' : 'غیرفعال'" />
</template>
</Column>
@@ -344,15 +326,7 @@ const getInitials = (name, email) => {
<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)"
/>
<Button icon="pi pi-ban" outlined rounded severity="danger" size="small" v-tooltip.top="'غیرفعال‌سازی'" @click="confirmDelete(data, $event)" />
</div>
</template>
</Column>
@@ -360,13 +334,7 @@ const getInitials = (name, email) => {
</div>
<!-- Create / Edit Dialog -->
<Dialog
v-model:visible="showDialog"
:header="editMode ? 'ویرایش کاربر' : 'افزودن کاربر جدید'"
:style="{ width: '520px' }"
modal
dir="rtl"
>
<Dialog v-model:visible="showDialog" :header="editMode ? 'ویرایش کاربر' : 'افزودن کاربر جدید'" :style="{ width: '520px' }" modal dir="rtl">
<div class="form-grid">
<div class="form-field">
<label>نام و نام خانوادگی</label>
@@ -384,27 +352,12 @@ const getInitials = (name, email) => {
<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"
/>
<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"
/>
<Dropdown v-model="form.role" :options="roleOptions" optionLabel="label" optionValue="value" placeholder="انتخاب نقش" class="w-full" />
</div>
<div class="checkbox-row">
@@ -422,12 +375,7 @@ const getInitials = (name, email) => {
<template #footer>
<Button label="انصراف" icon="pi pi-times" text @click="showDialog = false" />
<Button
:label="editMode ? 'ذخیره تغییرات' : 'ایجاد کاربر'"
icon="pi pi-check"
:loading="submitting"
@click="submitForm"
/>
<Button :label="editMode ? 'ذخیره تغییرات' : 'ایجاد کاربر'" icon="pi pi-check" :loading="submitting" @click="submitForm" />
</template>
</Dialog>
</div>
@@ -20,34 +20,20 @@ const form = ref({
quantity: null,
reason: '',
note: '',
date: '',
date: ''
});
// Stock preview for the selected item+warehouse combo
const stockPreview = ref(null);
const stockLoading = ref(false);
const IN_REASONS = [
'خرید از تامین‌کننده',
'برگشت از کارگاه',
'انتقال از انبار دیگر',
'اهدا / دریافت رایگان',
'موجودی اولیه',
'سایر',
];
const IN_REASONS = ['خرید از تامین‌کننده', 'برگشت از کارگاه', 'انتقال از انبار دیگر', 'اهدا / دریافت رایگان', 'موجودی اولیه', 'سایر'];
const OUT_REASONS = [
'مصرف در پروژه',
'انتقال به انبار دیگر',
'فروش / واگذاری',
'ضایعات',
'مفقودی',
'سایر',
];
const OUT_REASONS = ['مصرف در پروژه', 'انتقال به انبار دیگر', 'فروش / واگذاری', 'ضایعات', 'مفقودی', 'سایر'];
const reasons = computed(() => form.value.type === 'IN' ? IN_REASONS : 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));
const selectedItem = computed(() => items.value.find((i) => i._id === form.value.itemId));
onMounted(async () => {
await Promise.all([loadItems(), loadWarehouses()]);
@@ -79,11 +65,11 @@ const onItemOrWarehouseChange = async () => {
stockLoading.value = true;
try {
const data = await WarehouseItemService.getStockByItem(form.value.itemId);
const warehouseStock = data.stocks.find(s => s.warehouse?._id === form.value.warehouseId);
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,
unitEn: data.unitEn
};
} catch (e) {
stockPreview.value = null;
@@ -116,14 +102,14 @@ const submitTransaction = async () => {
quantity: form.value.quantity,
reason: form.value.reason,
note: form.value.note,
date: form.value.date || undefined,
date: form.value.date || undefined
});
toast.add({
severity: 'success',
summary: 'موفق',
detail: form.value.type === 'IN' ? 'موجودی با موفقیت به انبار افزوده شد' : 'موجودی با موفقیت از انبار کسر شد',
life: 4000,
life: 4000
});
// Reset form
@@ -135,7 +121,7 @@ const submitTransaction = async () => {
severity: 'error',
summary: 'خطا',
detail: e.response?.data?.message || 'عملیات ناموفق بود',
life: 5000,
life: 5000
});
} finally {
submitting.value = false;
@@ -151,7 +137,6 @@ const goToStockView = () => {
<template>
<div class="create-transaction-page">
<!-- Header -->
<div class="page-header">
<div class="header-left">
@@ -164,29 +149,14 @@ const goToStockView = () => {
</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"
/>
<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
@@ -194,7 +164,10 @@ const goToStockView = () => {
class="type-card"
:class="{ active: form.type === 'IN' }"
type="button"
@click="form.type = 'IN'; onTypeChange()"
@click="
form.type = 'IN';
onTypeChange();
"
>
<div class="type-icon in-icon"><i class="pi pi-arrow-down"></i></div>
<div class="type-text">
@@ -207,7 +180,10 @@ const goToStockView = () => {
class="type-card"
:class="{ active: form.type === 'OUT' }"
type="button"
@click="form.type = 'OUT'; onTypeChange()"
@click="
form.type = 'OUT';
onTypeChange();
"
>
<div class="type-icon out-icon"><i class="pi pi-arrow-up"></i></div>
<div class="type-text">
@@ -223,18 +199,7 @@ const goToStockView = () => {
<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"
>
<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>
@@ -248,16 +213,7 @@ const goToStockView = () => {
<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"
>
<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>
@@ -278,10 +234,8 @@ const goToStockView = () => {
<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" />
<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>
@@ -316,21 +270,9 @@ const goToStockView = () => {
<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>
<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 }"
/>
<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>
@@ -347,7 +289,13 @@ const goToStockView = () => {
icon="pi pi-times"
severity="secondary"
outlined
@click="() => { form = { itemId: '', warehouseId: '', type: 'IN', quantity: null, reason: '', note: '', date: '' }; submitted = false; stockPreview = null; }"
@click="
() => {
form = { itemId: '', warehouseId: '', type: 'IN', quantity: null, reason: '', note: '', date: '' };
submitted = false;
stockPreview = null;
}
"
/>
<Button
:label="form.type === 'IN' ? 'ثبت ورود به انبار' : 'ثبت خروج از انبار'"
@@ -387,22 +335,8 @@ const goToStockView = () => {
<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')"
/>
<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>
@@ -410,31 +344,59 @@ const goToStockView = () => {
</template>
<style scoped>
.create-transaction-page { padding: 1.5rem; direction: rtl; }
.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;
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-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;
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);
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);
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;
}
.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 {
@@ -444,14 +406,25 @@ const goToStockView = () => {
align-items: start;
}
@media (max-width: 900px) {
.form-layout { grid-template-columns: 1fr; }
.form-layout {
grid-template-columns: 1fr;
}
}
/* Form card */
.form-card { border-radius: 16px; display: flex; flex-direction: column; gap: 1.25rem; }
.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-selector {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.type-card {
border: 2px solid var(--surface-border);
background: var(--surface-50);
@@ -465,52 +438,132 @@ const goToStockView = () => {
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-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;
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);
}
.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; }
.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);
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);
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; }
.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-chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.reason-chip {
border: 1.5px solid var(--surface-border);
background: var(--surface-50);
@@ -521,9 +574,12 @@ const goToStockView = () => {
transition: all 0.2s;
color: var(--text-color);
}
.reason-chip:hover { border-color: var(--primary-color); color: var(--primary-color); }
.reason-chip:hover {
border-color: var(--primary-color);
color: var(--primary-color);
}
.reason-chip.active {
background: rgba(99,102,241,0.1);
background: rgba(99, 102, 241, 0.1);
border-color: #6366f1;
color: #6366f1;
font-weight: 600;
@@ -531,42 +587,100 @@ const goToStockView = () => {
/* Submit row */
.submit-row {
display: flex; justify-content: space-between; align-items: center;
padding-top: 0.5rem; gap: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 0.5rem;
gap: 1rem;
}
.submit-btn {
flex: 0 0 auto;
}
.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; }
.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);
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); }
.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; }
.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;
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;
}
.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); }
.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>
@@ -22,7 +22,7 @@ const filters = ref({
warehouseId: '',
type: '',
dateFrom: '',
dateTo: '',
dateTo: ''
});
const items = ref([]);
@@ -30,7 +30,7 @@ const warehouses = ref([]);
const typeOptions = [
{ label: 'ورود', value: 'IN' },
{ label: 'خروج', value: 'OUT' },
{ label: 'خروج', value: 'OUT' }
];
onMounted(async () => {
@@ -39,10 +39,7 @@ onMounted(async () => {
const loadLookups = async () => {
try {
const [wi, wh] = await Promise.all([
WarehouseItemService.listItems({ limit: 200 }),
WarehouseService.listWarehouses(),
]);
const [wi, wh] = await Promise.all([WarehouseItemService.listItems({ limit: 200 }), WarehouseService.listWarehouses()]);
items.value = wi.items || [];
warehouses.value = wh;
} catch (e) {
@@ -55,7 +52,7 @@ const loadData = async () => {
try {
const params = {
page: page.value,
limit: perPage.value,
limit: perPage.value
};
if (filters.value.itemId) params.itemId = filters.value.itemId;
if (filters.value.warehouseId) params.warehouseId = filters.value.warehouseId;
@@ -93,14 +90,17 @@ const onPageChange = (event) => {
const formatDate = (d) => {
if (!d) return '—';
return new Intl.DateTimeFormat('fa-IR', {
year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit'
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">
@@ -123,39 +123,15 @@ const formatDate = (d) => {
<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"
/>
<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"
/>
<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"
/>
<Select v-model="filters.type" :options="typeOptions" optionLabel="label" optionValue="value" placeholder="همه" showClear class="w-full" />
</div>
<div class="filter-field">
<label>از تاریخ</label>
@@ -211,18 +187,13 @@ const formatDate = (d) => {
</div>
</template>
<Column header="نوع" style="width:7%">
<Column header="نوع" style="width: 7%">
<template #body="{ data }">
<Tag
:severity="data.type === 'IN' ? 'success' : 'danger'"
:value="data.type === 'IN' ? 'ورود' : 'خروج'"
class="type-tag"
/>
<Tag :severity="data.type === 'IN' ? 'success' : 'danger'" :value="data.type === 'IN' ? 'ورود' : 'خروج'" class="type-tag" />
</template>
</Column>
<Column header="قلم انبار" style="width:16%">
<Column header="قلم انبار" style="width: 16%">
<template #body="{ data }">
<div class="item-cell">
<span class="item-name-fa">{{ data.item?.nameFa || '—' }}</span>
@@ -231,7 +202,7 @@ const formatDate = (d) => {
</template>
</Column>
<Column header="مقدار" style="width:12%">
<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') }}
@@ -240,16 +211,16 @@ const formatDate = (d) => {
</template>
</Column>
<Column header="انبار" style="width:13%">
<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>
<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%">
<Column header="دلیل" style="width: 18%">
<template #body="{ data }">
<div>
<span class="reason-text">{{ data.reason }}</span>
@@ -258,32 +229,24 @@ const formatDate = (d) => {
</template>
</Column>
<Column header="کاربر" style="width:12%">
<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>
<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%">
<Column header="تاریخ" style="width: 14%">
<template #body="{ data }">
<span class="date-text">{{ formatDate(data.date || data.createdAt) }}</span>
</template>
</Column>
<Column style="width:8%">
<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}`)"
/>
<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>
@@ -292,38 +255,83 @@ const formatDate = (d) => {
</template>
<style scoped>
.transactions-page { padding: 1.5rem; direction: rtl; }
.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; }
.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;
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);
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;
}
.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-card {
border-radius: 16px;
margin-bottom: 1.25rem;
}
.filters-header {
display: flex; justify-content: space-between; align-items: center;
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-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; }
.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;
@@ -331,32 +339,89 @@ const formatDate = (d) => {
}
/* Table */
.main-card { border-radius: 16px; }
.table-title { display: flex; align-items: center; gap: 0.75rem; font-weight: 600; }
.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;
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; }
.empty-state i {
font-size: 2.5rem;
display: block;
margin-bottom: 0.75rem;
opacity: 0.3;
}
/* Cells */
.type-tag { font-weight: 700; }
.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; }
.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); }
.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; }
.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; }
.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); }
.date-text {
font-size: 0.8rem;
color: var(--text-color-secondary);
}
</style>
@@ -18,7 +18,6 @@ const perPage = ref(Number(route.query.per_page) || 20);
const searchPhrase = ref(route.query.search || '');
const loading = ref(true);
// Create/Edit dialog
const showDialog = ref(false);
const editMode = ref(false);
@@ -48,7 +47,7 @@ const UNITS = [
{ fa: 'رول', en: 'Roll' },
{ fa: 'جعبه', en: 'Box' },
{ fa: 'کیسه', en: 'Bag' },
{ fa: 'پالت', en: 'Pallet' },
{ fa: 'پالت', en: 'Pallet' }
];
let searchTimeout = null;
@@ -64,12 +63,16 @@ watch(searchPhrase, (val) => {
}, 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 });
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;
@@ -169,7 +172,6 @@ const viewStock = (itemId) => {
<template>
<ConfirmPopup />
<div class="warehouse-items-page">
<!-- ─── Header ─────────────────────────────────────────────────── -->
<div class="page-header">
<div class="header-content">
@@ -182,12 +184,7 @@ const viewStock = (itemId) => {
</div>
</div>
<div class="header-actions">
<Button
label="افزودن قلم جدید"
icon="pi pi-plus"
class="create-btn"
@click="openCreateDialog"
/>
<Button label="افزودن قلم جدید" icon="pi pi-plus" class="create-btn" @click="openCreateDialog" />
</div>
</div>
@@ -233,15 +230,14 @@ const viewStock = (itemId) => {
</div>
</template>
<Column field="code" header="کد" style="width:8%">
<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%">
<Column field="nameFa" header="نام فارسی" sortable style="width: 18%">
<template #body="{ data }">
<div class="item-name">
<span class="name-fa">{{ data.nameFa }}</span>
@@ -249,58 +245,37 @@ const viewStock = (itemId) => {
</template>
</Column>
<Column field="nameEn" header="نام انگلیسی" sortable style="width:18%">
<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%">
<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%">
<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%">
<Column field="isActive" header="وضعیت" style="width: 9%">
<template #body="{ data }">
<Tag :severity="data.isActive ? 'success' : 'danger'"
:value="data.isActive ? 'فعال' : 'غیرفعال'" />
<Tag :severity="data.isActive ? 'success' : 'danger'" :value="data.isActive ? 'فعال' : 'غیرفعال'" />
</template>
</Column>
<Column header="عملیات" style="width:21%">
<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)"
/>
<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>
@@ -308,13 +283,7 @@ const viewStock = (itemId) => {
</div>
<!-- ─── Create/Edit Dialog ──────────────────────────────────────── -->
<Dialog
v-model:visible="showDialog"
:header="editMode ? 'ویرایش قلم انبار' : 'افزودن قلم جدید'"
:style="{ width: '580px' }"
modal
:dir="'rtl'"
>
<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">
@@ -342,14 +311,7 @@ const viewStock = (itemId) => {
<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)"
>
<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>
@@ -368,12 +330,7 @@ const viewStock = (itemId) => {
<template #footer>
<Button label="انصراف" icon="pi pi-times" text @click="showDialog = false" />
<Button
:label="editMode ? 'ذخیره تغییرات' : 'ایجاد قلم'"
icon="pi pi-check"
:loading="submitting"
@click="submitForm"
/>
<Button :label="editMode ? 'ذخیره تغییرات' : 'ایجاد قلم'" icon="pi pi-check" :loading="submitting" @click="submitForm" />
</template>
</Dialog>
</div>
@@ -443,7 +400,9 @@ const viewStock = (itemId) => {
font-size: 1rem;
font-weight: 600;
}
.search-input { width: 260px; }
.search-input {
width: 260px;
}
/* Empty state */
.empty-state {
@@ -467,7 +426,9 @@ const viewStock = (itemId) => {
font-size: 0.8rem;
font-weight: 600;
}
.name-fa { font-weight: 600; }
.name-fa {
font-weight: 600;
}
.name-en {
color: var(--text-color-secondary);
font-size: 0.875rem;
@@ -475,7 +436,7 @@ const viewStock = (itemId) => {
display: inline-block;
}
.unit-badge {
background: linear-gradient(135deg, rgba(99,102,241,0.1), rgba(139,92,246,0.1));
background: linear-gradient(135deg, rgba(99, 102, 241, 0.1), rgba(139, 92, 246, 0.1));
color: #6366f1;
padding: 3px 10px;
border-radius: 20px;
@@ -488,7 +449,10 @@ const viewStock = (itemId) => {
border-radius: 20px;
font-size: 0.8rem;
}
.text-muted { color: var(--text-color-secondary); opacity: 0.5; }
.text-muted {
color: var(--text-color-secondary);
opacity: 0.5;
}
/* Actions */
.action-buttons {
@@ -498,12 +462,31 @@ const viewStock = (itemId) => {
}
/* 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; }
.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 {
@@ -526,10 +509,14 @@ const viewStock = (itemId) => {
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: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));
background: linear-gradient(135deg, rgba(99, 102, 241, 0.15), rgba(139, 92, 246, 0.15));
color: #6366f1;
}
.unit-en {
+115 -59
View File
@@ -21,7 +21,7 @@ const form = ref({
name: '',
code: '',
description: '',
location: { address: '' },
location: { address: '' }
});
onMounted(() => loadData());
@@ -53,7 +53,7 @@ const openEdit = (wh) => {
name: wh.name,
code: wh.code || '',
description: wh.description || '',
location: { address: wh.location?.address || '' },
location: { address: wh.location?.address || '' }
};
showDialog.value = true;
};
@@ -110,7 +110,6 @@ const onSearch = () => {
<template>
<ConfirmPopup />
<div class="warehouses-page">
<!-- Header -->
<div class="page-header">
<div class="header-content">
@@ -127,13 +126,7 @@ const onSearch = () => {
<!-- Table -->
<div class="card main-card">
<DataTable
:value="warehouses"
:loading="loading"
dataKey="_id"
stripedRows
responsiveLayout="scroll"
>
<DataTable :value="warehouses" :loading="loading" dataKey="_id" stripedRows responsiveLayout="scroll">
<template #header>
<div class="table-header">
<span class="table-title">لیست انبارها ({{ warehouses.length }})</span>
@@ -159,46 +152,44 @@ const onSearch = () => {
</div>
</template>
<Column field="code" header="کد" style="width:8%">
<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%">
<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%">
<Column header="آدرس" style="width: 28%">
<template #body="{ data }">
<span class="address-text">{{ data.location?.address || '—' }}</span>
</template>
</Column>
<Column header="مدیر انبار" style="width:16%">
<Column header="مدیر انبار" style="width: 16%">
<template #body="{ data }">
{{ data.manager?.name || '—' }}
</template>
</Column>
<Column field="isActive" header="وضعیت" style="width:9%">
<Column field="isActive" header="وضعیت" style="width: 9%">
<template #body="{ data }">
<Tag :severity="data.isActive ? 'success' : 'danger'"
:value="data.isActive ? 'فعال' : 'غیرفعال'" />
<Tag :severity="data.isActive ? 'success' : 'danger'" :value="data.isActive ? 'فعال' : 'غیرفعال'" />
</template>
</Column>
<Column header="توضیحات" style="width:12%">
<Column header="توضیحات" style="width: 12%">
<template #body="{ data }">
<span class="desc-text">{{ data.description || '—' }}</span>
</template>
</Column>
<Column header="عملیات" style="width:7%">
<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)" />
@@ -210,13 +201,7 @@ const onSearch = () => {
</div>
<!-- Dialog -->
<Dialog
v-model:visible="showDialog"
:header="editMode ? 'ویرایش انبار' : 'افزودن انبار جدید'"
:style="{ width: '480px' }"
modal
dir="rtl"
>
<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">
@@ -240,32 +225,51 @@ const onSearch = () => {
<template #footer>
<Button label="انصراف" icon="pi pi-times" text @click="showDialog = false" />
<Button
:label="editMode ? 'ذخیره تغییرات' : 'ایجاد انبار'"
icon="pi pi-check"
:loading="submitting"
@click="submitForm"
/>
<Button :label="editMode ? 'ذخیره تغییرات' : 'ایجاد انبار'" icon="pi pi-check" :loading="submitting" @click="submitForm" />
</template>
</Dialog>
</div>
</template>
<style scoped>
.warehouses-page { padding: 1.5rem; direction: rtl; }
.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; }
.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;
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);
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;
}
.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;
@@ -273,13 +277,34 @@ const onSearch = () => {
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; }
.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; }
.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;
@@ -289,16 +314,47 @@ const onSearch = () => {
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; }
.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; }
.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>
@@ -29,28 +29,14 @@ const txForm = ref({
warehouseId: '',
quantity: null,
reason: '',
note: '',
note: ''
});
const IN_REASONS = [
'خرید از تامین‌کننده',
'برگشت از کارگاه',
'انتقال از انبار دیگر',
'اهدا / دریافت رایگان',
'موجودی اولیه',
'سایر',
];
const IN_REASONS = ['خرید از تامین‌کننده', 'برگشت از کارگاه', 'انتقال از انبار دیگر', 'اهدا / دریافت رایگان', 'موجودی اولیه', 'سایر'];
const OUT_REASONS = [
'مصرف در پروژه',
'انتقال به انبار دیگر',
'فروش / واگذاری',
'ضایعات',
'مفقودی',
'سایر',
];
const OUT_REASONS = ['مصرف در پروژه', 'انتقال به انبار دیگر', 'فروش / واگذاری', 'ضایعات', 'مفقودی', 'سایر'];
const reasons = computed(() => txForm.value.type === 'IN' ? IN_REASONS : OUT_REASONS);
const reasons = computed(() => (txForm.value.type === 'IN' ? IN_REASONS : OUT_REASONS));
onMounted(async () => {
await Promise.all([loadStock(), loadWarehouses()]);
@@ -106,7 +92,7 @@ const submitTransaction = async () => {
type: txForm.value.type,
quantity: txForm.value.quantity,
reason: txForm.value.reason,
note: txForm.value.note,
note: txForm.value.note
});
toast.add({
severity: 'success',
@@ -158,12 +144,16 @@ const confirmDeleteTransaction = (tx, event) => {
const formatDate = (d) => {
if (!d) return '—';
return new Intl.DateTimeFormat('fa-IR', {
year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit'
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);
const w = warehouses.value.find((w) => w._id === id);
return w ? w.name : id;
};
</script>
@@ -171,7 +161,6 @@ const getWarehouseName = (id) => {
<template>
<ConfirmPopup />
<div class="stock-view-page">
<!-- ─── Back + Title ──────────────────────────────────────────── -->
<div class="page-header">
<Button icon="pi pi-arrow-right" text rounded @click="router.back()" />
@@ -183,26 +172,9 @@ const getWarehouseName = (id) => {
<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"
/>
<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>
@@ -211,7 +183,6 @@ const getWarehouseName = (id) => {
</div>
<div v-else-if="stockData" class="content-grid">
<!-- ─── Total Summary Card ────────────────────────────────── -->
<div class="summary-card">
<div class="summary-icon">
@@ -280,7 +251,13 @@ const getWarehouseName = (id) => {
text
rounded
v-tooltip.top="'افزودن موجودی'"
@click="() => { txForm.warehouseId = data.warehouse?._id; openTransactionDialog('IN'); txForm.warehouseId = data.warehouse?._id; }"
@click="
() => {
txForm.warehouseId = data.warehouse?._id;
openTransactionDialog('IN');
txForm.warehouseId = data.warehouse?._id;
}
"
/>
<Button
icon="pi pi-minus"
@@ -289,7 +266,13 @@ const getWarehouseName = (id) => {
text
rounded
v-tooltip.top="'کسر موجودی'"
@click="() => { txForm.warehouseId = data.warehouse?._id; openTransactionDialog('OUT'); txForm.warehouseId = data.warehouse?._id; }"
@click="
() => {
txForm.warehouseId = data.warehouse?._id;
openTransactionDialog('OUT');
txForm.warehouseId = data.warehouse?._id;
}
"
/>
</div>
</template>
@@ -306,30 +289,20 @@ const getWarehouseName = (id) => {
<span class="history-count">({{ totalTransactions }} تراکنش)</span>
</h3>
<DataTable
:value="transactions"
:loading="transactionsLoading"
stripedRows
responsiveLayout="scroll"
>
<DataTable :value="transactions" :loading="transactionsLoading" stripedRows responsiveLayout="scroll">
<template #empty>
<div class="empty-state"><p>هیچ تراکنشی یافت نشد</p></div>
</template>
<Column header="نوع" style="width:8%">
<Column header="نوع" style="width: 8%">
<template #body="{ data }">
<Tag
:severity="data.type === 'IN' ? 'success' : 'danger'"
:value="data.type === 'IN' ? 'ورود' : 'خروج'"
/>
<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>
<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>
@@ -352,17 +325,9 @@ const getWarehouseName = (id) => {
<template #body="{ data }">{{ formatDate(data.date) }}</template>
</Column>
<Column style="width:5%">
<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)"
/>
<Button icon="pi pi-trash" text rounded severity="danger" size="small" v-tooltip.top="'حذف تراکنش'" @click="confirmDeleteTransaction(data, $event)" />
</template>
</Column>
</DataTable>
@@ -371,70 +336,28 @@ const getWarehouseName = (id) => {
</div>
<!-- ─── Transaction Dialog ────────────────────────────────────── -->
<Dialog
v-model:visible="showTransactionDialog"
:header="txForm.type === 'IN' ? 'افزودن موجودی' : 'کسر موجودی'"
:style="{ width: '500px' }"
modal
dir="rtl"
>
<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>
<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"
/>
<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' } }"
/>
<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>
<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>
@@ -449,11 +372,7 @@ const getWarehouseName = (id) => {
<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>
<strong> {{ (stockData.stocks.find((s) => s.warehouse?._id === txForm.warehouseId)?.quantity || 0).toLocaleString('fa-IR') }} {{ stockData?.unit }} </strong>
</span>
</div>
</div>
@@ -473,7 +392,10 @@ const getWarehouseName = (id) => {
</template>
<style scoped>
.stock-view-page { padding: 1.5rem; direction: rtl; }
.stock-view-page {
padding: 1.5rem;
direction: rtl;
}
/* Header */
.page-header {
@@ -483,7 +405,9 @@ const getWarehouseName = (id) => {
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.header-text { flex: 1; }
.header-text {
flex: 1;
}
.page-title {
margin: 0;
font-size: 1.4rem;
@@ -492,7 +416,9 @@ const getWarehouseName = (id) => {
align-items: center;
gap: 0.75rem;
}
.item-name-fa { color: var(--text-color); }
.item-name-fa {
color: var(--text-color);
}
.item-name-en {
font-size: 0.95rem;
color: var(--text-color-secondary);
@@ -500,13 +426,29 @@ const getWarehouseName = (id) => {
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; }
.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; }
.loading-container {
display: flex;
justify-content: center;
padding: 4rem;
}
/* Content grid */
.content-grid { display: flex; flex-direction: column; gap: 1.5rem; }
.content-grid {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* Summary card */
.summary-card {
@@ -517,22 +459,46 @@ const getWarehouseName = (id) => {
display: flex;
align-items: center;
gap: 1.5rem;
box-shadow: 0 8px 30px rgba(99,102,241,0.35);
box-shadow: 0 8px 30px rgba(99, 102, 241, 0.35);
}
.summary-icon {
width: 64px; height: 64px;
background: rgba(255,255,255,0.2);
width: 64px;
height: 64px;
background: rgba(255, 255, 255, 0.2);
border-radius: 16px;
display: flex; align-items: center; justify-content: center;
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-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;
@@ -542,7 +508,10 @@ const getWarehouseName = (id) => {
}
/* Table card */
.stock-table-card, .history-card { border-radius: 16px; }
.stock-table-card,
.history-card {
border-radius: 16px;
}
.card-title {
font-size: 1rem;
font-weight: 600;
@@ -551,38 +520,105 @@ const getWarehouseName = (id) => {
align-items: center;
gap: 0.5rem;
}
.history-count { font-size: 0.8rem; color: var(--text-color-secondary); font-weight: 400; }
.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); }
.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; }
.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); }
.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); }
.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); }
.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; }
.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;
@@ -606,11 +642,25 @@ const getWarehouseName = (id) => {
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); }
.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-chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.reason-chip {
border: 1.5px solid var(--surface-border);
background: var(--surface-50);
@@ -621,8 +671,16 @@ const getWarehouseName = (id) => {
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; }
.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;
+57 -86
View File
@@ -1,95 +1,66 @@
<template>
<div
:id="galleryId"
class="pswp-gallery pswp-gallery--single-column my-3 flex min-w-0 flex-row flex-nowrap overflow-x-auto rounded-md bg-brand-dark-3 p-3"
>
<div v-for="(image, key) in imagesData" :key="key" class="relative">
<a
:href="image.url"
:data-pswp-width="image.width"
:data-pswp-height="image.height"
target="_blank"
rel="noreferrer"
class="mx-3 block flex-shrink-0"
>
<img :src="image.url" alt="" class="max-h-60 rounded-md" :style="{ height: '150px' }" />
</a>
<button
class="absolute right-4 top-2 flex h-6 w-6 items-center justify-center rounded-full text-brand-danger"
@click="removeImage(key)"
>
<font-awesome-icon icon="fa-solid fa-x" />
</button>
</div>
</div>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import PhotoSwipeLightbox from 'photoswipe/lightbox'
import 'photoswipe/style.css'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { ref, watch } from 'vue';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import ImageViewerModal from '@/views/uikit/ImageViewerModal.vue';
const props = defineProps({
galleryId: {
type: String,
required: true
},
modelValue: {
type: Array,
required: true
}
});
const emit = defineEmits(['update:modelValue']);
const imagesData = ref([]);
const viewerVisible = ref(false);
const viewerInitialIndex = ref(0);
function updateImagesData(images) {
for (let i = 0; i < images.length; i++) {
const img = new Image()
img.src = images[i].url
img.onload = function () {
images[i].height = img.naturalHeight
images[i].width = img.naturalWidth
}
}
return images
if (!images || !Array.isArray(images)) return [];
return images.map((item) => {
if (typeof item === 'string') {
return { url: item, name: item.split('?')[0].split('/').pop() || 'image' };
}
return { ...item };
});
}
// Props
const props = defineProps({
galleryId: {
type: String,
required: true
},
modelValue: {
type: Array,
required: true
}
})
function openViewer(index) {
viewerInitialIndex.value = index;
viewerVisible.value = true;
}
const imagesData = ref([])
let lightbox = null
// Lifecycle hook: mounted
onMounted(() => {
if (!lightbox) {
console.log('Launching Gallery')
console.log({
gallery: '#' + props.galleryId,
children: 'a'
})
lightbox = new PhotoSwipeLightbox({
gallery: '#' + props.galleryId,
children: 'a',
pswpModule: () => import('photoswipe')
})
lightbox.init()
}
imagesData.value = updateImagesData(props.modelValue)
})
function removeImage(index) {
const newImages = [...props.modelValue];
newImages.splice(index, 1);
emit('update:modelValue', newImages);
}
watch(
() => props.modelValue,
(newImages) => {
console.log('propsimages', newImages)
imagesData.value = updateImagesData(newImages)
},
{ deep: true, immediate: true }
)
const emit = defineEmits(['update:modelValue'])
function removeImage(index) {
const newImages = [...props.modelValue]
newImages.splice(index, 1)
emit('update:modelValue', newImages)
}
() => props.modelValue,
(newImages) => {
imagesData.value = updateImagesData(newImages);
},
{ deep: true, immediate: true }
);
</script>
<template>
<div :id="galleryId" class="my-3 flex min-w-0 flex-row flex-nowrap overflow-x-auto rounded-md bg-brand-dark-3 p-3 gap-3">
<div v-for="(image, key) in imagesData" :key="key" class="relative group flex-shrink-0">
<div class="cursor-pointer overflow-hidden rounded-md" @click="openViewer(key)">
<img :src="image.url" alt="" class="h-[150px] object-cover rounded-md transition-transform duration-200 group-hover:scale-105" />
</div>
<button type="button" class="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-white shadow-md hover:bg-red-600 focus:outline-none" @click.stop="removeImage(key)">
<font-awesome-icon icon="fa-solid fa-x" class="text-xs" />
</button>
</div>
<ImageViewerModal v-model:visible="viewerVisible" :images="imagesData" :initial-index="viewerInitialIndex" />
</div>
</template>
+78 -111
View File
@@ -1,77 +1,6 @@
<template>
<div
:id="galleryId"
class="pswp-gallery flex flex-wrap gap-3 p-3 rounded-xl bg-surface-50 dark:bg-surface-800/60 border border-surface-200 dark:border-surface-700/60 min-h-[90px] items-center"
>
<div
v-for="(item, key) in itemsData"
:key="key"
class="group relative flex flex-col items-center justify-between rounded-xl border border-surface-200 dark:border-surface-700 bg-surface-0 dark:bg-surface-900 p-2 shadow-sm transition-all hover:shadow-md overflow-hidden"
:class="isImageFile(item.url) ? 'w-36 h-36 sm:w-40 sm:h-40' : 'w-44 h-36'"
draggable="true"
@dragstart="startDrag($event, key)"
@dragover.prevent
@dragenter.prevent
@drop="onDrop($event, key)"
>
<!-- Image Thumbnail & PhotoSwipe Link -->
<template v-if="isImageFile(item.url)">
<a
:href="item.url"
:data-pswp-width="item.width || 1200"
:data-pswp-height="item.height || 800"
target="_blank"
rel="noreferrer"
class="pswp-link w-full h-full flex items-center justify-center overflow-hidden rounded-lg relative cursor-pointer"
>
<img
:src="item.url"
:alt="item.name || 'Image'"
class="w-full h-full object-cover rounded-lg transition-transform duration-300 group-hover:scale-105"
crossorigin="anonymous"
/>
<div class="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-white">
<i class="pi pi-search-plus text-xl"></i>
</div>
</a>
</template>
<!-- Non-Image File (PDF, DOC, etc.) -->
<template v-else>
<div class="w-full h-full flex flex-col items-center justify-center p-2 text-center">
<i :class="[getFileIconClass(item.url), getFileIconColor(item.url), 'text-3xl mb-2']"></i>
<p class="text-xs font-medium text-surface-700 dark:text-surface-200 truncate max-w-full px-1 mb-2" :title="getFileName(item.url)">
{{ getFileName(item.url) }}
</p>
<a
:href="item.url"
target="_blank"
download
class="inline-flex items-center gap-1 text-[11px] px-2.5 py-1 rounded-lg bg-surface-100 dark:bg-surface-800 hover:bg-surface-200 dark:hover:bg-surface-700 text-surface-700 dark:text-surface-200 transition-colors"
>
<i class="pi pi-download text-[10px]"></i>
<span>دانلود</span>
</a>
</div>
</template>
<!-- Remove Button -->
<button
type="button"
class="absolute top-1.5 right-1.5 z-10 w-6 h-6 rounded-full bg-red-500 hover:bg-red-600 text-white flex items-center justify-center shadow-md opacity-90 hover:opacity-100 transition-all cursor-pointer"
title="حذف"
@click.stop="removeItem(key, item.url)"
>
<i class="pi pi-times text-[10px]"></i>
</button>
</div>
</div>
</template>
<script setup>
import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
import PhotoSwipeLightbox from 'photoswipe/lightbox';
import 'photoswipe/style.css';
import { ref, computed, watch } from 'vue';
import ImageViewerModal from '@/views/uikit/ImageViewerModal.vue';
const props = defineProps({
galleryId: {
@@ -87,13 +16,14 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue', 'delete-image']);
const itemsData = ref([]);
let lightbox = null;
const viewerVisible = ref(false);
const viewerInitialIndex = ref(0);
function isImageFile(url) {
if (!url || typeof url !== 'string') return false;
const cleanUrl = url.split('?')[0];
const ext = cleanUrl.toLowerCase().split('.').pop();
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext);
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'avif'].includes(ext) || cleanUrl.startsWith('blob:') || cleanUrl.startsWith('data:image/');
}
function getFileIconClass(url) {
@@ -133,49 +63,27 @@ function updateItemsData(items) {
}
return items.map((item) => {
const itemObj = typeof item === 'string'
? { url: item, name: item.split('/').pop() || 'file' }
: { ...item };
if (itemObj && itemObj.url && isImageFile(itemObj.url)) {
const img = new Image();
img.src = itemObj.url;
img.onload = function () {
itemObj.height = img.naturalHeight || 800;
itemObj.width = img.naturalWidth || 1200;
if (typeof item === 'string') {
return {
url: item,
name: item.split('?')[0].split('/').pop() || 'file'
};
if (!itemObj.width) itemObj.width = 1200;
if (!itemObj.height) itemObj.height = 800;
}
return itemObj;
return { ...item };
});
}
function initLightbox() {
if (lightbox) {
lightbox.destroy();
lightbox = null;
}
lightbox = new PhotoSwipeLightbox({
gallery: '#' + props.galleryId,
children: 'a.pswp-link',
pswpModule: () => import('photoswipe')
});
lightbox.init();
// Extract only images for the lightbox viewer
const imageItems = computed(() => {
return itemsData.value.filter((item) => isImageFile(item.url));
});
function openViewer(clickedItem) {
const idx = imageItems.value.findIndex((img) => img.url === clickedItem.url);
viewerInitialIndex.value = idx >= 0 ? idx : 0;
viewerVisible.value = true;
}
onMounted(() => {
initLightbox();
itemsData.value = updateItemsData(props.modelValue);
});
onBeforeUnmount(() => {
if (lightbox) {
lightbox.destroy();
lightbox = null;
}
});
watch(
() => props.modelValue,
(newItems) => {
@@ -209,3 +117,62 @@ function onDrop(event, dropIndex) {
emit('update:modelValue', newItems);
}
</script>
<template>
<div :id="galleryId" class="flex flex-wrap gap-3 p-3 rounded-xl bg-surface-50 dark:bg-surface-800/60 border border-surface-200 dark:border-surface-700/60 min-h-[90px] items-center">
<div
v-for="(item, key) in itemsData"
:key="key"
class="group relative flex flex-col items-center justify-between rounded-xl border border-surface-200 dark:border-surface-700 bg-surface-0 dark:bg-surface-900 p-2 shadow-sm transition-all hover:shadow-md overflow-hidden"
:class="isImageFile(item.url) ? 'w-36 h-36 sm:w-40 sm:h-40' : 'w-44 h-36'"
draggable="true"
@dragstart="startDrag($event, key)"
@dragover.prevent
@dragenter.prevent
@drop="onDrop($event, key)"
>
<!-- Image Thumbnail -->
<template v-if="isImageFile(item.url)">
<div class="w-full h-full flex items-center justify-center overflow-hidden rounded-lg relative cursor-pointer group" @click="openViewer(item)">
<img :src="item.url" :alt="item.name || 'Image'" class="w-full h-full object-cover rounded-lg transition-transform duration-300 group-hover:scale-105 select-none pointer-events-none" crossorigin="anonymous" loading="lazy" />
<div class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-1 text-white">
<i class="pi pi-search-plus text-2xl drop-shadow-md"></i>
<span class="text-[11px] font-medium drop-shadow-md">مشاهده و بزرگ‌نمایی</span>
</div>
</div>
</template>
<!-- Non-Image File (PDF, DOC, etc.) -->
<template v-else>
<div class="w-full h-full flex flex-col items-center justify-center p-2 text-center">
<i :class="[getFileIconClass(item.url), getFileIconColor(item.url), 'text-3xl mb-2']"></i>
<p class="text-xs font-medium text-surface-700 dark:text-surface-200 truncate max-w-full px-1 mb-2" :title="getFileName(item.url)">
{{ getFileName(item.url) }}
</p>
<a
:href="item.url"
target="_blank"
download
class="inline-flex items-center gap-1 text-[11px] px-2.5 py-1 rounded-lg bg-surface-100 dark:bg-surface-800 hover:bg-surface-200 dark:hover:bg-surface-700 text-surface-700 dark:text-surface-200 transition-colors"
>
<i class="pi pi-download text-[10px]"></i>
<span>دانلود</span>
</a>
</div>
</template>
<!-- Remove Button -->
<button
type="button"
class="absolute top-1.5 right-1.5 z-10 w-6 h-6 rounded-full bg-red-500 hover:bg-red-600 text-white flex items-center justify-center shadow-md opacity-90 hover:opacity-100 transition-all cursor-pointer focus:outline-none"
title="حذف"
@click.stop="removeItem(key, item.url)"
>
<i class="pi pi-times text-[10px]"></i>
</button>
</div>
<!-- High-Performance Image Viewer Lightbox Modal -->
<ImageViewerModal v-model:visible="viewerVisible" :images="imageItems" :initial-index="viewerInitialIndex" />
</div>
</template>
+34 -38
View File
@@ -1,36 +1,3 @@
<template>
<div class="image-uploader-wrapper">
<FilePond
ref="pond"
name="filepond-image"
:max-files="maxFiles"
:allow-multiple="allowMultiple"
store-as-file="true"
:server="{
process: {
url: resolvedUploadUrl,
method: 'POST',
withCredentials: true,
headers: authHeaders,
onload: (response) => handleProcessResponse(response)
},
revert: {
url: resolvedDeleteUrl,
method: 'DELETE',
withCredentials: true,
headers: authHeaders,
onload: (response) => handleRevertResponse(response)
}
}"
:label-idle="'برای آپلود کلیک کنید یا فایل را بکشید و رها کنید <span class=\'filepond--label-action\'>انتخاب فایل</span>'"
label-tap-to-undo="برای حذف کلیک کنید"
label-file-processing="در حال آپلود..."
label-file-processing-complete="آپلود فایل تکمیل شد"
@removefile="handleRemoveFile"
/>
</div>
</template>
<script>
import vueFilePond from 'vue-filepond';
import 'filepond/dist/filepond.min.css';
@@ -150,10 +117,7 @@ function handleProcessResponse(response) {
fileRecord = parsed;
}
const fileUrl =
props.mode === 'private'
? fileRecord?.privateUrl || fileRecord?.fileURL || fileRecord?.url || ''
: fileRecord?.publicUrl || fileRecord?.fileURL || fileRecord?.url || '';
const fileUrl = props.mode === 'private' ? fileRecord?.privateUrl || fileRecord?.fileURL || fileRecord?.url || '' : fileRecord?.publicUrl || fileRecord?.fileURL || fileRecord?.url || '';
const fileName = fileRecord?.fileName || fileRecord?.name || (fileUrl ? fileUrl.split('/').pop() : 'file');
@@ -214,6 +178,39 @@ function removeFromFileList(identifier) {
}
</script>
<template>
<div class="image-uploader-wrapper">
<FilePond
ref="pond"
name="filepond-image"
:max-files="maxFiles"
:allow-multiple="allowMultiple"
store-as-file="true"
:server="{
process: {
url: resolvedUploadUrl,
method: 'POST',
withCredentials: true,
headers: authHeaders,
onload: (response) => handleProcessResponse(response)
},
revert: {
url: resolvedDeleteUrl,
method: 'DELETE',
withCredentials: true,
headers: authHeaders,
onload: (response) => handleRevertResponse(response)
}
}"
:label-idle="'برای آپلود کلیک کنید یا فایل را بکشید و رها کنید <span class=\'filepond--label-action\'>انتخاب فایل</span>'"
label-tap-to-undo="برای حذف کلیک کنید"
label-file-processing="در حال آپلود..."
label-file-processing-complete="آپلود فایل تکمیل شد"
@removefile="handleRemoveFile"
/>
</div>
</template>
<style>
/* FilePond Container & Theme Styling */
.image-uploader-wrapper {
@@ -261,4 +258,3 @@ function removeFromFileList(identifier) {
background-color: var(--primary-color, #10b981) !important;
}
</style>
+636
View File
@@ -0,0 +1,636 @@
<script setup>
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
const props = defineProps({
visible: {
type: Boolean,
default: false
},
images: {
type: Array,
default: () => []
},
initialIndex: {
type: Number,
default: 0
}
});
const emit = defineEmits(['update:visible', 'change', 'close']);
// Viewport and container refs
const modalContainerRef = ref(null);
const viewportRef = ref(null);
const imageRef = ref(null);
// Active state
const currentIndex = ref(0);
const loading = ref(true);
const isFullscreen = ref(false);
const showThumbnails = ref(true);
// Zoom and Transform states
const scale = ref(1);
const minScale = 0.2;
const maxScale = 8.0;
const translateX = ref(0);
const translateY = ref(0);
const rotation = ref(0);
const flipH = ref(1);
const flipV = ref(1);
// Drag & Pan state
const isDragging = ref(false);
const dragStartX = ref(0);
const dragStartY = ref(0);
const initialTranslateX = ref(0);
const initialTranslateY = ref(0);
// Touch tracking for pinch-to-zoom
let initialTouchDistance = 0;
let initialTouchScale = 1;
// Filter only valid image items
function isImageItem(item) {
const url = getImageUrl(item);
if (!url || typeof url !== 'string') return false;
const clean = url.split('?')[0].toLowerCase();
const ext = clean.split('.').pop();
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'avif'].includes(ext) || clean.startsWith('blob:') || clean.startsWith('data:image/');
}
const validImages = computed(() => {
if (!props.images || !Array.isArray(props.images)) return [];
return props.images.filter(isImageItem);
});
const currentImage = computed(() => {
return validImages.value[currentIndex.value] || null;
});
const currentImageUrl = computed(() => {
return getImageUrl(currentImage.value);
});
const currentImageName = computed(() => {
return getImageName(currentImage.value);
});
const isActualSize = computed(() => {
return Math.abs(scale.value - 1) < 0.05 && translateX.value === 0 && translateY.value === 0;
});
function getImageUrl(item) {
if (!item) return '';
if (typeof item === 'string') return item;
return item.url || item.source || item.src || '';
}
function getImageName(item) {
if (!item) return 'تصویر';
if (typeof item === 'string') {
const clean = item.split('?')[0];
try {
return decodeURIComponent(clean.split('/').pop() || 'تصویر');
} catch {
return clean.split('/').pop() || 'تصویر';
}
}
const name = item.fileName || item.name || '';
if (name) return name;
const url = item.url || item.source || '';
const clean = url.split('?')[0];
try {
return decodeURIComponent(clean.split('/').pop() || 'تصویر');
} catch {
return clean.split('/').pop() || 'تصویر';
}
}
function formatPersianNumber(num) {
if (num === null || num === undefined) return '';
return num.toLocaleString('fa-IR');
}
// Transform matrix
const transformStyle = computed(() => {
return {
transform: `translate3d(${translateX.value}px, ${translateY.value}px, 0) scale(${scale.value}) rotate(${rotation.value}deg) scaleX(${flipH.value}) scaleY(${flipV.value})`,
transition: isDragging.value ? 'none' : 'transform 0.18s cubic-bezier(0.2, 0, 0.2, 1)'
};
});
// Watch visibility
watch(
() => props.visible,
(val) => {
if (val) {
currentIndex.value = Math.max(0, Math.min(props.initialIndex, validImages.value.length - 1));
resetTransform();
loading.value = true;
nextTick(() => {
modalContainerRef.value?.focus();
});
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
}
}
},
{ immediate: true }
);
watch(
() => props.initialIndex,
(idx) => {
if (props.visible) {
currentIndex.value = Math.max(0, Math.min(idx, validImages.value.length - 1));
resetTransform();
loading.value = true;
}
}
);
function selectImage(idx) {
if (idx >= 0 && idx < validImages.value.length) {
currentIndex.value = idx;
resetTransform();
loading.value = true;
emit('change', idx);
}
}
function prevImage() {
if (validImages.value.length <= 1) return;
const nextIdx = (currentIndex.value - 1 + validImages.value.length) % validImages.value.length;
selectImage(nextIdx);
}
function nextImage() {
if (validImages.value.length <= 1) return;
const nextIdx = (currentIndex.value + 1) % validImages.value.length;
selectImage(nextIdx);
}
function onImageLoaded() {
loading.value = false;
}
function onImageError() {
loading.value = false;
}
// Zoom operations
function zoomIn() {
const newScale = Math.min(scale.value * 1.25, maxScale);
scale.value = Number(newScale.toFixed(2));
}
function zoomOut() {
const newScale = Math.max(scale.value / 1.25, minScale);
scale.value = Number(newScale.toFixed(2));
if (scale.value <= 1) {
translateX.value = 0;
translateY.value = 0;
}
}
function toggleActualSize() {
if (Math.abs(scale.value - 1) < 0.05) {
scale.value = 2.0;
} else {
resetTransform();
}
}
function rotateRight() {
rotation.value = (rotation.value + 90) % 360;
}
function rotateLeft() {
rotation.value = (rotation.value - 90 + 360) % 360;
}
function toggleFlipH() {
flipH.value = flipH.value === 1 ? -1 : 1;
}
function toggleFlipV() {
flipV.value = flipV.value === 1 ? -1 : 1;
}
function resetTransform() {
scale.value = 1;
translateX.value = 0;
translateY.value = 0;
rotation.value = 0;
flipH.value = 1;
flipV.value = 1;
}
// Mouse Wheel Zoom
function handleWheel(e) {
const zoomFactor = e.deltaY < 0 ? 1.15 : 0.87;
const newScale = Math.min(Math.max(scale.value * zoomFactor, minScale), maxScale);
if (newScale === scale.value) return;
if (viewportRef.value) {
const rect = viewportRef.value.getBoundingClientRect();
const mouseX = e.clientX - (rect.left + rect.width / 2);
const mouseY = e.clientY - (rect.top + rect.height / 2);
// Calculate offset zoom
translateX.value = mouseX - (mouseX - translateX.value) * (newScale / scale.value);
translateY.value = mouseY - (mouseY - translateY.value) * (newScale / scale.value);
}
scale.value = Number(newScale.toFixed(2));
if (scale.value <= 1) {
translateX.value = 0;
translateY.value = 0;
}
}
// Double Click Zoom
function onDoubleClick(e) {
if (scale.value <= 1.1) {
if (viewportRef.value) {
const rect = viewportRef.value.getBoundingClientRect();
const mouseX = e.clientX - (rect.left + rect.width / 2);
const mouseY = e.clientY - (rect.top + rect.height / 2);
translateX.value = -mouseX * 1.5;
translateY.value = -mouseY * 1.5;
}
scale.value = 2.5;
} else {
resetTransform();
}
}
// Mouse Drag / Pan
function onMouseDown(e) {
if (e.button !== 0) return; // Only left click
isDragging.value = true;
dragStartX.value = e.clientX;
dragStartY.value = e.clientY;
initialTranslateX.value = translateX.value;
initialTranslateY.value = translateY.value;
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}
function onMouseMove(e) {
if (!isDragging.value) return;
const dx = e.clientX - dragStartX.value;
const dy = e.clientY - dragStartY.value;
translateX.value = initialTranslateX.value + dx;
translateY.value = initialTranslateY.value + dy;
}
function onMouseUp() {
isDragging.value = false;
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
}
// Touch Handling for Mobile / Tablets
function onTouchStart(e) {
if (e.touches.length === 2) {
// Pinch zoom
const t1 = e.touches[0];
const t2 = e.touches[1];
initialTouchDistance = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
initialTouchScale = scale.value;
} else if (e.touches.length === 1) {
// Pan
isDragging.value = true;
dragStartX.value = e.touches[0].clientX;
dragStartY.value = e.touches[0].clientY;
initialTranslateX.value = translateX.value;
initialTranslateY.value = translateY.value;
}
}
function onTouchMove(e) {
if (e.touches.length === 2 && initialTouchDistance > 0) {
const t1 = e.touches[0];
const t2 = e.touches[1];
const currentDistance = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
const factor = currentDistance / initialTouchDistance;
scale.value = Math.min(Math.max(initialTouchScale * factor, minScale), maxScale);
} else if (e.touches.length === 1 && isDragging.value) {
const dx = e.touches[0].clientX - dragStartX.value;
const dy = e.touches[0].clientY - dragStartY.value;
translateX.value = initialTranslateX.value + dx;
translateY.value = initialTranslateY.value + dy;
}
}
function onTouchEnd() {
isDragging.value = false;
initialTouchDistance = 0;
}
// Fullscreen
function toggleFullscreen() {
if (!document.fullscreenElement) {
modalContainerRef.value
?.requestFullscreen?.()
.then(() => {
isFullscreen.value = true;
})
.catch(() => {});
} else {
document
.exitFullscreen?.()
.then(() => {
isFullscreen.value = false;
})
.catch(() => {});
}
}
// Download
async function downloadCurrentImage() {
const url = currentImageUrl.value;
if (!url) return;
const filename = currentImageName.value || 'image.jpg';
try {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
} catch {
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.target = '_blank';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
}
// Close
function closeViewer() {
emit('update:visible', false);
emit('close');
}
// Keyboard shortcuts
function handleKeydown(e) {
if (!props.visible) return;
switch (e.key) {
case 'Escape':
closeViewer();
break;
case 'ArrowRight':
prevImage();
break;
case 'ArrowLeft':
nextImage();
break;
case '+':
case '=':
zoomIn();
break;
case '-':
case '_':
zoomOut();
break;
case '0':
resetTransform();
break;
case 'r':
case 'R':
case 'ق': // Persian layout mapping
rotateRight();
break;
case 'f':
case 'F':
case 'ب':
toggleFullscreen();
break;
}
}
function onFullscreenChange() {
isFullscreen.value = !!document.fullscreenElement;
}
onMounted(() => {
document.addEventListener('fullscreenchange', onFullscreenChange);
});
onBeforeUnmount(() => {
document.removeEventListener('fullscreenchange', onFullscreenChange);
document.body.style.overflow = '';
});
</script>
<template>
<Teleport to="body">
<Transition name="iv-fade">
<div v-if="visible" ref="modalContainerRef" class="fixed inset-0 z-[99999] flex flex-col bg-black/92 backdrop-blur-xl text-white select-none overflow-hidden font-sans outline-none" tabindex="-1" @keydown="handleKeydown">
<!-- TOP HEADER BAR -->
<div class="relative z-20 flex items-center justify-between px-4 py-3 bg-gradient-to-b from-black/80 via-black/40 to-transparent">
<!-- Left / Image Counter & Name -->
<div class="flex items-center gap-3 max-w-[60%] truncate">
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-white/10 text-white/90 backdrop-blur-md border border-white/10 tracking-wide">
{{ formatPersianNumber(currentIndex + 1) }} / {{ formatPersianNumber(validImages.length) }}
</span>
<h3 class="text-sm font-medium text-white/80 truncate" :title="currentImageName">
{{ currentImageName }}
</h3>
</div>
<!-- Right / Top Action Controls -->
<div class="flex items-center gap-2">
<!-- Fullscreen -->
<button type="button" class="iv-btn" :title="isFullscreen ? 'خروج از تمام صفحه (F)' : 'تمام صفحه (F)'" @click="toggleFullscreen">
<i :class="isFullscreen ? 'pi pi-window-minimize' : 'pi pi-window-maximize'" class="text-sm"></i>
</button>
<!-- Download -->
<button type="button" class="iv-btn" title="دانلود تصویر" @click="downloadCurrentImage">
<i class="pi pi-download text-sm"></i>
</button>
<!-- Close Button -->
<button type="button" class="iv-btn iv-btn-close" title="بستن (Esc)" @click="closeViewer">
<i class="pi pi-times text-base font-bold"></i>
</button>
</div>
</div>
<!-- MAIN IMAGE VIEWPORT -->
<div
ref="viewportRef"
class="relative flex-1 w-full h-full flex items-center justify-center overflow-hidden cursor-grab active:cursor-grabbing touch-none"
@wheel.prevent="handleWheel"
@mousedown="onMouseDown"
@dblclick="onDoubleClick"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
>
<!-- Loading Spinner -->
<div v-if="loading" class="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<i class="pi pi-spin pi-spinner text-4xl text-primary-400"></i>
</div>
<!-- Image Container with Hardware Accelerated Transform -->
<div v-show="currentImageUrl" class="relative will-change-transform flex items-center justify-center max-w-full max-h-full" :style="transformStyle">
<img
ref="imageRef"
:src="currentImageUrl"
:alt="currentImageName"
class="max-w-[85vw] max-h-[75vh] object-contain pointer-events-none select-none drop-shadow-2xl transition-opacity duration-200"
:class="{ 'opacity-0': loading, 'opacity-100': !loading }"
crossorigin="anonymous"
@load="onImageLoaded"
@error="onImageError"
/>
</div>
<!-- Navigation Arrow: Previous (Right side in RTL, left side in LTR) -->
<button v-if="validImages.length > 1" type="button" class="iv-nav-btn absolute right-4 top-1/2 -translate-y-1/2 z-20" title="تصویر قبلی (کلید جهت‌نما)" @click.stop="prevImage">
<i class="pi pi-chevron-right text-xl"></i>
</button>
<!-- Navigation Arrow: Next (Left side in RTL, right side in LTR) -->
<button v-if="validImages.length > 1" type="button" class="iv-nav-btn absolute left-4 top-1/2 -translate-y-1/2 z-20" title="تصویر بعدی (کلید جهت‌نما)" @click.stop="nextImage">
<i class="pi pi-chevron-left text-xl"></i>
</button>
</div>
<!-- FLOATING BOTTOM TOOLBAR -->
<div class="relative z-20 flex flex-col items-center gap-2 pb-3 px-4 pointer-events-none">
<div class="pointer-events-auto flex items-center gap-1.5 p-1.5 rounded-2xl bg-surface-900/80 dark:bg-surface-950/80 backdrop-blur-xl border border-white/10 shadow-2xl">
<!-- Zoom Out -->
<button type="button" class="iv-tool-btn" title="کوچک‌نمایی (-)" :disabled="scale <= minScale" @click="zoomOut">
<i class="pi pi-search-minus text-sm"></i>
</button>
<!-- Zoom Percentage / Reset Zoom -->
<button type="button" class="iv-tool-btn px-3 font-mono text-xs font-semibold text-white/90 min-w-[54px] text-center" title="بازنشانی بزرگ‌نمایی (0)" @click="resetTransform">{{ Math.round(scale * 100) }}%</button>
<!-- Zoom In -->
<button type="button" class="iv-tool-btn" title="بزرگ‌نمایی (+)" :disabled="scale >= maxScale" @click="zoomIn">
<i class="pi pi-search-plus text-sm"></i>
</button>
<div class="w-[1px] h-5 bg-white/15 mx-1"></div>
<!-- 1:1 Actual Size -->
<button type="button" class="iv-tool-btn" :class="{ 'text-primary-400 bg-white/10': isActualSize }" title="اندازه واقعی (۱:۱)" @click="toggleActualSize">
<span class="text-xs font-bold font-mono">1:1</span>
</button>
<!-- Rotate Counter-Clockwise -->
<button type="button" class="iv-tool-btn" title="چرخش ۹۰ درجه پادساعت‌گرد" @click="rotateLeft">
<i class="pi pi-replay text-sm"></i>
</button>
<!-- Rotate Clockwise -->
<button type="button" class="iv-tool-btn" title="چرخش ۹۰ درجه ساعت‌گرد (R)" @click="rotateRight">
<i class="pi pi-refresh text-sm"></i>
</button>
<!-- Flip Horizontal -->
<button type="button" class="iv-tool-btn" :class="{ 'text-primary-400 bg-white/10': flipH === -1 }" title="قرینه افقی" @click="toggleFlipH">
<i class="pi pi-arrows-h text-sm"></i>
</button>
<!-- Flip Vertical -->
<button type="button" class="iv-tool-btn" :class="{ 'text-primary-400 bg-white/10': flipV === -1 }" title="قرینه عمودی" @click="toggleFlipV">
<i class="pi pi-arrows-v text-sm"></i>
</button>
<div class="w-[1px] h-5 bg-white/15 mx-1"></div>
<!-- Reset Everything -->
<button type="button" class="iv-tool-btn" title="تنظیم مجدد موقعیت و چرخش" @click="resetTransform">
<i class="pi pi-undo text-sm"></i>
</button>
<!-- Toggle Thumbnail Strip -->
<button v-if="validImages.length > 1" type="button" class="iv-tool-btn" :class="{ 'text-primary-400 bg-white/10': showThumbnails }" title="نمایش/مخفی‌سازی گالری تصاویر" @click="showThumbnails = !showThumbnails">
<i class="pi pi-images text-sm"></i>
</button>
</div>
<!-- THUMBNAIL STRIP CAROUSEL -->
<Transition name="iv-slide-up">
<div
v-if="showThumbnails && validImages.length > 1"
class="pointer-events-auto flex items-center gap-2 p-2 rounded-2xl bg-black/60 backdrop-blur-xl border border-white/10 max-w-[90vw] overflow-x-auto scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent"
>
<button
v-for="(img, idx) in validImages"
:key="idx"
type="button"
class="relative flex-shrink-0 w-14 h-14 rounded-xl overflow-hidden border-2 transition-all duration-200 cursor-pointer focus:outline-none"
:class="idx === currentIndex ? 'border-primary-400 ring-2 ring-primary-400/40 scale-105 opacity-100 shadow-lg' : 'border-white/20 opacity-60 hover:opacity-100 hover:border-white/50'"
@click="selectImage(idx)"
>
<img :src="getImageUrl(img)" :alt="getImageName(img)" class="w-full h-full object-cover" crossorigin="anonymous" />
</button>
</div>
</Transition>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.iv-btn {
@apply flex items-center justify-center w-9 h-9 rounded-full bg-white/10 hover:bg-white/20 active:bg-white/30 text-white/90 transition-all backdrop-blur-md border border-white/10 cursor-pointer shadow-md focus:outline-none;
}
.iv-btn-close {
@apply bg-red-500/80 hover:bg-red-600 text-white border-red-400/30;
}
.iv-nav-btn {
@apply flex items-center justify-center w-12 h-12 rounded-full bg-surface-900/70 hover:bg-surface-900 text-white/80 hover:text-white transition-all backdrop-blur-xl border border-white/15 shadow-2xl cursor-pointer hover:scale-110 active:scale-95 focus:outline-none;
}
.iv-tool-btn {
@apply flex items-center justify-center h-8 px-2.5 rounded-xl text-white/80 hover:text-white hover:bg-white/10 active:bg-white/20 transition-all cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed focus:outline-none;
}
/* Animations */
.iv-fade-enter-active,
.iv-fade-leave-active {
transition: opacity 0.25s ease;
}
.iv-fade-enter-from,
.iv-fade-leave-to {
opacity: 0;
}
.iv-slide-up-enter-active,
.iv-slide-up-leave-active {
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
.iv-slide-up-enter-from,
.iv-slide-up-leave-to {
opacity: 0;
transform: translateY(12px);
}
</style>
+8 -8
View File
@@ -1,11 +1,3 @@
<template>
<!-- Outer container that mimics a text field with a green border -->
<div class="flex flex-wrap items-center gap-2 rounded p-2 cursor-text" @click="focusInput">
<InputText v-model="inputValue" @keyup.enter="addChip" :placeholder="placeholder" class="w-full min-w-[100px]" />
<Chip v-for="(tag, index) in localTags" :key="tag" :label="tag" removable @remove="removeChip(index)" class="text-sm" />
</div>
</template>
<script setup>
import { ref, watch } from 'vue';
import Chip from 'primevue/chip';
@@ -51,6 +43,14 @@ function removeChip(index) {
}
</script>
<template>
<!-- Outer container that mimics a text field with a green border -->
<div class="flex flex-wrap items-center gap-2 rounded p-2 cursor-text" @click="focusInput">
<InputText v-model="inputValue" @keyup.enter="addChip" :placeholder="placeholder" class="w-full min-w-[100px]" />
<Chip v-for="(tag, index) in localTags" :key="tag" :label="tag" removable @remove="removeChip(index)" class="text-sm" />
</div>
</template>
<!--
No <style> block is needed if you're using Tailwind CSS utility classes.
However, if you need to override or add custom CSS, do it here or in a global stylesheet.
+7 -7
View File
@@ -1,10 +1,3 @@
<template>
<FloatLabel>
<ToggleSwitch :id="id" v-model="computedValue" :class="inputClass" @change="handleChange" @keyup="handleKeyUp" />
<label :for="id">label</label>
</FloatLabel>
</template>
<script setup>
import ToggleSwitch from 'primevue/toggleswitch';
import { computed } from 'vue';
@@ -46,3 +39,10 @@ const handleKeyUp = (event) => {
emit('key-up-event', event);
};
</script>
<template>
<FloatLabel>
<ToggleSwitch :id="id" v-model="computedValue" :class="inputClass" @change="handleChange" @keyup="handleKeyUp" />
<label :for="id">label</label>
</FloatLabel>
</template>
+171 -164
View File
@@ -1,137 +1,3 @@
<template>
<div ref="mapContainer" class="map-container" :class="{ 'map-fullscreen': isFullscreen }">
<!-- ── Top-left: Search bar ── -->
<div class="map-overlay search-bar-wrap">
<span class="search-icon">🔍</span>
<input
v-model="searchQuery"
@keyup.enter="handleSearch"
placeholder="جستجو روی نقشه..."
class="search-input"
/>
<button v-if="searchQuery" class="search-clear" @click="searchQuery = ''" title="پاک کردن">✕</button>
</div>
<!-- ── Top-right: Action buttons ── -->
<div class="map-overlay action-buttons">
<!-- Fullscreen toggle -->
<button class="map-btn" @click="toggleFullscreen" :title="isFullscreen ? 'خروج از تمام‌صفحه' : 'تمام‌صفحه'">
<!-- Exit fullscreen icon -->
<svg v-if="isFullscreen" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/>
<path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/>
</svg>
<!-- Enter fullscreen icon -->
<svg v-else xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 7V3h4"/><path d="M21 7V3h-4"/>
<path d="M3 17v4h4"/><path d="M21 17v4h-4"/>
</svg>
</button>
<!-- Locate me -->
<button class="map-btn map-btn-locate" @click="locateMe" title="موقعیت من" :class="{ locating: isLocating }">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M12 2v3M12 19v3M2 12h3M19 12h3"/>
<circle cx="12" cy="12" r="7" opacity="0.3"/>
</svg>
</button>
<!-- Clear marker -->
<button v-if="marker.length > 0" class="map-btn map-btn-danger" @click="clearMarker" title="حذف نشانگر">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6M14 11v6"/>
<path d="M9 6V4h6v2"/>
</svg>
</button>
</div>
<!-- ── Bottom-left: Coordinate readout ── -->
<div v-if="hoverCoords" class="map-overlay coords-readout">
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="opacity:0.7;flex-shrink:0">
<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3"/>
</svg>
<span>{{ hoverCoords.lat.toFixed(6) }}, {{ hoverCoords.lon.toFixed(6) }}</span>
</div>
<!-- ── Bottom-right: Marker info pill ── -->
<div v-if="marker.length > 0" class="map-overlay marker-info">
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="#6366f1" stroke="#6366f1" stroke-width="1">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
</svg>
<span class="marker-coords">{{ marker[0].toFixed(5) }}, {{ marker[1].toFixed(5) }}</span>
<button class="copy-btn" @click="copyCoords" :title="copied ? 'کپی شد!' : 'کپی مختصات'">
<svg v-if="!copied" xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
<rect x="9" y="9" width="13" height="13" rx="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2.5">
<polyline points="20 6 9 17 4 12"/>
</svg>
</button>
</div>
<!-- Lock Indicator -->
<div v-if="locked" class="map-overlay lock-indicator">
🔒 قفل شده
</div>
<!-- ── Leaflet Map ── -->
<l-map
ref="map"
v-model:zoom="zoom"
:center="center"
:use-global-leaflet="false"
style="height: 100%; width: 100%;"
@click="handleClick($event)"
@mousemove="handleMouseMove($event)"
@mouseout="hoverCoords = null"
@ready="onMapReady"
>
<!-- Layer switcher -->
<l-control-layers position="bottomright" />
<!-- OpenStreetMap -->
<l-tile-layer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
layer-type="base"
name="نمای نقشه‌ای"
:attribution="attribution"
/>
<!-- Satellite -->
<l-tile-layer
:visible="false"
url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
layer-type="base"
name="نمای ماهواره‌ای"
attribution="Tiles &copy; Esri"
/>
<!-- Scale control -->
<l-control-scale position="bottomleft" :imperial="false" :maxWidth="150" />
<!-- Marker with rich popup -->
<l-marker v-if="marker.length > 0" :lat-lng="marker">
<l-popup>
<div class="popup-content">
<strong>📍 نقطه انتخاب‌شده</strong><br />
<span>عرض: {{ marker[0].toFixed(6) }}</span><br />
<span>طول: {{ marker[1].toFixed(6) }}</span>
</div>
</l-popup>
</l-marker>
</l-map>
<!-- Locating spinner overlay -->
<div v-if="isLocating" class="locating-overlay">
<div class="locating-spinner"></div>
<span>در حال تعیین موقعیت...</span>
</div>
</div>
</template>
<script setup>
import { ref, watch, nextTick, onBeforeUnmount } from 'vue';
import 'leaflet/dist/leaflet.css';
@@ -188,7 +54,7 @@ const clearMarker = () => {
marker.value = [];
emit('update:modelValue', []);
emit('marker-updated', []);
emit('cleared'); // notify parent to also reset nationalGeoCode
emit('cleared'); // notify parent to also reset nationalGeoCode
};
// ── Fullscreen ─────────────────────────────────────────────────────────────
@@ -229,7 +95,9 @@ const locateMe = () => {
isLocating.value = false;
setMarker(pos.coords.latitude, pos.coords.longitude);
},
() => { isLocating.value = false; },
() => {
isLocating.value = false;
},
{ timeout: 10000, enableHighAccuracy: true }
);
};
@@ -332,6 +200,117 @@ watch(
defineExpose({ searchByCoordinates: setMarker, setCenterAndMarker: setMarker, searchLocation, onMapReady });
</script>
<template>
<div ref="mapContainer" class="map-container" :class="{ 'map-fullscreen': isFullscreen }">
<!-- ── Top-left: Search bar ── -->
<div class="map-overlay search-bar-wrap">
<span class="search-icon">🔍</span>
<input v-model="searchQuery" @keyup.enter="handleSearch" placeholder="جستجو روی نقشه..." class="search-input" />
<button v-if="searchQuery" class="search-clear" @click="searchQuery = ''" title="پاک کردن">✕</button>
</div>
<!-- ── Top-right: Action buttons ── -->
<div class="map-overlay action-buttons">
<!-- Fullscreen toggle -->
<button class="map-btn" @click="toggleFullscreen" :title="isFullscreen ? 'خروج از تمام‌صفحه' : 'تمام‌صفحه'">
<!-- Exit fullscreen icon -->
<svg v-if="isFullscreen" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 3v3a2 2 0 0 1-2 2H3" />
<path d="M21 8h-3a2 2 0 0 1-2-2V3" />
<path d="M3 16h3a2 2 0 0 1 2 2v3" />
<path d="M16 21v-3a2 2 0 0 1 2-2h3" />
</svg>
<!-- Enter fullscreen icon -->
<svg v-else xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 7V3h4" />
<path d="M21 7V3h-4" />
<path d="M3 17v4h4" />
<path d="M21 17v4h-4" />
</svg>
</button>
<!-- Locate me -->
<button class="map-btn map-btn-locate" @click="locateMe" title="موقعیت من" :class="{ locating: isLocating }">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
<circle cx="12" cy="12" r="7" opacity="0.3" />
</svg>
</button>
<!-- Clear marker -->
<button v-if="marker.length > 0" class="map-btn map-btn-danger" @click="clearMarker" title="حذف نشانگر">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4h6v2" />
</svg>
</button>
</div>
<!-- ── Bottom-left: Coordinate readout ── -->
<div v-if="hoverCoords" class="map-overlay coords-readout">
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="opacity: 0.7; flex-shrink: 0">
<circle cx="12" cy="12" r="3" />
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
</svg>
<span>{{ hoverCoords.lat.toFixed(6) }}, {{ hoverCoords.lon.toFixed(6) }}</span>
</div>
<!-- ── Bottom-right: Marker info pill ── -->
<div v-if="marker.length > 0" class="map-overlay marker-info">
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="#6366f1" stroke="#6366f1" stroke-width="1">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z" />
</svg>
<span class="marker-coords">{{ marker[0].toFixed(5) }}, {{ marker[1].toFixed(5) }}</span>
<button class="copy-btn" @click="copyCoords" :title="copied ? 'کپی شد!' : 'کپی مختصات'">
<svg v-if="!copied" xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2.5">
<polyline points="20 6 9 17 4 12" />
</svg>
</button>
</div>
<!-- Lock Indicator -->
<div v-if="locked" class="map-overlay lock-indicator">🔒 قفل شده</div>
<!-- ── Leaflet Map ── -->
<l-map ref="map" v-model:zoom="zoom" :center="center" :use-global-leaflet="false" style="height: 100%; width: 100%" @click="handleClick($event)" @mousemove="handleMouseMove($event)" @mouseout="hoverCoords = null" @ready="onMapReady">
<!-- Layer switcher -->
<l-control-layers position="bottomright" />
<!-- OpenStreetMap -->
<l-tile-layer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" layer-type="base" name="نمای نقشه‌ای" :attribution="attribution" />
<!-- Satellite -->
<l-tile-layer :visible="false" url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" layer-type="base" name="نمای ماهواره‌ای" attribution="Tiles &copy; Esri" />
<!-- Scale control -->
<l-control-scale position="bottomleft" :imperial="false" :maxWidth="150" />
<!-- Marker with rich popup -->
<l-marker v-if="marker.length > 0" :lat-lng="marker">
<l-popup>
<div class="popup-content">
<strong>📍 نقطه انتخاب‌شده</strong><br />
<span>عرض: {{ marker[0].toFixed(6) }}</span
><br />
<span>طول: {{ marker[1].toFixed(6) }}</span>
</div>
</l-popup>
</l-marker>
</l-map>
<!-- Locating spinner overlay -->
<div v-if="isLocating" class="locating-overlay">
<div class="locating-spinner"></div>
<span>در حال تعیین موقعیت...</span>
</div>
</div>
</template>
<style scoped>
/* ── Container ──────────────────────────────────────────────────────────── */
.map-container {
@@ -370,12 +349,15 @@ defineExpose({ searchByCoordinates: setMarker, setCenterAndMarker: setMarker, se
-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);
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-icon {
font-size: 13px;
opacity: 0.5;
}
.search-input {
border: none;
@@ -388,7 +370,9 @@ defineExpose({ searchByCoordinates: setMarker, setCenterAndMarker: setMarker, se
direction: rtl;
}
.search-input::placeholder { color: #9ca3af; }
.search-input::placeholder {
color: #9ca3af;
}
.search-clear {
background: none;
@@ -400,7 +384,9 @@ defineExpose({ searchByCoordinates: setMarker, setCenterAndMarker: setMarker, se
line-height: 1;
transition: color 0.15s;
}
.search-clear:hover { color: #ef4444; }
.search-clear:hover {
color: #ef4444;
}
/* ── Action buttons (top-right) ─────────────────────────────────────────── */
.action-buttons {
@@ -418,45 +404,57 @@ defineExpose({ searchByCoordinates: setMarker, setCenterAndMarker: setMarker, se
border: none;
background: #6366f1;
color: #fff;
box-shadow: 0 3px 10px rgba(99,102,241,0.35);
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;
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);
box-shadow: 0 6px 18px rgba(79, 70, 229, 0.45);
transform: scale(1.06);
}
.map-btn:active { transform: scale(0.95); }
.map-btn:active {
transform: scale(0.95);
}
.map-btn-locate {
background: #0ea5e9;
box-shadow: 0 3px 10px rgba(14,165,233,0.35);
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);
box-shadow: 0 6px 18px rgba(2, 132, 199, 0.45);
}
.map-btn.locating { animation: pulse-btn 1s infinite; }
.map-btn.locating {
animation: pulse-btn 1s infinite;
}
.map-btn-danger {
background: #ef4444;
box-shadow: 0 3px 10px rgba(239,68,68,0.35);
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);
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); }
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) ────────────────────────────────────────────── */
@@ -485,20 +483,25 @@ defineExpose({ searchByCoordinates: setMarker, setCenterAndMarker: setMarker, se
display: flex;
align-items: center;
gap: 6px;
background: rgba(255,255,255,0.95);
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);
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; }
.marker-pin-icon {
font-size: 14px;
}
.marker-coords {
font-family: 'Courier New', monospace;
font-size: 11px;
}
.copy-btn {
background: none;
@@ -509,14 +512,16 @@ defineExpose({ searchByCoordinates: setMarker, setCenterAndMarker: setMarker, se
padding: 0;
transition: transform 0.1s;
}
.copy-btn:hover { transform: scale(1.2); }
.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);
background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(4px);
display: flex;
flex-direction: column;
@@ -532,14 +537,16 @@ defineExpose({ searchByCoordinates: setMarker, setCenterAndMarker: setMarker, se
.locating-spinner {
width: 36px;
height: 36px;
border: 3px solid rgba(99,102,241,0.2);
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); }
to {
transform: rotate(360deg);
}
}
/* ── Lock badge ──────────────────────────────────────────────────────────── */
@@ -570,7 +577,7 @@ defineExpose({ searchByCoordinates: setMarker, setCenterAndMarker: setMarker, se
.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;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4) !important;
}
.app-dark .search-input {
@@ -590,7 +597,7 @@ defineExpose({ searchByCoordinates: setMarker, setCenterAndMarker: setMarker, se
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;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.4) !important;
}
/* Locating overlay */
@@ -600,7 +607,7 @@ defineExpose({ searchByCoordinates: setMarker, setCenterAndMarker: setMarker, se
}
.app-dark .locating-spinner {
border-color: rgba(99,102,241,0.15) !important;
border-color: rgba(99, 102, 241, 0.15) !important;
border-top-color: #818cf8 !important;
}
</style>
+16 -1
View File
@@ -3,8 +3,17 @@ import { PhotoService } from '@/service/PhotoService';
import { ProductService } from '@/service/ProductService';
import { onMounted, ref } from 'vue';
import ImageGallery from '@/views/uikit/ImageGallery.vue';
const products = ref([]);
const images = ref([]);
const sampleGalleryImages = ref([
{ url: 'https://primefaces.org/cdn/primevue/images/galleria/galleria10.jpg', name: 'تصویر منظره شماره ۱' },
{ url: 'https://primefaces.org/cdn/primevue/images/galleria/galleria11.jpg', name: 'تصویر منظره شماره ۲' },
{ url: 'https://primefaces.org/cdn/primevue/images/galleria/galleria12.jpg', name: 'تصویر سند / عمودی' },
{ url: 'https://primefaces.org/cdn/primevue/images/galleria/galleria13.jpg', name: 'تصویر شماره ۴' },
{ url: 'https://example.com/sample-document.pdf', name: 'سند_مالکیت.pdf' }
]);
const galleriaResponsiveOptions = ref([
{
breakpoint: '1024px',
@@ -91,7 +100,13 @@ function getSeverity(status) {
</div>
<div class="card">
<div class="font-semibold text-xl mb-4">Image</div>
<div class="font-semibold text-xl mb-2">Image Gallery with Aspect-Preserving Zoom & Rotate</div>
<p class="text-surface-500 mb-4">گالری پیشرفته تصاویر با بزرگ‌نمایی بدون تغییر نسبت تصویر (بدون کشیدگی)، قابلیت چرخش ۹۰ درجه، قرینه، تمام صفحه و نوار پیش‌نمایش بندانگشتی.</p>
<ImageGallery v-model="sampleGalleryImages" galleryId="demoMediaGallery" />
</div>
<div class="card">
<div class="font-semibold text-xl mb-4">PrimeVue Image</div>
<Image src="https://primefaces.org/cdn/primevue/images/galleria/galleria10.jpg" alt="Image" width="250" />
</div>