initial commit of mobile app

This commit is contained in:
Luca Haid
2026-01-13 12:44:45 +01:00
parent a513507b8f
commit 51b63acbde
32 changed files with 8025 additions and 0 deletions

View File

@@ -25,6 +25,32 @@ RewriteCond %{REQUEST_FILENAME} !-l
RewriteRule ^api/(v\d+)/([^/]+)(/.+)$ index.php?action=Api&apiv=$1&apicall=$2&apiparams=$3 [QSA]
# MobileApp routing: /MobileApp/{module}/{submodule}/{action}
# Example: /MobileApp/Lager/Inventur/getActiveStocktakes
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-l
RewriteRule ^MobileApp/([^/]+)/([^/]+)/([^/]+)/?$ index.php?action=MobileApp&module=$1&submodule=$2&endpoint=$3 [QSA,L]
# /MobileApp/{module}/{submodule} - e.g., /MobileApp/Lager/Inventur
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-l
RewriteRule ^MobileApp/([^/]+)/([^/]+)/?$ index.php?action=MobileApp&module=$1&submodule=$2 [QSA,L]
# /MobileApp/{module} - e.g., /MobileApp/auth or /MobileApp/Lager
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-l
RewriteRule ^MobileApp/([^/]+)/?$ index.php?action=MobileApp&module=$1 [QSA,L]
# /MobileApp - Main app
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-l
RewriteRule ^MobileApp/?$ index.php?action=MobileApp [QSA,L]
# regular web calls
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d

581
public/mobile/app.js Normal file
View File

