REFACTOR: Belonging → Property system migration + dashboard + map improvements

Property system:
- Add PropertyService.js and PropertyStatusService.js
- Add Property pages: CreateProperty.vue, ListProperties.vue, TransactionDialog.vue
- Remove legacy BelongingService.js and Belonging pages (CreateBelonging, ListBelongings, TransactionDialog)

Dashboard:
- Update RecentSalesWidget.vue
- Update RevenueStreamWidget.vue
- Update StatsWidget.vue

UI & config:
- Major MapComponent.vue improvements (extended functionality)
- Update ListCategories.vue
- Update ListTransactions.vue
- Update vite.config.mjs
This commit is contained in:
2026-08-11 20:36:02 +03:30
parent c01fed2622
commit 79da0d24ab
13 changed files with 1522 additions and 742 deletions
@@ -1,546 +0,0 @@
<script setup>
import { Listbox, InputText } from 'primevue';
import { onBeforeMount, ref, watch } from 'vue';
import { CategoryService } from '@/service/CategoryService';
import ImageUploader from '@/views/uikit/ImageUploader.vue';
import ImageGallery from '@/views/uikit/ImageGallery.vue';
import KiwiChips from '@/views/uikit/KiwiChips.vue';
import { useToast } from 'primevue/usetoast';
import { BelongingService } from '@/service/BelongingService';
import { useRoute, useRouter } from 'vue-router';
import MapComponent from '@/views/uikit/MapComponent.vue';
import TransactionDialog from './TransactionDialog.vue';
import { TransactionService } from '@/service/TransactionService';
import { FilterMatchMode, FilterOperator } from '@primevue/core/api';
import Popover from 'primevue/popover';
// For the single popover approach
const op = ref();
const activeDescription = ref('');
const toggle = (event, data) => {
activeDescription.value = data;
op.value.toggle(event);
};
const toast = useToast();
const categories = ref([]);
const selectedCategory = ref();
const features = ref([]);
const featuresValues = ref({});
const documentData = ref([]);
const mainImageUrlsWithData = ref([]);
const uploadedMainImageUrls = ref([]);
const documentUrlsWithData = ref([]);
const uploadedDocumentUrls = ref([]);
const editMode = ref(false);
const belonging = ref({
documentUrls: [],
mainImageUrls: [],
location: {
type: 'Point',
coordinates: [0, 0]
}
});
const tags = ref([]);
const signedImageUrls = ref([]);
const signedDocumentsUrls = ref([]);
const route = useRoute();
const router = useRouter();
const mapKey = 'web.79abbab4970e401781f434da3e41cfa6';
const serviceKey = 'service.11d860f52c434bd29e5f84d0a0a41108';
const transactions = ref([]);
const stockData = ref({});
const handleDocumentUpload = (newDocumentUrls) => {
uploadedDocumentUrls.value = newDocumentUrls.value;
const tempConcatImages = [...belonging.value?.documentUrls, ...newDocumentUrls.value];
documentUrlsWithData.value = fillImagesArray(tempConcatImages);
};
const handleDocumentDelete = (newDocumentUrls) => {
uploadedDocumentUrls.value = newDocumentUrls;
const tempConcatImages = [...belonging.value?.documentUrls, ...newDocumentUrls.value];
documentUrlsWithData.value = fillImagesArray(tempConcatImages);
};
const handleDocumentGalleryDelete = (deletedDocumentUrl, index) => {
console.log('deletedImageUrl', deletedDocumentUrl, 'at index', index);
// Find which array contains the image to delete
if (index < belonging.value.documentUrls.length) {
// Remove only at specific index from belonging.value.mainImageNames
belonging.value.documentUrls.splice(index, 1);
} else {
// Adjust index for uploadedMainImageNames array
const adjustedIndex = index - belonging.value?.documentUrls.length;
uploadedDocumentUrls.value.splice(adjustedIndex, 1);
}
// Recombine arrays
const tempConcatImages = [...belonging.value?.documentUrls, ...uploadedDocumentUrls.value];
documentUrlsWithData.value = fillImagesArray(tempConcatImages);
};
const handleImageUpload = (newImageUrls) => {
uploadedMainImageUrls.value = newImageUrls.value;
// const tempConcatImages = [...belonging.value?.mainImageUrls, ...newImageUrls.value];
// mainImageUrlsWithData.value = fillImagesArray(tempConcatImages);
};
const handleImageDelete = (newImageUrls) => {
uploadedMainImageUrls.value = newImageUrls;
// const tempConcatImages = [...belonging.value?.mainImageUrls.value, ...newImageUrls.value];
// mainImageUrlsWithData.value = fillImagesArray(tempConcatImages);
};
const handleImageGalleryDelete = (deletedImageUrl, index) => {
console.log('deletedImageUrl', deletedImageUrl, 'at index', index);
// Find which array contains the image to delete
if (index < belonging.value?.mainImageUrls.length) {
// Remove only at specific index from belonging.value.mainImageNames
belonging.value?.mainImageUrls.splice(index, 1);
} else {
// Adjust index for uploadedMainImageNames array
const adjustedIndex = index - belonging.value?.mainImageUrls.length;
uploadedMainImageUrls.value?.splice(adjustedIndex, 1);
}
// Recombine arrays
const tempConcatImages = [...belonging.value?.mainImageUrls, ...uploadedMainImageUrls.value];
mainImageUrlsWithData.value = fillImagesArray(tempConcatImages);
};
onBeforeMount(async () => {
categories.value = await CategoryService.getAllCategories();
console.log(route.name);
if (route.name === 'edit-belonging') {
editMode.value = true;
belonging.value = await BelongingService.getBelonging(route.params.belongingId);
featuresValues.value = extractFeaturesValues(belonging.value.features);
transactions.value = await TransactionService.getTransactionsByBelongingId(route.params.belongingId);
stockData.value = await TransactionService.getStockByBelongingId(route.params.belongingId);
mainImageUrlsWithData.value = fillImagesArray(belonging.value?.mainImageUrls || []);
documentUrlsWithData.value = fillImagesArray(belonging.value?.documentUrls || []);
}
});
const showTransactionDialog = ref(false);
import { UTMtoLatLon, LatLontoUTM } from '@/utm';
const easting = ref();
const northing = ref();
const zone = ref();
const lat = ref();
const long = ref();
const convertUTMtoLatLng = () => {
if (easting.value && northing.value && zone.value) {
try {
const result = UTMtoLatLon(
Number(easting.value),
Number(northing.value),
Number(zone.value),
'N' // Change to 'S' if in Southern Hemisphere
);
lat.value = result.latitude;
long.value = result.longitude;
console.log(lat.value, long.value);
searchLocation(lat.value, long.value);
} catch (error) {
console.error('Error converting UTM to Lat/Lng:', error);
}
}
};
// Watch nationalGeoCode to split and convert to Lat/Lng
watch(
() => belonging.value.nationalGeoCode,
(newCode) => {
if (newCode && newCode.length === 15) {
easting.value = newCode.substring(0, 6); // First 6 digits for easting
northing.value = newCode.substring(6, 13); // Next 6 digits for northing
zone.value = newCode.substring(13, 15); // Last 2 digits for zone
// Convert to Lat/Lng after splitting
convertUTMtoLatLng();
}
}
);
const mapRef = ref();
const searchLocation = (lat, lon) => {
if (mapRef.value) {
mapRef.value.searchByCoordinates(lat, lon);
}
};
watch(
() => belonging.value.category,
(newCategory) => {
if (editMode.value && newCategory) {
selectedCategory.value = categories.value.find((x) => x._id === newCategory);
if (selectedCategory.value) {
features.value = selectedCategory.value.features;
}
}
}
);
const fillImagesArray = (imageUrls) => {
const images = [];
for (let url of imageUrls) {
images.push({ url: url });
}
return images;
};
const openTransactionDialog = () => {
showTransactionDialog.value = true;
};
const onTransactionSaved = async () => {
if (editMode.value && belonging.value?._id) {
belonging.value = await BelongingService.getBelonging(belonging.value._id);
}
// toast.add({ severity: 'success', summary: 'موفق', detail: 'تراکنش با موفقیت ثبت شد', life: 3000 });
};
const handleSelectCategory = ($event) => {
if ($event.value) {
belonging.value.category = $event.value;
selectedCategory.value = categories.value.find((x) => x._id === $event.value);
features.value = selectedCategory.value.features;
} else {
belonging.value = {
location: {
type: 'Point',
coordinates: [0, 0]
}
};
selectedCategory.value = {};
}
};
const extractFeatures = (featuresValues) => {
const features = [];
for (const [key, value] of Object.entries(featuresValues)) {
features.push({
definition: key,
value: value
});
}
return features;
};
const extractFeaturesValues = (features) => {
const featuresValues = {};
for (let feature of features) {
featuresValues[feature.definition] = feature.value;
}
return featuresValues;
};
const saveBelonging = async () => {
let status = 'failed';
console.log(belonging);
if (belonging?.value._id?.trim()) {
belonging.value.mainImageUrls = mainImageUrlsWithData.value.map((x) => x.url);
belonging.value.documentUrls = documentUrlsWithData.value.map((x) => x.url);
belonging.value.features = extractFeatures(featuresValues.value);
status = await BelongingService.updateBelonging(belonging.value._id, belonging.value);
} else {
belonging.value.mainImageUrls = mainImageUrlsWithData.value.map((x) => x.url);
belonging.value.documentUrls = documentUrlsWithData.value.map((x) => x.url);
belonging.value.features = extractFeatures(featuresValues.value);
status = await BelongingService.createBelonging(belonging.value);
}
if (status === 'success') {
toast.add({ severity: 'success', summary: 'موفق', detail: 'ویژگی جدید ساخته شد.', life: 3000 });
} else {
toast.add({ severity: 'error', summary: 'ناموفق', detail: status, life: 3000 });
}
setTimeout(async () => {
await router.push('/belonging/list-belongings');
}, 1000);
};
const filters = ref({
global: { value: null, matchMode: FilterMatchMode.CONTAINS },
type: { value: null, matchMode: FilterMatchMode.EQUALS },
status: { value: null, matchMode: FilterMatchMode.EQUALS },
value: { value: null, matchMode: FilterMatchMode.BETWEEN },
date: { value: null, matchMode: FilterMatchMode.DATE_IS }
});
// Options for filters
const typeOptions = [
{ label: 'واریز', value: 'deposit' },
{ label: 'برداشت', value: 'withdraw' }
];
const statusOptions = [
{ label: 'ناموفق', value: 'failed' },
{ label: 'در انتظار', value: 'pending' },
{ label: 'تکمیل شده', value: 'finished' }
];
// Helper functions for translations and formatting
const getTypeTranslation = (type) => {
switch (type) {
case 'deposit':
return 'واریز';
case 'withdraw':
return 'برداشت';
default:
return type;
}
};
const getTypeSeverity = (type) => {
switch (type) {
case 'deposit':
return 'success';
case 'withdraw':
return 'danger';
default:
return null;
}
};
const getStatusTranslation = (status) => {
switch (status) {
case 'failed':
return 'ناموفق';
case 'pending':
return 'در انتظار';
case 'finished':
return 'تکمیل شده';
default:
return status;
}
};
const getStatusSeverity = (status) => {
switch (status) {
case 'failed':
return 'danger';
case 'pending':
return 'warn';
case 'finished':
return 'success';
default:
return null;
}
};
const formatCurrency = (value) => {
return new Intl.NumberFormat('fa-IR', {
style: 'currency',
currency: 'IRR',
minimumFractionDigits: 0
}).format(Math.abs(value));
};
const formatDate = (dateString) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat('fa-IR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(date);
};
</script>
<template>
<TransactionDialog v-model:visible="showTransactionDialog" :belongingId="belonging._id" @saved="onTransactionSaved" />
<div className="card lg:p-5 px-3">
<div class="mb-4" v-if="editMode === true">
<Button @click="openTransactionDialog">افزایش یا کاهش موجودی</Button>
</div>
<div class="font-semibold text-xl mb-4" v-if="editMode === false">ایجاد اموال</div>
<p v-if="editMode === false">از طریق این صفحه می توانید اموال جدید را به سوابق اضافه کنید.</p>
<div class="font-semibold text-xl mb-4" v-if="editMode === true">
در حال ویرایش
<span class="text-primary">{{ belonging.name }}</span>
</div>
<div class="lg:flex my-6">
<div class="lg:w-3/4 w-full ml-3">
<div class="">
<label class="text-lg"> نام دارایی </label>
<InputText v-model="belonging.name" class="w-full my-2" placeholder="مثلا: 100 عدد کولر گازی" />
</div>
<div class="my-6">
<label class="text-lg"> توضیحات </label>
<Textarea v-model="belonging.description" class="w-full my-2" placeholder="مثلا: در تاریخ 1403/09/11 خریداری شد." rows="6" />
</div>
<div class="my-6">
<label class="text-lg"> تصاویر، فایل ها و مدارک </label>
<ImageUploader
v-model="uploadedDocumentUrls"
:max-files="50"
mode="private"
delete-endpoint="/api/files/bucket/delete-file/belonging-documents"
upload-endpoint="/api/files/bucket/upload-multi-filepond/belonging-documents"
@upload="handleDocumentUpload"
@delete="handleDocumentDelete"
/>
<div v-if="documentUrlsWithData.length > 0">
<ImageGallery v-model="documentUrlsWithData" gallery-id="documents" @delete-image="handleDocumentGalleryDelete" />
</div>
</div>
<div class="my-6" v-if="signedDocumentsUrls.length > 0">
<label class="text-lg"> فایل ها و مدارک </label>
</div>
<div class="my-6 max-h-96 overflow-y-auto" v-if="features.length > 0">
<label class="text-lg"> اطلاعات دارایی </label>
<div class="my-6 mr-10" v-for="feature of features">
<label>{{ feature.nativeName }}</label>
<InputText v-model="featuresValues[feature._id]" class="w-1/3 my-2 block" placeholder="" />
</div>
</div>
<div class="my-10">
<label class="text-lg"> موقعیت دارایی </label>
<MapComponent ref="mapRef" v-model="belonging.location.coordinates" />
<!-- <MapIrComponent />-->
</div>
<div class="my-10" v-if="editMode">
<label class="text-lg"> موجودی </label>
<p class="mx-3 text-blue-400">موجودی فعلی : {{ stockData.finishedInventory }}</p>
<p class="mx-3 text-green-400">موجودی در حال اضافه شدن : {{ stockData.pendingDepositsInventory }}</p>
<p class="mx-3 text-red-400">
موجودی در حال کم شدن : <span dir="ltr">{{ stockData.pendingWithdrawsInventory }}</span>
</p>
<div class="mx-6 py-4">
<label> تراکنش ها </label>
<div class="card lg:p-5 px-3">
<DataTable
:value="transactions"
paginator
:rows="20"
:rowsPerPageOptions="[20, 50, 100]"
tableStyle="min-width: 50rem"
stripedRows
v-model:filters="filters"
filterDisplay="menu"
:globalFilterFields="['type', 'status', 'value', 'date', 'description']"
>
<template #header>
<div class="lg:flex lg:justify-content-between">
<span class="p-input-icon-left">
<!-- <i class="pi pi-search" />-->
<InputText v-model="filters['global'].value" placeholder="جستجو..." class="w-full" />
</span>
</div>
</template>
<Column field="id" header="شناسه" sortable style="width: 25%"></Column>
<Column field="type" header="نوع" sortable style="width: 15%">
<template #body="{ data }">
<Tag :severity="getTypeSeverity(data.type)" :value="getTypeTranslation(data.type)" />
</template>
<template #filter="{ filterModel, filterCallback }">
<Dropdown v-model="filterModel.value" @change="filterCallback()" :options="typeOptions" optionLabel="label" optionValue="value" placeholder="انتخاب نوع" class="p-column-filter" showClear />
</template>
</Column>
<Column field="status" header="وضعیت" sortable style="width: 15%">
<template #body="{ data }">
<Tag :severity="getStatusSeverity(data.status)" :value="getStatusTranslation(data.status)" />
</template>
<template #filter="{ filterModel, filterCallback }">
<Dropdown v-model="filterModel.value" @change="filterCallback()" :options="statusOptions" optionLabel="label" optionValue="value" placeholder="انتخاب وضعیت" class="p-column-filter" showClear />
</template>
</Column>
<Column field="value" header="تعداد" sortable style="width: 15%">
<template #body="{ data }">
<span :class="data.value >= 0 ? 'text-green-500' : 'text-red-500'" dir="ltr"> {{ data.value }} </span>
</template>
</Column>
<Column field="date" header="تاریخ" sortable style="width: 20%">
<template #body="{ data }">
{{ formatDate(data.date) }}
</template>
</Column>
<Column field="description" header="توضیحات" style="width: 10%">
<template #body="{ data }">
<Button v-if="data.description" icon="pi pi-info-circle" text rounded aria-label="توضیحات" @click="toggle($event, data.description)" />
</template>
</Column>
<Popover ref="op" :showCloseIcon="true" :autoHide="false">
<div class="p-2">{{ activeDescription }}</div>
</Popover>
</DataTable>
</div>
</div>
</div>
</div>
<div class="lg:w-1/4 w-full">
<div>
<label class="text-lg"> دسته بندی </label>
<Listbox @change="handleSelectCategory($event)" v-model="belonging.category" :options="categories" optionLabel="nativeName" optionValue="_id" checkmark :highlightOnSelect="false" class="w-full my-2" />
</div>
<div class="my-6">
<label class="text-lg"> تصویر اصلی </label>
<ImageUploader
v-model="uploadedMainImageUrls"
accepted-files="image/*"
:max-files="1"
mode="private"
delete-endpoint="/api/files/bucket/delete/belonging-images"
upload-endpoint="/api/files/bucket/upload-multi-filepond/belonging-images"
@upload="handleImageUpload"
@delete="handleImageDelete"
/>
<div class="my-10 border rounded-lg p-3 border-surface-700" v-if="mainImageUrlsWithData.length > 0">
<label for="ImageGallery">گالری تصاویر</label>
<div class="">
<ImageGallery v-model="mainImageUrlsWithData" gallery-id="productImages" @delete-image="handleImageGalleryDelete" />
</div>
</div>
</div>
<div class="my-8">
<label class="text-lg"> واحد </label>
<InputText v-model="belonging.unit" class="w-full" placeholder="مثلا: عدد، متر، و..." />
</div>
<div class="my-8">
<label class="text-lg"> برچسب ها </label>
<KiwiChips v-model="belonging.tags" class="w-full" placeholder="مثلا: زمین، ملک، کیانپارس..." />
</div>
<div class="my-8">
<label class="text-lg"> شناسه ملی جغرافیایی </label>
<InputText v-model="belonging.nationalGeoCode" class="w-full" placeholder="شناسه 14 رقمی سند" />
</div>
</div>
</div>
<div class="flex justify-center">
<Button @click="saveBelonging">ذخیره</Button>
</div>
</div>
</template>
<style>
@import url('leaflet/dist/leaflet.css');
</style>
<style scoped>
label {
@apply text-primary;
}
</style>
+1 -1
View File
@@ -121,7 +121,7 @@ function deleteSelectedCategories() {
:filters="filters"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
:rowsPerPageOptions="[20, 40, 80]"
currentPageReportTemplate="نمایش {first} تا {last} از {totalRecords} دارایی"
currentPageReportTemplate="نمایش {first} تا {last} از {totalRecords} دسته‌بندی"
>
<template #header>
<div class="flex flex-wrap gap-2 items-center justify-between">
+909
View File
@@ -0,0 +1,909 @@
<script setup>
import { onBeforeMount, ref, watch, computed } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useRoute, useRouter } from 'vue-router';
import { CategoryService } from '@/service/CategoryService';
import { PropertyService } from '@/service/PropertyService';
import { PropertyStatusService } from '@/service/PropertyStatusService';
import ImageUploader from '@/views/uikit/ImageUploader.vue';
import KiwiChips from '@/views/uikit/KiwiChips.vue';
import MapComponent from '@/views/uikit/MapComponent.vue';
import { UTMtoLatLon } from '@/utm';
// PrimeVue Components
import Dropdown from 'primevue/dropdown';
import MultiSelect from 'primevue/multiselect';
import InputText from 'primevue/inputtext';
import InputNumber from 'primevue/inputnumber';
import Textarea from 'primevue/textarea';
import ToggleSwitch from 'primevue/toggleswitch';
import Button from 'primevue/button';
import Tag from 'primevue/tag';
import SelectButton from 'primevue/selectbutton';
const toast = useToast();
const route = useRoute();
const router = useRouter();
// State
const categories = ref([]);
const statuses = ref([]);
const selectedCategory = ref(null);
const features = ref([]);
const featuresValues = ref({});
const mainImageUrlsWithData = ref([]);
const documentUrlsWithData = ref([]);
const editMode = ref(false);
const isSaving = ref(false);
const propertyTypes = ref([
{ label: 'زمین', value: 'Land' },
{ label: 'ساختمان', value: 'Building' }
]);
const documentTypeOptions = ref(['سند تک‌برگ', 'منگوله‌دار', 'وقفی', 'مشاع', 'بنچاق', 'قولنامه‌ای']);
const specialStatusOptions = ref(['عادی', 'توقیف', 'رهن', 'وراثتی', 'اوقافی', 'دولتی', 'در دست ساخت', 'مسدود']);
const zoningOptions = ref(['مسکونی', 'تجاری', 'کشاورزی', 'صنعتی', 'ویلایی', 'باغی', 'آموزشی', 'اداری']);
const structureTypeOptions = ref(['اسکلت بتنی', 'اسکلت فلزی', 'دیوار باربر', 'سازه پیش‌ساخته']);
const flooringTypeOptions = ref(['سرامیک', 'سنگ', 'پارکت', 'لمینت', 'موزاییک']);
const sideDirectionOptions = ref(['شمالی', 'جنوبی', 'شرقی', 'غربی', 'شمال شرقی', 'شمال غربی', 'جنوب شرقی', 'جنوب غربی']);
const property = ref({
name: '',
propertyType: 'Land',
category: null,
status: null,
description: '',
tags: [],
nationalGeoCode: '',
uniqueId: '',
serialNumber: '',
provinceRegistry: '',
localRegistryOffice: '',
registrationDate: '',
documentUrls: [],
mainImageUrls: [],
location: {
type: 'Point',
coordinates: [0, 0]
},
// 1. مشخصات مالک
ownerName: '',
fatherName: '',
idCardNumber: '',
nationalId: '',
issuePlace: '',
birthDate: '',
nationality: 'ایران',
dong: 6,
ownershipDocs: '',
// 2. مشخصات ملک
subNumber: '',
mainNumber: '',
separatedFrom: '',
plotNumber: '',
registrationSection: '',
district: '',
area: null,
blockNumber: '',
sideDirection: '',
floor: '',
specialStatus: 'عادی',
zoningType: 'مسکونی',
documentType: 'سند تک‌برگ',
address: '',
postalCode: '',
propertyBoundaries: '',
restrictions: '',
// Technical Features
landArea: null,
streetWidth: null,
facadeCount: null,
landLength: null,
landWidth: null,
builtArea: null,
floorsCount: null,
unitsCount: null,
constructionYear: null,
elevator: false,
parking: false,
warehouse: false,
heatingSystem: '',
coolingSystem: '',
structureType: '',
flooringType: ''
});
// Map state
const mapRef = ref(null);
const easting = ref();
const northing = ref();
const zone = ref();
const lat = ref();
const long = ref();
// Auto-generated property name: ownerName + شماره اصلی/شماره فرعی
const computedPropertyName = computed(() => {
const owner = property.value.ownerName?.trim() || '';
const main = property.value.mainNumber?.trim() || '';
const sub = property.value.subNumber?.trim() || '';
const regNum = main && sub ? `${main}/${sub}` : (main || sub || '');
if (owner && regNum) return `${owner} - ${regNum}`;
if (owner) return owner;
if (regNum) return regNum;
return '';
});
// Dong options with fractional description
const dongOptions = [
{ value: 1, label: '۱ دانگ', fraction: '۱⁏۶' },
{ value: 2, label: '۲ دانگ', fraction: '۱⁏۳ (یک سوم)' },
{ value: 3, label: '۳ دانگ', fraction: '۱⁏۲ (نیم)' },
{ value: 4, label: '۴ دانگ', fraction: '۲⁏۳ (دو سوم)' },
{ value: 5, label: '۵ دانگ', fraction: '۵⁏۶' },
{ value: 6, label: '۶ دانگ', fraction: 'تمام (مالکیت کامل)' },
];
const convertUTMtoLatLng = () => {
if (easting.value && northing.value && zone.value) {
try {
const result = UTMtoLatLon(
Number(easting.value),
Number(northing.value),
Number(zone.value),
'N'
);
lat.value = result.latitude;
long.value = result.longitude;
searchLocation(lat.value, long.value);
} catch (error) {
console.error('Error converting UTM to Lat/Lng:', error);
}
}
};
const handleMarkerUpdated = (coords) => {
if (coords && Array.isArray(coords)) {
property.value.location = {
type: 'Point',
coordinates: [coords[1], coords[0]]
};
lat.value = coords[0];
long.value = coords[1];
}
};
const searchLocation = (latitude, longitude) => {
if (mapRef.value && latitude && longitude) {
mapRef.value.setCenterAndMarker(Number(latitude), Number(longitude));
}
};
let geoCodeDebounceTimeout = null;
/**
* Parse Iran's شناسه ملی جغرافیایی (JAM code) which is a concatenated UTM string:
* - First 6 digits → Easting (e.g. 538136)
* - Next 7 digits → Northing (e.g. 3950135)
* - Last 2 digits → UTM Zone (e.g. 39 for most of Iran)
* If the code doesn't match the 15-digit pattern, falls back to zone 39.
*/
const parseNationalGeoCode = (code) => {
const digits = code.replace(/\D/g, ''); // strip non-digits
if (digits.length === 15) {
// Standard JAM: 6 easting + 7 northing + 2 zone
const e = parseInt(digits.slice(0, 6), 10);
const n = parseInt(digits.slice(6, 13), 10);
const z = parseInt(digits.slice(13, 15), 10);
return { easting: e, northing: n, zone: z };
}
if (digits.length === 13) {
// Some codes omit the zone (older format) — assume zone 39 for Iran
const e = parseInt(digits.slice(0, 6), 10);
const n = parseInt(digits.slice(6, 13), 10);
return { easting: e, northing: n, zone: 39 };
}
return null;
};
const applyNationalGeoCode = (val) => {
// 1. Try parsing as a JAM UTM code (15-digit or 13-digit)
const parsed = parseNationalGeoCode(val);
if (parsed) {
try {
const result = UTMtoLatLon(parsed.easting, parsed.northing, parsed.zone, 'N');
lat.value = result.latitude;
long.value = result.longitude;
easting.value = parsed.easting;
northing.value = parsed.northing;
zone.value = parsed.zone;
searchLocation(result.latitude, result.longitude);
return;
} catch (err) {
console.error('UTM conversion failed:', err);
}
}
// 2. Fallback: treat as lat,lon text or geocode search via map
if (mapRef.value && typeof mapRef.value.searchLocation === 'function') {
mapRef.value.searchLocation(val);
}
};
const handleNationalGeoCodeChange = () => {
const val = property.value.nationalGeoCode?.trim();
if (!val) return;
const digits = val.replace(/\D/g, '');
// Fire immediately once a full 15-digit JAM code is complete — no waiting
if (digits.length === 15) {
if (geoCodeDebounceTimeout) clearTimeout(geoCodeDebounceTimeout);
applyNationalGeoCode(val);
return;
}
// For partial / non-standard input, debounce to avoid firing on every keystroke
if (geoCodeDebounceTimeout) clearTimeout(geoCodeDebounceTimeout);
geoCodeDebounceTimeout = setTimeout(() => applyNationalGeoCode(val), 600);
};
// Auto sync category when propertyType changes
watch(() => property.value.propertyType, (newType) => {
if (!categories.value || categories.value.length === 0) return;
let targetCatName = newType === 'Land' ? 'Lot' : 'Building';
let matchedCategory = categories.value.find(c => c.englishName === targetCatName || c.nativeName === (newType === 'Land' ? 'زمین' : 'ساختمان'));
if (matchedCategory) {
property.value.category = matchedCategory._id;
handleSelectCategory(matchedCategory._id);
}
});
const initializeFeatureDefaultValues = () => {
featuresValues.value = {};
if (features.value && Array.isArray(features.value)) {
features.value.forEach((feat) => {
const definition = feat.definition || feat;
if (definition && definition._id) {
if (definition.type === 'boolean') {
featuresValues.value[definition._id] = false;
} else if (definition.type === 'multiselect') {
featuresValues.value[definition._id] = [];
} else {
featuresValues.value[definition._id] = null;
}
}
});
}
};
const extractFeaturesValues = (featuresArray) => {
const values = {};
if (Array.isArray(featuresArray)) {
featuresArray.forEach((item) => {
const defId = item.definition?._id || item.definition;
if (defId) {
values[defId] = item.value;
}
});
}
return values;
};
const serializeFeaturesPayload = (valuesObject) => {
const result = [];
if (valuesObject && typeof valuesObject === 'object') {
Object.entries(valuesObject).forEach(([definitionId, val]) => {
if (val !== null && val !== undefined && val !== '') {
result.push({
definition: definitionId,
value: val
});
}
});
}
return result;
};
const groupedFeatures = computed(() => {
if (!features.value || !Array.isArray(features.value)) return {};
const groups = {};
features.value.forEach((feat) => {
const def = feat.definition || feat;
const tag = def.tag || 'سایر ویژگی‌ها';
if (!groups[tag]) groups[tag] = [];
groups[tag].push(def);
});
return groups;
});
const fillImagesArray = (urlsArray) => {
if (!Array.isArray(urlsArray)) return [];
return urlsArray.map((url) => ({
name: url.split('/').pop() || 'file',
url: url,
progress: 100
}));
};
const handleSelectCategory = (catId) => {
selectedCategory.value = categories.value.find((c) => c._id === catId);
if (selectedCategory.value) {
features.value = selectedCategory.value.features || [];
initializeFeatureDefaultValues();
} else {
features.value = [];
featuresValues.value = {};
}
};
const onMainImageUploaded = (res) => {
if (res && res.url) {
mainImageUrlsWithData.value.push({
name: res.originalName || 'image',
url: res.url,
progress: 100
});
}
};
const onDocumentUploaded = (res) => {
if (res && res.url) {
documentUrlsWithData.value.push({
name: res.originalName || 'document',
url: res.url,
progress: 100
});
}
};
const getStatusSeverity = (st) => {
if (!st) return 'secondary';
// Use the color field from the API; fall back to secondary
const valid = ['success', 'info', 'warn', 'danger', 'secondary', 'contrast'];
return valid.includes(st.color) ? st.color : 'secondary';
};
onBeforeMount(async () => {
try {
const [fetchedCategories, fetchedStatuses] = await Promise.all([
CategoryService.getAllCategories(),
PropertyStatusService.getAllStatuses()
]);
categories.value = fetchedCategories || [];
statuses.value = fetchedStatuses || [];
const propId = route.params.propertyId || route.params.belongingId;
if (propId) {
editMode.value = true;
const data = await PropertyService.getProperty(propId);
if (data) {
property.value = { ...property.value, ...data };
featuresValues.value = extractFeaturesValues(data.features);
mainImageUrlsWithData.value = fillImagesArray(data.mainImageUrls || []);
documentUrlsWithData.value = fillImagesArray(data.documentUrls || []);
if (data.category) {
const catId = typeof data.category === 'object' ? data.category._id : data.category;
selectedCategory.value = categories.value.find((x) => x._id === catId);
if (selectedCategory.value) {
features.value = selectedCategory.value.features || [];
}
}
}
} else {
let matchedCat = categories.value.find(c => c.englishName === 'Lot' || c.nativeName === 'زمین');
if (matchedCat) {
property.value.category = matchedCat._id;
handleSelectCategory(matchedCat._id);
}
}
} catch (err) {
console.error('Initialization error:', err);
}
});
const saveProperty = async () => {
// Auto-set name from ownerName + mainNumber/subNumber
property.value.name = computedPropertyName.value || 'ملک جدید';
if (!property.value.nationalGeoCode?.trim()) {
toast.add({ severity: 'warn', summary: 'هشدار', detail: 'شناسه ملی جغرافیایی الزامی است.', life: 3000 });
return;
}
isSaving.value = true;
try {
property.value.mainImageUrls = mainImageUrlsWithData.value.map((x) => x.url);
property.value.documentUrls = documentUrlsWithData.value.map((x) => x.url);
property.value.features = serializeFeaturesPayload(featuresValues.value);
let status = 'failed';
if (property.value._id?.trim()) {
status = await PropertyService.updateProperty(property.value._id, property.value);
} else {
status = await PropertyService.createProperty(property.value);
}
if (status === 'success') {
toast.add({
severity: 'success',
summary: 'موفقیت‌آمیز',
detail: editMode.value ? 'اطلاعات ملک با موفقیت بروزرسانی شد.' : 'ملک جدید با موفقیت ایجاد شد.',
life: 3000
});
setTimeout(async () => {
await router.push('/property/list-properties');
}, 800);
} else {
toast.add({ severity: 'error', summary: 'خطا در ثبت', detail: status, life: 4000 });
}
} catch (error) {
console.error('Save error:', error);
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطایی در ارتباط با سرور رخ داد.', life: 4000 });
} finally {
isSaving.value = false;
}
};
</script>
<template>
<div class="space-y-6 dir-rtl" style="direction: rtl;">
<!-- Header Bar -->
<div class="card p-4 bg-surface-0 dark:bg-surface-900 shadow-sm border border-surface-200 dark:border-surface-800 rounded-2xl flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div class="flex items-center gap-3">
<div class="p-3 bg-primary-50 dark:bg-primary-950/50 rounded-xl text-primary">
<i class="pi pi-building text-2xl"></i>
</div>
<div>
<div class="flex items-center gap-2 flex-wrap">
<h1 class="text-xl font-bold text-surface-900 dark:text-surface-0">
{{ editMode ? 'ویرایش ملک' : 'ایجاد ملک جدید' }}
</h1>
<Tag :value="editMode ? 'حالت ویرایش' : 'حالت ایجاد'" :severity="editMode ? 'warn' : 'success'" rounded class="px-2.5 text-xs font-semibold" />
</div>
<p class="text-xs text-surface-500 dark:text-surface-400 mt-0.5">
{{ editMode ? property.name || 'در حال بروزرسانی اطلاعات ملک' : 'مشخصات ملک (زمین یا ساختمان)، ویژگی‌ها و شناسه ملی جغرافیایی را ثبت نمایید' }}
</p>
</div>
</div>
<div class="flex items-center gap-2 flex-wrap justify-end">
<Button label="بازگشت" icon="pi pi-arrow-right" severity="secondary" text rounded @click="router.push('/property/list-properties')" />
<Button
:label="editMode ? 'بروزرسانی ملک' : 'ذخیره ملک'"
icon="pi pi-check"
severity="primary"
rounded
:loading="isSaving"
@click="saveProperty"
class="px-5 font-semibold"
/>
</div>
</div>
<!-- Main Layout Grid -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
<!-- Main Content Area -->
<div class="lg:col-span-8 space-y-6 w-full">
<!-- 1. General Identification & Registry Card (شناسه و اداره ثبت) -->
<div class="card p-4 sm:p-6 bg-surface-0 dark:bg-surface-900 shadow-sm border border-surface-200 dark:border-surface-800 rounded-2xl space-y-5">
<div class="flex items-center gap-2 text-primary font-bold text-lg border-b border-surface-100 dark:border-surface-800 pb-3">
<i class="pi pi-file-edit text-xl"></i>
<span>شناسه و اداره ثبت اسناد</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- 1. شناسه یکتا -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">
شناسه یکتا ثبت ملک (۱۸ رقم)
</label>
<InputText
v-model="property.uniqueId"
placeholder="مثلاً: ۱۳۹۹۲۱۵۸۰۳۰۱۰۰۰۱۲۳"
class="w-full !rounded-xl"
fluid
/>
</div>
<!-- شناسه ملی جغرافیایی (جام) -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">
شناسه ملی جغرافیایی (جام) <span class="text-red-500">*</span>
</label>
<div class="flex gap-2">
<InputText
v-model="property.nationalGeoCode"
placeholder="کد ۱۵ رقمی جام (UTM)"
class="w-full !rounded-xl"
fluid
@input="handleNationalGeoCodeChange"
@change="handleNationalGeoCodeChange"
@keyup.enter="handleNationalGeoCodeChange"
/>
<Button
icon="pi pi-map-marker"
severity="secondary"
outlined
rounded
title="یافتن نقطه روی نقشه"
@click="handleNationalGeoCodeChange"
/>
</div>
</div>
<!-- 2. شماره سریال -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">شماره سریال سند</label>
<InputText v-model="property.serialNumber" placeholder="مثلاً: الف/۹۹ ۱۲۳۴۵۶" class="w-full !rounded-xl" fluid />
</div>
<!-- 3. اداره کل استان -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">اداره کل استان</label>
<InputText v-model="property.provinceRegistry" placeholder="مثلاً: خوزستان" class="w-full !rounded-xl" fluid />
</div>
<!-- 4. اداره ثبت اسناد و املاک -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">اداره ثبت اسناد و املاک</label>
<InputText v-model="property.localRegistryOffice" placeholder="مثلاً: کرج - ناحیه ۱" class="w-full !rounded-xl" fluid />
</div>
<!-- 5. تاریخ ثبت -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">تاریخ ثبت</label>
<InputText v-model="property.registrationDate" placeholder="مثلاً: ۱۳۹۹/۰۴/۱۵" class="w-full !rounded-xl" fluid />
</div>
<!-- Auto Name Preview -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">
نام ملک
<span class="text-xs text-surface-400 font-normal mr-1">(خودکار تولید می‌شود)</span>
</label>
<div class="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-surface-100 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 min-h-[42px]">
<i class="pi pi-tag text-primary text-sm"></i>
<span v-if="computedPropertyName" class="text-sm font-semibold text-surface-800 dark:text-surface-100">
{{ computedPropertyName }}
</span>
<span v-else class="text-sm text-surface-400 italic">
پس از وارد کردن نام مالک و شماره ثبتی نمایش داده می‌شود
</span>
</div>
</div>
<!-- Category Selector & Status -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">دسته‌بندی اصلی</label>
<Dropdown
v-model="property.category"
:options="categories"
optionLabel="nativeName"
optionValue="_id"
placeholder="انتخاب دسته‌بندی"
class="w-full !rounded-xl"
@change="handleSelectCategory($event.value)"
showClear
fluid
/>
</div>
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">وضعیت ملک</label>
<Dropdown
v-model="property.status"
:options="statuses"
optionLabel="nativeName"
optionValue="_id"
placeholder="انتخاب وضعیت"
class="w-full !rounded-xl"
showClear
fluid
>
<template #option="slotProps">
<div class="flex items-center gap-2">
<Tag :value="slotProps.option.nativeName" :severity="getStatusSeverity(slotProps.option)" rounded class="text-xs" />
</div>
</template>
</Dropdown>
</div>
</div>
</div>
<!-- 2. Owner Specifications Card (مشخصات مالک) -->
<div class="card p-4 sm:p-6 bg-surface-0 dark:bg-surface-900 shadow-sm border border-surface-200 dark:border-surface-800 rounded-2xl space-y-5">
<div class="flex items-center gap-2 text-primary font-bold text-lg border-b border-surface-100 dark:border-surface-800 pb-3">
<i class="pi pi-user text-xl"></i>
<span>مشخصات مالک</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- 1. نام و نام خانوادگی مالک -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">نام و نام خانوادگی مالک</label>
<InputText v-model="property.ownerName" placeholder="نام و نام خانوادگی" class="w-full !rounded-xl" fluid />
</div>
<!-- 2. نام پدر مالک -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">نام پدر مالک</label>
<InputText v-model="property.fatherName" placeholder="نام پدر" class="w-full !rounded-xl" fluid />
</div>
<!-- 3. شماره شناسنامه مالک -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">شماره شناسنامه مالک</label>
<InputText v-model="property.idCardNumber" placeholder="شماره شناسنامه" class="w-full !rounded-xl" fluid />
</div>
<!-- 4. شماره ملی مالک یا شناسه ملی شرکت -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">شماره ملی مالک یا شناسه ملی شرکت</label>
<InputText v-model="property.nationalId" placeholder="شماره ملی (۱۰ رقم) / شناسه شرکت (۱۱ رقم)" class="w-full !rounded-xl" fluid />
</div>
<!-- 5. محل صدور شناسنامه مالک یا محل ثبت شرکت -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">محل صدور شناسنامه / محل ثبت شرکت</label>
<InputText v-model="property.issuePlace" placeholder="محل صدور یا ثبت" class="w-full !rounded-xl" fluid />
</div>
<!-- 6. تاریخ تولد مالک یا تاریخ ثبت شرکت -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">تاریخ تولد مالک / تاریخ ثبت شرکت</label>
<InputText v-model="property.birthDate" placeholder="مثلاً: ۱۳۶۵/۰۵/۱۲" class="w-full !rounded-xl" fluid />
</div>
<!-- 7. تابعیت -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">تابعیت</label>
<InputText v-model="property.nationality" placeholder="مثلاً: ایران" class="w-full !rounded-xl" fluid />
</div>
<!-- 8. میزان مالکیت (دانگ) -->
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-2">
میزان مالکیت (دانگ)
<span class="text-xs text-surface-400 font-normal mr-2">
(هر ملک از ۶ دانگ تشکیل شده)
</span>
</label>
<div class="flex gap-2 flex-wrap items-center">
<button
v-for="opt in dongOptions"
:key="opt.value"
type="button"
@click="property.dong = opt.value"
class="flex flex-col items-center justify-center px-3 py-1.5 rounded-xl border transition-all cursor-pointer min-w-[64px] text-xs"
:class="property.dong === opt.value
? 'bg-primary text-primary-contrast border-primary shadow-md shadow-primary/25 font-bold scale-[1.02]'
: 'bg-surface-0 dark:bg-surface-900 text-surface-700 dark:text-surface-200 border-surface-200 dark:border-surface-700 hover:border-primary/50 hover:bg-surface-50 dark:hover:bg-surface-800'"
>
<span class="text-xs font-bold leading-tight">{{ opt.label }}</span>
<span class="text-[10px] opacity-70 leading-tight mt-0.5">{{ opt.fraction }}</span>
</button>
</div>
</div>
<!-- 9. مستندات مالکیت -->
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">مستندات مالکیت</label>
<Textarea v-model="property.ownershipDocs" placeholder="شرح مستندات، شماره بنچاق، مبایعه‌نامه و انتقال..." rows="2" class="w-full !rounded-xl" fluid />
</div>
</div>
</div>
<!-- 3. Property Specifications Card (مشخصات ملک) -->
<div class="card p-4 sm:p-6 bg-surface-0 dark:bg-surface-900 shadow-sm border border-surface-200 dark:border-surface-800 rounded-2xl space-y-5">
<div class="flex items-center gap-2 text-primary font-bold text-lg border-b border-surface-100 dark:border-surface-800 pb-3">
<i class="pi pi-building text-xl"></i>
<span>مشخصات ملک</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- 1. شماره فرعی -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">شماره فرعی</label>
<InputText v-model="property.subNumber" placeholder="شماره فرعی ثبت" class="w-full !rounded-xl" fluid />
</div>
<!-- 2. شماره اصلی -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">شماره اصلی</label>
<InputText v-model="property.mainNumber" placeholder="شماره اصلی ثبت" class="w-full !rounded-xl" fluid />
</div>
<!-- 3. مفروز و مجزی از -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">مفروز و مجزی از</label>
<InputText v-model="property.separatedFrom" placeholder="شماره پلاک یا بخش قبلی" class="w-full !rounded-xl" fluid />
</div>
<!-- 4. قطعه -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">قطعه</label>
<InputText v-model="property.plotNumber" placeholder="شماره قطعه" class="w-full !rounded-xl" fluid />
</div>
<!-- 5. بخش ثبتی -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">بخش ثبتی</label>
<InputText v-model="property.registrationSection" placeholder="مثلاً: بخش ۱ اهواز" class="w-full !rounded-xl" fluid />
</div>
<!-- 6. ناحیه -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">ناحیه</label>
<InputText v-model="property.district" placeholder="مثلاً: ناحیه ۲" class="w-full !rounded-xl" fluid />
</div>
<!-- 7. مساحت به متر مربع -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">مساحت به متر مربع</label>
<InputNumber v-model="property.area" placeholder="مثلاً: ۳۵۰.۵" class="w-full !rounded-xl" fluid />
</div>
<!-- 8. بلوک -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">بلوک</label>
<InputText v-model="property.blockNumber" placeholder="شماره بلوک" class="w-full !rounded-xl" fluid />
</div>
<!-- 9. سمت -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">سمت</label>
<Dropdown v-model="property.sideDirection" :options="sideDirectionOptions" placeholder="انتخاب سمت" class="w-full !rounded-xl" fluid showClear />
</div>
<!-- 10. طبقه -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">طبقه</label>
<InputText v-model="property.floor" placeholder="مثلاً: طبقه اول / همکف" class="w-full !rounded-xl" fluid />
</div>
<!-- 11. نوع ملک -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">
نوع ملک <span class="text-red-500">*</span>
</label>
<SelectButton v-model="property.propertyType" :options="propertyTypes" optionLabel="label" optionValue="value" class="w-full" />
</div>
<!-- 12. وضعیت خاص -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">وضعیت خاص</label>
<Dropdown v-model="property.specialStatus" :options="specialStatusOptions" placeholder="انتخاب وضعیت خاص" class="w-full !rounded-xl" fluid />
</div>
<!-- 13. کاربری -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">کاربری</label>
<Dropdown v-model="property.zoningType" :options="zoningOptions" placeholder="انتخاب کاربری" class="w-full !rounded-xl" fluid />
</div>
<!-- 14. نوع سند -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">نوع سند</label>
<Dropdown v-model="property.documentType" :options="documentTypeOptions" placeholder="انتخاب نوع سند" class="w-full !rounded-xl" fluid />
</div>
<!-- 15. نشانی ملک -->
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">نشانی ملک</label>
<Textarea v-model="property.address" placeholder="نشانی کامل و دقیق ملک..." rows="2" class="w-full !rounded-xl" fluid />
</div>
<!-- 16. کدپستی -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">کدپستی ۱۰ رقمی</label>
<InputText v-model="property.postalCode" placeholder="کدپستی ملک" class="w-full !rounded-xl" fluid />
</div>
<!-- 17. حدود مفروزات -->
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">حدود مفروزات</label>
<Textarea v-model="property.propertyBoundaries" placeholder="شرح تفصیلی حدود مفروزات و مجاورین (شمال، جنوب، شرق، غرب)..." rows="3" class="w-full !rounded-xl" fluid />
</div>
<!-- 18. محدودیت ها و نقل انتقالات -->
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">محدودیت‌ها و نقل و انتقالات</label>
<Textarea v-model="property.restrictions" placeholder="شرح حقوق ارتفاقی، بازداشت، رهن و نقل و انتقالات ثبت‌شده..." rows="3" class="w-full !rounded-xl" fluid />
</div>
</div>
</div>
<!-- 5. Category Dynamic Features Card (if present) -->
<div v-if="features && features.length > 0" class="card p-4 sm:p-6 bg-surface-0 dark:bg-surface-900 shadow-sm border border-surface-200 dark:border-surface-800 rounded-2xl space-y-5">
<div class="flex items-center gap-2 text-primary font-bold text-lg border-b border-surface-100 dark:border-surface-800 pb-3">
<i class="pi pi-cog text-xl"></i>
<span>ویژگی‌های تکمیلی دسته‌بندی</span>
</div>
<div v-for="(groupFeats, tagName) in groupedFeatures" :key="tagName" class="space-y-3">
<h3 class="text-sm font-bold text-surface-600 dark:text-surface-400 border-b border-surface-100 dark:border-surface-800 pb-1">
{{ tagName }}
</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div v-for="feat in groupFeats" :key="feat._id" class="space-y-1">
<label class="block text-xs font-medium text-surface-600 dark:text-surface-300">
{{ feat.nativeName }}
</label>
<Dropdown
v-if="feat.predefinedValues && feat.predefinedValues.length > 0"
v-model="featuresValues[feat._id]"
:options="feat.predefinedValues"
placeholder="انتخاب کنید"
class="w-full !rounded-xl"
showClear
fluid
/>
<InputText v-else-if="feat.featureType === 'string' || feat.featureType === 'text' || feat.type === 'string'" v-model="featuresValues[feat._id]" class="w-full !rounded-xl" fluid />
<InputNumber v-else-if="feat.featureType === 'number' || feat.type === 'number'" v-model="featuresValues[feat._id]" class="w-full !rounded-xl" fluid />
<ToggleSwitch v-else-if="feat.featureType === 'boolean' || feat.type === 'boolean'" v-model="featuresValues[feat._id]" />
</div>
</div>
</div>
</div>
<!-- 6. Documents Section -->
<div class="card p-4 sm:p-6 bg-surface-0 dark:bg-surface-900 shadow-sm border border-surface-200 dark:border-surface-800 rounded-2xl space-y-5">
<div class="flex items-center gap-2 text-primary font-bold text-lg border-b border-surface-100 dark:border-surface-800 pb-3">
<i class="pi pi-file-pdf text-xl"></i>
<span>اسناد و مدارک تصویری / پی‌دی‌اف ملک</span>
</div>
<ImageUploader v-model="documentUrlsWithData" uploadUrl="/api/files/admin/upload-file" @uploaded="onDocumentUploaded" />
</div>
</div>
<!-- Sidebar Area -->
<div class="lg:col-span-4 space-y-6 w-full">
<!-- Location Map Card -->
<div class="card p-4 sm:p-6 bg-surface-0 dark:bg-surface-900 shadow-sm border border-surface-200 dark:border-surface-800 rounded-2xl space-y-4">
<div class="flex items-center gap-2 text-primary font-bold text-lg border-b border-surface-100 dark:border-surface-800 pb-3">
<i class="pi pi-map-marker text-xl"></i>
<span>موقعیت روی نقشه</span>
</div>
<div class="space-y-3">
<div class="grid grid-cols-3 gap-2">
<InputText v-model="easting" placeholder="Easting" class="!rounded-xl text-xs" />
<InputText v-model="northing" placeholder="Northing" class="!rounded-xl text-xs" />
<InputText v-model="zone" placeholder="Zone" class="!rounded-xl text-xs" />
</div>
<Button label="تبدیل UTM" size="small" severity="secondary" rounded @click="convertUTMtoLatLng" class="w-full" />
<div class="h-[600px] rounded-xl overflow-hidden border border-surface-200 dark:border-surface-800">
<MapComponent
ref="mapRef"
:locked="!!property.nationalGeoCode?.trim()"
@marker-updated="handleMarkerUpdated"
@cleared="property.nationalGeoCode = ''"
/>
</div>
</div>
</div>
<!-- Main Image Card -->
<div class="card p-4 sm:p-6 bg-surface-0 dark:bg-surface-900 shadow-sm border border-surface-200 dark:border-surface-800 rounded-2xl space-y-4">
<div class="flex items-center gap-2 text-primary font-bold text-lg border-b border-surface-100 dark:border-surface-800 pb-3">
<i class="pi pi-image text-xl"></i>
<span>تصاویر ملک</span>
</div>
<ImageUploader v-model="mainImageUrlsWithData" uploadUrl="/api/files/admin/upload-file" @uploaded="onMainImageUploaded" />
</div>
</div>
</div>
</div>
</template>
<style scoped>
.dir-rtl {
direction: rtl;
}
</style>
@@ -1,9 +1,8 @@
<script setup>
import { ProductService } from '@/service/ProductService';
import { FilterMatchMode } from '@primevue/core/api';
import { useToast } from 'primevue/usetoast';
import { onMounted, ref, watch } from 'vue';
import { BelongingService } from '@/service/BelongingService';
import { PropertyService } from '@/service/PropertyService';
import { useRouter, useRoute } from 'vue-router';
import Chip from 'primevue/chip';
import InputIcon from 'primevue/inputicon';
@@ -12,6 +11,7 @@ import LoadingCheck from '@/components/LoadingCheck.vue';
import { useConfirm } from 'primevue/useconfirm';
import ConfirmPopup from 'primevue/confirmpopup';
import axios from 'axios';
const deleteConfirm = useConfirm();
onMounted(() => {
loadData();
@@ -19,12 +19,12 @@ onMounted(() => {
const toast = useToast();
const dt = ref();
const belongings = ref();
const properties = ref();
const productDialog = ref(false);
const deleteProductDialog = ref(false);
const deleteProductsDialog = ref(false);
const belonging = ref({});
const selectedBelongings = ref();
const property = ref({});
const selectedProperties = ref();
const filters = ref({
global: { value: null, matchMode: FilterMatchMode.CONTAINS }
});
@@ -37,8 +37,8 @@ function hideDialog() {
const router = useRouter();
function editBelonging(belongingId) {
router.push('/belonging/edit-belonging/' + belongingId);
function editProperty(propertyId) {
router.push('/property/edit-property/' + propertyId);
}
const route = useRoute();
@@ -50,17 +50,15 @@ const pagesCount = ref(0);
let searchTimeout;
// Update your search watcher
watch(searchPhrase, (newPhrase, oldPhrase) => {
if (newPhrase !== oldPhrase) {
// Debounce search to avoid too many requests
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
router.push({
query: {
...route.query,
query: newPhrase || undefined,
page: 1 // Reset to first page on new search
page: 1
}
});
}, 300);
@@ -77,8 +75,9 @@ watch(
},
{ deep: true }
);
const handleRemoveSearch = () => {
removeFromQuery('searchPhrase'); // Ensure 'searchPhrase' is the correct key
removeFromQuery('searchPhrase');
searchPhrase.value = '';
};
@@ -110,10 +109,9 @@ const onPageChange = (event) => {
};
const loadData = async () => {
await BelongingService.searchBelongings(route.query)
await PropertyService.searchProperties(route.query)
.then((response) => {
console.log('response', response);
belongings.value = response.belongings || response;
properties.value = response.properties || response.belongings || response;
totalCount.value = response.totalCount || response.length || 0;
pagesCount.value = Math.ceil(totalCount.value / per_page.value);
})
@@ -128,34 +126,29 @@ const indexingFinished = ref(false);
const indexingMessage = ref('در حال ایندکس...');
const animationKey = ref(0);
// Function to handle indexing
const indexBelongings = async () => {
const indexProperties = async () => {
loading.value = true;
indexingFinished.value = false;
try {
// Your indexing logic here
// await new Promise((resolve) => setTimeout(resolve, 2000)); // Simulate API call
await BelongingService.indexBelongings();
// When indexing completes
await PropertyService.indexProperties();
loadData();
indexingFinished.value = true;
indexingMessage.value = 'ایندکس با موفقیت انجام شد';
} catch (error) {
indexingMessage.value = 'خطا در ایندکس';
} finally {
// Reset after some time
setTimeout(() => {
loading.value = false;
animationKey.value++; // Force re-render of animation
animationKey.value++;
}, 3000);
}
};
const confirmDeleteBelonging = (id, event) => {
const confirmDeleteProperty = (id, event) => {
deleteConfirm.require({
target: event.currentTarget,
message: `آیا از حذف این دارایی اطمینان دارید؟`,
message: `آیا از حذف این ملک اطمینان دارید؟`,
icon: 'pi pi-exclamation-triangle',
rejectProps: {
label: 'خیر',
@@ -167,7 +160,7 @@ const confirmDeleteBelonging = (id, event) => {
},
accept: async () => {
try {
let url = '/api/belongings/admin/delete/' + id;
let url = '/api/properties/admin/delete/' + id;
const method = 'delete';
const config = {
method,
@@ -175,21 +168,16 @@ const confirmDeleteBelonging = (id, event) => {
withCredentials: true
};
const response = await axios(config);
console.log(url, response);
toast.add({ severity: 'success', summary: 'موفقیت آمیز', detail: 'حذف دارایی با موفقیت انجام شد', life: 3000 });
toast.add({ severity: 'success', summary: 'موفقیت آمیز', detail: 'حذف ملک با موفقیت انجام شد', life: 3000 });
await indexBelongings();
await indexProperties();
await loadData();
} catch (error) {
// handle 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);
}
},
reject: () => {
// User rejected the confirmation, do nothing
}
reject: () => {}
});
};
</script>
@@ -200,10 +188,10 @@ const confirmDeleteBelonging = (id, event) => {
<div class="card">
<Toolbar class="mb-6">
<template #start>
<Button label="ساخت دارایی جدید" icon="pi pi-plus" severity="secondary" class="mr-2 mx-3" @click="router.push('/belonging/create-belonging')" />
<Button @click="indexBelongings" :disabled="loading" severity="secondary mx-3" class="indexing-button">
<Button label="ساخت ملک جدید" icon="pi pi-plus" severity="secondary" class="mr-2 mx-3" @click="router.push('/property/create-property')" />
<Button @click="indexProperties" :disabled="loading" severity="secondary mx-3" class="indexing-button">
<div class="button-content">
<div v-if="!loading">ایندکس کردن محصولات</div>
<div v-if="!loading">ایندکس کردن املاک</div>
<div v-else class="loading-content flex">
<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">
@@ -219,8 +207,8 @@ const confirmDeleteBelonging = (id, event) => {
<DataTable
ref="dt"
v-model:selection="selectedBelongings"
:value="belongings"
v-model:selection="selectedProperties"
:value="properties"
dataKey="id"
:paginator="true"
:rows="per_page"
@@ -230,11 +218,11 @@ const confirmDeleteBelonging = (id, event) => {
@page="onPageChange($event)"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
:rowsPerPageOptions="[20, 40, 80]"
currentPageReportTemplate="نمایش {first} تا {last} از {totalRecords} دارایی"
currentPageReportTemplate="نمایش {first} تا {last} از {totalRecords} ملک"
>
<template #header>
<div class="flex flex-wrap gap-2 items-center justify-start">
<h4 class="m-0">مدیریت اموال</h4>
<h4 class="m-0">مدیریت املاک</h4>
<IconField>
<InputIcon>
<i class="pi pi-search" />
@@ -248,11 +236,12 @@ const confirmDeleteBelonging = (id, event) => {
<Column selectionMode="multiple" style="width: 3rem" :exportable="false"></Column>
<Column header="تصویر">
<template #body="slotProps">
<img :src="`${slotProps.data.imageUrl}`" :alt="slotProps.data.name" class="rounded" style="max-width: 64px" crossorigin="use-credentials" />
<img :src="`${slotProps.data.imageUrl || slotProps.data.mainImageUrls?.[0]}`" :alt="slotProps.data.name" class="rounded" style="max-width: 64px" crossorigin="use-credentials" />
</template>
</Column>
<Column field="belongingCode" header="کد" style="min-width: 4rem"></Column>
<Column field="name" header="نام" sortable style="min-width: 16rem"></Column>
<Column field="propertyCode" header="کد" style="min-width: 4rem"></Column>
<Column field="nationalGeoCode" header="شناسه ملی جغرافیایی" style="min-width: 10rem"></Column>
<Column field="name" header="نام" sortable style="min-width: 14rem"></Column>
<Column field="category" header="دسته بندی" sortable style="min-width: 6rem">
<template #body="slotProps">
{{ slotProps.data.category?.nativeName || slotProps.data.nativeCategory }}
@@ -260,36 +249,11 @@ const confirmDeleteBelonging = (id, event) => {
</Column>
<Column :exportable="false" style="min-width: 12rem">
<template #body="slotProps">
<Button icon="pi pi-pencil" outlined rounded class="mr-2 mx-3" @click="editBelonging(slotProps.data._id)" />
<Button icon="pi pi-trash" outlined rounded severity="danger" class="mx-3" @click="confirmDeleteBelonging(slotProps.data._id, $event)" />
<Button icon="pi pi-pencil" outlined rounded class="mr-2 mx-3" @click="editProperty(slotProps.data._id)" />
<Button icon="pi pi-trash" outlined rounded severity="danger" class="mx-3" @click="confirmDeleteProperty(slotProps.data._id, $event)" />
</template>
</Column>
</DataTable>
</div>
<Dialog v-model:visible="deleteProductDialog" :style="{ width: '450px' }" header="Confirm" :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 <b>{{ product.name }}</b
>?</span
>
</div>
<template #footer>
<Button label="خیر" icon="pi pi-times" text @click="deleteProductDialog = false" />
<Button label="بله" icon="pi pi-check" @click="deleteProduct" />
</template>
</Dialog>
<Dialog v-model:visible="deleteProductsDialog" :style="{ width: '450px' }" header="Confirm" :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>
</div>
<template #footer>
<Button label="No" icon="pi pi-times" text @click="deleteProductsDialog = false" />
<Button label="Yes" icon="pi pi-check" text @click="deleteselectedBelongings" />
</template>
</Dialog>
</div>
</template>
@@ -280,7 +280,7 @@ const toggle = (event, data) => {
</template>
<Column field="_id" header="شناسه" sortable style="width: 10%"></Column>
<Column field="belongingName" header="دارایی" sortable style="width: 15%"></Column>
<Column field="belongingName" header="ملک" sortable style="width: 15%"></Column>
<Column field="type" header="نوع تراکنش" sortable style="width: 15%">
<template #body="{ data }">