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,10 +0,0 @@
<?php
$appConfig = [
'title' => 'Lager Inventur',
'appName' => 'Inventur',
'manifestPath' => '/mobile/warehouse-stocktake/manifest.json',
'appJsPath' => '/mobile/warehouse-stocktake/app.js',
'swPath' => '/mobile/warehouse-stocktake/sw.js',
'additionalStylesheets' => ['/mobile/warehouse-stocktake/app.css'],
];
require __DIR__ . '/Base.php';

View File

@@ -1,973 +0,0 @@
<?php
$openreplayUserId = '';
$openreplayWorkerId = '';
if (class_exists('mfUser') && class_exists('mfLoginController') && mfLoginController::isLoggedIn()) {
$user = mfUser::singleton();
if ($user && $user->id) {
$openreplayUserId = !empty($user->email) ? $user->email : (string) $user->id;
$openreplayWorkerId = (string) $user->id;
}
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Inventur Scanner</title>
<link rel="shortcut icon" href="/assets/images/favicon.ico">
<meta name="theme-color" content="#005384">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/vue@3.4.27/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios@1.7.2/dist/axios.min.js"></script>
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
<script>
window.TT_CONFIG = <?= json_encode($JSGlobals ?? []) ?>;
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
'primary': '#005384',
'secondary': '#fac41b',
},
}
}
}
</script>
<!-- OpenReplay Session Recording -->
<script>
var initOpts = {
projectKey: "96MdXVcId8Ph3eOirMWj",
ingestPoint: "https://openreplay.xinon.at/ingest",
defaultInputMode: 2,
obscureTextNumbers: false,
obscureTextEmails: true,
};
var startOpts = { userID: <?= json_encode($openreplayUserId) ?> };
(function(A,s,a,y,e,r){
r=window.OpenReplay=[e,r,y,[s-1, e]];
s=document.createElement('script');s.src=A;s.async=!a;
document.getElementsByTagName('head')[0].appendChild(s);
r.start=function(v){r.push([0])};
r.stop=function(v){r.push([1])};
r.setUserID=function(id){r.push([2,id])};
r.setUserAnonymousID=function(id){r.push([3,id])};
r.setMetadata=function(k,v){r.push([4,k,v])};
r.event=function(k,p,i){r.push([5,k,p,i])};
r.issue=function(k,p){r.push([6,k,p])};
r.isActive=function(){return false};
r.getSessionToken=function(){};
})("//static.openreplay.com/17.0.0/openreplay.js",1,0,initOpts,startOpts);
window.OpenReplay.setMetadata('userType', 'internal');
window.OpenReplay.setMetadata('app', 'warehouse-stocktake-pwa');
window.OpenReplay.setMetadata('workerId', <?= json_encode($openreplayWorkerId) ?>);
</script>
<style>
html, body {
overscroll-behavior: none;
touch-action: manipulation;
}
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease-in-out; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.slide-up-enter-active, .slide-up-leave-active { transition: transform 0.3s ease-out, opacity 0.3s ease-out; }
.slide-up-enter-from, .slide-up-leave-to { transform: translateY(100%); opacity: 0; }
#qr-reader {
width: 100%;
border: none !important;
}
#qr-reader video {
border-radius: 12px;
}
#qr-reader__scan_region {
background: transparent !important;
}
#qr-reader__dashboard {
display: none !important;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.animate-pulse { animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
.success-flash {
animation: successFlash 0.5s ease-out;
}
@keyframes successFlash {
0% { background-color: rgb(34, 197, 94); }
100% { background-color: transparent; }
}
/* Custom numpad styles */
.numpad-btn {
min-height: 52px;
font-size: 1.25rem;
font-weight: 600;
border-radius: 12px;
transition: all 0.15s ease;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.numpad-btn:active {
transform: scale(0.95);
}
/* Warning banner animation - intense without causing overflow */
@keyframes pulse-warning {
0%, 100% {
opacity: 1;
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.7), inset 0 0 0 0 rgba(251, 191, 36, 0.2);
border-color: rgb(251, 191, 36);
background-color: rgb(254, 243, 199);
}
50% {
opacity: 0.95;
box-shadow: 0 0 20px 5px rgba(251, 191, 36, 0.6), inset 0 0 20px 0 rgba(251, 191, 36, 0.2);
border-color: rgb(245, 158, 11);
background-color: rgb(253, 230, 138);
}
}
.warning-pulse {
animation: pulse-warning 0.8s ease-in-out infinite;
}
.dark .warning-pulse {
animation: pulse-warning-dark 0.8s ease-in-out infinite;
}
@keyframes pulse-warning-dark {
0%, 100% {
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.5), inset 0 0 0 0 rgba(251, 191, 36, 0.1);
border-color: rgb(217, 119, 6);
}
50% {
box-shadow: 0 0 25px 8px rgba(251, 191, 36, 0.4), inset 0 0 15px 0 rgba(251, 191, 36, 0.15);
border-color: rgb(245, 158, 11);
}
}
</style>
</head>
<body class="bg-slate-100 dark:bg-slate-900 transition-colors duration-300">
<div id="app" class="min-h-screen"></div>
<script>
const { createApp, ref, reactive, computed, onMounted, onUnmounted, watch, nextTick } = Vue;
const app = createApp({
setup() {
// === STATE ===
const currentScreen = ref('stocktake-select'); // stocktake-select, scanner, manual-entry
const stocktakes = ref([]);
const selectedStocktake = ref(null);
const isLoading = ref(true);
const scannerActive = ref(false);
const cameraAvailable = ref(true);
const lastScan = ref(null);
const recentScans = ref([]);
const progress = reactive({ totalScanned: 0, myScanned: 0 });
const theme = ref(localStorage.getItem('theme') || 'system');
// Categories
const categories = ref([]);
const selectedCategory = ref(null);
const showCategoryBrowser = ref(false);
// Already scanned warning
const alreadyScannedWarning = reactive({
show: false,
existingItem: null,
});
// Form state
const manualForm = reactive({
show: false,
article: null,
quantity: '',
rack: '',
shelf: '',
note: '',
searchQuery: '',
searchResults: [],
searching: false,
showNumpad: false,
});
// Scanner instance
let html5QrCode = null;
const API_BASE = window.TT_CONFIG.BASE_PATH || '/WarehouseStocktakePWA';
const api = axios.create({ baseURL: API_BASE });
// === COMPUTED ===
const isDark = computed(() => {
if (theme.value === 'dark') return true;
if (theme.value === 'light') return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
});
// Check if mobile device for numpad display
const isMobile = computed(() => {
const ua = navigator.userAgent.toLowerCase();
return /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini|mobile/i.test(ua);
});
// === METHODS ===
const applyTheme = () => {
document.documentElement.classList.toggle('dark', isDark.value);
};
const setTheme = (newTheme) => {
theme.value = newTheme;
localStorage.setItem('theme', newTheme);
applyTheme();
};
const fetchStocktakes = async () => {
isLoading.value = true;
try {
const res = await api.get('/getActiveStocktakes');
if (res.data.success) {
stocktakes.value = res.data.stocktakes;
}
} catch (e) {
console.error('Failed to fetch stocktakes:', e);
} finally {
isLoading.value = false;
}
};
const fetchCategories = async () => {
try {
const res = await api.get('/getCategories');
if (res.data.success) {
categories.value = res.data.categories;
}
} catch (e) {
console.error('Failed to fetch categories:', e);
}
};
const selectStocktake = async (stocktake) => {
selectedStocktake.value = stocktake;
currentScreen.value = 'scanner';
await fetchMyScans();
await fetchProgress();
await fetchCategories();
await nextTick();
startScanner();
};
const backToList = () => {
stopScanner();
selectedStocktake.value = null;
currentScreen.value = 'stocktake-select';
fetchStocktakes();
};
const startScanner = async () => {
if (html5QrCode) {
await stopScanner();
}
try {
html5QrCode = new Html5Qrcode("qr-reader");
await html5QrCode.start(
{ facingMode: "environment" },
{
fps: 10,
qrbox: { width: 250, height: 250 },
aspectRatio: 1.0,
},
onScanSuccess,
onScanFailure
);
scannerActive.value = true;
cameraAvailable.value = true;
} catch (err) {
console.error('Scanner start error:', err);
cameraAvailable.value = false;
// Don't show alert - user can use manual search
}
};
const stopScanner = async () => {
if (html5QrCode && scannerActive.value) {
try {
await html5QrCode.stop();
} catch (e) {
console.error('Scanner stop error:', e);
}
}
scannerActive.value = false;
};
const onScanSuccess = async (decodedText) => {
// Prevent rapid duplicate scans
if (lastScan.value && lastScan.value.code === decodedText && Date.now() - lastScan.value.time < 2000) {
return;
}
lastScan.value = { code: decodedText, time: Date.now() };
// Vibrate feedback
if (navigator.vibrate) {
navigator.vibrate(100);
}
// Lookup article
try {
const res = await api.get('/getArticle', { params: { code: decodedText } });
if (res.data.success) {
await handleArticleSelected(res.data.article);
} else {
showToast(res.data.message || 'Artikel nicht gefunden', 'error');
}
} catch (e) {
showToast('Fehler beim Laden des Artikels', 'error');
}
};
const onScanFailure = (error) => {
// Ignore - continuous scanning
};
const handleArticleSelected = async (article) => {
await stopScanner();
// Reset form fields
manualForm.article = article;
manualForm.quantity = '';
manualForm.rack = '';
manualForm.shelf = '';
manualForm.note = '';
manualForm.showNumpad = true;
// Check if already scanned
try {
const checkRes = await api.get('/checkAlreadyScanned', {
params: {
stocktakeId: selectedStocktake.value.id,
articleId: article.id
}
});
if (checkRes.data.success && checkRes.data.alreadyScanned) {
alreadyScannedWarning.show = true;
alreadyScannedWarning.existingItem = checkRes.data.existingItem;
} else {
alreadyScannedWarning.show = false;
alreadyScannedWarning.existingItem = null;
}
} catch (e) {
console.error('Check already scanned error:', e);
}
manualForm.show = true;
};
const submitScan = async (overwrite = false) => {
const qty = parseFloat(manualForm.quantity) || 0;
if (!manualForm.article || qty <= 0) {
showToast('Bitte Menge angeben', 'error');
return;
}
try {
const payload = {
stocktakeId: selectedStocktake.value.id,
articleId: manualForm.article.id,
quantity: qty,
rack: manualForm.rack || null,
shelf: manualForm.shelf || null,
note: manualForm.note || null,
};
if (overwrite && alreadyScannedWarning.existingItem) {
payload.overwrite = true;
payload.overwriteItemId = alreadyScannedWarning.existingItem.id;
}
const res = await api.post('/submitScan', payload);
if (res.data.success) {
showToast(res.data.message, 'success');
// Add to recent scans
recentScans.value.unshift({
...res.data.item,
scannedAt: new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }),
flash: true,
});
if (recentScans.value.length > 20) {
recentScans.value.pop();
}
// Update progress
if (!overwrite) {
progress.totalScanned++;
progress.myScanned++;
}
closeForm();
} else {
showToast(res.data.message || 'Fehler beim Speichern', 'error');
}
} catch (e) {
showToast('Netzwerkfehler', 'error');
}
};
const closeForm = async () => {
manualForm.show = false;
manualForm.article = null;
manualForm.quantity = '';
manualForm.searchQuery = '';
manualForm.searchResults = [];
manualForm.showNumpad = false;
alreadyScannedWarning.show = false;
alreadyScannedWarning.existingItem = null;
selectedCategory.value = null;
showCategoryBrowser.value = false;
await nextTick();
if (cameraAvailable.value) {
startScanner();
}
};
const openManualEntry = async () => {
await stopScanner();
// Reset form fields
manualForm.show = true;
manualForm.article = null;
manualForm.quantity = '';
manualForm.rack = '';
manualForm.shelf = '';
manualForm.note = '';
manualForm.searchQuery = '';
manualForm.searchResults = [];
manualForm.showNumpad = false;
alreadyScannedWarning.show = false;
selectedCategory.value = null;
showCategoryBrowser.value = false;
};
const openCategoryBrowser = () => {
showCategoryBrowser.value = true;
selectedCategory.value = null;
manualForm.searchResults = [];
};
const selectCategory = async (category) => {
selectedCategory.value = category;
showCategoryBrowser.value = false;
manualForm.searching = true;
try {
const res = await api.get('/searchArticles', {
params: { categoryId: category.id, query: manualForm.searchQuery || '' }
});
if (res.data.success) {
manualForm.searchResults = res.data.articles;
}
} catch (e) {
console.error('Category search error:', e);
} finally {
manualForm.searching = false;
}
};
const clearCategoryFilter = () => {
selectedCategory.value = null;
manualForm.searchResults = [];
if (manualForm.searchQuery.length >= 2) {
searchArticles();
}
};
const searchArticles = async () => {
if (manualForm.searchQuery.length < 2 && !selectedCategory.value) {
manualForm.searchResults = [];
return;
}
manualForm.searching = true;
try {
const params = { query: manualForm.searchQuery };
if (selectedCategory.value) {
params.categoryId = selectedCategory.value.id;
}
const res = await api.get('/searchArticles', { params });
if (res.data.success) {
manualForm.searchResults = res.data.articles;
}
} catch (e) {
console.error('Search error:', e);
} finally {
manualForm.searching = false;
}
};
const selectSearchResult = async (article) => {
await handleArticleSelected(article);
};
const fetchMyScans = async () => {
if (!selectedStocktake.value) return;
try {
const res = await api.get('/getMyScans', { params: { stocktakeId: selectedStocktake.value.id } });
if (res.data.success) {
recentScans.value = res.data.items;
}
} catch (e) {
console.error('Failed to fetch scans:', e);
}
};
const fetchProgress = async () => {
if (!selectedStocktake.value) return;
try {
const res = await api.get('/getProgress', { params: { stocktakeId: selectedStocktake.value.id } });
if (res.data.success) {
progress.totalScanned = res.data.progress.totalScanned;
progress.myScanned = res.data.progress.myScanned;
}
} catch (e) {
console.error('Failed to fetch progress:', e);
}
};
// Numpad functions
const numpadInput = (val) => {
if (val === 'clear') {
manualForm.quantity = '';
} else if (val === 'backspace') {
manualForm.quantity = String(manualForm.quantity).slice(0, -1);
} else if (val === '+') {
const current = parseFloat(manualForm.quantity) || 0;
manualForm.quantity = String(current + 1);
} else if (val === '-') {
const current = parseFloat(manualForm.quantity) || 0;
if (current > 1) {
manualForm.quantity = String(current - 1);
}
} else if (val === '.') {
if (!String(manualForm.quantity).includes('.')) {
manualForm.quantity = (manualForm.quantity || '0') + '.';
}
} else {
manualForm.quantity = (manualForm.quantity || '') + val;
}
};
// Toast notification
const toast = reactive({ show: false, message: '', type: 'success' });
let toastTimeout = null;
const showToast = (message, type = 'success') => {
toast.message = message;
toast.type = type;
toast.show = true;
if (toastTimeout) clearTimeout(toastTimeout);
toastTimeout = setTimeout(() => { toast.show = false; }, 3000);
};
const logout = () => {
window.location.href = API_BASE + '/logout';
};
// === LIFECYCLE ===
onMounted(() => {
applyTheme();
fetchStocktakes();
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyTheme);
});
onUnmounted(() => {
stopScanner();
});
// Debounced search
let searchTimeout = null;
watch(() => manualForm.searchQuery, (val) => {
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(searchArticles, 300);
});
return {
currentScreen, stocktakes, selectedStocktake, isLoading, scannerActive, cameraAvailable,
recentScans, progress, theme, manualForm, toast, categories, selectedCategory,
showCategoryBrowser, alreadyScannedWarning, isMobile,
selectStocktake, backToList, submitScan, closeForm,
openManualEntry, selectSearchResult, setTheme, logout,
openCategoryBrowser, selectCategory, clearCategoryFilter, numpadInput,
};
},
template: `
<div class="min-h-screen flex flex-col">
<!-- Header -->
<header class="bg-primary text-white px-4 py-3 flex items-center justify-between sticky top-0 z-30 shadow-lg">
<div class="flex items-center">
<button v-if="currentScreen === 'scanner'" @click="backToList" class="mr-3 p-1">
<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>
<div>
<h1 class="font-bold text-lg">Inventur Scanner</h1>
<p v-if="selectedStocktake" class="text-xs text-white/80">{{ selectedStocktake.title }}</p>
</div>
</div>
<div class="flex items-center space-x-2">
<button @click="setTheme(theme === 'dark' ? 'light' : 'dark')" class="p-2 rounded-full hover:bg-white/10">
<svg v-if="theme === 'dark'" 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>
<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="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>
</button>
<button @click="logout" class="p-2 rounded-full hover:bg-white/10">
<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="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>
</button>
</div>
</header>
<!-- Main Content -->
<main class="flex-1 overflow-auto">
<!-- Stocktake Selection Screen -->
<div v-if="currentScreen === 'stocktake-select'" class="p-4">
<div v-if="isLoading" class="space-y-4">
<div v-for="i in 3" :key="i" class="bg-white dark:bg-slate-800 rounded-xl p-4 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"></div>
</div>
</div>
<div v-else-if="stocktakes.length === 0" class="text-center py-20">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mx-auto text-slate-300 dark:text-slate-600 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="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>
<button @click="fetchStocktakes" class="mt-4 px-4 py-2 bg-primary text-white rounded-lg">
Aktualisieren
</button>
</div>
<div v-else class="space-y-4">
<p class="text-sm text-slate-500 dark:text-slate-400 mb-2">Aktive Inventuren auswählen:</p>
<div v-for="st in stocktakes" :key="st.id"
@click="selectStocktake(st)"
class="bg-white dark:bg-slate-800 rounded-xl p-4 shadow cursor-pointer active:scale-[0.98] transition">
<div class="flex justify-between items-start">
<div>
<h3 class="font-bold text-slate-800 dark:text-white">{{ st.title }}</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">{{ st.locationName }}</p>
<p class="text-xs text-slate-400 dark:text-slate-500 mt-1">{{ st.stocktakeNumber }}</p>
</div>
<div class="text-right">
<span class="inline-block bg-secondary text-primary text-xs font-bold px-2 py-1 rounded-full">
{{ st.totalScannedItems }} Artikel
</span>
<p v-if="st.startedAt" class="text-xs text-slate-400 mt-1">{{ st.startedAt }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Scanner Screen -->
<div v-if="currentScreen === 'scanner'" class="flex flex-col h-full">
<!-- Progress Bar -->
<div class="bg-white dark:bg-slate-800 px-4 py-2 flex justify-between items-center text-sm border-b dark:border-slate-700">
<span class="text-slate-600 dark:text-slate-300">
<strong class="text-primary dark:text-secondary">{{ progress.totalScanned }}</strong> gesamt
</span>
<span class="text-slate-600 dark:text-slate-300">
<strong class="text-green-600 dark:text-green-400">{{ progress.myScanned }}</strong> von mir
</span>
</div>
<!-- Scanner View -->
<div v-if="!manualForm.show" class="p-4">
<div v-if="cameraAvailable" class="relative bg-black rounded-xl overflow-hidden mb-4">
<div id="qr-reader" class="w-full"></div>
<div v-if="!scannerActive" class="absolute inset-0 flex items-center justify-center bg-slate-900/80">
<div class="text-center text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto mb-2 animate-pulse" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<p>Kamera wird gestartet...</p>
</div>
</div>
</div>
<div v-else class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-4 mb-4">
<div class="flex items-start">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-amber-500 mr-2 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<p class="text-amber-800 dark:text-amber-200 font-medium">Kamera nicht verfügbar</p>
<p class="text-amber-700 dark:text-amber-300 text-sm">Verwenden Sie die manuelle Suche unten.</p>
</div>
</div>
</div>
<button @click="openManualEntry"
class="w-full py-3 bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-200 rounded-xl font-medium">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
Manuelle Suche
</button>
</div>
<!-- Entry Form -->
<transition name="slide-up">
<div v-if="manualForm.show" class="flex-1 bg-white dark:bg-slate-800 p-4 overflow-auto">
<!-- Search (if no article selected) -->
<div v-if="!manualForm.article" class="space-y-4">
<!-- Category Filter -->
<div v-if="selectedCategory" class="flex items-center bg-primary/10 dark:bg-primary/20 rounded-lg p-2 mb-2">
<span class="text-sm text-primary dark:text-secondary font-medium flex-1">
Kategorie: {{ selectedCategory.name }}
</span>
<button @click="clearCategoryFilter" class="p-1 text-primary dark:text-secondary">
<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>
<!-- Search Input -->
<div class="relative">
<input v-model="manualForm.searchQuery" type="text" inputmode="search"
placeholder="Artikelnummer oder Name..."
class="w-full px-4 py-3 rounded-xl border dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:ring-2 focus:ring-primary">
<div v-if="manualForm.searching" class="absolute right-3 top-3">
<svg class="animate-spin h-5 w-5 text-slate-400" 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>
</div>
</div>
<!-- Category Browser Button -->
<button @click="openCategoryBrowser"
class="w-full py-3 bg-primary/10 dark:bg-primary/20 text-primary dark:text-secondary rounded-xl font-medium 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="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
Nach Kategorie durchsuchen
</button>
<!-- Category Browser Modal -->
<div v-if="showCategoryBrowser" class="fixed inset-0 bg-black/50 z-50 flex items-end">
<div class="bg-white dark:bg-slate-800 w-full rounded-t-2xl max-h-[70vh] flex flex-col">
<div class="p-4 border-b dark:border-slate-700 flex justify-between items-center">
<h3 class="font-bold text-lg dark:text-white">Kategorie wählen</h3>
<button @click="showCategoryBrowser = false" class="p-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 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="overflow-y-auto flex-1 p-4">
<div class="grid grid-cols-2 gap-2">
<div v-for="cat in categories" :key="cat.id"
@click="selectCategory(cat)"
class="p-3 bg-slate-50 dark:bg-slate-700 rounded-lg cursor-pointer active:bg-slate-100 dark:active:bg-slate-600 text-center">
<p class="font-medium text-slate-800 dark:text-white text-sm">{{ cat.name }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Search Results -->
<div v-if="manualForm.searchResults.length" class="space-y-2 max-h-64 overflow-auto">
<div v-for="article in manualForm.searchResults" :key="article.id"
@click="selectSearchResult(article)"
class="p-3 bg-slate-50 dark:bg-slate-700 rounded-lg cursor-pointer active:bg-slate-100 dark:active:bg-slate-600">
<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>
<button @click="closeForm" class="w-full py-3 bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-200 rounded-xl">
Abbrechen
</button>
</div>
<!-- Article Form (if article selected) -->
<div v-else class="space-y-4">
<!-- Already Scanned Warning -->
<div v-if="alreadyScannedWarning.show" class="bg-amber-100 dark:bg-amber-900/30 border-2 border-amber-400 dark:border-amber-600 rounded-xl p-4 warning-pulse">
<div class="flex items-start">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-amber-600 dark:text-amber-400 mr-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<p class="font-bold text-amber-800 dark:text-amber-200">Bereits gescannt!</p>
<p class="text-sm text-amber-700 dark:text-amber-300 mt-1">
Dieser Artikel wurde bereits erfasst:
<br><strong>{{ alreadyScannedWarning.existingItem.countedQuantity }} Stk.</strong>
von {{ alreadyScannedWarning.existingItem.scannedBy }}
({{ alreadyScannedWarning.existingItem.scannedAt }})
</p>
<p class="text-xs text-amber-600 dark:text-amber-400 mt-2">
Geben Sie die neue Menge ein und klicken Sie auf "Überschreiben".
</p>
</div>
</div>
</div>
<div class="bg-slate-50 dark:bg-slate-700 rounded-xl p-4">
<p class="font-bold text-lg text-slate-800 dark:text-white">{{ manualForm.article.title }}</p>
<p class="text-sm text-slate-500 dark:text-slate-400">{{ manualForm.article.articleNumber }}</p>
<p v-if="manualForm.article.categoryName" class="text-xs text-slate-400 mt-1">{{ manualForm.article.categoryName }}</p>
</div>
<!-- Quantity with Custom Numpad -->
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Menge ({{ manualForm.article.unit }}) *
</label>
<div class="text-center bg-slate-100 dark:bg-slate-700 rounded-xl p-4 mb-3">
<span class="text-4xl font-bold text-primary dark:text-secondary">
{{ manualForm.quantity || '0' }}
</span>
<span class="text-xl text-slate-500 ml-2">{{ manualForm.article.unit }}</span>
</div>
<!-- Desktop: regular input field -->
<div v-if="!isMobile" class="mt-2">
<input v-model="manualForm.quantity" type="number" inputmode="decimal" min="0.01" step="0.01"
placeholder="Menge eingeben..."
class="w-full px-4 py-3 text-xl font-bold text-center rounded-xl border dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:ring-2 focus:ring-primary">
</div>
<!-- Numpad (only on mobile devices) -->
<div v-if="manualForm.showNumpad && isMobile" class="grid grid-cols-4 gap-2">
<button @click="numpadInput('1')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">1</button>
<button @click="numpadInput('2')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">2</button>
<button @click="numpadInput('3')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">3</button>
<button @click="numpadInput('+')" class="numpad-btn bg-green-500 text-white">+</button>
<button @click="numpadInput('4')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">4</button>
<button @click="numpadInput('5')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">5</button>
<button @click="numpadInput('6')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">6</button>
<button @click="numpadInput('-')" class="numpad-btn bg-red-500 text-white">-</button>
<button @click="numpadInput('7')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">7</button>
<button @click="numpadInput('8')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">8</button>
<button @click="numpadInput('9')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">9</button>
<button @click="numpadInput('backspace')" class="numpad-btn bg-slate-300 dark:bg-slate-500 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>
<button @click="numpadInput('.')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">.</button>
<button @click="numpadInput('0')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">0</button>
<button @click="numpadInput('clear')" class="numpad-btn bg-slate-300 dark:bg-slate-500 text-slate-800 dark:text-white col-span-2">C</button>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Regal</label>
<input v-model="manualForm.rack" type="text"
class="w-full px-4 py-2 rounded-xl border dark:border-slate-600 dark:bg-slate-700 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Fach</label>
<input v-model="manualForm.shelf" type="text"
class="w-full px-4 py-2 rounded-xl border dark:border-slate-600 dark:bg-slate-700 dark:text-white">
</div>
</div>
<!-- Action Buttons -->
<div class="space-y-2 pt-4">
<div class="flex space-x-3">
<button @click="closeForm" class="flex-1 py-3 bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-200 rounded-xl font-medium">
Abbrechen
</button>
<!-- Show "Speichern" only when NOT already scanned -->
<button v-if="!alreadyScannedWarning.show"
@click="submitScan(false)"
class="flex-1 py-3 bg-green-600 text-white rounded-xl font-bold">
Speichern
</button>
<!-- Show "Überschreiben" only when already scanned -->
<button v-else
@click="submitScan(true)"
class="flex-1 py-3 bg-amber-500 text-white rounded-xl font-bold">
Überschreiben
</button>
</div>
</div>
</div>
</div>
</transition>
<!-- Recent Scans List -->
<div v-if="!manualForm.show && recentScans.length" class="flex-1 bg-white dark:bg-slate-800 overflow-auto">
<div class="px-4 py-2 bg-slate-50 dark:bg-slate-700/50 sticky top-0">
<p class="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Letzte Scans</p>
</div>
<div class="divide-y dark:divide-slate-700">
<div v-for="(item, index) in recentScans" :key="item.id"
:class="{ 'success-flash': item.flash }"
class="px-4 py-3 flex justify-between items-center">
<div class="min-w-0 flex-1">
<p class="font-medium text-slate-800 dark:text-white truncate">{{ item.articleTitle }}</p>
<p class="text-xs text-slate-500 dark:text-slate-400">
{{ item.articleNumber }}
<span v-if="item.rack || item.shelf" class="ml-2">
| {{ item.rack || '-' }} / {{ item.shelf || '-' }}
</span>
</p>
</div>
<div class="text-right ml-4">
<p class="font-bold text-primary dark:text-secondary">{{ item.countedQuantity }} {{ item.unit }}</p>
<p class="text-xs text-slate-400">{{ item.scannedAt }}</p>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- Toast Notification -->
<transition name="fade">
<div v-if="toast.show"
:class="toast.type === 'success' ? 'bg-green-600' : 'bg-red-600'"
class="fixed bottom-20 left-4 right-4 p-4 rounded-xl text-white text-center font-medium shadow-lg z-50">
{{ toast.message }}
</div>
</transition>
</div>
`
});
app.mount('#app');
</script>
</body>
</html>