@@ -0,0 +1,581 @@
/**
* MobileApp PWA - Main Vue Application
*
* Unified mobile app with module navigation.
* Routes: /MobileApp -> Home, /MobileApp/Lager -> Module, /MobileApp/Lager/Inventur -> Submodule
*/
import { authState, checkAuth, login, logout } from '/mobile/shared/auth.js';
import LoginScreen from '/mobile/components/LoginScreen.js';
import MainMenu from '/mobile/components/MainMenu.js';
import LagerModule from '/mobile/modules/lager/LagerModule.js';
const { createApp, ref, computed, onMounted, onUnmounted, watch } = Vue;
// Check if running as installed PWA
const isPWAInstalled = () => {
// Check display-mode standalone (Android Chrome, desktop)
if (window.matchMedia('(display-mode: standalone)').matches) return true;
// Check iOS Safari standalone mode
if (window.navigator.standalone === true) return true;
// Check if launched from TWA (Trusted Web Activity)
if (document.referrer.includes('android-app://')) return true;
return false;
};
// Check if we should require PWA installation
const shouldRequirePWA = () => {
const hostname = window.location.hostname;
// Only require PWA on production domain
return hostname === 'thetool.xinon.at';
};
// Parse initial path from config
const parseInitialRoute = () => {
const initialPath = window.TT_CONFIG?.INITIAL_PATH || '/MobileApp';
const parts = initialPath.replace('/MobileApp', '').split('/').filter(Boolean);
return {
module: parts[0] || null,
submodule: parts[1] || null
};
};
const App = {
components: {
LoginScreen,
MainMenu,
LagerModule
},
setup() {
// ==================== STATE ====================
const currentView = ref('loading');
const user = ref(null);
const toast = ref({ show: false, message: '', type: 'success' });
const theme = ref('system');
const showSettings = ref(false);
// Module-specific settings
const lagerSimpleMode = ref(false);
// Navigation state
const currentModule = ref(null);
const currentSubmodule = ref(null);
// PWA Install state
const showInstallPrompt = ref(false);
const deferredInstallPrompt = ref(null);
const isIOS = ref(/iPad|iPhone|iPod/.test(navigator.userAgent));
const isAndroid = ref(/Android/.test(navigator.userAgent));
// Can go back?
const canGoBack = computed(() => currentModule.value !== null);
// ==================== 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();
};
// ==================== PWA INSTALL ====================
const handleInstallPrompt = (e) => {
// Prevent Chrome's default install prompt
e.preventDefault();
// Store the event for later use
deferredInstallPrompt.value = e;
};
const triggerInstall = async () => {
if (!deferredInstallPrompt.value) return;
// Show the install prompt
deferredInstallPrompt.value.prompt();
// Wait for user response
const { outcome } = await deferredInstallPrompt.value.userChoice;
if (outcome === 'accepted') {
showInstallPrompt.value = false;
// Reload to get standalone mode
window.location.reload();
}
deferredInstallPrompt.value = null;
};
// ==================== LAGER SETTINGS ====================
const loadLagerSettings = () => {
try {
const saved = localStorage.getItem('movement_settings');
if (saved) {
const settings = JSON.parse(saved);
lagerSimpleMode.value = settings.simpleMode || false;
}
} catch (e) {}
};
const setLagerSimpleMode = (value) => {
lagerSimpleMode.value = value;
try {
const saved = localStorage.getItem('movement_settings');
const settings = saved ? JSON.parse(saved) : {};
settings.simpleMode = value;
localStorage.setItem('movement_settings', JSON.stringify(settings));
} catch (e) {}
};
// ==================== NAVIGATION ====================
const navigate = (module, submodule = null) => {
currentModule.value = module;
currentSubmodule.value = submodule;
// Update browser URL
let path = '/MobileApp';
if (module) path += '/' + module;
if (submodule) path += '/' + submodule;
history.pushState({ module, submodule }, '', path);
};
const goHome = () => {
navigate(null, null);
};
const goBack = () => {
if (currentSubmodule.value) {
navigate(currentModule.value, null);
} else if (currentModule.value) {
navigate(null, null);
}
};
// Handle browser back button
window.addEventListener('popstate', (event) => {
if (event.state) {
currentModule.value = event.state.module;
currentSubmodule.value = event.state.submodule;
} else {
currentModule.value = null;
currentSubmodule.value = null;
}
});
// ==================== AUTH ====================
const handleLogin = async (credentials) => {
// Handle 2FA success (already verified in LoginScreen)
if (credentials._2faSuccess) {
user.value = credentials.user;
currentView.value = 'app';
showToast('Erfolgreich angemeldet', 'success');
return { success: true };
}
const result = await login(credentials);
if (result.success) {
user.value = result.user;
currentView.value = 'app';
showToast('Erfolgreich angemeldet', 'success');
}
return result;
};
const handleLogout = async () => {
await logout();
user.value = null;
currentModule.value = null;
currentSubmodule.value = null;
currentView.value = 'login';
showToast('Abgemeldet', 'success');
};
// ==================== TOAST ====================
const showToast = (message, type = 'success') => {
toast.value = { show: true, message, type };
setTimeout(() => {
toast.value.show = false;
}, 3000);
};
// ==================== COMPUTED ====================
const currentComponent = computed(() => {
if (currentView.value !== 'app') return null;
if (!currentModule.value) return 'MainMenu';
if (currentModule.value.toLowerCase() === 'lager') return 'LagerModule';
return 'MainMenu';
});
const breadcrumbs = computed(() => {
const crumbs = [{ label: 'Home', module: null, submodule: null }];
if (currentModule.value) {
crumbs.push({ label: currentModule.value, module: currentModule.value, submodule: null });
}
if (currentSubmodule.value) {
crumbs.push({ label: currentSubmodule.value, module: currentModule.value, submodule: currentSubmodule.value });
}
return crumbs;
});
// ==================== 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);
// Load module settings
loadLagerSettings();
// Listen for beforeinstallprompt (Android)
window.addEventListener('beforeinstallprompt', handleInstallPrompt);
// Check if PWA is required but not installed
if (shouldRequirePWA() && !isPWAInstalled()) {
showInstallPrompt.value = true;
currentView.value = 'install';
return;
}
// Check authentication
const result = await checkAuth();
if (result.authenticated) {
user.value = result.user;
currentView.value = 'app';
// Parse initial route
const initialRoute = parseInitialRoute();
currentModule.value = initialRoute.module;
currentSubmodule.value = initialRoute.submodule;
// Set initial history state
history.replaceState(
{ module: initialRoute.module, submodule: initialRoute.submodule },
'',
window.location.pathname
);
} else {
currentView.value = 'login';
}
});
return {
currentView,
user,
toast,
theme,
showSettings,
currentModule,
currentSubmodule,
currentComponent,
canGoBack,
breadcrumbs,
handleLogin,
handleLogout,
navigate,
goHome,
goBack,
showToast,
setTheme,
lagerSimpleMode,
setLagerSimpleMode,
// PWA Install
showInstallPrompt,
deferredInstallPrompt,
isIOS,
isAndroid,
triggerInstall,
};
},
template: `
<div class="relative h-full w-full bg-slate-50 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>
<!-- PWA Install Prompt -->
<div v-else-if="currentView === 'install'" class="min-h-screen flex items-center justify-center p-4 relative overflow-hidden">
<!-- Network Background (same as login) -->
<div class="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
<div class="absolute inset-0 opacity-40" style="background-image: linear-gradient(to right, rgba(0, 83, 132, 0.15) 1px, transparent 1px), linear-gradient(to bottom, rgba(0, 83, 132, 0.15) 1px, transparent 1px); background-size: 50px 50px;"></div>
<div class="absolute w-3 h-3 bg-cyan-400 rounded-full network-node" style="top: 12%; left: 18%; box-shadow: 0 0 25px 8px rgba(34, 211, 238, 0.6);"></div>
<div class="absolute w-2.5 h-2.5 bg-blue-400 rounded-full network-node" style="top: 22%; left: 78%; animation-delay: 0.5s; box-shadow: 0 0 20px 6px rgba(96, 165, 250, 0.6);"></div>
<div class="absolute w-3 h-3 bg-cyan-300 rounded-full network-node-slow" style="top: 72%; left: 12%; animation-delay: 1s; box-shadow: 0 0 25px 8px rgba(103, 232, 249, 0.6);"></div>
<div class="absolute w-2.5 h-2.5 bg-blue-300 rounded-full network-node" style="top: 82%; left: 85%; animation-delay: 0.3s; box-shadow: 0 0 20px 6px rgba(147, 197, 253, 0.6);"></div>
</div>
<!-- Install Card -->
<div class="relative bg-white/95 dark:bg-slate-800/95 backdrop-blur-sm rounded-2xl shadow-2xl p-6 w-full max-w-sm border border-white/20 dark:border-slate-700/50">
<div class="mb-6">
<img src="/assets/images/xinon-full-transparent.png" class="h-14 mx-auto dark:hidden" alt="Logo">
<img src="/assets/images/xinon-full-transparent-white.png" class="h-14 mx-auto hidden dark:block" alt="Logo">
</div>
<div class="text-center mb-6">
<div class="w-16 h-16 bg-primary/10 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
<h2 class="text-xl font-bold text-slate-800 dark:text-white mb-2">App installieren</h2>
<p class="text-sm text-slate-500 dark:text-slate-400">
Für die beste Erfahrung installiere die App auf deinem Gerät.
</p>
</div>
<!-- Android Install Button -->
<div v-if="isAndroid && deferredInstallPrompt">
<button
@click="triggerInstall"
class="w-full py-3 px-4 bg-primary text-white font-bold rounded-xl hover:bg-primary/90 focus:ring-4 focus:ring-primary/30 transition flex items-center justify-center gap-2"
>
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
App installieren
</button>
</div>
<!-- iOS Instructions -->
<div v-else-if="isIOS" class="space-y-4">
<div class="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-4">
<p class="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">So installierst du die App:</p>
<ol class="text-sm text-slate-600 dark:text-slate-400 space-y-3">
<li class="flex items-start gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 text-primary rounded-full flex items-center justify-center text-xs font-bold">1</span>
<span>Tippe auf das <strong>Teilen</strong>-Symbol
<svg xmlns="http://www.w3.org/2000/svg" class="inline h-4 w-4 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
</span>
</li>
<li class="flex items-start gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 text-primary rounded-full flex items-center justify-center text-xs font-bold">2</span>
<span>Scrolle und wähle <strong>"Zum Home-Bildschirm"</strong></span>
</li>
<li class="flex items-start gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 text-primary rounded-full flex items-center justify-center text-xs font-bold">3</span>
<span>Tippe auf <strong>"Hinzufügen"</strong></span>
</li>
</ol>
</div>
</div>
<!-- Android Manual Instructions (fallback) -->
<div v-else-if="isAndroid" class="space-y-4">
<div class="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-4">
<p class="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">So installierst du die App:</p>
<ol class="text-sm text-slate-600 dark:text-slate-400 space-y-3">
<li class="flex items-start gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 text-primary rounded-full flex items-center justify-center text-xs font-bold">1</span>
<span>Tippe auf das <strong>Menü</strong> (⋮) oben rechts</span>
</li>
<li class="flex items-start gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 text-primary rounded-full flex items-center justify-center text-xs font-bold">2</span>
<span>Wähle <strong>"App installieren"</strong> oder <strong>"Zum Startbildschirm hinzufügen"</strong></span>
</li>
<li class="flex items-start gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 text-primary rounded-full flex items-center justify-center text-xs font-bold">3</span>
<span>Bestätige mit <strong>"Installieren"</strong></span>
</li>
</ol>
</div>
</div>
<!-- Desktop / Unknown -->
<div v-else class="space-y-4">
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-4">
<p class="text-sm text-amber-700 dark:text-amber-400">
<strong>Hinweis:</strong> Diese App ist für mobile Geräte optimiert. Bitte öffne diese Seite auf deinem Smartphone und installiere die App.
</p>
</div>
</div>
<div class="mt-4 pt-4 border-t border-slate-100 dark:border-slate-700 text-center">
<p class="text-xs text-slate-400 dark:text-slate-500">
powered by <span class="font-semibold">XINON</span>
</p>
</div>
</div>
</div>
<!-- Login Screen -->
<LoginScreen
v-else-if="currentView === 'login'"
@login="handleLogin"
:theme="theme"
@set-theme="setTheme"
/>
<!-- Main App -->
<template v-else-if="currentView === 'app'">
<div class="h-full flex flex-col">
<!-- Persistent Header -->
<header class="bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 px-2 py-2 flex items-center safe-area-top flex-shrink-0 z-10">
<!-- Left: Back Button -->
<button
@click="goBack"
:class="[
'w-10 h-10 flex items-center justify-center rounded-full transition',
canGoBack
? 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700'
: 'text-transparent pointer-events-none'
]"
>
<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="M15 19l-7-7 7-7" />
</svg>
</button>
<!-- Center: Logo -->
<div class="flex-1 flex justify-center">
<img src="/assets/images/xinon-full-transparent.png" class="h-7 dark:hidden" alt="Logo">
<img src="/assets/images/xinon-full-transparent-white.png" class="h-7 hidden dark:block" alt="Logo">
</div>
<!-- Right: Settings -->
<button
@click="showSettings = true"
class="w-10 h-10 flex items-center justify-center rounded-full text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 transition"
>
<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="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>
</header>
<!-- Content Area -->
<main class="flex-1 overflow-y-auto">
<MainMenu
v-if="!currentModule"
:user="user"
@navigate="navigate"
/>
<LagerModule
v-else-if="currentModule?.toLowerCase() === 'lager'"
:user="user"
:submodule="currentSubmodule"
:simple-mode="lagerSimpleMode"
@navigate="navigate"
@toast="showToast"
/>
</main>
</div>
<!-- Settings Panel -->
<transition name="slide-right">
<div v-if="showSettings" class="fixed inset-0 z-50">
<div class="absolute inset-0 bg-black/40" @click="showSettings = false"></div>
<div class="absolute right-0 top-0 bottom-0 w-72 bg-white dark:bg-slate-800 shadow-xl flex flex-col">
<div class="safe-area-top border-b border-slate-200 dark:border-slate-700 px-4 py-3 flex items-center justify-between">
<h2 class="font-semibold text-slate-800 dark:text-white">Einstellungen</h2>
<button @click="showSettings = false" class="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-full text-slate-500">
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex-1 overflow-y-auto">
<!-- User Info -->
<div class="px-4 py-3 border-b border-slate-100 dark:border-slate-700">
<p class="font-medium text-slate-800 dark:text-white">{{ user?.name }}</p>
<p class="text-sm text-slate-500 dark:text-slate-400">{{ user?.username }}</p>
</div>
<!-- Theme Selection -->
<div class="px-4 py-3">
<p class="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-2">Farbschema</p>
<div class="flex space-x-2">
<button
@click="setTheme('light')"
:class="[theme === 'light' ? 'ring-2 ring-primary bg-primary/5' : 'bg-slate-100 dark:bg-slate-700', 'flex-1 py-2 rounded-lg text-sm font-medium text-slate-700 dark:text-slate-300']"
>Hell</button>
<button
@click="setTheme('dark')"
:class="[theme === 'dark' ? 'ring-2 ring-primary bg-primary/5' : 'bg-slate-100 dark:bg-slate-700', 'flex-1 py-2 rounded-lg text-sm font-medium text-slate-700 dark:text-slate-300']"
>Dunkel</button>
<button
@click="setTheme('system')"
:class="[theme === 'system' ? 'ring-2 ring-primary bg-primary/5' : 'bg-slate-100 dark:bg-slate-700', 'flex-1 py-2 rounded-lg text-sm font-medium text-slate-700 dark:text-slate-300']"
>Auto</button>
</div>
</div>
<!-- Lager Settings -->
<div class="px-4 py-3 border-t border-slate-100 dark:border-slate-700">
<p class="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-3">Lager</p>
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-slate-800 dark:text-white text-sm">Simpel Modus</p>
<p class="text-xs text-slate-500 dark:text-slate-400">Weniger Optionen</p>
</div>
<button
@click="setLagerSimpleMode(!lagerSimpleMode)"
:class="[
'relative w-11 h-6 rounded-full transition-colors',
lagerSimpleMode ? 'bg-green-500' : 'bg-slate-300 dark:bg-slate-600'
]"
>
<span :class="[
'absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform',
lagerSimpleMode ? 'left-5' : 'left-0.5'
]"></span>
</button>
</div>
</div>
</div>
<!-- Logout at bottom -->
<div class="p-3 border-t border-slate-100 dark:border-slate-700">
<button
@click="showSettings = false; handleLogout()"
class="w-full py-2.5 px-4 text-red-600 dark:text-red-400 font-medium rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition flex items-center justify-center"
>
<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>
</div>
</div>
</transition>
</template>
<!-- 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>
`
};
createApp(App).mount('#app');

