This commit is contained in:
2026-02-22 14:38:53 -06:00
parent bec656b105
commit a73de4a4fa
47 changed files with 4290 additions and 3 deletions

View File

@@ -0,0 +1,230 @@
/**
* IndexedDB Wrapper for Offline Colaboraciones
* Stores pending colaboraciones when offline using GUID-based IDs
*/
const ColaboracionesOfflineDB = {
dbName: 'ColaboracionesOfflineDB',
version: 1,
storeName: 'colaboraciones',
/**
* Initialize the database
*/
async init() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Create object store if it doesn't exist
if (!db.objectStoreNames.contains(this.storeName)) {
const objectStore = db.createObjectStore(this.storeName, {
keyPath: 'id' // GUID generated client-side
});
// Indexes for querying
objectStore.createIndex('syncStatus', 'syncStatus', { unique: false });
objectStore.createIndex('timestamp', 'timestamp', { unique: false });
objectStore.createIndex('updatedAt', 'updatedAt', { unique: false });
objectStore.createIndex('miembroId', 'miembroId', { unique: false });
}
};
});
},
/**
* Generate a GUID (v4 UUID)
*/
generateGuid() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
},
/**
* Add a new colaboracion to offline queue
*/
async addColaboracion(colaboracionData) {
const db = await this.init();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
// Prepare record with GUID and sync metadata
const record = {
id: this.generateGuid(), // GUID generated client-side
miembroId: colaboracionData.miembroId,
mesInicial: colaboracionData.mesInicial,
anioInicial: colaboracionData.anioInicial,
mesFinal: colaboracionData.mesFinal,
anioFinal: colaboracionData.anioFinal,
montoTotal: colaboracionData.montoTotal,
observaciones: colaboracionData.observaciones || '',
tiposSeleccionados: colaboracionData.tiposSeleccionados || [],
tipoPrioritario: colaboracionData.tipoPrioritario || null,
registradoPor: colaboracionData.registradoPor || 'Usuario',
syncStatus: 'pending', // pending, syncing, synced, failed
timestamp: new Date().toISOString(),
updatedAt: new Date().toISOString(),
retryCount: 0
};
const request = store.add(record);
request.onsuccess = () => {
console.log('[OfflineDB] Colaboración guardada con ID:', record.id);
resolve(record);
};
request.onerror = () => {
console.error('[OfflineDB] Error al guardar:', request.error);
reject(request.error);
};
});
},
/**
* Get all pending colaboraciones
*/
async getPending() {
const db = await this.init();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('syncStatus');
const request = index.getAll('pending');
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
},
/**
* Get all colaboraciones (any status)
*/
async getAll() {
const db = await this.init();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
},
/**
* Update sync status of a colaboracion
*/
async updateSyncStatus(id, status, retryCount = 0) {
const db = await this.init();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const getRequest = store.get(id);
getRequest.onsuccess = () => {
const record = getRequest.result;
if (record) {
record.syncStatus = status;
record.retryCount = retryCount;
record.lastSyncAttempt = new Date().toISOString();
const updateRequest = store.put(record);
updateRequest.onsuccess = () => resolve(record);
updateRequest.onerror = () => reject(updateRequest.error);
} else {
reject(new Error('Record not found'));
}
};
getRequest.onerror = () => reject(getRequest.error);
});
},
/**
* Remove a colaboracion by ID (after successful sync)
*/
async remove(id) {
const db = await this.init();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.delete(id);
request.onsuccess = () => {
console.log('[OfflineDB] Colaboración eliminada:', id);
resolve();
};
request.onerror = () => reject(request.error);
});
},
/**
* Get count of pending colaboraciones
*/
async getPendingCount() {
const db = await this.init();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('syncStatus');
const request = index.count('pending');
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
},
/**
* Clear all records (use with caution)
*/
async clearAll() {
const db = await this.init();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.clear();
request.onsuccess = () => {
console.log('[OfflineDB] All records cleared');
resolve();
};
request.onerror = () => reject(request.error);
});
},
/**
* Get a specific colaboracion by ID
*/
async getById(id) {
const db = await this.init();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const request = store.get(id);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
};
// Initialize database when script loads
ColaboracionesOfflineDB.init().catch(error => {
console.error('[OfflineDB] Initialization failed:', error);
});

View File

