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>
import axios from 'axios'
axios.defaults.baseURL = import.meta.env.VITE_API_URL
import axios from 'axios';
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>
<template>
+1 -1
View File
@@ -4,7 +4,7 @@ import axios from 'axios';
import Cookies from 'js-cookie';
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) {
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 = {
getAllCategories: async () => {
const config = {
method: 'get',
url: '/api/categories/admin/get-all',
withCredentials: true
};
try {
const response = await axios(config);
return response.data.data;
const response = await axios.get('/api/categories/admin/get-all');
return response.data?.data || [];
} catch (err) {
console.log(err);
console.error('Error in getAllCategories:', err);
return [];
}
},
createCategory: async (data) => {
const config = {
method: 'post',
url: '/api/categories/admin/create',
data,
withCredentials: true
};
try {
const response = await axios(config);
const response = await axios.post('/api/categories/admin/create', data);
return 'success';
} catch (err) {
return err.response.data.message;
return err.response?.data?.message || 'خطا در ساخت دسته‌بندی';
}
},
updateCategory: async (categoryId, data) => {
const config = {
method: 'put',
url: '/api/categories/admin/update/' + categoryId,
data,
withCredentials: true
};
try {
const response = await axios(config);
const response = await axios.patch('/api/categories/admin/update/' + categoryId, data);
return 'success';
} 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 = {
getAllFeatures: async () => {
const config = {
method: 'get',
url: '/api/categories/admin/get-all',
withCredentials: true
};
try {
const response = await axios(config);
return response.data.data;
const response = await axios.get('/api/features/admin/get-all');
return response.data?.data || [];
} 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) => {
const config = {
method: 'post',
url: '/api/features/admin/create',
data,
withCredentials: true
};
try {
const response = await axios(config);
await axios.post('/api/features/admin/create', data);
return 'success';
} catch (err) {
return err.response.data.message;
return err.response?.data?.message || 'خطا در ساخت ویژگی';
}
},
updateFeature: async (featureId, data) => {
const config = {
method: 'put',
url: '/api/features/admin/update/' + featureId,
data,
withCredentials: true
};
try {
const response = await axios(config);
await axios.put('/api/features/admin/update/' + featureId, data);
return 'success';
} 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 = {
_getAuthHeader: () => {
const bmToken = document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1];
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 {
Authorization: `Bearer ${bmToken}`
};
return bmToken && bmToken !== 'undefined' && bmToken !== 'null'
? { Authorization: `Bearer ${bmToken}` }
: {};
},
getProperty: async (propertyId) => {
+6 -7
View File
@@ -2,14 +2,13 @@ import axios from 'axios';
export const SettingService = {
_getAuthHeader: () => {
const bmToken = document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1];
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 {
Authorization: `Bearer ${bmToken}`
};
return bmToken && bmToken !== 'undefined' && bmToken !== 'null'
? { Authorization: `Bearer ${bmToken}` }
: {};
},
/**
+6 -7
View File
@@ -2,14 +2,13 @@ import axios from 'axios';
export const TransactionService = {
_getAuthHeader: () => {
const bmToken = document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1];
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 {
Authorization: `Bearer ${bmToken}`
};
return bmToken && bmToken !== 'undefined' && bmToken !== 'null'
? { Authorization: `Bearer ${bmToken}` }
: {};
},
/**
* Get a single transaction by ID
+4 -5
View File
@@ -1,11 +1,10 @@
import axios from 'axios';
const getAuthHeader = () => {
const bmToken = document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1];
return { Authorization: `Bearer ${bmToken}` };
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 WarehouseItemService = {
+4 -5
View File
@@ -1,11 +1,10 @@
import axios from 'axios';
const getAuthHeader = () => {
const bmToken = document.cookie
.split('; ')
.find((row) => row.startsWith('bm-token='))
?.split('=')[1];
return { Authorization: `Bearer ${bmToken}` };
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 WarehouseService = {
+82 -67
View File
@@ -3,17 +3,30 @@ import { FilterMatchMode } from '@primevue/core/api';
import { onMounted, ref } from 'vue';
import { CategoryService } from '@/service/CategoryService';
import { useToast } from 'primevue/usetoast';
const toast = useToast();
import axios from 'axios';
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 () => {
dataLoading.value = true;
try {
categories.value = await CategoryService.getAllCategories();
const data = await CategoryService.getAllCategories();
categories.value = Array.isArray(data) ? data : [];
} catch (err) {
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در دریافت اطلاعات', life: 3000 });
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در دریافت اطلاعات دسته‌بندی‌ها', life: 3000 });
categories.value = [];
} finally {
dataLoading.value = false;
}
@@ -23,19 +36,6 @@ onMounted(() => {
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() {
category.value = {};
submitted.value = false;
@@ -49,24 +49,25 @@ function hideDialog() {
async function saveCategory() {
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);
} else {
status = await CategoryService.createCategory(category.value);
}
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 {
toast.add({ severity: 'error', summary: 'ناموفق', detail: status, life: 3000 });
}
categoryDialog.value = false;
category.value = {};
CategoryService.getAllCategories().then((data) => (categories.value = data));
}
function editCategory(cat) {
@@ -74,42 +75,47 @@ function editCategory(cat) {
categoryDialog.value = true;
}
function confirmDeleteCategory(prod) {
category.value = prod;
function confirmDeleteCategory(cat) {
category.value = cat;
deleteCategoryDialog.value = true;
}
async function deleteCategory() {
const config = {
method: 'delete',
url: '/api/categories/admin/delete/' + category.value._id,
data: category.value,
withCredentials: true
};
try {
const response = await axios(config);
toast.add({ severity: 'success', summary: 'موفق', detail: 'دسته بندی جدید حذف شد.', life: 3000 });
} catch (err) {
toast.add({ severity: 'error', summary: 'ناموفق', detail: err.response.data.message, life: 3000 });
if (!category.value?._id) return;
const status = await CategoryService.deleteCategory(category.value._id);
if (status === 'success') {
toast.add({ severity: 'success', summary: 'موفقیت‌آمیز', detail: 'دسته‌بندی با موفقیت حذف شد.', life: 3000 });
deleteCategoryDialog.value = false;
category.value = {};
await loadData();
} else {
toast.add({ severity: 'error', summary: 'ناموفق', detail: status, life: 3000 });
}
CategoryService.getAllCategories().then((data) => (categories.value = data));
deleteCategoryDialog.value = false;
category.value = {};
}
function exportCSV() {
dt.value.exportCSV();
}
function confirmDeleteSelected() {
deleteProductsDialog.value = true;
deleteSelectedDialog.value = true;
}
function deleteSelectedCategories() {
categories.value = categories.value.filter((val) => !selectedCategories.value.includes(val));
deleteProductsDialog.value = false;
selectedCategories.value = null;
toast.add({ severity: 'success', summary: 'موفقیت آمیز', detail: 'دسته بندی مورد نظر حذف شد.', life: 3000 });
async function deleteSelectedCategories() {
if (!selectedCategories.value || !selectedCategories.value.length) return;
let successCount = 0;
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>
@@ -119,11 +125,13 @@ function deleteSelectedCategories() {
<div class="card">
<Toolbar class="mb-6">
<template #start>
<Button label="ساخت دسته بندی جدید" icon="pi pi-plus" severity="secondary" 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-plus" severity="primary" class="mr-2 mx-3" @click="openNew" />
<Button label="حذف موارد انتخابی" icon="pi pi-trash" severity="danger" outlined @click="confirmDeleteSelected" :disabled="!selectedCategories || !selectedCategories.length" />
</template>
<template #end> </template>
<template #end>
<Button label="خروجی CSV" icon="pi pi-upload" severity="secondary" @click="exportCSV" />
</template>
</Toolbar>
<DataTable
@@ -131,7 +139,7 @@ function deleteSelectedCategories() {
v-model:selection="selectedCategories"
:value="categories"
:loading="dataLoading"
dataKey="id"
dataKey="_id"
:paginator="true"
:rows="20"
:filters="filters"
@@ -153,7 +161,7 @@ function deleteSelectedCategories() {
</template>
<template #header>
<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>
<InputIcon>
@@ -167,10 +175,17 @@ function deleteSelectedCategories() {
<Column selectionMode="multiple" style="width: 3rem" :exportable="false"></Column>
<Column field="nativeName" 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">
<template #body="slotProps">
<Button icon="pi pi-pencil" outlined rounded class="mr-2 mx-3" @click="editCategory(slotProps.data)" />
<Button icon="pi pi-trash" class="mx-3" outlined rounded severity="danger" @click="confirmDeleteCategory(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-2" outlined rounded severity="danger" title="حذف" @click="confirmDeleteCategory(slotProps.data)" />
</template>
</Column>
</DataTable>
@@ -185,7 +200,7 @@ function deleteSelectedCategories() {
</div>
<div>
<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>
</div>
</div>
@@ -196,27 +211,27 @@ function deleteSelectedCategories() {
</template>
</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">
<i class="pi pi-exclamation-triangle !text-3xl" />
<i class="pi pi-exclamation-triangle !text-3xl text-red-500" />
<span v-if="category"
>آیا از حذف دسته بندی <b>{{ category.nativeName }} </b> مطمئن هستید؟</span
>آیا از حذف دسته بندی <b>{{ category.nativeName }}</b> مطمئن هستید؟</span
>
</div>
<template #footer>
<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>
</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">
<i class="pi pi-exclamation-triangle !text-3xl" />
<span v-if="product">Are you sure you want to delete the selected products?</span>
<i class="pi pi-exclamation-triangle !text-3xl text-red-500" />
<span>آیا از حذف <b>{{ selectedCategories?.length || 0 }}</b> دسته‌بندی انتخاب شده اطمینان دارید؟</span>
</div>
<template #footer>
<Button label="No" icon="pi pi-times" text @click="deleteProductsDialog = false" />
<Button label="Yes" icon="pi pi-check" text @click="deleteSelectedCategories" />
<Button label="خیر" icon="pi pi-times" text @click="deleteSelectedDialog = false" />
<Button label="بله، حذف شوند" icon="pi pi-check" severity="danger" @click="deleteSelectedCategories" />
</template>
</Dialog>
</div>
+225 -73
View File
@@ -1,148 +1,300 @@
<script setup>
import { onBeforeMount, onMounted, ref } from 'vue';
import { onMounted, ref, computed } from 'vue';
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 { Tabs, TabList, Tab, TabPanels, TabPanel } from 'primevue';
import { useToast } from 'primevue/usetoast';
const toast = useToast();
const selectedTab = ref('');
const selectedFeature = ref();
const submitted = ref(false);
const feature = ref({});
const categories = ref([]);
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;
try {
categories.value = await CategoryService.getAllCategories();
if (categories.value.length > 0 && !selectedTab.value) {
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(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() {
featureDialog.value = false;
submitted.value = false;
feature.value = {};
}
function openNew(categoryId) {
feature.value = {};
feature.value.category = categoryId;
feature.value = {
category: categoryId || selectedTab.value,
featureType: 'string',
isFilterable: true
};
submitted.value = false;
featureDialog.value = true;
}
const editFeature = (event, categoryId) => {
feature.value = event.value;
const editFeature = (feat, categoryId) => {
feature.value = { ...feat, category: categoryId || selectedTab.value };
submitted.value = false;
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 () => {
submitted.value = true;
if (!feature.value.nativeName || !feature.value.englishName || !feature.value.featureType) {
return;
}
let status = 'failed';
if (feature?.value._id?.trim()) {
status = await FeatureService.updateFeature(feature.value._id, feature.value);
} else {
status = await FeatureService.createFeature(feature.value);
try {
if (feature.value._id?.trim()) {
status = await FeatureService.updateFeature(feature.value._id, 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') {
toast.add({ severity: 'success', summary: 'موفق', detail: 'ویژگی جدید ساخته شد.', life: 3000 });
} else {
toast.add({ severity: 'error', summary: 'ناموفق', detail: status, life: 3000 });
}
featureDialog.value = false;
feature.value = {};
CategoryService.getAllCategories().then((data) => (categories.value = data));
const getFeatureTypeLabel = (type) => {
const found = featureTypes.find((t) => t.value === type);
return found ? found.name : (type || '-');
};
</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 class="flex gap-2">
<Skeleton v-for="i in 4" :key="i" width="8rem" height="2.5rem" class="rounded-xl" />
</div>
<div class="w-1/3 my-4 space-y-2">
<Skeleton width="100%" height="15rem" class="rounded-xl" />
<Skeleton width="6rem" height="2.5rem" class="rounded-lg" />
<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>
<Tabs v-else :value="selectedTab" :pt="{}">
<TabList>
<Tab v-for="category in categories" :key="category._id" :value="category._id">
{{ category.nativeName }}
<!-- 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>
</TabList>
<TabPanels>
<TabPanel v-for="category in categories" :key="category._id + '_content'" :value="category._id">
<p>لیست ویژگی های {{ category.nativeName }}</p>
<div class="w-1/3 my-3">
<Listbox filter :options="category.features" option-value="_id" option-label="nativeName" @item-dblclick="editFeature($event, category._id)" emptyMessage="موردی برای نمایش وجود ندارد."></Listbox>
<Button class="my-3" :categoryId="category._id" @click="openNew(category._id)">افزودن</Button>
<TabPanel v-for="cat in categories" :key="cat._id + '_content'" :value="cat._id">
<div class="flex items-center justify-between mb-4">
<span class="text-sm font-semibold text-slate-700 dark:text-slate-200">
لیست ویژگی‌های «{{ cat.nativeName }}» ({{ cat.features?.length || 0 }} مورد)
</span>
<Button
label="افزودن ویژگی"
icon="pi pi-plus"
size="small"
severity="secondary"
outlined
@click="openNew(cat._id)"
/>
</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>
</TabPanel>
</TabPanels>
</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>
<label for="nativeName" class="block font-bold mb-3">نام فارسی</label>
<InputText id="nativeName" v-model.trim="feature.nativeName" required="true" autofocus :invalid="submitted && !feature.nativeName" fluid />
<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" placeholder="مثلا: متراژ، تعداد خواب" required autofocus :invalid="submitted && !feature.nativeName" fluid />
<small v-if="submitted && !feature.nativeName" class="text-red-500">ورود نام فارسی الزامی است.</small>
</div>
<div>
<label for="englishName" class="block font-bold mb-3">نام انگلیسی</label>
<InputText id="englishName" v-model.trim="feature.englishName" required="true" autofocus :invalid="submitted && !feature.englishName" fluid />
<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" placeholder="مثلا: area, roomCount" required :invalid="submitted && !feature.englishName" fluid />
<small v-if="submitted && !feature.englishName" class="text-red-500">ورود نام انگلیسی الزامی است.</small>
</div>
<div>
<label for="unit" class="block font-bold mb-3">واحد</label>
<InputText id="unit" v-model.trim="feature.unit" required="true" autofocus :invalid="submitted && !feature.unit" 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 />
<label for="featureType" class="block font-semibold text-sm mb-1.5">نوع داده‌ای <span class="text-red-500">*</span></label>
<Select id="featureType" option-label="name" option-value="value" :options="featureTypes" v-model="feature.featureType" :invalid="submitted && !feature.featureType" fluid />
<small v-if="submitted && !feature.featureType" class="text-red-500">ورود نوع ویژگی الزامی است.</small>
</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>
<template #footer>
<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>
</Dialog>
</div>
+14 -3
View File
@@ -1,7 +1,7 @@
<script setup>
import { FilterMatchMode } from '@primevue/core/api';
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 { SettingService } from '@/service/SettingService';
import { useRouter, useRoute } from 'vue-router';
@@ -33,6 +33,7 @@ const dt = ref();
const properties = ref([]);
const selectedProperties = ref();
const selectedPropertyId = ref(null);
const hoveredPropertyId = ref(null);
const mapRef = ref(null);
const splitMapRef = ref(null);
const propertyCardRefs = ref({});
@@ -241,7 +242,11 @@ const onSelectProperty = (propertyId) => {
};
const onCardHover = (propertyId) => {
selectedPropertyId.value = propertyId;
hoveredPropertyId.value = propertyId;
};
const onCardLeave = () => {
hoveredPropertyId.value = null;
};
</script>
@@ -467,8 +472,12 @@ const onCardHover = (propertyId) => {
:key="prop._id || prop.id"
:id="`property-card-${prop._id || prop.id}`"
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)"
@mouseleave="onCardLeave"
@click="onSelectProperty(prop._id || prop.id)"
>
<!-- Card Image -->
@@ -564,6 +573,7 @@ const onCardHover = (propertyId) => {
ref="splitMapRef"
:properties="properties"
:selectedPropertyId="selectedPropertyId"
:hoveredPropertyId="hoveredPropertyId"
@select-property="onSelectProperty"
@edit-property="editProperty"
/>
@@ -576,6 +586,7 @@ const onCardHover = (propertyId) => {
ref="mapRef"
:properties="properties"
:selectedPropertyId="selectedPropertyId"
:hoveredPropertyId="hoveredPropertyId"
@select-property="onSelectProperty"
@edit-property="editProperty"
/>
+24 -6
View File
@@ -163,7 +163,7 @@
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue';
import 'leaflet/dist/leaflet.css';
import L from 'leaflet';
import { LMap, LTileLayer, LMarker, LPopup, LControlLayers, LControlScale } from '@vue-leaflet/vue-leaflet';
@@ -190,6 +190,10 @@ const props = defineProps({
selectedPropertyId: {
type: [String, Number],
default: null
},
hoveredPropertyId: {
type: [String, Number],
default: null
}
});
@@ -313,12 +317,14 @@ const getCategoryName = (prop) => {
* Creates custom Airbnb-style Leaflet pin icon
*/
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 cat = getCategoryName(prop) || 'ملک';
const html = `
<div class="airbnb-pin ${isSelected ? 'is-selected' : ''}">
<div class="airbnb-pin ${isSelected ? 'is-selected' : ''} ${isHovered ? 'is-hovered' : ''}">
<div class="pin-content">
<span class="pin-icon">🏠</span>
<span class="pin-label">${code || cat}</span>
@@ -421,9 +427,19 @@ const toggleFullscreen = () => {
}
};
document.addEventListener('fullscreenchange', () => {
const onFullscreenChange = () => {
isFullscreen.value = !!document.fullscreenElement;
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
@@ -628,7 +644,8 @@ defineExpose({
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);
z-index: 1000 !important;
}
@@ -654,7 +671,8 @@ defineExpose({
transition: all 0.2s ease;
}
.airbnb-pin:hover .pin-content {
.airbnb-pin:hover .pin-content,
.airbnb-pin.is-hovered .pin-content {
background: #4f46e5;
color: #ffffff;
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,
password: password.value
});
const isSecure = typeof window !== 'undefined' && window.location.protocol === 'https:';
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' });
} 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);
await router.push('/');
@@ -31,17 +32,16 @@ const login = async () => {
};
onBeforeMount(async () => {
const token = Cookies.get('bm-token');
try {
const response = await axios.get('/api/users/user/get-self', {
headers: {
Authorization: `Bearer ${token}`
const token = Cookies.get('bm-token') || (typeof localStorage !== 'undefined' ? localStorage.getItem('bm-token') : null);
if (token) {
try {
const response = await axios.get('/api/users/user/get-self');
if (response.data?.data?.user) {
await router.push('/');
}
});
console.log(response);
await router.push('/');
} catch (error) {
console.log(error);
} catch (error) {
console.log(error);
}
}
});
</script>