Xinon mobile/improve

This commit is contained in:
Luca Haid
2026-01-17 12:48:08 +00:00
parent 51c9c5ae7e
commit 1426d769d2
37 changed files with 7502 additions and 1042 deletions
+22 -57
View File
@@ -1,19 +1,6 @@
/**
* Scanner Component (Inventur)
*
* The main scanning interface for stocktakes.
* Uses the new API path: /MobileApp/Lager/Inventur/{action}
*/
import { createModuleApi, debounce } from '/mobile/shared/api.js';
// 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())
};
const inventurApi = createModuleApi('Lager/Inventur');
export default {
name: 'Scanner',
@@ -26,44 +13,29 @@ export default {
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 {
@@ -92,7 +64,6 @@ export default {
await lookupArticle(decodedText);
};
// Article lookup
const lookupArticle = async (code) => {
isLoading.value = true;
alreadyScannedWarning.value = null;
@@ -121,7 +92,6 @@ export default {
}
};
// Submit
const submitScan = async (overwrite = false) => {
if (!canSubmit.value) return;
isLoading.value = true;
@@ -140,6 +110,7 @@ export default {
const result = await inventurApi.post('submitScan', payload);
if (result.success) {
navigator.vibrate?.([100]);
emit('toast', result.message, 'success');
scannedArticle.value = null;
quantity.value = '1';
@@ -157,13 +128,12 @@ export default {
}
};
// Search
const loadCategories = async () => {
const result = await inventurApi.get('getCategories');
if (result.success) categories.value = result.categories;
};
const searchArticles = async () => {
const doSearch = async () => {
if (searchQuery.value.length < 2 && !selectedCategory.value) {
searchResults.value = [];
return;
@@ -180,6 +150,8 @@ export default {
}
};
const searchArticles = debounce(doSearch, 300);
const selectSearchResult = async (article) => {
await stopScanner();
scannedArticle.value = article;
@@ -194,7 +166,6 @@ export default {
}
};
// History
const loadHistory = async () => {
isLoadingHistory.value = true;
try {
@@ -205,7 +176,6 @@ export default {
}
};
// Keypad
const appendDigit = (digit) => {
if (digit === '.' && quantity.value.includes('.')) return;
if (quantity.value === '0' && digit !== '.') {
@@ -221,7 +191,6 @@ export default {
const clearQuantity = () => { quantity.value = '0'; };
// Navigation
const handleClose = async () => {
await stopScanner();
emit('close');
@@ -265,7 +234,6 @@ export default {
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">
@@ -275,18 +243,14 @@ export default {
</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">
@@ -296,9 +260,7 @@ export default {
<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">
@@ -315,24 +277,31 @@ export default {
</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 class="flex gap-2 mb-3">
<button @click="quantity = '1'" :class="['flex-1 py-2 rounded-lg font-bold transition', quantity === '1' ? 'bg-primary text-white' : 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300']">1</button>
<button @click="quantity = '5'" :class="['flex-1 py-2 rounded-lg font-bold transition', quantity === '5' ? 'bg-primary text-white' : 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300']">5</button>
<button @click="quantity = '10'" :class="['flex-1 py-2 rounded-lg font-bold transition', quantity === '10' ? 'bg-primary text-white' : 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300']">10</button>
<button @click="quantity = '20'" :class="['flex-1 py-2 rounded-lg font-bold transition', quantity === '20' ? 'bg-primary text-white' : 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300']">20</button>
</div>
<div class="flex items-center gap-2">
<button @click="quantity = String(Math.max(1, parseFloat(quantity) - 1))" class="w-14 h-14 bg-slate-100 dark:bg-slate-700 rounded-lg text-2xl font-bold text-slate-700 dark:text-slate-300 active:bg-slate-200 dark:active:bg-slate-600">-</button>
<div @click="showKeypad = true" class="flex-1 p-3 text-3xl font-bold text-center border-2 border-slate-300 dark:border-slate-600 rounded-lg cursor-pointer hover:border-primary transition text-slate-800 dark:text-white">
{{ quantity }}
</div>
<button @click="quantity = String(parseFloat(quantity) + 1)" class="w-14 h-14 bg-slate-100 dark:bg-slate-700 rounded-lg text-2xl font-bold text-slate-700 dark:text-slate-300 active:bg-slate-200 dark:active:bg-slate-600">+</button>
</div>
</div>
<!-- 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>
@@ -344,19 +313,17 @@ export default {
</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">
<button v-if="alreadyScannedWarning" @click="submitScan(false)" :disabled="!canSubmit" class="w-full py-5 bg-green-600 text-white text-lg font-bold rounded-xl disabled:opacity-50 active:scale-[0.98] transition">Zur Menge addieren</button>
<button v-if="alreadyScannedWarning" @click="submitScan(true)" :disabled="!canSubmit" class="w-full py-4 bg-amber-600 text-white font-bold rounded-xl disabled:opacity-50 active:scale-[0.98] transition">Überschreiben</button>
<button v-if="!alreadyScannedWarning" @click="submitScan(false)" :disabled="!canSubmit" class="w-full py-5 bg-green-600 text-white text-lg font-bold rounded-xl disabled:opacity-50 active:scale-[0.98] transition">
{{ isLoading ? 'Speichert...' : 'Speichern' }}
</button>
<button @click="cancelScan" class="w-full py-3 text-slate-600 dark:text-slate-400 font-medium">Abbrechen</button>
<button @click="cancelScan" class="w-full py-2 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300">Abbrechen</button>
</div>
</div>
</div>
<!-- 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">
@@ -382,7 +349,6 @@ export default {
</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">
@@ -412,7 +378,6 @@ export default {
</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">
@@ -1,21 +1,6 @@
/**
* StocktakeList Component (Inventur)
*
* Displays a list of active stocktakes.
* Uses the new API path: /MobileApp/Lager/Inventur/{action}
*/
import { createModuleApi } from '/mobile/shared/api.js';
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())
};
const inventurApi = createModuleApi('Lager/Inventur');
export default {
name: 'StocktakeList',
@@ -1,20 +1,8 @@
/**
* MovementForm Component (WarehouseMovement)
*
* The main interface for stock movements (IN/OUT/ADJUSTMENT).
* API: /MobileApp/Lager/Movement/{action}
*/
import { createModuleApi, debounce } from '/mobile/shared/api.js';
const movementApi = createModuleApi('Lager/Movement');
const movementApi = {
get: (endpoint) => fetch(`/MobileApp/Lager/Movement/${endpoint}`).then(r => r.json()),
post: (endpoint, data) => fetch(`/MobileApp/Lager/Movement/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}).then(r => r.json())
};
// Custom BottomSheet Select Component
const BottomSheetSelect = {
name: 'BottomSheetSelect',
emits: ['update:modelValue'],
@@ -212,6 +200,14 @@ export default {
const recentMovements = ref([]);
const isLoadingHistory = ref(false);
// ==================== ORDER RECEIVING ====================
const pendingOrders = ref([]);
const isLoadingOrders = ref(false);
const selectedOrder = ref(null);
const orderPositions = ref([]);
const deliveryNotePhoto = ref(null);
const isSubmittingOrder = ref(false);
// ==================== KEYPAD ====================
const showKeypad = ref(false);
const showNote = ref(false);
@@ -569,6 +565,9 @@ export default {
const result = await movementApi.post('submitMovement', payload);
if (result.success) {
// Haptic feedback on success
navigator.vibrate?.([100]);
// Store for undo
lastMovement.value = result.movement;
showUndo.value = true;
@@ -667,6 +666,8 @@ export default {
isLoading.value = false;
if (errorCount === 0) {
// Haptic feedback on success
navigator.vibrate?.([100, 50, 100]);
emit('toast', `${successCount} Bewegungen erfolgreich`, 'success');
clearCart();
} else {
@@ -709,6 +710,8 @@ export default {
const result = await movementApi.post('submitMovement', payload);
if (result.success) {
// Haptic feedback on success
navigator.vibrate?.([100]);
emit('toast', result.message, 'success');
// Reset form
scannedArticle.value = null;
@@ -727,8 +730,7 @@ export default {
}
};
// Search
const searchArticles = async () => {
const doSearch = async () => {
if (searchQuery.value.length < 2) {
searchResults.value = [];
return;
@@ -742,6 +744,8 @@ export default {
}
};
const searchArticles = debounce(doSearch, 300);
const selectSearchResult = async (article) => {
await stopScanner();
scannedArticle.value = article;
@@ -767,6 +771,97 @@ export default {
}
};
// ==================== ORDER RECEIVING FUNCTIONS ====================
const loadPendingOrders = async () => {
isLoadingOrders.value = true;
try {
const result = await movementApi.get('getPendingOrders');
if (result.success) {
pendingOrders.value = result.orders;
}
} catch (e) {
emit('toast', 'Fehler beim Laden der Bestellungen', 'error');
} finally {
isLoadingOrders.value = false;
}
};
const selectOrderForReceiving = async (order) => {
isLoadingOrders.value = true;
try {
const result = await movementApi.get(`getOrderForReceiving?orderId=${order.id}`);
if (result.success) {
selectedOrder.value = result.order;
orderPositions.value = result.positions;
deliveryNotePhoto.value = null;
}
} catch (e) {
emit('toast', 'Fehler beim Laden der Bestellung', 'error');
} finally {
isLoadingOrders.value = false;
}
};
const cancelOrderReceiving = () => {
selectedOrder.value = null;
orderPositions.value = [];
deliveryNotePhoto.value = null;
};
const submitOrderReceiving = async () => {
if (!selectedOrder.value || !selectedLocation.value) return;
// Collect positions with quantity > 0
const positionsToSubmit = orderPositions.value
.filter(p => p.receivingQty > 0)
.map(p => ({
articleId: p.articleId,
quantity: p.receivingQty
}));
if (positionsToSubmit.length === 0) {
emit('toast', 'Bitte mindestens eine Menge eingeben', 'error');
return;
}
isSubmittingOrder.value = true;
try {
const result = await movementApi.post('submitOrderReceiving', {
orderId: selectedOrder.value.id,
locationId: selectedLocation.value,
positions: positionsToSubmit,
deliveryNoteFileId: deliveryNotePhoto.value,
note: null
});
if (result.success) {
navigator.vibrate?.([100, 50, 100]);
emit('toast', result.message, 'success');
// Reset and reload orders
cancelOrderReceiving();
await loadPendingOrders();
} else {
emit('toast', result.message || 'Fehler beim Speichern', 'error');
}
} catch (e) {
emit('toast', 'Netzwerkfehler', 'error');
} finally {
isSubmittingOrder.value = false;
}
};
const setAllReceivingQty = () => {
orderPositions.value.forEach(p => {
p.receivingQty = p.remainingQty;
});
};
const clearAllReceivingQty = () => {
orderPositions.value.forEach(p => {
p.receivingQty = 0;
});
};
// Keypad
const appendDigit = (digit) => {
if (digit === '.' && quantity.value.includes('.')) return;
@@ -794,6 +889,9 @@ export default {
} else if (tab === 'history') {
await stopScanner();
await loadHistory();
} else if (tab === 'orders') {
await stopScanner();
await loadPendingOrders();
}
};
@@ -849,6 +947,11 @@ export default {
searchQuery, searchResults, isSearching,
// History
recentMovements, isLoadingHistory,
// Order Receiving
pendingOrders, isLoadingOrders, selectedOrder, orderPositions,
deliveryNotePhoto, isSubmittingOrder,
loadPendingOrders, selectOrderForReceiving, cancelOrderReceiving,
submitOrderReceiving, setAllReceivingQty, clearAllReceivingQty,
// UI
showKeypad, showNote, canSubmit, typeColor,
// Functions
@@ -923,29 +1026,30 @@ export default {
</button>
</div>
<!-- Mode Toggles & Quick Actions Bar (hidden in simple mode) -->
<!-- Mode Toggles (hidden in simple mode) -->
<div v-if="!simpleMode" class="flex items-center gap-2">
<!-- Turbo Mode Toggle -->
<!-- Turbo Mode Toggle - More Prominent -->
<button
@click="turboMode = !turboMode"
:class="[
'flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-all',
'flex items-center gap-2 px-4 py-2.5 rounded-xl font-medium transition-all flex-1',
turboMode
? 'bg-orange-500 text-white'
: 'bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-slate-700'
? 'bg-gradient-to-r from-orange-500 to-orange-600 text-white shadow-lg shadow-orange-500/30'
: 'bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-400 border-2 border-dashed border-orange-300 dark:border-orange-800 hover:border-orange-400'
]"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Turbo
<span class="text-sm">Turbo-Modus</span>
<span v-if="!turboMode" class="text-xs opacity-60">(1-Klick)</span>
</button>
<!-- Batch Mode Toggle -->
<button
@click="batchMode = !batchMode"
:class="[
'flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-all',
'flex items-center gap-1.5 px-3 py-2.5 rounded-xl text-sm font-medium transition-all',
batchMode
? 'bg-purple-500 text-white'
: 'bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-slate-700'
@@ -957,18 +1061,6 @@ export default {
Sammel
<span v-if="cartTotal > 0" class="bg-white/30 px-1.5 rounded-full">{{ cartTotal }}</span>
</button>
<!-- Quick Actions -->
<div class="flex-1"></div>
<button
@click="quickAction('OUT', 'Verbrauch')"
class="flex items-center gap-1 px-3 py-2 rounded-lg text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-900/50 transition"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H4" />
</svg>
Schnell
</button>
</div>
</div>
@@ -976,6 +1068,10 @@ export default {
<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 v-if="!simpleMode" @click="switchTab('orders')" :class="[currentTab === 'orders' ? 'bg-white dark:bg-slate-800 shadow-sm' : 'text-slate-500 dark:text-slate-400', pendingOrders.length > 0 ? 'relative' : '']" class="flex-1 py-2 text-sm font-medium rounded-lg transition mx-1">
Bestellung
<span v-if="pendingOrders.length > 0 && currentTab !== 'orders'" class="absolute -top-1 -right-1 bg-blue-500 text-white text-xs w-5 h-5 rounded-full flex items-center justify-center">{{ pendingOrders.length }}</span>
</button>
<button v-if="!simpleMode" @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>
@@ -1016,17 +1112,30 @@ export default {
</div>
</div>
<!-- Quantity (Compact) -->
<div @click="showKeypad = true" :class="[
'flex items-center justify-between p-3 rounded-xl cursor-pointer transition active:scale-[0.98]',
selectedType === 'IN' ? 'bg-green-500 text-white' : '',
selectedType === 'OUT' ? 'bg-red-500 text-white' : '',
selectedType === 'ADJUSTMENT' ? 'bg-yellow-500 text-white' : ''
]">
<span class="font-medium">Menge</span>
<span class="text-2xl font-bold">
{{ selectedType === 'OUT' ? '-' : selectedType === 'IN' ? '+' : '' }}{{ quantity }}
</span>
<!-- Quantity with Quick Buttons -->
<div class="bg-white dark:bg-slate-800 p-3 rounded-xl shadow-sm">
<!-- Quick preset buttons -->
<div class="flex gap-2 mb-2">
<button @click="quantity = '1'" :class="['flex-1 py-2 rounded-lg font-bold text-sm transition', quantity === '1' ? 'bg-primary text-white' : 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300']">1</button>
<button @click="quantity = '5'" :class="['flex-1 py-2 rounded-lg font-bold text-sm transition', quantity === '5' ? 'bg-primary text-white' : 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300']">5</button>
<button @click="quantity = '10'" :class="['flex-1 py-2 rounded-lg font-bold text-sm transition', quantity === '10' ? 'bg-primary text-white' : 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300']">10</button>
<button @click="quantity = '20'" :class="['flex-1 py-2 rounded-lg font-bold text-sm transition', quantity === '20' ? 'bg-primary text-white' : 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300']">20</button>
</div>
<!-- Quantity with +/- and colored display -->
<div class="flex items-center gap-2">
<button @click="quantity = String(Math.max(1, parseFloat(quantity) - 1))" class="w-12 h-12 bg-slate-100 dark:bg-slate-700 rounded-lg text-xl font-bold text-slate-700 dark:text-slate-300 active:bg-slate-200 dark:active:bg-slate-600">-</button>
<div @click="showKeypad = true" :class="[
'flex-1 py-3 rounded-xl cursor-pointer transition active:scale-[0.98] text-center',
selectedType === 'IN' ? 'bg-green-500 text-white' : '',
selectedType === 'OUT' ? 'bg-red-500 text-white' : '',
selectedType === 'ADJUSTMENT' ? 'bg-yellow-500 text-white' : ''
]">
<span class="text-2xl font-bold">
{{ selectedType === 'OUT' ? '-' : selectedType === 'IN' ? '+' : '' }}{{ quantity }}
</span>
</div>
<button @click="quantity = String(parseFloat(quantity) + 1)" class="w-12 h-12 bg-slate-100 dark:bg-slate-700 rounded-lg text-xl font-bold text-slate-700 dark:text-slate-300 active:bg-slate-200 dark:active:bg-slate-600">+</button>
</div>
</div>
<!-- Reason + Note Button (Combined Row) -->
@@ -1065,19 +1174,13 @@ export default {
></textarea>
</div>
<!-- Buttons Row -->
<div class="flex gap-2">
<button
@click="cancelScan"
class="flex-1 py-3 font-medium rounded-xl bg-slate-200 dark:bg-slate-700 text-slate-600 dark:text-slate-300 transition active:scale-[0.98]"
>
Abbrechen
</button>
<!-- Buttons -->
<div class="space-y-2">
<button
@click="submitMovement"
:disabled="!canSubmit"
:class="[
'flex-[2] py-3 font-bold rounded-xl text-white transition active:scale-[0.98] disabled:opacity-50 disabled:active:scale-100',
'w-full py-5 text-lg font-bold rounded-xl text-white transition active:scale-[0.98] disabled:opacity-50 disabled:active:scale-100',
selectedType === 'IN' ? 'bg-green-600' : '',
selectedType === 'OUT' ? 'bg-red-600' : '',
selectedType === 'ADJUSTMENT' ? 'bg-yellow-600' : ''
@@ -1085,6 +1188,12 @@ export default {
>
{{ isLoading ? 'Speichert...' : (selectedType === 'IN' ? 'Einbuchen' : selectedType === 'OUT' ? 'Ausbuchen' : 'Korrigieren') }}
</button>
<button
@click="cancelScan"
class="w-full py-2 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300"
>
Abbrechen
</button>
</div>
</div>
</div>
@@ -1163,6 +1272,149 @@ export default {
</div>
</div>
</div>
<!-- ORDERS TAB -->
<div v-else-if="currentTab === 'orders'" class="p-4">
<!-- Loading -->
<div v-if="isLoadingOrders" 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-1/2 mb-3"></div>
<div class="h-4 bg-slate-200 dark:bg-slate-700 rounded w-3/4"></div>
</div>
</div>
<!-- Order List (when no order selected) -->
<template v-else-if="!selectedOrder">
<div v-if="pendingOrders.length === 0" class="text-center py-12">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 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="1.5" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<p class="text-slate-500 dark:text-slate-400 text-lg font-medium">Keine offenen Bestellungen</p>
<p class="text-slate-400 dark:text-slate-500 text-sm mt-1">Alle Lieferungen wurden empfangen</p>
</div>
<div v-else class="space-y-3">
<p class="text-sm text-slate-500 dark:text-slate-400 mb-2">{{ pendingOrders.length }} Bestellung(en) warten auf Wareneingang:</p>
<div
v-for="order in pendingOrders"
:key="order.id"
@click="selectOrderForReceiving(order)"
class="bg-white dark:bg-slate-800 p-4 rounded-xl shadow-sm cursor-pointer hover:shadow-md active:scale-[0.98] transition"
>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<span class="font-bold text-slate-800 dark:text-white">{{ order.orderNumber }}</span>
<span :class="[
'px-2 py-0.5 text-xs font-medium rounded',
order.status === 'sent' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' : 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
]">
{{ order.statusLabel }}
</span>
</div>
<p class="text-slate-600 dark:text-slate-300">{{ order.distributorName }}</p>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">
{{ order.positionCount }} Position(en) · {{ order.totalItems }} Artikel gesamt
</p>
</div>
<div class="text-right">
<p class="text-xs text-slate-400">{{ order.create }}</p>
<p v-if="order.daysSinceSent > 0" :class="['text-xs mt-1', order.daysSinceSent > 7 ? 'text-red-500 font-medium' : 'text-slate-400']">
{{ order.daysSinceSent }} Tag(e)
</p>
</div>
</div>
<div class="mt-3 flex items-center gap-2 text-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4" />
</svg>
<span class="text-sm font-medium">Wareneingang erfassen</span>
</div>
</div>
</div>
</template>
<!-- Order Receiving Form (when order selected) -->
<template v-else>
<!-- Header -->
<div class="bg-white dark:bg-slate-800 p-4 rounded-xl shadow-sm mb-4">
<div class="flex items-start justify-between mb-2">
<div>
<h3 class="font-bold text-lg text-slate-800 dark:text-white">{{ selectedOrder.orderNumber }}</h3>
<p class="text-slate-600 dark:text-slate-300">{{ selectedOrder.distributorName }}</p>
</div>
<button @click="cancelOrderReceiving" class="p-2 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 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>
<p class="text-sm text-slate-500 dark:text-slate-400">Bestellt: {{ selectedOrder.create }}</p>
</div>
<!-- Quick Actions -->
<div class="flex gap-2 mb-4">
<button @click="setAllReceivingQty" class="flex-1 py-2 px-3 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-lg text-sm font-medium transition active:scale-[0.98]">
Alle übernehmen
</button>
<button @click="clearAllReceivingQty" class="flex-1 py-2 px-3 bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 rounded-lg text-sm font-medium transition active:scale-[0.98]">
Alle löschen
</button>
</div>
<!-- Positions -->
<div class="space-y-3 mb-4">
<div
v-for="(pos, index) in orderPositions"
:key="pos.articleId"
class="bg-white dark:bg-slate-800 p-4 rounded-xl shadow-sm"
>
<div class="flex items-start justify-between mb-3">
<div class="flex-1 min-w-0">
<p class="font-medium text-slate-800 dark:text-white truncate">{{ pos.articleTitle }}</p>
<p class="text-xs text-slate-500 dark:text-slate-400">{{ pos.articleNumber }}</p>
</div>
<div class="text-right text-sm text-slate-500 dark:text-slate-400 ml-2">
<p>Bestellt: {{ pos.orderedQty }}</p>
<p v-if="pos.deliveredQty > 0" class="text-green-600 dark:text-green-400">Erhalten: {{ pos.deliveredQty }}</p>
</div>
</div>
<div class="flex items-center gap-3">
<span class="text-sm text-slate-500 dark:text-slate-400 w-20">Empfangen:</span>
<div class="flex items-center gap-2 flex-1">
<button
@click="orderPositions[index].receivingQty = Math.max(0, pos.receivingQty - 1)"
class="w-10 h-10 bg-slate-100 dark:bg-slate-700 rounded-lg text-lg font-bold text-slate-600 dark:text-slate-300 active:bg-slate-200 dark:active:bg-slate-600"
>-</button>
<input
type="number"
v-model.number="orderPositions[index].receivingQty"
min="0"
:max="pos.remainingQty"
class="flex-1 text-center py-2 px-3 bg-slate-50 dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-lg text-lg font-bold text-slate-800 dark:text-white"
/>
<button
@click="orderPositions[index].receivingQty = Math.min(pos.remainingQty, pos.receivingQty + 1)"
class="w-10 h-10 bg-slate-100 dark:bg-slate-700 rounded-lg text-lg font-bold text-slate-600 dark:text-slate-300 active:bg-slate-200 dark:active:bg-slate-600"
>+</button>
</div>
<span class="text-xs text-slate-400 w-16 text-right">/ {{ pos.remainingQty }} {{ pos.unit }}</span>
</div>
</div>
</div>
<!-- Submit -->
<div class="sticky bottom-0 bg-slate-50 dark:bg-slate-900 -mx-4 -mb-4 p-4 border-t border-slate-200 dark:border-slate-700">
<button
@click="submitOrderReceiving"
:disabled="isSubmittingOrder"
class="w-full py-4 bg-green-600 text-white text-lg font-bold rounded-xl transition active:scale-[0.98] disabled:opacity-50 disabled:active:scale-100"
>
{{ isSubmittingOrder ? 'Wird gespeichert...' : 'Wareneingang buchen' }}
</button>
</div>
</template>
</div>
</main>
<!-- Keypad -->
@@ -0,0 +1,282 @@
/**
* DatePicker Component
*
* Beautiful mobile date picker with bottom sheet modal.
* Features quick buttons (Heute, Gestern) and calendar grid.
*/
export default {
name: 'DatePicker',
emits: ['update:modelValue', 'close'],
props: {
modelValue: {
type: String,
default: null
},
show: {
type: Boolean,
default: false
}
},
setup(props, { emit }) {
const { ref, computed, watch } = Vue;
// Current calendar view month/year
const viewDate = ref(new Date());
// Initialize view date when opened
watch(() => props.show, (newVal) => {
if (newVal && props.modelValue) {
viewDate.value = new Date(props.modelValue);
} else if (newVal) {
viewDate.value = new Date();
}
});
// German weekday names (short)
const weekDays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
// German month names
const monthNames = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
];
const shortMonthNames = [
'Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun',
'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'
];
const weekDayNames = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
// Format date for display
const formatDisplayDate = (dateStr) => {
if (!dateStr) return 'Datum wählen';
const date = new Date(dateStr);
const dayName = weekDayNames[date.getDay()];
const day = date.getDate();
const month = shortMonthNames[date.getMonth()];
const year = date.getFullYear();
return `${dayName}, ${day}. ${month} ${year}`;
};
// Current month/year display
const currentMonthYear = computed(() => {
const month = monthNames[viewDate.value.getMonth()];
const year = viewDate.value.getFullYear();
return `${month} ${year}`;
});
// Get calendar days for current view
const calendarDays = computed(() => {
const year = viewDate.value.getFullYear();
const month = viewDate.value.getMonth();
// First day of month
const firstDay = new Date(year, month, 1);
// Last day of month
const lastDay = new Date(year, month + 1, 0);
// Day of week for first day (0=Sun, convert to 0=Mon)
let startDay = firstDay.getDay() - 1;
if (startDay < 0) startDay = 6;
const days = [];
// Add empty slots for days before first of month
for (let i = 0; i < startDay; i++) {
days.push({ day: null, date: null });
}
// Add days of month
for (let d = 1; d <= lastDay.getDate(); d++) {
const date = new Date(year, month, d);
const dateStr = formatDateISO(date);
days.push({
day: d,
date: dateStr,
isToday: isToday(date),
isSelected: dateStr === props.modelValue
});
}
return days;
});
// Check if date is today
const isToday = (date) => {
const today = new Date();
return date.toDateString() === today.toDateString();
};
// Format date as ISO string (YYYY-MM-DD)
const formatDateISO = (date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
// Quick date helpers
const getToday = () => formatDateISO(new Date());
const getYesterday = () => {
const d = new Date();
d.setDate(d.getDate() - 1);
return formatDateISO(d);
};
// Navigation
const prevMonth = () => {
viewDate.value = new Date(viewDate.value.getFullYear(), viewDate.value.getMonth() - 1, 1);
};
const nextMonth = () => {
viewDate.value = new Date(viewDate.value.getFullYear(), viewDate.value.getMonth() + 1, 1);
};
// Selection
const selectDate = (dateStr) => {
if (!dateStr) return;
emit('update:modelValue', dateStr);
emit('close');
};
const selectToday = () => selectDate(getToday());
const selectYesterday = () => selectDate(getYesterday());
const close = () => {
emit('close');
};
return {
viewDate,
weekDays,
currentMonthYear,
calendarDays,
formatDisplayDate,
prevMonth,
nextMonth,
selectDate,
selectToday,
selectYesterday,
close,
getToday,
getYesterday
};
},
template: `
<!-- Bottom Sheet Modal -->
<teleport to="body">
<transition name="fade">
<div v-if="show" class="fixed inset-0 z-50">
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/50" @click="close"></div>
<!-- Sheet -->
<transition name="slide-up-sheet">
<div v-if="show" class="absolute bottom-0 left-0 right-0 bg-white dark:bg-slate-800 rounded-t-2xl shadow-xl max-h-[80vh] overflow-hidden">
<!-- Handle -->
<div class="flex justify-center pt-2 pb-1">
<div class="w-10 h-1 bg-slate-300 dark:bg-slate-600 rounded-full"></div>
</div>
<!-- Header -->
<div class="px-4 pb-3 border-b border-slate-100 dark:border-slate-700">
<h3 class="text-lg font-semibold text-slate-800 dark:text-white text-center">
Datum wählen
</h3>
</div>
<!-- Quick Buttons -->
<div class="px-4 py-3 flex gap-2">
<button
@click="selectToday"
:class="[
'flex-1 py-2.5 rounded-xl font-medium transition',
modelValue === getToday()
? 'bg-primary text-white'
: 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300'
]"
>
Heute
</button>
<button
@click="selectYesterday"
:class="[
'flex-1 py-2.5 rounded-xl font-medium transition',
modelValue === getYesterday()
? 'bg-primary text-white'
: 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300'
]"
>
Gestern
</button>
</div>
<!-- Month Navigation -->
<div class="px-4 py-2 flex items-center justify-between">
<button
@click="prevMonth"
class="w-10 h-10 flex items-center justify-center rounded-full hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-400"
>
<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="M15 19l-7-7 7-7" />
</svg>
</button>
<span class="text-base font-semibold text-slate-800 dark:text-white">
{{ currentMonthYear }}
</span>
<button
@click="nextMonth"
class="w-10 h-10 flex items-center justify-center rounded-full hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-400"
>
<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="M9 5l7 7-7 7" />
</svg>
</button>
</div>
<!-- Calendar Grid -->
<div class="px-4 pb-6">
<!-- Weekday Headers -->
<div class="grid grid-cols-7 gap-1 mb-2">
<div
v-for="day in weekDays"
:key="day"
class="h-8 flex items-center justify-center text-xs font-medium text-slate-500 dark:text-slate-400"
>
{{ day }}
</div>
</div>
<!-- Days Grid -->
<div class="grid grid-cols-7 gap-1">
<button
v-for="(item, idx) in calendarDays"
:key="idx"
@click="selectDate(item.date)"
:disabled="!item.day"
:class="[
'h-10 flex items-center justify-center rounded-full text-sm font-medium transition',
!item.day ? 'invisible' : '',
item.isSelected ? 'bg-primary text-white' : '',
item.isToday && !item.isSelected ? 'ring-2 ring-primary text-primary' : '',
!item.isSelected && !item.isToday && item.day ? 'text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700' : ''
]"
>
{{ item.day }}
</button>
</div>
</div>
<!-- Safe area padding for iOS -->
<div class="h-6"></div>
</div>
</transition>
</div>
</transition>
</teleport>
`
};
@@ -0,0 +1,189 @@
/**
* EmployeeSelector Component
*
* Bottom sheet modal for searching and selecting employees.
* Supports lazy word search (e.g., "fab her" matches "Fabian Herbst").
*/
export default {
name: 'EmployeeSelector',
emits: ['select', 'close'],
props: {
show: {
type: Boolean,
default: false
},
excludeIds: {
type: Array,
default: () => []
}
},
setup(props, { emit }) {
const { ref, computed, watch } = Vue;
const searchQuery = ref('');
const employees = ref([]);
const isLoading = ref(false);
const searchTimeout = ref(null);
// Filter out already selected employees
const filteredEmployees = computed(() => {
return employees.value.filter(emp => !props.excludeIds.includes(emp.id));
});
// Search employees when query changes
watch(searchQuery, (newVal) => {
if (searchTimeout.value) {
clearTimeout(searchTimeout.value);
}
// Debounce search
searchTimeout.value = setTimeout(() => {
searchEmployees(newVal);
}, 300);
});
// Load employees when modal opens
watch(() => props.show, (newVal) => {
if (newVal) {
searchQuery.value = '';
searchEmployees('');
}
});
const searchEmployees = async (query) => {
isLoading.value = true;
try {
const params = new URLSearchParams();
if (query) params.append('query', query);
const response = await fetch(`/MobileApp/Lager/ShippingNote/searchEmployees?${params}`, {
credentials: 'include'
});
const data = await response.json();
if (data.success) {
employees.value = data.employees;
}
} catch (error) {
console.error('Error searching employees:', error);
} finally {
isLoading.value = false;
}
};
const selectEmployee = (employee) => {
emit('select', employee);
emit('close');
};
const close = () => {
emit('close');
};
return {
searchQuery,
filteredEmployees,
isLoading,
selectEmployee,
close
};
},
template: `
<!-- Bottom Sheet Modal -->
<teleport to="body">
<transition name="fade">
<div v-if="show" class="fixed inset-0 z-50">
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/50" @click="close"></div>
<!-- Sheet -->
<transition name="slide-up-sheet">
<div v-if="show" class="absolute bottom-0 left-0 right-0 bg-white dark:bg-slate-800 rounded-t-2xl shadow-xl max-h-[85vh] flex flex-col">
<!-- Handle -->
<div class="flex justify-center pt-2 pb-1 flex-shrink-0">
<div class="w-10 h-1 bg-slate-300 dark:bg-slate-600 rounded-full"></div>
</div>
<!-- Header -->
<div class="px-4 pb-3 border-b border-slate-100 dark:border-slate-700 flex-shrink-0">
<h3 class="text-lg font-semibold text-slate-800 dark:text-white text-center">
Mitarbeiter auswählen
</h3>
</div>
<!-- Search Input -->
<div class="px-4 py-3 flex-shrink-0">
<div class="relative">
<svg xmlns="http://www.w3.org/2000/svg" class="absolute left-3 top-1/2 -translate-y-1/2 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
v-model="searchQuery"
type="text"
placeholder="Name suchen..."
class="w-full pl-10 pr-4 py-3 bg-slate-100 dark:bg-slate-700 rounded-xl text-slate-800 dark:text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-primary"
autofocus
>
</div>
</div>
<!-- Results List -->
<div class="flex-1 overflow-y-auto px-4 pb-6">
<!-- Loading -->
<div v-if="isLoading" class="py-8 text-center">
<div class="animate-spin w-8 h-8 border-3 border-primary border-t-transparent rounded-full mx-auto"></div>
</div>
<!-- Empty State -->
<div v-else-if="filteredEmployees.length === 0" class="py-8 text-center text-slate-500 dark:text-slate-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto mb-3 text-slate-300 dark:text-slate-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<p>Keine Mitarbeiter gefunden</p>
</div>
<!-- Employee List -->
<div v-else class="space-y-2">
<button
v-for="emp in filteredEmployees"
:key="emp.id"
@click="selectEmployee(emp)"
class="w-full flex items-center p-3 bg-slate-50 dark:bg-slate-700/50 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-700 transition text-left"
>
<!-- Avatar -->
<div class="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center flex-shrink-0">
<span class="text-primary font-semibold text-sm">
{{ emp.name.charAt(0).toUpperCase() }}
</span>
</div>
<!-- Info -->
<div class="ml-3 flex-1 min-w-0">
<p class="font-medium text-slate-800 dark:text-white truncate">
{{ emp.name }}
</p>
<p v-if="emp.email" class="text-sm text-slate-500 dark:text-slate-400 truncate">
{{ emp.email }}
</p>
</div>
<!-- Select Icon -->
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-slate-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
</div>
<!-- Safe area padding for iOS -->
<div class="h-6 flex-shrink-0"></div>
</div>
</transition>
</div>
</transition>
</teleport>
`
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,227 @@
/**
* ShippingNoteList Component
*
* Lists unsigned shipping notes for the current user.
* Features:
* - Pull to refresh
* - Tap to open signature modal
* - Shows customer, date, note preview
*/
import { shippingNoteApi } from '/mobile/modules/lager/shippingnote/ShippingNoteModule.js';
export default {
name: 'ShippingNoteList',
emits: ['sign', 'toast'],
props: {
user: Object
},
setup(props, { emit }) {
const { ref, onMounted } = Vue;
// Data
const shippingNotes = ref([]);
const loading = ref(true);
const refreshing = ref(false);
const error = ref(null);
// Load shipping notes
const loadShippingNotes = async (isRefresh = false) => {
if (isRefresh) {
refreshing.value = true;
} else {
loading.value = true;
}
error.value = null;
try {
const data = await shippingNoteApi.get('getMyShippingNotes');
if (data.success) {
shippingNotes.value = data.shippingNotes || [];
} else {
error.value = data.error || 'Fehler beim Laden';
}
} catch (e) {
console.error('Failed to load shipping notes:', e);
error.value = 'Netzwerkfehler';
} finally {
loading.value = false;
refreshing.value = false;
}
};
// Pull to refresh
let touchStartY = 0;
let isPulling = false;
const handleTouchStart = (e) => {
const scrollTop = e.currentTarget.scrollTop;
if (scrollTop === 0) {
touchStartY = e.touches[0].clientY;
isPulling = true;
}
};
const handleTouchMove = (e) => {
if (!isPulling) return;
const deltaY = e.touches[0].clientY - touchStartY;
if (deltaY > 80 && !refreshing.value) {
loadShippingNotes(true);
isPulling = false;
}
};
const handleTouchEnd = () => {
isPulling = false;
};
// Open signature for a shipping note
const openSignature = (shippingNote) => {
emit('sign', shippingNote);
navigator.vibrate?.([50]);
};
// Format date
const formatDate = (dateStr) => {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleDateString('de-AT', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
};
// Initialize
onMounted(() => {
loadShippingNotes();
});
return {
shippingNotes,
loading,
refreshing,
error,
loadShippingNotes,
handleTouchStart,
handleTouchMove,
handleTouchEnd,
openSignature,
formatDate
};
},
template: `
<div
class="h-full overflow-y-auto"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
>
<!-- Pull to refresh indicator -->
<div v-if="refreshing" class="flex items-center justify-center py-4">
<svg class="animate-spin h-6 w-6 text-primary" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<!-- Loading state -->
<div v-if="loading && !refreshing" class="flex flex-col items-center justify-center py-16">
<svg class="animate-spin h-8 w-8 text-primary mb-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-sm text-slate-500 dark:text-slate-400">Lade Lieferscheine...</p>
</div>
<!-- Error state -->
<div v-else-if="error" class="flex flex-col items-center justify-center py-16 px-4">
<div class="w-16 h-16 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-red-500" 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-sm text-slate-500 dark:text-slate-400 mb-4">{{ error }}</p>
<button
@click="loadShippingNotes()"
class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium"
>
Erneut versuchen
</button>
</div>
<!-- Empty state -->
<div v-else-if="shippingNotes.length === 0" class="flex flex-col items-center justify-center py-16 px-4">
<div class="w-16 h-16 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p class="text-lg font-medium text-slate-800 dark:text-white mb-1">Alles unterschrieben!</p>
<p class="text-sm text-slate-500 dark:text-slate-400 text-center">
Keine offenen Lieferscheine zum Unterschreiben.
</p>
</div>
<!-- Shipping note list -->
<div v-else class="p-3 space-y-2">
<div class="text-xs text-slate-500 dark:text-slate-400 mb-2 px-1">
{{ shippingNotes.length }} {{ shippingNotes.length === 1 ? 'Lieferschein' : 'Lieferscheine' }} zum Unterschreiben
</div>
<button
v-for="note in shippingNotes"
:key="note.id"
@click="openSignature(note)"
class="w-full text-left bg-white dark:bg-slate-800 rounded-xl p-4 shadow-sm active:bg-slate-50 dark:active:bg-slate-700 transition"
>
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<!-- Number and Date -->
<div class="flex items-center gap-2 mb-1">
<span class="text-sm font-semibold text-primary">
#{{ note.number || note.id }}
</span>
<span class="text-xs text-slate-400 dark:text-slate-500">
{{ formatDate(note.date) }}
</span>
</div>
<!-- Customer Name -->
<div class="font-medium text-slate-800 dark:text-white truncate">
{{ note.customerName || note.deliveryAddressName || 'Unbekannter Kunde' }}
</div>
<!-- Address -->
<div class="text-sm text-slate-500 dark:text-slate-400 truncate mt-0.5">
{{ note.deliveryAddressLine }}, {{ note.deliveryAddressPLZ }} {{ note.deliveryAddressCity }}
</div>
<!-- Note preview -->
<div v-if="note.note" class="text-xs text-slate-400 dark:text-slate-500 mt-2 truncate">
{{ note.note }}
</div>
</div>
<!-- Sign indicator -->
<div class="flex-shrink-0 ml-3">
<div class="w-10 h-10 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</div>
</div>
</div>
</button>
</div>
<!-- Refresh hint -->
<div class="text-center py-4 text-xs text-slate-400 dark:text-slate-500">
Ziehen zum Aktualisieren
</div>
</div>
`
};
@@ -0,0 +1,164 @@
import { createModuleApi } from '/mobile/shared/api.js';
import ShippingNoteForm from '/mobile/modules/lager/shippingnote/ShippingNoteForm.js';
import ShippingNoteList from '/mobile/modules/lager/shippingnote/ShippingNoteList.js';
import SignaturePad from '/mobile/modules/lager/shippingnote/SignaturePad.js';
const shippingNoteApi = createModuleApi('Lager/ShippingNote');
export { shippingNoteApi };
export default {
name: 'ShippingNoteModule',
emits: ['navigate', 'toast'],
props: {
user: Object,
submodule: String
},
components: {
ShippingNoteForm,
ShippingNoteList,
SignaturePad
},
setup(props, { emit }) {
const { ref, computed, watch, onMounted } = Vue;
// Current view: 'create' | 'list' | 'sign'
const currentTab = ref('create');
// Signature modal state
const showSignatureModal = ref(false);
const signatureShippingNoteId = ref(null);
const signatureShippingNote = ref(null);
// Last created shipping note (for immediate signing)
const lastCreatedId = ref(null);
// Open signature modal for a shipping note
const openSignature = (shippingNote) => {
signatureShippingNoteId.value = shippingNote.id;
signatureShippingNote.value = shippingNote;
showSignatureModal.value = true;
};
// Close signature modal
const closeSignature = () => {
showSignatureModal.value = false;
signatureShippingNoteId.value = null;
signatureShippingNote.value = null;
};
// Handle successful signature
const handleSignatureComplete = () => {
closeSignature();
emit('toast', 'Unterschrift gespeichert', 'success');
// Haptic feedback
navigator.vibrate?.([100, 50, 100]);
};
// Handle shipping note created
const handleCreated = (shippingNote) => {
lastCreatedId.value = shippingNote.id;
emit('toast', 'Lieferschein erstellt', 'success');
// Haptic feedback
navigator.vibrate?.([100]);
};
// Handle immediate sign after create
const handleCreateAndSign = (shippingNote) => {
handleCreated(shippingNote);
// Open signature modal immediately
openSignature(shippingNote);
};
// Show toast
const showToast = (message, type) => {
emit('toast', message, type);
};
// Switch tab
const switchTab = (tab) => {
currentTab.value = tab;
};
return {
currentTab,
showSignatureModal,
signatureShippingNoteId,
signatureShippingNote,
lastCreatedId,
openSignature,
closeSignature,
handleSignatureComplete,
handleCreated,
handleCreateAndSign,
showToast,
switchTab
};
},
template: `
<div class="flex flex-col h-full">
<!-- 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('create')"
:class="[
'flex-1 py-2.5 text-sm font-medium rounded-lg transition flex items-center justify-center gap-2',
currentTab === 'create' ? 'bg-white dark:bg-slate-800 shadow-sm text-primary' : 'text-slate-500 dark:text-slate-400'
]"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Neu erstellen
</button>
<button
@click="switchTab('list')"
:class="[
'flex-1 py-2.5 text-sm font-medium rounded-lg transition flex items-center justify-center gap-2 ml-1',
currentTab === 'list' ? 'bg-white dark:bg-slate-800 shadow-sm text-primary' : 'text-slate-500 dark:text-slate-400'
]"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Unterschreiben
</button>
</div>
<!-- Content -->
<main class="flex-1 overflow-y-auto bg-slate-50 dark:bg-slate-900">
<!-- Create Form -->
<ShippingNoteForm
v-if="currentTab === 'create'"
:user="user"
@created="handleCreated"
@createAndSign="handleCreateAndSign"
@toast="showToast"
/>
<!-- List of unsigned notes -->
<ShippingNoteList
v-else-if="currentTab === 'list'"
:user="user"
@sign="openSignature"
@toast="showToast"
/>
</main>
<!-- Signature Modal -->
<transition name="slide-up">
<div v-if="showSignatureModal" class="fixed inset-0 z-50 flex flex-col bg-white dark:bg-slate-900">
<SignaturePad
:shipping-note-id="signatureShippingNoteId"
:shipping-note="signatureShippingNote"
@close="closeSignature"
@signed="handleSignatureComplete"
@toast="showToast"
/>
</div>
</transition>
</div>
`
};
@@ -0,0 +1,300 @@
/**
* SignaturePad Component
*
* Full-screen signature capture for shipping notes.
* Features:
* - Canvas-based signature drawing
* - Customer name input
* - Clear/retry functionality
* - Base64 PNG export
*/
import { shippingNoteApi } from '/mobile/modules/lager/shippingnote/ShippingNoteModule.js';
export default {
name: 'SignaturePad',
emits: ['close', 'signed', 'toast'],
props: {
shippingNoteId: [Number, String],
shippingNote: Object
},
setup(props, { emit }) {
const { ref, onMounted, onUnmounted, nextTick } = Vue;
// Refs
const canvasRef = ref(null);
const signatureName = ref('');
const loading = ref(false);
const hasSignature = ref(false);
// Canvas context
let ctx = null;
let isDrawing = false;
let lastX = 0;
let lastY = 0;
// Initialize canvas
onMounted(async () => {
await nextTick();
initCanvas();
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
const initCanvas = () => {
const canvas = canvasRef.value;
if (!canvas) return;
// Set canvas size to container
const container = canvas.parentElement;
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
ctx = canvas.getContext('2d');
ctx.strokeStyle = '#000000';
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// Fill with white background
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
};
const handleResize = () => {
// Save current signature
const canvas = canvasRef.value;
if (!canvas) return;
const imageData = canvas.toDataURL();
// Resize canvas
initCanvas();
// Restore signature
if (hasSignature.value) {
const img = new Image();
img.onload = () => {
ctx.drawImage(img, 0, 0);
};
img.src = imageData;
}
};
// Get position from touch/mouse event
const getPosition = (e) => {
const canvas = canvasRef.value;
const rect = canvas.getBoundingClientRect();
if (e.touches && e.touches.length > 0) {
return {
x: e.touches[0].clientX - rect.left,
y: e.touches[0].clientY - rect.top
};
}
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
};
// Start drawing
const startDrawing = (e) => {
e.preventDefault();
isDrawing = true;
const pos = getPosition(e);
lastX = pos.x;
lastY = pos.y;
};
// Draw
const draw = (e) => {
if (!isDrawing) return;
e.preventDefault();
const pos = getPosition(e);
ctx.beginPath();
ctx.moveTo(lastX, lastY);
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
lastX = pos.x;
lastY = pos.y;
hasSignature.value = true;
};
// Stop drawing
const stopDrawing = () => {
isDrawing = false;
};
// Clear canvas
const clearCanvas = () => {
const canvas = canvasRef.value;
if (!canvas || !ctx) return;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
hasSignature.value = false;
navigator.vibrate?.([30]);
};
// Submit signature
const submitSignature = async () => {
if (!hasSignature.value) {
emit('toast', 'Bitte unterschreiben', 'error');
return;
}
if (!signatureName.value.trim()) {
emit('toast', 'Bitte Namen eingeben', 'error');
return;
}
loading.value = true;
try {
const canvas = canvasRef.value;
const signatureData = canvas.toDataURL('image/png');
const data = await shippingNoteApi.post(`sign?id=${props.shippingNoteId}`, {
signature: signatureData,
signatureName: signatureName.value.trim()
});
if (data.success) {
emit('signed', data);
} else {
emit('toast', data.error || 'Fehler beim Speichern', 'error');
}
} catch (e) {
console.error('Signature submit failed:', e);
emit('toast', 'Netzwerkfehler', 'error');
} finally {
loading.value = false;
}
};
// Close handler
const handleClose = () => {
emit('close');
};
return {
canvasRef,
signatureName,
loading,
hasSignature,
startDrawing,
draw,
stopDrawing,
clearCanvas,
submitSignature,
handleClose
};
},
template: `
<div class="flex flex-col h-full bg-white dark:bg-slate-900">
<!-- Header -->
<div class="flex items-center justify-between px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800">
<button
@click="handleClose"
class="p-2 -ml-2 text-slate-500"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" 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>
<h2 class="text-lg font-semibold text-slate-800 dark:text-white">Unterschrift</h2>
<button
@click="clearCanvas"
class="p-2 -mr-2 text-red-500"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<!-- Shipping Note Info -->
<div v-if="shippingNote" class="px-4 py-3 bg-slate-100 dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700">
<div class="text-sm text-slate-500 dark:text-slate-400">Lieferschein Nr.</div>
<div class="font-medium text-slate-800 dark:text-white">{{ shippingNote.number || shippingNote.id }}</div>
<div v-if="shippingNote.customerName" class="text-sm text-slate-600 dark:text-slate-300 mt-1">
{{ shippingNote.customerName }}
</div>
</div>
<!-- Signature Canvas -->
<div class="flex-1 relative bg-white overflow-hidden">
<canvas
ref="canvasRef"
class="absolute inset-0 touch-none"
@mousedown="startDrawing"
@mousemove="draw"
@mouseup="stopDrawing"
@mouseleave="stopDrawing"
@touchstart="startDrawing"
@touchmove="draw"
@touchend="stopDrawing"
@touchcancel="stopDrawing"
></canvas>
<!-- Signature line hint -->
<div class="absolute bottom-16 left-8 right-8 border-b-2 border-dashed border-slate-300 pointer-events-none"></div>
<div class="absolute bottom-12 left-8 text-xs text-slate-400 pointer-events-none">Hier unterschreiben</div>
<!-- Empty state hint -->
<div v-if="!hasSignature" class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div class="text-center text-slate-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto mb-2 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
<p class="text-sm">Mit Finger unterschreiben</p>
</div>
</div>
</div>
<!-- Footer -->
<div class="p-4 bg-slate-50 dark:bg-slate-800 border-t border-slate-200 dark:border-slate-700 space-y-3">
<!-- Name input -->
<div>
<label class="block text-xs text-slate-500 dark:text-slate-400 mb-1">Name des Unterzeichners *</label>
<input
type="text"
v-model="signatureName"
class="w-full px-4 py-3 bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-xl text-base"
placeholder="Max Mustermann"
/>
</div>
<!-- Submit button -->
<button
@click="submitSignature"
:disabled="!hasSignature || loading"
:class="[
'w-full py-5 rounded-xl text-base font-semibold transition flex items-center justify-center gap-2',
hasSignature && !loading
? 'bg-gradient-to-r from-green-500 to-emerald-600 text-white shadow-lg active:scale-[0.98]'
: 'bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-500'
]"
>
<svg v-if="loading" class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<svg v-else 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="M5 13l4 4L19 7" />
</svg>
{{ loading ? 'Wird gespeichert...' : 'Unterschrift speichern' }}
</button>
</div>
</div>
`
};