cleanup warehousestocktake progressive web app

This commit is contained in:
Luca Haid
2026-02-02 10:17:52 +01:00
parent 042e2112f0
commit 6942aa4de1
10 changed files with 0 additions and 3043 deletions

View File

@@ -1,168 +0,0 @@
/**
* Warehouse Stocktake PWA - App-Specific Styles
*/
/* QR Scanner Container */
#qr-reader {
width: 100%;
max-width: 400px;
margin: 0 auto;
border-radius: 0.5rem;
overflow: hidden;
}
#qr-reader video {
border-radius: 0.5rem;
}
/* Hide default html5-qrcode UI elements we don't need */
#qr-reader__status_span,
#qr-reader__dashboard_section_csr,
#qr-reader__dashboard_section_swaplink {
display: none !important;
}
/* Scanner frame styling */
#qr-reader__scan_region {
background: transparent !important;
}
#qr-reader__scan_region img {
opacity: 0.3;
}
/* Keypad styling */
.keypad-button {
min-height: 60px;
}
/* Numeric display */
.quantity-display {
font-variant-numeric: tabular-nums;
letter-spacing: 0.05em;
}
/* Tab indicator animation */
.tab-indicator {
transition: transform 0.3s ease, width 0.3s ease;
}
/* Card hover effect */
.stocktake-card:active {
transform: scale(0.98);
}
/* Search results scrolling */
.search-results {
max-height: calc(100vh - 280px);
overflow-y: auto;
}
/* History list */
.history-list {
max-height: calc(100vh - 200px);
overflow-y: auto;
}
/* Already scanned badge pulse */
@keyframes pulse-amber {
0%, 100% {
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.4);
}
50% {
box-shadow: 0 0 0 8px rgba(245, 158, 11, 0);
}
}
.already-scanned-pulse {
animation: pulse-amber 2s ease-in-out infinite;
}
/* Toast slide up animation (complementing base.css) */
.toast-enter-active {
animation: toast-slide-up 0.3s ease-out;
}
.toast-leave-active {
animation: toast-slide-down 0.3s ease-in;
}
@keyframes toast-slide-up {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes toast-slide-down {
from {
transform: translateY(0);
opacity: 1;
}
to {
transform: translateY(100%);
opacity: 0;
}
}
/* Settings panel slide */
.settings-panel {
transform: translateX(100%);
transition: transform 0.3s ease-out;
}
.settings-panel.open {
transform: translateX(0);
}
/* Loading spinner */
.spinner {
border: 3px solid rgba(0, 83, 132, 0.1);
border-top-color: #005384;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
}
/* Dark mode specific overrides */
.dark .spinner {
border-color: rgba(250, 196, 27, 0.1);
border-top-color: #fac41b;
}
/* Scan success flash */
@keyframes scan-flash {
0% {
background-color: rgba(34, 197, 94, 0.3);
}
100% {
background-color: transparent;
}
}
.scan-success-flash {
animation: scan-flash 0.5s ease-out;
}
/* Custom scrollbar for webkit browsers */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.2);
}

View File