View File

@@ -0,0 +1,639 @@
/**
* LoginScreen Component
*
* Displays the login form for the PWA with 2FA support.
* Features:
* - Username/password authentication
* - 2FA verification with OTP auto-detection (Web OTP API for Android, autocomplete for iOS)
* - Remember me option
*/
import { login, verify2FA, resend2FA } from '/mobile/shared/auth.js';
export default {
name: 'LoginScreen',
emits: ['login', 'set-theme'],
props: {
theme: {
type: String,
default: 'system'
}
},
setup(props, { emit }) {
const { ref, onMounted, onUnmounted, nextTick } = Vue;
// Login form state
const username = ref('');
const password = ref('');
const rememberMe = ref(true);
const showPassword = ref(false);
// 2FA state
const show2FA = ref(false);
const otpCode = ref('');
const otpDigits = ref(['', '', '', '', '']);
const deliveryMethod = ref('');
const maskedTarget = ref('');
const resendCooldown = ref(0);
// General state
const error = ref('');
const success = ref('');
const loading = ref(false);
const showThemePicker = ref(!localStorage.getItem('theme'));
// OTP input refs
let otpInputRefs = [];
let otpAbortController = null;
let resendTimer = null;
// Handle login form submission
const handleSubmit = async () => {
if (!username.value || !password.value) {
error.value = 'Bitte Benutzername und Passwort eingeben';
return;
}
loading.value = true;
error.value = '';
try {
// Call login API directly
const result = await login({
username: username.value,
password: password.value,
rememberMe: rememberMe.value
});
if (result.requires2FA) {
// Show 2FA verification screen
show2FA.value = true;
deliveryMethod.value = result.deliveryMethod;
maskedTarget.value = result.maskedTarget;
success.value = result.message;
error.value = '';
// Start resend cooldown
startResendCooldown();
// Focus first OTP input after render
await nextTick();
focusOtpInput(0);
// Try Web OTP API for SMS
if (result.deliveryMethod === 'sms') {
startWebOTP();
}
} else if (result.success) {
// Direct login success (no 2FA) - notify parent
emit('login', { _2faSuccess: true, user: result.user });
} else {
error.value = result.message || 'Login fehlgeschlagen';
}
} catch (e) {
error.value = 'Ein Fehler ist aufgetreten';
} finally {
loading.value = false;
}
};
// Handle 2FA verification
const handleVerify2FA = async () => {
const code = otpDigits.value.join('');
if (code.length !== 5) {
error.value = 'Bitte gib den 5-stelligen Code ein';
return;
}
loading.value = true;
error.value = '';
success.value = '';
try {
const result = await verify2FA(code);
if (result.success) {
// Emit the successful result to parent (which handles navigation)
emit('login', { _2faSuccess: true, user: result.user });
} else {
error.value = result.message || 'Ungültiger Code';
if (result.expired || result.codeExpired) {
// Session or code expired - go back to login
resetTo2FA();
}
}
} catch (e) {
error.value = 'Ein Fehler ist aufgetreten';
} finally {
loading.value = false;
}
};
// Handle resend 2FA code
const handleResend = async () => {
if (resendCooldown.value > 0) return;
loading.value = true;
error.value = '';
try {
const result = await resend2FA();
if (result.success) {
success.value = result.message || 'Neuer Code wurde gesendet';
startResendCooldown();
// Clear OTP inputs
otpDigits.value = ['', '', '', '', ''];
focusOtpInput(0);
// Restart Web OTP if SMS
if (deliveryMethod.value === 'sms') {
startWebOTP();
}
} else {
error.value = result.message || 'Code konnte nicht gesendet werden';
if (result.expired) {
resetTo2FA();
}
}
} catch (e) {
error.value = 'Ein Fehler ist aufgetreten';
} finally {
loading.value = false;
}
};
// Go back to login form
const backToLogin = () => {
show2FA.value = false;
otpDigits.value = ['', '', '', '', ''];
error.value = '';
success.value = '';
abortWebOTP();
};
// Reset after session expired
const resetTo2FA = () => {
show2FA.value = false;
password.value = '';
otpDigits.value = ['', '', '', '', ''];
error.value = 'Sitzung abgelaufen. Bitte erneut anmelden.';
};
// Start resend cooldown (30 seconds)
const startResendCooldown = () => {
resendCooldown.value = 30;
if (resendTimer) clearInterval(resendTimer);
resendTimer = setInterval(() => {
resendCooldown.value--;
if (resendCooldown.value <= 0) {
clearInterval(resendTimer);
}
}, 1000);
};
// OTP input handlers
const focusOtpInput = (index) => {
const inputs = document.querySelectorAll('.otp-input');
if (inputs[index]) {
inputs[index].focus();
}
};
const handleOtpInput = (index, event) => {
const value = event.target.value;
// Only allow digits
if (!/^\d*$/.test(value)) {
event.target.value = otpDigits.value[index];
return;
}
// Handle paste of full code
if (value.length > 1) {
const digits = value.replace(/\D/g, '').slice(0, 5).split('');
digits.forEach((digit, i) => {
if (i < 5) otpDigits.value[i] = digit;
});
focusOtpInput(Math.min(digits.length, 4));
// Auto-submit if complete
if (otpDigits.value.join('').length === 5) {
handleVerify2FA();
}
return;
}
otpDigits.value[index] = value;
// Move to next input
if (value && index < 4) {
focusOtpInput(index + 1);
}
// Auto-submit when complete
if (otpDigits.value.join('').length === 5) {
handleVerify2FA();
}
};
const handleOtpKeydown = (index, event) => {
// Handle backspace
if (event.key === 'Backspace' && !otpDigits.value[index] && index > 0) {
focusOtpInput(index - 1);
}
};
const handleOtpPaste = (event) => {
event.preventDefault();
const pastedData = event.clipboardData.getData('text');
const digits = pastedData.replace(/\D/g, '').slice(0, 5).split('');
digits.forEach((digit, i) => {
if (i < 5) otpDigits.value[i] = digit;
});
focusOtpInput(Math.min(digits.length, 4));
// Auto-submit if complete
if (otpDigits.value.join('').length === 5) {
handleVerify2FA();
}
};
// Web OTP API for automatic SMS code detection (Android)
const startWebOTP = async () => {
if (!('OTPCredential' in window)) {
console.log('Web OTP API not supported');
return;
}
abortWebOTP();
otpAbortController = new AbortController();
try {
const otp = await navigator.credentials.get({
otp: { transport: ['sms'] },
signal: otpAbortController.signal
});
if (otp && otp.code) {
// Extract 5-digit code from SMS
const code = otp.code.replace(/\D/g, '').slice(0, 5);
if (code.length === 5) {
code.split('').forEach((digit, i) => {
otpDigits.value[i] = digit;
});
// Auto-submit
handleVerify2FA();
}
}
} catch (err) {
if (err.name !== 'AbortError') {
console.log('Web OTP error:', err);
}
}
};
const abortWebOTP = () => {
if (otpAbortController) {
otpAbortController.abort();
otpAbortController = null;
}
};
// Theme picker
const selectTheme = (newTheme) => {
emit('set-theme', newTheme);
showThemePicker.value = false;
};
// Cleanup
onUnmounted(() => {
abortWebOTP();
if (resendTimer) clearInterval(resendTimer);
});
return {
// Login state
username,
password,
rememberMe,
showPassword,
// 2FA state
show2FA,
otpDigits,
deliveryMethod,
maskedTarget,
resendCooldown,
// General state
error,
success,
loading,
showThemePicker,
// Methods
handleSubmit,
handleVerify2FA,
handleResend,
backToLogin,
handleOtpInput,
handleOtpKeydown,
handleOtpPaste,
selectTheme
};
},
template: `
<div class="min-h-screen flex items-center justify-center p-4 relative overflow-hidden">
<!-- Animated Network Background -->
<div class="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
<!-- Fiber grid pattern -->
<div class="absolute inset-0 opacity-40" style="background-image:
linear-gradient(to right, rgba(0, 83, 132, 0.15) 1px, transparent 1px),
linear-gradient(to bottom, rgba(0, 83, 132, 0.15) 1px, transparent 1px);
background-size: 50px 50px;"></div>
<!-- Glowing nodes with enhanced animation -->
<div class="absolute w-3 h-3 bg-cyan-400 rounded-full network-node" style="top: 12%; left: 18%; box-shadow: 0 0 25px 8px rgba(34, 211, 238, 0.6);"></div>
<div class="absolute w-2.5 h-2.5 bg-blue-400 rounded-full network-node" style="top: 22%; left: 78%; animation-delay: 0.5s; box-shadow: 0 0 20px 6px rgba(96, 165, 250, 0.6);"></div>
<div class="absolute w-3 h-3 bg-cyan-300 rounded-full network-node-slow" style="top: 72%; left: 12%; animation-delay: 1s; box-shadow: 0 0 25px 8px rgba(103, 232, 249, 0.6);"></div>
<div class="absolute w-2.5 h-2.5 bg-blue-300 rounded-full network-node" style="top: 82%; left: 85%; animation-delay: 0.3s; box-shadow: 0 0 20px 6px rgba(147, 197, 253, 0.6);"></div>
<div class="absolute w-2 h-2 bg-cyan-400 rounded-full network-node-slow" style="top: 42%; left: 8%; animation-delay: 0.7s; box-shadow: 0 0 15px 5px rgba(34, 211, 238, 0.5);"></div>
<div class="absolute w-2.5 h-2.5 bg-blue-400 rounded-full network-node" style="top: 32%; left: 92%; animation-delay: 1.2s; box-shadow: 0 0 20px 6px rgba(96, 165, 250, 0.6);"></div>
<div class="absolute w-2 h-2 bg-cyan-300 rounded-full network-node" style="top: 58%; left: 88%; animation-delay: 0.9s; box-shadow: 0 0 15px 5px rgba(103, 232, 249, 0.5);"></div>
<div class="absolute w-2 h-2 bg-blue-300 rounded-full network-node-slow" style="top: 88%; left: 45%; animation-delay: 0.4s; box-shadow: 0 0 15px 5px rgba(147, 197, 253, 0.5);"></div>
<div class="absolute w-1.5 h-1.5 bg-cyan-400 rounded-full network-node" style="top: 5%; left: 55%; animation-delay: 1.5s; box-shadow: 0 0 12px 4px rgba(34, 211, 238, 0.5);"></div>
<div class="absolute w-1.5 h-1.5 bg-blue-400 rounded-full network-node-slow" style="top: 95%; left: 25%; animation-delay: 0.8s; box-shadow: 0 0 12px 4px rgba(96, 165, 250, 0.5);"></div>
<!-- Connection lines (SVG) with animations -->
<svg class="absolute inset-0 w-full h-full network-lines" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none">
<defs>
<linearGradient id="lineGrad1" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:rgb(34, 211, 238);stop-opacity:0" />
<stop offset="50%" style="stop-color:rgb(34, 211, 238);stop-opacity:1" />
<stop offset="100%" style="stop-color:rgb(34, 211, 238);stop-opacity:0" />
</linearGradient>
<linearGradient id="lineGrad2" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:rgb(96, 165, 250);stop-opacity:0" />
<stop offset="50%" style="stop-color:rgb(96, 165, 250);stop-opacity:1" />
<stop offset="100%" style="stop-color:rgb(96, 165, 250);stop-opacity:0" />
</linearGradient>
<linearGradient id="lineGrad3" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:rgb(103, 232, 249);stop-opacity:0" />
<stop offset="50%" style="stop-color:rgb(103, 232, 249);stop-opacity:1" />
<stop offset="100%" style="stop-color:rgb(103, 232, 249);stop-opacity:0" />
</linearGradient>
</defs>
<!-- Main network connections -->
<line x1="18%" y1="12%" x2="78%" y2="22%" stroke="url(#lineGrad1)" stroke-width="1.5"/>
<line x1="78%" y1="22%" x2="85%" y2="82%" stroke="url(#lineGrad2)" stroke-width="1.5"/>
<line x1="18%" y1="12%" x2="12%" y2="72%" stroke="url(#lineGrad1)" stroke-width="1.5"/>
<line x1="12%" y1="72%" x2="85%" y2="82%" stroke="url(#lineGrad2)" stroke-width="1.5"/>
<line x1="8%" y1="42%" x2="18%" y2="12%" stroke="url(#lineGrad3)" stroke-width="1.5"/>
<line x1="92%" y1="32%" x2="78%" y2="22%" stroke="url(#lineGrad2)" stroke-width="1.5"/>
<!-- Additional connections -->
<line x1="12%" y1="72%" x2="45%" y2="88%" stroke="url(#lineGrad1)" stroke-width="1.5"/>
<line x1="45%" y1="88%" x2="85%" y2="82%" stroke="url(#lineGrad3)" stroke-width="1.5"/>
<line x1="88%" y1="58%" x2="92%" y2="32%" stroke="url(#lineGrad2)" stroke-width="1.5"/>
<line x1="88%" y1="58%" x2="85%" y2="82%" stroke="url(#lineGrad1)" stroke-width="1.5"/>
<line x1="8%" y1="42%" x2="12%" y2="72%" stroke="url(#lineGrad3)" stroke-width="1.5"/>
<line x1="55%" y1="5%" x2="78%" y2="22%" stroke="url(#lineGrad1)" stroke-width="1.5"/>
<line x1="55%" y1="5%" x2="18%" y2="12%" stroke="url(#lineGrad2)" stroke-width="1.5"/>
<line x1="25%" y1="95%" x2="45%" y2="88%" stroke="url(#lineGrad3)" stroke-width="1.5"/>
<line x1="25%" y1="95%" x2="12%" y2="72%" stroke="url(#lineGrad1)" stroke-width="1.5"/>
<!-- Cross connections -->
<line x1="18%" y1="12%" x2="88%" y2="58%" stroke="url(#lineGrad2)" stroke-width="1"/>
<line x1="8%" y1="42%" x2="78%" y2="22%" stroke="url(#lineGrad3)" stroke-width="1"/>
<line x1="12%" y1="72%" x2="92%" y2="32%" stroke="url(#lineGrad1)" stroke-width="1"/>
</svg>
<!-- Flowing data lines overlay -->
<svg class="absolute inset-0 w-full h-full opacity-50" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none">
<line x1="18%" y1="12%" x2="78%" y2="22%" stroke="rgb(34, 211, 238)" stroke-width="2" class="network-line-flow"/>
<line x1="12%" y1="72%" x2="85%" y2="82%" stroke="rgb(96, 165, 250)" stroke-width="2" class="network-line-flow" style="animation-delay: 2s;"/>
<line x1="8%" y1="42%" x2="18%" y2="12%" stroke="rgb(103, 232, 249)" stroke-width="2" class="network-line-flow" style="animation-delay: 4s;"/>
<line x1="55%" y1="5%" x2="78%" y2="22%" stroke="rgb(34, 211, 238)" stroke-width="2" class="network-line-flow" style="animation-delay: 1s;"/>
<line x1="45%" y1="88%" x2="85%" y2="82%" stroke="rgb(96, 165, 250)" stroke-width="2" class="network-line-flow" style="animation-delay: 3s;"/>
</svg>
<!-- Subtle radial glow -->
<div class="absolute inset-0" style="background: radial-gradient(ellipse at 30% 20%, rgba(0, 83, 132, 0.2) 0%, transparent 50%);"></div>
<div class="absolute inset-0" style="background: radial-gradient(ellipse at 70% 80%, rgba(34, 211, 238, 0.15) 0%, transparent 40%);"></div>
<div class="absolute inset-0" style="background: radial-gradient(ellipse at 90% 40%, rgba(96, 165, 250, 0.1) 0%, transparent 35%);"></div>
</div>
<!-- Theme Picker Modal -->
<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-2xl p-6 w-full max-w-xs text-center shadow-2xl border border-slate-200 dark:border-slate-700">
<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-100 text-slate-800 font-semibold rounded-xl hover:bg-slate-200 transition flex items-center justify-center gap-2">
<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="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
Hell
</button>
<button @click="selectTheme('dark')" class="w-full px-4 py-3 bg-slate-700 text-white font-semibold rounded-xl hover:bg-slate-600 transition flex items-center justify-center gap-2">
<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="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
Dunkel
</button>
<button @click="selectTheme('system')" class="w-full mt-1 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300 transition">
Automatisch (System)
</button>
</div>
</div>
</div>
</transition>
<!-- Login/2FA Form Container -->
<div class="relative bg-white/95 dark:bg-slate-800/95 backdrop-blur-sm rounded-2xl shadow-2xl p-6 w-full max-w-sm border border-white/20 dark:border-slate-700/50">
<div class="mb-5">
<img src="/assets/images/xinon-full-transparent.png" class="h-14 mx-auto dark:hidden" alt="Logo">
<img src="/assets/images/xinon-full-transparent-white.png" class="h-14 mx-auto hidden dark:block" alt="Logo">
</div>
<!-- 2FA Verification Screen -->
<template v-if="show2FA">
<div class="text-center mb-6">
<div class="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4">
<svg v-if="deliveryMethod === 'sms'" xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<h2 class="text-xl font-bold text-slate-800 dark:text-white">
Verifizierung
</h2>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-2">
Code wurde gesendet an<br>
<span class="font-medium text-slate-700 dark:text-slate-300">{{ maskedTarget }}</span>
</p>
</div>
<!-- OTP Input -->
<div class="mb-6">
<div class="flex justify-center space-x-2">
<input
v-for="(digit, index) in otpDigits"
:key="index"
type="text"
inputmode="numeric"
:autocomplete="index === 0 ? 'one-time-code' : 'off'"
maxlength="5"
class="otp-input w-12 h-14 text-center text-2xl font-bold border-2 border-slate-300 rounded-lg focus:border-primary focus:ring-2 focus:ring-primary/30 transition dark:bg-slate-700 dark:border-slate-600 dark:text-white"
:value="digit"
@input="handleOtpInput(index, $event)"
@keydown="handleOtpKeydown(index, $event)"
@paste="handleOtpPaste"
>
</div>
<p class="text-xs text-slate-400 dark:text-slate-500 text-center mt-3">
Code ist 5 Minuten gültig
</p>
</div>
<!-- Success Message -->
<div v-if="success" class="mb-4 p-3 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg">
<p class="text-sm text-green-600 dark:text-green-400">{{ success }}</p>
</div>
<!-- Error Message -->
<div v-if="error" class="mb-4 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>
<!-- Verify Button -->
<button
@click="handleVerify2FA"
:disabled="loading || otpDigits.join('').length !== 5"
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 verifiziert...' : 'Verifizieren' }}
</button>
<!-- Resend and Back buttons -->
<div class="mt-4 flex flex-col items-center space-y-3">
<button
@click="handleResend"
:disabled="resendCooldown > 0 || loading"
class="text-sm text-primary hover:underline disabled:text-slate-400 disabled:no-underline"
>
{{ resendCooldown > 0 ? 'Neuer Code in ' + resendCooldown + 's' : 'Neuen Code senden' }}
</button>
<button
@click="backToLogin"
class="text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300"
>
Zurück zur Anmeldung
</button>
</div>
</template>
<!-- Login Form -->
<template v-else>
<form @submit.prevent="handleSubmit" class="space-y-4">
<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-xl 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>
<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-xl 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>
<!-- Beautiful Toggle Switch -->
<div class="flex items-center justify-between py-1">
<span class="text-sm text-slate-600 dark:text-slate-300">Angemeldet bleiben</span>
<button
type="button"
@click="rememberMe = !rememberMe"
:class="[
'relative w-11 h-6 rounded-full transition-colors duration-200',
rememberMe ? 'bg-primary' : 'bg-slate-300 dark:bg-slate-600'
]"
>
<span :class="[
'absolute top-0.5 w-5 h-5 bg-white rounded-full shadow-md transition-transform duration-200',
rememberMe ? 'left-5' : 'left-0.5'
]"></span>
</button>
</div>
<div v-if="error" class="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-xl">
<p class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
</div>
<button
type="submit"
:disabled="loading"
class="w-full py-3 px-4 bg-primary text-white font-bold rounded-xl 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>
</template>
<div class="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700 text-center">
<p class="text-xs text-slate-400 dark:text-slate-500">
powered by <span class="font-semibold">XINON</span>
</p>
</div>
</div>
</div>
`
};

