/**
* 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: `
{{ formattedGpsDistance }}
{{ 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 }}
{{ 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 }}
Sammelkorb ({{ cartTotal }})
Korb ist leer
{{ item.article.title }}
{{ item.article.articleNumber }}
{{ item.quantity }}
TURBO: Scan = Sofort buchen (1x)
{{ lastMovement?.articleTitle }} gebucht
`
};