438 lines
22 KiB
JavaScript
438 lines
22 KiB
JavaScript
/**
|
|
* 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: `
|
|
<div class="flex flex-col h-full">
|
|
<!-- Title bar with close -->
|
|
<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>
|
|
|
|
<!-- Tabs -->
|
|
<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>
|
|
|
|
<!-- Content -->
|
|
<main class="flex-grow overflow-y-auto bg-slate-50 dark:bg-slate-900">
|
|
<!-- SCAN TAB -->
|
|
<div v-if="currentTab === 'scan'" class="p-4 space-y-4">
|
|
<!-- Scanner -->
|
|
<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>
|
|
|
|
<!-- Scanned Article -->
|
|
<div v-else class="space-y-4">
|
|
<!-- 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 -->
|
|
<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>
|
|
|
|
<!-- Rack/Shelf -->
|
|
<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>
|
|
|
|
<!-- 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">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">Ü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">
|
|
{{ 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>
|
|
|
|
<!-- Keypad -->
|
|
<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>
|
|
`
|
|
};
|