fix(dashboard): fix properties freeze, category/feature list views, and auth interceptor
deploy-chabokan / deploy (push) Canceled after 0s

This commit is contained in:
2026-08-19 07:08:43 +03:30
parent b81a85f672
commit 3723f801e7
14 changed files with 456 additions and 247 deletions
+29 -2
View File
@@ -1,6 +1,33 @@
<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>
+1 -1
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
+17 -26
View File
@@ -2,46 +2,37 @@ 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 || 'خطا در حذف دسته‌بندی';
} }
} }
}; };
+26 -26
View File
@@ -2,46 +2,46 @@ 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 || 'خطا در حذف ویژگی';
} }
} }
}; };
+6 -7
View File
@@ -2,14 +2,13 @@ import axios from 'axios';
export const PropertyService = { export const PropertyService = {
_getAuthHeader: () => { _getAuthHeader: () => {
const bmToken = document.cookie const bmToken = (typeof document !== 'undefined'
.split('; ') ? document.cookie.split('; ').find((row) => row.startsWith('bm-token='))?.split('=')[1]
.find((row) => row.startsWith('bm-token=')) : null) || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
?.split('=')[1];
return { return bmToken && bmToken !== 'undefined' && bmToken !== 'null'
Authorization: `Bearer ${bmToken}` ? { Authorization: `Bearer ${bmToken}` }
}; : {};
}, },
getProperty: async (propertyId) => { getProperty: async (propertyId) => {
+6 -7
View File
@@ -2,14 +2,13 @@ import axios from 'axios';
export const SettingService = { export const SettingService = {
_getAuthHeader: () => { _getAuthHeader: () => {
const bmToken = document.cookie const bmToken = (typeof document !== 'undefined'
.split('; ') ? document.cookie.split('; ').find((row) => row.startsWith('bm-token='))?.split('=')[1]
.find((row) => row.startsWith('bm-token=')) : null) || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
?.split('=')[1];
return { return bmToken && bmToken !== 'undefined' && bmToken !== 'null'
Authorization: `Bearer ${bmToken}` ? { Authorization: `Bearer ${bmToken}` }
}; : {};
}, },
/** /**
+6 -7
View File
@@ -2,14 +2,13 @@ import axios from 'axios';
export const TransactionService = { export const TransactionService = {
_getAuthHeader: () => { _getAuthHeader: () => {
const bmToken = document.cookie const bmToken = (typeof document !== 'undefined'
.split('; ') ? document.cookie.split('; ').find((row) => row.startsWith('bm-token='))?.split('=')[1]
.find((row) => row.startsWith('bm-token=')) : null) || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
?.split('=')[1];
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
+4 -5
View File
@@ -1,11 +1,10 @@
import axios from 'axios'; import axios from 'axios';
const getAuthHeader = () => { const getAuthHeader = () => {
const bmToken = document.cookie const bmToken = (typeof document !== 'undefined'
.split('; ') ? document.cookie.split('; ').find((row) => row.startsWith('bm-token='))?.split('=')[1]
.find((row) => row.startsWith('bm-token=')) : null) || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
?.split('=')[1]; return bmToken && bmToken !== 'undefined' && bmToken !== 'null' ? { Authorization: `Bearer ${bmToken}` } : {};
return { Authorization: `Bearer ${bmToken}` };
}; };
export const WarehouseItemService = { export const WarehouseItemService = {
+4 -5
View File
@@ -1,11 +1,10 @@
import axios from 'axios'; import axios from 'axios';
const getAuthHeader = () => { const getAuthHeader = () => {
const bmToken = document.cookie const bmToken = (typeof document !== 'undefined'
.split('; ') ? document.cookie.split('; ').find((row) => row.startsWith('bm-token='))?.split('=')[1]
.find((row) => row.startsWith('bm-token=')) : null) || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
?.split('=')[1]; return bmToken && bmToken !== 'undefined' && bmToken !== 'null' ? { Authorization: `Bearer ${bmToken}` } : {};
return { Authorization: `Bearer ${bmToken}` };
}; };
export const WarehouseService = { export const WarehouseService = {
+82 -67
View File
@@ -3,17 +3,30 @@ 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(); const toast = useToast();
import axios from 'axios';
const dataLoading = ref(true); const dataLoading = ref(true);
const dt = ref();
const categories = ref([]);
const categoryDialog = ref(false);
const deleteCategoryDialog = ref(false);
const deleteSelectedDialog = ref(false);
const category = ref({});
const selectedCategories = ref([]);
const filters = ref({
global: { value: null, matchMode: FilterMatchMode.CONTAINS }
});
const submitted = ref(false);
const loadData = async () => { const loadData = async () => {
dataLoading.value = true; dataLoading.value = true;
try { try {
categories.value = await CategoryService.getAllCategories(); const data = await CategoryService.getAllCategories();
categories.value = Array.isArray(data) ? data : [];
} catch (err) { } catch (err) {
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در دریافت اطلاعات', life: 3000 }); toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در دریافت اطلاعات دسته‌بندی‌ها', life: 3000 });
categories.value = [];
} finally { } finally {
dataLoading.value = false; dataLoading.value = false;
} }
@@ -23,19 +36,6 @@ onMounted(() => {
loadData(); loadData();
}); });
const dt = ref();
const categories = ref();
const categoryDialog = ref(false);
const deleteCategoryDialog = ref(false);
const deleteProductsDialog = ref(false);
const category = ref({});
const selectedCategories = ref();
const filters = ref({
global: { value: null, matchMode: FilterMatchMode.CONTAINS }
});
const submitted = ref(false);
function openNew() { function openNew() {
category.value = {}; category.value = {};
submitted.value = false; submitted.value = false;
@@ -49,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) {
@@ -74,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>
@@ -119,11 +125,13 @@ 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
@@ -131,7 +139,7 @@ function deleteSelectedCategories() {
v-model:selection="selectedCategories" v-model:selection="selectedCategories"
:value="categories" :value="categories"
:loading="dataLoading" :loading="dataLoading"
dataKey="id" dataKey="_id"
:paginator="true" :paginator="true"
:rows="20" :rows="20"
:filters="filters" :filters="filters"
@@ -153,7 +161,7 @@ function deleteSelectedCategories() {
</template> </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>
@@ -167,10 +175,17 @@ 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>
@@ -185,7 +200,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>
@@ -196,27 +211,27 @@ 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>
+225 -73
View File
@@ -1,148 +1,300 @@
<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 dataLoading = ref(true);
const featureDialog = ref(false);
const deleteFeatureDialog = ref(false);
const featureToDelete = ref(null);
onMounted(async () => { const featureTypes = [
{ value: 'string', name: 'متن (String)' },
{ value: 'number', name: 'عدد (Number)' },
{ value: 'boolean', name: 'بله یا خیر (Boolean)' },
{ value: 'date', name: 'تاریخ (Date)' }
];
const loadCategories = async () => {
dataLoading.value = true; dataLoading.value = true;
try { try {
categories.value = await CategoryService.getAllCategories(); const data = await CategoryService.getAllCategories();
if (categories.value.length > 0 && !selectedTab.value) { 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; selectedTab.value = categories.value[0]._id;
} }
} catch (e) { } catch (e) {
console.error(e); console.error('Error loading categories:', e);
categories.value = [];
} finally { } finally {
dataLoading.value = false; 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>
<div className="card">
<div class="font-semibold text-xl mb-4">ویژگی دسته بندی ها</div>
<template>
<div class="card p-4">
<Toast></Toast>
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<div>
<h3 class="text-xl font-bold m-0 text-slate-800 dark:text-slate-100">ویژگی دسته‌بندی‌ها</h3>
<p class="text-xs text-slate-500 mt-1 mb-0">تعریف و مدیریت فیلدهای اختصاصی برای هر دسته‌بندی</p>
</div>
<Button
v-if="selectedTab"
label="افزودن ویژگی جدید"
icon="pi pi-plus"
severity="primary"
@click="openNew(selectedTab)"
/>
</div>
<!-- Skeleton Loading -->
<div v-if="dataLoading" class="space-y-4"> <div v-if="dataLoading" class="space-y-4">
<div class="flex gap-2"> <div class="flex gap-2">
<Skeleton v-for="i in 4" :key="i" width="8rem" height="2.5rem" class="rounded-xl" /> <Skeleton v-for="i in 4" :key="i" width="8rem" height="2.5rem" class="rounded-xl" />
</div> </div>
<div class="w-1/3 my-4 space-y-2"> <div class="w-full my-4 space-y-3">
<Skeleton width="100%" height="15rem" class="rounded-xl" /> <Skeleton width="100%" height="4rem" class="rounded-xl" />
<Skeleton width="6rem" height="2.5rem" class="rounded-lg" /> <Skeleton width="100%" height="4rem" class="rounded-xl" />
<Skeleton width="100%" height="4rem" class="rounded-xl" />
</div> </div>
</div> </div>
<Tabs v-else :value="selectedTab" :pt="{}"> <!-- Empty state if no categories exist -->
<TabList> <div v-else-if="!categories.length" class="p-8 text-center text-slate-500">
<Tab v-for="category in categories" :key="category._id" :value="category._id"> <i class="pi pi-inbox text-4xl mb-2 text-surface-400"></i>
{{ category.nativeName }} <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">
<Listbox filter :options="category.features" option-value="_id" option-label="nativeName" @item-dblclick="editFeature($event, category._id)" emptyMessage="موردی برای نمایش وجود ندارد."></Listbox> لیست ویژگی‌های «{{ cat.nativeName }}» ({{ cat.features?.length || 0 }} مورد)
<Button class="my-3" :categoryId="category._id" @click="openNew(category._id)">افزودن</Button> </span>
<Button
label="افزودن ویژگی"
icon="pi pi-plus"
size="small"
severity="secondary"
outlined
@click="openNew(cat._id)"
/>
</div>
<!-- Features Table / List -->
<div v-if="cat.features && cat.features.length > 0" class="overflow-x-auto rounded-xl border border-surface-200 dark:border-surface-700">
<table class="w-full text-right text-sm">
<thead class="bg-surface-50 dark:bg-surface-800/60 text-surface-700 dark:text-surface-200 font-semibold border-b border-surface-200 dark:border-surface-700">
<tr>
<th class="p-3">#</th>
<th class="p-3">نام فارسی</th>
<th class="p-3">نام انگلیسی</th>
<th class="p-3">نوع ویژگی</th>
<th class="p-3">واحد</th>
<th class="p-3">تگ دسته‌بندی</th>
<th class="p-3 text-center">عملیات</th>
</tr>
</thead>
<tbody class="divide-y divide-surface-100 dark:divide-surface-800">
<tr v-for="(feat, idx) in cat.features" :key="feat._id || idx" class="hover:bg-surface-50/50 dark:hover:bg-surface-800/40 transition-colors">
<td class="p-3 font-mono text-xs text-slate-400">{{ idx + 1 }}</td>
<td class="p-3 font-semibold text-slate-800 dark:text-slate-100">{{ feat.nativeName }}</td>
<td class="p-3 font-mono text-xs text-slate-600 dark:text-slate-300">{{ feat.englishName }}</td>
<td class="p-3">
<span class="px-2 py-0.5 text-xs rounded-full bg-blue-50 dark:bg-blue-950/60 text-blue-700 dark:text-blue-300 font-medium">
{{ getFeatureTypeLabel(feat.featureType) }}
</span>
</td>
<td class="p-3 text-xs text-slate-500">{{ feat.unit || '-' }}</td>
<td class="p-3 text-xs text-slate-500">{{ feat.tag || '-' }}</td>
<td class="p-3 text-center">
<div class="flex items-center justify-center gap-2">
<Button
icon="pi pi-pencil"
size="small"
outlined
rounded
severity="info"
title="ویرایش"
@click="editFeature(feat, cat._id)"
/>
<Button
icon="pi pi-trash"
size="small"
outlined
rounded
severity="danger"
title="حذف"
@click="confirmDelete(feat)"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="p-8 text-center text-slate-400 bg-surface-50 dark:bg-surface-800/30 rounded-xl border border-dashed border-surface-200 dark:border-surface-700">
<i class="pi pi-tags text-3xl mb-2 text-slate-300 dark:text-slate-600"></i>
<p class="mb-3 text-sm">هیچ ویژگی‌ای برای این دسته‌بندی تعریف نشده است.</p>
<Button label="ساخت اولین ویژگی" icon="pi pi-plus" size="small" @click="openNew(cat._id)" />
</div> </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>
+14 -3
View File
@@ -1,7 +1,7 @@
<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, computed } 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 { SettingService } from '@/service/SettingService';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
@@ -33,6 +33,7 @@ const dt = ref();
const properties = ref([]); const properties = ref([]);
const selectedProperties = ref(); const selectedProperties = ref();
const selectedPropertyId = ref(null); const selectedPropertyId = ref(null);
const hoveredPropertyId = ref(null);
const mapRef = ref(null); const mapRef = ref(null);
const splitMapRef = ref(null); const splitMapRef = ref(null);
const propertyCardRefs = ref({}); const propertyCardRefs = ref({});
@@ -241,7 +242,11 @@ const onSelectProperty = (propertyId) => {
}; };
const onCardHover = (propertyId) => { const onCardHover = (propertyId) => {
selectedPropertyId.value = propertyId; hoveredPropertyId.value = propertyId;
};
const onCardLeave = () => {
hoveredPropertyId.value = null;
}; };
</script> </script>
@@ -467,8 +472,12 @@ const onCardHover = (propertyId) => {
:key="prop._id || prop.id" :key="prop._id || prop.id"
:id="`property-card-${prop._id || prop.id}`" :id="`property-card-${prop._id || prop.id}`"
class="property-card" class="property-card"
:class="{ 'card-selected': selectedPropertyId === (prop._id || prop.id) }" :class="{
'card-selected': selectedPropertyId === (prop._id || prop.id),
'card-hovered': hoveredPropertyId === (prop._id || prop.id)
}"
@mouseenter="onCardHover(prop._id || prop.id)" @mouseenter="onCardHover(prop._id || prop.id)"
@mouseleave="onCardLeave"
@click="onSelectProperty(prop._id || prop.id)" @click="onSelectProperty(prop._id || prop.id)"
> >
<!-- Card Image --> <!-- Card Image -->
@@ -564,6 +573,7 @@ const onCardHover = (propertyId) => {
ref="splitMapRef" ref="splitMapRef"
:properties="properties" :properties="properties"
:selectedPropertyId="selectedPropertyId" :selectedPropertyId="selectedPropertyId"
:hoveredPropertyId="hoveredPropertyId"
@select-property="onSelectProperty" @select-property="onSelectProperty"
@edit-property="editProperty" @edit-property="editProperty"
/> />
@@ -576,6 +586,7 @@ const onCardHover = (propertyId) => {
ref="mapRef" ref="mapRef"
:properties="properties" :properties="properties"
:selectedPropertyId="selectedPropertyId" :selectedPropertyId="selectedPropertyId"
:hoveredPropertyId="hoveredPropertyId"
@select-property="onSelectProperty" @select-property="onSelectProperty"
@edit-property="editProperty" @edit-property="editProperty"
/> />
+24 -6
View File
@@ -163,7 +163,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, watch, nextTick } from 'vue'; import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import L from 'leaflet'; import L from 'leaflet';
import { LMap, LTileLayer, LMarker, LPopup, LControlLayers, LControlScale } from '@vue-leaflet/vue-leaflet'; import { LMap, LTileLayer, LMarker, LPopup, LControlLayers, LControlScale } from '@vue-leaflet/vue-leaflet';
@@ -190,6 +190,10 @@ const props = defineProps({
selectedPropertyId: { selectedPropertyId: {
type: [String, Number], type: [String, Number],
default: null default: null
},
hoveredPropertyId: {
type: [String, Number],
default: null
} }
}); });
@@ -313,12 +317,14 @@ const getCategoryName = (prop) => {
* Creates custom Airbnb-style Leaflet pin icon * Creates custom Airbnb-style Leaflet pin icon
*/ */
const getMarkerIcon = (prop) => { const getMarkerIcon = (prop) => {
const isSelected = props.selectedPropertyId === (prop._id || prop.id); const propId = prop._id || prop.id;
const isSelected = props.selectedPropertyId === propId;
const isHovered = props.hoveredPropertyId === propId;
const code = prop.propertyCode ? `#${prop.propertyCode}` : ''; const code = prop.propertyCode ? `#${prop.propertyCode}` : '';
const cat = getCategoryName(prop) || 'ملک'; const cat = getCategoryName(prop) || 'ملک';
const html = ` const html = `
<div class="airbnb-pin ${isSelected ? 'is-selected' : ''}"> <div class="airbnb-pin ${isSelected ? 'is-selected' : ''} ${isHovered ? 'is-hovered' : ''}">
<div class="pin-content"> <div class="pin-content">
<span class="pin-icon">🏠</span> <span class="pin-icon">🏠</span>
<span class="pin-label">${code || cat}</span> <span class="pin-label">${code || cat}</span>
@@ -421,9 +427,19 @@ const toggleFullscreen = () => {
} }
}; };
document.addEventListener('fullscreenchange', () => { const onFullscreenChange = () => {
isFullscreen.value = !!document.fullscreenElement; isFullscreen.value = !!document.fullscreenElement;
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);
}
}); });
// Watch for selected property changes to zoom to it // Watch for selected property changes to zoom to it
@@ -628,7 +644,8 @@ defineExpose({
transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
} }
.airbnb-pin:hover { .airbnb-pin:hover,
.airbnb-pin.is-hovered {
transform: scale(1.15) translateY(-4px); transform: scale(1.15) translateY(-4px);
z-index: 1000 !important; z-index: 1000 !important;
} }
@@ -654,7 +671,8 @@ defineExpose({
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.airbnb-pin:hover .pin-content { .airbnb-pin:hover .pin-content,
.airbnb-pin.is-hovered .pin-content {
background: #4f46e5; background: #4f46e5;
color: #ffffff; color: #ffffff;
box-shadow: 0 6px 20px rgba(79, 70, 229, 0.45); box-shadow: 0 6px 20px rgba(79, 70, 229, 0.45);
+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>