cleanup warehousestocktake progressive web app
This commit is contained in:
@@ -1,168 +0,0 @@
|
||||
/**
|
||||
* Warehouse Stocktake PWA - App-Specific Styles
|
||||
*/
|
||||
|
||||
/* QR Scanner Container */
|
||||
#qr-reader {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#qr-reader video {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Hide default html5-qrcode UI elements we don't need */
|
||||
#qr-reader__status_span,
|
||||
#qr-reader__dashboard_section_csr,
|
||||
#qr-reader__dashboard_section_swaplink {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Scanner frame styling */
|
||||
#qr-reader__scan_region {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
#qr-reader__scan_region img {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* Keypad styling */
|
||||
.keypad-button {
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
/* Numeric display */
|
||||
.quantity-display {
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Tab indicator animation */
|
||||
.tab-indicator {
|
||||
transition: transform 0.3s ease, width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Card hover effect */
|
||||
.stocktake-card:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Search results scrolling */
|
||||
.search-results {
|
||||
max-height: calc(100vh - 280px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* History list */
|
||||
.history-list {
|
||||
max-height: calc(100vh - 200px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Already scanned badge pulse */
|
||||
@keyframes pulse-amber {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px rgba(245, 158, 11, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.already-scanned-pulse {
|
||||
animation: pulse-amber 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Toast slide up animation (complementing base.css) */
|
||||
.toast-enter-active {
|
||||
animation: toast-slide-up 0.3s ease-out;
|
||||
}
|
||||
|
||||
.toast-leave-active {
|
||||
animation: toast-slide-down 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes toast-slide-up {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-slide-down {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Settings panel slide */
|
||||
.settings-panel {
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease-out;
|
||||
}
|
||||
|
||||
.settings-panel.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.spinner {
|
||||
border: 3px solid rgba(0, 83, 132, 0.1);
|
||||
border-top-color: #005384;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Dark mode specific overrides */
|
||||
.dark .spinner {
|
||||
border-color: rgba(250, 196, 27, 0.1);
|
||||
border-top-color: #fac41b;
|
||||
}
|
||||
|
||||
/* Scan success flash */
|
||||
@keyframes scan-flash {
|
||||
0% {
|
||||
background-color: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.scan-success-flash {
|
||||
animation: scan-flash 0.5s ease-out;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for webkit browsers */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
/**
|
||||
* Warehouse Stocktake PWA - Main Vue Application
|
||||
*
|
||||
* This is the entry point for the Warehouse Stocktake PWA.
|
||||
* It manages authentication state and routes between views.
|
||||
*/
|
||||
|
||||
// Import shared modules
|
||||
import { api, authState, checkAuth, login, logout } from '/mobile/shared/auth.js';
|
||||
|
||||
// Import components
|
||||
import LoginScreen from './components/LoginScreen.js';
|
||||
import StocktakeList from './components/StocktakeList.js';
|
||||
import Scanner from './components/Scanner.js';
|
||||
|
||||
const { createApp, ref, computed, onMounted, watch, nextTick } = Vue;
|
||||
|
||||
const App = {
|
||||
components: {
|
||||
LoginScreen,
|
||||
StocktakeList,
|
||||
Scanner
|
||||
},
|
||||
|
||||
setup() {
|
||||
// ==================== STATE ====================
|
||||
const currentView = ref('loading'); // 'loading', 'login', 'list', 'scanner'
|
||||
const user = ref(null);
|
||||
const selectedStocktake = ref(null);
|
||||
const toast = ref({ show: false, message: '', type: 'success' });
|
||||
const theme = ref('system');
|
||||
|
||||
// ==================== THEME ====================
|
||||
const applyTheme = () => {
|
||||
const isDark = localStorage.theme === 'dark' ||
|
||||
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
|
||||
const metaThemeColor = document.querySelector('meta[name="theme-color"]');
|
||||
if (metaThemeColor) {
|
||||
metaThemeColor.setAttribute('content', isDark ? '#0f172a' : '#005384');
|
||||
}
|
||||
};
|
||||
|
||||
const setTheme = (newTheme) => {
|
||||
theme.value = newTheme;
|
||||
if (newTheme === 'system') {
|
||||
localStorage.removeItem('theme');
|
||||
} else {
|
||||
localStorage.setItem('theme', newTheme);
|
||||
}
|
||||
applyTheme();
|
||||
};
|
||||
|
||||
// ==================== AUTH ====================
|
||||
const handleLogin = async (credentials) => {
|
||||
const result = await login(credentials);
|
||||
if (result.success) {
|
||||
user.value = result.user;
|
||||
currentView.value = 'list';
|
||||
showToast('Erfolgreich angemeldet', 'success');
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
user.value = null;
|
||||
selectedStocktake.value = null;
|
||||
currentView.value = 'login';
|
||||
showToast('Abgemeldet', 'success');
|
||||
};
|
||||
|
||||
// ==================== NAVIGATION ====================
|
||||
const openScanner = (stocktake) => {
|
||||
selectedStocktake.value = stocktake;
|
||||
currentView.value = 'scanner';
|
||||
};
|
||||
|
||||
const closeScanner = () => {
|
||||
selectedStocktake.value = null;
|
||||
currentView.value = 'list';
|
||||
};
|
||||
|
||||
// ==================== TOAST ====================
|
||||
const showToast = (message, type = 'success') => {
|
||||
toast.value = { show: true, message, type };
|
||||
setTimeout(() => {
|
||||
toast.value.show = false;
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
// ==================== LIFECYCLE ====================
|
||||
onMounted(async () => {
|
||||
// Initialize theme
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme) {
|
||||
theme.value = savedTheme;
|
||||
}
|
||||
applyTheme();
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyTheme);
|
||||
|
||||
// Check authentication
|
||||
const result = await checkAuth();
|
||||
if (result.authenticated) {
|
||||
user.value = result.user;
|
||||
currentView.value = 'list';
|
||||
} else {
|
||||
currentView.value = 'login';
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
// State
|
||||
currentView,
|
||||
user,
|
||||
selectedStocktake,
|
||||
toast,
|
||||
theme,
|
||||
|
||||
// Methods
|
||||
handleLogin,
|
||||
handleLogout,
|
||||
openScanner,
|
||||
closeScanner,
|
||||
showToast,
|
||||
setTheme,
|
||||
};
|
||||
},
|
||||
|
||||
template: `
|
||||
<div class="relative h-full w-full bg-slate-100 dark:bg-slate-900 transition-colors duration-300">
|
||||
<!-- Loading State -->
|
||||
<div v-if="currentView === 'loading'" class="flex items-center justify-center h-full">
|
||||
<div class="text-center">
|
||||
<img src="/assets/images/xinon-full-transparent.png" class="h-12 mx-auto mb-4 dark:hidden">
|
||||
<img src="/assets/images/xinon-full-transparent-white.png" class="h-12 mx-auto mb-4 hidden dark:block">
|
||||
<div class="animate-pulse text-slate-500 dark:text-slate-400">Lädt...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Screen -->
|
||||
<LoginScreen
|
||||
v-else-if="currentView === 'login'"
|
||||
@login="handleLogin"
|
||||
:theme="theme"
|
||||
@set-theme="setTheme"
|
||||
/>
|
||||
|
||||
<!-- Stocktake List -->
|
||||
<StocktakeList
|
||||
v-else-if="currentView === 'list'"
|
||||
:user="user"
|
||||
:theme="theme"
|
||||
@select="openScanner"
|
||||
@logout="handleLogout"
|
||||
@set-theme="setTheme"
|
||||
/>
|
||||
|
||||
<!-- Scanner View -->
|
||||
<Scanner
|
||||
v-else-if="currentView === 'scanner'"
|
||||
:stocktake="selectedStocktake"
|
||||
:user="user"
|
||||
@close="closeScanner"
|
||||
@toast="showToast"
|
||||
/>
|
||||
|
||||
<!-- Toast Notifications -->
|
||||
<transition name="slide-up">
|
||||
<div v-if="toast.show" class="toast-container">
|
||||
<div :class="['toast', 'toast-' + toast.type]">
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
|
||||
// Mount the app
|
||||
createApp(App).mount('#app');
|
||||
@@ -1,207 +0,0 @@
|
||||
/**
|
||||
* LoginScreen Component
|
||||
*
|
||||
* Displays the login form for the PWA.
|
||||
* Handles username/password authentication with remember me option.
|
||||
*/
|
||||
|
||||
export default {
|
||||
name: 'LoginScreen',
|
||||
emits: ['login', 'set-theme'],
|
||||
props: {
|
||||
theme: {
|
||||
type: String,
|
||||
default: 'system'
|
||||
}
|
||||
},
|
||||
|
||||
setup(props, { emit }) {
|
||||
const { ref } = Vue;
|
||||
|
||||
// Form state
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
const rememberMe = ref(true);
|
||||
const error = ref('');
|
||||
const loading = ref(false);
|
||||
const showPassword = ref(false);
|
||||
|
||||
// Theme picker (shown on first visit)
|
||||
const showThemePicker = ref(!localStorage.getItem('theme'));
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!username.value || !password.value) {
|
||||
error.value = 'Bitte Benutzername und Passwort eingeben';
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
const result = await new Promise((resolve) => {
|
||||
// Emit returns undefined, we need to wait for parent to call back
|
||||
const loginPromise = emit('login', {
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
rememberMe: rememberMe.value
|
||||
});
|
||||
|
||||
// The parent will return the result
|
||||
resolve(loginPromise);
|
||||
});
|
||||
|
||||
if (result && !result.success) {
|
||||
error.value = result.message || 'Login fehlgeschlagen';
|
||||
if (result.requires2FA) {
|
||||
error.value = 'Zwei-Faktor-Authentifizierung wird derzeit nicht unterstützt.';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = 'Ein Fehler ist aufgetreten';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const selectTheme = (newTheme) => {
|
||||
emit('set-theme', newTheme);
|
||||
showThemePicker.value = false;
|
||||
};
|
||||
|
||||
return {
|
||||
username,
|
||||
password,
|
||||
rememberMe,
|
||||
error,
|
||||
loading,
|
||||
showPassword,
|
||||
showThemePicker,
|
||||
handleSubmit,
|
||||
selectTheme
|
||||
};
|
||||
},
|
||||
|
||||
template: `
|
||||
<div class="min-h-screen flex items-center justify-center bg-slate-100 dark:bg-slate-900 p-4">
|
||||
<!-- Theme Picker Modal (First Visit) -->
|
||||
<transition name="fade">
|
||||
<div v-if="showThemePicker" class="fixed inset-0 bg-black bg-opacity-60 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg p-6 w-full max-w-xs text-center shadow-2xl">
|
||||
<h3 class="font-bold text-lg mb-2 text-slate-800 dark:text-white">Willkommen!</h3>
|
||||
<p class="text-sm text-slate-600 dark:text-slate-300 mb-6">Wähle dein bevorzugtes Farbschema.</p>
|
||||
<div class="flex flex-col space-y-3">
|
||||
<button @click="selectTheme('light')" class="w-full px-4 py-3 bg-slate-200 text-slate-800 font-bold rounded-md hover:bg-slate-300 transition">
|
||||
Hell
|
||||
</button>
|
||||
<button @click="selectTheme('dark')" class="w-full px-4 py-3 bg-slate-700 text-white font-bold rounded-md hover:bg-slate-600 transition">
|
||||
Dunkel
|
||||
</button>
|
||||
<button @click="selectTheme('system')" class="w-full mt-2 text-sm text-slate-500 dark:text-slate-400 hover:underline">
|
||||
Systemstandard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Login Form -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 w-full max-w-sm">
|
||||
<!-- Logo -->
|
||||
<div class="mb-8">
|
||||
<img src="/assets/images/xinon-full-transparent.png" class="h-10 mx-auto dark:hidden" alt="Logo">
|
||||
<img src="/assets/images/xinon-full-transparent-white.png" class="h-10 mx-auto hidden dark:block" alt="Logo">
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h1 class="text-xl font-bold text-center text-slate-800 dark:text-white mb-6">
|
||||
Lager Inventur
|
||||
</h1>
|
||||
|
||||
<!-- Form -->
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<!-- Username -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
Benutzername
|
||||
</label>
|
||||
<input
|
||||
v-model="username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
autocapitalize="none"
|
||||
class="w-full p-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition dark:bg-slate-700 dark:border-slate-600 dark:text-white"
|
||||
placeholder="Benutzername eingeben"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
Passwort
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="current-password"
|
||||
class="w-full p-3 pr-12 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition dark:bg-slate-700 dark:border-slate-600 dark:text-white"
|
||||
placeholder="Passwort eingeben"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
|
||||
>
|
||||
<svg v-if="!showPassword" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remember Me -->
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
v-model="rememberMe"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 rounded border-slate-300 text-primary focus:ring-primary dark:border-slate-600 dark:bg-slate-700"
|
||||
>
|
||||
<span class="ml-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
Angemeldet bleiben
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div v-if="error" class="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="w-full py-3 px-4 bg-primary text-white font-bold rounded-lg hover:bg-primary/90 focus:ring-4 focus:ring-primary/30 transition disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
|
||||
>
|
||||
<svg v-if="loading" class="animate-spin -ml-1 mr-2 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ loading ? 'Wird angemeldet...' : 'Anmelden' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-8 text-center">
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500">
|
||||
powered by XINON GmbH
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -1,607 +0,0 @@
|
||||
/**
|
||||
* Scanner Component
|
||||
*
|
||||
* The main scanning interface for the stocktake.
|
||||
* Features:
|
||||
* - QR code scanning via camera
|
||||
* - Manual article search
|
||||
* - Quantity input with custom keypad
|
||||
* - Recent scans list
|
||||
*/
|
||||
|
||||
import { api } from '/mobile/shared/auth.js';
|
||||
|
||||
export default {
|
||||
name: 'Scanner',
|
||||
emits: ['close', 'toast'],
|
||||
props: {
|
||||
stocktake: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
user: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
setup(props, { emit }) {
|
||||
const { ref, onMounted, onUnmounted, nextTick, computed } = Vue;
|
||||
|
||||
// ==================== STATE ====================
|
||||
const currentTab = ref('scan'); // 'scan', 'search', 'history'
|
||||
const isLoading = ref(false);
|
||||
|
||||
// Scanner state
|
||||
const scanner = ref(null);
|
||||
const isScannerActive = ref(false);
|
||||
const scannerError = ref('');
|
||||
|
||||
// Article state
|
||||
const scannedArticle = ref(null);
|
||||
const quantity = ref('1');
|
||||
const rack = ref('');
|
||||
const shelf = ref('');
|
||||
|
||||
// Search state
|
||||
const searchQuery = ref('');
|
||||
const searchResults = ref([]);
|
||||
const categories = ref([]);
|
||||
const selectedCategory = ref(0);
|
||||
const isSearching = ref(false);
|
||||
|
||||
// History state
|
||||
const recentScans = ref([]);
|
||||
const isLoadingHistory = ref(false);
|
||||
|
||||
// Already scanned warning
|
||||
const alreadyScannedWarning = ref(null);
|
||||
|
||||
// Custom keypad
|
||||
const showKeypad = ref(false);
|
||||
|
||||
// ==================== COMPUTED ====================
|
||||
const canSubmit = computed(() => {
|
||||
return scannedArticle.value &&
|
||||
parseFloat(quantity.value) > 0 &&
|
||||
!isLoading.value;
|
||||
});
|
||||
|
||||
// ==================== SCANNER ====================
|
||||
const startScanner = async () => {
|
||||
scannerError.value = '';
|
||||
|
||||
try {
|
||||
// Initialize scanner
|
||||
scanner.value = new Html5Qrcode('qr-reader');
|
||||
|
||||
await scanner.value.start(
|
||||
{ facingMode: 'environment' },
|
||||
{
|
||||
fps: 10,
|
||||
qrbox: { width: 250, height: 250 },
|
||||
aspectRatio: 1.0
|
||||
},
|
||||
onScanSuccess,
|
||||
onScanError
|
||||
);
|
||||
|
||||
isScannerActive.value = true;
|
||||
} catch (err) {
|
||||
console.error('Scanner start error:', 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) {
|
||||
console.error('Scanner stop error:', e);
|
||||
}
|
||||
isScannerActive.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onScanSuccess = async (decodedText) => {
|
||||
// Stop scanner temporarily
|
||||
await stopScanner();
|
||||
|
||||
// Look up article
|
||||
await lookupArticle(decodedText);
|
||||
};
|
||||
|
||||
const onScanError = (errorMessage) => {
|
||||
// Silent - this fires constantly when no QR code is detected
|
||||
};
|
||||
|
||||
// ==================== ARTICLE LOOKUP ====================
|
||||
const lookupArticle = async (code) => {
|
||||
isLoading.value = true;
|
||||
alreadyScannedWarning.value = null;
|
||||
|
||||
try {
|
||||
const result = await api.get(`WarehouseStocktake/getArticle?code=${encodeURIComponent(code)}`);
|
||||
|
||||
if (result.success) {
|
||||
scannedArticle.value = result.article;
|
||||
|
||||
// Check if already scanned
|
||||
const checkResult = await api.get(
|
||||
`WarehouseStocktake/checkAlreadyScanned?stocktakeId=${props.stocktake.id}&articleId=${result.article.id}`
|
||||
);
|
||||
|
||||
if (checkResult.success && checkResult.alreadyScanned) {
|
||||
alreadyScannedWarning.value = checkResult.existingItem;
|
||||
}
|
||||
|
||||
// Reset quantity
|
||||
quantity.value = '1';
|
||||
} else {
|
||||
emit('toast', result.message || 'Artikel nicht gefunden', 'error');
|
||||
// Restart scanner
|
||||
await startScanner();
|
||||
}
|
||||
} catch (e) {
|
||||
emit('toast', 'Fehler beim Laden des Artikels', 'error');
|
||||
await startScanner();
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== SUBMIT SCAN ====================
|
||||
const submitScan = async (overwrite = false) => {
|
||||
if (!canSubmit.value) return;
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
stocktakeId: props.stocktake.id,
|
||||
articleId: scannedArticle.value.id,
|
||||
quantity: parseFloat(quantity.value),
|
||||
rack: rack.value || null,
|
||||
shelf: shelf.value || null,
|
||||
overwrite: overwrite,
|
||||
overwriteItemId: overwrite && alreadyScannedWarning.value ? alreadyScannedWarning.value.id : 0
|
||||
};
|
||||
|
||||
const result = await api.post('WarehouseStocktake/submitScan', payload);
|
||||
|
||||
if (result.success) {
|
||||
emit('toast', result.message, 'success');
|
||||
|
||||
// Reset state
|
||||
scannedArticle.value = null;
|
||||
quantity.value = '1';
|
||||
rack.value = '';
|
||||
shelf.value = '';
|
||||
alreadyScannedWarning.value = null;
|
||||
|
||||
// Restart scanner
|
||||
await startScanner();
|
||||
} else {
|
||||
emit('toast', result.message || 'Fehler beim Speichern', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
emit('toast', 'Netzwerkfehler', 'error');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== SEARCH ====================
|
||||
const loadCategories = async () => {
|
||||
const result = await api.get('WarehouseStocktake/getCategories');
|
||||
if (result.success) {
|
||||
categories.value = result.categories;
|
||||
}
|
||||
};
|
||||
|
||||
const searchArticles = async () => {
|
||||
if (searchQuery.value.length < 2 && !selectedCategory.value) {
|
||||
searchResults.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
isSearching.value = true;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (searchQuery.value) params.set('query', searchQuery.value);
|
||||
if (selectedCategory.value) params.set('categoryId', selectedCategory.value);
|
||||
|
||||
const result = await api.get(`WarehouseStocktake/searchArticles?${params}`);
|
||||
|
||||
if (result.success) {
|
||||
searchResults.value = result.articles;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Search error:', e);
|
||||
} finally {
|
||||
isSearching.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const selectSearchResult = async (article) => {
|
||||
await stopScanner();
|
||||
scannedArticle.value = article;
|
||||
quantity.value = '1';
|
||||
currentTab.value = 'scan';
|
||||
|
||||
// Check if already scanned
|
||||
const checkResult = await api.get(
|
||||
`WarehouseStocktake/checkAlreadyScanned?stocktakeId=${props.stocktake.id}&articleId=${article.id}`
|
||||
);
|
||||
|
||||
if (checkResult.success && checkResult.alreadyScanned) {
|
||||
alreadyScannedWarning.value = checkResult.existingItem;
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== HISTORY ====================
|
||||
const loadHistory = async () => {
|
||||
isLoadingHistory.value = true;
|
||||
|
||||
try {
|
||||
const result = await api.get(`WarehouseStocktake/getMyScans?stocktakeId=${props.stocktake.id}`);
|
||||
|
||||
if (result.success) {
|
||||
recentScans.value = result.items;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('History load error:', 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 = () => {
|
||||
if (quantity.value.length > 1) {
|
||||
quantity.value = quantity.value.slice(0, -1);
|
||||
} else {
|
||||
quantity.value = '0';
|
||||
}
|
||||
};
|
||||
|
||||
const clearQuantity = () => {
|
||||
quantity.value = '0';
|
||||
};
|
||||
|
||||
// ==================== LIFECYCLE ====================
|
||||
const handleClose = async () => {
|
||||
await stopScanner();
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const switchTab = async (tab) => {
|
||||
currentTab.value = tab;
|
||||
|
||||
if (tab === 'scan' && !scannedArticle.value) {
|
||||
await nextTick();
|
||||
await startScanner();
|
||||
} else if (tab === 'search') {
|
||||
await stopScanner();
|
||||
await loadCategories();
|
||||
} else if (tab === 'history') {
|
||||
await stopScanner();
|
||||
await loadHistory();
|
||||
}
|
||||
};
|
||||
|
||||
const cancelScan = async () => {
|
||||
scannedArticle.value = null;
|
||||
alreadyScannedWarning.value = null;
|
||||
quantity.value = '1';
|
||||
await startScanner();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await startScanner();
|
||||
});
|
||||
|
||||
onUnmounted(async () => {
|
||||
await stopScanner();
|
||||
});
|
||||
|
||||
return {
|
||||
// State
|
||||
currentTab,
|
||||
isLoading,
|
||||
isScannerActive,
|
||||
scannerError,
|
||||
scannedArticle,
|
||||
quantity,
|
||||
rack,
|
||||
shelf,
|
||||
searchQuery,
|
||||
searchResults,
|
||||
categories,
|
||||
selectedCategory,
|
||||
isSearching,
|
||||
recentScans,
|
||||
isLoadingHistory,
|
||||
alreadyScannedWarning,
|
||||
showKeypad,
|
||||
canSubmit,
|
||||
|
||||
// Methods
|
||||
startScanner,
|
||||
stopScanner,
|
||||
submitScan,
|
||||
searchArticles,
|
||||
selectSearchResult,
|
||||
loadHistory,
|
||||
appendDigit,
|
||||
deleteDigit,
|
||||
clearQuantity,
|
||||
handleClose,
|
||||
switchTab,
|
||||
cancelScan
|
||||
};
|
||||
},
|
||||
|
||||
template: `
|
||||
<div class="flex flex-col h-full bg-slate-100 dark:bg-slate-900">
|
||||
<!-- Header -->
|
||||
<header class="bg-white dark:bg-slate-800 shadow-sm p-4 flex-shrink-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<button @click="handleClose" class="p-2 -ml-2 rounded-full hover:bg-slate-100 dark:hover:bg-slate-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-slate-600 dark:text-slate-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 class="text-lg font-bold text-slate-800 dark:text-white truncate px-2">
|
||||
{{ stocktake.title || 'Inventur #' + stocktake.stocktakeNumber }}
|
||||
</h1>
|
||||
<div class="w-10"></div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex mt-4 bg-slate-100 dark:bg-slate-700 rounded-lg p-1">
|
||||
<button
|
||||
@click="switchTab('scan')"
|
||||
:class="[currentTab === 'scan' ? 'bg-white dark:bg-slate-600 shadow' : '']"
|
||||
class="flex-1 py-2 text-sm font-medium rounded-md transition text-slate-700 dark:text-slate-200"
|
||||
>
|
||||
Scannen
|
||||
</button>
|
||||
<button
|
||||
@click="switchTab('search')"
|
||||
:class="[currentTab === 'search' ? 'bg-white dark:bg-slate-600 shadow' : '']"
|
||||
class="flex-1 py-2 text-sm font-medium rounded-md transition text-slate-700 dark:text-slate-200"
|
||||
>
|
||||
Suche
|
||||
</button>
|
||||
<button
|
||||
@click="switchTab('history')"
|
||||
:class="[currentTab === 'history' ? 'bg-white dark:bg-slate-600 shadow' : '']"
|
||||
class="flex-1 py-2 text-sm font-medium rounded-md transition text-slate-700 dark:text-slate-200"
|
||||
>
|
||||
Verlauf
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Content -->
|
||||
<main class="flex-grow overflow-y-auto">
|
||||
<!-- SCAN TAB -->
|
||||
<div v-if="currentTab === 'scan'" class="p-4 space-y-4">
|
||||
<!-- Scanner or Article View -->
|
||||
<div v-if="!scannedArticle" class="space-y-4">
|
||||
<!-- QR Scanner -->
|
||||
<div id="qr-reader" class="w-full rounded-lg overflow-hidden bg-black"></div>
|
||||
|
||||
<!-- Scanner Error -->
|
||||
<div v-if="scannerError" class="p-4 bg-red-50 dark:bg-red-900/30 rounded-lg">
|
||||
<p class="text-sm text-red-600 dark:text-red-400">{{ scannerError }}</p>
|
||||
<button @click="startScanner" class="mt-2 text-sm font-medium text-primary">
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-sm text-slate-500 dark:text-slate-400">
|
||||
QR-Code scannen oder Artikel suchen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Scanned Article -->
|
||||
<div v-else class="space-y-4">
|
||||
<!-- Already Scanned Warning -->
|
||||
<div v-if="alreadyScannedWarning" class="p-4 bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<div class="flex items-start">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-amber-500 mr-2 flex-shrink-0 mt-0.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-medium text-amber-800 dark:text-amber-300">Bereits gescannt</p>
|
||||
<p class="text-sm text-amber-700 dark:text-amber-400 mt-1">
|
||||
Menge: {{ alreadyScannedWarning.countedQuantity }}<br>
|
||||
Von: {{ alreadyScannedWarning.scannedBy }}<br>
|
||||
Am: {{ alreadyScannedWarning.scannedAt }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Article Info -->
|
||||
<div class="bg-white dark:bg-slate-800 p-4 rounded-lg shadow-sm">
|
||||
<h3 class="font-bold text-lg text-slate-800 dark:text-white">
|
||||
{{ scannedArticle.title }}
|
||||
</h3>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||
Art.-Nr.: {{ scannedArticle.articleNumber }}
|
||||
</p>
|
||||
<p v-if="scannedArticle.categoryName" class="text-sm text-slate-500 dark:text-slate-400">
|
||||
Kategorie: {{ scannedArticle.categoryName }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Quantity Input -->
|
||||
<div class="bg-white dark:bg-slate-800 p-4 rounded-lg shadow-sm">
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Menge ({{ scannedArticle.unit || 'Stk.' }})
|
||||
</label>
|
||||
<div
|
||||
@click="showKeypad = true"
|
||||
class="w-full p-4 text-3xl font-bold text-center border-2 border-slate-300 dark:border-slate-600 rounded-lg cursor-pointer hover:border-primary transition text-slate-800 dark:text-white"
|
||||
>
|
||||
{{ quantity }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Optional Fields -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Regal</label>
|
||||
<input v-model="rack" type="text" class="w-full p-3 border border-slate-300 dark:border-slate-600 rounded-lg dark:bg-slate-800 dark:text-white" placeholder="z.B. A1">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Fach</label>
|
||||
<input v-model="shelf" type="text" class="w-full p-3 border border-slate-300 dark:border-slate-600 rounded-lg dark:bg-slate-800 dark:text-white" placeholder="z.B. 3">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="space-y-2">
|
||||
<button
|
||||
v-if="alreadyScannedWarning"
|
||||
@click="submitScan(false)"
|
||||
:disabled="!canSubmit"
|
||||
class="w-full py-4 bg-green-600 text-white font-bold rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Zur Menge addieren
|
||||
</button>
|
||||
<button
|
||||
v-if="alreadyScannedWarning"
|
||||
@click="submitScan(true)"
|
||||
:disabled="!canSubmit"
|
||||
class="w-full py-4 bg-amber-600 text-white font-bold rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Überschreiben
|
||||
</button>
|
||||
<button
|
||||
v-if="!alreadyScannedWarning"
|
||||
@click="submitScan(false)"
|
||||
:disabled="!canSubmit"
|
||||
class="w-full py-4 bg-green-600 text-white font-bold rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{{ isLoading ? 'Speichert...' : 'Speichern' }}
|
||||
</button>
|
||||
<button @click="cancelScan" class="w-full py-3 text-slate-600 dark:text-slate-400 font-medium">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SEARCH TAB -->
|
||||
<div v-else-if="currentTab === 'search'" class="p-4 space-y-4">
|
||||
<div class="sticky top-0 bg-slate-100 dark:bg-slate-900 pb-2 space-y-3">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
@input="searchArticles"
|
||||
type="search"
|
||||
placeholder="Artikel suchen..."
|
||||
class="w-full p-3 border border-slate-300 dark:border-slate-600 rounded-lg dark:bg-slate-800 dark:text-white"
|
||||
>
|
||||
<select
|
||||
v-model="selectedCategory"
|
||||
@change="searchArticles"
|
||||
class="w-full p-3 border border-slate-300 dark:border-slate-600 rounded-lg dark:bg-slate-800 dark:text-white"
|
||||
>
|
||||
<option :value="0">Alle Kategorien</option>
|
||||
<option v-for="cat in categories" :key="cat.id" :value="cat.id">{{ cat.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="isSearching" class="text-center py-8">
|
||||
<div class="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full mx-auto"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="searchResults.length === 0" class="text-center py-8">
|
||||
<p class="text-slate-500 dark:text-slate-400">
|
||||
{{ searchQuery.length < 2 ? 'Mindestens 2 Zeichen eingeben' : 'Keine Artikel gefunden' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="article in searchResults"
|
||||
:key="article.id"
|
||||
@click="selectSearchResult(article)"
|
||||
class="bg-white dark:bg-slate-800 p-3 rounded-lg shadow-sm cursor-pointer hover:shadow-md active:scale-[0.98] transition"
|
||||
>
|
||||
<p class="font-medium text-slate-800 dark:text-white">{{ article.title }}</p>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">{{ article.articleNumber }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HISTORY TAB -->
|
||||
<div v-else-if="currentTab === 'history'" class="p-4">
|
||||
<div v-if="isLoadingHistory" class="space-y-3">
|
||||
<div v-for="i in 5" :key="i" class="bg-white dark:bg-slate-800 p-3 rounded-lg shadow-sm animate-pulse">
|
||||
<div class="h-4 bg-slate-200 dark:bg-slate-700 rounded w-3/4 mb-2"></div>
|
||||
<div class="h-3 bg-slate-200 dark:bg-slate-700 rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="recentScans.length === 0" class="text-center py-8">
|
||||
<p class="text-slate-500 dark:text-slate-400">Noch keine Scans</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="scan in recentScans" :key="scan.id" class="bg-white dark:bg-slate-800 p-3 rounded-lg shadow-sm">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<p class="font-medium text-slate-800 dark:text-white">{{ scan.articleTitle }}</p>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">{{ scan.articleNumber }}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-bold text-slate-800 dark:text-white">{{ scan.countedQuantity }} {{ scan.unit }}</p>
|
||||
<p class="text-xs text-slate-400">{{ scan.scannedAt }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Custom Keypad Modal -->
|
||||
<transition name="slide-up">
|
||||
<div v-if="showKeypad" class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-end">
|
||||
<div class="bg-white dark:bg-slate-800 w-full rounded-t-2xl p-4 safe-area-bottom">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<button @click="clearQuantity" class="px-4 py-2 text-red-500 font-medium">C</button>
|
||||
<div class="text-2xl font-bold text-slate-800 dark:text-white">{{ quantity }}</div>
|
||||
<button @click="showKeypad = false" class="px-4 py-2 text-primary font-medium">Fertig</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<button v-for="d in ['1','2','3','4','5','6','7','8','9','.','0']" :key="d" @click="appendDigit(d)" class="py-4 text-xl font-bold bg-slate-100 dark:bg-slate-700 rounded-lg active:bg-slate-200 dark:active:bg-slate-600 text-slate-800 dark:text-white">
|
||||
{{ d }}
|
||||
</button>
|
||||
<button @click="deleteDigit" class="py-4 text-xl font-bold bg-slate-100 dark:bg-slate-700 rounded-lg active:bg-slate-200 dark:active:bg-slate-600 text-slate-800 dark:text-white">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2M3 12l6.414 6.414a2 2 0 001.414.586H19a2 2 0 002-2V7a2 2 0 00-2-2h-8.172a2 2 0 00-1.414.586L3 12z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -1,266 +0,0 @@
|
||||
/**
|
||||
* StocktakeList Component
|
||||
*
|
||||
* Displays a list of active stocktakes that the user can participate in.
|
||||
* Includes settings menu with theme toggle and logout.
|
||||
*/
|
||||
|
||||
import { api } from '/mobile/shared/auth.js';
|
||||
|
||||
export default {
|
||||
name: 'StocktakeList',
|
||||
emits: ['select', 'logout', 'set-theme'],
|
||||
props: {
|
||||
user: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
theme: {
|
||||
type: String,
|
||||
default: 'system'
|
||||
}
|
||||
},
|
||||
|
||||
setup(props, { emit }) {
|
||||
const { ref, onMounted } = Vue;
|
||||
|
||||
// State
|
||||
const stocktakes = ref([]);
|
||||
const isLoading = ref(true);
|
||||
const error = ref('');
|
||||
const isSettingsOpen = ref(false);
|
||||
|
||||
// Fetch stocktakes
|
||||
const fetchStocktakes = async () => {
|
||||
isLoading.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
const result = await api.get('WarehouseStocktake/getActiveStocktakes');
|
||||
|
||||
if (result.success) {
|
||||
stocktakes.value = result.stocktakes;
|
||||
} else {
|
||||
error.value = result.error || 'Fehler beim Laden';
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = 'Netzwerkfehler';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const selectStocktake = (stocktake) => {
|
||||
emit('select', stocktake);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
isSettingsOpen.value = false;
|
||||
emit('logout');
|
||||
};
|
||||
|
||||
const setTheme = (newTheme) => {
|
||||
emit('set-theme', newTheme);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchStocktakes();
|
||||
});
|
||||
|
||||
return {
|
||||
stocktakes,
|
||||
isLoading,
|
||||
error,
|
||||
isSettingsOpen,
|
||||
fetchStocktakes,
|
||||
selectStocktake,
|
||||
handleLogout,
|
||||
setTheme
|
||||
};
|
||||
},
|
||||
|
||||
template: `
|
||||
<div class="flex flex-col h-full bg-slate-100 dark:bg-slate-900">
|
||||
<!-- Overlay for settings -->
|
||||
<transition name="fade">
|
||||
<div v-if="isSettingsOpen" @click="isSettingsOpen = false" class="overlay"></div>
|
||||
</transition>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="bg-white dark:bg-slate-800 shadow-sm p-4 flex-shrink-0 z-10">
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Refresh Button -->
|
||||
<button
|
||||
@click="fetchStocktakes"
|
||||
class="p-2 rounded-full text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 active:bg-slate-200 dark:active:bg-slate-600 transition"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" :class="{ 'animate-spin': isLoading }" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Logo -->
|
||||
<div>
|
||||
<img src="/assets/images/xinon-full-transparent.png" class="h-8 w-auto dark:hidden" alt="Logo">
|
||||
<img src="/assets/images/xinon-full-transparent-white.png" class="h-8 w-auto hidden dark:block" alt="Logo">
|
||||
</div>
|
||||
|
||||
<!-- Settings Button -->
|
||||
<button
|
||||
@click="isSettingsOpen = true"
|
||||
class="p-2 rounded-full text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 active:bg-slate-200 dark:active:bg-slate-600 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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h1 class="text-xl font-bold text-slate-800 dark:text-white mt-4">
|
||||
Aktive Inventuren
|
||||
</h1>
|
||||
<p v-if="user" class="text-sm text-slate-500 dark:text-slate-400">
|
||||
Angemeldet als {{ user.name }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Content -->
|
||||
<main class="flex-grow overflow-y-auto p-4">
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="space-y-3">
|
||||
<div v-for="i in 3" :key="i" class="bg-white dark:bg-slate-800 p-4 rounded-lg shadow animate-pulse">
|
||||
<div class="h-5 bg-slate-200 dark:bg-slate-700 rounded w-3/4 mb-2"></div>
|
||||
<div class="h-4 bg-slate-200 dark:bg-slate-700 rounded w-1/2 mb-3"></div>
|
||||
<div class="h-3 bg-slate-200 dark:bg-slate-700 rounded w-1/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="text-center py-8">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto text-red-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p class="text-slate-500 dark:text-slate-400 mb-4">{{ error }}</p>
|
||||
<button @click="fetchStocktakes" class="px-4 py-2 bg-primary text-white rounded-lg font-medium">
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="stocktakes.length === 0" class="text-center py-8">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto text-slate-300 dark:text-slate-600 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
<p class="text-slate-500 dark:text-slate-400">Keine aktiven Inventuren</p>
|
||||
</div>
|
||||
|
||||
<!-- Stocktake List -->
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="stocktake in stocktakes"
|
||||
:key="stocktake.id"
|
||||
@click="selectStocktake(stocktake)"
|
||||
class="bg-white dark:bg-slate-800 p-4 rounded-lg shadow-sm cursor-pointer hover:shadow-md active:scale-[0.98] transition"
|
||||
>
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 class="font-bold text-slate-800 dark:text-white">
|
||||
{{ stocktake.title || 'Inventur #' + stocktake.stocktakeNumber }}
|
||||
</h3>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||
{{ stocktake.locationName }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
|
||||
Aktiv
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center mt-3 pt-3 border-t border-slate-100 dark:border-slate-700">
|
||||
<div class="text-sm text-slate-500 dark:text-slate-400">
|
||||
<span class="font-medium text-slate-700 dark:text-slate-300">{{ stocktake.totalScannedItems || 0 }}</span>
|
||||
Artikel gescannt
|
||||
</div>
|
||||
<div class="text-xs text-slate-400 dark:text-slate-500">
|
||||
{{ stocktake.startedAt || 'Nicht gestartet' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Settings Panel -->
|
||||
<transition name="slide">
|
||||
<div v-if="isSettingsOpen" class="fixed inset-y-0 right-0 w-80 max-w-full bg-white dark:bg-slate-800 shadow-xl z-20 flex flex-col">
|
||||
<div class="p-4 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-slate-800 dark:text-white">Einstellungen</h2>
|
||||
<button @click="isSettingsOpen = false" class="p-2 rounded-full hover:bg-slate-100 dark:hover:bg-slate-700">
|
||||
<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>
|
||||
|
||||
<div class="flex-grow overflow-y-auto p-4 space-y-6">
|
||||
<!-- User Info -->
|
||||
<div v-if="user" class="pb-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">Angemeldet als</p>
|
||||
<p class="font-medium text-slate-800 dark:text-white">{{ user.name }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Theme Settings -->
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">Farbschema</h3>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
@click="setTheme('light')"
|
||||
:class="{'ring-2 ring-primary': theme === 'light'}"
|
||||
class="p-2 text-sm font-medium rounded-lg border border-slate-300 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700 transition text-slate-700 dark:text-slate-300"
|
||||
>
|
||||
Hell
|
||||
</button>
|
||||
<button
|
||||
@click="setTheme('dark')"
|
||||
:class="{'ring-2 ring-primary': theme === 'dark'}"
|
||||
class="p-2 text-sm font-medium rounded-lg border border-slate-300 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700 transition text-slate-700 dark:text-slate-300"
|
||||
>
|
||||
Dunkel
|
||||
</button>
|
||||
<button
|
||||
@click="setTheme('system')"
|
||||
:class="{'ring-2 ring-primary': theme === 'system'}"
|
||||
class="p-2 text-sm font-medium rounded-lg border border-slate-300 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700 transition text-slate-700 dark:text-slate-300"
|
||||
>
|
||||
System
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="p-4 border-t border-slate-200 dark:border-slate-700 space-y-4">
|
||||
<button
|
||||
@click="handleLogout"
|
||||
class="w-full flex items-center justify-center px-4 py-3 text-red-600 dark:text-red-400 font-medium rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Abmelden
|
||||
</button>
|
||||
|
||||
<div class="text-center">
|
||||
<img src="/assets/images/xinon-sm.png" class="h-8 mx-auto mb-2" alt="XINON">
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500">
|
||||
powered by XINON GmbH
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"name": "Lager Inventur",
|
||||
"short_name": "Inventur",
|
||||
"description": "PWA für Lager-Inventur und Artikelerfassung",
|
||||
"start_url": "/MobileApp/WarehouseStocktake",
|
||||
"display": "standalone",
|
||||
"background_color": "#0f172a",
|
||||
"theme_color": "#005384",
|
||||
"orientation": "portrait-primary",
|
||||
"scope": "/MobileApp/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/assets/images/xinon-sm-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/assets/images/xinon-sm-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["business", "productivity"],
|
||||
"lang": "de-DE"
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* Warehouse Stocktake PWA - Service Worker
|
||||
*
|
||||
* Provides basic caching for the app shell (offline-first for static assets).
|
||||
* API calls are always fetched from network.
|
||||
*/
|
||||
|
||||
const CACHE_NAME = 'warehouse-stocktake-v1';
|
||||
|
||||
// Static assets to cache for offline use
|
||||
const ASSETS_TO_CACHE = [
|
||||
'/MobileApp/WarehouseStocktake',
|
||||
'/mobile/warehouse-stocktake/app.js',
|
||||
'/mobile/warehouse-stocktake/app.css',
|
||||
'/mobile/warehouse-stocktake/components/LoginScreen.js',
|
||||
'/mobile/warehouse-stocktake/components/StocktakeList.js',
|
||||
'/mobile/warehouse-stocktake/components/Scanner.js',
|
||||
'/mobile/shared/auth.js',
|
||||
'/mobile/shared/base.css',
|
||||
'/assets/images/xinon-full-transparent.png',
|
||||
'/assets/images/xinon-full-transparent-white.png',
|
||||
'/assets/images/xinon-sm.png',
|
||||
'/assets/images/favicon.ico'
|
||||
];
|
||||
|
||||
// Install event - cache static assets
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then(cache => {
|
||||
console.log('[SW] Caching app shell');
|
||||
return cache.addAll(ASSETS_TO_CACHE);
|
||||
})
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
// Activate event - clean up old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then(cacheNames => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter(name => name !== CACHE_NAME)
|
||||
.map(name => {
|
||||
console.log('[SW] Deleting old cache:', name);
|
||||
return caches.delete(name);
|
||||
})
|
||||
);
|
||||
})
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch event - network first for API, cache first for assets
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Skip non-GET requests
|
||||
if (request.method !== 'GET') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip CDN requests (Vue, Tailwind, etc.)
|
||||
if (url.hostname !== self.location.hostname) {
|
||||
return;
|
||||
}
|
||||
|
||||
// API calls - always go to network (no caching)
|
||||
if (url.pathname.startsWith('/MobileApp/') &&
|
||||
!url.pathname.endsWith('/WarehouseStocktake') &&
|
||||
url.pathname !== '/MobileApp/WarehouseStocktake') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Static assets - cache first, fallback to network
|
||||
event.respondWith(
|
||||
caches.match(request)
|
||||
.then(cachedResponse => {
|
||||
if (cachedResponse) {
|
||||
// Return cached version, but also update cache in background
|
||||
event.waitUntil(
|
||||
fetch(request)
|
||||
.then(networkResponse => {
|
||||
if (networkResponse.ok) {
|
||||
caches.open(CACHE_NAME)
|
||||
.then(cache => cache.put(request, networkResponse));
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
);
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
// Not in cache - fetch from network and cache
|
||||
return fetch(request)
|
||||
.then(networkResponse => {
|
||||
if (networkResponse.ok) {
|
||||
const responseToCache = networkResponse.clone();
|
||||
caches.open(CACHE_NAME)
|
||||
.then(cache => cache.put(request, responseToCache));
|
||||
}
|
||||
return networkResponse;
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user