View File

@@ -0,0 +1,66 @@
/**
* MainMenu Component
*
* Displays the main module menu for the MobileApp.
* Shows available modules like "Lager" that the user can access.
*/
export default {
name: 'MainMenu',
emits: ['navigate'],
props: {
user: Object
},
setup(props, { emit }) {
// Available modules
const modules = [
{
id: 'Lager',
name: 'Lager',
icon: 'warehouse',
color: 'bg-blue-500',
iconColor: 'text-blue-500'
}
// Future modules can be added here
];
const openModule = (moduleId) => {
emit('navigate', moduleId, null);
};
return {
modules,
openModule
};
},
template: `
<div class="p-3">
<!-- Module List -->
<div class="bg-white dark:bg-slate-800 rounded-xl overflow-hidden shadow-sm">
<button
v-for="(module, index) in modules"
:key="module.id"
@click="openModule(module.id)"
:class="[
'w-full flex items-center px-4 py-3.5 hover:bg-slate-50 dark:hover:bg-slate-700/50 active:bg-slate-100 dark:active:bg-slate-700 transition text-left',
index !== modules.length - 1 ? 'border-b border-slate-100 dark:border-slate-700' : ''
]"
>
<div :class="[module.color, 'w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0']">
<svg v-if="module.icon === 'warehouse'" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 14v3m4-3v3m4-3v3M3 21h18M3 10h18M3 7l9-4 9 4M4 10h16v11H4V10z" />
</svg>
</div>
<span class="ml-3 font-medium text-slate-800 dark:text-white flex-1">
{{ module.name }}
</span>
<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="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
`
};

