1561 lines
81 KiB
JavaScript
1561 lines
81 KiB
JavaScript
import { createModuleApi, debounce } from '/mobile/shared/api.js';
|
|
|
|
const movementApi = createModuleApi('Lager/Movement');
|
|
|
|
|
|
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: `
|
|
<div>
|
|
<!-- Trigger Button -->
|
|
<button
|
|
@click="isOpen = true"
|
|
type="button"
|
|
class="w-full flex items-center justify-between px-4 py-3 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl shadow-sm hover:border-slate-300 dark:hover:border-slate-600 transition active:scale-[0.98]"
|
|
>
|
|
<div class="flex items-center gap-3">
|
|
<div v-if="icon" class="w-8 h-8 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-slate-500 dark:text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path v-if="icon === 'location'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
|
<path v-if="icon === 'location'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
<path v-if="icon === 'clipboard'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
|
</svg>
|
|
</div>
|
|
<div class="text-left">
|
|
<p v-if="label" class="text-xs text-slate-400 dark:text-slate-500 font-medium">{{ label }}</p>
|
|
<p class="font-medium text-slate-800 dark:text-white">{{ selectedLabel }}</p>
|
|
</div>
|
|
</div>
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- Sheet Overlay -->
|
|
<teleport to="body">
|
|
<transition name="fade">
|
|
<div v-if="isOpen" class="fixed inset-0 bg-black/50 z-50" @click="isOpen = false"></div>
|
|
</transition>
|
|
|
|
<!-- Top Sheet -->
|
|
<transition :name="position === 'top' ? 'slide-down' : 'slide-up'">
|
|
<div v-if="isOpen" :class="[
|
|
'fixed left-0 right-0 z-50 bg-white dark:bg-slate-800 shadow-xl max-h-[60vh] flex flex-col',
|
|
position === 'top' ? 'top-0 rounded-b-2xl safe-area-top' : 'bottom-0 rounded-t-2xl safe-area-bottom'
|
|
]">
|
|
<!-- Handle -->
|
|
<div v-if="position === 'bottom'" class="flex justify-center py-3">
|
|
<div class="w-10 h-1 bg-slate-300 dark:bg-slate-600 rounded-full"></div>
|
|
</div>
|
|
<!-- Title -->
|
|
<div v-if="label" :class="['px-4', position === 'top' ? 'pt-4 pb-2' : 'pb-2']">
|
|
<h3 class="font-semibold text-lg text-slate-800 dark:text-white">{{ label }}</h3>
|
|
</div>
|
|
<!-- Options -->
|
|
<div class="overflow-y-auto flex-1 px-2 pb-4">
|
|
<button
|
|
v-for="option in options"
|
|
:key="typeof option === 'object' ? option[valueKey] : option"
|
|
@click="select(option)"
|
|
:class="[
|
|
'w-full flex items-center justify-between px-4 py-3.5 rounded-xl mb-1 transition',
|
|
(typeof option === 'object' ? option[valueKey] : option) === modelValue
|
|
? 'bg-primary/10 dark:bg-primary/20'
|
|
: 'hover:bg-slate-100 dark:hover:bg-slate-700/50 active:bg-slate-200 dark:active:bg-slate-700'
|
|
]"
|
|
>
|
|
<span :class="[
|
|
'font-medium',
|
|
(typeof option === 'object' ? option[valueKey] : option) === modelValue
|
|
? 'text-primary'
|
|
: 'text-slate-700 dark:text-slate-200'
|
|
]">
|
|
{{ typeof option === 'object' ? option[labelKey] : option }}
|
|
</span>
|
|
<svg
|
|
v-if="(typeof option === 'object' ? option[valueKey] : option) === modelValue"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="h-5 w-5 text-primary"
|
|
viewBox="0 0 20 20"
|
|
fill="currentColor"
|
|
>
|
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<!-- Handle at bottom for top sheet -->
|
|
<div v-if="position === 'top'" class="flex justify-center py-3">
|
|
<div class="w-10 h-1 bg-slate-300 dark:bg-slate-600 rounded-full"></div>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
</teleport>
|
|
</div>
|
|
`
|
|
};
|
|
|
|
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);
|
|
|
|
// ==================== 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);
|
|
|
|
// ==================== 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) {
|
|
// Haptic feedback on success
|
|
navigator.vibrate?.([100]);
|
|
|
|
// 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) {
|
|
// Haptic feedback on success
|
|
navigator.vibrate?.([100, 50, 100]);
|
|
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) {
|
|
// Haptic feedback on success
|
|
navigator.vibrate?.([100]);
|
|
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;
|
|
}
|
|
};
|
|
|
|
const doSearch = 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 searchArticles = debounce(doSearch, 300);
|
|
|
|
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;
|
|
}
|
|
};
|
|
|
|
// ==================== 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;
|
|
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();
|
|
} else if (tab === 'orders') {
|
|
await stopScanner();
|
|
await loadPendingOrders();
|
|
}
|
|
};
|
|
|
|
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,
|
|
// Order Receiving
|
|
pendingOrders, isLoadingOrders, selectedOrder, orderPositions,
|
|
deliveryNotePhoto, isSubmittingOrder,
|
|
loadPendingOrders, selectOrderForReceiving, cancelOrderReceiving,
|
|
submitOrderReceiving, setAllReceivingQty, clearAllReceivingQty,
|
|
// UI
|
|
showKeypad, showNote, canSubmit, typeColor,
|
|
// Functions
|
|
startScanner, stopScanner, submitMovement,
|
|
searchArticles, selectSearchResult, loadHistory,
|
|
appendDigit, deleteDigit, clearQuantity,
|
|
switchTab, cancelScan, getTypeBadgeClass
|
|
};
|
|
},
|
|
|
|
template: `
|
|
<div class="flex flex-col h-full">
|
|
<!-- Config bar -->
|
|
<div class="bg-slate-50 dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700 px-3 py-3 space-y-3">
|
|
<!-- Location selector with GPS indicator -->
|
|
<div class="relative">
|
|
<BottomSheetSelect
|
|
v-model="selectedLocation"
|
|
:options="locations"
|
|
value-key="id"
|
|
label-key="title"
|
|
label="Lagerort"
|
|
icon="location"
|
|
placeholder="Lagerort wählen..."
|
|
position="top"
|
|
/>
|
|
<!-- GPS Status indicator -->
|
|
<div class="absolute right-12 top-1/2 -translate-y-1/2">
|
|
<div v-if="gpsStatus === 'detecting'" class="flex items-center gap-1 text-xs text-slate-400">
|
|
<div class="w-2 h-2 bg-blue-400 rounded-full animate-pulse"></div>
|
|
GPS...
|
|
</div>
|
|
<div v-else-if="gpsStatus === 'detected'" :class="['flex items-center gap-1 text-xs', gpsDistanceColor]">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor">
|
|
<path fill-rule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd" />
|
|
</svg>
|
|
{{ formattedGpsDistance }}
|
|
</div>
|
|
<div v-else-if="gpsStatus === 'error'" class="flex items-center gap-1 text-xs text-slate-400">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor">
|
|
<path fill-rule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd" />
|
|
</svg>
|
|
<span class="line-through">GPS</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Movement Type - Segmented Control -->
|
|
<div class="bg-white dark:bg-slate-800 p-1.5 rounded-xl shadow-sm flex gap-1">
|
|
<button
|
|
v-for="type in filteredMovementTypes"
|
|
:key="type.value"
|
|
@click="selectedType = type.value"
|
|
:class="[
|
|
'flex-1 py-2.5 px-2 rounded-lg font-medium text-sm transition-all flex items-center justify-center gap-1.5',
|
|
selectedType === type.value
|
|
? type.value === 'IN'
|
|
? 'bg-green-500 text-white shadow-sm'
|
|
: type.value === 'OUT'
|
|
? 'bg-red-500 text-white shadow-sm'
|
|
: 'bg-yellow-500 text-white shadow-sm'
|
|
: 'text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700'
|
|
]"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path v-if="type.value === 'IN'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
|
<path v-else-if="type.value === 'OUT'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H4" />
|
|
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
|
</svg>
|
|
<span class="hidden sm:inline">{{ type.text }}</span>
|
|
<span class="sm:hidden">{{ type.value === 'IN' ? 'Ein' : type.value === 'OUT' ? 'Aus' : 'Korr.' }}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Mode Toggles (hidden in simple mode) -->
|
|
<div v-if="!simpleMode" class="flex items-center gap-2">
|
|
<!-- Turbo Mode Toggle - More Prominent -->
|
|
<button
|
|
@click="turboMode = !turboMode"
|
|
:class="[
|
|
'flex items-center gap-2 px-4 py-2.5 rounded-xl font-medium transition-all flex-1',
|
|
turboMode
|
|
? '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-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>
|
|
<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.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'
|
|
]"
|
|
>
|
|
<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="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
|
|
</svg>
|
|
Sammel
|
|
<span v-if="cartTotal > 0" class="bg-white/30 px-1.5 rounded-full">{{ cartTotal }}</span>
|
|
</button>
|
|
</div>
|
|
</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 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>
|
|
|
|
<!-- Content -->
|
|
<main class="flex-grow overflow-y-auto bg-slate-50 dark:bg-slate-900">
|
|
<!-- Loading -->
|
|
<div v-if="!isInitialized" class="flex items-center justify-center h-full">
|
|
<div class="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div>
|
|
</div>
|
|
|
|
<!-- SCAN TAB -->
|
|
<div v-else-if="currentTab === 'scan'" class="p-4 space-y-4">
|
|
<!-- Scanner -->
|
|
<div v-if="!scannedArticle" class="space-y-4">
|
|
<div id="qr-reader-movement" class="w-full rounded-lg overflow-hidden bg-black"></div>
|
|
<div v-if="scannerError" class="p-4 bg-red-50 dark:bg-red-900/30 rounded-lg">
|
|
<p class="text-sm text-red-600 dark:text-red-400">{{ scannerError }}</p>
|
|
<button @click="startScanner" class="mt-2 text-sm font-medium text-primary">Erneut versuchen</button>
|
|
</div>
|
|
<p class="text-center text-sm text-slate-500 dark:text-slate-400">QR-Code scannen oder Artikel suchen</p>
|
|
</div>
|
|
|
|
<!-- Scanned Article Form (Compact) -->
|
|
<div v-else class="space-y-3">
|
|
<!-- Article + Stock (Combined) -->
|
|
<div class="bg-white dark:bg-slate-800 p-3 rounded-xl shadow-sm">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div class="flex-1 min-w-0">
|
|
<h3 class="font-bold text-slate-800 dark:text-white truncate">{{ scannedArticle.title }}</h3>
|
|
<p class="text-xs text-slate-500 dark:text-slate-400">{{ scannedArticle.articleNumber }}</p>
|
|
</div>
|
|
<div :class="['text-right flex-shrink-0 px-2 py-1 rounded-lg', currentStock < 0 ? 'bg-orange-100 dark:bg-orange-900/30' : 'bg-slate-100 dark:bg-slate-700']">
|
|
<span :class="['font-bold text-lg', currentStock < 0 ? 'text-orange-600 dark:text-orange-400' : 'text-slate-700 dark:text-slate-200']">
|
|
{{ currentStock }}
|
|
</span>
|
|
<span class="text-xs text-slate-500 dark:text-slate-400 ml-1">{{ scannedArticle.unit || 'Stk.' }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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) -->
|
|
<div class="flex gap-2 items-stretch">
|
|
<div class="flex-1">
|
|
<BottomSheetSelect
|
|
v-model="selectedReason"
|
|
:options="reasonOptions"
|
|
value-key="value"
|
|
label-key="text"
|
|
label="Grund"
|
|
icon="clipboard"
|
|
placeholder="Grund..."
|
|
/>
|
|
</div>
|
|
<button
|
|
@click="showNote = !showNote"
|
|
:class="[
|
|
'w-14 rounded-xl flex items-center justify-center transition flex-shrink-0',
|
|
showNote || note ? 'bg-primary text-white' : 'bg-white dark:bg-slate-800 text-slate-400 border border-slate-200 dark:border-slate-700'
|
|
]"
|
|
>
|
|
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Note (Expandable) -->
|
|
<div v-if="showNote" class="bg-white dark:bg-slate-800 rounded-xl shadow-sm overflow-hidden">
|
|
<textarea
|
|
v-model="note"
|
|
rows="2"
|
|
class="w-full p-3 border-0 bg-transparent text-slate-800 dark:text-white resize-none focus:ring-0"
|
|
placeholder="Notiz hinzufügen..."
|
|
></textarea>
|
|
</div>
|
|
|
|
<!-- Buttons -->
|
|
<div class="space-y-2">
|
|
<button
|
|
@click="submitMovement"
|
|
:disabled="!canSubmit"
|
|
:class="[
|
|
'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' : ''
|
|
]"
|
|
>
|
|
{{ 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>
|
|
|
|
<!-- SEARCH TAB -->
|
|
<div v-else-if="currentTab === 'search'" class="p-4 space-y-4">
|
|
<div class="sticky top-0 bg-slate-50 dark:bg-slate-900 pb-2">
|
|
<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"
|
|
>
|
|
</div>
|
|
|
|
<div v-if="isSearching" class="text-center py-8">
|
|
<div class="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full mx-auto"></div>
|
|
</div>
|
|
|
|
<div v-else-if="searchResults.length === 0" class="text-center py-8">
|
|
<p class="text-slate-500 dark:text-slate-400">{{ searchQuery.length < 2 ? 'Mindestens 2 Zeichen eingeben' : 'Keine Artikel gefunden' }}</p>
|
|
</div>
|
|
|
|
<div v-else class="space-y-2">
|
|
<div
|
|
v-for="article in searchResults"
|
|
:key="article.id"
|
|
@click="selectSearchResult(article)"
|
|
class="bg-white dark:bg-slate-800 p-3 rounded-lg shadow-sm cursor-pointer hover:shadow-md active:scale-[0.98] transition"
|
|
>
|
|
<p class="font-medium text-slate-800 dark:text-white">{{ article.title }}</p>
|
|
<p class="text-sm text-slate-500 dark:text-slate-400">{{ article.articleNumber }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- HISTORY TAB -->
|
|
<div v-else-if="currentTab === 'history'" class="p-4">
|
|
<div v-if="isLoadingHistory" class="space-y-3">
|
|
<div v-for="i in 5" :key="i" class="bg-white dark:bg-slate-800 p-3 rounded-lg shadow-sm animate-pulse">
|
|
<div class="h-4 bg-slate-200 dark:bg-slate-700 rounded w-3/4 mb-2"></div>
|
|
<div class="h-3 bg-slate-200 dark:bg-slate-700 rounded w-1/2"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else-if="recentMovements.length === 0" class="text-center py-8">
|
|
<p class="text-slate-500 dark:text-slate-400">Noch keine Bewegungen</p>
|
|
</div>
|
|
|
|
<div v-else class="space-y-2">
|
|
<div v-for="movement in recentMovements" :key="movement.id" class="bg-white dark:bg-slate-800 p-3 rounded-lg shadow-sm">
|
|
<div class="flex justify-between items-start">
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center gap-2 mb-1">
|
|
<span :class="['px-2 py-0.5 text-xs font-medium rounded', getTypeBadgeClass(movement.movementType)]">
|
|
{{ movement.movementType === 'IN' ? 'Einbuchung' : movement.movementType === 'OUT' ? 'Ausbuchung' : 'Korrektur' }}
|
|
</span>
|
|
<span class="text-xs text-slate-400">{{ movement.locationTitle }}</span>
|
|
</div>
|
|
<p class="font-medium text-slate-800 dark:text-white truncate">{{ movement.articleTitle }}</p>
|
|
<p class="text-sm text-slate-500 dark:text-slate-400">{{ movement.articleNumber }}</p>
|
|
</div>
|
|
<div class="text-right ml-2">
|
|
<p :class="[
|
|
'font-bold',
|
|
movement.movementType === 'IN' ? 'text-green-600 dark:text-green-400' : '',
|
|
movement.movementType === 'OUT' ? 'text-red-600 dark:text-red-400' : '',
|
|
movement.movementType === 'ADJUSTMENT' ? 'text-yellow-600 dark:text-yellow-400' : ''
|
|
]">
|
|
{{ movement.movementType === 'IN' ? '+' : movement.movementType === 'OUT' ? '-' : '' }}{{ movement.quantity }} {{ movement.unit }}
|
|
</p>
|
|
<p class="text-xs text-slate-400">{{ movement.create }}</p>
|
|
</div>
|
|
</div>
|
|
</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 -->
|
|
<transition name="slide-up">
|
|
<div v-if="showKeypad" class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-end">
|
|
<div class="bg-white dark:bg-slate-800 w-full rounded-t-2xl p-4 safe-area-bottom">
|
|
<div class="flex justify-between items-center mb-4">
|
|
<button @click="clearQuantity" class="px-4 py-2 text-red-500 font-medium">C</button>
|
|
<div class="text-2xl font-bold text-slate-800 dark:text-white">{{ quantity }}</div>
|
|
<button @click="showKeypad = false" class="px-4 py-2 text-primary font-medium">Fertig</button>
|
|
</div>
|
|
<div class="grid grid-cols-3 gap-2">
|
|
<button
|
|
v-for="d in ['1','2','3','4','5','6','7','8','9','.','0']"
|
|
:key="d"
|
|
@click="appendDigit(d)"
|
|
class="py-4 text-xl font-bold bg-slate-100 dark:bg-slate-700 rounded-lg active:bg-slate-200 dark:active:bg-slate-600 text-slate-800 dark:text-white"
|
|
>{{ d }}</button>
|
|
<button @click="deleteDigit" class="py-4 text-xl font-bold bg-slate-100 dark:bg-slate-700 rounded-lg active:bg-slate-200 dark:active:bg-slate-600 text-slate-800 dark:text-white">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2M3 12l6.414 6.414a2 2 0 001.414.586H19a2 2 0 002-2V7a2 2 0 00-2-2h-8.172a2 2 0 00-1.414.586L3 12z" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
|
|
<!-- Floating Cart Button (when batch mode active and has items) -->
|
|
<transition name="scale">
|
|
<button
|
|
v-if="batchMode && cartTotal > 0"
|
|
@click="showCart = true"
|
|
class="fixed bottom-6 right-4 w-14 h-14 bg-purple-500 text-white rounded-full shadow-lg flex items-center justify-center z-40 active:scale-95 transition"
|
|
>
|
|
<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="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
|
|
</svg>
|
|
<span class="absolute -top-1 -right-1 bg-white text-purple-600 text-xs font-bold w-5 h-5 rounded-full flex items-center justify-center">{{ cartTotal }}</span>
|
|
</button>
|
|
</transition>
|
|
|
|
<!-- Cart Modal -->
|
|
<transition name="slide-up">
|
|
<div v-if="showCart" class="fixed inset-0 bg-black/50 z-50 flex items-end">
|
|
<div class="bg-white dark:bg-slate-800 w-full rounded-t-2xl shadow-xl max-h-[85vh] flex flex-col safe-area-bottom">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between px-4 py-3 border-b border-slate-200 dark:border-slate-700">
|
|
<h3 class="font-bold text-lg text-slate-800 dark:text-white flex items-center gap-2">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
|
|
</svg>
|
|
Sammelkorb ({{ cartTotal }})
|
|
</h3>
|
|
<button @click="showCart = false" class="p-2 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-full">
|
|
<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>
|
|
|
|
<!-- Cart Items -->
|
|
<div class="flex-1 overflow-y-auto p-4 space-y-2">
|
|
<div v-if="cartItems.length === 0" class="text-center py-8 text-slate-400">
|
|
Korb ist leer
|
|
</div>
|
|
<div
|
|
v-for="(item, index) in cartItems"
|
|
:key="item.article.id"
|
|
class="bg-slate-50 dark:bg-slate-700/50 p-3 rounded-xl flex items-center gap-3"
|
|
>
|
|
<div class="flex-1 min-w-0">
|
|
<p class="font-medium text-slate-800 dark:text-white truncate">{{ item.article.title }}</p>
|
|
<p class="text-xs text-slate-500 dark:text-slate-400">{{ item.article.articleNumber }}</p>
|
|
</div>
|
|
<!-- Quantity Controls -->
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
@click="updateCartQuantity(index, item.quantity - 1)"
|
|
class="w-8 h-8 rounded-lg bg-slate-200 dark:bg-slate-600 flex items-center justify-center text-slate-600 dark:text-slate-300"
|
|
>-</button>
|
|
<span class="w-8 text-center font-bold text-slate-800 dark:text-white">{{ item.quantity }}</span>
|
|
<button
|
|
@click="updateCartQuantity(index, item.quantity + 1)"
|
|
class="w-8 h-8 rounded-lg bg-slate-200 dark:bg-slate-600 flex items-center justify-center text-slate-600 dark:text-slate-300"
|
|
>+</button>
|
|
</div>
|
|
<!-- Remove -->
|
|
<button @click="removeFromCart(index)" class="p-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg">
|
|
<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="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>
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div class="p-4 border-t border-slate-200 dark:border-slate-700 space-y-2">
|
|
<button
|
|
@click="submitCart"
|
|
:disabled="cartItems.length === 0 || isLoading"
|
|
:class="[
|
|
'w-full py-4 font-bold rounded-xl text-white transition disabled:opacity-50',
|
|
selectedType === 'IN' ? 'bg-green-500' : selectedType === 'OUT' ? 'bg-red-500' : 'bg-yellow-500'
|
|
]"
|
|
>
|
|
{{ isLoading ? 'Buche...' : 'Alle ' + (selectedType === 'IN' ? 'einbuchen' : selectedType === 'OUT' ? 'ausbuchen' : 'korrigieren') }}
|
|
</button>
|
|
<button @click="clearCart" class="w-full py-2 text-slate-500 dark:text-slate-400 font-medium">
|
|
Korb leeren
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
|
|
<!-- Mode Indicator Banner -->
|
|
<transition name="slide-up">
|
|
<div
|
|
v-if="turboMode && !scannedArticle"
|
|
class="fixed bottom-0 left-0 right-0 bg-orange-500 text-white text-center py-2 text-sm font-medium z-30 safe-area-bottom"
|
|
>
|
|
<span class="flex items-center justify-center gap-2">
|
|
<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="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
</svg>
|
|
TURBO: Scan = Sofort buchen (1x)
|
|
</span>
|
|
</div>
|
|
</transition>
|
|
|
|
<!-- Undo Toast -->
|
|
<transition name="slide-up">
|
|
<div v-if="showUndo" class="fixed bottom-16 left-4 right-4 bg-slate-800 dark:bg-slate-700 text-white rounded-xl p-3 flex items-center justify-between z-40 shadow-lg">
|
|
<span class="text-sm">{{ lastMovement?.articleTitle }} gebucht</span>
|
|
<button class="px-3 py-1 bg-white/20 rounded-lg text-sm font-medium hover:bg-white/30 transition">
|
|
Rückgängig
|
|
</button>
|
|
</div>
|
|
</transition>
|
|
</div>
|
|
`
|
|
};
|