Merge branch 'master' into fronkdev

This commit is contained in:
Frank Schubert
2026-01-15 16:02:17 +01:00
95 changed files with 15612 additions and 473 deletions

View File

@@ -318,6 +318,9 @@ $pagination_entity_name = "Zu provisionierende CPEs";
<option value="FritzBox 6490 Cable" <?= ($product->cpeprovisioning->routertype == "FritzBox 6490 Cable") ? "selected='selected'" : "" ?>>
FritzBox 6490 Cable (Inet, Phone, IPTV)
</option>
<option value="FritzBox 6670 Cable" <?= ($product->cpeprovisioning->routertype == "FritzBox 6670 Cable") ? "selected='selected'" : "" ?>>
FritzBox 6670 Cable (Inet, Phone, IPTV)
</option>
<?php endif; ?>
</select>
</div>

View File

@@ -408,7 +408,7 @@ foreach ($devicesall as $deviceall) {
</div>
</div>
<?php
if ($devicesconfig->success == "true" && $devicesconfig->data > 0) {
if ($devicesconfig->success == "true" && $devicesconfig->data) {
?>
<div>
<table class="table table-sm">
@@ -825,7 +825,7 @@ foreach ($devicesall as $deviceall) {
if (data.success == false) {
$('#olt-body').text('Keine OLT/ONT Daten verfügbar');
} else {
$('#service-ports-h4').show();
$('#service-ports-h4').show();
$('#olt-body').append(`<div >
<table class="sp-table-border float-left" >
<thead><tr><td>OLT</td></tr></thead>

View File

@@ -0,0 +1,77 @@
<?php
/**
* MobileApp PWA View Template
*
* Main shell for the unified Mobile App.
* Vue handles internal navigation between modules.
*/
?>
<!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>Xinon Mobile</title>
<link rel="shortcut icon" href="/assets/images/favicon.ico">
<!-- PWA Configuration -->
<link rel="manifest" href="/mobile/manifest.json">
<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">
<meta name="apple-mobile-web-app-title" content="Xinon">
<!-- External Libraries (CDN) -->
<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://cdn.jsdelivr.net/npm/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
<!-- Shared Styles -->
<link rel="stylesheet" href="/mobile/shared/base.css">
<!-- App Configuration -->
<script>
window.TT_CONFIG = <?= json_encode($JSGlobals ?? []) ?>;
// Tailwind configuration
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
'primary': '#005384',
'secondary': '#fac41b',
},
}
}
};
</script>
</head>
<body class="transition-colors duration-300 overflow-hidden">
<div id="app" class="h-screen w-screen overflow-hidden antialiased">
<!-- Loading state while Vue initializes -->
<div class="flex items-center justify-center h-full bg-slate-100 dark:bg-slate-900">
<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>
</div>
<!-- Load Vue app as ES module -->
<script type="module" src="/mobile/app.js"></script>
<!-- Register Service Worker -->
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/mobile/sw.js')
.then(reg => console.log('SW registered:', reg.scope))
.catch(err => console.log('SW registration failed:', err));
});
}
</script>
</body>
</html>

View File

@@ -0,0 +1,78 @@
<?php
/**
* Warehouse Stocktake PWA View Template
*
* This is the HTML shell for the Warehouse Stocktake PWA.
* The Vue application is loaded via ES modules.
*/
?>
<!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>Lager Inventur</title>
<link rel="shortcut icon" href="/assets/images/favicon.ico">
<!-- PWA Configuration -->
<link rel="manifest" href="/mobile/warehouse-stocktake/manifest.json">
<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">
<meta name="apple-mobile-web-app-title" content="Inventur">
<!-- External Libraries (CDN) -->
<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://cdn.jsdelivr.net/npm/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
<!-- Shared Styles -->
<link rel="stylesheet" href="/mobile/shared/base.css">
<link rel="stylesheet" href="/mobile/warehouse-stocktake/app.css">
<!-- App Configuration -->
<script>
window.TT_CONFIG = <?= json_encode($JSGlobals ?? []) ?>;
// Tailwind configuration
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
'primary': '#005384',
'secondary': '#fac41b',
},
}
}
};
</script>
</head>
<body class="transition-colors duration-300 overflow-hidden">
<div id="app" class="h-screen w-screen overflow-hidden antialiased">
<!-- Loading state while Vue initializes -->
<div class="flex items-center justify-center h-full bg-slate-100 dark:bg-slate-900">
<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>
</div>
<!-- Load Vue app as ES module -->
<script type="module" src="/mobile/warehouse-stocktake/app.js"></script>
<!-- Register Service Worker -->
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/mobile/warehouse-stocktake/sw.js')
.then(reg => console.log('SW registered:', reg.scope))
.catch(err => console.log('SW registration failed:', err));
});
}
</script>
</body>
</html>

View File

@@ -722,12 +722,12 @@ if (!empty(trim($pops->vlan_ipv6)))
$('[data-toggle="popover"]').popover();
});
</script>
<script src="https://cdn.jsdelivr.net/npm/handsontable/dist/handsontable.full.min.js"></script>
<!--<script src="https://cdn.jsdelivr.net/npm/handsontable/dist/handsontable.full.min.js"></script>-->
<script type="text/javascript" src="<?= self::getResourcePath() ?>assets/js/print.min.js?<?= $git_merge_ts ?>"></script>
<script type="text/javascript" src="<?= self::getResourcePath() ?>js/pages/pop/detail.js?<?= $git_merge_ts ?>"></script>
<script type="text/javascript" src="<?= self::getResourcePath() ?>js/pages/pop/fiber.js?<?= $git_merge_ts ?>"></script>
<script type="text/javascript"
src="<?= self::getResourcePath() ?>js/pages/pop/fibertable.js?<?= $git_merge_ts ?>"></script>
<script type="text/javascript" src="<?= self::getResourcePath() ?>js/pages/Pop/detail/detail.js?<?= $git_merge_ts ?>"></script>
<script type="text/javascript" src="<?= self::getResourcePath() ?>js/pages/Pop/detail/fiber.js?<?= $git_merge_ts ?>"></script>
<!--script type="text/javascript"
src="<?= self::getResourcePath() ?>js/pages/pop/fibertable.js?<?= $git_merge_ts ?>"></script-->
<script type="text/javascript"
src="<?= self::getResourcePath() ?>assets/js/datatables-std.js?<?= $git_merge_ts ?>"></script>

View File

@@ -888,7 +888,7 @@ $pagination_entity_name = "Vorbestellungen";
Filter-Vorlagen <i class="fas fa-caret-down"></i>
</button>
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton">
<li><a class="dropdown-item" href="<?=self::getUrl("Preorder", "Index", ["filter" => ["status" => [21,22,23,24,25], "rimo_workorder" => 1, "borderpoint" => "all"]])?>">Gelöschte Bestellungen mit Workorder</a></li>
<li><a class="dropdown-item" href="<?=self::getUrl("Preorder", "Index", ["filter" => ["status" => [21,22,23,24,25,930,931,932,933,934], "rimo_workorder" => 1, "rimo_workorder_status" => ["Clarify","Accepted","Plan released","Assigned","Executed","Documented","Review"]]])?>">Gelöschte Bestellungen mit Workorder</a></li>
<li><a class="dropdown-item" href="<?=self::getUrl("Preorder", "Index", ["filter" => ["preorder_status_flags" => [4], "connection_type" => ["apartment", "apartment-building"], "borderpoint" => "all"]])?>">Wohnung - Verkabelung erledigt</a></li>
<li><a class="dropdown-item" href="<?=self::getUrl("Preorder", "Index", ["filter" => ["status" => [21,22,23,24,25]]])?>">Storniert</a></li>
<?php if ($me->isAdmin() || $me->address->id == 209): ?>

View File

@@ -0,0 +1,935 @@
<?php
?>
<!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>
<style>
html, body {
overscroll-behavior: none;
touch-action: manipulation;
}
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease-in-out; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.slide-up-enter-active, .slide-up-leave-active { transition: transform 0.3s ease-out, opacity 0.3s ease-out; }
.slide-up-enter-from, .slide-up-leave-to { transform: translateY(100%); opacity: 0; }
#qr-reader {
width: 100%;
border: none !important;
}
#qr-reader video {
border-radius: 12px;
}
#qr-reader__scan_region {
background: transparent !important;
}
#qr-reader__dashboard {
display: none !important;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.animate-pulse { animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
.success-flash {
animation: successFlash 0.5s ease-out;
}
@keyframes successFlash {
0% { background-color: rgb(34, 197, 94); }
100% { background-color: transparent; }
}
/* Custom numpad styles */
.numpad-btn {
min-height: 52px;
font-size: 1.25rem;
font-weight: 600;
border-radius: 12px;
transition: all 0.15s ease;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.numpad-btn:active {
transform: scale(0.95);
}
/* Warning banner animation - intense without causing overflow */
@keyframes pulse-warning {
0%, 100% {
opacity: 1;
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.7), inset 0 0 0 0 rgba(251, 191, 36, 0.2);
border-color: rgb(251, 191, 36);
background-color: rgb(254, 243, 199);
}
50% {
opacity: 0.95;
box-shadow: 0 0 20px 5px rgba(251, 191, 36, 0.6), inset 0 0 20px 0 rgba(251, 191, 36, 0.2);
border-color: rgb(245, 158, 11);
background-color: rgb(253, 230, 138);
}
}
.warning-pulse {
animation: pulse-warning 0.8s ease-in-out infinite;
}
.dark .warning-pulse {
animation: pulse-warning-dark 0.8s ease-in-out infinite;
}
@keyframes pulse-warning-dark {
0%, 100% {
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.5), inset 0 0 0 0 rgba(251, 191, 36, 0.1);
border-color: rgb(217, 119, 6);
}
50% {
box-shadow: 0 0 25px 8px rgba(251, 191, 36, 0.4), inset 0 0 15px 0 rgba(251, 191, 36, 0.15);
border-color: rgb(245, 158, 11);
}
}
</style>
</head>
<body class="bg-slate-100 dark:bg-slate-900 transition-colors duration-300">
<div id="app" class="min-h-screen"></div>
<script>
const { createApp, ref, reactive, computed, onMounted, onUnmounted, watch, nextTick } = Vue;
const app = createApp({
setup() {
// === STATE ===
const currentScreen = ref('stocktake-select'); // stocktake-select, scanner, manual-entry
const stocktakes = ref([]);
const selectedStocktake = ref(null);
const isLoading = ref(true);
const scannerActive = ref(false);
const cameraAvailable = ref(true);
const lastScan = ref(null);
const recentScans = ref([]);
const progress = reactive({ totalScanned: 0, myScanned: 0 });
const theme = ref(localStorage.getItem('theme') || 'system');
// Categories
const categories = ref([]);
const selectedCategory = ref(null);
const showCategoryBrowser = ref(false);
// Already scanned warning
const alreadyScannedWarning = reactive({
show: false,
existingItem: null,
});
// Form state
const manualForm = reactive({
show: false,
article: null,
quantity: '',
rack: '',
shelf: '',
note: '',
searchQuery: '',
searchResults: [],
searching: false,
showNumpad: false,
});
// Scanner instance
let html5QrCode = null;
const API_BASE = window.TT_CONFIG.BASE_PATH || '/WarehouseStocktakePWA';
const api = axios.create({ baseURL: API_BASE });
// === COMPUTED ===
const isDark = computed(() => {
if (theme.value === 'dark') return true;
if (theme.value === 'light') return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
});
// Check if mobile device for numpad display
const isMobile = computed(() => {
const ua = navigator.userAgent.toLowerCase();
return /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini|mobile/i.test(ua);
});
// === METHODS ===
const applyTheme = () => {
document.documentElement.classList.toggle('dark', isDark.value);
};
const setTheme = (newTheme) => {
theme.value = newTheme;
localStorage.setItem('theme', newTheme);
applyTheme();
};
const fetchStocktakes = async () => {
isLoading.value = true;
try {
const res = await api.get('/getActiveStocktakes');
if (res.data.success) {
stocktakes.value = res.data.stocktakes;
}
} catch (e) {
console.error('Failed to fetch stocktakes:', e);
} finally {
isLoading.value = false;
}
};
const fetchCategories = async () => {
try {
const res = await api.get('/getCategories');
if (res.data.success) {
categories.value = res.data.categories;
}
} catch (e) {
console.error('Failed to fetch categories:', e);
}
};
const selectStocktake = async (stocktake) => {
selectedStocktake.value = stocktake;
currentScreen.value = 'scanner';
await fetchMyScans();
await fetchProgress();
await fetchCategories();
await nextTick();
startScanner();
};
const backToList = () => {
stopScanner();
selectedStocktake.value = null;
currentScreen.value = 'stocktake-select';
fetchStocktakes();
};
const startScanner = async () => {
if (html5QrCode) {
await stopScanner();
}
try {
html5QrCode = new Html5Qrcode("qr-reader");
await html5QrCode.start(
{ facingMode: "environment" },
{
fps: 10,
qrbox: { width: 250, height: 250 },
aspectRatio: 1.0,
},
onScanSuccess,
onScanFailure
);
scannerActive.value = true;
cameraAvailable.value = true;
} catch (err) {
console.error('Scanner start error:', err);
cameraAvailable.value = false;
// Don't show alert - user can use manual search
}
};
const stopScanner = async () => {
if (html5QrCode && scannerActive.value) {
try {
await html5QrCode.stop();
} catch (e) {
console.error('Scanner stop error:', e);
}
}
scannerActive.value = false;
};
const onScanSuccess = async (decodedText) => {
// Prevent rapid duplicate scans
if (lastScan.value && lastScan.value.code === decodedText && Date.now() - lastScan.value.time < 2000) {
return;
}
lastScan.value = { code: decodedText, time: Date.now() };
// Vibrate feedback
if (navigator.vibrate) {
navigator.vibrate(100);
}
// Lookup article
try {
const res = await api.get('/getArticle', { params: { code: decodedText } });
if (res.data.success) {
await handleArticleSelected(res.data.article);
} else {
showToast(res.data.message || 'Artikel nicht gefunden', 'error');
}
} catch (e) {
showToast('Fehler beim Laden des Artikels', 'error');
}
};
const onScanFailure = (error) => {
// Ignore - continuous scanning
};
const handleArticleSelected = async (article) => {
await stopScanner();
// Reset form fields
manualForm.article = article;
manualForm.quantity = '';
manualForm.rack = '';
manualForm.shelf = '';
manualForm.note = '';
manualForm.showNumpad = true;
// Check if already scanned
try {
const checkRes = await api.get('/checkAlreadyScanned', {
params: {
stocktakeId: selectedStocktake.value.id,
articleId: article.id
}
});
if (checkRes.data.success && checkRes.data.alreadyScanned) {
alreadyScannedWarning.show = true;
alreadyScannedWarning.existingItem = checkRes.data.existingItem;
} else {
alreadyScannedWarning.show = false;
alreadyScannedWarning.existingItem = null;
}
} catch (e) {
console.error('Check already scanned error:', e);
}
manualForm.show = true;
};
const submitScan = async (overwrite = false) => {
const qty = parseFloat(manualForm.quantity) || 0;
if (!manualForm.article || qty <= 0) {
showToast('Bitte Menge angeben', 'error');
return;
}
try {
const payload = {
stocktakeId: selectedStocktake.value.id,
articleId: manualForm.article.id,
quantity: qty,
rack: manualForm.rack || null,
shelf: manualForm.shelf || null,
note: manualForm.note || null,
};
if (overwrite && alreadyScannedWarning.existingItem) {
payload.overwrite = true;
payload.overwriteItemId = alreadyScannedWarning.existingItem.id;
}
const res = await api.post('/submitScan', payload);
if (res.data.success) {
showToast(res.data.message, 'success');
// Add to recent scans
recentScans.value.unshift({
...res.data.item,
scannedAt: new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }),
flash: true,
});
if (recentScans.value.length > 20) {
recentScans.value.pop();
}
// Update progress
if (!overwrite) {
progress.totalScanned++;
progress.myScanned++;
}
closeForm();
} else {
showToast(res.data.message || 'Fehler beim Speichern', 'error');
}
} catch (e) {
showToast('Netzwerkfehler', 'error');
}
};
const closeForm = async () => {
manualForm.show = false;
manualForm.article = null;
manualForm.quantity = '';
manualForm.searchQuery = '';
manualForm.searchResults = [];
manualForm.showNumpad = false;
alreadyScannedWarning.show = false;
alreadyScannedWarning.existingItem = null;
selectedCategory.value = null;
showCategoryBrowser.value = false;
await nextTick();
if (cameraAvailable.value) {
startScanner();
}
};
const openManualEntry = async () => {
await stopScanner();
// Reset form fields
manualForm.show = true;
manualForm.article = null;
manualForm.quantity = '';
manualForm.rack = '';
manualForm.shelf = '';
manualForm.note = '';
manualForm.searchQuery = '';
manualForm.searchResults = [];
manualForm.showNumpad = false;
alreadyScannedWarning.show = false;
selectedCategory.value = null;
showCategoryBrowser.value = false;
};
const openCategoryBrowser = () => {
showCategoryBrowser.value = true;
selectedCategory.value = null;
manualForm.searchResults = [];
};
const selectCategory = async (category) => {
selectedCategory.value = category;
showCategoryBrowser.value = false;
manualForm.searching = true;
try {
const res = await api.get('/searchArticles', {
params: { categoryId: category.id, query: manualForm.searchQuery || '' }
});
if (res.data.success) {
manualForm.searchResults = res.data.articles;
}
} catch (e) {
console.error('Category search error:', e);
} finally {
manualForm.searching = false;
}
};
const clearCategoryFilter = () => {
selectedCategory.value = null;
manualForm.searchResults = [];
if (manualForm.searchQuery.length >= 2) {
searchArticles();
}
};
const searchArticles = async () => {
if (manualForm.searchQuery.length < 2 && !selectedCategory.value) {
manualForm.searchResults = [];
return;
}
manualForm.searching = true;
try {
const params = { query: manualForm.searchQuery };
if (selectedCategory.value) {
params.categoryId = selectedCategory.value.id;
}
const res = await api.get('/searchArticles', { params });
if (res.data.success) {
manualForm.searchResults = res.data.articles;
}
} catch (e) {
console.error('Search error:', e);
} finally {
manualForm.searching = false;
}
};
const selectSearchResult = async (article) => {
await handleArticleSelected(article);
};
const fetchMyScans = async () => {
if (!selectedStocktake.value) return;
try {
const res = await api.get('/getMyScans', { params: { stocktakeId: selectedStocktake.value.id } });
if (res.data.success) {
recentScans.value = res.data.items;
}
} catch (e) {
console.error('Failed to fetch scans:', e);
}
};
const fetchProgress = async () => {
if (!selectedStocktake.value) return;
try {
const res = await api.get('/getProgress', { params: { stocktakeId: selectedStocktake.value.id } });
if (res.data.success) {
progress.totalScanned = res.data.progress.totalScanned;
progress.myScanned = res.data.progress.myScanned;
}
} catch (e) {
console.error('Failed to fetch progress:', e);
}
};
// Numpad functions
const numpadInput = (val) => {
if (val === 'clear') {
manualForm.quantity = '';
} else if (val === 'backspace') {
manualForm.quantity = String(manualForm.quantity).slice(0, -1);
} else if (val === '+') {
const current = parseFloat(manualForm.quantity) || 0;
manualForm.quantity = String(current + 1);
} else if (val === '-') {
const current = parseFloat(manualForm.quantity) || 0;
if (current > 1) {
manualForm.quantity = String(current - 1);
}
} else if (val === '.') {
if (!String(manualForm.quantity).includes('.')) {
manualForm.quantity = (manualForm.quantity || '0') + '.';
}
} else {
manualForm.quantity = (manualForm.quantity || '') + val;
}
};
// Toast notification
const toast = reactive({ show: false, message: '', type: 'success' });
let toastTimeout = null;
const showToast = (message, type = 'success') => {
toast.message = message;
toast.type = type;
toast.show = true;
if (toastTimeout) clearTimeout(toastTimeout);
toastTimeout = setTimeout(() => { toast.show = false; }, 3000);
};
const logout = () => {
window.location.href = API_BASE + '/logout';
};
// === LIFECYCLE ===
onMounted(() => {
applyTheme();
fetchStocktakes();
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyTheme);
});
onUnmounted(() => {
stopScanner();
});
// Debounced search
let searchTimeout = null;
watch(() => manualForm.searchQuery, (val) => {
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(searchArticles, 300);
});
return {
currentScreen, stocktakes, selectedStocktake, isLoading, scannerActive, cameraAvailable,
recentScans, progress, theme, manualForm, toast, categories, selectedCategory,
showCategoryBrowser, alreadyScannedWarning, isMobile,
selectStocktake, backToList, submitScan, closeForm,
openManualEntry, selectSearchResult, setTheme, logout,
openCategoryBrowser, selectCategory, clearCategoryFilter, numpadInput,
};
},
template: `
<div class="min-h-screen flex flex-col">
<!-- Header -->
<header class="bg-primary text-white px-4 py-3 flex items-center justify-between sticky top-0 z-30 shadow-lg">
<div class="flex items-center">
<button v-if="currentScreen === 'scanner'" @click="backToList" class="mr-3 p-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<div>
<h1 class="font-bold text-lg">Inventur Scanner</h1>
<p v-if="selectedStocktake" class="text-xs text-white/80">{{ selectedStocktake.title }}</p>
</div>
</div>
<div class="flex items-center space-x-2">
<button @click="setTheme(theme === 'dark' ? 'light' : 'dark')" class="p-2 rounded-full hover:bg-white/10">
<svg v-if="theme === 'dark'" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
</button>
<button @click="logout" class="p-2 rounded-full hover:bg-white/10">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
</header>
<!-- Main Content -->
<main class="flex-1 overflow-auto">
<!-- Stocktake Selection Screen -->
<div v-if="currentScreen === 'stocktake-select'" class="p-4">
<div v-if="isLoading" class="space-y-4">
<div v-for="i in 3" :key="i" class="bg-white dark:bg-slate-800 rounded-xl p-4 shadow animate-pulse">
<div class="h-5 bg-slate-200 dark:bg-slate-700 rounded w-3/4 mb-2"></div>
<div class="h-4 bg-slate-200 dark:bg-slate-700 rounded w-1/2"></div>
</div>
</div>
<div v-else-if="stocktakes.length === 0" class="text-center py-20">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mx-auto text-slate-300 dark:text-slate-600 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<p class="text-slate-500 dark:text-slate-400">Keine aktiven Inventuren</p>
<button @click="fetchStocktakes" class="mt-4 px-4 py-2 bg-primary text-white rounded-lg">
Aktualisieren
</button>
</div>
<div v-else class="space-y-4">
<p class="text-sm text-slate-500 dark:text-slate-400 mb-2">Aktive Inventuren auswählen:</p>
<div v-for="st in stocktakes" :key="st.id"
@click="selectStocktake(st)"
class="bg-white dark:bg-slate-800 rounded-xl p-4 shadow cursor-pointer active:scale-[0.98] transition">
<div class="flex justify-between items-start">
<div>
<h3 class="font-bold text-slate-800 dark:text-white">{{ st.title }}</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">{{ st.locationName }}</p>
<p class="text-xs text-slate-400 dark:text-slate-500 mt-1">{{ st.stocktakeNumber }}</p>
</div>
<div class="text-right">
<span class="inline-block bg-secondary text-primary text-xs font-bold px-2 py-1 rounded-full">
{{ st.totalScannedItems }} Artikel
</span>
<p v-if="st.startedAt" class="text-xs text-slate-400 mt-1">{{ st.startedAt }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Scanner Screen -->
<div v-if="currentScreen === 'scanner'" class="flex flex-col h-full">
<!-- Progress Bar -->
<div class="bg-white dark:bg-slate-800 px-4 py-2 flex justify-between items-center text-sm border-b dark:border-slate-700">
<span class="text-slate-600 dark:text-slate-300">
<strong class="text-primary dark:text-secondary">{{ progress.totalScanned }}</strong> gesamt
</span>
<span class="text-slate-600 dark:text-slate-300">
<strong class="text-green-600 dark:text-green-400">{{ progress.myScanned }}</strong> von mir
</span>
</div>
<!-- Scanner View -->
<div v-if="!manualForm.show" class="p-4">
<div v-if="cameraAvailable" class="relative bg-black rounded-xl overflow-hidden mb-4">
<div id="qr-reader" class="w-full"></div>
<div v-if="!scannerActive" class="absolute inset-0 flex items-center justify-center bg-slate-900/80">
<div class="text-center text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto mb-2 animate-pulse" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<p>Kamera wird gestartet...</p>
</div>
</div>
</div>
<div v-else class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-4 mb-4">
<div class="flex items-start">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-amber-500 mr-2 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<p class="text-amber-800 dark:text-amber-200 font-medium">Kamera nicht verfügbar</p>
<p class="text-amber-700 dark:text-amber-300 text-sm">Verwenden Sie die manuelle Suche unten.</p>
</div>
</div>
</div>
<button @click="openManualEntry"
class="w-full py-3 bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-200 rounded-xl font-medium">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
Manuelle Suche
</button>
</div>
<!-- Entry Form -->
<transition name="slide-up">
<div v-if="manualForm.show" class="flex-1 bg-white dark:bg-slate-800 p-4 overflow-auto">
<!-- Search (if no article selected) -->
<div v-if="!manualForm.article" class="space-y-4">
<!-- Category Filter -->
<div v-if="selectedCategory" class="flex items-center bg-primary/10 dark:bg-primary/20 rounded-lg p-2 mb-2">
<span class="text-sm text-primary dark:text-secondary font-medium flex-1">
Kategorie: {{ selectedCategory.name }}
</span>
<button @click="clearCategoryFilter" class="p-1 text-primary dark:text-secondary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Search Input -->
<div class="relative">
<input v-model="manualForm.searchQuery" type="text" inputmode="search"
placeholder="Artikelnummer oder Name..."
class="w-full px-4 py-3 rounded-xl border dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:ring-2 focus:ring-primary">
<div v-if="manualForm.searching" class="absolute right-3 top-3">
<svg class="animate-spin h-5 w-5 text-slate-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
</div>
<!-- Category Browser Button -->
<button @click="openCategoryBrowser"
class="w-full py-3 bg-primary/10 dark:bg-primary/20 text-primary dark:text-secondary rounded-xl font-medium flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
Nach Kategorie durchsuchen
</button>
<!-- Category Browser Modal -->
<div v-if="showCategoryBrowser" class="fixed inset-0 bg-black/50 z-50 flex items-end">
<div class="bg-white dark:bg-slate-800 w-full rounded-t-2xl max-h-[70vh] flex flex-col">
<div class="p-4 border-b dark:border-slate-700 flex justify-between items-center">
<h3 class="font-bold text-lg dark:text-white">Kategorie wählen</h3>
<button @click="showCategoryBrowser = false" class="p-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="overflow-y-auto flex-1 p-4">
<div class="grid grid-cols-2 gap-2">
<div v-for="cat in categories" :key="cat.id"
@click="selectCategory(cat)"
class="p-3 bg-slate-50 dark:bg-slate-700 rounded-lg cursor-pointer active:bg-slate-100 dark:active:bg-slate-600 text-center">
<p class="font-medium text-slate-800 dark:text-white text-sm">{{ cat.name }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Search Results -->
<div v-if="manualForm.searchResults.length" class="space-y-2 max-h-64 overflow-auto">
<div v-for="article in manualForm.searchResults" :key="article.id"
@click="selectSearchResult(article)"
class="p-3 bg-slate-50 dark:bg-slate-700 rounded-lg cursor-pointer active:bg-slate-100 dark:active:bg-slate-600">
<p class="font-medium text-slate-800 dark:text-white">{{ article.title }}</p>
<p class="text-sm text-slate-500 dark:text-slate-400">{{ article.articleNumber }}</p>
</div>
</div>
<button @click="closeForm" class="w-full py-3 bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-200 rounded-xl">
Abbrechen
</button>
</div>
<!-- Article Form (if article selected) -->
<div v-else class="space-y-4">
<!-- Already Scanned Warning -->
<div v-if="alreadyScannedWarning.show" class="bg-amber-100 dark:bg-amber-900/30 border-2 border-amber-400 dark:border-amber-600 rounded-xl p-4 warning-pulse">
<div class="flex items-start">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-amber-600 dark:text-amber-400 mr-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<p class="font-bold text-amber-800 dark:text-amber-200">Bereits gescannt!</p>
<p class="text-sm text-amber-700 dark:text-amber-300 mt-1">
Dieser Artikel wurde bereits erfasst:
<br><strong>{{ alreadyScannedWarning.existingItem.countedQuantity }} Stk.</strong>
von {{ alreadyScannedWarning.existingItem.scannedBy }}
({{ alreadyScannedWarning.existingItem.scannedAt }})
</p>
<p class="text-xs text-amber-600 dark:text-amber-400 mt-2">
Geben Sie die neue Menge ein und klicken Sie auf "Überschreiben".
</p>
</div>
</div>
</div>
<div class="bg-slate-50 dark:bg-slate-700 rounded-xl p-4">
<p class="font-bold text-lg text-slate-800 dark:text-white">{{ manualForm.article.title }}</p>
<p class="text-sm text-slate-500 dark:text-slate-400">{{ manualForm.article.articleNumber }}</p>
<p v-if="manualForm.article.categoryName" class="text-xs text-slate-400 mt-1">{{ manualForm.article.categoryName }}</p>
</div>
<!-- Quantity with Custom Numpad -->
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Menge ({{ manualForm.article.unit }}) *
</label>
<div class="text-center bg-slate-100 dark:bg-slate-700 rounded-xl p-4 mb-3">
<span class="text-4xl font-bold text-primary dark:text-secondary">
{{ manualForm.quantity || '0' }}
</span>
<span class="text-xl text-slate-500 ml-2">{{ manualForm.article.unit }}</span>
</div>
<!-- Desktop: regular input field -->
<div v-if="!isMobile" class="mt-2">
<input v-model="manualForm.quantity" type="number" inputmode="decimal" min="0.01" step="0.01"
placeholder="Menge eingeben..."
class="w-full px-4 py-3 text-xl font-bold text-center rounded-xl border dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:ring-2 focus:ring-primary">
</div>
<!-- Numpad (only on mobile devices) -->
<div v-if="manualForm.showNumpad && isMobile" class="grid grid-cols-4 gap-2">
<button @click="numpadInput('1')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">1</button>
<button @click="numpadInput('2')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">2</button>
<button @click="numpadInput('3')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">3</button>
<button @click="numpadInput('+')" class="numpad-btn bg-green-500 text-white">+</button>
<button @click="numpadInput('4')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">4</button>
<button @click="numpadInput('5')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">5</button>
<button @click="numpadInput('6')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">6</button>
<button @click="numpadInput('-')" class="numpad-btn bg-red-500 text-white">-</button>
<button @click="numpadInput('7')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">7</button>
<button @click="numpadInput('8')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">8</button>
<button @click="numpadInput('9')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">9</button>
<button @click="numpadInput('backspace')" class="numpad-btn bg-slate-300 dark:bg-slate-500 text-slate-800 dark:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2M3 12l6.414 6.414a2 2 0 001.414.586H19a2 2 0 002-2V7a2 2 0 00-2-2h-8.172a2 2 0 00-1.414.586L3 12z" />
</svg>
</button>
<button @click="numpadInput('.')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">.</button>
<button @click="numpadInput('0')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">0</button>
<button @click="numpadInput('clear')" class="numpad-btn bg-slate-300 dark:bg-slate-500 text-slate-800 dark:text-white col-span-2">C</button>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Regal</label>
<input v-model="manualForm.rack" type="text"
class="w-full px-4 py-2 rounded-xl border dark:border-slate-600 dark:bg-slate-700 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Fach</label>
<input v-model="manualForm.shelf" type="text"
class="w-full px-4 py-2 rounded-xl border dark:border-slate-600 dark:bg-slate-700 dark:text-white">
</div>
</div>
<!-- Action Buttons -->
<div class="space-y-2 pt-4">
<div class="flex space-x-3">
<button @click="closeForm" class="flex-1 py-3 bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-200 rounded-xl font-medium">
Abbrechen
</button>
<!-- Show "Speichern" only when NOT already scanned -->
<button v-if="!alreadyScannedWarning.show"
@click="submitScan(false)"
class="flex-1 py-3 bg-green-600 text-white rounded-xl font-bold">
Speichern
</button>
<!-- Show "Überschreiben" only when already scanned -->
<button v-else
@click="submitScan(true)"
class="flex-1 py-3 bg-amber-500 text-white rounded-xl font-bold">
Überschreiben
</button>
</div>
</div>
</div>
</div>
</transition>
<!-- Recent Scans List -->
<div v-if="!manualForm.show && recentScans.length" class="flex-1 bg-white dark:bg-slate-800 overflow-auto">
<div class="px-4 py-2 bg-slate-50 dark:bg-slate-700/50 sticky top-0">
<p class="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Letzte Scans</p>
</div>
<div class="divide-y dark:divide-slate-700">
<div v-for="(item, index) in recentScans" :key="item.id"
:class="{ 'success-flash': item.flash }"
class="px-4 py-3 flex justify-between items-center">
<div class="min-w-0 flex-1">
<p class="font-medium text-slate-800 dark:text-white truncate">{{ item.articleTitle }}</p>
<p class="text-xs text-slate-500 dark:text-slate-400">
{{ item.articleNumber }}
<span v-if="item.rack || item.shelf" class="ml-2">
| {{ item.rack || '-' }} / {{ item.shelf || '-' }}
</span>
</p>
</div>
<div class="text-right ml-4">
<p class="font-bold text-primary dark:text-secondary">{{ item.countedQuantity }} {{ item.unit }}</p>
<p class="text-xs text-slate-400">{{ item.scannedAt }}</p>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- Toast Notification -->
<transition name="fade">
<div v-if="toast.show"
:class="toast.type === 'success' ? 'bg-green-600' : 'bg-red-600'"
class="fixed bottom-20 left-4 right-4 p-4 rounded-xl text-white text-center font-medium shadow-lg z-50">
{{ toast.message }}
</div>
</transition>
</div>
`
});
app.mount('#app');
</script>
</body>
</html>

View File

@@ -0,0 +1,40 @@
<?php
use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions;
// QR code options - small padding, high quality
$options = new QROptions([
'outputType' => QRCode::OUTPUT_IMAGE_PNG,
'scale' => 10,
'quietzoneSize' => 1,
]);
// Generate QR code data - encode article ID for Inventur scanning
$qrData = "WA:" . $articleId . ":" . $articleNumber;
$qrCodeBase64 = (new QRCode($options))->render($qrData);
?>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; }
html, body { height: 25mm; width: 63mm; overflow: hidden; }
body { font-family: Arial, sans-serif; color: #000; }
table { border-collapse: collapse; }
</style>
</head>
<body>
<table cellpadding="0" cellspacing="0" border="0" style="width: 63mm; height: 25mm;">
<tr>
<td style="width: 24mm; height: 25mm; position: relative;">
<img src="<?php echo $qrCodeBase64; ?>" style="width: 21mm; height: 21mm; position: absolute; top: 50%; left: 50%; margin-top: -10.5mm; margin-left: -10.5mm;">
</td>
<td style="height: 25mm; vertical-align: middle; padding-left: 1mm; padding-right: 1mm;">
<img src="<?php echo BASEDIR; ?>/public/assets/images/xinon-full-transparent.png" style="width: 24mm; height: auto; display: block; margin: 0 auto 1mm auto;">
<div style="font-size: 11px; font-weight: bold; color: #000; text-align: center;"><?php echo htmlspecialchars($articleNumber); ?></div>
<div style="font-size: 9px; color: #000; margin-top: 1px; word-wrap: break-word; overflow: hidden; text-align: center;"><?php echo htmlspecialchars($articleTitle); ?></div>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,54 @@
<?php
use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions;
// QR code options - small padding, high quality
$options = new QROptions([
'outputType' => QRCode::OUTPUT_IMAGE_PNG,
'scale' => 10,
'quietzoneSize' => 1,
]);
$qrcode = new QRCode($options);
?>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; }
html, body { margin: 0; padding: 0; }
body { font-family: Arial, sans-serif; color: #000; }
table { border-collapse: collapse; }
.label-page {
height: 25mm;
width: 63mm;
overflow: hidden;
page-break-after: always;
}
/* Last page should not have a break if possible, but wkhtmltopdf handles it fine usually */
.label-page:last-child {
page-break-after: auto;
}
</style>
</head>
<body>
<?php foreach($articles as $article):
$qrData = "WA:" . $article->id . ":" . $article->articleNumber;
$qrCodeBase64 = $qrcode->render($qrData);
?>
<div class="label-page">
<table cellpadding="0" cellspacing="0" border="0" style="width: 63mm; height: 25mm;">
<tr>
<td style="width: 24mm; height: 25mm; position: relative;">
<img src="<?php echo $qrCodeBase64; ?>" style="width: 21mm; height: 21mm; position: absolute; top: 50%; left: 50%; margin-top: -10.5mm; margin-left: -10.5mm;">
</td>
<td style="height: 25mm; vertical-align: middle; padding-left: 1mm; padding-right: 1mm;">
<img src="<?php echo BASEDIR; ?>/public/assets/images/xinon-full-transparent.png" style="width: 24mm; height: auto; display: block; margin: 0 auto 1mm auto;">
<div style="font-size: 11px; font-weight: bold; color: #000; text-align: center;"><?php echo htmlspecialchars($article->articleNumber); ?></div>
<div style="font-size: 9px; color: #000; margin-top: 1px; word-wrap: break-word; overflow: hidden; text-align: center;"><?php echo htmlspecialchars($article->title); ?></div>
</td>
</tr>
</table>
</div>
<?php endforeach; ?>
</body>
</html>

View File

@@ -61,8 +61,10 @@ if ($includeTax) {
}
$formattedOfferDate = date("d.m.Y", $offerDate);
$validityDays = isset($validity) ? (int)$validity : 14;
$formattedValidUntil = date("d.m.Y", strtotime("+$validityDays days", $offerDate));
$validityDays = isset($validity) ? (int)$validity : 31;
// Use versionDate (when this version was created) for validity calculation, fallback to offerDate
$validityBaseDate = isset($versionDate) ? $versionDate : $offerDate;
$formattedValidUntil = date("d.m.Y", strtotime("+$validityDays days", $validityBaseDate));
?>
<!DOCTYPE html>

View File

@@ -184,6 +184,8 @@
<?php if($me->can("WarehouseAdmin") || $me->can("WarehouseUser")): ?><li><a href="<?=self::getUrl("WarehouseOrderRequest")?>"><i class="far fa-fw fa-shopping-cart text-info"></i> Bestellwünsche</a></li><?php endif; ?>
<?php if($me->can("WarehouseAdmin") || $me->can("WarehouseUser")): ?><li><a href="<?=self::getUrl("WarehouseShippingNote")?>"><i class="far fa-fw fa-shipping-fast text-info"></i> Lieferscheine</a></li><?php endif; ?>
<?php if($me->can("WarehouseAdmin") || $me->can("WarehouseUser")): ?><li><a href="<?=self::getUrl("WarehouseProject")?>"><i class="fas fa-fw fa-project-diagram text-info"></i> Projekte</a></li><?php endif; ?>
<?php if($me->can("WarehouseAdmin")): ?><li><a href="<?=self::getUrl("WarehouseStocktake")?>"><i class="far fa-fw fa-clipboard-check text-info"></i> Inventur</a></li><?php endif; ?>
<?php if($me->can("WarehouseAdmin") || $me->can("WarehouseUser")): ?><li><a href="<?=self::getUrl("WarehouseMovement")?>"><i class="far fa-fw fa-arrow-right-arrow-left text-info"></i> Lagerbewegung</a></li><?php endif; ?>
<?php if($me->can("WarehouseAdmin")): ?><li><a href="<?=self::getUrl("WarehouseDistributor")?>"><i class="far fa-fw fa-cogs text-info"></i> Administration</a></li><?php endif; ?>