View File

@@ -0,0 +1,26 @@
{
"name": "Xinon Mobile",
"short_name": "Xinon",
"description": "Mobile-optimierte Tools für Xinon",
"start_url": "/MobileApp",
"scope": "/MobileApp",
"display": "standalone",
"orientation": "portrait",
"background_color": "#f1f5f9",
"theme_color": "#005384",
"icons": [
{
"src": "/assets/images/xinon-sm.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/assets/images/xinon-sm.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"categories": ["business", "productivity"]
}

View File

@@ -0,0 +1,166 @@
/**
* Lager Module
*
* Main module for warehouse management.
* Shows submodules: Inventur (stocktake)
*/
import StocktakeList from '/mobile/modules/lager/inventur/StocktakeList.js';
import Scanner from '/mobile/modules/lager/inventur/Scanner.js';
import MovementForm from '/mobile/modules/lager/movement/MovementForm.js';
export default {
name: 'LagerModule',
emits: ['navigate', 'toast'],
props: {
user: Object,
submodule: String,
simpleMode: Boolean
},
components: {
StocktakeList,
Scanner,
MovementForm
},
setup(props, { emit }) {
const { ref, computed, watch } = Vue;
// Submodules available in Lager
const submodules = [
{
id: 'Inventur',
name: 'Inventur',
icon: 'clipboard',
color: 'bg-green-500'
},
{
id: 'Movement',
name: 'Lagerbewegung',
icon: 'arrows',
color: 'bg-blue-500'
}
];
// Scanner state
const selectedStocktake = ref(null);
const showScanner = ref(false);
// Current view based on submodule
const currentView = computed(() => {
if (!props.submodule) return 'menu';
if (props.submodule.toLowerCase() === 'inventur') {
return showScanner.value ? 'scanner' : 'inventur';
}
if (props.submodule.toLowerCase() === 'movement') {
return 'movement';
}
return 'menu';
});
// Watch for submodule changes
watch(() => props.submodule, (newVal) => {
if (!newVal) {
showScanner.value = false;
selectedStocktake.value = null;
}
});
const openSubmodule = (submoduleId) => {
emit('navigate', 'Lager', submoduleId);
};
const goBack = () => {
if (showScanner.value) {
showScanner.value = false;
selectedStocktake.value = null;
}
};
const openScanner = (stocktake) => {
selectedStocktake.value = stocktake;
showScanner.value = true;
};
const closeScanner = () => {
showScanner.value = false;
selectedStocktake.value = null;
};
const showToast = (message, type) => {
emit('toast', message, type);
};
return {
submodules,
selectedStocktake,
showScanner,
currentView,
openSubmodule,
goBack,
openScanner,
closeScanner,
showToast
};
},
template: `
<div class="h-full">
<!-- Submodule Menu -->
<template v-if="currentView === 'menu'">
<div class="p-3">
<div class="bg-white dark:bg-slate-800 rounded-xl overflow-hidden shadow-sm">
<button
v-for="(sub, index) in submodules"
:key="sub.id"
@click="openSubmodule(sub.id)"
:class="[
'w-full flex items-center px-4 py-3.5 hover:bg-slate-50 dark:hover:bg-slate-700/50 active:bg-slate-100 dark:active:bg-slate-700 transition text-left',
index !== submodules.length - 1 ? 'border-b border-slate-100 dark:border-slate-700' : ''
]"
>
<div :class="[sub.color, 'w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0']">
<svg v-if="sub.icon === 'clipboard'" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-white" 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 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
<svg v-else-if="sub.icon === 'arrows'" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
</div>
<span class="ml-3 font-medium text-slate-800 dark:text-white flex-1">
{{ sub.name }}
</span>
<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="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</template>
<!-- Inventur: Stocktake List -->
<StocktakeList
v-else-if="currentView === 'inventur'"
:user="user"
@select="openScanner"
/>
<!-- Inventur: Scanner -->
<Scanner
v-else-if="currentView === 'scanner'"
:stocktake="selectedStocktake"
:user="user"
@close="closeScanner"
@toast="showToast"
/>
<!-- Movement: Movement Form -->
<MovementForm
v-else-if="currentView === 'movement'"
:user="user"
:simple-mode="simpleMode"
@toast="showToast"
/>
</div>
`
};

View File

@@ -0,0 +1,437 @@
/**
* Scanner Component (Inventur)
*
* The main scanning interface for stocktakes.
* Uses the new API path: /MobileApp/Lager/Inventur/{action}
*/
// Inventur-specific API
const inventurApi = {
get: (endpoint) => fetch(`/MobileApp/Lager/Inventur/${endpoint}`).then(r => r.json()),
post: (endpoint, data) => fetch(`/MobileApp/Lager/Inventur/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}).then(r => r.json())
};
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');
const isLoading = ref(false);
// Scanner
const scanner = ref(null);
const isScannerActive = ref(false);
const scannerError = ref('');
// Article
const scannedArticle = ref(null);
const quantity = ref('1');
const rack = ref('');
const shelf = ref('');
// Search
const searchQuery = ref('');
const searchResults = ref([]);
const categories = ref([]);
const selectedCategory = ref(0);
const isSearching = ref(false);
// History
const recentScans = ref([]);
const isLoadingHistory = ref(false);
// Warning
const alreadyScannedWarning = ref(null);
// Keypad
const showKeypad = ref(false);
// Computed
const canSubmit = computed(() => {
return scannedArticle.value && parseFloat(quantity.value) > 0 && !isLoading.value;
});
// Scanner functions
const startScanner = async () => {
scannerError.value = '';
try {
scanner.value = new Html5Qrcode('qr-reader');
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;
alreadyScannedWarning.value = null;
try {
const result = await inventurApi.get(`getArticle?code=${encodeURIComponent(code)}`);
if (result.success) {
scannedArticle.value = result.article;
const checkResult = await inventurApi.get(
`checkAlreadyScanned?stocktakeId=${props.stocktake.id}&articleId=${result.article.id}`
);
if (checkResult.success && checkResult.alreadyScanned) {
alreadyScannedWarning.value = checkResult.existingItem;
}
quantity.value = '1';
} 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;
}
};
// Submit
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 inventurApi.post('submitScan', payload);
if (result.success) {
emit('toast', result.message, 'success');
scannedArticle.value = null;
quantity.value = '1';
rack.value = '';
shelf.value = '';
alreadyScannedWarning.value = null;
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 inventurApi.get('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 inventurApi.get(`searchArticles?${params}`);
if (result.success) searchResults.value = result.articles;
} catch (e) {} finally {
isSearching.value = false;
}
};
const selectSearchResult = async (article) => {
await stopScanner();
scannedArticle.value = article;
quantity.value = '1';
currentTab.value = 'scan';
const checkResult = await inventurApi.get(
`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 inventurApi.get(`getMyScans?stocktakeId=${props.stocktake.id}`);
if (result.success) recentScans.value = result.items;
} catch (e) {} finally {
isLoadingHistory.value = false;
}
};
// Keypad
const appendDigit = (digit) => {
if (digit === '.' && quantity.value.includes('.')) return;
if (quantity.value === '0' && digit !== '.') {
quantity.value = digit;
} else {
quantity.value += digit;
}
};
const deleteDigit = () => {
quantity.value = quantity.value.length > 1 ? quantity.value.slice(0, -1) : '0';
};
const clearQuantity = () => { quantity.value = '0'; };
// Navigation
const 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 {
currentTab, isLoading, isScannerActive, scannerError,
scannedArticle, quantity, rack, shelf,
searchQuery, searchResults, categories, selectedCategory, isSearching,
recentScans, isLoadingHistory,
alreadyScannedWarning, showKeypad, canSubmit,
startScanner, stopScanner, submitScan, searchArticles, selectSearchResult,
loadHistory, appendDigit, deleteDigit, clearQuantity,
handleClose, switchTab, cancelScan
};
},
template: `
<div class="flex flex-col h-full">
<!-- Title bar with close -->
<div class="flex items-center justify-between px-3 py-2 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700">
<span class="text-sm font-medium text-slate-600 dark:text-slate-300 truncate flex-1">{{ stocktake.title || 'Inventur #' + stocktake.stocktakeNumber }}</span>
<button @click="handleClose" class="ml-2 p-1.5 rounded-full text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 transition">
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Tabs -->
<div class="flex bg-slate-100 dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700 px-3 py-2">
<button @click="switchTab('scan')" :class="[currentTab === 'scan' ? 'bg-white dark:bg-slate-800 shadow-sm' : 'text-slate-500 dark:text-slate-400']" class="flex-1 py-2 text-sm font-medium rounded-lg transition">Scannen</button>
<button @click="switchTab('search')" :class="[currentTab === 'search' ? 'bg-white dark:bg-slate-800 shadow-sm' : 'text-slate-500 dark:text-slate-400']" class="flex-1 py-2 text-sm font-medium rounded-lg transition mx-1">Suche</button>
<button @click="switchTab('history')" :class="[currentTab === 'history' ? 'bg-white dark:bg-slate-800 shadow-sm' : 'text-slate-500 dark:text-slate-400']" class="flex-1 py-2 text-sm font-medium rounded-lg transition">Verlauf</button>
</div>
<!-- Content -->
<main class="flex-grow overflow-y-auto bg-slate-50 dark:bg-slate-900">
<!-- SCAN TAB -->
<div v-if="currentTab === 'scan'" class="p-4 space-y-4">
<!-- Scanner -->
<div v-if="!scannedArticle" class="space-y-4">
<div id="qr-reader" class="w-full rounded-lg overflow-hidden bg-black"></div>
<div v-if="scannerError" class="p-4 bg-red-50 dark:bg-red-900/30 rounded-lg">
<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">
<!-- 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 -->
<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>
<!-- Rack/Shelf -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Regal</label>
<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>
<!-- Buttons -->
<div class="space-y-2">
<button v-if="alreadyScannedWarning" @click="submitScan(false)" :disabled="!canSubmit" class="w-full py-4 bg-green-600 text-white font-bold rounded-lg disabled:opacity-50">Zur Menge addieren</button>
<button v-if="alreadyScannedWarning" @click="submitScan(true)" :disabled="!canSubmit" class="w-full py-4 bg-amber-600 text-white font-bold rounded-lg disabled:opacity-50">Überschreiben</button>
<button v-if="!alreadyScannedWarning" @click="submitScan(false)" :disabled="!canSubmit" class="w-full py-4 bg-green-600 text-white font-bold rounded-lg disabled:opacity-50">
{{ 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>
<!-- 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>
</div>
`
};