@@ -1,182 +0,0 @@
/**
* Warehouse Stocktake PWA - Main Vue Application
*
* This is the entry point for the Warehouse Stocktake PWA.
* It manages authentication state and routes between views.
*/
// Import shared modules
import { api, authState, checkAuth, login, logout } from '/mobile/shared/auth.js';
// Import components
import LoginScreen from './components/LoginScreen.js';
import StocktakeList from './components/StocktakeList.js';
import Scanner from './components/Scanner.js';
const { createApp, ref, computed, onMounted, watch, nextTick } = Vue;
const App = {
components: {
LoginScreen,
StocktakeList,
Scanner
},
setup() {
// ==================== STATE ====================
const currentView = ref('loading'); // 'loading', 'login', 'list', 'scanner'
const user = ref(null);
const selectedStocktake = ref(null);
const toast = ref({ show: false, message: '', type: 'success' });
const theme = ref('system');
// ==================== THEME ====================
const applyTheme = () => {
const isDark = localStorage.theme === 'dark' ||
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.toggle('dark', isDark);
const metaThemeColor = document.querySelector('meta[name="theme-color"]');
if (metaThemeColor) {
metaThemeColor.setAttribute('content', isDark ? '#0f172a' : '#005384');
}
};
const setTheme = (newTheme) => {
theme.value = newTheme;
if (newTheme === 'system') {
localStorage.removeItem('theme');
} else {
localStorage.setItem('theme', newTheme);
}
applyTheme();
};
// ==================== AUTH ====================
const handleLogin = async (credentials) => {
const result = await login(credentials);
if (result.success) {
user.value = result.user;
currentView.value = 'list';
showToast('Erfolgreich angemeldet', 'success');
}
return result;
};
const handleLogout = async () => {
await logout();
user.value = null;
selectedStocktake.value = null;
currentView.value = 'login';
showToast('Abgemeldet', 'success');
};
// ==================== NAVIGATION ====================
const openScanner = (stocktake) => {
selectedStocktake.value = stocktake;
currentView.value = 'scanner';
};
const closeScanner = () => {
selectedStocktake.value = null;
currentView.value = 'list';
};
// ==================== TOAST ====================
const showToast = (message, type = 'success') => {
toast.value = { show: true, message, type };
setTimeout(() => {
toast.value.show = false;
}, 3000);
};
// ==================== LIFECYCLE ====================
onMounted(async () => {
// Initialize theme
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
theme.value = savedTheme;
}
applyTheme();
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyTheme);
// Check authentication
const result = await checkAuth();
if (result.authenticated) {
user.value = result.user;
currentView.value = 'list';
} else {
currentView.value = 'login';
}
});
return {
// State
currentView,
user,
selectedStocktake,
toast,
theme,
// Methods
handleLogin,
handleLogout,
openScanner,
closeScanner,
showToast,
setTheme,
};
},
template: `
<div class="relative h-full w-full bg-slate-100 dark:bg-slate-900 transition-colors duration-300">
<!-- Loading State -->
<div v-if="currentView === 'loading'" class="flex items-center justify-center h-full">
<div class="text-center">
<img src="/assets/images/xinon-full-transparent.png" class="h-12 mx-auto mb-4 dark:hidden">
<img src="/assets/images/xinon-full-transparent-white.png" class="h-12 mx-auto mb-4 hidden dark:block">
<div class="animate-pulse text-slate-500 dark:text-slate-400">Lädt...</div>
</div>
</div>
<!-- Login Screen -->
<LoginScreen
v-else-if="currentView === 'login'"
@login="handleLogin"
:theme="theme"
@set-theme="setTheme"
/>
<!-- Stocktake List -->
<StocktakeList
v-else-if="currentView === 'list'"
:user="user"
:theme="theme"
@select="openScanner"
@logout="handleLogout"
@set-theme="setTheme"
/>
<!-- Scanner View -->
<Scanner
v-else-if="currentView === 'scanner'"
:stocktake="selectedStocktake"
:user="user"
@close="closeScanner"
@toast="showToast"
/>
<!-- Toast Notifications -->
<transition name="slide-up">
<div v-if="toast.show" class="toast-container">
<div :class="['toast', 'toast-' + toast.type]">
{{ toast.message }}
</div>
</div>
</transition>
</div>
`
};
// Mount the app
createApp(App).mount('#app');

View File

@@ -1,207 +0,0 @@
/**
* LoginScreen Component
*
* Displays the login form for the PWA.
* Handles username/password authentication with remember me option.
*/
export default {
name: 'LoginScreen',
emits: ['login', 'set-theme'],
props: {
theme: {
type: String,
default: 'system'
}
},
setup(props, { emit }) {
const { ref } = Vue;
// Form state
const username = ref('');
const password = ref('');
const rememberMe = ref(true);
const error = ref('');
const loading = ref(false);
const showPassword = ref(false);
// Theme picker (shown on first visit)
const showThemePicker = ref(!localStorage.getItem('theme'));
const handleSubmit = async () => {
if (!username.value || !password.value) {
error.value = 'Bitte Benutzername und Passwort eingeben';
return;
}
loading.value = true;
error.value = '';
try {
const result = await new Promise((resolve) => {
// Emit returns undefined, we need to wait for parent to call back
const loginPromise = emit('login', {
username: username.value,
password: password.value,
rememberMe: rememberMe.value
});
// The parent will return the result
resolve(loginPromise);
});
if (result && !result.success) {
error.value = result.message || 'Login fehlgeschlagen';
if (result.requires2FA) {
error.value = 'Zwei-Faktor-Authentifizierung wird derzeit nicht unterstützt.';
}
}
} catch (e) {
error.value = 'Ein Fehler ist aufgetreten';
} finally {
loading.value = false;
}
};
const selectTheme = (newTheme) => {
emit('set-theme', newTheme);
showThemePicker.value = false;
};
return {
username,
password,
rememberMe,
error,
loading,
showPassword,
showThemePicker,
handleSubmit,
selectTheme
};
},
template: `
<div class="min-h-screen flex items-center justify-center bg-slate-100 dark:bg-slate-900 p-4">
<!-- Theme Picker Modal (First Visit) -->
<transition name="fade">
<div v-if="showThemePicker" class="fixed inset-0 bg-black bg-opacity-60 z-50 flex items-center justify-center p-4">
<div class="bg-white dark:bg-slate-800 rounded-lg p-6 w-full max-w-xs text-center shadow-2xl">
<h3 class="font-bold text-lg mb-2 text-slate-800 dark:text-white">Willkommen!</h3>
<p class="text-sm text-slate-600 dark:text-slate-300 mb-6">Wähle dein bevorzugtes Farbschema.</p>
<div class="flex flex-col space-y-3">
<button @click="selectTheme('light')" class="w-full px-4 py-3 bg-slate-200 text-slate-800 font-bold rounded-md hover:bg-slate-300 transition">
Hell
</button>
<button @click="selectTheme('dark')" class="w-full px-4 py-3 bg-slate-700 text-white font-bold rounded-md hover:bg-slate-600 transition">
Dunkel
</button>
<button @click="selectTheme('system')" class="w-full mt-2 text-sm text-slate-500 dark:text-slate-400 hover:underline">
Systemstandard
</button>
</div>
</div>
</div>
</transition>
<!-- Login Form -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 w-full max-w-sm">
<!-- Logo -->
<div class="mb-8">
<img src="/assets/images/xinon-full-transparent.png" class="h-10 mx-auto dark:hidden" alt="Logo">
<img src="/assets/images/xinon-full-transparent-white.png" class="h-10 mx-auto hidden dark:block" alt="Logo">
</div>
<!-- Title -->
<h1 class="text-xl font-bold text-center text-slate-800 dark:text-white mb-6">
Lager Inventur
</h1>
<!-- Form -->
<form @submit.prevent="handleSubmit" class="space-y-4">
<!-- Username -->
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Benutzername
</label>
<input
v-model="username"
type="text"
autocomplete="username"
autocapitalize="none"
class="w-full p-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition dark:bg-slate-700 dark:border-slate-600 dark:text-white"
placeholder="Benutzername eingeben"
>
</div>
<!-- Password -->
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Passwort
</label>
<div class="relative">
<input
v-model="password"
:type="showPassword ? 'text' : 'password'"
autocomplete="current-password"
class="w-full p-3 pr-12 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition dark:bg-slate-700 dark:border-slate-600 dark:text-white"
placeholder="Passwort eingeben"
>
<button
type="button"
@click="showPassword = !showPassword"
class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
>
<svg v-if="!showPassword" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
</button>
</div>
</div>
<!-- Remember Me -->
<label class="flex items-center cursor-pointer">
<input
v-model="rememberMe"
type="checkbox"
class="w-4 h-4 rounded border-slate-300 text-primary focus:ring-primary dark:border-slate-600 dark:bg-slate-700"
>
<span class="ml-2 text-sm text-slate-600 dark:text-slate-300">
Angemeldet bleiben
</span>
</label>
<!-- Error Message -->
<div v-if="error" class="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg">
<p class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
</div>
<!-- Submit Button -->
<button
type="submit"
:disabled="loading"
class="w-full py-3 px-4 bg-primary text-white font-bold rounded-lg hover:bg-primary/90 focus:ring-4 focus:ring-primary/30 transition disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
>
<svg v-if="loading" class="animate-spin -ml-1 mr-2 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ loading ? 'Wird angemeldet...' : 'Anmelden' }}
</button>
</form>
<!-- Footer -->
<div class="mt-8 text-center">
<p class="text-xs text-slate-400 dark:text-slate-500">
powered by XINON GmbH
</p>
</div>
</div>
</div>
`
};