@@ -0,0 +1,310 @@
/**
* Sync Manager for Colaboraciones
* Handles connection monitoring, offline queue, and synchronization
*/
const ColaboracionesSyncManager = {
isOnline: navigator.onLine,
isSyncing: false,
syncInProgress: false,
statusIndicator: null,
pendingBadge: null,
maxRetries: 3,
retryDelay: 2000, // milliseconds
/**
* Initialize the sync manager
*/
init() {
console.log('[SyncManager] Initializing...');
// Get UI elements
this.statusIndicator = document.getElementById('offlineStatus');
this.pendingBadge = document.getElementById('pendingBadge');
// Listen for online/offline events
window.addEventListener('online', () => this.handleOnline());
window.addEventListener('offline', () => this.handleOffline());
// Set initial status
this.updateStatusUI();
this.updatePendingBadge();
// If online, try to sync pending items
if (this.isOnline) {
setTimeout(() => this.syncPending(), 1000);
}
console.log('[SyncManager] Initialized. Status:', this.isOnline ? 'Online' : 'Offline');
},
/**
* Handle online event
*/
async handleOnline() {
console.log('[SyncManager] Connection restored');
this.isOnline = true;
this.updateStatusUI();
// Auto-sync after short delay
setTimeout(() => this.syncPending(), 500);
},
/**
* Handle offline event
*/
handleOffline() {
console.log('[SyncManager] Connection lost');
this.isOnline = false;
this.updateStatusUI();
},
/**
* Update status indicator UI
*/
updateStatusUI() {
if (!this.statusIndicator) return;
if (this.isSyncing) {
this.statusIndicator.className = 'badge bg-warning ms-2';
this.statusIndicator.innerHTML = '<i class="bi bi-arrow-repeat"></i> Sincronizando';
this.statusIndicator.style.display = 'inline-block';
} else if (this.isOnline) {
this.statusIndicator.className = 'badge bg-success ms-2';
this.statusIndicator.innerHTML = '<i class="bi bi-wifi"></i> En línea';
this.statusIndicator.style.display = 'inline-block';
} else {
this.statusIndicator.className = 'badge bg-secondary ms-2';
this.statusIndicator.innerHTML = '<i class="bi bi-wifi-off"></i> Sin conexión';
this.statusIndicator.style.display = 'inline-block';
}
},
/**
* Update pending items badge
*/
async updatePendingBadge() {
if (!this.pendingBadge) return;
try {
const count = await ColaboracionesOfflineDB.getPendingCount();
if (count > 0) {
this.pendingBadge.textContent = count;
this.pendingBadge.style.display = 'inline-block';
} else {
this.pendingBadge.style.display = 'none';
}
} catch (error) {
console.error('[SyncManager] Error updating badge:', error);
}
},
/**
* Save colaboracion (online or offline)
*/
async saveColaboracion(colaboracionData) {
if (this.isOnline) {
try {
// Try to save directly to server
const result = await this.sendToServer(colaboracionData);
if (result.success) {
return {
success: true,
message: 'Colaboración registrada exitosamente',
online: true
};
} else {
throw new Error(result.message || 'Error al guardar');
}
} catch (error) {
console.warn('[SyncManager] Online save failed, using offline mode:', error);
// Fall back to offline save
return await this.saveOffline(colaboracionData);
}
} else {
// Save offline
return await this.saveOffline(colaboracionData);
}
},
/**
* Save to offline queue
*/
async saveOffline(colaboracionData) {
try {
const record = await ColaboracionesOfflineDB.addColaboracion(colaboracionData);
await this.updatePendingBadge();
return {
success: true,
offline: true,
message: 'Guardado offline. Se sincronizará automáticamente cuando haya conexión.',
id: record.id
};
} catch (error) {
console.error('[SyncManager] Offline save failed:', error);
return {
success: false,
message: 'Error al guardar en modo offline: ' + error.message
};
}
},
/**
* Send colaboracion to server
*/
async sendToServer(colaboracionData) {
const formData = new FormData();
formData.append('MiembroId', colaboracionData.miembroId);
formData.append('MesInicial', colaboracionData.mesInicial);
formData.append('AnioInicial', colaboracionData.anioInicial);
formData.append('MesFinal', colaboracionData.mesFinal);
formData.append('AnioFinal', colaboracionData.anioFinal);
formData.append('MontoTotal', colaboracionData.montoTotal);
formData.append('Observaciones', colaboracionData.observaciones || '');
if (colaboracionData.tipoPrioritario) {
formData.append('TipoPrioritario', colaboracionData.tipoPrioritario);
}
if (colaboracionData.tiposSeleccionados && colaboracionData.tiposSeleccionados.length > 0) {
colaboracionData.tiposSeleccionados.forEach(tipo => {
formData.append('TiposSeleccionados', tipo);
});
}
const response = await fetch('/Colaboracion/Create', {
method: 'POST',
body: formData
});
if (response.redirected) {
// Success - ASP.NET redirected to Index
return { success: true };
}
const text = await response.text();
// Check if response contains success indicators
if (text.includes('exitosamente') || response.ok) {
return { success: true };
}
throw new Error('Error en la respuesta del servidor');
},
/**
* Synchronize all pending colaboraciones
*/
async syncPending() {
if (!this.isOnline || this.syncInProgress) {
console.log('[SyncManager] Sync skipped. Online:', this.isOnline, 'InProgress:', this.syncInProgress);
return;
}
try {
this.syncInProgress = true;
this.isSyncing = true;
this.updateStatusUI();
const pending = await ColaboracionesOfflineDB.getPending();
if (pending.length === 0) {
console.log('[SyncManager] No pending items to sync');
return;
}
console.log(`[SyncManager] Syncing ${pending.length} pending item(s)...`);
let successCount = 0;
let failCount = 0;
for (const item of pending) {
try {
// Update status to syncing
await ColaboracionesOfflineDB.updateSyncStatus(item.id, 'syncing', item.retryCount);
// Try to send to server
const result = await this.sendToServer(item);
if (result.success) {
// Remove from offline DB
await ColaboracionesOfflineDB.remove(item.id);
successCount++;
console.log(`[SyncManager] Item ${item.id} synced successfully`);
} else {
throw new Error(result.message || 'Unknown error');
}
} catch (error) {
console.error(`[SyncManager] Sync failed for item ${item.id}:`, error);
// Update retry count
const newRetryCount = (item.retryCount || 0) + 1;
if (newRetryCount >= this.maxRetries) {
await ColaboracionesOfflineDB.updateSyncStatus(item.id, 'failed', newRetryCount);
failCount++;
} else {
await ColaboracionesOfflineDB.updateSyncStatus(item.id, 'pending', newRetryCount);
}
}
}
await this.updatePendingBadge();
// Show results
if (successCount > 0) {
toastr.success(`${successCount} colaboración(es) sincronizada(s) exitosamente`);
// Reload page to show updated data
setTimeout(() => {
window.location.reload();
}, 1500);
}
if (failCount > 0) {
toastr.error(`${failCount} colaboración(es) no se pudieron sincronizar. Se reintentará automáticamente.`);
}
} catch (error) {
console.error('[SyncManager] Sync error:', error);
toastr.error('Error durante la sincronización');
} finally {
this.syncInProgress = false;
this.isSyncing = false;
this.updateStatusUI();
}
},
/**
* Manually trigger sync
*/
async manualSync() {
if (!this.isOnline) {
toastr.warning('No hay conexión a internet');
return;
}
toastr.info('Iniciando sincronización...');
await this.syncPending();
},
/**
* Check if currently online
*/
checkOnlineStatus() {
return this.isOnline;
}
};
// Auto-initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
ColaboracionesSyncManager.init();
});
} else {
ColaboracionesSyncManager.init();
}

