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:
@@ -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>
|
||||
@@ -0,0 +1,259 @@
|
||||
<script setup>
|
||||
import { FilterMatchMode } from '@primevue/core/api';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { PropertyService } from '@/service/PropertyService';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import Chip from 'primevue/chip';
|
||||
import InputIcon from 'primevue/inputicon';
|
||||
import IconField from 'primevue/iconfield';
|
||||
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();
|
||||
});
|
||||
|
||||
const toast = useToast();
|
||||
const dt = ref();
|
||||
const properties = ref();
|
||||
const productDialog = ref(false);
|
||||
const deleteProductDialog = ref(false);
|
||||
const deleteProductsDialog = ref(false);
|
||||
const property = ref({});
|
||||
const selectedProperties = ref();
|
||||
const filters = ref({
|
||||
global: { value: null, matchMode: FilterMatchMode.CONTAINS }
|
||||
});
|
||||
const submitted = ref(false);
|
||||
|
||||
function hideDialog() {
|
||||
productDialog.value = false;
|
||||
submitted.value = false;
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function editProperty(propertyId) {
|
||||
router.push('/property/edit-property/' + propertyId);
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
const searchPhrase = ref(route.query.query);
|
||||
const page = ref(Number(route.query.page) || 1);
|
||||
const per_page = ref(Number(route.query.per_page) || 20);
|
||||
const totalCount = ref(0);
|
||||
const pagesCount = ref(0);
|
||||
|
||||
let searchTimeout;
|
||||
|
||||
watch(searchPhrase, (newPhrase, oldPhrase) => {
|
||||
if (newPhrase !== oldPhrase) {
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
router.push({
|
||||
query: {
|
||||
...route.query,
|
||||
query: newPhrase || undefined,
|
||||
page: 1
|
||||
}
|
||||
});
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.query,
|
||||
() => {
|
||||
page.value = Number(route.query.page) || 1;
|
||||
per_page.value = Number(route.query.per_page) || 20;
|
||||
searchPhrase.value = route.query.query || '';
|
||||
loadData();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
const handleRemoveSearch = () => {
|
||||
removeFromQuery('searchPhrase');
|
||||
searchPhrase.value = '';
|
||||
};
|
||||
|
||||
const removeFromQuery = (queryToRemove) => {
|
||||
const newQuery = { ...route.query };
|
||||
|
||||
delete newQuery[queryToRemove];
|
||||
|
||||
router.replace({ query: newQuery }).catch((err) => {
|
||||
if (err.name !== 'NavigationDuplicated') {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onPageChange = (event) => {
|
||||
const newPage = event.page + 1;
|
||||
const newPerPage = event.rows;
|
||||
|
||||
if (page.value !== newPage || per_page.value !== newPerPage) {
|
||||
router.push({
|
||||
query: {
|
||||
...route.query,
|
||||
page: newPage,
|
||||
per_page: newPerPage
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
await PropertyService.searchProperties(route.query)
|
||||
.then((response) => {
|
||||
properties.value = response.properties || response.belongings || response;
|
||||
totalCount.value = response.totalCount || response.length || 0;
|
||||
pagesCount.value = Math.ceil(totalCount.value / per_page.value);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در بازیابی اطلاعات', life: 3000 });
|
||||
console.error('Error loading data:', error);
|
||||
});
|
||||
};
|
||||
|
||||
const loading = ref(false);
|
||||
const indexingFinished = ref(false);
|
||||
const indexingMessage = ref('در حال ایندکس...');
|
||||
const animationKey = ref(0);
|
||||
|
||||
const indexProperties = async () => {
|
||||
loading.value = true;
|
||||
indexingFinished.value = false;
|
||||
|
||||
try {
|
||||
await PropertyService.indexProperties();
|
||||
loadData();
|
||||
indexingFinished.value = true;
|
||||
indexingMessage.value = 'ایندکس با موفقیت انجام شد';
|
||||
} catch (error) {
|
||||
indexingMessage.value = 'خطا در ایندکس';
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
loading.value = false;
|
||||
animationKey.value++;
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeleteProperty = (id, event) => {
|
||||
deleteConfirm.require({
|
||||
target: event.currentTarget,
|
||||
message: `آیا از حذف این ملک اطمینان دارید؟`,
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
rejectProps: {
|
||||
label: 'خیر',
|
||||
severity: 'secondary',
|
||||
outlined: true
|
||||
},
|
||||
acceptProps: {
|
||||
label: 'بله'
|
||||
},
|
||||
accept: async () => {
|
||||
try {
|
||||
let url = '/api/properties/admin/delete/' + id;
|
||||
const method = 'delete';
|
||||
const config = {
|
||||
method,
|
||||
url,
|
||||
withCredentials: true
|
||||
};
|
||||
const response = await axios(config);
|
||||
toast.add({ severity: 'success', summary: 'موفقیت آمیز', detail: 'حذف ملک با موفقیت انجام شد', life: 3000 });
|
||||
|
||||
await indexProperties();
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: ' خطا در انجام عملیات ', detail: error.response?.data?.message || 'خطایی در حذف ملک', life: 3000 });
|
||||
console.log(error);
|
||||
}
|
||||
},
|
||||
reject: () => {}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmPopup></ConfirmPopup>
|
||||
<div>
|
||||
<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('/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-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">
|
||||
<div :key="indexingMessage">{{ indexingMessage }}</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<template #end> </template>
|
||||
</Toolbar>
|
||||
|
||||
<DataTable
|
||||
ref="dt"
|
||||
v-model:selection="selectedProperties"
|
||||
:value="properties"
|
||||
dataKey="id"
|
||||
:paginator="true"
|
||||
:rows="per_page"
|
||||
:filters="filters"
|
||||
:total-records="totalCount"
|
||||
:current-page="page - 1"
|
||||
@page="onPageChange($event)"
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||
:rowsPerPageOptions="[20, 40, 80]"
|
||||
currentPageReportTemplate="نمایش {first} تا {last} از {totalRecords} ملک"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex flex-wrap gap-2 items-center justify-start">
|
||||
<h4 class="m-0">مدیریت املاک</h4>
|
||||
<IconField>
|
||||
<InputIcon>
|
||||
<i class="pi pi-search" />
|
||||
</InputIcon>
|
||||
<InputText v-model="searchPhrase" placeholder="جستجو..." />
|
||||
</IconField>
|
||||
</div>
|
||||
<Chip v-if="searchPhrase" class="mx-2" :label="searchPhrase" removable @remove="handleRemoveSearch" />
|
||||
</template>
|
||||
|
||||
<Column selectionMode="multiple" style="width: 3rem" :exportable="false"></Column>
|
||||
<Column header="تصویر">
|
||||
<template #body="slotProps">
|
||||
<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="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 }}
|
||||
</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="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>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,190 @@
|
||||
<script setup>
|
||||
import { ref, reactive, watch, onMounted } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { TransactionService } from '@/service/TransactionService';
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
belongingId: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
editMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
transactionData: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:visible', 'saved']);
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const transaction = reactive({
|
||||
type: 'deposit',
|
||||
status: 'pending',
|
||||
value: null,
|
||||
description: '',
|
||||
date: new Date().toISOString(),
|
||||
_id: null
|
||||
});
|
||||
|
||||
const typeOptions = [
|
||||
{ label: 'واریز', value: 'deposit' },
|
||||
{ label: 'برداشت', value: 'withdraw' }
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'در انتظار', value: 'pending' },
|
||||
{ label: 'تکمیل شده', value: 'finished' },
|
||||
{ label: 'ناموفق', value: 'failed' }
|
||||
];
|
||||
|
||||
const errors = reactive({
|
||||
type: '',
|
||||
status: '',
|
||||
value: ''
|
||||
});
|
||||
|
||||
// Watch for changes in editMode and transactionData
|
||||
watch(
|
||||
() => props.transactionData,
|
||||
(newValue) => {
|
||||
if (newValue && props.editMode) {
|
||||
// Load transaction data for editing
|
||||
Object.assign(transaction, {
|
||||
type: newValue.type || 'deposit',
|
||||
status: newValue.status || 'pending',
|
||||
value: newValue.value || null,
|
||||
description: newValue.description || '',
|
||||
date: newValue.date || new Date().toISOString(),
|
||||
_id: newValue._id || null
|
||||
});
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Reset form when dialog opens
|
||||
watch(
|
||||
() => props.visible,
|
||||
(isVisible) => {
|
||||
if (isVisible && !props.editMode) {
|
||||
// Reset form for create mode
|
||||
Object.assign(transaction, {
|
||||
type: 'deposit',
|
||||
status: 'pending',
|
||||
value: null,
|
||||
description: '',
|
||||
date: new Date().toISOString(),
|
||||
_id: null
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const validateForm = () => {
|
||||
let isValid = true;
|
||||
|
||||
if (!transaction.type) {
|
||||
errors.type = 'نوع تراکنش الزامی است';
|
||||
isValid = false;
|
||||
} else {
|
||||
errors.type = '';
|
||||
}
|
||||
|
||||
if (!transaction.status) {
|
||||
errors.status = 'وضعیت تراکنش الزامی است';
|
||||
isValid = false;
|
||||
} else {
|
||||
errors.status = '';
|
||||
}
|
||||
|
||||
if (!transaction.value || transaction.value <= 0) {
|
||||
errors.value = 'مقدار باید بزرگتر از صفر باشد';
|
||||
isValid = false;
|
||||
} else {
|
||||
errors.value = '';
|
||||
}
|
||||
|
||||
return isValid;
|
||||
};
|
||||
|
||||
const saveTransaction = async () => {
|
||||
if (!validateForm()) {
|
||||
toast.add({ severity: 'error', summary: 'خطا', detail: 'لطفا تمام فیلدهای الزامی را پر کنید', life: 3000 });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
transaction.belongingId = props.belongingId;
|
||||
let result;
|
||||
|
||||
if (props.editMode && transaction._id) {
|
||||
// Update existing transaction
|
||||
result = await TransactionService.updateTransaction(transaction._id, transaction);
|
||||
} else {
|
||||
// Create new transaction
|
||||
result = await TransactionService.createTransaction(transaction);
|
||||
}
|
||||
|
||||
if (result === 'success') {
|
||||
const message = props.editMode ? 'تراکنش با موفقیت بروزرسانی شد' : 'تراکنش با موفقیت ثبت شد';
|
||||
toast.add({ severity: 'success', summary: 'موفق', detail: message, life: 3000 });
|
||||
emit('saved');
|
||||
closeDialog();
|
||||
} else {
|
||||
toast.add({ severity: 'error', summary: 'خطا', detail: result || 'خطا در ثبت تراکنش', life: 3000 });
|
||||
}
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در ارتباط با سرور', life: 3000 });
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
emit('update:visible', false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:visible="props.visible" :modal="true" :closable="true" :style="{ width: '450px' }" :header="props.editMode ? 'ویرایش تراکنش' : 'افزایش یا کاهش موجودی'" @update:visible="closeDialog">
|
||||
<div class="p-fluid">
|
||||
<div class="field mb-4">
|
||||
<label class="font-bold mb-2 block">نوع تراکنش</label>
|
||||
<SelectButton v-model="transaction.type" :options="typeOptions" optionLabel="label" optionValue="value" class="w-full" />
|
||||
<small v-if="errors.type" class="p-error">{{ errors.type }}</small>
|
||||
</div>
|
||||
|
||||
<div class="field mb-4">
|
||||
<label class="font-bold mb-2 block">وضعیت</label>
|
||||
<SelectButton v-model="transaction.status" :options="statusOptions" optionLabel="label" optionValue="value" class="w-full" />
|
||||
<small v-if="errors.status" class="p-error">{{ errors.status }}</small>
|
||||
</div>
|
||||
|
||||
<div class="field mb-4">
|
||||
<label class="font-bold mb-2 block">مقدار</label>
|
||||
<InputNumber v-model="transaction.value" placeholder="مقدار را وارد کنید" class="w-full" :min="0" />
|
||||
<small v-if="errors.value" class="p-error">{{ errors.value }}</small>
|
||||
</div>
|
||||
|
||||
<div class="field mb-4">
|
||||
<label class="font-bold mb-2 block">توضیحات</label>
|
||||
<Textarea v-model="transaction.description" placeholder="توضیحات تراکنش را وارد کنید" rows="3" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button label="انصراف" icon="pi pi-times" class="p-button-text" @click="closeDialog" />
|
||||
<Button :label="props.editMode ? 'بروزرسانی' : 'ذخیره'" icon="pi pi-check" @click="saveTransaction" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
Reference in New Issue
Block a user