added offline mode for xinon app

This commit is contained in:
Luca Haid
2026-01-27 10:35:15 +01:00
parent e3b9720425
commit d9386892ab
12 changed files with 4224 additions and 137 deletions

View File

@@ -4,6 +4,11 @@ import MainMenu from '/mobile/components/MainMenu.js';
import LagerModule from '/mobile/modules/lager/LagerModule.js'; import LagerModule from '/mobile/modules/lager/LagerModule.js';
import ShippingNoteModule from '/mobile/modules/lager/shippingnote/ShippingNoteModule.js'; import ShippingNoteModule from '/mobile/modules/lager/shippingnote/ShippingNoteModule.js';
import WorkorderModule from '/mobile/modules/workorder/WorkorderModule.js'; import WorkorderModule from '/mobile/modules/workorder/WorkorderModule.js';
import OfflineIndicator from '/mobile/components/OfflineIndicator.js';
import SyncStatus from '/mobile/components/SyncStatus.js';
import { initDatabase, clearAllData, getStorageEstimate } from '/mobile/shared/db.js';
import offlineSettings from '/mobile/shared/offlineSettings.js';
import SyncManager from '/mobile/shared/syncManager.js';
const { createApp, ref, computed, onMounted, onUnmounted, watch } = Vue; const { createApp, ref, computed, onMounted, onUnmounted, watch } = Vue;
@@ -33,7 +38,9 @@ const App = {
MainMenu, MainMenu,
LagerModule, LagerModule,
ShippingNoteModule, ShippingNoteModule,
WorkorderModule WorkorderModule,
OfflineIndicator,
SyncStatus
}, },
setup() { setup() {
@@ -55,6 +62,20 @@ const App = {
const workorderDetailOpen = ref(false); const workorderDetailOpen = ref(false);
const workorderRef = ref(null); const workorderRef = ref(null);
// Offline mode state
const offlineModeEnabled = ref(false);
const offlineAutoSync = ref(true);
const offlinePendingCount = ref(0);
const offlinePendingOps = ref(0);
const offlinePendingPhotos = ref(0);
const offlineFailedCount = ref(0);
const offlineIsSyncing = ref(false);
const offlineLastSyncText = ref('Nie synchronisiert');
const offlineFreshness = ref('unknown');
const offlineSyncProgress = ref(null);
const offlineStorageUsed = ref(0);
const isOnline = ref(navigator.onLine);
const applyTheme = () => { const applyTheme = () => {
const isDark = localStorage.theme === 'dark' || const isDark = localStorage.theme === 'dark' ||
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches); (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
@@ -112,6 +133,109 @@ const App = {
} catch (e) {} } catch (e) {}
}; };
// Offline mode functions
const loadOfflineSettings = () => {
const settings = offlineSettings.load();
offlineModeEnabled.value = settings.enabled;
offlineAutoSync.value = settings.autoSync;
offlineLastSyncText.value = offlineSettings.getLastSyncText();
offlineFreshness.value = offlineSettings.getFreshness();
};
const toggleOfflineMode = async () => {
if (offlineModeEnabled.value) {
offlineSettings.disable();
offlineModeEnabled.value = false;
} else {
offlineSettings.enable();
offlineModeEnabled.value = true;
// Initialize database
try {
await initDatabase();
SyncManager.init();
} catch (error) {
console.error('Failed to initialize offline mode:', error);
showToast('Offline-Modus konnte nicht aktiviert werden', 'error');
offlineSettings.disable();
offlineModeEnabled.value = false;
}
}
};
const setOfflineAutoSync = (value) => {
offlineAutoSync.value = value;
offlineSettings.setAutoSync(value);
};
const triggerManualSync = async () => {
if (!navigator.onLine) {
showToast('Keine Internetverbindung', 'error');
return;
}
const result = await SyncManager.sync();
if (result.success) {
showToast('Synchronisation abgeschlossen', 'success');
} else {
showToast(result.error || 'Synchronisation fehlgeschlagen', 'error');
}
};
const clearOfflineData = async () => {
try {
await clearAllData();
offlineSettings.clear();
offlineModeEnabled.value = false;
offlinePendingCount.value = 0;
showToast('Offline-Daten gelöscht', 'success');
} catch (error) {
showToast('Fehler beim Löschen', 'error');
}
};
const updateOfflineStatus = async () => {
if (offlineModeEnabled.value) {
const summary = await SyncManager.getPendingSummary();
offlinePendingCount.value = summary.total;
offlinePendingOps.value = summary.operations;
offlinePendingPhotos.value = summary.photos;
offlineFailedCount.value = summary.failed;
offlineLastSyncText.value = offlineSettings.getLastSyncText();
offlineFreshness.value = offlineSettings.getFreshness();
const storage = await getStorageEstimate();
offlineStorageUsed.value = storage.usage;
}
};
const handleSyncEvent = (event, data) => {
switch (event) {
case 'sync-start':
offlineIsSyncing.value = true;
offlineSyncProgress.value = null;
break;
case 'sync-progress':
offlineSyncProgress.value = data;
break;
case 'sync-complete':
offlineIsSyncing.value = false;
offlineSyncProgress.value = null;
updateOfflineStatus();
if (data.reassigned?.length > 0) {
for (const wo of data.reassigned) {
showToast(`Arbeitsauftrag #${wo.id} wurde neu zugewiesen`, 'warning');
}
}
break;
case 'sync-error':
offlineIsSyncing.value = false;
offlineSyncProgress.value = null;
break;
}
};
const handleOnlineStatusChange = () => {
isOnline.value = navigator.onLine;
};
const saveLastWorkflow = (module, submodule) => { const saveLastWorkflow = (module, submodule) => {
if (module) { if (module) {
const workflow = { module, submodule: submodule || null, timestamp: Date.now() }; const workflow = { module, submodule: submodule || null, timestamp: Date.now() };
@@ -252,7 +376,22 @@ const App = {
mediaQuery.addEventListener('change', applyTheme); mediaQuery.addEventListener('change', applyTheme);
window.addEventListener('popstate', handlePopstate); window.addEventListener('popstate', handlePopstate);
window.addEventListener('beforeinstallprompt', handleInstallPrompt); window.addEventListener('beforeinstallprompt', handleInstallPrompt);
window.addEventListener('online', handleOnlineStatusChange);
window.addEventListener('offline', handleOnlineStatusChange);
loadLagerSettings(); loadLagerSettings();
loadOfflineSettings();
// Initialize offline mode if enabled
if (offlineModeEnabled.value) {
try {
await initDatabase();
SyncManager.init();
SyncManager.subscribe(handleSyncEvent);
await updateOfflineStatus();
} catch (error) {
console.error('Failed to initialize offline mode:', error);
}
}
if (shouldRequirePWA() && !isPWAInstalled()) { if (shouldRequirePWA() && !isPWAInstalled()) {
showInstallPrompt.value = true; showInstallPrompt.value = true;
@@ -288,6 +427,11 @@ const App = {
mediaQuery.removeEventListener('change', applyTheme); mediaQuery.removeEventListener('change', applyTheme);
window.removeEventListener('popstate', handlePopstate); window.removeEventListener('popstate', handlePopstate);
window.removeEventListener('beforeinstallprompt', handleInstallPrompt); window.removeEventListener('beforeinstallprompt', handleInstallPrompt);
window.removeEventListener('online', handleOnlineStatusChange);
window.removeEventListener('offline', handleOnlineStatusChange);
if (offlineModeEnabled.value) {
SyncManager.destroy();
}
}); });
return { return {
@@ -322,6 +466,23 @@ const App = {
workorderRef, workorderRef,
handleWorkorderDetailOpen, handleWorkorderDetailOpen,
handleWorkorderDetailClose, handleWorkorderDetailClose,
// Offline mode
offlineModeEnabled,
offlineAutoSync,
offlinePendingCount,
offlinePendingOps,
offlinePendingPhotos,
offlineFailedCount,
offlineIsSyncing,
offlineLastSyncText,
offlineFreshness,
offlineSyncProgress,
offlineStorageUsed,
isOnline,
toggleOfflineMode,
setOfflineAutoSync,
triggerManualSync,
clearOfflineData,
}; };
}, },
@@ -458,9 +619,16 @@ const App = {
</svg> </svg>
</button> </button>
<div class="flex-1 flex justify-center"> <div class="flex-1 flex items-center justify-center gap-2">
<img src="/assets/images/xinon-full-transparent.png" class="h-7 dark:hidden" alt="Logo"> <img src="/assets/images/xinon-full-transparent.png" class="h-7 dark:hidden" alt="Logo">
<img src="/assets/images/xinon-full-transparent-white.png" class="h-7 hidden dark:block" alt="Logo"> <img src="/assets/images/xinon-full-transparent-white.png" class="h-7 hidden dark:block" alt="Logo">
<OfflineIndicator
:offline-mode-enabled="offlineModeEnabled"
:pending-count="offlinePendingCount"
:is-syncing="offlineIsSyncing"
:freshness="offlineFreshness"
@sync-click="triggerManualSync"
/>
</div> </div>
<button <button
@@ -588,6 +756,68 @@ const App = {
</button> </button>
</div> </div>
</div> </div>
<div class="px-4 py-3 border-t border-slate-100 dark:border-slate-700">
<p class="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-3">Offline-Modus (Workorder)</p>
<div class="flex items-center justify-between mb-3">
<div>
<p class="font-medium text-slate-800 dark:text-white text-sm">Offline-Modus</p>
<p class="text-xs text-slate-500 dark:text-slate-400">Arbeitsaufträge offline verfügbar</p>
</div>
<button
@click="toggleOfflineMode"
:class="[
'relative w-11 h-6 rounded-full transition-colors',
offlineModeEnabled ? 'bg-green-500' : 'bg-slate-300 dark:bg-slate-600'
]"
>
<span :class="[
'absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform',
offlineModeEnabled ? 'left-5' : 'left-0.5'
]"></span>
</button>
</div>
<template v-if="offlineModeEnabled">
<div class="flex items-center justify-between mb-3">
<div>
<p class="font-medium text-slate-800 dark:text-white text-sm">Auto-Sync</p>
<p class="text-xs text-slate-500 dark:text-slate-400">Automatisch synchronisieren</p>
</div>
<button
@click="setOfflineAutoSync(!offlineAutoSync)"
:class="[
'relative w-11 h-6 rounded-full transition-colors',
offlineAutoSync ? 'bg-green-500' : 'bg-slate-300 dark:bg-slate-600'
]"
>
<span :class="[
'absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform',
offlineAutoSync ? 'left-5' : 'left-0.5'
]"></span>
</button>
</div>
<SyncStatus
:pending-operations="offlinePendingOps"
:pending-photos="offlinePendingPhotos"
:failed-count="offlineFailedCount"
:last-sync-text="offlineLastSyncText"
:is-syncing="offlineIsSyncing"
:is-online="isOnline"
:sync-progress="offlineSyncProgress"
@sync="triggerManualSync"
/>
<button
@click="clearOfflineData"
class="w-full mt-3 py-2 px-4 text-sm text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition"
>
Offline-Daten löschen
</button>
</template>
</div>
</div> </div>
<div class="p-3 border-t border-slate-100 dark:border-slate-700"> <div class="p-3 border-t border-slate-100 dark:border-slate-700">

View File

@@ -0,0 +1,211 @@
/**
* Offline Indicator Component
*
* Displays network status and sync state in the header.
* Shows: Online, Offline, Syncing states with appropriate colors.
*/
const { ref, computed, onMounted, onUnmounted, watch } = Vue;
export default {
name: 'OfflineIndicator',
props: {
// Whether offline mode is enabled in settings
offlineModeEnabled: {
type: Boolean,
default: false
},
// Number of pending changes
pendingCount: {
type: Number,
default: 0
},
// Whether sync is currently running
isSyncing: {
type: Boolean,
default: false
},
// Data freshness level: 'fresh', 'stale', 'old', 'unknown'
freshness: {
type: String,
default: 'unknown'
}
},
emits: ['sync-click'],
setup(props, { emit }) {
const isOnline = ref(navigator.onLine);
// Update online status
const updateOnlineStatus = () => {
isOnline.value = navigator.onLine;
};
onMounted(() => {
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
});
onUnmounted(() => {
window.removeEventListener('online', updateOnlineStatus);
window.removeEventListener('offline', updateOnlineStatus);
});
// Computed display state
const displayState = computed(() => {
if (!props.offlineModeEnabled) {
return isOnline.value ? 'online' : 'offline-no-cache';
}
if (props.isSyncing) {
return 'syncing';
}
if (!isOnline.value) {
return 'offline';
}
if (props.pendingCount > 0) {
return 'pending';
}
return 'online';
});
// Status text
const statusText = computed(() => {
switch (displayState.value) {
case 'syncing':
return 'Synchronisiere...';
case 'offline':
return 'Offline';
case 'offline-no-cache':
return 'Keine Verbindung';
case 'pending':
return `${props.pendingCount} ausstehend`;
case 'online':
return props.offlineModeEnabled ? 'Synchronisiert' : '';
default:
return '';
}
});
// Status icon
const statusIcon = computed(() => {
switch (displayState.value) {
case 'syncing':
return 'sync';
case 'offline':
case 'offline-no-cache':
return 'cloud-off';
case 'pending':
return 'cloud-upload';
case 'online':
return 'cloud-check';
default:
return 'cloud';
}
});
// Status color classes
const statusClasses = computed(() => {
switch (displayState.value) {
case 'syncing':
return 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300';
case 'offline':
return 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300';
case 'offline-no-cache':
return 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300';
case 'pending':
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300';
case 'online':
return 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300';
default:
return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300';
}
});
// Freshness indicator color
const freshnessColor = computed(() => {
switch (props.freshness) {
case 'fresh':
return 'bg-green-500';
case 'stale':
return 'bg-yellow-500';
case 'old':
return 'bg-red-500';
default:
return 'bg-gray-400';
}
});
// Click handler
const handleClick = () => {
if (isOnline.value && props.pendingCount > 0) {
emit('sync-click');
}
};
// Should show (only show if offline mode enabled or offline)
const shouldShow = computed(() => {
return props.offlineModeEnabled || !isOnline.value;
});
return {
isOnline,
displayState,
statusText,
statusIcon,
statusClasses,
freshnessColor,
handleClick,
shouldShow
};
},
template: `
<div
v-if="shouldShow"
:class="[
'inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium transition-all',
statusClasses,
(isOnline && pendingCount > 0) ? 'cursor-pointer hover:opacity-80' : ''
]"
@click="handleClick"
>
<!-- Syncing spinner -->
<svg v-if="displayState === 'syncing'" class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<!-- Cloud off icon (offline) -->
<svg v-else-if="displayState === 'offline' || displayState === 'offline-no-cache'" class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3l18 18M10.5 6.5A7.5 7.5 0 0118 12m-3.5 4.5H6a4 4 0 01-.5-7.97"/>
</svg>
<!-- Cloud upload icon (pending) -->
<svg v-else-if="displayState === 'pending'" class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
</svg>
<!-- Cloud check icon (synced) -->
<svg v-else-if="displayState === 'online'" class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4"/>
</svg>
<!-- Status text -->
<span v-if="statusText">{{ statusText }}</span>
<!-- Freshness dot (when showing cached data) -->
<span
v-if="offlineModeEnabled && displayState !== 'syncing'"
:class="['w-1.5 h-1.5 rounded-full', freshnessColor]"
:title="freshness === 'fresh' ? 'Aktuell' : freshness === 'stale' ? 'Etwas älter' : freshness === 'old' ? 'Veraltet' : ''"
></span>
</div>
`
};

View File

@@ -0,0 +1,225 @@
/**
* Sync Status Component
*
* Shows detailed sync status including pending count,
* last sync time, and manual sync button.
*/
const { ref, computed } = Vue;
export default {
name: 'SyncStatus',
props: {
// Number of pending operations
pendingOperations: {
type: Number,
default: 0
},
// Number of pending photos
pendingPhotos: {
type: Number,
default: 0
},
// Number of failed operations
failedCount: {
type: Number,
default: 0
},
// Last sync timestamp text
lastSyncText: {
type: String,
default: 'Nie synchronisiert'
},
// Whether sync is running
isSyncing: {
type: Boolean,
default: false
},
// Whether device is online
isOnline: {
type: Boolean,
default: true
},
// Sync progress info
syncProgress: {
type: Object,
default: null
}
},
emits: ['sync', 'retry-failed'],
setup(props, { emit }) {
// Total pending count
const totalPending = computed(() => {
return props.pendingOperations + props.pendingPhotos;
});
// Status summary text
const summaryText = computed(() => {
const parts = [];
if (props.pendingOperations > 0) {
parts.push(`${props.pendingOperations} Änderung${props.pendingOperations === 1 ? '' : 'en'}`);
}
if (props.pendingPhotos > 0) {
parts.push(`${props.pendingPhotos} Foto${props.pendingPhotos === 1 ? '' : 's'}`);
}
if (parts.length === 0) {
return 'Keine ausstehenden Änderungen';
}
return parts.join(', ') + ' ausstehend';
});
// Progress text during sync
const progressText = computed(() => {
if (!props.syncProgress) return '';
const { phase, current, total, fileName } = props.syncProgress;
if (phase === 'operations') {
return `Synchronisiere ${current}/${total} Änderungen...`;
}
if (phase === 'photos') {
return fileName
? `Lade ${current}/${total} hoch: ${fileName}`
: `Lade ${current}/${total} Fotos hoch...`;
}
return 'Synchronisiere...';
});
// Handle sync button click
const handleSync = () => {
if (!props.isSyncing && props.isOnline) {
emit('sync');
}
};
// Handle retry failed click
const handleRetryFailed = () => {
if (!props.isSyncing) {
emit('retry-failed');
}
};
return {
totalPending,
summaryText,
progressText,
handleSync,
handleRetryFailed
};
},
template: `
<div class="bg-white dark:bg-slate-800 rounded-xl p-4 shadow-sm border border-slate-200 dark:border-slate-700">
<!-- Header -->
<div class="flex items-center justify-between mb-3">
<h3 class="font-medium text-slate-900 dark:text-white flex items-center gap-2">
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Synchronisation
</h3>
<!-- Online/Offline badge -->
<span
:class="[
'px-2 py-0.5 rounded text-xs font-medium',
isOnline
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
]"
>
{{ isOnline ? 'Online' : 'Offline' }}
</span>
</div>
<!-- Sync progress bar (when syncing) -->
<div v-if="isSyncing && syncProgress" class="mb-3">
<div class="flex items-center justify-between text-xs text-slate-500 dark:text-slate-400 mb-1">
<span>{{ progressText }}</span>
<span v-if="syncProgress.total">{{ Math.round((syncProgress.current / syncProgress.total) * 100) }}%</span>
</div>
<div class="w-full bg-slate-200 dark:bg-slate-700 rounded-full h-1.5">
<div
class="bg-primary h-1.5 rounded-full transition-all duration-300"
:style="{ width: syncProgress.total ? ((syncProgress.current / syncProgress.total) * 100) + '%' : '50%' }"
></div>
</div>
</div>
<!-- Summary -->
<div class="space-y-2 mb-4">
<!-- Pending count -->
<div class="flex items-center justify-between text-sm">
<span class="text-slate-600 dark:text-slate-400">Ausstehend</span>
<span
:class="[
'font-medium',
totalPending > 0 ? 'text-orange-600 dark:text-orange-400' : 'text-slate-900 dark:text-white'
]"
>
{{ summaryText }}
</span>
</div>
<!-- Failed count (if any) -->
<div v-if="failedCount > 0" class="flex items-center justify-between text-sm">
<span class="text-slate-600 dark:text-slate-400">Fehlgeschlagen</span>
<button
@click="handleRetryFailed"
class="text-red-600 dark:text-red-400 font-medium hover:underline"
:disabled="isSyncing"
>
{{ failedCount }} - Erneut versuchen
</button>
</div>
<!-- Last sync -->
<div class="flex items-center justify-between text-sm">
<span class="text-slate-600 dark:text-slate-400">Letzte Synchronisation</span>
<span class="text-slate-900 dark:text-white">{{ lastSyncText }}</span>
</div>
</div>
<!-- Sync button -->
<button
@click="handleSync"
:disabled="isSyncing || !isOnline || totalPending === 0"
:class="[
'w-full py-2.5 px-4 rounded-lg font-medium transition-colors flex items-center justify-center gap-2',
(isSyncing || !isOnline || totalPending === 0)
? 'bg-slate-100 dark:bg-slate-700 text-slate-400 dark:text-slate-500 cursor-not-allowed'
: 'bg-primary text-white hover:bg-primary/90'
]"
>
<!-- Spinner when syncing -->
<svg v-if="isSyncing" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<!-- Sync icon -->
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
<span>
{{ isSyncing ? 'Synchronisiere...' : !isOnline ? 'Offline' : totalPending === 0 ? 'Alles synchronisiert' : 'Jetzt synchronisieren' }}
</span>
</button>
<!-- Offline hint -->
<p v-if="!isOnline && totalPending > 0" class="mt-2 text-xs text-slate-500 dark:text-slate-400 text-center">
Änderungen werden synchronisiert, sobald Sie wieder online sind.
</p>
</div>
`
};

View File

@@ -3,8 +3,13 @@
* *
* Main module for workorder management in MobileApp. * Main module for workorder management in MobileApp.
* Provides list view and full-screen detail view with collapsible cards. * Provides list view and full-screen detail view with collapsible cards.
* Supports offline mode via WorkorderOfflineService.
*/ */
import workorderService from '/mobile/shared/workorderOfflineService.js';
import photoQueue from '/mobile/shared/photoQueue.js';
import { isOfflineModeEnabled } from '/mobile/shared/offlineSettings.js';
export default { export default {
name: 'WorkorderModule', name: 'WorkorderModule',
emits: ['navigate', 'toast', 'detail-open', 'detail-close'], emits: ['navigate', 'toast', 'detail-open', 'detail-close'],
@@ -184,15 +189,14 @@ export default {
const fetchWorkorders = async () => { const fetchWorkorders = async () => {
isLoading.value = true; isLoading.value = true;
try { try {
const response = await fetch('/MobileApp/Workorder/Workorder/get', { const data = await workorderService.getWorkorders({ pagination: { page: 1, per_page: 500 } });
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ pagination: { page: 1, per_page: 500 } })
});
const data = await response.json();
if (data.success) { if (data.success) {
workorders.value = data.workorders; workorders.value = data.workorders;
if (data.fromCache) {
console.log('[WorkorderModule] Loaded from cache');
}
} else if (data.offline) {
emit('toast', 'Offline - Daten aus Cache', 'info');
} }
} catch (error) { } catch (error) {
console.error('Error fetching workorders:', error); console.error('Error fetching workorders:', error);
@@ -209,9 +213,8 @@ export default {
emit('detail-open', workorder.id); emit('detail-open', workorder.id);
try { try {
// Fetch all workorder details in a single request // Fetch all workorder details via offline service
const response = await fetch(`/MobileApp/Workorder/Workorder/getWorkorderDetail?id=${workorder.id}`, { credentials: 'include' }); const data = await workorderService.getWorkorderDetail(workorder.id);
const data = await response.json();
if (data.success) { if (data.success) {
selectedWorkorder.value = data.workorder; selectedWorkorder.value = data.workorder;
@@ -219,10 +222,16 @@ export default {
cableLength: data.workorder.cableLength || '', cableLength: data.workorder.cableLength || '',
cableType: data.workorder.cableType || '' cableType: data.workorder.cableType || ''
}; };
documentation.value = { docs: data.docs, journals: data.journals }; documentation.value = data.documentation || { docs: [], journals: [] };
tenantConfig.value = data.tenantConfig; tenantConfig.value = data.tenantConfig;
checklist.value = data.checklist; checklist.value = data.checklist || [];
technicalData.value = data.technicalData || null; technicalData.value = data.technicalData || null;
if (data.fromCache) {
console.log('[WorkorderModule] Detail loaded from cache');
}
} else if (data.offline) {
emit('toast', 'Offline - keine gecachten Details', 'error');
closeDetail();
} else { } else {
emit('toast', data.message || 'Fehler beim Laden', 'error'); emit('toast', data.message || 'Fehler beim Laden', 'error');
} }
@@ -261,24 +270,20 @@ export default {
const saveNotes = async () => { const saveNotes = async () => {
try { try {
const response = await fetch('/MobileApp/Workorder/Workorder/updateAdditionalInfo', { const data = await workorderService.updateNotes(selectedWorkorder.value.id, tempNotes.value);
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
workorderId: selectedWorkorder.value.id,
additionalInfo: tempNotes.value
})
});
const data = await response.json();
if (data.success) { if (data.success) {
selectedWorkorder.value.additionalInfo = data.newInfo; selectedWorkorder.value.additionalInfo = tempNotes.value;
isEditingNotes.value = false; isEditingNotes.value = false;
emit('toast', 'Notiz gespeichert', 'success'); if (data.queued) {
// Refresh journals emit('toast', 'Notiz wird synchronisiert', 'info');
const docRes = await fetch(`/MobileApp/Workorder/Workorder/getDocumentation?workorderId=${selectedWorkorder.value.id}`, { credentials: 'include' }); } else {
const docData = await docRes.json(); emit('toast', 'Notiz gespeichert', 'success');
if (docData.success) documentation.value.journals = docData.journals; // Refresh detail to get updated journals
const detailData = await workorderService.getWorkorderDetail(selectedWorkorder.value.id);
if (detailData.success && detailData.documentation) {
documentation.value.journals = detailData.documentation.journals || [];
}
}
} }
} catch (error) { } catch (error) {
emit('toast', 'Fehler beim Speichern', 'error'); emit('toast', 'Fehler beim Speichern', 'error');
@@ -289,20 +294,22 @@ export default {
const addJournalEntry = async () => { const addJournalEntry = async () => {
if (!newJournalText.value.trim()) return; if (!newJournalText.value.trim()) return;
try { try {
const response = await fetch('/MobileApp/Workorder/Workorder/addJournal', { const data = await workorderService.addJournal(selectedWorkorder.value.id, newJournalText.value);
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
workorderId: selectedWorkorder.value.id,
text: newJournalText.value
})
});
const data = await response.json();
if (data.success) { if (data.success) {
documentation.value.journals = data.journals; if (data.queued) {
// Add pending journal entry to local display
documentation.value.journals.unshift({
id: 'pending-' + data.localId,
text: newJournalText.value,
create: Math.floor(Date.now() / 1000),
_pending: true
});
emit('toast', 'Eintrag wird synchronisiert', 'info');
} else if (data.journals) {
documentation.value.journals = data.journals;
emit('toast', 'Eintrag hinzugefügt', 'success');
}
newJournalText.value = ''; newJournalText.value = '';
emit('toast', 'Eintrag hinzugefügt', 'success');
} }
} catch (error) { } catch (error) {
emit('toast', 'Fehler beim Hinzufügen', 'error'); emit('toast', 'Fehler beim Hinzufügen', 'error');
@@ -312,19 +319,17 @@ export default {
// Cable data // Cable data
const saveCableData = async () => { const saveCableData = async () => {
try { try {
const response = await fetch('/MobileApp/Workorder/Workorder/updateWorkorderData', { const data = await workorderService.updateCableData(
method: 'POST', selectedWorkorder.value.id,
headers: { 'Content-Type': 'application/json' }, cableDataForm.value.cableLength,
credentials: 'include', cableDataForm.value.cableType
body: JSON.stringify({ );
workorderId: selectedWorkorder.value.id,
cableLength: cableDataForm.value.cableLength,
cableType: cableDataForm.value.cableType
})
});
const data = await response.json();
if (data.success) { if (data.success) {
emit('toast', 'Daten gespeichert', 'success'); if (data.queued) {
emit('toast', 'Daten werden synchronisiert', 'info');
} else {
emit('toast', 'Daten gespeichert', 'success');
}
} }
} catch (error) { } catch (error) {
emit('toast', 'Fehler beim Speichern', 'error'); emit('toast', 'Fehler beim Speichern', 'error');
@@ -351,6 +356,45 @@ export default {
isUploading.value = true; isUploading.value = true;
const wasFromChecklist = pendingChecklistUpload.value !== null; const wasFromChecklist = pendingChecklistUpload.value !== null;
// Check if offline mode is enabled and we're offline
const offlineEnabled = isOfflineModeEnabled();
const isOffline = !navigator.onLine;
if (offlineEnabled && isOffline) {
// Queue files for later upload
try {
for (let i = 0; i < files.length; i++) {
const result = await photoQueue.queue(
selectedWorkorder.value.id,
files[i],
uploadDocType.value,
''
);
if (result.success) {
// Add pending doc to local display
documentation.value.docs.push({
id: 'pending-' + result.localId,
documentType: uploadDocType.value,
fileName: files[i].name,
_pending: true
});
}
}
triggerHaptic('light');
emit('toast', 'Foto wird bei Verbindung hochgeladen', 'info');
showDocUploadSheet.value = false;
} catch (error) {
emit('toast', 'Fehler beim Speichern', 'error');
} finally {
isUploading.value = false;
if (fileInputRef.value) fileInputRef.value.value = '';
pendingChecklistUpload.value = null;
}
return;
}
// Online upload
const formData = new FormData(); const formData = new FormData();
formData.append('workorderId', selectedWorkorder.value.id); formData.append('workorderId', selectedWorkorder.value.id);
formData.append('documentType', uploadDocType.value); formData.append('documentType', uploadDocType.value);
@@ -372,14 +416,11 @@ export default {
showDocUploadSheet.value = false; showDocUploadSheet.value = false;
// Refresh documentation and checklist // Refresh documentation and checklist
const [docRes, checkRes] = await Promise.all([ const detailData = await workorderService.getWorkorderDetail(selectedWorkorder.value.id);
fetch(`/MobileApp/Workorder/Workorder/getDocumentation?workorderId=${selectedWorkorder.value.id}`, { credentials: 'include' }), if (detailData.success) {
fetch(`/MobileApp/Workorder/Workorder/getChecklist?workorderId=${selectedWorkorder.value.id}`, { credentials: 'include' }) documentation.value = detailData.documentation || { docs: [], journals: [] };
]); checklist.value = detailData.checklist || [];
const docData = await docRes.json(); }
const checkData = await checkRes.json();
if (docData.success) documentation.value = { docs: docData.docs, journals: docData.journals };
if (checkData.success) checklist.value = checkData.checklist;
// Auto-advance: If upload was from checklist, open camera for next item // Auto-advance: If upload was from checklist, open camera for next item
if (wasFromChecklist) { if (wasFromChecklist) {
@@ -395,7 +436,15 @@ export default {
} }
} }
} catch (error) { } catch (error) {
emit('toast', 'Upload fehlgeschlagen', 'error'); // If upload fails and offline mode is enabled, queue for later
if (offlineEnabled) {
for (let i = 0; i < files.length; i++) {
await photoQueue.queue(selectedWorkorder.value.id, files[i], uploadDocType.value, '');
}
emit('toast', 'Foto wird bei Verbindung hochgeladen', 'info');
} else {
emit('toast', 'Upload fehlgeschlagen', 'error');
}
} finally { } finally {
isUploading.value = false; isUploading.value = false;
if (fileInputRef.value) fileInputRef.value.value = ''; if (fileInputRef.value) fileInputRef.value.value = '';
@@ -418,19 +467,17 @@ export default {
const typeText = tenantConfig.value?.interventionTypes?.find(t => t.value === problemType.value)?.text || problemType.value; const typeText = tenantConfig.value?.interventionTypes?.find(t => t.value === problemType.value)?.text || problemType.value;
try { try {
const response = await fetch('/MobileApp/Workorder/Workorder/requestIntervention', { const data = await workorderService.requestIntervention(
method: 'POST', selectedWorkorder.value.id,
headers: { 'Content-Type': 'application/json' }, typeText,
credentials: 'include', problemComment.value || typeText
body: JSON.stringify({ );
workorderId: selectedWorkorder.value.id,
interventionType: typeText,
journalText: problemComment.value || typeText
})
});
const data = await response.json();
if (data.success) { if (data.success) {
emit('toast', 'Problem gemeldet', 'success'); if (data.queued) {
emit('toast', 'Problem wird bei Verbindung gemeldet', 'info');
} else {
emit('toast', 'Problem gemeldet', 'success');
}
showProblemSheet.value = false; showProblemSheet.value = false;
closeDetail(); closeDetail();
fetchWorkorders(); fetchWorkorders();
@@ -449,21 +496,16 @@ export default {
const submitComplete = async () => { const submitComplete = async () => {
try { try {
const response = await fetch('/MobileApp/Workorder/Workorder/completeWorkorder', { const data = await workorderService.completeWorkorder(selectedWorkorder.value.id);
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
workorderId: selectedWorkorder.value.id
})
});
const data = await response.json();
if (data.success) { if (data.success) {
triggerHaptic('success'); triggerHaptic('success');
emit('toast', 'Auftrag abgeschlossen', 'success'); emit('toast', 'Auftrag abgeschlossen', 'success');
showCompleteSheet.value = false; showCompleteSheet.value = false;
closeDetail(); closeDetail();
fetchWorkorders(); fetchWorkorders();
} else if (data.blocked) {
// Completion blocked when offline
emit('toast', data.message || 'Bitte zuerst synchronisieren', 'warning');
} else { } else {
emit('toast', data.message || 'Fehler', 'error'); emit('toast', data.message || 'Fehler', 'error');
} }

188
public/mobile/shared/db.js Normal file
View File

@@ -0,0 +1,188 @@
/**
* IndexedDB setup using Dexie.js for workorder offline mode
*
* Schema mirrors the backend models:
* - WorkorderModel
* - WorkorderDocumentationModel
* - WorkorderJournalModel
* - WorkorderTenantConfigModel
*/
import Dexie from 'https://cdn.jsdelivr.net/npm/dexie@4/dist/dexie.min.mjs';
// Database instance
export const db = new Dexie('xinon-workorder-offline');
// Schema definition
db.version(1).stores({
// Workorder list and basic data
// Mirrors WorkorderModel from backend
workorders: 'id, companyId, status, [companyId+status], _syncStatus, _lastModified',
// Full workorder details (lazy-loaded)
// Contains customer info, address, etc.
workorderDetails: 'workorderId, lastFetched',
// Documentation metadata with thumbnail references
// Mirrors WorkorderDocumentationModel
documentation: 'id, workorderId, documentType, _syncStatus',
// Documentation thumbnails (blob storage)
thumbnails: 'documentationId, workorderId',
// Journal entries (audit trail)
// Mirrors WorkorderJournalModel
journals: 'id, workorderId, create, _localId',
// Tenant configurations
// Mirrors WorkorderTenantConfigModel
tenantConfigs: 'addressId, lastFetched',
// Sync queue for pending mutations
syncQueue: '++localId, operation, workorderId, status, createdAt',
// Pending file uploads
pendingFiles: '++localId, workorderId, documentType, status',
// Sync metadata (lastSync, etc.)
syncMeta: 'key'
});
// Sync status enum
export const SyncStatus = {
SYNCED: 'synced',
PENDING: 'pending',
CONFLICT: 'conflict',
ERROR: 'error'
};
// Operation types for sync queue
export const OperationType = {
ADD_JOURNAL: 'addJournal',
UPDATE_NOTES: 'updateNotes',
SCHEDULE_APPOINTMENT: 'scheduleAppointment',
REQUEST_INTERVENTION: 'requestIntervention',
UPDATE_CABLE_DATA: 'updateCableData',
UPLOAD_DOCUMENTATION: 'uploadDocumentation',
COMPLETE_WORKORDER: 'completeWorkorder'
};
// Operation priority (lower = higher priority)
export const OperationPriority = {
[OperationType.SCHEDULE_APPOINTMENT]: 1,
[OperationType.UPDATE_NOTES]: 2,
[OperationType.ADD_JOURNAL]: 3,
[OperationType.UPDATE_CABLE_DATA]: 4,
[OperationType.UPLOAD_DOCUMENTATION]: 5,
[OperationType.REQUEST_INTERVENTION]: 6,
[OperationType.COMPLETE_WORKORDER]: 7 // Must be last
};
/**
* Initialize database and return instance
*/
export async function initDatabase() {
try {
await db.open();
console.log('[DB] Database opened successfully');
return db;
} catch (error) {
console.error('[DB] Failed to open database:', error);
throw error;
}
}
/**
* Clear all offline data (for logout or cache clear)
*/
export async function clearAllData() {
try {
await db.transaction('rw',
db.workorders,
db.workorderDetails,
db.documentation,
db.thumbnails,
db.journals,
db.tenantConfigs,
db.syncQueue,
db.pendingFiles,
db.syncMeta,
async () => {
await db.workorders.clear();
await db.workorderDetails.clear();
await db.documentation.clear();
await db.thumbnails.clear();
await db.journals.clear();
await db.tenantConfigs.clear();
await db.syncQueue.clear();
await db.pendingFiles.clear();
await db.syncMeta.clear();
}
);
console.log('[DB] All offline data cleared');
} catch (error) {
console.error('[DB] Failed to clear data:', error);
throw error;
}
}
/**
* Get sync metadata value
*/
export async function getSyncMeta(key) {
const meta = await db.syncMeta.get(key);
return meta?.value ?? null;
}
/**
* Set sync metadata value
*/
export async function setSyncMeta(key, value) {
await db.syncMeta.put({ key, value });
}
/**
* Get pending operations count
*/
export async function getPendingCount() {
const queueCount = await db.syncQueue.where('status').equals('pending').count();
const filesCount = await db.pendingFiles.where('status').equals('pending').count();
return queueCount + filesCount;
}
/**
* Check if database has any cached workorders
*/
export async function hasOfflineData() {
const count = await db.workorders.count();
return count > 0;
}
/**
* Get storage usage estimate
*/
export async function getStorageEstimate() {
if (navigator.storage && navigator.storage.estimate) {
const estimate = await navigator.storage.estimate();
return {
usage: estimate.usage || 0,
quota: estimate.quota || 0,
usagePercent: estimate.quota ? Math.round((estimate.usage / estimate.quota) * 100) : 0
};
}
return { usage: 0, quota: 0, usagePercent: 0 };
}
/**
* Request persistent storage (important for iOS Safari)
*/
export async function requestPersistentStorage() {
if (navigator.storage && navigator.storage.persist) {
const isPersisted = await navigator.storage.persist();
console.log('[DB] Persistent storage:', isPersisted ? 'granted' : 'denied');
return isPersisted;
}
return false;
}
export default db;

View File

@@ -0,0 +1,250 @@
/**
* Offline mode settings management
*
* Stores settings in localStorage following the existing pattern
* used by theme and movement_settings.
*/
const STORAGE_KEY = 'workorder_offline';
// Default settings
const defaultSettings = {
enabled: false,
autoSync: true,
lastSyncTimestamp: null,
deviceId: null // Generated on first enable
};
// In-memory cache of settings
let cachedSettings = null;
/**
* Generate a unique device ID using crypto API
*/
function generateDeviceId() {
if (crypto.randomUUID) {
return crypto.randomUUID();
}
// Fallback for older browsers
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);
});
}
/**
* Load settings from localStorage
*/
export function loadOfflineSettings() {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
cachedSettings = { ...defaultSettings, ...JSON.parse(saved) };
} else {
cachedSettings = { ...defaultSettings };
}
} catch (error) {
console.error('[OfflineSettings] Failed to load settings:', error);
cachedSettings = { ...defaultSettings };
}
return cachedSettings;
}
/**
* Save settings to localStorage
*/
export function saveOfflineSettings(settings) {
try {
cachedSettings = { ...cachedSettings, ...settings };
localStorage.setItem(STORAGE_KEY, JSON.stringify(cachedSettings));
return true;
} catch (error) {
console.error('[OfflineSettings] Failed to save settings:', error);
return false;
}
}
/**
* Get current settings (from cache or load)
*/
export function getOfflineSettings() {
if (!cachedSettings) {
loadOfflineSettings();
}
return { ...cachedSettings };
}
/**
* Check if offline mode is enabled
*/
export function isOfflineModeEnabled() {
const settings = getOfflineSettings();
return settings.enabled === true;
}
/**
* Check if auto-sync is enabled
*/
export function isAutoSyncEnabled() {
const settings = getOfflineSettings();
return settings.autoSync === true;
}
/**
* Enable offline mode
* Generates device ID if not exists
*/
export function enableOfflineMode() {
const settings = getOfflineSettings();
if (!settings.deviceId) {
settings.deviceId = generateDeviceId();
}
settings.enabled = true;
return saveOfflineSettings(settings);
}
/**
* Disable offline mode
* Does NOT clear cached data (user must do that explicitly)
*/
export function disableOfflineMode() {
return saveOfflineSettings({ enabled: false });
}
/**
* Toggle offline mode
*/
export function toggleOfflineMode() {
if (isOfflineModeEnabled()) {
return disableOfflineMode();
} else {
return enableOfflineMode();
}
}
/**
* Set auto-sync preference
*/
export function setAutoSync(enabled) {
return saveOfflineSettings({ autoSync: enabled });
}
/**
* Get device ID (generates if needed)
*/
export function getDeviceId() {
const settings = getOfflineSettings();
if (!settings.deviceId) {
settings.deviceId = generateDeviceId();
saveOfflineSettings(settings);
}
return settings.deviceId;
}
/**
* Update last sync timestamp
*/
export function updateLastSyncTimestamp() {
return saveOfflineSettings({ lastSyncTimestamp: Date.now() });
}
/**
* Get last sync timestamp
*/
export function getLastSyncTimestamp() {
const settings = getOfflineSettings();
return settings.lastSyncTimestamp;
}
/**
* Get last sync as human-readable string
*/
export function getLastSyncText() {
const timestamp = getLastSyncTimestamp();
if (!timestamp) {
return 'Nie synchronisiert';
}
const now = Date.now();
const diff = now - timestamp;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) {
return 'Gerade eben';
} else if (minutes < 60) {
return `Vor ${minutes} Minute${minutes === 1 ? '' : 'n'}`;
} else if (hours < 24) {
return `Vor ${hours} Stunde${hours === 1 ? '' : 'n'}`;
} else {
return `Vor ${days} Tag${days === 1 ? '' : 'en'}`;
}
}
/**
* Get data freshness level based on last sync
* Returns: 'fresh' (<1h), 'stale' (1-24h), 'old' (>24h)
*/
export function getDataFreshness() {
const timestamp = getLastSyncTimestamp();
if (!timestamp) {
return 'unknown';
}
const now = Date.now();
const diff = now - timestamp;
const hours = diff / 3600000;
if (hours < 1) {
return 'fresh'; // Green
} else if (hours < 24) {
return 'stale'; // Yellow
} else {
return 'old'; // Red
}
}
/**
* Clear all offline settings (for logout)
*/
export function clearOfflineSettings() {
try {
localStorage.removeItem(STORAGE_KEY);
cachedSettings = null;
return true;
} catch (error) {
console.error('[OfflineSettings] Failed to clear settings:', error);
return false;
}
}
// Reactive state for Vue components
export const offlineSettingsState = {
get enabled() { return isOfflineModeEnabled(); },
get autoSync() { return isAutoSyncEnabled(); },
get lastSync() { return getLastSyncTimestamp(); },
get lastSyncText() { return getLastSyncText(); },
get freshness() { return getDataFreshness(); },
get deviceId() { return getDeviceId(); }
};
export default {
load: loadOfflineSettings,
save: saveOfflineSettings,
get: getOfflineSettings,
isEnabled: isOfflineModeEnabled,
isAutoSyncEnabled,
enable: enableOfflineMode,
disable: disableOfflineMode,
toggle: toggleOfflineMode,
setAutoSync,
getDeviceId,
updateLastSync: updateLastSyncTimestamp,
getLastSync: getLastSyncTimestamp,
getLastSyncText,
getFreshness: getDataFreshness,
clear: clearOfflineSettings,
state: offlineSettingsState
};

View File

@@ -0,0 +1,437 @@
/**
* Photo Queue - Image compression and upload queue
*
* Handles image capture, compression, storage, and upload queueing for offline mode.
* Converts HEIC (iOS) to JPEG, compresses images, and stores them for later upload.
*/
import { db, OperationType } from './db.js';
import { isOfflineModeEnabled, getDeviceId } from './offlineSettings.js';
// Import image compression from CDN
const imageCompression = await import('https://cdn.jsdelivr.net/npm/browser-image-compression@2/dist/browser-image-compression.mjs').then(m => m.default);
// Compression settings
const COMPRESSION_OPTIONS = {
maxSizeMB: 1, // Max 1MB after compression
maxWidthOrHeight: 1920, // Max dimension
useWebWorker: true,
fileType: 'image/jpeg',
initialQuality: 0.8
};
// Upload status
export const UploadStatus = {
PENDING: 'pending',
COMPRESSING: 'compressing',
UPLOADING: 'uploading',
COMPLETED: 'completed',
FAILED: 'failed'
};
const API_BASE = '/MobileApp/Workorder/Workorder';
/**
* Generate unique local ID
*/
function generateLocalId() {
if (crypto.randomUUID) {
return crypto.randomUUID();
}
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);
});
}
/**
* Check if file is HEIC format (iOS)
*/
function isHeicFile(file) {
const type = file.type?.toLowerCase() || '';
const name = file.name?.toLowerCase() || '';
return type === 'image/heic' ||
type === 'image/heif' ||
name.endsWith('.heic') ||
name.endsWith('.heif');
}
/**
* Convert HEIC to JPEG
*/
async function convertHeicToJpeg(file) {
try {
// Dynamically import heic2any
const heic2any = await import('https://cdn.jsdelivr.net/npm/heic2any@0.0.4/dist/heic2any.min.js').then(m => m.default || window.heic2any);
const result = await heic2any({
blob: file,
toType: 'image/jpeg',
quality: 0.9
});
// heic2any can return array or single blob
const jpegBlob = Array.isArray(result) ? result[0] : result;
// Create new file with .jpg extension
const newName = file.name.replace(/\.(heic|heif)$/i, '.jpg');
return new File([jpegBlob], newName, { type: 'image/jpeg' });
} catch (error) {
console.error('[PhotoQueue] HEIC conversion failed:', error);
throw new Error('HEIC-Konvertierung fehlgeschlagen');
}
}
/**
* Compress image file
*/
async function compressImage(file, onProgress) {
try {
// Handle HEIC files first
let processedFile = file;
if (isHeicFile(file)) {
onProgress?.({ phase: 'converting', message: 'Konvertiere HEIC...' });
processedFile = await convertHeicToJpeg(file);
}
// Check if compression needed
const fileSizeMB = processedFile.size / (1024 * 1024);
if (fileSizeMB <= COMPRESSION_OPTIONS.maxSizeMB && processedFile.type === 'image/jpeg') {
// Already small enough and correct format
return processedFile;
}
onProgress?.({ phase: 'compressing', message: 'Komprimiere Bild...' });
const compressedFile = await imageCompression(processedFile, {
...COMPRESSION_OPTIONS,
onProgress: (progress) => {
onProgress?.({ phase: 'compressing', progress });
}
});
console.log(`[PhotoQueue] Compressed ${file.name}: ${fileSizeMB.toFixed(2)}MB -> ${(compressedFile.size / (1024 * 1024)).toFixed(2)}MB`);
return compressedFile;
} catch (error) {
console.error('[PhotoQueue] Compression failed:', error);
throw new Error('Bildkomprimierung fehlgeschlagen');
}
}
/**
* Queue photo for upload
*/
export async function queuePhoto(workorderId, file, documentType, description = '') {
const localId = generateLocalId();
// Create initial entry
const entry = {
localId,
workorderId,
documentType,
description,
originalFileName: file.name,
originalSize: file.size,
mimeType: file.type,
status: UploadStatus.COMPRESSING,
progress: 0,
createdAt: Date.now(),
deviceId: getDeviceId(),
error: null
};
// Store entry
await db.pendingFiles.add(entry);
try {
// Compress image
const compressedFile = await compressImage(file, (progressInfo) => {
// Update status
db.pendingFiles.update(localId, { progress: progressInfo.progress || 0 });
});
// Convert to blob and store
const blob = new Blob([compressedFile], { type: 'image/jpeg' });
await db.pendingFiles.update(localId, {
status: UploadStatus.PENDING,
blob,
compressedSize: blob.size,
fileName: compressedFile.name || `photo_${localId}.jpg`,
progress: 100
});
console.log(`[PhotoQueue] Queued photo ${localId} for workorder ${workorderId}`);
return {
success: true,
localId,
message: 'Foto wird bei nächster Verbindung hochgeladen'
};
} catch (error) {
await db.pendingFiles.update(localId, {
status: UploadStatus.FAILED,
error: error.message
});
return {
success: false,
localId,
error: error.message
};
}
}
/**
* Get pending photos
*/
export async function getPendingPhotos() {
return db.pendingFiles
.where('status')
.equals(UploadStatus.PENDING)
.toArray();
}
/**
* Get pending photos for workorder
*/
export async function getWorkorderPendingPhotos(workorderId) {
return db.pendingFiles
.where('workorderId')
.equals(workorderId)
.toArray();
}
/**
* Get pending photos count
*/
export async function getPendingCount() {
return db.pendingFiles
.where('status')
.anyOf([UploadStatus.PENDING, UploadStatus.UPLOADING])
.count();
}
/**
* Upload single photo
*/
export async function uploadPhoto(localId, onProgress) {
const entry = await db.pendingFiles.get(localId);
if (!entry || entry.status !== UploadStatus.PENDING) {
return { success: false, error: 'Foto nicht gefunden oder bereits verarbeitet' };
}
await db.pendingFiles.update(localId, { status: UploadStatus.UPLOADING });
try {
const formData = new FormData();
formData.append('workorderId', entry.workorderId);
formData.append('documentType', entry.documentType);
formData.append('description', entry.description || '');
formData.append('files[]', entry.blob, entry.fileName);
const response = await fetch(`${API_BASE}/uploadDocumentation`, {
method: 'POST',
credentials: 'include',
body: formData
});
const result = await response.json();
if (result.success) {
// Mark as completed but keep for a while (cleanup later)
await db.pendingFiles.update(localId, {
status: UploadStatus.COMPLETED,
completedAt: Date.now(),
serverResponse: result
});
console.log(`[PhotoQueue] Uploaded photo ${localId}`);
return { success: true, localId, serverResponse: result };
} else {
await db.pendingFiles.update(localId, {
status: UploadStatus.FAILED,
error: result.error || 'Upload fehlgeschlagen'
});
return { success: false, localId, error: result.error };
}
} catch (error) {
console.error(`[PhotoQueue] Upload failed for ${localId}:`, error);
await db.pendingFiles.update(localId, {
status: UploadStatus.PENDING, // Back to pending for retry
error: error.message
});
return { success: false, localId, error: error.message, retriable: true };
}
}
/**
* Upload all pending photos
*/
export async function uploadAllPending(onProgress) {
const pending = await getPendingPhotos();
const total = pending.length;
let uploaded = 0;
let failed = 0;
for (const photo of pending) {
onProgress?.({
current: uploaded + 1,
total,
fileName: photo.fileName
});
const result = await uploadPhoto(photo.localId);
if (result.success) {
uploaded++;
} else {
failed++;
}
}
return { uploaded, failed, total };
}
/**
* Delete pending photo
*/
export async function deletePendingPhoto(localId) {
await db.pendingFiles.delete(localId);
console.log(`[PhotoQueue] Deleted pending photo ${localId}`);
}
/**
* Delete all pending photos for workorder
*/
export async function deleteWorkorderPhotos(workorderId) {
const count = await db.pendingFiles
.where('workorderId')
.equals(workorderId)
.delete();
console.log(`[PhotoQueue] Deleted ${count} pending photos for workorder ${workorderId}`);
return count;
}
/**
* Retry failed photo
*/
export async function retryPhoto(localId) {
await db.pendingFiles.update(localId, {
status: UploadStatus.PENDING,
error: null
});
}
/**
* Cleanup completed photos older than specified time
*/
export async function cleanupCompleted(maxAgeMs = 24 * 60 * 60 * 1000) {
const cutoff = Date.now() - maxAgeMs;
const count = await db.pendingFiles
.where('status')
.equals(UploadStatus.COMPLETED)
.filter(p => p.completedAt && p.completedAt < cutoff)
.delete();
console.log(`[PhotoQueue] Cleaned up ${count} completed photos`);
return count;
}
/**
* Get thumbnail blob for pending photo (for preview)
*/
export async function getPhotoThumbnail(localId) {
const entry = await db.pendingFiles.get(localId);
if (!entry || !entry.blob) return null;
// Create small thumbnail using canvas
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const maxSize = 200;
let width = img.width;
let height = img.height;
if (width > height) {
if (width > maxSize) {
height = Math.round((height * maxSize) / width);
width = maxSize;
}
} else {
if (height > maxSize) {
width = Math.round((width * maxSize) / height);
height = maxSize;
}
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob(resolve, 'image/jpeg', 0.7);
};
img.onerror = () => resolve(null);
img.src = URL.createObjectURL(entry.blob);
});
}
/**
* Get photo URL for display (creates object URL)
*/
export async function getPhotoUrl(localId) {
const entry = await db.pendingFiles.get(localId);
if (!entry || !entry.blob) return null;
return URL.createObjectURL(entry.blob);
}
/**
* Get storage size used by pending photos
*/
export async function getStorageUsed() {
const photos = await db.pendingFiles.toArray();
let totalSize = 0;
for (const photo of photos) {
if (photo.blob) {
totalSize += photo.blob.size;
}
}
return totalSize;
}
/**
* Check if there's enough storage for new photo
*/
export async function hasStorageSpace(estimatedSize = 2 * 1024 * 1024) {
if (navigator.storage && navigator.storage.estimate) {
const estimate = await navigator.storage.estimate();
const available = (estimate.quota || 0) - (estimate.usage || 0);
const buffer = 10 * 1024 * 1024; // 10MB buffer
return available > (estimatedSize + buffer);
}
return true; // Assume we have space if we can't check
}
export default {
UploadStatus,
queue: queuePhoto,
getPending: getPendingPhotos,
getWorkorderPhotos: getWorkorderPendingPhotos,
getPendingCount,
upload: uploadPhoto,
uploadAll: uploadAllPending,
delete: deletePendingPhoto,
deleteForWorkorder: deleteWorkorderPhotos,
retry: retryPhoto,
cleanup: cleanupCompleted,
getThumbnail: getPhotoThumbnail,
getUrl: getPhotoUrl,
getStorageUsed,
hasSpace: hasStorageSpace
};

View File

@@ -0,0 +1,414 @@
/**
* Sync Manager - iOS-compatible sync orchestration
*
* Background Sync API is NOT supported on iOS Safari.
* This manager uses visibility-based and online-event sync as primary strategy.
*/
import { db, getPendingCount as getDbPendingCount } from './db.js';
import { isOfflineModeEnabled, isAutoSyncEnabled, updateLastSyncTimestamp } from './offlineSettings.js';
import syncQueue from './syncQueue.js';
import photoQueue from './photoQueue.js';
import workorderService from './workorderOfflineService.js';
const API_BASE = '/MobileApp/Workorder/Workorder';
// Sync state
const syncState = {
isSyncing: false,
lastSyncAttempt: null,
lastSyncSuccess: null,
syncError: null,
listeners: new Set()
};
// Minimum time between auto-syncs (prevent rapid fire)
const MIN_SYNC_INTERVAL = 30000; // 30 seconds
/**
* Notify listeners of state change
*/
function notifyListeners(event, data) {
for (const listener of syncState.listeners) {
try {
listener(event, data);
} catch (error) {
console.error('[SyncManager] Listener error:', error);
}
}
}
/**
* Subscribe to sync events
*/
export function subscribe(listener) {
syncState.listeners.add(listener);
return () => syncState.listeners.delete(listener);
}
/**
* Get current sync state
*/
export function getSyncState() {
return { ...syncState };
}
/**
* Check if sync is currently running
*/
export function isSyncing() {
return syncState.isSyncing;
}
/**
* Initialize sync manager
* Sets up event listeners for iOS-compatible sync
*/
export function initSyncManager() {
// Primary iOS strategy: sync when app becomes visible
document.addEventListener('visibilitychange', handleVisibilityChange);
// Sync when network reconnects
window.addEventListener('online', handleOnline);
// Also handle page show (for bfcache restoration)
window.addEventListener('pageshow', handlePageShow);
// Initial sync check on load
window.addEventListener('load', () => {
if (navigator.onLine && isOfflineModeEnabled()) {
// Delay initial sync slightly to let app stabilize
setTimeout(() => maybeTriggerSync('load'), 2000);
}
});
// Try to register for Background Sync (Chrome/Edge only)
registerBackgroundSync().catch(() => {
console.log('[SyncManager] Background Sync not available, using fallback');
});
console.log('[SyncManager] Initialized');
}
/**
* Handle visibility change
*/
function handleVisibilityChange() {
if (document.visibilityState === 'visible' && navigator.onLine) {
maybeTriggerSync('visibility');
}
}
/**
* Handle online event
*/
function handleOnline() {
maybeTriggerSync('online');
}
/**
* Handle pageshow event
*/
function handlePageShow(event) {
if (event.persisted && navigator.onLine) {
// Page was restored from bfcache
maybeTriggerSync('pageshow');
}
}
/**
* Maybe trigger sync (with throttling)
*/
async function maybeTriggerSync(trigger) {
if (!isOfflineModeEnabled() || !isAutoSyncEnabled()) {
return;
}
if (syncState.isSyncing) {
console.log('[SyncManager] Sync already in progress, skipping');
return;
}
// Throttle syncs
const now = Date.now();
if (syncState.lastSyncAttempt && (now - syncState.lastSyncAttempt) < MIN_SYNC_INTERVAL) {
console.log('[SyncManager] Sync throttled, too recent');
return;
}
// Check if there's anything to sync
const pendingOps = await syncQueue.getPendingCount();
const pendingPhotos = await photoQueue.getPendingCount();
if (pendingOps === 0 && pendingPhotos === 0) {
console.log('[SyncManager] No pending changes to sync');
return;
}
console.log(`[SyncManager] Auto-sync triggered by ${trigger} (${pendingOps} ops, ${pendingPhotos} photos)`);
await performSync();
}
/**
* Register for Background Sync API (Chrome/Edge only)
*/
async function registerBackgroundSync() {
if ('serviceWorker' in navigator && 'sync' in window.SyncManager?.prototype) {
const registration = await navigator.serviceWorker.ready;
if ('sync' in registration) {
await registration.sync.register('workorder-sync');
console.log('[SyncManager] Background Sync registered');
}
}
}
/**
* Perform sync - main sync logic
*/
export async function performSync(options = {}) {
if (syncState.isSyncing && !options.force) {
return { success: false, error: 'Sync already in progress' };
}
if (!navigator.onLine) {
return { success: false, error: 'Offline' };
}
syncState.isSyncing = true;
syncState.lastSyncAttempt = Date.now();
syncState.syncError = null;
notifyListeners('sync-start', {});
const results = {
operations: { success: 0, failed: 0, total: 0 },
photos: { success: 0, failed: 0, total: 0 },
conflicts: [],
reassigned: []
};
try {
// 1. Process pending operations
const pendingOps = await syncQueue.getPending();
results.operations.total = pendingOps.length;
for (const op of pendingOps) {
notifyListeners('sync-progress', {
phase: 'operations',
current: results.operations.success + results.operations.failed + 1,
total: results.operations.total
});
const opResult = await processOperation(op);
if (opResult.success) {
results.operations.success++;
} else {
results.operations.failed++;
if (opResult.conflict) {
results.conflicts.push({
operation: op,
serverData: opResult.serverData
});
}
}
}
// 2. Upload pending photos
const pendingPhotos = await photoQueue.getPending();
results.photos.total = pendingPhotos.length;
for (const photo of pendingPhotos) {
notifyListeners('sync-progress', {
phase: 'photos',
current: results.photos.success + results.photos.failed + 1,
total: results.photos.total,
fileName: photo.fileName
});
const photoResult = await photoQueue.upload(photo.localId);
if (photoResult.success) {
results.photos.success++;
} else {
results.photos.failed++;
}
}
// 3. Check for reassigned workorders (if enabled)
if (options.checkReassigned !== false) {
const serverWorkorderIds = await fetchServerWorkorderIds();
if (serverWorkorderIds) {
results.reassigned = await workorderService.checkReassignedWorkorders(serverWorkorderIds);
}
}
// 4. Cleanup old completed items
await syncQueue.cleanup();
await photoQueue.cleanup();
// Update last sync time
updateLastSyncTimestamp();
syncState.lastSyncSuccess = Date.now();
console.log('[SyncManager] Sync completed:', results);
notifyListeners('sync-complete', results);
return { success: true, results };
} catch (error) {
console.error('[SyncManager] Sync error:', error);
syncState.syncError = error.message;
notifyListeners('sync-error', { error: error.message });
return { success: false, error: error.message };
} finally {
syncState.isSyncing = false;
}
}
/**
* Process single operation from queue
*/
async function processOperation(op) {
await syncQueue.markProcessing(op.localId);
try {
const result = await executeOperation(op);
if (result.success) {
await syncQueue.markCompleted(op.localId, result);
return { success: true };
}
if (result.conflict) {
// Conflict detected - don't retry automatically
await syncQueue.markFailed(op.localId, 'Konflikt erkannt', false);
return { success: false, conflict: true, serverData: result.serverData };
}
// Check if retriable
const retriable = syncQueue.isRetriableError(result._status, result.error);
await syncQueue.markFailed(op.localId, result.error, retriable);
return { success: false, error: result.error };
} catch (error) {
await syncQueue.markFailed(op.localId, error.message, true);
return { success: false, error: error.message };
}
}
/**
* Execute operation via API
*/
async function executeOperation(op) {
const endpoint = getOperationEndpoint(op.operation);
if (!endpoint) {
return { success: false, error: `Unknown operation: ${op.operation}` };
}
const response = await fetch(`${API_BASE}/${endpoint}`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': op.operationId,
'X-Client-Timestamp': String(op.clientTimestamp),
'X-Device-Id': op.deviceId
},
body: JSON.stringify(op.payload)
});
const data = await response.json();
return { ...data, _status: response.status };
}
/**
* Map operation type to API endpoint
*/
function getOperationEndpoint(operation) {
const endpoints = {
'addJournal': 'addJournal',
'updateNotes': 'updateAdditionalInfo',
'scheduleAppointment': 'scheduleAppointment',
'requestIntervention': 'requestIntervention',
'updateCableData': 'updateWorkorderData',
'completeWorkorder': 'completeWorkorder'
};
return endpoints[operation];
}
/**
* Fetch server workorder IDs for reassignment check
*/
async function fetchServerWorkorderIds() {
try {
const response = await fetch(`${API_BASE}/getWorkorderIds`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
return data.success ? data.ids : null;
}
} catch (error) {
console.warn('[SyncManager] Failed to fetch workorder IDs:', error);
}
return null;
}
/**
* Manual sync trigger
*/
export async function triggerManualSync() {
console.log('[SyncManager] Manual sync triggered');
return performSync({ force: true });
}
/**
* Get pending changes count
*/
export async function getPendingCount() {
const ops = await syncQueue.getPendingCount();
const photos = await photoQueue.getPendingCount();
return ops + photos;
}
/**
* Get detailed pending summary
*/
export async function getPendingSummary() {
const ops = await syncQueue.getPendingCount();
const photos = await photoQueue.getPendingCount();
const failedOps = await syncQueue.getFailed();
return {
operations: ops,
photos: photos,
total: ops + photos,
failed: failedOps.length,
hasFailures: failedOps.length > 0
};
}
/**
* Cleanup and stop sync manager
*/
export function destroySyncManager() {
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('online', handleOnline);
window.removeEventListener('pageshow', handlePageShow);
syncState.listeners.clear();
console.log('[SyncManager] Destroyed');
}
// Export for service worker communication
export const SyncManager = {
init: initSyncManager,
destroy: destroySyncManager,
sync: performSync,
triggerSync: triggerManualSync,
subscribe,
getState: getSyncState,
isSyncing,
getPendingCount,
getPendingSummary
};
export default SyncManager;

View File

@@ -0,0 +1,349 @@
/**
* Sync Queue - Operation queue for pending mutations
*
* Handles queueing operations when offline and processing them when online.
* Implements idempotency keys, retry logic with exponential backoff, and priority ordering.
*/
import { db, OperationType, OperationPriority, SyncStatus } from './db.js';
import { getDeviceId } from './offlineSettings.js';
// Queue entry status
export const QueueStatus = {
PENDING: 'pending',
PROCESSING: 'processing',
COMPLETED: 'completed',
FAILED: 'failed'
};
// Retry configuration
const RETRY_CONFIG = {
maxRetries: 5,
baseDelay: 1000, // 1 second
maxDelay: 30000, // 30 seconds
factor: 2,
jitter: 0.3 // +/- 30%
};
/**
* Generate a unique operation ID (idempotency key)
*/
function generateOperationId() {
if (crypto.randomUUID) {
return crypto.randomUUID();
}
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);
});
}
/**
* Calculate delay with exponential backoff and jitter
*/
function calculateDelay(retryCount) {
const delay = Math.min(
RETRY_CONFIG.baseDelay * Math.pow(RETRY_CONFIG.factor, retryCount),
RETRY_CONFIG.maxDelay
);
// Add jitter
const jitter = delay * RETRY_CONFIG.jitter * (Math.random() * 2 - 1);
return Math.round(delay + jitter);
}
/**
* Add operation to sync queue
*/
export async function queueOperation(operation, workorderId, payload, options = {}) {
const entry = {
operationId: options.operationId || generateOperationId(),
operation,
workorderId,
payload,
status: QueueStatus.PENDING,
priority: OperationPriority[operation] || 99,
retryCount: 0,
createdAt: Date.now(),
clientTimestamp: Date.now(),
deviceId: getDeviceId(),
lastAttempt: null,
nextAttempt: null,
error: null
};
const localId = await db.syncQueue.add(entry);
console.log(`[SyncQueue] Queued operation: ${operation} for workorder ${workorderId}, localId: ${localId}`);
return { localId, operationId: entry.operationId };
}
/**
* Get all pending operations sorted by priority
*/
export async function getPendingOperations() {
const now = Date.now();
const pending = await db.syncQueue
.where('status')
.equals(QueueStatus.PENDING)
.toArray();
// Filter out operations that are scheduled for later (retry delay)
const ready = pending.filter(op => !op.nextAttempt || op.nextAttempt <= now);
// Sort by priority (lower = higher priority), then by createdAt
return ready.sort((a, b) => {
if (a.priority !== b.priority) {
return a.priority - b.priority;
}
return a.createdAt - b.createdAt;
});
}
/**
* Get operations for a specific workorder
*/
export async function getWorkorderOperations(workorderId) {
return db.syncQueue
.where('workorderId')
.equals(workorderId)
.toArray();
}
/**
* Get pending count
*/
export async function getPendingCount() {
return db.syncQueue
.where('status')
.equals(QueueStatus.PENDING)
.count();
}
/**
* Get failed operations
*/
export async function getFailedOperations() {
return db.syncQueue
.where('status')
.equals(QueueStatus.FAILED)
.toArray();
}
/**
* Mark operation as processing
*/
export async function markProcessing(localId) {
await db.syncQueue.update(localId, {
status: QueueStatus.PROCESSING,
lastAttempt: Date.now()
});
}
/**
* Mark operation as completed
*/
export async function markCompleted(localId, serverResponse = null) {
await db.syncQueue.update(localId, {
status: QueueStatus.COMPLETED,
serverResponse,
completedAt: Date.now()
});
console.log(`[SyncQueue] Operation ${localId} completed`);
}
/**
* Mark operation as failed with retry scheduling
*/
export async function markFailed(localId, error, isRetriable = true) {
const operation = await db.syncQueue.get(localId);
if (!operation) return;
const newRetryCount = operation.retryCount + 1;
if (isRetriable && newRetryCount < RETRY_CONFIG.maxRetries) {
// Schedule retry
const delay = calculateDelay(newRetryCount);
await db.syncQueue.update(localId, {
status: QueueStatus.PENDING, // Back to pending for retry
retryCount: newRetryCount,
lastAttempt: Date.now(),
nextAttempt: Date.now() + delay,
error: error?.message || String(error)
});
console.log(`[SyncQueue] Operation ${localId} failed, retry ${newRetryCount}/${RETRY_CONFIG.maxRetries} in ${delay}ms`);
} else {
// Max retries reached or non-retriable error
await db.syncQueue.update(localId, {
status: QueueStatus.FAILED,
retryCount: newRetryCount,
lastAttempt: Date.now(),
error: error?.message || String(error)
});
console.log(`[SyncQueue] Operation ${localId} permanently failed after ${newRetryCount} attempts`);
}
}
/**
* Check if error is retriable
* Retriable: 408, 429, 500, 502, 503, 504, network errors
* Non-retriable: 400, 401, 403, 404, 409, 422
*/
export function isRetriableError(status, error) {
// Network errors are retriable
if (!status && error) {
return true;
}
// Retriable status codes
const retriableCodes = [408, 429, 500, 502, 503, 504];
return retriableCodes.includes(status);
}
/**
* Remove completed operations older than specified time
*/
export async function cleanupCompleted(maxAgeMs = 24 * 60 * 60 * 1000) {
const cutoff = Date.now() - maxAgeMs;
const count = await db.syncQueue
.where('status')
.equals(QueueStatus.COMPLETED)
.filter(op => op.completedAt && op.completedAt < cutoff)
.delete();
console.log(`[SyncQueue] Cleaned up ${count} completed operations`);
return count;
}
/**
* Retry a failed operation
*/
export async function retryOperation(localId) {
await db.syncQueue.update(localId, {
status: QueueStatus.PENDING,
nextAttempt: null,
error: null
});
console.log(`[SyncQueue] Operation ${localId} marked for retry`);
}
/**
* Retry all failed operations
*/
export async function retryAllFailed() {
const failed = await getFailedOperations();
for (const op of failed) {
await retryOperation(op.localId);
}
return failed.length;
}
/**
* Delete an operation from queue
*/
export async function deleteOperation(localId) {
await db.syncQueue.delete(localId);
console.log(`[SyncQueue] Operation ${localId} deleted`);
}
/**
* Delete all operations for a workorder (e.g., when reassigned)
*/
export async function deleteWorkorderOperations(workorderId) {
const count = await db.syncQueue
.where('workorderId')
.equals(workorderId)
.delete();
console.log(`[SyncQueue] Deleted ${count} operations for workorder ${workorderId}`);
return count;
}
/**
* Get queue statistics
*/
export async function getQueueStats() {
const all = await db.syncQueue.toArray();
const stats = {
total: all.length,
pending: 0,
processing: 0,
completed: 0,
failed: 0,
byOperation: {}
};
for (const op of all) {
stats[op.status]++;
stats.byOperation[op.operation] = (stats.byOperation[op.operation] || 0) + 1;
}
return stats;
}
/**
* Check if there are pending changes for a workorder
*/
export async function hasPendingChanges(workorderId) {
const count = await db.syncQueue
.where('workorderId')
.equals(workorderId)
.filter(op => op.status === QueueStatus.PENDING || op.status === QueueStatus.PROCESSING)
.count();
return count > 0;
}
/**
* Get the optimistic state for a workorder (apply pending operations)
*/
export async function getOptimisticState(workorderId, baseState) {
const operations = await getWorkorderOperations(workorderId);
const pendingOps = operations.filter(
op => op.status === QueueStatus.PENDING || op.status === QueueStatus.PROCESSING
);
let state = { ...baseState };
for (const op of pendingOps) {
switch (op.operation) {
case OperationType.UPDATE_NOTES:
state.additionalInfo = op.payload.additionalInfo;
break;
case OperationType.SCHEDULE_APPOINTMENT:
state.appointmentDate = op.payload.appointmentDate;
state.status = 'scheduled';
break;
case OperationType.UPDATE_CABLE_DATA:
state.cableLength = op.payload.cableLength;
state.cableType = op.payload.cableType;
break;
case OperationType.REQUEST_INTERVENTION:
state.status = 'intervention_required';
break;
case OperationType.COMPLETE_WORKORDER:
state.status = 'documented';
break;
}
}
return state;
}
export default {
QueueStatus,
queue: queueOperation,
getPending: getPendingOperations,
getWorkorderOps: getWorkorderOperations,
getPendingCount,
getFailed: getFailedOperations,
markProcessing,
markCompleted,
markFailed,
isRetriableError,
cleanup: cleanupCompleted,
retry: retryOperation,
retryAll: retryAllFailed,
delete: deleteOperation,
deleteForWorkorder: deleteWorkorderOperations,
getStats: getQueueStats,
hasPending: hasPendingChanges,
getOptimistic: getOptimisticState
};

View File

@@ -0,0 +1,710 @@
/**
* 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
};

View File

@@ -1,84 +1,303 @@
/** /**
* MobileApp Service Worker * MobileApp Service Worker
* Provides basic caching for the PWA shell and assets. * Provides caching for PWA shell, assets, and offline workorder support.
*/ */
const CACHE_NAME = 'xinon-mobile-v1'; const CACHE_NAME = 'xinon-mobile-v2';
const ASSETS_TO_CACHE = [ const ASSETS_TO_CACHE = [
'/MobileApp', '/MobileApp',
'/mobile/app.js', '/mobile/app.js',
'/mobile/shared/auth.js', '/mobile/shared/auth.js',
'/mobile/shared/api.js',
'/mobile/shared/base.css', '/mobile/shared/base.css',
'/mobile/shared/db.js',
'/mobile/shared/offlineSettings.js',
'/mobile/shared/syncQueue.js',
'/mobile/shared/workorderOfflineService.js',
'/mobile/shared/photoQueue.js',
'/mobile/shared/syncManager.js',
'/mobile/components/LoginScreen.js', '/mobile/components/LoginScreen.js',
'/mobile/components/MainMenu.js', '/mobile/components/MainMenu.js',
'/mobile/components/OfflineIndicator.js',
'/mobile/components/SyncStatus.js',
'/mobile/modules/lager/LagerModule.js', '/mobile/modules/lager/LagerModule.js',
'/mobile/modules/lager/inventur/StocktakeList.js', '/mobile/modules/lager/inventur/StocktakeList.js',
'/mobile/modules/lager/inventur/Scanner.js', '/mobile/modules/lager/inventur/Scanner.js',
'/mobile/modules/workorder/WorkorderModule.js',
'/assets/images/xinon-full-transparent.png', '/assets/images/xinon-full-transparent.png',
'/assets/images/xinon-full-transparent-white.png', '/assets/images/xinon-full-transparent-white.png',
'/assets/images/xinon-sm.png' '/assets/images/xinon-sm.png'
]; ];
// Workorder API endpoints that can be served from cache
const WORKORDER_CACHEABLE_ENDPOINTS = [
'/MobileApp/Workorder/Workorder/get',
'/MobileApp/Workorder/Workorder/getWorkorder',
'/MobileApp/Workorder/Workorder/getWorkorderDetail',
'/MobileApp/Workorder/Workorder/getDocumentation',
'/MobileApp/Workorder/Workorder/getChecklist',
'/MobileApp/Workorder/Workorder/getTenantConfig'
];
// Install: cache assets // Install: cache assets
self.addEventListener('install', (event) => { self.addEventListener('install', (event) => {
console.log('[SW] Installing...');
event.waitUntil( event.waitUntil(
caches.open(CACHE_NAME) caches.open(CACHE_NAME)
.then(cache => cache.addAll(ASSETS_TO_CACHE)) .then(cache => cache.addAll(ASSETS_TO_CACHE))
.then(() => self.skipWaiting()) .then(() => {
console.log('[SW] Assets cached');
return self.skipWaiting();
})
.catch(err => {
console.error('[SW] Cache failed:', err);
})
); );
}); });
// Activate: clean old caches // Activate: clean old caches
self.addEventListener('activate', (event) => { self.addEventListener('activate', (event) => {
console.log('[SW] Activating...');
event.waitUntil( event.waitUntil(
caches.keys().then(cacheNames => { caches.keys().then(cacheNames => {
return Promise.all( return Promise.all(
cacheNames cacheNames
.filter(name => name !== CACHE_NAME) .filter(name => name.startsWith('xinon-mobile-') && name !== CACHE_NAME)
.map(name => caches.delete(name)) .map(name => {
console.log('[SW] Deleting old cache:', name);
return caches.delete(name);
})
); );
}).then(() => self.clients.claim()) }).then(() => self.clients.claim())
); );
}); });
// Fetch: network-first for API, cache-first for assets // Fetch: handle requests with offline support
self.addEventListener('fetch', (event) => { self.addEventListener('fetch', (event) => {
// Only handle GET requests
if (event.request.method !== 'GET') return;
const url = new URL(event.request.url); const url = new URL(event.request.url);
// API calls: network only // Only handle same-origin requests
if (url.origin !== location.origin) return;
// Handle workorder API requests specially
if (url.pathname.startsWith('/MobileApp/Workorder/')) {
event.respondWith(handleWorkorderRequest(event.request, url));
return;
}
// Other API calls: network only (no caching)
if (url.pathname.startsWith('/MobileApp/') && if (url.pathname.startsWith('/MobileApp/') &&
url.pathname !== '/MobileApp' && url.pathname !== '/MobileApp' &&
url.pathname !== '/MobileApp/') { url.pathname !== '/MobileApp/') {
return; return;
} }
// Everything else: cache-first, falling back to network // Static assets: cache-first with background update
event.respondWith( if (event.request.method === 'GET') {
caches.match(event.request).then(cached => { event.respondWith(handleAssetRequest(event.request, url));
if (cached) { }
// Return cached, but update in background });
fetch(event.request).then(response => {
if (response.ok) { /**
caches.open(CACHE_NAME).then(cache => { * Handle workorder API requests with offline fallback
cache.put(event.request, response); */
}); async function handleWorkorderRequest(request, url) {
} const isGet = request.method === 'GET';
}).catch(() => {}); const isPost = request.method === 'POST';
return cached; const endpoint = url.pathname;
}
// Check if offline mode is enabled via message or localStorage check
return fetch(event.request).then(response => { const offlineEnabled = await isOfflineModeEnabled();
if (response.ok && url.origin === location.origin) {
const clone = response.clone(); // For GET requests to cacheable endpoints
caches.open(CACHE_NAME).then(cache => { if (isGet && WORKORDER_CACHEABLE_ENDPOINTS.some(ep => endpoint.startsWith(ep))) {
cache.put(event.request, clone); try {
}); // Try network first
} const response = await fetch(request);
return response; if (response.ok && offlineEnabled) {
}); // Cache successful response for offline use
}) const cache = await caches.open(CACHE_NAME + '-api');
); cache.put(request, response.clone());
}
return response;
} catch (error) {
// Network failed, try cache
if (offlineEnabled) {
const cached = await caches.match(request);
if (cached) {
console.log('[SW] Serving from cache:', endpoint);
return cached;
}
}
// Return offline error response
return new Response(JSON.stringify({
success: false,
error: 'Offline - keine gecachten Daten',
offline: true
}), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
}
}
// For POST requests (mutations) - let them through, main thread handles queueing
if (isPost) {
try {
return await fetch(request);
} catch (error) {
// Network error - return error response
// The main thread WorkorderOfflineService handles queueing
return new Response(JSON.stringify({
success: false,
error: 'Netzwerkfehler',
networkError: true
}), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
}
}
// Default: try network
return fetch(request);
}
/**
* Handle static asset requests with cache-first strategy
*/
async function handleAssetRequest(request, url) {
const cached = await caches.match(request);
if (cached) {
// Return cached, update in background
fetch(request).then(response => {
if (response.ok) {
caches.open(CACHE_NAME).then(cache => {
cache.put(request, response);
});
}
}).catch(() => {});
return cached;
}
// Not cached, fetch from network
try {
const response = await fetch(request);
if (response.ok && url.origin === location.origin) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
} catch (error) {
// Return offline page or error
return new Response('Offline', { status: 503 });
}
}
/**
* Check if offline mode is enabled
* This is a simplified check - the main thread handles the full logic
*/
async function isOfflineModeEnabled() {
// Try to get from clients
const clients = await self.clients.matchAll();
for (const client of clients) {
// Ask client for offline status
// For now, assume enabled if we have cached API data
const apiCache = await caches.open(CACHE_NAME + '-api');
const keys = await apiCache.keys();
return keys.length > 0;
}
return false;
}
// Background Sync event (Chrome/Edge only)
self.addEventListener('sync', (event) => {
console.log('[SW] Background sync triggered:', event.tag);
if (event.tag === 'workorder-sync') {
event.waitUntil(handleBackgroundSync());
}
});
/**
* Handle background sync
* Notify main thread to process sync queue
*/
async function handleBackgroundSync() {
const clients = await self.clients.matchAll({ type: 'window' });
for (const client of clients) {
client.postMessage({
type: 'BACKGROUND_SYNC',
tag: 'workorder-sync'
});
}
}
// Message handler for communication with main thread
self.addEventListener('message', (event) => {
const { type, data } = event.data || {};
switch (type) {
case 'SKIP_WAITING':
self.skipWaiting();
break;
case 'CLEAR_API_CACHE':
caches.delete(CACHE_NAME + '-api').then(() => {
console.log('[SW] API cache cleared');
event.ports[0]?.postMessage({ success: true });
});
break;
case 'CACHE_WORKORDER_DATA':
// Cache workorder data from main thread
if (data && data.url && data.response) {
caches.open(CACHE_NAME + '-api').then(cache => {
const response = new Response(JSON.stringify(data.response), {
headers: { 'Content-Type': 'application/json' }
});
cache.put(data.url, response);
console.log('[SW] Cached workorder data:', data.url);
});
}
break;
case 'GET_CACHE_STATUS':
getCacheStatus().then(status => {
event.ports[0]?.postMessage(status);
});
break;
default:
console.log('[SW] Unknown message type:', type);
}
});
/**
* Get cache status for debugging
*/
async function getCacheStatus() {
const assetCache = await caches.open(CACHE_NAME);
const apiCache = await caches.open(CACHE_NAME + '-api');
const assetKeys = await assetCache.keys();
const apiKeys = await apiCache.keys();
return {
cacheName: CACHE_NAME,
assetsCached: assetKeys.length,
apiCached: apiKeys.length,
apiEndpoints: apiKeys.map(k => new URL(k.url).pathname)
};
}
// Periodic sync (if supported - experimental)
self.addEventListener('periodicsync', (event) => {
if (event.tag === 'workorder-periodic-sync') {
console.log('[SW] Periodic sync triggered');
event.waitUntil(handleBackgroundSync());
}
}); });