711 lines
20 KiB
JavaScript
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
|
|
};
|