added offline mode for xinon app
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,11 @@ import MainMenu from '/mobile/components/MainMenu.js';
|
||||
import LagerModule from '/mobile/modules/lager/LagerModule.js';
|
||||
import ShippingNoteModule from '/mobile/modules/lager/shippingnote/ShippingNoteModule.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;
|
||||
|
||||
@@ -33,7 +38,9 @@ const App = {
|
||||
MainMenu,
|
||||
LagerModule,
|
||||
ShippingNoteModule,
|
||||
WorkorderModule
|
||||
WorkorderModule,
|
||||
OfflineIndicator,
|
||||
SyncStatus
|
||||
},
|
||||
|
||||
setup() {
|
||||
@@ -55,6 +62,20 @@ const App = {
|
||||
const workorderDetailOpen = ref(false);
|
||||
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 isDark = localStorage.theme === 'dark' ||
|
||||
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
@@ -112,6 +133,109 @@ const App = {
|
||||
} 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) => {
|
||||
if (module) {
|
||||
const workflow = { module, submodule: submodule || null, timestamp: Date.now() };
|
||||
@@ -252,7 +376,22 @@ const App = {
|
||||
mediaQuery.addEventListener('change', applyTheme);
|
||||
window.addEventListener('popstate', handlePopstate);
|
||||
window.addEventListener('beforeinstallprompt', handleInstallPrompt);
|
||||
window.addEventListener('online', handleOnlineStatusChange);
|
||||
window.addEventListener('offline', handleOnlineStatusChange);
|
||||
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()) {
|
||||
showInstallPrompt.value = true;
|
||||
@@ -288,6 +427,11 @@ const App = {
|
||||
mediaQuery.removeEventListener('change', applyTheme);
|
||||
window.removeEventListener('popstate', handlePopstate);
|
||||
window.removeEventListener('beforeinstallprompt', handleInstallPrompt);
|
||||
window.removeEventListener('online', handleOnlineStatusChange);
|
||||
window.removeEventListener('offline', handleOnlineStatusChange);
|
||||
if (offlineModeEnabled.value) {
|
||||
SyncManager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -322,6 +466,23 @@ const App = {
|
||||
workorderRef,
|
||||
handleWorkorderDetailOpen,
|
||||
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>
|
||||
</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-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>
|
||||
|
||||
<button
|
||||
@@ -588,6 +756,68 @@ const App = {
|
||||
</button>
|
||||
</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 class="p-3 border-t border-slate-100 dark:border-slate-700">
|
||||
|
||||
211
public/mobile/components/OfflineIndicator.js
Normal file
211
public/mobile/components/OfflineIndicator.js
Normal 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>
|
||||
`
|
||||
};
|
||||
225
public/mobile/components/SyncStatus.js
Normal file
225
public/mobile/components/SyncStatus.js
Normal 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>
|
||||
`
|
||||
};
|
||||
@@ -3,8 +3,13 @@
|
||||
*
|
||||
* Main module for workorder management in MobileApp.
|
||||
* 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 {
|
||||
name: 'WorkorderModule',
|
||||
emits: ['navigate', 'toast', 'detail-open', 'detail-close'],
|
||||
@@ -184,15 +189,14 @@ export default {
|
||||
const fetchWorkorders = async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const response = await fetch('/MobileApp/Workorder/Workorder/get', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ pagination: { page: 1, per_page: 500 } })
|
||||
});
|
||||
const data = await response.json();
|
||||
const data = await workorderService.getWorkorders({ pagination: { page: 1, per_page: 500 } });
|
||||
if (data.success) {
|
||||
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) {
|
||||
console.error('Error fetching workorders:', error);
|
||||
@@ -209,9 +213,8 @@ export default {
|
||||
emit('detail-open', workorder.id);
|
||||
|
||||
try {
|
||||
// Fetch all workorder details in a single request
|
||||
const response = await fetch(`/MobileApp/Workorder/Workorder/getWorkorderDetail?id=${workorder.id}`, { credentials: 'include' });
|
||||
const data = await response.json();
|
||||
// Fetch all workorder details via offline service
|
||||
const data = await workorderService.getWorkorderDetail(workorder.id);
|
||||
|
||||
if (data.success) {
|
||||
selectedWorkorder.value = data.workorder;
|
||||
@@ -219,10 +222,16 @@ export default {
|
||||
cableLength: data.workorder.cableLength || '',
|
||||
cableType: data.workorder.cableType || ''
|
||||
};
|
||||
documentation.value = { docs: data.docs, journals: data.journals };
|
||||
documentation.value = data.documentation || { docs: [], journals: [] };
|
||||
tenantConfig.value = data.tenantConfig;
|
||||
checklist.value = data.checklist;
|
||||
checklist.value = data.checklist || [];
|
||||
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 {
|
||||
emit('toast', data.message || 'Fehler beim Laden', 'error');
|
||||
}
|
||||
@@ -261,24 +270,20 @@ export default {
|
||||
|
||||
const saveNotes = async () => {
|
||||
try {
|
||||
const response = await fetch('/MobileApp/Workorder/Workorder/updateAdditionalInfo', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
workorderId: selectedWorkorder.value.id,
|
||||
additionalInfo: tempNotes.value
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
const data = await workorderService.updateNotes(selectedWorkorder.value.id, tempNotes.value);
|
||||
if (data.success) {
|
||||
selectedWorkorder.value.additionalInfo = data.newInfo;
|
||||
selectedWorkorder.value.additionalInfo = tempNotes.value;
|
||||
isEditingNotes.value = false;
|
||||
if (data.queued) {
|
||||
emit('toast', 'Notiz wird synchronisiert', 'info');
|
||||
} else {
|
||||
emit('toast', 'Notiz gespeichert', 'success');
|
||||
// Refresh journals
|
||||
const docRes = await fetch(`/MobileApp/Workorder/Workorder/getDocumentation?workorderId=${selectedWorkorder.value.id}`, { credentials: 'include' });
|
||||
const docData = await docRes.json();
|
||||
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) {
|
||||
emit('toast', 'Fehler beim Speichern', 'error');
|
||||
@@ -289,21 +294,23 @@ export default {
|
||||
const addJournalEntry = async () => {
|
||||
if (!newJournalText.value.trim()) return;
|
||||
try {
|
||||
const response = await fetch('/MobileApp/Workorder/Workorder/addJournal', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
workorderId: selectedWorkorder.value.id,
|
||||
text: newJournalText.value
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
const data = await workorderService.addJournal(selectedWorkorder.value.id, newJournalText.value);
|
||||
if (data.success) {
|
||||
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;
|
||||
newJournalText.value = '';
|
||||
emit('toast', 'Eintrag hinzugefügt', 'success');
|
||||
}
|
||||
newJournalText.value = '';
|
||||
}
|
||||
} catch (error) {
|
||||
emit('toast', 'Fehler beim Hinzufügen', 'error');
|
||||
}
|
||||
@@ -312,20 +319,18 @@ export default {
|
||||
// Cable data
|
||||
const saveCableData = async () => {
|
||||
try {
|
||||
const response = await fetch('/MobileApp/Workorder/Workorder/updateWorkorderData', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
workorderId: selectedWorkorder.value.id,
|
||||
cableLength: cableDataForm.value.cableLength,
|
||||
cableType: cableDataForm.value.cableType
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
const data = await workorderService.updateCableData(
|
||||
selectedWorkorder.value.id,
|
||||
cableDataForm.value.cableLength,
|
||||
cableDataForm.value.cableType
|
||||
);
|
||||
if (data.success) {
|
||||
if (data.queued) {
|
||||
emit('toast', 'Daten werden synchronisiert', 'info');
|
||||
} else {
|
||||
emit('toast', 'Daten gespeichert', 'success');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
emit('toast', 'Fehler beim Speichern', 'error');
|
||||
}
|
||||
@@ -351,6 +356,45 @@ export default {
|
||||
|
||||
isUploading.value = true;
|
||||
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();
|
||||
formData.append('workorderId', selectedWorkorder.value.id);
|
||||
formData.append('documentType', uploadDocType.value);
|
||||
@@ -372,14 +416,11 @@ export default {
|
||||
showDocUploadSheet.value = false;
|
||||
|
||||
// Refresh documentation and checklist
|
||||
const [docRes, checkRes] = await Promise.all([
|
||||
fetch(`/MobileApp/Workorder/Workorder/getDocumentation?workorderId=${selectedWorkorder.value.id}`, { credentials: 'include' }),
|
||||
fetch(`/MobileApp/Workorder/Workorder/getChecklist?workorderId=${selectedWorkorder.value.id}`, { credentials: 'include' })
|
||||
]);
|
||||
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;
|
||||
const detailData = await workorderService.getWorkorderDetail(selectedWorkorder.value.id);
|
||||
if (detailData.success) {
|
||||
documentation.value = detailData.documentation || { docs: [], journals: [] };
|
||||
checklist.value = detailData.checklist || [];
|
||||
}
|
||||
|
||||
// Auto-advance: If upload was from checklist, open camera for next item
|
||||
if (wasFromChecklist) {
|
||||
@@ -395,7 +436,15 @@ export default {
|
||||
}
|
||||
}
|
||||
} catch (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 {
|
||||
isUploading.value = false;
|
||||
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;
|
||||
|
||||
try {
|
||||
const response = await fetch('/MobileApp/Workorder/Workorder/requestIntervention', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
workorderId: selectedWorkorder.value.id,
|
||||
interventionType: typeText,
|
||||
journalText: problemComment.value || typeText
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
const data = await workorderService.requestIntervention(
|
||||
selectedWorkorder.value.id,
|
||||
typeText,
|
||||
problemComment.value || typeText
|
||||
);
|
||||
if (data.success) {
|
||||
if (data.queued) {
|
||||
emit('toast', 'Problem wird bei Verbindung gemeldet', 'info');
|
||||
} else {
|
||||
emit('toast', 'Problem gemeldet', 'success');
|
||||
}
|
||||
showProblemSheet.value = false;
|
||||
closeDetail();
|
||||
fetchWorkorders();
|
||||
@@ -449,21 +496,16 @@ export default {
|
||||
|
||||
const submitComplete = async () => {
|
||||
try {
|
||||
const response = await fetch('/MobileApp/Workorder/Workorder/completeWorkorder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
workorderId: selectedWorkorder.value.id
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
const data = await workorderService.completeWorkorder(selectedWorkorder.value.id);
|
||||
if (data.success) {
|
||||
triggerHaptic('success');
|
||||
emit('toast', 'Auftrag abgeschlossen', 'success');
|
||||
showCompleteSheet.value = false;
|
||||
closeDetail();
|
||||
fetchWorkorders();
|
||||
} else if (data.blocked) {
|
||||
// Completion blocked when offline
|
||||
emit('toast', data.message || 'Bitte zuerst synchronisieren', 'warning');
|
||||
} else {
|
||||
emit('toast', data.message || 'Fehler', 'error');
|
||||
}
|
||||
|
||||
188
public/mobile/shared/db.js
Normal file
188
public/mobile/shared/db.js
Normal 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;
|
||||
250
public/mobile/shared/offlineSettings.js
Normal file
250
public/mobile/shared/offlineSettings.js
Normal 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
|
||||
};
|
||||
437
public/mobile/shared/photoQueue.js
Normal file
437
public/mobile/shared/photoQueue.js
Normal 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
|
||||
};
|
||||
414
public/mobile/shared/syncManager.js
Normal file
414
public/mobile/shared/syncManager.js
Normal 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;
|
||||
349
public/mobile/shared/syncQueue.js
Normal file
349
public/mobile/shared/syncQueue.js
Normal 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
|
||||
};
|
||||
710
public/mobile/shared/workorderOfflineService.js
Normal file
710
public/mobile/shared/workorderOfflineService.js
Normal 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
|
||||
};
|
||||
@@ -1,84 +1,303 @@
|
||||
/**
|
||||
* 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 = [
|
||||
'/MobileApp',
|
||||
'/mobile/app.js',
|
||||
'/mobile/shared/auth.js',
|
||||
'/mobile/shared/api.js',
|
||||
'/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/MainMenu.js',
|
||||
'/mobile/components/OfflineIndicator.js',
|
||||
'/mobile/components/SyncStatus.js',
|
||||
'/mobile/modules/lager/LagerModule.js',
|
||||
'/mobile/modules/lager/inventur/StocktakeList.js',
|
||||
'/mobile/modules/lager/inventur/Scanner.js',
|
||||
'/mobile/modules/workorder/WorkorderModule.js',
|
||||
'/assets/images/xinon-full-transparent.png',
|
||||
'/assets/images/xinon-full-transparent-white.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
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('[SW] Installing...');
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.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
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('[SW] Activating...');
|
||||
event.waitUntil(
|
||||
caches.keys().then(cacheNames => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter(name => name !== CACHE_NAME)
|
||||
.map(name => caches.delete(name))
|
||||
.filter(name => name.startsWith('xinon-mobile-') && name !== CACHE_NAME)
|
||||
.map(name => {
|
||||
console.log('[SW] Deleting old cache:', name);
|
||||
return caches.delete(name);
|
||||
})
|
||||
);
|
||||
}).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch: network-first for API, cache-first for assets
|
||||
// Fetch: handle requests with offline support
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// Only handle GET requests
|
||||
if (event.request.method !== 'GET') return;
|
||||
|
||||
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/') &&
|
||||
url.pathname !== '/MobileApp' &&
|
||||
url.pathname !== '/MobileApp/') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Everything else: cache-first, falling back to network
|
||||
event.respondWith(
|
||||
caches.match(event.request).then(cached => {
|
||||
// Static assets: cache-first with background update
|
||||
if (event.request.method === 'GET') {
|
||||
event.respondWith(handleAssetRequest(event.request, url));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle workorder API requests with offline fallback
|
||||
*/
|
||||
async function handleWorkorderRequest(request, url) {
|
||||
const isGet = request.method === 'GET';
|
||||
const isPost = request.method === 'POST';
|
||||
const endpoint = url.pathname;
|
||||
|
||||
// Check if offline mode is enabled via message or localStorage check
|
||||
const offlineEnabled = await isOfflineModeEnabled();
|
||||
|
||||
// For GET requests to cacheable endpoints
|
||||
if (isGet && WORKORDER_CACHEABLE_ENDPOINTS.some(ep => endpoint.startsWith(ep))) {
|
||||
try {
|
||||
// Try network first
|
||||
const response = await fetch(request);
|
||||
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) {
|
||||
// Return cached, but update in background
|
||||
fetch(event.request).then(response => {
|
||||
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(event.request, response);
|
||||
cache.put(request, response);
|
||||
});
|
||||
}
|
||||
}).catch(() => {});
|
||||
return cached;
|
||||
}
|
||||
|
||||
return fetch(event.request).then(response => {
|
||||
// Not cached, fetch from network
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok && url.origin === location.origin) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then(cache => {
|
||||
cache.put(event.request, clone);
|
||||
});
|
||||
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());
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user