initial commit of mobile app
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Lager Module
|
||||
*
|
||||
* Main module for warehouse management.
|
||||
* Shows submodules: Inventur (stocktake)
|
||||
*/
|
||||
|
||||
import StocktakeList from '/mobile/modules/lager/inventur/StocktakeList.js';
|
||||
import Scanner from '/mobile/modules/lager/inventur/Scanner.js';
|
||||
import MovementForm from '/mobile/modules/lager/movement/MovementForm.js';
|
||||
|
||||
export default {
|
||||
name: 'LagerModule',
|
||||
emits: ['navigate', 'toast'],
|
||||
props: {
|
||||
user: Object,
|
||||
submodule: String,
|
||||
simpleMode: Boolean
|
||||
},
|
||||
components: {
|
||||
StocktakeList,
|
||||
Scanner,
|
||||
MovementForm
|
||||
},
|
||||
|
||||
setup(props, { emit }) {
|
||||
const { ref, computed, watch } = Vue;
|
||||
|
||||
// Submodules available in Lager
|
||||
const submodules = [
|
||||
{
|
||||
id: 'Inventur',
|
||||
name: 'Inventur',
|
||||
icon: 'clipboard',
|
||||
color: 'bg-green-500'
|
||||
},
|
||||
{
|
||||
id: 'Movement',
|
||||
name: 'Lagerbewegung',
|
||||
icon: 'arrows',
|
||||
color: 'bg-blue-500'
|
||||
}
|
||||
];
|
||||
|
||||
// Scanner state
|
||||
const selectedStocktake = ref(null);
|
||||
const showScanner = ref(false);
|
||||
|
||||
// Current view based on submodule
|
||||
const currentView = computed(() => {
|
||||
if (!props.submodule) return 'menu';
|
||||
if (props.submodule.toLowerCase() === 'inventur') {
|
||||
return showScanner.value ? 'scanner' : 'inventur';
|
||||
}
|
||||
if (props.submodule.toLowerCase() === 'movement') {
|
||||
return 'movement';
|
||||
}
|
||||
return 'menu';
|
||||
});
|
||||
|
||||
// Watch for submodule changes
|
||||
watch(() => props.submodule, (newVal) => {
|
||||
if (!newVal) {
|
||||
showScanner.value = false;
|
||||
selectedStocktake.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
const openSubmodule = (submoduleId) => {
|
||||
emit('navigate', 'Lager', submoduleId);
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
if (showScanner.value) {
|
||||
showScanner.value = false;
|
||||
selectedStocktake.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const openScanner = (stocktake) => {
|
||||
selectedStocktake.value = stocktake;
|
||||
showScanner.value = true;
|
||||
};
|
||||
|
||||
const closeScanner = () => {
|
||||
showScanner.value = false;
|
||||
selectedStocktake.value = null;
|
||||
};
|
||||
|
||||
const showToast = (message, type) => {
|
||||
emit('toast', message, type);
|
||||
};
|
||||
|
||||
return {
|
||||
submodules,
|
||||
selectedStocktake,
|
||||
showScanner,
|
||||
currentView,
|
||||
openSubmodule,
|
||||
goBack,
|
||||
openScanner,
|
||||
closeScanner,
|
||||
showToast
|
||||
};
|
||||
},
|
||||
|
||||
template: `
|
||||
<div class="h-full">
|
||||
<!-- Submodule Menu -->
|
||||
<template v-if="currentView === 'menu'">
|
||||
<div class="p-3">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl overflow-hidden shadow-sm">
|
||||
<button
|
||||
v-for="(sub, index) in submodules"
|
||||
:key="sub.id"
|
||||
@click="openSubmodule(sub.id)"
|
||||
:class="[
|
||||
'w-full flex items-center px-4 py-3.5 hover:bg-slate-50 dark:hover:bg-slate-700/50 active:bg-slate-100 dark:active:bg-slate-700 transition text-left',
|
||||
index !== submodules.length - 1 ? 'border-b border-slate-100 dark:border-slate-700' : ''
|
||||
]"
|
||||
>
|
||||
<div :class="[sub.color, 'w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0']">
|
||||
<svg v-if="sub.icon === 'clipboard'" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||
</svg>
|
||||
<svg v-else-if="sub.icon === 'arrows'" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="ml-3 font-medium text-slate-800 dark:text-white flex-1">
|
||||
{{ sub.name }}
|
||||
</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Inventur: Stocktake List -->
|
||||
<StocktakeList
|
||||
v-else-if="currentView === 'inventur'"
|
||||
:user="user"
|
||||
@select="openScanner"
|
||||
/>
|
||||
|
||||
<!-- Inventur: Scanner -->
|
||||
<Scanner
|
||||
v-else-if="currentView === 'scanner'"
|
||||
:stocktake="selectedStocktake"
|
||||
:user="user"
|
||||
@close="closeScanner"
|
||||
@toast="showToast"
|
||||
/>
|
||||
|
||||
<!-- Movement: Movement Form -->
|
||||
<MovementForm
|
||||
v-else-if="currentView === 'movement'"
|
||||
:user="user"
|
||||
:simple-mode="simpleMode"
|
||||
@toast="showToast"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* 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>
|
||||
`
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* StocktakeList Component (Inventur)
|
||||
*
|
||||
* Displays a list of active stocktakes.
|
||||
* Uses the new API path: /MobileApp/Lager/Inventur/{action}
|
||||
*/
|
||||
|
||||
import { api } from '/mobile/shared/auth.js';
|
||||
|
||||
// Override API base for Inventur
|
||||
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: 'StocktakeList',
|
||||
emits: ['select'],
|
||||
props: {
|
||||
user: Object
|
||||
},
|
||||
|
||||
setup(props, { emit }) {
|
||||
const { ref, onMounted } = Vue;
|
||||
|
||||
const stocktakes = ref([]);
|
||||
const isLoading = ref(true);
|
||||
const error = ref('');
|
||||
|
||||
const fetchStocktakes = async () => {
|
||||
isLoading.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
const result = await inventurApi.get('getActiveStocktakes');
|
||||
|
||||
if (result.success) {
|
||||
stocktakes.value = result.stocktakes;
|
||||
} else {
|
||||
error.value = result.error || 'Fehler beim Laden';
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = 'Netzwerkfehler';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const selectStocktake = (stocktake) => {
|
||||
emit('select', stocktake);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchStocktakes();
|
||||
});
|
||||
|
||||
return {
|
||||
stocktakes,
|
||||
isLoading,
|
||||
error,
|
||||
fetchStocktakes,
|
||||
selectStocktake
|
||||
};
|
||||
},
|
||||
|
||||
template: `
|
||||
<div class="h-full flex flex-col">
|
||||
<!-- Refresh bar -->
|
||||
<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">Aktive Inventuren</span>
|
||||
<button
|
||||
@click="fetchStocktakes"
|
||||
class="p-2 rounded-full text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 transition"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" :class="{ 'animate-spin': isLoading }" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto p-3">
|
||||
<!-- Loading -->
|
||||
<div v-if="isLoading" class="space-y-3">
|
||||
<div v-for="i in 3" :key="i" class="bg-white dark:bg-slate-800 p-4 rounded-xl shadow-sm animate-pulse">
|
||||
<div class="h-5 bg-slate-200 dark:bg-slate-700 rounded w-3/4 mb-2"></div>
|
||||
<div class="h-4 bg-slate-200 dark:bg-slate-700 rounded w-1/2 mb-3"></div>
|
||||
<div class="h-3 bg-slate-200 dark:bg-slate-700 rounded w-1/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="error" class="text-center py-8">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto text-red-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p class="text-slate-500 dark:text-slate-400 mb-4">{{ error }}</p>
|
||||
<button @click="fetchStocktakes" class="px-4 py-2 bg-primary text-white rounded-lg font-medium">
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="stocktakes.length === 0" class="text-center py-8">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto text-slate-300 dark:text-slate-600 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
<p class="text-slate-500 dark:text-slate-400">Keine aktiven Inventuren</p>
|
||||
</div>
|
||||
|
||||
<!-- Stocktake List -->
|
||||
<div v-else class="bg-white dark:bg-slate-800 rounded-xl overflow-hidden shadow-sm">
|
||||
<button
|
||||
v-for="(stocktake, index) in stocktakes"
|
||||
:key="stocktake.id"
|
||||
@click="selectStocktake(stocktake)"
|
||||
:class="[
|
||||
'w-full text-left px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-700/50 active:bg-slate-100 dark:active:bg-slate-700 transition',
|
||||
index !== stocktakes.length - 1 ? 'border-b border-slate-100 dark:border-slate-700' : ''
|
||||
]"
|
||||
>
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-medium text-slate-800 dark:text-white truncate">
|
||||
{{ stocktake.title || 'Inventur #' + stocktake.stocktakeNumber }}
|
||||
</h3>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
|
||||
{{ stocktake.locationName }}
|
||||
</p>
|
||||
</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-slate-400 flex-shrink-0 ml-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex items-center mt-2 text-xs text-slate-400 dark:text-slate-500">
|
||||
<span class="font-medium text-slate-600 dark:text-slate-400">{{ stocktake.totalScannedItems || 0 }}</span>
|
||||
<span class="ml-1">Artikel</span>
|
||||
<span class="mx-2">·</span>
|
||||
<span>{{ stocktake.startedAt || 'Nicht gestartet' }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user