Files
thetool/public/mobile/modules/lager/inventur/Scanner.js
2026-01-17 12:48:08 +00:00

403 lines
23 KiB
JavaScript

import { createModuleApi, debounce } from '/mobile/shared/api.js';
const inventurApi = createModuleApi('Lager/Inventur');
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;
const currentTab = ref('scan');
const isLoading = ref(false);
const scanner = ref(null);
const isScannerActive = ref(false);
const scannerError = ref('');
const scannedArticle = ref(null);
const quantity = ref('1');
const rack = ref('');
const shelf = ref('');
const searchQuery = ref('');
const searchResults = ref([]);
const categories = ref([]);
const selectedCategory = ref(0);
const isSearching = ref(false);
const recentScans = ref([]);
const isLoadingHistory = ref(false);
const alreadyScannedWarning = ref(null);
const showKeypad = ref(false);
const canSubmit = computed(() => {
return scannedArticle.value && parseFloat(quantity.value) > 0 && !isLoading.value;
});
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);
};
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;
}
};
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) {
navigator.vibrate?.([100]);
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;
}
};
const loadCategories = async () => {
const result = await inventurApi.get('getCategories');
if (result.success) categories.value = result.categories;
};
const doSearch = 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 searchArticles = debounce(doSearch, 300);
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;
}
};
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;
}
};
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'; };
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: `
<div class="flex flex-col h-full">
<div class="flex items-center justify-between px-3 py-2 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700">
<span class="text-sm font-medium text-slate-600 dark:text-slate-300 truncate flex-1">{{ stocktake.title || 'Inventur #' + stocktake.stocktakeNumber }}</span>
<button @click="handleClose" class="ml-2 p-1.5 rounded-full text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 transition">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex bg-slate-100 dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700 px-3 py-2">
<button @click="switchTab('scan')" :class="[currentTab === 'scan' ? 'bg-white dark:bg-slate-800 shadow-sm' : 'text-slate-500 dark:text-slate-400']" class="flex-1 py-2 text-sm font-medium rounded-lg transition">Scannen</button>
<button @click="switchTab('search')" :class="[currentTab === 'search' ? 'bg-white dark:bg-slate-800 shadow-sm' : 'text-slate-500 dark:text-slate-400']" class="flex-1 py-2 text-sm font-medium rounded-lg transition mx-1">Suche</button>
<button @click="switchTab('history')" :class="[currentTab === 'history' ? 'bg-white dark:bg-slate-800 shadow-sm' : 'text-slate-500 dark:text-slate-400']" class="flex-1 py-2 text-sm font-medium rounded-lg transition">Verlauf</button>
</div>
<main class="flex-grow overflow-y-auto bg-slate-50 dark:bg-slate-900">
<div v-if="currentTab === 'scan'" class="p-4 space-y-4">
<div v-if="!scannedArticle" class="space-y-4">
<div id="qr-reader" class="w-full rounded-lg overflow-hidden bg-black"></div>
<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>
<div v-else class="space-y-4">
<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>
<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>
<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 class="flex gap-2 mb-3">
<button @click="quantity = '1'" :class="['flex-1 py-2 rounded-lg font-bold transition', quantity === '1' ? 'bg-primary text-white' : 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300']">1</button>
<button @click="quantity = '5'" :class="['flex-1 py-2 rounded-lg font-bold transition', quantity === '5' ? 'bg-primary text-white' : 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300']">5</button>
<button @click="quantity = '10'" :class="['flex-1 py-2 rounded-lg font-bold transition', quantity === '10' ? 'bg-primary text-white' : 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300']">10</button>
<button @click="quantity = '20'" :class="['flex-1 py-2 rounded-lg font-bold transition', quantity === '20' ? 'bg-primary text-white' : 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300']">20</button>
</div>
<div class="flex items-center gap-2">
<button @click="quantity = String(Math.max(1, parseFloat(quantity) - 1))" class="w-14 h-14 bg-slate-100 dark:bg-slate-700 rounded-lg text-2xl font-bold text-slate-700 dark:text-slate-300 active:bg-slate-200 dark:active:bg-slate-600">-</button>
<div @click="showKeypad = true" class="flex-1 p-3 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>
<button @click="quantity = String(parseFloat(quantity) + 1)" class="w-14 h-14 bg-slate-100 dark:bg-slate-700 rounded-lg text-2xl font-bold text-slate-700 dark:text-slate-300 active:bg-slate-200 dark:active:bg-slate-600">+</button>
</div>
</div>
<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>
<div class="space-y-2">
<button v-if="alreadyScannedWarning" @click="submitScan(false)" :disabled="!canSubmit" class="w-full py-5 bg-green-600 text-white text-lg font-bold rounded-xl disabled:opacity-50 active:scale-[0.98] transition">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-xl disabled:opacity-50 active:scale-[0.98] transition">Überschreiben</button>
<button v-if="!alreadyScannedWarning" @click="submitScan(false)" :disabled="!canSubmit" class="w-full py-5 bg-green-600 text-white text-lg font-bold rounded-xl disabled:opacity-50 active:scale-[0.98] transition">
{{ isLoading ? 'Speichert...' : 'Speichern' }}
</button>
<button @click="cancelScan" class="w-full py-2 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300">Abbrechen</button>
</div>
</div>
</div>
<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>
<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>
<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>
`
};