View File

@@ -1,607 +0,0 @@
/**
* Scanner Component
*
* The main scanning interface for the stocktake.
* Features:
* - QR code scanning via camera
* - Manual article search
* - Quantity input with custom keypad
* - Recent scans list
*/
import { api } from '/mobile/shared/auth.js';
export default {
name: 'Scanner',
emits: ['close', 'toast'],
props: {
stocktake: {
type: Object,
required: true
},
user: {
type: Object,
required: true
}
},
setup(props, { emit }) {
const { ref, onMounted, onUnmounted, nextTick, computed } = Vue;
// ==================== STATE ====================
const currentTab = ref('scan'); // 'scan', 'search', 'history'
const isLoading = ref(false);
// Scanner state
const scanner = ref(null);
const isScannerActive = ref(false);
const scannerError = ref('');
// Article state
const scannedArticle = ref(null);
const quantity = ref('1');
const rack = ref('');
const shelf = ref('');
// Search state
const searchQuery = ref('');
const searchResults = ref([]);
const categories = ref([]);
const selectedCategory = ref(0);
const isSearching = ref(false);
// History state
const recentScans = ref([]);
const isLoadingHistory = ref(false);
// Already scanned warning
const alreadyScannedWarning = ref(null);
// Custom keypad
const showKeypad = ref(false);
// ==================== COMPUTED ====================
const canSubmit = computed(() => {
return scannedArticle.value &&
parseFloat(quantity.value) > 0 &&
!isLoading.value;
});
// ==================== SCANNER ====================
const startScanner = async () => {
scannerError.value = '';
try {
// Initialize scanner
scanner.value = new Html5Qrcode('qr-reader');
await scanner.value.start(
{ facingMode: 'environment' },
{
fps: 10,
qrbox: { width: 250, height: 250 },
aspectRatio: 1.0
},
onScanSuccess,
onScanError
);
isScannerActive.value = true;
} catch (err) {
console.error('Scanner start error:', err);
scannerError.value = 'Kamera konnte nicht gestartet werden. Bitte Berechtigungen prüfen.';
}
};
const stopScanner = async () => {
if (scanner.value && isScannerActive.value) {
try {
await scanner.value.stop();
} catch (e) {
console.error('Scanner stop error:', e);
}
isScannerActive.value = false;
}
};
const onScanSuccess = async (decodedText) => {
// Stop scanner temporarily
await stopScanner();
// Look up article
await lookupArticle(decodedText);
};
const onScanError = (errorMessage) => {
// Silent - this fires constantly when no QR code is detected
};
// ==================== ARTICLE LOOKUP ====================
const lookupArticle = async (code) => {
isLoading.value = true;
alreadyScannedWarning.value = null;
try {
const result = await api.get(`WarehouseStocktake/getArticle?code=${encodeURIComponent(code)}`);
if (result.success) {
scannedArticle.value = result.article;
// Check if already scanned
const checkResult = await api.get(
`WarehouseStocktake/checkAlreadyScanned?stocktakeId=${props.stocktake.id}&articleId=${result.article.id}`
);
if (checkResult.success && checkResult.alreadyScanned) {
alreadyScannedWarning.value = checkResult.existingItem;
}
// Reset quantity
quantity.value = '1';
} else {
emit('toast', result.message || 'Artikel nicht gefunden', 'error');
// Restart scanner
await startScanner();
}
} catch (e) {
emit('toast', 'Fehler beim Laden des Artikels', 'error');
await startScanner();
} finally {
isLoading.value = false;
}
};
// ==================== SUBMIT SCAN ====================
const submitScan = async (overwrite = false) => {
if (!canSubmit.value) return;
isLoading.value = true;
try {
const payload = {
stocktakeId: props.stocktake.id,
articleId: scannedArticle.value.id,
quantity: parseFloat(quantity.value),
rack: rack.value || null,
shelf: shelf.value || null,
overwrite: overwrite,
overwriteItemId: overwrite && alreadyScannedWarning.value ? alreadyScannedWarning.value.id : 0
};
const result = await api.post('WarehouseStocktake/submitScan', payload);
if (result.success) {
emit('toast', result.message, 'success');
// Reset state
scannedArticle.value = null;
quantity.value = '1';
rack.value = '';
shelf.value = '';
alreadyScannedWarning.value = null;
// Restart scanner
await startScanner();
} else {
emit('toast', result.message || 'Fehler beim Speichern', 'error');
}
} catch (e) {
emit('toast', 'Netzwerkfehler', 'error');
} finally {
isLoading.value = false;
}
};
// ==================== SEARCH ====================
const loadCategories = async () => {
const result = await api.get('WarehouseStocktake/getCategories');
if (result.success) {
categories.value = result.categories;
}
};
const searchArticles = async () => {
if (searchQuery.value.length < 2 && !selectedCategory.value) {
searchResults.value = [];
return;
}
isSearching.value = true;
try {
const params = new URLSearchParams();
if (searchQuery.value) params.set('query', searchQuery.value);
if (selectedCategory.value) params.set('categoryId', selectedCategory.value);
const result = await api.get(`WarehouseStocktake/searchArticles?${params}`);
if (result.success) {
searchResults.value = result.articles;
}
} catch (e) {
console.error('Search error:', e);
} finally {
isSearching.value = false;
}
};
const selectSearchResult = async (article) => {
await stopScanner();
scannedArticle.value = article;
quantity.value = '1';
currentTab.value = 'scan';
// Check if already scanned
const checkResult = await api.get(
`WarehouseStocktake/checkAlreadyScanned?stocktakeId=${props.stocktake.id}&articleId=${article.id}`
);
if (checkResult.success && checkResult.alreadyScanned) {
alreadyScannedWarning.value = checkResult.existingItem;
}
};
// ==================== HISTORY ====================
const loadHistory = async () => {
isLoadingHistory.value = true;
try {
const result = await api.get(`WarehouseStocktake/getMyScans?stocktakeId=${props.stocktake.id}`);
if (result.success) {
recentScans.value = result.items;
}
} catch (e) {
console.error('History load error:', e);
} finally {
isLoadingHistory.value = false;
}
};
// ==================== KEYPAD ====================
const appendDigit = (digit) => {
if (digit === '.' && quantity.value.includes('.')) return;
if (quantity.value === '0' && digit !== '.') {
quantity.value = digit;
} else {
quantity.value += digit;
}
};
const deleteDigit = () => {
if (quantity.value.length > 1) {
quantity.value = quantity.value.slice(0, -1);
} else {
quantity.value = '0';
}
};
const clearQuantity = () => {
quantity.value = '0';
};
// ==================== LIFECYCLE ====================
const handleClose = async () => {
await stopScanner();
emit('close');
};
const switchTab = async (tab) => {
currentTab.value = tab;
if (tab === 'scan' && !scannedArticle.value) {
await nextTick();
await startScanner();
} else if (tab === 'search') {
await stopScanner();
await loadCategories();
} else if (tab === 'history') {
await stopScanner();
await loadHistory();
}
};
const cancelScan = async () => {
scannedArticle.value = null;
alreadyScannedWarning.value = null;
quantity.value = '1';
await startScanner();
};
onMounted(async () => {
await startScanner();
});
onUnmounted(async () => {
await stopScanner();
});
return {
// State
currentTab,
isLoading,
isScannerActive,
scannerError,
scannedArticle,
quantity,
rack,
shelf,
searchQuery,
searchResults,
categories,
selectedCategory,
isSearching,
recentScans,
isLoadingHistory,
alreadyScannedWarning,
showKeypad,
canSubmit,
// Methods
startScanner,
stopScanner,
submitScan,
searchArticles,
selectSearchResult,
loadHistory,
appendDigit,
deleteDigit,
clearQuantity,
handleClose,
switchTab,
cancelScan
};
},
template: `
<div class="flex flex-col h-full bg-slate-100 dark:bg-slate-900">
<!-- Header -->
<header class="bg-white dark:bg-slate-800 shadow-sm p-4 flex-shrink-0">
<div class="flex items-center justify-between">
<button @click="handleClose" class="p-2 -ml-2 rounded-full hover:bg-slate-100 dark:hover:bg-slate-700">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-slate-600 dark:text-slate-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<h1 class="text-lg font-bold text-slate-800 dark:text-white truncate px-2">
{{ stocktake.title || 'Inventur #' + stocktake.stocktakeNumber }}
</h1>
<div class="w-10"></div>
</div>
<!-- Tabs -->
<div class="flex mt-4 bg-slate-100 dark:bg-slate-700 rounded-lg p-1">
<button
@click="switchTab('scan')"
:class="[currentTab === 'scan' ? 'bg-white dark:bg-slate-600 shadow' : '']"
class="flex-1 py-2 text-sm font-medium rounded-md transition text-slate-700 dark:text-slate-200"
>
Scannen
</button>
<button
@click="switchTab('search')"
:class="[currentTab === 'search' ? 'bg-white dark:bg-slate-600 shadow' : '']"
class="flex-1 py-2 text-sm font-medium rounded-md transition text-slate-700 dark:text-slate-200"
>
Suche
</button>
<button
@click="switchTab('history')"
:class="[currentTab === 'history' ? 'bg-white dark:bg-slate-600 shadow' : '']"
class="flex-1 py-2 text-sm font-medium rounded-md transition text-slate-700 dark:text-slate-200"
>
Verlauf
</button>
</div>
</header>
<!-- Content -->
<main class="flex-grow overflow-y-auto">
<!-- SCAN TAB -->
<div v-if="currentTab === 'scan'" class="p-4 space-y-4">
<!-- Scanner or Article View -->
<div v-if="!scannedArticle" class="space-y-4">
<!-- QR Scanner -->
<div id="qr-reader" class="w-full rounded-lg overflow-hidden bg-black"></div>
<!-- Scanner Error -->
<div v-if="scannerError" class="p-4 bg-red-50 dark:bg-red-900/30 rounded-lg">
<p class="text-sm text-red-600 dark:text-red-400">{{ scannerError }}</p>
<button @click="startScanner" class="mt-2 text-sm font-medium text-primary">
Erneut versuchen
</button>
</div>
<p class="text-center text-sm text-slate-500 dark:text-slate-400">
QR-Code scannen oder Artikel suchen
</p>
</div>
<!-- Scanned Article -->
<div v-else class="space-y-4">
<!-- Already Scanned Warning -->
<div v-if="alreadyScannedWarning" class="p-4 bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 rounded-lg">
<div class="flex items-start">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-amber-500 mr-2 flex-shrink-0 mt-0.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
<div>
<p class="font-medium text-amber-800 dark:text-amber-300">Bereits gescannt</p>
<p class="text-sm text-amber-700 dark:text-amber-400 mt-1">
Menge: {{ alreadyScannedWarning.countedQuantity }}<br>
Von: {{ alreadyScannedWarning.scannedBy }}<br>
Am: {{ alreadyScannedWarning.scannedAt }}
</p>
</div>
</div>
</div>
<!-- Article Info -->
<div class="bg-white dark:bg-slate-800 p-4 rounded-lg shadow-sm">
<h3 class="font-bold text-lg text-slate-800 dark:text-white">
{{ scannedArticle.title }}
</h3>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">
Art.-Nr.: {{ scannedArticle.articleNumber }}
</p>
<p v-if="scannedArticle.categoryName" class="text-sm text-slate-500 dark:text-slate-400">
Kategorie: {{ scannedArticle.categoryName }}
</p>
</div>
<!-- Quantity Input -->
<div class="bg-white dark:bg-slate-800 p-4 rounded-lg shadow-sm">
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Menge ({{ scannedArticle.unit || 'Stk.' }})
</label>
<div
@click="showKeypad = true"
class="w-full p-4 text-3xl font-bold text-center border-2 border-slate-300 dark:border-slate-600 rounded-lg cursor-pointer hover:border-primary transition text-slate-800 dark:text-white"
>
{{ quantity }}
</div>
</div>
<!-- Optional Fields -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Regal</label>
<input v-model="rack" type="text" class="w-full p-3 border border-slate-300 dark:border-slate-600 rounded-lg dark:bg-slate-800 dark:text-white" placeholder="z.B. A1">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Fach</label>
<input v-model="shelf" type="text" class="w-full p-3 border border-slate-300 dark:border-slate-600 rounded-lg dark:bg-slate-800 dark:text-white" placeholder="z.B. 3">
</div>
</div>
<!-- Action Buttons -->
<div class="space-y-2">
<button
v-if="alreadyScannedWarning"
@click="submitScan(false)"
:disabled="!canSubmit"
class="w-full py-4 bg-green-600 text-white font-bold rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
Zur Menge addieren
</button>
<button
v-if="alreadyScannedWarning"
@click="submitScan(true)"
:disabled="!canSubmit"
class="w-full py-4 bg-amber-600 text-white font-bold rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
Überschreiben
</button>
<button
v-if="!alreadyScannedWarning"
@click="submitScan(false)"
:disabled="!canSubmit"
class="w-full py-4 bg-green-600 text-white font-bold rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ isLoading ? 'Speichert...' : 'Speichern' }}
</button>
<button @click="cancelScan" class="w-full py-3 text-slate-600 dark:text-slate-400 font-medium">
Abbrechen
</button>
</div>
</div>
</div>
<!-- SEARCH TAB -->
<div v-else-if="currentTab === 'search'" class="p-4 space-y-4">
<div class="sticky top-0 bg-slate-100 dark:bg-slate-900 pb-2 space-y-3">
<input
v-model="searchQuery"
@input="searchArticles"
type="search"
placeholder="Artikel suchen..."
class="w-full p-3 border border-slate-300 dark:border-slate-600 rounded-lg dark:bg-slate-800 dark:text-white"
>
<select
v-model="selectedCategory"
@change="searchArticles"
class="w-full p-3 border border-slate-300 dark:border-slate-600 rounded-lg dark:bg-slate-800 dark:text-white"
>
<option :value="0">Alle Kategorien</option>
<option v-for="cat in categories" :key="cat.id" :value="cat.id">{{ cat.name }}</option>
</select>
</div>
<div v-if="isSearching" class="text-center py-8">
<div class="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full mx-auto"></div>
</div>
<div v-else-if="searchResults.length === 0" class="text-center py-8">
<p class="text-slate-500 dark:text-slate-400">
{{ searchQuery.length < 2 ? 'Mindestens 2 Zeichen eingeben' : 'Keine Artikel gefunden' }}
</p>
</div>
<div v-else class="space-y-2">
<div
v-for="article in searchResults"
:key="article.id"
@click="selectSearchResult(article)"
class="bg-white dark:bg-slate-800 p-3 rounded-lg shadow-sm cursor-pointer hover:shadow-md active:scale-[0.98] transition"
>
<p class="font-medium text-slate-800 dark:text-white">{{ article.title }}</p>
<p class="text-sm text-slate-500 dark:text-slate-400">{{ article.articleNumber }}</p>
</div>
</div>
</div>
<!-- HISTORY TAB -->
<div v-else-if="currentTab === 'history'" class="p-4">
<div v-if="isLoadingHistory" class="space-y-3">
<div v-for="i in 5" :key="i" class="bg-white dark:bg-slate-800 p-3 rounded-lg shadow-sm animate-pulse">
<div class="h-4 bg-slate-200 dark:bg-slate-700 rounded w-3/4 mb-2"></div>
<div class="h-3 bg-slate-200 dark:bg-slate-700 rounded w-1/2"></div>
</div>
</div>
<div v-else-if="recentScans.length === 0" class="text-center py-8">
<p class="text-slate-500 dark:text-slate-400">Noch keine Scans</p>
</div>
<div v-else class="space-y-2">
<div v-for="scan in recentScans" :key="scan.id" class="bg-white dark:bg-slate-800 p-3 rounded-lg shadow-sm">
<div class="flex justify-between items-start">
<div>
<p class="font-medium text-slate-800 dark:text-white">{{ scan.articleTitle }}</p>
<p class="text-sm text-slate-500 dark:text-slate-400">{{ scan.articleNumber }}</p>
</div>
<div class="text-right">
<p class="font-bold text-slate-800 dark:text-white">{{ scan.countedQuantity }} {{ scan.unit }}</p>
<p class="text-xs text-slate-400">{{ scan.scannedAt }}</p>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- Custom Keypad Modal -->
<transition name="slide-up">
<div v-if="showKeypad" class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-end">
<div class="bg-white dark:bg-slate-800 w-full rounded-t-2xl p-4 safe-area-bottom">
<div class="flex justify-between items-center mb-4">
<button @click="clearQuantity" class="px-4 py-2 text-red-500 font-medium">C</button>
<div class="text-2xl font-bold text-slate-800 dark:text-white">{{ quantity }}</div>
<button @click="showKeypad = false" class="px-4 py-2 text-primary font-medium">Fertig</button>
</div>
<div class="grid grid-cols-3 gap-2">
<button v-for="d in ['1','2','3','4','5','6','7','8','9','.','0']" :key="d" @click="appendDigit(d)" class="py-4 text-xl font-bold bg-slate-100 dark:bg-slate-700 rounded-lg active:bg-slate-200 dark:active:bg-slate-600 text-slate-800 dark:text-white">
{{ d }}
</button>
<button @click="deleteDigit" class="py-4 text-xl font-bold bg-slate-100 dark:bg-slate-700 rounded-lg active:bg-slate-200 dark:active:bg-slate-600 text-slate-800 dark:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2M3 12l6.414 6.414a2 2 0 001.414.586H19a2 2 0 002-2V7a2 2 0 00-2-2h-8.172a2 2 0 00-1.414.586L3 12z" />
</svg>
</button>
</div>
</div>
</div>
</transition>
</div>
`
};

