Feat: Persistencia de cambios y alertas al salir del formulario
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 57s

- Agregar cookie para persistir cambios del formulario (7 días)
- Detectar y mostrar campos modificados con indicadores visuales
- Alertar al usuario antes de salir si hay cambios sin guardar
- Restaurar borrador automáticamente al reabrir formulario
- Notificar al usuario de cambios restaurados con toast
- Limpiar cookie al guardar exitosamente

Indicadores visuales:
- Label en color warning para campos modificados
- Badge "Modificado" con ícono de lápiz
- Mensaje en beforeunload listando campos pendientes

La cookie guarda todos los campos del formulario y se restauran
automáticamente al abrir el formulario de nuevo.
This commit is contained in:
2025-10-17 17:01:55 -06:00
parent 4a7f6bb5f0
commit 2c7a12a829

View File

@@ -86,10 +86,14 @@
<!-- Nombre completo -->
<div class="form-field">
<label class="field-label">
<label class="field-label" :class="{ 'field-modified': modifiedFields.name }">
<UIcon name="i-heroicons-user" class="w-4 h-4" />
Nombre completo
<span class="required">*</span>
<span v-if="modifiedFields.name" class="modified-indicator">
<UIcon name="i-heroicons-pencil-square" class="w-3 h-3" />
Modificado
</span>
</label>
<UInput
v-model="formData.name"
@@ -100,10 +104,14 @@
<!-- Email -->
<div class="form-field">
<label class="field-label">
<label class="field-label" :class="{ 'field-modified': modifiedFields.email }">
<UIcon name="i-heroicons-envelope" class="w-4 h-4" />
Correo electrónico
<span class="required">*</span>
<span v-if="modifiedFields.email" class="modified-indicator">
<UIcon name="i-heroicons-pencil-square" class="w-3 h-3" />
Modificado
</span>
</label>
<UInput
v-model="formData.email"
@@ -305,8 +313,15 @@ const isSubmitting = ref(false)
const isUploading = ref(false)
const showCamera = ref(false)
// Datos del formulario
const formData = ref({
// Cookie para persistir cambios del formulario
const formCookie = useCookie<Record<string, string>>('profile-form-draft', {
maxAge: 60 * 60 * 24 * 7, // 7 días
sameSite: 'lax',
default: () => ({})
})
// Datos originales del usuario (para comparar)
const originalData = ref({
name: user.value?.name || '',
email: user.value?.email || '',
avatar: '',
@@ -318,9 +333,62 @@ const formData = ref({
nucleoCode: ''
})
// Datos del formulario (restaurar desde cookie si existe)
const formData = ref({
name: formCookie.value.name || user.value?.name || '',
email: formCookie.value.email || user.value?.email || '',
avatar: formCookie.value.avatar || '',
phone: formCookie.value.phone || '',
cedula: formCookie.value.cedula || '',
birthdate: formCookie.value.birthdate || '',
nfc: formCookie.value.nfc || '',
pin: formCookie.value.pin || '',
nucleoCode: formCookie.value.nucleoCode || ''
})
// Avatar actual del usuario
const currentAvatar = ref(user.value?.avatar || '')
// Detectar qué campos han sido modificados
const modifiedFields = computed(() => {
const modified: Record<string, boolean> = {}
const keys = Object.keys(formData.value) as Array<keyof typeof formData.value>
keys.forEach(key => {
modified[key] = formData.value[key] !== originalData.value[key]
})
return modified
})
// Verificar si hay cambios pendientes
const hasChanges = computed(() => {
return Object.values(modifiedFields.value).some(modified => modified)
})
// Lista de campos modificados (para mostrar en alerta)
const modifiedFieldsList = computed(() => {
const fieldNames: Record<string, string> = {
name: 'Nombre completo',
email: 'Correo electrónico',
phone: 'Teléfono',
cedula: 'Cédula',
birthdate: 'Fecha de nacimiento',
nfc: 'NFC vinculada',
pin: 'PIN numérico',
nucleoCode: 'Código Nucleo V2'
}
return Object.entries(modifiedFields.value)
.filter(([_, modified]) => modified)
.map(([field, _]) => fieldNames[field] || field)
})
// Guardar en cookie cuando cambian los datos
watch(formData, (newData) => {
formCookie.value = { ...newData }
}, { deep: true })
// Actualizar avatar cuando cambie el usuario
watch(() => user.value?.avatar, (newAvatar) => {
if (newAvatar) {
@@ -328,6 +396,36 @@ watch(() => user.value?.avatar, (newAvatar) => {
}
})
// Alertar al usuario antes de salir si hay cambios
if (import.meta.client) {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (hasChanges.value) {
const message = `Tienes cambios sin guardar en:\n${modifiedFieldsList.value.join(', ')}\n\nLos cambios quedarán guardados en memoria pero no subidos al servidor.`
e.preventDefault()
e.returnValue = message
return message
}
}
onMounted(() => {
window.addEventListener('beforeunload', handleBeforeUnload)
// Mostrar notificación si hay datos restaurados
if (Object.keys(formCookie.value).length > 0 && hasChanges.value) {
toast.add({
title: 'Borrador restaurado',
description: `Se restauraron cambios pendientes en: ${modifiedFieldsList.value.join(', ')}`,
color: 'info',
icon: 'i-heroicons-document-text'
})
}
})
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
})
}
// Enviar formulario
const handleSubmit = async () => {
if (!formData.value.name || !formData.value.email) {
@@ -351,6 +449,13 @@ const handleSubmit = async () => {
}
})
// Limpiar cookie de borrador al guardar exitosamente
formCookie.value = {}
// Actualizar datos originales
originalData.value.name = formData.value.name
originalData.value.email = formData.value.email
toast.add({
title: 'Perfil actualizado',
description: 'Tus cambios se guardaron correctamente. Recarga la página para verlos.',
@@ -538,6 +643,11 @@ const removeAvatar = async () => {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-gray-700);
transition: color 0.3s ease;
}
.field-label.field-modified {
color: rgb(var(--color-warning-600));
}
.field-label .required {
@@ -545,6 +655,20 @@ const removeAvatar = async () => {
margin-left: 0.125rem;
}
.modified-indicator {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-left: auto;
padding: 0.125rem 0.5rem;
font-size: 0.6875rem;
font-weight: 600;
border-radius: 0.375rem;
background: rgba(var(--color-warning-500), 0.15);
color: rgb(var(--color-warning-600));
border: 1px solid rgba(var(--color-warning-500), 0.3);
}
.field-help {
font-size: 0.75rem;
color: var(--color-gray-500);
@@ -639,6 +763,16 @@ const removeAvatar = async () => {
color: var(--color-gray-300) !important;
}
.dark .field-label.field-modified {
color: rgb(var(--color-warning-400)) !important;
}
.dark .modified-indicator {
background: rgba(var(--color-warning-500), 0.2) !important;
color: rgb(var(--color-warning-400)) !important;
border-color: rgba(var(--color-warning-500), 0.4) !important;
}
.dark .field-help {
color: var(--color-gray-400) !important;
}