Files
thetool/public/mobile/shared/workorderOfflineService.js
2026-01-27 10:35:15 +01:00

711 lines
20 KiB
JavaScript

/**
* 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
};