View File

@@ -1,494 +0,0 @@
<?php
class WarehouseStocktakePWAController extends mfBaseController {
protected $user;
protected function init() {
$this->needlogin = true;
$me = mfValuecache::singleton()->get("me");
if (!$me) {
$me = new User();
$me->loadMe();
mfValuecache::singleton()->set("me", $me);
}
$this->me = $me;
$this->user = $me;
$this->layout()->set("me", $me);
// Check permission
if (!$me->can('WarehouseUser')) {
$this->redirect("Dashboard");
}
}
/**
* Main PWA View
*/
public function indexAction() {
$this->layout()->setTemplate("VueViews/WarehouseStocktakePWA");
$this->layout()->set("JSGlobals", [
'BASE_PATH' => '/WarehouseStocktakePWA',
'USER_ID' => $this->user->id,
'USER_NAME' => $this->user->name,
]);
}
/**
* Logout
*/
protected function logoutAction() {
mfLoginController::staticLogout();
$this->redirect('/WarehouseStocktakePWA');
}
/**
* Get active stocktakes that user can participate in
*/
protected function getActiveStocktakesAction() {
$stocktakes = WarehouseStocktakeModel::getAll(['status' => 'in_progress']);
$result = [];
foreach ($stocktakes as $stocktake) {
$location = $stocktake->getLocation();
$result[] = [
'id' => $stocktake->id,
'stocktakeNumber' => $stocktake->stocktakeNumber,
'title' => $stocktake->title,
'locationName' => $location ? $location->title : 'Unbekannt',
'totalScannedItems' => $stocktake->totalScannedItems,
'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null,
];
}
self::returnJson(['success' => true, 'stocktakes' => $result]);
}
/**
* Get stocktake details
*/
protected function getStocktakeAction() {
$id = intval($this->request->id);
if (!$id) {
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
return;
}
$stocktake = WarehouseStocktakeModel::get($id);
if (!$stocktake) {
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
return;
}
$location = $stocktake->getLocation();
self::returnJson([
'success' => true,
'stocktake' => [
'id' => $stocktake->id,
'stocktakeNumber' => $stocktake->stocktakeNumber,
'title' => $stocktake->title,
'status' => $stocktake->status,
'locationId' => $stocktake->warehouseLocationId,
'locationName' => $location ? $location->title : 'Unbekannt',
'totalScannedItems' => $stocktake->totalScannedItems,
'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null,
]
]);
}
/**
* Get article by QR code or article number
*/
protected function getArticleAction() {
$code = $this->request->code;
if (!$code) {
self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']);
return;
}
$articleId = null;
// Try to parse QR code format: WA:articleId:articleNumber (Warehouse Article)
// Also accept WH: for backwards compatibility
if (preg_match('/^(?:WA|WH):(\d+):/', $code, $matches)) {
$articleId = intval($matches[1]);
} else {
// Try to find by article number
$article = WarehouseArticleModel::getFirst(['articleNumber' => $code]);
if ($article) {
$articleId = $article->id;
}
}
if (!$articleId) {
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
return;
}
$article = WarehouseArticleModel::get($articleId);
if (!$article) {
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
return;
}
// Get category name
$category = WarehouseCategory::get($article->category_id);
self::returnJson([
'success' => true,
'article' => [
'id' => $article->id,
'articleNumber' => $article->articleNumber,
'title' => $article->title,
'description' => $article->description ?? '',
'unit' => $article->unit ?? 'Stk.',
'categoryName' => $category ? $category->name : '',
]
]);
}
/**
* Search articles by text with optional category filter
*/
protected function searchArticlesAction() {
$query = $this->request->query ?? '';
$categoryId = intval($this->request->categoryId ?? 0);
$db = FronkDB::singleton();
$conditions = ["(isEndOfLife IS NULL OR isEndOfLife = 0)"];
if ($query && strlen($query) >= 2) {
$escapedQuery = $db->escape($query);
$conditions[] = "(articleNumber LIKE '%{$escapedQuery}%' OR title LIKE '%{$escapedQuery}%' OR description LIKE '%{$escapedQuery}%')";
}
if ($categoryId > 0) {
$conditions[] = "category_id = {$categoryId}";
}
if (count($conditions) === 1 && !$categoryId) {
self::returnJson(['success' => true, 'articles' => []]);
return;
}
$whereClause = implode(' AND ', $conditions);
$result = $db->query("SELECT id, articleNumber, title, unit, category_id
FROM WarehouseArticle
WHERE {$whereClause}
ORDER BY title ASC
LIMIT 50");
$articles = [];
while ($row = $result->fetch_assoc()) {
$articles[] = [
'id' => intval($row['id']),
'articleNumber' => $row['articleNumber'],
'title' => $row['title'],
'unit' => $row['unit'] ?? 'Stk.',
'categoryId' => intval($row['category_id'] ?? 0),
];
}
self::returnJson(['success' => true, 'articles' => $articles]);
}
/**
* Get all categories for browsing
*/
protected function getCategoriesAction() {
$db = FronkDB::singleton();
$res = $db->query("SELECT id, name FROM WarehouseCategory ORDER BY name ASC");
$categories = [];
while ($row = $res->fetch_assoc()) {
$categories[] = [
'id' => intval($row['id']),
'name' => $row['name'],
];
}
self::returnJson(['success' => true, 'categories' => $categories]);
}
/**
* Check if article is already scanned in stocktake
*/
protected function checkAlreadyScannedAction() {
$stocktakeId = intval($this->request->stocktakeId);
$articleId = intval($this->request->articleId);
if (!$stocktakeId || !$articleId) {
self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']);
return;
}
$existing = WarehouseStocktakeItemModel::getFirst([
'stocktakeId' => $stocktakeId,
'articleId' => $articleId,
'overwrittenById' => null
]);
if ($existing) {
$db = FronkDB::singleton();
$scannedByResult = $db->query("SELECT name FROM Worker WHERE id = {$existing->scannedBy}");
$scannedByRow = $scannedByResult->fetch_assoc();
self::returnJson([
'success' => true,
'alreadyScanned' => true,
'existingItem' => [
'id' => $existing->id,
'countedQuantity' => $existing->countedQuantity,
'scannedAt' => $existing->scannedAt ? date('d.m.Y H:i', $existing->scannedAt) : null,
'scannedBy' => $scannedByRow ? $scannedByRow['name'] : 'Unbekannt',
]
]);
} else {
self::returnJson(['success' => true, 'alreadyScanned' => false]);
}
}
/**
* Submit a scanned item
*/
protected function submitScanAction() {
$postData = json_decode(file_get_contents('php://input'), true) ?? [];
$stocktakeId = intval($postData['stocktakeId'] ?? 0);
$articleId = intval($postData['articleId'] ?? 0);
$quantity = floatval($postData['quantity'] ?? 0);
$rack = $postData['rack'] ?? null;
$shelf = $postData['shelf'] ?? null;
$note = $postData['note'] ?? null;
$overwrite = boolval($postData['overwrite'] ?? false);
$overwriteItemId = intval($postData['overwriteItemId'] ?? 0);
if (!$stocktakeId || !$articleId) {
self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']);
return;
}
if ($quantity <= 0) {
self::returnJson(['success' => false, 'message' => 'Menge muss größer als 0 sein']);
return;
}
// Verify stocktake exists and is in progress
$stocktake = WarehouseStocktakeModel::get($stocktakeId);
if (!$stocktake) {
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
return;
}
if ($stocktake->status !== 'in_progress') {
self::returnJson(['success' => false, 'message' => 'Inventur ist nicht aktiv']);
return;
}
// Verify article exists
$article = WarehouseArticleModel::get($articleId);
if (!$article) {
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
return;
}
$db = FronkDB::singleton();
// If overwrite mode is enabled, mark existing item as overwritten
if ($overwrite && $overwriteItemId) {
// Create new entry
$db->query("INSERT INTO WarehouseStocktakeItem
(stocktakeId, articleId, countedQuantity, rack, shelf, note, scannedAt, scannedBy, createBy, `create`)
VALUES ({$stocktakeId}, {$articleId}, {$quantity},
" . ($rack ? "'{$db->escape($rack)}'" : "NULL") . ",
" . ($shelf ? "'{$db->escape($shelf)}'" : "NULL") . ",
" . ($note ? "'{$db->escape($note)}'" : "NULL") . ",
" . time() . ", {$this->user->id}, {$this->user->id}, " . time() . ")");
$itemId = $db->insert_id;
// Mark old item as overwritten by new item
$db->query("UPDATE WarehouseStocktakeItem SET overwrittenById = {$itemId} WHERE id = {$overwriteItemId}");
$finalQuantity = $quantity;
$isOverwrite = true;
// Log the overwrite
WarehouseStocktakeLogModel::log($stocktakeId, 'overwritten', $itemId, [
'articleId' => $articleId,
'articleNumber' => $article->articleNumber,
'articleTitle' => $article->title,
'quantity' => $quantity,
'overwrittenItemId' => $overwriteItemId,
]);
// Update stocktake progress (don't increase count since we're replacing)
$stocktake->updateProgress();
self::returnJson([
'success' => true,
'message' => "'{$article->title}' überschrieben ({$quantity} {$article->unit})",
'item' => [
'id' => $itemId,
'articleId' => $articleId,
'articleNumber' => $article->articleNumber,
'articleTitle' => $article->title,
'countedQuantity' => $finalQuantity,
'unit' => $article->unit ?? 'Stk.',
'rack' => $rack,
'shelf' => $shelf,
'isOverwrite' => true,
]
]);
return;
}
// Check if this article was already scanned in this stocktake (non-overwritten)
$existing = WarehouseStocktakeItemModel::getFirst([
'stocktakeId' => $stocktakeId,
'articleId' => $articleId,
'overwrittenById' => null
]);
if ($existing) {
// Update existing entry - add to quantity
$newQuantity = $existing->countedQuantity + $quantity;
$db->query("UPDATE WarehouseStocktakeItem SET
countedQuantity = {$newQuantity},
rack = " . ($rack ? "'{$db->escape($rack)}'" : "rack") . ",
shelf = " . ($shelf ? "'{$db->escape($shelf)}'" : "shelf") . ",
scannedAt = " . time() . ",
scannedBy = {$this->user->id}
WHERE id = {$existing->id}");
$itemId = $existing->id;
$finalQuantity = $newQuantity;
$isUpdate = true;
} else {
// Create new entry
$db->query("INSERT INTO WarehouseStocktakeItem
(stocktakeId, articleId, countedQuantity, rack, shelf, note, scannedAt, scannedBy, createBy, `create`)
VALUES ({$stocktakeId}, {$articleId}, {$quantity},
" . ($rack ? "'{$db->escape($rack)}'" : "NULL") . ",
" . ($shelf ? "'{$db->escape($shelf)}'" : "NULL") . ",
" . ($note ? "'{$db->escape($note)}'" : "NULL") . ",
" . time() . ", {$this->user->id}, {$this->user->id}, " . time() . ")");
$itemId = $db->insert_id;
$finalQuantity = $quantity;
$isUpdate = false;
}
// Update stocktake progress
$stocktake->updateProgress();
// Log the scan
WarehouseStocktakeLogModel::log($stocktakeId, 'scanned', $itemId, [
'articleId' => $articleId,
'articleNumber' => $article->articleNumber,
'articleTitle' => $article->title,
'quantity' => $quantity,
'totalQuantity' => $finalQuantity,
'rack' => $rack,
'shelf' => $shelf,
'isUpdate' => $isUpdate,
]);
self::returnJson([
'success' => true,
'message' => $isUpdate
? "Menge für '{$article->title}' erhöht auf {$finalQuantity}"
: "'{$article->title}' hinzugefügt ({$quantity} {$article->unit})",
'item' => [
'id' => $itemId,
'articleId' => $articleId,
'articleNumber' => $article->articleNumber,
'articleTitle' => $article->title,
'countedQuantity' => $finalQuantity,
'unit' => $article->unit ?? 'Stk.',
'rack' => $rack,
'shelf' => $shelf,
'isUpdate' => $isUpdate,
]
]);
}
/**
* Get recent scans for current user in a stocktake
*/
protected function getMyScansAction() {
$stocktakeId = intval($this->request->stocktakeId);
if (!$stocktakeId) {
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
return;
}
$db = FronkDB::singleton();
$result = $db->query("SELECT si.*, wa.articleNumber, wa.title as articleTitle, wa.unit
FROM WarehouseStocktakeItem si
JOIN WarehouseArticle wa ON wa.id = si.articleId
WHERE si.stocktakeId = {$stocktakeId}
AND si.scannedBy = {$this->user->id}
ORDER BY si.scannedAt DESC
LIMIT 50");
$items = [];
while ($row = $result->fetch_assoc()) {
$items[] = [
'id' => intval($row['id']),
'articleId' => intval($row['articleId']),
'articleNumber' => $row['articleNumber'],
'articleTitle' => $row['articleTitle'],
'countedQuantity' => floatval($row['countedQuantity']),
'unit' => $row['unit'] ?? 'Stk.',
'rack' => $row['rack'],
'shelf' => $row['shelf'],
'scannedAt' => $row['scannedAt'] ? date('H:i', $row['scannedAt']) : null,
];
}
self::returnJson(['success' => true, 'items' => $items]);
}
/**
* Get progress stats
*/
protected function getProgressAction() {
$stocktakeId = intval($this->request->stocktakeId);
if (!$stocktakeId) {
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
return;
}
$stocktake = WarehouseStocktakeModel::get($stocktakeId);
if (!$stocktake) {
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
return;
}
$db = FronkDB::singleton();
// Total scanned items
$totalResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId}");
$totalRow = $totalResult->fetch_assoc();
$totalScanned = intval($totalRow['count']);
// My scanned items
$myResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId} AND scannedBy = {$this->user->id}");
$myRow = $myResult->fetch_assoc();
$myScanned = intval($myRow['count']);
self::returnJson([
'success' => true,
'progress' => [
'totalScanned' => $totalScanned,
'myScanned' => $myScanned,
'status' => $stocktake->status,
]
]);
}
}

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;
});
})
);
});