feat(property): add save toasts, background autosave with bottom-right status popup, and configurable autosave interval in settings
deploy-chabokan / deploy (push) Canceled after 0s

This commit is contained in:
2026-08-19 07:27:09 +03:30
parent 0503845786
commit 30c8149008
3 changed files with 331 additions and 6 deletions
+61
View File
@@ -136,5 +136,66 @@ export const SettingService = {
console.error('Error seeding database:', err); console.error('Error seeding database:', err);
throw err; throw err;
} }
},
/**
* Get auto-save configuration
* @returns {Promise<{enabled: boolean, intervalSeconds: number}>}
*/
getAutoSaveSetting: async () => {
const config = {
method: 'get',
url: '/api/settings/autosave',
withCredentials: true,
headers: SettingService._getAuthHeader()
};
try {
const response = await axios(config);
if (response.data?.data) {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('bm-autosave-settings', JSON.stringify(response.data.data));
}
return response.data.data;
}
} catch (err) {
console.warn('Error fetching autosave setting from server, using local fallback:', err);
}
if (typeof localStorage !== 'undefined') {
const cached = localStorage.getItem('bm-autosave-settings');
if (cached) {
try {
return JSON.parse(cached);
} catch (e) {}
}
}
return { enabled: true, intervalSeconds: 60 };
},
/**
* Update auto-save configuration
* @param {{enabled: boolean, intervalSeconds: number}} data
* @returns {Promise<{enabled: boolean, intervalSeconds: number}>}
*/
updateAutoSaveSetting: async (data) => {
const config = {
method: 'put',
url: '/api/settings/autosave',
withCredentials: true,
data,
headers: SettingService._getAuthHeader()
};
try {
const response = await axios(config);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('bm-autosave-settings', JSON.stringify(response.data.data));
}
return response.data.data;
} catch (err) {
console.error('Error updating autosave setting:', err);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('bm-autosave-settings', JSON.stringify(data));
}
return data;
}
} }
}; };
+159 -5
View File
@@ -1,10 +1,11 @@
<script setup> <script setup>
import { onBeforeMount, ref, watch, computed, nextTick } from 'vue'; import { onBeforeMount, onBeforeUnmount, 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';
import { PropertyService } from '@/service/PropertyService'; import { PropertyService } from '@/service/PropertyService';
import { PropertyStatusService } from '@/service/PropertyStatusService'; import { PropertyStatusService } from '@/service/PropertyStatusService';
import { SettingService } from '@/service/SettingService';
import ImageUploader from '@/views/uikit/ImageUploader.vue'; 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';
@@ -23,6 +24,7 @@ import Button from 'primevue/button';
import Tag from 'primevue/tag'; import Tag from 'primevue/tag';
import SelectButton from 'primevue/selectbutton'; import SelectButton from 'primevue/selectbutton';
import ProgressSpinner from 'primevue/progressspinner'; import ProgressSpinner from 'primevue/progressspinner';
import Toast from 'primevue/toast';
const toast = useToast(); const toast = useToast();
const route = useRoute(); const route = useRoute();
@@ -40,6 +42,14 @@ const editMode = ref(false);
const isSaving = ref(false); const isSaving = ref(false);
const pageLoading = ref(true); const pageLoading = ref(true);
// Auto-Save State
const autoSaveEnabled = ref(true);
const autoSaveIntervalSeconds = ref(60);
const lastSavedTime = ref(null);
const isAutoSaving = ref(false);
const isDirty = ref(false);
let autoSaveTimer = null;
// Registration Date Fields (Jalali: Year, Month, Day) // Registration Date Fields (Jalali: Year, Month, Day)
const regYear = ref(''); const regYear = ref('');
const regMonth = ref(null); const regMonth = ref(null);
@@ -315,6 +325,25 @@ watch(() => property.value.propertyType, (newType) => {
} }
}); });
// Watch for form modifications to mark dirty for auto-save
watch(
[
() => property.value,
() => featuresValues.value,
() => regYear.value,
() => regMonth.value,
() => regDay.value,
() => mainImageUrlsWithData.value,
() => documentUrlsWithData.value
],
() => {
if (!pageLoading.value) {
isDirty.value = true;
}
},
{ deep: true }
);
const initializeFeatureDefaultValues = () => { const initializeFeatureDefaultValues = () => {
if (features.value && Array.isArray(features.value)) { if (features.value && Array.isArray(features.value)) {
features.value.forEach((feat) => { features.value.forEach((feat) => {
@@ -414,16 +443,80 @@ const getStatusSeverity = (st) => {
return valid.includes(st.color) ? st.color : 'secondary'; return valid.includes(st.color) ? st.color : 'secondary';
}; };
const initAutoSaveTimer = () => {
if (autoSaveTimer) clearInterval(autoSaveTimer);
if (autoSaveEnabled.value && autoSaveIntervalSeconds.value > 0) {
autoSaveTimer = setInterval(performAutoSave, autoSaveIntervalSeconds.value * 1000);
}
};
const performAutoSave = async () => {
if (!autoSaveEnabled.value || pageLoading.value || isSaving.value || isAutoSaving.value || !isDirty.value) {
return;
}
// Auto-save in editMode
if (editMode.value && property.value._id) {
isAutoSaving.value = true;
try {
const payload = { ...property.value };
payload.name = computedPropertyName.value || payload.name || 'ملک بدون عنوان';
if (regYear.value && regMonth.value && regDay.value) {
const dObj = jalaliToDate(regYear.value, regMonth.value, regDay.value);
payload.registrationDate = dObj ? dObj.toISOString() : null;
}
payload.mainImageUrls = (mainImageUrlsWithData.value || [])
.map((x) => typeof x === 'string' ? x : (x.url || x.source || ''))
.filter(Boolean);
payload.documentUrls = (documentUrlsWithData.value || [])
.map((x) => typeof x === 'string' ? x : (x.url || x.source || ''))
.filter(Boolean);
payload.features = serializeFeaturesPayload(featuresValues.value);
const status = await PropertyService.updateProperty(property.value._id, payload);
if (status === 'success') {
isDirty.value = false;
lastSavedTime.value = new Date().toLocaleTimeString('fa-IR');
}
} catch (e) {
console.warn('Auto-save background exception:', e);
} finally {
isAutoSaving.value = false;
}
} else {
// Save local draft for new property
try {
const draft = {
property: property.value,
featuresValues: featuresValues.value,
regYear: regYear.value,
regMonth: regMonth.value,
regDay: regDay.value,
savedAt: new Date().toISOString()
};
localStorage.setItem('bm_property_draft', JSON.stringify(draft));
isDirty.value = false;
lastSavedTime.value = new Date().toLocaleTimeString('fa-IR');
} catch (e) {}
}
};
onBeforeMount(async () => { onBeforeMount(async () => {
pageLoading.value = true; pageLoading.value = true;
try { try {
const [fetchedCategories, fetchedStatuses] = await Promise.all([ const [fetchedCategories, fetchedStatuses, autoSettings] = await Promise.all([
CategoryService.getAllCategories(), CategoryService.getAllCategories(),
PropertyStatusService.getAllStatuses() PropertyStatusService.getAllStatuses(),
SettingService.getAutoSaveSetting().catch(() => ({ enabled: true, intervalSeconds: 60 }))
]); ]);
categories.value = fetchedCategories || []; categories.value = fetchedCategories || [];
statuses.value = fetchedStatuses || []; statuses.value = fetchedStatuses || [];
if (autoSettings) {
autoSaveEnabled.value = autoSettings.enabled !== undefined ? autoSettings.enabled : true;
autoSaveIntervalSeconds.value = Number(autoSettings.intervalSeconds) || 60;
}
const propId = route.params.propertyId || route.params.belongingId; const propId = route.params.propertyId || route.params.belongingId;
if (propId) { if (propId) {
editMode.value = true; editMode.value = true;
@@ -510,14 +603,26 @@ onBeforeMount(async () => {
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در دریافت اطلاعات ملک از سرور', life: 4000 }); toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در دریافت اطلاعات ملک از سرور', life: 4000 });
} finally { } finally {
pageLoading.value = false; pageLoading.value = false;
isDirty.value = false;
initAutoSaveTimer();
} }
}); });
onBeforeUnmount(() => {
if (autoSaveTimer) clearInterval(autoSaveTimer);
if (geoCodeDebounceTimeout) clearTimeout(geoCodeDebounceTimeout);
});
const saveProperty = async () => { const saveProperty = async () => {
property.value.name = computedPropertyName.value || 'ملک جدید'; property.value.name = computedPropertyName.value || 'ملک جدید';
if (!property.value.nationalGeoCode?.trim()) { if (!property.value.nationalGeoCode?.trim()) {
toast.add({ severity: 'warn', summary: 'هشدار', detail: 'شناسه ملی جغرافیایی الزامی است.', life: 3000 }); toast.add({
severity: 'warn',
summary: 'هشدار اعتبارسنجی',
detail: 'ورود شناسه ملی جغرافیایی (کد جام) الزامی است.',
life: 4000
});
return; return;
} }
@@ -529,6 +634,13 @@ const saveProperty = async () => {
} }
isSaving.value = true; isSaving.value = true;
toast.add({
severity: 'info',
summary: 'در حال ذخیره‌سازی',
detail: editMode.value ? 'در حال بروزرسانی اطلاعات ملک...' : 'در حال ثبت ملک جدید...',
life: 2000
});
try { try {
property.value.mainImageUrls = (mainImageUrlsWithData.value || []) property.value.mainImageUrls = (mainImageUrlsWithData.value || [])
.map((x) => { .map((x) => {
@@ -552,6 +664,8 @@ const saveProperty = async () => {
} }
if (status === 'success') { if (status === 'success') {
isDirty.value = false;
lastSavedTime.value = new Date().toLocaleTimeString('fa-IR');
toast.add({ toast.add({
severity: 'success', severity: 'success',
summary: 'موفقیت‌آمیز', summary: 'موفقیت‌آمیز',
@@ -574,7 +688,10 @@ const saveProperty = async () => {
</script> </script>
<template> <template>
<div class="space-y-6 dir-rtl" style="direction: rtl;"> <div class="space-y-6 dir-rtl relative" style="direction: rtl;">
<!-- Toast Notification Container -->
<Toast></Toast>
<!-- Header Bar --> <!-- 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="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="flex items-center gap-3">
@@ -1026,5 +1143,42 @@ const saveProperty = async () => {
</div> </div>
</div> </div>
</div> </div>
<!-- Floating Auto-Save Status Popup (Bottom-Right) -->
<Transition name="fade">
<div
v-if="autoSaveEnabled && (lastSavedTime || isAutoSaving)"
class="fixed bottom-6 right-6 z-50 flex items-center gap-3 px-4 py-2.5 rounded-2xl bg-surface-0/95 dark:bg-surface-900/95 backdrop-blur-md shadow-xl border border-surface-200 dark:border-surface-700 select-none text-xs text-surface-700 dark:text-surface-200 animate-fade-in"
style="box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.15), 0 8px 10px -6px rgba(0, 0, 0, 0.1);"
>
<div class="flex items-center gap-2">
<span v-if="isAutoSaving" class="flex items-center text-primary gap-1.5 font-semibold">
<i class="pi pi-spin pi-sync text-sm"></i>
<span>در حال ذخیره خودکار...</span>
</span>
<span v-else class="flex items-center text-emerald-600 dark:text-emerald-400 gap-1.5 font-semibold">
<i class="pi pi-check-circle text-sm"></i>
<span>آخرین ذخیره: {{ lastSavedTime }}</span>
</span>
</div>
<span class="w-px h-3.5 bg-surface-200 dark:bg-surface-700"></span>
<span class="text-[10px] text-surface-400 font-mono" :title="`بازه زمانی ذخیره خودکار: ${autoSaveIntervalSeconds} ثانیه`">
{{ autoSaveIntervalSeconds }}s
</span>
</div>
</Transition>
</div> </div>
</template> </template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(10px);
}
</style>
+111 -1
View File
@@ -18,15 +18,68 @@ const seedStatus = ref({ isSeeded: false, seededAt: null });
const isSeeding = ref(false); const isSeeding = ref(false);
const seedLoading = ref(false); const seedLoading = ref(false);
// Auto-Save Settings State
const autoSaveSettings = ref({ enabled: true, intervalSeconds: 60 });
const autoSaveLoading = ref(false);
const autoSaveSaving = ref(false);
const autoSaveIntervalOptions = [
{ label: '۳۰ ثانیه (سریع)', value: 30 },
{ label: '۱ دقیقه (پیش‌فرض)', value: 60 },
{ label: '۲ دقیقه', value: 120 },
{ label: '۳ دقیقه', value: 180 },
{ label: '۵ دقیقه', value: 300 },
{ label: '۱۰ دقیقه', value: 600 }
];
const methodOptions = [ const methodOptions = [
{ label: 'Typesense', value: 'typesense', icon: 'pi pi-bolt', class: 'text-amber-500' }, { label: 'Typesense', value: 'typesense', icon: 'pi pi-bolt', class: 'text-amber-500' },
{ label: 'MongoDB', value: 'mongo', icon: 'pi pi-database', class: 'text-emerald-500' } { label: 'MongoDB', value: 'mongo', icon: 'pi pi-database', class: 'text-emerald-500' }
]; ];
onMounted(async () => { onMounted(async () => {
await Promise.all([loadSettings(), loadSeedStatus()]); await Promise.all([loadSettings(), loadSeedStatus(), loadAutoSaveSettings()]);
}); });
const loadAutoSaveSettings = async () => {
autoSaveLoading.value = true;
try {
const data = await SettingService.getAutoSaveSetting();
if (data) {
autoSaveSettings.value = {
enabled: data.enabled !== undefined ? data.enabled : true,
intervalSeconds: Number(data.intervalSeconds) || 60
};
}
} catch (e) {
console.warn('Failed to load autosave settings:', e);
} finally {
autoSaveLoading.value = false;
}
};
const saveAutoSaveSettings = async () => {
autoSaveSaving.value = true;
try {
await SettingService.updateAutoSaveSetting(autoSaveSettings.value);
toast.add({
severity: 'success',
summary: 'موفقیت‌آمیز',
detail: 'تنظیمات ذخیره خودکار با موفقیت ذخیره شد.',
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: 'خطا',
detail: 'خطا در ذخیره تنظیمات ذخیره خودکار',
life: 3000
});
} finally {
autoSaveSaving.value = false;
}
};
const loadSeedStatus = async () => { const loadSeedStatus = async () => {
seedLoading.value = true; seedLoading.value = true;
try { try {
@@ -276,6 +329,63 @@ const navigateToModule = (routePath) => {
</div> </div>
</div> </div>
<!-- ── Auto-Save Settings Card ── -->
<div class="card p-6 mb-6 shadow-sm border border-surface-200 dark:border-surface-700 bg-surface-0 dark:bg-surface-900 rounded-2xl">
<div class="flex flex-col lg:flex-row lg:items-center justify-between gap-6">
<div class="flex items-start gap-4">
<div class="w-14 h-14 rounded-2xl flex items-center justify-center text-2xl flex-shrink-0 bg-emerald-100 text-emerald-600 dark:bg-emerald-950 dark:text-emerald-400 shadow-sm">
<i class="pi pi-save"></i>
</div>
<div>
<div class="flex items-center gap-2.5 flex-wrap">
<h3 class="text-xl font-bold m-0 text-surface-900 dark:text-surface-0">
تنظیمات ذخیره خودکار (Auto-Save)
</h3>
<Tag
:value="autoSaveSettings.enabled ? 'فعال' : 'غیرفعال'"
:severity="autoSaveSettings.enabled ? 'success' : 'secondary'"
rounded
class="px-2.5 text-xs font-bold"
/>
</div>
<p class="text-surface-500 dark:text-surface-400 m-0 mt-1.5 text-sm leading-relaxed max-w-2xl">
پیکربندی ذخیره پس‌زمینه خودکار فرم‌ها و املاک. در صورت فعال بودن، تغییرات شما بدون نیاز به فشردن دستی دکمه ذخیره، در بازه‌های مشخص ذخیره شده و اعلان آن در گوشه صفحه نمایش داده می‌شود.
</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-4 flex-shrink-0">
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-surface-600 dark:text-surface-300">فعال بودن:</span>
<ToggleSwitch v-model="autoSaveSettings.enabled" />
</div>
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-surface-600 dark:text-surface-300">بازه زمانی:</span>
<Dropdown
v-model="autoSaveSettings.intervalSeconds"
:options="autoSaveIntervalOptions"
optionLabel="label"
optionValue="value"
:disabled="!autoSaveSettings.enabled"
class="!rounded-xl text-xs w-44"
/>
</div>
<Button
label="ذخیره تنظیمات"
icon="pi pi-check"
severity="primary"
rounded
size="small"
:loading="autoSaveSaving"
@click="saveAutoSaveSettings"
class="px-4 font-semibold"
/>
</div>
</div>
</div>
<!-- ── Quick Actions Toolbar ── --> <!-- ── Quick Actions Toolbar ── -->
<div class="card p-4 mb-6 flex flex-wrap items-center justify-between gap-4"> <div class="card p-4 mb-6 flex flex-wrap items-center justify-between gap-4">
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">