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