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 = {
-
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());
+ }
});