diff --git a/application/MobileApp/Modules/Workorder/Workorder/WorkorderHandler.php b/application/MobileApp/Modules/Workorder/Workorder/WorkorderHandler.php index 574b2d060..62d6a58a7 100644 --- a/application/MobileApp/Modules/Workorder/Workorder/WorkorderHandler.php +++ b/application/MobileApp/Modules/Workorder/Workorder/WorkorderHandler.php @@ -14,6 +14,12 @@ class WorkorderHandler extends MobileAppBaseHandler { protected $requiredPermission = 'RMLCompany'; + /** @var string Cache key prefix for idempotency */ + const IDEMPOTENCY_CACHE_PREFIX = 'workorder_idempotency_'; + + /** @var int Idempotency cache TTL in seconds (24 hours) */ + const IDEMPOTENCY_TTL = 86400; + /** @var array Status definitions for workorders */ protected $statusOptions = [ 'new' => ['text' => 'Neu', 'color' => 'primary'], @@ -378,6 +384,16 @@ class WorkorderHandler extends MobileAppBaseHandler { * POST /MobileApp/Workorder/Workorder/uploadDocumentation */ public function uploadDocumentationAction() { + // Check idempotency + $idempotencyKey = $_SERVER['HTTP_X_IDEMPOTENCY_KEY'] ?? null; + if ($idempotencyKey) { + $cached = $this->getIdempotencyCache($idempotencyKey); + if ($cached !== null) { + self::returnJson($cached); + return; + } + } + if (empty($_FILES['files']) && empty($_FILES['file'])) { self::returnJson(['success' => false, 'message' => 'Keine Datei hochgeladen']); return; @@ -391,6 +407,7 @@ class WorkorderHandler extends MobileAppBaseHandler { $documentType = $_POST['documentType'] ?? 'general'; $description = $_POST['description'] ?? ''; + $clientTimestamp = intval($_POST['clientTimestamp'] ?? 0); // Handle both single file and multiple files if (!empty($_FILES['files'])) { @@ -429,12 +446,18 @@ class WorkorderHandler extends MobileAppBaseHandler { 'workorderId' => $workorder->id, 'text' => 'Status wurde nach Dokumenten-Upload automatisch geändert.', 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText($newStatus), - 'create' => time(), + 'create' => $clientTimestamp ?: time(), 'createBy' => $this->user->id, ]); } - self::returnJson(['success' => true, 'message' => 'Datei(en) erfolgreich hochgeladen']); + $response = ['success' => true, 'message' => 'Datei(en) erfolgreich hochgeladen']; + + if ($idempotencyKey) { + $this->setIdempotencyCache($idempotencyKey, $response); + } + + self::returnJson($response); } /** @@ -459,6 +482,16 @@ class WorkorderHandler extends MobileAppBaseHandler { * POST /MobileApp/Workorder/Workorder/addJournal */ public function addJournalAction() { + // Check idempotency + $idempotencyKey = $_SERVER['HTTP_X_IDEMPOTENCY_KEY'] ?? null; + if ($idempotencyKey) { + $cached = $this->getIdempotencyCache($idempotencyKey); + if ($cached !== null) { + self::returnJson($cached); + return; + } + } + $postData = $this->getPostData(); $workorderId = intval($postData['workorderId'] ?? 0); $text = trim($postData['text'] ?? ''); @@ -468,11 +501,14 @@ class WorkorderHandler extends MobileAppBaseHandler { return; } + // Use client timestamp if provided + $clientTimestamp = intval($postData['clientTimestamp'] ?? 0); + WorkorderJournalModel::create([ 'workorderId' => $workorderId, 'text' => $text, 'createBy' => $this->user->id, - 'create' => time() + 'create' => $clientTimestamp ?: time() ]); // Return updated journals @@ -494,11 +530,18 @@ class WorkorderHandler extends MobileAppBaseHandler { ]; } - self::returnJson([ + $response = [ 'success' => true, 'message' => 'Journaleintrag hinzugefügt', 'journals' => $responseJournals - ]); + ]; + + // Cache for idempotency + if ($idempotencyKey) { + $this->setIdempotencyCache($idempotencyKey, $response); + } + + self::returnJson($response); } /** @@ -506,8 +549,19 @@ class WorkorderHandler extends MobileAppBaseHandler { * POST /MobileApp/Workorder/Workorder/updateAdditionalInfo */ public function updateAdditionalInfoAction() { + // Check idempotency + $idempotencyKey = $_SERVER['HTTP_X_IDEMPOTENCY_KEY'] ?? null; + if ($idempotencyKey) { + $cached = $this->getIdempotencyCache($idempotencyKey); + if ($cached !== null) { + self::returnJson($cached); + return; + } + } + $postData = $this->getPostData(); $workorderId = intval($postData['workorderId'] ?? 0); + $clientTimestamp = intval($postData['clientTimestamp'] ?? 0); if (!$workorderId) { self::returnJson(['success' => false, 'message' => 'Arbeitsauftrags-ID fehlt']); @@ -528,15 +582,21 @@ class WorkorderHandler extends MobileAppBaseHandler { WorkorderJournalModel::create([ 'workorderId' => $workorder->id, 'text' => "Zusatzinfo geändert.\nAlt: '{$oldInfo}'\nNeu: '{$newInfo}'", - 'create' => time(), + 'create' => $clientTimestamp ?: time(), 'createBy' => $this->user->id, ]); - self::returnJson([ + $response = [ 'success' => true, 'message' => 'Zusatzinfo aktualisiert', 'newInfo' => $newInfo - ]); + ]; + + if ($idempotencyKey) { + $this->setIdempotencyCache($idempotencyKey, $response); + } + + self::returnJson($response); } /** @@ -544,9 +604,20 @@ class WorkorderHandler extends MobileAppBaseHandler { * POST /MobileApp/Workorder/Workorder/scheduleAppointment */ public function scheduleAppointmentAction() { + // Check idempotency + $idempotencyKey = $_SERVER['HTTP_X_IDEMPOTENCY_KEY'] ?? null; + if ($idempotencyKey) { + $cached = $this->getIdempotencyCache($idempotencyKey); + if ($cached !== null) { + self::returnJson($cached); + return; + } + } + $postData = $this->getPostData(); $workorderId = intval($postData['workorderId'] ?? 0); $appointmentDate = intval($postData['appointmentDate'] ?? 0); + $clientTimestamp = intval($postData['clientTimestamp'] ?? 0); if (!$workorderId || !$appointmentDate) { self::returnJson(['success' => false, 'message' => 'Erforderliche Felder fehlen']); @@ -573,11 +644,17 @@ class WorkorderHandler extends MobileAppBaseHandler { WorkorderJournalModel::create([ 'workorderId' => $workorder->id, 'text' => 'Termin festgelegt auf: ' . date('d.m.Y H:i', $appointmentDate), - 'create' => time(), + 'create' => $clientTimestamp ?: time(), 'createBy' => $this->user->id, ]); - self::returnJson(['success' => true, 'message' => 'Termin erfolgreich gespeichert']); + $response = ['success' => true, 'message' => 'Termin erfolgreich gespeichert']; + + if ($idempotencyKey) { + $this->setIdempotencyCache($idempotencyKey, $response); + } + + self::returnJson($response); } /** @@ -585,10 +662,21 @@ class WorkorderHandler extends MobileAppBaseHandler { * POST /MobileApp/Workorder/Workorder/requestIntervention */ public function requestInterventionAction() { + // Check idempotency + $idempotencyKey = $_SERVER['HTTP_X_IDEMPOTENCY_KEY'] ?? null; + if ($idempotencyKey) { + $cached = $this->getIdempotencyCache($idempotencyKey); + if ($cached !== null) { + self::returnJson($cached); + return; + } + } + $postData = $this->getPostData(); $workorderId = intval($postData['workorderId'] ?? 0); $journalText = trim($postData['journalText'] ?? ''); $interventionType = $postData['interventionType'] ?? ''; + $clientTimestamp = intval($postData['clientTimestamp'] ?? 0); if (!$workorderId || !$journalText) { self::returnJson(['success' => false, 'message' => 'Erforderliche Felder fehlen']); @@ -611,11 +699,17 @@ class WorkorderHandler extends MobileAppBaseHandler { 'workorderId' => $workorder->id, 'text' => $fullText, 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('intervention_required'), - 'create' => time(), + 'create' => $clientTimestamp ?: time(), 'createBy' => $this->user->id, ]); - self::returnJson(['success' => true, 'message' => 'Eingriff wurde angefordert']); + $response = ['success' => true, 'message' => 'Eingriff wurde angefordert']; + + if ($idempotencyKey) { + $this->setIdempotencyCache($idempotencyKey, $response); + } + + self::returnJson($response); } /** @@ -623,8 +717,19 @@ class WorkorderHandler extends MobileAppBaseHandler { * POST /MobileApp/Workorder/Workorder/completeWorkorder */ public function completeWorkorderAction() { + // Check idempotency + $idempotencyKey = $_SERVER['HTTP_X_IDEMPOTENCY_KEY'] ?? null; + if ($idempotencyKey) { + $cached = $this->getIdempotencyCache($idempotencyKey); + if ($cached !== null) { + self::returnJson($cached); + return; + } + } + $postData = $this->getPostData(); $workorderId = intval($postData['workorderId'] ?? 0); + $clientTimestamp = intval($postData['clientTimestamp'] ?? 0); if (!$workorderId) { self::returnJson(['success' => false, 'message' => 'Arbeitsauftrags-ID fehlt']); @@ -650,6 +755,23 @@ class WorkorderHandler extends MobileAppBaseHandler { } } + // Validate checklist + $docTypes = $tenantConfig ? json_decode($tenantConfig->documentationTypes, true) : []; + $docs = WorkorderDocumentationModel::getAll(['workorderId' => $workorderId]); + $uploadedTypes = array_column((array)$docs, 'documentType'); + $uploadedTypeCounts = array_count_values($uploadedTypes); + + foreach ($docTypes as $type) { + if (($type['required'] ?? false) && empty($uploadedTypeCounts[$type['value']])) { + self::returnJson([ + 'success' => false, + 'message' => 'Pflichtdokumentation fehlt: ' . $type['text'], + 'checklistIncomplete' => true + ]); + return; + } + } + $oldStatus = $workorder->status; $workorder->status = 'documented'; WorkorderModel::update((array)$workorder); @@ -658,11 +780,17 @@ class WorkorderHandler extends MobileAppBaseHandler { 'workorderId' => $workorder->id, 'text' => 'Arbeitsauftrag zur Prüfung eingereicht.', 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('documented'), - 'create' => time(), + 'create' => $clientTimestamp ?: time(), 'createBy' => $this->user->id, ]); - self::returnJson(['success' => true, 'message' => 'Arbeitsauftrag zur Prüfung eingereicht']); + $response = ['success' => true, 'message' => 'Arbeitsauftrag zur Prüfung eingereicht']; + + if ($idempotencyKey) { + $this->setIdempotencyCache($idempotencyKey, $response); + } + + self::returnJson($response); } /** @@ -670,8 +798,19 @@ class WorkorderHandler extends MobileAppBaseHandler { * POST /MobileApp/Workorder/Workorder/updateWorkorderData */ public function updateWorkorderDataAction() { + // Check idempotency + $idempotencyKey = $_SERVER['HTTP_X_IDEMPOTENCY_KEY'] ?? null; + if ($idempotencyKey) { + $cached = $this->getIdempotencyCache($idempotencyKey); + if ($cached !== null) { + self::returnJson($cached); + return; + } + } + $postData = $this->getPostData(); $workorderId = intval($postData['workorderId'] ?? 0); + $clientTimestamp = intval($postData['clientTimestamp'] ?? 0); if (!$workorderId) { self::returnJson(['success' => false, 'message' => 'Arbeitsauftrags-ID fehlt']); @@ -704,7 +843,11 @@ class WorkorderHandler extends MobileAppBaseHandler { } if (!$changed) { - self::returnJson(['success' => true, 'message' => 'Keine Änderungen vorgenommen']); + $response = ['success' => true, 'message' => 'Keine Änderungen vorgenommen']; + if ($idempotencyKey) { + $this->setIdempotencyCache($idempotencyKey, $response); + } + self::returnJson($response); return; } @@ -713,11 +856,17 @@ class WorkorderHandler extends MobileAppBaseHandler { WorkorderJournalModel::create([ 'workorderId' => $workorder->id, 'text' => $journalText, - 'create' => time(), + 'create' => $clientTimestamp ?: time(), 'createBy' => $this->user->id, ]); - self::returnJson(['success' => true, 'message' => 'Daten gespeichert']); + $response = ['success' => true, 'message' => 'Daten gespeichert']; + + if ($idempotencyKey) { + $this->setIdempotencyCache($idempotencyKey, $response); + } + + self::returnJson($response); } /** @@ -767,6 +916,669 @@ class WorkorderHandler extends MobileAppBaseHandler { ]); } + // ===================== + // OFFLINE SYNC ENDPOINTS + // ===================== + + /** + * Get all workorders for offline mode initial sync + * Returns full workorder data with details for caching + * POST /MobileApp/Workorder/Workorder/getAllForOffline + */ + public function getAllForOfflineAction() { + $postData = $this->getPostData(); + $lastSyncTimestamp = intval($postData['lastSyncTimestamp'] ?? 0); + + // Get company for current user + $company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]); + if (!$company) { + self::returnJson([ + 'success' => true, + 'workorders' => [], + 'workorderDetails' => [], + 'tenantConfigs' => [], + 'serverTimestamp' => time(), + 'isFullSync' => true + ]); + return; + } + + // Get all workorders for this company (active statuses only) + $activeStatuses = ['new', 'assigned', 'scheduled', 'in_progress', 'correction_requested', + 'intervention_required', 'civil_engineering_required', 'civil_engineering_completed', + 'problem_solved', 'documented']; + + $workorders = WorkorderModel::getCompanyWorkorders( + ['status' => $activeStatuses], + 9999, // Large limit for offline sync + 0, + ['key' => 'deadlineDate', 'order' => 'ASC'], + $company->id + ); + + $result = []; + $workorderDetails = []; + $tenantConfigCache = []; + + foreach ($workorders as $wo) { + $woId = $wo['id']; + $result[] = $this->transformWorkorder($wo); + + // Get detailed data for each workorder + $detailData = $this->getWorkorderWithDetails($woId, $company->id); + if ($detailData) { + $detail = [ + 'workorderId' => $woId, + 'workorder' => $this->transformWorkorder($detailData, true), + 'lastFetched' => time() + ]; + + // Get tenant config (cached by addressId) + $tenantConfig = $this->getTenantConfigFromWorkorder($woId); + if ($tenantConfig) { + $configAddressId = $tenantConfig->addressId; + if (!isset($tenantConfigCache[$configAddressId])) { + $tenantConfigCache[$configAddressId] = [ + 'addressId' => $configAddressId, + 'documentationTypes' => json_decode($tenantConfig->documentationTypes, true) ?? [], + 'civilEngineeringDocsRequired' => (bool)$tenantConfig->civilEngineeringDocsRequired, + 'interventionTypes' => json_decode($tenantConfig->interventionTypes, true) ?? [], + 'requireCableLength' => (bool)$tenantConfig->requireCableLength, + 'requireCableType' => (bool)$tenantConfig->requireCableType, + 'showTechnicalData' => (bool)$tenantConfig->showTechnicalData, + 'lastFetched' => time() + ]; + } + $detail['tenantConfigAddressId'] = $configAddressId; + } + + // Get documentation metadata (without full file data) + $docs = WorkorderDocumentationModel::getAll( + ['workorderId' => $woId], + null, 0, + ['key' => 'create', 'order' => 'ASC'] + ); + + $docMeta = []; + foreach ($docs as $doc) { + $file = new File($doc->fileId); + $docMeta[] = [ + 'id' => $doc->id, + 'fileId' => $doc->fileId, + 'documentType' => $doc->documentType, + 'description' => $doc->description, + 'mimetype' => $file->mimetype ?? 'application/octet-stream', + 'create' => $doc->create, + 'thumbnailUrl' => "/MobileApp/Workorder/Workorder/getThumbnail?fileId={$doc->fileId}", + 'previewUrl' => "/File/Download/{$doc->fileId}", + ]; + } + $detail['documentation'] = $docMeta; + + // Get journals + $journals = WorkorderJournalModel::getAll( + ['workorderId' => $woId], + null, 0, + ['key' => 'create', 'order' => 'DESC'] + ); + + $journalData = []; + foreach ($journals as $journal) { + $journalData[] = [ + 'id' => $journal->id, + 'text' => $journal->text, + 'statusChange' => $journal->statusChange ?? null, + 'createByName' => UserModel::getOne($journal->createBy)->name ?? 'Unbekannt', + 'create' => $journal->create, + ]; + } + $detail['journals'] = $journalData; + + $workorderDetails[] = $detail; + } + } + + self::returnJson([ + 'success' => true, + 'workorders' => $result, + 'workorderDetails' => $workorderDetails, + 'tenantConfigs' => array_values($tenantConfigCache), + 'serverTimestamp' => time(), + 'isFullSync' => $lastSyncTimestamp === 0 + ]); + } + + /** + * Get list of workorder IDs assigned to this company + * Used for reassignment detection + * GET /MobileApp/Workorder/Workorder/getWorkorderIds + */ + public function getWorkorderIdsAction() { + $company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]); + if (!$company) { + self::returnJson(['success' => true, 'workorderIds' => [], 'serverTimestamp' => time()]); + return; + } + + $activeStatuses = ['new', 'assigned', 'scheduled', 'in_progress', 'correction_requested', + 'intervention_required', 'civil_engineering_required', 'civil_engineering_completed', + 'problem_solved', 'documented']; + + $db = $this->db(); + $fronkDbName = FRONKDB_DBNAME; + + $statusIn = "'" . implode("','", $activeStatuses) . "'"; + $sql = "SELECT id FROM `{$fronkDbName}`.`Workorder` + WHERE companyId = " . intval($company->id) . " + AND status IN ({$statusIn})"; + + $result = $db->query($sql); + $ids = []; + while ($row = $result->fetch_assoc()) { + $ids[] = intval($row['id']); + } + + self::returnJson([ + 'success' => true, + 'workorderIds' => $ids, + 'serverTimestamp' => time() + ]); + } + + /** + * Process batch of queued operations from offline sync + * POST /MobileApp/Workorder/Workorder/syncBatch + */ + public function syncBatchAction() { + $postData = $this->getPostData(); + $operations = $postData['operations'] ?? []; + + if (empty($operations)) { + self::returnJson(['success' => true, 'results' => [], 'processedCount' => 0]); + return; + } + + $results = []; + $processedCount = 0; + + foreach ($operations as $op) { + $idempotencyKey = $op['idempotencyKey'] ?? null; + $operationType = $op['type'] ?? ''; + $payload = $op['payload'] ?? []; + $clientTimestamp = intval($op['clientTimestamp'] ?? 0); + + // Check idempotency cache + if ($idempotencyKey) { + $cached = $this->getIdempotencyCache($idempotencyKey); + if ($cached !== null) { + $results[] = [ + 'idempotencyKey' => $idempotencyKey, + 'success' => true, + 'cached' => true, + 'result' => $cached + ]; + $processedCount++; + continue; + } + } + + // Process operation + $opResult = $this->processOfflineOperation($operationType, $payload, $clientTimestamp); + + // Store in idempotency cache + if ($idempotencyKey && $opResult['success']) { + $this->setIdempotencyCache($idempotencyKey, $opResult); + } + + $results[] = [ + 'idempotencyKey' => $idempotencyKey, + 'success' => $opResult['success'], + 'cached' => false, + 'result' => $opResult, + 'error' => $opResult['error'] ?? null + ]; + + if ($opResult['success']) { + $processedCount++; + } + } + + self::returnJson([ + 'success' => true, + 'results' => $results, + 'processedCount' => $processedCount, + 'totalOperations' => count($operations), + 'serverTimestamp' => time() + ]); + } + + /** + * Process a single offline operation + */ + private function processOfflineOperation($type, $payload, $clientTimestamp) { + try { + switch ($type) { + case 'ADD_JOURNAL': + return $this->processAddJournal($payload, $clientTimestamp); + + case 'UPDATE_NOTES': + return $this->processUpdateNotes($payload, $clientTimestamp); + + case 'SCHEDULE_APPOINTMENT': + return $this->processScheduleAppointment($payload, $clientTimestamp); + + case 'REQUEST_INTERVENTION': + return $this->processRequestIntervention($payload, $clientTimestamp); + + case 'UPDATE_CABLE_DATA': + return $this->processUpdateCableData($payload, $clientTimestamp); + + case 'COMPLETE_WORKORDER': + return $this->processCompleteWorkorder($payload, $clientTimestamp); + + default: + return ['success' => false, 'error' => 'Unknown operation type: ' . $type]; + } + } catch (Exception $e) { + return ['success' => false, 'error' => $e->getMessage()]; + } + } + + private function processAddJournal($payload, $clientTimestamp) { + $workorderId = intval($payload['workorderId'] ?? 0); + $text = trim($payload['text'] ?? ''); + + if (!$workorderId || !$text) { + return ['success' => false, 'error' => 'Missing required fields']; + } + + $journalId = WorkorderJournalModel::create([ + 'workorderId' => $workorderId, + 'text' => $text, + 'createBy' => $this->user->id, + 'create' => $clientTimestamp ?: time() + ]); + + return ['success' => true, 'journalId' => $journalId]; + } + + private function processUpdateNotes($payload, $clientTimestamp) { + $workorderId = intval($payload['workorderId'] ?? 0); + + if (!$workorderId) { + return ['success' => false, 'error' => 'Missing workorderId']; + } + + $workorder = WorkorderModel::get($workorderId); + if (!$workorder) { + return ['success' => false, 'error' => 'Workorder not found']; + } + + $oldInfo = $workorder->additionalInfo; + $newInfo = $payload['additionalInfo'] ?? null; + + // Conflict resolution: concatenate if server has newer changes + // For now, we use last-write-wins but log the change + $workorder->additionalInfo = $newInfo; + WorkorderModel::update((array)$workorder); + + WorkorderJournalModel::create([ + 'workorderId' => $workorder->id, + 'text' => "Zusatzinfo geändert (Offline-Sync).\nAlt: '{$oldInfo}'\nNeu: '{$newInfo}'", + 'create' => $clientTimestamp ?: time(), + 'createBy' => $this->user->id, + ]); + + return ['success' => true, 'newInfo' => $newInfo]; + } + + private function processScheduleAppointment($payload, $clientTimestamp) { + $workorderId = intval($payload['workorderId'] ?? 0); + $appointmentDate = intval($payload['appointmentDate'] ?? 0); + + if (!$workorderId || !$appointmentDate) { + return ['success' => false, 'error' => 'Missing required fields']; + } + + $workorder = WorkorderModel::get($workorderId); + if (!$workorder) { + return ['success' => false, 'error' => 'Workorder not found']; + } + + $workorder->appointmentDate = $appointmentDate; + $workorder->status = 'scheduled'; + WorkorderModel::update((array)$workorder); + + WorkorderJournalModel::create([ + 'workorderId' => $workorder->id, + 'text' => 'Termin festgelegt auf: ' . date('d.m.Y H:i', $appointmentDate) . ' (Offline-Sync)', + 'create' => $clientTimestamp ?: time(), + 'createBy' => $this->user->id, + ]); + + return ['success' => true, 'appointmentDate' => $appointmentDate]; + } + + private function processRequestIntervention($payload, $clientTimestamp) { + $workorderId = intval($payload['workorderId'] ?? 0); + $journalText = trim($payload['journalText'] ?? ''); + $interventionType = $payload['interventionType'] ?? ''; + + if (!$workorderId || !$journalText) { + return ['success' => false, 'error' => 'Missing required fields']; + } + + $workorder = WorkorderModel::get($workorderId); + if (!$workorder) { + return ['success' => false, 'error' => 'Workorder not found']; + } + + $oldStatus = $workorder->status; + $workorder->status = 'intervention_required'; + WorkorderModel::update((array)$workorder); + + $fullText = $interventionType ? "{$interventionType}: {$journalText}" : "Eingriff erforderlich: {$journalText}"; + $fullText .= ' (Offline-Sync)'; + + WorkorderJournalModel::create([ + 'workorderId' => $workorder->id, + 'text' => $fullText, + 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('intervention_required'), + 'create' => $clientTimestamp ?: time(), + 'createBy' => $this->user->id, + ]); + + return ['success' => true, 'newStatus' => 'intervention_required']; + } + + private function processUpdateCableData($payload, $clientTimestamp) { + $workorderId = intval($payload['workorderId'] ?? 0); + + if (!$workorderId) { + return ['success' => false, 'error' => 'Missing workorderId']; + } + + $workorder = WorkorderModel::get($workorderId); + if (!$workorder) { + return ['success' => false, 'error' => 'Workorder not found']; + } + + $journalText = "Zusatzdaten aktualisiert (Offline-Sync):\n"; + $changed = false; + + if (isset($payload['cableLength'])) { + if ($workorder->cableLength != $payload['cableLength']) { + $journalText .= "Kabellänge: '{$workorder->cableLength}' -> '{$payload['cableLength']}'\n"; + $workorder->cableLength = $payload['cableLength']; + $changed = true; + } + } + + if (isset($payload['cableType'])) { + if ($workorder->cableType != $payload['cableType']) { + $journalText .= "Kabeltyp: '{$workorder->cableType}' -> '{$payload['cableType']}'\n"; + $workorder->cableType = $payload['cableType']; + $changed = true; + } + } + + if ($changed) { + WorkorderModel::update((array)$workorder); + WorkorderJournalModel::create([ + 'workorderId' => $workorder->id, + 'text' => $journalText, + 'create' => $clientTimestamp ?: time(), + 'createBy' => $this->user->id, + ]); + } + + return ['success' => true, 'changed' => $changed]; + } + + private function processCompleteWorkorder($payload, $clientTimestamp) { + $workorderId = intval($payload['workorderId'] ?? 0); + + if (!$workorderId) { + return ['success' => false, 'error' => 'Missing workorderId']; + } + + $workorder = WorkorderModel::get($workorderId); + if (!$workorder) { + return ['success' => false, 'error' => 'Workorder not found']; + } + + // Validate cable data if required + $tenantConfig = $this->getTenantConfigFromWorkorder($workorderId); + if ($tenantConfig) { + if ($tenantConfig->requireCableLength && empty(trim($workorder->cableLength))) { + return ['success' => false, 'error' => 'Cable length required', 'validationError' => true]; + } + if ($tenantConfig->requireCableType && empty(trim($workorder->cableType))) { + return ['success' => false, 'error' => 'Cable type required', 'validationError' => true]; + } + } + + // Validate checklist + $docTypes = $tenantConfig ? json_decode($tenantConfig->documentationTypes, true) : []; + $docs = WorkorderDocumentationModel::getAll(['workorderId' => $workorderId]); + $uploadedTypes = array_column((array)$docs, 'documentType'); + $uploadedTypeCounts = array_count_values($uploadedTypes); + + foreach ($docTypes as $type) { + if (($type['required'] ?? false) && empty($uploadedTypeCounts[$type['value']])) { + return [ + 'success' => false, + 'error' => 'Required documentation missing: ' . $type['text'], + 'validationError' => true, + 'checklistIncomplete' => true + ]; + } + } + + $oldStatus = $workorder->status; + $workorder->status = 'documented'; + WorkorderModel::update((array)$workorder); + + WorkorderJournalModel::create([ + 'workorderId' => $workorder->id, + 'text' => 'Arbeitsauftrag zur Prüfung eingereicht (Offline-Sync).', + 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('documented'), + 'create' => $clientTimestamp ?: time(), + 'createBy' => $this->user->id, + ]); + + return ['success' => true, 'newStatus' => 'documented']; + } + + /** + * Get thumbnail for documentation image + * GET /MobileApp/Workorder/Workorder/getThumbnail?fileId=X + */ + public function getThumbnailAction() { + $fileId = intval($this->request->fileId ?? 0); + + if (!$fileId) { + header('HTTP/1.1 400 Bad Request'); + echo 'File ID required'; + return; + } + + $file = new File($fileId); + if (!$file->id) { + header('HTTP/1.1 404 Not Found'); + echo 'File not found'; + return; + } + + // Check if it's an image + $mimetype = $file->mimetype ?? ''; + if (strpos($mimetype, 'image/') !== 0) { + // For non-images, redirect to download + header('Location: /File/Download/' . $fileId); + return; + } + + // Generate thumbnail path + $thumbDir = DATADIR . 'thumbnails/workorder/'; + if (!is_dir($thumbDir)) { + mkdir($thumbDir, 0755, true); + } + + $thumbPath = $thumbDir . $fileId . '_200x200.jpg'; + + // Generate thumbnail if it doesn't exist + if (!file_exists($thumbPath)) { + $sourcePath = $file->getFilepath(); + if (!$sourcePath || !file_exists($sourcePath)) { + header('HTTP/1.1 404 Not Found'); + echo 'Source file not found'; + return; + } + + // Create thumbnail + $this->createThumbnail($sourcePath, $thumbPath, 200, 200, $mimetype); + } + + // Serve the thumbnail + if (file_exists($thumbPath)) { + header('Content-Type: image/jpeg'); + header('Cache-Control: public, max-age=31536000'); // Cache for 1 year + header('Content-Length: ' . filesize($thumbPath)); + readfile($thumbPath); + } else { + // Fallback to original + header('Location: /File/Download/' . $fileId); + } + } + + /** + * Create a thumbnail from an image + */ + private function createThumbnail($sourcePath, $destPath, $maxWidth, $maxHeight, $mimetype) { + // Get source image info + $imageInfo = getimagesize($sourcePath); + if (!$imageInfo) { + return false; + } + + $srcWidth = $imageInfo[0]; + $srcHeight = $imageInfo[1]; + + // Calculate new dimensions + $ratio = min($maxWidth / $srcWidth, $maxHeight / $srcHeight); + $newWidth = round($srcWidth * $ratio); + $newHeight = round($srcHeight * $ratio); + + // Create source image resource + switch ($mimetype) { + case 'image/jpeg': + case 'image/jpg': + $srcImage = imagecreatefromjpeg($sourcePath); + break; + case 'image/png': + $srcImage = imagecreatefrompng($sourcePath); + break; + case 'image/gif': + $srcImage = imagecreatefromgif($sourcePath); + break; + case 'image/webp': + $srcImage = imagecreatefromwebp($sourcePath); + break; + default: + return false; + } + + if (!$srcImage) { + return false; + } + + // Create destination image + $destImage = imagecreatetruecolor($newWidth, $newHeight); + + // Preserve transparency for PNG + if ($mimetype === 'image/png') { + imagealphablending($destImage, false); + imagesavealpha($destImage, true); + $transparent = imagecolorallocatealpha($destImage, 255, 255, 255, 127); + imagefilledrectangle($destImage, 0, 0, $newWidth, $newHeight, $transparent); + } else { + // White background for other formats + $white = imagecolorallocate($destImage, 255, 255, 255); + imagefilledrectangle($destImage, 0, 0, $newWidth, $newHeight, $white); + } + + // Resize + imagecopyresampled( + $destImage, $srcImage, + 0, 0, 0, 0, + $newWidth, $newHeight, + $srcWidth, $srcHeight + ); + + // Save as JPEG + $result = imagejpeg($destImage, $destPath, 85); + + // Clean up + imagedestroy($srcImage); + imagedestroy($destImage); + + return $result; + } + + // ===================== + // IDEMPOTENCY HELPERS + // ===================== + + /** + * Get cached idempotency response + */ + private function getIdempotencyCache($key) { + $cacheKey = self::IDEMPOTENCY_CACHE_PREFIX . $key; + + // Try APC cache first (if available) + if (function_exists('apcu_fetch')) { + $success = false; + $data = apcu_fetch($cacheKey, $success); + if ($success) { + return $data; + } + } + + // Fall back to file cache + $cachePath = sys_get_temp_dir() . '/' . $cacheKey . '.json'; + if (file_exists($cachePath)) { + $data = json_decode(file_get_contents($cachePath), true); + if ($data && isset($data['expires']) && $data['expires'] > time()) { + return $data['response']; + } + // Expired, delete + unlink($cachePath); + } + + return null; + } + + /** + * Store idempotency response in cache + */ + private function setIdempotencyCache($key, $response) { + $cacheKey = self::IDEMPOTENCY_CACHE_PREFIX . $key; + + // Try APC cache first (if available) + if (function_exists('apcu_store')) { + apcu_store($cacheKey, $response, self::IDEMPOTENCY_TTL); + return; + } + + // Fall back to file cache + $cachePath = sys_get_temp_dir() . '/' . $cacheKey . '.json'; + $data = [ + 'response' => $response, + 'expires' => time() + self::IDEMPOTENCY_TTL + ]; + file_put_contents($cachePath, json_encode($data)); + } + // ===================== // HELPER METHODS // ===================== diff --git a/public/mobile/app.js b/public/mobile/app.js index 1373cc5d6..2442bc899 100644 --- a/public/mobile/app.js +++ b/public/mobile/app.js @@ -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 = { -
+
Logo +
+ +
+

Offline-Modus (Workorder)

+ +
+
+

Offline-Modus

+

Arbeitsaufträge offline verfügbar

+
+ +
+ + +
diff --git a/public/mobile/components/OfflineIndicator.js b/public/mobile/components/OfflineIndicator.js new file mode 100644 index 000000000..f47d311ff --- /dev/null +++ b/public/mobile/components/OfflineIndicator.js @@ -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: ` +
+ + + + + + + + + + + + + + + + + + + + + + + + {{ statusText }} + + + +
+ ` +}; diff --git a/public/mobile/components/SyncStatus.js b/public/mobile/components/SyncStatus.js new file mode 100644 index 000000000..bef0db9cd --- /dev/null +++ b/public/mobile/components/SyncStatus.js @@ -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: ` +
+ +
+

+ + + + Synchronisation +

+ + + + {{ isOnline ? 'Online' : 'Offline' }} + +
+ + +
+
+ {{ progressText }} + {{ Math.round((syncProgress.current / syncProgress.total) * 100) }}% +
+
+
+
+
+ + +
+ +
+ Ausstehend + + {{ summaryText }} + +
+ + +
+ Fehlgeschlagen + +
+ + +
+ Letzte Synchronisation + {{ lastSyncText }} +
+
+ + + + + +

+ Änderungen werden synchronisiert, sobald Sie wieder online sind. +

+
+ ` +}; diff --git a/public/mobile/modules/workorder/WorkorderModule.js b/public/mobile/modules/workorder/WorkorderModule.js index 32c0cf0f5..512625d1d 100644 --- a/public/mobile/modules/workorder/WorkorderModule.js +++ b/public/mobile/modules/workorder/WorkorderModule.js @@ -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; - 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; + if (data.queued) { + emit('toast', 'Notiz wird synchronisiert', 'info'); + } else { + emit('toast', 'Notiz gespeichert', 'success'); + // 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,20 +294,22 @@ 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) { - documentation.value.journals = data.journals; + if (data.queued) { + // Add pending journal entry to local display + documentation.value.journals.unshift({ + id: 'pending-' + data.localId, + text: newJournalText.value, + create: Math.floor(Date.now() / 1000), + _pending: true + }); + emit('toast', 'Eintrag wird synchronisiert', 'info'); + } else if (data.journals) { + documentation.value.journals = data.journals; + emit('toast', 'Eintrag hinzugefügt', 'success'); + } newJournalText.value = ''; - emit('toast', 'Eintrag hinzugefügt', 'success'); } } catch (error) { emit('toast', 'Fehler beim Hinzufügen', 'error'); @@ -312,19 +319,17 @@ 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) { - emit('toast', 'Daten gespeichert', '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) { - emit('toast', 'Upload fehlgeschlagen', 'error'); + // If upload fails and offline mode is enabled, queue for later + if (offlineEnabled) { + for (let i = 0; i < files.length; i++) { + await photoQueue.queue(selectedWorkorder.value.id, files[i], uploadDocType.value, ''); + } + emit('toast', 'Foto wird bei Verbindung hochgeladen', 'info'); + } else { + emit('toast', 'Upload fehlgeschlagen', 'error'); + } } finally { 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) { - emit('toast', 'Problem gemeldet', 'success'); + if (data.queued) { + emit('toast', 'Problem wird bei Verbindung gemeldet', 'info'); + } else { + emit('toast', 'Problem gemeldet', 'success'); + } showProblemSheet.value = false; 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'); } diff --git a/public/mobile/shared/db.js b/public/mobile/shared/db.js new file mode 100644 index 000000000..4e1df108c --- /dev/null +++ b/public/mobile/shared/db.js @@ -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; diff --git a/public/mobile/shared/offlineSettings.js b/public/mobile/shared/offlineSettings.js new file mode 100644 index 000000000..7ca3f1f66 --- /dev/null +++ b/public/mobile/shared/offlineSettings.js @@ -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 +}; diff --git a/public/mobile/shared/photoQueue.js b/public/mobile/shared/photoQueue.js new file mode 100644 index 000000000..d03366902 --- /dev/null +++ b/public/mobile/shared/photoQueue.js @@ -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 +}; diff --git a/public/mobile/shared/syncManager.js b/public/mobile/shared/syncManager.js new file mode 100644 index 000000000..471983bdc --- /dev/null +++ b/public/mobile/shared/syncManager.js @@ -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; diff --git a/public/mobile/shared/syncQueue.js b/public/mobile/shared/syncQueue.js new file mode 100644 index 000000000..7f0ef5763 --- /dev/null +++ b/public/mobile/shared/syncQueue.js @@ -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 +}; diff --git a/public/mobile/shared/workorderOfflineService.js b/public/mobile/shared/workorderOfflineService.js new file mode 100644 index 000000000..2be29cc22 --- /dev/null +++ b/public/mobile/shared/workorderOfflineService.js @@ -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 +}; diff --git a/public/mobile/sw.js b/public/mobile/sw.js index 80ee45176..9ea6bed38 100644 --- a/public/mobile/sw.js +++ b/public/mobile/sw.js @@ -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 => { - if (cached) { - // Return cached, but update in background - fetch(event.request).then(response => { - if (response.ok) { - caches.open(CACHE_NAME).then(cache => { - cache.put(event.request, response); - }); - } - }).catch(() => {}); - return cached; - } - - return fetch(event.request).then(response => { - if (response.ok && url.origin === location.origin) { - const clone = response.clone(); - caches.open(CACHE_NAME).then(cache => { - cache.put(event.request, clone); - }); - } - return response; - }); - }) - ); + // 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) { + console.log('[SW] Serving from cache:', endpoint); + return cached; + } + } + // Return offline error response + return new Response(JSON.stringify({ + success: false, + error: 'Offline - keine gecachten Daten', + offline: true + }), { + status: 503, + headers: { 'Content-Type': 'application/json' } + }); + } + } + + // For POST requests (mutations) - let them through, main thread handles queueing + if (isPost) { + try { + return await fetch(request); + } catch (error) { + // Network error - return error response + // The main thread WorkorderOfflineService handles queueing + return new Response(JSON.stringify({ + success: false, + error: 'Netzwerkfehler', + networkError: true + }), { + status: 503, + headers: { 'Content-Type': 'application/json' } + }); + } + } + + // Default: try network + return fetch(request); +} + +/** + * Handle static asset requests with cache-first strategy + */ +async function handleAssetRequest(request, url) { + const cached = await caches.match(request); + + if (cached) { + // Return cached, update in background + fetch(request).then(response => { + if (response.ok) { + caches.open(CACHE_NAME).then(cache => { + cache.put(request, response); + }); + } + }).catch(() => {}); + return cached; + } + + // Not cached, fetch from network + try { + const response = await fetch(request); + if (response.ok && url.origin === location.origin) { + const cache = await caches.open(CACHE_NAME); + cache.put(request, response.clone()); + } + return response; + } catch (error) { + // Return offline page or error + return new Response('Offline', { status: 503 }); + } +} + +/** + * Check if offline mode is enabled + * This is a simplified check - the main thread handles the full logic + */ +async function isOfflineModeEnabled() { + // Try to get from clients + const clients = await self.clients.matchAll(); + for (const client of clients) { + // Ask client for offline status + // For now, assume enabled if we have cached API data + const apiCache = await caches.open(CACHE_NAME + '-api'); + const keys = await apiCache.keys(); + return keys.length > 0; + } + return false; +} + +// Background Sync event (Chrome/Edge only) +self.addEventListener('sync', (event) => { + console.log('[SW] Background sync triggered:', event.tag); + + if (event.tag === 'workorder-sync') { + event.waitUntil(handleBackgroundSync()); + } +}); + +/** + * Handle background sync + * Notify main thread to process sync queue + */ +async function handleBackgroundSync() { + const clients = await self.clients.matchAll({ type: 'window' }); + + for (const client of clients) { + client.postMessage({ + type: 'BACKGROUND_SYNC', + tag: 'workorder-sync' + }); + } +} + +// Message handler for communication with main thread +self.addEventListener('message', (event) => { + const { type, data } = event.data || {}; + + switch (type) { + case 'SKIP_WAITING': + self.skipWaiting(); + break; + + case 'CLEAR_API_CACHE': + caches.delete(CACHE_NAME + '-api').then(() => { + console.log('[SW] API cache cleared'); + event.ports[0]?.postMessage({ success: true }); + }); + break; + + case 'CACHE_WORKORDER_DATA': + // Cache workorder data from main thread + if (data && data.url && data.response) { + caches.open(CACHE_NAME + '-api').then(cache => { + const response = new Response(JSON.stringify(data.response), { + headers: { 'Content-Type': 'application/json' } + }); + cache.put(data.url, response); + console.log('[SW] Cached workorder data:', data.url); + }); + } + break; + + case 'GET_CACHE_STATUS': + getCacheStatus().then(status => { + event.ports[0]?.postMessage(status); + }); + break; + + default: + console.log('[SW] Unknown message type:', type); + } +}); + +/** + * Get cache status for debugging + */ +async function getCacheStatus() { + const assetCache = await caches.open(CACHE_NAME); + const apiCache = await caches.open(CACHE_NAME + '-api'); + + const assetKeys = await assetCache.keys(); + const apiKeys = await apiCache.keys(); + + return { + cacheName: CACHE_NAME, + assetsCached: assetKeys.length, + apiCached: apiKeys.length, + apiEndpoints: apiKeys.map(k => new URL(k.url).pathname) + }; +} + +// Periodic sync (if supported - experimental) +self.addEventListener('periodicsync', (event) => { + if (event.tag === 'workorder-periodic-sync') { + console.log('[SW] Periodic sync triggered'); + event.waitUntil(handleBackgroundSync()); + } });