View File

@@ -0,0 +1,151 @@
/**
* StocktakeList Component (Inventur)
*
* Displays a list of active stocktakes.
* Uses the new API path: /MobileApp/Lager/Inventur/{action}
*/
import { api } from '/mobile/shared/auth.js';
// Override API base for Inventur
const inventurApi = {
get: (endpoint) => fetch(`/MobileApp/Lager/Inventur/${endpoint}`).then(r => r.json()),
post: (endpoint, data) => fetch(`/MobileApp/Lager/Inventur/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}).then(r => r.json())
};
export default {
name: 'StocktakeList',
emits: ['select'],
props: {
user: Object
},
setup(props, { emit }) {
const { ref, onMounted } = Vue;
const stocktakes = ref([]);
const isLoading = ref(true);
const error = ref('');
const fetchStocktakes = async () => {
isLoading.value = true;
error.value = '';
try {
const result = await inventurApi.get('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);
};
onMounted(() => {
fetchStocktakes();
});
return {
stocktakes,
isLoading,
error,
fetchStocktakes,
selectStocktake
};
},
template: `
<div class="h-full flex flex-col">
<!-- Refresh bar -->
<div class="flex items-center justify-between px-3 py-2 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700">
<span class="text-sm font-medium text-slate-600 dark:text-slate-300">Aktive Inventuren</span>
<button
@click="fetchStocktakes"
class="p-2 rounded-full text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 transition"
:disabled="isLoading"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" :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>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto p-3">
<!-- Loading -->
<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-xl shadow-sm 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 -->
<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 -->
<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="bg-white dark:bg-slate-800 rounded-xl overflow-hidden shadow-sm">
<button
v-for="(stocktake, index) in stocktakes"
:key="stocktake.id"
@click="selectStocktake(stocktake)"
:class="[
'w-full text-left px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-700/50 active:bg-slate-100 dark:active:bg-slate-700 transition',
index !== stocktakes.length - 1 ? 'border-b border-slate-100 dark:border-slate-700' : ''
]"
>
<div class="flex justify-between items-start">
<div class="flex-1 min-w-0">
<h3 class="font-medium text-slate-800 dark:text-white truncate">
{{ stocktake.title || 'Inventur #' + stocktake.stocktakeNumber }}
</h3>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
{{ stocktake.locationName }}
</p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-slate-400 flex-shrink-0 ml-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
<div class="flex items-center mt-2 text-xs text-slate-400 dark:text-slate-500">
<span class="font-medium text-slate-600 dark:text-slate-400">{{ stocktake.totalScannedItems || 0 }}</span>
<span class="ml-1">Artikel</span>
<span class="mx-2">·</span>
<span>{{ stocktake.startedAt || 'Nicht gestartet' }}</span>
</div>
</button>
</div>
</div>
</div>
`
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,199 @@
/**
* MobileApp Shared Authentication Module
*
* Provides authentication utilities for all mobile PWAs:
* - checkAuth() - Check if user is authenticated
* - login() - Authenticate user
* - logout() - Clear session
* - api - Generic API helper
*/
// Base API path for all MobileApp endpoints
const API_BASE = '/MobileApp';
// Shared auth state (can be imported by components)
export const authState = {
user: null,
isAuthenticated: false
};
/**
* Check if user is currently authenticated
* @returns {Promise<{authenticated: boolean, user?: object}>}
*/
export async function checkAuth() {
try {
const res = await fetch(`${API_BASE}/auth/check`, {
credentials: 'same-origin'
});
const data = await res.json();
authState.isAuthenticated = data.authenticated;
authState.user = data.user || null;
return data;
} catch (e) {
console.error('Auth check failed:', e);
authState.isAuthenticated = false;
authState.user = null;
return { authenticated: false };
}
}
/**
* Authenticate user with credentials
* @param {object} credentials - { username, password, rememberMe }
* @returns {Promise<{success: boolean, user?: object, message?: string}>}
*/
export async function login({ username, password, rememberMe = true }) {
try {
const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ username, password, rememberMe })
});
const data = await res.json();
if (data.success) {
authState.isAuthenticated = true;
authState.user = data.user;
}
return data;
} catch (e) {
console.error('Login failed:', e);
return { success: false, message: 'Netzwerkfehler' };
}
}
/**
* Verify 2FA code
* @param {string} code - 5-digit verification code
* @returns {Promise<{success: boolean, user?: object, message?: string}>}
*/
export async function verify2FA(code) {
try {
const res = await fetch(`${API_BASE}/auth/verify2fa`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ code })
});
const data = await res.json();
if (data.success) {
authState.isAuthenticated = true;
authState.user = data.user;
}
return data;
} catch (e) {
console.error('2FA verification failed:', e);
return { success: false, message: 'Netzwerkfehler' };
}
}
/**
* Resend 2FA code
* @returns {Promise<{success: boolean, message?: string}>}
*/
export async function resend2FA() {
try {
const res = await fetch(`${API_BASE}/auth/resend2fa`, {
method: 'POST',
credentials: 'same-origin'
});
return await res.json();
} catch (e) {
console.error('Resend 2FA failed:', e);
return { success: false, message: 'Netzwerkfehler' };
}
}
/**
* Logout current user
* @returns {Promise<{success: boolean}>}
*/
export async function logout() {
try {
await fetch(`${API_BASE}/auth/logout`, {
method: 'POST',
credentials: 'same-origin'
});
} catch (e) {
console.error('Logout request failed:', e);
}
authState.isAuthenticated = false;
authState.user = null;
return { success: true };
}
/**
* Generic API helper for app-specific endpoints
* Usage: api.get('WarehouseStocktake/getActiveStocktakes')
* api.post('WarehouseStocktake/submitScan', { ... })
*/
export const api = {
/**
* GET request
* @param {string} endpoint - Endpoint path (e.g., 'WarehouseStocktake/getArticle?code=123')
* @returns {Promise<object>}
*/
get: async (endpoint) => {
try {
const res = await fetch(`${API_BASE}/${endpoint}`, {
credentials: 'same-origin'
});
// Check for auth errors
if (res.status === 401) {
authState.isAuthenticated = false;
authState.user = null;
return { success: false, error: 'Not authenticated', authError: true };
}
return await res.json();
} catch (e) {
console.error(`API GET ${endpoint} failed:`, e);
return { success: false, error: 'Netzwerkfehler' };
}
},
/**
* POST request with JSON body
* @param {string} endpoint - Endpoint path
* @param {object} data - Request body
* @returns {Promise<object>}
*/
post: async (endpoint, data = {}) => {
try {
const res = await fetch(`${API_BASE}/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(data)
});
// Check for auth errors
if (res.status === 401) {
authState.isAuthenticated = false;
authState.user = null;
return { success: false, error: 'Not authenticated', authError: true };
}
return await res.json();
} catch (e) {
console.error(`API POST ${endpoint} failed:`, e);
return { success: false, error: 'Netzwerkfehler' };
}
}
};
// Export API_BASE for components that need to build URLs
export { API_BASE };