View File

@@ -1,266 +0,0 @@
/**
* StocktakeList Component
*
* Displays a list of active stocktakes that the user can participate in.
* Includes settings menu with theme toggle and logout.
*/
import { api } from '/mobile/shared/auth.js';
export default {
name: 'StocktakeList',
emits: ['select', 'logout', 'set-theme'],
props: {
user: {
type: Object,
default: null
},
theme: {
type: String,
default: 'system'
}
},
setup(props, { emit }) {
const { ref, onMounted } = Vue;
// State
const stocktakes = ref([]);
const isLoading = ref(true);
const error = ref('');
const isSettingsOpen = ref(false);
// Fetch stocktakes
const fetchStocktakes = async () => {
isLoading.value = true;
error.value = '';
try {
const result = await api.get('WarehouseStocktake/getActiveStocktakes');
if (result.success) {
stocktakes.value = result.stocktakes;
} else {
error.value = result.error || 'Fehler beim Laden';
}
} catch (e) {
error.value = 'Netzwerkfehler';
} finally {
isLoading.value = false;
}
};
const selectStocktake = (stocktake) => {
emit('select', stocktake);
};
const handleLogout = () => {
isSettingsOpen.value = false;
emit('logout');
};
const setTheme = (newTheme) => {
emit('set-theme', newTheme);
};
onMounted(() => {
fetchStocktakes();
});
return {
stocktakes,
isLoading,
error,
isSettingsOpen,
fetchStocktakes,
selectStocktake,
handleLogout,
setTheme
};
},
template: `
<div class="flex flex-col h-full bg-slate-100 dark:bg-slate-900">
<!-- Overlay for settings -->
<transition name="fade">
<div v-if="isSettingsOpen" @click="isSettingsOpen = false" class="overlay"></div>
</transition>
<!-- Header -->
<header class="bg-white dark:bg-slate-800 shadow-sm p-4 flex-shrink-0 z-10">
<div class="flex items-center justify-between">
<!-- Refresh Button -->
<button
@click="fetchStocktakes"
class="p-2 rounded-full text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 active:bg-slate-200 dark:active:bg-slate-600 transition"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" :class="{ 'animate-spin': isLoading }" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<!-- Logo -->
<div>
<img src="/assets/images/xinon-full-transparent.png" class="h-8 w-auto dark:hidden" alt="Logo">
<img src="/assets/images/xinon-full-transparent-white.png" class="h-8 w-auto hidden dark:block" alt="Logo">
</div>
<!-- Settings Button -->
<button
@click="isSettingsOpen = true"
class="p-2 rounded-full text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 active:bg-slate-200 dark:active:bg-slate-600 transition"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div>
<!-- Title -->
<h1 class="text-xl font-bold text-slate-800 dark:text-white mt-4">
Aktive Inventuren
</h1>
<p v-if="user" class="text-sm text-slate-500 dark:text-slate-400">
Angemeldet als {{ user.name }}
</p>
</header>
<!-- Content -->
<main class="flex-grow overflow-y-auto p-4">
<!-- Loading State -->
<div v-if="isLoading" class="space-y-3">
<div v-for="i in 3" :key="i" class="bg-white dark:bg-slate-800 p-4 rounded-lg shadow animate-pulse">
<div class="h-5 bg-slate-200 dark:bg-slate-700 rounded w-3/4 mb-2"></div>
<div class="h-4 bg-slate-200 dark:bg-slate-700 rounded w-1/2 mb-3"></div>
<div class="h-3 bg-slate-200 dark:bg-slate-700 rounded w-1/3"></div>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="text-center py-8">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto text-red-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-slate-500 dark:text-slate-400 mb-4">{{ error }}</p>
<button @click="fetchStocktakes" class="px-4 py-2 bg-primary text-white rounded-lg font-medium">
Erneut versuchen
</button>
</div>
<!-- Empty State -->
<div v-else-if="stocktakes.length === 0" class="text-center py-8">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto text-slate-300 dark:text-slate-600 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<p class="text-slate-500 dark:text-slate-400">Keine aktiven Inventuren</p>
</div>
<!-- Stocktake List -->
<div v-else class="space-y-3">
<div
v-for="stocktake in stocktakes"
:key="stocktake.id"
@click="selectStocktake(stocktake)"
class="bg-white dark:bg-slate-800 p-4 rounded-lg shadow-sm cursor-pointer hover:shadow-md active:scale-[0.98] transition"
>
<div class="flex justify-between items-start">
<div>
<h3 class="font-bold text-slate-800 dark:text-white">
{{ stocktake.title || 'Inventur #' + stocktake.stocktakeNumber }}
</h3>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">
{{ stocktake.locationName }}
</p>
</div>
<div class="text-right">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
Aktiv
</span>
</div>
</div>
<div class="flex justify-between items-center mt-3 pt-3 border-t border-slate-100 dark:border-slate-700">
<div class="text-sm text-slate-500 dark:text-slate-400">
<span class="font-medium text-slate-700 dark:text-slate-300">{{ stocktake.totalScannedItems || 0 }}</span>
Artikel gescannt
</div>
<div class="text-xs text-slate-400 dark:text-slate-500">
{{ stocktake.startedAt || 'Nicht gestartet' }}
</div>
</div>
</div>
</div>
</main>
<!-- Settings Panel -->
<transition name="slide">
<div v-if="isSettingsOpen" class="fixed inset-y-0 right-0 w-80 max-w-full bg-white dark:bg-slate-800 shadow-xl z-20 flex flex-col">
<div class="p-4 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between">
<h2 class="text-lg font-bold text-slate-800 dark:text-white">Einstellungen</h2>
<button @click="isSettingsOpen = false" class="p-2 rounded-full hover:bg-slate-100 dark:hover:bg-slate-700">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex-grow overflow-y-auto p-4 space-y-6">
<!-- User Info -->
<div v-if="user" class="pb-4 border-b border-slate-200 dark:border-slate-700">
<p class="text-sm text-slate-500 dark:text-slate-400">Angemeldet als</p>
<p class="font-medium text-slate-800 dark:text-white">{{ user.name }}</p>
</div>
<!-- Theme Settings -->
<div>
<h3 class="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">Farbschema</h3>
<div class="grid grid-cols-3 gap-2">
<button
@click="setTheme('light')"
:class="{'ring-2 ring-primary': theme === 'light'}"
class="p-2 text-sm font-medium rounded-lg border border-slate-300 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700 transition text-slate-700 dark:text-slate-300"
>
Hell
</button>
<button
@click="setTheme('dark')"
:class="{'ring-2 ring-primary': theme === 'dark'}"
class="p-2 text-sm font-medium rounded-lg border border-slate-300 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700 transition text-slate-700 dark:text-slate-300"
>
Dunkel
</button>
<button
@click="setTheme('system')"
:class="{'ring-2 ring-primary': theme === 'system'}"
class="p-2 text-sm font-medium rounded-lg border border-slate-300 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700 transition text-slate-700 dark:text-slate-300"
>
System
</button>
</div>
</div>
</div>
<!-- Footer -->
<div class="p-4 border-t border-slate-200 dark:border-slate-700 space-y-4">
<button
@click="handleLogout"
class="w-full flex items-center justify-center px-4 py-3 text-red-600 dark:text-red-400 font-medium rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Abmelden
</button>
<div class="text-center">
<img src="/assets/images/xinon-sm.png" class="h-8 mx-auto mb-2" alt="XINON">
<p class="text-xs text-slate-400 dark:text-slate-500">
powered by XINON GmbH
</p>
</div>
</div>
</div>
</transition>
</div>
`
};

