/** * Workorder Offline Service - Data access layer * * Abstracts data access to route through IndexedDB or network based on * offline mode setting and connectivity status. */ import { db, SyncStatus, OperationType } from './db.js'; import { isOfflineModeEnabled, getDeviceId, updateLastSyncTimestamp } from './offlineSettings.js'; import syncQueue from './syncQueue.js'; const API_BASE = '/MobileApp/Workorder/Workorder'; /** * Check if device is online */ function isOnline() { return navigator.onLine; } /** * Check if we should use offline storage */ function shouldUseOfflineStorage() { return isOfflineModeEnabled() && (!isOnline() || !navigator.onLine); } /** * Make API request with error handling */ async function apiRequest(endpoint, options = {}) { const url = `${API_BASE}/${endpoint}`; const config = { credentials: 'include', headers: { 'Content-Type': 'application/json', ...(options.idempotencyKey && { 'Idempotency-Key': options.idempotencyKey }), ...(options.headers || {}) }, ...options }; if (options.body && typeof options.body === 'object') { config.body = JSON.stringify(options.body); } try { const response = await fetch(url, config); if (response.status === 401) { // Auth error - should be handled by app return { success: false, error: 'Nicht autorisiert', authError: true }; } const data = await response.json(); return { ...data, _status: response.status }; } catch (error) { console.error(`[OfflineService] API error for ${endpoint}:`, error); return { success: false, error: 'Netzwerkfehler', networkError: true }; } } // ============================================================================ // READ OPERATIONS // ============================================================================ /** * Get workorders list * If offline mode enabled: serve from cache, fallback to network * If online: fetch from network, update cache */ export async function getWorkorders(filters = {}) { if (isOfflineModeEnabled()) { // Try to get from cache first const cached = await getCachedWorkorders(filters); if (!isOnline()) { // Offline - return cached data return { success: true, workorders: cached, fromCache: true, offline: true }; } // Online - fetch fresh data and update cache const result = await apiRequest('get', { method: 'POST', body: { filters } }); if (result.success && result.workorders) { await updateWorkordersCache(result.workorders); return { ...result, fromCache: false }; } // Network failed but we have cache if (cached.length > 0) { return { success: true, workorders: cached, fromCache: true, stale: true }; } return result; } // Offline mode disabled - direct API call return apiRequest('get', { method: 'POST', body: { filters } }); } /** * Get cached workorders from IndexedDB */ async function getCachedWorkorders(filters = {}) { let query = db.workorders.toCollection(); if (filters.companyId) { query = db.workorders.where('companyId').equals(filters.companyId); } if (filters.status) { query = query.filter(w => w.status === filters.status); } const workorders = await query.toArray(); // Apply optimistic updates from sync queue const result = []; for (const wo of workorders) { const optimistic = await syncQueue.getOptimistic(wo.id, wo); result.push(optimistic); } return result; } /** * Update workorders cache */ async function updateWorkordersCache(workorders) { await db.transaction('rw', db.workorders, async () => { for (const wo of workorders) { await db.workorders.put({ ...wo, _syncStatus: SyncStatus.SYNCED, _lastModified: Date.now() }); } }); } /** * Get workorder detail */ export async function getWorkorderDetail(workorderId) { if (isOfflineModeEnabled()) { // Try cache first const cached = await getCachedWorkorderDetail(workorderId); if (!isOnline()) { if (cached) { return { success: true, ...cached, fromCache: true, offline: true }; } return { success: false, error: 'Offline - keine gecachten Daten', offline: true }; } // Online - fetch fresh const result = await apiRequest(`getWorkorderDetail?id=${workorderId}`); if (result.success) { await cacheWorkorderDetail(workorderId, result); return { ...result, fromCache: false }; } // Network failed, return cache if available if (cached) { return { success: true, ...cached, fromCache: true, stale: true }; } return result; } // Offline mode disabled return apiRequest(`getWorkorderDetail?id=${workorderId}`); } /** * Get cached workorder detail */ async function getCachedWorkorderDetail(workorderId) { const detail = await db.workorderDetails.get(workorderId); if (!detail) return null; // Get related data const documentation = await db.documentation .where('workorderId') .equals(workorderId) .toArray(); const journals = await db.journals .where('workorderId') .equals(workorderId) .toArray(); // Get pending journals from sync queue const pendingOps = await syncQueue.getWorkorderOps(workorderId); const pendingJournals = pendingOps .filter(op => op.operation === OperationType.ADD_JOURNAL && op.status !== 'completed') .map(op => ({ id: `pending-${op.localId}`, workorderId, text: op.payload.text, create: op.clientTimestamp, _pending: true })); // Apply optimistic state to workorder const workorder = await db.workorders.get(workorderId); const optimisticWorkorder = workorder ? await syncQueue.getOptimistic(workorderId, { ...detail.workorder, ...workorder }) : detail.workorder; return { workorder: optimisticWorkorder, documentation: { docs: documentation, journals: [...journals, ...pendingJournals].sort((a, b) => b.create - a.create) }, tenantConfig: detail.tenantConfig, checklist: detail.checklist, technicalData: detail.technicalData }; } /** * Cache workorder detail */ async function cacheWorkorderDetail(workorderId, data) { await db.transaction('rw', [db.workorderDetails, db.documentation, db.journals, db.tenantConfigs], async () => { // Store main detail await db.workorderDetails.put({ workorderId, workorder: data.workorder, tenantConfig: data.tenantConfig, checklist: data.checklist, technicalData: data.technicalData, lastFetched: Date.now() }); // Store documentation if (data.documentation?.docs) { for (const doc of data.documentation.docs) { await db.documentation.put({ ...doc, workorderId, _syncStatus: SyncStatus.SYNCED }); } } // Store journals if (data.documentation?.journals) { for (const journal of data.documentation.journals) { await db.journals.put({ ...journal, workorderId }); } } // Store tenant config if (data.tenantConfig) { await db.tenantConfigs.put({ ...data.tenantConfig, lastFetched: Date.now() }); } }); } // ============================================================================ // WRITE OPERATIONS // ============================================================================ /** * Add journal entry */ export async function addJournal(workorderId, text) { if (isOfflineModeEnabled() && !isOnline()) { // Queue for later sync const { localId, operationId } = await syncQueue.queue( OperationType.ADD_JOURNAL, workorderId, { workorderId, text } ); return { success: true, queued: true, localId, operationId, message: 'Eintrag wird bei nächster Verbindung synchronisiert' }; } // Online - direct API call const result = await apiRequest('addJournal', { method: 'POST', body: { workorderId, text } }); if (result.success && isOfflineModeEnabled()) { // Update cache with new journal await db.journals.add({ ...result.journal, workorderId }); } return result; } /** * Update notes (additional info) */ export async function updateNotes(workorderId, additionalInfo) { if (isOfflineModeEnabled() && !isOnline()) { const { localId, operationId } = await syncQueue.queue( OperationType.UPDATE_NOTES, workorderId, { workorderId, additionalInfo } ); // Optimistic update in cache await db.workorders.update(workorderId, { additionalInfo, _syncStatus: SyncStatus.PENDING }); return { success: true, queued: true, localId, operationId, message: 'Änderungen werden bei nächster Verbindung synchronisiert' }; } const result = await apiRequest('updateAdditionalInfo', { method: 'POST', body: { workorderId, additionalInfo } }); if (result.success && isOfflineModeEnabled()) { await db.workorders.update(workorderId, { additionalInfo }); } return result; } /** * Schedule appointment */ export async function scheduleAppointment(workorderId, appointmentDate) { if (isOfflineModeEnabled() && !isOnline()) { const { localId, operationId } = await syncQueue.queue( OperationType.SCHEDULE_APPOINTMENT, workorderId, { workorderId, appointmentDate } ); await db.workorders.update(workorderId, { appointmentDate, status: 'scheduled', _syncStatus: SyncStatus.PENDING }); return { success: true, queued: true, localId, operationId, message: 'Termin wird bei nächster Verbindung synchronisiert' }; } const result = await apiRequest('scheduleAppointment', { method: 'POST', body: { workorderId, appointmentDate } }); if (result.success && isOfflineModeEnabled()) { await db.workorders.update(workorderId, { appointmentDate, status: 'scheduled' }); } return result; } /** * Request intervention */ export async function requestIntervention(workorderId, interventionType, journalText) { if (isOfflineModeEnabled() && !isOnline()) { const { localId, operationId } = await syncQueue.queue( OperationType.REQUEST_INTERVENTION, workorderId, { workorderId, interventionType, journalText } ); await db.workorders.update(workorderId, { status: 'intervention_required', _syncStatus: SyncStatus.PENDING }); return { success: true, queued: true, localId, operationId, message: 'Intervention wird bei nächster Verbindung gemeldet' }; } const result = await apiRequest('requestIntervention', { method: 'POST', body: { workorderId, interventionType, journalText } }); if (result.success && isOfflineModeEnabled()) { await db.workorders.update(workorderId, { status: 'intervention_required' }); } return result; } /** * Update cable data */ export async function updateCableData(workorderId, cableLength, cableType) { if (isOfflineModeEnabled() && !isOnline()) { const { localId, operationId } = await syncQueue.queue( OperationType.UPDATE_CABLE_DATA, workorderId, { workorderId, cableLength, cableType } ); await db.workorders.update(workorderId, { cableLength, cableType, _syncStatus: SyncStatus.PENDING }); return { success: true, queued: true, localId, operationId, message: 'Kabeldaten werden bei nächster Verbindung synchronisiert' }; } const result = await apiRequest('updateWorkorderData', { method: 'POST', body: { workorderId, cableLength, cableType } }); if (result.success && isOfflineModeEnabled()) { await db.workorders.update(workorderId, { cableLength, cableType }); } return result; } /** * Complete workorder * BLOCKED when offline - requires sync to validate checklist */ export async function completeWorkorder(workorderId) { if (!isOnline()) { return { success: false, blocked: true, error: 'Abschluss nur online möglich', message: 'Bitte synchronisieren Sie zuerst, um die Checkliste zu validieren' }; } const result = await apiRequest('completeWorkorder', { method: 'POST', body: { workorderId } }); if (result.success && isOfflineModeEnabled()) { await db.workorders.update(workorderId, { status: 'documented' }); } return result; } // ============================================================================ // FULL SYNC (Initial download of all workorders) // ============================================================================ /** * Perform full sync - download all workorders for offline use */ export async function performFullSync(onProgress = () => {}) { if (!isOnline()) { return { success: false, error: 'Keine Internetverbindung' }; } onProgress({ phase: 'fetching', message: 'Lade Arbeitsaufträge...' }); // Fetch all workorders const result = await apiRequest('getAllForOffline', { method: 'POST', body: {} }); if (!result.success) { return result; } const { workorders, totalCount } = result; onProgress({ phase: 'storing', message: `Speichere ${totalCount} Arbeitsaufträge...`, total: totalCount }); // Store all workorders let processed = 0; await db.transaction('rw', [db.workorders, db.workorderDetails, db.documentation, db.thumbnails, db.tenantConfigs], async () => { for (const wo of workorders) { // Store workorder await db.workorders.put({ ...wo.workorder, _syncStatus: SyncStatus.SYNCED, _lastModified: Date.now() }); // Store detail await db.workorderDetails.put({ workorderId: wo.workorder.id, workorder: wo.workorder, tenantConfig: wo.tenantConfig, checklist: wo.checklist, lastFetched: Date.now() }); // Store documentation if (wo.documentation) { for (const doc of wo.documentation) { await db.documentation.put({ ...doc, workorderId: wo.workorder.id, _syncStatus: SyncStatus.SYNCED }); } } // Store tenant config if (wo.tenantConfig) { await db.tenantConfigs.put({ ...wo.tenantConfig, lastFetched: Date.now() }); } processed++; if (processed % 10 === 0) { onProgress({ phase: 'storing', current: processed, total: totalCount }); } } }); // Download thumbnails if (result.thumbnailsToDownload && result.thumbnailsToDownload.length > 0) { onProgress({ phase: 'thumbnails', message: 'Lade Vorschaubilder...', total: result.thumbnailsToDownload.length }); await downloadThumbnails(result.thumbnailsToDownload, onProgress); } updateLastSyncTimestamp(); onProgress({ phase: 'complete', message: 'Synchronisation abgeschlossen' }); return { success: true, workordersCount: totalCount, message: `${totalCount} Arbeitsaufträge synchronisiert` }; } /** * Download thumbnails */ async function downloadThumbnails(thumbnails, onProgress) { let downloaded = 0; for (const thumb of thumbnails) { try { const response = await fetch(`${API_BASE}/getThumbnail?id=${thumb.documentationId}`, { credentials: 'include' }); if (response.ok) { const blob = await response.blob(); await db.thumbnails.put({ documentationId: thumb.documentationId, workorderId: thumb.workorderId, blob, downloadedAt: Date.now() }); } downloaded++; if (downloaded % 5 === 0) { onProgress({ phase: 'thumbnails', current: downloaded, total: thumbnails.length }); } } catch (error) { console.warn(`[OfflineService] Failed to download thumbnail ${thumb.documentationId}:`, error); } } } /** * Check for reassigned workorders after sync */ export async function checkReassignedWorkorders(serverWorkorderIds) { const localWorkorders = await db.workorders.toArray(); const serverIdSet = new Set(serverWorkorderIds); const reassigned = []; for (const wo of localWorkorders) { if (!serverIdSet.has(wo.id)) { reassigned.push({ id: wo.id, customer: wo.customer || `#${wo.id}`, hasPendingChanges: await syncQueue.hasPending(wo.id) }); // Remove from local cache await db.workorders.delete(wo.id); await db.workorderDetails.delete(wo.id); await db.documentation.where('workorderId').equals(wo.id).delete(); await db.journals.where('workorderId').equals(wo.id).delete(); await db.thumbnails.where('workorderId').equals(wo.id).delete(); } } if (reassigned.length > 0) { console.log(`[OfflineService] ${reassigned.length} workorders were reassigned`); } return reassigned; } /** * Get pending changes summary for a workorder */ export async function getPendingChangesSummary(workorderId) { const ops = await syncQueue.getWorkorderOps(workorderId); const pending = ops.filter(op => op.status === 'pending' || op.status === 'processing'); return pending.map(op => { switch (op.operation) { case OperationType.ADD_JOURNAL: return `Journaleintrag: "${op.payload.text?.substring(0, 50)}..."`; case OperationType.UPDATE_NOTES: return 'Zusatzinfo aktualisiert'; case OperationType.SCHEDULE_APPOINTMENT: return 'Termin geplant'; case OperationType.UPDATE_CABLE_DATA: return 'Kabeldaten aktualisiert'; case OperationType.REQUEST_INTERVENTION: return 'Intervention gemeldet'; default: return op.operation; } }); } export default { getWorkorders, getWorkorderDetail, addJournal, updateNotes, scheduleAppointment, requestIntervention, updateCableData, completeWorkorder, performFullSync, checkReassignedWorkorders, getPendingChangesSummary, isOnline };