Files
thetool/public/mobile/warehouse-stocktake/components/Scanner.js
2026-01-13 12:44:45 +01:00

608 lines
26 KiB
JavaScript

/**
* 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: `
<div class="flex flex-col h-full bg-slate-100 dark:bg-slate-900">
<!-- Header -->
<header class="bg-white dark:bg-slate-800 shadow-sm p-4 flex-shrink-0">
<div class="flex items-center justify-between">
<button @click="handleClose" class="p-2 -ml-2 rounded-full hover:bg-slate-100 dark:hover:bg-slate-700">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-slate-600 dark:text-slate-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<h1 class="text-lg font-bold text-slate-800 dark:text-white truncate px-2">
{{ stocktake.title || 'Inventur #' + stocktake.stocktakeNumber }}
</h1>
<div class="w-10"></div>
</div>
<!-- Tabs -->
<div class="flex mt-4 bg-slate-100 dark:bg-slate-700 rounded-lg p-1">
<button
@click="switchTab('scan')"
:class="[currentTab === 'scan' ? 'bg-white dark:bg-slate-600 shadow' : '']"
class="flex-1 py-2 text-sm font-medium rounded-md transition text-slate-700 dark:text-slate-200"
>
Scannen
</button>
<button
@click="switchTab('search')"
:class="[currentTab === 'search' ? 'bg-white dark:bg-slate-600 shadow' : '']"
class="flex-1 py-2 text-sm font-medium rounded-md transition text-slate-700 dark:text-slate-200"
>
Suche
</button>
<button
@click="switchTab('history')"
:class="[currentTab === 'history' ? 'bg-white dark:bg-slate-600 shadow' : '']"
class="flex-1 py-2 text-sm font-medium rounded-md transition text-slate-700 dark:text-slate-200"
>
Verlauf
</button>
</div>
</header>
<!-- Content -->
<main class="flex-grow overflow-y-auto">
<!-- SCAN TAB -->
<div v-if="currentTab === 'scan'" class="p-4 space-y-4">
<!-- Scanner or Article View -->
<div v-if="!scannedArticle" class="space-y-4">
<!-- QR Scanner -->
<div id="qr-reader" class="w-full rounded-lg overflow-hidden bg-black"></div>
<!-- Scanner Error -->
<div v-if="scannerError" class="p-4 bg-red-50 dark:bg-red-900/30 rounded-lg">
<p class="text-sm text-red-600 dark:text-red-400">{{ scannerError }}</p>
<button @click="startScanner" class="mt-2 text-sm font-medium text-primary">
Erneut versuchen
</button>
</div>
<p class="text-center text-sm text-slate-500 dark:text-slate-400">
QR-Code scannen oder Artikel suchen
</p>
</div>
<!-- Scanned Article -->
<div v-else class="space-y-4">
<!-- Already Scanned Warning -->
<div v-if="alreadyScannedWarning" class="p-4 bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 rounded-lg">
<div class="flex items-start">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-amber-500 mr-2 flex-shrink-0 mt-0.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
<div>
<p class="font-medium text-amber-800 dark:text-amber-300">Bereits gescannt</p>
<p class="text-sm text-amber-700 dark:text-amber-400 mt-1">
Menge: {{ alreadyScannedWarning.countedQuantity }}<br>
Von: {{ alreadyScannedWarning.scannedBy }}<br>
Am: {{ alreadyScannedWarning.scannedAt }}
</p>
</div>
</div>
</div>
<!-- Article Info -->
<div class="bg-white dark:bg-slate-800 p-4 rounded-lg shadow-sm">
<h3 class="font-bold text-lg text-slate-800 dark:text-white">
{{ scannedArticle.title }}
</h3>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">
Art.-Nr.: {{ scannedArticle.articleNumber }}
</p>
<p v-if="scannedArticle.categoryName" class="text-sm text-slate-500 dark:text-slate-400">
Kategorie: {{ scannedArticle.categoryName }}
</p>
</div>
<!-- Quantity Input -->
<div class="bg-white dark:bg-slate-800 p-4 rounded-lg shadow-sm">
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Menge ({{ scannedArticle.unit || 'Stk.' }})
</label>
<div
@click="showKeypad = true"
class="w-full p-4 text-3xl font-bold text-center border-2 border-slate-300 dark:border-slate-600 rounded-lg cursor-pointer hover:border-primary transition text-slate-800 dark:text-white"
>
{{ quantity }}
</div>
</div>
<!-- Optional Fields -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Regal</label>
<input v-model="rack" type="text" class="w-full p-3 border border-slate-300 dark:border-slate-600 rounded-lg dark:bg-slate-800 dark:text-white" placeholder="z.B. A1">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Fach</label>
<input v-model="shelf" type="text" class="w-full p-3 border border-slate-300 dark:border-slate-600 rounded-lg dark:bg-slate-800 dark:text-white" placeholder="z.B. 3">
</div>
</div>
<!-- Action Buttons -->
<div class="space-y-2">
<button
v-if="alreadyScannedWarning"
@click="submitScan(false)"
:disabled="!canSubmit"
class="w-full py-4 bg-green-600 text-white font-bold rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
Zur Menge addieren
</button>
<button
v-if="alreadyScannedWarning"
@click="submitScan(true)"
:disabled="!canSubmit"
class="w-full py-4 bg-amber-600 text-white font-bold rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
Überschreiben
</button>
<button
v-if="!alreadyScannedWarning"
@click="submitScan(false)"
:disabled="!canSubmit"
class="w-full py-4 bg-green-600 text-white font-bold rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ isLoading ? 'Speichert...' : 'Speichern' }}
</button>
<button @click="cancelScan" class="w-full py-3 text-slate-600 dark:text-slate-400 font-medium">
Abbrechen
</button>
</div>
</div>
</div>
<!-- SEARCH TAB -->
<div v-else-if="currentTab === 'search'" class="p-4 space-y-4">
<div class="sticky top-0 bg-slate-100 dark:bg-slate-900 pb-2 space-y-3">
<input
v-model="searchQuery"
@input="searchArticles"
type="search"
placeholder="Artikel suchen..."
class="w-full p-3 border border-slate-300 dark:border-slate-600 rounded-lg dark:bg-slate-800 dark:text-white"
>
<select
v-model="selectedCategory"
@change="searchArticles"
class="w-full p-3 border border-slate-300 dark:border-slate-600 rounded-lg dark:bg-slate-800 dark:text-white"
>
<option :value="0">Alle Kategorien</option>
<option v-for="cat in categories" :key="cat.id" :value="cat.id">{{ cat.name }}</option>
</select>
</div>
<div v-if="isSearching" class="text-center py-8">
<div class="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full mx-auto"></div>
</div>
<div v-else-if="searchResults.length === 0" class="text-center py-8">
<p class="text-slate-500 dark:text-slate-400">
{{ searchQuery.length < 2 ? 'Mindestens 2 Zeichen eingeben' : 'Keine Artikel gefunden' }}
</p>
</div>
<div v-else class="space-y-2">
<div
v-for="article in searchResults"
:key="article.id"
@click="selectSearchResult(article)"
class="bg-white dark:bg-slate-800 p-3 rounded-lg shadow-sm cursor-pointer hover:shadow-md active:scale-[0.98] transition"
>
<p class="font-medium text-slate-800 dark:text-white">{{ article.title }}</p>
<p class="text-sm text-slate-500 dark:text-slate-400">{{ article.articleNumber }}</p>
</div>
</div>
</div>
<!-- HISTORY TAB -->
<div v-else-if="currentTab === 'history'" class="p-4">
<div v-if="isLoadingHistory" class="space-y-3">
<div v-for="i in 5" :key="i" class="bg-white dark:bg-slate-800 p-3 rounded-lg shadow-sm animate-pulse">
<div class="h-4 bg-slate-200 dark:bg-slate-700 rounded w-3/4 mb-2"></div>
<div class="h-3 bg-slate-200 dark:bg-slate-700 rounded w-1/2"></div>
</div>
</div>
<div v-else-if="recentScans.length === 0" class="text-center py-8">
<p class="text-slate-500 dark:text-slate-400">Noch keine Scans</p>
</div>
<div v-else class="space-y-2">
<div v-for="scan in recentScans" :key="scan.id" class="bg-white dark:bg-slate-800 p-3 rounded-lg shadow-sm">
<div class="flex justify-between items-start">
<div>
<p class="font-medium text-slate-800 dark:text-white">{{ scan.articleTitle }}</p>
<p class="text-sm text-slate-500 dark:text-slate-400">{{ scan.articleNumber }}</p>
</div>
<div class="text-right">
<p class="font-bold text-slate-800 dark:text-white">{{ scan.countedQuantity }} {{ scan.unit }}</p>
<p class="text-xs text-slate-400">{{ scan.scannedAt }}</p>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- Custom Keypad Modal -->
<transition name="slide-up">
<div v-if="showKeypad" class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-end">
<div class="bg-white dark:bg-slate-800 w-full rounded-t-2xl p-4 safe-area-bottom">
<div class="flex justify-between items-center mb-4">
<button @click="clearQuantity" class="px-4 py-2 text-red-500 font-medium">C</button>
<div class="text-2xl font-bold text-slate-800 dark:text-white">{{ quantity }}</div>
<button @click="showKeypad = false" class="px-4 py-2 text-primary font-medium">Fertig</button>
</div>
<div class="grid grid-cols-3 gap-2">
<button v-for="d in ['1','2','3','4','5','6','7','8','9','.','0']" :key="d" @click="appendDigit(d)" class="py-4 text-xl font-bold bg-slate-100 dark:bg-slate-700 rounded-lg active:bg-slate-200 dark:active:bg-slate-600 text-slate-800 dark:text-white">
{{ d }}
</button>
<button @click="deleteDigit" class="py-4 text-xl font-bold bg-slate-100 dark:bg-slate-700 rounded-lg active:bg-slate-200 dark:active:bg-slate-600 text-slate-800 dark:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2M3 12l6.414 6.414a2 2 0 001.414.586H19a2 2 0 002-2V7a2 2 0 00-2-2h-8.172a2 2 0 00-1.414.586L3 12z" />
</svg>
</button>
</div>
</div>
</div>
</transition>
</div>
`
};