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