feat(property): add Iranian date fields with Date conversion and fix edit coordinates/map loading
deploy-chabokan / deploy (push) Canceled after 0s

This commit is contained in:
2026-08-19 06:45:55 +03:30
parent e2aa60c54c
commit 001ab07485
3 changed files with 364 additions and 39 deletions
+164
View File
@@ -0,0 +1,164 @@
/**
* Jalali (Solar Hijri / Shamsi) calendar conversion utilities
*/
export const persianMonths = [
{ value: 1, label: 'فروردین' },
{ value: 2, label: 'اردیبهشت' },
{ value: 3, label: 'خرداد' },
{ value: 4, label: 'تیر' },
{ value: 5, label: 'مرداد' },
{ value: 6, label: 'شهریور' },
{ value: 7, label: 'مهر' },
{ value: 8, label: 'آبان' },
{ value: 9, label: 'آذر' },
{ value: 10, label: 'دی' },
{ value: 11, label: 'بهمن' },
{ value: 12, label: 'اسفند' }
];
/**
* Converts Persian and Arabic digits in a string to English digits
*/
export function toEnglishDigits(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d))
.replace(/[٠-٩]/g, (d) => '٠١٢٣٤٥٦٧٨٩'.indexOf(d));
}
/**
* Converts Jalali date (year, month, day) to Gregorian date (gy, gm, gd)
*/
export function jalaliToGregorian(jy, jm, jd) {
jy = Number(jy);
jm = Number(jm);
jd = Number(jd);
let gy = jy > 979 ? 1600 : 621;
jy = jy > 979 ? jy - 979 : jy;
let days =
365 * jy +
Math.floor(jy / 33) * 8 +
Math.floor(((jy % 33) + 3) / 4) +
78 +
jd +
(jm < 7 ? (jm - 1) * 31 : (jm - 7) * 30 + 186);
gy += 400 * Math.floor(days / 146097);
days %= 146097;
if (days > 36524) {
gy += 100 * Math.floor(--days / 36524);
days %= 36524;
if (days >= 365) days++;
}
gy += 4 * Math.floor(days / 1461);
days %= 1461;
if (days > 365) {
gy += Math.floor((days - 1) / 365);
days = (days - 1) % 365;
}
let gd = days + 1;
const isLeap = (gy % 4 === 0 && gy % 100 !== 0) || gy % 400 === 0;
const sal_a = [0, 31, isLeap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let gm;
for (gm = 0; gm < 13; gm++) {
const v = sal_a[gm];
if (gd <= v) break;
gd -= v;
}
return { gy, gm, gd };
}
/**
* Converts Gregorian date (gy, gm, gd) to Jalali date (jy, jm, jd)
*/
export function gregorianToJalali(gy, gm, gd) {
gy = Number(gy);
gm = Number(gm);
gd = Number(gd);
const g_d_m = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
let jy = gy > 1600 ? 979 : 0;
gy = gy > 1600 ? gy - 1600 : gy - 621;
const gy2 = gm > 2 ? gy + 1 : gy;
let days =
365 * gy +
Math.floor((gy2 + 3) / 4) -
Math.floor((gy2 + 99) / 100) +
Math.floor((gy2 + 399) / 400) -
80 +
gd +
g_d_m[gm - 1];
jy += 33 * Math.floor(days / 12053);
days %= 12053;
jy += 4 * Math.floor(days / 1461);
days %= 1461;
if (days > 365) {
jy += Math.floor((days - 1) / 365);
days = (days - 1) % 365;
}
const jm = days < 186 ? 1 + Math.floor(days / 31) : 7 + Math.floor((days - 186) / 30);
const jd = 1 + (days < 186 ? days % 31 : (days - 186) % 30);
return { jy, jm, jd };
}
/**
* Converts Jalali year, month, day to a standard JS Date object at 12:00 UTC
*/
export function jalaliToDate(jy, jm, jd) {
const y = parseInt(toEnglishDigits(jy), 10);
const m = parseInt(toEnglishDigits(jm), 10);
const d = parseInt(toEnglishDigits(jd), 10);
if (isNaN(y) || isNaN(m) || isNaN(d) || y <= 0 || m < 1 || m > 12 || d < 1 || d > 31) {
return null;
}
const { gy, gm, gd } = jalaliToGregorian(y, m, d);
return new Date(Date.UTC(gy, gm - 1, gd, 12, 0, 0));
}
/**
* Converts a JS Date object, ISO string, or legacy Jalali string (YYYY/MM/DD) to { jy, jm, jd }
*/
export function parseDateToJalali(dateVal) {
if (!dateVal) return null;
// Check if it's already a JS Date or ISO string
if (dateVal instanceof Date || (typeof dateVal === 'string' && (dateVal.includes('T') || dateVal.includes('-')))) {
const d = new Date(dateVal);
if (!isNaN(d.getTime())) {
return gregorianToJalali(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate());
}
}
// Check if it's a legacy string format e.g. "1399/04/15" or "۱۳۹۹/۰۴/۱۵"
if (typeof dateVal === 'string' && dateVal.includes('/')) {
const clean = toEnglishDigits(dateVal.trim());
const parts = clean.split('/');
if (parts.length === 3) {
const jy = parseInt(parts[0], 10);
const jm = parseInt(parts[1], 10);
const jd = parseInt(parts[2], 10);
if (!isNaN(jy) && !isNaN(jm) && !isNaN(jd)) {
return { jy, jm, jd };
}
}
}
return null;
}
+169 -33
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { onBeforeMount, ref, watch, computed } from 'vue'; import { onBeforeMount, ref, watch, computed, nextTick } from 'vue';
import { useToast } from 'primevue/usetoast'; import { useToast } from 'primevue/usetoast';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { CategoryService } from '@/service/CategoryService'; import { CategoryService } from '@/service/CategoryService';
@@ -9,7 +9,8 @@ import ImageUploader from '@/views/uikit/ImageUploader.vue';
import ImageGallery from '@/views/uikit/ImageGallery.vue'; import ImageGallery from '@/views/uikit/ImageGallery.vue';
import KiwiChips from '@/views/uikit/KiwiChips.vue'; import KiwiChips from '@/views/uikit/KiwiChips.vue';
import MapComponent from '@/views/uikit/MapComponent.vue'; import MapComponent from '@/views/uikit/MapComponent.vue';
import { UTMtoLatLon } from '@/utm'; import { UTMtoLatLon, LatLontoUTM } from '@/utm';
import { persianMonths, jalaliToDate, parseDateToJalali, toEnglishDigits } from '@/utils/jalali';
// PrimeVue Components // PrimeVue Components
import Dropdown from 'primevue/dropdown'; import Dropdown from 'primevue/dropdown';
@@ -37,6 +38,11 @@ const documentUrlsWithData = ref([]);
const editMode = ref(false); const editMode = ref(false);
const isSaving = ref(false); const isSaving = ref(false);
// Registration Date Fields (Jalali: Year, Month, Day)
const regYear = ref('');
const regMonth = ref(null);
const regDay = ref('');
const propertyTypes = ref([ const propertyTypes = ref([
{ label: 'زمین', value: 'Land' }, { label: 'زمین', value: 'Land' },
{ label: 'ساختمان', value: 'Building' } { label: 'ساختمان', value: 'Building' }
@@ -61,7 +67,7 @@ const property = ref({
serialNumber: '', serialNumber: '',
provinceRegistry: '', provinceRegistry: '',
localRegistryOffice: '', localRegistryOffice: '',
registrationDate: '', registrationDate: null,
documentUrls: [], documentUrls: [],
mainImageUrls: [], mainImageUrls: [],
location: { location: {
@@ -120,9 +126,10 @@ const property = ref({
// Map state // Map state
const mapRef = ref(null); const mapRef = ref(null);
const easting = ref(); const mapMarker = ref([]);
const northing = ref(); const easting = ref('');
const zone = ref(); const northing = ref('');
const zone = ref('');
const lat = ref(); const lat = ref();
const long = ref(); const long = ref();
@@ -148,18 +155,52 @@ const dongOptions = [
{ value: 6, label: '۶ دانگ', fraction: 'تمام (مالکیت کامل)' }, { value: 6, label: '۶ دانگ', fraction: 'تمام (مالکیت کامل)' },
]; ];
/**
* Centrally updates lat, long, location, mapMarker, and UTM fields
*/
const setCoordinatesAndUTM = (latitude, longitude, utmE = null, utmN = null, utmZ = null) => {
if (latitude !== null && longitude !== null && !isNaN(latitude) && !isNaN(longitude)) {
const numLat = Number(latitude);
const numLon = Number(longitude);
lat.value = numLat;
long.value = numLon;
mapMarker.value = [numLat, numLon];
property.value.location = {
type: 'Point',
coordinates: [numLon, numLat]
};
if (utmE !== null && utmN !== null && utmZ !== null && utmE !== '' && utmN !== '' && utmZ !== '') {
easting.value = String(utmE);
northing.value = String(utmN);
zone.value = String(utmZ);
} else {
const utm = LatLontoUTM(numLat, numLon);
if (utm && !isNaN(utm.easting) && !isNaN(utm.northing)) {
easting.value = String(Math.round(utm.easting));
northing.value = String(Math.round(utm.northing));
zone.value = String(utm.zoneNumber);
}
}
nextTick(() => {
if (mapRef.value && typeof mapRef.value.setCenterAndMarker === 'function') {
mapRef.value.setCenterAndMarker(numLat, numLon);
}
});
}
};
const convertUTMtoLatLng = () => { const convertUTMtoLatLng = () => {
if (easting.value && northing.value && zone.value) { if (easting.value && northing.value && zone.value) {
try { try {
const result = UTMtoLatLon( const cleanE = Number(toEnglishDigits(easting.value));
Number(easting.value), const cleanN = Number(toEnglishDigits(northing.value));
Number(northing.value), const cleanZ = Number(toEnglishDigits(zone.value));
Number(zone.value), const result = UTMtoLatLon(cleanE, cleanN, cleanZ, 'N');
'N' if (result && !isNaN(result.latitude) && !isNaN(result.longitude)) {
); setCoordinatesAndUTM(result.latitude, result.longitude, cleanE, cleanN, cleanZ);
lat.value = result.latitude; }
long.value = result.longitude;
searchLocation(lat.value, long.value);
} catch (error) { } catch (error) {
console.error('Error converting UTM to Lat/Lng:', error); console.error('Error converting UTM to Lat/Lng:', error);
} }
@@ -167,19 +208,37 @@ const convertUTMtoLatLng = () => {
}; };
const handleMarkerUpdated = (coords) => { const handleMarkerUpdated = (coords) => {
if (coords && Array.isArray(coords)) { if (coords && Array.isArray(coords) && coords.length === 2 && coords[0] !== undefined && coords[1] !== undefined) {
const [cLat, cLon] = coords;
setCoordinatesAndUTM(cLat, cLon);
} else {
mapMarker.value = [];
lat.value = null;
long.value = null;
property.value.location = { property.value.location = {
type: 'Point', type: 'Point',
coordinates: [coords[1], coords[0]] coordinates: [0, 0]
}; };
lat.value = coords[0];
long.value = coords[1];
} }
}; };
const handleMapCleared = () => {
property.value.nationalGeoCode = '';
easting.value = '';
northing.value = '';
zone.value = '';
lat.value = null;
long.value = null;
mapMarker.value = [];
property.value.location = {
type: 'Point',
coordinates: [0, 0]
};
};
const searchLocation = (latitude, longitude) => { const searchLocation = (latitude, longitude) => {
if (mapRef.value && latitude && longitude) { if (latitude && longitude) {
mapRef.value.setCenterAndMarker(Number(latitude), Number(longitude)); setCoordinatesAndUTM(latitude, longitude);
} }
}; };
@@ -193,7 +252,8 @@ let geoCodeDebounceTimeout = null;
* If the code doesn't match the 15-digit pattern, falls back to zone 39. * If the code doesn't match the 15-digit pattern, falls back to zone 39.
*/ */
const parseNationalGeoCode = (code) => { const parseNationalGeoCode = (code) => {
const digits = code.replace(/\D/g, ''); // strip non-digits if (!code) return null;
const digits = toEnglishDigits(String(code)).replace(/\D/g, ''); // strip non-digits
if (digits.length === 15) { if (digits.length === 15) {
// Standard JAM: 6 easting + 7 northing + 2 zone // Standard JAM: 6 easting + 7 northing + 2 zone
const e = parseInt(digits.slice(0, 6), 10); const e = parseInt(digits.slice(0, 6), 10);
@@ -211,18 +271,16 @@ const parseNationalGeoCode = (code) => {
}; };
const applyNationalGeoCode = (val) => { const applyNationalGeoCode = (val) => {
if (!val) return;
// 1. Try parsing as a JAM UTM code (15-digit or 13-digit) // 1. Try parsing as a JAM UTM code (15-digit or 13-digit)
const parsed = parseNationalGeoCode(val); const parsed = parseNationalGeoCode(val);
if (parsed) { if (parsed) {
try { try {
const result = UTMtoLatLon(parsed.easting, parsed.northing, parsed.zone, 'N'); const result = UTMtoLatLon(parsed.easting, parsed.northing, parsed.zone, 'N');
lat.value = result.latitude; if (result && !isNaN(result.latitude) && !isNaN(result.longitude)) {
long.value = result.longitude; setCoordinatesAndUTM(result.latitude, result.longitude, parsed.easting, parsed.northing, parsed.zone);
easting.value = parsed.easting;
northing.value = parsed.northing;
zone.value = parsed.zone;
searchLocation(result.latitude, result.longitude);
return; return;
}
} catch (err) { } catch (err) {
console.error('UTM conversion failed:', err); console.error('UTM conversion failed:', err);
} }
@@ -237,7 +295,7 @@ const handleNationalGeoCodeChange = () => {
const val = property.value.nationalGeoCode?.trim(); const val = property.value.nationalGeoCode?.trim();
if (!val) return; if (!val) return;
const digits = val.replace(/\D/g, ''); const digits = toEnglishDigits(val).replace(/\D/g, '');
// Fire immediately once a full 15-digit JAM code is complete — no waiting // Fire immediately once a full 15-digit JAM code is complete — no waiting
if (digits.length === 15) { if (digits.length === 15) {
@@ -399,6 +457,39 @@ onBeforeMount(async () => {
property.value = { ...property.value, ...data }; property.value = { ...property.value, ...data };
featuresValues.value = extractFeaturesValues(data.features); featuresValues.value = extractFeaturesValues(data.features);
// 1. Parse registrationDate into Jalali Year, Month, Day
if (data.registrationDate) {
const jDate = parseDateToJalali(data.registrationDate);
if (jDate) {
regYear.value = String(jDate.jy);
regMonth.value = jDate.jm;
regDay.value = String(jDate.jd).padStart(2, '0');
}
}
// 2. Initialize Coordinates, UTM fields, and Map Marker
if (data.nationalGeoCode) {
const parsed = parseNationalGeoCode(data.nationalGeoCode);
if (parsed) {
try {
const result = UTMtoLatLon(parsed.easting, parsed.northing, parsed.zone, 'N');
if (result && !isNaN(result.latitude) && !isNaN(result.longitude)) {
setCoordinatesAndUTM(result.latitude, result.longitude, parsed.easting, parsed.northing, parsed.zone);
}
} catch (e) {
console.error('UTM conversion error on load:', e);
}
}
}
// Fallback to location.coordinates if mapMarker not set yet
if ((!mapMarker.value || mapMarker.value.length === 0) && data.location?.coordinates && Array.isArray(data.location.coordinates) && data.location.coordinates.length === 2) {
const [cLon, cLat] = data.location.coordinates;
if ((cLon !== 0 || cLat !== 0) && !isNaN(cLon) && !isNaN(cLat)) {
setCoordinatesAndUTM(cLat, cLon);
}
}
const mainImages = (data.signedMainImageUrls && data.signedMainImageUrls.length > 0) const mainImages = (data.signedMainImageUrls && data.signedMainImageUrls.length > 0)
? data.signedMainImageUrls ? data.signedMainImageUrls
: (data.mainImageUrls || []); : (data.mainImageUrls || []);
@@ -438,6 +529,14 @@ const saveProperty = async () => {
return; return;
} }
// Convert Jalali date to standard Date object
if (regYear.value && regMonth.value && regDay.value) {
const dObj = jalaliToDate(regYear.value, regMonth.value, regDay.value);
property.value.registrationDate = dObj ? dObj.toISOString() : null;
} else {
property.value.registrationDate = null;
}
isSaving.value = true; isSaving.value = true;
try { try {
property.value.mainImageUrls = (mainImageUrlsWithData.value || []) property.value.mainImageUrls = (mainImageUrlsWithData.value || [])
@@ -587,10 +686,46 @@ const saveProperty = async () => {
<InputText v-model="property.localRegistryOffice" placeholder="مثلاً: کرج - ناحیه ۱" class="w-full !rounded-xl" fluid /> <InputText v-model="property.localRegistryOffice" placeholder="مثلاً: کرج - ناحیه ۱" class="w-full !rounded-xl" fluid />
</div> </div>
<!-- 5. تاریخ ثبت --> <!-- 5. تاریخ ثبت سند -->
<div> <div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">تاریخ ثبت</label> <label class="block text-sm font-medium text-surface-700 dark:text-surface-200 mb-1.5">
<InputText v-model="property.registrationDate" placeholder="مثلاً: ۱۳۹۹/۰۴/۱۵" class="w-full !rounded-xl" fluid /> تاریخ ثبت سند
</label>
<div class="grid grid-cols-12 gap-2 items-center">
<!-- روز -->
<div class="col-span-3">
<InputText
v-model="regDay"
placeholder="روز"
class="w-full !rounded-xl text-center"
maxlength="2"
fluid
/>
</div>
<!-- ماه -->
<div class="col-span-5">
<Dropdown
v-model="regMonth"
:options="persianMonths"
optionLabel="label"
optionValue="value"
placeholder="انتخاب ماه"
class="w-full !rounded-xl"
showClear
fluid
/>
</div>
<!-- سال -->
<div class="col-span-4">
<InputText
v-model="regYear"
placeholder="سال (مثلاً ۱۴۰۲)"
class="w-full !rounded-xl text-center"
maxlength="4"
fluid
/>
</div>
</div>
</div> </div>
<!-- Auto Name Preview --> <!-- Auto Name Preview -->
@@ -918,9 +1053,10 @@ const saveProperty = async () => {
<div class="h-[600px] rounded-xl overflow-hidden border border-surface-200 dark:border-surface-800"> <div class="h-[600px] rounded-xl overflow-hidden border border-surface-200 dark:border-surface-800">
<MapComponent <MapComponent
ref="mapRef" ref="mapRef"
v-model="mapMarker"
:locked="!!property.nationalGeoCode?.trim()" :locked="!!property.nationalGeoCode?.trim()"
@marker-updated="handleMarkerUpdated" @marker-updated="handleMarkerUpdated"
@cleared="property.nationalGeoCode = ''" @cleared="handleMapCleared"
/> />
</div> </div>
</div> </div>
+30 -5
View File
@@ -87,6 +87,7 @@
@click="handleClick($event)" @click="handleClick($event)"
@mousemove="handleMouseMove($event)" @mousemove="handleMouseMove($event)"
@mouseout="hoverCoords = null" @mouseout="hoverCoords = null"
@ready="onMapReady"
> >
<!-- Layer switcher --> <!-- Layer switcher -->
<l-control-layers position="bottomright" /> <l-control-layers position="bottomright" />
@@ -274,20 +275,44 @@ const handleClick = (event) => {
emit('marker-updated', coords); emit('marker-updated', coords);
}; };
// ── Ready handler ──────────────────────────────────────────────────────────
const onMapReady = () => {
if (Array.isArray(marker.value) && marker.value.length === 2 && marker.value[0] && marker.value[1]) {
center.value = [...marker.value];
zoom.value = 16;
nextTick(() => {
if (map.value?.leafletObject) {
map.value.leafletObject.setView(marker.value, 16);
map.value.leafletObject.invalidateSize();
}
});
}
};
// ── Sync modelValue prop → marker ────────────────────────────────────────── // ── Sync modelValue prop → marker ──────────────────────────────────────────
watch( watch(
() => props.modelValue, () => props.modelValue,
(val) => { (val) => {
if (Array.isArray(val) && val.length === 2) { if (Array.isArray(val) && val.length === 2 && val[0] && val[1]) {
marker.value = [...val]; const newCoords = [parseFloat(val[0]), parseFloat(val[1])];
center.value = [...val]; marker.value = newCoords;
center.value = newCoords;
zoom.value = 16;
nextTick(() => {
if (map.value?.leafletObject) {
map.value.leafletObject.flyTo(newCoords, 16, { animate: true, duration: 0.8 });
map.value.leafletObject.invalidateSize();
}
});
} else if (Array.isArray(val) && val.length === 0) {
marker.value = [];
} }
}, },
{ immediate: true } { immediate: true, deep: true }
); );
// ── Expose to parent ─────────────────────────────────────────────────────── // ── Expose to parent ───────────────────────────────────────────────────────
defineExpose({ searchByCoordinates: setMarker, setCenterAndMarker: setMarker, searchLocation }); defineExpose({ searchByCoordinates: setMarker, setCenterAndMarker: setMarker, searchLocation, onMapReady });
</script> </script>
<style scoped> <style scoped>