View File

@@ -1,27 +0,0 @@
{
"name": "Lager Inventur",
"short_name": "Inventur",
"description": "PWA für Lager-Inventur und Artikelerfassung",
"start_url": "/MobileApp/WarehouseStocktake",
"display": "standalone",
"background_color": "#0f172a",
"theme_color": "#005384",
"orientation": "portrait-primary",
"scope": "/MobileApp/",
"icons": [
{
"src": "/assets/images/xinon-sm-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/assets/images/xinon-sm-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"categories": ["business", "productivity"],
"lang": "de-DE"
}

View File

@@ -1,109 +0,0 @@
/**
* Warehouse Stocktake PWA - Service Worker
*
* Provides basic caching for the app shell (offline-first for static assets).
* API calls are always fetched from network.
*/
const CACHE_NAME = 'warehouse-stocktake-v1';
// Static assets to cache for offline use
const ASSETS_TO_CACHE = [
'/MobileApp/WarehouseStocktake',
'/mobile/warehouse-stocktake/app.js',
'/mobile/warehouse-stocktake/app.css',
'/mobile/warehouse-stocktake/components/LoginScreen.js',
'/mobile/warehouse-stocktake/components/StocktakeList.js',
'/mobile/warehouse-stocktake/components/Scanner.js',
'/mobile/shared/auth.js',
'/mobile/shared/base.css',
'/assets/images/xinon-full-transparent.png',
'/assets/images/xinon-full-transparent-white.png',
'/assets/images/xinon-sm.png',
'/assets/images/favicon.ico'
];
// Install event - cache static assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('[SW] Caching app shell');
return cache.addAll(ASSETS_TO_CACHE);
})
.then(() => self.skipWaiting())
);
});
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name !== CACHE_NAME)
.map(name => {
console.log('[SW] Deleting old cache:', name);
return caches.delete(name);
})
);
})
.then(() => self.clients.claim())
);
});
// Fetch event - network first for API, cache first for assets
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests
if (request.method !== 'GET') {
return;
}
// Skip CDN requests (Vue, Tailwind, etc.)
if (url.hostname !== self.location.hostname) {
return;
}
// API calls - always go to network (no caching)
if (url.pathname.startsWith('/MobileApp/') &&
!url.pathname.endsWith('/WarehouseStocktake') &&
url.pathname !== '/MobileApp/WarehouseStocktake') {
return;
}
// Static assets - cache first, fallback to network
event.respondWith(
caches.match(request)
.then(cachedResponse => {
if (cachedResponse) {
// Return cached version, but also update cache in background
event.waitUntil(
fetch(request)
.then(networkResponse => {
if (networkResponse.ok) {
caches.open(CACHE_NAME)
.then(cache => cache.put(request, networkResponse));
}
})
.catch(() => {})
);
return cachedResponse;
}
// Not in cache - fetch from network and cache
return fetch(request)
.then(networkResponse => {
if (networkResponse.ok) {
const responseToCache = networkResponse.clone();
caches.open(CACHE_NAME)
.then(cache => cache.put(request, responseToCache));
}
return networkResponse;
});
})
);
});