diff --git a/Layout/default/VueViews/WarehouseStocktakePWA.php b/Layout/default/VueViews/WarehouseStocktakePWA.php index 3b3ff2036..9c65502bb 100644 --- a/Layout/default/VueViews/WarehouseStocktakePWA.php +++ b/Layout/default/VueViews/WarehouseStocktakePWA.php @@ -74,6 +74,52 @@ 0% { background-color: rgb(34, 197, 94); } 100% { background-color: transparent; } } + + /* Custom numpad styles */ + .numpad-btn { + min-height: 52px; + font-size: 1.25rem; + font-weight: 600; + border-radius: 12px; + transition: all 0.15s ease; + user-select: none; + -webkit-tap-highlight-color: transparent; + } + .numpad-btn:active { + transform: scale(0.95); + } + + /* Warning banner animation - intense without causing overflow */ + @keyframes pulse-warning { + 0%, 100% { + opacity: 1; + box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.7), inset 0 0 0 0 rgba(251, 191, 36, 0.2); + border-color: rgb(251, 191, 36); + background-color: rgb(254, 243, 199); + } + 50% { + opacity: 0.95; + box-shadow: 0 0 20px 5px rgba(251, 191, 36, 0.6), inset 0 0 20px 0 rgba(251, 191, 36, 0.2); + border-color: rgb(245, 158, 11); + background-color: rgb(253, 230, 138); + } + } + .warning-pulse { + animation: pulse-warning 0.8s ease-in-out infinite; + } + .dark .warning-pulse { + animation: pulse-warning-dark 0.8s ease-in-out infinite; + } + @keyframes pulse-warning-dark { + 0%, 100% { + box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.5), inset 0 0 0 0 rgba(251, 191, 36, 0.1); + border-color: rgb(217, 119, 6); + } + 50% { + box-shadow: 0 0 25px 8px rgba(251, 191, 36, 0.4), inset 0 0 15px 0 rgba(251, 191, 36, 0.15); + border-color: rgb(245, 158, 11); + } + } @@ -91,22 +137,35 @@ const app = createApp({ const selectedStocktake = ref(null); const isLoading = ref(true); const scannerActive = ref(false); + const cameraAvailable = ref(true); const lastScan = ref(null); const recentScans = ref([]); const progress = reactive({ totalScanned: 0, myScanned: 0 }); const theme = ref(localStorage.getItem('theme') || 'system'); + // Categories + const categories = ref([]); + const selectedCategory = ref(null); + const showCategoryBrowser = ref(false); + + // Already scanned warning + const alreadyScannedWarning = reactive({ + show: false, + existingItem: null, + }); + // Form state const manualForm = reactive({ show: false, article: null, - quantity: 1, + quantity: '', rack: '', shelf: '', note: '', searchQuery: '', searchResults: [], searching: false, + showNumpad: false, }); // Scanner instance @@ -122,6 +181,12 @@ const app = createApp({ return window.matchMedia('(prefers-color-scheme: dark)').matches; }); + // Check if mobile device for numpad display + const isMobile = computed(() => { + const ua = navigator.userAgent.toLowerCase(); + return /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini|mobile/i.test(ua); + }); + // === METHODS === const applyTheme = () => { document.documentElement.classList.toggle('dark', isDark.value); @@ -147,11 +212,23 @@ const app = createApp({ } }; + const fetchCategories = async () => { + try { + const res = await api.get('/getCategories'); + if (res.data.success) { + categories.value = res.data.categories; + } + } catch (e) { + console.error('Failed to fetch categories:', e); + } + }; + const selectStocktake = async (stocktake) => { selectedStocktake.value = stocktake; currentScreen.value = 'scanner'; await fetchMyScans(); await fetchProgress(); + await fetchCategories(); await nextTick(); startScanner(); }; @@ -181,9 +258,11 @@ const app = createApp({ onScanFailure ); scannerActive.value = true; + cameraAvailable.value = true; } catch (err) { console.error('Scanner start error:', err); - alert('Kamera konnte nicht gestartet werden. Bitte Berechtigung erteilen.'); + cameraAvailable.value = false; + // Don't show alert - user can use manual search } }; @@ -215,13 +294,7 @@ const app = createApp({ try { const res = await api.get('/getArticle', { params: { code: decodedText } }); if (res.data.success) { - manualForm.article = res.data.article; - manualForm.quantity = 1; - manualForm.rack = ''; - manualForm.shelf = ''; - manualForm.note = ''; - manualForm.show = true; - await stopScanner(); + await handleArticleSelected(res.data.article); } else { showToast(res.data.message || 'Artikel nicht gefunden', 'error'); } @@ -234,21 +307,63 @@ const app = createApp({ // Ignore - continuous scanning }; - const submitScan = async () => { - if (!manualForm.article || manualForm.quantity <= 0) { + const handleArticleSelected = async (article) => { + await stopScanner(); + + // Reset form fields + manualForm.article = article; + manualForm.quantity = ''; + manualForm.rack = ''; + manualForm.shelf = ''; + manualForm.note = ''; + manualForm.showNumpad = true; + + // Check if already scanned + try { + const checkRes = await api.get('/checkAlreadyScanned', { + params: { + stocktakeId: selectedStocktake.value.id, + articleId: article.id + } + }); + + if (checkRes.data.success && checkRes.data.alreadyScanned) { + alreadyScannedWarning.show = true; + alreadyScannedWarning.existingItem = checkRes.data.existingItem; + } else { + alreadyScannedWarning.show = false; + alreadyScannedWarning.existingItem = null; + } + } catch (e) { + console.error('Check already scanned error:', e); + } + + manualForm.show = true; + }; + + const submitScan = async (overwrite = false) => { + const qty = parseFloat(manualForm.quantity) || 0; + if (!manualForm.article || qty <= 0) { showToast('Bitte Menge angeben', 'error'); return; } try { - const res = await api.post('/submitScan', { + const payload = { stocktakeId: selectedStocktake.value.id, articleId: manualForm.article.id, - quantity: manualForm.quantity, + quantity: qty, rack: manualForm.rack || null, shelf: manualForm.shelf || null, note: manualForm.note || null, - }); + }; + + if (overwrite && alreadyScannedWarning.existingItem) { + payload.overwrite = true; + payload.overwriteItemId = alreadyScannedWarning.existingItem.id; + } + + const res = await api.post('/submitScan', payload); if (res.data.success) { showToast(res.data.message, 'success'); @@ -264,14 +379,12 @@ const app = createApp({ } // Update progress - progress.totalScanned++; - progress.myScanned++; + if (!overwrite) { + progress.totalScanned++; + progress.myScanned++; + } - // Close form and restart scanner - manualForm.show = false; - manualForm.article = null; - await nextTick(); - startScanner(); + closeForm(); } else { showToast(res.data.message || 'Fehler beim Speichern', 'error'); } @@ -280,30 +393,86 @@ const app = createApp({ } }; - const cancelScan = async () => { + const closeForm = async () => { manualForm.show = false; manualForm.article = null; + manualForm.quantity = ''; + manualForm.searchQuery = ''; + manualForm.searchResults = []; + manualForm.showNumpad = false; + alreadyScannedWarning.show = false; + alreadyScannedWarning.existingItem = null; + selectedCategory.value = null; + showCategoryBrowser.value = false; await nextTick(); - startScanner(); + if (cameraAvailable.value) { + startScanner(); + } }; const openManualEntry = async () => { await stopScanner(); + // Reset form fields manualForm.show = true; manualForm.article = null; + manualForm.quantity = ''; + manualForm.rack = ''; + manualForm.shelf = ''; + manualForm.note = ''; manualForm.searchQuery = ''; manualForm.searchResults = []; + manualForm.showNumpad = false; + alreadyScannedWarning.show = false; + selectedCategory.value = null; + showCategoryBrowser.value = false; + }; + + const openCategoryBrowser = () => { + showCategoryBrowser.value = true; + selectedCategory.value = null; + manualForm.searchResults = []; + }; + + const selectCategory = async (category) => { + selectedCategory.value = category; + showCategoryBrowser.value = false; + manualForm.searching = true; + + try { + const res = await api.get('/searchArticles', { + params: { categoryId: category.id, query: manualForm.searchQuery || '' } + }); + if (res.data.success) { + manualForm.searchResults = res.data.articles; + } + } catch (e) { + console.error('Category search error:', e); + } finally { + manualForm.searching = false; + } + }; + + const clearCategoryFilter = () => { + selectedCategory.value = null; + manualForm.searchResults = []; + if (manualForm.searchQuery.length >= 2) { + searchArticles(); + } }; const searchArticles = async () => { - if (manualForm.searchQuery.length < 2) { + if (manualForm.searchQuery.length < 2 && !selectedCategory.value) { manualForm.searchResults = []; return; } manualForm.searching = true; try { - const res = await api.get('/searchArticles', { params: { query: manualForm.searchQuery } }); + const params = { query: manualForm.searchQuery }; + if (selectedCategory.value) { + params.categoryId = selectedCategory.value.id; + } + const res = await api.get('/searchArticles', { params }); if (res.data.success) { manualForm.searchResults = res.data.articles; } @@ -314,11 +483,8 @@ const app = createApp({ } }; - const selectSearchResult = (article) => { - manualForm.article = article; - manualForm.quantity = 1; - manualForm.searchQuery = ''; - manualForm.searchResults = []; + const selectSearchResult = async (article) => { + await handleArticleSelected(article); }; const fetchMyScans = async () => { @@ -346,6 +512,29 @@ const app = createApp({ } }; + // Numpad functions + const numpadInput = (val) => { + if (val === 'clear') { + manualForm.quantity = ''; + } else if (val === 'backspace') { + manualForm.quantity = String(manualForm.quantity).slice(0, -1); + } else if (val === '+') { + const current = parseFloat(manualForm.quantity) || 0; + manualForm.quantity = String(current + 1); + } else if (val === '-') { + const current = parseFloat(manualForm.quantity) || 0; + if (current > 1) { + manualForm.quantity = String(current - 1); + } + } else if (val === '.') { + if (!String(manualForm.quantity).includes('.')) { + manualForm.quantity = (manualForm.quantity || '0') + '.'; + } + } else { + manualForm.quantity = (manualForm.quantity || '') + val; + } + }; + // Toast notification const toast = reactive({ show: false, message: '', type: 'success' }); let toastTimeout = null; @@ -381,10 +570,12 @@ const app = createApp({ }); return { - currentScreen, stocktakes, selectedStocktake, isLoading, scannerActive, - recentScans, progress, theme, manualForm, toast, - selectStocktake, backToList, submitScan, cancelScan, + currentScreen, stocktakes, selectedStocktake, isLoading, scannerActive, cameraAvailable, + recentScans, progress, theme, manualForm, toast, categories, selectedCategory, + showCategoryBrowser, alreadyScannedWarning, isMobile, + selectStocktake, backToList, submitScan, closeForm, openManualEntry, selectSearchResult, setTheme, logout, + openCategoryBrowser, selectCategory, clearCategoryFilter, numpadInput, }; }, template: ` @@ -476,7 +667,7 @@ const app = createApp({
-
+
@@ -489,6 +680,18 @@ const app = createApp({
+
+
+ + + +
+

Kamera nicht verfügbar

+

Verwenden Sie die manuelle Suche unten.

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

Kategorie wählen

+ +
+
+
+
+

{{ cat.name }}

+
+
+
+
+
+ +
-
+ +
+
+ + + +
+

Bereits gescannt!

+

+ Dieser Artikel wurde bereits erfasst: +
{{ alreadyScannedWarning.existingItem.countedQuantity }} Stk. + von {{ alreadyScannedWarning.existingItem.scannedBy }} + ({{ alreadyScannedWarning.existingItem.scannedAt }}) +

+

+ Geben Sie die neue Menge ein und klicken Sie auf "Überschreiben". +

+
+
+
+

{{ manualForm.article.title }}

{{ manualForm.article.articleNumber }}

{{ manualForm.article.categoryName }}

+
- +
+ + {{ manualForm.quantity || '0' }} + + {{ manualForm.article.unit }} +
+ + +
+ +
+ + +
+ + + + + + + + + + + + + + + + + + +
@@ -558,13 +866,25 @@ const app = createApp({
-
- - + +
+
+ + + + + +
diff --git a/application/WarehouseLocation/WarehouseLocationModel.php b/application/WarehouseLocation/WarehouseLocationModel.php index 44eab9211..00a66f571 100644 --- a/application/WarehouseLocation/WarehouseLocationModel.php +++ b/application/WarehouseLocation/WarehouseLocationModel.php @@ -3,7 +3,7 @@ class WarehouseLocationModel extends TTCrudBaseModel { public int $id; public string $title; - public string $description; + public ?string $description = null; public int $assignedTo; public int $createBy; public int $create; diff --git a/application/WarehouseStocktake/WarehouseStocktakeController.php b/application/WarehouseStocktake/WarehouseStocktakeController.php index a3c113af8..da775dab8 100644 --- a/application/WarehouseStocktake/WarehouseStocktakeController.php +++ b/application/WarehouseStocktake/WarehouseStocktakeController.php @@ -214,7 +214,8 @@ class WarehouseStocktakeController extends TTCrud { // Get items via direct SQL to avoid any ORM issues $db = FronkDB::singleton(); - $result = $db->query("SELECT si.*, a.articleNumber, a.title as articleTitle, w.name as scannedByName + $result = $db->query("SELECT si.*, a.articleNumber, a.title as articleTitle, a.cheapestPurchasePrice, w.name as scannedByName, + CASE WHEN si.overwrittenById IS NOT NULL THEN 1 ELSE 0 END as isOverwritten FROM WarehouseStocktakeItem si LEFT JOIN WarehouseArticle a ON si.articleId = a.id LEFT JOIN Worker w ON si.scannedBy = w.id @@ -222,18 +223,34 @@ class WarehouseStocktakeController extends TTCrud { ORDER BY si.`create` DESC"); $formattedItems = []; + $totalValue = 0; + $totalQuantity = 0; while ($row = $result->fetch_assoc()) { + $unitPrice = $row['cheapestPurchasePrice'] ? (float)$row['cheapestPurchasePrice'] : 0; + $quantity = (float)$row['countedQuantity']; + $lineTotal = $unitPrice * $quantity; + $isOverwritten = (bool)$row['isOverwritten']; + + // Only count non-overwritten items in totals + if (!$isOverwritten) { + $totalValue += $lineTotal; + $totalQuantity += $quantity; + } + $formattedItems[] = [ 'id' => (int)$row['id'], 'articleId' => (int)$row['articleId'], 'articleNumber' => $row['articleNumber'] ?? '', 'articleTitle' => $row['articleTitle'] ?? 'Unbekannt', - 'countedQuantity' => (float)$row['countedQuantity'], + 'countedQuantity' => $quantity, + 'unitPrice' => $unitPrice, + 'lineTotal' => $lineTotal, 'rack' => $row['rack'], 'shelf' => $row['shelf'], 'note' => $row['note'], 'scannedAt' => $row['scannedAt'] ? date('d.m.Y H:i:s', $row['scannedAt']) : null, 'scannedBy' => $row['scannedByName'], + 'isOverwritten' => $isOverwritten, ]; } @@ -251,6 +268,10 @@ class WarehouseStocktakeController extends TTCrud { 'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null, ], 'items' => $formattedItems, + 'summary' => [ + 'totalValue' => $totalValue, + 'totalQuantity' => $totalQuantity, + ], ]); } @@ -347,25 +368,53 @@ class WarehouseStocktakeController extends TTCrud { return; } - $items = WarehouseStocktakeItemModel::getAll(['stocktakeId' => $id]); - $rows = []; + // Get items via direct SQL to include price and overwritten status + $db = FronkDB::singleton(); + $result = $db->query("SELECT si.*, a.articleNumber, a.title as articleTitle, a.cheapestPurchasePrice, w.name as scannedByName + FROM WarehouseStocktakeItem si + LEFT JOIN WarehouseArticle a ON si.articleId = a.id + LEFT JOIN Worker w ON si.scannedBy = w.id + WHERE si.stocktakeId = {$id} + ORDER BY si.`create` ASC"); - foreach ($items as $item) { - $article = $item->getArticle(); - $scannedBy = $item->getScannedByUser(); + $rows = []; + $totalSum = 0; + + while ($row = $result->fetch_assoc()) { + $unitPrice = $row['cheapestPurchasePrice'] ? (float)$row['cheapestPurchasePrice'] : 0; + $quantity = (float)$row['countedQuantity']; + $lineTotal = $unitPrice * $quantity; + $isOverwritten = !empty($row['overwrittenById']); + + // Skip overwritten items in calculation but show them + if (!$isOverwritten) { + $totalSum += $lineTotal; + } $rows[] = [ - 'Artikel-Nr.' => $article ? $article->articleNumber : '', - 'Artikel' => $article ? $article->title : 'Unbekannt', - 'Menge' => $item->countedQuantity, - 'Regal' => $item->rack ?? '', - 'Fach' => $item->shelf ?? '', - 'Notiz' => $item->note ?? '', - 'Gescannt am' => $item->scannedAt ? date('d.m.Y H:i', $item->scannedAt) : '', - 'Gescannt von' => $scannedBy ? $scannedBy->name : '', + 'Artikel Titel' => $row['articleTitle'] ?? 'Unbekannt', + 'Artikel Nummer' => $row['articleNumber'] ?? '', + 'Einzelpreis' => number_format($unitPrice, 2, ',', '.') . ' €', + 'Anzahl' => $quantity, + 'Gesamtsumme' => number_format($lineTotal, 2, ',', '.') . ' €', + 'Gescannt am' => $row['scannedAt'] ? date('d.m.Y H:i', $row['scannedAt']) : '', + 'Gescannt von' => $row['scannedByName'] ?? '', + 'Status' => $isOverwritten ? 'Überschrieben' : '', ]; } + // Add summary row + $rows[] = [ + 'Artikel Titel' => '', + 'Artikel Nummer' => '', + 'Einzelpreis' => '', + 'Anzahl' => 'SUMME:', + 'Gesamtsumme' => number_format($totalSum, 2, ',', '.') . ' €', + 'Gescannt am' => '', + 'Gescannt von' => '', + 'Status' => '', + ]; + $filename = "Inventur_{$stocktake->stocktakeNumber}_" . date('Y-m-d') . ".csv"; $csv = Helper::arrayToCsv($rows); diff --git a/application/WarehouseStocktake/WarehouseStocktakeModel.php b/application/WarehouseStocktake/WarehouseStocktakeModel.php index 1821dc6f0..c36c8c071 100644 --- a/application/WarehouseStocktake/WarehouseStocktakeModel.php +++ b/application/WarehouseStocktake/WarehouseStocktakeModel.php @@ -2,18 +2,18 @@ class WarehouseStocktakeModel extends TTCrudBaseModel { public int $id; - public ?string $stocktakeNumber; + public ?string $stocktakeNumber = null; public string $title; - public ?string $description; + public ?string $description = null; public int $warehouseLocationId; - public string $status; - public ?int $startedAt; - public ?int $completedAt; - public ?int $startedBy; - public ?int $completedBy; + public string $status = 'planned'; + public ?int $startedAt = null; + public ?int $completedAt = null; + public ?int $startedBy = null; + public ?int $completedBy = null; public int $totalItems = 0; public int $totalScannedItems = 0; - public ?string $notes; + public ?string $notes = null; public int $createBy; public int $create; diff --git a/application/WarehouseStocktakeItem/WarehouseStocktakeItemController.php b/application/WarehouseStocktakeItem/WarehouseStocktakeItemController.php index f72f6ba98..e6dd8ac6e 100644 --- a/application/WarehouseStocktakeItem/WarehouseStocktakeItemController.php +++ b/application/WarehouseStocktakeItem/WarehouseStocktakeItemController.php @@ -157,9 +157,10 @@ class WarehouseStocktakeItemController extends TTCrud { return; } - // Try to parse QR code format: WH:articleId:articleNumber + // Try to parse QR code format: WA:articleId:articleNumber (Warehouse Article) + // Also accept WH: for backwards compatibility $articleId = null; - if (preg_match('/^WH:(\d+):/', $code, $matches)) { + if (preg_match('/^(?:WA|WH):(\d+):/', $code, $matches)) { $articleId = intval($matches[1]); } else { // Try to find by article number diff --git a/application/WarehouseStocktakeItem/WarehouseStocktakeItemModel.php b/application/WarehouseStocktakeItem/WarehouseStocktakeItemModel.php index 34efb0e44..ea289249d 100644 --- a/application/WarehouseStocktakeItem/WarehouseStocktakeItemModel.php +++ b/application/WarehouseStocktakeItem/WarehouseStocktakeItemModel.php @@ -11,6 +11,7 @@ class WarehouseStocktakeItemModel extends TTCrudBaseModel { public ?string $note; public ?int $scannedAt; public ?int $scannedBy; + public ?int $overwrittenById; public int $createBy; public int $create; @@ -31,8 +32,8 @@ class WarehouseStocktakeItemModel extends TTCrudBaseModel { /** * Get user who scanned this item */ - public function getScannedByUser(): ?UserModel { + public function getScannedByUser(): ?User { if (!$this->scannedBy) return null; - return UserModel::get($this->scannedBy); + return UserModel::getOne($this->scannedBy); } } diff --git a/application/WarehouseStocktakePWA/WarehouseStocktakePWAController.php b/application/WarehouseStocktakePWA/WarehouseStocktakePWAController.php index 7bf72fefd..64a8b2e1b 100644 --- a/application/WarehouseStocktakePWA/WarehouseStocktakePWAController.php +++ b/application/WarehouseStocktakePWA/WarehouseStocktakePWAController.php @@ -111,8 +111,9 @@ class WarehouseStocktakePWAController extends mfBaseController { $articleId = null; - // Try to parse QR code format: WH:articleId:articleNumber - if (preg_match('/^WH:(\d+):/', $code, $matches)) { + // 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 @@ -134,7 +135,7 @@ class WarehouseStocktakePWAController extends mfBaseController { } // Get category name - $category = WarehouseCategoryModel::get($article->category_id); + $category = WarehouseCategory::get($article->category_id); self::returnJson([ 'success' => true, @@ -150,24 +151,35 @@ class WarehouseStocktakePWAController extends mfBaseController { } /** - * Search articles by text + * Search articles by text with optional category filter */ protected function searchArticlesAction() { - $query = $this->request->query; + $query = $this->request->query ?? ''; + $categoryId = intval($this->request->categoryId ?? 0); - if (!$query || strlen($query) < 2) { + $db = FronkDB::singleton(); + $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; } - $db = FronkDB::singleton(); - $escapedQuery = $db->escape($query); - - $result = $db->query("SELECT id, articleNumber, title, unit + $whereClause = implode(' AND ', $conditions); + $result = $db->query("SELECT id, articleNumber, title, unit, category_id FROM WarehouseArticle - WHERE (articleNumber LIKE '%{$escapedQuery}%' OR title LIKE '%{$escapedQuery}%') - AND (isEndOfLife IS NULL OR isEndOfLife = 0) - LIMIT 20"); + WHERE {$whereClause} + ORDER BY title ASC + LIMIT 50"); $articles = []; while ($row = $result->fetch_assoc()) { @@ -176,12 +188,68 @@ class WarehouseStocktakePWAController extends mfBaseController { '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 + */ + protected function getCategoriesAction() { + $db = FronkDB::singleton(); + $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 + */ + protected 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 = FronkDB::singleton(); + $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 */ @@ -194,6 +262,8 @@ class WarehouseStocktakePWAController extends mfBaseController { $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']); @@ -224,14 +294,64 @@ class WarehouseStocktakePWAController extends mfBaseController { return; } - // Check if this article was already scanned in this stocktake + $db = FronkDB::singleton(); + + // 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; + $isOverwrite = true; + + // Log the overwrite + WarehouseStocktakeLogModel::log($stocktakeId, 'overwritten', $itemId, [ + 'articleId' => $articleId, + 'articleNumber' => $article->articleNumber, + 'articleTitle' => $article->title, + 'quantity' => $quantity, + 'overwrittenItemId' => $overwriteItemId, + ]); + + // Update stocktake progress (don't increase count since we're replacing) + $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 + 'articleId' => $articleId, + 'overwrittenById' => null ]); - $db = FronkDB::singleton(); - if ($existing) { // Update existing entry - add to quantity $newQuantity = $existing->countedQuantity + $quantity; diff --git a/public/js/pages/WarehouseStocktake/WarehouseStocktake.js b/public/js/pages/WarehouseStocktake/WarehouseStocktake.js index 7bf6d438e..bb125468b 100644 --- a/public/js/pages/WarehouseStocktake/WarehouseStocktake.js +++ b/public/js/pages/WarehouseStocktake/WarehouseStocktake.js @@ -31,54 +31,67 @@ Vue.component('stocktake-progress-modal', {
-
+
-
+
-
Inventur
-

{{ stocktake.title }}

+
Inventur
+
{{ stocktake.title }}
- +
-
+
-
+
-
Gescannte Artikel
-

{{ stocktake.totalScannedItems }}

+
Gescannte Artikel
+
{{ stocktake.totalScannedItems }}
- + +
+
+
+
+
+
+
+
+
+
Lagerort
+
{{ stocktake.locationName }}
+
+
-
-
+
+
-
Lagerort
-

{{ stocktake.locationName }}

+
Gesamtwert (Einkauf)
+
{{ formatCurrency(summary.totalValue) }}
- +
-
+
-
Status
-

{{ statusText }}

+
Status
+
{{ statusText }}
- +
@@ -111,24 +124,39 @@ Vue.component('stocktake-progress-modal', { Artikel-Nr. Artikel + Einzelpreis Menge + Gesamtpreis Regal Fach Gescannt am Gescannt von + Status - + {{ item.articleNumber }} {{ item.articleTitle }} + {{ formatCurrency(item.unitPrice) }} {{ item.countedQuantity }} + {{ formatCurrency(item.lineTotal) }} {{ item.rack || '-' }} {{ item.shelf || '-' }} {{ item.scannedAt || '-' }} {{ item.scannedBy || '-' }} + + Überschrieben + + + + Summe: + {{ formatCurrency(summary.totalValue) }} + + + @@ -151,6 +179,7 @@ Vue.component('stocktake-progress-modal', { refreshing: false, stocktake: null, items: [], + summary: { totalValue: 0, totalQuantity: 0 }, refreshInterval: null, countdownInterval: null, countdown: 5, @@ -216,6 +245,7 @@ Vue.component('stocktake-progress-modal', { if (response.data.success) { this.stocktake = response.data.stocktake; this.items = response.data.items; + this.summary = response.data.summary || { totalValue: 0, totalQuantity: 0 }; } } catch (error) { console.error('Failed to load progress:', error); @@ -252,6 +282,9 @@ Vue.component('stocktake-progress-modal', { clearInterval(this.countdownInterval); this.countdownInterval = null; } + }, + formatCurrency(value) { + return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value || 0); } }, beforeDestroy() {