Compare commits

..
22 Commits
Author SHA1 Message Date
kavehhn 195d0300a0 fix(gallery): resolve image zoom stretching and add rich viewer features
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-30 11:25:31 +03:30
kavehhn 7c56d2e66a fix(dashboard): resolve dark mode styling and text contrast for quick action buttons
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-30 11:09:24 +03:30
kavehhn 712b654d68 feat(map): improve co-located properties with smart grouping, layered pin badges, popup navigation, and spread mode
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-30 10:59:41 +03:30
kavehhn b6d5bff088 feat(property): add 'فاقد کاربری' to zoningType dropdown options
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-30 10:16:34 +03:30
kavehhn 9d16b90e18 build(docker): use liara npm mirror registry
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-30 09:42:14 +03:30
kavehhn 9ec5e22138 feat(dashboard): fix logout flow and redesign executive dashboard with real live data, charts and feeds
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-30 09:29:26 +03:30
kavehhn b0980f1448 feat(property): replace polling autosave with change-driven debounce and compact status indicator
deploy-chabokan / deploy (push) Canceled after 0s
Autosave previously ran on a fixed setInterval regardless of activity. It now
debounces a save shortly after the user stops editing, with a max-wait safety
net for long uninterrupted typing sessions, so it only does work when there
are actual changes. Also replaces the bulky bottom-right status popup with a
very small fixed pill (idle/pending/saving/saved/error) that stays visible
while scrolling.
2026-08-19 08:12:25 +03:30
kavehhn a155cdd481 fix(property): improve property save payload and error handling
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 07:46:20 +03:30
kavehhn 30c8149008 feat(property): add save toasts, background autosave with bottom-right status popup, and configurable autosave interval in settings
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 07:27:09 +03:30
kavehhn 0503845786 fix(property): fix persian months dropdown labels and values in registration date
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 07:17:42 +03:30
kavehhn 15dc77a76b fix(property): fix edit property loading, map event recursion, and category sync
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 07:13:32 +03:30
kavehhn 3723f801e7 fix(dashboard): fix properties freeze, category/feature list views, and auth interceptor
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 07:08:43 +03:30
kavehhn b81a85f672 feat(settings): add database seeder section with one-time execution and lock mechanism
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 06:50:01 +03:30
kavehhn 001ab07485 feat(property): add Iranian date fields with Date conversion and fix edit coordinates/map loading
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 06:45:55 +03:30
kavehhn e2aa60c54c fix(property): load signed urls for image gallery and strip query params on save
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 06:30:07 +03:30
kavehhn b6fde3e541 feat: add loading screens, skeleton states, and table loaders across all list pages
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 06:02:20 +03:30
kavehhn 859a86f1a5 feat: add search settings page in dashboard, sidebar menu, and MongoDB search integration
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 05:50:18 +03:30
kavehhn cf7e1ee45c fix(uploader): register FilePond component at module level and restore proper dropzone styling
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 05:14:03 +03:30
kavehhn ba54647837 fix(uploader): upload to temp bucket and fix initialFiles reactivity to prevent file vanishing
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 04:58:19 +03:30
kavehhn 36f2c200a1 fix(gallery): add ImageGallery to property create/edit page and modernize gallery UI
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 04:49:46 +03:30
kavehhn 119a6950f0 fix(uploader): resolve upload endpoint URL and update ImageUploader props in CreateProperty
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 04:35:49 +03:30
kavehhn a6ece05199 feat: add interactive PropertyMap component and Airbnb-style split view to ListProperties
deploy-chabokan / deploy (push) Canceled after 0s
2026-08-19 04:23:18 +03:30
52 changed files with 7403 additions and 2073 deletions
+3
View File
@@ -6,6 +6,9 @@ WORKDIR /app
# Copy dependency definitions # Copy dependency definitions
COPY package*.json ./ COPY package*.json ./
# Configure Liara npm mirror registry
RUN npm config set registry https://package-mirror.liara.ir/repository/npm/ --global
# Install all dependencies (including devDependencies required for Vite build) # Install all dependencies (including devDependencies required for Vite build)
RUN npm ci RUN npm ci
+7
View File
@@ -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",
+1
View File
@@ -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",
+32 -2
View File
@@ -1,6 +1,36 @@
<script setup> <script setup>
import axios from 'axios' import axios from 'axios';
axios.defaults.baseURL = import.meta.env.VITE_API_URL import Cookies from 'js-cookie';
axios.defaults.baseURL = import.meta.env.VITE_API_URL;
axios.defaults.withCredentials = true;
// Global Axios Request Interceptor for Authentication
axios.interceptors.request.use(
(config) => {
config.withCredentials = true;
// Resolve token from Cookies, localStorage, or document.cookie
let token = Cookies.get('bm-token') || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
if (!token && typeof document !== 'undefined') {
token = document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1];
}
if (token && token !== 'undefined' && token !== 'null' && token.trim() !== '') {
config.headers = config.headers || {};
config.headers.Authorization = `Bearer ${token.trim()}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
</script> </script>
<template> <template>
+114
View File
@@ -0,0 +1,114 @@
<script setup>
defineProps({
type: {
type: String,
default: 'table' // 'table' | 'cards' | 'split' | 'spinner'
},
rows: {
type: Number,
default: 6
},
hasImage: {
type: Boolean,
default: false
},
showHeader: {
type: Boolean,
default: false
},
message: {
type: String,
default: 'در حال بارگذاری اطلاعات...'
}
});
</script>
<template>
<div class="list-loading-container w-full animate-pulse">
<!-- Optional Top Header Shimmer -->
<div v-if="showHeader" class="flex flex-wrap items-center justify-between gap-4 mb-4">
<Skeleton width="12rem" height="2rem" class="rounded-xl" />
<Skeleton width="16rem" height="2.5rem" class="rounded-xl" />
</div>
<!-- ── TABLE SKELETON ── -->
<div v-if="type === 'table'" class="rounded-2xl border border-surface-200 dark:border-surface-700 overflow-hidden bg-surface-0 dark:bg-surface-900 shadow-sm">
<!-- Table Header Bar -->
<div class="p-4 bg-surface-50 dark:bg-surface-800/60 border-b border-surface-200 dark:border-surface-700 flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-3">
<Skeleton width="2rem" height="2rem" class="rounded-lg" />
<Skeleton width="10rem" height="1.5rem" class="rounded-lg" />
</div>
<Skeleton width="14rem" height="2.25rem" class="rounded-xl" />
</div>
<!-- Table Rows -->
<div class="divide-y divide-surface-100 dark:divide-surface-800">
<div v-for="r in rows" :key="r" class="p-4 flex items-center gap-4">
<Skeleton width="1.75rem" height="1.75rem" class="rounded-md shrink-0" />
<Skeleton v-if="hasImage" width="3.5rem" height="3.5rem" class="rounded-xl shrink-0" />
<div class="flex-1 grid grid-cols-2 sm:grid-cols-4 gap-4 items-center">
<Skeleton width="75%" height="1.25rem" class="rounded-md" />
<Skeleton width="55%" height="1.25rem" class="rounded-md" />
<Skeleton width="65%" height="1.25rem" class="rounded-md hidden sm:block" />
<Skeleton width="45%" height="1.25rem" class="rounded-md hidden sm:block" />
</div>
<div class="flex items-center gap-2 shrink-0">
<Skeleton width="2.25rem" height="2.25rem" class="rounded-full" />
<Skeleton width="2.25rem" height="2.25rem" class="rounded-full" />
</div>
</div>
</div>
</div>
<!-- ── CARDS GRID SKELETON ── -->
<div v-else-if="type === 'cards'" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<div v-for="c in rows" :key="c" class="card p-4 rounded-2xl border border-surface-200 dark:border-surface-700 bg-surface-0 dark:bg-surface-900 shadow-sm flex flex-col gap-3">
<Skeleton width="100%" height="12rem" class="rounded-xl" />
<div class="flex items-center justify-between gap-2 mt-1">
<Skeleton width="60%" height="1.5rem" class="rounded-md" />
<Skeleton width="25%" height="1.25rem" class="rounded-full" />
</div>
<Skeleton width="40%" height="1rem" class="rounded-md" />
<div class="flex items-center justify-between pt-3 border-t border-surface-100 dark:border-surface-800 mt-auto">
<Skeleton width="30%" height="1.25rem" class="rounded-md" />
<Skeleton width="2rem" height="2rem" class="rounded-lg" />
</div>
</div>
</div>
<!-- ── SPLIT VIEW SKELETON (Cards + Map Placeholder) ── -->
<div v-else-if="type === 'split'" class="grid grid-cols-1 lg:grid-cols-12 gap-6 h-[750px]">
<!-- Left Cards list skeleton -->
<div class="lg:col-span-7 flex flex-col gap-4 overflow-hidden">
<div class="flex items-center justify-between mb-2">
<Skeleton width="10rem" height="1.5rem" class="rounded-md" />
<Skeleton width="6rem" height="1.25rem" class="rounded-md" />
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div v-for="i in 4" :key="i" class="p-4 rounded-2xl border border-surface-200 dark:border-surface-700 bg-surface-0 dark:bg-surface-900 shadow-sm flex flex-col gap-3">
<Skeleton width="100%" height="10rem" class="rounded-xl" />
<Skeleton width="70%" height="1.25rem" class="rounded-md" />
<Skeleton width="45%" height="1rem" class="rounded-md" />
<div class="flex justify-between items-center pt-2 border-t border-surface-100 dark:border-surface-800">
<Skeleton width="35%" height="1rem" class="rounded-md" />
<Skeleton width="2rem" height="2rem" class="rounded-lg" />
</div>
</div>
</div>
</div>
<!-- Right Map skeleton -->
<div class="lg:col-span-5 h-full rounded-2xl border border-surface-200 dark:border-surface-700 overflow-hidden bg-surface-100 dark:bg-surface-800 flex flex-col items-center justify-center p-8 text-center">
<i class="pi pi-map text-5xl text-surface-400 dark:text-surface-600 mb-4 animate-bounce"></i>
<Skeleton width="12rem" height="1.5rem" class="rounded-md mb-2" />
<Skeleton width="8rem" height="1rem" class="rounded-md" />
</div>
</div>
<!-- ── SPINNER / OVERLAY SKELETON ── -->
<div v-else class="flex flex-col items-center justify-center py-16 gap-4">
<ProgressSpinner style="width: 48px; height: 48px" strokeWidth="4" />
<span class="text-sm font-medium text-surface-500 dark:text-surface-400">{{ message }}</span>
</div>
</div>
</template>
+53 -74
View File
@@ -1,93 +1,72 @@
<!-- LoadingCheck.vue --> <!-- 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: {
type: Boolean, type: Boolean,
default: false default: false
}, },
size: { size: {
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',
strokeDashoffset: props.isComplete ? '60' : String(dashOffset.value), strokeDashoffset: props.isComplete ? '60' : String(dashOffset.value),
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,
strokeDasharray: 20, strokeDasharray: 20,
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>
@@ -0,0 +1,121 @@
<script setup>
import { useLayout } from '@/layout/composables/layout';
import { computed, onMounted, ref, watch } from 'vue';
const props = defineProps({
statusStats: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
}
});
const { isDarkTheme } = useLayout();
const chartData = ref(null);
const chartOptions = ref(null);
const defaultColors = ['#10b981', '#3b82f6', '#f59e0b', '#8b5cf6', '#ec4899', '#06b6d4', '#64748b'];
function updateChart() {
const statuses = props.statusStats || [];
const labels = statuses.length > 0 ? statuses.map((s) => s.name) : ['نامشخص'];
const data = statuses.length > 0 ? statuses.map((s) => s.count) : [0];
const colors = statuses.map((s, i) => s.color || defaultColors[i % defaultColors.length]);
chartData.value = {
labels: labels,
datasets: [
{
data: data,
backgroundColor: colors,
borderWidth: 2,
borderColor: isDarkTheme.value ? '#18181b' : '#ffffff'
}
]
};
chartOptions.value = {
maintainAspectRatio: false,
responsive: true,
plugins: {
legend: {
position: 'bottom',
rtl: true,
labels: {
usePointStyle: true,
pointStyle: 'circle',
padding: 16,
font: {
family: 'Estedad',
size: 12
}
}
},
tooltip: {
rtl: true,
titleFont: { family: 'Estedad', size: 13 },
bodyFont: { family: 'Estedad', size: 12 },
callbacks: {
label: function (context) {
const count = context.parsed;
const statusItem = statuses[context.dataIndex];
const pct = statusItem?.percentage !== undefined ? ` (${statusItem.percentage}٪)` : '';
return `${context.label}: ${Number(count).toLocaleString('fa-IR')} ملک${pct}`;
}
}
}
},
cutout: '65%'
};
}
watch(
() => props.statusStats,
() => {
updateChart();
},
{ deep: true }
);
watch(isDarkTheme, () => {
updateChart();
});
onMounted(() => {
updateChart();
});
</script>
<template>
<div class="card h-full p-5 border border-surface-200 dark:border-surface-800 rounded-2xl bg-surface-0 dark:bg-surface-900">
<div class="flex justify-between items-center mb-4">
<div>
<h3 class="font-bold text-lg text-surface-900 dark:text-surface-0 mb-1">وضعیت اسناد و املاک</h3>
<p class="text-xs text-muted-color">تفکیک املاک بر اساس وضعیت سند و مالکیت</p>
</div>
<div class="flex items-center gap-2">
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-emerald-50 dark:bg-emerald-950/40 text-emerald-600 dark:text-emerald-400">
<i class="pi pi-check-circle text-[10px]"></i>
وضعیت‌ها
</span>
</div>
</div>
<div v-if="loading" class="h-72 flex items-center justify-center">
<i class="pi pi-spin pi-spinner text-primary text-3xl"></i>
</div>
<div v-else-if="!statusStats || statusStats.length === 0" class="h-72 flex flex-col items-center justify-center text-muted-color">
<i class="pi pi-chart-pie text-4xl mb-2 opacity-40"></i>
<span class="text-sm">اطلاعات وضعیت املاک ثبت نشده است</span>
</div>
<div v-else class="h-72">
<Chart type="doughnut" :data="chartData" :options="chartOptions" class="h-full w-full" />
</div>
</div>
</template>
@@ -0,0 +1,213 @@
<script setup>
import { useRouter } from 'vue-router';
const router = useRouter();
const actions = [
{
title: 'ثبت ملک جدید',
description: 'افزودن زمین، ساختمان یا آپارتمان',
icon: 'pi pi-file-plus',
colorClass: 'icon-blue',
route: '/property/create-property'
},
{
title: 'ثبت تراکنش انبار',
description: 'ثبت ورود یا خروج مصالح و اقلام',
icon: 'pi pi-plus-circle',
colorClass: 'icon-emerald',
route: '/warehouse-items/create-transaction'
},
{
title: 'فهرست املاک',
description: 'جستجو، فیلتر و مدیریت اسناد',
icon: 'pi pi-list',
colorClass: 'icon-purple',
route: '/property/list-properties'
},
{
title: 'اقلام و موجودی انبار',
description: 'مشاهده کاردکس و موجودی انبارها',
icon: 'pi pi-box',
colorClass: 'icon-amber',
route: '/warehouse-items/list'
}
];
function navigate(route) {
if (route) {
router.push(route);
}
}
</script>
<template>
<div class="card p-5 border border-surface-200 dark:border-surface-800 rounded-2xl bg-surface-0 dark:bg-surface-900">
<div class="flex justify-between items-center mb-4">
<div>
<h3 class="font-bold text-lg text-surface-900 dark:text-surface-0 mb-1">دسترسی و عملیات سریع</h3>
<p class="text-xs text-muted-color">میانبرهای پرکاربرد برای تسهیل امور مدیریت و ثبت</p>
</div>
<div class="flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-950/40 px-2.5 py-1 rounded-full font-medium">
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
سیستم آنلاین
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3.5">
<div v-for="item in actions" :key="item.title" @click="navigate(item.route)" class="quick-action-card group">
<div :class="['action-icon-box', item.colorClass]">
<i :class="[item.icon, 'text-lg']"></i>
</div>
<div class="action-text-wrap truncate">
<h4 class="action-title">
{{ item.title }}
</h4>
<p class="action-desc">
{{ item.description }}
</p>
</div>
<div class="action-arrow">
<i class="pi pi-arrow-left text-xs"></i>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.quick-action-card {
position: relative;
padding: 0.95rem 1rem;
border-radius: 14px;
border: 1px solid #e2e8f0;
background: #ffffff;
display: flex;
align-items: center;
gap: 0.875rem;
cursor: pointer;
transition: all 0.22s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
font-family: 'Estedad', sans-serif !important;
}
.quick-action-card:hover {
transform: translateY(-2px);
border-color: #6366f1;
background: #f8fafc;
box-shadow: 0 6px 18px rgba(99, 102, 241, 0.12);
}
.action-icon-box {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.08);
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.quick-action-card:hover .action-icon-box {
transform: scale(1.08);
}
.action-text-wrap {
flex: 1;
min-width: 0;
}
.action-title {
font-size: 13.5px;
font-weight: 700;
color: #0f172a;
margin: 0;
line-height: 1.4;
transition: color 0.2s ease;
}
.quick-action-card:hover .action-title {
color: #4f46e5;
}
.action-desc {
font-size: 11px;
color: #64748b;
margin-top: 2px;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.action-arrow {
opacity: 0;
transform: translateX(4px);
color: #94a3b8;
transition: all 0.2s ease;
}
.quick-action-card:hover .action-arrow {
opacity: 1;
transform: translateX(0);
color: #4f46e5;
}
/* ── Gradient Icon Box Styles ── */
.icon-blue {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: #ffffff;
}
.icon-emerald {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: #ffffff;
}
.icon-purple {
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
color: #ffffff;
}
.icon-amber {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
color: #ffffff;
}
/* ── Dark Mode Styles (via .app-dark selector) ── */
:global(.app-dark) .quick-action-card {
background: #18181b;
border-color: #27272a;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}
:global(.app-dark) .quick-action-card:hover {
background: #27272a;
border-color: #6366f1;
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.45),
0 0 0 1px rgba(99, 102, 241, 0.3);
}
:global(.app-dark) .action-title {
color: #f8fafc;
}
:global(.app-dark) .quick-action-card:hover .action-title {
color: #a5b4fc;
}
:global(.app-dark) .action-desc {
color: #94a3b8;
}
:global(.app-dark) .action-arrow {
color: #64748b;
}
:global(.app-dark) .quick-action-card:hover .action-arrow {
color: #a5b4fc;
}
</style>
+112 -27
View File
@@ -1,40 +1,125 @@
<script setup> <script setup>
import { onMounted, ref } from 'vue';
import { PropertyService } from '@/service/PropertyService';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { parseDateToJalali } from '@/utils/jalali';
const properties = ref(null); const props = defineProps({
properties: {
onMounted(() => { type: Array,
PropertyService.getAllProperties().then((data) => (properties.value = data)); default: () => []
},
loading: {
type: Boolean,
default: false
}
}); });
const router = useRouter(); const router = useRouter();
function editProperty(propertyId) { function editProperty(propertyId) {
router.push('/property/edit-property/' + propertyId); if (propertyId) {
router.push('/property/edit-property/' + propertyId);
}
} }
function viewAllProperties() {
router.push('/property/list-properties');
}
function formatJalaliDate(dateVal) {
if (!dateVal) return '—';
const j = parseDateToJalali(dateVal);
if (!j) return '—';
return `${j.jy}/${String(j.jm).padStart(2, '0')}/${String(j.jd).padStart(2, '0')}`;
}
const formatNumber = (num) => {
if (num === null || num === undefined) return '۰';
return Number(num).toLocaleString('fa-IR');
};
</script> </script>
<template> <template>
<div class="card"> <div class="card h-full p-5 border border-surface-200 dark:border-surface-800 rounded-2xl bg-surface-0 dark:bg-surface-900">
<div class="font-semibold text-xl mb-4">آخرین املاک افزوده شده</div> <div class="flex justify-between items-center mb-4">
<DataTable :value="properties" :rows="5" :paginator="true" responsiveLayout="scroll"> <div>
<Column style="min-width: 15%" header="تصویر"> <h3 class="font-bold text-lg text-surface-900 dark:text-surface-0 mb-1">آخرین املاک ثبت شده</h3>
<template #body="slotProps"> <p class="text-xs text-muted-color">جدیدترین املاک و دارایی‌های ثبت شده در سیستم</p>
<img :src="`${slotProps.data.mainImageUrl || slotProps.data.mainImageUrls?.[0]}`" :alt="slotProps.data.name" width="50" class="shadow" /> </div>
</template> <Button label="مشاهده همه" icon="pi pi-arrow-left" iconPos="right" size="small" text class="text-xs" @click="viewAllProperties" />
</Column> </div>
<Column field="name" header="نام" :sortable="true" style="min-width: 35%"></Column>
<Column field="category" header="دسته بندی" :sortable="true" style="min-width: 30%"> <div v-if="loading" class="py-12 flex items-center justify-center">
<template #body="slotProps"> <i class="pi pi-spin pi-spinner text-primary text-2xl"></i>
{{ slotProps.data.category?.nativeName }} </div>
</template>
</Column> <div v-else-if="!properties || properties.length === 0" class="py-12 flex flex-col items-center justify-center text-muted-color">
<Column style="min-width: 15%" header="نمایش"> <i class="pi pi-building text-4xl mb-2 opacity-40"></i>
<template #body="slotProps"> <span class="text-sm">ملکی ثبت نشده است</span>
<Button icon="pi pi-search" type="button" class="p-button-text" @click="editProperty(slotProps.data._id)"></Button> <router-link to="/property/create-property" class="mt-3">
</template> <Button label="ثبت ملک جدید" icon="pi pi-plus" size="small" />
</Column> </router-link>
</DataTable> </div>
<div v-else class="overflow-x-auto">
<DataTable :value="properties" responsiveLayout="scroll" class="p-datatable-sm">
<!-- Image -->
<Column header="تصویر" style="width: 60px">
<template #body="slotProps">
<div class="w-11 h-11 rounded-lg overflow-hidden border border-surface-200 dark:border-surface-700 bg-surface-100 dark:bg-surface-800 flex items-center justify-center">
<img :src="slotProps.data.imageUrl" :alt="slotProps.data.name" class="w-full h-full object-cover" @error="(e) => (e.target.src = 'https://cloud.mbmanager.ir/images/item-no-image.png')" />
</div>
</template>
</Column>
<!-- Code & Name -->
<Column header="نام و کد ملک" style="min-width: 160px">
<template #body="slotProps">
<div class="flex flex-col">
<span class="font-semibold text-surface-900 dark:text-surface-100 text-sm hover:text-primary cursor-pointer" @click="editProperty(slotProps.data._id)">
{{ slotProps.data.name }}
</span>
<span class="text-[11px] text-muted-color font-mono"> کد: {{ slotProps.data.propertyCode }} </span>
</div>
</template>
</Column>
<!-- Category -->
<Column header="دسته‌بندی" style="min-width: 110px">
<template #body="slotProps">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface-100 dark:bg-surface-800 text-surface-700 dark:text-surface-300">
{{ slotProps.data.category }}
</span>
</template>
</Column>
<!-- Status / Owner -->
<Column header="مشخصات" style="min-width: 130px">
<template #body="slotProps">
<div class="flex flex-col text-xs gap-0.5">
<span class="text-surface-600 dark:text-surface-400"
>مالک: <b class="text-surface-800 dark:text-surface-200">{{ slotProps.data.ownerName }}</b></span
>
<span class="text-muted-color text-[11px]">مساحت: {{ formatNumber(slotProps.data.area) }} م² ({{ slotProps.data.dong }} دانگ)</span>
</div>
</template>
</Column>
<!-- Date -->
<Column header="تاریخ ثبت" style="min-width: 95px">
<template #body="slotProps">
<span class="text-xs text-muted-color font-mono">
{{ formatJalaliDate(slotProps.data.createdAt) }}
</span>
</template>
</Column>
<!-- Action -->
<Column header="عملیات" style="width: 50px">
<template #body="slotProps">
<Button icon="pi pi-arrow-left" class="p-button-rounded p-button-text p-button-sm !w-8 !h-8" v-tooltip.top="'مشاهده و ویرایش'" @click="editProperty(slotProps.data._id)" />
</template>
</Column>
</DataTable>
</div>
</div> </div>
</template> </template>
@@ -1,95 +1,146 @@
<script setup> <script setup>
import { useLayout } from '@/layout/composables/layout'; import { useLayout } from '@/layout/composables/layout';
import { onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
const props = defineProps({
categoryStats: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
}
});
const { getPrimary, getSurface, isDarkTheme } = useLayout(); const { getPrimary, getSurface, isDarkTheme } = useLayout();
const chartData = ref(null); const chartData = ref(null);
const chartOptions = ref(null); const chartOptions = ref(null);
function setChartData() { const palette = ['#3b82f6', '#10b981', '#f59e0b', '#ec4899', '#8b5cf6', '#06b6d4', '#f97316', '#6366f1', '#14b8a6', '#84cc16'];
const documentStyle = getComputedStyle(document.documentElement);
return { function updateChart() {
labels: ['زمین', 'ساختمان'], const documentStyle = getComputedStyle(document.documentElement);
const textMutedColor = documentStyle.getPropertyValue('--text-color-secondary') || '#6b7280';
const borderColor = documentStyle.getPropertyValue('--surface-border') || '#e5e7eb';
const categories = props.categoryStats || [];
const labels = categories.length > 0 ? categories.map((c) => c.name) : ['بدون دسته‌بندی'];
const data = categories.length > 0 ? categories.map((c) => c.count) : [0];
const backgroundColors = labels.map((_, i) => palette[i % palette.length]);
chartData.value = {
labels: labels,
datasets: [ datasets: [
{ {
type: 'bar',
label: 'تعداد املاک', label: 'تعداد املاک',
backgroundColor: documentStyle.getPropertyValue('--p-primary-400'), data: data,
font: { backgroundColor: backgroundColors,
family: 'Estedad', borderRadius: 8,
size: 14 barThickness: 28,
}, maxBarThickness: 36
data: [18, 13],
barThickness: 32
} }
] ]
}; };
}
function setChartOptions() { chartOptions.value = {
const documentStyle = getComputedStyle(document.documentElement);
const borderColor = documentStyle.getPropertyValue('--surface-border');
const textMutedColor = documentStyle.getPropertyValue('--text-color-secondary');
return {
maintainAspectRatio: false, maintainAspectRatio: false,
aspectRatio: 0.8, responsive: true,
plugins: {
legend: {
display: false
},
tooltip: {
rtl: true,
titleFont: { family: 'Estedad', size: 13 },
bodyFont: { family: 'Estedad', size: 12 },
padding: 10,
callbacks: {
label: function (context) {
const count = context.parsed.y !== undefined ? context.parsed.y : context.parsed;
const cat = categories[context.dataIndex];
const areaText = cat?.totalArea ? ` (${Number(cat.totalArea).toLocaleString('fa-IR')} م²)` : '';
return `تعداد: ${Number(count).toLocaleString('fa-IR')} ملک${areaText}`;
}
}
}
},
scales: { scales: {
x: { x: {
stacked: true,
ticks: { ticks: {
color: textMutedColor, color: textMutedColor,
font: { font: {
family: 'Estedad', family: 'Estedad',
size: 14 size: 13
} }
}, },
grid: { grid: {
color: 'transparent', display: false
borderColor: 'transparent'
} }
}, },
y: { y: {
stacked: true, beginAtZero: true,
ticks: { ticks: {
precision: 0,
color: textMutedColor, color: textMutedColor,
font: { font: {
family: 'Estedad', family: 'Estedad',
size: 14 size: 12
} }
}, },
grid: { grid: {
color: borderColor, color: borderColor,
borderColor: 'transparent', drawBorder: false
drawTicks: false
} }
} }
} }
}; };
} }
watch(
() => props.categoryStats,
() => {
updateChart();
},
{ deep: true }
);
watch([getPrimary, getSurface, isDarkTheme], () => { watch([getPrimary, getSurface, isDarkTheme], () => {
chartData.value = setChartData(); updateChart();
chartOptions.value = setChartOptions();
}); });
onMounted(() => { onMounted(() => {
chartData.value = setChartData(); updateChart();
chartOptions.value = setChartOptions();
}); });
</script> </script>
<template> <template>
<div class="card"> <div class="card h-full p-5 border border-surface-200 dark:border-surface-800 rounded-2xl bg-surface-0 dark:bg-surface-900">
<div class="font-semibold text-xl mb-4">تقسیم بندی املاک</div> <div class="flex justify-between items-center mb-4">
<Chart type="bar" :data="chartData" :options="chartOptions" class="h-80" /> <div>
<h3 class="font-bold text-lg text-surface-900 dark:text-surface-0 mb-1">تفکیک املاک بر اساس دسته‌بندی</h3>
<p class="text-xs text-muted-color">توزیع و تعداد املاک ثبت شده در دسته‌بندی‌های مختلف سامانه</p>
</div>
<div class="flex items-center gap-2">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-primary-50 dark:bg-primary-950/40 text-primary">
<i class="pi pi-th-large text-[10px]"></i>
{{ categoryStats?.length || 0 }} دسته‌بندی
</span>
</div>
</div>
<div v-if="loading" class="h-72 flex items-center justify-center">
<i class="pi pi-spin pi-spinner text-primary text-3xl"></i>
</div>
<div v-else-if="!categoryStats || categoryStats.length === 0" class="h-72 flex flex-col items-center justify-center text-muted-color">
<i class="pi pi-chart-bar text-4xl mb-2 opacity-40"></i>
<span class="text-sm">اطلاعاتی برای نمایش وجود ندارد</span>
</div>
<div v-else class="h-72">
<Chart type="bar" :data="chartData" :options="chartOptions" class="h-full w-full" />
</div>
</div> </div>
</template> </template>
<style>
.card {
font-family: 'Estedad', sans-serif !important;
}
</style>
+108 -21
View File
@@ -1,41 +1,128 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
stats: {
type: Object,
default: () => ({})
},
loading: {
type: Boolean,
default: false
}
});
const formatNumber = (num) => {
if (num === null || num === undefined) return '۰';
return Number(num).toLocaleString('fa-IR');
};
const overview = computed(() => props.stats?.overview || {});
const warehouseFlow = computed(() => props.stats?.warehouseFlow || {});
</script>
<template> <template>
<div class="col-span-12 lg:col-span-6 xl:col-span-3"> <!-- Total Properties Card -->
<div class="card mb-0"> <div class="col-span-12 sm:col-span-6 lg:col-span-6 xl:col-span-3">
<div class="flex justify-between mb-4"> <div
class="card mb-0 h-full p-5 border border-surface-200 dark:border-surface-800 hover:shadow-lg transition-all duration-300 rounded-2xl relative overflow-hidden bg-gradient-to-br from-surface-0 to-surface-50 dark:from-surface-900 dark:to-surface-950"
>
<div class="absolute -right-6 -bottom-6 w-24 h-24 bg-blue-500/5 dark:bg-blue-500/10 rounded-full blur-xl pointer-events-none"></div>
<div class="flex justify-between items-start mb-3">
<div> <div>
<span class="block text-muted-color font-medium mb-4">تعداد املاک</span> <span class="block text-muted-color font-medium text-sm mb-1">املاک و مستغلات</span>
<div class="text-surface-900 dark:text-surface-0 font-medium text-xl">100</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">{{ 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-400/10 rounded-border" style="width: 2.5rem; height: 2.5rem"> <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-shopping-cart text-blue-500 !text-xl"></i> <i class="pi pi-building text-xl"></i>
</div> </div>
</div> </div>
<div class="flex items-center gap-2 text-xs font-medium text-surface-600 dark:text-surface-400 pt-2 border-t border-surface-100 dark:border-surface-800">
<i class="pi pi-map text-blue-500"></i>
<span
>مساحت کل: <b class="text-surface-900 dark:text-surface-100">{{ formatNumber(overview.totalArea) }}</b> م²</span
>
</div>
</div> </div>
</div> </div>
<div class="col-span-12 lg:col-span-6 xl:col-span-3">
<div class="card mb-0"> <!-- Warehouse Items Card -->
<div class="flex justify-between mb-4"> <div class="col-span-12 sm:col-span-6 lg:col-span-6 xl:col-span-3">
<div
class="card mb-0 h-full p-5 border border-surface-200 dark:border-surface-800 hover:shadow-lg transition-all duration-300 rounded-2xl relative overflow-hidden bg-gradient-to-br from-surface-0 to-surface-50 dark:from-surface-900 dark:to-surface-950"
>
<div class="absolute -right-6 -bottom-6 w-24 h-24 bg-amber-500/5 dark:bg-amber-500/10 rounded-full blur-xl pointer-events-none"></div>
<div class="flex justify-between items-start mb-3">
<div> <div>
<span class="block text-muted-color font-medium mb-4">ارز ها</span> <span class="block text-muted-color font-medium text-sm mb-1">اقلام انبار</span>
<div class="text-surface-900 dark:text-surface-0 font-medium text-xl">2800 دلار</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">{{ formatNumber(overview.totalWarehouseItems) }} <span class="text-xs font-normal text-muted-color">قلم کالا</span></div>
</div> </div>
<div class="flex items-center justify-center bg-orange-100 dark:bg-orange-400/10 rounded-border" style="width: 2.5rem; height: 2.5rem"> <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-dollar text-orange-500 !text-xl"></i> <i class="pi pi-box text-xl"></i>
</div> </div>
</div> </div>
<div class="flex items-center gap-2 text-xs font-medium text-surface-600 dark:text-surface-400 pt-2 border-t border-surface-100 dark:border-surface-800">
<i class="pi pi-warehouse text-amber-500"></i>
<span
>مستقر در <b class="text-surface-900 dark:text-surface-100">{{ formatNumber(overview.totalWarehouses) }}</b> انبار فعال</span
>
</div>
</div> </div>
</div> </div>
<div class="col-span-12 lg:col-span-6 xl:col-span-3">
<div class="card mb-0"> <!-- Warehouse Transactions Card -->
<div class="flex justify-between mb-4"> <div class="col-span-12 sm:col-span-6 lg:col-span-6 xl:col-span-3">
<div
class="card mb-0 h-full p-5 border border-surface-200 dark:border-surface-800 hover:shadow-lg transition-all duration-300 rounded-2xl relative overflow-hidden bg-gradient-to-br from-surface-0 to-surface-50 dark:from-surface-900 dark:to-surface-950"
>
<div class="absolute -right-6 -bottom-6 w-24 h-24 bg-emerald-500/5 dark:bg-emerald-500/10 rounded-full blur-xl pointer-events-none"></div>
<div class="flex justify-between items-start mb-3">
<div> <div>
<span class="block text-muted-color font-medium mb-4">تعداد کاربران</span> <span class="block text-muted-color font-medium text-sm mb-1">گردش انبارداری</span>
<div class="text-surface-900 dark:text-surface-0 font-medium text-xl">3 عدد</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">{{ formatNumber(overview.totalWarehouseTransactions) }} <span class="text-xs font-normal text-muted-color">تراکنش</span></div>
</div> </div>
<div class="flex items-center justify-center bg-cyan-100 dark:bg-cyan-400/10 rounded-border" style="width: 2.5rem; height: 2.5rem"> <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-users text-cyan-500 !text-xl"></i> <i class="pi pi-sync text-xl"></i>
</div> </div>
</div> </div>
<div class="flex items-center justify-between text-xs font-medium text-surface-600 dark:text-surface-400 pt-2 border-t border-surface-100 dark:border-surface-800">
<span class="text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
<i class="pi pi-arrow-down-left"></i>
{{ formatNumber(warehouseFlow.inCount) }} ورود
</span>
<span class="text-rose-600 dark:text-rose-400 flex items-center gap-1">
<i class="pi pi-arrow-up-right"></i>
{{ formatNumber(warehouseFlow.outCount) }} خروج
</span>
</div>
</div>
</div>
<!-- Active Users & Roles Card -->
<div class="col-span-12 sm:col-span-6 lg:col-span-6 xl:col-span-3">
<div
class="card mb-0 h-full p-5 border border-surface-200 dark:border-surface-800 hover:shadow-lg transition-all duration-300 rounded-2xl relative overflow-hidden bg-gradient-to-br from-surface-0 to-surface-50 dark:from-surface-900 dark:to-surface-950"
>
<div class="absolute -right-6 -bottom-6 w-24 h-24 bg-purple-500/5 dark:bg-purple-500/10 rounded-full blur-xl pointer-events-none"></div>
<div class="flex justify-between items-start mb-3">
<div>
<span class="block text-muted-color font-medium text-sm mb-1">کاربران سیستم</span>
<div v-if="loading" class="h-8 w-20 bg-surface-200 dark:bg-surface-700 animate-pulse rounded my-1"></div>
<div v-else class="text-surface-900 dark:text-surface-0 font-bold text-2xl tracking-tight">{{ formatNumber(overview.totalUsers) }} <span class="text-xs font-normal text-muted-color">کاربر</span></div>
</div>
<div class="flex items-center justify-center bg-purple-100 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400 rounded-xl shadow-sm" style="width: 3rem; height: 3rem">
<i class="pi pi-users text-xl"></i>
</div>
</div>
<div class="flex items-center gap-2 text-xs font-medium text-surface-600 dark:text-surface-400 pt-2 border-t border-surface-100 dark:border-surface-800">
<i class="pi pi-shield text-purple-500"></i>
<span
>تعریف شده در <b class="text-surface-900 dark:text-surface-100">{{ formatNumber(overview.totalRoles) }}</b> نقش سازمانی</span
>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -0,0 +1,142 @@
<script setup>
import { useRouter } from 'vue-router';
import { parseDateToJalali } from '@/utils/jalali';
const props = defineProps({
transactions: {
type: Array,
default: () => []
},
warehouseFlow: {
type: Object,
default: () => ({ inCount: 0, outCount: 0, inQuantity: 0, outQuantity: 0 })
},
loading: {
type: Boolean,
default: false
}
});
const router = useRouter();
function viewAllTransactions() {
router.push('/warehouse-items/transactions');
}
function createTransaction() {
router.push('/warehouse-items/create-transaction');
}
function formatJalaliDate(dateVal) {
if (!dateVal) return '—';
const j = parseDateToJalali(dateVal);
if (!j) return '—';
return `${j.jy}/${String(j.jm).padStart(2, '0')}/${String(j.jd).padStart(2, '0')}`;
}
const formatNumber = (num) => {
if (num === null || num === undefined) return '۰';
return Number(num).toLocaleString('fa-IR');
};
</script>
<template>
<div class="card h-full p-5 border border-surface-200 dark:border-surface-800 rounded-2xl bg-surface-0 dark:bg-surface-900 flex flex-col justify-between">
<div>
<div class="flex justify-between items-center mb-4">
<div>
<h3 class="font-bold text-lg text-surface-900 dark:text-surface-0 mb-1">آخرین گردش‌های انبار</h3>
<p class="text-xs text-muted-color">گزارش لحظه‌ای ثبت ورود و خروج کالا در انبارها</p>
</div>
<Button label="مشاهده همه" icon="pi pi-arrow-left" iconPos="right" size="small" text class="text-xs" @click="viewAllTransactions" />
</div>
<!-- Flow stats summary banner -->
<div class="grid grid-cols-2 gap-3 mb-4">
<div class="p-3 rounded-xl bg-emerald-50/60 dark:bg-emerald-950/20 border border-emerald-200/50 dark:border-emerald-800/30 flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-lg bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 flex items-center justify-center">
<i class="pi pi-arrow-down-left text-sm"></i>
</div>
<div>
<span class="text-[11px] text-muted-color block">مجموع ورودی</span>
<span class="text-xs font-bold text-emerald-700 dark:text-emerald-400"> {{ formatNumber(warehouseFlow.inQuantity) }} واحد </span>
</div>
</div>
<span class="text-xs font-medium text-emerald-600 dark:text-emerald-400"> {{ formatNumber(warehouseFlow.inCount) }} تراکنش </span>
</div>
<div class="p-3 rounded-xl bg-rose-50/60 dark:bg-rose-950/20 border border-rose-200/50 dark:border-rose-800/30 flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-lg bg-rose-500/10 text-rose-600 dark:text-rose-400 flex items-center justify-center">
<i class="pi pi-arrow-up-right text-sm"></i>
</div>
<div>
<span class="text-[11px] text-muted-color block">مجموع خروجی</span>
<span class="text-xs font-bold text-rose-700 dark:text-rose-400"> {{ formatNumber(warehouseFlow.outQuantity) }} واحد </span>
</div>
</div>
<span class="text-xs font-medium text-rose-600 dark:text-rose-400"> {{ formatNumber(warehouseFlow.outCount) }} تراکنش </span>
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="py-10 flex items-center justify-center">
<i class="pi pi-spin pi-spinner text-primary text-2xl"></i>
</div>
<!-- Empty -->
<div v-else-if="!transactions || transactions.length === 0" class="py-10 flex flex-col items-center justify-center text-muted-color">
<i class="pi pi-history text-4xl mb-2 opacity-40"></i>
<span class="text-sm">تراکنش انباری ثبت نشده است</span>
<Button label="ثبت اولین تراکنش" icon="pi pi-plus" size="small" class="mt-3" @click="createTransaction" />
</div>
<!-- Transactions List -->
<ul v-else class="list-none p-0 m-0 divide-y divide-surface-100 dark:divide-surface-800">
<li v-for="tx in transactions" :key="tx._id" class="py-3 flex items-center justify-between gap-3 hover:bg-surface-50 dark:hover:bg-surface-800/50 px-2 rounded-xl transition-colors duration-150">
<div class="flex items-center gap-3 min-w-0">
<div
:class="[
'w-9 h-9 rounded-xl flex items-center justify-center shrink-0 font-bold text-xs',
tx.type === 'IN' ? 'bg-emerald-100 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400' : 'bg-rose-100 dark:bg-rose-500/20 text-rose-600 dark:text-rose-400'
]"
>
<i :class="['pi text-xs', tx.type === 'IN' ? 'pi-arrow-down' : 'pi-arrow-up']"></i>
</div>
<div class="truncate">
<div class="flex items-center gap-2">
<span class="font-semibold text-surface-900 dark:text-surface-100 text-sm truncate">
{{ tx.itemName }}
</span>
<span
:class="[
'px-1.5 py-0.2 rounded text-[10px] font-bold',
tx.type === 'IN' ? 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300' : 'bg-rose-100 dark:bg-rose-900/40 text-rose-700 dark:text-rose-300'
]"
>
{{ tx.type === 'IN' ? 'ورود' : 'خروج' }}
</span>
</div>
<div class="text-[11px] text-muted-color flex items-center gap-2 mt-0.5">
<span>انبار: {{ tx.warehouseName }}</span>
<span>•</span>
<span>توسط: {{ tx.actorName }}</span>
</div>
</div>
</div>
<div class="text-left shrink-0">
<div :class="['font-bold text-sm font-mono', tx.type === 'IN' ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400']">
{{ tx.type === 'IN' ? '+' : '-' }}{{ formatNumber(tx.quantity) }} {{ tx.unit }}
</div>
<span class="text-[10px] text-muted-color font-mono block">
{{ formatJalaliDate(tx.date) }}
</span>
</div>
</li>
</ul>
</div>
</div>
</template>
+4
View File
@@ -37,6 +37,10 @@ const model = ref([
{ label: 'کاربران', icon: 'pi pi-fw pi-users', to: '/users/list-users' }, { label: 'کاربران', icon: 'pi pi-fw pi-users', to: '/users/list-users' },
{ label: 'نقش‌ها و دسترسی‌ها', icon: 'pi pi-fw pi-shield', to: '/roles/list-roles' } { label: 'نقش‌ها و دسترسی‌ها', icon: 'pi pi-fw pi-shield', to: '/roles/list-roles' }
] ]
},
{
label: 'تنظیمات سیستم',
items: [{ label: 'تنظیمات جستجو', icon: 'pi pi-fw pi-sliders-h', to: '/settings/search-settings' }]
} }
]); ]);
</script> </script>
+61 -21
View File
@@ -2,12 +2,54 @@
import { useLayout } from '@/layout/composables/layout'; import { useLayout } from '@/layout/composables/layout';
import AppConfigurator from './AppConfigurator.vue'; import AppConfigurator from './AppConfigurator.vue';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import axios from 'axios';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
const router = useRouter()
const signOut = () => { const router = useRouter();
Cookies.set('bm-token', '')
router.push('/auth/login') const signOut = async () => {
} try {
await axios.post('/api/users/logout', {}, { withCredentials: true });
} catch (e) {
console.warn('Logout API error:', e?.message || e);
}
// Comprehensive cookie cleaning
Cookies.remove('bm-token');
Cookies.remove('bm-token', { domain: '.mbmanager.ir', path: '/' });
Cookies.remove('bm-token', { path: '/' });
Cookies.remove('belonging_token');
Cookies.remove('belonging_token', { domain: '.mbmanager.ir', path: '/' });
Cookies.remove('belonging_token', { path: '/' });
Cookies.remove('jwt');
Cookies.remove('jwt', { domain: '.mbmanager.ir', path: '/' });
Cookies.remove('jwt', { path: '/' });
// Force expire raw cookies in document.cookie
if (typeof document !== 'undefined') {
const cookiesToClear = ['bm-token', 'belonging_token', 'jwt'];
const domains = ['', '; domain=.mbmanager.ir', '; domain=' + window.location.hostname];
cookiesToClear.forEach((name) => {
domains.forEach((dom) => {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/${dom}`;
});
});
}
// Clear local & session storage
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('bm-token');
localStorage.removeItem('belonging_token');
localStorage.removeItem('jwt');
}
if (typeof sessionStorage !== 'undefined') {
sessionStorage.clear();
}
delete axios.defaults.headers.common['Authorization'];
await router.replace('/auth/login');
};
const { toggleMenu, toggleDarkMode, isDarkTheme } = useLayout(); const { toggleMenu, toggleDarkMode, isDarkTheme } = useLayout();
</script> </script>
@@ -15,7 +57,7 @@ const { toggleMenu, toggleDarkMode, isDarkTheme } = useLayout();
<template> <template>
<div class="layout-topbar"> <div class="layout-topbar">
<div class="layout-topbar-logo-container"> <div class="layout-topbar-logo-container">
<button class="layout-menu-button layout-topbar-action" @click="toggleMenu"> <button class="layout-menu-button layout-topbar-action" @click="toggleMenu" title="منو">
<i class="pi pi-bars"></i> <i class="pi pi-bars"></i>
</button> </button>
<router-link to="/" class="layout-topbar-logo"> <router-link to="/" class="layout-topbar-logo">
@@ -43,7 +85,7 @@ const { toggleMenu, toggleDarkMode, isDarkTheme } = useLayout();
<div class="layout-topbar-actions"> <div class="layout-topbar-actions">
<div class="layout-config-menu"> <div class="layout-config-menu">
<button type="button" class="layout-topbar-action" @click="toggleDarkMode"> <button type="button" class="layout-topbar-action" @click="toggleDarkMode" title="تغییر حالت شب/روز">
<i :class="['pi', { 'pi-moon': isDarkTheme, 'pi-sun': !isDarkTheme }]"></i> <i :class="['pi', { 'pi-moon': isDarkTheme, 'pi-sun': !isDarkTheme }]"></i>
</button> </button>
<div class="relative"> <div class="relative">
@@ -51,6 +93,7 @@ const { toggleMenu, toggleDarkMode, isDarkTheme } = useLayout();
v-styleclass="{ selector: '@next', enterFromClass: 'hidden', enterActiveClass: 'animate-scalein', leaveToClass: 'hidden', leaveActiveClass: 'animate-fadeout', hideOnOutsideClick: true }" v-styleclass="{ selector: '@next', enterFromClass: 'hidden', enterActiveClass: 'animate-scalein', leaveToClass: 'hidden', leaveActiveClass: 'animate-fadeout', hideOnOutsideClick: true }"
type="button" type="button"
class="layout-topbar-action layout-topbar-action-highlight" class="layout-topbar-action layout-topbar-action-highlight"
title="تنظیمات تم"
> >
<i class="pi pi-palette"></i> <i class="pi pi-palette"></i>
</button> </button>
@@ -61,27 +104,24 @@ const { toggleMenu, toggleDarkMode, isDarkTheme } = useLayout();
<button <button
class="layout-topbar-menu-button layout-topbar-action" class="layout-topbar-menu-button layout-topbar-action"
v-styleclass="{ selector: '@next', enterFromClass: 'hidden', enterActiveClass: 'animate-scalein', leaveToClass: 'hidden', leaveActiveClass: 'animate-fadeout', hideOnOutsideClick: true }" v-styleclass="{ selector: '@next', enterFromClass: 'hidden', enterActiveClass: 'animate-scalein', leaveToClass: 'hidden', leaveActiveClass: 'animate-fadeout', hideOnOutsideClick: true }"
title="منوی کاربری"
> >
<i class="pi pi-ellipsis-v"></i> <i class="pi pi-ellipsis-v"></i>
</button> </button>
<div class="layout-topbar-menu hidden lg:block"> <div class="layout-topbar-menu hidden lg:block">
<div class="layout-topbar-menu-content"> <div class="layout-topbar-menu-content">
<button type="button" class="layout-topbar-action"> <router-link to="/property/create-property" class="layout-topbar-action" title="ثبت ملک جدید">
<i class="pi pi-calendar"></i> <i class="pi pi-plus-circle"></i>
<span>Calendar</span> <span>ثبت ملک</span>
</button> </router-link>
<button type="button" class="layout-topbar-action"> <router-link to="/users/list-users" class="layout-topbar-action" title="مدیریت کاربران">
<i class="pi pi-inbox"></i>
<span>Messages</span>
</button>
<button type="button" class="layout-topbar-action">
<i class="pi pi-user"></i> <i class="pi pi-user"></i>
<span>Profile</span> <span>کاربران</span>
</button> </router-link>
<button type="button" class="layout-topbar-action" @click="signOut"> <button type="button" class="layout-topbar-action text-red-500 hover:text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30" title="خروج از حساب کاربری" @click="signOut">
<i class="pi pi-power-off"></i> <i class="pi pi-power-off text-red-500"></i>
<span>Profile</span> <span class="text-red-500">خروج</span>
</button> </button>
</div> </div>
</div> </div>
+1 -1
View File
@@ -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() {
+13 -7
View File
@@ -4,7 +4,7 @@ import axios from 'axios';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
const loginGuard = async (to, from, next) => { const loginGuard = async (to, from, next) => {
const token = Cookies.get('bm-token'); const token = Cookies.get('bm-token') || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
if (!token) { if (!token) {
next('/auth/login'); // Redirect to log in if no token next('/auth/login'); // Redirect to log in if no token
@@ -51,7 +51,7 @@ const router = createRouter({
{ {
path: '/property/list-properties', path: '/property/list-properties',
name: 'list-properties', name: 'list-properties',
component: () => import('@/views/pages/Property/CreateProperty.vue') component: () => import('@/views/pages/Property/ListProperties.vue')
}, },
// Aliases for backward compatibility // Aliases for backward compatibility
{ {
@@ -69,11 +69,6 @@ const router = createRouter({
name: 'list-belongings', name: 'list-belongings',
component: () => import('@/views/pages/Property/ListProperties.vue') component: () => import('@/views/pages/Property/ListProperties.vue')
}, },
{
path: '/property/list-properties',
name: 'list-properties-main',
component: () => import('@/views/pages/Property/ListProperties.vue')
},
{ {
path: '/categories/list-categories', path: '/categories/list-categories',
name: 'list-categories', name: 'list-categories',
@@ -126,6 +121,17 @@ const router = createRouter({
name: 'list-roles', name: 'list-roles',
component: () => import('@/views/pages/Roles/ListRoles.vue') component: () => import('@/views/pages/Roles/ListRoles.vue')
}, },
// ─── Settings ────────────────────────────────────────────────
{
path: '/settings/search-settings',
name: 'search-settings',
component: () => import('@/views/pages/Settings/SearchSettings.vue')
},
{
path: '/settings',
redirect: '/settings/search-settings'
},
{ {
path: '/uikit/formlayout', path: '/uikit/formlayout',
name: 'formlayout', name: 'formlayout',
+16 -26
View File
@@ -2,46 +2,36 @@ import axios from 'axios';
export const CategoryService = { export const CategoryService = {
getAllCategories: async () => { getAllCategories: async () => {
const config = {
method: 'get',
url: '/api/categories/admin/get-all',
withCredentials: true
};
try { try {
const response = await axios(config); const response = await axios.get('/api/categories/admin/get-all');
return response.data.data; return response.data?.data || [];
} catch (err) { } catch (err) {
console.log(err); console.error('Error in getAllCategories:', err);
return [];
} }
}, },
createCategory: async (data) => { createCategory: async (data) => {
const config = {
method: 'post',
url: '/api/categories/admin/create',
data,
withCredentials: true
};
try { try {
const response = await axios(config); const response = await axios.post('/api/categories/admin/create', data);
return 'success'; return 'success';
} catch (err) { } catch (err) {
return err.response.data.message; return err.response?.data?.message || 'خطا در ساخت دسته‌بندی';
} }
}, },
updateCategory: async (categoryId, data) => { updateCategory: async (categoryId, data) => {
const config = {
method: 'put',
url: '/api/categories/admin/update/' + categoryId,
data,
withCredentials: true
};
try { try {
const response = await axios(config); const response = await axios.patch('/api/categories/admin/update/' + categoryId, data);
return 'success'; return 'success';
} catch (err) { } catch (err) {
return err.response.data.message; return err.response?.data?.message || 'خطا در ویرایش دسته‌بندی';
}
},
deleteCategory: async (categoryId) => {
try {
await axios.delete('/api/categories/admin/delete/' + categoryId);
return 'success';
} catch (err) {
return err.response?.data?.message || 'خطا در حذف دسته‌بندی';
} }
} }
}; };
+27
View File
@@ -0,0 +1,27 @@
import axios from 'axios';
const getAuthHeader = () => {
const bmToken =
(typeof document !== 'undefined'
? document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1]
: null) || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
return bmToken && bmToken !== 'undefined' && bmToken !== 'null' ? { Authorization: `Bearer ${bmToken}` } : {};
};
export const DashboardService = {
/**
* Fetch aggregated live statistics for the dashboard
* @returns {Promise<Object>}
*/
getDashboardStats: async () => {
const response = await axios.get('/api/dashboard/stats', {
withCredentials: true,
headers: getAuthHeader()
});
return response.data?.data || response.data;
}
};
+25 -26
View File
@@ -2,46 +2,45 @@ import axios from 'axios';
export const FeatureService = { export const FeatureService = {
getAllFeatures: async () => { getAllFeatures: async () => {
const config = {
method: 'get',
url: '/api/categories/admin/get-all',
withCredentials: true
};
try { try {
const response = await axios(config); const response = await axios.get('/api/features/admin/get-all');
return response.data.data; return response.data?.data || [];
} catch (err) { } catch (err) {
console.log(err); console.error('Error in getAllFeatures:', err);
return [];
}
},
getFeaturesByCategory: async (categoryId) => {
try {
const response = await axios.get(`/api/features/admin/get-by-category/${categoryId}`);
return response.data?.data || [];
} catch (err) {
console.error('Error in getFeaturesByCategory:', err);
return [];
} }
}, },
createFeature: async (data) => { createFeature: async (data) => {
const config = {
method: 'post',
url: '/api/features/admin/create',
data,
withCredentials: true
};
try { try {
const response = await axios(config); await axios.post('/api/features/admin/create', data);
return 'success'; return 'success';
} catch (err) { } catch (err) {
return err.response.data.message; return err.response?.data?.message || 'خطا در ساخت ویژگی';
} }
}, },
updateFeature: async (featureId, data) => { updateFeature: async (featureId, data) => {
const config = {
method: 'put',
url: '/api/features/admin/update/' + featureId,
data,
withCredentials: true
};
try { try {
const response = await axios(config); await axios.put('/api/features/admin/update/' + featureId, data);
return 'success'; return 'success';
} catch (err) { } catch (err) {
return err.response.data.message; return err.response?.data?.message || 'خطا در ویرایش ویژگی';
}
},
deleteFeature: async (featureId) => {
try {
await axios.delete('/api/features/admin/delete/' + featureId);
return 'success';
} catch (err) {
return err.response?.data?.message || 'خطا در حذف ویژگی';
} }
} }
}; };
+5 -4
View File
@@ -1,10 +1,11 @@
import axios from 'axios'; import axios from 'axios';
const getAuthHeader = () => { const getAuthHeader = () => {
const bmToken = document.cookie const bmToken =
.split('; ') document.cookie
.find((row) => row.startsWith('bm-token=')) .split('; ')
?.split('=')[1] || localStorage.getItem('bm-token'); .find((row) => row.startsWith('bm-token='))
?.split('=')[1] || localStorage.getItem('bm-token');
return bmToken ? { Authorization: `Bearer ${bmToken}` } : {}; return bmToken ? { Authorization: `Bearer ${bmToken}` } : {};
}; };
+30 -11
View File
@@ -2,14 +2,15 @@ import axios from 'axios';
export const PropertyService = { export const PropertyService = {
_getAuthHeader: () => { _getAuthHeader: () => {
const bmToken = document.cookie const bmToken =
.split('; ') (typeof document !== 'undefined'
.find((row) => row.startsWith('bm-token=')) ? document.cookie
?.split('=')[1]; .split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1]
: null) || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
return { return bmToken && bmToken !== 'undefined' && bmToken !== 'null' ? { Authorization: `Bearer ${bmToken}` } : {};
Authorization: `Bearer ${bmToken}`
};
}, },
getProperty: async (propertyId) => { getProperty: async (propertyId) => {
@@ -55,6 +56,24 @@ export const PropertyService = {
return response.data; return response.data;
} catch (err) { } catch (err) {
console.log(err); console.log(err);
throw err;
}
},
searchPropertiesMongo: async (query) => {
const config = {
method: 'get',
url: '/api/properties/admin/search-mongo',
withCredentials: true,
params: query,
headers: PropertyService._getAuthHeader()
};
try {
const response = await axios(config);
return response.data;
} catch (err) {
console.log(err);
throw err;
} }
}, },
@@ -85,8 +104,8 @@ export const PropertyService = {
const response = await axios(config); const response = await axios(config);
return 'success'; return 'success';
} catch (err) { } catch (err) {
console.log(err); console.error('createProperty error:', err);
return err.response.data.message; return err?.response?.data?.message || err?.message || 'خطا در ثبت ملک';
} }
}, },
@@ -102,8 +121,8 @@ export const PropertyService = {
const response = await axios(config); const response = await axios(config);
return 'success'; return 'success';
} catch (err) { } catch (err) {
console.log(err); console.error('updateProperty error:', err);
return err.response.data.message; return err?.response?.data?.message || err?.message || 'خطا در بروزرسانی ملک';
} }
}, },
+5 -4
View File
@@ -1,10 +1,11 @@
import axios from 'axios'; import axios from 'axios';
const getAuthHeader = () => { const getAuthHeader = () => {
const bmToken = document.cookie const bmToken =
.split('; ') document.cookie
.find((row) => row.startsWith('bm-token=')) .split('; ')
?.split('=')[1] || localStorage.getItem('bm-token'); .find((row) => row.startsWith('bm-token='))
?.split('=')[1] || localStorage.getItem('bm-token');
return bmToken ? { Authorization: `Bearer ${bmToken}` } : {}; return bmToken ? { Authorization: `Bearer ${bmToken}` } : {};
}; };
+203
View File
@@ -0,0 +1,203 @@
import axios from 'axios';
export const SettingService = {
_getAuthHeader: () => {
const bmToken =
(typeof document !== 'undefined'
? document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1]
: null) || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
return bmToken && bmToken !== 'undefined' && bmToken !== 'null' ? { Authorization: `Bearer ${bmToken}` } : {};
},
/**
* Get search settings for all modules
* @returns {Promise<Array>}
*/
getSearchSettings: async () => {
const config = {
method: 'get',
url: '/api/settings/search-settings',
withCredentials: true,
headers: SettingService._getAuthHeader()
};
try {
const response = await axios(config);
return response.data.data;
} catch (err) {
console.error('Error fetching search settings:', err);
throw err;
}
},
/**
* Get search setting for a specific page key
* @param {string} pageKey
* @returns {Promise<Object>}
*/
getSearchSettingByPage: async (pageKey) => {
const config = {
method: 'get',
url: `/api/settings/search-settings/${pageKey}`,
withCredentials: true,
headers: SettingService._getAuthHeader()
};
try {
const response = await axios(config);
return response.data.data;
} catch (err) {
console.error(`Error fetching search setting for ${pageKey}:`, err);
throw err;
}
},
/**
* Update search method for a specific page key
* @param {string} pageKey
* @param {string} searchMethod - 'typesense' | 'mongo'
* @param {Object} extraConfig
* @returns {Promise<Object>}
*/
updateSearchSetting: async (pageKey, searchMethod, extraConfig = {}) => {
const config = {
method: 'put',
url: `/api/settings/search-settings/${pageKey}`,
withCredentials: true,
data: { searchMethod, extraConfig },
headers: SettingService._getAuthHeader()
};
try {
const response = await axios(config);
return response.data.data;
} catch (err) {
console.error(`Error updating search setting for ${pageKey}:`, err);
throw err;
}
},
/**
* Bulk update search settings
* @param {Array<{pageKey: string, searchMethod: string}>} settings
* @returns {Promise<Array>}
*/
bulkUpdateSearchSettings: async (settings) => {
const config = {
method: 'put',
url: '/api/settings/search-settings',
withCredentials: true,
data: { settings },
headers: SettingService._getAuthHeader()
};
try {
const response = await axios(config);
return response.data.data;
} catch (err) {
console.error('Error bulk updating search settings:', err);
throw err;
}
},
/**
* Get database seeding status
* @returns {Promise<{isSeeded: boolean, seededAt: string|null, seededBy: string|null}>}
*/
getSeedStatus: async () => {
const config = {
method: 'get',
url: '/api/settings/seed-status',
withCredentials: true,
headers: SettingService._getAuthHeader()
};
try {
const response = await axios(config);
return response.data.data;
} catch (err) {
console.error('Error fetching seed status:', err);
throw err;
}
},
/**
* Execute one-time database seeding
* @returns {Promise<Object>}
*/
seedDatabase: async () => {
const config = {
method: 'post',
url: '/api/settings/seed-database',
withCredentials: true,
headers: SettingService._getAuthHeader()
};
try {
const response = await axios(config);
return response.data.data;
} catch (err) {
console.error('Error seeding database:', err);
throw err;
}
},
/**
* Get auto-save configuration
* @returns {Promise<{enabled: boolean, intervalSeconds: number}>}
*/
getAutoSaveSetting: async () => {
const config = {
method: 'get',
url: '/api/settings/autosave',
withCredentials: true,
headers: SettingService._getAuthHeader()
};
try {
const response = await axios(config);
if (response.data?.data) {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('bm-autosave-settings', JSON.stringify(response.data.data));
}
return response.data.data;
}
} catch (err) {
console.warn('Error fetching autosave setting from server, using local fallback:', err);
}
if (typeof localStorage !== 'undefined') {
const cached = localStorage.getItem('bm-autosave-settings');
if (cached) {
try {
return JSON.parse(cached);
} catch (e) {}
}
}
return { enabled: true, intervalSeconds: 60 };
},
/**
* Update auto-save configuration
* @param {{enabled: boolean, intervalSeconds: number}} data
* @returns {Promise<{enabled: boolean, intervalSeconds: number}>}
*/
updateAutoSaveSetting: async (data) => {
const config = {
method: 'put',
url: '/api/settings/autosave',
withCredentials: true,
data,
headers: SettingService._getAuthHeader()
};
try {
const response = await axios(config);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('bm-autosave-settings', JSON.stringify(response.data.data));
}
return response.data.data;
} catch (err) {
console.error('Error updating autosave setting:', err);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('bm-autosave-settings', JSON.stringify(data));
}
return data;
}
}
};
+26 -7
View File
@@ -2,14 +2,15 @@ import axios from 'axios';
export const TransactionService = { export const TransactionService = {
_getAuthHeader: () => { _getAuthHeader: () => {
const bmToken = document.cookie const bmToken =
.split('; ') (typeof document !== 'undefined'
.find((row) => row.startsWith('bm-token=')) ? document.cookie
?.split('=')[1]; .split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1]
: null) || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
return { return bmToken && bmToken !== 'undefined' && bmToken !== 'null' ? { Authorization: `Bearer ${bmToken}` } : {};
Authorization: `Bearer ${bmToken}`
};
}, },
/** /**
* Get a single transaction by ID * Get a single transaction by ID
@@ -168,6 +169,24 @@ export const TransactionService = {
return response.data; return response.data;
} catch (err) { } catch (err) {
console.log(err); console.log(err);
throw err;
}
},
searchTransactionsMongo: async (query) => {
const config = {
method: 'get',
url: '/api/transactions/admin/search-mongo',
withCredentials: true,
params: query,
headers: TransactionService._getAuthHeader()
};
try {
const response = await axios(config);
return response.data;
} catch (err) {
console.log(err);
throw err;
} }
}, },
+5 -4
View File
@@ -1,10 +1,11 @@
import axios from 'axios'; import axios from 'axios';
const getAuthHeader = () => { const getAuthHeader = () => {
const bmToken = document.cookie const bmToken =
.split('; ') document.cookie
.find((row) => row.startsWith('bm-token=')) .split('; ')
?.split('=')[1] || localStorage.getItem('bm-token'); .find((row) => row.startsWith('bm-token='))
?.split('=')[1] || localStorage.getItem('bm-token');
return bmToken ? { Authorization: `Bearer ${bmToken}` } : {}; return bmToken ? { Authorization: `Bearer ${bmToken}` } : {};
}; };
+8 -5
View File
@@ -1,11 +1,14 @@
import axios from 'axios'; import axios from 'axios';
const getAuthHeader = () => { const getAuthHeader = () => {
const bmToken = document.cookie const bmToken =
.split('; ') (typeof document !== 'undefined'
.find((row) => row.startsWith('bm-token=')) ? document.cookie
?.split('=')[1]; .split('; ')
return { Authorization: `Bearer ${bmToken}` }; .find((row) => row.startsWith('bm-token='))
?.split('=')[1]
: null) || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
return bmToken && bmToken !== 'undefined' && bmToken !== 'null' ? { Authorization: `Bearer ${bmToken}` } : {};
}; };
export const WarehouseItemService = { export const WarehouseItemService = {
+8 -5
View File
@@ -1,11 +1,14 @@
import axios from 'axios'; import axios from 'axios';
const getAuthHeader = () => { const getAuthHeader = () => {
const bmToken = document.cookie const bmToken =
.split('; ') (typeof document !== 'undefined'
.find((row) => row.startsWith('bm-token=')) ? document.cookie
?.split('=')[1]; .split('; ')
return { Authorization: `Bearer ${bmToken}` }; .find((row) => row.startsWith('bm-token='))
?.split('=')[1]
: null) || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
return bmToken && bmToken !== 'undefined' && bmToken !== 'null' ? { Authorization: `Bearer ${bmToken}` } : {};
}; };
export const WarehouseService = { export const WarehouseService = {
+151
View File
@@ -0,0 +1,151 @@
/**
* Jalali (Solar Hijri / Shamsi) calendar conversion utilities
*/
export const persianMonths = [
{ value: 1, id: 1, label: 'فروردین', name: 'فروردین' },
{ value: 2, id: 2, label: 'اردیبهشت', name: 'اردیبهشت' },
{ value: 3, id: 3, label: 'خرداد', name: 'خرداد' },
{ value: 4, id: 4, label: 'تیر', name: 'تیر' },
{ value: 5, id: 5, label: 'مرداد', name: 'مرداد' },
{ value: 6, id: 6, label: 'شهریور', name: 'شهریور' },
{ value: 7, id: 7, label: 'مهر', name: 'مهر' },
{ value: 8, id: 8, label: 'آبان', name: 'آبان' },
{ value: 9, id: 9, label: 'آذر', name: 'آذر' },
{ value: 10, id: 10, label: 'دی', name: 'دی' },
{ value: 11, id: 11, label: 'بهمن', name: 'بهمن' },
{ value: 12, id: 12, label: 'اسفند', name: 'اسفند' }
];
/**
* Converts Persian and Arabic digits in a string to English digits
*/
export function toEnglishDigits(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d))
.replace(/[٠-٩]/g, (d) => '٠١٢٣٤٥٦٧٨٩'.indexOf(d));
}
/**
* Converts Jalali date (year, month, day) to Gregorian date (gy, gm, gd)
*/
export function jalaliToGregorian(jy, jm, jd) {
jy = Number(jy);
jm = Number(jm);
jd = Number(jd);
let gy = jy > 979 ? 1600 : 621;
jy = jy > 979 ? jy - 979 : jy;
let days = 365 * jy + Math.floor(jy / 33) * 8 + Math.floor(((jy % 33) + 3) / 4) + 78 + jd + (jm < 7 ? (jm - 1) * 31 : (jm - 7) * 30 + 186);
gy += 400 * Math.floor(days / 146097);
days %= 146097;
if (days > 36524) {
gy += 100 * Math.floor(--days / 36524);
days %= 36524;
if (days >= 365) days++;
}
gy += 4 * Math.floor(days / 1461);
days %= 1461;
if (days > 365) {
gy += Math.floor((days - 1) / 365);
days = (days - 1) % 365;
}
let gd = days + 1;
const isLeap = (gy % 4 === 0 && gy % 100 !== 0) || gy % 400 === 0;
const sal_a = [0, 31, isLeap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let gm;
for (gm = 0; gm < 13; gm++) {
const v = sal_a[gm];
if (gd <= v) break;
gd -= v;
}
return { gy, gm, gd };
}
/**
* Converts Gregorian date (gy, gm, gd) to Jalali date (jy, jm, jd)
*/
export function gregorianToJalali(gy, gm, gd) {
gy = Number(gy);
gm = Number(gm);
gd = Number(gd);
const g_d_m = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
let jy = gy > 1600 ? 979 : 0;
gy = gy > 1600 ? gy - 1600 : gy - 621;
const gy2 = gm > 2 ? gy + 1 : gy;
let days = 365 * gy + Math.floor((gy2 + 3) / 4) - Math.floor((gy2 + 99) / 100) + Math.floor((gy2 + 399) / 400) - 80 + gd + g_d_m[gm - 1];
jy += 33 * Math.floor(days / 12053);
days %= 12053;
jy += 4 * Math.floor(days / 1461);
days %= 1461;
if (days > 365) {
jy += Math.floor((days - 1) / 365);
days = (days - 1) % 365;
}
const jm = days < 186 ? 1 + Math.floor(days / 31) : 7 + Math.floor((days - 186) / 30);
const jd = 1 + (days < 186 ? days % 31 : (days - 186) % 30);
return { jy, jm, jd };
}
/**
* Converts Jalali year, month, day to a standard JS Date object at 12:00 UTC
*/
export function jalaliToDate(jy, jm, jd) {
const y = parseInt(toEnglishDigits(jy), 10);
const m = parseInt(toEnglishDigits(jm), 10);
const d = parseInt(toEnglishDigits(jd), 10);
if (isNaN(y) || isNaN(m) || isNaN(d) || y <= 0 || m < 1 || m > 12 || d < 1 || d > 31) {
return null;
}
const { gy, gm, gd } = jalaliToGregorian(y, m, d);
return new Date(Date.UTC(gy, gm - 1, gd, 12, 0, 0));
}
/**
* Converts a JS Date object, ISO string, or legacy Jalali string (YYYY/MM/DD) to { jy, jm, jd }
*/
export function parseDateToJalali(dateVal) {
if (!dateVal) return null;
// Check if it's already a JS Date or ISO string
if (dateVal instanceof Date || (typeof dateVal === 'string' && (dateVal.includes('T') || dateVal.includes('-')))) {
const d = new Date(dateVal);
if (!isNaN(d.getTime())) {
return gregorianToJalali(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate());
}
}
// Check if it's a legacy string format e.g. "1399/04/15" or "۱۳۹۹/۰۴/۱۵"
if (typeof dateVal === 'string' && dateVal.includes('/')) {
const clean = toEnglishDigits(dateVal.trim());
const parts = clean.split('/');
if (parts.length === 3) {
const jy = parseInt(parts[0], 10);
const jm = parseInt(parts[1], 10);
const jd = parseInt(parts[2], 10);
if (!isNaN(jy) && !isNaN(jm) && !isNaN(jd)) {
return { jy, jm, jd };
}
}
}
return null;
}
+111 -12
View File
@@ -1,22 +1,121 @@
<script setup> <script setup>
import BestSellingWidget from '@/components/dashboard/BestSellingWidget.vue'; import { onMounted, ref } from 'vue';
import NotificationsWidget from '@/components/dashboard/NotificationsWidget.vue';
import RecentSalesWidget from '@/components/dashboard/RecentSalesWidget.vue';
import RevenueStreamWidget from '@/components/dashboard/RevenueStreamWidget.vue';
import StatsWidget from '@/components/dashboard/StatsWidget.vue'; import StatsWidget from '@/components/dashboard/StatsWidget.vue';
import RevenueStreamWidget from '@/components/dashboard/RevenueStreamWidget.vue';
import PropertyStatusWidget from '@/components/dashboard/PropertyStatusWidget.vue';
import RecentSalesWidget from '@/components/dashboard/RecentSalesWidget.vue';
import WarehouseActivityWidget from '@/components/dashboard/WarehouseActivityWidget.vue';
import QuickActionsWidget from '@/components/dashboard/QuickActionsWidget.vue';
import { DashboardService } from '@/service/DashboardService';
import { gregorianToJalali, persianMonths } from '@/utils/jalali';
import { useRouter } from 'vue-router';
const router = useRouter();
const stats = ref(null);
const loading = ref(true);
const refreshing = ref(false);
const dayNames = ['یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنج‌شنبه', 'جمعه', 'شنبه'];
const getTodayPersianDate = () => {
const now = new Date();
const j = gregorianToJalali(now.getFullYear(), now.getMonth() + 1, now.getDate());
const dayName = dayNames[now.getDay()];
const monthName = persianMonths.find((m) => m.value === j.jm)?.name || '';
return `${dayName} ${j.jd} ${monthName} ${j.jy}`;
};
const todayFormatted = getTodayPersianDate();
const fetchDashboardStats = async (isManual = false) => {
try {
if (isManual) refreshing.value = true;
else loading.value = true;
const data = await DashboardService.getDashboardStats();
stats.value = data;
} catch (err) {
console.error('Failed to load dashboard statistics:', err);
} finally {
loading.value = false;
refreshing.value = false;
}
};
onMounted(() => {
fetchDashboardStats();
});
</script> </script>
<template> <template>
<div class="grid grid-cols-12 gap-8"> <div class="flex flex-col gap-6">
<StatsWidget /> <!-- Dashboard Header Welcome Banner -->
<div
class="card p-6 border border-surface-200 dark:border-surface-800 rounded-2xl bg-gradient-to-l from-primary-500/10 via-surface-0 to-surface-0 dark:from-primary-500/15 dark:via-surface-900 dark:to-surface-900 shadow-sm relative overflow-hidden"
>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<div class="flex items-center gap-2 mb-1.5">
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-primary-100 dark:bg-primary-900/40 text-primary">
<i class="pi pi-compass text-[10px]"></i>
مرکز مدیریت و پایش
</span>
<span class="text-xs text-muted-color">|</span>
<span class="text-xs font-medium text-surface-600 dark:text-surface-400">
{{ todayFormatted }}
</span>
</div>
<h1 class="text-2xl font-black text-surface-900 dark:text-surface-0 tracking-tight">داشبورد جامع مدیریت اموال و انبارداری</h1>
<p class="text-sm text-muted-color mt-1">خلاصه آماری دارایی‌ها، املاک ثبتی و گزارش لحظه‌ای گردش کالاهای انبار</p>
</div>
<div class="col-span-12 xl:col-span-6"> <div class="flex items-center gap-3 shrink-0">
<RecentSalesWidget /> <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)" />
<!-- <BestSellingWidget />--> <router-link to="/property/create-property">
<Button label="ثبت ملک جدید" icon="pi pi-plus" class="shadow-sm" />
</router-link>
</div>
</div>
</div> </div>
<div class="col-span-12 xl:col-span-6">
<RevenueStreamWidget /> <!-- 4 KPI Stats Cards Row -->
<!-- <NotificationsWidget />--> <div class="grid grid-cols-12 gap-5">
<StatsWidget :stats="stats" :loading="loading" />
</div>
<!-- Quick Actions Shortcuts -->
<QuickActionsWidget />
<!-- Real Data Visual Charts Row -->
<div class="grid grid-cols-12 gap-6">
<!-- Property Category Distribution Chart -->
<div class="col-span-12 xl:col-span-7">
<RevenueStreamWidget :category-stats="stats?.propertiesByCategory || []" :loading="loading" />
</div>
<!-- Property Status & Deeds Breakdown Chart -->
<div class="col-span-12 xl:col-span-5">
<PropertyStatusWidget :status-stats="stats?.propertiesByStatus || []" :loading="loading" />
</div>
</div>
<!-- Live Feeds: Recent Properties & Recent Warehouse Transactions Row -->
<div class="grid grid-cols-12 gap-6">
<!-- Recent Properties Table -->
<div class="col-span-12 xl:col-span-7">
<RecentSalesWidget :properties="stats?.recentProperties || []" :loading="loading" />
</div>
<!-- Recent Warehouse Transactions Feed -->
<div class="col-span-12 xl:col-span-5">
<WarehouseActivityWidget :transactions="stats?.recentWarehouseTransactions || []" :warehouse-flow="stats?.warehouseFlow || { inCount: 0, outCount: 0, inQuantity: 0, outQuantity: 0 }" :loading="loading" />
</div>
</div> </div>
</div> </div>
</template> </template>
<style scoped>
.card {
font-family: 'Estedad', sans-serif !important;
}
</style>
+103 -59
View File
@@ -3,24 +3,39 @@ import { FilterMatchMode } from '@primevue/core/api';
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { CategoryService } from '@/service/CategoryService'; import { CategoryService } from '@/service/CategoryService';
import { useToast } from 'primevue/usetoast'; import { useToast } from 'primevue/usetoast';
const toast = useToast();
import axios from 'axios';
onMounted(() => {
CategoryService.getAllCategories().then((data) => (categories.value = data));
});
const toast = useToast();
const dataLoading = ref(true);
const dt = ref(); const dt = ref();
const categories = ref(); const categories = ref([]);
const categoryDialog = ref(false); const categoryDialog = ref(false);
const deleteCategoryDialog = ref(false); const deleteCategoryDialog = ref(false);
const deleteProductsDialog = ref(false); const deleteSelectedDialog = ref(false);
const category = ref({}); const category = ref({});
const selectedCategories = ref(); const selectedCategories = ref([]);
const filters = ref({ const filters = ref({
global: { value: null, matchMode: FilterMatchMode.CONTAINS } global: { value: null, matchMode: FilterMatchMode.CONTAINS }
}); });
const submitted = ref(false); const submitted = ref(false);
const loadData = async () => {
dataLoading.value = true;
try {
const data = await CategoryService.getAllCategories();
categories.value = Array.isArray(data) ? data : [];
} catch (err) {
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در دریافت اطلاعات دسته‌بندی‌ها', life: 3000 });
categories.value = [];
} finally {
dataLoading.value = false;
}
};
onMounted(() => {
loadData();
});
function openNew() { function openNew() {
category.value = {}; category.value = {};
submitted.value = false; submitted.value = false;
@@ -34,24 +49,25 @@ function hideDialog() {
async function saveCategory() { async function saveCategory() {
submitted.value = true; submitted.value = true;
let status = 'failed'; if (!category.value?.nativeName || !category.value?.englishName) {
return;
}
if (category?.value._id?.trim()) { let status = 'failed';
if (category.value._id?.trim()) {
status = await CategoryService.updateCategory(category.value._id, category.value); status = await CategoryService.updateCategory(category.value._id, category.value);
} else { } else {
status = await CategoryService.createCategory(category.value); status = await CategoryService.createCategory(category.value);
} }
if (status === 'success') { if (status === 'success') {
toast.add({ severity: 'success', summary: 'موفق', detail: 'دسته بندی جدید ساخته شد.', life: 3000 }); toast.add({ severity: 'success', summary: 'موفق', detail: 'دسته‌بندی با موفقیت ذخیره شد.', life: 3000 });
categoryDialog.value = false;
category.value = {};
await loadData();
} else { } else {
toast.add({ severity: 'error', summary: 'ناموفق', detail: status, life: 3000 }); toast.add({ severity: 'error', summary: 'ناموفق', detail: status, life: 3000 });
} }
categoryDialog.value = false;
category.value = {};
CategoryService.getAllCategories().then((data) => (categories.value = data));
} }
function editCategory(cat) { function editCategory(cat) {
@@ -59,42 +75,47 @@ function editCategory(cat) {
categoryDialog.value = true; categoryDialog.value = true;
} }
function confirmDeleteCategory(prod) { function confirmDeleteCategory(cat) {
category.value = prod; category.value = cat;
deleteCategoryDialog.value = true; deleteCategoryDialog.value = true;
} }
async function deleteCategory() { async function deleteCategory() {
const config = { if (!category.value?._id) return;
method: 'delete', const status = await CategoryService.deleteCategory(category.value._id);
url: '/api/categories/admin/delete/' + category.value._id, if (status === 'success') {
data: category.value, toast.add({ severity: 'success', summary: 'موفقیت‌آمیز', detail: 'دسته‌بندی با موفقیت حذف شد.', life: 3000 });
withCredentials: true deleteCategoryDialog.value = false;
}; category.value = {};
try { await loadData();
const response = await axios(config); } else {
toast.add({ severity: 'success', summary: 'موفق', detail: 'دسته بندی جدید حذف شد.', life: 3000 }); toast.add({ severity: 'error', summary: 'ناموفق', detail: status, life: 3000 });
} catch (err) {
toast.add({ severity: 'error', summary: 'ناموفق', detail: err.response.data.message, life: 3000 });
} }
CategoryService.getAllCategories().then((data) => (categories.value = data));
deleteCategoryDialog.value = false;
category.value = {};
}
function exportCSV() {
dt.value.exportCSV();
} }
function confirmDeleteSelected() { function confirmDeleteSelected() {
deleteProductsDialog.value = true; deleteSelectedDialog.value = true;
} }
function deleteSelectedCategories() { async function deleteSelectedCategories() {
categories.value = categories.value.filter((val) => !selectedCategories.value.includes(val)); if (!selectedCategories.value || !selectedCategories.value.length) return;
deleteProductsDialog.value = false;
selectedCategories.value = null; let successCount = 0;
toast.add({ severity: 'success', summary: 'موفقیت آمیز', detail: 'دسته بندی مورد نظر حذف شد.', life: 3000 }); for (const cat of selectedCategories.value) {
if (cat._id) {
const res = await CategoryService.deleteCategory(cat._id);
if (res === 'success') successCount++;
}
}
toast.add({ severity: 'success', summary: 'موفقیت‌آمیز', detail: `${successCount} دسته‌بندی حذف شد.`, life: 3000 });
deleteSelectedDialog.value = false;
selectedCategories.value = [];
await loadData();
}
function exportCSV() {
dt.value?.exportCSV();
} }
</script> </script>
@@ -104,18 +125,21 @@ function deleteSelectedCategories() {
<div class="card"> <div class="card">
<Toolbar class="mb-6"> <Toolbar class="mb-6">
<template #start> <template #start>
<Button label="ساخت دسته بندی جدید" icon="pi pi-plus" severity="secondary" class="mr-2 mx-3" @click="openNew" /> <Button label="ساخت دسته بندی جدید" icon="pi pi-plus" severity="primary" class="mr-2 mx-3" @click="openNew" />
<Button label="حذف دسته بندی" icon="pi pi-trash" severity="secondary" @click="confirmDeleteSelected" :disabled="!selectedCategories || !selectedCategories.length" /> <Button label="حذف موارد انتخابی" icon="pi pi-trash" severity="danger" outlined @click="confirmDeleteSelected" :disabled="!selectedCategories || !selectedCategories.length" />
</template> </template>
<template #end> </template> <template #end>
<Button label="خروجی CSV" icon="pi pi-upload" severity="secondary" @click="exportCSV" />
</template>
</Toolbar> </Toolbar>
<DataTable <DataTable
ref="dt" ref="dt"
v-model:selection="selectedCategories" v-model:selection="selectedCategories"
:value="categories" :value="categories"
dataKey="id" :loading="dataLoading"
dataKey="_id"
:paginator="true" :paginator="true"
:rows="20" :rows="20"
:filters="filters" :filters="filters"
@@ -123,9 +147,22 @@ function deleteSelectedCategories() {
:rowsPerPageOptions="[20, 40, 80]" :rowsPerPageOptions="[20, 40, 80]"
currentPageReportTemplate="نمایش {first} تا {last} از {totalRecords} دسته‌بندی" currentPageReportTemplate="نمایش {first} تا {last} از {totalRecords} دسته‌بندی"
> >
<template #loading>
<div class="flex flex-col items-center justify-center p-8 gap-3">
<ProgressSpinner style="width: 42px; height: 42px" strokeWidth="4" />
<span class="text-sm font-medium text-surface-500">در حال بارگذاری دسته‌بندی‌ها...</span>
</div>
</template>
<template #empty>
<div v-if="!dataLoading" class="p-8 text-center text-surface-500">
<i class="pi pi-inbox text-4xl mb-2 text-surface-400"></i>
<p>هیچ دسته‌بندی یافت نشد.</p>
</div>
</template>
<template #header> <template #header>
<div class="flex flex-wrap gap-2 items-center justify-between"> <div class="flex flex-wrap gap-2 items-center justify-between">
<h4 class="m-0">مدیریت دسته بندی ها</h4> <h4 class="m-0 font-bold">مدیریت دسته بندی ها ({{ categories.length }} مورد)</h4>
<IconField> <IconField>
<InputIcon> <InputIcon>
<i class="pi pi-search" /> <i class="pi pi-search" />
@@ -138,10 +175,15 @@ function deleteSelectedCategories() {
<Column selectionMode="multiple" style="width: 3rem" :exportable="false"></Column> <Column selectionMode="multiple" style="width: 3rem" :exportable="false"></Column>
<Column field="nativeName" header="نام فارسی" sortable style="min-width: 16rem"></Column> <Column field="nativeName" header="نام فارسی" sortable style="min-width: 16rem"></Column>
<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">
<template #body="slotProps">
<span class="px-2.5 py-1 text-xs rounded-full bg-indigo-50 dark:bg-indigo-950/60 text-indigo-700 dark:text-indigo-300 font-medium"> {{ slotProps.data.features?.length || 0 }} ویژگی </span>
</template>
</Column>
<Column :exportable="false" style="min-width: 12rem"> <Column :exportable="false" style="min-width: 12rem">
<template #body="slotProps"> <template #body="slotProps">
<Button icon="pi pi-pencil" outlined rounded class="mr-2 mx-3" @click="editCategory(slotProps.data)" /> <Button icon="pi pi-pencil" outlined rounded class="mr-2 mx-2" severity="info" title="ویرایش" @click="editCategory(slotProps.data)" />
<Button icon="pi pi-trash" class="mx-3" outlined rounded severity="danger" @click="confirmDeleteCategory(slotProps.data)" /> <Button icon="pi pi-trash" class="mx-2" outlined rounded severity="danger" title="حذف" @click="confirmDeleteCategory(slotProps.data)" />
</template> </template>
</Column> </Column>
</DataTable> </DataTable>
@@ -156,7 +198,7 @@ function deleteSelectedCategories() {
</div> </div>
<div> <div>
<label for="englishName" class="block font-bold mb-3">نام انگلیسی</label> <label for="englishName" class="block font-bold mb-3">نام انگلیسی</label>
<InputText id="englishName" v-model.trim="category.englishName" required="true" autofocus :invalid="submitted && !category.englishName" fluid /> <InputText id="englishName" v-model.trim="category.englishName" required="true" :invalid="submitted && !category.englishName" fluid />
<small v-if="submitted && !category.englishName" class="text-red-500">ورود نام انگلیسی الزامی است.</small> <small v-if="submitted && !category.englishName" class="text-red-500">ورود نام انگلیسی الزامی است.</small>
</div> </div>
</div> </div>
@@ -167,27 +209,29 @@ function deleteSelectedCategories() {
</template> </template>
</Dialog> </Dialog>
<Dialog v-model:visible="deleteCategoryDialog" :style="{ width: '450px' }" header="تایید نهایی" :modal="true"> <Dialog v-model:visible="deleteCategoryDialog" :style="{ width: '450px' }" header="تایید حذف" :modal="true">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<i class="pi pi-exclamation-triangle !text-3xl" /> <i class="pi pi-exclamation-triangle !text-3xl text-red-500" />
<span v-if="category" <span v-if="category"
>آیا از حذف دسته بندی <b>{{ category.nativeName }} </b> مطمئن هستید؟</span >آیا از حذف دسته بندی <b>{{ category.nativeName }}</b> مطمئن هستید؟</span
> >
</div> </div>
<template #footer> <template #footer>
<Button label="خیر" icon="pi pi-times" text @click="deleteCategoryDialog = false" /> <Button label="خیر" icon="pi pi-times" text @click="deleteCategoryDialog = false" />
<Button label="بله" icon="pi pi-check" @click="deleteCategory" /> <Button label="بله، حذف شود" icon="pi pi-check" severity="danger" @click="deleteCategory" />
</template> </template>
</Dialog> </Dialog>
<Dialog v-model:visible="deleteProductsDialog" :style="{ width: '450px' }" header="Confirm" :modal="true"> <Dialog v-model:visible="deleteSelectedDialog" :style="{ width: '450px' }" header="تایید حذف گروهی" :modal="true">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<i class="pi pi-exclamation-triangle !text-3xl" /> <i class="pi pi-exclamation-triangle !text-3xl text-red-500" />
<span v-if="product">Are you sure you want to delete the selected products?</span> <span
>آیا از حذف <b>{{ selectedCategories?.length || 0 }}</b> دسته‌بندی انتخاب شده اطمینان دارید؟</span
>
</div> </div>
<template #footer> <template #footer>
<Button label="No" icon="pi pi-times" text @click="deleteProductsDialog = false" /> <Button label="خیر" icon="pi pi-times" text @click="deleteSelectedDialog = false" />
<Button label="Yes" icon="pi pi-check" text @click="deleteSelectedCategories" /> <Button label="بله، حذف شوند" icon="pi pi-check" severity="danger" @click="deleteSelectedCategories" />
</template> </template>
</Dialog> </Dialog>
</div> </div>
+212 -67
View File
@@ -1,124 +1,269 @@
<script setup> <script setup>
import { onBeforeMount, onMounted, ref } from 'vue'; import { onMounted, ref, computed } from 'vue';
import { CategoryService } from '@/service/CategoryService'; import { CategoryService } from '@/service/CategoryService';
import { Tabs, TabList, Tab } from 'primevue';
import { useToast } from 'primevue/usetoast';
import axios from 'axios';
import { FeatureService } from '@/service/FeatureService'; import { FeatureService } from '@/service/FeatureService';
import { Tabs, TabList, Tab, TabPanels, TabPanel } from 'primevue';
import { useToast } from 'primevue/usetoast';
const toast = useToast(); const toast = useToast();
const selectedTab = ref(''); const selectedTab = ref('');
const selectedFeature = ref();
const submitted = ref(false); const submitted = ref(false);
const feature = ref({}); const feature = ref({});
const categories = ref([]); const categories = ref([]);
const dataLoading = ref(true);
const featureDialog = ref(false); const featureDialog = ref(false);
onMounted(async () => { const deleteFeatureDialog = ref(false);
categories.value = await CategoryService.getAllCategories(); const featureToDelete = ref(null);
const featureTypes = [
{ value: 'string', name: 'متن (String)' },
{ value: 'number', name: 'عدد (Number)' },
{ value: 'boolean', name: 'بله یا خیر (Boolean)' },
{ value: 'date', name: 'تاریخ (Date)' }
];
const loadCategories = async () => {
dataLoading.value = true;
try {
const data = await CategoryService.getAllCategories();
categories.value = Array.isArray(data) ? data : [];
if (categories.value.length > 0 && (!selectedTab.value || !categories.value.some((c) => c._id === selectedTab.value))) {
selectedTab.value = categories.value[0]._id;
}
} catch (e) {
console.error('Error loading categories:', e);
categories.value = [];
} finally {
dataLoading.value = false;
}
};
onMounted(() => {
loadCategories();
});
const currentCategory = computed(() => {
return categories.value.find((c) => c._id === selectedTab.value) || null;
}); });
const featureType = [
{
value: 'string',
name: 'متن'
},
{
value: 'boolean',
name: 'بله یا خیر'
},
{
value: 'number',
name: 'عدد'
},
{
value: 'date',
name: 'تاریخ'
}
];
function hideDialog() { function hideDialog() {
featureDialog.value = false; featureDialog.value = false;
submitted.value = false; submitted.value = false;
feature.value = {};
} }
function openNew(categoryId) { function openNew(categoryId) {
feature.value = {}; feature.value = {
feature.value.category = categoryId; category: categoryId || selectedTab.value,
featureType: 'string',
isFilterable: true
};
submitted.value = false; submitted.value = false;
featureDialog.value = true; featureDialog.value = true;
} }
const editFeature = (event, categoryId) => { const editFeature = (feat, categoryId) => {
feature.value = event.value; feature.value = { ...feat, category: categoryId || selectedTab.value };
submitted.value = false; submitted.value = false;
featureDialog.value = true; featureDialog.value = true;
}; };
const confirmDelete = (feat) => {
featureToDelete.value = feat;
deleteFeatureDialog.value = true;
};
const deleteFeature = async () => {
if (!featureToDelete.value?._id) return;
try {
const status = await FeatureService.deleteFeature(featureToDelete.value._id);
if (status === 'success') {
toast.add({ severity: 'success', summary: 'موفق', detail: 'ویژگی با موفقیت حذف شد.', life: 3000 });
deleteFeatureDialog.value = false;
featureToDelete.value = null;
await loadCategories();
} else {
toast.add({ severity: 'error', summary: 'ناموفق', detail: status, life: 3000 });
}
} catch (e) {
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در حذف ویژگی', life: 3000 });
}
};
const saveFeature = async () => { const saveFeature = async () => {
submitted.value = true; submitted.value = true;
if (!feature.value.nativeName || !feature.value.englishName || !feature.value.featureType) {
return;
}
let status = 'failed'; let status = 'failed';
if (feature?.value._id?.trim()) { try {
status = await FeatureService.updateFeature(feature.value._id, feature.value); if (feature.value._id?.trim()) {
} else { status = await FeatureService.updateFeature(feature.value._id, feature.value);
status = await FeatureService.createFeature(feature.value); } else {
status = await FeatureService.createFeature(feature.value);
}
if (status === 'success') {
toast.add({ severity: 'success', summary: 'موفق', detail: 'ویژگی با موفقیت ذخیره شد.', life: 3000 });
featureDialog.value = false;
feature.value = {};
await loadCategories();
} else {
toast.add({ severity: 'error', summary: 'ناموفق', detail: status, life: 3000 });
}
} catch (e) {
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در ذخیره ویژگی', life: 3000 });
} }
};
if (status === 'success') { const getFeatureTypeLabel = (type) => {
toast.add({ severity: 'success', summary: 'موفق', detail: 'ویژگی جدید ساخته شد.', life: 3000 }); const found = featureTypes.find((t) => t.value === type);
} else { return found ? found.name : type || '-';
toast.add({ severity: 'error', summary: 'ناموفق', detail: status, life: 3000 });
}
featureDialog.value = false;
feature.value = {};
CategoryService.getAllCategories().then((data) => (categories.value = data));
}; };
</script> </script>
<template> <template>
<div className="card"> <div class="card p-4">
<div class="font-semibold text-xl mb-4">ویژگی دسته بندی ها</div> <Toast></Toast>
<Tabs :value="selectedTab" :pt="{}"> <div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<TabList> <div>
<Tab v-for="category in categories" :key="category._id" :value="category._id"> <h3 class="text-xl font-bold m-0 text-slate-800 dark:text-slate-100">ویژگی دسته‌بندی‌ها</h3>
{{ category.nativeName }} <p class="text-xs text-slate-500 mt-1 mb-0">تعریف و مدیریت فیلدهای اختصاصی برای هر دسته‌بندی</p>
</div>
<Button v-if="selectedTab" label="افزودن ویژگی جدید" icon="pi pi-plus" severity="primary" @click="openNew(selectedTab)" />
</div>
<!-- Skeleton Loading -->
<div v-if="dataLoading" class="space-y-4">
<div class="flex gap-2">
<Skeleton v-for="i in 4" :key="i" width="8rem" height="2.5rem" class="rounded-xl" />
</div>
<div class="w-full my-4 space-y-3">
<Skeleton width="100%" height="4rem" class="rounded-xl" />
<Skeleton width="100%" height="4rem" class="rounded-xl" />
<Skeleton width="100%" height="4rem" class="rounded-xl" />
</div>
</div>
<!-- Empty state if no categories exist -->
<div v-else-if="!categories.length" class="p-8 text-center text-slate-500">
<i class="pi pi-inbox text-4xl mb-2 text-surface-400"></i>
<p>هیچ دسته‌بندی‌ای یافت نشد. ابتدا یک دسته‌بندی ایجاد کنید.</p>
</div>
<!-- Category Tabs -->
<Tabs v-else :value="selectedTab" @update:value="selectedTab = $event">
<TabList class="mb-4">
<Tab v-for="cat in categories" :key="cat._id" :value="cat._id">
<div class="flex items-center gap-2">
<i class="pi pi-tag text-xs"></i>
<span>{{ cat.nativeName }}</span>
<span class="text-xs px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300">
{{ cat.features?.length || 0 }}
</span>
</div>
</Tab> </Tab>
</TabList> </TabList>
<TabPanels> <TabPanels>
<TabPanel v-for="category in categories" :key="category._id + '_content'" :value="category._id"> <TabPanel v-for="cat in categories" :key="cat._id + '_content'" :value="cat._id">
<p>لیست ویژگی های {{ category.nativeName }}</p> <div class="flex items-center justify-between mb-4">
<div class="w-1/3 my-3"> <span class="text-sm font-semibold text-slate-700 dark:text-slate-200"> لیست ویژگی‌های «{{ cat.nativeName }}» ({{ cat.features?.length || 0 }} مورد) </span>
<Listbox filter :options="category.features" option-value="_id" option-label="nativeName" @item-dblclick="editFeature($event, category._id)" emptyMessage="موردی برای نمایش وجود ندارد."></Listbox> <Button label="افزودن ویژگی" icon="pi pi-plus" size="small" severity="secondary" outlined @click="openNew(cat._id)" />
<Button class="my-3" :categoryId="category._id" @click="openNew(category._id)">افزودن</Button> </div>
<!-- Features Table / List -->
<div v-if="cat.features && cat.features.length > 0" class="overflow-x-auto rounded-xl border border-surface-200 dark:border-surface-700">
<table class="w-full text-right text-sm">
<thead class="bg-surface-50 dark:bg-surface-800/60 text-surface-700 dark:text-surface-200 font-semibold border-b border-surface-200 dark:border-surface-700">
<tr>
<th class="p-3">#</th>
<th class="p-3">نام فارسی</th>
<th class="p-3">نام انگلیسی</th>
<th class="p-3">نوع ویژگی</th>
<th class="p-3">واحد</th>
<th class="p-3">تگ دسته‌بندی</th>
<th class="p-3 text-center">عملیات</th>
</tr>
</thead>
<tbody class="divide-y divide-surface-100 dark:divide-surface-800">
<tr v-for="(feat, idx) in cat.features" :key="feat._id || idx" class="hover:bg-surface-50/50 dark:hover:bg-surface-800/40 transition-colors">
<td class="p-3 font-mono text-xs text-slate-400">{{ idx + 1 }}</td>
<td class="p-3 font-semibold text-slate-800 dark:text-slate-100">{{ feat.nativeName }}</td>
<td class="p-3 font-mono text-xs text-slate-600 dark:text-slate-300">{{ feat.englishName }}</td>
<td class="p-3">
<span class="px-2 py-0.5 text-xs rounded-full bg-blue-50 dark:bg-blue-950/60 text-blue-700 dark:text-blue-300 font-medium">
{{ getFeatureTypeLabel(feat.featureType) }}
</span>
</td>
<td class="p-3 text-xs text-slate-500">{{ feat.unit || '-' }}</td>
<td class="p-3 text-xs text-slate-500">{{ feat.tag || '-' }}</td>
<td class="p-3 text-center">
<div class="flex items-center justify-center gap-2">
<Button icon="pi pi-pencil" size="small" outlined rounded severity="info" title="ویرایش" @click="editFeature(feat, cat._id)" />
<Button icon="pi pi-trash" size="small" outlined rounded severity="danger" title="حذف" @click="confirmDelete(feat)" />
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="p-8 text-center text-slate-400 bg-surface-50 dark:bg-surface-800/30 rounded-xl border border-dashed border-surface-200 dark:border-surface-700">
<i class="pi pi-tags text-3xl mb-2 text-slate-300 dark:text-slate-600"></i>
<p class="mb-3 text-sm">هیچ ویژگی‌ای برای این دسته‌بندی تعریف نشده است.</p>
<Button label="ساخت اولین ویژگی" icon="pi pi-plus" size="small" @click="openNew(cat._id)" />
</div> </div>
</TabPanel> </TabPanel>
</TabPanels> </TabPanels>
</Tabs> </Tabs>
<Dialog v-model:visible="featureDialog" :style="{ width: '450px' }" header="ساخت ویژگی" :modal="true">
<div class="flex flex-col gap-6"> <!-- Feature Create / Edit Dialog -->
<Dialog v-model:visible="featureDialog" :style="{ width: '480px' }" :header="feature._id ? 'ویرایش ویژگی' : 'ساخت ویژگی جدید'" :modal="true">
<div class="flex flex-col gap-4">
<div> <div>
<label for="nativeName" class="block font-bold mb-3">نام فارسی</label> <label for="nativeName" class="block font-semibold text-sm mb-1.5">نام فارسی <span class="text-red-500">*</span></label>
<InputText id="nativeName" v-model.trim="feature.nativeName" required="true" autofocus :invalid="submitted && !feature.nativeName" fluid /> <InputText id="nativeName" v-model.trim="feature.nativeName" placeholder="مثلا: متراژ، تعداد خواب" required autofocus :invalid="submitted && !feature.nativeName" fluid />
<small v-if="submitted && !feature.nativeName" class="text-red-500">ورود نام فارسی الزامی است.</small> <small v-if="submitted && !feature.nativeName" class="text-red-500">ورود نام فارسی الزامی است.</small>
</div> </div>
<div> <div>
<label for="englishName" class="block font-bold mb-3">نام انگلیسی</label> <label for="englishName" class="block font-semibold text-sm mb-1.5">نام انگلیسی <span class="text-red-500">*</span></label>
<InputText id="englishName" v-model.trim="feature.englishName" required="true" autofocus :invalid="submitted && !feature.englishName" fluid /> <InputText id="englishName" v-model.trim="feature.englishName" placeholder="مثلا: area, roomCount" required :invalid="submitted && !feature.englishName" fluid />
<small v-if="submitted && !feature.englishName" class="text-red-500">ورود نام انگلیسی الزامی است.</small> <small v-if="submitted && !feature.englishName" class="text-red-500">ورود نام انگلیسی الزامی است.</small>
</div> </div>
<div> <div>
<label for="unit" class="block font-bold mb-3">واحد</label> <label for="featureType" class="block font-semibold text-sm mb-1.5">نوع داده‌ای <span class="text-red-500">*</span></label>
<InputText id="unit" v-model.trim="feature.unit" required="true" autofocus :invalid="submitted && !feature.unit" fluid /> <Select id="featureType" option-label="name" option-value="value" :options="featureTypes" v-model="feature.featureType" :invalid="submitted && !feature.featureType" fluid />
</div>
<div class="mb-6">
<label for="featureType" class="block font-bold mb-3">نوع ویژگی</label>
<Select id="featureType" option-label="name" option-value="value" :options="featureType" v-model.trim="feature.featureType" autofocus :invalid="submitted && !feature.featureType" fluid />
<small v-if="submitted && !feature.featureType" class="text-red-500">ورود نوع ویژگی الزامی است.</small> <small v-if="submitted && !feature.featureType" class="text-red-500">ورود نوع ویژگی الزامی است.</small>
</div> </div>
<div>
<label for="unit" class="block font-semibold text-sm mb-1.5">واحد اندازه‌گیری (اختیاری)</label>
<InputText id="unit" v-model.trim="feature.unit" placeholder="مثلا: متر مربع، دستگاه" fluid />
</div>
<div>
<label for="tag" class="block font-semibold text-sm mb-1.5">تگ گروهی (اختیاری)</label>
<InputText id="tag" v-model.trim="feature.tag" placeholder="مثلا: مشخصات کلی، امکانات" fluid />
</div>
</div> </div>
<template #footer> <template #footer>
<Button label="لغو" icon="pi pi-times" text @click="hideDialog" /> <Button label="لغو" icon="pi pi-times" text @click="hideDialog" />
<Button label="ذخیره" icon="pi pi-check" @click="saveFeature" /> <Button label="ذخیره" icon="pi pi-check" severity="primary" @click="saveFeature" />
</template>
</Dialog>
<!-- Delete Confirmation Dialog -->
<Dialog v-model:visible="deleteFeatureDialog" :style="{ width: '420px' }" header="تایید حذف ویژگی" :modal="true">
<div class="flex items-center gap-3">
<i class="pi pi-exclamation-triangle text-2xl text-red-500" />
<span v-if="featureToDelete">
آیا از حذف ویژگی <b>«{{ featureToDelete.nativeName }}»</b> مطمئن هستید؟
</span>
</div>
<template #footer>
<Button label="انصراف" icon="pi pi-times" text @click="deleteFeatureDialog = false" />
<Button label="حذف شود" icon="pi pi-trash" severity="danger" @click="deleteFeature" />
</template> </template>
</Dialog> </Dialog>
</div> </div>
File diff suppressed because it is too large Load Diff
+585 -109
View File
@@ -1,53 +1,71 @@
<script setup> <script setup>
import { FilterMatchMode } from '@primevue/core/api'; import { FilterMatchMode } from '@primevue/core/api';
import { useToast } from 'primevue/usetoast'; import { useToast } from 'primevue/usetoast';
import { onMounted, ref, watch } from 'vue'; import { onMounted, ref, watch, computed, nextTick } from 'vue';
import { PropertyService } from '@/service/PropertyService'; import { PropertyService } from '@/service/PropertyService';
import { SettingService } from '@/service/SettingService';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import Chip from 'primevue/chip'; import Chip from 'primevue/chip';
import Tag from 'primevue/tag';
import InputIcon from 'primevue/inputicon'; import InputIcon from 'primevue/inputicon';
import IconField from 'primevue/iconfield'; import IconField from 'primevue/iconfield';
import SelectButton from 'primevue/selectbutton';
import LoadingCheck from '@/components/LoadingCheck.vue'; import LoadingCheck from '@/components/LoadingCheck.vue';
import { useConfirm } from 'primevue/useconfirm'; import { useConfirm } from 'primevue/useconfirm';
import ConfirmPopup from 'primevue/confirmpopup'; import ConfirmPopup from 'primevue/confirmpopup';
import PropertyMap from './PropertyMap.vue';
import axios from 'axios'; import axios from 'axios';
const deleteConfirm = useConfirm(); const deleteConfirm = useConfirm();
onMounted(() => {
loadData();
});
const toast = useToast(); const toast = useToast();
const router = useRouter();
const route = useRoute();
// View Mode
const viewMode = ref('split'); // 'table' | 'split' | 'map'
const viewModeOptions = ref([
{ label: 'جدول', value: 'table', icon: 'pi pi-table' },
{ label: 'نمای ترکیبی (Airbnb)', value: 'split', icon: 'pi pi-th-large' },
{ label: 'نقشه کامل', value: 'map', icon: 'pi pi-map' }
]);
const dt = ref(); const dt = ref();
const properties = ref(); const properties = ref([]);
const productDialog = ref(false);
const deleteProductDialog = ref(false);
const deleteProductsDialog = ref(false);
const property = ref({});
const selectedProperties = ref(); const selectedProperties = ref();
const selectedPropertyId = ref(null);
const hoveredPropertyId = ref(null);
const mapRef = ref(null);
const splitMapRef = ref(null);
const propertyCardRefs = ref({});
const searchMethod = ref(null); // 'typesense' | 'mongo'
const filters = ref({ const filters = ref({
global: { value: null, matchMode: FilterMatchMode.CONTAINS } global: { value: null, matchMode: FilterMatchMode.CONTAINS }
}); });
const submitted = ref(false);
function hideDialog() { const searchPhrase = ref(route.query.query || '');
productDialog.value = false;
submitted.value = false;
}
const router = useRouter();
function editProperty(propertyId) {
router.push('/property/edit-property/' + propertyId);
}
const route = useRoute();
const searchPhrase = ref(route.query.query);
const page = ref(Number(route.query.page) || 1); const page = ref(Number(route.query.page) || 1);
const per_page = ref(Number(route.query.per_page) || 20); const per_page = ref(Number(route.query.per_page) || 20);
const totalCount = ref(0); const totalCount = ref(0);
const pagesCount = ref(0); const pagesCount = ref(0);
const DEFAULT_IMAGE = 'https://cloud.mbmanager.ir/images/item-no-image.png';
const loadSearchSetting = async () => {
try {
const setting = await SettingService.getSearchSettingByPage('properties');
searchMethod.value = setting?.searchMethod || 'typesense';
} catch (e) {
console.warn('Failed to load search setting, defaulting to typesense', e);
searchMethod.value = 'typesense';
}
};
onMounted(async () => {
await loadSearchSetting();
loadData();
});
let searchTimeout; let searchTimeout;
watch(searchPhrase, (newPhrase, oldPhrase) => { watch(searchPhrase, (newPhrase, oldPhrase) => {
@@ -77,17 +95,16 @@ watch(
); );
const handleRemoveSearch = () => { const handleRemoveSearch = () => {
removeFromQuery('searchPhrase'); removeFromQuery('query');
searchPhrase.value = ''; searchPhrase.value = '';
}; };
const removeFromQuery = (queryToRemove) => { const removeFromQuery = (queryToRemove) => {
const newQuery = { ...route.query }; const newQuery = { ...route.query };
delete newQuery[queryToRemove]; delete newQuery[queryToRemove];
router.replace({ query: newQuery }).catch((err) => { router.replace({ query: newQuery }).catch((err) => {
if (err.name !== 'NavigationDuplicated') { if (err?.name !== 'NavigationDuplicated') {
console.error(err); console.error(err);
} }
}); });
@@ -108,17 +125,29 @@ const onPageChange = (event) => {
} }
}; };
const dataLoading = ref(true);
const loadData = async () => { const loadData = async () => {
await PropertyService.searchProperties(route.query) dataLoading.value = true;
.then((response) => { try {
properties.value = response.properties || response.belongings || response; if (searchMethod.value === null) {
totalCount.value = response.totalCount || response.length || 0; await loadSearchSetting();
pagesCount.value = Math.ceil(totalCount.value / per_page.value); }
}) let response;
.catch((error) => { if (searchMethod.value === 'mongo') {
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در بازیابی اطلاعات', life: 3000 }); response = await PropertyService.searchPropertiesMongo(route.query);
console.error('Error loading data:', error); } else {
}); response = await PropertyService.searchProperties(route.query);
}
properties.value = response.properties || response.belongings || response || [];
totalCount.value = response.totalCount || (Array.isArray(response) ? response.length : 0);
pagesCount.value = Math.ceil(totalCount.value / per_page.value);
} catch (error) {
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در بازیابی اطلاعات املاک', life: 3000 });
console.error('Error loading data:', error);
} finally {
dataLoading.value = false;
}
}; };
const loading = ref(false); const loading = ref(false);
@@ -132,7 +161,7 @@ const indexProperties = async () => {
try { try {
await PropertyService.indexProperties(); await PropertyService.indexProperties();
loadData(); await loadData();
indexingFinished.value = true; indexingFinished.value = true;
indexingMessage.value = 'ایندکس با موفقیت انجام شد'; indexingMessage.value = 'ایندکس با موفقیت انجام شد';
} catch (error) { } catch (error) {
@@ -145,6 +174,14 @@ const indexProperties = async () => {
} }
}; };
function editProperty(propertyId) {
router.push('/property/edit-property/' + propertyId);
}
function openInNewTab(propertyId) {
window.open('/property/edit-property/' + propertyId, '_blank');
}
const confirmDeleteProperty = (id, event) => { const confirmDeleteProperty = (id, event) => {
deleteConfirm.require({ deleteConfirm.require({
target: event.currentTarget, target: event.currentTarget,
@@ -160,100 +197,539 @@ const confirmDeleteProperty = (id, event) => {
}, },
accept: async () => { accept: async () => {
try { try {
let url = '/api/properties/admin/delete/' + id; const url = '/api/properties/admin/delete/' + id;
const method = 'delete'; await axios.delete(url, { withCredentials: true });
const config = {
method,
url,
withCredentials: true
};
const response = await axios(config);
toast.add({ severity: 'success', summary: 'موفقیت آمیز', detail: 'حذف ملک با موفقیت انجام شد', life: 3000 }); toast.add({ severity: 'success', summary: 'موفقیت آمیز', detail: 'حذف ملک با موفقیت انجام شد', life: 3000 });
await indexProperties(); await indexProperties();
await loadData(); await loadData();
} catch (error) { } catch (error) {
toast.add({ severity: 'error', summary: ' خطا در انجام عملیات ', detail: error.response?.data?.message || 'خطایی در حذف ملک', life: 3000 }); toast.add({ severity: 'error', summary: 'خطا در انجام عملیات', detail: error.response?.data?.message || 'خطایی در حذف ملک رخ داد', life: 3000 });
console.log(error); console.error(error);
} }
}, }
reject: () => {}
}); });
}; };
const getPropertyImage = (prop) => {
return prop.imageUrl || prop.mainImageUrls?.[0] || prop.signedMainImageUrls?.[0] || DEFAULT_IMAGE;
};
const handleImageError = (e) => {
e.target.src = DEFAULT_IMAGE;
};
const getCategoryName = (prop) => {
if (prop.nativeCategory) return prop.nativeCategory;
if (typeof prop.category === 'object' && prop.category?.nativeName) return prop.category.nativeName;
if (typeof prop.category === 'string') return prop.category;
return '';
};
// Selection sync between Map & Cards
const onSelectProperty = (propertyId) => {
selectedPropertyId.value = propertyId;
// Scroll to the selected property card in split view
nextTick(() => {
const cardEl = document.getElementById(`property-card-${propertyId}`);
if (cardEl) {
cardEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
});
};
const onCardHover = (propertyId) => {
hoveredPropertyId.value = propertyId;
};
const onCardLeave = () => {
hoveredPropertyId.value = null;
};
</script> </script>
<template> <template>
<ConfirmPopup></ConfirmPopup> <ConfirmPopup></ConfirmPopup>
<div> <div class="properties-page">
<div class="card"> <div class="card p-4">
<Toolbar class="mb-6"> <!-- ── Top Toolbar ── -->
<template #start> <div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<Button label="ساخت ملک جدید" icon="pi pi-plus" severity="secondary" class="mr-2 mx-3" @click="router.push('/property/create-property')" /> <!-- Start: Actions -->
<Button @click="indexProperties" :disabled="loading" severity="secondary mx-3" class="indexing-button"> <div class="flex flex-wrap items-center gap-3">
<Button label="ساخت ملک جدید" icon="pi pi-plus" severity="primary" @click="router.push('/property/create-property')" />
<Button @click="indexProperties" :disabled="loading" severity="secondary" class="indexing-button">
<div class="button-content"> <div class="button-content">
<div v-if="!loading">ایندکس کردن املاک</div> <div v-if="!loading" class="flex items-center gap-2">
<div v-else class="loading-content flex"> <i class="pi pi-refresh"></i>
<span>ایندکس کردن املاک</span>
</div>
<div v-else class="loading-content flex items-center gap-2">
<LoadingCheck :key="animationKey" :is-complete="indexingFinished" /> <LoadingCheck :key="animationKey" :is-complete="indexingFinished" />
<Transition enter-active-class="animate__animated animate__fadeIn animate__fast" leave-active-class="animate__animated animate__fadeOut animate__fast" mode="out-in"> <Transition enter-active-class="animate__animated animate__fadeIn animate__fast" leave-active-class="animate__animated animate__fadeOut animate__fast" mode="out-in">
<div :key="indexingMessage">{{ indexingMessage }}</div> <span :key="indexingMessage">{{ indexingMessage }}</span>
</Transition> </Transition>
</div> </div>
</div> </div>
</Button> </Button>
</template> </div>
<template #end> </template> <!-- Center/End: Search & View Switcher -->
</Toolbar> <div class="flex flex-wrap items-center gap-3">
<!-- Search Input -->
<IconField>
<InputIcon>
<i class="pi pi-search" />
</InputIcon>
<InputText v-model="searchPhrase" placeholder="جستجوی املاک..." class="w-64" />
</IconField>
<Chip v-if="searchPhrase" class="mx-1" :label="searchPhrase" removable @remove="handleRemoveSearch" />
<DataTable <!-- Search Engine Mode Tag -->
ref="dt" <Tag
v-model:selection="selectedProperties" v-if="searchMethod"
:value="properties" :value="searchMethod === 'mongo' ? 'جستجو: MongoDB' : 'جستجو: Typesense'"
dataKey="id" :severity="searchMethod === 'mongo' ? 'success' : 'info'"
:paginator="true" :icon="searchMethod === 'mongo' ? 'pi pi-database' : 'pi pi-bolt'"
:rows="per_page" class="cursor-pointer text-xs"
:filters="filters" v-tooltip.bottom="'کلیک برای تغییر موتور جستجو در تنظیمات'"
:total-records="totalCount" @click="router.push('/settings/search-settings')"
:current-page="page - 1" />
@page="onPageChange($event)"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown" <!-- View Mode Selector -->
:rowsPerPageOptions="[20, 40, 80]" <SelectButton v-model="viewMode" :options="viewModeOptions" optionLabel="label" optionValue="value" :allowEmpty="false" class="view-mode-selector">
currentPageReportTemplate="نمایش {first} تا {last} از {totalRecords} ملک" <template #option="slotProps">
> <div class="flex items-center gap-2 px-2 py-1">
<template #header> <i :class="slotProps.option.icon"></i>
<div class="flex flex-wrap gap-2 items-center justify-start"> <span class="hidden sm:inline">{{ slotProps.option.label }}</span>
<h4 class="m-0">مدیریت املاک</h4> </div>
<IconField> </template>
<InputIcon> </SelectButton>
<i class="pi pi-search" /> </div>
</InputIcon> </div>
<InputText v-model="searchPhrase" placeholder="جستجو..." />
</IconField> <!-- ── VIEW 1: DATA TABLE VIEW ── -->
<div v-if="viewMode === 'table'">
<DataTable
ref="dt"
v-model:selection="selectedProperties"
:value="properties"
:loading="dataLoading"
dataKey="_id"
:paginator="true"
:rows="per_page"
:filters="filters"
:total-records="totalCount"
:current-page="page - 1"
@page="onPageChange($event)"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
:rowsPerPageOptions="[20, 40, 80]"
currentPageReportTemplate="نمایش {first} تا {last} از {totalRecords} ملک"
stripedRows
responsiveLayout="scroll"
>
<template #loading>
<div class="flex flex-col items-center justify-center p-8 gap-3">
<ProgressSpinner style="width: 42px; height: 42px" strokeWidth="4" />
<span class="text-sm font-medium text-surface-500">در حال بارگذاری اطلاعات املاک...</span>
</div>
</template>
<template #empty>
<div v-if="!dataLoading" class="p-8 text-center text-surface-500">
<i class="pi pi-inbox text-4xl mb-2 text-surface-400"></i>
<p>هیچ ملکی یافت نشد.</p>
</div>
</template>
<template #header>
<div class="flex flex-wrap gap-2 items-center justify-between">
<h4 class="m-0 text-lg font-bold">لیست املاک ({{ totalCount }} مورد)</h4>
</div>
</template>
<Column selectionMode="multiple" style="width: 3rem" :exportable="false"></Column>
<Column header="تصویر" style="width: 5rem">
<template #body="slotProps">
<img :src="getPropertyImage(slotProps.data)" :alt="slotProps.data.name" class="rounded-lg object-cover w-14 h-14 shadow-sm" @error="handleImageError" />
</template>
</Column>
<Column field="propertyCode" header="کد" style="min-width: 5rem">
<template #body="slotProps">
<span class="font-mono text-sm font-semibold text-indigo-600 dark:text-indigo-400">
{{ slotProps.data.propertyCode || '-' }}
</span>
</template>
</Column>
<Column field="nationalGeoCode" header="شناسه ملی جغرافیایی" style="min-width: 11rem">
<template #body="slotProps">
<span class="font-mono text-xs text-slate-600 dark:text-slate-300">
{{ slotProps.data.nationalGeoCode || '-' }}
</span>
</template>
</Column>
<Column field="name" header="نام ملک" sortable style="min-width: 14rem">
<template #body="slotProps">
<span class="font-semibold text-slate-800 dark:text-slate-100">
{{ slotProps.data.name || 'بدون نام' }}
</span>
</template>
</Column>
<Column field="category" header="دسته‌بندی" sortable style="min-width: 8rem">
<template #body="slotProps">
<span class="px-2.5 py-1 text-xs rounded-full bg-indigo-50 dark:bg-indigo-950/60 text-indigo-700 dark:text-indigo-300 font-medium">
{{ getCategoryName(slotProps.data) || '-' }}
</span>
</template>
</Column>
<Column :exportable="false" style="min-width: 12rem">
<template #body="slotProps">
<div class="flex items-center gap-2">
<Button icon="pi pi-external-link" outlined rounded severity="secondary" title="باز کردن در تب جدید" @click="openInNewTab(slotProps.data._id || slotProps.data.id)" />
<Button icon="pi pi-pencil" outlined rounded severity="info" title="ویرایش ملک" @click="editProperty(slotProps.data._id || slotProps.data.id)" />
<Button icon="pi pi-trash" outlined rounded severity="danger" title="حذف ملک" @click="confirmDeleteProperty(slotProps.data._id || slotProps.data.id, $event)" />
</div>
</template>
</Column>
</DataTable>
</div>
<!-- ── VIEW 2: AIRBNB SPLIT VIEW (Cards + Map) ── -->
<div v-else-if="viewMode === 'split'" class="airbnb-split-container">
<!-- Left/Main: Property Cards Scrollable Column -->
<div class="properties-cards-column">
<div class="cards-header">
<span class="text-sm font-semibold text-slate-700 dark:text-slate-200"> {{ totalCount }} ملک یافت شد (صفحه {{ page }} از {{ pagesCount || 1 }}) </span>
</div> </div>
<Chip v-if="searchPhrase" class="mx-2" :label="searchPhrase" removable @remove="handleRemoveSearch" />
</template>
<Column selectionMode="multiple" style="width: 3rem" :exportable="false"></Column> <!-- Loading Skeleton in Split View -->
<Column header="تصویر"> <div v-if="dataLoading" class="cards-grid">
<template #body="slotProps"> <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">
<img :src="`${slotProps.data.imageUrl || slotProps.data.mainImageUrls?.[0]}`" :alt="slotProps.data.name" class="rounded" style="max-width: 64px" crossorigin="use-credentials" /> <Skeleton width="100%" height="11rem" class="rounded-xl" />
</template> <Skeleton width="70%" height="1.25rem" class="rounded-md" />
</Column> <Skeleton width="45%" height="1rem" class="rounded-md" />
<Column field="propertyCode" header="کد" style="min-width: 4rem"></Column> <div class="flex justify-between items-center pt-2 border-t border-surface-100 dark:border-surface-800">
<Column field="nationalGeoCode" header="شناسه ملی جغرافیایی" style="min-width: 10rem"></Column> <Skeleton width="35%" height="1rem" class="rounded-md" />
<Column field="name" header="نام" sortable style="min-width: 14rem"></Column> <Skeleton width="2rem" height="2rem" class="rounded-lg" />
<Column field="category" header="دسته بندی" sortable style="min-width: 6rem"> </div>
<template #body="slotProps"> </div>
{{ slotProps.data.category?.nativeName || slotProps.data.nativeCategory }} </div>
</template>
</Column> <!-- Cards Grid -->
<Column :exportable="false" style="min-width: 12rem"> <div v-else-if="properties.length > 0" class="cards-grid">
<template #body="slotProps"> <div
<Button icon="pi pi-pencil" outlined rounded class="mr-2 mx-3" @click="editProperty(slotProps.data._id)" /> v-for="prop in properties"
<Button icon="pi pi-trash" outlined rounded severity="danger" class="mx-3" @click="confirmDeleteProperty(slotProps.data._id, $event)" /> :key="prop._id || prop.id"
</template> :id="`property-card-${prop._id || prop.id}`"
</Column> class="property-card"
</DataTable> :class="{
'card-selected': selectedPropertyId === (prop._id || prop.id),
'card-hovered': hoveredPropertyId === (prop._id || prop.id)
}"
@mouseenter="onCardHover(prop._id || prop.id)"
@mouseleave="onCardLeave"
@click="onSelectProperty(prop._id || prop.id)"
>
<!-- Card Image -->
<div class="card-img-wrap">
<img :src="getPropertyImage(prop)" :alt="prop.name" class="card-img" @error="handleImageError" />
<span v-if="getCategoryName(prop)" class="card-badge-category">
{{ getCategoryName(prop) }}
</span>
<span v-if="prop.propertyCode" class="card-badge-code"> #{{ prop.propertyCode }} </span>
</div>
<!-- Card Body -->
<div class="card-body">
<h4 class="card-title" :title="prop.name">
{{ prop.name || 'ملک بدون نام' }}
</h4>
<div class="card-details">
<div v-if="prop.nationalGeoCode" class="detail-row">
<i class="pi pi-id-card text-xs text-slate-400"></i>
<span class="font-mono text-xs">{{ prop.nationalGeoCode }}</span>
</div>
<div v-if="prop.dong" class="detail-row">
<i class="pi pi-chart-pie text-xs text-slate-400"></i>
<span class="text-xs">{{ prop.dong }} دانگ</span>
</div>
<div v-if="prop.address" class="detail-row address-row">
<i class="pi pi-map-marker text-xs text-slate-400"></i>
<span class="text-xs truncate">{{ prop.address }}</span>
</div>
</div>
<!-- Card Actions -->
<div class="card-actions" @click.stop>
<a :href="'/property/edit-property/' + (prop._id || prop.id)" target="_blank" rel="noopener noreferrer" class="card-btn card-btn-newtab" title="باز کردن در تب جدید">
<i class="pi pi-external-link"></i>
<span>تب جدید</span>
</a>
<Button icon="pi pi-pencil" label="ویرایش" size="small" severity="secondary" outlined class="flex-1" @click="editProperty(prop._id || prop.id)" />
<Button icon="pi pi-trash" size="small" severity="danger" outlined @click="confirmDeleteProperty(prop._id || prop.id, $event)" />
</div>
</div>
</div>
</div>
<div v-else class="p-8 text-center text-slate-500">
<i class="pi pi-inbox text-4xl mb-2 text-surface-400"></i>
<p>ملکی یافت نشد.</p>
</div>
<!-- Pagination -->
<Paginator :rows="per_page" :totalRecords="totalCount" :first="(page - 1) * per_page" :rowsPerPageOptions="[20, 40, 80]" @page="onPageChange($event)" class="mt-4" />
</div>
<!-- Right/Side: Sticky Interactive Map -->
<div class="properties-map-column">
<PropertyMap ref="splitMapRef" :properties="properties" :selectedPropertyId="selectedPropertyId" :hoveredPropertyId="hoveredPropertyId" @select-property="onSelectProperty" @edit-property="editProperty" />
</div>
</div>
<!-- ── VIEW 3: FULL MAP VIEW ── -->
<div v-else-if="viewMode === 'map'" class="full-map-container">
<PropertyMap ref="mapRef" :properties="properties" :selectedPropertyId="selectedPropertyId" :hoveredPropertyId="hoveredPropertyId" @select-property="onSelectProperty" @edit-property="editProperty" />
</div>
</div> </div>
</div> </div>
</template> </template>
<style scoped>
.properties-page {
font-family: 'Estedad', sans-serif;
}
/* ── Split View Layout (Airbnb style) ── */
.airbnb-split-container {
display: grid;
grid-template-columns: 1.15fr 1fr;
gap: 20px;
min-height: calc(100vh - 220px);
}
@media (max-width: 1024px) {
.airbnb-split-container {
grid-template-columns: 1fr;
}
}
.properties-cards-column {
display: flex;
flex-direction: column;
gap: 16px;
max-height: calc(100vh - 220px);
overflow-y: auto;
padding-right: 4px;
}
.cards-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 8px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
}
/* Property Card */
.property-card {
background: #ffffff;
border-radius: 14px;
border: 1px solid #e2e8f0;
overflow: hidden;
display: flex;
flex-direction: column;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.property-card:hover {
transform: translateY(-4px);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08);
border-color: #cbd5e1;
}
.property-card.card-selected {
border-color: #6366f1;
box-shadow:
0 0 0 2px #6366f1,
0 12px 28px rgba(99, 102, 241, 0.18);
transform: translateY(-4px);
}
.card-img-wrap {
position: relative;
width: 100%;
height: 150px;
background: #f8fafc;
overflow: hidden;
}
.card-img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.property-card:hover .card-img {
transform: scale(1.05);
}
.card-badge-category {
position: absolute;
top: 8px;
right: 8px;
background: rgba(79, 70, 229, 0.9);
backdrop-filter: blur(4px);
color: #ffffff;
font-size: 11px;
font-weight: 600;
padding: 3px 8px;
border-radius: 20px;
}
.card-badge-code {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(15, 23, 42, 0.82);
backdrop-filter: blur(4px);
color: #ffffff;
font-size: 11px;
font-weight: 600;
padding: 2px 6px;
border-radius: 6px;
font-family: 'Courier New', monospace;
}
.card-body {
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.card-title {
font-size: 14px;
font-weight: 700;
color: #1e293b;
margin: 0;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-details {
display: flex;
flex-direction: column;
gap: 4px;
color: #64748b;
}
.detail-row {
display: flex;
align-items: center;
gap: 6px;
}
.detail-row.address-row {
max-width: 100%;
}
.card-actions {
display: flex;
align-items: center;
gap: 6px;
margin-top: auto;
padding-top: 8px;
border-top: 1px solid #f1f5f9;
}
.card-btn-newtab {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
color: #4f46e5;
background: #eef2ff;
border: 1px solid #e0e7ff;
text-decoration: none;
transition: all 0.15s ease;
}
.card-btn-newtab:hover {
background: #e0e7ff;
color: #4338ca;
text-decoration: none;
}
.properties-map-column {
position: sticky;
top: 20px;
height: calc(100vh - 220px);
min-height: 500px;
border-radius: 16px;
overflow: hidden;
}
/* Full Map Container */
.full-map-container {
height: calc(100vh - 220px);
min-height: 550px;
border-radius: 16px;
overflow: hidden;
}
/* ── Dark Mode Adjustments ── */
.app-dark .property-card {
background: #1e293b;
border-color: #334155;
}
.app-dark .property-card:hover {
border-color: #475569;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.4);
}
.app-dark .property-card.card-selected {
border-color: #6366f1;
box-shadow:
0 0 0 2px #6366f1,
0 12px 28px rgba(99, 102, 241, 0.35);
}
.app-dark .card-title {
color: #f8fafc;
}
.app-dark .card-img-wrap {
background: #0f172a;
}
.app-dark .card-actions {
border-top-color: #334155;
}
.app-dark .card-btn-newtab {
background: #1e1b4b;
border-color: #312e81;
color: #a5b4fc;
}
.app-dark .card-btn-newtab:hover {
background: #312e81;
color: #c7d2fe;
}
</style>
File diff suppressed because it is too large Load Diff
+19 -52
View File
@@ -11,8 +11,9 @@ const confirm = useConfirm();
const roles = ref([]); const roles = ref([]);
const allPermissions = ref([]); const allPermissions = ref([]);
const loading = ref(false); const loading = ref(true);
const showDialog = ref(false); const showDialog = ref(false);
const editMode = ref(false); const editMode = ref(false);
const submitting = ref(false); const submitting = ref(false);
const selectedRole = ref(null); const selectedRole = ref(null);
@@ -236,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>
@@ -253,8 +248,15 @@ const confirmDelete = (r, event) => {
</div> </div>
</template> </template>
<template #loading>
<div class="flex flex-col items-center justify-center p-8 gap-3">
<ProgressSpinner style="width: 42px; height: 42px" strokeWidth="4" />
<span class="text-sm font-medium text-surface-500">در حال بارگذاری نقش‌ها و دسترسی‌ها...</span>
</div>
</template>
<template #empty> <template #empty>
<div class="empty-state"> <div v-if="!loading" class="empty-state">
<i class="pi pi-shield empty-icon"></i> <i class="pi pi-shield empty-icon"></i>
<p>هیچ نقشی تعریف نشده است</p> <p>هیچ نقشی تعریف نشده است</p>
<Button label="افزودن اولین نقش" icon="pi pi-plus" @click="openCreate" /> <Button label="افزودن اولین نقش" icon="pi pi-plus" @click="openCreate" />
@@ -266,9 +268,7 @@ const confirmDelete = (r, event) => {
<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>
@@ -283,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>
@@ -307,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>
@@ -324,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">
@@ -384,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>
@@ -407,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>
+453
View File
@@ -0,0 +1,453 @@
<script setup>
import { onMounted, ref, computed } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { SettingService } from '@/service/SettingService';
import { useRouter } from 'vue-router';
const toast = useToast();
const confirm = useConfirm();
const router = useRouter();
const loading = ref(false);
const saving = ref(false);
const settings = ref([]);
// Seeder State
const seedStatus = ref({ isSeeded: false, seededAt: null });
const isSeeding = ref(false);
const seedLoading = ref(false);
// Auto-Save Settings State
const autoSaveSettings = ref({ enabled: true, intervalSeconds: 60 });
const autoSaveLoading = ref(false);
const autoSaveSaving = ref(false);
const autoSaveIntervalOptions = [
{ label: '۳۰ ثانیه (سریع)', value: 30 },
{ label: '۱ دقیقه (پیش‌فرض)', value: 60 },
{ label: '۲ دقیقه', value: 120 },
{ label: '۳ دقیقه', value: 180 },
{ label: '۵ دقیقه', value: 300 },
{ label: '۱۰ دقیقه', value: 600 }
];
const methodOptions = [
{ label: 'Typesense', value: 'typesense', icon: 'pi pi-bolt', class: 'text-amber-500' },
{ label: 'MongoDB', value: 'mongo', icon: 'pi pi-database', class: 'text-emerald-500' }
];
onMounted(async () => {
await Promise.all([loadSettings(), loadSeedStatus(), loadAutoSaveSettings()]);
});
const loadAutoSaveSettings = async () => {
autoSaveLoading.value = true;
try {
const data = await SettingService.getAutoSaveSetting();
if (data) {
autoSaveSettings.value = {
enabled: data.enabled !== undefined ? data.enabled : true,
intervalSeconds: Number(data.intervalSeconds) || 60
};
}
} catch (e) {
console.warn('Failed to load autosave settings:', e);
} finally {
autoSaveLoading.value = false;
}
};
const saveAutoSaveSettings = async () => {
autoSaveSaving.value = true;
try {
await SettingService.updateAutoSaveSetting(autoSaveSettings.value);
toast.add({
severity: 'success',
summary: 'موفقیت‌آمیز',
detail: 'تنظیمات ذخیره خودکار با موفقیت ذخیره شد.',
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: 'خطا',
detail: 'خطا در ذخیره تنظیمات ذخیره خودکار',
life: 3000
});
} finally {
autoSaveSaving.value = false;
}
};
const loadSeedStatus = async () => {
seedLoading.value = true;
try {
const data = await SettingService.getSeedStatus();
seedStatus.value = data || { isSeeded: false, seededAt: null };
} catch (e) {
console.warn('Failed to load seed status:', e);
} finally {
seedLoading.value = false;
}
};
const handleSeedDatabase = () => {
if (seedStatus.value.isSeeded) return;
confirm.require({
message:
'توجه: این عملیات داده‌های اولیه سیستم (کاربران پیش‌فرض، نقش‌ها، دسته‌بندی‌ها، ویژگی‌های تکمیلی و وضعیت‌های املاک) را در پایگاه داده ایجاد می‌کند. این عملیات فقط یک‌بار قابل اجراست و پس از انجام دکمه برای همیشه قفل خواهد شد. آیا ادامه می‌دهید؟',
header: 'تایید مقداردهی اولیه پایگاه داده (Seed Database)',
icon: 'pi pi-exclamation-triangle',
rejectProps: { label: 'انصراف', severity: 'secondary', outlined: true },
acceptProps: { label: 'تایید و شروع مقداردهی', severity: 'primary' },
accept: async () => {
isSeeding.value = true;
try {
const res = await SettingService.seedDatabase();
seedStatus.value = {
isSeeded: true,
seededAt: res.seededAt || new Date()
};
toast.add({
severity: 'success',
summary: 'موفقیت‌آمیز',
detail: res.message || 'پایگاه داده با موفقیت مقداردهی شد و قفل گردید.',
life: 5000
});
await loadSettings();
} catch (err) {
const errMsg = err?.response?.data?.message || err.message || 'خطا در مقداردهی پایگاه داده';
toast.add({
severity: 'error',
summary: 'خطا در مقداردهی',
detail: errMsg,
life: 4000
});
await loadSeedStatus();
} finally {
isSeeding.value = false;
}
}
});
};
const loadSettings = async () => {
loading.value = true;
try {
const data = await SettingService.getSearchSettings();
settings.value = data;
} catch (e) {
toast.add({
severity: 'error',
summary: 'خطا',
detail: 'خطا در بارگذاری تنظیمات جستجو',
life: 3000
});
} finally {
loading.value = false;
}
};
const stats = computed(() => {
const total = settings.value.length;
const mongoCount = settings.value.filter((s) => s.searchMethod === 'mongo').length;
const typesenseCount = settings.value.filter((s) => s.searchMethod === 'typesense').length;
const persistedCount = settings.value.filter((s) => s.isPersisted).length;
return { total, mongoCount, typesenseCount, persistedCount };
});
const updateMethod = async (pageKey, newMethod) => {
try {
const updated = await SettingService.updateSearchSetting(pageKey, newMethod);
const index = settings.value.findIndex((s) => s.pageKey === pageKey);
if (index !== -1) {
settings.value[index] = { ...settings.value[index], ...updated };
}
toast.add({
severity: 'success',
summary: 'بروزرسانی شد',
detail: `روش جستجوی "${updated.title}" به ${newMethod === 'mongo' ? 'MongoDB' : 'Typesense'} تغییر یافت.`,
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: 'خطا',
detail: 'خطا در ذخیره‌سازی تنظیمات در پایگاه داده',
life: 3000
});
}
};
const setAllToMethod = async (method) => {
confirm.require({
message: `آیا از تغییر روش جستجوی تمامی بخش‌ها به ${method === 'mongo' ? 'MongoDB' : 'Typesense'} اطمینان دارید؟`,
header: 'تایید تغییر همگانی',
icon: 'pi pi-exclamation-triangle',
rejectProps: { label: 'انصراف', severity: 'secondary', outlined: true },
acceptProps: { label: 'تایید و اعمال', severity: method === 'mongo' ? 'success' : 'warn' },
accept: async () => {
saving.value = true;
try {
const payload = settings.value.map((s) => ({
pageKey: s.pageKey,
searchMethod: method
}));
const updatedList = await SettingService.bulkUpdateSearchSettings(payload);
settings.value = updatedList;
toast.add({
severity: 'success',
summary: 'موفقیت‌آمیز',
detail: `تمامی بخش‌ها با موفقیت به ${method === 'mongo' ? 'MongoDB' : 'Typesense'} تغییر یافتند.`,
life: 4000
});
} catch (e) {
toast.add({
severity: 'error',
summary: 'خطا',
detail: 'خطا در بروزرسانی همگانی تنظیمات',
life: 3000
});
} finally {
saving.value = false;
}
}
});
};
const navigateToModule = (routePath) => {
if (routePath) {
router.push(routePath);
}
};
</script>
<template>
<div class="search-settings-page">
<Toast />
<ConfirmDialog />
<!-- ── Header Banner ── -->
<div class="card p-6 mb-6 shadow-sm border border-surface-200 dark:border-surface-700 bg-surface-0 dark:bg-surface-900 rounded-2xl">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-2xl bg-primary-100 dark:bg-primary-950 flex items-center justify-center text-primary-600 dark:text-primary-400">
<i class="pi pi-sliders-h text-2xl"></i>
</div>
<div>
<h2 class="text-2xl font-bold m-0 text-surface-900 dark:text-surface-0">تنظیمات موتور جستجو</h2>
<p class="text-surface-500 dark:text-surface-400 m-0 mt-1 text-sm">انتخاب روش جستجو (MongoDB یا Typesense) برای هر بخش از سیستم به صورت مجزا</p>
</div>
</div>
<!-- Stats Badges -->
<div class="flex flex-wrap items-center gap-2">
<div class="flex items-center gap-2 px-3 py-2 rounded-xl bg-surface-100 dark:bg-surface-800 text-surface-700 dark:text-surface-300 text-xs font-semibold">
<i class="pi pi-list text-primary-500"></i>
<span>کل بخش‌ها: {{ stats.total }}</span>
</div>
<div class="flex items-center gap-2 px-3 py-2 rounded-xl bg-emerald-50 dark:bg-emerald-950 text-emerald-700 dark:text-emerald-300 text-xs font-semibold">
<i class="pi pi-database text-emerald-500"></i>
<span>MongoDB: {{ stats.mongoCount }}</span>
</div>
<div class="flex items-center gap-2 px-3 py-2 rounded-xl bg-amber-50 dark:bg-amber-950 text-amber-700 dark:text-amber-300 text-xs font-semibold">
<i class="pi pi-bolt text-amber-500"></i>
<span>Typesense: {{ stats.typesenseCount }}</span>
</div>
</div>
</div>
</div>
<!-- ── Database Seeder Section (One-Time Execution) ── -->
<div class="card p-6 mb-6 shadow-sm border border-surface-200 dark:border-surface-700 bg-surface-0 dark:bg-surface-900 rounded-2xl">
<div class="flex flex-col lg:flex-row lg:items-center justify-between gap-6">
<div class="flex items-start gap-4">
<div
class="w-14 h-14 rounded-2xl flex items-center justify-center text-2xl flex-shrink-0 transition-colors"
:class="seedStatus.isSeeded ? 'bg-surface-100 text-surface-500 dark:bg-surface-800 dark:text-surface-400' : 'bg-indigo-100 text-indigo-600 dark:bg-indigo-950 dark:text-indigo-400 shadow-sm'"
>
<i :class="seedStatus.isSeeded ? 'pi pi-lock' : 'pi pi-database'"></i>
</div>
<div>
<div class="flex items-center gap-2.5 flex-wrap">
<h3 class="text-xl font-bold m-0 text-surface-900 dark:text-surface-0">مقداردهی اولیه پایگاه داده (Seed Database)</h3>
<Tag v-if="seedStatus.isSeeded" value="مقداردهی شده و قفل است" severity="success" icon="pi pi-lock" rounded class="px-2.5 text-xs font-bold" />
<Tag v-else value="آماده مقداردهی (یک‌بار مصرف)" severity="warn" icon="pi pi-exclamation-circle" rounded class="px-2.5 text-xs font-bold" />
</div>
<p class="text-surface-500 dark:text-surface-400 m-0 mt-1.5 text-sm leading-relaxed max-w-2xl">
ایجاد خودکار کاربران اولیه، نقش‌ها و سطوح دسترسی، دسته‌بندی‌های املاک، ویژگی‌های تکمیلی و وضعیت‌های املاک در دیتابیس. این عملیات صرفاً یک‌بار قابل اجراست و پس از انجام دکمه برای همیشه قفل می‌شود.
</p>
<div v-if="seedStatus.isSeeded && seedStatus.seededAt" class="mt-2 text-xs text-surface-400 flex items-center gap-1.5 font-mono">
<i class="pi pi-calendar text-xs"></i>
<span>تاریخ اجرا: {{ new Date(seedStatus.seededAt).toLocaleString('fa-IR') }}</span>
</div>
</div>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<Button v-if="!seedStatus.isSeeded" label="اجرای مقداردهی اولیه داده‌ها" icon="pi pi-database" severity="primary" rounded :loading="isSeeding" @click="handleSeedDatabase" class="px-5 font-semibold" />
<Button v-else label="پایگاه داده مقداردهی شده است" icon="pi pi-lock" severity="secondary" rounded disabled class="px-5 font-semibold opacity-70 cursor-not-allowed" />
</div>
</div>
</div>
<!-- ── Auto-Save Settings Card ── -->
<div class="card p-6 mb-6 shadow-sm border border-surface-200 dark:border-surface-700 bg-surface-0 dark:bg-surface-900 rounded-2xl">
<div class="flex flex-col lg:flex-row lg:items-center justify-between gap-6">
<div class="flex items-start gap-4">
<div class="w-14 h-14 rounded-2xl flex items-center justify-center text-2xl flex-shrink-0 bg-emerald-100 text-emerald-600 dark:bg-emerald-950 dark:text-emerald-400 shadow-sm">
<i class="pi pi-save"></i>
</div>
<div>
<div class="flex items-center gap-2.5 flex-wrap">
<h3 class="text-xl font-bold m-0 text-surface-900 dark:text-surface-0">تنظیمات ذخیره خودکار (Auto-Save)</h3>
<Tag :value="autoSaveSettings.enabled ? 'فعال' : 'غیرفعال'" :severity="autoSaveSettings.enabled ? 'success' : 'secondary'" rounded class="px-2.5 text-xs font-bold" />
</div>
<p class="text-surface-500 dark:text-surface-400 m-0 mt-1.5 text-sm leading-relaxed max-w-2xl">
پیکربندی ذخیره پس‌زمینه خودکار فرم‌ها و املاک. در صورت فعال بودن، تغییرات شما بدون نیاز به فشردن دستی دکمه ذخیره، در بازه‌های مشخص ذخیره شده و اعلان آن در گوشه صفحه نمایش داده می‌شود.
</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-4 flex-shrink-0">
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-surface-600 dark:text-surface-300">فعال بودن:</span>
<ToggleSwitch v-model="autoSaveSettings.enabled" />
</div>
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-surface-600 dark:text-surface-300">بازه زمانی:</span>
<Dropdown v-model="autoSaveSettings.intervalSeconds" :options="autoSaveIntervalOptions" optionLabel="label" optionValue="value" :disabled="!autoSaveSettings.enabled" class="!rounded-xl text-xs w-44" />
</div>
<Button label="ذخیره تنظیمات" icon="pi pi-check" severity="primary" rounded size="small" :loading="autoSaveSaving" @click="saveAutoSaveSettings" class="px-4 font-semibold" />
</div>
</div>
</div>
<!-- ── Quick Actions Toolbar ── -->
<div class="card p-4 mb-6 flex flex-wrap items-center justify-between gap-4">
<div class="flex flex-wrap items-center gap-3">
<Button label="تنظیم همه روی MongoDB" icon="pi pi-database" severity="success" outlined size="small" :disabled="loading || saving" @click="setAllToMethod('mongo')" />
<Button label="تنظیم همه روی Typesense" icon="pi pi-bolt" severity="warn" outlined size="small" :disabled="loading || saving" @click="setAllToMethod('typesense')" />
</div>
<div class="flex items-center gap-2">
<Button icon="pi pi-refresh" severity="secondary" text rounded :loading="loading" v-tooltip.top="'بارگذاری مجدد'" @click="loadSettings" />
</div>
</div>
<!-- ── Settings Cards Grid ── -->
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div v-for="i in 6" :key="i" class="card p-6">
<Skeleton width="40%" height="1.5rem" class="mb-3" />
<Skeleton width="80%" height="1rem" class="mb-4" />
<Skeleton width="100%" height="2.5rem" />
</div>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div
v-for="item in settings"
:key="item.pageKey"
class="card p-6 rounded-2xl border transition-all duration-200 hover:shadow-md flex flex-col justify-between"
:class="[item.searchMethod === 'mongo' ? 'border-emerald-200 dark:border-emerald-900 bg-emerald-50/10' : 'border-amber-200 dark:border-amber-900 bg-amber-50/10']"
>
<div>
<!-- Card Top Header -->
<div class="flex items-start justify-between gap-3 mb-3">
<div class="flex items-center gap-3">
<div
class="w-10 h-10 rounded-xl flex items-center justify-center text-lg"
:class="[item.searchMethod === 'mongo' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300' : 'bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-300']"
>
<i :class="item.icon || 'pi pi-file'"></i>
</div>
<div>
<h3 class="text-lg font-bold m-0 text-surface-900 dark:text-surface-0">{{ item.title }}</h3>
<span class="text-xs text-surface-500 font-mono">{{ item.pageKey }}</span>
</div>
</div>
<!-- Persistence Badge -->
<Tag :value="item.isPersisted ? 'ذخیره در دیتابیس' : 'پیش‌فرض'" :severity="item.isPersisted ? 'success' : 'secondary'" class="text-xs" />
</div>
<!-- Description -->
<p class="text-sm text-surface-600 dark:text-surface-300 mb-4 leading-relaxed">
{{ item.description }}
</p>
<!-- Endpoints Information -->
<div class="p-3 mb-4 rounded-xl bg-surface-100 dark:bg-surface-800/80 text-xs space-y-1 font-mono">
<div class="flex items-center justify-between">
<span class="text-surface-500">اندپوینت فعال:</span>
<span class="font-bold" :class="item.searchMethod === 'mongo' ? 'text-emerald-600 dark:text-emerald-400' : 'text-amber-600 dark:text-amber-400'">
{{ item.searchMethod === 'mongo' ? item.endpointMongo : item.endpointTypesense }}
</span>
</div>
</div>
</div>
<!-- Bottom Selector & Actions -->
<div class="pt-4 border-t border-surface-200 dark:border-surface-700 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2">
<span class="text-xs font-semibold text-surface-600 dark:text-surface-400">موتور جستجو:</span>
<SelectButton :modelValue="item.searchMethod" :options="methodOptions" optionLabel="label" optionValue="value" :allowEmpty="false" size="small" @update:modelValue="(val) => updateMethod(item.pageKey, val)">
<template #option="slotProps">
<div class="flex items-center gap-1.5 px-1 py-0.5 text-xs font-bold">
<i :class="slotProps.option.icon"></i>
<span>{{ slotProps.option.label }}</span>
</div>
</template>
</SelectButton>
</div>
<Button v-if="item.routePath" label="مشاهده صفحه" icon="pi pi-arrow-left" iconPos="right" size="small" severity="secondary" text @click="navigateToModule(item.routePath)" />
</div>
</div>
</div>
<!-- ── Information Guide ── -->
<div class="card p-6 mt-8 rounded-2xl bg-surface-50 dark:bg-surface-800/50 border border-surface-200 dark:border-surface-700">
<h4 class="text-base font-bold mb-3 flex items-center gap-2 text-surface-800 dark:text-surface-100">
<i class="pi pi-info-circle text-primary-500"></i>
<span>راهنمای روش‌های جستجو</span>
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs leading-relaxed text-surface-600 dark:text-surface-300">
<div class="p-4 rounded-xl bg-surface-0 dark:bg-surface-900 border border-amber-200 dark:border-amber-900/50">
<div class="font-bold text-amber-700 dark:text-amber-400 flex items-center gap-2 mb-1">
<i class="pi pi-bolt"></i>
<span>موتور Typesense</span>
</div>
<p class="m-0">موتور جستجوی ایندکس‌شده با سرعت فوق‌العاده بالا، تحمل خطای تایپی (Typo Tolerance) و سیستم Faceting پیشرفته. نیازمند فرآیند ایندکس‌سازی داده‌ها است.</p>
</div>
<div class="p-4 rounded-xl bg-surface-0 dark:bg-surface-900 border border-emerald-200 dark:border-emerald-900/50">
<div class="font-bold text-emerald-700 dark:text-emerald-400 flex items-center gap-2 mb-1">
<i class="pi pi-database"></i>
<span>موتور MongoDB</span>
</div>
<p class="m-0">جستجوی زنده مستقیم روی پایگاه داده MongoDB با الگوهای عبارت منظم (Regex) و فیلترهای دقیق. بدون نیاز به ایندکس جداگانه و همیشه هماهنگ با آخرین تغییرات پایگاه داده.</p>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.search-settings-page {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
@@ -3,13 +3,29 @@ import { FilterMatchMode } from '@primevue/core/api';
import { useToast } from 'primevue/usetoast'; import { useToast } from 'primevue/usetoast';
import { onMounted, ref, watch } from 'vue'; import { onMounted, ref, watch } from 'vue';
import { TransactionService } from '@/service/TransactionService'; import { TransactionService } from '@/service/TransactionService';
import { SettingService } from '@/service/SettingService';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import Chip from 'primevue/chip'; import Chip from 'primevue/chip';
import Tag from 'primevue/tag';
import InputIcon from 'primevue/inputicon'; import InputIcon from 'primevue/inputicon';
import IconField from 'primevue/iconfield'; import IconField from 'primevue/iconfield';
import LoadingCheck from '@/components/LoadingCheck.vue'; import LoadingCheck from '@/components/LoadingCheck.vue';
import Popover from 'primevue/popover'; import Popover from 'primevue/popover';
onMounted(() => {
const searchMethod = ref(null);
const loadSearchSetting = async () => {
try {
const setting = await SettingService.getSearchSettingByPage('transactions');
searchMethod.value = setting?.searchMethod || 'typesense';
} catch (e) {
console.warn('Failed to load search setting for transactions, defaulting to typesense', e);
searchMethod.value = 'typesense';
}
};
onMounted(async () => {
await loadSearchSetting();
loadData(); loadData();
}); });
@@ -74,7 +90,7 @@ watch(
{ deep: true } { deep: true }
); );
const handleRemoveSearch = () => { const handleRemoveSearch = () => {
removeFromQuery('searchPhrase'); // Ensure 'searchPhrase' is the correct key removeFromQuery('query');
searchPhrase.value = ''; searchPhrase.value = '';
}; };
@@ -105,18 +121,29 @@ const onPageChange = (event) => {
} }
}; };
const loadData = () => { const dataLoading = ref(true);
TransactionService.searchTransactions(route.query)
.then((response) => { const loadData = async () => {
console.log('response', response); dataLoading.value = true;
transactions.value = response.transactions || response; try {
totalCount.value = response.totalCount || response.length || 0; if (searchMethod.value === null) {
pagesCount.value = Math.ceil(totalCount.value / per_page.value); await loadSearchSetting();
}) }
.catch((error) => { let response;
toast.add({ severity: 'error', summary: 'Error', detail: 'خطا در دریافت اطلاعات', life: 3000 }); if (searchMethod.value === 'mongo') {
console.error('Error loading data:', error); response = await TransactionService.searchTransactionsMongo(route.query);
}); } else {
response = await TransactionService.searchTransactions(route.query);
}
transactions.value = response.transactions || response;
totalCount.value = response.totalCount || response.length || 0;
pagesCount.value = Math.ceil(totalCount.value / per_page.value);
} catch (error) {
toast.add({ severity: 'error', summary: 'Error', detail: 'خطا در دریافت اطلاعات', life: 3000 });
console.error('Error loading data:', error);
} finally {
dataLoading.value = false;
}
}; };
const loading = ref(false); const loading = ref(false);
@@ -254,6 +281,7 @@ const toggle = (event, data) => {
ref="dt" ref="dt"
v-model:selection="selectedTransactions" v-model:selection="selectedTransactions"
:value="transactions" :value="transactions"
:loading="dataLoading"
dataKey="id" dataKey="id"
:paginator="true" :paginator="true"
:rows="per_page" :rows="per_page"
@@ -269,11 +297,35 @@ const toggle = (event, data) => {
filterDisplay="menu" filterDisplay="menu"
:globalFilterFields="['type', 'status', 'belongingName', 'createdAt', 'description']" :globalFilterFields="['type', 'status', 'belongingName', 'createdAt', 'description']"
> >
<template #loading>
<div class="flex flex-col items-center justify-center p-8 gap-3">
<ProgressSpinner style="width: 42px; height: 42px" strokeWidth="4" />
<span class="text-sm font-medium text-surface-500">در حال بارگذاری تراکنش‌ها...</span>
</div>
</template>
<template #empty>
<div v-if="!dataLoading" class="p-8 text-center text-surface-500">
<i class="pi pi-inbox text-4xl mb-2 text-surface-400"></i>
<p>هیچ تراکنشی یافت نشد.</p>
</div>
</template>
<template #header> <template #header>
<div class=""> <div class="flex flex-wrap items-center justify-between gap-3">
<h4 class="m-0">مدیریت تراکنش‌ها</h4> <div class="flex items-center gap-3">
<div class="p-input-icon-right w-1/3 mt-2 lg:mt-0"> <h4 class="m-0 font-bold">مدیریت تراکنش‌ها</h4>
<InputText v-model="searchPhrase" placeholder="جستجو..." class="w-full" /> <Tag
v-if="searchMethod"
:value="searchMethod === 'mongo' ? 'جستجو: MongoDB' : 'جستجو: Typesense'"
:severity="searchMethod === 'mongo' ? 'success' : 'info'"
:icon="searchMethod === 'mongo' ? 'pi pi-database' : 'pi pi-bolt'"
class="cursor-pointer text-xs"
v-tooltip.bottom="'کلیک برای تغییر موتور جستجو در تنظیمات'"
@click="router.push('/settings/search-settings')"
/>
</div>
<div class="p-input-icon-right w-full sm:w-72 mt-2 lg:mt-0">
<InputText v-model="searchPhrase" placeholder="جستجو در تراکنش‌ها..." class="w-full" />
</div> </div>
</div> </div>
<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" />
+18 -61
View File
@@ -11,8 +11,9 @@ const confirm = useConfirm();
const users = ref([]); const users = ref([]);
const roles = ref([]); const roles = ref([]);
const loading = ref(false); const loading = ref(true);
const showDialog = ref(false); const showDialog = ref(false);
const editMode = ref(false); const editMode = ref(false);
const submitting = ref(false); const submitting = ref(false);
const selectedUser = ref(null); const selectedUser = ref(null);
@@ -246,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>
@@ -266,8 +258,15 @@ const getInitials = (name, email) => {
</div> </div>
</template> </template>
<template #loading>
<div class="flex flex-col items-center justify-center p-8 gap-3">
<ProgressSpinner style="width: 42px; height: 42px" strokeWidth="4" />
<span class="text-sm font-medium text-surface-500">در حال بارگذاری کاربران...</span>
</div>
</template>
<template #empty> <template #empty>
<div class="empty-state"> <div v-if="!loading" class="empty-state">
<i class="pi pi-user-minus empty-icon"></i> <i class="pi pi-user-minus empty-icon"></i>
<p>هیچ کاربری یافت نشد</p> <p>هیچ کاربری یافت نشد</p>
<Button label="افزودن اولین کاربر" icon="pi pi-plus" @click="openCreate" /> <Button label="افزودن اولین کاربر" icon="pi pi-plus" @click="openCreate" />
@@ -303,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>
@@ -316,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>
@@ -335,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>
@@ -351,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>
@@ -375,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">
@@ -413,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,10 +234,8 @@ 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> موجودی صفر است <ProgressSpinner v-if="stockLoading" style="width: 20px; height: 20px" strokeWidth="5" />
</span>
<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,31 +344,59 @@ 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 {
background: linear-gradient(135deg, #22c55e, #16a34a); background: linear-gradient(135deg, #22c55e, #16a34a);
box-shadow: 0 4px 15px rgba(34,197,94,0.35); box-shadow: 0 4px 15px rgba(34, 197, 94, 0.35);
} }
.icon-out { .icon-out {
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-subtitle {
margin: 0;
font-size: 0.875rem;
color: var(--text-color-secondary);
}
.header-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
} }
.page-title { margin: 0; font-size: 1.5rem; font-weight: 700; }
.page-subtitle { margin: 0; font-size: 0.875rem; color: var(--text-color-secondary); }
.header-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
/* Layout */ /* 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,52 +438,132 @@ 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;
background: rgba(99,102,241,0.08); align-items: center;
border: 1px solid rgba(99,102,241,0.25); gap: 0.75rem;
background: rgba(99, 102, 241, 0.08);
border: 1px solid rgba(99, 102, 241, 0.25);
border-radius: 10px; border-radius: 10px;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
font-size: 0.9rem; font-size: 0.9rem;
} }
.stock-preview.stock-zero { .stock-preview.stock-zero {
background: rgba(239,68,68,0.08); background: rgba(239, 68, 68, 0.08);
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,9 +574,12 @@ 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;
color: #6366f1; color: #6366f1;
font-weight: 600; font-weight: 600;
@@ -531,42 +587,100 @@ 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;
background: rgba(99,102,241,0.1); background: rgba(99, 102, 241, 0.1);
color: #6366f1; color: #6366f1;
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>
@@ -11,8 +11,9 @@ const toast = useToast();
const transactions = ref([]); const transactions = ref([]);
const totalCount = ref(0); const totalCount = ref(0);
const loading = ref(false); const loading = ref(true);
const page = ref(1); const page = ref(1);
const perPage = ref(30); const perPage = ref(30);
// Filters // Filters
@@ -21,7 +22,7 @@ const filters = ref({
warehouseId: '', warehouseId: '',
type: '', type: '',
dateFrom: '', dateFrom: '',
dateTo: '', dateTo: ''
}); });
const items = ref([]); const items = ref([]);
@@ -29,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 () => {
@@ -38,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) {
@@ -54,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;
@@ -92,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">
@@ -122,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>
@@ -196,24 +173,27 @@ const formatDate = (d) => {
</div> </div>
</template> </template>
<template #loading>
<div class="flex flex-col items-center justify-center p-8 gap-3">
<ProgressSpinner style="width: 42px; height: 42px" strokeWidth="4" />
<span class="text-sm font-medium text-surface-500">در حال بارگذاری تراکنش‌های انبار...</span>
</div>
</template>
<template #empty> <template #empty>
<div class="empty-state"> <div v-if="!loading" class="empty-state">
<i class="pi pi-inbox"></i> <i class="pi pi-inbox"></i>
<p>هیچ تراکنشی یافت نشد</p> <p>هیچ تراکنشی یافت نشد</p>
</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>
<Column header="قلم انبار" style="width:16%"> <Column header="قلم انبار" style="width: 16%">
<template #body="{ data }"> <template #body="{ data }">
<div class="item-cell"> <div class="item-cell">
<span class="item-name-fa">{{ data.item?.nameFa || '—' }}</span> <span class="item-name-fa">{{ data.item?.nameFa || '—' }}</span>
@@ -222,7 +202,7 @@ const formatDate = (d) => {
</template> </template>
</Column> </Column>
<Column header="مقدار" style="width:12%"> <Column header="مقدار" style="width: 12%">
<template #body="{ data }"> <template #body="{ data }">
<span class="qty-display" :class="data.type === 'IN' ? 'qty-in' : 'qty-out'"> <span class="qty-display" :class="data.type === 'IN' ? 'qty-in' : 'qty-out'">
{{ data.type === 'OUT' ? '−' : '+' }}{{ data.quantity.toLocaleString('fa-IR') }} {{ data.type === 'OUT' ? '−' : '+' }}{{ data.quantity.toLocaleString('fa-IR') }}
@@ -231,16 +211,16 @@ const formatDate = (d) => {
</template> </template>
</Column> </Column>
<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>
</Column> </Column>
<Column header="دلیل" style="width:18%"> <Column header="دلیل" style="width: 18%">
<template #body="{ data }"> <template #body="{ data }">
<div> <div>
<span class="reason-text">{{ data.reason }}</span> <span class="reason-text">{{ data.reason }}</span>
@@ -249,32 +229,24 @@ const formatDate = (d) => {
</template> </template>
</Column> </Column>
<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>
</Column> </Column>
<Column header="تاریخ" style="width:14%"> <Column header="تاریخ" style="width: 14%">
<template #body="{ data }"> <template #body="{ data }">
<span class="date-text">{{ formatDate(data.date || data.createdAt) }}</span> <span class="date-text">{{ formatDate(data.date || data.createdAt) }}</span>
</template> </template>
</Column> </Column>
<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>
@@ -283,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;
box-shadow: 0 4px 15px rgba(14,165,233,0.35); justify-content: center;
font-size: 1.5rem;
color: #fff;
box-shadow: 0 4px 15px rgba(14, 165, 233, 0.35);
}
.page-title {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
}
.page-subtitle {
margin: 0;
color: var(--text-color-secondary);
font-size: 0.875rem;
} }
.page-title { margin: 0; font-size: 1.5rem; font-weight: 700; }
.page-subtitle { margin: 0; color: var(--text-color-secondary); font-size: 0.875rem; }
/* Filters */ /* Filters */
.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;
@@ -322,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>
@@ -16,7 +16,7 @@ const totalCount = ref(0);
const page = ref(Number(route.query.page) || 1); const page = ref(Number(route.query.page) || 1);
const perPage = ref(Number(route.query.per_page) || 20); const perPage = ref(Number(route.query.per_page) || 20);
const searchPhrase = ref(route.query.search || ''); const searchPhrase = ref(route.query.search || '');
const loading = ref(false); const loading = ref(true);
// Create/Edit dialog // Create/Edit dialog
const showDialog = ref(false); const showDialog = ref(false);
@@ -47,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;
@@ -63,12 +63,16 @@ watch(searchPhrase, (val) => {
}, 400); }, 400);
}); });
watch(() => route.query, () => { watch(
page.value = Number(route.query.page) || 1; () => route.query,
perPage.value = Number(route.query.per_page) || 20; () => {
searchPhrase.value = route.query.search || ''; page.value = Number(route.query.page) || 1;
loadData(); perPage.value = Number(route.query.per_page) || 20;
}, { deep: true }); searchPhrase.value = route.query.search || '';
loadData();
},
{ deep: true }
);
const loadData = async () => { const loadData = async () => {
loading.value = true; loading.value = true;
@@ -168,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">
@@ -181,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>
@@ -217,22 +215,29 @@ const viewStock = (itemId) => {
</div> </div>
</template> </template>
<template #loading>
<div class="flex flex-col items-center justify-center p-8 gap-3">
<ProgressSpinner style="width: 42px; height: 42px" strokeWidth="4" />
<span class="text-sm font-medium text-surface-500">در حال بارگذاری اقلام انبار...</span>
</div>
</template>
<template #empty> <template #empty>
<div class="empty-state"> <div v-if="!loading" class="empty-state">
<i class="pi pi-inbox empty-icon"></i> <i class="pi pi-inbox empty-icon"></i>
<p>هیچ قلمی یافت نشد</p> <p>هیچ قلمی یافت نشد</p>
<Button label="افزودن اولین قلم" icon="pi pi-plus" @click="openCreateDialog" /> <Button label="افزودن اولین قلم" icon="pi pi-plus" @click="openCreateDialog" />
</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>
<span class="text-muted" v-else>—</span> <span class="text-muted" v-else>—</span>
</template> </template>
</Column> </Column>
<Column field="nameFa" header="نام فارسی" sortable style="width:18%"> <Column field="nameFa" header="نام فارسی" sortable style="width: 18%">
<template #body="{ data }"> <template #body="{ data }">
<div class="item-name"> <div class="item-name">
<span class="name-fa">{{ data.nameFa }}</span> <span class="name-fa">{{ data.nameFa }}</span>
@@ -240,58 +245,37 @@ const viewStock = (itemId) => {
</template> </template>
</Column> </Column>
<Column field="nameEn" header="نام انگلیسی" sortable style="width:18%"> <Column field="nameEn" header="نام انگلیسی" sortable style="width: 18%">
<template #body="{ data }"> <template #body="{ data }">
<span class="name-en">{{ data.nameEn }}</span> <span class="name-en">{{ data.nameEn }}</span>
</template> </template>
</Column> </Column>
<Column header="واحد" style="width:12%"> <Column header="واحد" style="width: 12%">
<template #body="{ data }"> <template #body="{ data }">
<span class="unit-badge">{{ data.unitFa }} / {{ data.unitEn }}</span> <span class="unit-badge">{{ data.unitFa }} / {{ data.unitEn }}</span>
</template> </template>
</Column> </Column>
<Column field="category" header="دسته‌بندی" style="width:14%"> <Column field="category" header="دسته‌بندی" style="width: 14%">
<template #body="{ data }"> <template #body="{ data }">
<span v-if="data.category" class="category-chip">{{ data.category }}</span> <span v-if="data.category" class="category-chip">{{ data.category }}</span>
<span v-else class="text-muted">—</span> <span v-else class="text-muted">—</span>
</template> </template>
</Column> </Column>
<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>
@@ -299,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">
@@ -333,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>
@@ -359,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>
@@ -434,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 {
@@ -458,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;
@@ -466,7 +436,7 @@ const viewStock = (itemId) => {
display: inline-block; display: inline-block;
} }
.unit-badge { .unit-badge {
background: linear-gradient(135deg, rgba(99,102,241,0.1), rgba(139,92,246,0.1)); background: linear-gradient(135deg, rgba(99, 102, 241, 0.1), rgba(139, 92, 246, 0.1));
color: #6366f1; color: #6366f1;
padding: 3px 10px; padding: 3px 10px;
border-radius: 20px; border-radius: 20px;
@@ -479,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 {
@@ -489,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 {
@@ -517,10 +509,14 @@ 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));
color: #6366f1; color: #6366f1;
} }
.unit-en { .unit-en {
+125 -60
View File
@@ -9,8 +9,9 @@ const toast = useToast();
const confirm = useConfirm(); const confirm = useConfirm();
const warehouses = ref([]); const warehouses = ref([]);
const loading = ref(false); const loading = ref(true);
const showDialog = ref(false); const showDialog = ref(false);
const editMode = ref(false); const editMode = ref(false);
const submitting = ref(false); const submitting = ref(false);
const selectedWarehouse = ref(null); const selectedWarehouse = ref(null);
@@ -20,7 +21,7 @@ const form = ref({
name: '', name: '',
code: '', code: '',
description: '', description: '',
location: { address: '' }, location: { address: '' }
}); });
onMounted(() => loadData()); onMounted(() => loadData());
@@ -52,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;
}; };
@@ -109,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">
@@ -126,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>
@@ -143,53 +137,59 @@ const onSearch = () => {
</div> </div>
</template> </template>
<template #loading>
<div class="flex flex-col items-center justify-center p-8 gap-3">
<ProgressSpinner style="width: 42px; height: 42px" strokeWidth="4" />
<span class="text-sm font-medium text-surface-500">در حال بارگذاری انبارها...</span>
</div>
</template>
<template #empty> <template #empty>
<div class="empty-state"> <div v-if="!loading" class="empty-state">
<i class="pi pi-map empty-icon"></i> <i class="pi pi-map empty-icon"></i>
<p>هیچ انباری تعریف نشده است</p> <p>هیچ انباری تعریف نشده است</p>
<Button label="افزودن اولین انبار" icon="pi pi-plus" @click="openCreate" /> <Button label="افزودن اولین انبار" icon="pi pi-plus" @click="openCreate" />
</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>
<span v-else class="text-muted">—</span> <span v-else class="text-muted">—</span>
</template> </template>
</Column> </Column>
<Column field="name" header="نام انبار" sortable style="width:20%"> <Column field="name" header="نام انبار" sortable style="width: 20%">
<template #body="{ data }"> <template #body="{ data }">
<span class="warehouse-name">{{ data.name }}</span> <span class="warehouse-name">{{ data.name }}</span>
</template> </template>
</Column> </Column>
<Column header="آدرس" style="width:28%"> <Column header="آدرس" style="width: 28%">
<template #body="{ data }"> <template #body="{ data }">
<span class="address-text">{{ data.location?.address || '—' }}</span> <span class="address-text">{{ data.location?.address || '—' }}</span>
</template> </template>
</Column> </Column>
<Column header="مدیر انبار" style="width:16%"> <Column header="مدیر انبار" style="width: 16%">
<template #body="{ data }"> <template #body="{ data }">
{{ data.manager?.name || '—' }} {{ data.manager?.name || '—' }}
</template> </template>
</Column> </Column>
<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:12%"> <Column header="توضیحات" style="width: 12%">
<template #body="{ data }"> <template #body="{ data }">
<span class="desc-text">{{ data.description || '—' }}</span> <span class="desc-text">{{ data.description || '—' }}</span>
</template> </template>
</Column> </Column>
<Column header="عملیات" style="width:7%"> <Column header="عملیات" style="width: 7%">
<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)" />
@@ -201,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">
@@ -231,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;
box-shadow: 0 4px 15px rgba(8,145,178,0.35); justify-content: center;
font-size: 1.5rem;
color: #fff;
box-shadow: 0 4px 15px rgba(8, 145, 178, 0.35);
}
.page-title {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
}
.page-subtitle {
margin: 0;
color: var(--text-color-secondary);
font-size: 0.875rem;
} }
.page-title { margin: 0; font-size: 1.5rem; font-weight: 700; }
.page-subtitle { margin: 0; color: var(--text-color-secondary); font-size: 0.875rem; }
.create-btn { .create-btn {
background: linear-gradient(135deg, #0891b2, #0ea5e9) !important; background: linear-gradient(135deg, #0891b2, #0ea5e9) !important;
border: none !important; border: none !important;
@@ -264,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;
@@ -280,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>
@@ -352,17 +325,9 @@ const getWarehouseName = (id) => {
<template #body="{ data }">{{ formatDate(data.date) }}</template> <template #body="{ data }">{{ formatDate(data.date) }}</template>
</Column> </Column>
<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 {
@@ -517,22 +459,46 @@ const getWarehouseName = (id) => {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1.5rem; gap: 1.5rem;
box-shadow: 0 8px 30px rgba(99,102,241,0.35); box-shadow: 0 8px 30px rgba(99, 102, 241, 0.35);
} }
.summary-icon { .summary-icon {
width: 64px; height: 64px; width: 64px;
background: rgba(255,255,255,0.2); height: 64px;
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;
+12 -12
View File
@@ -16,11 +16,12 @@ const login = async () => {
email: email.value, email: email.value,
password: password.value password: password.value
}); });
const isSecure = typeof window !== 'undefined' && window.location.protocol === 'https:';
const env = import.meta.env.VITE_ENV; const env = import.meta.env.VITE_ENV;
if (env === 'production') { if (env === 'production' && isSecure) {
Cookies.set('bm-token', response.data.data.token, { sameSite: 'none', secure: true, expires: 1000, domain: '.mbmanager.ir' }); Cookies.set('bm-token', response.data.data.token, { sameSite: 'none', secure: true, expires: 1000, domain: '.mbmanager.ir' });
} else { } else {
Cookies.set('bm-token', response.data.data.token, { sameSite: 'none', secure: true, expires: 1000 }); Cookies.set('bm-token', response.data.data.token, { expires: 1000, secure: isSecure, sameSite: isSecure ? 'none' : 'lax' });
} }
localStorage.setItem('bm-token', response.data.data.token); localStorage.setItem('bm-token', response.data.data.token);
await router.push('/'); await router.push('/');
@@ -31,17 +32,16 @@ const login = async () => {
}; };
onBeforeMount(async () => { onBeforeMount(async () => {
const token = Cookies.get('bm-token'); const token = Cookies.get('bm-token') || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
try { if (token) {
const response = await axios.get('/api/users/user/get-self', { try {
headers: { const response = await axios.get('/api/users/user/get-self');
Authorization: `Bearer ${token}` if (response.data?.data?.user) {
await router.push('/');
} }
}); } catch (error) {
console.log(response); console.log(error);
await router.push('/'); }
} catch (error) {
console.log(error);
} }
}); });
</script> </script>
+57 -86
View File
@@ -1,95 +1,66 @@
<template>
<div
:id="galleryId"
class="pswp-gallery pswp-gallery--single-column my-3 flex min-w-0 flex-row flex-nowrap overflow-x-auto rounded-md bg-brand-dark-3 p-3"
>
<div v-for="(image, key) in imagesData" :key="key" class="relative">
<a
:href="image.url"
:data-pswp-width="image.width"
:data-pswp-height="image.height"
target="_blank"
rel="noreferrer"
class="mx-3 block flex-shrink-0"
>
<img :src="image.url" alt="" class="max-h-60 rounded-md" :style="{ height: '150px' }" />
</a>
<button
class="absolute right-4 top-2 flex h-6 w-6 items-center justify-center rounded-full text-brand-danger"
@click="removeImage(key)"
>
<font-awesome-icon icon="fa-solid fa-x" />
</button>
</div>
</div>
</template>
<script setup> <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'
const props = defineProps({
galleryId: {
type: String,
required: true
},
modelValue: {
type: Array,
required: true
}
});
const emit = defineEmits(['update:modelValue']);
const imagesData = ref([]);
const viewerVisible = ref(false);
const viewerInitialIndex = ref(0);
function updateImagesData(images) { function updateImagesData(images) {
for (let i = 0; i < images.length; i++) { if (!images || !Array.isArray(images)) return [];
const img = new Image() return images.map((item) => {
img.src = images[i].url if (typeof item === 'string') {
img.onload = function () { return { url: item, name: item.split('?')[0].split('/').pop() || 'image' };
images[i].height = img.naturalHeight }
images[i].width = img.naturalWidth return { ...item };
} });
}
return images
} }
// Props function openViewer(index) {
const props = defineProps({ viewerInitialIndex.value = index;
galleryId: { viewerVisible.value = true;
type: String, }
required: true
},
modelValue: {
type: Array,
required: true
}
})
const imagesData = ref([]) function removeImage(index) {
const newImages = [...props.modelValue];
let lightbox = null newImages.splice(index, 1);
emit('update:modelValue', newImages);
// Lifecycle hook: mounted }
onMounted(() => {
if (!lightbox) {
console.log('Launching Gallery')
console.log({
gallery: '#' + props.galleryId,
children: 'a'
})
lightbox = new PhotoSwipeLightbox({
gallery: '#' + props.galleryId,
children: 'a',
pswpModule: () => import('photoswipe')
})
lightbox.init()
}
imagesData.value = updateImagesData(props.modelValue)
})
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>
+128 -121
View File
@@ -1,86 +1,60 @@
<template>
<div :id="galleryId" class="pswp-gallery pswp-gallery--single-column my-3 flex min-w-0 flex-row flex-nowrap overflow-x-auto rounded-md bg-brand-dark-3 p-3">
<div v-for="(item, key) in itemsData" :key="key" class="relative mx-3 flex-shrink-0" draggable="true" @dragstart="startDrag($event, key)" @dragover.prevent @dragenter.prevent @drop="onDrop($event, key)">
<!-- Image Display -->
<template v-if="isImageFile(item.url)">
<a :href="item.url" :data-pswp-width="item.width" :data-pswp-height="item.height" target="_blank" rel="noreferrer" class="block">
<img :src="item.url" alt="" class="max-h-60 rounded-md" :style="{ height: '150px' }" />
</a>
</template>
<!-- File Display -->
<template v-else>
<div class="flex h-[150px] w-[200px] flex-col items-center justify-center rounded-md border-2 border-gray-200 bg-white p-4">
<!-- File Icon -->
<font-awesome-icon :icon="getFileIcon(item.url)" class="mb-2 text-4xl" :class="getIconColor(item.url)" />
<!-- File Name -->
<p class="mb-2 max-w-full truncate text-sm font-medium text-gray-700">
{{ getFileName(item.url) }}
</p>
<!-- Download Button -->
<Button icon="pi pi-download" severity="secondary" size="small" @click="downloadFile(item.url)" class="p-button-sm"> دانلود </Button>
</div>
</template>
<!-- Remove Button -->
<button class="absolute right-4 top-2 flex h-6 w-6 items-center justify-center rounded-full text-red-500" @click="removeItem(key, item.url)">
<font-awesome-icon icon="fa-solid fa-x" />
</button>
</div>
</div>
</template>
<script setup> <script setup>
import { ref, watch, onMounted } from 'vue'; import { ref, computed, watch } from 'vue';
import PhotoSwipeLightbox from 'photoswipe/lightbox'; import ImageViewerModal from '@/views/uikit/ImageViewerModal.vue';
import 'photoswipe/style.css';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; const props = defineProps({
import { Button } from 'primevue'; galleryId: {
type: String,
required: true
},
modelValue: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['update:modelValue', 'delete-image']);
const itemsData = ref([]);
const viewerVisible = ref(false);
const viewerInitialIndex = ref(0);
// File type checking functions
function isImageFile(url) { function isImageFile(url) {
const extension = url.toLowerCase().split('.').pop(); if (!url || typeof url !== 'string') return false;
return ['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(extension); const cleanUrl = url.split('?')[0];
const ext = cleanUrl.toLowerCase().split('.').pop();
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'avif'].includes(ext) || cleanUrl.startsWith('blob:') || cleanUrl.startsWith('data:image/');
} }
function getFileIcon(url) { function getFileIconClass(url) {
const extension = url.toLowerCase().split('.').pop(); if (!url || typeof url !== 'string') return 'pi pi-file';
const iconMap = { const cleanUrl = url.split('?')[0];
pdf: 'fa-solid fa-file-pdf', const ext = cleanUrl.toLowerCase().split('.').pop();
doc: 'fa-solid fa-file-word', if (ext === 'pdf') return 'pi pi-file-pdf';
docx: 'fa-solid fa-file-word', if (['doc', 'docx'].includes(ext)) return 'pi pi-file-word';
xls: 'fa-solid fa-file-excel', if (['xls', 'xlsx', 'csv'].includes(ext)) return 'pi pi-file-excel';
xlsx: 'fa-solid fa-file-excel', return 'pi pi-file';
txt: 'fa-solid fa-file-lines',
zip: 'fa-solid fa-file-zipper'
// Add more file types as needed
};
return iconMap[extension] || 'fa-solid fa-file';
} }
function getIconColor(url) { function getFileIconColor(url) {
const extension = url.toLowerCase().split('.').pop(); if (!url || typeof url !== 'string') return 'text-surface-500';
const colorMap = { const cleanUrl = url.split('?')[0];
pdf: 'text-red-500', const ext = cleanUrl.toLowerCase().split('.').pop();
doc: 'text-blue-500', if (ext === 'pdf') return 'text-red-500';
docx: 'text-blue-500', if (['doc', 'docx'].includes(ext)) return 'text-blue-500';
xls: 'text-green-500', if (['xls', 'xlsx', 'csv'].includes(ext)) return 'text-emerald-500';
xlsx: 'text-green-500', return 'text-primary-500';
txt: 'text-gray-500',
zip: 'text-yellow-600'
// Add more file types as needed
};
return colorMap[extension] || 'text-gray-400';
} }
function getFileName(url) { function getFileName(url) {
return url.split('/').pop(); if (!url || typeof url !== 'string') return 'file';
} const cleanUrl = url.split('?')[0];
const raw = cleanUrl.split('/').pop() || 'file';
function downloadFile(url) { try {
window.open(url, '_blank'); return decodeURIComponent(raw);
} catch {
return raw;
}
} }
function updateItemsData(items) { function updateItemsData(items) {
@@ -89,44 +63,26 @@ function updateItemsData(items) {
} }
return items.map((item) => { return items.map((item) => {
if (item && item.url && isImageFile(item.url)) { if (typeof item === 'string') {
// Handle images return {
const img = new Image(); url: item,
img.src = item.url; name: item.split('?')[0].split('/').pop() || 'file'
img.onload = function () {
item.height = img.naturalHeight;
item.width = img.naturalWidth;
}; };
} }
return item; return { ...item };
}); });
} }
// Props
const props = defineProps({ // Extract only images for the lightbox viewer
galleryId: { const imageItems = computed(() => {
type: String, return itemsData.value.filter((item) => isImageFile(item.url));
required: true
},
modelValue: {
type: Array,
required: true
}
}); });
const itemsData = ref([]); function openViewer(clickedItem) {
let lightbox = null; const idx = imageItems.value.findIndex((img) => img.url === clickedItem.url);
viewerInitialIndex.value = idx >= 0 ? idx : 0;
onMounted(() => { viewerVisible.value = true;
if (!lightbox) { }
lightbox = new PhotoSwipeLightbox({
gallery: '#' + props.galleryId,
children: 'a',
pswpModule: () => import('photoswipe')
});
lightbox.init();
}
itemsData.value = updateItemsData(props.modelValue);
});
watch( watch(
() => props.modelValue, () => props.modelValue,
@@ -136,8 +92,6 @@ watch(
{ deep: true, immediate: true } { deep: true, immediate: true }
); );
const emit = defineEmits(['update:modelValue', 'delete-image']);
function removeItem(index, url) { function removeItem(index, url) {
const newItems = [...props.modelValue]; const newItems = [...props.modelValue];
newItems.splice(index, 1); newItems.splice(index, 1);
@@ -148,24 +102,77 @@ function removeItem(index, url) {
function startDrag(event, index) { function startDrag(event, index) {
event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.dropEffect = 'move'; event.dataTransfer.dropEffect = 'move';
event.dataTransfer.setData('imageIndex', index); event.dataTransfer.setData('imageIndex', index.toString());
} }
function onDrop(event, dropIndex) { function onDrop(event, dropIndex) {
const dragIndex = event.dataTransfer.getData('imageIndex'); const dragIndexStr = event.dataTransfer.getData('imageIndex');
const dragIndex = parseInt(dragIndexStr, 10);
if (isNaN(dragIndex) || dragIndex === dropIndex) return;
if (dragIndex === dropIndex.toString()) return; const newItems = [...props.modelValue];
const draggedItem = newItems.splice(dragIndex, 1)[0];
newItems.splice(dropIndex, 0, draggedItem);
// Create a copy of the images array emit('update:modelValue', newItems);
const newImages = [...props.modelValue];
// Remove the dragged item
const draggedItem = newImages.splice(dragIndex, 1)[0];
// Insert it at the new position
newImages.splice(dropIndex, 0, draggedItem);
// Update the model
emit('update:modelValue', newImages);
} }
</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>
+201 -99
View File
@@ -1,62 +1,52 @@
<template> <script>
<div id="app">
<FilePond
ref="pond"
name="filepond-image"
:max-files="maxFiles"
store-as-file="true"
:server="{
process: {
url: API_URL + uploadEndpoint,
method: 'POST',
withCredentials: true,
headers: {
authorization: 'Bearer ' + Cookies.get('bm-token')
},
onload: (response) => handleProcessResponse(response)
},
revert: {
url: API_URL + deleteEndpoint,
method: 'DELETE',
withCredentials: true,
headers: {
authorization: 'Bearer ' + Cookies.get('bm-token')
},
onload: (response) => handleRevertResponse(response)
}
}"
label-idle="برای آپلود کلیک کنید"
label-tap-to-undo=" برای حذف کلیک کنید "
label-file-processing=" در حال آپلود "
label-file-processing-complete=" آپلود فایل تکمیل شد "
:allow-multiple="true"
v-model="uploadedFiles"
/>
</div>
</template>
<script setup>
import { ref, watch, defineProps, computed } from 'vue';
import Cookies from 'js-cookie';
import vueFilePond from 'vue-filepond'; import vueFilePond from 'vue-filepond';
import 'filepond/dist/filepond.min.css'; import 'filepond/dist/filepond.min.css';
import 'filepond-plugin-image-preview/dist/filepond-plugin-image-preview.min.css'; import 'filepond-plugin-image-preview/dist/filepond-plugin-image-preview.min.css';
import FilePondPluginFileValidateType from 'filepond-plugin-file-validate-type'; import FilePondPluginFileValidateType from 'filepond-plugin-file-validate-type';
import FilePondPluginImagePreview from 'filepond-plugin-image-preview'; import FilePondPluginImagePreview from 'filepond-plugin-image-preview';
// Define props // Create FilePond component at module level
const FilePond = vueFilePond(FilePondPluginFileValidateType, FilePondPluginImagePreview);
export default {
components: {
FilePond
}
};
</script>
<script setup>
import { ref, computed } from 'vue';
import Cookies from 'js-cookie';
const props = defineProps({ const props = defineProps({
bucketName: {
type: String,
default: 'temp'
},
uploadEndpoint: { uploadEndpoint: {
type: String, type: String,
required: true default: ''
},
uploadUrl: {
type: String,
default: ''
}, },
deleteEndpoint: { deleteEndpoint: {
type: String, type: String,
required: true default: ''
},
deleteUrl: {
type: String,
default: ''
}, },
maxFiles: { maxFiles: {
type: Number, type: Number,
default: 1 default: 10
},
allowMultiple: {
type: Boolean,
default: true
}, },
acceptedFiles: { acceptedFiles: {
type: String, type: String,
@@ -64,95 +54,207 @@ const props = defineProps({
}, },
mode: { mode: {
type: String, type: String,
required: true,
default: 'public' default: 'public'
}, },
modelValue: { modelValue: {
type: Array, type: Array,
required: false,
default: () => [] default: () => []
} }
}); });
// Setup reactive variables const emit = defineEmits(['update:modelValue', 'upload', 'uploaded', 'delete']);
const API_URL = import.meta.env.VITE_API_URL; const pond = ref(null);
const uploadedFiles = ref([...props.modelValue]);
// Define FilePond component const API_URL = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '');
const FilePond = vueFilePond(FilePondPluginFileValidateType, FilePondPluginImagePreview);
const emit = defineEmits(['update:modelValue', 'upload', 'delete']); const authHeaders = computed(() => {
const token = Cookies.get('bm-token') || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : '') || '';
return token ? { authorization: `Bearer ${token}` } : {};
});
const resolvedUploadUrl = computed(() => {
let endpoint = props.uploadUrl || props.uploadEndpoint;
if (!endpoint || endpoint === '/api/files/admin/upload-file') {
endpoint = `/api/files/bucket/upload-single-filepond/${props.bucketName || 'temp'}`;
}
if (endpoint.startsWith('http://') || endpoint.startsWith('https://')) {
return endpoint;
}
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
return `${API_URL}${cleanEndpoint}`;
});
const resolvedDeleteUrl = computed(() => {
let endpoint = props.deleteUrl || props.deleteEndpoint;
if (!endpoint) {
endpoint = `/api/files/bucket/delete-file/${props.bucketName || 'temp'}`;
}
if (endpoint.startsWith('http://') || endpoint.startsWith('https://')) {
return endpoint;
}
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
return `${API_URL}${cleanEndpoint}`;
});
// Handle server responses for file processing // Handle server responses for file processing
function handleProcessResponse(response) { function handleProcessResponse(response) {
if (props.mode === 'public') { let parsed;
const data = JSON.parse(response); try {
const publicUrl = data.data[0].fileRecord.publicUrl; parsed = typeof response === 'string' ? JSON.parse(response) : response;
const fileName = data.data[0].fileRecord.fileName; } catch (e) {
uploadedFiles.value.push(publicUrl); console.error('Failed to parse file upload response:', e);
emit('update:modelValue', uploadedFiles); return '';
emit('upload', uploadedFiles);
return publicUrl;
} else {
const data = JSON.parse(response);
const privateUrl = data.data[0].fileRecord.privateUrl;
const fileName = data.data[0].fileRecord.fileName;
uploadedFiles.value.push(privateUrl);
emit('update:modelValue', uploadedFiles);
emit('upload', uploadedFiles);
return privateUrl;
} }
let fileRecord = null;
if (Array.isArray(parsed?.data)) {
fileRecord = parsed.data[0]?.fileRecord || parsed.data[0];
} else if (parsed?.data?.fileRecord) {
fileRecord = parsed.data.fileRecord;
} else if (parsed?.data) {
fileRecord = parsed.data;
} else {
fileRecord = parsed;
}
const fileUrl = props.mode === 'private' ? fileRecord?.privateUrl || fileRecord?.fileURL || fileRecord?.url || '' : fileRecord?.publicUrl || fileRecord?.fileURL || fileRecord?.url || '';
const fileName = fileRecord?.fileName || fileRecord?.name || (fileUrl ? fileUrl.split('/').pop() : 'file');
const newItem = {
name: fileName,
url: fileUrl,
progress: 100,
bucketName: props.bucketName
};
const currentList = Array.isArray(props.modelValue) ? [...props.modelValue] : [];
const isStringArray = currentList.length > 0 && typeof currentList[0] === 'string';
currentList.push(isStringArray ? fileUrl : newItem);
emit('update:modelValue', currentList);
emit('upload', newItem);
emit('uploaded', newItem);
return fileName || fileUrl;
} }
// Handle server responses for file revert // Handle server responses for file revert
function handleRevertResponse(response) { function handleRevertResponse(response) {
const data = JSON.parse(response); let deletedIdentifier = '';
const fileName = data.data; try {
// Find and remove the file name from uploadedFiles const parsed = typeof response === 'string' ? JSON.parse(response) : response;
const fileIndex = uploadedFiles.value.findIndex((name) => name === fileName); deletedIdentifier = parsed?.data || parsed?.fileName || response;
if (fileIndex !== -1) { } catch (e) {
uploadedFiles.value.splice(fileIndex, 1); deletedIdentifier = response;
emit('update:modelValue', uploadedFiles);
emit('delete', uploadedFiles);
} }
return fileName; removeFromFileList(deletedIdentifier);
emit('delete', deletedIdentifier);
return deletedIdentifier;
}
function handleRemoveFile(error, fileItem) {
if (error) return;
const identifier = fileItem?.serverId || fileItem?.source || fileItem?.filename || '';
if (identifier) {
removeFromFileList(identifier);
}
}
function removeFromFileList(identifier) {
if (!Array.isArray(props.modelValue) || !identifier) return;
const updated = props.modelValue.filter((item) => {
if (typeof item === 'string') {
return item !== identifier && !item.includes(identifier);
}
if (typeof item === 'object' && item !== null) {
const u = item.url || item.source || item.name || '';
return u !== identifier && !u.includes(identifier);
}
return true;
});
emit('update:modelValue', updated);
} }
</script> </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>
/* Apply dark mode styles to FilePond component */ /* FilePond Container & Theme Styling */
.image-uploader-wrapper {
width: 100%;
}
.filepond--root { .filepond--root {
font-family: 'Estedad FD', sans-serif; font-family: inherit;
background-color: var(--surface-ground); margin-bottom: 0 !important;
color: var(--text-color);
border-radius: 8px;
} }
.app-dark .filepond--root { .filepond--panel-root {
background-color: var(--code-background); border-radius: 0.75rem !important;
border-radius: 8px; background-color: var(--surface-100, #f1f5f9) !important;
border: 2px dashed var(--surface-300, #cbd5e1) !important;
transition: all 0.2s ease;
} }
.filepond--label { .app-dark .filepond--panel-root,
color: var(--text-color-secondary); .dark .filepond--panel-root {
background-color: var(--surface-800, #1e293b) !important;
border-color: var(--surface-700, #334155) !important;
} }
.app-dark .filepond--label { .filepond--drop-label {
color: var(--code-color); min-height: 5.5rem !important;
color: var(--text-color-secondary, #64748b) !important;
cursor: pointer;
font-size: 0.875rem !important;
} }
.app-dark .filepond--drop-label { .app-dark .filepond--drop-label,
background-color: var(--code-background); .dark .filepond--drop-label {
color: var(--code-color); color: var(--text-color-secondary, #94a3b8) !important;
border: 1px solid var(--surface-border); }
border-radius: 8px;
.filepond--label-action {
color: var(--primary-color, #10b981) !important;
font-weight: 600;
text-decoration: underline;
text-underline-offset: 3px;
} }
.filepond--item-panel { .filepond--item-panel {
background-color: var(--surface-card); background-color: var(--primary-color, #10b981) !important;
}
.app-dark .filepond--item-panel {
background-color: var(--code-background);
} }
</style> </style>
+636
View File
@@ -0,0 +1,636 @@
<script setup>
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
const props = defineProps({
visible: {
type: Boolean,
default: false
},
images: {
type: Array,
default: () => []
},
initialIndex: {
type: Number,
default: 0
}
});
const emit = defineEmits(['update:visible', 'change', 'close']);
// Viewport and container refs
const modalContainerRef = ref(null);
const viewportRef = ref(null);
const imageRef = ref(null);
// Active state
const currentIndex = ref(0);
const loading = ref(true);
const isFullscreen = ref(false);
const showThumbnails = ref(true);
// Zoom and Transform states
const scale = ref(1);
const minScale = 0.2;
const maxScale = 8.0;
const translateX = ref(0);
const translateY = ref(0);
const rotation = ref(0);
const flipH = ref(1);
const flipV = ref(1);
// Drag & Pan state
const isDragging = ref(false);
const dragStartX = ref(0);
const dragStartY = ref(0);
const initialTranslateX = ref(0);
const initialTranslateY = ref(0);
// Touch tracking for pinch-to-zoom
let initialTouchDistance = 0;
let initialTouchScale = 1;
// Filter only valid image items
function isImageItem(item) {
const url = getImageUrl(item);
if (!url || typeof url !== 'string') return false;
const clean = url.split('?')[0].toLowerCase();
const ext = clean.split('.').pop();
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'avif'].includes(ext) || clean.startsWith('blob:') || clean.startsWith('data:image/');
}
const validImages = computed(() => {
if (!props.images || !Array.isArray(props.images)) return [];
return props.images.filter(isImageItem);
});
const currentImage = computed(() => {
return validImages.value[currentIndex.value] || null;
});
const currentImageUrl = computed(() => {
return getImageUrl(currentImage.value);
});
const currentImageName = computed(() => {
return getImageName(currentImage.value);
});
const isActualSize = computed(() => {
return Math.abs(scale.value - 1) < 0.05 && translateX.value === 0 && translateY.value === 0;
});
function getImageUrl(item) {
if (!item) return '';
if (typeof item === 'string') return item;
return item.url || item.source || item.src || '';
}
function getImageName(item) {
if (!item) return 'تصویر';
if (typeof item === 'string') {
const clean = item.split('?')[0];
try {
return decodeURIComponent(clean.split('/').pop() || 'تصویر');
} catch {
return clean.split('/').pop() || 'تصویر';
}
}
const name = item.fileName || item.name || '';
if (name) return name;
const url = item.url || item.source || '';
const clean = url.split('?')[0];
try {
return decodeURIComponent(clean.split('/').pop() || 'تصویر');
} catch {
return clean.split('/').pop() || 'تصویر';
}
}
function formatPersianNumber(num) {
if (num === null || num === undefined) return '';
return num.toLocaleString('fa-IR');
}
// Transform matrix
const transformStyle = computed(() => {
return {
transform: `translate3d(${translateX.value}px, ${translateY.value}px, 0) scale(${scale.value}) rotate(${rotation.value}deg) scaleX(${flipH.value}) scaleY(${flipV.value})`,
transition: isDragging.value ? 'none' : 'transform 0.18s cubic-bezier(0.2, 0, 0.2, 1)'
};
});
// Watch visibility
watch(
() => props.visible,
(val) => {
if (val) {
currentIndex.value = Math.max(0, Math.min(props.initialIndex, validImages.value.length - 1));
resetTransform();
loading.value = true;
nextTick(() => {
modalContainerRef.value?.focus();
});
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
}
}
},
{ immediate: true }
);
watch(
() => props.initialIndex,
(idx) => {
if (props.visible) {
currentIndex.value = Math.max(0, Math.min(idx, validImages.value.length - 1));
resetTransform();
loading.value = true;
}
}
);
function selectImage(idx) {
if (idx >= 0 && idx < validImages.value.length) {
currentIndex.value = idx;
resetTransform();
loading.value = true;
emit('change', idx);
}
}
function prevImage() {
if (validImages.value.length <= 1) return;
const nextIdx = (currentIndex.value - 1 + validImages.value.length) % validImages.value.length;
selectImage(nextIdx);
}
function nextImage() {
if (validImages.value.length <= 1) return;
const nextIdx = (currentIndex.value + 1) % validImages.value.length;
selectImage(nextIdx);
}
function onImageLoaded() {
loading.value = false;
}
function onImageError() {
loading.value = false;
}
// Zoom operations
function zoomIn() {
const newScale = Math.min(scale.value * 1.25, maxScale);
scale.value = Number(newScale.toFixed(2));
}
function zoomOut() {
const newScale = Math.max(scale.value / 1.25, minScale);
scale.value = Number(newScale.toFixed(2));
if (scale.value <= 1) {
translateX.value = 0;
translateY.value = 0;
}
}
function toggleActualSize() {
if (Math.abs(scale.value - 1) < 0.05) {
scale.value = 2.0;
} else {
resetTransform();
}
}
function rotateRight() {
rotation.value = (rotation.value + 90) % 360;
}
function rotateLeft() {
rotation.value = (rotation.value - 90 + 360) % 360;
}
function toggleFlipH() {
flipH.value = flipH.value === 1 ? -1 : 1;
}
function toggleFlipV() {
flipV.value = flipV.value === 1 ? -1 : 1;
}
function resetTransform() {
scale.value = 1;
translateX.value = 0;
translateY.value = 0;
rotation.value = 0;
flipH.value = 1;
flipV.value = 1;
}
// Mouse Wheel Zoom
function handleWheel(e) {
const zoomFactor = e.deltaY < 0 ? 1.15 : 0.87;
const newScale = Math.min(Math.max(scale.value * zoomFactor, minScale), maxScale);
if (newScale === scale.value) return;
if (viewportRef.value) {
const rect = viewportRef.value.getBoundingClientRect();
const mouseX = e.clientX - (rect.left + rect.width / 2);
const mouseY = e.clientY - (rect.top + rect.height / 2);
// Calculate offset zoom
translateX.value = mouseX - (mouseX - translateX.value) * (newScale / scale.value);
translateY.value = mouseY - (mouseY - translateY.value) * (newScale / scale.value);
}
scale.value = Number(newScale.toFixed(2));
if (scale.value <= 1) {
translateX.value = 0;
translateY.value = 0;
}
}
// Double Click Zoom
function onDoubleClick(e) {
if (scale.value <= 1.1) {
if (viewportRef.value) {
const rect = viewportRef.value.getBoundingClientRect();
const mouseX = e.clientX - (rect.left + rect.width / 2);
const mouseY = e.clientY - (rect.top + rect.height / 2);
translateX.value = -mouseX * 1.5;
translateY.value = -mouseY * 1.5;
}
scale.value = 2.5;
} else {
resetTransform();
}
}
// Mouse Drag / Pan
function onMouseDown(e) {
if (e.button !== 0) return; // Only left click
isDragging.value = true;
dragStartX.value = e.clientX;
dragStartY.value = e.clientY;
initialTranslateX.value = translateX.value;
initialTranslateY.value = translateY.value;
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}
function onMouseMove(e) {
if (!isDragging.value) return;
const dx = e.clientX - dragStartX.value;
const dy = e.clientY - dragStartY.value;
translateX.value = initialTranslateX.value + dx;
translateY.value = initialTranslateY.value + dy;
}
function onMouseUp() {
isDragging.value = false;
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
}
// Touch Handling for Mobile / Tablets
function onTouchStart(e) {
if (e.touches.length === 2) {
// Pinch zoom
const t1 = e.touches[0];
const t2 = e.touches[1];
initialTouchDistance = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
initialTouchScale = scale.value;
} else if (e.touches.length === 1) {
// Pan
isDragging.value = true;
dragStartX.value = e.touches[0].clientX;
dragStartY.value = e.touches[0].clientY;
initialTranslateX.value = translateX.value;
initialTranslateY.value = translateY.value;
}
}
function onTouchMove(e) {
if (e.touches.length === 2 && initialTouchDistance > 0) {
const t1 = e.touches[0];
const t2 = e.touches[1];
const currentDistance = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
const factor = currentDistance / initialTouchDistance;
scale.value = Math.min(Math.max(initialTouchScale * factor, minScale), maxScale);
} else if (e.touches.length === 1 && isDragging.value) {
const dx = e.touches[0].clientX - dragStartX.value;
const dy = e.touches[0].clientY - dragStartY.value;
translateX.value = initialTranslateX.value + dx;
translateY.value = initialTranslateY.value + dy;
}
}
function onTouchEnd() {
isDragging.value = false;
initialTouchDistance = 0;
}
// Fullscreen
function toggleFullscreen() {
if (!document.fullscreenElement) {
modalContainerRef.value
?.requestFullscreen?.()
.then(() => {
isFullscreen.value = true;
})
.catch(() => {});
} else {
document
.exitFullscreen?.()
.then(() => {
isFullscreen.value = false;
})
.catch(() => {});
}
}
// Download
async function downloadCurrentImage() {
const url = currentImageUrl.value;
if (!url) return;
const filename = currentImageName.value || 'image.jpg';
try {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
} catch {
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.target = '_blank';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
}
// Close
function closeViewer() {
emit('update:visible', false);
emit('close');
}
// Keyboard shortcuts
function handleKeydown(e) {
if (!props.visible) return;
switch (e.key) {
case 'Escape':
closeViewer();
break;
case 'ArrowRight':
prevImage();
break;
case 'ArrowLeft':
nextImage();
break;
case '+':
case '=':
zoomIn();
break;
case '-':
case '_':
zoomOut();
break;
case '0':
resetTransform();
break;
case 'r':
case 'R':
case 'ق': // Persian layout mapping
rotateRight();
break;
case 'f':
case 'F':
case 'ب':
toggleFullscreen();
break;
}
}
function onFullscreenChange() {
isFullscreen.value = !!document.fullscreenElement;
}
onMounted(() => {
document.addEventListener('fullscreenchange', onFullscreenChange);
});
onBeforeUnmount(() => {
document.removeEventListener('fullscreenchange', onFullscreenChange);
document.body.style.overflow = '';
});
</script>
<template>
<Teleport to="body">
<Transition name="iv-fade">
<div v-if="visible" ref="modalContainerRef" class="fixed inset-0 z-[99999] flex flex-col bg-black/92 backdrop-blur-xl text-white select-none overflow-hidden font-sans outline-none" tabindex="-1" @keydown="handleKeydown">
<!-- TOP HEADER BAR -->
<div class="relative z-20 flex items-center justify-between px-4 py-3 bg-gradient-to-b from-black/80 via-black/40 to-transparent">
<!-- Left / Image Counter & Name -->
<div class="flex items-center gap-3 max-w-[60%] truncate">
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-white/10 text-white/90 backdrop-blur-md border border-white/10 tracking-wide">
{{ formatPersianNumber(currentIndex + 1) }} / {{ formatPersianNumber(validImages.length) }}
</span>
<h3 class="text-sm font-medium text-white/80 truncate" :title="currentImageName">
{{ currentImageName }}
</h3>
</div>
<!-- Right / Top Action Controls -->
<div class="flex items-center gap-2">
<!-- Fullscreen -->
<button type="button" class="iv-btn" :title="isFullscreen ? 'خروج از تمام صفحه (F)' : 'تمام صفحه (F)'" @click="toggleFullscreen">
<i :class="isFullscreen ? 'pi pi-window-minimize' : 'pi pi-window-maximize'" class="text-sm"></i>
</button>
<!-- Download -->
<button type="button" class="iv-btn" title="دانلود تصویر" @click="downloadCurrentImage">
<i class="pi pi-download text-sm"></i>
</button>
<!-- Close Button -->
<button type="button" class="iv-btn iv-btn-close" title="بستن (Esc)" @click="closeViewer">
<i class="pi pi-times text-base font-bold"></i>
</button>
</div>
</div>
<!-- MAIN IMAGE VIEWPORT -->
<div
ref="viewportRef"
class="relative flex-1 w-full h-full flex items-center justify-center overflow-hidden cursor-grab active:cursor-grabbing touch-none"
@wheel.prevent="handleWheel"
@mousedown="onMouseDown"
@dblclick="onDoubleClick"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
>
<!-- Loading Spinner -->
<div v-if="loading" class="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<i class="pi pi-spin pi-spinner text-4xl text-primary-400"></i>
</div>
<!-- Image Container with Hardware Accelerated Transform -->
<div v-show="currentImageUrl" class="relative will-change-transform flex items-center justify-center max-w-full max-h-full" :style="transformStyle">
<img
ref="imageRef"
:src="currentImageUrl"
:alt="currentImageName"
class="max-w-[85vw] max-h-[75vh] object-contain pointer-events-none select-none drop-shadow-2xl transition-opacity duration-200"
:class="{ 'opacity-0': loading, 'opacity-100': !loading }"
crossorigin="anonymous"
@load="onImageLoaded"
@error="onImageError"
/>
</div>
<!-- Navigation Arrow: Previous (Right side in RTL, left side in LTR) -->
<button v-if="validImages.length > 1" type="button" class="iv-nav-btn absolute right-4 top-1/2 -translate-y-1/2 z-20" title="تصویر قبلی (کلید جهت‌نما)" @click.stop="prevImage">
<i class="pi pi-chevron-right text-xl"></i>
</button>
<!-- Navigation Arrow: Next (Left side in RTL, right side in LTR) -->
<button v-if="validImages.length > 1" type="button" class="iv-nav-btn absolute left-4 top-1/2 -translate-y-1/2 z-20" title="تصویر بعدی (کلید جهت‌نما)" @click.stop="nextImage">
<i class="pi pi-chevron-left text-xl"></i>
</button>
</div>
<!-- FLOATING BOTTOM TOOLBAR -->
<div class="relative z-20 flex flex-col items-center gap-2 pb-3 px-4 pointer-events-none">
<div class="pointer-events-auto flex items-center gap-1.5 p-1.5 rounded-2xl bg-surface-900/80 dark:bg-surface-950/80 backdrop-blur-xl border border-white/10 shadow-2xl">
<!-- Zoom Out -->
<button type="button" class="iv-tool-btn" title="کوچک‌نمایی (-)" :disabled="scale <= minScale" @click="zoomOut">
<i class="pi pi-search-minus text-sm"></i>
</button>
<!-- Zoom Percentage / Reset Zoom -->
<button type="button" class="iv-tool-btn px-3 font-mono text-xs font-semibold text-white/90 min-w-[54px] text-center" title="بازنشانی بزرگ‌نمایی (0)" @click="resetTransform">{{ Math.round(scale * 100) }}%</button>
<!-- Zoom In -->
<button type="button" class="iv-tool-btn" title="بزرگ‌نمایی (+)" :disabled="scale >= maxScale" @click="zoomIn">
<i class="pi pi-search-plus text-sm"></i>
</button>
<div class="w-[1px] h-5 bg-white/15 mx-1"></div>
<!-- 1:1 Actual Size -->
<button type="button" class="iv-tool-btn" :class="{ 'text-primary-400 bg-white/10': isActualSize }" title="اندازه واقعی (۱:۱)" @click="toggleActualSize">
<span class="text-xs font-bold font-mono">1:1</span>
</button>
<!-- Rotate Counter-Clockwise -->
<button type="button" class="iv-tool-btn" title="چرخش ۹۰ درجه پادساعت‌گرد" @click="rotateLeft">
<i class="pi pi-replay text-sm"></i>
</button>
<!-- Rotate Clockwise -->
<button type="button" class="iv-tool-btn" title="چرخش ۹۰ درجه ساعت‌گرد (R)" @click="rotateRight">
<i class="pi pi-refresh text-sm"></i>
</button>
<!-- Flip Horizontal -->
<button type="button" class="iv-tool-btn" :class="{ 'text-primary-400 bg-white/10': flipH === -1 }" title="قرینه افقی" @click="toggleFlipH">
<i class="pi pi-arrows-h text-sm"></i>
</button>
<!-- Flip Vertical -->
<button type="button" class="iv-tool-btn" :class="{ 'text-primary-400 bg-white/10': flipV === -1 }" title="قرینه عمودی" @click="toggleFlipV">
<i class="pi pi-arrows-v text-sm"></i>
</button>
<div class="w-[1px] h-5 bg-white/15 mx-1"></div>
<!-- Reset Everything -->
<button type="button" class="iv-tool-btn" title="تنظیم مجدد موقعیت و چرخش" @click="resetTransform">
<i class="pi pi-undo text-sm"></i>
</button>
<!-- Toggle Thumbnail Strip -->
<button v-if="validImages.length > 1" type="button" class="iv-tool-btn" :class="{ 'text-primary-400 bg-white/10': showThumbnails }" title="نمایش/مخفی‌سازی گالری تصاویر" @click="showThumbnails = !showThumbnails">
<i class="pi pi-images text-sm"></i>
</button>
</div>
<!-- THUMBNAIL STRIP CAROUSEL -->
<Transition name="iv-slide-up">
<div
v-if="showThumbnails && validImages.length > 1"
class="pointer-events-auto flex items-center gap-2 p-2 rounded-2xl bg-black/60 backdrop-blur-xl border border-white/10 max-w-[90vw] overflow-x-auto scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent"
>
<button
v-for="(img, idx) in validImages"
:key="idx"
type="button"
class="relative flex-shrink-0 w-14 h-14 rounded-xl overflow-hidden border-2 transition-all duration-200 cursor-pointer focus:outline-none"
:class="idx === currentIndex ? 'border-primary-400 ring-2 ring-primary-400/40 scale-105 opacity-100 shadow-lg' : 'border-white/20 opacity-60 hover:opacity-100 hover:border-white/50'"
@click="selectImage(idx)"
>
<img :src="getImageUrl(img)" :alt="getImageName(img)" class="w-full h-full object-cover" crossorigin="anonymous" />
</button>
</div>
</Transition>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.iv-btn {
@apply flex items-center justify-center w-9 h-9 rounded-full bg-white/10 hover:bg-white/20 active:bg-white/30 text-white/90 transition-all backdrop-blur-md border border-white/10 cursor-pointer shadow-md focus:outline-none;
}
.iv-btn-close {
@apply bg-red-500/80 hover:bg-red-600 text-white border-red-400/30;
}
.iv-nav-btn {
@apply flex items-center justify-center w-12 h-12 rounded-full bg-surface-900/70 hover:bg-surface-900 text-white/80 hover:text-white transition-all backdrop-blur-xl border border-white/15 shadow-2xl cursor-pointer hover:scale-110 active:scale-95 focus:outline-none;
}
.iv-tool-btn {
@apply flex items-center justify-center h-8 px-2.5 rounded-xl text-white/80 hover:text-white hover:bg-white/10 active:bg-white/20 transition-all cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed focus:outline-none;
}
/* Animations */
.iv-fade-enter-active,
.iv-fade-leave-active {
transition: opacity 0.25s ease;
}
.iv-fade-enter-from,
.iv-fade-leave-to {
opacity: 0;
}
.iv-slide-up-enter-active,
.iv-slide-up-leave-active {
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
.iv-slide-up-enter-from,
.iv-slide-up-leave-to {
opacity: 0;
transform: translateY(12px);
}
</style>
+8 -8
View File
@@ -1,11 +1,3 @@
<template>
<!-- Outer container that mimics a text field with a green border -->
<div class="flex flex-wrap items-center gap-2 rounded p-2 cursor-text" @click="focusInput">
<InputText v-model="inputValue" @keyup.enter="addChip" :placeholder="placeholder" class="w-full min-w-[100px]" />
<Chip v-for="(tag, index) in localTags" :key="tag" :label="tag" removable @remove="removeChip(index)" class="text-sm" />
</div>
</template>
<script setup> <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.
+7 -7
View File
@@ -1,10 +1,3 @@
<template>
<FloatLabel>
<ToggleSwitch :id="id" v-model="computedValue" :class="inputClass" @change="handleChange" @keyup="handleKeyUp" />
<label :for="id">label</label>
</FloatLabel>
</template>
<script setup> <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>
+222 -173
View File
@@ -1,138 +1,5 @@
<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"
>
<!-- Layer switcher -->
<l-control-layers position="bottomright" />
<!-- OpenStreetMap -->
<l-tile-layer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
layer-type="base"
name="نمای نقشه‌ای"
:attribution="attribution"
/>
<!-- Satellite -->
<l-tile-layer
:visible="false"
url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
layer-type="base"
name="نمای ماهواره‌ای"
attribution="Tiles &copy; Esri"
/>
<!-- Scale control -->
<l-control-scale position="bottomleft" :imperial="false" :maxWidth="150" />
<!-- Marker with rich popup -->
<l-marker v-if="marker.length > 0" :lat-lng="marker">
<l-popup>
<div class="popup-content">
<strong>📍 نقطه انتخاب‌شده</strong><br />
<span>عرض: {{ marker[0].toFixed(6) }}</span><br />
<span>طول: {{ marker[1].toFixed(6) }}</span>
</div>
</l-popup>
</l-marker>
</l-map>
<!-- Locating spinner overlay -->
<div v-if="isLocating" class="locating-overlay">
<div class="locating-spinner"></div>
<span>در حال تعیین موقعیت...</span>
</div>
</div>
</template>
<script setup> <script setup>
import { ref, watch, nextTick } from 'vue'; import { ref, watch, nextTick, onBeforeUnmount } from 'vue';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import { LMap, LTileLayer, LMarker, LPopup, LControlLayers, LControlScale } from '@vue-leaflet/vue-leaflet'; import { LMap, LTileLayer, LMarker, LPopup, LControlLayers, LControlScale } from '@vue-leaflet/vue-leaflet';
import axios from 'axios'; import axios from 'axios';
@@ -151,7 +18,7 @@ const emit = defineEmits(['update:modelValue', 'marker-updated', 'cleared']);
const zoom = ref(12); const zoom = ref(12);
const center = ref([31.3183, 48.6706]); const center = ref([31.3183, 48.6706]);
const attribution = ref('© OpenStreetMap contributors'); const attribution = ref('© OpenStreetMap contributors');
const marker = ref([...props.modelValue]); const marker = ref([...(props.modelValue || [])]);
const map = ref(null); const map = ref(null);
const mapContainer = ref(null); const mapContainer = ref(null);
@@ -164,7 +31,12 @@ const isFullscreen = ref(false);
// ── Core: set marker + flyTo ─────────────────────────────────────────────── // ── Core: set marker + flyTo ───────────────────────────────────────────────
const setMarker = (latVal, lonVal) => { const setMarker = (latVal, lonVal) => {
if (latVal !== null && lonVal !== null && !isNaN(latVal) && !isNaN(lonVal)) { if (latVal !== null && lonVal !== null && !isNaN(latVal) && !isNaN(lonVal)) {
const newCoords = [parseFloat(latVal), parseFloat(lonVal)]; const numLat = parseFloat(latVal);
const numLon = parseFloat(lonVal);
if (marker.value && marker.value.length === 2 && marker.value[0] === numLat && marker.value[1] === numLon) {
return;
}
const newCoords = [numLat, numLon];
marker.value = newCoords; marker.value = newCoords;
center.value = newCoords; center.value = newCoords;
zoom.value = 16; zoom.value = 16;
@@ -182,7 +54,7 @@ const clearMarker = () => {
marker.value = []; marker.value = [];
emit('update:modelValue', []); emit('update:modelValue', []);
emit('marker-updated', []); emit('marker-updated', []);
emit('cleared'); // notify parent to also reset nationalGeoCode emit('cleared'); // notify parent to also reset nationalGeoCode
}; };
// ── Fullscreen ───────────────────────────────────────────────────────────── // ── Fullscreen ─────────────────────────────────────────────────────────────
@@ -199,10 +71,19 @@ const toggleFullscreen = () => {
} }
}; };
document.addEventListener('fullscreenchange', () => { const onFullscreenChange = () => {
isFullscreen.value = !!document.fullscreenElement; isFullscreen.value = !!document.fullscreenElement;
// Invalidate map size so Leaflet redraws correctly after fullscreen
nextTick(() => map.value?.leafletObject?.invalidateSize()); nextTick(() => map.value?.leafletObject?.invalidateSize());
};
if (typeof document !== 'undefined') {
document.addEventListener('fullscreenchange', onFullscreenChange);
}
onBeforeUnmount(() => {
if (typeof document !== 'undefined') {
document.removeEventListener('fullscreenchange', onFullscreenChange);
}
}); });
// ── Locate Me ───────────────────────────────────────────────────────────── // ── Locate Me ─────────────────────────────────────────────────────────────
@@ -214,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 }
); );
}; };
@@ -274,22 +157,160 @@ const handleClick = (event) => {
emit('marker-updated', coords); emit('marker-updated', coords);
}; };
// ── Ready handler ──────────────────────────────────────────────────────────
const onMapReady = () => {
if (Array.isArray(marker.value) && marker.value.length === 2 && marker.value[0] && marker.value[1]) {
center.value = [...marker.value];
zoom.value = 16;
nextTick(() => {
if (map.value?.leafletObject) {
map.value.leafletObject.setView(marker.value, 16);
map.value.leafletObject.invalidateSize();
}
});
}
};
// ── Sync modelValue prop → marker ────────────────────────────────────────── // ── Sync modelValue prop → marker ──────────────────────────────────────────
watch( watch(
() => props.modelValue, () => props.modelValue,
(val) => { (val) => {
if (Array.isArray(val) && val.length === 2) { if (Array.isArray(val) && val.length === 2 && val[0] !== undefined && val[1] !== undefined && !isNaN(val[0]) && !isNaN(val[1])) {
marker.value = [...val]; const newCoords = [parseFloat(val[0]), parseFloat(val[1])];
center.value = [...val]; if (marker.value && marker.value.length === 2 && marker.value[0] === newCoords[0] && marker.value[1] === newCoords[1]) {
return;
}
marker.value = newCoords;
center.value = newCoords;
zoom.value = 16;
nextTick(() => {
if (map.value?.leafletObject) {
map.value.leafletObject.flyTo(newCoords, 16, { animate: true, duration: 0.8 });
map.value.leafletObject.invalidateSize();
}
});
} else if (Array.isArray(val) && val.length === 0) {
marker.value = [];
} }
}, },
{ immediate: true } { immediate: true, deep: true }
); );
// ── Expose to parent ─────────────────────────────────────────────────────── // ── Expose to parent ───────────────────────────────────────────────────────
defineExpose({ searchByCoordinates: setMarker, setCenterAndMarker: setMarker, searchLocation }); 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 &copy; Esri" />
<!-- Scale control -->
<l-control-scale position="bottomleft" :imperial="false" :maxWidth="150" />
<!-- Marker with rich popup -->
<l-marker v-if="marker.length > 0" :lat-lng="marker">
<l-popup>
<div class="popup-content">
<strong>📍 نقطه انتخاب‌شده</strong><br />
<span>عرض: {{ marker[0].toFixed(6) }}</span
><br />
<span>طول: {{ marker[1].toFixed(6) }}</span>
</div>
</l-popup>
</l-marker>
</l-map>
<!-- Locating spinner overlay -->
<div v-if="isLocating" class="locating-overlay">
<div class="locating-spinner"></div>
<span>در حال تعیین موقعیت...</span>
</div>
</div>
</template>
<style scoped> <style scoped>
/* ── Container ──────────────────────────────────────────────────────────── */ /* ── Container ──────────────────────────────────────────────────────────── */
.map-container { .map-container {
@@ -328,12 +349,15 @@ defineExpose({ searchByCoordinates: setMarker, setCenterAndMarker: setMarker, se
-webkit-backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
border-radius: 12px; border-radius: 12px;
padding: 6px 10px; padding: 6px 10px;
box-shadow: 0 4px 16px rgba(0,0,0,0.15); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
border: 1px solid rgba(255,255,255,0.6); border: 1px solid rgba(255, 255, 255, 0.6);
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;
@@ -346,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;
@@ -358,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 {
@@ -376,45 +404,57 @@ defineExpose({ searchByCoordinates: setMarker, setCenterAndMarker: setMarker, se
border: none; border: none;
background: #6366f1; background: #6366f1;
color: #fff; color: #fff;
box-shadow: 0 3px 10px rgba(99,102,241,0.35); box-shadow: 0 3px 10px rgba(99, 102, 241, 0.35);
cursor: pointer; cursor: pointer;
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 {
background: #4f46e5; background: #4f46e5;
box-shadow: 0 6px 18px rgba(79,70,229,0.45); box-shadow: 0 6px 18px rgba(79, 70, 229, 0.45);
transform: scale(1.06); 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;
box-shadow: 0 3px 10px rgba(14,165,233,0.35); box-shadow: 0 3px 10px rgba(14, 165, 233, 0.35);
} }
.map-btn-locate:hover { .map-btn-locate:hover {
background: #0284c7; background: #0284c7;
box-shadow: 0 6px 18px rgba(2,132,199,0.45); box-shadow: 0 6px 18px rgba(2, 132, 199, 0.45);
} }
.map-btn.locating { animation: pulse-btn 1s infinite; } .map-btn.locating {
animation: pulse-btn 1s infinite;
}
.map-btn-danger { .map-btn-danger {
background: #ef4444; background: #ef4444;
box-shadow: 0 3px 10px rgba(239,68,68,0.35); box-shadow: 0 3px 10px rgba(239, 68, 68, 0.35);
} }
.map-btn-danger:hover { .map-btn-danger:hover {
background: #dc2626; background: #dc2626;
box-shadow: 0 6px 18px rgba(220,38,38,0.45); box-shadow: 0 6px 18px rgba(220, 38, 38, 0.45);
} }
@keyframes pulse-btn { @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) ────────────────────────────────────────────── */
@@ -443,20 +483,25 @@ defineExpose({ searchByCoordinates: setMarker, setCenterAndMarker: setMarker, se
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
background: rgba(255,255,255,0.95); background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
border-radius: 20px; border-radius: 20px;
padding: 5px 12px; padding: 5px 12px;
box-shadow: 0 4px 14px rgba(0,0,0,0.14); box-shadow: 0 4px 14px rgba(0, 0, 0, 0.14);
border: 1px solid rgba(255,255,255,0.6); border: 1px solid rgba(255, 255, 255, 0.6);
font-size: 12px; font-size: 12px;
color: #1a1a2e; color: #1a1a2e;
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;
@@ -467,14 +512,16 @@ 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 {
position: absolute; position: absolute;
inset: 0; inset: 0;
z-index: 1100; z-index: 1100;
background: rgba(255,255,255,0.55); background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -490,14 +537,16 @@ defineExpose({ searchByCoordinates: setMarker, setCenterAndMarker: setMarker, se
.locating-spinner { .locating-spinner {
width: 36px; width: 36px;
height: 36px; height: 36px;
border: 3px solid rgba(99,102,241,0.2); border: 3px solid rgba(99, 102, 241, 0.2);
border-top-color: #6366f1; border-top-color: #6366f1;
border-radius: 50%; border-radius: 50%;
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
} }
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to {
transform: rotate(360deg);
}
} }
/* ── Lock badge ──────────────────────────────────────────────────────────── */ /* ── Lock badge ──────────────────────────────────────────────────────────── */
@@ -528,7 +577,7 @@ defineExpose({ searchByCoordinates: setMarker, setCenterAndMarker: setMarker, se
.app-dark .search-bar-wrap { .app-dark .search-bar-wrap {
background: rgba(30, 30, 50, 0.92) !important; background: rgba(30, 30, 50, 0.92) !important;
border-color: rgba(99, 102, 241, 0.25) !important; border-color: rgba(99, 102, 241, 0.25) !important;
box-shadow: 0 4px 16px rgba(0,0,0,0.4) !important; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4) !important;
} }
.app-dark .search-input { .app-dark .search-input {
@@ -548,7 +597,7 @@ defineExpose({ searchByCoordinates: setMarker, setCenterAndMarker: setMarker, se
background: rgba(30, 30, 50, 0.92) !important; background: rgba(30, 30, 50, 0.92) !important;
border-color: rgba(99, 102, 241, 0.25) !important; border-color: rgba(99, 102, 241, 0.25) !important;
color: #e2e8f0 !important; color: #e2e8f0 !important;
box-shadow: 0 4px 14px rgba(0,0,0,0.4) !important; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.4) !important;
} }
/* Locating overlay */ /* Locating overlay */
@@ -558,7 +607,7 @@ defineExpose({ searchByCoordinates: setMarker, setCenterAndMarker: setMarker, se
} }
.app-dark .locating-spinner { .app-dark .locating-spinner {
border-color: rgba(99,102,241,0.15) !important; border-color: rgba(99, 102, 241, 0.15) !important;
border-top-color: #818cf8 !important; border-top-color: #818cf8 !important;
} }
</style> </style>
+16 -1
View File
@@ -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>