/** * MovementForm Component (WarehouseMovement) * * The main interface for stock movements (IN/OUT/ADJUSTMENT). * API: /MobileApp/Lager/Movement/{action} */ 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'], props: { modelValue: [String, Number], options: { type: Array, default: () => [] }, label: { type: String, default: '' }, placeholder: { type: String, default: 'Auswählen...' }, valueKey: { type: String, default: 'value' }, labelKey: { type: String, default: 'text' }, icon: { type: String, default: null }, position: { type: String, default: 'bottom' } // 'bottom' or 'top' }, setup(props, { emit }) { const isOpen = Vue.ref(false); const selectedLabel = Vue.computed(() => { const option = props.options.find(o => (typeof o === 'object' ? o[props.valueKey] : o) === props.modelValue ); if (!option) return props.placeholder; return typeof option === 'object' ? option[props.labelKey] : option; }); const select = (option) => { const value = typeof option === 'object' ? option[props.valueKey] : option; emit('update:modelValue', value); isOpen.value = false; }; return { isOpen, selectedLabel, select }; }, template: `

{{ label }}

` }; export default { name: 'MovementForm', emits: ['toast'], props: { user: { type: Object, required: true }, simpleMode: { type: Boolean, default: false } }, components: { BottomSheetSelect }, setup(props, { emit }) { const { ref, onMounted, onUnmounted, nextTick, computed, watch } = Vue; // ==================== CONSTANTS ==================== const STORAGE_KEY = 'movement_settings'; const LOCATION_COORDS = { office: { lat: 46.99552810791587, lng: 15.7751923956463, name: 'K1 Fladnitz 150' }, aussenlager: { lat: 46.99909466636262, lng: 15.77571245012429, name: 'Aussenlager-Extern' } }; const GPS_ACCURACY_THRESHOLD = 100; // meters // ==================== CONFIG ==================== const locations = ref([]); const movementTypes = [ { value: 'IN', text: 'Einbuchung', icon: 'plus', color: 'green', defaultReason: 'Warenlieferung' }, { value: 'OUT', text: 'Ausbuchung', icon: 'minus', color: 'red', defaultReason: 'Verbrauch' }, { value: 'ADJUSTMENT', text: 'Korrektur', icon: 'edit', color: 'yellow', defaultReason: 'Inventurkorrektur' } ]; const reasonCategories = ref({}); // ==================== SELECTION STATE ==================== const selectedLocation = ref(null); const selectedType = ref('IN'); // ==================== GPS STATE ==================== const detectedLocation = ref(null); const gpsStatus = ref('idle'); // 'idle', 'detecting', 'detected', 'error' const gpsDistance = ref(null); // ==================== MODE TOGGLES ==================== const turboMode = ref(false); const batchMode = ref(false); // ==================== BATCH CART ==================== const cartItems = ref([]); const showCart = ref(false); // ==================== TABS ==================== const currentTab = ref('scan'); // ==================== LOADING ==================== const isLoading = ref(false); const isInitialized = ref(false); // ==================== SCANNER ==================== const scanner = ref(null); const isScannerActive = ref(false); const scannerError = ref(''); // ==================== ARTICLE ==================== const scannedArticle = ref(null); const currentStock = ref(0); const quantity = ref('1'); const selectedReason = ref(''); const note = ref(''); // ==================== SEARCH ==================== const searchQuery = ref(''); const searchResults = ref([]); const isSearching = ref(false); // ==================== HISTORY ==================== const recentMovements = ref([]); const isLoadingHistory = ref(false); // ==================== KEYPAD ==================== const showKeypad = ref(false); const showNote = ref(false); // ==================== UNDO STATE ==================== const lastMovement = ref(null); const showUndo = ref(false); let undoTimeout = null; // ==================== COMPUTED ==================== const canSubmit = computed(() => { return scannedArticle.value && selectedLocation.value && selectedType.value && parseFloat(quantity.value) > 0 && selectedReason.value && !isLoading.value; }); const typeColor = computed(() => { const type = movementTypes.find(t => t.value === selectedType.value); return type ? type.color : 'blue'; }); const reasonOptions = computed(() => { const reasons = reasonCategories.value[selectedType.value]; if (!reasons) return []; return Object.entries(reasons).map(([key, label]) => ({ value: key, text: label })); }); const cartTotal = computed(() => cartItems.value.length); // Filtered movement types (hide ADJUSTMENT in simple mode) const filteredMovementTypes = computed(() => { if (props.simpleMode) { return movementTypes.filter(t => t.value !== 'ADJUSTMENT'); } return movementTypes; }); // GPS distance formatting and color const formattedGpsDistance = computed(() => { if (gpsDistance.value === null) return ''; if (gpsDistance.value >= 1000) { return (gpsDistance.value / 1000).toFixed(1) + 'km'; } return gpsDistance.value + 'm'; }); const gpsDistanceColor = computed(() => { if (gpsDistance.value === null) return 'text-slate-400'; // Green: within 200m (auto-selected range) if (gpsDistance.value <= 200) return 'text-green-500'; // Yellow: 200m - 500m (getting far) if (gpsDistance.value <= 500) return 'text-yellow-500'; // Red: over 500m (probably wrong location) return 'text-red-500'; }); // ==================== LOCALSTORAGE PERSISTENCE ==================== const saveSettings = () => { const settings = { locationId: selectedLocation.value, type: selectedType.value, turboMode: turboMode.value, batchMode: batchMode.value }; localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); }; const loadSettings = () => { try { const saved = localStorage.getItem(STORAGE_KEY); if (saved) { return JSON.parse(saved); } } catch (e) {} return null; }; // Load settings IMMEDIATELY (synchronously) before anything else const savedSettings = loadSettings(); if (savedSettings) { if (savedSettings.type) selectedType.value = savedSettings.type; if (savedSettings.turboMode !== undefined) turboMode.value = savedSettings.turboMode; if (savedSettings.batchMode !== undefined) batchMode.value = savedSettings.batchMode; // locationId will be applied after locations are loaded } // ==================== GPS DETECTION ==================== const calculateDistance = (lat1, lng1, lat2, lng2) => { const R = 6371e3; // Earth's radius in meters const φ1 = lat1 * Math.PI / 180; const φ2 = lat2 * Math.PI / 180; const Δφ = (lat2 - lat1) * Math.PI / 180; const Δλ = (lng2 - lng1) * Math.PI / 180; const a = Math.sin(Δφ/2) * Math.sin(Δφ/2) + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ/2) * Math.sin(Δλ/2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); return R * c; }; const detectLocation = () => { if (!navigator.geolocation) { gpsStatus.value = 'error'; return; } gpsStatus.value = 'detecting'; navigator.geolocation.getCurrentPosition( (position) => { const { latitude, longitude, accuracy } = position.coords; // Calculate distances to both locations const distToOffice = calculateDistance(latitude, longitude, LOCATION_COORDS.office.lat, LOCATION_COORDS.office.lng); const distToAussen = calculateDistance(latitude, longitude, LOCATION_COORDS.aussenlager.lat, LOCATION_COORDS.aussenlager.lng); // Find closest location const closest = distToOffice < distToAussen ? { name: 'office', distance: distToOffice } : { name: 'aussenlager', distance: distToAussen }; gpsDistance.value = Math.round(closest.distance); // Only auto-select if accuracy is good and we're reasonably close if (accuracy <= GPS_ACCURACY_THRESHOLD && closest.distance < 500) { // Find matching location in our locations list const matchingLoc = locations.value.find(loc => loc.title.toLowerCase() === LOCATION_COORDS[closest.name].name.toLowerCase() ); if (matchingLoc) { detectedLocation.value = matchingLoc.id; // Only auto-set if user hasn't saved a preference if (!savedSettings?.locationId) { selectedLocation.value = matchingLoc.id; } gpsStatus.value = 'detected'; } } else { gpsStatus.value = 'detected'; } }, (error) => { gpsStatus.value = 'error'; }, { enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 } ); }; // ==================== QUICK ACTIONS ==================== const quickAction = async (type, reason) => { selectedType.value = type; await nextTick(); selectedReason.value = reason; saveSettings(); currentTab.value = 'scan'; if (!scannedArticle.value) { await startScanner(); } }; // ==================== LOAD INITIAL DATA ==================== const loadInitialData = async () => { try { const [locResult, reasonResult] = await Promise.all([ movementApi.get('getLocations'), movementApi.get('getReasonCategories') ]); if (locResult.success) { locations.value = locResult.locations; // Try to restore saved location (from already-loaded settings), otherwise use first if (savedSettings?.locationId && locations.value.find(l => l.id === savedSettings.locationId)) { selectedLocation.value = savedSettings.locationId; } else if (locations.value.length > 0) { selectedLocation.value = locations.value[0].id; } // Start GPS detection after locations are loaded detectLocation(); } if (reasonResult.success) { reasonCategories.value = reasonResult.categories; updateReasonOptions(); } isInitialized.value = true; } catch (e) { emit('toast', 'Fehler beim Laden der Konfiguration', 'error'); } }; // ==================== REASON OPTIONS ==================== const updateReasonOptions = () => { const reasons = reasonCategories.value?.[selectedType.value]; if (reasons && typeof reasons === 'object') { const keys = Object.keys(reasons); if (keys.length > 0) { selectedReason.value = keys[0]; } } else { // Fallback defaults const defaults = { 'IN': 'Warenlieferung', 'OUT': 'Verbrauch', 'ADJUSTMENT': 'Inventurkorrektur' }; if (defaults[selectedType.value]) { selectedReason.value = defaults[selectedType.value]; } } }; // ==================== WATCHERS ==================== watch(selectedType, () => { updateReasonOptions(); saveSettings(); }); watch(selectedLocation, () => { saveSettings(); if (scannedArticle.value) { loadCurrentStock(); } }); // Ensure location is always selected when locations are loaded watch(locations, (newLocations) => { if (newLocations.length > 0 && !selectedLocation.value) { // Try saved settings first if (savedSettings?.locationId && newLocations.find(l => l.id === savedSettings.locationId)) { selectedLocation.value = savedSettings.locationId; } else { selectedLocation.value = newLocations[0].id; } } }, { immediate: true }); watch(() => props.simpleMode, (newVal) => { if (newVal) { // Reset to IN/OUT if ADJUSTMENT was selected if (selectedType.value === 'ADJUSTMENT') { selectedType.value = 'OUT'; } // Switch away from history tab if (currentTab.value === 'history') { currentTab.value = 'scan'; } // Disable turbo/batch modes in simple mode turboMode.value = false; batchMode.value = false; } }); watch(turboMode, () => { saveSettings(); }); watch(batchMode, () => { saveSettings(); }); // Also update reason when categories are loaded watch(reasonCategories, () => { updateReasonOptions(); }, { deep: true }); // ==================== SCANNER FUNCTIONS ==================== const startScanner = async () => { scannerError.value = ''; try { scanner.value = new Html5Qrcode('qr-reader-movement'); 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; try { const result = await movementApi.get(`getArticle?code=${encodeURIComponent(code)}`); if (result.success) { scannedArticle.value = result.article; await loadCurrentStock(); quantity.value = '1'; // Ensure reason is set if (!selectedReason.value) { updateReasonOptions(); } // TURBO MODE: Auto-submit with qty=1 and default reason if (turboMode.value && !batchMode.value) { await turboSubmit(result.article); return; } // BATCH MODE: Add to cart and continue scanning if (batchMode.value) { addToCart(result.article); return; } } 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; } }; // ==================== TURBO MODE ==================== const turboSubmit = async (article) => { const typeConfig = movementTypes.find(t => t.value === selectedType.value); const defaultReason = typeConfig?.defaultReason || selectedReason.value; try { const payload = { movementType: selectedType.value, articleId: article.id, locationId: selectedLocation.value, quantity: 1, reasonCategory: defaultReason, note: null }; const result = await movementApi.post('submitMovement', payload); if (result.success) { // Store for undo lastMovement.value = result.movement; showUndo.value = true; if (undoTimeout) clearTimeout(undoTimeout); undoTimeout = setTimeout(() => { showUndo.value = false; }, 5000); // Show quick toast const typeLabel = selectedType.value === 'IN' ? '+' : selectedType.value === 'OUT' ? '-' : '±'; emit('toast', `${typeLabel}1 ${article.title}`, 'success'); // Reset and restart scanner scannedArticle.value = null; currentStock.value = 0; await startScanner(); } else { emit('toast', result.message || 'Fehler', 'error'); // Fall back to normal mode scannedArticle.value = article; } } catch (e) { emit('toast', 'Netzwerkfehler', 'error'); scannedArticle.value = article; } }; // ==================== BATCH/CART MODE ==================== const addToCart = (article) => { // Check if already in cart const existing = cartItems.value.find(item => item.article.id === article.id); if (existing) { existing.quantity += 1; emit('toast', `${article.title} (${existing.quantity}x)`, 'success'); } else { cartItems.value.push({ article: article, quantity: 1, stock: currentStock.value }); emit('toast', `+ ${article.title}`, 'success'); } // Reset and restart scanner scannedArticle.value = null; currentStock.value = 0; startScanner(); }; const updateCartQuantity = (index, qty) => { if (qty <= 0) { cartItems.value.splice(index, 1); } else { cartItems.value[index].quantity = qty; } }; const removeFromCart = (index) => { cartItems.value.splice(index, 1); }; const clearCart = () => { cartItems.value = []; showCart.value = false; }; const submitCart = async () => { if (cartItems.value.length === 0) return; isLoading.value = true; const typeConfig = movementTypes.find(t => t.value === selectedType.value); const defaultReason = typeConfig?.defaultReason || selectedReason.value; let successCount = 0; let errorCount = 0; for (const item of cartItems.value) { try { const payload = { movementType: selectedType.value, articleId: item.article.id, locationId: selectedLocation.value, quantity: item.quantity, reasonCategory: defaultReason, note: null }; const result = await movementApi.post('submitMovement', payload); if (result.success) { successCount++; } else { errorCount++; } } catch (e) { errorCount++; } } isLoading.value = false; if (errorCount === 0) { emit('toast', `${successCount} Bewegungen erfolgreich`, 'success'); clearCart(); } else { emit('toast', `${successCount} OK, ${errorCount} Fehler`, 'error'); } }; // Load current stock for article at location const loadCurrentStock = async () => { if (!scannedArticle.value || !selectedLocation.value) { currentStock.value = 0; return; } try { const result = await movementApi.get( `getCurrentStock?articleId=${scannedArticle.value.id}&locationId=${selectedLocation.value}` ); currentStock.value = result.success ? result.currentStock : 0; } catch (e) { currentStock.value = 0; } }; // Submit movement const submitMovement = async () => { if (!canSubmit.value) return; isLoading.value = true; try { const payload = { movementType: selectedType.value, articleId: scannedArticle.value.id, locationId: selectedLocation.value, quantity: parseFloat(quantity.value), reasonCategory: selectedReason.value, note: note.value || null }; const result = await movementApi.post('submitMovement', payload); if (result.success) { emit('toast', result.message, 'success'); // Reset form scannedArticle.value = null; currentStock.value = 0; quantity.value = '1'; note.value = ''; showNote.value = false; await startScanner(); } else { emit('toast', result.message || 'Fehler beim Speichern', 'error'); } } catch (e) { emit('toast', 'Netzwerkfehler', 'error'); } finally { isLoading.value = false; } }; // Search const searchArticles = async () => { if (searchQuery.value.length < 2) { searchResults.value = []; return; } isSearching.value = true; try { const result = await movementApi.get(`searchArticles?query=${encodeURIComponent(searchQuery.value)}`); if (result.success) searchResults.value = result.articles; } catch (e) {} finally { isSearching.value = false; } }; const selectSearchResult = async (article) => { await stopScanner(); scannedArticle.value = article; await loadCurrentStock(); quantity.value = '1'; showNote.value = false; // Ensure reason is set if (!selectedReason.value) { updateReasonOptions(); } currentTab.value = 'scan'; }; // History const loadHistory = async () => { isLoadingHistory.value = true; try { const params = selectedLocation.value ? `?locationId=${selectedLocation.value}` : ''; const result = await movementApi.get(`getMyMovements${params}`); if (result.success) recentMovements.value = result.movements; } 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 switchTab = async (tab) => { currentTab.value = tab; if (tab === 'scan' && !scannedArticle.value) { await nextTick(); await startScanner(); } else if (tab === 'search') { await stopScanner(); } else if (tab === 'history') { await stopScanner(); await loadHistory(); } }; const cancelScan = async () => { scannedArticle.value = null; currentStock.value = 0; quantity.value = '1'; note.value = ''; showNote.value = false; await startScanner(); }; // Get type badge classes const getTypeBadgeClass = (type) => { const colors = { 'IN': 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', 'OUT': 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400', 'ADJUSTMENT': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400' }; return colors[type] || 'bg-slate-100 text-slate-800'; }; onMounted(async () => { await loadInitialData(); await startScanner(); }); onUnmounted(async () => { await stopScanner(); }); return { // Config locations, movementTypes, reasonCategories, reasonOptions, selectedLocation, selectedType, // GPS detectedLocation, gpsStatus, gpsDistance, detectLocation, formattedGpsDistance, gpsDistanceColor, // Mode toggles turboMode, batchMode, filteredMovementTypes, // Cart cartItems, cartTotal, showCart, addToCart, updateCartQuantity, removeFromCart, clearCart, submitCart, // Quick actions quickAction, // Undo lastMovement, showUndo, // Tabs & Loading currentTab, isLoading, isInitialized, isScannerActive, scannerError, // Article scannedArticle, currentStock, quantity, selectedReason, note, // Search searchQuery, searchResults, isSearching, // History recentMovements, isLoadingHistory, // UI showKeypad, showNote, canSubmit, typeColor, // Functions startScanner, stopScanner, submitMovement, searchArticles, selectSearchResult, loadHistory, appendDigit, deleteDigit, clearQuantity, switchTab, cancelScan, getTypeBadgeClass }; }, template: `
GPS...
{{ formattedGpsDistance }}
GPS

