fixed stocktake and warehouselocation

This commit is contained in:
2025-12-17 18:29:49 +01:00
parent 510cf384c6
commit 08e4fb7f71
8 changed files with 633 additions and 109 deletions

View File

@@ -74,6 +74,52 @@
0% { background-color: rgb(34, 197, 94); } 0% { background-color: rgb(34, 197, 94); }
100% { background-color: transparent; } 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);
}
}
</style> </style>
</head> </head>
<body class="bg-slate-100 dark:bg-slate-900 transition-colors duration-300"> <body class="bg-slate-100 dark:bg-slate-900 transition-colors duration-300">
@@ -91,22 +137,35 @@ const app = createApp({
const selectedStocktake = ref(null); const selectedStocktake = ref(null);
const isLoading = ref(true); const isLoading = ref(true);
const scannerActive = ref(false); const scannerActive = ref(false);
const cameraAvailable = ref(true);
const lastScan = ref(null); const lastScan = ref(null);
const recentScans = ref([]); const recentScans = ref([]);
const progress = reactive({ totalScanned: 0, myScanned: 0 }); const progress = reactive({ totalScanned: 0, myScanned: 0 });
const theme = ref(localStorage.getItem('theme') || 'system'); 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 // Form state
const manualForm = reactive({ const manualForm = reactive({
show: false, show: false,
article: null, article: null,
quantity: 1, quantity: '',
rack: '', rack: '',
shelf: '', shelf: '',
note: '', note: '',
searchQuery: '', searchQuery: '',
searchResults: [], searchResults: [],
searching: false, searching: false,
showNumpad: false,
}); });
// Scanner instance // Scanner instance
@@ -122,6 +181,12 @@ const app = createApp({
return window.matchMedia('(prefers-color-scheme: dark)').matches; 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 === // === METHODS ===
const applyTheme = () => { const applyTheme = () => {
document.documentElement.classList.toggle('dark', isDark.value); 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) => { const selectStocktake = async (stocktake) => {
selectedStocktake.value = stocktake; selectedStocktake.value = stocktake;
currentScreen.value = 'scanner'; currentScreen.value = 'scanner';
await fetchMyScans(); await fetchMyScans();
await fetchProgress(); await fetchProgress();
await fetchCategories();
await nextTick(); await nextTick();
startScanner(); startScanner();
}; };
@@ -181,9 +258,11 @@ const app = createApp({
onScanFailure onScanFailure
); );
scannerActive.value = true; scannerActive.value = true;
cameraAvailable.value = true;
} catch (err) { } catch (err) {
console.error('Scanner start error:', 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 { try {
const res = await api.get('/getArticle', { params: { code: decodedText } }); const res = await api.get('/getArticle', { params: { code: decodedText } });
if (res.data.success) { if (res.data.success) {
manualForm.article = res.data.article; await handleArticleSelected(res.data.article);
manualForm.quantity = 1;
manualForm.rack = '';
manualForm.shelf = '';
manualForm.note = '';
manualForm.show = true;
await stopScanner();
} else { } else {
showToast(res.data.message || 'Artikel nicht gefunden', 'error'); showToast(res.data.message || 'Artikel nicht gefunden', 'error');
} }
@@ -234,21 +307,63 @@ const app = createApp({
// Ignore - continuous scanning // Ignore - continuous scanning
}; };
const submitScan = async () => { const handleArticleSelected = async (article) => {
if (!manualForm.article || manualForm.quantity <= 0) { 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'); showToast('Bitte Menge angeben', 'error');
return; return;
} }
try { try {
const res = await api.post('/submitScan', { const payload = {
stocktakeId: selectedStocktake.value.id, stocktakeId: selectedStocktake.value.id,
articleId: manualForm.article.id, articleId: manualForm.article.id,
quantity: manualForm.quantity, quantity: qty,
rack: manualForm.rack || null, rack: manualForm.rack || null,
shelf: manualForm.shelf || null, shelf: manualForm.shelf || null,
note: manualForm.note || 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) { if (res.data.success) {
showToast(res.data.message, 'success'); showToast(res.data.message, 'success');
@@ -264,14 +379,12 @@ const app = createApp({
} }
// Update progress // Update progress
progress.totalScanned++; if (!overwrite) {
progress.myScanned++; progress.totalScanned++;
progress.myScanned++;
}
// Close form and restart scanner closeForm();
manualForm.show = false;
manualForm.article = null;
await nextTick();
startScanner();
} else { } else {
showToast(res.data.message || 'Fehler beim Speichern', 'error'); 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.show = false;
manualForm.article = null; 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(); await nextTick();
startScanner(); if (cameraAvailable.value) {
startScanner();
}
}; };
const openManualEntry = async () => { const openManualEntry = async () => {
await stopScanner(); await stopScanner();
// Reset form fields
manualForm.show = true; manualForm.show = true;
manualForm.article = null; manualForm.article = null;
manualForm.quantity = '';
manualForm.rack = '';
manualForm.shelf = '';
manualForm.note = '';
manualForm.searchQuery = ''; manualForm.searchQuery = '';
manualForm.searchResults = []; 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 () => { const searchArticles = async () => {
if (manualForm.searchQuery.length < 2) { if (manualForm.searchQuery.length < 2 && !selectedCategory.value) {
manualForm.searchResults = []; manualForm.searchResults = [];
return; return;
} }
manualForm.searching = true; manualForm.searching = true;
try { 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) { if (res.data.success) {
manualForm.searchResults = res.data.articles; manualForm.searchResults = res.data.articles;
} }
@@ -314,11 +483,8 @@ const app = createApp({
} }
}; };
const selectSearchResult = (article) => { const selectSearchResult = async (article) => {
manualForm.article = article; await handleArticleSelected(article);
manualForm.quantity = 1;
manualForm.searchQuery = '';
manualForm.searchResults = [];
}; };
const fetchMyScans = async () => { 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 // Toast notification
const toast = reactive({ show: false, message: '', type: 'success' }); const toast = reactive({ show: false, message: '', type: 'success' });
let toastTimeout = null; let toastTimeout = null;
@@ -381,10 +570,12 @@ const app = createApp({
}); });
return { return {
currentScreen, stocktakes, selectedStocktake, isLoading, scannerActive, currentScreen, stocktakes, selectedStocktake, isLoading, scannerActive, cameraAvailable,
recentScans, progress, theme, manualForm, toast, recentScans, progress, theme, manualForm, toast, categories, selectedCategory,
selectStocktake, backToList, submitScan, cancelScan, showCategoryBrowser, alreadyScannedWarning, isMobile,
selectStocktake, backToList, submitScan, closeForm,
openManualEntry, selectSearchResult, setTheme, logout, openManualEntry, selectSearchResult, setTheme, logout,
openCategoryBrowser, selectCategory, clearCategoryFilter, numpadInput,
}; };
}, },
template: ` template: `
@@ -476,7 +667,7 @@ const app = createApp({
<!-- Scanner View --> <!-- Scanner View -->
<div v-if="!manualForm.show" class="p-4"> <div v-if="!manualForm.show" class="p-4">
<div class="relative bg-black rounded-xl overflow-hidden mb-4"> <div v-if="cameraAvailable" class="relative bg-black rounded-xl overflow-hidden mb-4">
<div id="qr-reader" class="w-full"></div> <div id="qr-reader" class="w-full"></div>
<div v-if="!scannerActive" class="absolute inset-0 flex items-center justify-center bg-slate-900/80"> <div v-if="!scannerActive" class="absolute inset-0 flex items-center justify-center bg-slate-900/80">
<div class="text-center text-white"> <div class="text-center text-white">
@@ -489,6 +680,18 @@ const app = createApp({
</div> </div>
</div> </div>
<div v-else class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-4 mb-4">
<div class="flex items-start">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-amber-500 mr-2 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<p class="text-amber-800 dark:text-amber-200 font-medium">Kamera nicht verfügbar</p>
<p class="text-amber-700 dark:text-amber-300 text-sm">Verwenden Sie die manuelle Suche unten.</p>
</div>
</div>
</div>
<button @click="openManualEntry" <button @click="openManualEntry"
class="w-full py-3 bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-200 rounded-xl font-medium"> class="w-full py-3 bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-200 rounded-xl font-medium">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -503,6 +706,19 @@ const app = createApp({
<div v-if="manualForm.show" class="flex-1 bg-white dark:bg-slate-800 p-4 overflow-auto"> <div v-if="manualForm.show" class="flex-1 bg-white dark:bg-slate-800 p-4 overflow-auto">
<!-- Search (if no article selected) --> <!-- Search (if no article selected) -->
<div v-if="!manualForm.article" class="space-y-4"> <div v-if="!manualForm.article" class="space-y-4">
<!-- Category Filter -->
<div v-if="selectedCategory" class="flex items-center bg-primary/10 dark:bg-primary/20 rounded-lg p-2 mb-2">
<span class="text-sm text-primary dark:text-secondary font-medium flex-1">
Kategorie: {{ selectedCategory.name }}
</span>
<button @click="clearCategoryFilter" class="p-1 text-primary dark:text-secondary">
<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>
<!-- Search Input -->
<div class="relative"> <div class="relative">
<input v-model="manualForm.searchQuery" type="text" inputmode="search" <input v-model="manualForm.searchQuery" type="text" inputmode="search"
placeholder="Artikelnummer oder Name..." placeholder="Artikelnummer oder Name..."
@@ -515,6 +731,39 @@ const app = createApp({
</div> </div>
</div> </div>
<!-- Category Browser Button -->
<button @click="openCategoryBrowser"
class="w-full py-3 bg-primary/10 dark:bg-primary/20 text-primary dark:text-secondary rounded-xl font-medium flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
Nach Kategorie durchsuchen
</button>
<!-- Category Browser Modal -->
<div v-if="showCategoryBrowser" class="fixed inset-0 bg-black/50 z-50 flex items-end">
<div class="bg-white dark:bg-slate-800 w-full rounded-t-2xl max-h-[70vh] flex flex-col">
<div class="p-4 border-b dark:border-slate-700 flex justify-between items-center">
<h3 class="font-bold text-lg dark:text-white">Kategorie wählen</h3>
<button @click="showCategoryBrowser = false" class="p-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-slate-500" 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="overflow-y-auto flex-1 p-4">
<div class="grid grid-cols-2 gap-2">
<div v-for="cat in categories" :key="cat.id"
@click="selectCategory(cat)"
class="p-3 bg-slate-50 dark:bg-slate-700 rounded-lg cursor-pointer active:bg-slate-100 dark:active:bg-slate-600 text-center">
<p class="font-medium text-slate-800 dark:text-white text-sm">{{ cat.name }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Search Results -->
<div v-if="manualForm.searchResults.length" class="space-y-2 max-h-64 overflow-auto"> <div v-if="manualForm.searchResults.length" class="space-y-2 max-h-64 overflow-auto">
<div v-for="article in manualForm.searchResults" :key="article.id" <div v-for="article in manualForm.searchResults" :key="article.id"
@click="selectSearchResult(article)" @click="selectSearchResult(article)"
@@ -524,25 +773,84 @@ const app = createApp({
</div> </div>
</div> </div>
<button @click="cancelScan" class="w-full py-3 bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-200 rounded-xl"> <button @click="closeForm" class="w-full py-3 bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-200 rounded-xl">
Abbrechen Abbrechen
</button> </button>
</div> </div>
<!-- Article Form (if article selected) --> <!-- Article Form (if article selected) -->
<div v-else class="space-y-4"> <div v-else class="space-y-4">
<!-- Already Scanned Warning -->
<div v-if="alreadyScannedWarning.show" class="bg-amber-100 dark:bg-amber-900/30 border-2 border-amber-400 dark:border-amber-600 rounded-xl p-4 warning-pulse">
<div class="flex items-start">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-amber-600 dark:text-amber-400 mr-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<p class="font-bold text-amber-800 dark:text-amber-200">Bereits gescannt!</p>
<p class="text-sm text-amber-700 dark:text-amber-300 mt-1">
Dieser Artikel wurde bereits erfasst:
<br><strong>{{ alreadyScannedWarning.existingItem.countedQuantity }} Stk.</strong>
von {{ alreadyScannedWarning.existingItem.scannedBy }}
({{ alreadyScannedWarning.existingItem.scannedAt }})
</p>
<p class="text-xs text-amber-600 dark:text-amber-400 mt-2">
Geben Sie die neue Menge ein und klicken Sie auf "Überschreiben".
</p>
</div>
</div>
</div>
<div class="bg-slate-50 dark:bg-slate-700 rounded-xl p-4"> <div class="bg-slate-50 dark:bg-slate-700 rounded-xl p-4">
<p class="font-bold text-lg text-slate-800 dark:text-white">{{ manualForm.article.title }}</p> <p class="font-bold text-lg text-slate-800 dark:text-white">{{ manualForm.article.title }}</p>
<p class="text-sm text-slate-500 dark:text-slate-400">{{ manualForm.article.articleNumber }}</p> <p class="text-sm text-slate-500 dark:text-slate-400">{{ manualForm.article.articleNumber }}</p>
<p v-if="manualForm.article.categoryName" class="text-xs text-slate-400 mt-1">{{ manualForm.article.categoryName }}</p> <p v-if="manualForm.article.categoryName" class="text-xs text-slate-400 mt-1">{{ manualForm.article.categoryName }}</p>
</div> </div>
<!-- Quantity with Custom Numpad -->
<div> <div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"> <label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Menge ({{ manualForm.article.unit }}) * Menge ({{ manualForm.article.unit }}) *
</label> </label>
<input v-model.number="manualForm.quantity" type="number" inputmode="decimal" min="0.01" step="0.01" <div class="text-center bg-slate-100 dark:bg-slate-700 rounded-xl p-4 mb-3">
class="w-full px-4 py-3 text-xl font-bold text-center rounded-xl border dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:ring-2 focus:ring-primary"> <span class="text-4xl font-bold text-primary dark:text-secondary">
{{ manualForm.quantity || '0' }}
</span>
<span class="text-xl text-slate-500 ml-2">{{ manualForm.article.unit }}</span>
</div>
<!-- Desktop: regular input field -->
<div v-if="!isMobile" class="mt-2">
<input v-model="manualForm.quantity" type="number" inputmode="decimal" min="0.01" step="0.01"
placeholder="Menge eingeben..."
class="w-full px-4 py-3 text-xl font-bold text-center rounded-xl border dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:ring-2 focus:ring-primary">
</div>
<!-- Numpad (only on mobile devices) -->
<div v-if="manualForm.showNumpad && isMobile" class="grid grid-cols-4 gap-2">
<button @click="numpadInput('1')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">1</button>
<button @click="numpadInput('2')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">2</button>
<button @click="numpadInput('3')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">3</button>
<button @click="numpadInput('+')" class="numpad-btn bg-green-500 text-white">+</button>
<button @click="numpadInput('4')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">4</button>
<button @click="numpadInput('5')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">5</button>
<button @click="numpadInput('6')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">6</button>
<button @click="numpadInput('-')" class="numpad-btn bg-red-500 text-white">-</button>
<button @click="numpadInput('7')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">7</button>
<button @click="numpadInput('8')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">8</button>
<button @click="numpadInput('9')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">9</button>
<button @click="numpadInput('backspace')" class="numpad-btn bg-slate-300 dark:bg-slate-500 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>
<button @click="numpadInput('.')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">.</button>
<button @click="numpadInput('0')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">0</button>
<button @click="numpadInput('clear')" class="numpad-btn bg-slate-300 dark:bg-slate-500 text-slate-800 dark:text-white col-span-2">C</button>
</div>
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
@@ -558,13 +866,25 @@ const app = createApp({
</div> </div>
</div> </div>
<div class="flex space-x-3 pt-4"> <!-- Action Buttons -->
<button @click="cancelScan" class="flex-1 py-3 bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-200 rounded-xl font-medium"> <div class="space-y-2 pt-4">
Abbrechen <div class="flex space-x-3">
</button> <button @click="closeForm" class="flex-1 py-3 bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-200 rounded-xl font-medium">
<button @click="submitScan" class="flex-1 py-3 bg-green-600 text-white rounded-xl font-bold"> Abbrechen
Speichern </button>
</button> <!-- Show "Speichern" only when NOT already scanned -->
<button v-if="!alreadyScannedWarning.show"
@click="submitScan(false)"
class="flex-1 py-3 bg-green-600 text-white rounded-xl font-bold">
Speichern
</button>
<!-- Show "Überschreiben" only when already scanned -->
<button v-else
@click="submitScan(true)"
class="flex-1 py-3 bg-amber-500 text-white rounded-xl font-bold">
Überschreiben
</button>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -3,7 +3,7 @@
class WarehouseLocationModel extends TTCrudBaseModel { class WarehouseLocationModel extends TTCrudBaseModel {
public int $id; public int $id;
public string $title; public string $title;
public string $description; public ?string $description = null;
public int $assignedTo; public int $assignedTo;
public int $createBy; public int $createBy;
public int $create; public int $create;

View File

@@ -214,7 +214,8 @@ class WarehouseStocktakeController extends TTCrud {
// Get items via direct SQL to avoid any ORM issues // Get items via direct SQL to avoid any ORM issues
$db = FronkDB::singleton(); $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 FROM WarehouseStocktakeItem si
LEFT JOIN WarehouseArticle a ON si.articleId = a.id LEFT JOIN WarehouseArticle a ON si.articleId = a.id
LEFT JOIN Worker w ON si.scannedBy = w.id LEFT JOIN Worker w ON si.scannedBy = w.id
@@ -222,18 +223,34 @@ class WarehouseStocktakeController extends TTCrud {
ORDER BY si.`create` DESC"); ORDER BY si.`create` DESC");
$formattedItems = []; $formattedItems = [];
$totalValue = 0;
$totalQuantity = 0;
while ($row = $result->fetch_assoc()) { 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[] = [ $formattedItems[] = [
'id' => (int)$row['id'], 'id' => (int)$row['id'],
'articleId' => (int)$row['articleId'], 'articleId' => (int)$row['articleId'],
'articleNumber' => $row['articleNumber'] ?? '', 'articleNumber' => $row['articleNumber'] ?? '',
'articleTitle' => $row['articleTitle'] ?? 'Unbekannt', 'articleTitle' => $row['articleTitle'] ?? 'Unbekannt',
'countedQuantity' => (float)$row['countedQuantity'], 'countedQuantity' => $quantity,
'unitPrice' => $unitPrice,
'lineTotal' => $lineTotal,
'rack' => $row['rack'], 'rack' => $row['rack'],
'shelf' => $row['shelf'], 'shelf' => $row['shelf'],
'note' => $row['note'], 'note' => $row['note'],
'scannedAt' => $row['scannedAt'] ? date('d.m.Y H:i:s', $row['scannedAt']) : null, 'scannedAt' => $row['scannedAt'] ? date('d.m.Y H:i:s', $row['scannedAt']) : null,
'scannedBy' => $row['scannedByName'], '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, 'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null,
], ],
'items' => $formattedItems, 'items' => $formattedItems,
'summary' => [
'totalValue' => $totalValue,
'totalQuantity' => $totalQuantity,
],
]); ]);
} }
@@ -347,25 +368,53 @@ class WarehouseStocktakeController extends TTCrud {
return; return;
} }
$items = WarehouseStocktakeItemModel::getAll(['stocktakeId' => $id]); // Get items via direct SQL to include price and overwritten status
$rows = []; $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) { $rows = [];
$article = $item->getArticle(); $totalSum = 0;
$scannedBy = $item->getScannedByUser();
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[] = [ $rows[] = [
'Artikel-Nr.' => $article ? $article->articleNumber : '', 'Artikel Titel' => $row['articleTitle'] ?? 'Unbekannt',
'Artikel' => $article ? $article->title : 'Unbekannt', 'Artikel Nummer' => $row['articleNumber'] ?? '',
'Menge' => $item->countedQuantity, 'Einzelpreis' => number_format($unitPrice, 2, ',', '.') . ' €',
'Regal' => $item->rack ?? '', 'Anzahl' => $quantity,
'Fach' => $item->shelf ?? '', 'Gesamtsumme' => number_format($lineTotal, 2, ',', '.') . '',
'Notiz' => $item->note ?? '', 'Gescannt am' => $row['scannedAt'] ? date('d.m.Y H:i', $row['scannedAt']) : '',
'Gescannt am' => $item->scannedAt ? date('d.m.Y H:i', $item->scannedAt) : '', 'Gescannt von' => $row['scannedByName'] ?? '',
'Gescannt von' => $scannedBy ? $scannedBy->name : '', '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"; $filename = "Inventur_{$stocktake->stocktakeNumber}_" . date('Y-m-d') . ".csv";
$csv = Helper::arrayToCsv($rows); $csv = Helper::arrayToCsv($rows);

View File

@@ -2,18 +2,18 @@
class WarehouseStocktakeModel extends TTCrudBaseModel { class WarehouseStocktakeModel extends TTCrudBaseModel {
public int $id; public int $id;
public ?string $stocktakeNumber; public ?string $stocktakeNumber = null;
public string $title; public string $title;
public ?string $description; public ?string $description = null;
public int $warehouseLocationId; public int $warehouseLocationId;
public string $status; public string $status = 'planned';
public ?int $startedAt; public ?int $startedAt = null;
public ?int $completedAt; public ?int $completedAt = null;
public ?int $startedBy; public ?int $startedBy = null;
public ?int $completedBy; public ?int $completedBy = null;
public int $totalItems = 0; public int $totalItems = 0;
public int $totalScannedItems = 0; public int $totalScannedItems = 0;
public ?string $notes; public ?string $notes = null;
public int $createBy; public int $createBy;
public int $create; public int $create;

View File

@@ -157,9 +157,10 @@ class WarehouseStocktakeItemController extends TTCrud {
return; 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; $articleId = null;
if (preg_match('/^WH:(\d+):/', $code, $matches)) { if (preg_match('/^(?:WA|WH):(\d+):/', $code, $matches)) {
$articleId = intval($matches[1]); $articleId = intval($matches[1]);
} else { } else {
// Try to find by article number // Try to find by article number

View File

@@ -11,6 +11,7 @@ class WarehouseStocktakeItemModel extends TTCrudBaseModel {
public ?string $note; public ?string $note;
public ?int $scannedAt; public ?int $scannedAt;
public ?int $scannedBy; public ?int $scannedBy;
public ?int $overwrittenById;
public int $createBy; public int $createBy;
public int $create; public int $create;
@@ -31,8 +32,8 @@ class WarehouseStocktakeItemModel extends TTCrudBaseModel {
/** /**
* Get user who scanned this item * Get user who scanned this item
*/ */
public function getScannedByUser(): ?UserModel { public function getScannedByUser(): ?User {
if (!$this->scannedBy) return null; if (!$this->scannedBy) return null;
return UserModel::get($this->scannedBy); return UserModel::getOne($this->scannedBy);
} }
} }

View File

@@ -111,8 +111,9 @@ class WarehouseStocktakePWAController extends mfBaseController {
$articleId = null; $articleId = null;
// Try to parse QR code format: WH:articleId:articleNumber // Try to parse QR code format: WA:articleId:articleNumber (Warehouse Article)
if (preg_match('/^WH:(\d+):/', $code, $matches)) { // Also accept WH: for backwards compatibility
if (preg_match('/^(?:WA|WH):(\d+):/', $code, $matches)) {
$articleId = intval($matches[1]); $articleId = intval($matches[1]);
} else { } else {
// Try to find by article number // Try to find by article number
@@ -134,7 +135,7 @@ class WarehouseStocktakePWAController extends mfBaseController {
} }
// Get category name // Get category name
$category = WarehouseCategoryModel::get($article->category_id); $category = WarehouseCategory::get($article->category_id);
self::returnJson([ self::returnJson([
'success' => true, '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() { 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' => []]); self::returnJson(['success' => true, 'articles' => []]);
return; return;
} }
$db = FronkDB::singleton(); $whereClause = implode(' AND ', $conditions);
$escapedQuery = $db->escape($query); $result = $db->query("SELECT id, articleNumber, title, unit, category_id
$result = $db->query("SELECT id, articleNumber, title, unit
FROM WarehouseArticle FROM WarehouseArticle
WHERE (articleNumber LIKE '%{$escapedQuery}%' OR title LIKE '%{$escapedQuery}%') WHERE {$whereClause}
AND (isEndOfLife IS NULL OR isEndOfLife = 0) ORDER BY title ASC
LIMIT 20"); LIMIT 50");
$articles = []; $articles = [];
while ($row = $result->fetch_assoc()) { while ($row = $result->fetch_assoc()) {
@@ -176,12 +188,68 @@ class WarehouseStocktakePWAController extends mfBaseController {
'articleNumber' => $row['articleNumber'], 'articleNumber' => $row['articleNumber'],
'title' => $row['title'], 'title' => $row['title'],
'unit' => $row['unit'] ?? 'Stk.', 'unit' => $row['unit'] ?? 'Stk.',
'categoryId' => intval($row['category_id'] ?? 0),
]; ];
} }
self::returnJson(['success' => true, 'articles' => $articles]); 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 * Submit a scanned item
*/ */
@@ -194,6 +262,8 @@ class WarehouseStocktakePWAController extends mfBaseController {
$rack = $postData['rack'] ?? null; $rack = $postData['rack'] ?? null;
$shelf = $postData['shelf'] ?? null; $shelf = $postData['shelf'] ?? null;
$note = $postData['note'] ?? null; $note = $postData['note'] ?? null;
$overwrite = boolval($postData['overwrite'] ?? false);
$overwriteItemId = intval($postData['overwriteItemId'] ?? 0);
if (!$stocktakeId || !$articleId) { if (!$stocktakeId || !$articleId) {
self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']); self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']);
@@ -224,14 +294,64 @@ class WarehouseStocktakePWAController extends mfBaseController {
return; 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([ $existing = WarehouseStocktakeItemModel::getFirst([
'stocktakeId' => $stocktakeId, 'stocktakeId' => $stocktakeId,
'articleId' => $articleId 'articleId' => $articleId,
'overwrittenById' => null
]); ]);
$db = FronkDB::singleton();
if ($existing) { if ($existing) {
// Update existing entry - add to quantity // Update existing entry - add to quantity
$newQuantity = $existing->countedQuantity + $quantity; $newQuantity = $existing->countedQuantity + $quantity;

View File

@@ -31,54 +31,67 @@ Vue.component('stocktake-progress-modal', {
<div v-else-if="stocktake" class="h-100 d-flex flex-column"> <div v-else-if="stocktake" class="h-100 d-flex flex-column">
<!-- Stats Cards --> <!-- Stats Cards -->
<div class="row g-2 mb-3"> <div class="row g-2 mb-3">
<div class="col-md-3"> <div class="col-md-2">
<div class="card stat-card card-primary h-100"> <div class="card stat-card card-primary h-100">
<div class="card-body"> <div class="card-body py-2">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div> <div>
<h6 class="card-subtitle">Inventur</h6> <h6 class="card-subtitle small">Inventur</h6>
<h4 class="card-title">{{ stocktake.title }}</h4> <h5 class="card-title mb-0">{{ stocktake.title }}</h5>
</div> </div>
<i class="fas fa-clipboard-list fa-2x"></i> <i class="fas fa-clipboard-list fa-lg"></i>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-md-2">
<div class="card stat-card card-success h-100"> <div class="card stat-card card-success h-100">
<div class="card-body"> <div class="card-body py-2">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div> <div>
<h6 class="card-subtitle">Gescannte Artikel</h6> <h6 class="card-subtitle small">Gescannte Artikel</h6>
<h4 class="card-title">{{ stocktake.totalScannedItems }}</h4> <h5 class="card-title mb-0">{{ stocktake.totalScannedItems }}</h5>
</div> </div>
<i class="fas fa-barcode fa-2x"></i> <i class="fas fa-barcode fa-lg"></i>
</div>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card stat-card card-info h-100">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="card-subtitle small">Lagerort</h6>
<h5 class="card-title mb-0">{{ stocktake.locationName }}</h5>
</div>
<i class="fas fa-warehouse fa-lg"></i>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<div class="card stat-card card-info h-100"> <div class="card stat-card card-warning h-100">
<div class="card-body"> <div class="card-body py-2">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div> <div>
<h6 class="card-subtitle">Lagerort</h6> <h6 class="card-subtitle small">Gesamtwert (Einkauf)</h6>
<h4 class="card-title">{{ stocktake.locationName }}</h4> <h5 class="card-title mb-0">{{ formatCurrency(summary.totalValue) }}</h5>
</div> </div>
<i class="fas fa-warehouse fa-2x"></i> <i class="fas fa-euro-sign fa-lg"></i>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<div class="card stat-card h-100" :class="statusCardClass"> <div class="card stat-card h-100" :class="statusCardClass">
<div class="card-body"> <div class="card-body py-2">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div> <div>
<h6 class="card-subtitle">Status</h6> <h6 class="card-subtitle small">Status</h6>
<h4 class="card-title">{{ statusText }}</h4> <h5 class="card-title mb-0">{{ statusText }}</h5>
</div> </div>
<i class="fas fa-info-circle fa-2x"></i> <i class="fas fa-info-circle fa-lg"></i>
</div> </div>
</div> </div>
</div> </div>
@@ -111,24 +124,39 @@ Vue.component('stocktake-progress-modal', {
<tr> <tr>
<th style="width: 120px;">Artikel-Nr.</th> <th style="width: 120px;">Artikel-Nr.</th>
<th>Artikel</th> <th>Artikel</th>
<th class="text-end" style="width: 100px;">Einzelpreis</th>
<th class="text-end" style="width: 80px;">Menge</th> <th class="text-end" style="width: 80px;">Menge</th>
<th class="text-end" style="width: 110px;">Gesamtpreis</th>
<th style="width: 80px;">Regal</th> <th style="width: 80px;">Regal</th>
<th style="width: 80px;">Fach</th> <th style="width: 80px;">Fach</th>
<th style="width: 140px;">Gescannt am</th> <th style="width: 140px;">Gescannt am</th>
<th style="width: 130px;">Gescannt von</th> <th style="width: 130px;">Gescannt von</th>
<th style="width: 80px;">Status</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="item in items" :key="item.id"> <tr v-for="item in items" :key="item.id" :class="{ 'table-secondary text-decoration-line-through': item.isOverwritten }">
<td><code class="text-primary">{{ item.articleNumber }}</code></td> <td><code class="text-primary">{{ item.articleNumber }}</code></td>
<td>{{ item.articleTitle }}</td> <td>{{ item.articleTitle }}</td>
<td class="text-end">{{ formatCurrency(item.unitPrice) }}</td>
<td class="text-end"><strong class="text-success">{{ item.countedQuantity }}</strong></td> <td class="text-end"><strong class="text-success">{{ item.countedQuantity }}</strong></td>
<td class="text-end"><strong>{{ formatCurrency(item.lineTotal) }}</strong></td>
<td>{{ item.rack || '-' }}</td> <td>{{ item.rack || '-' }}</td>
<td>{{ item.shelf || '-' }}</td> <td>{{ item.shelf || '-' }}</td>
<td class="text-nowrap">{{ item.scannedAt || '-' }}</td> <td class="text-nowrap">{{ item.scannedAt || '-' }}</td>
<td>{{ item.scannedBy || '-' }}</td> <td>{{ item.scannedBy || '-' }}</td>
<td>
<span v-if="item.isOverwritten" class="badge bg-warning text-dark">Überschrieben</span>
</td>
</tr> </tr>
</tbody> </tbody>
<tfoot class="table-light">
<tr>
<th colspan="4" class="text-end">Summe:</th>
<th class="text-end text-primary">{{ formatCurrency(summary.totalValue) }}</th>
<th colspan="5"></th>
</tr>
</tfoot>
</table> </table>
<!-- Empty State --> <!-- Empty State -->
@@ -151,6 +179,7 @@ Vue.component('stocktake-progress-modal', {
refreshing: false, refreshing: false,
stocktake: null, stocktake: null,
items: [], items: [],
summary: { totalValue: 0, totalQuantity: 0 },
refreshInterval: null, refreshInterval: null,
countdownInterval: null, countdownInterval: null,
countdown: 5, countdown: 5,
@@ -216,6 +245,7 @@ Vue.component('stocktake-progress-modal', {
if (response.data.success) { if (response.data.success) {
this.stocktake = response.data.stocktake; this.stocktake = response.data.stocktake;
this.items = response.data.items; this.items = response.data.items;
this.summary = response.data.summary || { totalValue: 0, totalQuantity: 0 };
} }
} catch (error) { } catch (error) {
console.error('Failed to load progress:', error); console.error('Failed to load progress:', error);
@@ -252,6 +282,9 @@ Vue.component('stocktake-progress-modal', {
clearInterval(this.countdownInterval); clearInterval(this.countdownInterval);
this.countdownInterval = null; this.countdownInterval = null;
} }
},
formatCurrency(value) {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value || 0);
} }
}, },
beforeDestroy() { beforeDestroy() {