diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..64b936302 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "Bash(docker-compose up:*)", + "Bash(python:*)", + "Bash(cat:*)", + "Bash(find:*)", + "Bash(docker-compose exec:*)", + "mcp__sequentialthinking__sequentialthinking" + ] + } +} diff --git a/Layout/default/MobileApp/App.php b/Layout/default/MobileApp/App.php new file mode 100644 index 000000000..b60ddf599 --- /dev/null +++ b/Layout/default/MobileApp/App.php @@ -0,0 +1,77 @@ + + + + + + + Xinon Mobile + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + +
Lädt...
+
+
+
+ + + + + + + + diff --git a/Layout/default/MobileApp/WarehouseStocktake.php b/Layout/default/MobileApp/WarehouseStocktake.php new file mode 100644 index 000000000..6ba94de1a --- /dev/null +++ b/Layout/default/MobileApp/WarehouseStocktake.php @@ -0,0 +1,78 @@ + + + + + + + Lager Inventur + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + +
Lädt...
+
+
+
+ + + + + + + + diff --git a/Layout/default/menu.php b/Layout/default/menu.php index baf61ddc7..82ce59296 100644 --- a/Layout/default/menu.php +++ b/Layout/default/menu.php @@ -185,6 +185,7 @@ can("WarehouseAdmin") || $me->can("WarehouseUser")): ?>
  • "> Lieferscheine
  • can("WarehouseAdmin") || $me->can("WarehouseUser")): ?>
  • "> Projekte
  • can("WarehouseAdmin")): ?>
  • "> Inventur
  • + can("WarehouseAdmin") || $me->can("WarehouseUser")): ?>
  • "> Lagerbewegung
  • can("WarehouseAdmin")): ?>
  • "> Administration
  • diff --git a/application/MobileApp/Apps/WarehouseStocktake/WarehouseStocktakeHandler.php b/application/MobileApp/Apps/WarehouseStocktake/WarehouseStocktakeHandler.php new file mode 100644 index 000000000..a9773471c --- /dev/null +++ b/application/MobileApp/Apps/WarehouseStocktake/WarehouseStocktakeHandler.php @@ -0,0 +1,473 @@ + 'in_progress']); + + $result = []; + foreach ($stocktakes as $stocktake) { + $location = $stocktake->getLocation(); + $result[] = [ + 'id' => $stocktake->id, + 'stocktakeNumber' => $stocktake->stocktakeNumber, + 'title' => $stocktake->title, + 'locationName' => $location ? $location->title : 'Unbekannt', + 'totalScannedItems' => $stocktake->totalScannedItems, + 'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null, + ]; + } + + self::returnJson(['success' => true, 'stocktakes' => $result]); + } + + /** + * Get stocktake details + * GET /MobileApp/WarehouseStocktake/getStocktake?id=X + */ + public function getStocktakeAction() { + $id = intval($this->request->id); + if (!$id) { + self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']); + return; + } + + $stocktake = WarehouseStocktakeModel::get($id); + if (!$stocktake) { + self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']); + return; + } + + $location = $stocktake->getLocation(); + + self::returnJson([ + 'success' => true, + 'stocktake' => [ + 'id' => $stocktake->id, + 'stocktakeNumber' => $stocktake->stocktakeNumber, + 'title' => $stocktake->title, + 'status' => $stocktake->status, + 'locationId' => $stocktake->warehouseLocationId, + 'locationName' => $location ? $location->title : 'Unbekannt', + 'totalScannedItems' => $stocktake->totalScannedItems, + 'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null, + ] + ]); + } + + /** + * Get article by QR code or article number + * GET /MobileApp/WarehouseStocktake/getArticle?code=X + */ + public function getArticleAction() { + $code = $this->request->code; + + if (!$code) { + self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']); + return; + } + + $articleId = null; + + // Try to parse QR code format: WA:articleId:articleNumber (Warehouse Article) + // Also accept WH: for backwards compatibility + if (preg_match('/^(?:WA|WH):(\d+):/', $code, $matches)) { + $articleId = intval($matches[1]); + } else { + // Try to find by article number + $article = WarehouseArticleModel::getFirst(['articleNumber' => $code]); + if ($article) { + $articleId = $article->id; + } + } + + if (!$articleId) { + self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']); + return; + } + + $article = WarehouseArticleModel::get($articleId); + if (!$article) { + self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']); + return; + } + + // Get category name + $category = WarehouseCategory::get($article->category_id); + + self::returnJson([ + 'success' => true, + 'article' => [ + 'id' => $article->id, + 'articleNumber' => $article->articleNumber, + 'title' => $article->title, + 'description' => $article->description ?? '', + 'unit' => $article->unit ?? 'Stk.', + 'categoryName' => $category ? $category->name : '', + ] + ]); + } + + /** + * Search articles by text with optional category filter + * GET /MobileApp/WarehouseStocktake/searchArticles?query=X&categoryId=Y + */ + public function searchArticlesAction() { + $query = $this->request->query ?? ''; + $categoryId = intval($this->request->categoryId ?? 0); + + $db = $this->db(); + $conditions = ["(isEndOfLife IS NULL OR isEndOfLife = 0)"]; + + if ($query && strlen($query) >= 2) { + $escapedQuery = $db->escape($query); + $conditions[] = "(articleNumber LIKE '%{$escapedQuery}%' OR title LIKE '%{$escapedQuery}%' OR description LIKE '%{$escapedQuery}%')"; + } + + if ($categoryId > 0) { + $conditions[] = "category_id = {$categoryId}"; + } + + if (count($conditions) === 1 && !$categoryId) { + self::returnJson(['success' => true, 'articles' => []]); + return; + } + + $whereClause = implode(' AND ', $conditions); + $result = $db->query("SELECT id, articleNumber, title, unit, category_id + FROM WarehouseArticle + WHERE {$whereClause} + ORDER BY title ASC + LIMIT 50"); + + $articles = []; + while ($row = $result->fetch_assoc()) { + $articles[] = [ + 'id' => intval($row['id']), + 'articleNumber' => $row['articleNumber'], + 'title' => $row['title'], + 'unit' => $row['unit'] ?? 'Stk.', + 'categoryId' => intval($row['category_id'] ?? 0), + ]; + } + + self::returnJson(['success' => true, 'articles' => $articles]); + } + + /** + * Get all categories for browsing + * GET /MobileApp/WarehouseStocktake/getCategories + */ + public function getCategoriesAction() { + $db = $this->db(); + $res = $db->query("SELECT id, name FROM WarehouseCategory ORDER BY name ASC"); + + $categories = []; + while ($row = $res->fetch_assoc()) { + $categories[] = [ + 'id' => intval($row['id']), + 'name' => $row['name'], + ]; + } + self::returnJson(['success' => true, 'categories' => $categories]); + } + + /** + * Check if article is already scanned in stocktake + * GET /MobileApp/WarehouseStocktake/checkAlreadyScanned?stocktakeId=X&articleId=Y + */ + public function checkAlreadyScannedAction() { + $stocktakeId = intval($this->request->stocktakeId); + $articleId = intval($this->request->articleId); + + if (!$stocktakeId || !$articleId) { + self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']); + return; + } + + $existing = WarehouseStocktakeItemModel::getFirst([ + 'stocktakeId' => $stocktakeId, + 'articleId' => $articleId, + 'overwrittenById' => null + ]); + + if ($existing) { + $db = $this->db(); + $scannedByResult = $db->query("SELECT name FROM Worker WHERE id = {$existing->scannedBy}"); + $scannedByRow = $scannedByResult->fetch_assoc(); + + self::returnJson([ + 'success' => true, + 'alreadyScanned' => true, + 'existingItem' => [ + 'id' => $existing->id, + 'countedQuantity' => $existing->countedQuantity, + 'scannedAt' => $existing->scannedAt ? date('d.m.Y H:i', $existing->scannedAt) : null, + 'scannedBy' => $scannedByRow ? $scannedByRow['name'] : 'Unbekannt', + ] + ]); + } else { + self::returnJson(['success' => true, 'alreadyScanned' => false]); + } + } + + /** + * Submit a scanned item + * POST /MobileApp/WarehouseStocktake/submitScan + */ + public function submitScanAction() { + $postData = $this->getPostData(); + + $stocktakeId = intval($postData['stocktakeId'] ?? 0); + $articleId = intval($postData['articleId'] ?? 0); + $quantity = floatval($postData['quantity'] ?? 0); + $rack = $postData['rack'] ?? null; + $shelf = $postData['shelf'] ?? null; + $note = $postData['note'] ?? null; + $overwrite = boolval($postData['overwrite'] ?? false); + $overwriteItemId = intval($postData['overwriteItemId'] ?? 0); + + if (!$stocktakeId || !$articleId) { + self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']); + return; + } + + if ($quantity <= 0) { + self::returnJson(['success' => false, 'message' => 'Menge muss größer als 0 sein']); + return; + } + + // Verify stocktake exists and is in progress + $stocktake = WarehouseStocktakeModel::get($stocktakeId); + if (!$stocktake) { + self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']); + return; + } + + if ($stocktake->status !== 'in_progress') { + self::returnJson(['success' => false, 'message' => 'Inventur ist nicht aktiv']); + return; + } + + // Verify article exists + $article = WarehouseArticleModel::get($articleId); + if (!$article) { + self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']); + return; + } + + $db = $this->db(); + + // If overwrite mode is enabled, mark existing item as overwritten + if ($overwrite && $overwriteItemId) { + // Create new entry + $db->query("INSERT INTO WarehouseStocktakeItem + (stocktakeId, articleId, countedQuantity, rack, shelf, note, scannedAt, scannedBy, createBy, `create`) + VALUES ({$stocktakeId}, {$articleId}, {$quantity}, + " . ($rack ? "'{$db->escape($rack)}'" : "NULL") . ", + " . ($shelf ? "'{$db->escape($shelf)}'" : "NULL") . ", + " . ($note ? "'{$db->escape($note)}'" : "NULL") . ", + " . time() . ", {$this->user->id}, {$this->user->id}, " . time() . ")"); + + $itemId = $db->insert_id; + + // Mark old item as overwritten by new item + $db->query("UPDATE WarehouseStocktakeItem SET overwrittenById = {$itemId} WHERE id = {$overwriteItemId}"); + + $finalQuantity = $quantity; + + // Log the overwrite + WarehouseStocktakeLogModel::log($stocktakeId, 'overwritten', $itemId, [ + 'articleId' => $articleId, + 'articleNumber' => $article->articleNumber, + 'articleTitle' => $article->title, + 'quantity' => $quantity, + 'overwrittenItemId' => $overwriteItemId, + ]); + + // Update stocktake progress + $stocktake->updateProgress(); + + self::returnJson([ + 'success' => true, + 'message' => "'{$article->title}' überschrieben ({$quantity} {$article->unit})", + 'item' => [ + 'id' => $itemId, + 'articleId' => $articleId, + 'articleNumber' => $article->articleNumber, + 'articleTitle' => $article->title, + 'countedQuantity' => $finalQuantity, + 'unit' => $article->unit ?? 'Stk.', + 'rack' => $rack, + 'shelf' => $shelf, + 'isOverwrite' => true, + ] + ]); + return; + } + + // Check if this article was already scanned in this stocktake (non-overwritten) + $existing = WarehouseStocktakeItemModel::getFirst([ + 'stocktakeId' => $stocktakeId, + 'articleId' => $articleId, + 'overwrittenById' => null + ]); + + if ($existing) { + // Update existing entry - add to quantity + $newQuantity = $existing->countedQuantity + $quantity; + $db->query("UPDATE WarehouseStocktakeItem SET + countedQuantity = {$newQuantity}, + rack = " . ($rack ? "'{$db->escape($rack)}'" : "rack") . ", + shelf = " . ($shelf ? "'{$db->escape($shelf)}'" : "shelf") . ", + scannedAt = " . time() . ", + scannedBy = {$this->user->id} + WHERE id = {$existing->id}"); + + $itemId = $existing->id; + $finalQuantity = $newQuantity; + $isUpdate = true; + } else { + // Create new entry + $db->query("INSERT INTO WarehouseStocktakeItem + (stocktakeId, articleId, countedQuantity, rack, shelf, note, scannedAt, scannedBy, createBy, `create`) + VALUES ({$stocktakeId}, {$articleId}, {$quantity}, + " . ($rack ? "'{$db->escape($rack)}'" : "NULL") . ", + " . ($shelf ? "'{$db->escape($shelf)}'" : "NULL") . ", + " . ($note ? "'{$db->escape($note)}'" : "NULL") . ", + " . time() . ", {$this->user->id}, {$this->user->id}, " . time() . ")"); + + $itemId = $db->insert_id; + $finalQuantity = $quantity; + $isUpdate = false; + } + + // Update stocktake progress + $stocktake->updateProgress(); + + // Log the scan + WarehouseStocktakeLogModel::log($stocktakeId, 'scanned', $itemId, [ + 'articleId' => $articleId, + 'articleNumber' => $article->articleNumber, + 'articleTitle' => $article->title, + 'quantity' => $quantity, + 'totalQuantity' => $finalQuantity, + 'rack' => $rack, + 'shelf' => $shelf, + 'isUpdate' => $isUpdate, + ]); + + self::returnJson([ + 'success' => true, + 'message' => $isUpdate + ? "Menge für '{$article->title}' erhöht auf {$finalQuantity}" + : "'{$article->title}' hinzugefügt ({$quantity} {$article->unit})", + 'item' => [ + 'id' => $itemId, + 'articleId' => $articleId, + 'articleNumber' => $article->articleNumber, + 'articleTitle' => $article->title, + 'countedQuantity' => $finalQuantity, + 'unit' => $article->unit ?? 'Stk.', + 'rack' => $rack, + 'shelf' => $shelf, + 'isUpdate' => $isUpdate, + ] + ]); + } + + /** + * Get recent scans for current user in a stocktake + * GET /MobileApp/WarehouseStocktake/getMyScans?stocktakeId=X + */ + public function getMyScansAction() { + $stocktakeId = intval($this->request->stocktakeId); + + if (!$stocktakeId) { + self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']); + return; + } + + $db = $this->db(); + $result = $db->query("SELECT si.*, wa.articleNumber, wa.title as articleTitle, wa.unit + FROM WarehouseStocktakeItem si + JOIN WarehouseArticle wa ON wa.id = si.articleId + WHERE si.stocktakeId = {$stocktakeId} + AND si.scannedBy = {$this->user->id} + ORDER BY si.scannedAt DESC + LIMIT 50"); + + $items = []; + while ($row = $result->fetch_assoc()) { + $items[] = [ + 'id' => intval($row['id']), + 'articleId' => intval($row['articleId']), + 'articleNumber' => $row['articleNumber'], + 'articleTitle' => $row['articleTitle'], + 'countedQuantity' => floatval($row['countedQuantity']), + 'unit' => $row['unit'] ?? 'Stk.', + 'rack' => $row['rack'], + 'shelf' => $row['shelf'], + 'scannedAt' => $row['scannedAt'] ? date('H:i', $row['scannedAt']) : null, + ]; + } + + self::returnJson(['success' => true, 'items' => $items]); + } + + /** + * Get progress stats + * GET /MobileApp/WarehouseStocktake/getProgress?stocktakeId=X + */ + public function getProgressAction() { + $stocktakeId = intval($this->request->stocktakeId); + + if (!$stocktakeId) { + self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']); + return; + } + + $stocktake = WarehouseStocktakeModel::get($stocktakeId); + if (!$stocktake) { + self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']); + return; + } + + $db = $this->db(); + + // Total scanned items + $totalResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId}"); + $totalRow = $totalResult->fetch_assoc(); + $totalScanned = intval($totalRow['count']); + + // My scanned items + $myResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId} AND scannedBy = {$this->user->id}"); + $myRow = $myResult->fetch_assoc(); + $myScanned = intval($myRow['count']); + + self::returnJson([ + 'success' => true, + 'progress' => [ + 'totalScanned' => $totalScanned, + 'myScanned' => $myScanned, + 'status' => $stocktake->status, + ] + ]); + } +} diff --git a/application/MobileApp/MobileAppController.php b/application/MobileApp/MobileAppController.php new file mode 100644 index 000000000..96f62b246 --- /dev/null +++ b/application/MobileApp/MobileAppController.php @@ -0,0 +1,451 @@ +needlogin = false; + + // Try to load user if session exists + $me = mfValuecache::singleton()->get("me"); + if (!$me) { + if (mfLoginController::isLoggedIn()) { + $me = new User(); + $me->loadMe(); + mfValuecache::singleton()->set("me", $me); + } + } + $this->user = $me; + } + + /** + * Main dispatcher + */ + public function indexAction() { + $module = $this->request->module ?? null; + $submodule = $this->request->submodule ?? null; + $endpoint = $this->request->endpoint ?? null; + + // Auth endpoints: /MobileApp/auth/{action} + if (strtolower($module) === 'auth') { + return $this->handleAuth($submodule ?? 'check'); + } + + // API call: /MobileApp/{module}/{submodule}/{endpoint} + if ($module && $submodule && $endpoint) { + return $this->handleApiCall($module, $submodule, $endpoint); + } + + // Everything else: render the main Vue SPA + // The Vue app handles internal routing for /MobileApp, /MobileApp/Lager, /MobileApp/Lager/Inventur, etc. + return $this->renderApp(); + } + + /** + * Render the main Vue SPA + */ + protected function renderApp() { + $this->layout()->setTemplate("MobileApp/App"); + $this->layout()->set("JSGlobals", [ + 'BASE_PATH' => '/MobileApp', + 'USER' => $this->user ? [ + 'id' => $this->user->id, + 'name' => $this->user->name, + 'username' => $this->user->username, + ] : null, + 'INITIAL_PATH' => $_SERVER['REQUEST_URI'] ?? '/MobileApp', + ]); + } + + /** + * Handle authentication endpoints + */ + protected function handleAuth($action) { + switch (strtolower($action)) { + case 'login': + return $this->authLogin(); + case 'verify2fa': + return $this->authVerify2FA(); + case 'resend2fa': + return $this->authResend2FA(); + case 'logout': + return $this->authLogout(); + case 'check': + return $this->authCheck(); + default: + self::returnJson(['success' => false, 'error' => 'Unknown auth endpoint'], 404); + } + } + + /** + * POST /MobileApp/auth/login + * + * Step 1 of authentication. If 2FA is required, returns requires2FA: true + * and the frontend should proceed to verify2fa endpoint. + */ + protected function authLogin() { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + self::returnJson(['success' => false, 'error' => 'Method not allowed'], 405); + return; + } + + $postData = json_decode(file_get_contents('php://input'), true) ?? []; + $username = $postData['username'] ?? ''; + $password = $postData['password'] ?? ''; + $rememberMe = $postData['rememberMe'] ?? false; + + if (!$username || !$password) { + self::returnJson(['success' => false, 'message' => 'Benutzername und Passwort erforderlich']); + return; + } + + $db = FronkDB::singleton(); + $escapedUsername = $db->escape($username); + + $res = $db->select(MFUSERTABLE, "*", "username='$escapedUsername'"); + if (!$db->num_rows($res)) { + sleep(1); + self::returnJson(['success' => false, 'message' => 'Ungültige Anmeldedaten']); + return; + } + + $userRow = $db->fetch_object($res); + + if ($userRow->active == 0) { + self::returnJson(['success' => false, 'message' => 'Benutzer ist deaktiviert']); + return; + } + + $hash = $userRow->password; + $salt = substr($hash, 0, 16); + $passhash = mfLoginController::generatePasswordHash($password, $salt); + + if ($passhash !== $hash) { + sleep(1); + self::returnJson(['success' => false, 'message' => 'Ungültige Anmeldedaten']); + return; + } + + // Check if 2FA is required + if ($userRow->twofactor !== "0") { + // Generate and send 2FA code + $twoFactor = new UserTwofactor($userRow->id); + $twoFactor->sendCode(); + + // Store pending auth in session for 2FA verification + $_SESSION['mobileapp_2fa_pending'] = [ + 'user_id' => $userRow->id, + 'username' => $userRow->username, + 'remember_me' => $rememberMe, + 'timestamp' => time() + ]; + + // Determine delivery method for UI feedback + $deliveryMethod = $userRow->twofactor == 1 ? 'email' : 'sms'; + $maskedTarget = $deliveryMethod === 'email' + ? $this->maskEmail($userRow->email) + : $this->maskPhone($userRow->mobile); + + self::returnJson([ + 'success' => false, + 'requires2FA' => true, + 'deliveryMethod' => $deliveryMethod, + 'maskedTarget' => $maskedTarget, + 'message' => 'Verifizierungscode wurde gesendet' + ]); + return; + } + + // No 2FA - complete login directly + $this->completeLogin($userRow, $rememberMe); + } + + /** + * POST /MobileApp/auth/verify2fa + * + * Step 2 of authentication - verify the 2FA code + */ + protected function authVerify2FA() { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + self::returnJson(['success' => false, 'error' => 'Method not allowed'], 405); + return; + } + + $postData = json_decode(file_get_contents('php://input'), true) ?? []; + $code = $postData['code'] ?? ''; + + // Check for pending 2FA session + if (!isset($_SESSION['mobileapp_2fa_pending'])) { + self::returnJson(['success' => false, 'message' => 'Keine ausstehende Verifizierung']); + return; + } + + $pending = $_SESSION['mobileapp_2fa_pending']; + + // Check if pending session is expired (10 minutes max) + if (time() - $pending['timestamp'] > 600) { + unset($_SESSION['mobileapp_2fa_pending']); + self::returnJson(['success' => false, 'message' => 'Sitzung abgelaufen. Bitte erneut anmelden.', 'expired' => true]); + return; + } + + if (!$code || strlen($code) !== 5) { + self::returnJson(['success' => false, 'message' => 'Bitte gib den 5-stelligen Code ein']); + return; + } + + $db = FronkDB::singleton(); + $userId = intval($pending['user_id']); + + // Get user's 2FA code and timestamp + $res = $db->select(MFUSERTABLE, "twofactorcode, twofactortimestamp, username", "id = {$userId}"); + if (!$db->num_rows($res)) { + unset($_SESSION['mobileapp_2fa_pending']); + self::returnJson(['success' => false, 'message' => 'Benutzer nicht gefunden']); + return; + } + + $userRow = $db->fetch_object($res); + $storedCode = $userRow->twofactorcode; + $codeTimestamp = intval($userRow->twofactortimestamp); + + // Check if code is expired (5 minutes) + if (time() - $codeTimestamp > 300) { + self::returnJson(['success' => false, 'message' => 'Code abgelaufen. Bitte neuen Code anfordern.', 'codeExpired' => true]); + return; + } + + // Verify code + if ($code !== $storedCode) { + sleep(1); // Rate limiting + self::returnJson(['success' => false, 'message' => 'Ungültiger Code']); + return; + } + + // Clear the 2FA code + $twoFactor = new UserTwofactor($userId); + $twoFactor->removeCode(); + + // Clear pending session + unset($_SESSION['mobileapp_2fa_pending']); + + // Get full user row for login completion + $res = $db->select(MFUSERTABLE, "*", "id = {$userId}"); + $userRow = $db->fetch_object($res); + + // Complete login + $this->completeLogin($userRow, $pending['remember_me']); + } + + /** + * POST /MobileApp/auth/resend2fa + * + * Resend the 2FA code + */ + protected function authResend2FA() { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + self::returnJson(['success' => false, 'error' => 'Method not allowed'], 405); + return; + } + + // Check for pending 2FA session + if (!isset($_SESSION['mobileapp_2fa_pending'])) { + self::returnJson(['success' => false, 'message' => 'Keine ausstehende Verifizierung']); + return; + } + + $pending = $_SESSION['mobileapp_2fa_pending']; + + // Check if pending session is expired (10 minutes max) + if (time() - $pending['timestamp'] > 600) { + unset($_SESSION['mobileapp_2fa_pending']); + self::returnJson(['success' => false, 'message' => 'Sitzung abgelaufen. Bitte erneut anmelden.', 'expired' => true]); + return; + } + + // Resend 2FA code + $twoFactor = new UserTwofactor($pending['user_id']); + $twoFactor->sendCode(); + + self::returnJson([ + 'success' => true, + 'message' => 'Neuer Code wurde gesendet' + ]); + } + + /** + * Complete the login process after password (and optionally 2FA) verification + */ + protected function completeLogin($userRow, $rememberMe) { + $db = FronkDB::singleton(); + + $db->update(MFUSERTABLE, [ + 'ip' => $_SERVER['REMOTE_ADDR'], + 'sessionid' => session_id() + ], "id = {$userRow->id}"); + + $_SESSION[MFAPPNAME . '_username'] = $userRow->username; + $_SESSION[MFAPPNAME . '_ip'] = $_SERVER['REMOTE_ADDR']; + + if ($rememberMe) { + UserToken::generateToken($userRow->id); + } + + $user = new User(); + $user->loadMe(); + + self::returnJson([ + 'success' => true, + 'user' => [ + 'id' => $user->id, + 'name' => $user->name, + 'username' => $user->username, + ] + ]); + } + + /** + * Mask email address for privacy (e.g., j***@example.com) + */ + protected function maskEmail($email) { + if (!$email) return '***'; + $parts = explode('@', $email); + if (count($parts) !== 2) return '***'; + $local = $parts[0]; + $domain = $parts[1]; + $masked = strlen($local) > 1 ? $local[0] . str_repeat('*', min(5, strlen($local) - 1)) : '*'; + return $masked . '@' . $domain; + } + + /** + * Mask phone number for privacy (e.g., +43***123) + */ + protected function maskPhone($phone) { + if (!$phone) return '***'; + $phone = preg_replace('/\s+/', '', $phone); + if (strlen($phone) < 6) return '***'; + return substr($phone, 0, 3) . str_repeat('*', strlen($phone) - 6) . substr($phone, -3); + } + + /** + * POST /MobileApp/auth/logout + */ + protected function authLogout() { + mfLoginController::staticLogout(); + self::returnJson(['success' => true]); + } + + /** + * GET /MobileApp/auth/check + */ + protected function authCheck() { + if (mfLoginController::isLoggedIn()) { + $user = new User(); + $user->loadMe(); + + if ($user->id) { + self::returnJson([ + 'authenticated' => true, + 'user' => [ + 'id' => $user->id, + 'name' => $user->name, + 'username' => $user->username, + ] + ]); + return; + } + } + + UserToken::checkToken(); + + if (isset($_SESSION[MFAPPNAME . '_username']) && $_SESSION[MFAPPNAME . '_username']) { + $user = new User(); + $user->loadMe(); + + if ($user->id) { + self::returnJson([ + 'authenticated' => true, + 'user' => [ + 'id' => $user->id, + 'name' => $user->name, + 'username' => $user->username, + ] + ]); + return; + } + } + + self::returnJson(['authenticated' => false]); + } + + /** + * Handle API calls to module endpoints + * /MobileApp/{module}/{submodule}/{endpoint} + */ + protected function handleApiCall($module, $submodule, $endpoint) { + // Normalize names + $moduleName = ucfirst(strtolower($module)); + $submoduleName = ucfirst(strtolower($submodule)); + + // Check authentication for API calls + if (!$this->user || !$this->user->id) { + self::returnJson(['success' => false, 'error' => 'Not authenticated'], 401); + return; + } + + // Build handler path + $handlerFile = APPDIR . "MobileApp/Modules/{$moduleName}/{$submoduleName}/{$submoduleName}Handler.php"; + + if (!file_exists($handlerFile)) { + self::returnJson(['success' => false, 'error' => "Module not found: {$moduleName}/{$submoduleName}"], 404); + return; + } + + require_once $handlerFile; + + $handlerClass = "{$submoduleName}Handler"; + + if (!class_exists($handlerClass)) { + self::returnJson(['success' => false, 'error' => "Handler class not found"], 500); + return; + } + + $handler = new $handlerClass($this->request, $this->user, $this); + + // Check permissions + if (!$handler->checkPermission()) { + self::returnJson(['success' => false, 'error' => 'Permission denied'], 403); + return; + } + + // Route to method + $method = $endpoint . 'Action'; + if (method_exists($handler, $method)) { + return $handler->$method(); + } + + if (method_exists($handler, $endpoint)) { + return $handler->$endpoint(); + } + + self::returnJson(['success' => false, 'error' => "Endpoint not found: {$endpoint}"], 404); + } +} diff --git a/application/MobileApp/Modules/Lager/Inventur/InventurHandler.php b/application/MobileApp/Modules/Lager/Inventur/InventurHandler.php new file mode 100644 index 000000000..75c1b36f3 --- /dev/null +++ b/application/MobileApp/Modules/Lager/Inventur/InventurHandler.php @@ -0,0 +1,443 @@ + Inventur module. + * API Base: /MobileApp/Lager/Inventur/{action} + */ +class InventurHandler extends MobileAppBaseHandler { + + protected $requiredPermission = 'WarehouseUser'; + + /** + * Get active stocktakes + * GET /MobileApp/Lager/Inventur/getActiveStocktakes + */ + public function getActiveStocktakesAction() { + $stocktakes = WarehouseStocktakeModel::getAll(['status' => 'in_progress']); + + $result = []; + foreach ($stocktakes as $stocktake) { + $location = $stocktake->getLocation(); + $result[] = [ + 'id' => $stocktake->id, + 'stocktakeNumber' => $stocktake->stocktakeNumber, + 'title' => $stocktake->title, + 'locationName' => $location ? $location->title : 'Unbekannt', + 'totalScannedItems' => $stocktake->totalScannedItems, + 'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null, + ]; + } + + self::returnJson(['success' => true, 'stocktakes' => $result]); + } + + /** + * Get stocktake details + */ + public function getStocktakeAction() { + $id = intval($this->request->id); + if (!$id) { + self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']); + return; + } + + $stocktake = WarehouseStocktakeModel::get($id); + if (!$stocktake) { + self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']); + return; + } + + $location = $stocktake->getLocation(); + + self::returnJson([ + 'success' => true, + 'stocktake' => [ + 'id' => $stocktake->id, + 'stocktakeNumber' => $stocktake->stocktakeNumber, + 'title' => $stocktake->title, + 'status' => $stocktake->status, + 'locationId' => $stocktake->warehouseLocationId, + 'locationName' => $location ? $location->title : 'Unbekannt', + 'totalScannedItems' => $stocktake->totalScannedItems, + 'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null, + ] + ]); + } + + /** + * Get article by QR code or article number + */ + public function getArticleAction() { + $code = $this->request->code; + + if (!$code) { + self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']); + return; + } + + $articleId = null; + + if (preg_match('/^(?:WA|WH):(\d+):/', $code, $matches)) { + $articleId = intval($matches[1]); + } else { + $article = WarehouseArticleModel::getFirst(['articleNumber' => $code]); + if ($article) { + $articleId = $article->id; + } + } + + if (!$articleId) { + self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']); + return; + } + + $article = WarehouseArticleModel::get($articleId); + if (!$article) { + self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']); + return; + } + + $category = WarehouseCategory::get($article->category_id); + + self::returnJson([ + 'success' => true, + 'article' => [ + 'id' => $article->id, + 'articleNumber' => $article->articleNumber, + 'title' => $article->title, + 'description' => $article->description ?? '', + 'unit' => $article->unit ?? 'Stk.', + 'categoryName' => $category ? $category->name : '', + ] + ]); + } + + /** + * Search articles + */ + public function searchArticlesAction() { + $query = $this->request->query ?? ''; + $categoryId = intval($this->request->categoryId ?? 0); + + $db = $this->db(); + $conditions = ["(isEndOfLife IS NULL OR isEndOfLife = 0)"]; + + if ($query && strlen($query) >= 2) { + $escapedQuery = $db->escape($query); + $conditions[] = "(articleNumber LIKE '%{$escapedQuery}%' OR title LIKE '%{$escapedQuery}%' OR description LIKE '%{$escapedQuery}%')"; + } + + if ($categoryId > 0) { + $conditions[] = "category_id = {$categoryId}"; + } + + if (count($conditions) === 1 && !$categoryId) { + self::returnJson(['success' => true, 'articles' => []]); + return; + } + + $whereClause = implode(' AND ', $conditions); + $result = $db->query("SELECT id, articleNumber, title, unit, category_id + FROM WarehouseArticle + WHERE {$whereClause} + ORDER BY title ASC + LIMIT 50"); + + $articles = []; + while ($row = $result->fetch_assoc()) { + $articles[] = [ + 'id' => intval($row['id']), + 'articleNumber' => $row['articleNumber'], + 'title' => $row['title'], + 'unit' => $row['unit'] ?? 'Stk.', + 'categoryId' => intval($row['category_id'] ?? 0), + ]; + } + + self::returnJson(['success' => true, 'articles' => $articles]); + } + + /** + * Get categories + */ + public function getCategoriesAction() { + $db = $this->db(); + $res = $db->query("SELECT id, name FROM WarehouseCategory ORDER BY name ASC"); + + $categories = []; + while ($row = $res->fetch_assoc()) { + $categories[] = [ + 'id' => intval($row['id']), + 'name' => $row['name'], + ]; + } + self::returnJson(['success' => true, 'categories' => $categories]); + } + + /** + * Check if already scanned + */ + public function checkAlreadyScannedAction() { + $stocktakeId = intval($this->request->stocktakeId); + $articleId = intval($this->request->articleId); + + if (!$stocktakeId || !$articleId) { + self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']); + return; + } + + $existing = WarehouseStocktakeItemModel::getFirst([ + 'stocktakeId' => $stocktakeId, + 'articleId' => $articleId, + 'overwrittenById' => null + ]); + + if ($existing) { + $db = $this->db(); + $scannedByResult = $db->query("SELECT name FROM Worker WHERE id = {$existing->scannedBy}"); + $scannedByRow = $scannedByResult->fetch_assoc(); + + self::returnJson([ + 'success' => true, + 'alreadyScanned' => true, + 'existingItem' => [ + 'id' => $existing->id, + 'countedQuantity' => $existing->countedQuantity, + 'scannedAt' => $existing->scannedAt ? date('d.m.Y H:i', $existing->scannedAt) : null, + 'scannedBy' => $scannedByRow ? $scannedByRow['name'] : 'Unbekannt', + ] + ]); + } else { + self::returnJson(['success' => true, 'alreadyScanned' => false]); + } + } + + /** + * Submit scan + */ + public function submitScanAction() { + $postData = $this->getPostData(); + + $stocktakeId = intval($postData['stocktakeId'] ?? 0); + $articleId = intval($postData['articleId'] ?? 0); + $quantity = floatval($postData['quantity'] ?? 0); + $rack = $postData['rack'] ?? null; + $shelf = $postData['shelf'] ?? null; + $note = $postData['note'] ?? null; + $overwrite = boolval($postData['overwrite'] ?? false); + $overwriteItemId = intval($postData['overwriteItemId'] ?? 0); + + if (!$stocktakeId || !$articleId) { + self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']); + return; + } + + if ($quantity <= 0) { + self::returnJson(['success' => false, 'message' => 'Menge muss größer als 0 sein']); + return; + } + + $stocktake = WarehouseStocktakeModel::get($stocktakeId); + if (!$stocktake) { + self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']); + return; + } + + if ($stocktake->status !== 'in_progress') { + self::returnJson(['success' => false, 'message' => 'Inventur ist nicht aktiv']); + return; + } + + $article = WarehouseArticleModel::get($articleId); + if (!$article) { + self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']); + return; + } + + $db = $this->db(); + + if ($overwrite && $overwriteItemId) { + $db->query("INSERT INTO WarehouseStocktakeItem + (stocktakeId, articleId, countedQuantity, rack, shelf, note, scannedAt, scannedBy, createBy, `create`) + VALUES ({$stocktakeId}, {$articleId}, {$quantity}, + " . ($rack ? "'{$db->escape($rack)}'" : "NULL") . ", + " . ($shelf ? "'{$db->escape($shelf)}'" : "NULL") . ", + " . ($note ? "'{$db->escape($note)}'" : "NULL") . ", + " . time() . ", {$this->user->id}, {$this->user->id}, " . time() . ")"); + + $itemId = $db->insert_id; + $db->query("UPDATE WarehouseStocktakeItem SET overwrittenById = {$itemId} WHERE id = {$overwriteItemId}"); + $finalQuantity = $quantity; + + WarehouseStocktakeLogModel::log($stocktakeId, 'overwritten', $itemId, [ + 'articleId' => $articleId, + 'articleNumber' => $article->articleNumber, + 'articleTitle' => $article->title, + 'quantity' => $quantity, + 'overwrittenItemId' => $overwriteItemId, + ]); + + $stocktake->updateProgress(); + + self::returnJson([ + 'success' => true, + 'message' => "'{$article->title}' überschrieben ({$quantity} {$article->unit})", + 'item' => [ + 'id' => $itemId, + 'articleId' => $articleId, + 'articleNumber' => $article->articleNumber, + 'articleTitle' => $article->title, + 'countedQuantity' => $finalQuantity, + 'unit' => $article->unit ?? 'Stk.', + 'rack' => $rack, + 'shelf' => $shelf, + 'isOverwrite' => true, + ] + ]); + return; + } + + $existing = WarehouseStocktakeItemModel::getFirst([ + 'stocktakeId' => $stocktakeId, + 'articleId' => $articleId, + 'overwrittenById' => null + ]); + + if ($existing) { + $newQuantity = $existing->countedQuantity + $quantity; + $db->query("UPDATE WarehouseStocktakeItem SET + countedQuantity = {$newQuantity}, + rack = " . ($rack ? "'{$db->escape($rack)}'" : "rack") . ", + shelf = " . ($shelf ? "'{$db->escape($shelf)}'" : "shelf") . ", + scannedAt = " . time() . ", + scannedBy = {$this->user->id} + WHERE id = {$existing->id}"); + + $itemId = $existing->id; + $finalQuantity = $newQuantity; + $isUpdate = true; + } else { + $db->query("INSERT INTO WarehouseStocktakeItem + (stocktakeId, articleId, countedQuantity, rack, shelf, note, scannedAt, scannedBy, createBy, `create`) + VALUES ({$stocktakeId}, {$articleId}, {$quantity}, + " . ($rack ? "'{$db->escape($rack)}'" : "NULL") . ", + " . ($shelf ? "'{$db->escape($shelf)}'" : "NULL") . ", + " . ($note ? "'{$db->escape($note)}'" : "NULL") . ", + " . time() . ", {$this->user->id}, {$this->user->id}, " . time() . ")"); + + $itemId = $db->insert_id; + $finalQuantity = $quantity; + $isUpdate = false; + } + + $stocktake->updateProgress(); + + WarehouseStocktakeLogModel::log($stocktakeId, 'scanned', $itemId, [ + 'articleId' => $articleId, + 'articleNumber' => $article->articleNumber, + 'articleTitle' => $article->title, + 'quantity' => $quantity, + 'totalQuantity' => $finalQuantity, + 'rack' => $rack, + 'shelf' => $shelf, + 'isUpdate' => $isUpdate, + ]); + + self::returnJson([ + 'success' => true, + 'message' => $isUpdate + ? "Menge für '{$article->title}' erhöht auf {$finalQuantity}" + : "'{$article->title}' hinzugefügt ({$quantity} {$article->unit})", + 'item' => [ + 'id' => $itemId, + 'articleId' => $articleId, + 'articleNumber' => $article->articleNumber, + 'articleTitle' => $article->title, + 'countedQuantity' => $finalQuantity, + 'unit' => $article->unit ?? 'Stk.', + 'rack' => $rack, + 'shelf' => $shelf, + 'isUpdate' => $isUpdate, + ] + ]); + } + + /** + * Get my scans + */ + public function getMyScansAction() { + $stocktakeId = intval($this->request->stocktakeId); + + if (!$stocktakeId) { + self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']); + return; + } + + $db = $this->db(); + $result = $db->query("SELECT si.*, wa.articleNumber, wa.title as articleTitle, wa.unit + FROM WarehouseStocktakeItem si + JOIN WarehouseArticle wa ON wa.id = si.articleId + WHERE si.stocktakeId = {$stocktakeId} + AND si.scannedBy = {$this->user->id} + ORDER BY si.scannedAt DESC + LIMIT 50"); + + $items = []; + while ($row = $result->fetch_assoc()) { + $items[] = [ + 'id' => intval($row['id']), + 'articleId' => intval($row['articleId']), + 'articleNumber' => $row['articleNumber'], + 'articleTitle' => $row['articleTitle'], + 'countedQuantity' => floatval($row['countedQuantity']), + 'unit' => $row['unit'] ?? 'Stk.', + 'rack' => $row['rack'], + 'shelf' => $row['shelf'], + 'scannedAt' => $row['scannedAt'] ? date('H:i', $row['scannedAt']) : null, + ]; + } + + self::returnJson(['success' => true, 'items' => $items]); + } + + /** + * Get progress + */ + public function getProgressAction() { + $stocktakeId = intval($this->request->stocktakeId); + + if (!$stocktakeId) { + self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']); + return; + } + + $stocktake = WarehouseStocktakeModel::get($stocktakeId); + if (!$stocktake) { + self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']); + return; + } + + $db = $this->db(); + + $totalResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId}"); + $totalRow = $totalResult->fetch_assoc(); + $totalScanned = intval($totalRow['count']); + + $myResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId} AND scannedBy = {$this->user->id}"); + $myRow = $myResult->fetch_assoc(); + $myScanned = intval($myRow['count']); + + self::returnJson([ + 'success' => true, + 'progress' => [ + 'totalScanned' => $totalScanned, + 'myScanned' => $myScanned, + 'status' => $stocktake->status, + ] + ]); + } +} diff --git a/application/MobileApp/Modules/Lager/Movement/MovementHandler.php b/application/MobileApp/Modules/Lager/Movement/MovementHandler.php new file mode 100644 index 000000000..19451ad01 --- /dev/null +++ b/application/MobileApp/Modules/Lager/Movement/MovementHandler.php @@ -0,0 +1,346 @@ + Movement module. + * API Base: /MobileApp/Lager/Movement/{action} + */ +class MovementHandler extends MobileAppBaseHandler { + + protected $requiredPermission = 'WarehouseUser'; + + /** + * Get available locations (Office + Außenlager only) + * GET /MobileApp/Lager/Movement/getLocations + */ + public function getLocationsAction() { + $allLocations = WarehouseLocationModel::getAll(); + $locations = []; + + foreach ($allLocations as $location) { + $title = strtolower($location->title); + if ($title === 'k1 fladnitz 150' || $title === 'aussenlager-extern') { + $locations[] = [ + 'id' => $location->id, + 'title' => $location->title, + ]; + } + } + + self::returnJson(['success' => true, 'locations' => $locations]); + } + + /** + * Get article by QR code or article number + * GET /MobileApp/Lager/Movement/getArticle?code=X + */ + public function getArticleAction() { + $code = $this->request->code; + + if (!$code) { + self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']); + return; + } + + $articleId = null; + + // Check for QR code format WA:ID: or WH:ID: + if (preg_match('/^(?:WA|WH):(\d+):/', $code, $matches)) { + $articleId = intval($matches[1]); + } else { + // Try to find by article number + $article = WarehouseArticleModel::getFirst(['articleNumber' => $code]); + if ($article) { + $articleId = $article->id; + } + } + + if (!$articleId) { + self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']); + return; + } + + $article = WarehouseArticleModel::get($articleId); + if (!$article) { + self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']); + return; + } + + $category = WarehouseCategory::get($article->category_id); + + self::returnJson([ + 'success' => true, + 'article' => [ + 'id' => $article->id, + 'articleNumber' => $article->articleNumber, + 'title' => $article->title, + 'description' => $article->description ?? '', + 'unit' => $article->unit ?? 'Stk.', + 'categoryName' => $category ? $category->name : '', + ] + ]); + } + + /** + * Search articles + * GET /MobileApp/Lager/Movement/searchArticles?query=X + */ + public function searchArticlesAction() { + $query = $this->request->query ?? ''; + + $db = $this->db(); + $conditions = ["(isEndOfLife IS NULL OR isEndOfLife = 0)"]; + + if ($query && strlen($query) >= 2) { + $escapedQuery = $db->escape($query); + $conditions[] = "(articleNumber LIKE '%{$escapedQuery}%' OR title LIKE '%{$escapedQuery}%' OR description LIKE '%{$escapedQuery}%')"; + } else { + self::returnJson(['success' => true, 'articles' => []]); + return; + } + + $whereClause = implode(' AND ', $conditions); + $result = $db->query("SELECT id, articleNumber, title, unit, category_id + FROM WarehouseArticle + WHERE {$whereClause} + ORDER BY title ASC + LIMIT 50"); + + $articles = []; + while ($row = $result->fetch_assoc()) { + $articles[] = [ + 'id' => intval($row['id']), + 'articleNumber' => $row['articleNumber'], + 'title' => $row['title'], + 'unit' => $row['unit'] ?? 'Stk.', + ]; + } + + self::returnJson(['success' => true, 'articles' => $articles]); + } + + /** + * Get reason categories for a movement type + * GET /MobileApp/Lager/Movement/getReasonCategories?type=IN|OUT|ADJUSTMENT + */ + public function getReasonCategoriesAction() { + $type = $this->request->type ?? null; + + $categories = WarehouseMovementModel::getReasonCategories($type); + + if ($type && is_array($categories)) { + $items = []; + foreach ($categories as $key => $label) { + $items[] = ['value' => $key, 'text' => $label]; + } + self::returnJson(['success' => true, 'categories' => $items]); + } else { + self::returnJson(['success' => true, 'categories' => $categories]); + } + } + + /** + * Get current stock for an article at a location + * GET /MobileApp/Lager/Movement/getCurrentStock?articleId=X&locationId=X + */ + public function getCurrentStockAction() { + $articleId = intval($this->request->articleId ?? 0); + $locationId = intval($this->request->locationId ?? 0); + + if (!$articleId || !$locationId) { + self::returnJson(['success' => true, 'currentStock' => 0]); + return; + } + + $existingItems = WarehouseItemModel::getAll([ + 'articleId' => $articleId, + 'warehouseLocationId' => $locationId + ]); + + $currentStock = count($existingItems) > 0 ? floatval($existingItems[0]->quantity) : 0; + + self::returnJson(['success' => true, 'currentStock' => $currentStock]); + } + + /** + * Submit a stock movement + * POST /MobileApp/Lager/Movement/submitMovement + */ + public function submitMovementAction() { + $postData = $this->getPostData(); + + $movementType = $postData['movementType'] ?? ''; + $articleId = intval($postData['articleId'] ?? 0); + $locationId = intval($postData['locationId'] ?? 0); + $quantity = floatval($postData['quantity'] ?? 0); + $reasonCategory = $postData['reasonCategory'] ?? ''; + $note = $postData['note'] ?? null; + + // Validate required fields + if (!in_array($movementType, ['IN', 'OUT', 'ADJUSTMENT'])) { + self::returnJson(['success' => false, 'message' => 'Ungültiger Bewegungstyp']); + return; + } + + if ($articleId <= 0) { + self::returnJson(['success' => false, 'message' => 'Kein Artikel ausgewählt']); + return; + } + + if ($locationId <= 0) { + self::returnJson(['success' => false, 'message' => 'Kein Lagerort ausgewählt']); + return; + } + + if ($quantity <= 0) { + self::returnJson(['success' => false, 'message' => 'Menge muss größer als 0 sein']); + return; + } + + if (empty($reasonCategory)) { + self::returnJson(['success' => false, 'message' => 'Bitte Grund auswählen']); + return; + } + + // Get article info + $article = WarehouseArticleModel::get($articleId); + if (!$article) { + self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']); + return; + } + + $db = $this->db(); + + // Find or create WarehouseItem for this article at this location + $existingItems = WarehouseItemModel::getAll([ + 'articleId' => $articleId, + 'warehouseLocationId' => $locationId + ]); + + $warehouseItem = count($existingItems) > 0 ? $existingItems[0] : null; + $currentQty = $warehouseItem ? floatval($warehouseItem->quantity) : 0; + + // Calculate new quantity based on movement type + // Note: Negative stock is allowed (items can be taken out even if stock is 0) + switch ($movementType) { + case 'IN': + $newQty = $currentQty + $quantity; + break; + case 'OUT': + $newQty = $currentQty - $quantity; + // Negative stock is allowed - no validation needed + break; + case 'ADJUSTMENT': + // For adjustment, quantity is the new absolute value + $newQty = $quantity; + break; + default: + $newQty = $currentQty; + } + + // Update or create WarehouseItem + $warehouseItemId = null; + if ($warehouseItem) { + $db->query("UPDATE WarehouseItem SET quantity = {$newQty} WHERE id = {$warehouseItem->id}"); + $warehouseItemId = $warehouseItem->id; + } else { + $db->query("INSERT INTO WarehouseItem (articleId, warehouseLocationId, quantity, createBy, `create`) + VALUES ({$articleId}, {$locationId}, {$newQty}, {$this->user->id}, " . time() . ")"); + $warehouseItemId = $db->insert_id; + } + + // Create the movement record + $noteEscaped = $note ? "'" . $db->escape($note) . "'" : "NULL"; + $db->query("INSERT INTO WarehouseMovement + (movementType, articleId, warehouseLocationId, warehouseItemId, quantity, quantityBefore, quantityAfter, reasonCategory, note, userId, createBy, `create`) + VALUES ('{$movementType}', {$articleId}, {$locationId}, {$warehouseItemId}, {$quantity}, {$currentQty}, {$newQty}, '{$db->escape($reasonCategory)}', {$noteEscaped}, {$this->user->id}, {$this->user->id}, " . time() . ")"); + + $movementId = $db->insert_id; + + // Generate movement number + $movementNumber = WarehouseMovementModel::generateMovementNumber(); + $db->query("UPDATE WarehouseMovement SET movementNumber = '{$movementNumber}' WHERE id = {$movementId}"); + + // Get type label for message + $typeLabels = ['IN' => 'Einbuchung', 'OUT' => 'Ausbuchung', 'ADJUSTMENT' => 'Korrektur']; + $typeLabel = $typeLabels[$movementType] ?? $movementType; + + self::returnJson([ + 'success' => true, + 'message' => "{$typeLabel} erfolgreich: {$quantity} x {$article->title}", + 'movement' => [ + 'id' => $movementId, + 'movementNumber' => $movementNumber, + 'movementType' => $movementType, + 'articleId' => $articleId, + 'articleTitle' => $article->title, + 'quantity' => $quantity, + 'quantityBefore' => $currentQty, + 'quantityAfter' => $newQty, + ] + ]); + } + + /** + * Get recent movements by current user + * GET /MobileApp/Lager/Movement/getMyMovements + */ + public function getMyMovementsAction() { + $locationId = intval($this->request->locationId ?? 0); + $limit = intval($this->request->limit ?? 20); + + $db = $this->db(); + + $whereClause = "m.userId = {$this->user->id}"; + if ($locationId > 0) { + $whereClause .= " AND m.warehouseLocationId = {$locationId}"; + } + + $result = $db->query("SELECT m.*, wa.articleNumber, wa.title as articleTitle, wa.unit, wl.title as locationTitle + FROM WarehouseMovement m + LEFT JOIN WarehouseArticle wa ON wa.id = m.articleId + LEFT JOIN WarehouseLocation wl ON wl.id = m.warehouseLocationId + WHERE {$whereClause} + ORDER BY m.`create` DESC + LIMIT {$limit}"); + + $movements = []; + while ($row = $result->fetch_assoc()) { + $movements[] = [ + 'id' => intval($row['id']), + 'movementNumber' => $row['movementNumber'], + 'movementType' => $row['movementType'], + 'articleId' => intval($row['articleId']), + 'articleNumber' => $row['articleNumber'], + 'articleTitle' => $row['articleTitle'], + 'unit' => $row['unit'] ?? 'Stk.', + 'locationTitle' => $row['locationTitle'], + 'quantity' => floatval($row['quantity']), + 'quantityBefore' => floatval($row['quantityBefore']), + 'quantityAfter' => floatval($row['quantityAfter']), + 'reasonCategory' => $row['reasonCategory'], + 'note' => $row['note'], + 'create' => date('d.m.Y H:i', $row['create']), + ]; + } + + self::returnJson(['success' => true, 'movements' => $movements]); + } + + /** + * Get movement types with labels + * GET /MobileApp/Lager/Movement/getMovementTypes + */ + public function getMovementTypesAction() { + $types = [ + ['value' => 'IN', 'text' => 'Einbuchung', 'icon' => 'plus-circle', 'color' => 'green'], + ['value' => 'OUT', 'text' => 'Ausbuchung', 'icon' => 'minus-circle', 'color' => 'red'], + ['value' => 'ADJUSTMENT', 'text' => 'Korrektur', 'icon' => 'edit', 'color' => 'yellow'], + ]; + + self::returnJson(['success' => true, 'types' => $types]); + } +} diff --git a/application/MobileApp/Shared/MobileAppBaseHandler.php b/application/MobileApp/Shared/MobileAppBaseHandler.php new file mode 100644 index 000000000..508d77e7d --- /dev/null +++ b/application/MobileApp/Shared/MobileAppBaseHandler.php @@ -0,0 +1,113 @@ +request = $request; + $this->user = $user; + $this->controller = $controller; + } + + /** + * Check if user has required permission + * @return bool + */ + public function checkPermission() { + // If no permission required, allow access + if (!$this->requiredPermission) { + return true; + } + + // If no user, deny access + if (!$this->user || !$this->user->id) { + return false; + } + + // Check permission + return $this->user->can($this->requiredPermission); + } + + /** + * Render the app view + * Override in subclass if custom rendering needed + */ + public function renderView() { + $layout = $this->controller->layout(); + + // Set template + if ($this->viewTemplate) { + $layout->setTemplate($this->viewTemplate); + } else { + $layout->setTemplate("MobileApp/{$this->appName}"); + } + + // Set default JS globals + $layout->set("JSGlobals", $this->getJSGlobals()); + } + + /** + * Get JS globals to pass to frontend + * Override in subclass to add app-specific globals + */ + protected function getJSGlobals() { + $globals = [ + 'BASE_PATH' => '/MobileApp/' . $this->appName, + 'APP_NAME' => $this->appName, + ]; + + if ($this->user && $this->user->id) { + $globals['USER_ID'] = $this->user->id; + $globals['USER_NAME'] = $this->user->name; + } + + return $globals; + } + + /** + * Return JSON response (shorthand) + */ + protected static function returnJson($data, $statusCode = 200) { + mfBaseController::returnJson($data, $statusCode); + } + + /** + * Get POST data from JSON body + */ + protected function getPostData() { + return json_decode(file_get_contents('php://input'), true) ?? []; + } + + /** + * Get database instance + */ + protected function db() { + return FronkDB::singleton(); + } +} diff --git a/application/WarehouseMovement/WarehouseMovementController.php b/application/WarehouseMovement/WarehouseMovementController.php new file mode 100644 index 000000000..df04de7a9 --- /dev/null +++ b/application/WarehouseMovement/WarehouseMovementController.php @@ -0,0 +1,258 @@ + 'movementNumber', 'text' => 'Bewegungs-Nr.', 'required' => false, + 'modal' => false, + 'table' => ['priority' => 10]], + ['key' => 'movementType', 'text' => 'Typ', 'required' => true, + 'modal' => ['type' => 'select', 'items' => []], + 'table' => ['priority' => 9, 'filter' => 'iconSelect', 'filterOptions' => [ + ['value' => 'IN', 'text' => 'Einbuchung', 'icon' => 'fas fa-plus-circle text-success'], + ['value' => 'OUT', 'text' => 'Ausbuchung', 'icon' => 'fas fa-minus-circle text-danger'], + ['value' => 'ADJUSTMENT', 'text' => 'Korrektur', 'icon' => 'fas fa-edit text-warning'], + ]]], + ['key' => 'articleId', 'text' => 'Artikel', 'required' => true, + 'modal' => ['type' => 'articleSelect'], + 'table' => ['priority' => 8, 'sortable' => false, 'filter' => 'text']], + ['key' => 'warehouseLocationId', 'text' => 'Lagerort', 'required' => true, + 'modal' => ['type' => 'select', 'items' => []], + 'table' => ['priority' => 7, 'filter' => 'select']], + ['key' => 'quantity', 'text' => 'Menge', 'required' => true, + 'modal' => ['type' => 'number', 'step' => '0.01', 'min' => '0.01'], + 'table' => ['priority' => 6, 'filter' => false]], + ['key' => 'quantityBefore', 'text' => 'Bestand vorher', 'required' => false, + 'modal' => false, + 'table' => ['priority' => 5, 'filter' => false]], + ['key' => 'quantityAfter', 'text' => 'Bestand nachher', 'required' => false, + 'modal' => false, + 'table' => ['priority' => 4, 'filter' => false]], + ['key' => 'reasonCategory', 'text' => 'Grund', 'required' => true, + 'modal' => ['type' => 'select', 'items' => [], 'dependsOn' => 'movementType'], + 'table' => ['priority' => 3, 'filter' => false]], + ['key' => 'note', 'text' => 'Notiz', 'required' => false, + 'modal' => ['type' => 'textarea'], + 'table' => ['priority' => 2, 'filter' => false]], + ['key' => 'create', 'text' => 'Erstellt', 'required' => false, + 'modal' => false, + 'table' => ['priority' => 1, 'filter' => 'dateRange']], + ]; + + protected array $additionalActions = []; + + protected array $permissionCheck = ['WarehouseUser']; + + protected array $infoMessages = [ + 'create' => 'Lagerbewegung wurde erstellt', + 'update' => 'Lagerbewegung wurde aktualisiert', + 'delete' => 'Lagerbewegung wurde gelöscht', + 'noChanges' => 'Keine Änderungen', + ]; + + public function prepareCrudConfig() { + // Populate movement type dropdown + $movementTypes = [ + ['value' => 'IN', 'text' => 'Einbuchung'], + ['value' => 'OUT', 'text' => 'Ausbuchung'], + ['value' => 'ADJUSTMENT', 'text' => 'Korrektur'], + ]; + + // Populate locations dropdown (Office + Außenlager only) + $allLocations = WarehouseLocationModel::getAll(); + $locations = []; + foreach ($allLocations as $location) { + $title = strtolower($location->title); + if ($title === 'k1 fladnitz 150' || $title === 'aussenlager-extern') { + $locations[] = ['value' => $location->id, 'text' => $location->title]; + } + } + + // Get all reason categories for initial load + $allReasons = WarehouseMovementModel::getReasonCategories(); + $reasonItems = []; + foreach ($allReasons as $type => $categories) { + foreach ($categories as $key => $label) { + $reasonItems[] = ['value' => $key, 'text' => $label, 'group' => $type]; + } + } + + foreach ($this->columns as &$col) { + if ($col['key'] === 'movementType') { + $col['modal']['items'] = $movementTypes; + } + if ($col['key'] === 'warehouseLocationId') { + $col['modal']['items'] = $locations; + $col['table']['filterOptions'] = $locations; + } + if ($col['key'] === 'reasonCategory') { + $col['modal']['items'] = $reasonItems; + } + } + + $this->additionalJSVariables['REASON_CATEGORIES'] = $allReasons; + } + + protected function beforeCreate(): bool { + // Validate required fields + $movementType = $this->postData['movementType'] ?? ''; + $articleId = intval($this->postData['articleId'] ?? 0); + $locationId = intval($this->postData['warehouseLocationId'] ?? 0); + $quantity = floatval($this->postData['quantity'] ?? 0); + + if (!in_array($movementType, ['IN', 'OUT', 'ADJUSTMENT'])) { + $this->returnJson(['success' => false, 'message' => 'Ungültiger Bewegungstyp']); + return false; + } + + if ($articleId <= 0) { + $this->returnJson(['success' => false, 'message' => 'Kein Artikel ausgewählt']); + return false; + } + + if ($locationId <= 0) { + $this->returnJson(['success' => false, 'message' => 'Kein Lagerort ausgewählt']); + return false; + } + + if ($quantity <= 0) { + $this->returnJson(['success' => false, 'message' => 'Menge muss größer als 0 sein']); + return false; + } + + // Find or create WarehouseItem for this article at this location + $db = FronkDB::singleton(); + $existingItems = WarehouseItemModel::getAll([ + 'articleId' => $articleId, + 'warehouseLocationId' => $locationId + ]); + + $warehouseItem = count($existingItems) > 0 ? $existingItems[0] : null; + $currentQty = $warehouseItem ? floatval($warehouseItem->quantity) : 0; + + // Calculate new quantity based on movement type + // Note: Negative stock is allowed (items can be taken out even if stock is 0) + switch ($movementType) { + case 'IN': + $newQty = $currentQty + $quantity; + break; + case 'OUT': + $newQty = $currentQty - $quantity; + // Negative stock is allowed - no validation needed + break; + case 'ADJUSTMENT': + // For adjustment, quantity is the new absolute value + $newQty = $quantity; + break; + default: + $newQty = $currentQty; + } + + // Store before/after quantities + $this->postData['quantityBefore'] = $currentQty; + $this->postData['quantityAfter'] = $newQty; + $this->postData['userId'] = $this->user->id; + + // Update or create WarehouseItem + if ($warehouseItem) { + $db->query("UPDATE WarehouseItem SET quantity = {$newQty} WHERE id = {$warehouseItem->id}"); + $this->postData['warehouseItemId'] = $warehouseItem->id; + } else { + $db->query("INSERT INTO WarehouseItem (articleId, warehouseLocationId, quantity, createBy, `create`) + VALUES ({$articleId}, {$locationId}, {$newQty}, {$this->user->id}, " . time() . ")"); + $this->postData['warehouseItemId'] = $db->insert_id(); + } + + return true; + } + + protected function afterCreate($postData) { + // Generate movement number + $movement = WarehouseMovementModel::get($postData['id']); + if ($movement) { + $movementNumber = WarehouseMovementModel::generateMovementNumber(); + $db = FronkDB::singleton(); + $db->query("UPDATE WarehouseMovement SET movementNumber = '{$movementNumber}' WHERE id = {$movement->id}"); + } + } + + protected function customRowsHandler($rows) { + return array_map(fn($row) => $this->formatRow((array)$row), $rows); + } + + protected function formatRow($row) { + // Format movement type with badge + $typeLabels = [ + 'IN' => 'Einbuchung', + 'OUT' => 'Ausbuchung', + 'ADJUSTMENT' => 'Korrektur', + ]; + $row['movementType'] = $typeLabels[$row['movementType']] ?? $row['movementType']; + + // Format article + if (!empty($row['articleId'])) { + $article = ArticleModel::get($row['articleId']); + if ($article) { + $row['articleId'] = "{$article->articleNumber}
    {$article->title}"; + } + } + + // Format quantities + $row['quantityBefore'] = $row['quantityBefore'] !== null ? number_format((float)$row['quantityBefore'], 2, ',', '.') : '-'; + $row['quantityAfter'] = $row['quantityAfter'] !== null ? number_format((float)$row['quantityAfter'], 2, ',', '.') : '-'; + $row['quantity'] = number_format((float)$row['quantity'], 2, ',', '.'); + + // Format reason category + $row['reasonCategory'] = WarehouseMovementModel::getReasonCategories()[$row['movementType']][$row['reasonCategory']] ?? $row['reasonCategory']; + + // Format create date + if (!empty($row['create'])) { + $row['create'] = date('d.m.Y H:i', $row['create']); + } + + return $row; + } + + /** + * Get reason categories for a specific movement type + */ + protected function getReasonCategoriesAction() { + $type = $this->request->type ?? null; + $categories = WarehouseMovementModel::getReasonCategories($type); + + if ($type && is_array($categories)) { + $items = []; + foreach ($categories as $key => $label) { + $items[] = ['value' => $key, 'text' => $label]; + } + self::returnJson(['success' => true, 'categories' => $items]); + } else { + self::returnJson(['success' => true, 'categories' => $categories]); + } + } + + /** + * Get current stock for an article at a location + */ + protected function getCurrentStockAction() { + $articleId = intval($this->request->articleId ?? 0); + $locationId = intval($this->request->locationId ?? 0); + + if (!$articleId || !$locationId) { + self::returnJson(['success' => false, 'currentStock' => 0]); + return; + } + + $existingItems = WarehouseItemModel::getAll([ + 'articleId' => $articleId, + 'warehouseLocationId' => $locationId + ]); + + $currentStock = count($existingItems) > 0 ? floatval($existingItems[0]->quantity) : 0; + + self::returnJson(['success' => true, 'currentStock' => $currentStock]); + } +} diff --git a/application/WarehouseMovement/WarehouseMovementModel.php b/application/WarehouseMovement/WarehouseMovementModel.php new file mode 100644 index 000000000..23cfd4407 --- /dev/null +++ b/application/WarehouseMovement/WarehouseMovementModel.php @@ -0,0 +1,137 @@ +query("SELECT movementNumber FROM WarehouseMovement + WHERE movementNumber LIKE '{$prefix}%' + ORDER BY movementNumber DESC LIMIT 1"); + + if ($row = $result->fetch_assoc()) { + $lastNumber = intval(substr($row['movementNumber'], -6)); + $nextNumber = $lastNumber + 1; + } else { + $nextNumber = 1; + } + + return $prefix . str_pad((string)$nextNumber, 6, '0', STR_PAD_LEFT); + } + + /** + * Get reason categories for a movement type + */ + public static function getReasonCategories(?string $type = null): array { + $categories = [ + 'IN' => [ + 'Warenlieferung' => 'Warenlieferung', + 'Rueckgabe' => 'Rückgabe', + 'Gefunden' => 'Gefunden/Inventurdifferenz', + 'UmlagerungEingang' => 'Umlagerung (Eingang)', + 'Erstbestand' => 'Erstbestand', + 'Sonstiges' => 'Sonstiges' + ], + 'OUT' => [ + 'Verbrauch' => 'Verbrauch', + 'Beschaedigung' => 'Beschädigung/Defekt', + 'Verlust' => 'Verlust/Schwund', + 'UmlagerungAusgang' => 'Umlagerung (Ausgang)', + 'Entsorgung' => 'Entsorgung', + 'Sonstiges' => 'Sonstiges' + ], + 'ADJUSTMENT' => [ + 'Inventurkorrektur' => 'Inventurkorrektur', + 'Buchungsfehler' => 'Buchungsfehler', + 'Systemkorrektur' => 'Systemkorrektur', + 'SonstigeKorrektur' => 'Sonstige Korrektur' + ] + ]; + + if ($type && isset($categories[$type])) { + return $categories[$type]; + } + + return $categories; + } + + /** + * Get movement type labels + */ + public static function getMovementTypes(): array { + return [ + 'IN' => 'Einbuchung', + 'OUT' => 'Ausbuchung', + 'ADJUSTMENT' => 'Korrektur' + ]; + } + + /** + * Get article object + */ + public function getArticle(): ?ArticleModel { + return ArticleModel::get($this->articleId); + } + + /** + * Get location object + */ + public function getLocation(): ?WarehouseLocationModel { + return WarehouseLocationModel::get($this->warehouseLocationId); + } + + /** + * Get user who made the movement + */ + public function getUser(): ?UserModel { + return UserModel::get($this->userId); + } + + /** + * Get warehouse item if linked + */ + public function getWarehouseItem(): ?WarehouseItemModel { + if (!$this->warehouseItemId) return null; + return WarehouseItemModel::get($this->warehouseItemId); + } + + /** + * Get formatted movement type label + */ + public function getMovementTypeLabel(): string { + $types = self::getMovementTypes(); + return $types[$this->movementType] ?? $this->movementType; + } + + /** + * Get formatted reason category label + */ + public function getReasonCategoryLabel(): string { + $allCategories = self::getReasonCategories(); + foreach ($allCategories as $typeCategories) { + if (isset($typeCategories[$this->reasonCategory])) { + return $typeCategories[$this->reasonCategory]; + } + } + return $this->reasonCategory; + } +} diff --git a/db/migrations/20260113130000_create_warehouse_lagerbewegung.php b/db/migrations/20260113130000_create_warehouse_lagerbewegung.php new file mode 100644 index 000000000..fea8726bf --- /dev/null +++ b/db/migrations/20260113130000_create_warehouse_lagerbewegung.php @@ -0,0 +1,42 @@ +getEnvironment() == "thetool") { + $lagerbewegung = $this->table('WarehouseLagerbewegung'); + $lagerbewegung + ->addColumn('movementNumber', 'string', ['limit' => 50, 'null' => true]) + ->addColumn('movementType', 'enum', ['values' => ['IN', 'OUT', 'ADJUSTMENT']]) + ->addColumn('articleId', 'integer', ['signed' => false]) + ->addColumn('warehouseLocationId', 'integer', ['signed' => true]) + ->addColumn('warehouseItemId', 'integer', ['null' => true, 'signed' => true]) + ->addColumn('quantity', 'decimal', ['precision' => 10, 'scale' => 2]) + ->addColumn('quantityBefore', 'decimal', ['precision' => 10, 'scale' => 2, 'null' => true]) + ->addColumn('quantityAfter', 'decimal', ['precision' => 10, 'scale' => 2, 'null' => true]) + ->addColumn('reasonCategory', 'string', ['limit' => 50]) + ->addColumn('note', 'text', ['null' => true]) + ->addColumn('userId', 'integer', ['signed' => false]) + ->addColumn('createBy', 'integer', ['signed' => false]) + ->addColumn('create', 'integer') + ->addIndex(['movementNumber'], ['unique' => true]) + ->addIndex(['articleId']) + ->addIndex(['warehouseLocationId']) + ->addIndex(['movementType']) + ->addIndex(['userId']) + ->addIndex(['create']) + ->create(); + } + } + + public function down(): void + { + if ($this->getEnvironment() == "thetool") { + $this->table('WarehouseLagerbewegung')->drop()->save(); + } + } +} diff --git a/db/migrations/20260113140000_rename_lagerbewegung_to_movement.php b/db/migrations/20260113140000_rename_lagerbewegung_to_movement.php new file mode 100644 index 000000000..b368d3754 --- /dev/null +++ b/db/migrations/20260113140000_rename_lagerbewegung_to_movement.php @@ -0,0 +1,21 @@ +getEnvironment() == "thetool") { + $this->table('WarehouseLagerbewegung')->rename('WarehouseMovement')->save(); + } + } + + public function down(): void + { + if ($this->getEnvironment() == "thetool") { + $this->table('WarehouseMovement')->rename('WarehouseLagerbewegung')->save(); + } + } +} diff --git a/public/.htaccess b/public/.htaccess index efdafabbc..7485068b2 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -25,6 +25,32 @@ RewriteCond %{REQUEST_FILENAME} !-l RewriteRule ^api/(v\d+)/([^/]+)(/.+)$ index.php?action=Api&apiv=$1&apicall=$2&apiparams=$3 [QSA] +# MobileApp routing: /MobileApp/{module}/{submodule}/{action} +# Example: /MobileApp/Lager/Inventur/getActiveStocktakes +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteCond %{REQUEST_FILENAME} !-l +RewriteRule ^MobileApp/([^/]+)/([^/]+)/([^/]+)/?$ index.php?action=MobileApp&module=$1&submodule=$2&endpoint=$3 [QSA,L] + +# /MobileApp/{module}/{submodule} - e.g., /MobileApp/Lager/Inventur +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteCond %{REQUEST_FILENAME} !-l +RewriteRule ^MobileApp/([^/]+)/([^/]+)/?$ index.php?action=MobileApp&module=$1&submodule=$2 [QSA,L] + +# /MobileApp/{module} - e.g., /MobileApp/auth or /MobileApp/Lager +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteCond %{REQUEST_FILENAME} !-l +RewriteRule ^MobileApp/([^/]+)/?$ index.php?action=MobileApp&module=$1 [QSA,L] + +# /MobileApp - Main app +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteCond %{REQUEST_FILENAME} !-l +RewriteRule ^MobileApp/?$ index.php?action=MobileApp [QSA,L] + + # regular web calls RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d diff --git a/public/mobile/app.js b/public/mobile/app.js new file mode 100644 index 000000000..537c82703 --- /dev/null +++ b/public/mobile/app.js @@ -0,0 +1,581 @@ +/** + * MobileApp PWA - Main Vue Application + * + * Unified mobile app with module navigation. + * Routes: /MobileApp -> Home, /MobileApp/Lager -> Module, /MobileApp/Lager/Inventur -> Submodule + */ + +import { authState, checkAuth, login, logout } from '/mobile/shared/auth.js'; +import LoginScreen from '/mobile/components/LoginScreen.js'; +import MainMenu from '/mobile/components/MainMenu.js'; +import LagerModule from '/mobile/modules/lager/LagerModule.js'; + +const { createApp, ref, computed, onMounted, onUnmounted, watch } = Vue; + +// Check if running as installed PWA +const isPWAInstalled = () => { + // Check display-mode standalone (Android Chrome, desktop) + if (window.matchMedia('(display-mode: standalone)').matches) return true; + // Check iOS Safari standalone mode + if (window.navigator.standalone === true) return true; + // Check if launched from TWA (Trusted Web Activity) + if (document.referrer.includes('android-app://')) return true; + return false; +}; + +// Check if we should require PWA installation +const shouldRequirePWA = () => { + const hostname = window.location.hostname; + // Only require PWA on production domain + return hostname === 'thetool.xinon.at'; +}; + +// Parse initial path from config +const parseInitialRoute = () => { + const initialPath = window.TT_CONFIG?.INITIAL_PATH || '/MobileApp'; + const parts = initialPath.replace('/MobileApp', '').split('/').filter(Boolean); + return { + module: parts[0] || null, + submodule: parts[1] || null + }; +}; + +const App = { + components: { + LoginScreen, + MainMenu, + LagerModule + }, + + setup() { + // ==================== STATE ==================== + const currentView = ref('loading'); + const user = ref(null); + const toast = ref({ show: false, message: '', type: 'success' }); + const theme = ref('system'); + const showSettings = ref(false); + + // Module-specific settings + const lagerSimpleMode = ref(false); + + // Navigation state + const currentModule = ref(null); + const currentSubmodule = ref(null); + + // PWA Install state + const showInstallPrompt = ref(false); + const deferredInstallPrompt = ref(null); + const isIOS = ref(/iPad|iPhone|iPod/.test(navigator.userAgent)); + const isAndroid = ref(/Android/.test(navigator.userAgent)); + + // Can go back? + const canGoBack = computed(() => currentModule.value !== null); + + // ==================== THEME ==================== + const applyTheme = () => { + const isDark = localStorage.theme === 'dark' || + (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches); + document.documentElement.classList.toggle('dark', isDark); + + const metaThemeColor = document.querySelector('meta[name="theme-color"]'); + if (metaThemeColor) { + metaThemeColor.setAttribute('content', isDark ? '#0f172a' : '#005384'); + } + }; + + const setTheme = (newTheme) => { + theme.value = newTheme; + if (newTheme === 'system') { + localStorage.removeItem('theme'); + } else { + localStorage.setItem('theme', newTheme); + } + applyTheme(); + }; + + // ==================== PWA INSTALL ==================== + const handleInstallPrompt = (e) => { + // Prevent Chrome's default install prompt + e.preventDefault(); + // Store the event for later use + deferredInstallPrompt.value = e; + }; + + const triggerInstall = async () => { + if (!deferredInstallPrompt.value) return; + + // Show the install prompt + deferredInstallPrompt.value.prompt(); + + // Wait for user response + const { outcome } = await deferredInstallPrompt.value.userChoice; + + if (outcome === 'accepted') { + showInstallPrompt.value = false; + // Reload to get standalone mode + window.location.reload(); + } + + deferredInstallPrompt.value = null; + }; + + // ==================== LAGER SETTINGS ==================== + const loadLagerSettings = () => { + try { + const saved = localStorage.getItem('movement_settings'); + if (saved) { + const settings = JSON.parse(saved); + lagerSimpleMode.value = settings.simpleMode || false; + } + } catch (e) {} + }; + + const setLagerSimpleMode = (value) => { + lagerSimpleMode.value = value; + try { + const saved = localStorage.getItem('movement_settings'); + const settings = saved ? JSON.parse(saved) : {}; + settings.simpleMode = value; + localStorage.setItem('movement_settings', JSON.stringify(settings)); + } catch (e) {} + }; + + // ==================== NAVIGATION ==================== + const navigate = (module, submodule = null) => { + currentModule.value = module; + currentSubmodule.value = submodule; + + // Update browser URL + let path = '/MobileApp'; + if (module) path += '/' + module; + if (submodule) path += '/' + submodule; + history.pushState({ module, submodule }, '', path); + }; + + const goHome = () => { + navigate(null, null); + }; + + const goBack = () => { + if (currentSubmodule.value) { + navigate(currentModule.value, null); + } else if (currentModule.value) { + navigate(null, null); + } + }; + + // Handle browser back button + window.addEventListener('popstate', (event) => { + if (event.state) { + currentModule.value = event.state.module; + currentSubmodule.value = event.state.submodule; + } else { + currentModule.value = null; + currentSubmodule.value = null; + } + }); + + // ==================== AUTH ==================== + const handleLogin = async (credentials) => { + // Handle 2FA success (already verified in LoginScreen) + if (credentials._2faSuccess) { + user.value = credentials.user; + currentView.value = 'app'; + showToast('Erfolgreich angemeldet', 'success'); + return { success: true }; + } + + const result = await login(credentials); + if (result.success) { + user.value = result.user; + currentView.value = 'app'; + showToast('Erfolgreich angemeldet', 'success'); + } + return result; + }; + + const handleLogout = async () => { + await logout(); + user.value = null; + currentModule.value = null; + currentSubmodule.value = null; + currentView.value = 'login'; + showToast('Abgemeldet', 'success'); + }; + + // ==================== TOAST ==================== + const showToast = (message, type = 'success') => { + toast.value = { show: true, message, type }; + setTimeout(() => { + toast.value.show = false; + }, 3000); + }; + + // ==================== COMPUTED ==================== + const currentComponent = computed(() => { + if (currentView.value !== 'app') return null; + if (!currentModule.value) return 'MainMenu'; + if (currentModule.value.toLowerCase() === 'lager') return 'LagerModule'; + return 'MainMenu'; + }); + + const breadcrumbs = computed(() => { + const crumbs = [{ label: 'Home', module: null, submodule: null }]; + if (currentModule.value) { + crumbs.push({ label: currentModule.value, module: currentModule.value, submodule: null }); + } + if (currentSubmodule.value) { + crumbs.push({ label: currentSubmodule.value, module: currentModule.value, submodule: currentSubmodule.value }); + } + return crumbs; + }); + + // ==================== LIFECYCLE ==================== + onMounted(async () => { + // Initialize theme + const savedTheme = localStorage.getItem('theme'); + if (savedTheme) { + theme.value = savedTheme; + } + applyTheme(); + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyTheme); + + // Load module settings + loadLagerSettings(); + + // Listen for beforeinstallprompt (Android) + window.addEventListener('beforeinstallprompt', handleInstallPrompt); + + // Check if PWA is required but not installed + if (shouldRequirePWA() && !isPWAInstalled()) { + showInstallPrompt.value = true; + currentView.value = 'install'; + return; + } + + // Check authentication + const result = await checkAuth(); + if (result.authenticated) { + user.value = result.user; + currentView.value = 'app'; + + // Parse initial route + const initialRoute = parseInitialRoute(); + currentModule.value = initialRoute.module; + currentSubmodule.value = initialRoute.submodule; + + // Set initial history state + history.replaceState( + { module: initialRoute.module, submodule: initialRoute.submodule }, + '', + window.location.pathname + ); + } else { + currentView.value = 'login'; + } + }); + + return { + currentView, + user, + toast, + theme, + showSettings, + currentModule, + currentSubmodule, + currentComponent, + canGoBack, + breadcrumbs, + handleLogin, + handleLogout, + navigate, + goHome, + goBack, + showToast, + setTheme, + lagerSimpleMode, + setLagerSimpleMode, + // PWA Install + showInstallPrompt, + deferredInstallPrompt, + isIOS, + isAndroid, + triggerInstall, + }; + }, + + template: ` +
    + +
    +
    + + +
    Lädt...
    +
    +
    + + +
    + +
    +
    +
    +
    +
    +
    +
    + + +
    +
    + Logo + +
    + +
    +
    + + + +
    +

    App installieren

    +

    + Für die beste Erfahrung installiere die App auf deinem Gerät. +

    +
    + + +
    + +
    + + +
    +
    +

    So installierst du die App:

    +
      +
    1. + 1 + Tippe auf das Teilen-Symbol + + + + +
    2. +
    3. + 2 + Scrolle und wähle "Zum Home-Bildschirm" +
    4. +
    5. + 3 + Tippe auf "Hinzufügen" +
    6. +
    +
    +
    + + +
    +
    +

    So installierst du die App:

    +
      +
    1. + 1 + Tippe auf das Menü (⋮) oben rechts +
    2. +
    3. + 2 + Wähle "App installieren" oder "Zum Startbildschirm hinzufügen" +
    4. +
    5. + 3 + Bestätige mit "Installieren" +
    6. +
    +
    +
    + + +
    +
    +

    + Hinweis: Diese App ist für mobile Geräte optimiert. Bitte öffne diese Seite auf deinem Smartphone und installiere die App. +

    +
    +
    + +
    +

    + powered by XINON +

    +
    +
    +
    + + + + + + + + + +
    +
    + {{ toast.message }} +
    +
    +
    +
    + ` +}; + +createApp(App).mount('#app'); diff --git a/public/mobile/components/LoginScreen.js b/public/mobile/components/LoginScreen.js new file mode 100644 index 000000000..47acf087f --- /dev/null +++ b/public/mobile/components/LoginScreen.js @@ -0,0 +1,639 @@ +/** + * LoginScreen Component + * + * Displays the login form for the PWA with 2FA support. + * Features: + * - Username/password authentication + * - 2FA verification with OTP auto-detection (Web OTP API for Android, autocomplete for iOS) + * - Remember me option + */ + +import { login, verify2FA, resend2FA } from '/mobile/shared/auth.js'; + +export default { + name: 'LoginScreen', + emits: ['login', 'set-theme'], + props: { + theme: { + type: String, + default: 'system' + } + }, + + setup(props, { emit }) { + const { ref, onMounted, onUnmounted, nextTick } = Vue; + + // Login form state + const username = ref(''); + const password = ref(''); + const rememberMe = ref(true); + const showPassword = ref(false); + + // 2FA state + const show2FA = ref(false); + const otpCode = ref(''); + const otpDigits = ref(['', '', '', '', '']); + const deliveryMethod = ref(''); + const maskedTarget = ref(''); + const resendCooldown = ref(0); + + // General state + const error = ref(''); + const success = ref(''); + const loading = ref(false); + const showThemePicker = ref(!localStorage.getItem('theme')); + + // OTP input refs + let otpInputRefs = []; + let otpAbortController = null; + let resendTimer = null; + + // Handle login form submission + const handleSubmit = async () => { + if (!username.value || !password.value) { + error.value = 'Bitte Benutzername und Passwort eingeben'; + return; + } + + loading.value = true; + error.value = ''; + + try { + // Call login API directly + const result = await login({ + username: username.value, + password: password.value, + rememberMe: rememberMe.value + }); + + if (result.requires2FA) { + // Show 2FA verification screen + show2FA.value = true; + deliveryMethod.value = result.deliveryMethod; + maskedTarget.value = result.maskedTarget; + success.value = result.message; + error.value = ''; + + // Start resend cooldown + startResendCooldown(); + + // Focus first OTP input after render + await nextTick(); + focusOtpInput(0); + + // Try Web OTP API for SMS + if (result.deliveryMethod === 'sms') { + startWebOTP(); + } + } else if (result.success) { + // Direct login success (no 2FA) - notify parent + emit('login', { _2faSuccess: true, user: result.user }); + } else { + error.value = result.message || 'Login fehlgeschlagen'; + } + } catch (e) { + error.value = 'Ein Fehler ist aufgetreten'; + } finally { + loading.value = false; + } + }; + + // Handle 2FA verification + const handleVerify2FA = async () => { + const code = otpDigits.value.join(''); + + if (code.length !== 5) { + error.value = 'Bitte gib den 5-stelligen Code ein'; + return; + } + + loading.value = true; + error.value = ''; + success.value = ''; + + try { + const result = await verify2FA(code); + + if (result.success) { + // Emit the successful result to parent (which handles navigation) + emit('login', { _2faSuccess: true, user: result.user }); + } else { + error.value = result.message || 'Ungültiger Code'; + + if (result.expired || result.codeExpired) { + // Session or code expired - go back to login + resetTo2FA(); + } + } + } catch (e) { + error.value = 'Ein Fehler ist aufgetreten'; + } finally { + loading.value = false; + } + }; + + // Handle resend 2FA code + const handleResend = async () => { + if (resendCooldown.value > 0) return; + + loading.value = true; + error.value = ''; + + try { + const result = await resend2FA(); + + if (result.success) { + success.value = result.message || 'Neuer Code wurde gesendet'; + startResendCooldown(); + + // Clear OTP inputs + otpDigits.value = ['', '', '', '', '']; + focusOtpInput(0); + + // Restart Web OTP if SMS + if (deliveryMethod.value === 'sms') { + startWebOTP(); + } + } else { + error.value = result.message || 'Code konnte nicht gesendet werden'; + + if (result.expired) { + resetTo2FA(); + } + } + } catch (e) { + error.value = 'Ein Fehler ist aufgetreten'; + } finally { + loading.value = false; + } + }; + + // Go back to login form + const backToLogin = () => { + show2FA.value = false; + otpDigits.value = ['', '', '', '', '']; + error.value = ''; + success.value = ''; + abortWebOTP(); + }; + + // Reset after session expired + const resetTo2FA = () => { + show2FA.value = false; + password.value = ''; + otpDigits.value = ['', '', '', '', '']; + error.value = 'Sitzung abgelaufen. Bitte erneut anmelden.'; + }; + + // Start resend cooldown (30 seconds) + const startResendCooldown = () => { + resendCooldown.value = 30; + if (resendTimer) clearInterval(resendTimer); + resendTimer = setInterval(() => { + resendCooldown.value--; + if (resendCooldown.value <= 0) { + clearInterval(resendTimer); + } + }, 1000); + }; + + // OTP input handlers + const focusOtpInput = (index) => { + const inputs = document.querySelectorAll('.otp-input'); + if (inputs[index]) { + inputs[index].focus(); + } + }; + + const handleOtpInput = (index, event) => { + const value = event.target.value; + + // Only allow digits + if (!/^\d*$/.test(value)) { + event.target.value = otpDigits.value[index]; + return; + } + + // Handle paste of full code + if (value.length > 1) { + const digits = value.replace(/\D/g, '').slice(0, 5).split(''); + digits.forEach((digit, i) => { + if (i < 5) otpDigits.value[i] = digit; + }); + focusOtpInput(Math.min(digits.length, 4)); + + // Auto-submit if complete + if (otpDigits.value.join('').length === 5) { + handleVerify2FA(); + } + return; + } + + otpDigits.value[index] = value; + + // Move to next input + if (value && index < 4) { + focusOtpInput(index + 1); + } + + // Auto-submit when complete + if (otpDigits.value.join('').length === 5) { + handleVerify2FA(); + } + }; + + const handleOtpKeydown = (index, event) => { + // Handle backspace + if (event.key === 'Backspace' && !otpDigits.value[index] && index > 0) { + focusOtpInput(index - 1); + } + }; + + const handleOtpPaste = (event) => { + event.preventDefault(); + const pastedData = event.clipboardData.getData('text'); + const digits = pastedData.replace(/\D/g, '').slice(0, 5).split(''); + + digits.forEach((digit, i) => { + if (i < 5) otpDigits.value[i] = digit; + }); + + focusOtpInput(Math.min(digits.length, 4)); + + // Auto-submit if complete + if (otpDigits.value.join('').length === 5) { + handleVerify2FA(); + } + }; + + // Web OTP API for automatic SMS code detection (Android) + const startWebOTP = async () => { + if (!('OTPCredential' in window)) { + console.log('Web OTP API not supported'); + return; + } + + abortWebOTP(); + otpAbortController = new AbortController(); + + try { + const otp = await navigator.credentials.get({ + otp: { transport: ['sms'] }, + signal: otpAbortController.signal + }); + + if (otp && otp.code) { + // Extract 5-digit code from SMS + const code = otp.code.replace(/\D/g, '').slice(0, 5); + if (code.length === 5) { + code.split('').forEach((digit, i) => { + otpDigits.value[i] = digit; + }); + // Auto-submit + handleVerify2FA(); + } + } + } catch (err) { + if (err.name !== 'AbortError') { + console.log('Web OTP error:', err); + } + } + }; + + const abortWebOTP = () => { + if (otpAbortController) { + otpAbortController.abort(); + otpAbortController = null; + } + }; + + // Theme picker + const selectTheme = (newTheme) => { + emit('set-theme', newTheme); + showThemePicker.value = false; + }; + + // Cleanup + onUnmounted(() => { + abortWebOTP(); + if (resendTimer) clearInterval(resendTimer); + }); + + return { + // Login state + username, + password, + rememberMe, + showPassword, + + // 2FA state + show2FA, + otpDigits, + deliveryMethod, + maskedTarget, + resendCooldown, + + // General state + error, + success, + loading, + showThemePicker, + + // Methods + handleSubmit, + handleVerify2FA, + handleResend, + backToLogin, + handleOtpInput, + handleOtpKeydown, + handleOtpPaste, + selectTheme + }; + }, + + template: ` +
    + +
    + +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    + + + +
    +
    +

    Willkommen!

    +

    Wähle dein bevorzugtes Farbschema.

    +
    + + + +
    +
    +
    +
    + + +
    +
    + Logo + +
    + + + + + + + +
    +

    + powered by XINON +

    +
    +
    +
    + ` +}; diff --git a/public/mobile/components/MainMenu.js b/public/mobile/components/MainMenu.js new file mode 100644 index 000000000..6efe3d0d8 --- /dev/null +++ b/public/mobile/components/MainMenu.js @@ -0,0 +1,66 @@ +/** + * MainMenu Component + * + * Displays the main module menu for the MobileApp. + * Shows available modules like "Lager" that the user can access. + */ + +export default { + name: 'MainMenu', + emits: ['navigate'], + props: { + user: Object + }, + + setup(props, { emit }) { + // Available modules + const modules = [ + { + id: 'Lager', + name: 'Lager', + icon: 'warehouse', + color: 'bg-blue-500', + iconColor: 'text-blue-500' + } + // Future modules can be added here + ]; + + const openModule = (moduleId) => { + emit('navigate', moduleId, null); + }; + + return { + modules, + openModule + }; + }, + + template: ` +
    + +
    + +
    +
    + ` +}; diff --git a/public/mobile/manifest.json b/public/mobile/manifest.json new file mode 100644 index 000000000..eeb099605 --- /dev/null +++ b/public/mobile/manifest.json @@ -0,0 +1,26 @@ +{ + "name": "Xinon Mobile", + "short_name": "Xinon", + "description": "Mobile-optimierte Tools für Xinon", + "start_url": "/MobileApp", + "scope": "/MobileApp", + "display": "standalone", + "orientation": "portrait", + "background_color": "#f1f5f9", + "theme_color": "#005384", + "icons": [ + { + "src": "/assets/images/xinon-sm.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/assets/images/xinon-sm.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "categories": ["business", "productivity"] +} diff --git a/public/mobile/modules/lager/LagerModule.js b/public/mobile/modules/lager/LagerModule.js new file mode 100644 index 000000000..5e25a0c35 --- /dev/null +++ b/public/mobile/modules/lager/LagerModule.js @@ -0,0 +1,166 @@ +/** + * Lager Module + * + * Main module for warehouse management. + * Shows submodules: Inventur (stocktake) + */ + +import StocktakeList from '/mobile/modules/lager/inventur/StocktakeList.js'; +import Scanner from '/mobile/modules/lager/inventur/Scanner.js'; +import MovementForm from '/mobile/modules/lager/movement/MovementForm.js'; + +export default { + name: 'LagerModule', + emits: ['navigate', 'toast'], + props: { + user: Object, + submodule: String, + simpleMode: Boolean + }, + components: { + StocktakeList, + Scanner, + MovementForm + }, + + setup(props, { emit }) { + const { ref, computed, watch } = Vue; + + // Submodules available in Lager + const submodules = [ + { + id: 'Inventur', + name: 'Inventur', + icon: 'clipboard', + color: 'bg-green-500' + }, + { + id: 'Movement', + name: 'Lagerbewegung', + icon: 'arrows', + color: 'bg-blue-500' + } + ]; + + // Scanner state + const selectedStocktake = ref(null); + const showScanner = ref(false); + + // Current view based on submodule + const currentView = computed(() => { + if (!props.submodule) return 'menu'; + if (props.submodule.toLowerCase() === 'inventur') { + return showScanner.value ? 'scanner' : 'inventur'; + } + if (props.submodule.toLowerCase() === 'movement') { + return 'movement'; + } + return 'menu'; + }); + + // Watch for submodule changes + watch(() => props.submodule, (newVal) => { + if (!newVal) { + showScanner.value = false; + selectedStocktake.value = null; + } + }); + + const openSubmodule = (submoduleId) => { + emit('navigate', 'Lager', submoduleId); + }; + + const goBack = () => { + if (showScanner.value) { + showScanner.value = false; + selectedStocktake.value = null; + } + }; + + const openScanner = (stocktake) => { + selectedStocktake.value = stocktake; + showScanner.value = true; + }; + + const closeScanner = () => { + showScanner.value = false; + selectedStocktake.value = null; + }; + + const showToast = (message, type) => { + emit('toast', message, type); + }; + + return { + submodules, + selectedStocktake, + showScanner, + currentView, + openSubmodule, + goBack, + openScanner, + closeScanner, + showToast + }; + }, + + template: ` +
    + + + + + + + + + + + +
    + ` +}; diff --git a/public/mobile/modules/lager/inventur/Scanner.js b/public/mobile/modules/lager/inventur/Scanner.js new file mode 100644 index 000000000..0697fbc67 --- /dev/null +++ b/public/mobile/modules/lager/inventur/Scanner.js @@ -0,0 +1,437 @@ +/** + * Scanner Component (Inventur) + * + * The main scanning interface for stocktakes. + * Uses the new API path: /MobileApp/Lager/Inventur/{action} + */ + +// Inventur-specific API +const inventurApi = { + get: (endpoint) => fetch(`/MobileApp/Lager/Inventur/${endpoint}`).then(r => r.json()), + post: (endpoint, data) => fetch(`/MobileApp/Lager/Inventur/${endpoint}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }).then(r => r.json()) +}; + +export default { + name: 'Scanner', + emits: ['close', 'toast'], + props: { + stocktake: { type: Object, required: true }, + user: { type: Object, required: true } + }, + + setup(props, { emit }) { + const { ref, onMounted, onUnmounted, nextTick, computed } = Vue; + + // State + const currentTab = ref('scan'); + const isLoading = ref(false); + + // Scanner + const scanner = ref(null); + const isScannerActive = ref(false); + const scannerError = ref(''); + + // Article + const scannedArticle = ref(null); + const quantity = ref('1'); + const rack = ref(''); + const shelf = ref(''); + + // Search + const searchQuery = ref(''); + const searchResults = ref([]); + const categories = ref([]); + const selectedCategory = ref(0); + const isSearching = ref(false); + + // History + const recentScans = ref([]); + const isLoadingHistory = ref(false); + + // Warning + const alreadyScannedWarning = ref(null); + + // Keypad + const showKeypad = ref(false); + + // Computed + const canSubmit = computed(() => { + return scannedArticle.value && parseFloat(quantity.value) > 0 && !isLoading.value; + }); + + // Scanner functions + const startScanner = async () => { + scannerError.value = ''; + try { + scanner.value = new Html5Qrcode('qr-reader'); + await scanner.value.start( + { facingMode: 'environment' }, + { fps: 10, qrbox: { width: 250, height: 250 }, aspectRatio: 1.0 }, + onScanSuccess, + () => {} + ); + isScannerActive.value = true; + } catch (err) { + scannerError.value = 'Kamera konnte nicht gestartet werden. Bitte Berechtigungen prüfen.'; + } + }; + + const stopScanner = async () => { + if (scanner.value && isScannerActive.value) { + try { await scanner.value.stop(); } catch (e) {} + isScannerActive.value = false; + } + }; + + const onScanSuccess = async (decodedText) => { + await stopScanner(); + await lookupArticle(decodedText); + }; + + // Article lookup + const lookupArticle = async (code) => { + isLoading.value = true; + alreadyScannedWarning.value = null; + + try { + const result = await inventurApi.get(`getArticle?code=${encodeURIComponent(code)}`); + + if (result.success) { + scannedArticle.value = result.article; + const checkResult = await inventurApi.get( + `checkAlreadyScanned?stocktakeId=${props.stocktake.id}&articleId=${result.article.id}` + ); + if (checkResult.success && checkResult.alreadyScanned) { + alreadyScannedWarning.value = checkResult.existingItem; + } + quantity.value = '1'; + } else { + emit('toast', result.message || 'Artikel nicht gefunden', 'error'); + await startScanner(); + } + } catch (e) { + emit('toast', 'Fehler beim Laden des Artikels', 'error'); + await startScanner(); + } finally { + isLoading.value = false; + } + }; + + // Submit + const submitScan = async (overwrite = false) => { + if (!canSubmit.value) return; + isLoading.value = true; + + try { + const payload = { + stocktakeId: props.stocktake.id, + articleId: scannedArticle.value.id, + quantity: parseFloat(quantity.value), + rack: rack.value || null, + shelf: shelf.value || null, + overwrite: overwrite, + overwriteItemId: overwrite && alreadyScannedWarning.value ? alreadyScannedWarning.value.id : 0 + }; + + const result = await inventurApi.post('submitScan', payload); + + if (result.success) { + emit('toast', result.message, 'success'); + scannedArticle.value = null; + quantity.value = '1'; + rack.value = ''; + shelf.value = ''; + alreadyScannedWarning.value = null; + await startScanner(); + } else { + emit('toast', result.message || 'Fehler beim Speichern', 'error'); + } + } catch (e) { + emit('toast', 'Netzwerkfehler', 'error'); + } finally { + isLoading.value = false; + } + }; + + // Search + const loadCategories = async () => { + const result = await inventurApi.get('getCategories'); + if (result.success) categories.value = result.categories; + }; + + const searchArticles = async () => { + if (searchQuery.value.length < 2 && !selectedCategory.value) { + searchResults.value = []; + return; + } + isSearching.value = true; + try { + const params = new URLSearchParams(); + if (searchQuery.value) params.set('query', searchQuery.value); + if (selectedCategory.value) params.set('categoryId', selectedCategory.value); + const result = await inventurApi.get(`searchArticles?${params}`); + if (result.success) searchResults.value = result.articles; + } catch (e) {} finally { + isSearching.value = false; + } + }; + + const selectSearchResult = async (article) => { + await stopScanner(); + scannedArticle.value = article; + quantity.value = '1'; + currentTab.value = 'scan'; + + const checkResult = await inventurApi.get( + `checkAlreadyScanned?stocktakeId=${props.stocktake.id}&articleId=${article.id}` + ); + if (checkResult.success && checkResult.alreadyScanned) { + alreadyScannedWarning.value = checkResult.existingItem; + } + }; + + // History + const loadHistory = async () => { + isLoadingHistory.value = true; + try { + const result = await inventurApi.get(`getMyScans?stocktakeId=${props.stocktake.id}`); + if (result.success) recentScans.value = result.items; + } catch (e) {} finally { + isLoadingHistory.value = false; + } + }; + + // Keypad + const appendDigit = (digit) => { + if (digit === '.' && quantity.value.includes('.')) return; + if (quantity.value === '0' && digit !== '.') { + quantity.value = digit; + } else { + quantity.value += digit; + } + }; + + const deleteDigit = () => { + quantity.value = quantity.value.length > 1 ? quantity.value.slice(0, -1) : '0'; + }; + + const clearQuantity = () => { quantity.value = '0'; }; + + // Navigation + const handleClose = async () => { + await stopScanner(); + emit('close'); + }; + + const switchTab = async (tab) => { + currentTab.value = tab; + if (tab === 'scan' && !scannedArticle.value) { + await nextTick(); + await startScanner(); + } else if (tab === 'search') { + await stopScanner(); + await loadCategories(); + } else if (tab === 'history') { + await stopScanner(); + await loadHistory(); + } + }; + + const cancelScan = async () => { + scannedArticle.value = null; + alreadyScannedWarning.value = null; + quantity.value = '1'; + await startScanner(); + }; + + onMounted(async () => { await startScanner(); }); + onUnmounted(async () => { await stopScanner(); }); + + return { + currentTab, isLoading, isScannerActive, scannerError, + scannedArticle, quantity, rack, shelf, + searchQuery, searchResults, categories, selectedCategory, isSearching, + recentScans, isLoadingHistory, + alreadyScannedWarning, showKeypad, canSubmit, + startScanner, stopScanner, submitScan, searchArticles, selectSearchResult, + loadHistory, appendDigit, deleteDigit, clearQuantity, + handleClose, switchTab, cancelScan + }; + }, + + template: ` +
    + +
    + {{ stocktake.title || 'Inventur #' + stocktake.stocktakeNumber }} + +
    + + +
    + + + +
    + + +
    + +
    + +
    +
    +
    +

    {{ scannerError }}

    + +
    +

    QR-Code scannen oder Artikel suchen

    +
    + + +
    + +
    +
    + + + +
    +

    Bereits gescannt

    +

    + Menge: {{ alreadyScannedWarning.countedQuantity }}
    + Von: {{ alreadyScannedWarning.scannedBy }}
    + Am: {{ alreadyScannedWarning.scannedAt }} +

    +
    +
    +
    + + +
    +

    {{ scannedArticle.title }}

    +

    Art.-Nr.: {{ scannedArticle.articleNumber }}

    +

    Kategorie: {{ scannedArticle.categoryName }}

    +
    + + +
    + +
    + {{ quantity }} +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + + + + +
    +
    +
    + + +
    +
    + + +
    + +
    +
    +
    + +
    +

    {{ searchQuery.length < 2 ? 'Mindestens 2 Zeichen eingeben' : 'Keine Artikel gefunden' }}

    +
    + +
    +
    +

    {{ article.title }}

    +

    {{ article.articleNumber }}

    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    + +
    +

    Noch keine Scans

    +
    + +
    +
    +
    +
    +

    {{ scan.articleTitle }}

    +

    {{ scan.articleNumber }}

    +
    +
    +

    {{ scan.countedQuantity }} {{ scan.unit }}

    +

    {{ scan.scannedAt }}

    +
    +
    +
    +
    +
    +
    + + + +
    +
    +
    + +
    {{ quantity }}
    + +
    +
    + + +
    +
    +
    +
    +
    + ` +}; diff --git a/public/mobile/modules/lager/inventur/StocktakeList.js b/public/mobile/modules/lager/inventur/StocktakeList.js new file mode 100644 index 000000000..4fd749571 --- /dev/null +++ b/public/mobile/modules/lager/inventur/StocktakeList.js @@ -0,0 +1,151 @@ +/** + * StocktakeList Component (Inventur) + * + * Displays a list of active stocktakes. + * Uses the new API path: /MobileApp/Lager/Inventur/{action} + */ + +import { api } from '/mobile/shared/auth.js'; + +// Override API base for Inventur +const inventurApi = { + get: (endpoint) => fetch(`/MobileApp/Lager/Inventur/${endpoint}`).then(r => r.json()), + post: (endpoint, data) => fetch(`/MobileApp/Lager/Inventur/${endpoint}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }).then(r => r.json()) +}; + +export default { + name: 'StocktakeList', + emits: ['select'], + props: { + user: Object + }, + + setup(props, { emit }) { + const { ref, onMounted } = Vue; + + const stocktakes = ref([]); + const isLoading = ref(true); + const error = ref(''); + + const fetchStocktakes = async () => { + isLoading.value = true; + error.value = ''; + + try { + const result = await inventurApi.get('getActiveStocktakes'); + + if (result.success) { + stocktakes.value = result.stocktakes; + } else { + error.value = result.error || 'Fehler beim Laden'; + } + } catch (e) { + error.value = 'Netzwerkfehler'; + } finally { + isLoading.value = false; + } + }; + + const selectStocktake = (stocktake) => { + emit('select', stocktake); + }; + + onMounted(() => { + fetchStocktakes(); + }); + + return { + stocktakes, + isLoading, + error, + fetchStocktakes, + selectStocktake + }; + }, + + template: ` +
    + +
    + Aktive Inventuren + +
    + + +
    + +
    +
    +
    +
    +
    +
    +
    + + +
    + + + +

    {{ error }}

    + +
    + + +
    + + + +

    Keine aktiven Inventuren

    +
    + + +
    + +
    +
    +
    + ` +}; diff --git a/public/mobile/modules/lager/movement/MovementForm.js b/public/mobile/modules/lager/movement/MovementForm.js new file mode 100644 index 000000000..d10b8fe1e --- /dev/null +++ b/public/mobile/modules/lager/movement/MovementForm.js @@ -0,0 +1,1308 @@ +/** + * MovementForm Component (WarehouseMovement) + * + * The main interface for stock movements (IN/OUT/ADJUSTMENT). + * API: /MobileApp/Lager/Movement/{action} + */ + +const movementApi = { + get: (endpoint) => fetch(`/MobileApp/Lager/Movement/${endpoint}`).then(r => r.json()), + post: (endpoint, data) => fetch(`/MobileApp/Lager/Movement/${endpoint}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }).then(r => r.json()) +}; + +// Custom BottomSheet Select Component +const BottomSheetSelect = { + name: 'BottomSheetSelect', + emits: ['update:modelValue'], + props: { + modelValue: [String, Number], + options: { type: Array, default: () => [] }, + label: { type: String, default: '' }, + placeholder: { type: String, default: 'Auswählen...' }, + valueKey: { type: String, default: 'value' }, + labelKey: { type: String, default: 'text' }, + icon: { type: String, default: null }, + position: { type: String, default: 'bottom' } // 'bottom' or 'top' + }, + setup(props, { emit }) { + const isOpen = Vue.ref(false); + + const selectedLabel = Vue.computed(() => { + const option = props.options.find(o => + (typeof o === 'object' ? o[props.valueKey] : o) === props.modelValue + ); + if (!option) return props.placeholder; + return typeof option === 'object' ? option[props.labelKey] : option; + }); + + const select = (option) => { + const value = typeof option === 'object' ? option[props.valueKey] : option; + emit('update:modelValue', value); + isOpen.value = false; + }; + + return { isOpen, selectedLabel, select }; + }, + template: ` +
    + + + + + + +
    +
    + + + +
    + +
    +
    +
    + +
    +

    {{ label }}

    +
    + +
    + +
    + +
    +
    +
    +
    +
    +
    +
    + ` +}; + +export default { + name: 'MovementForm', + emits: ['toast'], + props: { + user: { type: Object, required: true }, + simpleMode: { type: Boolean, default: false } + }, + components: { + BottomSheetSelect + }, + + setup(props, { emit }) { + const { ref, onMounted, onUnmounted, nextTick, computed, watch } = Vue; + + // ==================== CONSTANTS ==================== + const STORAGE_KEY = 'movement_settings'; + const LOCATION_COORDS = { + office: { lat: 46.99552810791587, lng: 15.7751923956463, name: 'K1 Fladnitz 150' }, + aussenlager: { lat: 46.99909466636262, lng: 15.77571245012429, name: 'Aussenlager-Extern' } + }; + const GPS_ACCURACY_THRESHOLD = 100; // meters + + // ==================== CONFIG ==================== + const locations = ref([]); + const movementTypes = [ + { value: 'IN', text: 'Einbuchung', icon: 'plus', color: 'green', defaultReason: 'Warenlieferung' }, + { value: 'OUT', text: 'Ausbuchung', icon: 'minus', color: 'red', defaultReason: 'Verbrauch' }, + { value: 'ADJUSTMENT', text: 'Korrektur', icon: 'edit', color: 'yellow', defaultReason: 'Inventurkorrektur' } + ]; + const reasonCategories = ref({}); + + // ==================== SELECTION STATE ==================== + const selectedLocation = ref(null); + const selectedType = ref('IN'); + + // ==================== GPS STATE ==================== + const detectedLocation = ref(null); + const gpsStatus = ref('idle'); // 'idle', 'detecting', 'detected', 'error' + const gpsDistance = ref(null); + + // ==================== MODE TOGGLES ==================== + const turboMode = ref(false); + const batchMode = ref(false); + + // ==================== BATCH CART ==================== + const cartItems = ref([]); + const showCart = ref(false); + + // ==================== TABS ==================== + const currentTab = ref('scan'); + + // ==================== LOADING ==================== + const isLoading = ref(false); + const isInitialized = ref(false); + + // ==================== SCANNER ==================== + const scanner = ref(null); + const isScannerActive = ref(false); + const scannerError = ref(''); + + // ==================== ARTICLE ==================== + const scannedArticle = ref(null); + const currentStock = ref(0); + const quantity = ref('1'); + const selectedReason = ref(''); + const note = ref(''); + + // ==================== SEARCH ==================== + const searchQuery = ref(''); + const searchResults = ref([]); + const isSearching = ref(false); + + // ==================== HISTORY ==================== + const recentMovements = ref([]); + const isLoadingHistory = ref(false); + + // ==================== KEYPAD ==================== + const showKeypad = ref(false); + const showNote = ref(false); + + // ==================== UNDO STATE ==================== + const lastMovement = ref(null); + const showUndo = ref(false); + let undoTimeout = null; + + // ==================== COMPUTED ==================== + const canSubmit = computed(() => { + return scannedArticle.value && + selectedLocation.value && + selectedType.value && + parseFloat(quantity.value) > 0 && + selectedReason.value && + !isLoading.value; + }); + + const typeColor = computed(() => { + const type = movementTypes.find(t => t.value === selectedType.value); + return type ? type.color : 'blue'; + }); + + const reasonOptions = computed(() => { + const reasons = reasonCategories.value[selectedType.value]; + if (!reasons) return []; + return Object.entries(reasons).map(([key, label]) => ({ + value: key, + text: label + })); + }); + + const cartTotal = computed(() => cartItems.value.length); + + // Filtered movement types (hide ADJUSTMENT in simple mode) + const filteredMovementTypes = computed(() => { + if (props.simpleMode) { + return movementTypes.filter(t => t.value !== 'ADJUSTMENT'); + } + return movementTypes; + }); + + // GPS distance formatting and color + const formattedGpsDistance = computed(() => { + if (gpsDistance.value === null) return ''; + if (gpsDistance.value >= 1000) { + return (gpsDistance.value / 1000).toFixed(1) + 'km'; + } + return gpsDistance.value + 'm'; + }); + + const gpsDistanceColor = computed(() => { + if (gpsDistance.value === null) return 'text-slate-400'; + // Green: within 200m (auto-selected range) + if (gpsDistance.value <= 200) return 'text-green-500'; + // Yellow: 200m - 500m (getting far) + if (gpsDistance.value <= 500) return 'text-yellow-500'; + // Red: over 500m (probably wrong location) + return 'text-red-500'; + }); + + // ==================== LOCALSTORAGE PERSISTENCE ==================== + const saveSettings = () => { + const settings = { + locationId: selectedLocation.value, + type: selectedType.value, + turboMode: turboMode.value, + batchMode: batchMode.value + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); + }; + + const loadSettings = () => { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + return JSON.parse(saved); + } + } catch (e) {} + return null; + }; + + // Load settings IMMEDIATELY (synchronously) before anything else + const savedSettings = loadSettings(); + if (savedSettings) { + if (savedSettings.type) selectedType.value = savedSettings.type; + if (savedSettings.turboMode !== undefined) turboMode.value = savedSettings.turboMode; + if (savedSettings.batchMode !== undefined) batchMode.value = savedSettings.batchMode; + // locationId will be applied after locations are loaded + } + + // ==================== GPS DETECTION ==================== + const calculateDistance = (lat1, lng1, lat2, lng2) => { + const R = 6371e3; // Earth's radius in meters + const φ1 = lat1 * Math.PI / 180; + const φ2 = lat2 * Math.PI / 180; + const Δφ = (lat2 - lat1) * Math.PI / 180; + const Δλ = (lng2 - lng1) * Math.PI / 180; + const a = Math.sin(Δφ/2) * Math.sin(Δφ/2) + + Math.cos(φ1) * Math.cos(φ2) * + Math.sin(Δλ/2) * Math.sin(Δλ/2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); + return R * c; + }; + + const detectLocation = () => { + if (!navigator.geolocation) { + gpsStatus.value = 'error'; + return; + } + + gpsStatus.value = 'detecting'; + + navigator.geolocation.getCurrentPosition( + (position) => { + const { latitude, longitude, accuracy } = position.coords; + + // Calculate distances to both locations + const distToOffice = calculateDistance(latitude, longitude, LOCATION_COORDS.office.lat, LOCATION_COORDS.office.lng); + const distToAussen = calculateDistance(latitude, longitude, LOCATION_COORDS.aussenlager.lat, LOCATION_COORDS.aussenlager.lng); + + // Find closest location + const closest = distToOffice < distToAussen + ? { name: 'office', distance: distToOffice } + : { name: 'aussenlager', distance: distToAussen }; + + gpsDistance.value = Math.round(closest.distance); + + // Only auto-select if accuracy is good and we're reasonably close + if (accuracy <= GPS_ACCURACY_THRESHOLD && closest.distance < 500) { + // Find matching location in our locations list + const matchingLoc = locations.value.find(loc => + loc.title.toLowerCase() === LOCATION_COORDS[closest.name].name.toLowerCase() + ); + if (matchingLoc) { + detectedLocation.value = matchingLoc.id; + // Only auto-set if user hasn't saved a preference + if (!savedSettings?.locationId) { + selectedLocation.value = matchingLoc.id; + } + gpsStatus.value = 'detected'; + } + } else { + gpsStatus.value = 'detected'; + } + }, + (error) => { + gpsStatus.value = 'error'; + }, + { enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 } + ); + }; + + // ==================== QUICK ACTIONS ==================== + const quickAction = async (type, reason) => { + selectedType.value = type; + await nextTick(); + selectedReason.value = reason; + saveSettings(); + currentTab.value = 'scan'; + if (!scannedArticle.value) { + await startScanner(); + } + }; + + // ==================== LOAD INITIAL DATA ==================== + const loadInitialData = async () => { + try { + const [locResult, reasonResult] = await Promise.all([ + movementApi.get('getLocations'), + movementApi.get('getReasonCategories') + ]); + + if (locResult.success) { + locations.value = locResult.locations; + + // Try to restore saved location (from already-loaded settings), otherwise use first + if (savedSettings?.locationId && locations.value.find(l => l.id === savedSettings.locationId)) { + selectedLocation.value = savedSettings.locationId; + } else if (locations.value.length > 0) { + selectedLocation.value = locations.value[0].id; + } + + // Start GPS detection after locations are loaded + detectLocation(); + } + + if (reasonResult.success) { + reasonCategories.value = reasonResult.categories; + updateReasonOptions(); + } + + isInitialized.value = true; + } catch (e) { + emit('toast', 'Fehler beim Laden der Konfiguration', 'error'); + } + }; + + // ==================== REASON OPTIONS ==================== + const updateReasonOptions = () => { + const reasons = reasonCategories.value?.[selectedType.value]; + if (reasons && typeof reasons === 'object') { + const keys = Object.keys(reasons); + if (keys.length > 0) { + selectedReason.value = keys[0]; + } + } else { + // Fallback defaults + const defaults = { 'IN': 'Warenlieferung', 'OUT': 'Verbrauch', 'ADJUSTMENT': 'Inventurkorrektur' }; + if (defaults[selectedType.value]) { + selectedReason.value = defaults[selectedType.value]; + } + } + }; + + // ==================== WATCHERS ==================== + watch(selectedType, () => { + updateReasonOptions(); + saveSettings(); + }); + + watch(selectedLocation, () => { + saveSettings(); + if (scannedArticle.value) { + loadCurrentStock(); + } + }); + + // Ensure location is always selected when locations are loaded + watch(locations, (newLocations) => { + if (newLocations.length > 0 && !selectedLocation.value) { + // Try saved settings first + if (savedSettings?.locationId && newLocations.find(l => l.id === savedSettings.locationId)) { + selectedLocation.value = savedSettings.locationId; + } else { + selectedLocation.value = newLocations[0].id; + } + } + }, { immediate: true }); + + watch(() => props.simpleMode, (newVal) => { + if (newVal) { + // Reset to IN/OUT if ADJUSTMENT was selected + if (selectedType.value === 'ADJUSTMENT') { + selectedType.value = 'OUT'; + } + // Switch away from history tab + if (currentTab.value === 'history') { + currentTab.value = 'scan'; + } + // Disable turbo/batch modes in simple mode + turboMode.value = false; + batchMode.value = false; + } + }); + + watch(turboMode, () => { + saveSettings(); + }); + + watch(batchMode, () => { + saveSettings(); + }); + + // Also update reason when categories are loaded + watch(reasonCategories, () => { + updateReasonOptions(); + }, { deep: true }); + + // ==================== SCANNER FUNCTIONS ==================== + const startScanner = async () => { + scannerError.value = ''; + try { + scanner.value = new Html5Qrcode('qr-reader-movement'); + await scanner.value.start( + { facingMode: 'environment' }, + { fps: 10, qrbox: { width: 250, height: 250 }, aspectRatio: 1.0 }, + onScanSuccess, + () => {} + ); + isScannerActive.value = true; + } catch (err) { + scannerError.value = 'Kamera konnte nicht gestartet werden. Bitte Berechtigungen prüfen.'; + } + }; + + const stopScanner = async () => { + if (scanner.value && isScannerActive.value) { + try { await scanner.value.stop(); } catch (e) {} + isScannerActive.value = false; + } + }; + + const onScanSuccess = async (decodedText) => { + await stopScanner(); + await lookupArticle(decodedText); + }; + + // Article lookup + const lookupArticle = async (code) => { + isLoading.value = true; + + try { + const result = await movementApi.get(`getArticle?code=${encodeURIComponent(code)}`); + + if (result.success) { + scannedArticle.value = result.article; + await loadCurrentStock(); + quantity.value = '1'; + + // Ensure reason is set + if (!selectedReason.value) { + updateReasonOptions(); + } + + // TURBO MODE: Auto-submit with qty=1 and default reason + if (turboMode.value && !batchMode.value) { + await turboSubmit(result.article); + return; + } + + // BATCH MODE: Add to cart and continue scanning + if (batchMode.value) { + addToCart(result.article); + return; + } + } else { + emit('toast', result.message || 'Artikel nicht gefunden', 'error'); + await startScanner(); + } + } catch (e) { + emit('toast', 'Fehler beim Laden des Artikels', 'error'); + await startScanner(); + } finally { + isLoading.value = false; + } + }; + + // ==================== TURBO MODE ==================== + const turboSubmit = async (article) => { + const typeConfig = movementTypes.find(t => t.value === selectedType.value); + const defaultReason = typeConfig?.defaultReason || selectedReason.value; + + try { + const payload = { + movementType: selectedType.value, + articleId: article.id, + locationId: selectedLocation.value, + quantity: 1, + reasonCategory: defaultReason, + note: null + }; + + const result = await movementApi.post('submitMovement', payload); + + if (result.success) { + // Store for undo + lastMovement.value = result.movement; + showUndo.value = true; + if (undoTimeout) clearTimeout(undoTimeout); + undoTimeout = setTimeout(() => { showUndo.value = false; }, 5000); + + // Show quick toast + const typeLabel = selectedType.value === 'IN' ? '+' : selectedType.value === 'OUT' ? '-' : '±'; + emit('toast', `${typeLabel}1 ${article.title}`, 'success'); + + // Reset and restart scanner + scannedArticle.value = null; + currentStock.value = 0; + await startScanner(); + } else { + emit('toast', result.message || 'Fehler', 'error'); + // Fall back to normal mode + scannedArticle.value = article; + } + } catch (e) { + emit('toast', 'Netzwerkfehler', 'error'); + scannedArticle.value = article; + } + }; + + // ==================== BATCH/CART MODE ==================== + const addToCart = (article) => { + // Check if already in cart + const existing = cartItems.value.find(item => item.article.id === article.id); + if (existing) { + existing.quantity += 1; + emit('toast', `${article.title} (${existing.quantity}x)`, 'success'); + } else { + cartItems.value.push({ + article: article, + quantity: 1, + stock: currentStock.value + }); + emit('toast', `+ ${article.title}`, 'success'); + } + + // Reset and restart scanner + scannedArticle.value = null; + currentStock.value = 0; + startScanner(); + }; + + const updateCartQuantity = (index, qty) => { + if (qty <= 0) { + cartItems.value.splice(index, 1); + } else { + cartItems.value[index].quantity = qty; + } + }; + + const removeFromCart = (index) => { + cartItems.value.splice(index, 1); + }; + + const clearCart = () => { + cartItems.value = []; + showCart.value = false; + }; + + const submitCart = async () => { + if (cartItems.value.length === 0) return; + isLoading.value = true; + + const typeConfig = movementTypes.find(t => t.value === selectedType.value); + const defaultReason = typeConfig?.defaultReason || selectedReason.value; + let successCount = 0; + let errorCount = 0; + + for (const item of cartItems.value) { + try { + const payload = { + movementType: selectedType.value, + articleId: item.article.id, + locationId: selectedLocation.value, + quantity: item.quantity, + reasonCategory: defaultReason, + note: null + }; + + const result = await movementApi.post('submitMovement', payload); + if (result.success) { + successCount++; + } else { + errorCount++; + } + } catch (e) { + errorCount++; + } + } + + isLoading.value = false; + + if (errorCount === 0) { + emit('toast', `${successCount} Bewegungen erfolgreich`, 'success'); + clearCart(); + } else { + emit('toast', `${successCount} OK, ${errorCount} Fehler`, 'error'); + } + }; + + // Load current stock for article at location + const loadCurrentStock = async () => { + if (!scannedArticle.value || !selectedLocation.value) { + currentStock.value = 0; + return; + } + + try { + const result = await movementApi.get( + `getCurrentStock?articleId=${scannedArticle.value.id}&locationId=${selectedLocation.value}` + ); + currentStock.value = result.success ? result.currentStock : 0; + } catch (e) { + currentStock.value = 0; + } + }; + + // Submit movement + const submitMovement = async () => { + if (!canSubmit.value) return; + isLoading.value = true; + + try { + const payload = { + movementType: selectedType.value, + articleId: scannedArticle.value.id, + locationId: selectedLocation.value, + quantity: parseFloat(quantity.value), + reasonCategory: selectedReason.value, + note: note.value || null + }; + + const result = await movementApi.post('submitMovement', payload); + + if (result.success) { + emit('toast', result.message, 'success'); + // Reset form + scannedArticle.value = null; + currentStock.value = 0; + quantity.value = '1'; + note.value = ''; + showNote.value = false; + await startScanner(); + } else { + emit('toast', result.message || 'Fehler beim Speichern', 'error'); + } + } catch (e) { + emit('toast', 'Netzwerkfehler', 'error'); + } finally { + isLoading.value = false; + } + }; + + // Search + const searchArticles = async () => { + if (searchQuery.value.length < 2) { + searchResults.value = []; + return; + } + isSearching.value = true; + try { + const result = await movementApi.get(`searchArticles?query=${encodeURIComponent(searchQuery.value)}`); + if (result.success) searchResults.value = result.articles; + } catch (e) {} finally { + isSearching.value = false; + } + }; + + const selectSearchResult = async (article) => { + await stopScanner(); + scannedArticle.value = article; + await loadCurrentStock(); + quantity.value = '1'; + showNote.value = false; + // Ensure reason is set + if (!selectedReason.value) { + updateReasonOptions(); + } + currentTab.value = 'scan'; + }; + + // History + const loadHistory = async () => { + isLoadingHistory.value = true; + try { + const params = selectedLocation.value ? `?locationId=${selectedLocation.value}` : ''; + const result = await movementApi.get(`getMyMovements${params}`); + if (result.success) recentMovements.value = result.movements; + } catch (e) {} finally { + isLoadingHistory.value = false; + } + }; + + // Keypad + const appendDigit = (digit) => { + if (digit === '.' && quantity.value.includes('.')) return; + if (quantity.value === '0' && digit !== '.') { + quantity.value = digit; + } else { + quantity.value += digit; + } + }; + + const deleteDigit = () => { + quantity.value = quantity.value.length > 1 ? quantity.value.slice(0, -1) : '0'; + }; + + const clearQuantity = () => { quantity.value = '0'; }; + + // Navigation + const switchTab = async (tab) => { + currentTab.value = tab; + if (tab === 'scan' && !scannedArticle.value) { + await nextTick(); + await startScanner(); + } else if (tab === 'search') { + await stopScanner(); + } else if (tab === 'history') { + await stopScanner(); + await loadHistory(); + } + }; + + const cancelScan = async () => { + scannedArticle.value = null; + currentStock.value = 0; + quantity.value = '1'; + note.value = ''; + showNote.value = false; + await startScanner(); + }; + + // Get type badge classes + const getTypeBadgeClass = (type) => { + const colors = { + 'IN': 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', + 'OUT': 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400', + 'ADJUSTMENT': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400' + }; + return colors[type] || 'bg-slate-100 text-slate-800'; + }; + + onMounted(async () => { + await loadInitialData(); + await startScanner(); + }); + + onUnmounted(async () => { + await stopScanner(); + }); + + return { + // Config + locations, movementTypes, reasonCategories, reasonOptions, + selectedLocation, selectedType, + // GPS + detectedLocation, gpsStatus, gpsDistance, detectLocation, formattedGpsDistance, gpsDistanceColor, + // Mode toggles + turboMode, batchMode, filteredMovementTypes, + // Cart + cartItems, cartTotal, showCart, + addToCart, updateCartQuantity, removeFromCart, clearCart, submitCart, + // Quick actions + quickAction, + // Undo + lastMovement, showUndo, + // Tabs & Loading + currentTab, isLoading, isInitialized, + isScannerActive, scannerError, + // Article + scannedArticle, currentStock, quantity, selectedReason, note, + // Search + searchQuery, searchResults, isSearching, + // History + recentMovements, isLoadingHistory, + // UI + showKeypad, showNote, canSubmit, typeColor, + // Functions + startScanner, stopScanner, submitMovement, + searchArticles, selectSearchResult, loadHistory, + appendDigit, deleteDigit, clearQuantity, + switchTab, cancelScan, getTypeBadgeClass + }; + }, + + template: ` +
    + +
    + +
    + + +
    +
    +
    + GPS... +
    +
    + + + + {{ formattedGpsDistance }} +
    +
    + + + + GPS +
    +
    +
    + + +
    + +
    + + +
    + + + + + + + +
    + +
    +
    + + +
    + + + +
    + + +
    + +
    +
    +
    + + +
    + +
    +
    +
    +

    {{ scannerError }}

    + +
    +

    QR-Code scannen oder Artikel suchen

    +
    + + +
    + +
    +
    +
    +

    {{ scannedArticle.title }}

    +

    {{ scannedArticle.articleNumber }}

    +
    +
    + + {{ currentStock }} + + {{ scannedArticle.unit || 'Stk.' }} +
    +
    +
    + + +
    + Menge + + {{ selectedType === 'OUT' ? '-' : selectedType === 'IN' ? '+' : '' }}{{ quantity }} + +
    + + +
    +
    + +
    + +
    + + +
    + +
    + + +
    + + +
    +
    +
    + + +
    +
    + +
    + +
    +
    +
    + +
    +

    {{ searchQuery.length < 2 ? 'Mindestens 2 Zeichen eingeben' : 'Keine Artikel gefunden' }}

    +
    + +
    +
    +

    {{ article.title }}

    +

    {{ article.articleNumber }}

    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    + +
    +

    Noch keine Bewegungen

    +
    + +
    +
    +
    +
    +
    + + {{ movement.movementType === 'IN' ? 'Einbuchung' : movement.movementType === 'OUT' ? 'Ausbuchung' : 'Korrektur' }} + + {{ movement.locationTitle }} +
    +

    {{ movement.articleTitle }}

    +

    {{ movement.articleNumber }}

    +
    +
    +

    + {{ movement.movementType === 'IN' ? '+' : movement.movementType === 'OUT' ? '-' : '' }}{{ movement.quantity }} {{ movement.unit }} +

    +

    {{ movement.create }}

    +
    +
    +
    +
    +
    +
    + + + +
    +
    +
    + +
    {{ quantity }}
    + +
    +
    + + +
    +
    +
    +
    + + + + + + + + +
    +
    + +
    +

    + + + + Sammelkorb ({{ cartTotal }}) +

    + +
    + + +
    +
    + Korb ist leer +
    +
    +
    +

    {{ item.article.title }}

    +

    {{ item.article.articleNumber }}

    +
    + +
    + + {{ item.quantity }} + +
    + + +
    +
    + + +
    + + +
    +
    +
    +
    + + + +
    + + + + + TURBO: Scan = Sofort buchen (1x) + +
    +
    + + + +
    + {{ lastMovement?.articleTitle }} gebucht + +
    +
    +
    + ` +}; diff --git a/public/mobile/shared/auth.js b/public/mobile/shared/auth.js new file mode 100644 index 000000000..57ce12eef --- /dev/null +++ b/public/mobile/shared/auth.js @@ -0,0 +1,199 @@ +/** + * MobileApp Shared Authentication Module + * + * Provides authentication utilities for all mobile PWAs: + * - checkAuth() - Check if user is authenticated + * - login() - Authenticate user + * - logout() - Clear session + * - api - Generic API helper + */ + +// Base API path for all MobileApp endpoints +const API_BASE = '/MobileApp'; + +// Shared auth state (can be imported by components) +export const authState = { + user: null, + isAuthenticated: false +}; + +/** + * Check if user is currently authenticated + * @returns {Promise<{authenticated: boolean, user?: object}>} + */ +export async function checkAuth() { + try { + const res = await fetch(`${API_BASE}/auth/check`, { + credentials: 'same-origin' + }); + const data = await res.json(); + + authState.isAuthenticated = data.authenticated; + authState.user = data.user || null; + + return data; + } catch (e) { + console.error('Auth check failed:', e); + authState.isAuthenticated = false; + authState.user = null; + return { authenticated: false }; + } +} + +/** + * Authenticate user with credentials + * @param {object} credentials - { username, password, rememberMe } + * @returns {Promise<{success: boolean, user?: object, message?: string}>} + */ +export async function login({ username, password, rememberMe = true }) { + try { + const res = await fetch(`${API_BASE}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ username, password, rememberMe }) + }); + + const data = await res.json(); + + if (data.success) { + authState.isAuthenticated = true; + authState.user = data.user; + } + + return data; + } catch (e) { + console.error('Login failed:', e); + return { success: false, message: 'Netzwerkfehler' }; + } +} + +/** + * Verify 2FA code + * @param {string} code - 5-digit verification code + * @returns {Promise<{success: boolean, user?: object, message?: string}>} + */ +export async function verify2FA(code) { + try { + const res = await fetch(`${API_BASE}/auth/verify2fa`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ code }) + }); + + const data = await res.json(); + + if (data.success) { + authState.isAuthenticated = true; + authState.user = data.user; + } + + return data; + } catch (e) { + console.error('2FA verification failed:', e); + return { success: false, message: 'Netzwerkfehler' }; + } +} + +/** + * Resend 2FA code + * @returns {Promise<{success: boolean, message?: string}>} + */ +export async function resend2FA() { + try { + const res = await fetch(`${API_BASE}/auth/resend2fa`, { + method: 'POST', + credentials: 'same-origin' + }); + + return await res.json(); + } catch (e) { + console.error('Resend 2FA failed:', e); + return { success: false, message: 'Netzwerkfehler' }; + } +} + +/** + * Logout current user + * @returns {Promise<{success: boolean}>} + */ +export async function logout() { + try { + await fetch(`${API_BASE}/auth/logout`, { + method: 'POST', + credentials: 'same-origin' + }); + } catch (e) { + console.error('Logout request failed:', e); + } + + authState.isAuthenticated = false; + authState.user = null; + + return { success: true }; +} + +/** + * Generic API helper for app-specific endpoints + * Usage: api.get('WarehouseStocktake/getActiveStocktakes') + * api.post('WarehouseStocktake/submitScan', { ... }) + */ +export const api = { + /** + * GET request + * @param {string} endpoint - Endpoint path (e.g., 'WarehouseStocktake/getArticle?code=123') + * @returns {Promise} + */ + get: async (endpoint) => { + try { + const res = await fetch(`${API_BASE}/${endpoint}`, { + credentials: 'same-origin' + }); + + // Check for auth errors + if (res.status === 401) { + authState.isAuthenticated = false; + authState.user = null; + return { success: false, error: 'Not authenticated', authError: true }; + } + + return await res.json(); + } catch (e) { + console.error(`API GET ${endpoint} failed:`, e); + return { success: false, error: 'Netzwerkfehler' }; + } + }, + + /** + * POST request with JSON body + * @param {string} endpoint - Endpoint path + * @param {object} data - Request body + * @returns {Promise} + */ + post: async (endpoint, data = {}) => { + try { + const res = await fetch(`${API_BASE}/${endpoint}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify(data) + }); + + // Check for auth errors + if (res.status === 401) { + authState.isAuthenticated = false; + authState.user = null; + return { success: false, error: 'Not authenticated', authError: true }; + } + + return await res.json(); + } catch (e) { + console.error(`API POST ${endpoint} failed:`, e); + return { success: false, error: 'Netzwerkfehler' }; + } + } +}; + +// Export API_BASE for components that need to build URLs +export { API_BASE }; diff --git a/public/mobile/shared/base.css b/public/mobile/shared/base.css new file mode 100644 index 000000000..f201ee9ae --- /dev/null +++ b/public/mobile/shared/base.css @@ -0,0 +1,324 @@ +/** + * MobileApp Shared Base Styles + * + * Common styles for all mobile PWAs including: + * - Dark mode support + * - PWA-specific optimizations + * - Common animations + * - Utility classes + */ + +/* ==================== ROOT & DARK MODE ==================== */ + +:root { + --color-primary: #005384; + --color-secondary: #fac41b; + --color-success: #22c55e; + --color-danger: #ef4444; + --color-warning: #f59e0b; +} + +/* Dark mode is toggled by adding 'dark' class to */ +.dark { + color-scheme: dark; +} + + +/* ==================== PWA OPTIMIZATIONS ==================== */ + +html, body { + /* Prevents rubber-band scroll on iOS and pull-to-refresh on Android */ + overscroll-behavior: none; + /* Prevent text selection on double tap */ + -webkit-user-select: none; + user-select: none; + /* Smooth font rendering */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + /* Prevent zoom on input focus (iOS) */ + touch-action: manipulation; +} + +/* Allow text selection in inputs and textareas */ +input, textarea, [contenteditable] { + -webkit-user-select: text; + user-select: text; +} + +/* Safe area insets for notched devices */ +.safe-area-top { + padding-top: env(safe-area-inset-top); +} + +.safe-area-bottom { + padding-bottom: env(safe-area-inset-bottom); +} + + +/* ==================== ANIMATIONS ==================== */ + +/* Slide transition for panels */ +.slide-enter-active, +.slide-leave-active { + transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1); +} + +.slide-enter-from, +.slide-leave-to { + transform: translateX(100%); +} + +/* Slide up transition for modals */ +.slide-up-enter-active, +.slide-up-leave-active { + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.slide-up-enter-from, +.slide-up-leave-to { + transform: translateY(100%); +} + +/* Slide down transition for top sheets */ +.slide-down-enter-active, +.slide-down-leave-active { + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.slide-down-enter-from, +.slide-down-leave-to { + transform: translateY(-100%); +} + +/* Fade transition */ +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.2s ease-in-out; +} + +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +/* Overlay transition */ +.overlay-enter-active, +.overlay-leave-active { + transition: opacity 0.35s ease; +} + +.overlay-enter-from, +.overlay-leave-to { + opacity: 0; +} + +/* Scale in transition */ +.scale-enter-active, +.scale-leave-active { + transition: transform 0.2s ease, opacity 0.2s ease; +} + +.scale-enter-from, +.scale-leave-to { + transform: scale(0.95); + opacity: 0; +} + +/* Spinner animation */ +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.spin { + animation: spin 1s linear infinite; +} + +/* Pulse animation for loading states */ +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.pulse { + animation: pulse 1.5s ease-in-out infinite; +} + +/* Network background animations */ +@keyframes node-glow { + 0%, 100% { + transform: scale(1); + opacity: 0.8; + } + 50% { + transform: scale(1.4); + opacity: 1; + } +} + +@keyframes node-glow-slow { + 0%, 100% { + transform: scale(1); + opacity: 0.7; + } + 50% { + transform: scale(1.3); + opacity: 1; + } +} + +@keyframes line-pulse { + 0%, 100% { opacity: 0.3; } + 50% { opacity: 0.6; } +} + +@keyframes line-flow { + 0% { stroke-dashoffset: 1000; } + 100% { stroke-dashoffset: 0; } +} + +.network-node { + animation: node-glow 2s ease-in-out infinite; +} + +.network-node-slow { + animation: node-glow-slow 3s ease-in-out infinite; +} + +.network-lines { + animation: line-pulse 3s ease-in-out infinite; +} + +.network-line-flow { + stroke-dasharray: 20 30; + animation: line-flow 8s linear infinite; +} + + +/* ==================== PANEL EFFECTS ==================== */ + +/* Background blur effect when panel is open */ +.panel-open { + transform: scale(0.95); + filter: blur(4px); + opacity: 0.7; + transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), + filter 0.35s, + opacity 0.35s; +} + +.list-container { + transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), + filter 0.35s, + opacity 0.35s; +} + + +/* ==================== OVERLAY ==================== */ + +.overlay { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.4); + transition: opacity 0.35s ease; + z-index: 15; +} + + +/* ==================== TOAST NOTIFICATIONS ==================== */ + +.toast-container { + position: fixed; + bottom: calc(1rem + env(safe-area-inset-bottom)); + left: 1rem; + right: 1rem; + z-index: 100; + display: flex; + flex-direction: column; + gap: 0.5rem; + pointer-events: none; +} + +.toast { + padding: 0.75rem 1rem; + border-radius: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + text-align: center; + pointer-events: auto; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); +} + +.toast-success { + background-color: var(--color-success); + color: white; +} + +.toast-error { + background-color: var(--color-danger); + color: white; +} + +.toast-warning { + background-color: var(--color-warning); + color: white; +} + + +/* ==================== FORM ELEMENTS ==================== */ + +/* Prevent iOS zoom on input focus */ +input[type="text"], +input[type="password"], +input[type="email"], +input[type="number"], +input[type="tel"], +input[type="search"], +textarea, +select { + font-size: 16px !important; +} + +/* Remove tap highlight on mobile */ +button, a, input, select, textarea { + -webkit-tap-highlight-color: transparent; +} + +/* Better focus styles for accessibility */ +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + + +/* ==================== UTILITIES ==================== */ + +/* Hide scrollbar but allow scrolling */ +.hide-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; +} + +.hide-scrollbar::-webkit-scrollbar { + display: none; +} + +/* Loading overlay */ +.loading-overlay { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 50; +} + +/* Numeric keypad input */ +.numeric-input { + font-variant-numeric: tabular-nums; + letter-spacing: 0.05em; +} diff --git a/public/mobile/sw.js b/public/mobile/sw.js new file mode 100644 index 000000000..80ee45176 --- /dev/null +++ b/public/mobile/sw.js @@ -0,0 +1,84 @@ +/** + * MobileApp Service Worker + * Provides basic caching for the PWA shell and assets. + */ + +const CACHE_NAME = 'xinon-mobile-v1'; +const ASSETS_TO_CACHE = [ + '/MobileApp', + '/mobile/app.js', + '/mobile/shared/auth.js', + '/mobile/shared/base.css', + '/mobile/components/LoginScreen.js', + '/mobile/components/MainMenu.js', + '/mobile/modules/lager/LagerModule.js', + '/mobile/modules/lager/inventur/StocktakeList.js', + '/mobile/modules/lager/inventur/Scanner.js', + '/assets/images/xinon-full-transparent.png', + '/assets/images/xinon-full-transparent-white.png', + '/assets/images/xinon-sm.png' +]; + +// Install: cache assets +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME) + .then(cache => cache.addAll(ASSETS_TO_CACHE)) + .then(() => self.skipWaiting()) + ); +}); + +// Activate: clean old caches +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then(cacheNames => { + return Promise.all( + cacheNames + .filter(name => name !== CACHE_NAME) + .map(name => caches.delete(name)) + ); + }).then(() => self.clients.claim()) + ); +}); + +// Fetch: network-first for API, cache-first for assets +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 + 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; + }); + }) + ); +}); diff --git a/public/mobile/warehouse-stocktake/app.css b/public/mobile/warehouse-stocktake/app.css new file mode 100644 index 000000000..7aa7294d7 --- /dev/null +++ b/public/mobile/warehouse-stocktake/app.css @@ -0,0 +1,168 @@ +/** + * Warehouse Stocktake PWA - App-Specific Styles + */ + +/* QR Scanner Container */ +#qr-reader { + width: 100%; + max-width: 400px; + margin: 0 auto; + border-radius: 0.5rem; + overflow: hidden; +} + +#qr-reader video { + border-radius: 0.5rem; +} + +/* Hide default html5-qrcode UI elements we don't need */ +#qr-reader__status_span, +#qr-reader__dashboard_section_csr, +#qr-reader__dashboard_section_swaplink { + display: none !important; +} + +/* Scanner frame styling */ +#qr-reader__scan_region { + background: transparent !important; +} + +#qr-reader__scan_region img { + opacity: 0.3; +} + +/* Keypad styling */ +.keypad-button { + min-height: 60px; +} + +/* Numeric display */ +.quantity-display { + font-variant-numeric: tabular-nums; + letter-spacing: 0.05em; +} + +/* Tab indicator animation */ +.tab-indicator { + transition: transform 0.3s ease, width 0.3s ease; +} + +/* Card hover effect */ +.stocktake-card:active { + transform: scale(0.98); +} + +/* Search results scrolling */ +.search-results { + max-height: calc(100vh - 280px); + overflow-y: auto; +} + +/* History list */ +.history-list { + max-height: calc(100vh - 200px); + overflow-y: auto; +} + +/* Already scanned badge pulse */ +@keyframes pulse-amber { + 0%, 100% { + box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.4); + } + 50% { + box-shadow: 0 0 0 8px rgba(245, 158, 11, 0); + } +} + +.already-scanned-pulse { + animation: pulse-amber 2s ease-in-out infinite; +} + +/* Toast slide up animation (complementing base.css) */ +.toast-enter-active { + animation: toast-slide-up 0.3s ease-out; +} + +.toast-leave-active { + animation: toast-slide-down 0.3s ease-in; +} + +@keyframes toast-slide-up { + from { + transform: translateY(100%); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes toast-slide-down { + from { + transform: translateY(0); + opacity: 1; + } + to { + transform: translateY(100%); + opacity: 0; + } +} + +/* Settings panel slide */ +.settings-panel { + transform: translateX(100%); + transition: transform 0.3s ease-out; +} + +.settings-panel.open { + transform: translateX(0); +} + +/* Loading spinner */ +.spinner { + border: 3px solid rgba(0, 83, 132, 0.1); + border-top-color: #005384; + border-radius: 50%; + width: 40px; + height: 40px; + animation: spin 1s linear infinite; +} + +/* Dark mode specific overrides */ +.dark .spinner { + border-color: rgba(250, 196, 27, 0.1); + border-top-color: #fac41b; +} + +/* Scan success flash */ +@keyframes scan-flash { + 0% { + background-color: rgba(34, 197, 94, 0.3); + } + 100% { + background-color: transparent; + } +} + +.scan-success-flash { + animation: scan-flash 0.5s ease-out; +} + +/* Custom scrollbar for webkit browsers */ +.custom-scrollbar::-webkit-scrollbar { + width: 6px; +} + +.custom-scrollbar::-webkit-scrollbar-track { + background: transparent; +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.2); + border-radius: 3px; +} + +.dark .custom-scrollbar::-webkit-scrollbar-thumb { + background-color: rgba(255, 255, 255, 0.2); +} diff --git a/public/mobile/warehouse-stocktake/app.js b/public/mobile/warehouse-stocktake/app.js new file mode 100644 index 000000000..b7b09ab45 --- /dev/null +++ b/public/mobile/warehouse-stocktake/app.js @@ -0,0 +1,182 @@ +/** + * Warehouse Stocktake PWA - Main Vue Application + * + * This is the entry point for the Warehouse Stocktake PWA. + * It manages authentication state and routes between views. + */ + +// Import shared modules +import { api, authState, checkAuth, login, logout } from '/mobile/shared/auth.js'; + +// Import components +import LoginScreen from './components/LoginScreen.js'; +import StocktakeList from './components/StocktakeList.js'; +import Scanner from './components/Scanner.js'; + +const { createApp, ref, computed, onMounted, watch, nextTick } = Vue; + +const App = { + components: { + LoginScreen, + StocktakeList, + Scanner + }, + + setup() { + // ==================== STATE ==================== + const currentView = ref('loading'); // 'loading', 'login', 'list', 'scanner' + const user = ref(null); + const selectedStocktake = ref(null); + const toast = ref({ show: false, message: '', type: 'success' }); + const theme = ref('system'); + + // ==================== THEME ==================== + const applyTheme = () => { + const isDark = localStorage.theme === 'dark' || + (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches); + document.documentElement.classList.toggle('dark', isDark); + + const metaThemeColor = document.querySelector('meta[name="theme-color"]'); + if (metaThemeColor) { + metaThemeColor.setAttribute('content', isDark ? '#0f172a' : '#005384'); + } + }; + + const setTheme = (newTheme) => { + theme.value = newTheme; + if (newTheme === 'system') { + localStorage.removeItem('theme'); + } else { + localStorage.setItem('theme', newTheme); + } + applyTheme(); + }; + + // ==================== AUTH ==================== + const handleLogin = async (credentials) => { + const result = await login(credentials); + if (result.success) { + user.value = result.user; + currentView.value = 'list'; + showToast('Erfolgreich angemeldet', 'success'); + } + return result; + }; + + const handleLogout = async () => { + await logout(); + user.value = null; + selectedStocktake.value = null; + currentView.value = 'login'; + showToast('Abgemeldet', 'success'); + }; + + // ==================== NAVIGATION ==================== + const openScanner = (stocktake) => { + selectedStocktake.value = stocktake; + currentView.value = 'scanner'; + }; + + const closeScanner = () => { + selectedStocktake.value = null; + currentView.value = 'list'; + }; + + // ==================== TOAST ==================== + const showToast = (message, type = 'success') => { + toast.value = { show: true, message, type }; + setTimeout(() => { + toast.value.show = false; + }, 3000); + }; + + // ==================== LIFECYCLE ==================== + onMounted(async () => { + // Initialize theme + const savedTheme = localStorage.getItem('theme'); + if (savedTheme) { + theme.value = savedTheme; + } + applyTheme(); + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyTheme); + + // Check authentication + const result = await checkAuth(); + if (result.authenticated) { + user.value = result.user; + currentView.value = 'list'; + } else { + currentView.value = 'login'; + } + }); + + return { + // State + currentView, + user, + selectedStocktake, + toast, + theme, + + // Methods + handleLogin, + handleLogout, + openScanner, + closeScanner, + showToast, + setTheme, + }; + }, + + template: ` +
    + +
    +
    + + +
    Lädt...
    +
    +
    + + + + + + + + + + + + +
    +
    + {{ toast.message }} +
    +
    +
    +
    + ` +}; + +// Mount the app +createApp(App).mount('#app'); diff --git a/public/mobile/warehouse-stocktake/components/LoginScreen.js b/public/mobile/warehouse-stocktake/components/LoginScreen.js new file mode 100644 index 000000000..b7b5382d9 --- /dev/null +++ b/public/mobile/warehouse-stocktake/components/LoginScreen.js @@ -0,0 +1,207 @@ +/** + * LoginScreen Component + * + * Displays the login form for the PWA. + * Handles username/password authentication with remember me option. + */ + +export default { + name: 'LoginScreen', + emits: ['login', 'set-theme'], + props: { + theme: { + type: String, + default: 'system' + } + }, + + setup(props, { emit }) { + const { ref } = Vue; + + // Form state + const username = ref(''); + const password = ref(''); + const rememberMe = ref(true); + const error = ref(''); + const loading = ref(false); + const showPassword = ref(false); + + // Theme picker (shown on first visit) + const showThemePicker = ref(!localStorage.getItem('theme')); + + const handleSubmit = async () => { + if (!username.value || !password.value) { + error.value = 'Bitte Benutzername und Passwort eingeben'; + return; + } + + loading.value = true; + error.value = ''; + + try { + const result = await new Promise((resolve) => { + // Emit returns undefined, we need to wait for parent to call back + const loginPromise = emit('login', { + username: username.value, + password: password.value, + rememberMe: rememberMe.value + }); + + // The parent will return the result + resolve(loginPromise); + }); + + if (result && !result.success) { + error.value = result.message || 'Login fehlgeschlagen'; + if (result.requires2FA) { + error.value = 'Zwei-Faktor-Authentifizierung wird derzeit nicht unterstützt.'; + } + } + } catch (e) { + error.value = 'Ein Fehler ist aufgetreten'; + } finally { + loading.value = false; + } + }; + + const selectTheme = (newTheme) => { + emit('set-theme', newTheme); + showThemePicker.value = false; + }; + + return { + username, + password, + rememberMe, + error, + loading, + showPassword, + showThemePicker, + handleSubmit, + selectTheme + }; + }, + + template: ` +
    + + +
    +
    +

    Willkommen!

    +

    Wähle dein bevorzugtes Farbschema.

    +
    + + + +
    +
    +
    +
    + + +
    + +
    + Logo + +
    + + +

    + Lager Inventur +

    + + +
    + +
    + + +
    + + +
    + +
    + + +
    +
    + + + + + +
    +

    {{ error }}

    +
    + + + +
    + + +
    +

    + powered by XINON GmbH +

    +
    +
    +
    + ` +}; diff --git a/public/mobile/warehouse-stocktake/components/Scanner.js b/public/mobile/warehouse-stocktake/components/Scanner.js new file mode 100644 index 000000000..64dc52088 --- /dev/null +++ b/public/mobile/warehouse-stocktake/components/Scanner.js @@ -0,0 +1,607 @@ +/** + * Scanner Component + * + * The main scanning interface for the stocktake. + * Features: + * - QR code scanning via camera + * - Manual article search + * - Quantity input with custom keypad + * - Recent scans list + */ + +import { api } from '/mobile/shared/auth.js'; + +export default { + name: 'Scanner', + emits: ['close', 'toast'], + props: { + stocktake: { + type: Object, + required: true + }, + user: { + type: Object, + required: true + } + }, + + setup(props, { emit }) { + const { ref, onMounted, onUnmounted, nextTick, computed } = Vue; + + // ==================== STATE ==================== + const currentTab = ref('scan'); // 'scan', 'search', 'history' + const isLoading = ref(false); + + // Scanner state + const scanner = ref(null); + const isScannerActive = ref(false); + const scannerError = ref(''); + + // Article state + const scannedArticle = ref(null); + const quantity = ref('1'); + const rack = ref(''); + const shelf = ref(''); + + // Search state + const searchQuery = ref(''); + const searchResults = ref([]); + const categories = ref([]); + const selectedCategory = ref(0); + const isSearching = ref(false); + + // History state + const recentScans = ref([]); + const isLoadingHistory = ref(false); + + // Already scanned warning + const alreadyScannedWarning = ref(null); + + // Custom keypad + const showKeypad = ref(false); + + // ==================== COMPUTED ==================== + const canSubmit = computed(() => { + return scannedArticle.value && + parseFloat(quantity.value) > 0 && + !isLoading.value; + }); + + // ==================== SCANNER ==================== + const startScanner = async () => { + scannerError.value = ''; + + try { + // Initialize scanner + scanner.value = new Html5Qrcode('qr-reader'); + + await scanner.value.start( + { facingMode: 'environment' }, + { + fps: 10, + qrbox: { width: 250, height: 250 }, + aspectRatio: 1.0 + }, + onScanSuccess, + onScanError + ); + + isScannerActive.value = true; + } catch (err) { + console.error('Scanner start error:', err); + scannerError.value = 'Kamera konnte nicht gestartet werden. Bitte Berechtigungen prüfen.'; + } + }; + + const stopScanner = async () => { + if (scanner.value && isScannerActive.value) { + try { + await scanner.value.stop(); + } catch (e) { + console.error('Scanner stop error:', e); + } + isScannerActive.value = false; + } + }; + + const onScanSuccess = async (decodedText) => { + // Stop scanner temporarily + await stopScanner(); + + // Look up article + await lookupArticle(decodedText); + }; + + const onScanError = (errorMessage) => { + // Silent - this fires constantly when no QR code is detected + }; + + // ==================== ARTICLE LOOKUP ==================== + const lookupArticle = async (code) => { + isLoading.value = true; + alreadyScannedWarning.value = null; + + try { + const result = await api.get(`WarehouseStocktake/getArticle?code=${encodeURIComponent(code)}`); + + if (result.success) { + scannedArticle.value = result.article; + + // Check if already scanned + const checkResult = await api.get( + `WarehouseStocktake/checkAlreadyScanned?stocktakeId=${props.stocktake.id}&articleId=${result.article.id}` + ); + + if (checkResult.success && checkResult.alreadyScanned) { + alreadyScannedWarning.value = checkResult.existingItem; + } + + // Reset quantity + quantity.value = '1'; + } else { + emit('toast', result.message || 'Artikel nicht gefunden', 'error'); + // Restart scanner + await startScanner(); + } + } catch (e) { + emit('toast', 'Fehler beim Laden des Artikels', 'error'); + await startScanner(); + } finally { + isLoading.value = false; + } + }; + + // ==================== SUBMIT SCAN ==================== + const submitScan = async (overwrite = false) => { + if (!canSubmit.value) return; + + isLoading.value = true; + + try { + const payload = { + stocktakeId: props.stocktake.id, + articleId: scannedArticle.value.id, + quantity: parseFloat(quantity.value), + rack: rack.value || null, + shelf: shelf.value || null, + overwrite: overwrite, + overwriteItemId: overwrite && alreadyScannedWarning.value ? alreadyScannedWarning.value.id : 0 + }; + + const result = await api.post('WarehouseStocktake/submitScan', payload); + + if (result.success) { + emit('toast', result.message, 'success'); + + // Reset state + scannedArticle.value = null; + quantity.value = '1'; + rack.value = ''; + shelf.value = ''; + alreadyScannedWarning.value = null; + + // Restart scanner + await startScanner(); + } else { + emit('toast', result.message || 'Fehler beim Speichern', 'error'); + } + } catch (e) { + emit('toast', 'Netzwerkfehler', 'error'); + } finally { + isLoading.value = false; + } + }; + + // ==================== SEARCH ==================== + const loadCategories = async () => { + const result = await api.get('WarehouseStocktake/getCategories'); + if (result.success) { + categories.value = result.categories; + } + }; + + const searchArticles = async () => { + if (searchQuery.value.length < 2 && !selectedCategory.value) { + searchResults.value = []; + return; + } + + isSearching.value = true; + + try { + const params = new URLSearchParams(); + if (searchQuery.value) params.set('query', searchQuery.value); + if (selectedCategory.value) params.set('categoryId', selectedCategory.value); + + const result = await api.get(`WarehouseStocktake/searchArticles?${params}`); + + if (result.success) { + searchResults.value = result.articles; + } + } catch (e) { + console.error('Search error:', e); + } finally { + isSearching.value = false; + } + }; + + const selectSearchResult = async (article) => { + await stopScanner(); + scannedArticle.value = article; + quantity.value = '1'; + currentTab.value = 'scan'; + + // Check if already scanned + const checkResult = await api.get( + `WarehouseStocktake/checkAlreadyScanned?stocktakeId=${props.stocktake.id}&articleId=${article.id}` + ); + + if (checkResult.success && checkResult.alreadyScanned) { + alreadyScannedWarning.value = checkResult.existingItem; + } + }; + + // ==================== HISTORY ==================== + const loadHistory = async () => { + isLoadingHistory.value = true; + + try { + const result = await api.get(`WarehouseStocktake/getMyScans?stocktakeId=${props.stocktake.id}`); + + if (result.success) { + recentScans.value = result.items; + } + } catch (e) { + console.error('History load error:', e); + } finally { + isLoadingHistory.value = false; + } + }; + + // ==================== KEYPAD ==================== + const appendDigit = (digit) => { + if (digit === '.' && quantity.value.includes('.')) return; + if (quantity.value === '0' && digit !== '.') { + quantity.value = digit; + } else { + quantity.value += digit; + } + }; + + const deleteDigit = () => { + if (quantity.value.length > 1) { + quantity.value = quantity.value.slice(0, -1); + } else { + quantity.value = '0'; + } + }; + + const clearQuantity = () => { + quantity.value = '0'; + }; + + // ==================== LIFECYCLE ==================== + const handleClose = async () => { + await stopScanner(); + emit('close'); + }; + + const switchTab = async (tab) => { + currentTab.value = tab; + + if (tab === 'scan' && !scannedArticle.value) { + await nextTick(); + await startScanner(); + } else if (tab === 'search') { + await stopScanner(); + await loadCategories(); + } else if (tab === 'history') { + await stopScanner(); + await loadHistory(); + } + }; + + const cancelScan = async () => { + scannedArticle.value = null; + alreadyScannedWarning.value = null; + quantity.value = '1'; + await startScanner(); + }; + + onMounted(async () => { + await startScanner(); + }); + + onUnmounted(async () => { + await stopScanner(); + }); + + return { + // State + currentTab, + isLoading, + isScannerActive, + scannerError, + scannedArticle, + quantity, + rack, + shelf, + searchQuery, + searchResults, + categories, + selectedCategory, + isSearching, + recentScans, + isLoadingHistory, + alreadyScannedWarning, + showKeypad, + canSubmit, + + // Methods + startScanner, + stopScanner, + submitScan, + searchArticles, + selectSearchResult, + loadHistory, + appendDigit, + deleteDigit, + clearQuantity, + handleClose, + switchTab, + cancelScan + }; + }, + + template: ` +
    + +
    +
    + +

    + {{ stocktake.title || 'Inventur #' + stocktake.stocktakeNumber }} +

    +
    +
    + + +
    + + + +
    +
    + + +
    + +
    + +
    + +
    + + +
    +

    {{ scannerError }}

    + +
    + +

    + QR-Code scannen oder Artikel suchen +

    +
    + + +
    + +
    +
    + + + +
    +

    Bereits gescannt

    +

    + Menge: {{ alreadyScannedWarning.countedQuantity }}
    + Von: {{ alreadyScannedWarning.scannedBy }}
    + Am: {{ alreadyScannedWarning.scannedAt }} +

    +
    +
    +
    + + +
    +

    + {{ scannedArticle.title }} +

    +

    + Art.-Nr.: {{ scannedArticle.articleNumber }} +

    +

    + Kategorie: {{ scannedArticle.categoryName }} +

    +
    + + +
    + +
    + {{ quantity }} +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + + + + +
    +
    +
    + + +
    +
    + + +
    + +
    +
    +
    + +
    +

    + {{ searchQuery.length < 2 ? 'Mindestens 2 Zeichen eingeben' : 'Keine Artikel gefunden' }} +

    +
    + +
    +
    +

    {{ article.title }}

    +

    {{ article.articleNumber }}

    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    + +
    +

    Noch keine Scans

    +
    + +
    +
    +
    +
    +

    {{ scan.articleTitle }}

    +

    {{ scan.articleNumber }}

    +
    +
    +

    {{ scan.countedQuantity }} {{ scan.unit }}

    +

    {{ scan.scannedAt }}

    +
    +
    +
    +
    +
    +
    + + + +
    +
    +
    + +
    {{ quantity }}
    + +
    +
    + + +
    +
    +
    +
    +
    + ` +}; diff --git a/public/mobile/warehouse-stocktake/components/StocktakeList.js b/public/mobile/warehouse-stocktake/components/StocktakeList.js new file mode 100644 index 000000000..d9b63f49a --- /dev/null +++ b/public/mobile/warehouse-stocktake/components/StocktakeList.js @@ -0,0 +1,266 @@ +/** + * StocktakeList Component + * + * Displays a list of active stocktakes that the user can participate in. + * Includes settings menu with theme toggle and logout. + */ + +import { api } from '/mobile/shared/auth.js'; + +export default { + name: 'StocktakeList', + emits: ['select', 'logout', 'set-theme'], + props: { + user: { + type: Object, + default: null + }, + theme: { + type: String, + default: 'system' + } + }, + + setup(props, { emit }) { + const { ref, onMounted } = Vue; + + // State + const stocktakes = ref([]); + const isLoading = ref(true); + const error = ref(''); + const isSettingsOpen = ref(false); + + // Fetch stocktakes + const fetchStocktakes = async () => { + isLoading.value = true; + error.value = ''; + + try { + const result = await api.get('WarehouseStocktake/getActiveStocktakes'); + + if (result.success) { + stocktakes.value = result.stocktakes; + } else { + error.value = result.error || 'Fehler beim Laden'; + } + } catch (e) { + error.value = 'Netzwerkfehler'; + } finally { + isLoading.value = false; + } + }; + + const selectStocktake = (stocktake) => { + emit('select', stocktake); + }; + + const handleLogout = () => { + isSettingsOpen.value = false; + emit('logout'); + }; + + const setTheme = (newTheme) => { + emit('set-theme', newTheme); + }; + + onMounted(() => { + fetchStocktakes(); + }); + + return { + stocktakes, + isLoading, + error, + isSettingsOpen, + fetchStocktakes, + selectStocktake, + handleLogout, + setTheme + }; + }, + + template: ` +
    + + +
    +
    + + +
    +
    + + + + +
    + Logo + +
    + + + +
    + + +

    + Aktive Inventuren +

    +

    + Angemeldet als {{ user.name }} +

    +
    + + +
    + +
    +
    +
    +
    +
    +
    +
    + + +
    + + + +

    {{ error }}

    + +
    + + +
    + + + +

    Keine aktiven Inventuren

    +
    + + +
    +
    +
    +
    +

    + {{ stocktake.title || 'Inventur #' + stocktake.stocktakeNumber }} +

    +

    + {{ stocktake.locationName }} +

    +
    +
    + + Aktiv + +
    +
    +
    +
    + {{ stocktake.totalScannedItems || 0 }} + Artikel gescannt +
    +
    + {{ stocktake.startedAt || 'Nicht gestartet' }} +
    +
    +
    +
    +
    + + + +
    +
    +

    Einstellungen

    + +
    + +
    + +
    +

    Angemeldet als

    +

    {{ user.name }}

    +
    + + +
    +

    Farbschema

    +
    + + + +
    +
    +
    + + +
    + + +
    + XINON +

    + powered by XINON GmbH +

    +
    +
    +
    +
    +
    + ` +}; diff --git a/public/mobile/warehouse-stocktake/manifest.json b/public/mobile/warehouse-stocktake/manifest.json new file mode 100644 index 000000000..415b4b751 --- /dev/null +++ b/public/mobile/warehouse-stocktake/manifest.json @@ -0,0 +1,27 @@ +{ + "name": "Lager Inventur", + "short_name": "Inventur", + "description": "PWA für Lager-Inventur und Artikelerfassung", + "start_url": "/MobileApp/WarehouseStocktake", + "display": "standalone", + "background_color": "#0f172a", + "theme_color": "#005384", + "orientation": "portrait-primary", + "scope": "/MobileApp/", + "icons": [ + { + "src": "/assets/images/xinon-sm-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/assets/images/xinon-sm-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "categories": ["business", "productivity"], + "lang": "de-DE" +} diff --git a/public/mobile/warehouse-stocktake/sw.js b/public/mobile/warehouse-stocktake/sw.js new file mode 100644 index 000000000..8c240672d --- /dev/null +++ b/public/mobile/warehouse-stocktake/sw.js @@ -0,0 +1,109 @@ +/** + * Warehouse Stocktake PWA - Service Worker + * + * Provides basic caching for the app shell (offline-first for static assets). + * API calls are always fetched from network. + */ + +const CACHE_NAME = 'warehouse-stocktake-v1'; + +// Static assets to cache for offline use +const ASSETS_TO_CACHE = [ + '/MobileApp/WarehouseStocktake', + '/mobile/warehouse-stocktake/app.js', + '/mobile/warehouse-stocktake/app.css', + '/mobile/warehouse-stocktake/components/LoginScreen.js', + '/mobile/warehouse-stocktake/components/StocktakeList.js', + '/mobile/warehouse-stocktake/components/Scanner.js', + '/mobile/shared/auth.js', + '/mobile/shared/base.css', + '/assets/images/xinon-full-transparent.png', + '/assets/images/xinon-full-transparent-white.png', + '/assets/images/xinon-sm.png', + '/assets/images/favicon.ico' +]; + +// Install event - cache static assets +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME) + .then(cache => { + console.log('[SW] Caching app shell'); + return cache.addAll(ASSETS_TO_CACHE); + }) + .then(() => self.skipWaiting()) + ); +}); + +// Activate event - clean up old caches +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys() + .then(cacheNames => { + return Promise.all( + cacheNames + .filter(name => name !== CACHE_NAME) + .map(name => { + console.log('[SW] Deleting old cache:', name); + return caches.delete(name); + }) + ); + }) + .then(() => self.clients.claim()) + ); +}); + +// Fetch event - network first for API, cache first for assets +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + // Skip non-GET requests + if (request.method !== 'GET') { + return; + } + + // Skip CDN requests (Vue, Tailwind, etc.) + if (url.hostname !== self.location.hostname) { + return; + } + + // API calls - always go to network (no caching) + if (url.pathname.startsWith('/MobileApp/') && + !url.pathname.endsWith('/WarehouseStocktake') && + url.pathname !== '/MobileApp/WarehouseStocktake') { + return; + } + + // Static assets - cache first, fallback to network + event.respondWith( + caches.match(request) + .then(cachedResponse => { + if (cachedResponse) { + // Return cached version, but also update cache in background + event.waitUntil( + fetch(request) + .then(networkResponse => { + if (networkResponse.ok) { + caches.open(CACHE_NAME) + .then(cache => cache.put(request, networkResponse)); + } + }) + .catch(() => {}) + ); + return cachedResponse; + } + + // Not in cache - fetch from network and cache + return fetch(request) + .then(networkResponse => { + if (networkResponse.ok) { + const responseToCache = networkResponse.clone(); + caches.open(CACHE_NAME) + .then(cache => cache.put(request, responseToCache)); + } + return networkResponse; + }); + }) + ); +});