View File

@@ -0,0 +1,324 @@
/**
* MobileApp Shared Base Styles
*
* Common styles for all mobile PWAs including:
* - Dark mode support
* - PWA-specific optimizations
* - Common animations
* - Utility classes
*/
/* ==================== ROOT & DARK MODE ==================== */
:root {
--color-primary: #005384;
--color-secondary: #fac41b;
--color-success: #22c55e;
--color-danger: #ef4444;
--color-warning: #f59e0b;
}
/* Dark mode is toggled by adding 'dark' class to <html> */
.dark {
color-scheme: dark;
}
/* ==================== PWA OPTIMIZATIONS ==================== */
html, body {
/* Prevents rubber-band scroll on iOS and pull-to-refresh on Android */
overscroll-behavior: none;
/* Prevent text selection on double tap */
-webkit-user-select: none;
user-select: none;
/* Smooth font rendering */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* Prevent zoom on input focus (iOS) */
touch-action: manipulation;
}
/* Allow text selection in inputs and textareas */
input, textarea, [contenteditable] {
-webkit-user-select: text;
user-select: text;
}
/* Safe area insets for notched devices */
.safe-area-top {
padding-top: env(safe-area-inset-top);
}
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
/* ==================== ANIMATIONS ==================== */
/* Slide transition for panels */
.slide-enter-active,
.slide-leave-active {
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.slide-enter-from,
.slide-leave-to {
transform: translateX(100%);
}
/* Slide up transition for modals */
.slide-up-enter-active,
.slide-up-leave-active {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.slide-up-enter-from,
.slide-up-leave-to {
transform: translateY(100%);
}
/* Slide down transition for top sheets */
.slide-down-enter-active,
.slide-down-leave-active {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.slide-down-enter-from,
.slide-down-leave-to {
transform: translateY(-100%);
}
/* Fade transition */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease-in-out;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* Overlay transition */
.overlay-enter-active,
.overlay-leave-active {
transition: opacity 0.35s ease;
}
.overlay-enter-from,
.overlay-leave-to {
opacity: 0;
}
/* Scale in transition */
.scale-enter-active,
.scale-leave-active {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.scale-enter-from,
.scale-leave-to {
transform: scale(0.95);
opacity: 0;
}
/* Spinner animation */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spin {
animation: spin 1s linear infinite;
}
/* Pulse animation for loading states */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.pulse {
animation: pulse 1.5s ease-in-out infinite;
}
/* Network background animations */
@keyframes node-glow {
0%, 100% {
transform: scale(1);
opacity: 0.8;
}
50% {
transform: scale(1.4);
opacity: 1;
}
}
@keyframes node-glow-slow {
0%, 100% {
transform: scale(1);
opacity: 0.7;
}
50% {
transform: scale(1.3);
opacity: 1;
}
}
@keyframes line-pulse {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.6; }
}
@keyframes line-flow {
0% { stroke-dashoffset: 1000; }
100% { stroke-dashoffset: 0; }
}
.network-node {
animation: node-glow 2s ease-in-out infinite;
}
.network-node-slow {
animation: node-glow-slow 3s ease-in-out infinite;
}
.network-lines {
animation: line-pulse 3s ease-in-out infinite;
}
.network-line-flow {
stroke-dasharray: 20 30;
animation: line-flow 8s linear infinite;
}
/* ==================== PANEL EFFECTS ==================== */
/* Background blur effect when panel is open */
.panel-open {
transform: scale(0.95);
filter: blur(4px);
opacity: 0.7;
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1),
filter 0.35s,
opacity 0.35s;
}
.list-container {
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1),
filter 0.35s,
opacity 0.35s;
}
/* ==================== OVERLAY ==================== */
.overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.4);
transition: opacity 0.35s ease;
z-index: 15;
}
/* ==================== TOAST NOTIFICATIONS ==================== */
.toast-container {
position: fixed;
bottom: calc(1rem + env(safe-area-inset-bottom));
left: 1rem;
right: 1rem;
z-index: 100;
display: flex;
flex-direction: column;
gap: 0.5rem;
pointer-events: none;
}
.toast {
padding: 0.75rem 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
text-align: center;
pointer-events: auto;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.toast-success {
background-color: var(--color-success);
color: white;
}
.toast-error {
background-color: var(--color-danger);
color: white;
}
.toast-warning {
background-color: var(--color-warning);
color: white;
}
/* ==================== FORM ELEMENTS ==================== */
/* Prevent iOS zoom on input focus */
input[type="text"],
input[type="password"],
input[type="email"],
input[type="number"],
input[type="tel"],
input[type="search"],
textarea,
select {
font-size: 16px !important;
}
/* Remove tap highlight on mobile */
button, a, input, select, textarea {
-webkit-tap-highlight-color: transparent;
}
/* Better focus styles for accessibility */
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* ==================== UTILITIES ==================== */
/* Hide scrollbar but allow scrolling */
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
/* Loading overlay */
.loading-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
/* Numeric keypad input */
.numeric-input {
font-variant-numeric: tabular-nums;
letter-spacing: 0.05em;
}

84
public/mobile/sw.js Normal file
View File

@@ -0,0 +1,84 @@
/**
* MobileApp Service Worker
* Provides basic caching for the PWA shell and assets.
*/
const CACHE_NAME = 'xinon-mobile-v1';
const ASSETS_TO_CACHE = [
'/MobileApp',
'/mobile/app.js',
'/mobile/shared/auth.js',
'/mobile/shared/base.css',
'/mobile/components/LoginScreen.js',
'/mobile/components/MainMenu.js',
'/mobile/modules/lager/LagerModule.js',
'/mobile/modules/lager/inventur/StocktakeList.js',
'/mobile/modules/lager/inventur/Scanner.js',
'/assets/images/xinon-full-transparent.png',
'/assets/images/xinon-full-transparent-white.png',
'/assets/images/xinon-sm.png'
];
// Install: cache assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(ASSETS_TO_CACHE))
.then(() => self.skipWaiting())
);
});
// Activate: clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name !== CACHE_NAME)
.map(name => caches.delete(name))
);
}).then(() => self.clients.claim())
);
});
// Fetch: network-first for API, cache-first for assets
self.addEventListener('fetch', (event) => {
// Only handle GET requests
if (event.request.method !== 'GET') return;
const url = new URL(event.request.url);
// API calls: network only
if (url.pathname.startsWith('/MobileApp/') &&
url.pathname !== '/MobileApp' &&
url.pathname !== '/MobileApp/') {
return;
}
// Everything else: cache-first, falling back to network
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) {
// Return cached, but update in background
fetch(event.request).then(response => {
if (response.ok) {
caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, response);
});
}
}).catch(() => {});
return cached;
}
return fetch(event.request).then(response => {
if (response.ok && url.origin === location.origin) {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, clone);
});
}
return response;
});
})
);
});

View File

@@ -0,0 +1,168 @@
/**
* 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);
}

View File

@@ -0,0 +1,182 @@
/**
* 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');

View File

@@ -0,0 +1,207 @@
/**
* 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>
`
};

View File

@@ -0,0 +1,607 @@
/**
* 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>
`
};

View File

@@ -0,0 +1,266 @@
/**
* 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>
`
};

View File

@@ -0,0 +1,27 @@
{
"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"
}

View File

@@ -0,0 +1,109 @@
/**
* 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;
});
})
);
});