{{ scannerError }}

QR-Code scannen oder Artikel suchen

{{ scannedArticle.title }}

{{ scannedArticle.articleNumber }}

{{ currentStock }} {{ scannedArticle.unit || 'Stk.' }}
Menge {{ selectedType === 'OUT' ? '-' : selectedType === 'IN' ? '+' : '' }}{{ quantity }}

{{ searchQuery.length < 2 ? 'Mindestens 2 Zeichen eingeben' : 'Keine Artikel gefunden' }}

{{ article.title }}

{{ article.articleNumber }}

Noch keine Bewegungen

{{ movement.movementType === 'IN' ? 'Einbuchung' : movement.movementType === 'OUT' ? 'Ausbuchung' : 'Korrektur' }} {{ movement.locationTitle }}

{{ movement.articleTitle }}

{{ movement.articleNumber }}

{{ movement.movementType === 'IN' ? '+' : movement.movementType === 'OUT' ? '-' : '' }}{{ movement.quantity }} {{ movement.unit }}

{{ movement.create }}

{{ quantity }}

Sammelkorb ({{ cartTotal }})

Korb ist leer

{{ item.article.title }}

{{ item.article.articleNumber }}

{{ item.quantity }}
TURBO: Scan = Sofort buchen (1x)
{{ lastMovement?.articleTitle }} gebucht
` };