View File

@@ -0,0 +1,241 @@
/**
* Service Worker for RS_system PWA
* Implements offline-first architecture with strategic caching
* Version: 1.0.0
*/
const CACHE_VERSION = 'rs-system-v1.0.0';
const STATIC_CACHE = `${CACHE_VERSION}-static`;
const DYNAMIC_CACHE = `${CACHE_VERSION}-dynamic`;
const API_CACHE = `${CACHE_VERSION}-api`;
// Critical resources to cache on install
const STATIC_ASSETS = [
'/',
'/Home/Index',
'/Colaboracion/Create',
'/Colaboracion/Index',
'/css/site.css',
'/css/bootstrap.min.css',
'/css/bootstrap-icons.min.css',
'/js/site.js',
'/js/colaboraciones-offline-db.js',
'/js/colaboraciones-sync.js',
'/lib/jquery/dist/jquery.min.js',
'/lib/bootstrap/dist/js/bootstrap.bundle.min.js',
'/manifest.json',
'/Assets/icon-192x192.png',
'/Assets/icon-512x512.png'
];
// Install event - cache static assets
self.addEventListener('install', (event) => {
console.log('[Service Worker] Installing...');
event.waitUntil(
caches.open(STATIC_CACHE)
.then((cache) => {
console.log('[Service Worker] Caching static assets');
return cache.addAll(STATIC_ASSETS);
})
.then(() => {
console.log('[Service Worker] Installation complete');
return self.skipWaiting(); // Activate immediately
})
.catch((error) => {
console.error('[Service Worker] Installation failed:', error);
})
);
});
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
console.log('[Service Worker] Activating...');
event.waitUntil(
caches.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => {
// Delete old version caches
return name.startsWith('rs-system-') && name !== STATIC_CACHE && name !== DYNAMIC_CACHE && name !== API_CACHE;
})
.map((name) => {
console.log('[Service Worker] Deleting old cache:', name);
return caches.delete(name);
})
);
})
.then(() => {
console.log('[Service Worker] Activation complete');
return self.clients.claim(); // Take control immediately
})
);
});
// Fetch event - implement caching strategies
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip chrome extension and non-HTTP requests
if (!url.protocol.startsWith('http')) {
return;
}
// API requests - Network First, fallback to offline indicator
if (url.pathname.includes('/api/') ||
url.pathname.includes('/Colaboracion/Sync') ||
url.pathname.includes('/Colaboracion/BuscarMiembros') ||
url.pathname.includes('/Colaboracion/ObtenerUltimosPagos')) {
event.respondWith(networkFirstStrategy(request, API_CACHE));
return;
}
// POST requests - Network Only (never cache)
if (request.method === 'POST') {
event.respondWith(
fetch(request).catch(() => {
return new Response(
JSON.stringify({
success: false,
offline: true,
message: 'Sin conexión. Por favor intente más tarde.'
}),
{
headers: { 'Content-Type': 'application/json' },
status: 503
}
);
})
);
return;
}
// Static assets - Cache First, fallback to Network
if (isStaticAsset(url.pathname)) {
event.respondWith(cacheFirstStrategy(request, STATIC_CACHE));
return;
}
// Dynamic content (HTML pages) - Network First, fallback to Cache
event.respondWith(networkFirstStrategy(request, DYNAMIC_CACHE));
});
/**
* Cache First Strategy
* Try cache first, fallback to network, then cache the response
*/
function cacheFirstStrategy(request, cacheName) {
return caches.match(request)
.then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(request)
.then((networkResponse) => {
// Clone the response
const responseToCache = networkResponse.clone();
caches.open(cacheName)
.then((cache) => {
cache.put(request, responseToCache);
});
return networkResponse;
})
.catch((error) => {
console.error('[Service Worker] Fetch failed:', error);
// Return offline page if available
return caches.match('/offline.html') || new Response('Offline');
});
});
}
/**
* Network First Strategy
* Try network first, fallback to cache
*/
function networkFirstStrategy(request, cacheName) {
return fetch(request)
.then((networkResponse) => {
// Clone and cache the response
const responseToCache = networkResponse.clone();
caches.open(cacheName)
.then((cache) => {
cache.put(request, responseToCache);
});
return networkResponse;
})
.catch((error) => {
console.log('[Service Worker] Network failed, trying cache:', error);
return caches.match(request)
.then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
// If API request and no cache, return offline indicator
if (request.url.includes('/api/') || request.url.includes('/Colaboracion/')) {
return new Response(
JSON.stringify({ offline: true }),
{
headers: { 'Content-Type': 'application/json' },
status: 503
}
);
}
throw error;
});
});
}
/**
* Check if request is for a static asset
*/
function isStaticAsset(pathname) {
const staticExtensions = ['.css', '.js', '.jpg', '.jpeg', '.png', '.gif', '.svg', '.woff', '.woff2', '.ttf', '.eot', '.ico'];
return staticExtensions.some(ext => pathname.endsWith(ext));
}
// Background Sync for future enhancement
self.addEventListener('sync', (event) => {
console.log('[Service Worker] Background sync triggered:', event.tag);
if (event.tag === 'sync-colaboraciones') {
event.waitUntil(
// This will be handled by colaboraciones-sync.js
self.registration.showNotification('Sincronización completada', {
body: 'Las colaboraciones offline se han sincronizado exitosamente.',
icon: '/Assets/icon-192x192.png',
badge: '/Assets/icon-192x192.png'
})
);
}
});
// Message handler for cache updates
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
if (event.data && event.data.type === 'CLEAR_CACHE') {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => caches.delete(cacheName))
);
})
);
}
});
console.log('[Service Worker] Loaded and ready');