Files
thetool/public/mobile/app.js
2026-01-13 12:44:45 +01:00

582 lines
30 KiB
JavaScript

/**
* 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');