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

@@ -0,0 +1,12 @@
{
"permissions": {
"allow": [
"Bash(docker-compose up:*)",
"Bash(python:*)",
"Bash(cat:*)",
"Bash(find:*)",
"Bash(docker-compose exec:*)",
"mcp__sequentialthinking__sequentialthinking"
]
}
}

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">

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; ?>

View File

@@ -24,6 +24,9 @@ class ADBNetzgebietController extends mfBaseController {
"GET_URL" => $this::getUrl("ADBNetzgebiet/getNetzgebiete"),
"SAVE_URL" => $this::getUrl("ADBNetzgebiet/save"),
"HISTORY_URL" => $this::getUrl("ADBNetzgebiet/getHistory"),
"START_RIMO_IMPORT_URL" => $this::getUrl("ADBNetzgebiet/startRimoImport"),
"GET_RIMO_IMPORT_STATUS_URL" => $this::getUrl("ADBNetzgebiet/getRimoImportStatus"),
"GET_RIMO_IMPORT_LOG_URL" => $this::getUrl("ADBNetzgebiet/getRimoImportLog"),
"NETWORK_URL" => $this::getUrl("Network/Index"),
"NETWORK_CREATE_URL" => $this::getUrl("Network/add"),
"CAMPAIGN_URL" => $this::getUrl("Preordercampaign/edit"),
@@ -130,6 +133,193 @@ class ADBNetzgebietController extends mfBaseController {
self::returnJson(['success' => true, 'data' => $history]);
}
protected function startRimoImportAction(): void {
$id = $_GET['id'] ?? null;
if (empty($id)) {
self::returnJson(['success' => false, 'message' => "Netzgebiet ID required."]);
return;
}
$netzgebiet = ADBNetzgebiet::get($id);
if (!$netzgebiet || !$netzgebiet->id) {
self::returnJson(['success' => false, 'message' => "Netzgebiet not found."]);
return;
}
if (strpos($netzgebiet->source, 'rimo-') !== 0) {
self::returnJson(['success' => false, 'message' => "This action is only for RIMO-source Netzgebiete."]);
return;
}
if (empty($netzgebiet->source_id)) {
self::returnJson(['success' => false, 'message' => "Netzgebiet has no Source ID."]);
return;
}
$safeSourceId = preg_replace('/[^a-zA-Z0-9_-]/', '_', $netzgebiet->source_id);
$importTempDir = TEMP_DIR . "/ADBNetzgebietRimoImport/";
$logDir = $importTempDir . $safeSourceId;
$logFile = $logDir . "/import.log";
$lockFile = $logDir . "/import.lock";
if (is_dir($importTempDir)) {
foreach (glob($importTempDir . "*") as $dir) {
if (is_dir($dir) && (time() - filemtime($dir)) > 86400) {
// simple cleanup
if (file_exists($dir . "/import.log")) @unlink($dir . "/import.log");
if (file_exists($dir . "/import.lock")) @unlink($dir . "/import.lock");
@rmdir($dir);
}
}
}
if (!is_dir($logDir)) {
mkdir($logDir, 0755, true);
}
if (file_exists($lockFile)) {
if ((time() - filemtime($lockFile)) > 3600) { // stale lock for 1h
@unlink($lockFile);
} else {
self::returnJson(['success' => false, 'message' => "Import is already running.", 'status' => 'running']);
return;
}
}
if (file_exists($logFile) && (time() - filemtime($logFile)) < 900) {
$remaining = 900 - (time() - filemtime($logFile));
self::returnJson(['success' => false, 'message' => "Please wait before starting another import.", 'status' => 'cooldown', 'remaining' => $remaining]);
return;
}
touch($lockFile);
$projectRoot = dirname(dirname(__DIR__));
$scriptRelativePath = 'scripts/adb-rimo-import/rimo-import.php';
$scriptFullPath = $projectRoot . '/' . $scriptRelativePath;
if (!file_exists($scriptFullPath)) {
self::returnJson(['success' => false, 'message' => "Import script not found."]);
return;
}
$php_executable = "php";
$command = "$php_executable $scriptRelativePath " . escapeshellarg($netzgebiet->source_id);
$bgCommand = 'cd ' . escapeshellarg($projectRoot) . ' && ' . $command . ' > ' . escapeshellarg($logFile) . ' 2>&1 & echo $!';
$pid = shell_exec($bgCommand);
if(empty($pid) || !is_numeric(trim($pid))) {
self::returnJson(['success' => false, 'message' => "Failed to start background process."]);
return;
}
file_put_contents($lockFile, trim($pid));
self::returnJson(['success' => true, 'message' => 'RIMO import started.']);
}
protected function getRimoImportStatusAction(): void {
$ids = $this->postData['ids'] ?? [];
if (empty($ids)) {
self::returnJson(['success' => true, 'data' => []]);
return;
}
$statuses = [];
foreach ($ids as $id) {
$netzgebiet = ADBNetzgebiet::get($id);
if (!$netzgebiet || !$netzgebiet->id || strpos($netzgebiet->source, 'rimo-') !== 0 || empty($netzgebiet->source_id)) {
$statuses[$id] = ['status' => 'not_applicable'];
continue;
}
$safeSourceId = preg_replace('/[^a-zA-Z0-9_-]/', '_', $netzgebiet->source_id);
$logDir = TEMP_DIR . "/ADBNetzgebietRimoImport/" . $safeSourceId;
$logFile = $logDir . "/import.log";
$lockFile = $logDir . "/import.lock";
if (file_exists($lockFile)) {
$pid = trim(file_get_contents($lockFile));
// Check if process is still running. posix_getpgid returns false if process does not exist.
if (is_numeric($pid) && posix_getpgid((int)$pid) !== false) {
$statuses[$id] = ['status' => 'running'];
} else {
// Stale lock file, process is gone.
@unlink($lockFile);
// Check for cooldown based on log file from the finished process
if (file_exists($logFile) && (time() - filemtime($logFile)) < 900) {
$statuses[$id] = [
'status' => 'cooldown',
'remaining' => 900 - (time() - filemtime($logFile))
];
} else {
$statuses[$id] = ['status' => 'idle'];
}
}
} elseif (file_exists($logFile) && (time() - filemtime($logFile)) < 900) {
$statuses[$id] = [
'status' => 'cooldown',
'remaining' => 900 - (time() - filemtime($logFile))
];
} else {
$statuses[$id] = ['status' => 'idle'];
}
}
self::returnJson(['success' => true, 'data' => $statuses]);
}
protected function getRimoImportLogAction(): void {
$id = $_GET['id'] ?? null;
if (empty($id)) {
self::returnJson(['success' => false, 'message' => "Netzgebiet ID required."]);
return;
}
$netzgebiet = ADBNetzgebiet::get($id);
if (!$netzgebiet || !$netzgebiet->id || empty($netzgebiet->source_id)) {
self::returnJson(['success' => false, 'message' => "Netzgebiet not found or not applicable."]);
return;
}
$safeSourceId = preg_replace('/[^a-zA-Z0-9_-]/', '_', $netzgebiet->source_id);
$logDir = TEMP_DIR . "/ADBNetzgebietRimoImport/" . $safeSourceId;
$logFile = $logDir . "/import.log";
$lockFile = $logDir . "/import.lock";
$logContent = "";
if (file_exists($logFile)) {
$logContent = file_get_contents($logFile);
}
$status = 'idle';
if (file_exists($lockFile)) {
$pid = trim(file_get_contents($lockFile));
if (is_numeric($pid) && posix_getpgid((int)$pid) !== false) {
$status = 'running';
} else {
@unlink($lockFile); // Stale lock, process is gone
}
}
if ($status !== 'running') {
if (file_exists($logFile) && (time() - filemtime($logFile)) < 900) {
$status = 'cooldown';
} else {
$status = file_exists($logFile) ? 'finished' : 'idle';
}
}
self::returnJson([
'success' => true,
'data' => [
'log' => $logContent,
'status' => $status,
'timestamp' => file_exists($logFile) ? filemtime($logFile) : null
]
]);
}
// TODO: Implement RIMO API check
protected function checkRimoSourceIdAction(): void {
self::returnJson(['success' => false, 'message' => "RIMO API check not available."]);

View File

@@ -726,16 +726,24 @@ class AddressController extends mfBaseController {
}
$xinon_project = new XinonProject();
$tickets = $xinon_project->searchSupportTickets('', 0, ['pageSize' => 100,
'filters' => json_encode([['customField6' => ['operator' => '=', 'values' => [$address->customer_number]]]])]);
$filterParams = ['pageSize' => 100,
'filters' => json_encode([['customField6' => ['operator' => '=', 'values' => [(string)$address->customer_number]]]])];
$tickets = $xinon_project->searchSupportTickets('', 0, $filterParams) ?? [];
$shippingNotes = array_map(function ($shippingNote) {
$shippingNote->createByName = (new User($shippingNote->createBy))->getAbbrName();
return $shippingNote;
}, WarehouseShippingNoteModel::getAll(['billingAddressId' => $address->id]));
Helper::renderVue($this,"AddressTickets",
"Tickets und Lieferscheine von Kunden: " . $address->getCompanyOrName() . '(' . $address->customer_number . ')', ["TICKETS" => $tickets,"SHIPPING_NOTES" => $shippingNotes,"ADDRESS" => $address]);
$customerName = str_replace(["\r", "\n"], ' ', $address->getCompanyOrName());
Helper::renderVue($this,"AddressTickets", "Tickets und Lieferscheine", [
"TICKETS" => $tickets,
"SHIPPING_NOTES" => $shippingNotes,
"CUSTOMER_NAME" => $customerName,
"CUSTOMER_NUMBER" => $address->customer_number,
"HIDE_PAGE_TITLE" => true
]);
}
protected function sendServicePinAction() {

View File

@@ -7,7 +7,7 @@ class AssetManagementController extends TTCrud
// Simplified columns for better layout, details are in the 'assetDetails' slot
protected array $columns = [
['key' => 'assetDetails', 'text' => 'Gerät', 'modal' => false, 'table' => ['filter' => 'search']],
['key' => 'assetDetails', 'text' => 'Gerät', 'modal' => false, 'table' => ['filter' => 'search', 'sortable' => true]],
['key' => 'currentUser', 'text' => 'Status', 'modal' => false, 'table' => ['sortable' => false, 'filter' => false]],
['key' => 'location', 'text' => 'Lagerort', 'required' => true, 'modal' => ['type' => 'text'], 'table' => ['filter' => 'search']],
['key' => 'serviceDueDate', 'text' => 'Service fällig', 'required' => false, 'modal' => ['type' => 'date'], 'table' => ['filter' => 'date']],
@@ -42,7 +42,12 @@ class AssetManagementController extends TTCrud
$json = json_decode(file_get_contents('php://input'), true);
$pagination = $json['pagination'] ?? ['page' => 1, 'per_page' => 10];
$filters = $json['filters'] ?? [];
$order = $json['order'] ?? ['key' => 'id', 'order' => 'DESC'];
$order = $json['order'] ?? ['key' => 'name', 'order' => 'ASC'];
// Map virtual column 'assetDetails' to actual 'name' column for sorting
if (isset($order['key']) && $order['key'] === 'assetDetails') {
$order['key'] = 'name';
}
// Fetch paginated assets
$assets = AssetManagementModel::getAll($filters, $pagination['per_page'], ($pagination['page'] - 1) * $pagination['per_page'], $order);

View File

@@ -265,9 +265,10 @@ class CalendarModel
continue;
}
if ($data['all_day_event'] == 1) {
if (in_array("Feiertag", $categories)) {
if (is_array($categories) && in_array("Feiertag", $categories)) {
continue;
}
$starttime = date("Y-m-d", $data['start_time']);
$endtime = date("Y-m-d", $data['end_time']);
} else {

View File

@@ -549,23 +549,23 @@ class CpeprovisioningController extends mfBaseController {
"ORDER_URL" => $this->getUrl("Order"),
"NETWORKS" => NetworkModel::getAll(),
"ROUTER_OPTIONS" => [
['value' => 'FritzBox 4050', 'text' => 'FritzBox 4050 (Inet, Phone IPTV)'],
['value' => 'FritzBox 7530', 'text' => 'FritzBox 7530 (Inet, Phone, IPTV)'],
['value' => 'FritzBox 7690', 'text' => 'FritzBox 7690 (Inet, Phone, IPTV)'],
['value' => 'FritzBox 6670 Cable', 'text' => 'FritzBox 6670 Cable (Inet, Phone, IPTV)'],
// General Options
['value' => 'eigener Router', 'text' => 'Eigener Router'],
['value' => 'anderes CPE', 'text' => 'Anderes CPE'],
// PPPoE/DHCP Routers
['value' => 'TP-Link Archer C80', 'text' => 'TP-Link Archer C80 (Inet, IPTV)'],
['value' => 'FritzBox 4040', 'text' => 'FritzBox 4040 (Inet, IPTV)'],
['value' => 'FritzBox 4050', 'text' => 'FritzBox 4050 (Inet, Phone IPTV)'],
['value' => 'FritzBox 5530', 'text' => 'FritzBox 5530 (Inet FiberP2P, Phone, IPTV)'],
['value' => 'FritzBox 7530', 'text' => 'FritzBox 7530 (Inet, Phone, IPTV)'],
['value' => 'FritzBox 7590', 'text' => 'FritzBox 7590 (Inet, Phone, IPTV)'],
['value' => 'FritzBox 7690', 'text' => 'FritzBox 7690 (Inet, Phone, IPTV)'],
// Static Routers
['value' => 'Mikrotik HAP AC', 'text' => 'Mikrotik HAP AC (Inet, IPTV)'],
['value' => 'Mikrotik HEX S', 'text' => 'Mikrotik HEX S (Inet, IPTV)'],
['value' => 'Mikrotik RB3011', 'text' => 'Mikrotik RB3011 (Inet, IPTV)'],
// CMTS Routers
// Legacy
['value' => 'FritzBox 6490 Cable', 'text' => 'FritzBox 6490 Cable (Inet, Phone, IPTV)'],
['value' => 'FritzBox 4040', 'text' => 'FritzBox 4040 (Inet, IPTV)'],
['value' => 'FritzBox 5530', 'text' => 'FritzBox 5530 (Inet FiberP2P, Phone, IPTV)'],
['value' => 'FritzBox 7590', 'text' => 'FritzBox 7590 (Inet, Phone, IPTV)'],
['value' => 'TP-Link Archer C80', 'text' => 'TP-Link Archer C80 (Inet, IPTV)'],
],
"ROUTER_SHIPPING_DATA" => [
"TP-Link Archer C80" => ["weight" => 1, "length" => 35, "width" => 24, "height" => 8],

View File

@@ -208,8 +208,6 @@ class ManualInvoiceController extends TTCrud
$post = json_decode(file_get_contents('php://input'), true);
$id = $post['id'] ?? null;
$recipientEmail = $post['email'] ?? null;
$subject = $post['subject'] ?? 'Ihre Rechnung von XINON GmbH';
$bodyText = $post['body'] ?? 'Sehr geehrte Damen und Herren,\n\nanbei erhalten Sie Ihre Rechnung.\n\nMit freundlichen Grüßen\nIhr Xinon Team';
if (!$id || !$recipientEmail) {
self::returnJson(['success' => false, 'message' => 'ID oder E-Mail-Adresse fehlt']);
@@ -222,6 +220,19 @@ class ManualInvoiceController extends TTCrud
return;
}
// Format invoice date for display
$invoiceDateFormatted = date('d.m.Y', $invoice->invoice_date);
// Set default subject and body with invoice number and date
$defaultSubject = "Ihre Rechnung {$invoice->invoice_number} vom {$invoiceDateFormatted}";
$defaultBody = "Sehr geehrte Damen und Herren,\n\nanbei erhalten Sie Ihre Rechnung Nr. {$invoice->invoice_number} vom {$invoiceDateFormatted}.\n\nMit freundlichen Grüßen\nIhr XINON Team";
$subject = $post['subject'] ?? $defaultSubject;
$bodyText = $post['body'] ?? $defaultBody;
// Convert literal \n strings to actual newlines (in case frontend sends escaped strings)
$bodyText = str_replace('\n', "\n", $bodyText);
// Generate PDF
$pdf_filename = $this->createPDFAction(true);
if (!$pdf_filename || !file_exists($pdf_filename)) {
@@ -232,19 +243,33 @@ class ManualInvoiceController extends TTCrud
$pdfContent = file_get_contents($pdf_filename);
// --- HTML Email Generation ---
$logoToolPath = BASEDIR . '/public/assets/images/the-tool-logo.png';
$logoXinonPath = BASEDIR . '/public/assets/images/xinon-full.png';
$logoToolExists = file_exists($logoToolPath);
$logoXinonExists = file_exists($logoXinonPath);
// Construct HTML Body
$html = '<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><title>Rechnung</title><style>body { font-family: Arial, sans-serif; color: #333; }</style></head><body style="margin:0;padding:20px;background-color:#f3f4f6;">';
$html .= '<div style="background-color:#fff;padding:20px;border-radius:8px;max-width:600px;margin:0 auto;box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">';
// Construct HTML Body with Outlook compatibility
$html = '<!DOCTYPE html>';
$html .= '<html lang="de" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">';
$html .= '<head>';
$html .= '<meta charset="UTF-8">';
$html .= '<meta http-equiv="X-UA-Compatible" content="IE=edge">';
$html .= '<meta name="viewport" content="width=device-width, initial-scale=1.0">';
$html .= '<title>Rechnung</title>';
$html .= '<!--[if mso]><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml><![endif]-->';
$html .= '<style>body { font-family: Arial, sans-serif; color: #333; margin: 0; padding: 0; }</style>';
$html .= '</head>';
$html .= '<body style="margin:0;padding:20px;background-color:#f3f4f6;">';
// Logos
$html .= '<div style="text-align:center;margin-bottom:20px;border-bottom: 1px solid #e5e7eb;padding-bottom: 15px;">';
if ($logoToolExists) $html .= '<img src="cid:logo_thetool" alt="The Tool" style="height:40px;margin-right:15px;vertical-align:middle;">';
if ($logoXinonExists) $html .= '<img src="cid:logo_xinon" alt="Xinon" style="height:40px;vertical-align:middle;">';
// Outlook-safe container table
$html .= '<!--[if mso]><table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0" align="center"><tr><td><![endif]-->';
$html .= '<div style="background-color:#fff;padding:20px;border-radius:8px;max-width:600px;margin:0 auto;">';
// Logo with Outlook-safe sizing
$html .= '<div style="text-align:center;margin-bottom:20px;border-bottom:1px solid #e5e7eb;padding-bottom:15px;">';
if ($logoXinonExists) {
$html .= '<!--[if mso]><table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0"><tr><td align="center"><![endif]-->';
$html .= '<img src="cid:logo_xinon" alt="XINON GmbH" width="150" height="50" style="display:block;width:150px;height:50px;max-width:150px;margin:0 auto;">';
$html .= '<!--[if mso]></td></tr></table><![endif]-->';
}
$html .= '</div>';
$html .= '<h2 style="color:#00558c;text-align:center;font-size:20px;margin-bottom:20px;">' . htmlspecialchars($subject) . '</h2>';
@@ -254,7 +279,9 @@ class ManualInvoiceController extends TTCrud
$html .= '<br><div style="border-top:1px solid #eee;padding-top:20px;font-size:12px;color:#999;text-align:center;">';
$html .= 'XINON GmbH | <a href="https://www.xinon.at" style="color:#00558c;text-decoration:none;">www.xinon.at</a>';
$html .= '</div></div></body></html>';
$html .= '</div></div>';
$html .= '<!--[if mso]></td></tr></table><![endif]-->';
$html .= '</body></html>';
$mail = new PHPMailer(true);
try {
@@ -269,12 +296,11 @@ class ManualInvoiceController extends TTCrud
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = 587;
// Logos
if ($logoToolExists) $mail->addEmbeddedImage($logoToolPath, 'logo_thetool');
// Logo embedding
if ($logoXinonExists) $mail->addEmbeddedImage($logoXinonPath, 'logo_xinon');
$mail->addReplyTo('backoffice@xinon.at', 'XINON Backoffice');
$mail->setFrom('thetool@xinon.at', 'XINON TheTool');
$mail->setFrom('thetool@xinon.at', 'XINON GmbH - Rechnungswesen');
$customerName = trim(($invoice->company ?: '') . ' ' . $invoice->firstname . ' ' . $invoice->lastname);
$mail->addAddress($recipientEmail, $customerName);
@@ -283,7 +309,10 @@ class ManualInvoiceController extends TTCrud
$mail->Body = $html;
$mail->AltBody = strip_tags($bodyText);
$mail->addStringAttachment($pdfContent, $invoice->invoice_number . '_Rechnung.pdf', 'base64', 'application/pdf');
// Attachment filename: YYYY-MM-DD_InvoiceNumber_Rechnung.pdf
$invoiceDateFile = date('Y-m-d', $invoice->invoice_date);
$attachmentFilename = "{$invoiceDateFile}_{$invoice->invoice_number}_Rechnung.pdf";
$mail->addStringAttachment($pdfContent, $attachmentFilename, 'base64', 'application/pdf');
$mail->send();
@@ -349,20 +378,21 @@ class ManualInvoiceController extends TTCrud
$data['invoice_date'] = strtotime($data['invoice_date']);
}
$data = array_merge([
'invoice_number' => ManualInvoiceModel::getNextInvoiceNumber(),
'invoice_date' => $data['invoice_date'] ?? time(),
'status' => 'erstellt',
'fibu_payment_skonto' => 0,
'fibu_payment_skonto_rate' => 0,
'gesamtrabatt' => 0,
'total' => 0,
'total_gross' => 0,
'create_by' => $me->id,
'edit_by' => $me->id,
'create' => time(),
'edit' => time()
], $data);
// Always generate invoice number (override any null from frontend)
$data['invoice_number'] = ManualInvoiceModel::getNextInvoiceNumber();
$data['invoice_date'] = $data['invoice_date'] ?? time();
$data['status'] = 'erstellt';
$data['fibu_payment_skonto'] = $data['fibu_payment_skonto'] ?? 0;
$data['fibu_payment_skonto_rate'] = $data['fibu_payment_skonto_rate'] ?? 0;
$data['gesamtrabatt'] = $data['gesamtrabatt'] ?? 0;
$data['total'] = $data['total'] ?? 0;
$data['total_gross'] = $data['total_gross'] ?? 0;
$data['lock'] = 0;
$data['exported'] = 0;
$data['create_by'] = $me->id;
$data['edit_by'] = $me->id;
$data['create'] = time();
$data['edit'] = time();
return true;
}
@@ -389,10 +419,16 @@ class ManualInvoiceController extends TTCrud
unset($data['positions']);
}
if (isset($data['id']) && ($invoice = ManualInvoiceModel::get($data['id'])) && $invoice->status === 'exportiert') {
if (isset($data['id']) && ($invoice = ManualInvoiceModel::get($data['id']))) {
if ($invoice->lock == 1) {
$this->infoMessages['update'] = 'Rechnung ist gesperrt und kann nicht bearbeitet werden';
return false;
}
if ($invoice->status === 'exportiert') {
$this->infoMessages['update'] = 'Rechnung wurde bereits exportiert und kann nicht mehr bearbeitet werden';
return false;
}
}
// Convert invoice_date from string to timestamp if needed
if (isset($data['invoice_date']) && is_string($data['invoice_date'])) {
@@ -626,6 +662,12 @@ class ManualInvoiceController extends TTCrud
if (!$originalInvoiceId || empty($positions) || !($originalInvoice = ManualInvoiceModel::get($originalInvoiceId))) {
self::returnJson(['success' => false, 'message' => 'Ungültige Anfrage']);
return;
}
if ($originalInvoice->lock == 1) {
self::returnJson(['success' => false, 'message' => 'Originalrechnung ist gesperrt und kann nicht gutgeschrieben werden']);
return;
}
$me = new User();
@@ -673,6 +715,8 @@ class ManualInvoiceController extends TTCrud
'vatgroup_id' => $originalInvoice->vatgroup_id,
'credit_for_invoice_id' => $originalInvoiceId,
'status' => 'erstellt',
'lock' => 0,
'exported' => 0,
'create' => time(),
'edit' => time(),
'create_by' => $me->id,
@@ -681,6 +725,7 @@ class ManualInvoiceController extends TTCrud
if (!($creditInvoiceId = ManualInvoiceModel::create($invoiceData))) {
self::returnJson(['success' => false, 'message' => 'Fehler beim Erstellen der Gutschrift']);
return;
}
foreach ($positions as $pos) {
@@ -718,7 +763,11 @@ class ManualInvoiceController extends TTCrud
protected function beforeDelete(): bool {
if ($id = $this->request->id) {
$invoice = ManualInvoiceModel::get($id);
if ($invoice && $invoice->status === 'exported') {
if ($invoice && $invoice->lock == 1) {
$this->infoMessages['delete'] = 'Rechnung ist gesperrt und kann nicht gelöscht werden';
return false;
}
if ($invoice && ($invoice->status === 'exported' || $invoice->status === 'exportiert')) {
$this->infoMessages['delete'] = 'Rechnung wurde bereits exportiert und kann nicht gelöscht werden';
return false;
}
@@ -732,4 +781,49 @@ class ManualInvoiceController extends TTCrud
}
return true;
}
protected function getArticleVatInfoAction() {
$articleId = $_GET['article_id'] ?? null;
$vatarea = $_GET['vatarea'] ?? 'domestic';
if (!$articleId) {
self::returnJson(['success' => false, 'message' => 'Article ID required']);
return;
}
$article = WarehouseArticleModel::get($articleId);
if (!$article) {
self::returnJson(['success' => false, 'message' => 'Article not found']);
return;
}
// Map revenueAccount to vatgroup_id
// revenueAccount 0 = Dienstleistungen = vatgroup_id 2
// revenueAccount 1 = Handelswaren = vatgroup_id 3
$vatgroupId = $article->revenueAccount == 0 ? 2 : 3;
// Get vatrate for this vatgroup and area
$vatrate = VatrateModel::getFirst(['vatgroup_id' => $vatgroupId, 'area' => $vatarea]);
if (!$vatrate) {
self::returnJson(['success' => false, 'message' => 'Vatrate not found for vatgroup ' . $vatgroupId . ' and area ' . $vatarea]);
return;
}
self::returnJson([
'success' => true,
'article' => [
'id' => $article->id,
'title' => $article->title,
'articleNumber' => $article->articleNumber,
'description' => $article->description,
'revenueAccount' => $article->revenueAccount
],
'vatgroup_id' => $vatgroupId,
'fibu_cost_account' => $vatrate->account,
'fibu_cost_account_legacy' => $vatrate->legacy_account,
'fibu_taxcode' => $vatrate->taxcode,
'vatrate' => $vatrate->rate
]);
}
}

View File

@@ -44,6 +44,8 @@ class ManualInvoiceModel extends TTCrudBaseModel {
public ?int $bmd_export_date;
public ?int $date_delivered;
public string $status;
public int $lock = 0;
public int $exported = 0;
public ?int $credit_for_invoice_id;
public int $create_by;
public int $edit_by;

View File

@@ -0,0 +1,473 @@
<?php
require_once APPDIR . 'MobileApp/Shared/MobileAppBaseHandler.php';
/**
* Warehouse Stocktake Handler
*
* Handles all endpoints for the Warehouse Stocktake PWA.
* Migrated from WarehouseStocktakePWAController with new structure.
*/
class WarehouseStocktakeHandler extends MobileAppBaseHandler {
protected $requiredPermission = 'WarehouseUser';
protected $appName = 'WarehouseStocktake';
protected $viewTemplate = 'MobileApp/WarehouseStocktake';
/**
* Get active stocktakes that user can participate in
* GET /MobileApp/WarehouseStocktake/getActiveStocktakes
*/
public 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
* GET /MobileApp/WarehouseStocktake/getStocktake?id=X
*/
public 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
* GET /MobileApp/WarehouseStocktake/getArticle?code=X
*/
public 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
* GET /MobileApp/WarehouseStocktake/searchArticles?query=X&categoryId=Y
*/
public function searchArticlesAction() {
$query = $this->request->query ?? '';
$categoryId = intval($this->request->categoryId ?? 0);
$db = $this->db();
$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
* GET /MobileApp/WarehouseStocktake/getCategories
*/
public function getCategoriesAction() {
$db = $this->db();
$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
* GET /MobileApp/WarehouseStocktake/checkAlreadyScanned?stocktakeId=X&articleId=Y
*/
public 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 = $this->db();
$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
* POST /MobileApp/WarehouseStocktake/submitScan
*/
public function submitScanAction() {
$postData = $this->getPostData();
$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 = $this->db();
// 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;
// Log the overwrite
WarehouseStocktakeLogModel::log($stocktakeId, 'overwritten', $itemId, [
'articleId' => $articleId,
'articleNumber' => $article->articleNumber,
'articleTitle' => $article->title,
'quantity' => $quantity,
'overwrittenItemId' => $overwriteItemId,
]);
// Update stocktake progress
$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
* GET /MobileApp/WarehouseStocktake/getMyScans?stocktakeId=X
*/
public function getMyScansAction() {
$stocktakeId = intval($this->request->stocktakeId);
if (!$stocktakeId) {
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
return;
}
$db = $this->db();
$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
* GET /MobileApp/WarehouseStocktake/getProgress?stocktakeId=X
*/
public 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 = $this->db();
// Total scanned items
$totalResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId}");
$totalRow = $totalResult->fetch_assoc();
$totalScanned = intval($totalRow['count']);
// My scanned items
$myResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId} AND scannedBy = {$this->user->id}");
$myRow = $myResult->fetch_assoc();
$myScanned = intval($myRow['count']);
self::returnJson([
'success' => true,
'progress' => [
'totalScanned' => $totalScanned,
'myScanned' => $myScanned,
'status' => $stocktake->status,
]
]);
}
}

View File

@@ -0,0 +1,451 @@
<?php
/**
* MobileApp Controller
*
* Main dispatcher for the Mobile PWA application.
*
* URL Structure:
* - /MobileApp → Main app (Vue SPA)
* - /MobileApp/auth/{action} → Auth endpoints (login/logout/check)
* - /MobileApp/{module}/{submodule} → Module view (handled by Vue SPA)
* - /MobileApp/{module}/{submodule}/{action} → API endpoints
*
* Example:
* - /MobileApp → Shows main menu
* - /MobileApp/Lager/Inventur → Shows stocktake (handled by Vue)
* - /MobileApp/Lager/Inventur/getActiveStocktakes → API call
*/
class MobileAppController extends mfBaseController {
protected $user;
protected function init() {
// We handle auth ourselves
$this->needlogin = false;
// Try to load user if session exists
$me = mfValuecache::singleton()->get("me");
if (!$me) {
if (mfLoginController::isLoggedIn()) {
$me = new User();
$me->loadMe();
mfValuecache::singleton()->set("me", $me);
}
}
$this->user = $me;
}
/**
* Main dispatcher
*/
public function indexAction() {
$module = $this->request->module ?? null;
$submodule = $this->request->submodule ?? null;
$endpoint = $this->request->endpoint ?? null;
// Auth endpoints: /MobileApp/auth/{action}
if (strtolower($module) === 'auth') {
return $this->handleAuth($submodule ?? 'check');
}
// API call: /MobileApp/{module}/{submodule}/{endpoint}
if ($module && $submodule && $endpoint) {
return $this->handleApiCall($module, $submodule, $endpoint);
}
// Everything else: render the main Vue SPA
// The Vue app handles internal routing for /MobileApp, /MobileApp/Lager, /MobileApp/Lager/Inventur, etc.
return $this->renderApp();
}
/**
* Render the main Vue SPA
*/
protected function renderApp() {
$this->layout()->setTemplate("MobileApp/App");
$this->layout()->set("JSGlobals", [
'BASE_PATH' => '/MobileApp',
'USER' => $this->user ? [
'id' => $this->user->id,
'name' => $this->user->name,
'username' => $this->user->username,
] : null,
'INITIAL_PATH' => $_SERVER['REQUEST_URI'] ?? '/MobileApp',
]);
}
/**
* Handle authentication endpoints
*/
protected function handleAuth($action) {
switch (strtolower($action)) {
case 'login':
return $this->authLogin();
case 'verify2fa':
return $this->authVerify2FA();
case 'resend2fa':
return $this->authResend2FA();
case 'logout':
return $this->authLogout();
case 'check':
return $this->authCheck();
default:
self::returnJson(['success' => false, 'error' => 'Unknown auth endpoint'], 404);
}
}
/**
* POST /MobileApp/auth/login
*
* Step 1 of authentication. If 2FA is required, returns requires2FA: true
* and the frontend should proceed to verify2fa endpoint.
*/
protected function authLogin() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
self::returnJson(['success' => false, 'error' => 'Method not allowed'], 405);
return;
}
$postData = json_decode(file_get_contents('php://input'), true) ?? [];
$username = $postData['username'] ?? '';
$password = $postData['password'] ?? '';
$rememberMe = $postData['rememberMe'] ?? false;
if (!$username || !$password) {
self::returnJson(['success' => false, 'message' => 'Benutzername und Passwort erforderlich']);
return;
}
$db = FronkDB::singleton();
$escapedUsername = $db->escape($username);
$res = $db->select(MFUSERTABLE, "*", "username='$escapedUsername'");
if (!$db->num_rows($res)) {
sleep(1);
self::returnJson(['success' => false, 'message' => 'Ungültige Anmeldedaten']);
return;
}
$userRow = $db->fetch_object($res);
if ($userRow->active == 0) {
self::returnJson(['success' => false, 'message' => 'Benutzer ist deaktiviert']);
return;
}
$hash = $userRow->password;
$salt = substr($hash, 0, 16);
$passhash = mfLoginController::generatePasswordHash($password, $salt);
if ($passhash !== $hash) {
sleep(1);
self::returnJson(['success' => false, 'message' => 'Ungültige Anmeldedaten']);
return;
}
// Check if 2FA is required
if ($userRow->twofactor !== "0") {
// Generate and send 2FA code
$twoFactor = new UserTwofactor($userRow->id);
$twoFactor->sendCode();
// Store pending auth in session for 2FA verification
$_SESSION['mobileapp_2fa_pending'] = [
'user_id' => $userRow->id,
'username' => $userRow->username,
'remember_me' => $rememberMe,
'timestamp' => time()
];
// Determine delivery method for UI feedback
$deliveryMethod = $userRow->twofactor == 1 ? 'email' : 'sms';
$maskedTarget = $deliveryMethod === 'email'
? $this->maskEmail($userRow->email)
: $this->maskPhone($userRow->mobile);
self::returnJson([
'success' => false,
'requires2FA' => true,
'deliveryMethod' => $deliveryMethod,
'maskedTarget' => $maskedTarget,
'message' => 'Verifizierungscode wurde gesendet'
]);
return;
}
// No 2FA - complete login directly
$this->completeLogin($userRow, $rememberMe);
}
/**
* POST /MobileApp/auth/verify2fa
*
* Step 2 of authentication - verify the 2FA code
*/
protected function authVerify2FA() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
self::returnJson(['success' => false, 'error' => 'Method not allowed'], 405);
return;
}
$postData = json_decode(file_get_contents('php://input'), true) ?? [];
$code = $postData['code'] ?? '';
// Check for pending 2FA session
if (!isset($_SESSION['mobileapp_2fa_pending'])) {
self::returnJson(['success' => false, 'message' => 'Keine ausstehende Verifizierung']);
return;
}
$pending = $_SESSION['mobileapp_2fa_pending'];
// Check if pending session is expired (10 minutes max)
if (time() - $pending['timestamp'] > 600) {
unset($_SESSION['mobileapp_2fa_pending']);
self::returnJson(['success' => false, 'message' => 'Sitzung abgelaufen. Bitte erneut anmelden.', 'expired' => true]);
return;
}
if (!$code || strlen($code) !== 5) {
self::returnJson(['success' => false, 'message' => 'Bitte gib den 5-stelligen Code ein']);
return;
}
$db = FronkDB::singleton();
$userId = intval($pending['user_id']);
// Get user's 2FA code and timestamp
$res = $db->select(MFUSERTABLE, "twofactorcode, twofactortimestamp, username", "id = {$userId}");
if (!$db->num_rows($res)) {
unset($_SESSION['mobileapp_2fa_pending']);
self::returnJson(['success' => false, 'message' => 'Benutzer nicht gefunden']);
return;
}
$userRow = $db->fetch_object($res);
$storedCode = $userRow->twofactorcode;
$codeTimestamp = intval($userRow->twofactortimestamp);
// Check if code is expired (5 minutes)
if (time() - $codeTimestamp > 300) {
self::returnJson(['success' => false, 'message' => 'Code abgelaufen. Bitte neuen Code anfordern.', 'codeExpired' => true]);
return;
}
// Verify code
if ($code !== $storedCode) {
sleep(1); // Rate limiting
self::returnJson(['success' => false, 'message' => 'Ungültiger Code']);
return;
}
// Clear the 2FA code
$twoFactor = new UserTwofactor($userId);
$twoFactor->removeCode();
// Clear pending session
unset($_SESSION['mobileapp_2fa_pending']);
// Get full user row for login completion
$res = $db->select(MFUSERTABLE, "*", "id = {$userId}");
$userRow = $db->fetch_object($res);
// Complete login
$this->completeLogin($userRow, $pending['remember_me']);
}
/**
* POST /MobileApp/auth/resend2fa
*
* Resend the 2FA code
*/
protected function authResend2FA() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
self::returnJson(['success' => false, 'error' => 'Method not allowed'], 405);
return;
}
// Check for pending 2FA session
if (!isset($_SESSION['mobileapp_2fa_pending'])) {
self::returnJson(['success' => false, 'message' => 'Keine ausstehende Verifizierung']);
return;
}
$pending = $_SESSION['mobileapp_2fa_pending'];
// Check if pending session is expired (10 minutes max)
if (time() - $pending['timestamp'] > 600) {
unset($_SESSION['mobileapp_2fa_pending']);
self::returnJson(['success' => false, 'message' => 'Sitzung abgelaufen. Bitte erneut anmelden.', 'expired' => true]);
return;
}
// Resend 2FA code
$twoFactor = new UserTwofactor($pending['user_id']);
$twoFactor->sendCode();
self::returnJson([
'success' => true,
'message' => 'Neuer Code wurde gesendet'
]);
}
/**
* Complete the login process after password (and optionally 2FA) verification
*/
protected function completeLogin($userRow, $rememberMe) {
$db = FronkDB::singleton();
$db->update(MFUSERTABLE, [
'ip' => $_SERVER['REMOTE_ADDR'],
'sessionid' => session_id()
], "id = {$userRow->id}");
$_SESSION[MFAPPNAME . '_username'] = $userRow->username;
$_SESSION[MFAPPNAME . '_ip'] = $_SERVER['REMOTE_ADDR'];
if ($rememberMe) {
UserToken::generateToken($userRow->id);
}
$user = new User();
$user->loadMe();
self::returnJson([
'success' => true,
'user' => [
'id' => $user->id,
'name' => $user->name,
'username' => $user->username,
]
]);
}
/**
* Mask email address for privacy (e.g., j***@example.com)
*/
protected function maskEmail($email) {
if (!$email) return '***';
$parts = explode('@', $email);
if (count($parts) !== 2) return '***';
$local = $parts[0];
$domain = $parts[1];
$masked = strlen($local) > 1 ? $local[0] . str_repeat('*', min(5, strlen($local) - 1)) : '*';
return $masked . '@' . $domain;
}
/**
* Mask phone number for privacy (e.g., +43***123)
*/
protected function maskPhone($phone) {
if (!$phone) return '***';
$phone = preg_replace('/\s+/', '', $phone);
if (strlen($phone) < 6) return '***';
return substr($phone, 0, 3) . str_repeat('*', strlen($phone) - 6) . substr($phone, -3);
}
/**
* POST /MobileApp/auth/logout
*/
protected function authLogout() {
mfLoginController::staticLogout();
self::returnJson(['success' => true]);
}
/**
* GET /MobileApp/auth/check
*/
protected function authCheck() {
if (mfLoginController::isLoggedIn()) {
$user = new User();
$user->loadMe();
if ($user->id) {
self::returnJson([
'authenticated' => true,
'user' => [
'id' => $user->id,
'name' => $user->name,
'username' => $user->username,
]
]);
return;
}
}
UserToken::checkToken();
if (isset($_SESSION[MFAPPNAME . '_username']) && $_SESSION[MFAPPNAME . '_username']) {
$user = new User();
$user->loadMe();
if ($user->id) {
self::returnJson([
'authenticated' => true,
'user' => [
'id' => $user->id,
'name' => $user->name,
'username' => $user->username,
]
]);
return;
}
}
self::returnJson(['authenticated' => false]);
}
/**
* Handle API calls to module endpoints
* /MobileApp/{module}/{submodule}/{endpoint}
*/
protected function handleApiCall($module, $submodule, $endpoint) {
// Normalize names
$moduleName = ucfirst(strtolower($module));
$submoduleName = ucfirst(strtolower($submodule));
// Check authentication for API calls
if (!$this->user || !$this->user->id) {
self::returnJson(['success' => false, 'error' => 'Not authenticated'], 401);
return;
}
// Build handler path
$handlerFile = APPDIR . "MobileApp/Modules/{$moduleName}/{$submoduleName}/{$submoduleName}Handler.php";
if (!file_exists($handlerFile)) {
self::returnJson(['success' => false, 'error' => "Module not found: {$moduleName}/{$submoduleName}"], 404);
return;
}
require_once $handlerFile;
$handlerClass = "{$submoduleName}Handler";
if (!class_exists($handlerClass)) {
self::returnJson(['success' => false, 'error' => "Handler class not found"], 500);
return;
}
$handler = new $handlerClass($this->request, $this->user, $this);
// Check permissions
if (!$handler->checkPermission()) {
self::returnJson(['success' => false, 'error' => 'Permission denied'], 403);
return;
}
// Route to method
$method = $endpoint . 'Action';
if (method_exists($handler, $method)) {
return $handler->$method();
}
if (method_exists($handler, $endpoint)) {
return $handler->$endpoint();
}
self::returnJson(['success' => false, 'error' => "Endpoint not found: {$endpoint}"], 404);
}
}

View File

@@ -0,0 +1,443 @@
<?php
require_once APPDIR . 'MobileApp/Shared/MobileAppBaseHandler.php';
/**
* Inventur (Stocktake) Handler
*
* Handles all endpoints for the Lager > Inventur module.
* API Base: /MobileApp/Lager/Inventur/{action}
*/
class InventurHandler extends MobileAppBaseHandler {
protected $requiredPermission = 'WarehouseUser';
/**
* Get active stocktakes
* GET /MobileApp/Lager/Inventur/getActiveStocktakes
*/
public 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
*/
public 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
*/
public function getArticleAction() {
$code = $this->request->code;
if (!$code) {
self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']);
return;
}
$articleId = null;
if (preg_match('/^(?:WA|WH):(\d+):/', $code, $matches)) {
$articleId = intval($matches[1]);
} else {
$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;
}
$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
*/
public function searchArticlesAction() {
$query = $this->request->query ?? '';
$categoryId = intval($this->request->categoryId ?? 0);
$db = $this->db();
$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 categories
*/
public function getCategoriesAction() {
$db = $this->db();
$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 already scanned
*/
public 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 = $this->db();
$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 scan
*/
public function submitScanAction() {
$postData = $this->getPostData();
$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;
}
$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;
}
$article = WarehouseArticleModel::get($articleId);
if (!$article) {
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
return;
}
$db = $this->db();
if ($overwrite && $overwriteItemId) {
$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;
$db->query("UPDATE WarehouseStocktakeItem SET overwrittenById = {$itemId} WHERE id = {$overwriteItemId}");
$finalQuantity = $quantity;
WarehouseStocktakeLogModel::log($stocktakeId, 'overwritten', $itemId, [
'articleId' => $articleId,
'articleNumber' => $article->articleNumber,
'articleTitle' => $article->title,
'quantity' => $quantity,
'overwrittenItemId' => $overwriteItemId,
]);
$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;
}
$existing = WarehouseStocktakeItemModel::getFirst([
'stocktakeId' => $stocktakeId,
'articleId' => $articleId,
'overwrittenById' => null
]);
if ($existing) {
$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 {
$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;
}
$stocktake->updateProgress();
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 my scans
*/
public function getMyScansAction() {
$stocktakeId = intval($this->request->stocktakeId);
if (!$stocktakeId) {
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
return;
}
$db = $this->db();
$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
*/
public 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 = $this->db();
$totalResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId}");
$totalRow = $totalResult->fetch_assoc();
$totalScanned = intval($totalRow['count']);
$myResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId} AND scannedBy = {$this->user->id}");
$myRow = $myResult->fetch_assoc();
$myScanned = intval($myRow['count']);
self::returnJson([
'success' => true,
'progress' => [
'totalScanned' => $totalScanned,
'myScanned' => $myScanned,
'status' => $stocktake->status,
]
]);
}
}

View File

@@ -0,0 +1,346 @@
<?php
require_once APPDIR . 'MobileApp/Shared/MobileAppBaseHandler.php';
/**
* Movement (Stock Movement) Handler
*
* Handles all endpoints for the Lager > Movement module.
* API Base: /MobileApp/Lager/Movement/{action}
*/
class MovementHandler extends MobileAppBaseHandler {
protected $requiredPermission = 'WarehouseUser';
/**
* Get available locations (Office + Außenlager only)
* GET /MobileApp/Lager/Movement/getLocations
*/
public function getLocationsAction() {
$allLocations = WarehouseLocationModel::getAll();
$locations = [];
foreach ($allLocations as $location) {
$title = strtolower($location->title);
if ($title === 'k1 fladnitz 150' || $title === 'aussenlager-extern') {
$locations[] = [
'id' => $location->id,
'title' => $location->title,
];
}
}
self::returnJson(['success' => true, 'locations' => $locations]);
}
/**
* Get article by QR code or article number
* GET /MobileApp/Lager/Movement/getArticle?code=X
*/
public function getArticleAction() {
$code = $this->request->code;
if (!$code) {
self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']);
return;
}
$articleId = null;
// Check for QR code format WA:ID: or WH:ID:
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;
}
$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
* GET /MobileApp/Lager/Movement/searchArticles?query=X
*/
public function searchArticlesAction() {
$query = $this->request->query ?? '';
$db = $this->db();
$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}%')";
} else {
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.',
];
}
self::returnJson(['success' => true, 'articles' => $articles]);
}
/**
* Get reason categories for a movement type
* GET /MobileApp/Lager/Movement/getReasonCategories?type=IN|OUT|ADJUSTMENT
*/
public function getReasonCategoriesAction() {
$type = $this->request->type ?? null;
$categories = WarehouseMovementModel::getReasonCategories($type);
if ($type && is_array($categories)) {
$items = [];
foreach ($categories as $key => $label) {
$items[] = ['value' => $key, 'text' => $label];
}
self::returnJson(['success' => true, 'categories' => $items]);
} else {
self::returnJson(['success' => true, 'categories' => $categories]);
}
}
/**
* Get current stock for an article at a location
* GET /MobileApp/Lager/Movement/getCurrentStock?articleId=X&locationId=X
*/
public function getCurrentStockAction() {
$articleId = intval($this->request->articleId ?? 0);
$locationId = intval($this->request->locationId ?? 0);
if (!$articleId || !$locationId) {
self::returnJson(['success' => true, 'currentStock' => 0]);
return;
}
$existingItems = WarehouseItemModel::getAll([
'articleId' => $articleId,
'warehouseLocationId' => $locationId
]);
$currentStock = count($existingItems) > 0 ? floatval($existingItems[0]->quantity) : 0;
self::returnJson(['success' => true, 'currentStock' => $currentStock]);
}
/**
* Submit a stock movement
* POST /MobileApp/Lager/Movement/submitMovement
*/
public function submitMovementAction() {
$postData = $this->getPostData();
$movementType = $postData['movementType'] ?? '';
$articleId = intval($postData['articleId'] ?? 0);
$locationId = intval($postData['locationId'] ?? 0);
$quantity = floatval($postData['quantity'] ?? 0);
$reasonCategory = $postData['reasonCategory'] ?? '';
$note = $postData['note'] ?? null;
// Validate required fields
if (!in_array($movementType, ['IN', 'OUT', 'ADJUSTMENT'])) {
self::returnJson(['success' => false, 'message' => 'Ungültiger Bewegungstyp']);
return;
}
if ($articleId <= 0) {
self::returnJson(['success' => false, 'message' => 'Kein Artikel ausgewählt']);
return;
}
if ($locationId <= 0) {
self::returnJson(['success' => false, 'message' => 'Kein Lagerort ausgewählt']);
return;
}
if ($quantity <= 0) {
self::returnJson(['success' => false, 'message' => 'Menge muss größer als 0 sein']);
return;
}
if (empty($reasonCategory)) {
self::returnJson(['success' => false, 'message' => 'Bitte Grund auswählen']);
return;
}
// Get article info
$article = WarehouseArticleModel::get($articleId);
if (!$article) {
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
return;
}
$db = $this->db();
// Find or create WarehouseItem for this article at this location
$existingItems = WarehouseItemModel::getAll([
'articleId' => $articleId,
'warehouseLocationId' => $locationId
]);
$warehouseItem = count($existingItems) > 0 ? $existingItems[0] : null;
$currentQty = $warehouseItem ? floatval($warehouseItem->quantity) : 0;
// Calculate new quantity based on movement type
// Note: Negative stock is allowed (items can be taken out even if stock is 0)
switch ($movementType) {
case 'IN':
$newQty = $currentQty + $quantity;
break;
case 'OUT':
$newQty = $currentQty - $quantity;
// Negative stock is allowed - no validation needed
break;
case 'ADJUSTMENT':
// For adjustment, quantity is the new absolute value
$newQty = $quantity;
break;
default:
$newQty = $currentQty;
}
// Update or create WarehouseItem
$warehouseItemId = null;
if ($warehouseItem) {
$db->query("UPDATE WarehouseItem SET quantity = {$newQty} WHERE id = {$warehouseItem->id}");
$warehouseItemId = $warehouseItem->id;
} else {
$db->query("INSERT INTO WarehouseItem (articleId, warehouseLocationId, quantity, createBy, `create`)
VALUES ({$articleId}, {$locationId}, {$newQty}, {$this->user->id}, " . time() . ")");
$warehouseItemId = $db->insert_id;
}
// Create the movement record
$noteEscaped = $note ? "'" . $db->escape($note) . "'" : "NULL";
$db->query("INSERT INTO WarehouseMovement
(movementType, articleId, warehouseLocationId, warehouseItemId, quantity, quantityBefore, quantityAfter, reasonCategory, note, userId, createBy, `create`)
VALUES ('{$movementType}', {$articleId}, {$locationId}, {$warehouseItemId}, {$quantity}, {$currentQty}, {$newQty}, '{$db->escape($reasonCategory)}', {$noteEscaped}, {$this->user->id}, {$this->user->id}, " . time() . ")");
$movementId = $db->insert_id;
// Generate movement number
$movementNumber = WarehouseMovementModel::generateMovementNumber();
$db->query("UPDATE WarehouseMovement SET movementNumber = '{$movementNumber}' WHERE id = {$movementId}");
// Get type label for message
$typeLabels = ['IN' => 'Einbuchung', 'OUT' => 'Ausbuchung', 'ADJUSTMENT' => 'Korrektur'];
$typeLabel = $typeLabels[$movementType] ?? $movementType;
self::returnJson([
'success' => true,
'message' => "{$typeLabel} erfolgreich: {$quantity} x {$article->title}",
'movement' => [
'id' => $movementId,
'movementNumber' => $movementNumber,
'movementType' => $movementType,
'articleId' => $articleId,
'articleTitle' => $article->title,
'quantity' => $quantity,
'quantityBefore' => $currentQty,
'quantityAfter' => $newQty,
]
]);
}
/**
* Get recent movements by current user
* GET /MobileApp/Lager/Movement/getMyMovements
*/
public function getMyMovementsAction() {
$locationId = intval($this->request->locationId ?? 0);
$limit = intval($this->request->limit ?? 20);
$db = $this->db();
$whereClause = "m.userId = {$this->user->id}";
if ($locationId > 0) {
$whereClause .= " AND m.warehouseLocationId = {$locationId}";
}
$result = $db->query("SELECT m.*, wa.articleNumber, wa.title as articleTitle, wa.unit, wl.title as locationTitle
FROM WarehouseMovement m
LEFT JOIN WarehouseArticle wa ON wa.id = m.articleId
LEFT JOIN WarehouseLocation wl ON wl.id = m.warehouseLocationId
WHERE {$whereClause}
ORDER BY m.`create` DESC
LIMIT {$limit}");
$movements = [];
while ($row = $result->fetch_assoc()) {
$movements[] = [
'id' => intval($row['id']),
'movementNumber' => $row['movementNumber'],
'movementType' => $row['movementType'],
'articleId' => intval($row['articleId']),
'articleNumber' => $row['articleNumber'],
'articleTitle' => $row['articleTitle'],
'unit' => $row['unit'] ?? 'Stk.',
'locationTitle' => $row['locationTitle'],
'quantity' => floatval($row['quantity']),
'quantityBefore' => floatval($row['quantityBefore']),
'quantityAfter' => floatval($row['quantityAfter']),
'reasonCategory' => $row['reasonCategory'],
'note' => $row['note'],
'create' => date('d.m.Y H:i', $row['create']),
];
}
self::returnJson(['success' => true, 'movements' => $movements]);
}
/**
* Get movement types with labels
* GET /MobileApp/Lager/Movement/getMovementTypes
*/
public function getMovementTypesAction() {
$types = [
['value' => 'IN', 'text' => 'Einbuchung', 'icon' => 'plus-circle', 'color' => 'green'],
['value' => 'OUT', 'text' => 'Ausbuchung', 'icon' => 'minus-circle', 'color' => 'red'],
['value' => 'ADJUSTMENT', 'text' => 'Korrektur', 'icon' => 'edit', 'color' => 'yellow'],
];
self::returnJson(['success' => true, 'types' => $types]);
}
}

View File

@@ -0,0 +1,113 @@
<?php
/**
* Base Handler for Mobile App endpoints
*
* All app handlers should extend this class.
* Provides common functionality for authentication, permissions, and responses.
*/
abstract class MobileAppBaseHandler {
/** @var object Request object */
protected $request;
/** @var User|null Current user */
protected $user;
/** @var MobileAppController Parent controller */
protected $controller;
/** @var string Required permission for this app (override in subclass) */
protected $requiredPermission = null;
/** @var string App name (used for view rendering) */
protected $appName = '';
/** @var string View template path */
protected $viewTemplate = '';
/**
* Constructor
*/
public function __construct($request, $user, $controller) {
$this->request = $request;
$this->user = $user;
$this->controller = $controller;
}
/**
* Check if user has required permission
* @return bool
*/
public function checkPermission() {
// If no permission required, allow access
if (!$this->requiredPermission) {
return true;
}
// If no user, deny access
if (!$this->user || !$this->user->id) {
return false;
}
// Check permission
return $this->user->can($this->requiredPermission);
}
/**
* Render the app view
* Override in subclass if custom rendering needed
*/
public function renderView() {
$layout = $this->controller->layout();
// Set template
if ($this->viewTemplate) {
$layout->setTemplate($this->viewTemplate);
} else {
$layout->setTemplate("MobileApp/{$this->appName}");
}
// Set default JS globals
$layout->set("JSGlobals", $this->getJSGlobals());
}
/**
* Get JS globals to pass to frontend
* Override in subclass to add app-specific globals
*/
protected function getJSGlobals() {
$globals = [
'BASE_PATH' => '/MobileApp/' . $this->appName,
'APP_NAME' => $this->appName,
];
if ($this->user && $this->user->id) {
$globals['USER_ID'] = $this->user->id;
$globals['USER_NAME'] = $this->user->name;
}
return $globals;
}
/**
* Return JSON response (shorthand)
*/
protected static function returnJson($data, $statusCode = 200) {
mfBaseController::returnJson($data, $statusCode);
}
/**
* Get POST data from JSON body
*/
protected function getPostData() {
return json_decode(file_get_contents('php://input'), true) ?? [];
}
/**
* Get database instance
*/
protected function db() {
return FronkDB::singleton();
}
}

View File

@@ -17,6 +17,25 @@ class PopController extends mfBaseController
}
}
private function getMapCategories()
{
$categories = [];
foreach (PopModel::$categoryArray as $id => $cat) {
$categories[] = [
'id' => $id,
'name' => $cat['name'],
'icon' => 'assets/img/markers/pop_' . $id . '.png',
];
}
$categories[] = [
'id' => null,
'name' => 'Unbekannt',
'icon' => 'assets/img/markers/pop_unknown.png',
];
return $categories;
}
protected function indexAction()
{
$networks = array_map(function ($network) {
@@ -30,7 +49,7 @@ class PopController extends mfBaseController
return [
"id" => $pop->id,
"name" => $pop->name,
"category" => $pop->category,
"category" => $pop->category ?: 99,
"networkArea" => $pop->networks,
"location" => $pop->location,
"state" => $pop->state,
@@ -45,6 +64,8 @@ class PopController extends mfBaseController
];
}, PopModel::getAlladv());
$categories = $this->getMapCategories();
$JSGlobals = ["BASE_URL" => self::getUrl(""),
"DASHBOARD_URL" => self::getUrl("Dashboard"),
"MFAPPNAME" => MFAPPNAME_SLUG,
@@ -55,11 +76,20 @@ class PopController extends mfBaseController
],
"NETWORKS" => $networks,
"POPS" => $pops,
"CATEGORIES" => $categories,
"IS_ADMIN" => $this->me->is("Admin"),
"MAPBOX_TOKEN" => TT_MAPBOX_TILE_API_TOKEN,
];
$this->layout()->set("vueViewName", "Pop");
$this->layout()->set("JSGlobals", $JSGlobals);
$this->layout()->set("additionalCSS", [
"assets/css/leaflet.css",
]);
$this->layout()->set("additionalJS", [
"assets/js/leaflet.js",
"assets/js/leaflet.MakiMarkers.js"
]);
$this->layout()->setTemplate("VueViews/Vue");
}
@@ -112,6 +142,7 @@ class PopController extends mfBaseController
{
$network_id = 90;
$this->layout()->set("network_id", $network_id);
$this->layout()->set("categories", $this->getMapCategories());
$this->layout()->setTemplate("Pop/Map");
}
@@ -258,28 +289,220 @@ class PopController extends mfBaseController
$home_id = $this->request->home_id;
if (!$fiber_id && !$home_id) {
return mfBaseController::returnJson(mfResponse::BadRequest(['message' => 'Ungültige Faser-ID oder Home-ID']));
return mfBaseController::returnJson(mfResponse::BadRequest(['message' => 'Ungültige ID']));
}
if ($home_id) {
if ($home_id && !$fiber_id) {
$sql = "SELECT id FROM FiberPlanFiber WHERE home_id = '" . $db->escape($home_id) . "' LIMIT 1";
$res = $db->query($sql);
if ($db->num_rows($res)) {
$row = $db->fetch_array($res);
$fiber_id = $row['id'];
} else {
return mfBaseController::returnJson(mfResponse::NotFound(['message' => 'Keine Faser für Home-ID gefunden']));
}
}
$fiber = new FiberPlanFiber($fiber_id);
if (!$fiber->id) {
return mfBaseController::returnJson(mfResponse::NotFound(['message' => 'Faser nicht gefunden']));
}
$this->log->debug("Lade Faser-Strecke für Faser ID: $fiber_id");
$details = $fiber->toArray();
$details['customer_cable_type'] = $fiber->customer_cable_type;
$details['customer_cable_fiber_nr'] = $fiber->customer_cable_fiber_nr;
$details['customer_connector_type'] = $fiber->customer_connector_type;
$details['customer_cable_spec'] = $fiber->customer_cable_spec;
$details['customer_fiber_range'] = $fiber->customer_fiber_range;
$details['bundle_nr'] = $fiber->bundle_nr;
$details['bundle_color'] = $fiber->bundle_color;
$details['bundle_color_hex'] = $fiber->bundle_color_hex;
$details['fiber_nr_bundle'] = $fiber->fiber_nr_bundle;
if ($fiber->address || $fiber->home_id) {
$customerGps = $this->geocodeAddress($fiber->address, $fiber->home_id);
if ($customerGps) $details['customer_gps'] = $customerGps;
}
$debug = [];
if ($home_id) {
$cableChain = $this->buildCompleteCableChain($fiber);
if (count($cableChain) > 0) {
$mainCable = $cableChain[0]['cable'];
$mainFiber = $cableChain[0]['fiber'];
$cable_route_data = FiberPlanCableModel::getCableRoute($mainCable->id);
$cable_route_array = [];
foreach ($cable_route_data as $station) $cable_route_array[] = $station['name'];
$allFibers = FiberPlanFiberModel::getByCableAndSheet($mainCable->id, null);
$fibersArray = [];
foreach ($allFibers as $f) {
$fibersArray[] = [
'id' => $f->id, 'fiber_nr_cable' => $f->fiber_nr_cable,
'fiber_color' => $f->fiber_color, 'fiber_color_hex' => $f->fiber_color_hex,
'bundle_nr' => $f->bundle_nr, 'bundle_color' => $f->bundle_color, 'bundle_color_hex' => $f->bundle_color_hex,
'branch_type' => $f->branch_type, 'branch_cable_nr' => $f->branch_cable_nr,
'branch_from_location' => $f->branch_from_location, 'branch_fiber_nr' => $f->branch_fiber_nr
];
}
$branchPoints = [];
$sql = "SELECT id, description as name, gps_lat, gps_long, object_type, 'dispatcher' as type
FROM FiberPlanDispatcher WHERE network_id = 90 AND object_type = 4 AND gps_lat IS NOT NULL";
$res = $db->query($sql);
while ($data = $db->fetch_array($res)) {
$branchPoints[] = ['id' => $data['id'], 'name' => $data['name'], 'gps_lat' => $data['gps_lat'], 'gps_long' => $data['gps_long'], 'object_type' => intval($data['object_type']), 'type' => $data['type']];
}
$details['cable_info'] = [
'id' => $mainCable->id, 'description' => $mainCable->description, 'fibers' => $fibersArray,
'diameter' => $mainCable->diameter, 'cable_route_array' => $cable_route_array,
'cable_route_full' => $cable_route_data, 'coordinates' => $mainCable->coordinates,
'location' => $mainFiber->location, 'branch_points' => $branchPoints
];
$allCablesForMatching = [];
foreach ($cableChain as $chainItem) {
$c = $chainItem['cable'];
$coords = json_decode($c->coordinates, true);
if ($coords) $allCablesForMatching[] = ['id' => $c->id, 'description' => $c->description, 'coordinates' => $coords];
}
$sql = "SELECT id, description, coordinates FROM FiberPlanCable WHERE network_id = 90 AND coordinates IS NOT NULL AND coordinates != '' AND coordinates != '[]'";
$res = $db->query($sql);
while ($c = $db->fetch_array($res)) {
$coords = json_decode($c['coordinates'], true);
if ($coords) {
$exists = false; foreach($allCablesForMatching as $ex) { if($ex['id'] == $c['id']) $exists=true; }
if(!$exists) $allCablesForMatching[] = ['id' => $c['id'], 'description' => $c['description'], 'coordinates' => $coords];
}
}
$details['all_cables'] = $allCablesForMatching;
}
if (count($cableChain) > 1) {
$details['branch_path'] = $this->buildBranchPathFromChain($cableChain, 1, $debug);
}
} else {
$this->log->debug("=== MODUS: Vorwärts-Trace ===");
if ($fiber->cable_id) {
$cable = new FiberPlanCable($fiber->cable_id);
if ($cable->id) {
$cable_route_data = FiberPlanCableModel::getCableRoute($cable->id);
$cable_route_array = [];
foreach ($cable_route_data as $station) $cable_route_array[] = $station['name'];
$branchPoints = [];
$sql = "SELECT id, description as name, gps_lat, gps_long, object_type, 'dispatcher' as type
FROM FiberPlanDispatcher WHERE network_id = 90 AND object_type = 4 AND gps_lat IS NOT NULL";
$res = $db->query($sql);
while ($data = $db->fetch_array($res)) {
$branchPoints[] = ['id' => $data['id'], 'name' => $data['name'], 'gps_lat' => $data['gps_lat'], 'gps_long' => $data['gps_long'], 'object_type' => intval($data['object_type']), 'type' => $data['type']];
}
$allFibers = FiberPlanFiberModel::getByCableAndSheet($cable->id, null);
$fibersArray = [];
foreach ($allFibers as $f) {
$fibersArray[] = [
'id' => $f->id, 'fiber_nr_cable' => $f->fiber_nr_cable, 'fiber_color' => $f->fiber_color, 'fiber_color_hex' => $f->fiber_color_hex,
'bundle_nr' => $f->bundle_nr, 'bundle_color' => $f->bundle_color, 'bundle_color_hex' => $f->bundle_color_hex,
'branch_type' => $f->branch_type, 'branch_cable_nr' => $f->branch_cable_nr,
'branch_from_location' => $f->branch_from_location, 'branch_fiber_nr' => $f->branch_fiber_nr
];
}
$details['cable_info'] = [
'id' => $cable->id, 'description' => $cable->description, 'fibers' => $fibersArray,
'diameter' => $cable->diameter, 'cable_route_array' => $cable_route_array,
'cable_route_full' => $cable_route_data, 'coordinates' => $cable->coordinates,
'location' => $fiber->location, 'branch_points' => $branchPoints
];
}
}
if ($fiber->branch_type === 'Abzweigkabel' && $fiber->branch_cable_nr) {
$details['branch_path'] = $this->traceBranchPath($fiber, 0, 10, $debug);
}
$allCablesForMatching = [];
$sql = "SELECT id, description, coordinates FROM FiberPlanCable WHERE network_id = 90 AND coordinates IS NOT NULL AND coordinates != '' AND coordinates != '[]'";
$res = $db->query($sql);
while ($c = $db->fetch_array($res)) {
$coords = json_decode($c['coordinates'], true);
if ($coords) $allCablesForMatching[] = ['id' => $c['id'], 'description' => $c['description'], 'coordinates' => $coords];
}
$details['all_cables'] = $allCablesForMatching;
}
$details['debug'] = $debug;
return mfBaseController::returnJson(mfResponse::Ok(['fiber' => $details]));
}
protected function getAllFiberPathsForHomeAction()
{
$db = FronkDB::singleton();
$home_id = $this->request->home_id;
if (!$home_id) {
return mfBaseController::returnJson(mfResponse::BadRequest(['message' => 'Ungültige Home-ID']));
}
$sql = "SELECT id FROM FiberPlanFiber WHERE home_id = '" . $db->escape($home_id) . "'";
$res = $db->query($sql);
$fiberIds = [];
if ($db->num_rows($res)) {
while ($row = $db->fetch_array($res)) {
$fiberIds[] = $row['id'];
}
}
if (empty($fiberIds)) {
return mfBaseController::returnJson(mfResponse::NotFound(['message' => 'Keine Fasern für Home-ID gefunden']));
}
$globalBranchPoints = [];
$sqlBP = "SELECT id, description as name, gps_lat, gps_long, object_type, 'dispatcher' as type
FROM FiberPlanDispatcher
WHERE network_id = 90 AND object_type IN (1,2,3,4)
AND gps_lat IS NOT NULL AND gps_long IS NOT NULL";
$resBP = $db->query($sqlBP);
if ($db->num_rows($resBP)) {
while ($data = $db->fetch_array($resBP)) {
$globalBranchPoints[] = [
'id' => $data['id'],
'name' => $data['name'],
'gps_lat' => $data['gps_lat'],
'gps_long' => $data['gps_long'],
'object_type' => intval($data['object_type']),
'type' => $data['type']
];
}
}
$globalCablesWithCoords = [];
$sqlCables = "SELECT id, description, coordinates
FROM FiberPlanCable
WHERE network_id = 90
AND coordinates IS NOT NULL
AND coordinates != ''
AND coordinates != '[]'";
$resCables = $db->query($sqlCables);
while ($cableData = $db->fetch_array($resCables)) {
$coords = json_decode($cableData['coordinates'], true);
if ($coords && is_array($coords) && count($coords) > 0) {
$globalCablesWithCoords[] = [
'id' => $cableData['id'],
'description' => $cableData['description'],
'coordinates' => $coords
];
}
}
$allPaths = [];
foreach ($fiberIds as $fiber_id) {
$fiber = new FiberPlanFiber($fiber_id);
if (!$fiber->id) continue;
$details = $fiber->toArray();
$details['customer_cable_type'] = $fiber->customer_cable_type;
$details['customer_cable_fiber_nr'] = $fiber->customer_cable_fiber_nr;
@@ -295,7 +518,6 @@ class PopController extends mfBaseController
$customerGps = $this->geocodeAddress($fiber->address, $fiber->home_id);
if ($customerGps) {
$details['customer_gps'] = $customerGps;
$this->log->debug("GPS für Kunde gefunden: " . json_encode($customerGps));
}
}
@@ -303,16 +525,11 @@ class PopController extends mfBaseController
$debug['start_fiber'] = [
'id' => $fiber->id,
'fiber_nr_cable' => $fiber->fiber_nr_cable,
'branch_type' => $fiber->branch_type,
'branch_cable_nr' => $fiber->branch_cable_nr,
'branch_fiber_nr' => $fiber->branch_fiber_nr
'branch_type' => $fiber->branch_type
];
if ($home_id) {
$this->log->debug("=== MODUS: Rückwärts-Trace (von home_id) ===");
$cableChain = $this->buildCompleteCableChain($fiber);
$debug['cable_chain_count'] = count($cableChain);
$debug['cable_chain'] = array_map(function($item) {
return [
'cable_id' => $item['cable']->id,
@@ -333,12 +550,17 @@ class PopController extends mfBaseController
$cable_route_array[] = $station['name'];
}
$allFibers = FiberPlanFiberModel::getByCableAndSheet($mainCable->id, null);
$allMainFibers = FiberPlanFiberModel::getByCableAndSheet($mainCable->id, null);
$fibersArray = [];
foreach ($allFibers as $f) {
foreach ($allMainFibers as $f) {
$fibersArray[] = [
'id' => $f->id,
'fiber_nr_cable' => $f->fiber_nr_cable,
'fiber_color' => $f->fiber_color,
'fiber_color_hex' => $f->fiber_color_hex,
'bundle_nr' => $f->bundle_nr,
'bundle_color' => $f->bundle_color,
'bundle_color_hex' => $f->bundle_color_hex,
'branch_type' => $f->branch_type,
'branch_cable_nr' => $f->branch_cable_nr,
'branch_from_location' => $f->branch_from_location,
@@ -346,26 +568,6 @@ class PopController extends mfBaseController
];
}
$branchPoints = [];
$sql = "SELECT id, description as name, gps_lat, gps_long, object_type, 'dispatcher' as type
FROM FiberPlanDispatcher
WHERE network_id = 90 AND object_type = 4
AND gps_lat IS NOT NULL AND gps_long IS NOT NULL";
$res = $db->query($sql);
if ($db->num_rows($res)) {
while ($data = $db->fetch_array($res)) {
$branchPoints[] = [
'id' => $data['id'],
'name' => $data['name'],
'gps_lat' => $data['gps_lat'],
'gps_long' => $data['gps_long'],
'object_type' => intval($data['object_type']),
'type' => $data['type']
];
}
}
$details['cable_info'] = [
'id' => $mainCable->id,
'description' => $mainCable->description,
@@ -375,139 +577,54 @@ class PopController extends mfBaseController
'cable_route_full' => $cable_route_data,
'coordinates' => $mainCable->coordinates,
'location' => $mainFiber->location,
'branch_points' => $branchPoints
'branch_points' => $globalBranchPoints
];
$allCablesForMatching = [];
foreach ($cableChain as $chainItem) {
$cable = $chainItem['cable'];
$coords = $cable->coordinates;
$c = $chainItem['cable'];
$coords = $c->coordinates;
if (is_string($coords)) {
$coords = json_decode($coords, true);
}
if ($coords && is_array($coords) && count($coords) > 0) {
$allCablesForMatching[] = [
'id' => $cable->id,
'description' => $cable->description,
'id' => $c->id,
'description' => $c->description,
'coordinates' => $coords
];
}
}
$sql = "SELECT id, description, coordinates
FROM FiberPlanCable
WHERE network_id = 90
AND coordinates IS NOT NULL
AND coordinates != ''
AND coordinates != '[]'";
$res = $db->query($sql);
while ($cableData = $db->fetch_array($res)) {
$coords = json_decode($cableData['coordinates'], true);
if ($coords && is_array($coords) && count($coords) > 0) {
foreach ($globalCablesWithCoords as $gc) {
$exists = false;
foreach ($allCablesForMatching as $existing) {
if ($existing['id'] == $cableData['id']) {
if ($existing['id'] == $gc['id']) {
$exists = true;
break;
}
}
if (!$exists) {
$allCablesForMatching[] = [
'id' => $cableData['id'],
'description' => $cableData['description'],
'coordinates' => $coords
];
}
$allCablesForMatching[] = $gc;
}
}
$details['all_cables'] = $allCablesForMatching;
$this->log->debug("Hausanschluss-Matching: " . count($allCablesForMatching) . " Kabel verfügbar");
}
if (count($cableChain) > 1) {
$details['branch_path'] = $this->buildBranchPathFromChain($cableChain, 1, $debug);
}
} else {
$this->log->debug("=== MODUS: Vorwärts-Trace (von fiber_id) ===");
if ($fiber->cable_id) {
$cable = new FiberPlanCable($fiber->cable_id);
if ($cable->id) {
$cable_route_data = FiberPlanCableModel::getCableRoute($cable->id);
$cable_route_array = [];
foreach ($cable_route_data as $station) {
$cable_route_array[] = $station['name'];
}
$branchPoints = [];
$sql = "SELECT id, description as name, gps_lat, gps_long, object_type, 'dispatcher' as type
FROM FiberPlanDispatcher
WHERE network_id = 90 AND object_type = 4
AND gps_lat IS NOT NULL AND gps_long IS NOT NULL";
$res = $db->query($sql);
if ($db->num_rows($res)) {
while ($data = $db->fetch_array($res)) {
$branchPoints[] = [
'id' => $data['id'],
'name' => $data['name'],
'gps_lat' => $data['gps_lat'],
'gps_long' => $data['gps_long'],
'object_type' => intval($data['object_type']),
'type' => $data['type']
];
}
}
$allFibers = FiberPlanFiberModel::getByCableAndSheet($cable->id, null);
$fibersArray = [];
foreach ($allFibers as $f) {
$fibersArray[] = [
'id' => $f->id,
'fiber_nr_cable' => $f->fiber_nr_cable,
'branch_type' => $f->branch_type,
'branch_cable_nr' => $f->branch_cable_nr,
'branch_from_location' => $f->branch_from_location,
'branch_fiber_nr' => $f->branch_fiber_nr
];
}
$details['cable_info'] = [
'id' => $cable->id,
'description' => $cable->description,
'fibers' => $fibersArray,
'diameter' => $cable->diameter,
'cable_route_array' => $cable_route_array,
'cable_route_full' => $cable_route_data,
'coordinates' => $cable->coordinates,
'location' => $fiber->location,
'branch_points' => $branchPoints
];
if ($cable->cable_route) {
$routeArray = json_decode($cable->cable_route, true);
if (is_array($routeArray)) {
$details['cable_info']['cable_route_array'] = $routeArray;
}
}
}
}
if ($fiber->branch_type === 'Abzweigkabel' && $fiber->branch_cable_nr) {
$details['branch_path'] = $this->traceBranchPath($fiber, 0, 10, $debug);
}
}
$details['debug'] = $debug;
return mfBaseController::returnJson(mfResponse::Ok(['fiber' => $details]));
$allPaths[] = [
'fiber' => $details
];
}
return mfBaseController::returnJson(mfResponse::Ok(['paths' => $allPaths]));
}
private function buildCompleteCableChain($endFiber)
@@ -530,9 +647,12 @@ class PopController extends mfBaseController
]);
while ($depth < $maxDepth) {
$currentFiberNr = intval($currentFiber->fiber_nr_cable);
$sql = "SELECT * FROM FiberPlanFiber
WHERE branch_type = 'Abzweigkabel'
AND branch_cable_nr = '" . $db->escape($currentCable->description) . "'
AND branch_fiber_nr = $currentFiberNr
LIMIT 1";
$this->log->debug("Depth $depth: Suche Parent-Faser für Kabel: {$currentCable->description}");
@@ -1267,6 +1387,9 @@ class PopController extends mfBaseController
case "getFiberPath":
return $this->getFiberPathAction();
break;
case "getAllFiberPathsForHome":
return $this->getAllFiberPathsForHomeAction();
break;
case "saveCableFibers":
return $this->saveCableFibersAction();
break;
@@ -1276,6 +1399,9 @@ class PopController extends mfBaseController
case "getNetworkMapData":
return $this->getNetworkMapDataAction();
break;
case "getSplicePlanForElement":
return $this->getSplicePlanForElementAction();
break;
default:
$return = false;
}
@@ -1459,7 +1585,7 @@ class PopController extends mfBaseController
$cables = [];
$cableRes = $db->select(
"FiberPlanCable",
"id, description, fibers, diameter, state, coordinates",
"id, description, fibers, diameter, state, coordinates, level, cable_type, status",
"network_id=$network_id"
);
@@ -1491,7 +1617,10 @@ class PopController extends mfBaseController
'coordinates' => $convertedCoords,
'fibers' => $cableData->fibers,
'diameter' => $cableData->diameter,
'state' => $cableData->state
'state' => $cableData->state,
'level' => $cableData->level,
'cable_type' => $cableData->cable_type,
'status' => $cableData->status
];
}
}
@@ -1648,4 +1777,126 @@ class PopController extends mfBaseController
'customerConnections' => $customerConnections
]));
}
protected function getSplicePlanForElementAction()
{
$id = $this->request->id;
if (!is_numeric($id)) {
return mfBaseController::returnJson(mfResponse::BadRequest(['message' => 'Invalid ID']));
}
$db = FronkDB::singleton();
$dispatcherRes = $db->select("FiberPlanDispatcher", "*", "id=$id");
if (!$db->num_rows($dispatcherRes)) {
return mfBaseController::returnJson(mfResponse::NotFound(['message' => 'Verteiler nicht gefunden']));
}
$dispatcher = $db->fetch_object($dispatcherRes);
$dispatcherName = $dispatcher->description;
$cableIds = [];
$sql = "SELECT DISTINCT cable_id FROM FiberPlanCableStation WHERE station_type='dispatcher' AND station_id=$id";
$res = $db->query($sql);
if ($db->num_rows($res)) {
while ($row = $db->fetch_array($res)) {
$cableIds[] = $row['cable_id'];
}
}
$result = [];
if (!empty($cableIds)) {
$cableIdsStr = implode(',', $cableIds);
$cableMap = [];
$cableRes = $db->select("FiberPlanCable", "id, description", "id IN ($cableIdsStr)");
while ($c = $db->fetch_object($cableRes)) {
$cableMap[$c->id] = $c->description;
}
$escapedName = $db->escape($dispatcherName);
$sqlFibers = "SELECT * FROM FiberPlanFiber WHERE cable_id IN ($cableIdsStr) AND (branch_from_location = '$escapedName' OR location = '$escapedName')";
$fiberRes = $db->query($sqlFibers);
$rawFibers = [];
$targetCableNames = [];
while ($fiber = $db->fetch_object($fiberRes)) {
$rawFibers[] = $fiber;
if ($fiber->branch_cable_nr) {
$targetCableNames[$fiber->branch_cable_nr] = true;
}
}
$targetColorMap = [];
if (!empty($targetCableNames)) {
$namesList = [];
foreach (array_keys($targetCableNames) as $name) {
$namesList[] = "'" . $db->escape($name) . "'";
}
$namesStr = implode(',', $namesList);
$targetCablesRes = $db->select("FiberPlanCable", "id, description", "description IN ($namesStr)");
$targetCableIds = [];
$targetCableIdToName = [];
while ($tc = $db->fetch_object($targetCablesRes)) {
$targetCableIds[] = $tc->id;
$targetCableIdToName[$tc->id] = $tc->description;
}
if (!empty($targetCableIds)) {
$tcIdsStr = implode(',', $targetCableIds);
$targetFibersRes = $db->select("FiberPlanFiber", "cable_id, fiber_nr_cable, fiber_color, fiber_color_hex", "cable_id IN ($tcIdsStr)");
while ($tf = $db->fetch_object($targetFibersRes)) {
$cName = $targetCableIdToName[$tf->cable_id] ?? null;
if ($cName) {
$targetColorMap[$cName][$tf->fiber_nr_cable] = [
'color' => $tf->fiber_color,
'hex' => $tf->fiber_color_hex
];
}
}
}
}
foreach ($rawFibers as $fiber) {
$targetColorInfo = null;
if ($fiber->branch_cable_nr && $fiber->branch_fiber_nr) {
$targetColorInfo = $targetColorMap[$fiber->branch_cable_nr][$fiber->branch_fiber_nr] ?? null;
}
$result[] = [
'cable_name' => $cableMap[$fiber->cable_id] ?? 'Unknown',
'fiber_nr' => $fiber->fiber_nr_cable,
'fiber_color' => $fiber->fiber_color,
'fiber_color_hex' => $fiber->fiber_color_hex,
'bundle_color' => $fiber->bundle_color,
'bundle_color_hex' => $fiber->bundle_color_hex,
'target_cable' => $fiber->branch_cable_nr,
'target_fiber' => $fiber->branch_fiber_nr,
'target_fiber_color' => $targetColorInfo['color'] ?? null,
'target_fiber_color_hex' => $targetColorInfo['hex'] ?? null,
'target_bundle_color' => $fiber->branch_bundle_color,
'target_bundle_color_hex' => $fiber->branch_bundle_color_hex,
'connector' => $fiber->connector_nr,
'description' => $fiber->comment,
'home_id' => $fiber->home_id,
'address' => $fiber->address ?? null,
'customer_cable_type' => $fiber->customer_cable_type ?? null,
'customer_cable_fiber_nr' => $fiber->customer_cable_fiber_nr ?? null,
'customer_connector_type' => $fiber->customer_connector_type ?? null,
'customer_cable_spec' => $fiber->customer_cable_spec ?? null,
'customer_fiber_range' => $fiber->customer_fiber_range ?? null,
];
}
}
return mfBaseController::returnJson(mfResponse::Ok([
'dispatcher' => $dispatcher,
'connections' => $result
]));
}
}

View File

@@ -12,6 +12,9 @@ class UserController extends mfBaseController
{
private $me;
// User IDs allowed to manage (add/edit/delete) users
private const ALLOWED_USER_MANAGER_IDS = [2, 5, 9, 6, 89, 145, 24];
protected function init($request = null)
{
$this->needlogin = true;
@@ -24,6 +27,11 @@ class UserController extends mfBaseController
if ($_SERVER['REQUEST_METHOD'] === 'POST') $this->postData = json_decode(file_get_contents('php://input'), true);
}
private function canManageUsers(): bool
{
return in_array($this->me->id, self::ALLOWED_USER_MANAGER_IDS);
}
protected function indexAction($request)
{
if (!$this->isAdmin()) {
@@ -32,6 +40,7 @@ class UserController extends mfBaseController
Helper::renderVue($this, "User", "Benutzer", [
"IS_ADMIN" => $this->me->isAdmin(),
"CAN_MANAGE_USERS" => $this->canManageUsers(),
"USERS" => array_map(fn($user) => [
"username" => $user->username,
"name" => $user->name,
@@ -53,6 +62,7 @@ class UserController extends mfBaseController
protected function formAction() {
if (!$this->isAdmin()) $this->redirect("Dashboard");
if (!$this->canManageUsers()) $this->redirect("User");
$id = $this->request->id;
$user = ($id && is_numeric($id) && $id > 0) ? new User($id) : new User();
@@ -178,6 +188,7 @@ class UserController extends mfBaseController
protected function generateApikeyAction($request) {
if (!$this->isAdmin()) $this->redirect("Dashboard");
if (!$this->canManageUsers()) $this->redirect("User");
$id = $request['id'];
if (!is_numeric($id) || $id < 1) {
@@ -207,6 +218,11 @@ class UserController extends mfBaseController
unset($r->address_id);
}
// Only allowed users can create/edit other users
if ($this->isAdmin() && !$this->canManageUsers()) {
self::redirect('User');
}
if (!$id && !$r->username) self::redirect('User');
$user = new User($id);
@@ -569,7 +585,7 @@ class UserController extends mfBaseController
}
protected function impersonateAction() {
if(!$this->me->isAdmin() || $this->me->address_id != 1) {
if(!$this->me->isAdmin() || $this->me->address_id != 1 || !$this->canManageUsers()) {
header("HTTP/1.1 403 Forbidden");
exit;
}
@@ -590,6 +606,10 @@ class UserController extends mfBaseController
protected function sendLoginEmailAction()
{
if (!$this->canManageUsers()) {
self::sendError("Keine Berechtigung.");
}
$id = $this->request->id;
if (!$id || !is_numeric($id)) {
self::sendError("Benutzer-ID fehlt oder ist ungültig.");

View File

@@ -2,7 +2,7 @@
class WarehouseArticleController extends TTCrud {
protected string $headerTitle = 'Artikel';
protected $createText = 'Artikel erstellen';
protected $createText = false;
protected string $singleText = 'Artikel';
protected bool $reopenOnCreate = true;
@@ -12,7 +12,7 @@ class WarehouseArticleController extends TTCrud {
['key' => 'articleNumber', 'text' => 'Nr.', 'required' => true],
['key' => 'description', 'text' => 'Beschreibung', 'required' => true,'modal' => ['type' => 'textarea'], 'table' => ['sortable' => false]],
['key' => 'category_id', 'text' => 'Kategorie', 'required' => true, 'modal' => ['type' => 'select', 'items' => []], 'table' => ['filter' => 'select']],
['key' => 'unit', 'text' => 'Einheit', 'required' => true,'modal' => ['type' => 'select', 'items' => [['value' => 'Stk.', 'text' => 'Stk.'], ['value' => 'Pau.', 'text' => 'Pau.'], ['value' => 'm.', 'text' => 'm.'], ['value' => 'Std.', 'text' => 'Std.'], ['value' => 'km', 'text' => 'km']]], 'table' => false],
['key' => 'unit', 'text' => 'Einheit', 'required' => true,'modal' => ['type' => 'select', 'items' => [['value' => 'Stk.', 'text' => 'Stk.'], ['value' => 'Pau.', 'text' => 'Pau.'], ['value' => 'm.', 'text' => 'm.'], ['value' => 'Std.', 'text' => 'Std.'], ['value' => 'km', 'text' => 'km']]], 'table' => ['filter' => 'select', 'filterOptions' => [['value' => 'Stk.', 'text' => 'Stk.'], ['value' => 'Pau.', 'text' => 'Pau.'], ['value' => 'm.', 'text' => 'm.'], ['value' => 'Std.', 'text' => 'Std.'], ['value' => 'km', 'text' => 'km']]]],
['key' => 'revenueAccount', 'text' => 'Erlöskonto', 'required' => true,'modal' => ['type' => 'select', 'items' => [['value' => 0, 'text' => 'Dienstleistungen'], ['value' => 1, 'text' => 'Handelswaren']]], 'table' => false],
['key' => 'cheapestPurchasePrice', 'text' => 'Einkauf', 'modal' => false, 'table' => ['class' => 'text-center', 'suffix' => ' €']],
['key' => 'cheapestSellPrice', 'text' => 'Verkauf', 'modal' => false, 'table' => ['class' => 'text-center', 'suffix' => ' €']],
@@ -32,10 +32,13 @@ class WarehouseArticleController extends TTCrud {
protected array $autocompleteColumns = ['articleNumber', 'title', 'description'];
protected array $permissionCheck = ['WarehouseUser'];
protected array $additionalActions = [['key' => 'openHistory','title' => 'Historie','class' => 'fas fa-history text-secondary']];
protected array $additionalActions = [
['key' => 'printLabel','title' => 'Label drucken','class' => 'fas fa-print text-secondary'],
['key' => 'openHistory','title' => 'Historie','class' => 'fas fa-history text-secondary']
];
// @formatter:on
protected array $additionalJSVariables = ['WAREHOUSE_ADMIN' => true];
protected array $additionalJSVariables = ['WAREHOUSE_ADMIN' => true, 'HIDE_PAGE_TITLE' => true];
protected function prepareCrudConfig() {
$categories = array_map(fn($category) => ['value' => $category->id, 'text' => $category->name], WarehouseCategory::getAll());
@@ -50,15 +53,19 @@ class WarehouseArticleController extends TTCrud {
$this->additionalJSVariables['WAREHOUSE_ADMIN'] = false;
}
protected function beforeCreate() {
protected function beforeCreate($postData): bool {
if (!in_array($this->user->id, [2, 5, 6, 145, 14]))
self::sendError("Sie haben keine Berechtigung, Artikel zu erstellen.");
$this->validateArticleNumber($postData);
return true;
}
protected function beforeUpdate($postData): bool {
if (!in_array($this->user->id, [2, 5, 6, 145, 14]))
self::sendError("Sie haben keine Berechtigung, Artikel zu bearbeiten.");
$this->validateArticleNumber($postData, $postData['id'] ?? null);
(new WarehouseHistoryController)->create($postData, $this->mod);
return true;
}
@@ -81,6 +88,38 @@ class WarehouseArticleController extends TTCrud {
self::updateSellPrices($postData['id']);
}
/**
* Validate article number for duplicates and correct category prefix
*/
private function validateArticleNumber(array $postData, ?int $excludeId = null): void {
$articleNumber = $postData['articleNumber'] ?? '';
$categoryId = $postData['category_id'] ?? null;
if (empty($articleNumber)) {
self::sendError("Artikelnummer ist erforderlich.");
}
// Check for duplicate article number
$existingArticles = WarehouseArticleModel::getAll(['articleNumber' => $articleNumber]);
foreach ($existingArticles as $existing) {
if ($excludeId === null || $existing->id != $excludeId) {
self::sendError("Artikelnummer '{$articleNumber}' existiert bereits (Artikel ID: {$existing->id}).");
}
}
// Validate category prefix
if ($categoryId) {
$category = WarehouseCategory::get($categoryId);
if ($category && $category->articleNumberPrefix) {
$expectedPrefix = str_pad($category->articleNumberPrefix, 4, '0', STR_PAD_LEFT);
$articlePrefix = substr($articleNumber, 0, strlen($expectedPrefix));
if ($articlePrefix !== $expectedPrefix) {
self::sendError("Artikelnummer muss mit dem Kategorie-Prefix '{$expectedPrefix}' beginnen.");
}
}
}
}
public static function updateSellPrices(int $id): void { // Added return type hint
$a = WarehouseArticleModel::get($id);
if (!$a instanceof WarehouseArticleModel) throw new Exception("Invalid article type");
@@ -131,6 +170,41 @@ class WarehouseArticleController extends TTCrud {
self::returnJson((new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns));
}
protected function getNextArticleNumberAction() {
$categoryId = intval($this->request->categoryId ?? 0);
if (!$categoryId) self::sendError("Kategorie nicht angegeben");
$category = WarehouseCategory::get($categoryId);
if (!$category) self::sendError("Kategorie nicht gefunden");
if (!$category->articleNumberPrefix) self::sendError("Kategorie hat keinen Artikelnummer-Prefix");
$prefix = str_pad($category->articleNumberPrefix, 4, '0', STR_PAD_LEFT);
$db = FronkDB::singleton();
// Get all existing article numbers with this prefix, sorted
$result = $db->query("SELECT CAST(articleNumber AS UNSIGNED) as num FROM WarehouseArticle WHERE articleNumber LIKE '{$prefix}%' ORDER BY num ASC");
$existingNumbers = [];
while ($row = $db->fetch_array($result)) {
$existingNumbers[] = intval($row['num']);
}
// Start from prefix * 10000 + 1 (e.g., 1800 -> 18000001)
$startNumber = intval($prefix) * 10000 + 1;
$nextNumber = $startNumber;
// Find first gap
foreach ($existingNumbers as $num) {
if ($num == $nextNumber) {
$nextNumber++;
} else if ($num > $nextNumber) {
// Found a gap
break;
}
}
self::returnJson(['success' => true, 'articleNumber' => str_pad($nextNumber, 8, '0', STR_PAD_LEFT)]);
}
protected function autocompleteAction() {
$textKey = property_exists($this->model, 'name') ? 'name' : 'title';
if (strlen($this->request->searchedID) > 0) {
@@ -163,4 +237,55 @@ class WarehouseArticleController extends TTCrud {
return ['value' => $item->id, 'text' => $item->$textKey];
}, $data));
}
protected function printLabelAction() {
$articleId = $this->request->id;
$article = WarehouseArticleModel::get($articleId);
if (!$article) {
self::sendError("Artikel nicht gefunden", 404);
}
$pdf_vars = [
'articleId' => $article->id,
'articleNumber' => $article->articleNumber,
'articleTitle' => $article->title
];
$pdf = new PdfForm("WarehouseArticle/LABEL", $pdf_vars);
$wkhtmltopdfArgs = "--page-height 25mm --page-width 63mm --margin-top 0 --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8 --dpi 96";
$filename = $pdf->render($wkhtmltopdfArgs);
header('Content-Type: application/pdf');
header('Content-Disposition: inline; filename="label-' . $article->articleNumber . '.pdf"');
readfile($filename);
die();
}
protected function printLabelsByCategoryAction() {
$categoryId = intval($this->request->categoryId);
if (!$categoryId) {
self::sendError("Kategorie nicht angegeben", 400);
}
$articles = WarehouseArticleModel::getAll(['category_id' => $categoryId], 10000, 0, ['key' => 'articleNumber', 'order' => 'ASC']);
if (empty($articles)) {
self::sendError("Keine Artikel in dieser Kategorie gefunden", 404);
}
$pdf_vars = ['articles' => $articles];
$pdf = new PdfForm("WarehouseArticle/LABEL_BULK", $pdf_vars);
$wkhtmltopdfArgs = "--page-height 25mm --page-width 63mm --margin-top 0 --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8 --dpi 96";
$filename = $pdf->render($wkhtmltopdfArgs);
$category = WarehouseCategory::get($categoryId);
$categoryName = $category ? $category->name : 'category-' . $categoryId;
header('Content-Type: application/pdf');
header('Content-Disposition: inline; filename="labels-' . str_replace(' ', '_', $categoryName) . '.pdf"');
readfile($filename);
die();
}
}

View File

@@ -3,7 +3,7 @@ class WarehouseCategory extends TTCrudBaseModel {
public int $id;
public string $name;
public string $description;
public ?int $articleNumberPrefix;
public ?string $articleNumberPrefix;
public int $create;
public int $create_by;
public ?int $edit;

View File

@@ -9,20 +9,86 @@ class WarehouseCategoryController extends TTCrud {
protected array $columns = [
['key' => 'name', 'text' => 'Name', 'required' => true,],
['key' => 'description', 'text' => 'Beschreibung', 'required' => true],
['key' => 'articleNumberPrefix', 'text' => 'Artikelnummerprefix', 'required' => true],
['key' => 'articleNumberPrefix', 'text' => 'Artikelnummerprefix', 'required' => false, 'modal' => ['disabled' => true, 'placeholder' => 'Wird automatisch generiert']],
['key' => 'create', 'text' => 'Erstellt am', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']],
['key' => 'create_by', 'text' => 'Erstellt von', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']],
['key' => 'actions', 'text' => 'Aktionen', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center', 'priority' => 10]],
];
// @formatter:on
protected array $additionalActions = [['key' => 'openHistory', 'title' => 'Historie', 'class' => 'fas fa-history text-primary']];
protected array $additionalActions = [
['key' => 'printLabels', 'title' => 'Labels drucken', 'class' => 'fas fa-print text-primary'],
['key' => 'openHistory', 'title' => 'Historie', 'class' => 'fas fa-history text-primary']
];
protected array $additionalJSVariables = ['WAREHOUSE_ADMIN' => true];
public function printLabelsAction() {
$categoryId = intval($this->request->id);
$articles = WarehouseArticleModel::getAll(['category_id' => $categoryId], 10000, 0, ['key' => 'articleNumber', 'order' => 'ASC']);
if (empty($articles)) {
echo "Keine Artikel in dieser Kategorie.";
die();
}
$pdf_vars = [
'articles' => $articles
];
$pdf = new PdfForm("WarehouseArticle/LABEL_BULK", $pdf_vars);
$wkhtmltopdfArgs = "--page-height 25mm --page-width 63mm --margin-top 0 --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8 --dpi 96";
$filename = $pdf->render($wkhtmltopdfArgs);
$category = WarehouseCategory::get($categoryId);
$categoryName = $category ? $category->name : 'category-' . $categoryId;
header('Content-Type: application/pdf');
header('Content-Disposition: inline; filename="labels-' . str_replace(' ', '_', $categoryName) . '.pdf"');
readfile($filename);
die();
}
protected function beforeCreate(): bool {
$this->postData['articleNumberPrefix'] = $this->getNextFreePrefix();
return true;
}
protected function beforeUpdate($postData): bool {
// Preserve existing prefix - don't allow changes
$existing = WarehouseCategory::get($postData['id']);
if ($existing) {
$this->postData['articleNumberPrefix'] = $existing->articleNumberPrefix;
}
(new WarehouseHistoryController)->create($postData, $this->mod);
return true;
}
private function getNextFreePrefix(): string {
$db = FronkDB::singleton();
$result = $db->query("SELECT articleNumberPrefix FROM WarehouseCategory WHERE articleNumberPrefix IS NOT NULL ORDER BY CAST(articleNumberPrefix AS UNSIGNED) DESC LIMIT 1");
$row = $db->fetch_array($result);
if ($row && $row['articleNumberPrefix']) {
$lastPrefix = intval($row['articleNumberPrefix']);
// Skip special ranges (9900+)
if ($lastPrefix >= 9900) {
// Find highest non-special prefix
$result = $db->query("SELECT articleNumberPrefix FROM WarehouseCategory WHERE articleNumberPrefix IS NOT NULL AND CAST(articleNumberPrefix AS UNSIGNED) < 9900 ORDER BY CAST(articleNumberPrefix AS UNSIGNED) DESC LIMIT 1");
$row = $db->fetch_array($result);
$lastPrefix = $row ? intval($row['articleNumberPrefix']) : 1800;
}
$nextPrefix = $lastPrefix + 100;
// Skip 9900+ range
if ($nextPrefix >= 9900) $nextPrefix = 9900;
} else {
$nextPrefix = 1900;
}
return str_pad($nextPrefix, 4, '0', STR_PAD_LEFT);
}
protected function getHistoryAction() {
self::returnJson((new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns));
}

View File

@@ -3,7 +3,7 @@
class WarehouseLocationModel extends TTCrudBaseModel {
public int $id;
public string $title;
public string $description;
public ?string $description = null;
public int $assignedTo;
public int $createBy;
public int $create;

View File

@@ -0,0 +1,258 @@
<?php
class WarehouseMovementController extends TTCrud {
protected string $headerTitle = 'Lagerbewegung';
protected string $createText = 'Bewegung erstellen';
protected bool $reopenOnCreate = true;
protected array $columns = [
['key' => 'movementNumber', 'text' => 'Bewegungs-Nr.', 'required' => false,
'modal' => false,
'table' => ['priority' => 10]],
['key' => 'movementType', 'text' => 'Typ', 'required' => true,
'modal' => ['type' => 'select', 'items' => []],
'table' => ['priority' => 9, 'filter' => 'iconSelect', 'filterOptions' => [
['value' => 'IN', 'text' => 'Einbuchung', 'icon' => 'fas fa-plus-circle text-success'],
['value' => 'OUT', 'text' => 'Ausbuchung', 'icon' => 'fas fa-minus-circle text-danger'],
['value' => 'ADJUSTMENT', 'text' => 'Korrektur', 'icon' => 'fas fa-edit text-warning'],
]]],
['key' => 'articleId', 'text' => 'Artikel', 'required' => true,
'modal' => ['type' => 'articleSelect'],
'table' => ['priority' => 8, 'sortable' => false, 'filter' => 'text']],
['key' => 'warehouseLocationId', 'text' => 'Lagerort', 'required' => true,
'modal' => ['type' => 'select', 'items' => []],
'table' => ['priority' => 7, 'filter' => 'select']],
['key' => 'quantity', 'text' => 'Menge', 'required' => true,
'modal' => ['type' => 'number', 'step' => '0.01', 'min' => '0.01'],
'table' => ['priority' => 6, 'filter' => false]],
['key' => 'quantityBefore', 'text' => 'Bestand vorher', 'required' => false,
'modal' => false,
'table' => ['priority' => 5, 'filter' => false]],
['key' => 'quantityAfter', 'text' => 'Bestand nachher', 'required' => false,
'modal' => false,
'table' => ['priority' => 4, 'filter' => false]],
['key' => 'reasonCategory', 'text' => 'Grund', 'required' => true,
'modal' => ['type' => 'select', 'items' => [], 'dependsOn' => 'movementType'],
'table' => ['priority' => 3, 'filter' => false]],
['key' => 'note', 'text' => 'Notiz', 'required' => false,
'modal' => ['type' => 'textarea'],
'table' => ['priority' => 2, 'filter' => false]],
['key' => 'create', 'text' => 'Erstellt', 'required' => false,
'modal' => false,
'table' => ['priority' => 1, 'filter' => 'dateRange']],
];
protected array $additionalActions = [];
protected array $permissionCheck = ['WarehouseUser'];
protected array $infoMessages = [
'create' => 'Lagerbewegung wurde erstellt',
'update' => 'Lagerbewegung wurde aktualisiert',
'delete' => 'Lagerbewegung wurde gelöscht',
'noChanges' => 'Keine Änderungen',
];
public function prepareCrudConfig() {
// Populate movement type dropdown
$movementTypes = [
['value' => 'IN', 'text' => 'Einbuchung'],
['value' => 'OUT', 'text' => 'Ausbuchung'],
['value' => 'ADJUSTMENT', 'text' => 'Korrektur'],
];
// Populate locations dropdown (Office + Außenlager only)
$allLocations = WarehouseLocationModel::getAll();
$locations = [];
foreach ($allLocations as $location) {
$title = strtolower($location->title);
if ($title === 'k1 fladnitz 150' || $title === 'aussenlager-extern') {
$locations[] = ['value' => $location->id, 'text' => $location->title];
}
}
// Get all reason categories for initial load
$allReasons = WarehouseMovementModel::getReasonCategories();
$reasonItems = [];
foreach ($allReasons as $type => $categories) {
foreach ($categories as $key => $label) {
$reasonItems[] = ['value' => $key, 'text' => $label, 'group' => $type];
}
}
foreach ($this->columns as &$col) {
if ($col['key'] === 'movementType') {
$col['modal']['items'] = $movementTypes;
}
if ($col['key'] === 'warehouseLocationId') {
$col['modal']['items'] = $locations;
$col['table']['filterOptions'] = $locations;
}
if ($col['key'] === 'reasonCategory') {
$col['modal']['items'] = $reasonItems;
}
}
$this->additionalJSVariables['REASON_CATEGORIES'] = $allReasons;
}
protected function beforeCreate(): bool {
// Validate required fields
$movementType = $this->postData['movementType'] ?? '';
$articleId = intval($this->postData['articleId'] ?? 0);
$locationId = intval($this->postData['warehouseLocationId'] ?? 0);
$quantity = floatval($this->postData['quantity'] ?? 0);
if (!in_array($movementType, ['IN', 'OUT', 'ADJUSTMENT'])) {
$this->returnJson(['success' => false, 'message' => 'Ungültiger Bewegungstyp']);
return false;
}
if ($articleId <= 0) {
$this->returnJson(['success' => false, 'message' => 'Kein Artikel ausgewählt']);
return false;
}
if ($locationId <= 0) {
$this->returnJson(['success' => false, 'message' => 'Kein Lagerort ausgewählt']);
return false;
}
if ($quantity <= 0) {
$this->returnJson(['success' => false, 'message' => 'Menge muss größer als 0 sein']);
return false;
}
// Find or create WarehouseItem for this article at this location
$db = FronkDB::singleton();
$existingItems = WarehouseItemModel::getAll([
'articleId' => $articleId,
'warehouseLocationId' => $locationId
]);
$warehouseItem = count($existingItems) > 0 ? $existingItems[0] : null;
$currentQty = $warehouseItem ? floatval($warehouseItem->quantity) : 0;
// Calculate new quantity based on movement type
// Note: Negative stock is allowed (items can be taken out even if stock is 0)
switch ($movementType) {
case 'IN':
$newQty = $currentQty + $quantity;
break;
case 'OUT':
$newQty = $currentQty - $quantity;
// Negative stock is allowed - no validation needed
break;
case 'ADJUSTMENT':
// For adjustment, quantity is the new absolute value
$newQty = $quantity;
break;
default:
$newQty = $currentQty;
}
// Store before/after quantities
$this->postData['quantityBefore'] = $currentQty;
$this->postData['quantityAfter'] = $newQty;
$this->postData['userId'] = $this->user->id;
// Update or create WarehouseItem
if ($warehouseItem) {
$db->query("UPDATE WarehouseItem SET quantity = {$newQty} WHERE id = {$warehouseItem->id}");
$this->postData['warehouseItemId'] = $warehouseItem->id;
} else {
$db->query("INSERT INTO WarehouseItem (articleId, warehouseLocationId, quantity, createBy, `create`)
VALUES ({$articleId}, {$locationId}, {$newQty}, {$this->user->id}, " . time() . ")");
$this->postData['warehouseItemId'] = $db->insert_id();
}
return true;
}
protected function afterCreate($postData) {
// Generate movement number
$movement = WarehouseMovementModel::get($postData['id']);
if ($movement) {
$movementNumber = WarehouseMovementModel::generateMovementNumber();
$db = FronkDB::singleton();
$db->query("UPDATE WarehouseMovement SET movementNumber = '{$movementNumber}' WHERE id = {$movement->id}");
}
}
protected function customRowsHandler($rows) {
return array_map(fn($row) => $this->formatRow((array)$row), $rows);
}
protected function formatRow($row) {
// Format movement type with badge
$typeLabels = [
'IN' => '<span class="badge bg-success">Einbuchung</span>',
'OUT' => '<span class="badge bg-danger">Ausbuchung</span>',
'ADJUSTMENT' => '<span class="badge bg-warning">Korrektur</span>',
];
$row['movementType'] = $typeLabels[$row['movementType']] ?? $row['movementType'];
// Format article
if (!empty($row['articleId'])) {
$article = ArticleModel::get($row['articleId']);
if ($article) {
$row['articleId'] = "<strong>{$article->articleNumber}</strong><br><small class='text-muted'>{$article->title}</small>";
}
}
// Format quantities
$row['quantityBefore'] = $row['quantityBefore'] !== null ? number_format((float)$row['quantityBefore'], 2, ',', '.') : '-';
$row['quantityAfter'] = $row['quantityAfter'] !== null ? number_format((float)$row['quantityAfter'], 2, ',', '.') : '-';
$row['quantity'] = number_format((float)$row['quantity'], 2, ',', '.');
// Format reason category
$row['reasonCategory'] = WarehouseMovementModel::getReasonCategories()[$row['movementType']][$row['reasonCategory']] ?? $row['reasonCategory'];
// Format create date
if (!empty($row['create'])) {
$row['create'] = date('d.m.Y H:i', $row['create']);
}
return $row;
}
/**
* Get reason categories for a specific movement type
*/
protected function getReasonCategoriesAction() {
$type = $this->request->type ?? null;
$categories = WarehouseMovementModel::getReasonCategories($type);
if ($type && is_array($categories)) {
$items = [];
foreach ($categories as $key => $label) {
$items[] = ['value' => $key, 'text' => $label];
}
self::returnJson(['success' => true, 'categories' => $items]);
} else {
self::returnJson(['success' => true, 'categories' => $categories]);
}
}
/**
* Get current stock for an article at a location
*/
protected function getCurrentStockAction() {
$articleId = intval($this->request->articleId ?? 0);
$locationId = intval($this->request->locationId ?? 0);
if (!$articleId || !$locationId) {
self::returnJson(['success' => false, 'currentStock' => 0]);
return;
}
$existingItems = WarehouseItemModel::getAll([
'articleId' => $articleId,
'warehouseLocationId' => $locationId
]);
$currentStock = count($existingItems) > 0 ? floatval($existingItems[0]->quantity) : 0;
self::returnJson(['success' => true, 'currentStock' => $currentStock]);
}
}

View File

@@ -0,0 +1,137 @@
<?php
class WarehouseMovementModel extends TTCrudBaseModel {
public int $id;
public ?string $movementNumber = null;
public string $movementType;
public int $articleId;
public int $warehouseLocationId;
public ?int $warehouseItemId = null;
public float $quantity;
public ?float $quantityBefore = null;
public ?float $quantityAfter = null;
public string $reasonCategory;
public ?string $note = null;
public int $userId;
public int $createBy;
public int $create;
/**
* Generate next movement number (WM-YYYY-X000001)
*/
public static function generateMovementNumber(): string {
$year = date('Y');
$prefix = "WM-{$year}-X";
$db = FronkDB::singleton();
$result = $db->query("SELECT movementNumber FROM WarehouseMovement
WHERE movementNumber LIKE '{$prefix}%'
ORDER BY movementNumber DESC LIMIT 1");
if ($row = $result->fetch_assoc()) {
$lastNumber = intval(substr($row['movementNumber'], -6));
$nextNumber = $lastNumber + 1;
} else {
$nextNumber = 1;
}
return $prefix . str_pad((string)$nextNumber, 6, '0', STR_PAD_LEFT);
}
/**
* Get reason categories for a movement type
*/
public static function getReasonCategories(?string $type = null): array {
$categories = [
'IN' => [
'Warenlieferung' => 'Warenlieferung',
'Rueckgabe' => 'Rückgabe',
'Gefunden' => 'Gefunden/Inventurdifferenz',
'UmlagerungEingang' => 'Umlagerung (Eingang)',
'Erstbestand' => 'Erstbestand',
'Sonstiges' => 'Sonstiges'
],
'OUT' => [
'Verbrauch' => 'Verbrauch',
'Beschaedigung' => 'Beschädigung/Defekt',
'Verlust' => 'Verlust/Schwund',
'UmlagerungAusgang' => 'Umlagerung (Ausgang)',
'Entsorgung' => 'Entsorgung',
'Sonstiges' => 'Sonstiges'
],
'ADJUSTMENT' => [
'Inventurkorrektur' => 'Inventurkorrektur',
'Buchungsfehler' => 'Buchungsfehler',
'Systemkorrektur' => 'Systemkorrektur',
'SonstigeKorrektur' => 'Sonstige Korrektur'
]
];
if ($type && isset($categories[$type])) {
return $categories[$type];
}
return $categories;
}
/**
* Get movement type labels
*/
public static function getMovementTypes(): array {
return [
'IN' => 'Einbuchung',
'OUT' => 'Ausbuchung',
'ADJUSTMENT' => 'Korrektur'
];
}
/**
* Get article object
*/
public function getArticle(): ?ArticleModel {
return ArticleModel::get($this->articleId);
}
/**
* Get location object
*/
public function getLocation(): ?WarehouseLocationModel {
return WarehouseLocationModel::get($this->warehouseLocationId);
}
/**
* Get user who made the movement
*/
public function getUser(): ?UserModel {
return UserModel::get($this->userId);
}
/**
* Get warehouse item if linked
*/
public function getWarehouseItem(): ?WarehouseItemModel {
if (!$this->warehouseItemId) return null;
return WarehouseItemModel::get($this->warehouseItemId);
}
/**
* Get formatted movement type label
*/
public function getMovementTypeLabel(): string {
$types = self::getMovementTypes();
return $types[$this->movementType] ?? $this->movementType;
}
/**
* Get formatted reason category label
*/
public function getReasonCategoryLabel(): string {
$allCategories = self::getReasonCategories();
foreach ($allCategories as $typeCategories) {
if (isset($typeCategories[$this->reasonCategory])) {
return $typeCategories[$this->reasonCategory];
}
}
return $this->reasonCategory;
}
}

View File

@@ -56,7 +56,7 @@ class WarehouseOfferController extends TTCrud
$this->postData['offerNumber'] = 'AN' . date('Y') . '-' . str_pad($currentCount + 1, 4, '0', STR_PAD_LEFT);
$this->postData['status'] = 'new';
$this->postData['version'] = 1;
$this->postData['validity'] = 14;
$this->postData['validity'] = 31;
$this->postData['alternativePositions'] = json_encode([]);
return true;
}
@@ -366,10 +366,13 @@ class WarehouseOfferController extends TTCrud
$version = $this->request->version ?? null;
$offerData = null;
$versionDate = null; // Date when this version was created (for validity calculation)
if ($version) {
$historyEntry = WarehouseHistoryModel::getOneByVersion($id, $this->mod, $version);
if ($historyEntry && !empty($historyEntry->data)) {
$offerData = json_decode($historyEntry->data);
$versionDate = $historyEntry->create; // Use version creation date
}
}
@@ -377,6 +380,10 @@ class WarehouseOfferController extends TTCrud
$offer = WarehouseOfferModel::get($id);
if (!$offer || !$offer->id) self::sendError('Angebot nicht gefunden');
$offerData = $offer;
// Get latest history entry for current version's date
$latestHistory = WarehouseHistoryModel::getOneByVersion($id, $this->mod, $offer->version);
$versionDate = $latestHistory ? $latestHistory->create : $offer->create;
}
@@ -432,11 +439,12 @@ class WarehouseOfferController extends TTCrud
"alternativeTotal" => $alternativeTotal,
"offerNumber" => $offerData->offerNumber,
"offerDate" => $offerData->create,
"versionDate" => $versionDate ?? $offerData->create, // Date for validity calculation
"offerEditorName" => $editor ? $editor->name : 'Unbekannt',
"includeTax" => true,
"vatRate" => 0.20,
"offerText" => $offerData->notes ?? '',
"validity" => $offerData->validity ?? 14,
"validity" => $offerData->validity ?? 31,
"closingText" => $offerData->closingText ?? '',
"bank_iban" => TT_INVOICE_BANK_IBAN,
"bank_bic" => TT_INVOICE_BANK_BIC,

View File

@@ -0,0 +1,462 @@
<?php
class WarehouseStocktakeController extends TTCrud {
protected string $headerTitle = 'Inventur';
protected string $createText = 'Inventur erstellen';
protected bool $reopenOnCreate = false;
protected array $columns = [
['key' => 'stocktakeNumber', 'text' => 'Inventur-Nr.', 'required' => false,
'modal' => false,
'table' => ['priority' => 10]],
['key' => 'title', 'text' => 'Titel', 'required' => true,
'modal' => ['type' => 'text'],
'table' => ['priority' => 9]],
['key' => 'warehouseLocationId', 'text' => 'Lagerort', 'required' => true,
'modal' => ['type' => 'select', 'items' => []],
'table' => ['priority' => 8, 'filter' => 'select']],
['key' => 'status', 'text' => 'Status', 'required' => false,
'modal' => false,
'table' => ['priority' => 7, 'filter' => 'iconSelect', 'filterOptions' => [
['value' => 'planned', 'text' => 'Geplant', 'icon' => 'fas fa-calendar text-secondary'],
['value' => 'in_progress', 'text' => 'In Bearbeitung', 'icon' => 'fas fa-cog text-primary'],
['value' => 'completed', 'text' => 'Abgeschlossen', 'icon' => 'fas fa-check-circle text-success'],
['value' => 'cancelled', 'text' => 'Abgebrochen', 'icon' => 'fas fa-times-circle text-danger'],
]]],
['key' => 'progress', 'text' => 'Fortschritt', 'required' => false,
'modal' => false,
'table' => ['priority' => 6, 'sortable' => false, 'filter' => false]],
['key' => 'startedAt', 'text' => 'Gestartet', 'required' => false,
'modal' => false,
'table' => ['priority' => 5, 'filter' => false]],
['key' => 'description', 'text' => 'Beschreibung', 'required' => false,
'modal' => ['type' => 'textarea'],
'table' => false],
['key' => 'actions', 'text' => 'Aktionen', 'required' => false,
'modal' => false,
'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']],
];
protected array $additionalActions = [
['key' => 'startStocktake', 'title' => 'Inventur starten', 'class' => 'fas fa-play text-success'],
['key' => 'viewProgress', 'title' => 'Fortschritt anzeigen', 'class' => 'fas fa-chart-line text-primary'],
['key' => 'completeStocktake', 'title' => 'Inventur abschließen', 'class' => 'fas fa-check text-success'],
['key' => 'applyToStock', 'title' => 'Auf Lager anwenden', 'class' => 'fas fa-boxes text-warning'],
['key' => 'exportReport', 'title' => 'Excel Export', 'class' => 'fas fa-download text-secondary'],
['key' => 'openHistory', 'title' => 'Historie', 'class' => 'fas fa-history text-secondary'],
];
protected array $additionalJSVariables = [];
protected array $statusOptions = [
['value' => 'planned', 'text' => 'Geplant', 'icon' => 'fas fa-calendar text-secondary', 'color' => 'secondary'],
['value' => 'in_progress', 'text' => 'In Bearbeitung', 'icon' => 'fas fa-cog text-primary', 'color' => 'primary'],
['value' => 'completed', 'text' => 'Abgeschlossen', 'icon' => 'fas fa-check-circle text-success', 'color' => 'success'],
['value' => 'cancelled', 'text' => 'Abgebrochen', 'icon' => 'fas fa-times-circle text-danger', 'color' => 'danger'],
];
protected array $permissionCheck = ['WarehouseUser'];
protected array $infoMessages = [
'create' => 'Inventur wurde erstellt',
'update' => 'Inventur wurde aktualisiert',
'delete' => 'Inventur wurde gelöscht',
'noChanges' => 'Keine Änderungen',
];
public function prepareCrudConfig() {
// Populate locations dropdown
$locations = array_map(function($location) {
return ['value' => $location->id, 'text' => $location->title];
}, WarehouseLocationModel::getAll());
foreach ($this->columns as &$col) {
if ($col['key'] === 'warehouseLocationId') {
$col['modal']['items'] = $locations;
$col['table']['filterOptions'] = $locations;
}
}
$this->additionalJSVariables['STATUS_ITEMS'] = $this->statusOptions;
}
protected function beforeCreate(): bool {
// Set default values
$this->postData['status'] = 'planned';
$this->postData['totalItems'] = 0;
$this->postData['totalScannedItems'] = 0;
return true;
}
protected function afterCreate($postData) {
// Generate stocktake number
$stocktake = WarehouseStocktakeModel::get($postData['id']);
if ($stocktake) {
$stocktakeNumber = WarehouseStocktakeModel::generateStocktakeNumber();
$db = FronkDB::singleton();
$db->query("UPDATE WarehouseStocktake SET stocktakeNumber = '{$stocktakeNumber}' WHERE id = {$stocktake->id}");
// Log creation
WarehouseStocktakeLogModel::log($stocktake->id, 'created', null, ['title' => $stocktake->title]);
}
}
protected function beforeUpdate($postData): bool {
(new WarehouseHistoryController)->create($postData, $this->mod);
return true;
}
protected function customRowsHandler($rows) {
return array_map(fn($row) => $this->formatRow((array)$row), $rows);
}
protected function formatRow($row) {
// Keep raw status for frontend conditional logic (don't modify 'status' - table needs raw value for filter)
$row['rawStatus'] = $row['status'];
// Don't modify warehouseLocationId - table uses items to display the text
// Don't modify status - table uses filterOptions to display
// Format progress (no filter on this column)
$row['progress'] = "<span class='badge bg-info'>{$row['totalScannedItems']} Artikel gescannt</span>";
// Format startedAt (no filter on this column)
if ($row['startedAt']) {
$row['startedAt'] = date('d.m.Y H:i', $row['startedAt']);
} else {
$row['startedAt'] = '-';
}
return $row;
}
/**
* Start a stocktake - changes status to in_progress
*/
protected function startStocktakeAction() {
$id = intval($this->postData['id'] ?? 0);
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;
}
if ($stocktake->status !== 'planned') {
self::returnJson(['success' => false, 'message' => 'Inventur kann nur im Status "Geplant" gestartet werden']);
return;
}
$db = FronkDB::singleton();
$db->query("UPDATE WarehouseStocktake SET
status = 'in_progress',
startedAt = " . time() . ",
startedBy = {$this->user->id}
WHERE id = {$id}");
WarehouseStocktakeLogModel::log($id, 'started', null, ['startedBy' => $this->user->name]);
self::returnJson(['success' => true, 'message' => 'Inventur wurde gestartet']);
}
/**
* Complete a stocktake - changes status to completed
*/
protected function completeStocktakeAction() {
$id = intval($this->postData['id'] ?? 0);
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;
}
if ($stocktake->status !== 'in_progress') {
self::returnJson(['success' => false, 'message' => 'Inventur kann nur im Status "In Bearbeitung" abgeschlossen werden']);
return;
}
$db = FronkDB::singleton();
$db->query("UPDATE WarehouseStocktake SET
status = 'completed',
completedAt = " . time() . ",
completedBy = {$this->user->id}
WHERE id = {$id}");
WarehouseStocktakeLogModel::log($id, 'completed', null, ['completedBy' => $this->user->name]);
self::returnJson(['success' => true, 'message' => 'Inventur wurde abgeschlossen']);
}
/**
* Get progress data for live updates
*/
protected function getProgressAction() {
$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;
}
// Get items via direct SQL to avoid any ORM issues
$db = FronkDB::singleton();
$result = $db->query("SELECT si.*, a.articleNumber, a.title as articleTitle, a.cheapestPurchasePrice, w.name as scannedByName,
CASE WHEN si.overwrittenById IS NOT NULL THEN 1 ELSE 0 END as isOverwritten
FROM WarehouseStocktakeItem si
LEFT JOIN WarehouseArticle a ON si.articleId = a.id
LEFT JOIN Worker w ON si.scannedBy = w.id
WHERE si.stocktakeId = {$id}
ORDER BY si.`create` DESC");
$formattedItems = [];
$totalValue = 0;
$totalQuantity = 0;
while ($row = $result->fetch_assoc()) {
$unitPrice = $row['cheapestPurchasePrice'] ? (float)$row['cheapestPurchasePrice'] : 0;
$quantity = (float)$row['countedQuantity'];
$lineTotal = $unitPrice * $quantity;
$isOverwritten = (bool)$row['isOverwritten'];
// Only count non-overwritten items in totals
if (!$isOverwritten) {
$totalValue += $lineTotal;
$totalQuantity += $quantity;
}
$formattedItems[] = [
'id' => (int)$row['id'],
'articleId' => (int)$row['articleId'],
'articleNumber' => $row['articleNumber'] ?? '',
'articleTitle' => $row['articleTitle'] ?? 'Unbekannt',
'countedQuantity' => $quantity,
'unitPrice' => $unitPrice,
'lineTotal' => $lineTotal,
'rack' => $row['rack'],
'shelf' => $row['shelf'],
'note' => $row['note'],
'scannedAt' => $row['scannedAt'] ? date('d.m.Y H:i:s', $row['scannedAt']) : null,
'scannedBy' => $row['scannedByName'],
'isOverwritten' => $isOverwritten,
];
}
$location = $stocktake->getLocation();
self::returnJson([
'success' => true,
'stocktake' => [
'id' => $stocktake->id,
'stocktakeNumber' => $stocktake->stocktakeNumber,
'title' => $stocktake->title,
'status' => $stocktake->status,
'locationName' => $location ? $location->title : 'Unbekannt',
'totalScannedItems' => $stocktake->totalScannedItems,
'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null,
],
'items' => $formattedItems,
'summary' => [
'totalValue' => $totalValue,
'totalQuantity' => $totalQuantity,
],
]);
}
/**
* Apply stocktake results to actual warehouse stock
*/
protected function applyToStockAction() {
$id = intval($this->postData['id'] ?? 0);
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;
}
if ($stocktake->status !== 'completed') {
self::returnJson(['success' => false, 'message' => 'Inventur muss abgeschlossen sein, um die Bestände anzupassen']);
return;
}
$db = FronkDB::singleton();
$items = WarehouseStocktakeItemModel::getAll(['stocktakeId' => $id]);
$appliedCount = 0;
$createdCount = 0;
foreach ($items as $item) {
// Check if a WarehouseItem already exists for this article at this location
$existingItems = WarehouseItemModel::getAll([
'articleId' => $item->articleId,
'warehouseLocationId' => $stocktake->warehouseLocationId
]);
if (count($existingItems) > 0) {
// Update existing item
$existingItem = $existingItems[0];
$oldQuantity = $existingItem->quantity;
$db->query("UPDATE WarehouseItem SET
quantity = {$item->countedQuantity},
rack = " . ($item->rack ? "'{$db->escape($item->rack)}'" : "NULL") . ",
shelf = " . ($item->shelf ? "'{$db->escape($item->shelf)}'" : "NULL") . "
WHERE id = {$existingItem->id}");
// Log history
(new WarehouseHistoryController)->create([
'id' => $existingItem->id,
'quantity' => $item->countedQuantity,
'rack' => $item->rack,
'shelf' => $item->shelf,
], 'WarehouseItem');
$appliedCount++;
} else {
// Create new WarehouseItem
$db->query("INSERT INTO WarehouseItem (articleId, warehouseLocationId, quantity, rack, shelf, createBy, `create`)
VALUES ({$item->articleId}, {$stocktake->warehouseLocationId}, {$item->countedQuantity},
" . ($item->rack ? "'{$db->escape($item->rack)}'" : "NULL") . ",
" . ($item->shelf ? "'{$db->escape($item->shelf)}'" : "NULL") . ",
{$this->user->id}, " . time() . ")");
$createdCount++;
}
}
WarehouseStocktakeLogModel::log($id, 'applied_to_stock', null, [
'appliedCount' => $appliedCount,
'createdCount' => $createdCount,
'appliedBy' => $this->user->name
]);
self::returnJson([
'success' => true,
'message' => "Bestände angepasst: {$appliedCount} aktualisiert, {$createdCount} neu erstellt"
]);
}
/**
* Export stocktake report to Excel
*/
protected function exportReportAction() {
$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;
}
// Get items via direct SQL to include price and overwritten status
$db = FronkDB::singleton();
$result = $db->query("SELECT si.*, a.articleNumber, a.title as articleTitle, a.cheapestPurchasePrice, w.name as scannedByName
FROM WarehouseStocktakeItem si
LEFT JOIN WarehouseArticle a ON si.articleId = a.id
LEFT JOIN Worker w ON si.scannedBy = w.id
WHERE si.stocktakeId = {$id}
ORDER BY si.`create` ASC");
$rows = [];
$totalSum = 0;
while ($row = $result->fetch_assoc()) {
$unitPrice = $row['cheapestPurchasePrice'] ? (float)$row['cheapestPurchasePrice'] : 0;
$quantity = (float)$row['countedQuantity'];
$lineTotal = $unitPrice * $quantity;
$isOverwritten = !empty($row['overwrittenById']);
// Skip overwritten items in calculation but show them
if (!$isOverwritten) {
$totalSum += $lineTotal;
}
$rows[] = [
'Artikel Titel' => $row['articleTitle'] ?? 'Unbekannt',
'Artikel Nummer' => $row['articleNumber'] ?? '',
'Einzelpreis' => number_format($unitPrice, 2, ',', '.') . ' €',
'Anzahl' => $quantity,
'Gesamtsumme' => number_format($lineTotal, 2, ',', '.') . ' €',
'Gescannt am' => $row['scannedAt'] ? date('d.m.Y H:i', $row['scannedAt']) : '',
'Gescannt von' => $row['scannedByName'] ?? '',
'Status' => $isOverwritten ? 'Überschrieben' : '',
];
}
// Add summary row
$rows[] = [
'Artikel Titel' => '',
'Artikel Nummer' => '',
'Einzelpreis' => '',
'Anzahl' => 'SUMME:',
'Gesamtsumme' => number_format($totalSum, 2, ',', '.') . ' €',
'Gescannt am' => '',
'Gescannt von' => '',
'Status' => '',
];
$filename = "Inventur_{$stocktake->stocktakeNumber}_" . date('Y-m-d') . ".csv";
$csv = Helper::arrayToCsv($rows);
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
echo "\xEF\xBB\xBF"; // UTF-8 BOM
echo $csv;
exit;
}
/**
* Get history for a stocktake
*/
protected function getHistoryAction() {
$this->prepareCrudConfig();
self::returnJson((new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns));
}
/**
* Get logs for a stocktake
*/
protected function getLogsAction() {
$id = intval($this->request->id);
if (!$id) {
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
return;
}
$logs = WarehouseStocktakeLogModel::getLogsForStocktake($id);
$formattedLogs = [];
foreach ($logs as $log) {
$user = UserModel::get($log->userId);
$formattedLogs[] = [
'id' => $log->id,
'action' => $log->action,
'details' => $log->details ? json_decode($log->details, true) : null,
'userName' => $user ? $user->name : 'Unbekannt',
'create' => date('d.m.Y H:i:s', $log->create),
];
}
self::returnJson(['success' => true, 'logs' => $formattedLogs]);
}
}

View File

@@ -0,0 +1,74 @@
<?php
class WarehouseStocktakeModel extends TTCrudBaseModel {
public int $id;
public ?string $stocktakeNumber = null;
public string $title;
public ?string $description = null;
public int $warehouseLocationId;
public string $status = 'planned';
public ?int $startedAt = null;
public ?int $completedAt = null;
public ?int $startedBy = null;
public ?int $completedBy = null;
public int $totalItems = 0;
public int $totalScannedItems = 0;
public ?string $notes = null;
public int $createBy;
public int $create;
/**
* Generate next stocktake number (ST-YYYY-NNNN)
*/
public static function generateStocktakeNumber(): string {
$year = date('Y');
$prefix = "IN{$year}-X";
$db = FronkDB::singleton();
$result = $db->query("SELECT stocktakeNumber FROM WarehouseStocktake
WHERE stocktakeNumber LIKE '{$prefix}%'
ORDER BY stocktakeNumber DESC LIMIT 1");
if ($row = $result->fetch_assoc()) {
$lastNumber = intval(substr($row['stocktakeNumber'], -6));
$nextNumber = $lastNumber + 1;
} else {
$nextNumber = 1;
}
return $prefix . str_pad((string)$nextNumber, 6, '0', STR_PAD_LEFT);
}
/**
* Get location object
*/
public function getLocation(): ?WarehouseLocationModel {
return WarehouseLocationModel::get($this->warehouseLocationId);
}
/**
* Get user who started the stocktake
*/
public function getStartedByUser(): ?UserModel {
if (!$this->startedBy) return null;
return UserModel::get($this->startedBy);
}
/**
* Get items for this stocktake
*/
public function getItems(): array {
return WarehouseStocktakeItemModel::getAll(['stocktakeId' => $this->id]);
}
/**
* Update progress counters
*/
public function updateProgress(): void {
$items = $this->getItems();
$this->totalScannedItems = count($items);
$db = FronkDB::singleton();
$db->query("UPDATE WarehouseStocktake SET totalScannedItems = {$this->totalScannedItems} WHERE id = {$this->id}");
}
}

View File

@@ -0,0 +1,195 @@
<?php
class WarehouseStocktakeItemController extends TTCrud {
protected string $headerTitle = 'Inventur-Artikel';
protected string $createText = 'Artikel hinzufügen';
protected array $columns = [
['key' => 'articleId', 'text' => 'Artikel', 'required' => true,
'modal' => ['type' => 'autocomplete', 'apiUrl' => '/WarehouseArticle/autocomplete'],
'table' => ['priority' => 10]],
['key' => 'countedQuantity', 'text' => 'Menge', 'required' => true,
'modal' => ['type' => 'number'],
'table' => ['priority' => 9]],
['key' => 'rack', 'text' => 'Regal', 'required' => false,
'modal' => ['type' => 'text'],
'table' => ['priority' => 8]],
['key' => 'shelf', 'text' => 'Fach', 'required' => false,
'modal' => ['type' => 'text'],
'table' => ['priority' => 7]],
['key' => 'note', 'text' => 'Notiz', 'required' => false,
'modal' => ['type' => 'textarea'],
'table' => ['priority' => 6]],
['key' => 'scannedAt', 'text' => 'Gescannt am', 'required' => false,
'modal' => false,
'table' => ['priority' => 5]],
['key' => 'actions', 'text' => 'Aktionen', 'required' => false,
'modal' => false,
'table' => ['filter' => false, 'sortable' => false]],
];
protected array $permissionCheck = ['WarehouseUser'];
protected function formatRow($row) {
// Format article
if ($row['articleId']) {
$article = WarehouseArticleModel::get($row['articleId']);
$row['articleId'] = $article ? "[{$article->articleNumber}] {$article->title}" : 'Unbekannt';
}
// Format scannedAt
if ($row['scannedAt']) {
$row['scannedAt'] = date('d.m.Y H:i', $row['scannedAt']);
} else {
$row['scannedAt'] = '-';
}
return $row;
}
/**
* Add item via scan (used by PWA)
*/
protected function scanItemAction() {
$stocktakeId = intval($this->request->stocktakeId);
$articleId = intval($this->request->articleId);
$quantity = floatval($this->request->quantity);
$rack = $this->request->rack ?? null;
$shelf = $this->request->shelf ?? null;
$note = $this->request->note ?? null;
if (!$stocktakeId || !$articleId) {
self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']);
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;
}
// Check if this article was already scanned in this stocktake
$existing = WarehouseStocktakeItemModel::getFirst([
'stocktakeId' => $stocktakeId,
'articleId' => $articleId
]);
$db = FronkDB::singleton();
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->me->id}
WHERE id = {$existing->id}");
$itemId = $existing->id;
$message = "Artikel aktualisiert: {$article->title} (Neue Menge: {$newQuantity})";
} 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->me->id}, {$this->me->id}, " . time() . ")");
$itemId = $db->insert_id;
$message = "Artikel hinzugefügt: {$article->title} (Menge: {$quantity})";
}
// Update stocktake progress
$stocktake->updateProgress();
// Log the scan
WarehouseStocktakeLogModel::log($stocktakeId, 'scanned', $itemId, [
'articleId' => $articleId,
'articleNumber' => $article->articleNumber,
'articleTitle' => $article->title,
'quantity' => $quantity,
'rack' => $rack,
'shelf' => $shelf,
]);
self::returnJson([
'success' => true,
'message' => $message,
'item' => [
'id' => $itemId,
'articleId' => $articleId,
'articleNumber' => $article->articleNumber,
'articleTitle' => $article->title,
'countedQuantity' => $existing ? ($existing->countedQuantity + $quantity) : $quantity,
'rack' => $rack,
'shelf' => $shelf,
],
'totalScanned' => $stocktake->totalScannedItems + 1,
]);
}
/**
* Get article info by QR code or article number
*/
protected function getArticleByCodeAction() {
$code = $this->request->code;
if (!$code) {
self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']);
return;
}
// Try to parse QR code format: WA:articleId:articleNumber (Warehouse Article)
// Also accept WH: for backwards compatibility
$articleId = null;
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;
}
self::returnJson([
'success' => true,
'article' => [
'id' => $article->id,
'articleNumber' => $article->articleNumber,
'title' => $article->title,
'description' => $article->description ?? '',
'unit' => $article->unit ?? 'Stk.',
]
]);
}
}

View File

@@ -0,0 +1,39 @@
<?php
class WarehouseStocktakeItemModel extends TTCrudBaseModel {
public int $id;
public int $stocktakeId;
public int $articleId;
public ?int $warehouseItemId;
public float $countedQuantity;
public ?string $rack;
public ?string $shelf;
public ?string $note;
public ?int $scannedAt;
public ?int $scannedBy;
public ?int $overwrittenById;
public int $createBy;
public int $create;
/**
* Get the article object
*/
public function getArticle(): ?WarehouseArticleModel {
return WarehouseArticleModel::get($this->articleId);
}
/**
* Get the stocktake object
*/
public function getStocktake(): ?WarehouseStocktakeModel {
return WarehouseStocktakeModel::get($this->stocktakeId);
}
/**
* Get user who scanned this item
*/
public function getScannedByUser(): ?User {
if (!$this->scannedBy) return null;
return UserModel::getOne($this->scannedBy);
}
}

View File

@@ -0,0 +1,43 @@
<?php
class WarehouseStocktakeLogModel extends TTCrudBaseModel {
public int $id;
public int $stocktakeId;
public ?int $stocktakeItemId;
public string $action;
public ?string $details;
public int $userId;
public int $create;
/**
* Create a log entry
*/
public static function log(int $stocktakeId, string $action, ?int $stocktakeItemId = null, ?array $details = null, ?int $userId = null): self {
$me = mfValuecache::singleton()->get("me");
$logUserId = $userId ?? ($me ? $me->id : 0);
$log = new self();
$log->stocktakeId = $stocktakeId;
$log->stocktakeItemId = $stocktakeItemId;
$log->action = $action;
$log->details = $details ? json_encode($details) : null;
$log->userId = $logUserId;
$log->create = time();
$db = FronkDB::singleton();
$db->query("INSERT INTO WarehouseStocktakeLog (stocktakeId, stocktakeItemId, action, details, userId, `create`)
VALUES ({$log->stocktakeId}, " . ($log->stocktakeItemId ? $log->stocktakeItemId : "NULL") . ",
'{$db->escape($log->action)}', " . ($log->details ? "'{$db->escape($log->details)}'" : "NULL") . ",
{$log->userId}, {$log->create})");
$log->id = $db->insert_id;
return $log;
}
/**
* Get logs for a stocktake
*/
public static function getLogsForStocktake(int $stocktakeId): array {
return self::getAll(['stocktakeId' => $stocktakeId], 0, 0, ['create' => 'DESC']);
}
}

View File

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

View File

@@ -161,7 +161,8 @@ class WorkorderBaseController extends TTCrud
$networks = NetworkModel::search(['owner_id' => $config->addressId]);
if (empty($networks)) continue;
$tenantCampaigns = array_map(fn($n) => $n->id, PreordercampaignModel::getAll(['network_id' => array_map(fn($n) => $n->id, $networks)]));
$networkIds = array_map(fn($n) => $n->id, $networks);
$tenantCampaigns = array_map(fn($c) => $c->id, PreordercampaignModel::search(['network_id' => $networkIds]));
if (empty($tenantCampaigns)) continue;
$filters['preordercampaign_id'] = $tenantCampaigns;
@@ -228,22 +229,25 @@ class WorkorderBaseController extends TTCrud
continue;
}
$tenantCampaignIds = array_column(PreordercampaignModel::getAll(['network_id' => array_column($networks, 'id')]), 'id');
$networkIds = array_map(fn($n) => $n->id, $networks);
$tenantCampaignIds = array_map(fn($c) => $c->id, PreordercampaignModel::search(['network_id' => $networkIds]));
if (empty($tenantCampaignIds)) {
continue;
}
$activeFilters['preordercampaign_id'] = $tenantCampaignIds;
$activePreorderIds = array_column(PreorderModel::searchActive($activeFilters), 'id');
$activePreorderIds = array_map(fn($p) => $p->id, PreorderModel::searchActive($activeFilters));
$activePreorderIdsSet = array_flip($activePreorderIds);
$statusesToCheck = ['new', 'assigned', 'scheduled', 'in_progress', 'correction_requested', 'intervention_required', 'civil_engineering_required', 'civil_engineering_completed', 'problem_solved'];
$allTenantPreorders = PreorderModel::getAll(['preordercampaign_id' => $tenantCampaignIds]);
// Get ALL preorders for tenant (including deleted/cancelled) to ensure their workorders get archived
// Note: Not passing 'deleted' filter means all preorders are returned regardless of deleted status
$allTenantPreorders = PreorderModel::search(['preordercampaign_id' => $tenantCampaignIds]);
if(empty($allTenantPreorders)) continue;
$allTenantPreorderIds = array_column($allTenantPreorders, 'id');
$allTenantPreorderIds = array_map(fn($p) => $p->id, $allTenantPreorders);
$workordersToCheck = WorkorderModel::getAll([
'status' => $statusesToCheck,

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateWarehouseStocktakeTables extends AbstractMigration
{
public function up(): void
{
if ($this->getEnvironment() == "thetool") {
// 1. Main Stocktake Session Table
$stocktake = $this->table('WarehouseStocktake');
$stocktake->addColumn('stocktakeNumber', 'string', ['limit' => 50, 'null' => true])
->addColumn('title', 'string', ['limit' => 255])
->addColumn('description', 'text', ['null' => true])
->addColumn('warehouseLocationId', 'integer', ['signed' => true])
->addColumn('status', 'enum', ['values' => ['planned', 'in_progress', 'completed', 'cancelled'], 'default' => 'planned'])
->addColumn('startedAt', 'integer', ['null' => true])
->addColumn('completedAt', 'integer', ['null' => true])
->addColumn('startedBy', 'integer', ['null' => true, 'signed' => false])
->addColumn('completedBy', 'integer', ['null' => true, 'signed' => false])
->addColumn('totalItems', 'integer', ['default' => 0])
->addColumn('totalScannedItems', 'integer', ['default' => 0])
->addColumn('notes', 'text', ['null' => true])
->addColumn('createBy', 'integer', ['signed' => false])
->addColumn('create', 'integer')
->addIndex(['stocktakeNumber'], ['unique' => true])
->addIndex(['status'])
->addIndex(['warehouseLocationId'])
->create();
// 2. Individual Stocktake Items
$stocktakeItem = $this->table('WarehouseStocktakeItem');
$stocktakeItem->addColumn('stocktakeId', 'integer', ['signed' => true])
->addColumn('articleId', 'integer', ['signed' => false])
->addColumn('warehouseItemId', 'integer', ['null' => true, 'signed' => false])
->addColumn('countedQuantity', 'decimal', ['precision' => 10, 'scale' => 2, 'default' => 0])
->addColumn('rack', 'string', ['limit' => 50, 'null' => true])
->addColumn('shelf', 'string', ['limit' => 50, 'null' => true])
->addColumn('note', 'text', ['null' => true])
->addColumn('scannedAt', 'integer', ['null' => true])
->addColumn('scannedBy', 'integer', ['null' => true, 'signed' => false])
->addColumn('createBy', 'integer', ['signed' => false])
->addColumn('create', 'integer')
->addIndex(['stocktakeId'])
->addIndex(['articleId'])
->create();
// 3. Activity Log
$stocktakeLog = $this->table('WarehouseStocktakeLog');
$stocktakeLog->addColumn('stocktakeId', 'integer', ['signed' => true])
->addColumn('stocktakeItemId', 'integer', ['null' => true, 'signed' => true])
->addColumn('action', 'string', ['limit' => 50])
->addColumn('details', 'text', ['null' => true])
->addColumn('userId', 'integer', ['signed' => false])
->addColumn('create', 'integer')
->addIndex(['stocktakeId'])
->create();
}
}
public function down(): void
{
if ($this->getEnvironment() == "thetool") {
$this->table('WarehouseStocktakeLog')->drop()->save();
$this->table('WarehouseStocktakeItem')->drop()->save();
$this->table('WarehouseStocktake')->drop()->save();
}
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class WarehouseCategorySetPrefixes extends AbstractMigration
{
public function up(): void
{
$table = $this->table('WarehouseCategory');
if (!$table->hasColumn('articleNumberPrefix')) {
$table->addColumn('articleNumberPrefix', 'string', ['limit' => 4, 'null' => true, 'after' => 'description'])
->update();
}
if ($this->getEnvironment() == "thetool") {
$prefixes = [
1 => '1901', // Dienstleistungen
3 => '9980', // EStmk Shop
4 => '1400', // GPON OLTs und Bridges
21 => '9990', // Import nicht erfolgreich
5 => '1700', // Kabel-TV und Zubehör
6 => '0700', // Kupferverkabelung und Schränke
7 => '0400', // LWL Aussen- und Universalkabel
8 => '0600', // LWL Boxen, Muffen und Gehäuse
9 => '0900', // LWL Leitungsbau
10 => '0500', // LWL Pigtails und Kupplungen
11 => '0800', // LWL Splitter, Filter und Dämpfer
12 => '1600', // Netzteile, USV, Akkus
13 => '0300', // Patchkabel Kupfer
14 => '0200', // Patchkabel LWL Multimode
15 => '0100', // Patchkabel LWL Singlemode
16 => '1000', // Richtfunk und WLAN
17 => '1100', // Router und Zubehör
18 => '1300', // SFP und Konverter
19 => '1200', // Switches und Zubehör
20 => '1500', // Telefonie und Zubehör
2 => '1800', // Elektromaterial etc. (no articles, assign next free)
];
foreach ($prefixes as $categoryId => $prefix) {
$this->execute("UPDATE WarehouseCategory SET articleNumberPrefix = '{$prefix}' WHERE id = {$categoryId}");
}
}
}
public function down(): void
{
$table = $this->table('WarehouseCategory');
if ($table->hasColumn('articleNumberPrefix')) {
$table->removeColumn('articleNumberPrefix')->update();
}
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class WarehouseStocktakeItemAddOverwritten extends AbstractMigration
{
public function up(): void
{
if ($this->getEnvironment() == "thetool") {
$table = $this->table('WarehouseStocktakeItem');
$table->addColumn('overwrittenById', 'integer', ['null' => true, 'signed' => true, 'after' => 'scannedBy'])
->update();
}
}
public function down(): void
{
if ($this->getEnvironment() == "thetool") {
$table = $this->table('WarehouseStocktakeItem');
$table->removeColumn('overwrittenById')
->update();
}
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddLockExportedToManualinvoice extends AbstractMigration
{
public function up(): void
{
if($this->getEnvironment() == "thetool") {
$table = $this->table("ManualInvoice");
$table->addColumn("lock", "integer", [
"null" => false,
"default" => 0,
"limit" => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY,
"after" => "status"
]);
$table->addColumn("exported", "integer", [
"null" => false,
"default" => 0,
"limit" => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY,
"after" => "lock"
]);
$table->save();
}
}
public function down(): void
{
if($this->getEnvironment() == "thetool") {
$table = $this->table("ManualInvoice");
$table->removeColumn("lock")->save();
$table->removeColumn("exported")->save();
}
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateWarehouseLagerbewegung extends AbstractMigration
{
public function up(): void
{
if ($this->getEnvironment() == "thetool") {
$lagerbewegung = $this->table('WarehouseLagerbewegung');
$lagerbewegung
->addColumn('movementNumber', 'string', ['limit' => 50, 'null' => true])
->addColumn('movementType', 'enum', ['values' => ['IN', 'OUT', 'ADJUSTMENT']])
->addColumn('articleId', 'integer', ['signed' => false])
->addColumn('warehouseLocationId', 'integer', ['signed' => true])
->addColumn('warehouseItemId', 'integer', ['null' => true, 'signed' => true])
->addColumn('quantity', 'decimal', ['precision' => 10, 'scale' => 2])
->addColumn('quantityBefore', 'decimal', ['precision' => 10, 'scale' => 2, 'null' => true])
->addColumn('quantityAfter', 'decimal', ['precision' => 10, 'scale' => 2, 'null' => true])
->addColumn('reasonCategory', 'string', ['limit' => 50])
->addColumn('note', 'text', ['null' => true])
->addColumn('userId', 'integer', ['signed' => false])
->addColumn('createBy', 'integer', ['signed' => false])
->addColumn('create', 'integer')
->addIndex(['movementNumber'], ['unique' => true])
->addIndex(['articleId'])
->addIndex(['warehouseLocationId'])
->addIndex(['movementType'])
->addIndex(['userId'])
->addIndex(['create'])
->create();
}
}
public function down(): void
{
if ($this->getEnvironment() == "thetool") {
$this->table('WarehouseLagerbewegung')->drop()->save();
}
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class RenameLagerbewegungToMovement extends AbstractMigration
{
public function up(): void
{
if ($this->getEnvironment() == "thetool") {
$this->table('WarehouseLagerbewegung')->rename('WarehouseMovement')->save();
}
}
public function down(): void
{
if ($this->getEnvironment() == "thetool") {
$this->table('WarehouseMovement')->rename('WarehouseLagerbewegung')->save();
}
}
}

View File

@@ -32,8 +32,6 @@ services:
image: adminer
ports:
- "8088:8080"
volumes:
- ./docker/adminer/php.ini:/etc/php/7.4/cli/conf.d/php.local.ini
phpmyadmin:
image: phpmyadmin
@@ -41,11 +39,30 @@ services:
- "8081:80"
environment:
- PMA_HOST=db
- PMA_UPLOAD_LIMIT=1G
- UPLOAD_LIMIT=1G
- MYSQL_ROOT_PASSWORD=junghan5
depends_on:
- db
db-downloader:
build:
context: ./docker/db-downloader
dockerfile: Dockerfile
ports:
- "8082:8082"
# volumes:
# - ./docker/db-downloader/ssh-keys:/app/ssh-keys:ro
environment:
- SCP_HOST=thetool-dbbackup.xinon.at
- SCP_PORT=22
- SCP_USERNAME=xinon
- SCP_DEFAULT_PATH=/opt/backup/mysql
- DB_HOST=db
- DB_PORT=3306
- DB_USER=root
- DB_PASSWORD=junghan5
- DB_AVAILABLE=thetool,addressdb
depends_on:
- db
volumes:
vendor:

View File

@@ -0,0 +1,25 @@
FROM python:3.12-slim-bookworm
RUN apt-get update && apt-get install -y \
mariadb-client \
openssh-client \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
COPY templates/ ./templates/
COPY static/ ./static/
RUN mkdir -p /app/downloads /app/ssh-keys
ENV FLASK_APP=app:app
ENV FLASK_ENV=production
ENV PYTHONUNBUFFERED=1
EXPOSE 8082
CMD ["gunicorn", "--bind", "0.0.0.0:8082", "--workers", "2", "--threads", "4", "app:app"]

444
docker/db-downloader/app.py Normal file
View File

@@ -0,0 +1,444 @@
import os
import stat
import uuid
import gzip
import struct
import subprocess
import threading
import time
from datetime import datetime
from flask import Flask, render_template, request, jsonify, session
import paramiko
# =============================================================================
# Configuration
# =============================================================================
class Config:
SCP_HOST = os.getenv('SCP_HOST', 'localhost')
SCP_PORT = int(os.getenv('SCP_PORT', 22))
SCP_USERNAME = os.getenv('SCP_USERNAME', 'root')
SCP_DEFAULT_PATH = os.getenv('SCP_DEFAULT_PATH', '/backups')
DB_HOST = os.getenv('DB_HOST', 'db')
DB_PORT = int(os.getenv('DB_PORT', 3306))
DB_USER = os.getenv('DB_USER', 'root')
DB_PASSWORD = os.getenv('DB_PASSWORD', '')
DB_AVAILABLE = os.getenv('DB_AVAILABLE', 'thetool,addressdb').split(',')
DOWNLOAD_PATH = '/app/downloads'
SSH_KEYS_PATH = '/app/ssh-keys'
SECRET_KEY = os.getenv('SECRET_KEY', os.urandom(24).hex())
# =============================================================================
# SFTP Client
# =============================================================================
class SFTPClient:
def __init__(self, host, port, username):
self.host = host
self.port = port
self.username = username
self.client = None
self.sftp = None
def connect_password(self, password):
self.client = paramiko.SSHClient()
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
self.client.connect(
hostname=self.host, port=self.port, username=self.username,
password=password, look_for_keys=False, allow_agent=False
)
self.sftp = self.client.open_sftp()
def connect_key(self, key_path, passphrase=None):
self.client = paramiko.SSHClient()
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
self.client.connect(
hostname=self.host, port=self.port, username=self.username,
key_filename=key_path, passphrase=passphrase,
look_for_keys=False, allow_agent=False
)
self.sftp = self.client.open_sftp()
def list_directory(self, path):
entries = []
for entry in self.sftp.listdir_attr(path):
is_dir = stat.S_ISDIR(entry.st_mode)
entries.append({
'name': entry.filename,
'size': entry.st_size,
'size_human': self._human_size(entry.st_size),
'mtime': entry.st_mtime,
'mtime_human': datetime.fromtimestamp(entry.st_mtime).strftime('%Y-%m-%d %H:%M'),
'is_dir': is_dir,
'is_sql': entry.filename.endswith(('.sql', '.sql.gz')),
'path': os.path.join(path, entry.filename)
})
return sorted(entries, key=lambda x: (not x['is_dir'], -x['mtime']))
def get_file_info(self, path):
entry = self.sftp.stat(path)
return {
'name': os.path.basename(path),
'size': entry.st_size,
'size_human': self._human_size(entry.st_size),
'mtime': entry.st_mtime,
'mtime_human': datetime.fromtimestamp(entry.st_mtime).strftime('%Y-%m-%d %H:%M'),
'path': path
}
def download_file(self, remote_path, local_path, callback=None):
self.sftp.get(remote_path, local_path, callback=callback)
def close(self):
if self.sftp:
self.sftp.close()
if self.client:
self.client.close()
@staticmethod
def _human_size(size):
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if size < 1024:
return f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} PB"
# =============================================================================
# Database Restore
# =============================================================================
class DatabaseRestore:
def __init__(self):
self.host = Config.DB_HOST
self.port = Config.DB_PORT
self.user = Config.DB_USER
self.password = Config.DB_PASSWORD
self.available_dbs = Config.DB_AVAILABLE
self.cancelled = False
def cancel(self):
self.cancelled = True
@staticmethod
def get_gzip_uncompressed_size(filepath):
with open(filepath, 'rb') as f:
f.seek(-4, 2)
return struct.unpack('<I', f.read(4))[0]
def _mysql_cmd(self, *extra_args):
return ['mysql', '-h', self.host, '-P', str(self.port), '-u', self.user, f'-p{self.password}'] + list(extra_args)
def ensure_database_exists(self, target_db):
if target_db not in self.available_dbs:
raise ValueError(f"Invalid database: {target_db}")
cmd = self._mysql_cmd('-e', f"CREATE DATABASE IF NOT EXISTS `{target_db}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise Exception(f"Failed to create database: {result.stderr}")
def clear_database(self, target_db):
cmd = self._mysql_cmd('-N', '-e', f"SELECT table_name FROM information_schema.tables WHERE table_schema='{target_db}'")
result = subprocess.run(cmd, capture_output=True, text=True)
tables = [t.strip() for t in result.stdout.strip().split('\n') if t.strip()]
if tables:
drop_sql = "SET FOREIGN_KEY_CHECKS=0; " + "; ".join(f"DROP TABLE IF EXISTS `{t}`" for t in tables) + "; SET FOREIGN_KEY_CHECKS=1;"
subprocess.run(self._mysql_cmd(target_db, '-e', drop_sql), check=True, capture_output=True)
return len(tables)
def restore_from_file(self, file_path, target_db, progress_callback=None):
if target_db not in self.available_dbs:
raise ValueError(f"Invalid database: {target_db}")
self.cancelled = False
self.ensure_database_exists(target_db)
tables_dropped = self.clear_database(target_db)
if self.cancelled:
raise Exception("Restore cancelled by user")
mysql_cmd = self._mysql_cmd(target_db)
process = None
try:
if file_path.endswith('.gz'):
with gzip.open(file_path, 'rb') as f:
process = subprocess.Popen(mysql_cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
bytes_read = 0
while True:
if self.cancelled:
process.terminate()
raise Exception("Restore cancelled by user")
chunk = f.read(1024 * 1024)
if not chunk:
break
if process.poll() is not None:
raise Exception(f"MySQL terminated: {process.stderr.read().decode()}")
try:
process.stdin.write(chunk)
process.stdin.flush()
except BrokenPipeError:
raise Exception(f"MySQL connection lost: {process.stderr.read().decode()}")
bytes_read += len(chunk)
if progress_callback:
progress_callback(bytes_read)
process.stdin.close()
process.wait(timeout=300)
if process.returncode != 0:
raise Exception(f"MySQL restore failed: {process.stderr.read().decode()}")
else:
with open(file_path, 'rb') as f:
result = subprocess.run(mysql_cmd, stdin=f, capture_output=True, timeout=600)
if result.returncode != 0:
raise Exception(f"MySQL restore failed: {result.stderr.decode()}")
except subprocess.TimeoutExpired:
if process:
process.kill()
raise Exception("MySQL restore timed out")
return {'tables_dropped': tables_dropped, 'file': os.path.basename(file_path), 'database': target_db}
# =============================================================================
# Flask Application
# =============================================================================
app = Flask(__name__)
app.config['SECRET_KEY'] = Config.SECRET_KEY
# Job storage
jobs = {}
restorers = {}
@app.route('/')
def index():
return render_template('index.html', databases=Config.DB_AVAILABLE, scp_host=Config.SCP_HOST, scp_username=Config.SCP_USERNAME)
@app.route('/health')
def health():
return {'status': 'ok'}
@app.route('/api/keys', methods=['GET'])
def list_keys():
keys = []
if os.path.exists(Config.SSH_KEYS_PATH):
keys = [f for f in os.listdir(Config.SSH_KEYS_PATH) if not f.endswith('.pub') and not f.startswith('.')]
return jsonify({'success': True, 'keys': keys})
@app.route('/api/connect', methods=['POST'])
def connect():
data = request.json
auth_type = data.get('auth_type', 'password')
try:
client = SFTPClient(Config.SCP_HOST, Config.SCP_PORT, Config.SCP_USERNAME)
if auth_type == 'password':
if not data.get('password'):
return jsonify({'success': False, 'error': 'Password is required'}), 400
client.connect_password(data['password'])
else:
if not data.get('key_file'):
return jsonify({'success': False, 'error': 'SSH key file is required'}), 400
key_path = os.path.join(Config.SSH_KEYS_PATH, data['key_file'])
if not os.path.exists(key_path):
return jsonify({'success': False, 'error': 'SSH key file not found'}), 400
client.connect_key(key_path, data.get('key_passphrase'))
files = client.list_directory(Config.SCP_DEFAULT_PATH)
client.close()
session['sftp_auth'] = {
'type': auth_type,
'password': data.get('password'),
'key_file': data.get('key_file'),
'key_passphrase': data.get('key_passphrase')
}
session['connected'] = True
return jsonify({'success': True, 'files': files, 'path': Config.SCP_DEFAULT_PATH, 'host': Config.SCP_HOST, 'username': Config.SCP_USERNAME})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 400
@app.route('/api/browse', methods=['POST'])
def browse():
if not session.get('connected'):
return jsonify({'success': False, 'error': 'Not connected'}), 401
auth = session.get('sftp_auth')
if not auth:
return jsonify({'success': False, 'error': 'Not authenticated'}), 401
path = request.json.get('path', Config.SCP_DEFAULT_PATH)
try:
client = SFTPClient(Config.SCP_HOST, Config.SCP_PORT, Config.SCP_USERNAME)
if auth['type'] == 'password':
client.connect_password(auth['password'])
else:
client.connect_key(os.path.join(Config.SSH_KEYS_PATH, auth['key_file']), auth.get('key_passphrase'))
files = client.list_directory(path)
client.close()
return jsonify({'success': True, 'files': files, 'path': path})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 400
@app.route('/api/disconnect', methods=['POST'])
def disconnect():
session.pop('sftp_auth', None)
session.pop('connected', None)
return jsonify({'success': True})
@app.route('/api/databases', methods=['GET'])
def list_databases():
return jsonify({'success': True, 'databases': Config.DB_AVAILABLE})
@app.route('/api/restore', methods=['POST'])
def restore():
if not session.get('connected'):
return jsonify({'success': False, 'error': 'Not connected to SFTP'}), 401
data = request.json
remote_file = data.get('file')
target_db = data.get('database')
if not remote_file:
return jsonify({'success': False, 'error': 'No file selected'}), 400
if target_db not in Config.DB_AVAILABLE:
return jsonify({'success': False, 'error': f'Invalid database'}), 400
auth = session.get('sftp_auth')
if not auth:
return jsonify({'success': False, 'error': 'Not authenticated'}), 401
job_id = str(uuid.uuid4())
jobs[job_id] = {
'status': 'starting', 'progress': 0, 'file': os.path.basename(remote_file),
'database': target_db, 'started_at': time.time(), 'message': 'Initializing...'
}
thread = threading.Thread(target=run_restore, args=(job_id, remote_file, target_db, dict(auth)))
thread.daemon = True
thread.start()
return jsonify({'success': True, 'job_id': job_id})
def run_restore(job_id, remote_file, target_db, auth):
local_file = os.path.join(Config.DOWNLOAD_PATH, os.path.basename(remote_file))
try:
if jobs[job_id].get('cancelled'):
jobs[job_id].update({'status': 'cancelled', 'message': 'Restore cancelled by user'})
return
jobs[job_id].update({'status': 'downloading', 'message': 'Connecting to remote server...'})
client = SFTPClient(Config.SCP_HOST, Config.SCP_PORT, Config.SCP_USERNAME)
if auth['type'] == 'password':
client.connect_password(auth['password'])
else:
client.connect_key(os.path.join(Config.SSH_KEYS_PATH, auth['key_file']), auth.get('key_passphrase'))
file_info = client.get_file_info(remote_file)
jobs[job_id]['file_size'] = file_info['size_human']
jobs[job_id]['message'] = f'Downloading {file_info["size_human"]}...'
def download_progress(transferred, total):
if jobs[job_id].get('cancelled'):
raise Exception("Download cancelled by user")
jobs[job_id].update({'progress': int((transferred / total) * 45) if total > 0 else 0, 'downloaded': transferred, 'total': total})
client.download_file(remote_file, local_file, callback=download_progress)
client.close()
if jobs[job_id].get('cancelled'):
jobs[job_id].update({'status': 'cancelled', 'message': 'Restore cancelled by user'})
if os.path.exists(local_file):
os.remove(local_file)
return
jobs[job_id].update({'progress': 45, 'message': 'Download complete. Preparing restore...', 'status': 'restoring'})
jobs[job_id]['progress'] = 50
jobs[job_id]['message'] = f'Clearing database {target_db}...'
restorer = DatabaseRestore()
restorers[job_id] = restorer
uncompressed_size = restorer.get_gzip_uncompressed_size(local_file) if local_file.endswith('.gz') else os.path.getsize(local_file)
def restore_progress(bytes_processed):
if jobs[job_id].get('cancelled'):
restorer.cancel()
pct = 50 + min(45, int((bytes_processed / uncompressed_size) * 45)) if uncompressed_size > 0 else 50
jobs[job_id].update({'progress': pct, 'message': f'Restoring to {target_db}... ({bytes_processed // (1024*1024)} MB / {uncompressed_size // (1024*1024)} MB)'})
result = restorer.restore_from_file(local_file, target_db, progress_callback=restore_progress)
if os.path.exists(local_file):
os.remove(local_file)
jobs[job_id].update({
'status': 'completed', 'progress': 100,
'message': f'Restore complete! Dropped {result["tables_dropped"]} tables and imported {result["file"]}',
'completed_at': time.time(), 'duration': time.time() - jobs[job_id]['started_at']
})
except Exception as e:
error_msg = str(e)
if 'cancelled' in error_msg.lower():
jobs[job_id].update({'status': 'cancelled', 'message': 'Restore cancelled by user'})
else:
jobs[job_id].update({'status': 'error', 'error': error_msg, 'message': f'Error: {error_msg}'})
if os.path.exists(local_file):
os.remove(local_file)
finally:
restorers.pop(job_id, None)
@app.route('/api/status/<job_id>')
def status(job_id):
if job_id not in jobs:
return jsonify({'success': False, 'error': 'Job not found'}), 404
job = jobs[job_id].copy()
job['success'] = True
if 'started_at' in job:
elapsed = (job.get('completed_at') or time.time()) - job['started_at']
job['elapsed'] = f'{int(elapsed // 60)}m {int(elapsed % 60)}s'
return jsonify(job)
@app.route('/api/jobs', methods=['GET'])
def list_jobs():
return jsonify({'success': True, 'jobs': dict(jobs)})
@app.route('/api/cancel/<job_id>', methods=['POST'])
def cancel(job_id):
if job_id not in jobs:
return jsonify({'success': False, 'error': 'Job not found'}), 404
if jobs[job_id]['status'] in ('completed', 'error', 'cancelled'):
return jsonify({'success': False, 'error': 'Job already finished'}), 400
jobs[job_id]['cancelled'] = True
if job_id in restorers:
restorers[job_id].cancel()
return jsonify({'success': True, 'message': 'Cancel signal sent'})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8082, debug=True)

View File

@@ -0,0 +1,5 @@
flask==3.0.0
gunicorn==21.2.0
paramiko==3.4.0
mysql-connector-python==8.2.0
python-dotenv==1.0.0

View File

@@ -0,0 +1,457 @@
// DB Restore Tool - Frontend JavaScript
let currentPath = '';
let selectedFile = null;
let isConnected = false;
let currentJobId = null;
let pollInterval = null;
// Initialize
document.addEventListener('DOMContentLoaded', function() {
loadAvailableKeys();
setupEventListeners();
});
function setupEventListeners() {
// Auth type toggle
document.getElementById('auth-type').addEventListener('change', function() {
const passwordAuth = document.getElementById('password-auth');
const keyAuth = document.getElementById('key-auth');
if (this.value === 'password') {
passwordAuth.classList.remove('hidden');
keyAuth.classList.add('hidden');
} else {
passwordAuth.classList.add('hidden');
keyAuth.classList.remove('hidden');
}
});
// Connect form
document.getElementById('connect-form').addEventListener('submit', function(e) {
e.preventDefault();
connect();
});
// Disconnect button
document.getElementById('disconnect-btn').addEventListener('click', disconnect);
// Restore button
document.getElementById('restore-btn').addEventListener('click', startRestore);
// Cancel button
document.getElementById('cancel-btn').addEventListener('click', cancelRestore);
}
async function loadAvailableKeys() {
try {
const response = await fetch('/api/keys');
const data = await response.json();
const select = document.getElementById('key-file');
select.innerHTML = '<option value="">Select a key...</option>';
data.keys.forEach(key => {
const option = document.createElement('option');
option.value = key;
option.textContent = key;
select.appendChild(option);
});
} catch (error) {
console.error('Failed to load keys:', error);
}
}
async function connect() {
const authType = document.getElementById('auth-type').value;
const password = document.getElementById('password').value;
const keyFile = document.getElementById('key-file').value;
const keyPassphrase = document.getElementById('key-passphrase').value;
const btn = document.getElementById('connect-btn');
btn.disabled = true;
btn.textContent = 'Connecting...';
try {
const response = await fetch('/api/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
auth_type: authType,
password: password,
key_file: keyFile,
key_passphrase: keyPassphrase
})
});
const data = await response.json();
if (data.success) {
isConnected = true;
currentPath = data.path;
showStatus('Connected to ' + data.host, 'success');
renderFiles(data.files, data.path);
updateBreadcrumb(data.path);
// Toggle buttons
btn.classList.add('hidden');
document.getElementById('disconnect-btn').classList.remove('hidden');
// Clear password field for security
document.getElementById('password').value = '';
} else {
showStatus('Connection failed: ' + data.error, 'error');
}
} catch (error) {
showStatus('Connection error: ' + error.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Connect';
}
}
async function disconnect() {
try {
await fetch('/api/disconnect', { method: 'POST' });
} catch (e) {}
isConnected = false;
selectedFile = null;
currentPath = '';
// Reset UI
document.getElementById('connect-btn').classList.remove('hidden');
document.getElementById('disconnect-btn').classList.add('hidden');
document.getElementById('file-browser').innerHTML = `
<div class="p-8 text-center text-gray-400">
<svg class="w-16 h-16 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 14v3m4-3v3m4-3v3M3 21h18M3 10h18M3 7l9-4 9 4M4 10h16v11H4V10z"/>
</svg>
<p>Connect to browse remote files</p>
</div>
`;
document.getElementById('breadcrumb').innerHTML = '<span class="text-gray-400">Not connected</span>';
document.getElementById('selected-file-info').innerHTML = '<p class="text-gray-500 text-sm">No file selected</p>';
document.getElementById('restore-btn').disabled = true;
hideStatus();
}
async function browse(path) {
if (!isConnected) return;
try {
const response = await fetch('/api/browse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path })
});
const data = await response.json();
if (data.success) {
currentPath = data.path;
renderFiles(data.files, data.path);
updateBreadcrumb(data.path);
} else {
showStatus('Browse failed: ' + data.error, 'error');
}
} catch (error) {
showStatus('Browse error: ' + error.message, 'error');
}
}
function renderFiles(files, path) {
const container = document.getElementById('file-browser');
if (files.length === 0) {
container.innerHTML = '<div class="p-8 text-center text-gray-400">Empty directory</div>';
return;
}
let html = '<div class="divide-y">';
// Parent directory link
if (path !== '/') {
const parentPath = path.split('/').slice(0, -1).join('/') || '/';
html += `
<div class="file-item p-3 cursor-pointer flex items-center" onclick="browse('${parentPath}')">
<svg class="w-5 h-5 mr-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 17l-5-5m0 0l5-5m-5 5h12"/>
</svg>
<span class="text-gray-600">..</span>
</div>
`;
}
files.forEach(file => {
const isSelected = selectedFile && selectedFile.path === file.path;
const selectedClass = isSelected ? 'selected' : '';
if (file.is_dir) {
html += `
<div class="file-item p-3 cursor-pointer flex items-center ${selectedClass}" onclick="browse('${file.path}')">
<svg class="w-5 h-5 mr-3 text-yellow-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M10 4H4a2 2 0 00-2 2v12a2 2 0 002 2h16a2 2 0 002-2V8a2 2 0 00-2-2h-8l-2-2z"/>
</svg>
<span class="flex-1 font-medium">${file.name}</span>
</div>
`;
} else if (file.is_sql) {
html += `
<div class="file-item p-3 cursor-pointer flex items-center ${selectedClass}" onclick="selectFile(${JSON.stringify(file).replace(/"/g, '&quot;')})">
<svg class="w-5 h-5 mr-3 text-green-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z"/>
<path d="M14 2v6h6"/>
</svg>
<span class="flex-1">${file.name}</span>
<span class="text-sm text-gray-500 mr-4">${file.size_human}</span>
<span class="text-sm text-gray-400">${file.mtime_human}</span>
</div>
`;
} else {
html += `
<div class="file-item p-3 flex items-center opacity-50">
<svg class="w-5 h-5 mr-3 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z"/>
</svg>
<span class="flex-1 text-gray-500">${file.name}</span>
<span class="text-sm text-gray-400">${file.size_human}</span>
</div>
`;
}
});
html += '</div>';
container.innerHTML = html;
}
function selectFile(file) {
selectedFile = file;
document.getElementById('selected-file-info').innerHTML = `
<p class="font-medium text-gray-800">${file.name}</p>
<p class="text-sm text-gray-500">${file.size_human} - ${file.mtime_human}</p>
`;
document.getElementById('restore-btn').disabled = false;
// Auto-detect target database from filename
const filename = file.name.toLowerCase();
const targetDbSelect = document.getElementById('target-db');
const availableDbs = Array.from(targetDbSelect.options).map(o => o.value);
for (const db of availableDbs) {
if (filename.includes(db.toLowerCase())) {
targetDbSelect.value = db;
break;
}
}
// Re-render to show selection
browse(currentPath);
}
function updateBreadcrumb(path) {
const parts = path.split('/').filter(p => p);
let html = `<span class="cursor-pointer hover:text-blue-600" onclick="browse('/')">/</span>`;
let currentPathBuild = '';
parts.forEach((part, index) => {
currentPathBuild += '/' + part;
const isLast = index === parts.length - 1;
html += `
<span class="mx-1">/</span>
<span class="${isLast ? 'font-medium' : 'cursor-pointer hover:text-blue-600'}"
${isLast ? '' : `onclick="browse('${currentPathBuild}')"`}>${part}</span>
`;
});
document.getElementById('breadcrumb').innerHTML = html;
}
async function startRestore() {
if (!selectedFile) return;
const targetDb = document.getElementById('target-db').value;
if (!confirm(`Are you sure you want to restore ${selectedFile.name} to database "${targetDb}"?\n\nThis will DROP ALL TABLES in the database!`)) {
return;
}
const btn = document.getElementById('restore-btn');
btn.disabled = true;
btn.textContent = 'Starting...';
try {
const response = await fetch('/api/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
file: selectedFile.path,
database: targetDb
})
});
const data = await response.json();
if (data.success) {
currentJobId = data.job_id;
showProgressPanel();
startPolling();
} else {
showStatus('Restore failed: ' + data.error, 'error');
btn.disabled = false;
btn.textContent = 'Start Restore';
}
} catch (error) {
showStatus('Restore error: ' + error.message, 'error');
btn.disabled = false;
btn.textContent = 'Start Restore';
}
}
function showProgressPanel() {
document.getElementById('progress-panel').classList.remove('hidden');
document.getElementById('progress-bar').style.width = '0%';
document.getElementById('progress-bar').classList.remove('bg-green-600', 'bg-red-600', 'bg-yellow-600');
document.getElementById('progress-bar').classList.add('bg-blue-600');
document.getElementById('progress-percent').textContent = '0%';
document.getElementById('progress-status').textContent = 'Starting...';
document.getElementById('progress-message').textContent = 'Initializing...';
document.getElementById('cancel-btn').classList.remove('hidden');
document.getElementById('cancel-btn').disabled = false;
document.getElementById('cancel-btn').textContent = 'Cancel Restore';
}
function startPolling() {
if (pollInterval) clearInterval(pollInterval);
pollInterval = setInterval(async () => {
try {
const response = await fetch(`/api/status/${currentJobId}`);
const data = await response.json();
if (data.success) {
updateProgress(data);
if (data.status === 'completed' || data.status === 'error' || data.status === 'cancelled') {
stopPolling();
document.getElementById('restore-btn').disabled = false;
document.getElementById('restore-btn').textContent = 'Start Restore';
if (data.status === 'completed') {
showStatus('Restore completed successfully!', 'success');
} else if (data.status === 'cancelled') {
showStatus('Restore was cancelled', 'error');
} else {
showStatus('Restore failed: ' + data.error, 'error');
}
}
}
} catch (error) {
console.error('Polling error:', error);
}
}, 250);
}
function stopPolling() {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
}
async function cancelRestore() {
if (!currentJobId) return;
if (!confirm('Are you sure you want to cancel the restore?')) {
return;
}
const btn = document.getElementById('cancel-btn');
btn.disabled = true;
btn.textContent = 'Cancelling...';
try {
const response = await fetch(`/api/cancel/${currentJobId}`, {
method: 'POST'
});
const data = await response.json();
if (data.success) {
showStatus('Cancellation requested...', 'error');
} else {
showStatus('Cancel failed: ' + data.error, 'error');
btn.disabled = false;
btn.textContent = 'Cancel Restore';
}
} catch (error) {
showStatus('Cancel error: ' + error.message, 'error');
btn.disabled = false;
btn.textContent = 'Cancel Restore';
}
}
function updateProgress(data) {
const bar = document.getElementById('progress-bar');
const percent = document.getElementById('progress-percent');
const status = document.getElementById('progress-status');
const message = document.getElementById('progress-message');
const elapsed = document.getElementById('progress-elapsed');
const cancelBtn = document.getElementById('cancel-btn');
bar.style.width = data.progress + '%';
percent.textContent = data.progress + '%';
// Update status label
const statusLabels = {
'starting': 'Starting',
'downloading': 'Downloading',
'restoring': 'Restoring',
'completed': 'Completed',
'error': 'Error',
'cancelled': 'Cancelled'
};
status.textContent = statusLabels[data.status] || data.status;
// Update color based on status
bar.classList.remove('bg-green-600', 'bg-red-600', 'bg-yellow-600');
if (data.status === 'completed') {
bar.classList.remove('bg-blue-600');
bar.classList.add('bg-green-600');
} else if (data.status === 'error') {
bar.classList.remove('bg-blue-600');
bar.classList.add('bg-red-600');
} else if (data.status === 'cancelled') {
bar.classList.remove('bg-blue-600');
bar.classList.add('bg-yellow-600');
}
// Show/hide cancel button based on job status
if (data.status === 'completed' || data.status === 'error' || data.status === 'cancelled') {
cancelBtn.classList.add('hidden');
} else {
cancelBtn.classList.remove('hidden');
cancelBtn.disabled = false;
cancelBtn.textContent = 'Cancel Restore';
}
message.textContent = data.message || '';
if (data.elapsed) {
elapsed.textContent = 'Elapsed: ' + data.elapsed;
}
}
function showStatus(message, type) {
const status = document.getElementById('connection-status');
status.classList.remove('hidden', 'bg-green-100', 'bg-red-100', 'text-green-800', 'text-red-800');
if (type === 'success') {
status.classList.add('bg-green-100', 'text-green-800');
} else if (type === 'error') {
status.classList.add('bg-red-100', 'text-red-800');
}
status.textContent = message;
}
function hideStatus() {
document.getElementById('connection-status').classList.add('hidden');
}

View File

@@ -0,0 +1,187 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DB Restore Tool</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
.spinner { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body class="bg-gray-100 min-h-screen">
<div class="container mx-auto px-4 py-8 max-w-6xl">
<!-- Header -->
<header class="mb-8">
<h1 class="text-3xl font-bold text-gray-800">Database Restore Tool</h1>
<p class="text-gray-600">Browse and restore database backups from remote server</p>
</header>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Connection Panel -->
<div class="lg:col-span-1">
<div class="bg-white rounded-lg shadow p-6" id="connection-panel">
<h2 class="text-xl font-semibold mb-4 flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 14v3m4-3v3m4-3v3M3 21h18M3 10h18M3 7l9-4 9 4M4 10h16v11H4V10z"/>
</svg>
Connection
</h2>
<div id="connection-status" class="mb-4 p-3 rounded hidden"></div>
<form id="connect-form">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Server</label>
<input type="text" value="{{ scp_host }}" disabled
class="w-full px-3 py-2 border rounded bg-gray-50 text-gray-600">
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Username</label>
<input type="text" value="{{ scp_username }}" disabled
class="w-full px-3 py-2 border rounded bg-gray-50 text-gray-600">
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Authentication</label>
<select id="auth-type" class="w-full px-3 py-2 border rounded">
<option value="password">Password</option>
<option value="key">SSH Key</option>
</select>
</div>
<!-- Password Auth -->
<div id="password-auth">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input type="password" id="password" placeholder="Enter password"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500">
</div>
</div>
<!-- Key Auth -->
<div id="key-auth" class="hidden">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">SSH Key</label>
<select id="key-file" class="w-full px-3 py-2 border rounded">
<option value="">Select a key...</option>
</select>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Key Passphrase (optional)</label>
<input type="password" id="key-passphrase" placeholder="Enter passphrase if required"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500">
</div>
</div>
<button type="submit" id="connect-btn"
class="w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700 transition">
Connect
</button>
<button type="button" id="disconnect-btn"
class="w-full bg-red-600 text-white py-2 px-4 rounded hover:bg-red-700 transition hidden mt-2">
Disconnect
</button>
</form>
</div>
<!-- Restore Panel -->
<div class="bg-white rounded-lg shadow p-6 mt-6" id="restore-panel">
<h2 class="text-xl font-semibold mb-4 flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4"/>
</svg>
Restore
</h2>
<div id="selected-file-info" class="mb-4 p-3 bg-gray-50 rounded">
<p class="text-gray-500 text-sm">No file selected</p>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Target Database</label>
<select id="target-db" class="w-full px-3 py-2 border rounded">
{% for db in databases %}
<option value="{{ db }}">{{ db }}</option>
{% endfor %}
</select>
</div>
<div class="bg-yellow-50 border border-yellow-200 rounded p-3 mb-4">
<p class="text-yellow-800 text-sm">
<strong>Warning:</strong> This will DROP all tables in the selected database before restoring!
</p>
</div>
<button type="button" id="restore-btn" disabled
class="w-full bg-green-600 text-white py-2 px-4 rounded hover:bg-green-700 transition disabled:bg-gray-400 disabled:cursor-not-allowed">
Start Restore
</button>
</div>
</div>
<!-- File Browser Panel -->
<div class="lg:col-span-2">
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-xl font-semibold mb-4 flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
Remote Browser
</h2>
<!-- Breadcrumb -->
<div id="breadcrumb" class="mb-4 flex items-center text-sm text-gray-600 overflow-x-auto">
<span class="text-gray-400">Not connected</span>
</div>
<!-- File List -->
<div id="file-browser" class="border rounded min-h-[400px] max-h-[600px] overflow-y-auto">
<div class="p-8 text-center text-gray-400">
<svg class="w-16 h-16 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 14v3m4-3v3m4-3v3M3 21h18M3 10h18M3 7l9-4 9 4M4 10h16v11H4V10z"/>
</svg>
<p>Connect to browse remote files</p>
</div>
</div>
</div>
<!-- Progress Panel -->
<div id="progress-panel" class="bg-white rounded-lg shadow p-6 mt-6 hidden">
<h2 class="text-xl font-semibold mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 spinner" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
Restore Progress
</h2>
<div class="mb-4">
<div class="flex justify-between text-sm mb-1">
<span id="progress-status">Initializing...</span>
<span id="progress-percent">0%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-4">
<div id="progress-bar" class="bg-blue-600 h-4 rounded-full transition-all duration-300" style="width: 0"></div>
</div>
</div>
<div id="progress-details" class="text-sm text-gray-600">
<p id="progress-message">Starting...</p>
<p id="progress-elapsed" class="mt-1"></p>
</div>
<button type="button" id="cancel-btn"
class="mt-4 w-full bg-red-600 text-white py-2 px-4 rounded hover:bg-red-700 transition hidden">
Cancel Restore
</button>
</div>
</div>
</div>
</div>
<script src="{{ url_for('static', filename='app.js') }}"></script>
</body>
</html>

View File

@@ -66,8 +66,10 @@ class XinonProject {
if (!is_null($overrideQueryParams)) $queryParams = $overrideQueryParams;
$url = $baseUrl . '?' . http_build_query($queryParams);
curl_setopt_array($curl, array(
CURLOPT_URL => $baseUrl . '?' . http_build_query($queryParams),
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => '',
CURLOPT_MAXREDIRS => 10,
@@ -84,7 +86,7 @@ class XinonProject {
$json = json_decode($response, true);
return $json['_embedded']['elements'];
return $json['_embedded']['elements'] ?? [];
}
}
?>

View File

@@ -25,6 +25,32 @@ RewriteCond %{REQUEST_FILENAME} !-l
RewriteRule ^api/(v\d+)/([^/]+)(/.+)$ index.php?action=Api&apiv=$1&apicall=$2&apiparams=$3 [QSA]
# MobileApp routing: /MobileApp/{module}/{submodule}/{action}
# Example: /MobileApp/Lager/Inventur/getActiveStocktakes
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-l
RewriteRule ^MobileApp/([^/]+)/([^/]+)/([^/]+)/?$ index.php?action=MobileApp&module=$1&submodule=$2&endpoint=$3 [QSA,L]
# /MobileApp/{module}/{submodule} - e.g., /MobileApp/Lager/Inventur
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-l
RewriteRule ^MobileApp/([^/]+)/([^/]+)/?$ index.php?action=MobileApp&module=$1&submodule=$2 [QSA,L]
# /MobileApp/{module} - e.g., /MobileApp/auth or /MobileApp/Lager
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-l
RewriteRule ^MobileApp/([^/]+)/?$ index.php?action=MobileApp&module=$1 [QSA,L]
# /MobileApp - Main app
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-l
RewriteRule ^MobileApp/?$ index.php?action=MobileApp [QSA,L]
# regular web calls
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -524,6 +524,54 @@
border: 1px solid #c9e6d8;
}
/* ===== Copy From Section ===== */
.tt-scope .copy-from-section {
background: #f8fafc;
border-radius: 8px;
padding: 12px 16px;
border: 1px dashed var(--tt-border);
}
.tt-scope .copy-from-row {
display: flex;
align-items: center;
gap: 10px;
}
.tt-scope .copy-select {
flex: 1;
max-width: 350px;
}
.tt-scope .copy-select select {
width: 100%;
}
.tt-scope .copy-btn {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.tt-scope .copy-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.tt-scope .copy-hint {
font-size: 11px;
color: var(--tt-muted);
margin-top: 6px;
}
.tt-scope .form-divider {
border: none;
height: 1px;
background: var(--tt-border);
margin: 4px 0 0 0;
}
/* ===== Utilities ===== */
.tt-scope .mono { font-family: var(--tt-mono); }
.tt-scope .muted { color: var(--tt-muted); }

View File

@@ -124,8 +124,7 @@ const ADBNetzgebiet = {
<template v-if="item.related.consent_projects.length">
<a v-for="cons in item.related.consent_projects.slice(0, 1)" :key="cons.id"
:href="window.TT_CONFIG.CONSENT_URL + '?id=' + cons.id"
target="_blank" class="related-link">
{{ cons.name }}
target="_blank" class="related-link" v-html="formatConsentName(cons.name)">
</a>
<span v-if="item.related.consent_projects.length > 1" class="more-badge">+{{ item.related.consent_projects.length - 1 }}</span>
</template>
@@ -134,7 +133,16 @@ const ADBNetzgebiet = {
</a>
</td>
<td class="col-actions">
<button
v-if="item.netzgebiet.source && item.netzgebiet.source.startsWith('rimo-')"
class="icon-btn"
@click.prevent="handleRimoImportClick(item)"
:title="getImportButtonTitle(item.netzgebiet.id)"
:disabled="getImportButtonDisabled(item.netzgebiet.id)">
<i class="fa-duotone" :class="getImportButtonIcon(item.netzgebiet.id)"></i>
</button>
<button class="icon-btn" @click.prevent="openEditModal(item)" title="Bearbeiten"><i class="fa-duotone fa-pen"></i></button>
<button class="icon-btn" @click.prevent="copyNetzgebiet(item)" title="Kopieren"><i class="fa-duotone fa-copy"></i></button>
<button class="icon-btn" @click.prevent="openHistoryModal(item)" title="Verlauf"><i class="fa-duotone fa-clock-rotate-left"></i></button>
</td>
</tr>
@@ -274,6 +282,25 @@ const ADBNetzgebiet = {
</div>
</div>
</tt-dialog>
<!-- RIMO Import Log Modal -->
<tt-dialog :show="showRimoLogModal" :title="rimoLogTitle" size="large" @close="closeRimoLogModal">
<div v-if="!rimoLogContent && rimoLogStatus === 'running'" class="table-placeholder compact">
<i class="fa-duotone fa-spinner fa-spin"></i>
<span>Lade Log...</span>
</div>
<div v-else-if="!rimoLogContent" class="table-placeholder compact">
<i class="fa-duotone fa-file-lines"></i>
<span>Kein Log vorhanden.</span>
</div>
<pre v-else class="log-view" style="white-space: pre-wrap; word-break: break-all; max-height: 60vh; overflow-y: auto; background: #f5f5f5; padding: 10px; border-radius: 5px;">{{ rimoLogContent }}</pre>
<template #footer>
<div class="footer-status" style="margin-right: auto; font-size: 12px; color: #666;">
Status: <strong :style="{color: rimoLogStatus === 'running' ? 'blue' : (rimoLogStatus === 'cooldown' ? 'orange' : 'green')}">{{ rimoLogStatus }}</strong>
</div>
<button class="ghost-btn" @click="closeRimoLogModal">Schließen</button>
</template>
</tt-dialog>
</div>
`,
@@ -294,6 +321,16 @@ const ADBNetzgebiet = {
historyItems: [],
historyTitle: 'Verlauf',
expandedIds: {},
// RIMO Import
importStatus: {},
showRimoLogModal: false,
rimoLogContent: '',
rimoLogTitle: '',
rimoLogStatus: 'idle',
rimoLogInterval: null,
statusInterval: null,
freigabeLabels: { interest: 'Interest', provision: 'Provision', order: 'Order', reorder: 'Reorder' },
freigabeOptions: [
{ key: 'interest', label: 'Interest' },
@@ -367,9 +404,24 @@ const ADBNetzgebiet = {
filteredNetzgebiete() { if (this.currentPage > this.totalPages) this.currentPage = 1; }
},
async mounted() { await this.fetchNetzgebiete(); },
async mounted() {
await this.fetchNetzgebiete();
this.fetchImportStatus();
this.statusInterval = setInterval(this.fetchImportStatus, 15000); // Poll every 15s
},
beforeDestroy() {
clearInterval(this.statusInterval);
clearInterval(this.rimoLogInterval);
},
methods: {
formatConsentName(name) {
if (name && name.startsWith('Glasfaserprojekt')) {
return name.replace('Glasfaserprojekt', 'Glasfaserprojekt<br />');
}
return name;
},
debouncedFilter() {
clearTimeout(this.filterDebounce);
this.filterDebounce = setTimeout(() => this.currentPage = 1, 300);
@@ -419,6 +471,25 @@ const ADBNetzgebiet = {
};
this.showEditModal = true;
},
async copyNetzgebiet(item) {
const n = item.netzgebiet;
let options = {};
try { options = JSON.parse(n.options || '{}'); } catch {}
let freigabeArr = [];
try { freigabeArr = JSON.parse(n.freigabe || '[]') || []; } catch {}
const freigabeObj = {};
['interest', 'provision', 'order', 'reorder'].forEach(f => freigabeObj[f] = freigabeArr.includes(f));
this.editItem = {
id: null,
name: '',
extref: '',
source: n.source || '',
source_id: '',
freigabe: freigabeObj,
options: { ...this.defaultOptions, ...options }
};
this.showEditModal = true;
},
async saveNetzgebiet() {
if (!this.editItem?.name) return;
this.isSaving = true;
@@ -451,6 +522,96 @@ const ADBNetzgebiet = {
} catch { window.notify?.('error', 'Verlauf konnte nicht geladen werden.'); }
finally { this.historyLoading = false; }
},
// RIMO Import Methods
async fetchImportStatus() {
const rimoIds = this.netzgebiete
.filter(item => item.netzgebiet.source && item.netzgebiet.source.startsWith('rimo-'))
.map(item => item.netzgebiet.id);
if (!rimoIds.length) return;
try {
const response = await axios.post(window.TT_CONFIG.GET_RIMO_IMPORT_STATUS_URL, { ids: rimoIds });
if (response.data.success) {
this.importStatus = response.data.data;
}
} catch (error) {
console.error("Could not fetch RIMO import statuses.", error);
}
},
handleRimoImportClick(item) {
const status = this.importStatus[item.netzgebiet.id]?.status || 'idle';
if (status === 'running') {
this.openRimoLogModal(item);
} else if (status === 'cooldown') {
const remaining = this.importStatus[item.netzgebiet.id]?.remaining || 0;
window.notify?.('info', `Bitte warten Sie noch ${Math.ceil(remaining / 60)} Minuten.`);
this.openRimoLogModal(item);
} else {
this.startRimoImport(item);
}
},
getImportButtonTitle(id) {
const status = this.importStatus[id]?.status || 'idle';
if (status === 'running') return 'Import-Log anzeigen';
if (status === 'cooldown') return 'Manueller RIMO-Import (Abkühlphase)';
return 'Manuellen RIMO-Import starten';
},
getImportButtonDisabled(id) {
const status = this.importStatus[id]?.status || 'idle';
return false; // Never truly disabled, just changes action
},
getImportButtonIcon(id) {
const status = this.importStatus[id]?.status || 'idle';
if (status === 'running') return 'fa-spinner fa-spin';
if (status === 'cooldown') return 'fa-hourglass-half';
return 'fa-rocket';
},
async startRimoImport(item) {
try {
const response = await axios.get(window.TT_CONFIG.START_RIMO_IMPORT_URL + '?id=' + item.netzgebiet.id);
if (response.data.success) {
window.notify?.('success', 'RIMO Import gestartet.');
this.importStatus[item.netzgebiet.id] = { status: 'running' };
this.openRimoLogModal(item);
} else {
window.notify?.('error', response.data.message || 'Import konnte nicht gestartet werden.');
}
} catch (error) {
window.notify?.('error', 'Fehler beim Starten des Imports.');
console.error(error);
}
},
openRimoLogModal(item) {
this.rimoLogTitle = `RIMO Import: ${item.netzgebiet.name}`;
this.showRimoLogModal = true;
this.fetchRimoLog(item); // initial fetch
this.rimoLogInterval = setInterval(() => this.fetchRimoLog(item), 3000);
},
closeRimoLogModal() {
this.showRimoLogModal = false;
clearInterval(this.rimoLogInterval);
this.rimoLogContent = '';
this.rimoLogTitle = '';
this.rimoLogStatus = 'idle';
},
async fetchRimoLog(item) {
try {
const response = await axios.get(window.TT_CONFIG.GET_RIMO_IMPORT_LOG_URL + '?id=' + item.netzgebiet.id);
if (response.data.success) {
this.rimoLogContent = response.data.data.log;
this.rimoLogStatus = response.data.data.status;
// If no longer running, stop polling
if (this.rimoLogStatus !== 'running') {
clearInterval(this.rimoLogInterval);
this.fetchImportStatus(); // refresh overall status
}
}
} catch (error) {
console.error('Could not fetch RIMO log.', error);
clearInterval(this.rimoLogInterval);
}
},
translateAction(action) { return { create: 'Erstellt', update: 'Geändert', delete: 'Gelöscht' }[action] || action; },
translateField(field) {
return { name: 'Name', extref: 'ExtRef', source: 'Quelle', source_id: 'Source ID',

View File

@@ -1,13 +1,15 @@
Vue.component('AddressTickets', {
template: `
<div>
<tt-card class="mb-4">
<h3 class="text-center mb-3">Tickets</h3>
<div class="table-responsive">
<tt-card class="mb-4 mt-4">
<h3 class="text-center mb-3">Tickets - {{ customerName }} ({{ customerNumber }})</h3>
<div v-if="tickets.length === 0" class="alert alert-info text-center">
Keine Tickets gefunden.
</div>
<div v-else class="table-responsive">
<table class="table table-striped table-hover table-sm">
<thead class="thead-light">
<tr>
<th>Kundennummer</th>
<th>Erstellt am</th>
<th>Betreff</th>
<th>Letztes Update</th>
@@ -16,7 +18,6 @@ Vue.component('AddressTickets', {
</thead>
<tbody>
<tr v-for="ticket in tickets" :key="ticket.id">
<td>{{ ticket.customField7 }}</td>
<td>{{ formatDate(ticket.createdAt) }}</td>
<td>{{ ticket.subject }}</td>
<td>{{ formatDate(ticket.updatedAt) }}</td>
@@ -30,7 +31,10 @@ Vue.component('AddressTickets', {
<tt-card>
<h3 class="text-center mb-3">Lieferscheine</h3>
<div class="table-responsive">
<div v-if="shippingNotes.length === 0" class="alert alert-info text-center">
Keine Lieferscheine gefunden.
</div>
<div v-else class="table-responsive">
<table class="table table-striped table-hover table-sm">
<thead class="thead-light">
<tr>
@@ -65,10 +69,15 @@ Vue.component('AddressTickets', {
return {window: window};
},
computed: {
customerName() {
return this.window.TT_CONFIG?.CUSTOMER_NAME || '';
},
customerNumber() {
return this.window.TT_CONFIG?.CUSTOMER_NUMBER || '';
},
tickets() {
return (this.window.TT_CONFIG?.TICKETS || []).map(t => ({
id: t.id,
customField7: t.customField7,
createdAt: t.createdAt,
subject: t.subject,
updatedAt: t.updatedAt,

View File

@@ -170,7 +170,7 @@ Vue.component('manual-invoice-modal', {
<tt-textarea label="Einleitender Text" v-model="invoiceData.einleitender_text" rows="3" sm row/>
</tt-card>
<tt-card><template v-slot:header><h5><i class="fas fa-list-ol mr-2"></i>Positionen</h5></template>
<tt-positions-manager group-mode ref="positionsManager" v-model="invoiceData.positions" :config="positionsConfig" />
<tt-positions-manager group-mode ref="positionsManager" v-model="invoiceData.positions" :config="positionsConfig" @updateField-article_id="onArticleSelected" />
</tt-card>
<tt-card><template v-slot:header><h5><i class="fas fa-paragraph mr-2"></i>Texte & Rabatt</h5></template>
<tt-input label="Gesamtrabatt (%)" v-model.number="invoiceData.gesamtrabatt" sm row type="number" placeholder="0"/>
@@ -208,6 +208,12 @@ Vue.component('manual-invoice-modal', {
billingTypeOptions: [{value: 'invoice', text: 'Rechnung'}, {value: 'sepa', text: 'SEPA'}],
positionsConfig: {
fields: {
article_id: {
type: 'autocomplete',
label: 'Artikel (optional)',
apiUrl: '/WarehouseArticle/autocomplete',
customFieldReference: 'WarehouseArticle'
},
product_name: { type: 'input', label: 'Bezeichnung' },
product_info: { type: 'input', label: 'Zusatzinfo' },
amount: { type: 'input', label: 'Menge', inputType: 'number' },
@@ -330,6 +336,28 @@ Vue.component('manual-invoice-modal', {
if (e.ctrlKey && e.key === 'q') { e.preventDefault(); this.togglePreviewVisibility(); }
},
togglePreviewVisibility() { if (!this.isLargeScreen) this.showPreviewOnSmallScreen = !this.showPreviewOnSmallScreen; },
async onArticleSelected(articleId) {
if (!articleId) return;
try {
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/getArticleVatInfo?article_id=${articleId}`);
if (data.success && this.$refs.positionsManager) {
// Update the formData in the positions manager
const pm = this.$refs.positionsManager;
if (data.article) {
pm.$set(pm.formData, 'product_name', data.article.title);
pm.$set(pm.formData, 'product_info', data.article.description || '');
}
pm.$set(pm.formData, 'vatrate', parseFloat(data.vatrate) || 20);
pm.$set(pm.formData, 'fibu_cost_account', data.fibu_cost_account);
pm.$set(pm.formData, 'fibu_cost_account_legacy', data.fibu_cost_account_legacy);
pm.$set(pm.formData, 'fibu_taxcode', data.fibu_taxcode);
// Store vatgroup_id on invoice level if needed
this.invoiceData.vatgroup_id = data.vatgroup_id;
}
} catch (e) {
console.error('Error fetching article VAT info:', e);
}
},
debouncedPreviewUpdate() {
clearTimeout(this.previewDebounceTimer);
this.previewDebounceTimer = setTimeout(() => this.updatePdfPreview(), 2000);

View File

@@ -0,0 +1,10 @@
.fa-map-location-dot:before
{
color: #d80000;
}
.fa-map-location-dot:after
{
color: #147d00;
opacity: 0.9;
}

View File

@@ -0,0 +1,383 @@
Vue.component('pop-map-modal', {
template: `
<div>
<div class="modal fade" id="popMapModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered" style="max-width: 95vw;">
<div class="modal-content" style="height: 90vh;">
<div class="modal-header bg-dark text-white">
<h5 class="modal-title"><i class="fas fa-map-marked-alt"></i><span class="text-light mt-1 d-inline-block"> POP Übersicht</span></h5>
<div class="d-flex align-items-center ml-auto">
<div class="input-group mr-3 position-relative" style="width: 300px;">
<input type="text" class="form-control form-control-sm"
v-model="searchQuery"
@input="filterPops"
@keydown.down.prevent="moveSelection(1)"
@keydown.up.prevent="moveSelection(-1)"
@keydown.enter.prevent="handleEnter"
placeholder="POP suchen...">
<div class="input-group-append">
<button class="btn btn-primary btn-sm" @click="searchPop"><i class="fas fa-search"></i></button>
<button v-if="searchQuery" class="btn btn-secondary btn-sm" @click="clearSearch"><i class="fas fa-times"></i></button>
</div>
<div v-if="filteredPops.length > 0 && showSuggestions" class="list-group position-absolute w-100" style="top: 100%; z-index: 1050; max-height: 300px; overflow-y: auto; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
<a href="#" v-for="(pop, index) in filteredPops" :key="pop.id"
class="list-group-item list-group-item-action py-2"
:class="{ 'active': index === selectedIndex }"
@click.prevent="selectPop(pop)">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1" :class="{ 'text-white': index === selectedIndex }">{{ pop.name }}</h6>
</div>
<small :class="index === selectedIndex ? 'text-white' : 'text-muted'">{{ categories[pop.category || 99] }} | {{ pop.location }}</small>
</a>
</div>
</div>
<button type="button" class="close text-white" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
</div>
<div class="modal-body p-0 position-relative">
<div id="pop-map" style="width: 100%; height: 100%;"></div>
<div class="legend-box" style="position: absolute; bottom: 30px; right: 20px; background: white; padding: 15px; border-radius: 5px; box-shadow: 0 0 15px rgba(0,0,0,0.2); z-index: 1000; min-width: 200px;">
<h5 class="border-bottom p-0 pb-2 mb-2 mt-0"><strong>Kategorien</strong></h5>
<div class="mb-1 d-flex align-items-center pb-1 border-bottom">
<div class="custom-control custom-checkbox mr-2">
<input type="checkbox" class="custom-control-input" id="cat-all" v-model="allCategoriesSelected">
<label class="custom-control-label" for="cat-all" style="cursor: pointer;">
</label>
</div>
<label for="cat-all" style="cursor: pointer; margin-bottom: 0;"><strong>Alle auswählen</strong> ({{ totalCount }} <span v-if="totalCount !== allPops.length" class="text-danger" title="Gesamtanzahl inklusive POPs ohne Koordinaten">/ {{ allPops.length }}</span>)</label>
</div>
<div v-for="(label, key) in categories" :key="key" class="mb-1 d-flex align-items-center">
<div class="custom-control custom-checkbox mr-2">
<input type="checkbox" class="custom-control-input" :id="'cat-'+key" v-model="visibleCategories[key]" @change="updateMap(false)">
<label class="custom-control-label" :for="'cat-'+key" style="cursor: pointer;">
</label>
</div>
<img :src="window.TT_CONFIG.BASE_URL + '/' + categoryImages[key]" style="height: 20px; margin-right: 5px;">
<label :for="'cat-'+key" style="cursor: pointer; margin-bottom: 0;">{{ label }} ({{ categoryCounts[key] || 0 }})</label>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`,
data() {
return {
map: null,
popLayer: null,
searchQuery: '',
filteredPops: [],
showSuggestions: false,
selectedIndex: -1,
categories: {
1: 'Outdoor (Kasten/Schrank)',
2: 'Indoor (Keller Gebäude)',
3: 'Sender/Funk (Sendemast)',
4: 'Container (Garage, Container)',
99: 'Unbekannt'
},
states: {
1: "Planung (Innenleben)",
2: "Bauphase (Schrank)",
3: "Grobdoku",
4: "in Betrieb",
5: "von Techniker abgenommen (Altbestand)"
},
categoryImages: {
1: 'img/markers/marker-pop.png',
2: 'img/markers/marker-pop-o.png',
3: 'img/markers/marker-pop-b.png',
4: 'img/markers/marker-pop-v.png',
99: 'img/markers/marker-pop-bl.png'
},
categoryColors: {
1: '#a1dfa0',
2: '#f8b767',
3: '#a9b8ec',
4: '#f89797',
99: '#808080'
},
visibleCategories: {
1: true,
2: true,
3: true,
4: true,
99: true
},
categoryCounts: {
1: 0,
2: 0,
3: 0,
4: 0,
99: 0
},
allPops: [],
markers: []
};
},
computed: {
totalCount() {
return Object.values(this.categoryCounts).reduce((acc, count) => acc + count, 0);
},
allCategoriesSelected: {
get() {
return Object.keys(this.categories).every(key => this.visibleCategories[key]);
},
set(value) {
Object.keys(this.categories).forEach(key => {
this.visibleCategories[key] = value;
});
this.updateMap(false);
}
}
},
mounted() {
const popsObj = window.TT_CONFIG.POPS || {};
this.allPops = Object.values(popsObj);
this.calculateCounts();
$(document).on('shown.bs.modal', '#popMapModal', this.initMap);
document.addEventListener('click', this.handleClickOutside);
},
beforeDestroy() {
$(document).off('shown.bs.modal', '#popMapModal', this.initMap);
document.removeEventListener('click', this.handleClickOutside);
},
methods: {
calculateCounts() {
for (let key in this.categoryCounts) {
this.categoryCounts[key] = 0;
}
this.allPops.forEach(pop => {
const gps = pop.gps;
if (!gps) return;
const parts = gps.split(',');
if (parts.length !== 2) return;
const lat = parseFloat(parts[0]);
const lng = parseFloat(parts[1]);
if (isNaN(lat) || isNaN(lng) || (lat === 0 && lng === 0)) return;
const category = pop.category || 99;
if (this.categoryCounts.hasOwnProperty(category)) {
this.categoryCounts[category]++;
} else {
this.categoryCounts[99]++;
}
});
},
open() {
$('#popMapModal').modal('show');
},
initMap() {
if (this.map) {
setTimeout(() => {
this.map.invalidateSize();
}, 100);
return;
}
if (typeof L === 'undefined' || !L.MakiMarkers) {
console.error('Leaflet or MakiMarkers not loaded');
return;
}
L.MakiMarkers.accessToken = window.TT_CONFIG.MAPBOX_TOKEN;
this.map = L.map('pop-map').setView([51.1657, 10.4515], 6);
const standardLayer = L.tileLayer('https://mapsneu.wien.gv.at/basemap/{id}/normal/google3857/{z}/{y}/{x}.{imgtype}', {
maxZoom: 19,
id: "geolandbasemap",
imgtype: "png",
attribution: 'Basemap.at'
});
const satelliteLayer = L.tileLayer('https://mapsneu.wien.gv.at/basemap/{id}/normal/google3857/{z}/{y}/{x}.{imgtype}', {
maxZoom: 19,
id: "bmaporthofoto30cm",
imgtype: "jpeg",
attribution: 'Basemap.at'
});
standardLayer.addTo(this.map);
const baseMaps = {
"Karte": standardLayer,
"Satellit": satelliteLayer
};
L.control.layers(baseMaps).addTo(this.map);
this.popLayer = L.featureGroup().addTo(this.map);
this.updateMap();
},
updateMap(shouldFit = true) {
if (!this.map) return;
this.popLayer.clearLayers();
this.markers = [];
const bounds = L.latLngBounds();
let hasMarkers = false;
this.allPops.forEach(pop => {
const category = pop.category || 99;
if (!this.visibleCategories[category]) return;
const gps = pop.gps;
if (!gps) return;
const parts = gps.split(',');
if (parts.length !== 2) return;
const lat = parseFloat(parts[0]);
const lng = parseFloat(parts[1]);
if (isNaN(lat) || isNaN(lng) || (lat === 0 && lng === 0)) return;
let iconUrl = this.categoryImages[category] || this.categoryImages[99];
let color = this.categoryColors[category] || '#808080';
const marker = L.marker([lat, lng], {
icon: L.MakiMarkers.icon({
icon: 'village',
color: color,
size: 'l'
})
});
let categoryName = this.categories[category] || 'Unbekannt';
let stateText = this.states[pop.state] || pop.state || '-';
const popupContent = `
<div style="min-width: 200px;">
<h6 class="p-0"><i class="fas fa-building"></i> <strong>${pop.name}</strong></h6>
<hr class="my-2">
<div><strong>Kategorie:</strong> ${categoryName}</div>
<div><strong>Status:</strong> ${stateText}</div>
<div><strong>Info:</strong> ${pop.location || '-'}</div>
<div class="d-flex align-items-center justify-content-between mt-1">
<span><strong>GPS:</strong> ${lat.toFixed(6)}, ${lng.toFixed(6)}</span>
<a target="_blank" href="https://www.google.com/maps?q=${lat},${lng}" class="btn btn-sm btn-outline-secondary py-0 px-1" title="In Google Maps öffnen">Google Maps <i class="fas fa-map-marker-alt"></i></a>
</div>
<div class="mt-2">
<a target="_blank" href="${window.TT_CONFIG.BASE_URL}/Pop/Detail?id=${pop.id}" class="btn btn-sm btn-info btn-block text-light"><i class="fas fa-info-circle"></i> Details</a>
</div>
</div>
`;
marker.bindPopup(popupContent);
marker.popData = pop;
this.popLayer.addLayer(marker);
this.markers.push(marker);
bounds.extend([lat, lng]);
hasMarkers = true;
});
if (shouldFit === true && hasMarkers && !this.searchQuery) {
this.map.fitBounds(bounds, {padding: [50, 50]});
}
},
filterPops() {
const query = this.searchQuery.toLowerCase().trim();
this.selectedIndex = -1;
if (query.length < 1) {
this.filteredPops = [];
this.showSuggestions = false;
return;
}
this.filteredPops = this.allPops.filter(pop =>
pop.name.toLowerCase().includes(query) ||
(pop.location && pop.location.toLowerCase().includes(query))
).slice(0, 10);
this.showSuggestions = true;
},
moveSelection(step) {
if (!this.showSuggestions || this.filteredPops.length === 0) return;
this.selectedIndex += step;
if (this.selectedIndex < 0) {
this.selectedIndex = this.filteredPops.length - 1;
} else if (this.selectedIndex >= this.filteredPops.length) {
this.selectedIndex = 0;
}
},
handleEnter() {
if (this.showSuggestions && this.selectedIndex >= 0 && this.selectedIndex < this.filteredPops.length) {
this.selectPop(this.filteredPops[this.selectedIndex]);
} else {
this.searchPop();
}
},
selectPop(pop) {
this.searchQuery = pop.name;
this.showSuggestions = false;
this.selectedIndex = -1;
this.searchPop();
},
handleClickOutside(event) {
if (!event.target.closest('.input-group')) {
this.showSuggestions = false;
this.selectedIndex = -1;
}
},
searchPop() {
const query = this.searchQuery.toLowerCase().trim();
if (!query) {
this.clearSearch();
return;
}
this.showSuggestions = false;
this.selectedIndex = -1;
let found = this.markers.find(m => m.popData.name.toLowerCase().includes(query));
if (!found) {
const hiddenPop = this.allPops.find(p => p.name.toLowerCase().includes(query));
if (hiddenPop) {
const category = hiddenPop.category || 99;
if (!this.visibleCategories[category]) {
this.visibleCategories[category] = true;
this.updateMap(false);
found = this.markers.find(m => m.popData.id === hiddenPop.id);
}
}
}
if (found) {
this.map.flyTo(found.getLatLng(), 15);
setTimeout(() => {
found.openPopup();
}, 500);
} else {
alert('Kein POP gefunden (oder keine GPS Koordinaten).');
}
},
clearSearch() {
this.searchQuery = '';
this.filteredPops = [];
this.showSuggestions = false;
this.selectedIndex = -1;
const bounds = L.latLngBounds();
this.markers.forEach(m => bounds.extend(m.getLatLng()));
if (this.markers.length > 0) {
this.map.fitBounds(bounds, {padding: [50, 50]});
}
}
}
});

View File

@@ -11,12 +11,19 @@ Vue.component('Pop', {
<i class="fas fa-plus"></i>
Pop hinzufügen
</button>
<button type="button" class="btn btn-light mr-2" @click="$refs.mapModal.open()">
<i class="fa-duotone fa-solid fa-map-location-dot"></i> <span class="font-weight-semibold">Übersichtskarte</span>
</button>
</template>
<template v-slot:name="{ row }">
<a target="_blank" :href="window['TT_CONFIG']['BASE_URL'] +'/Pop/Detail?id=' + row.id">{{row.name}}</a>
</template>
<template v-slot:category="{ row }">
{{ {1: 'Outdoor', 2: 'Indoor', 3: 'Sender/Funk', 4: 'Container', 99: 'Unbekannt'}[row.category] || 'Unbekannt' }}
</template>
<template v-slot:doku_date="{ row }">
<span>{{row.doku_date ? window.moment.unix(row.doku_date).format('DD.MM.YYYY') : ''}}</span>
</template>
@@ -45,6 +52,7 @@ Vue.component('Pop', {
</tt-table>
<pop-map-modal ref="mapModal"></pop-map-modal>
</tt-card>
`,
data() {
@@ -60,7 +68,8 @@ Vue.component('Pop', {
{value: '1', text: 'Outdoor (Kasten/Schrank)'},
{value: '2', text: 'Indoor (Keller Gebäude)'},
{value: '3', text: 'Sender/Funk (Sendemast)'},
{value: '4', text: 'Container (Garage, Container)'}]},
{value: '4', text: 'Container (Garage, Container)'},
{value: '99', text: 'Unbekannt'}]},
{text: 'Netzgebiet', key: 'networkArea', class: 'text-center',
// TODO: fix autocomplete Filter
// filter: 'autocomplete',

View File

@@ -3,14 +3,14 @@ Vue.component("User", {
<div>
<tt-card>
<tt-table :data="window['TT_CONFIG']['USERS']" :config="UserTableConfig">
<template v-slot:top-buttons>
<template v-slot:top-buttons v-if="canManageUsers">
<tt-button @click="window.location = window['TT_CONFIG']['ADD_URL']"
additional-class="btn-primary"
text="Benutzer hinzufügen"
icon="fas fa-plus"/>
</template>
<template v-slot:actions="{ row: user }">
<template v-slot:actions="{ row: user }" v-if="canManageUsers">
<div class="d-flex justify-content-center" style="gap: 4px">
<tt-button @click="window.location = window['TT_CONFIG']['EDIT_URL'] + '?id=' + user.id"
additional-class="btn-outline-primary"
@@ -49,11 +49,14 @@ Vue.component("User", {
showSendMailModal: false,
selectedUserForMail: null,
isSendingMail: false,
UserTableConfig: {
key: "UserTable",
tableHeader: "Benutzer",
defaultPageSize: 25,
headers: [{text: "Username", key: "username", class: "text-center", sortable: false, priority: 20},
}),
computed: {
canManageUsers() {
return window['TT_CONFIG']['CAN_MANAGE_USERS'] === '1';
},
UserTableConfig() {
const headers = [
{text: "Username", key: "username", class: "text-center", sortable: false, priority: 20},
{text: "Name", key: "name", class: "text-center", sortable: false, priority: 18},
{text: "Firma", key: "address", class: "text-center", priority: 19},
{text: "E-Mail", key: "email", priority: 14},
@@ -79,9 +82,18 @@ Vue.component("User", {
filterOptions: [{value: "1", text: "Ist Aktiv", icon: "fa-regular fa-circle-check text-success"},
{value: "0", text: "Ist nicht aktiv", icon: "fa-regular fa-circle-xmark text-danger"}],
},
{text: "Aktionen", key: "actions", class: "text-center", sortable: false, priority: 21, filter: false}]
];
if (this.canManageUsers) {
headers.push({text: "Aktionen", key: "actions", class: "text-center", sortable: false, priority: 21, filter: false});
}
}),
return {
key: "UserTable",
tableHeader: "Benutzer",
defaultPageSize: 25,
headers: headers
};
}
},
methods: {
openSendMailModal(user) {
this.selectedUserForMail = user;

View File

@@ -1,13 +1,95 @@
/* Main card margin */
#app > .card {
margin-top: 1rem;
}
/* Reduce button margin */
#app > .card > .card-body > .mb-3 {
margin-bottom: 0.5rem !important;
}
/* End of Life Row Highlighting */
.end-of-life {
background-color: #f8d7da !important;
}
/* Last Edited Row Highlighting */
.last-edited-row {
background-color: #fff3cd !important;
animation: highlight-fade 5s ease-out forwards;
}
@keyframes highlight-fade {
0% {
background-color: #fff3cd;
}
70% {
background-color: #fff3cd;
}
100% {
background-color: transparent;
}
}
/*
* Modal Layout
*/
.modal-body {
.modal-dialog.modal-xl .modal-body {
overflow-x: hidden;
overflow-y: auto;
max-height: calc(100vh - 200px);
}
.modal-dialog.modal-xl .modal-content {
max-height: calc(100vh - 50px);
}
/* Disabled checkbox styling */
.wa-checkbox-item.disabled {
opacity: 0.6;
cursor: not-allowed;
pointer-events: none;
}
/* Disabled form controls styling */
.wa-modal-content .form-control:disabled,
.wa-modal-content .form-control[disabled],
.wa-modal-content textarea:disabled,
.wa-modal-content textarea[disabled] {
background-color: #e9ecef !important;
cursor: not-allowed !important;
opacity: 0.7;
}
.wa-modal-content .tt-select-modern.disabled .tt-select-trigger,
.wa-modal-content .tt-select-trigger[disabled],
.wa-modal-content .tt-select-trigger.disabled {
background-color: #e9ecef !important;
cursor: not-allowed !important;
opacity: 0.7;
pointer-events: none;
}
.wa-modal-content .form-group.disabled {
opacity: 0.7;
pointer-events: none;
}
/* Disabled field styling */
.wa-field-disabled {
opacity: 0.5;
position: relative;
}
.wa-field-disabled::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
cursor: not-allowed;
z-index: 5;
}
.wa-modal-content {

View File

@@ -1,5 +1,13 @@
// Track last edited article for highlighting
window.TT_CONFIG.lastEditedArticleId = null;
window.TT_CONFIG.CRUD_CONFIG.customRowClass = (row) => {
if (row.isEndOfLife) return 'end-of-life';
const classes = [];
if (row.isEndOfLife) classes.push('end-of-life');
if (window.TT_CONFIG.lastEditedArticleId && row.id == window.TT_CONFIG.lastEditedArticleId) {
classes.push('last-edited-row');
}
return classes.join(' ');
}
async function handleApiResponse(responsePromise) {
@@ -14,15 +22,19 @@ async function handleApiResponse(responsePromise) {
}
Vue.component('warehouse-article-prices', {
props: {id: {type: Number, required: true}},
props: {
id: {type: Number, required: true},
cheapestPurchasePrice: {type: Number, default: null}
},
template: `
<div class="wa-prices-section">
<h5 class="wa-section-title"><i class="fas fa-tags mr-2"></i>Artikelpreise überschreiben</h5>
<div class="wa-prices-grid-dense">
<div v-for="(price, typeTitle) in articlePrices" :key="typeTitle" class="wa-price-item">
<div v-for="price in sortedPrices" :key="price.typeTitle" class="wa-price-item">
<div class="wa-price-header">
<i v-if="price.isRobot" class="fas fa-robot mr-1 text-muted" style="font-size: 0.75rem;"></i>
<strong style="font-size: 0.8rem;">{{ typeTitle }}</strong>
<strong style="font-size: 0.8rem;">{{ price.typeTitle }}</strong>
<span class="ml-1 text-muted" style="font-size: 0.75rem;">( {{ formatPrice(calculateCurrentPrice(price)) }} )</span>
<i v-if="price.pendingChanges" class="fas fa-exclamation-triangle text-warning ml-auto"
style="font-size: 0.75rem;" title="Nicht gespeichert"></i>
</div>
@@ -40,9 +52,6 @@ Vue.component('warehouse-article-prices', {
type="number"
sm no-form-group/>
<div class="wa-price-actions">
<button class="btn btn-sm btn-primary" @click="savePrice(price)" title="Speichern">
<i class="fas fa-save"></i>
</button>
<button class="btn btn-sm btn-danger" @click="deletePrice(price)" :disabled="price.isRobot" title="Löschen">
<i class="fas fa-trash"></i>
</button>
@@ -52,11 +61,51 @@ Vue.component('warehouse-article-prices', {
</div>
</div>
`,
data: () => ({window, articlePrices: {}}),
data: () => ({window, articlePrices: {}, priceTypes: []}),
computed: {
sortedPrices() {
// Sort: Verkauf first, Partner second, rest alphabetically
const priceOrder = {'Verkauf': 1, 'Partner': 2, 'Energie Steiermark': 3};
return Object.entries(this.articlePrices)
.map(([typeTitle, price]) => ({...price, typeTitle}))
.sort((a, b) => {
const orderA = priceOrder[a.typeTitle] || 99;
const orderB = priceOrder[b.typeTitle] || 99;
if (orderA !== orderB) return orderA - orderB;
return a.typeTitle.localeCompare(b.typeTitle);
});
}
},
async mounted() {
await this.fetchArticlePrices();
},
methods: {
formatPrice(price) {
if (price === null || price === undefined || isNaN(price)) return '-- €';
return price.toFixed(2).replace('.', ',') + ' €';
},
calculateCurrentPrice(price) {
const basePrice = this.cheapestPurchasePrice;
if (basePrice === null || basePrice === undefined) return null;
// If custom override price is set, use it
if (price.priceOverride !== null && price.priceOverride !== undefined && price.priceOverride !== '') {
return parseFloat(price.priceOverride);
}
// If custom multiplier is set, use it
if (price.priceMultiplier !== null && price.priceMultiplier !== undefined && price.priceMultiplier !== '') {
return basePrice * parseFloat(price.priceMultiplier);
}
// Fall back to default factor from price type
const priceType = this.priceTypes.find(pt => pt.id === price.articlePriceTypeId);
if (priceType && priceType.defaultPriceFactor) {
return basePrice * priceType.defaultPriceFactor;
}
return null;
},
handleFactorInput(price) {
if (price.priceMultiplier) price.priceOverride = null;
price.pendingChanges = true;
@@ -70,15 +119,16 @@ Vue.component('warehouse-article-prices', {
axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticlePrice/get`, {filters: {articleId: this.id}}),
axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticlePriceType/get`)
]);
this.priceTypes = typesRes.data.rows || [];
const prices = {};
typesRes.data.rows.forEach(type => prices[type.title] = {
this.priceTypes.forEach(type => prices[type.title] = {
isRobot: true,
articlePriceTypeId: type.id,
priceMultiplier: type.defaultPriceFactor,
priceOverride: null
});
pricesRes.data.rows.forEach(pData => {
const type = typesRes.data.rows.find(t => t.id === pData.articlePriceTypeId);
const type = this.priceTypes.find(t => t.id === pData.articlePriceTypeId);
if (!type) return;
prices[type.title] = {
id: pData.id,
@@ -91,7 +141,10 @@ Vue.component('warehouse-article-prices', {
});
this.articlePrices = prices;
},
async savePrice(price) {
async savePrices() {
// Save all prices with pending changes
const pendingPrices = this.sortedPrices.filter(p => p.pendingChanges);
for (const price of pendingPrices) {
const payload = {
articleId: this.id,
articlePriceTypeId: price.articlePriceTypeId,
@@ -100,9 +153,13 @@ Vue.component('warehouse-article-prices', {
};
const endpoint = price.isRobot ? 'create' : 'update';
const data = price.isRobot ? payload : {id: price.id, ...payload};
await this.window.handleApiResponse(axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticlePrice/${endpoint}`, data));
await axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticlePrice/${endpoint}`, data);
}
await this.fetchArticlePrices();
},
hasPendingChanges() {
return this.sortedPrices.some(p => p.pendingChanges);
},
async deletePrice(price) {
const payload = {id: price.id, articleId: this.id, articlePriceTypeId: price.articlePriceTypeId}
await this.window.handleApiResponse(axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticlePrice/delete`, payload));
@@ -243,9 +300,6 @@ Vue.component('warehouse-article-distributor', {
sm no-form-group/>
</div>
<div class="wa-distributor-actions">
<button class="btn btn-sm btn-primary" @click="saveDistributor(distributor)" title="Speichern">
<i class="fas fa-save"></i>
</button>
<button class="btn btn-sm btn-danger" @click="deleteDistributor(distributor.id)" :disabled="!distributor.id" title="Löschen">
<i class="fas fa-trash"></i>
</button>
@@ -292,10 +346,18 @@ Vue.component('warehouse-article-distributor', {
pendingChanges: true
});
},
async saveDistributor(distributor) {
delete distributor.pendingChanges;
distributor.purchasePrice = distributor.purchasePrice ? parseFloat(distributor.purchasePrice.toString().replace(',', '.')) : null;
await this.window.handleApiResponse(axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticleDistributor/${distributor.id ? 'update' : 'create'}`, distributor));
hasPendingChanges() {
return this.articleDistributors.some(d => d.pendingChanges || !d.id);
},
async saveDistributors() {
// Save all distributors with pending changes or newly added ones
const pendingDistributors = this.articleDistributors.filter(d => d.pendingChanges || !d.id);
for (const distributor of pendingDistributors) {
const data = {...distributor};
delete data.pendingChanges;
data.purchasePrice = data.purchasePrice ? parseFloat(data.purchasePrice.toString().replace(',', '.')) : null;
await axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticleDistributor/${data.id ? 'update' : 'create'}`, data);
}
await this.fetchArticleDistributors();
},
async deleteDistributor(distributorId) {
@@ -331,7 +393,7 @@ Vue.component('warehouse-article-modal', {
<!-- Basic Information -->
<div class="wa-section">
<div class="row">
<div class="col-md-12">
<div class="col-md-12" :class="{ 'wa-field-disabled': !isEditMode && !formData.category_id }">
<tt-input
label="Titel"
v-model="formData.title"
@@ -339,7 +401,7 @@ Vue.component('warehouse-article-modal', {
required
sm/>
</div>
<div class="col-md-12">
<div class="col-md-12" :class="{ 'wa-field-disabled': !isEditMode && !formData.category_id }">
<tt-textarea
label="Beschreibung"
v-model="formData.description"
@@ -351,6 +413,7 @@ Vue.component('warehouse-article-modal', {
label="Kategorie"
v-model="formData.category_id"
:options="categoryOptions"
@input="onCategoryChange"
required
sm/>
</div>
@@ -358,12 +421,12 @@ Vue.component('warehouse-article-modal', {
<tt-input
label="Artikel-Nummer"
v-model="formData.articleNumber"
placeholder="z.B. 1234"
placeholder="Wird automatisch generiert"
required
form-label
sm/>
</div>
<div class="col-md-2">
<div class="col-md-2" :class="{ 'wa-field-disabled': !isEditMode && !formData.category_id }">
<tt-select
label="Einheit"
v-model="formData.unit"
@@ -371,7 +434,7 @@ Vue.component('warehouse-article-modal', {
required
sm/>
</div>
<div class="col-md-2">
<div class="col-md-2" :class="{ 'wa-field-disabled': !isEditMode && !formData.category_id }">
<tt-select
label="Erlöskonto"
v-model="formData.revenueAccount"
@@ -383,13 +446,13 @@ Vue.component('warehouse-article-modal', {
</div>
<!-- Prices Section -->
<warehouse-article-prices v-if="isEditMode" :id="Number(id)"/>
<warehouse-article-prices v-if="isEditMode" ref="pricesComponent" :id="Number(id)" :cheapest-purchase-price="cheapestPurchasePrice"/>
<!-- Distributors Section -->
<warehouse-article-distributor v-if="isEditMode" :id="Number(id)"/>
<warehouse-article-distributor v-if="isEditMode" ref="distributorComponent" :id="Number(id)"/>
<!-- Additional Attributes -->
<div class="wa-section">
<div class="wa-section" :class="{ 'wa-field-disabled': !isEditMode && !formData.category_id }">
<h5 class="wa-section-title"><i class="fas fa-cog mr-2"></i>Zusätzliche Artikel Attribute</h5>
<div class="row">
<div class="col-md-6">
@@ -448,11 +511,16 @@ Vue.component('warehouse-article-modal', {
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="close">Abbrechen</button>
<button type="button" class="btn btn-primary" @click="save" :disabled="saving || !isValid">
<button type="button" class="btn btn-outline-primary" @click="save(false)" :disabled="saving || !isValid">
<i v-if="saving" class="fas fa-spinner fa-spin mr-1"></i>
<i v-else class="fas fa-save mr-1"></i>
Speichern
</button>
<button type="button" class="btn btn-primary" @click="save(true)" :disabled="saving || !isValid">
<i v-if="saving" class="fas fa-spinner fa-spin mr-1"></i>
<i v-else class="fas fa-save mr-1"></i>
Speichern und Schließen
</button>
</div>
</div>
</div>
@@ -461,6 +529,9 @@ Vue.component('warehouse-article-modal', {
data: () => ({
loading: false,
saving: false,
originalCategoryId: null,
cheapestPurchasePrice: null,
originalFormData: null,
formData: {
title: '',
description: '',
@@ -546,6 +617,12 @@ Vue.component('warehouse-article-modal', {
isSbidiShop: !!data.isSbidiShop,
isSbidiShopHide: !!data.isSbidiShopHide
};
// Store original category to detect changes
this.originalCategoryId = data.category_id;
// Store cheapest purchase price for price calculations
this.cheapestPurchasePrice = data.cheapestPurchasePrice || null;
// Store original form data to detect changes
this.originalFormData = JSON.stringify(this.formData);
}
} catch (e) {
window.notify('error', 'Fehler beim Laden');
@@ -571,10 +648,47 @@ Vue.component('warehouse-article-modal', {
isSbidiShopHide: false
};
},
async save() {
async onCategoryChange(categoryId) {
if (!categoryId) return;
// In edit mode, only regenerate if category actually changed from original
if (this.isEditMode && categoryId == this.originalCategoryId) return;
try {
const res = await axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseArticle/getNextArticleNumber`, {
params: { categoryId: categoryId }
});
if (res.data.success) {
this.formData.articleNumber = res.data.articleNumber;
}
} catch (e) {
console.error('Failed to get next article number:', e);
}
},
async save(closeAfterSave = true) {
if (!this.isValid) return;
this.saving = true;
try {
let savedPrices = false;
let savedDistributors = false;
let savedArticle = false;
// Save prices and distributors first (only in edit mode)
if (this.isEditMode) {
if (this.$refs.pricesComponent && this.$refs.pricesComponent.hasPendingChanges()) {
await this.$refs.pricesComponent.savePrices();
savedPrices = true;
}
if (this.$refs.distributorComponent && this.$refs.distributorComponent.hasPendingChanges()) {
await this.$refs.distributorComponent.saveDistributors();
savedDistributors = true;
}
}
// Check if main article data actually changed
const currentFormData = JSON.stringify(this.formData);
const articleDataChanged = !this.isEditMode || this.originalFormData !== currentFormData;
if (articleDataChanged) {
const endpoint = this.isEditMode ? 'update' : 'create';
const payload = {
...this.formData,
@@ -589,11 +703,38 @@ Vue.component('warehouse-article-modal', {
const res = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WarehouseArticle/${endpoint}`, payload);
if (res.data.success) {
savedArticle = true;
// Track last edited article for row highlighting
window.TT_CONFIG.lastEditedArticleId = this.isEditMode ? Number(this.id) : res.data.id;
if (!this.isEditMode) {
// For new articles, reopen in edit mode
window.notify('success', res.data.message || 'Gespeichert');
if (!this.isEditMode && window.TT_CONFIG.CRUD_CONFIG.reopenOnCreate) this.$emit('reopen', res.data.id);
else this.$emit('close');
this.$emit('reopen', res.data.id);
return;
}
} else {
window.notify('error', res.data.message || 'Fehler beim Speichern');
return;
}
}
// Show success message if anything was saved
if (savedArticle || savedPrices || savedDistributors) {
window.TT_CONFIG.lastEditedArticleId = Number(this.id);
window.notify('success', 'Gespeichert');
} else {
window.notify('info', 'Keine Änderungen');
}
if (closeAfterSave) {
// Close modal if requested
this.$emit('close');
} else {
// Stay open - reload data to refresh prices/distributors
await this.loadArticle();
if (this.$refs.pricesComponent) await this.$refs.pricesComponent.fetchArticlePrices();
if (this.$refs.distributorComponent) await this.$refs.distributorComponent.fetchArticleDistributors();
}
} catch (e) {
window.notify('error', 'Fehler beim Speichern');
@@ -618,10 +759,17 @@ Vue.component('warehouse-article', {
<warehouse-history-modal :show.sync="historyModal" :id="historyModalId"/>
<div class="mb-3" v-if="window.TT_CONFIG.WAREHOUSE_ADMIN">
<button @click="articleModalId = 'create'" class="btn btn-primary">
<i class="fas fa-plus me-1"></i>Artikel erstellen
</button>
</div>
<tt-table-crud
ref="table"
emit-edit
@openHistory="historyModalId = $event.id; historyModal = true"
@printLabel="printLabel($event)"
@edit="articleModalId = $event.id">
<template v-slot:cheapestsellprice="{ row }">
<template v-for="price in JSON.parse(row.cheapestSellPrice || '[]')">
@@ -661,5 +809,11 @@ Vue.component('warehouse-article', {
if (Object.keys(table.filters).length === 0) table.filters = {};
table.refreshTable();
}
},
methods: {
printLabel(event) {
const url = window.TT_CONFIG.BASE_PATH + "/WarehouseArticle/printLabel?id=" + event.id;
window.open(url, '_blank');
}
}
});

View File

@@ -0,0 +1,23 @@
Vue.component('warehouse-category', {
//language=Vue
template: `
<tt-card>
<warehouse-administration-switch/>
<tt-table-crud
@openHistory="historyModal = true; historyModalId = $event.id"
@printLabels="printLabels($event)"
/>
<warehouse-history-modal :show.sync="historyModal" :id="historyModalId"/>
</tt-card>
`, data() {
return {
window: window, historyModal: false, historyModalId: null,
}
},
methods: {
printLabels(event) {
const url = window.TT_CONFIG.BASE_PATH + "/WarehouseCategory/printLabels?id=" + event.id;
window.open(url, '_blank');
}
}
})

View File

@@ -277,20 +277,83 @@ Vue.component('warehouse-shipping-note-modal', {
this.loading = false;
},
async updateCarId(userId) {
if (!userId) return this.$refs.hoursManager.updateField('carId', null);
if (!userId) {
this.$refs.hoursManager.updateField('carId', null);
this.$refs.hoursManager.updateField('kilometerCount', null);
return;
}
this.hoursLoading = true;
try {
const {data} = await axios.get(window.TT_CONFIG["BASE_PATH"] + '/WarehouseShippingNote/timerecordingCarForUser?userId=' + userId);
if (data.status === 'USER_NO_CAR') this.$refs.hoursManager.updateField('carId', null);
else this.$refs.hoursManager.updateField('carId', data.id);
if (data.status === 'USER_NO_CAR') {
this.$refs.hoursManager.updateField('carId', null);
this.$refs.hoursManager.updateField('kilometerCount', null);
this.hoursLoading = false;
} else {
this.$refs.hoursManager.updateField('carId', data.id);
// Trigger kilometer calculation after car is set
// Note: updateKilometer will set hoursLoading = false when done
await this.$nextTick();
await this.updateKilometer(data.id);
}
} catch (error) {
console.error('Error fetching car for user:', error);
window.notify('error', 'Fehler beim Laden des Fahrzeugs');
this.hoursLoading = false;
}
},
async updateKilometer(carId) {
if (!carId || carId === '0' && this.$refs.hoursManager) return this.$refs.hoursManager?.updateField('kilometerCount', null);
this.hoursLoading = true;
const delAddr = this.shippingNote.deliveryAddressLine + ' ' + this.shippingNote.deliveryAddressPLZ + ' ' + this.shippingNote.deliveryAddressCity;
const {data} = await axios.get(window.TT_CONFIG["BASE_PATH"] + '/WarehouseShippingNote/getDistance?from=Xinon%20GmbH&to=' + delAddr);
this.$refs.hoursManager.updateField('kilometerCount', data.distance);
async updateKilometer(carIdParam) {
if (!this.$refs.hoursManager) {
this.hoursLoading = false;
return;
}
// Get current form data from the hours manager
const currentFormData = this.$refs.hoursManager.formData || {};
// Use passed carId, or fall back to current form state
const carId = carIdParam !== undefined ? carIdParam : currentFormData.carId;
const externalCar = currentFormData.externalCar;
// Check if we have a car selected OR external car is checked
const hasVehicle = (carId && carId !== '0' && carId !== 0) || externalCar;
if (!hasVehicle) {
this.$refs.hoursManager.updateField('kilometerCount', null);
this.hoursLoading = false;
return;
}
// Check if delivery address is complete enough for distance calculation
const deliveryAddr = this.shippingNote.deliveryAddressLine?.trim();
const deliveryPLZ = this.shippingNote.deliveryAddressPLZ?.trim();
const deliveryCity = this.shippingNote.deliveryAddressCity?.trim();
if (!deliveryAddr || !deliveryPLZ || !deliveryCity) {
this.hoursLoading = false;
return; // Don't calculate without complete address
}
this.hoursLoading = true;
try {
const delAddr = `${deliveryAddr} ${deliveryPLZ} ${deliveryCity}`;
const {data} = await axios.get(window.TT_CONFIG["BASE_PATH"] + '/WarehouseShippingNote/getDistance', {
params: {
from: 'Xinon GmbH',
to: delAddr
}
});
if (data.success === false) {
window.notify('error', data.message || 'Fehler bei der Kilometerberechnung');
} else if (data.distance !== undefined) {
this.$refs.hoursManager.updateField('kilometerCount', data.distance);
}
} catch (error) {
console.error('Error calculating distance:', error);
window.notify('error', 'Fehler bei der Kilometerberechnung');
} finally {
this.hoursLoading = false;
}
}
}
})

View File

@@ -0,0 +1,224 @@
/* Stocktake Progress Fullscreen Modal */
.stocktake-progress-fullscreen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1050;
background: #f5f6f8;
display: flex;
flex-direction: column;
}
/* Header - dark background with white text */
.stocktake-progress-header {
background: #343a40;
color: #ffffff !important;
padding: 0.75rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 2px solid #007bff;
}
.stocktake-progress-header * {
color: #ffffff !important;
}
.stocktake-progress-header h4 {
font-size: 1.1rem;
font-weight: 600;
color: #ffffff !important;
margin: 0;
}
.stocktake-progress-header .badge {
font-size: 0.85rem;
padding: 0.35rem 0.6rem;
margin-left: 0.75rem;
background: #ffffff !important;
color: #343a40 !important;
}
.stocktake-progress-header .btn-outline-light {
border-color: rgba(255,255,255,0.5);
color: #ffffff !important;
}
.stocktake-progress-header .btn-outline-light:hover {
background: rgba(255,255,255,0.1);
}
/* Body */
.stocktake-progress-body {
flex: 1;
padding: 1rem 1.5rem;
overflow-y: auto;
}
/* Stat cards - clean white cards with colored left border */
.stocktake-progress-fullscreen .stat-card {
background: #ffffff !important;
border: 1px solid #e0e0e0;
border-radius: 6px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
border-left: 4px solid;
}
.stocktake-progress-fullscreen .stat-card .card-body {
padding: 0.75rem 1rem;
}
.stocktake-progress-fullscreen .stat-card h6 {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.2rem !important;
color: #6c757d !important;
opacity: 1 !important;
}
.stocktake-progress-fullscreen .stat-card h4 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0 !important;
color: #212529 !important;
}
.stocktake-progress-fullscreen .stat-card i.fa-2x {
font-size: 1.25rem;
opacity: 0.4;
}
/* Card border colors */
.stocktake-progress-fullscreen .stat-card.card-primary {
border-left-color: #007bff;
}
.stocktake-progress-fullscreen .stat-card.card-primary i {
color: #007bff;
}
.stocktake-progress-fullscreen .stat-card.card-success {
border-left-color: #28a745;
}
.stocktake-progress-fullscreen .stat-card.card-success i {
color: #28a745;
}
.stocktake-progress-fullscreen .stat-card.card-info {
border-left-color: #17a2b8;
}
.stocktake-progress-fullscreen .stat-card.card-info i {
color: #17a2b8;
}
.stocktake-progress-fullscreen .stat-card.card-warning {
border-left-color: #ffc107;
}
.stocktake-progress-fullscreen .stat-card.card-warning i {
color: #ffc107;
}
.stocktake-progress-fullscreen .stat-card.card-secondary {
border-left-color: #6c757d;
}
.stocktake-progress-fullscreen .stat-card.card-secondary i {
color: #6c757d;
}
.stocktake-progress-fullscreen .stat-card.card-danger {
border-left-color: #dc3545;
}
.stocktake-progress-fullscreen .stat-card.card-danger i {
color: #dc3545;
}
/* Main content card */
.stocktake-progress-fullscreen .card {
border: 1px solid #e0e0e0;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
border-radius: 6px;
}
.stocktake-progress-fullscreen .card-header {
padding: 0.625rem 1rem;
border-bottom: 1px solid #e9ecef;
background: #ffffff;
}
.stocktake-progress-fullscreen .card-header h5 {
font-size: 0.9rem;
font-weight: 600;
color: #212529;
}
.stocktake-progress-fullscreen .card-body {
padding: 0;
}
/* Table styling */
.stocktake-progress-fullscreen .table {
margin-bottom: 0;
font-size: 0.85rem;
}
.stocktake-progress-fullscreen .table th {
font-weight: 600;
color: #495057;
border-bottom: 2px solid #dee2e6;
padding: 0.5rem 0.75rem;
white-space: nowrap;
background: #f8f9fa;
}
.stocktake-progress-fullscreen .table td {
padding: 0.5rem 0.75rem;
vertical-align: middle;
color: #212529;
}
.stocktake-progress-fullscreen .table tbody tr:hover {
background-color: #f8f9fa;
}
.stocktake-progress-fullscreen code {
background: #e9ecef;
padding: 0.15rem 0.35rem;
border-radius: 3px;
font-size: 0.8rem;
color: #007bff;
}
/* Info bar styling */
.stocktake-progress-fullscreen .info-bar {
background: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 0.5rem 1rem;
font-size: 0.85rem;
color: #212529;
}
.stocktake-progress-fullscreen .info-bar .refresh-info {
color: #6c757d;
font-size: 0.8rem;
}
/* Empty state styling */
.stocktake-progress-fullscreen .fa-inbox {
opacity: 0.3;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.stocktake-progress-header {
padding: 0.75rem 1rem;
flex-direction: column;
gap: 0.75rem;
}
.stocktake-progress-body {
padding: 0.75rem;
}
}

View File

@@ -0,0 +1,401 @@
// Stocktake Progress Modal Component - Fullscreen
Vue.component('stocktake-progress-modal', {
props: {
show: { type: Boolean, default: false },
id: { type: Number, default: null }
},
//language=Vue
template: `
<div v-if="show" class="stocktake-progress-fullscreen">
<div class="stocktake-progress-header">
<div class="d-flex align-items-center">
<h4 class="mb-0">
<i class="fas fa-clipboard-check me-2"></i>
Inventur Fortschritt
<span v-if="stocktake" class="badge bg-light text-dark">{{ stocktake.stocktakeNumber }}</span>
</h4>
</div>
<button class="btn btn-sm btn-outline-light" @click="close">
<i class="fas fa-times me-1"></i> Schließen
</button>
</div>
<div class="stocktake-progress-body">
<div v-if="loading" class="text-center py-5">
<div class="spinner-border text-primary" role="status" style="width: 2.5rem; height: 2.5rem;">
<span class="visually-hidden">Laden...</span>
</div>
<p class="mt-2 text-muted mb-0">Lade Inventurdaten...</p>
</div>
<div v-else-if="stocktake" class="h-100 d-flex flex-column">
<!-- Stats Cards -->
<div class="row g-2 mb-3">
<div class="col-md-2">
<div class="card stat-card card-primary h-100">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="card-subtitle small">Inventur</h6>
<h5 class="card-title mb-0">{{ stocktake.title }}</h5>
</div>
<i class="fas fa-clipboard-list fa-lg"></i>
</div>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card stat-card card-success h-100">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="card-subtitle small">Gescannte Artikel</h6>
<h5 class="card-title mb-0">{{ stocktake.totalScannedItems }}</h5>
</div>
<i class="fas fa-barcode fa-lg"></i>
</div>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card stat-card card-info h-100">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="card-subtitle small">Lagerort</h6>
<h5 class="card-title mb-0">{{ stocktake.locationName }}</h5>
</div>
<i class="fas fa-warehouse fa-lg"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card card-warning h-100">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="card-subtitle small">Gesamtwert (Einkauf)</h6>
<h5 class="card-title mb-0">{{ formatCurrency(summary.totalValue) }}</h5>
</div>
<i class="fas fa-euro-sign fa-lg"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card h-100" :class="statusCardClass">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="card-subtitle small">Status</h6>
<h5 class="card-title mb-0">{{ statusText }}</h5>
</div>
<i class="fas fa-info-circle fa-lg"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Started At Info Bar -->
<div v-if="stocktake.startedAt" class="info-bar d-flex align-items-center mb-3">
<i class="fas fa-clock me-2 text-primary"></i>
<span>Gestartet am: <strong>{{ stocktake.startedAt }}</strong></span>
<span class="ms-auto refresh-info">
<i class="fas fa-sync-alt me-2" :class="{ 'fa-spin': refreshing }"></i>
<span v-if="refreshing">Aktualisiere...</span>
<span v-else>Nächste Aktualisierung in {{ countdown }}s</span>
</span>
</div>
<!-- Scanned Items Table -->
<div class="card flex-grow-1">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-list me-2"></i>
Gescannte Artikel
</h5>
<span class="badge bg-secondary">{{ items.length }} Einträge</span>
</div>
<div class="card-body" style="overflow-y: auto; max-height: calc(100vh - 320px);">
<table class="table table-striped table-hover">
<thead class="sticky-top bg-light">
<tr>
<th style="width: 120px;">Artikel-Nr.</th>
<th>Artikel</th>
<th class="text-end" style="width: 100px;">Einzelpreis</th>
<th class="text-end" style="width: 80px;">Menge</th>
<th class="text-end" style="width: 110px;">Gesamtpreis</th>
<th style="width: 80px;">Regal</th>
<th style="width: 80px;">Fach</th>
<th style="width: 140px;">Gescannt am</th>
<th style="width: 130px;">Gescannt von</th>
<th style="width: 80px;">Status</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.id" :class="{ 'table-secondary text-decoration-line-through': item.isOverwritten }">
<td><code class="text-primary">{{ item.articleNumber }}</code></td>
<td>{{ item.articleTitle }}</td>
<td class="text-end">{{ formatCurrency(item.unitPrice) }}</td>
<td class="text-end"><strong class="text-success">{{ item.countedQuantity }}</strong></td>
<td class="text-end"><strong>{{ formatCurrency(item.lineTotal) }}</strong></td>
<td>{{ item.rack || '-' }}</td>
<td>{{ item.shelf || '-' }}</td>
<td class="text-nowrap">{{ item.scannedAt || '-' }}</td>
<td>{{ item.scannedBy || '-' }}</td>
<td>
<span v-if="item.isOverwritten" class="badge bg-warning text-dark">Überschrieben</span>
</td>
</tr>
</tbody>
<tfoot class="table-light">
<tr>
<th colspan="4" class="text-end">Summe:</th>
<th class="text-end text-primary">{{ formatCurrency(summary.totalValue) }}</th>
<th colspan="5"></th>
</tr>
</tfoot>
</table>
<!-- Empty State -->
<div v-if="items.length === 0" class="text-center py-4">
<i class="fas fa-inbox fa-3x text-muted mb-2"></i>
<h6 class="text-muted">Noch keine Artikel gescannt</h6>
<p class="text-muted small mb-0">
Scannen Sie Artikel mit der PWA-App, um sie hier zu sehen.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
`,
data() {
return {
loading: false,
refreshing: false,
stocktake: null,
items: [],
summary: { totalValue: 0, totalQuantity: 0 },
refreshInterval: null,
countdownInterval: null,
countdown: 5,
};
},
computed: {
statusText() {
if (!this.stocktake) return '';
const statusMap = {
'planned': 'Geplant',
'in_progress': 'In Bearbeitung',
'completed': 'Abgeschlossen',
'cancelled': 'Abgebrochen'
};
return statusMap[this.stocktake.status] || this.stocktake.status;
},
statusCardClass() {
if (!this.stocktake) return 'card-secondary';
const classMap = {
'planned': 'card-secondary',
'in_progress': 'card-warning',
'completed': 'card-success',
'cancelled': 'card-danger'
};
return classMap[this.stocktake.status] || 'card-secondary';
}
},
watch: {
show(newVal) {
if (newVal && this.id) {
document.body.style.overflow = 'hidden';
this.loadProgress();
this.startAutoRefresh();
} else {
document.body.style.overflow = '';
this.stopAutoRefresh();
}
},
id(newVal) {
if (this.show && newVal) {
this.loadProgress();
}
}
},
methods: {
close() {
this.$emit('update:show', false);
},
async loadProgress() {
if (!this.id) return;
if (!this.stocktake) {
this.loading = true;
} else {
this.refreshing = true;
}
try {
const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseStocktake/getProgress`, {
params: { id: this.id }
});
if (response.data.success) {
this.stocktake = response.data.stocktake;
this.items = response.data.items;
this.summary = response.data.summary || { totalValue: 0, totalQuantity: 0 };
}
} catch (error) {
console.error('Failed to load progress:', error);
} finally {
this.loading = false;
this.refreshing = false;
this.countdown = 5;
}
},
startAutoRefresh() {
this.stopAutoRefresh();
this.countdown = 5;
// Countdown timer
this.countdownInterval = setInterval(() => {
if (this.countdown > 0) {
this.countdown--;
}
}, 1000);
// Refresh data
this.refreshInterval = setInterval(() => {
if (this.show && this.id) {
this.loadProgress();
}
}, 5000);
},
stopAutoRefresh() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
this.refreshInterval = null;
}
if (this.countdownInterval) {
clearInterval(this.countdownInterval);
this.countdownInterval = null;
}
},
formatCurrency(value) {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value || 0);
}
},
beforeDestroy() {
document.body.style.overflow = '';
this.stopAutoRefresh();
}
});
// Main Stocktake Component
Vue.component('warehouse-stocktake', {
//language=Vue
template: `
<tt-card>
<tt-table-crud
@openHistory="historyModal = true; historyModalId = $event.id"
@startStocktake="startStocktake($event)"
@viewProgress="viewProgress($event)"
@completeStocktake="completeStocktake($event)"
@applyToStock="applyToStock($event)"
@exportReport="exportReport($event)"
>
<template v-slot:actions="{ row, actions }">
<template v-for="action in actions">
<!-- Hide start button if not planned -->
<span v-if="action.key === 'startStocktake' && row.rawStatus !== 'planned'" :key="action.key"></span>
<!-- Hide complete button if not in_progress -->
<span v-else-if="action.key === 'completeStocktake' && row.rawStatus !== 'in_progress'" :key="action.key"></span>
<!-- Hide apply button if not completed -->
<span v-else-if="action.key === 'applyToStock' && row.rawStatus !== 'completed'" :key="action.key"></span>
<!-- Show other actions normally -->
<button v-else
:key="action.key"
class="btn btn-sm btn-link p-1"
:title="action.title"
@click="$emit(action.key, row)">
<i :class="action.class"></i>
</button>
</template>
</template>
</tt-table-crud>
<warehouse-history-modal :show.sync="historyModal" :id="historyModalId"/>
<stocktake-progress-modal :show.sync="progressModal" :id="progressModalId"/>
</tt-card>
`,
data() {
return {
historyModal: false,
historyModalId: null,
progressModal: false,
progressModalId: null,
};
},
methods: {
async startStocktake(event) {
const row = event;
if (row.rawStatus !== 'planned') {
window.notify('warning', 'Inventur kann nur im Status "Geplant" gestartet werden');
return;
}
if (!confirm('Möchten Sie diese Inventur wirklich starten?')) {
return;
}
try {
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WarehouseStocktake/startStocktake`, {
id: row.id
});
if (response.data.success) {
window.notify('success', response.data.message);
window.dispatchEvent(new Event('refreshTable'));
} else {
window.notify('error', response.data.message);
}
} catch (error) {
window.notify('error', 'Fehler beim Starten der Inventur');
}
},
viewProgress(event) {
this.progressModalId = event.id;
this.progressModal = true;
},
async completeStocktake(event) {
const row = event;
if (!confirm('Möchten Sie diese Inventur wirklich abschließen?')) {
return;
}
try {
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WarehouseStocktake/completeStocktake`, {
id: row.id
});
if (response.data.success) {
window.notify('success', response.data.message);
window.dispatchEvent(new Event('refreshTable'));
} else {
window.notify('error', response.data.message);
}
} catch (error) {
window.notify('error', 'Fehler beim Abschließen der Inventur');
}
},
applyToStock(event) {
window.notify('warning', 'Aktuell noch nicht möglich');
},
exportReport(event) {
window.open(`${window.TT_CONFIG.BASE_PATH}/WarehouseStocktake/exportReport?id=${event.id}`, '_blank');
}
}
});

581
public/mobile/app.js Normal file
View File

@@ -0,0 +1,581 @@
/**
* MobileApp PWA - Main Vue Application
*
* Unified mobile app with module navigation.
* Routes: /MobileApp -> Home, /MobileApp/Lager -> Module, /MobileApp/Lager/Inventur -> Submodule
*/
import { authState, checkAuth, login, logout } from '/mobile/shared/auth.js';
import LoginScreen from '/mobile/components/LoginScreen.js';
import MainMenu from '/mobile/components/MainMenu.js';
import LagerModule from '/mobile/modules/lager/LagerModule.js';
const { createApp, ref, computed, onMounted, onUnmounted, watch } = Vue;
// Check if running as installed PWA
const isPWAInstalled = () => {
// Check display-mode standalone (Android Chrome, desktop)
if (window.matchMedia('(display-mode: standalone)').matches) return true;
// Check iOS Safari standalone mode
if (window.navigator.standalone === true) return true;
// Check if launched from TWA (Trusted Web Activity)
if (document.referrer.includes('android-app://')) return true;
return false;
};
// Check if we should require PWA installation
const shouldRequirePWA = () => {
const hostname = window.location.hostname;
// Only require PWA on production domain
return hostname === 'thetool.xinon.at';
};
// Parse initial path from config
const parseInitialRoute = () => {
const initialPath = window.TT_CONFIG?.INITIAL_PATH || '/MobileApp';
const parts = initialPath.replace('/MobileApp', '').split('/').filter(Boolean);
return {
module: parts[0] || null,
submodule: parts[1] || null
};
};
const App = {
components: {
LoginScreen,
MainMenu,
LagerModule
},
setup() {
// ==================== STATE ====================
const currentView = ref('loading');
const user = ref(null);
const toast = ref({ show: false, message: '', type: 'success' });
const theme = ref('system');
const showSettings = ref(false);
// Module-specific settings
const lagerSimpleMode = ref(false);
// Navigation state
const currentModule = ref(null);
const currentSubmodule = ref(null);
// PWA Install state
const showInstallPrompt = ref(false);
const deferredInstallPrompt = ref(null);
const isIOS = ref(/iPad|iPhone|iPod/.test(navigator.userAgent));
const isAndroid = ref(/Android/.test(navigator.userAgent));
// Can go back?
const canGoBack = computed(() => currentModule.value !== null);
// ==================== THEME ====================
const applyTheme = () => {
const isDark = localStorage.theme === 'dark' ||
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.toggle('dark', isDark);
const metaThemeColor = document.querySelector('meta[name="theme-color"]');
if (metaThemeColor) {
metaThemeColor.setAttribute('content', isDark ? '#0f172a' : '#005384');
}
};
const setTheme = (newTheme) => {
theme.value = newTheme;
if (newTheme === 'system') {
localStorage.removeItem('theme');
} else {
localStorage.setItem('theme', newTheme);
}
applyTheme();
};
// ==================== PWA INSTALL ====================
const handleInstallPrompt = (e) => {
// Prevent Chrome's default install prompt
e.preventDefault();
// Store the event for later use
deferredInstallPrompt.value = e;
};
const triggerInstall = async () => {
if (!deferredInstallPrompt.value) return;
// Show the install prompt
deferredInstallPrompt.value.prompt();
// Wait for user response
const { outcome } = await deferredInstallPrompt.value.userChoice;
if (outcome === 'accepted') {
showInstallPrompt.value = false;
// Reload to get standalone mode
window.location.reload();
}
deferredInstallPrompt.value = null;
};
// ==================== LAGER SETTINGS ====================
const loadLagerSettings = () => {
try {
const saved = localStorage.getItem('movement_settings');
if (saved) {
const settings = JSON.parse(saved);
lagerSimpleMode.value = settings.simpleMode || false;
}
} catch (e) {}
};
const setLagerSimpleMode = (value) => {
lagerSimpleMode.value = value;
try {
const saved = localStorage.getItem('movement_settings');
const settings = saved ? JSON.parse(saved) : {};
settings.simpleMode = value;
localStorage.setItem('movement_settings', JSON.stringify(settings));
} catch (e) {}
};
// ==================== NAVIGATION ====================
const navigate = (module, submodule = null) => {
currentModule.value = module;
currentSubmodule.value = submodule;
// Update browser URL
let path = '/MobileApp';
if (module) path += '/' + module;
if (submodule) path += '/' + submodule;
history.pushState({ module, submodule }, '', path);
};
const goHome = () => {
navigate(null, null);
};
const goBack = () => {
if (currentSubmodule.value) {
navigate(currentModule.value, null);
} else if (currentModule.value) {
navigate(null, null);
}
};
// Handle browser back button
window.addEventListener('popstate', (event) => {
if (event.state) {
currentModule.value = event.state.module;
currentSubmodule.value = event.state.submodule;
} else {
currentModule.value = null;
currentSubmodule.value = null;
}
});
// ==================== AUTH ====================
const handleLogin = async (credentials) => {
// Handle 2FA success (already verified in LoginScreen)
if (credentials._2faSuccess) {
user.value = credentials.user;
currentView.value = 'app';
showToast('Erfolgreich angemeldet', 'success');
return { success: true };
}
const result = await login(credentials);
if (result.success) {
user.value = result.user;
currentView.value = 'app';
showToast('Erfolgreich angemeldet', 'success');
}
return result;
};
const handleLogout = async () => {
await logout();
user.value = null;
currentModule.value = null;
currentSubmodule.value = null;
currentView.value = 'login';
showToast('Abgemeldet', 'success');
};
// ==================== TOAST ====================
const showToast = (message, type = 'success') => {
toast.value = { show: true, message, type };
setTimeout(() => {
toast.value.show = false;
}, 3000);
};
// ==================== COMPUTED ====================
const currentComponent = computed(() => {
if (currentView.value !== 'app') return null;
if (!currentModule.value) return 'MainMenu';
if (currentModule.value.toLowerCase() === 'lager') return 'LagerModule';
return 'MainMenu';
});
const breadcrumbs = computed(() => {
const crumbs = [{ label: 'Home', module: null, submodule: null }];
if (currentModule.value) {
crumbs.push({ label: currentModule.value, module: currentModule.value, submodule: null });
}
if (currentSubmodule.value) {
crumbs.push({ label: currentSubmodule.value, module: currentModule.value, submodule: currentSubmodule.value });
}
return crumbs;
});
// ==================== LIFECYCLE ====================
onMounted(async () => {
// Initialize theme
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
theme.value = savedTheme;
}
applyTheme();
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyTheme);
// Load module settings
loadLagerSettings();
// Listen for beforeinstallprompt (Android)
window.addEventListener('beforeinstallprompt', handleInstallPrompt);
// Check if PWA is required but not installed
if (shouldRequirePWA() && !isPWAInstalled()) {
showInstallPrompt.value = true;
currentView.value = 'install';
return;
}
// Check authentication
const result = await checkAuth();
if (result.authenticated) {
user.value = result.user;
currentView.value = 'app';
// Parse initial route
const initialRoute = parseInitialRoute();
currentModule.value = initialRoute.module;
currentSubmodule.value = initialRoute.submodule;
// Set initial history state
history.replaceState(
{ module: initialRoute.module, submodule: initialRoute.submodule },
'',
window.location.pathname
);
} else {
currentView.value = 'login';
}
});
return {
currentView,
user,
toast,
theme,
showSettings,
currentModule,
currentSubmodule,
currentComponent,
canGoBack,
breadcrumbs,
handleLogin,
handleLogout,
navigate,
goHome,
goBack,
showToast,
setTheme,
lagerSimpleMode,
setLagerSimpleMode,
// PWA Install
showInstallPrompt,
deferredInstallPrompt,
isIOS,
isAndroid,
triggerInstall,
};
},
template: `
<div class="relative h-full w-full bg-slate-50 dark:bg-slate-900 transition-colors duration-300">
<!-- Loading State -->
<div v-if="currentView === 'loading'" class="flex items-center justify-center h-full">
<div class="text-center">
<img src="/assets/images/xinon-full-transparent.png" class="h-12 mx-auto mb-4 dark:hidden">
<img src="/assets/images/xinon-full-transparent-white.png" class="h-12 mx-auto mb-4 hidden dark:block">
<div class="animate-pulse text-slate-500 dark:text-slate-400">Lädt...</div>
</div>
</div>
<!-- PWA Install Prompt -->
<div v-else-if="currentView === 'install'" class="min-h-screen flex items-center justify-center p-4 relative overflow-hidden">
<!-- Network Background (same as login) -->
<div class="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
<div class="absolute inset-0 opacity-40" style="background-image: linear-gradient(to right, rgba(0, 83, 132, 0.15) 1px, transparent 1px), linear-gradient(to bottom, rgba(0, 83, 132, 0.15) 1px, transparent 1px); background-size: 50px 50px;"></div>
<div class="absolute w-3 h-3 bg-cyan-400 rounded-full network-node" style="top: 12%; left: 18%; box-shadow: 0 0 25px 8px rgba(34, 211, 238, 0.6);"></div>
<div class="absolute w-2.5 h-2.5 bg-blue-400 rounded-full network-node" style="top: 22%; left: 78%; animation-delay: 0.5s; box-shadow: 0 0 20px 6px rgba(96, 165, 250, 0.6);"></div>
<div class="absolute w-3 h-3 bg-cyan-300 rounded-full network-node-slow" style="top: 72%; left: 12%; animation-delay: 1s; box-shadow: 0 0 25px 8px rgba(103, 232, 249, 0.6);"></div>
<div class="absolute w-2.5 h-2.5 bg-blue-300 rounded-full network-node" style="top: 82%; left: 85%; animation-delay: 0.3s; box-shadow: 0 0 20px 6px rgba(147, 197, 253, 0.6);"></div>
</div>
<!-- Install Card -->
<div class="relative bg-white/95 dark:bg-slate-800/95 backdrop-blur-sm rounded-2xl shadow-2xl p-6 w-full max-w-sm border border-white/20 dark:border-slate-700/50">
<div class="mb-6">
<img src="/assets/images/xinon-full-transparent.png" class="h-14 mx-auto dark:hidden" alt="Logo">
<img src="/assets/images/xinon-full-transparent-white.png" class="h-14 mx-auto hidden dark:block" alt="Logo">
</div>
<div class="text-center mb-6">
<div class="w-16 h-16 bg-primary/10 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
<h2 class="text-xl font-bold text-slate-800 dark:text-white mb-2">App installieren</h2>
<p class="text-sm text-slate-500 dark:text-slate-400">
Für die beste Erfahrung installiere die App auf deinem Gerät.
</p>
</div>
<!-- Android Install Button -->
<div v-if="isAndroid && deferredInstallPrompt">
<button
@click="triggerInstall"
class="w-full py-3 px-4 bg-primary text-white font-bold rounded-xl hover:bg-primary/90 focus:ring-4 focus:ring-primary/30 transition flex items-center justify-center gap-2"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
App installieren
</button>
</div>
<!-- iOS Instructions -->
<div v-else-if="isIOS" class="space-y-4">
<div class="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-4">
<p class="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">So installierst du die App:</p>
<ol class="text-sm text-slate-600 dark:text-slate-400 space-y-3">
<li class="flex items-start gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 text-primary rounded-full flex items-center justify-center text-xs font-bold">1</span>
<span>Tippe auf das <strong>Teilen</strong>-Symbol
<svg xmlns="http://www.w3.org/2000/svg" class="inline h-4 w-4 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
</span>
</li>
<li class="flex items-start gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 text-primary rounded-full flex items-center justify-center text-xs font-bold">2</span>
<span>Scrolle und wähle <strong>"Zum Home-Bildschirm"</strong></span>
</li>
<li class="flex items-start gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 text-primary rounded-full flex items-center justify-center text-xs font-bold">3</span>
<span>Tippe auf <strong>"Hinzufügen"</strong></span>
</li>
</ol>
</div>
</div>
<!-- Android Manual Instructions (fallback) -->
<div v-else-if="isAndroid" class="space-y-4">
<div class="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-4">
<p class="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">So installierst du die App:</p>
<ol class="text-sm text-slate-600 dark:text-slate-400 space-y-3">
<li class="flex items-start gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 text-primary rounded-full flex items-center justify-center text-xs font-bold">1</span>
<span>Tippe auf das <strong>Menü</strong> (⋮) oben rechts</span>
</li>
<li class="flex items-start gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 text-primary rounded-full flex items-center justify-center text-xs font-bold">2</span>
<span>Wähle <strong>"App installieren"</strong> oder <strong>"Zum Startbildschirm hinzufügen"</strong></span>
</li>
<li class="flex items-start gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 text-primary rounded-full flex items-center justify-center text-xs font-bold">3</span>
<span>Bestätige mit <strong>"Installieren"</strong></span>
</li>
</ol>
</div>
</div>
<!-- Desktop / Unknown -->
<div v-else class="space-y-4">
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-4">
<p class="text-sm text-amber-700 dark:text-amber-400">
<strong>Hinweis:</strong> Diese App ist für mobile Geräte optimiert. Bitte öffne diese Seite auf deinem Smartphone und installiere die App.
</p>
</div>
</div>
<div class="mt-4 pt-4 border-t border-slate-100 dark:border-slate-700 text-center">
<p class="text-xs text-slate-400 dark:text-slate-500">
powered by <span class="font-semibold">XINON</span>
</p>
</div>
</div>
</div>
<!-- Login Screen -->
<LoginScreen
v-else-if="currentView === 'login'"
@login="handleLogin"
:theme="theme"
@set-theme="setTheme"
/>
<!-- Main App -->
<template v-else-if="currentView === 'app'">
<div class="h-full flex flex-col">
<!-- Persistent Header -->
<header class="bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 px-2 py-2 flex items-center safe-area-top flex-shrink-0 z-10">
<!-- Left: Back Button -->
<button
@click="goBack"
:class="[
'w-10 h-10 flex items-center justify-center rounded-full transition',
canGoBack
? 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700'
: 'text-transparent pointer-events-none'
]"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<!-- Center: Logo -->
<div class="flex-1 flex justify-center">
<img src="/assets/images/xinon-full-transparent.png" class="h-7 dark:hidden" alt="Logo">
<img src="/assets/images/xinon-full-transparent-white.png" class="h-7 hidden dark:block" alt="Logo">
</div>
<!-- Right: Settings -->
<button
@click="showSettings = true"
class="w-10 h-10 flex items-center justify-center rounded-full text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 transition"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</header>
<!-- Content Area -->
<main class="flex-1 overflow-y-auto">
<MainMenu
v-if="!currentModule"
:user="user"
@navigate="navigate"
/>
<LagerModule
v-else-if="currentModule?.toLowerCase() === 'lager'"
:user="user"
:submodule="currentSubmodule"
:simple-mode="lagerSimpleMode"
@navigate="navigate"
@toast="showToast"
/>
</main>
</div>
<!-- Settings Panel -->
<transition name="slide-right">
<div v-if="showSettings" class="fixed inset-0 z-50">
<div class="absolute inset-0 bg-black/40" @click="showSettings = false"></div>
<div class="absolute right-0 top-0 bottom-0 w-72 bg-white dark:bg-slate-800 shadow-xl flex flex-col">
<div class="safe-area-top border-b border-slate-200 dark:border-slate-700 px-4 py-3 flex items-center justify-between">
<h2 class="font-semibold text-slate-800 dark:text-white">Einstellungen</h2>
<button @click="showSettings = false" class="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-full text-slate-500">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex-1 overflow-y-auto">
<!-- User Info -->
<div class="px-4 py-3 border-b border-slate-100 dark:border-slate-700">
<p class="font-medium text-slate-800 dark:text-white">{{ user?.name }}</p>
<p class="text-sm text-slate-500 dark:text-slate-400">{{ user?.username }}</p>
</div>
<!-- Theme Selection -->
<div class="px-4 py-3">
<p class="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-2">Farbschema</p>
<div class="flex space-x-2">
<button
@click="setTheme('light')"
:class="[theme === 'light' ? 'ring-2 ring-primary bg-primary/5' : 'bg-slate-100 dark:bg-slate-700', 'flex-1 py-2 rounded-lg text-sm font-medium text-slate-700 dark:text-slate-300']"
>Hell</button>
<button
@click="setTheme('dark')"
:class="[theme === 'dark' ? 'ring-2 ring-primary bg-primary/5' : 'bg-slate-100 dark:bg-slate-700', 'flex-1 py-2 rounded-lg text-sm font-medium text-slate-700 dark:text-slate-300']"
>Dunkel</button>
<button
@click="setTheme('system')"
:class="[theme === 'system' ? 'ring-2 ring-primary bg-primary/5' : 'bg-slate-100 dark:bg-slate-700', 'flex-1 py-2 rounded-lg text-sm font-medium text-slate-700 dark:text-slate-300']"
>Auto</button>
</div>
</div>
<!-- Lager Settings -->
<div class="px-4 py-3 border-t border-slate-100 dark:border-slate-700">
<p class="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-3">Lager</p>
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-slate-800 dark:text-white text-sm">Simpel Modus</p>
<p class="text-xs text-slate-500 dark:text-slate-400">Weniger Optionen</p>
</div>
<button
@click="setLagerSimpleMode(!lagerSimpleMode)"
:class="[
'relative w-11 h-6 rounded-full transition-colors',
lagerSimpleMode ? 'bg-green-500' : 'bg-slate-300 dark:bg-slate-600'
]"
>
<span :class="[
'absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform',
lagerSimpleMode ? 'left-5' : 'left-0.5'
]"></span>
</button>
</div>
</div>
</div>
<!-- Logout at bottom -->
<div class="p-3 border-t border-slate-100 dark:border-slate-700">
<button
@click="showSettings = false; handleLogout()"
class="w-full py-2.5 px-4 text-red-600 dark:text-red-400 font-medium rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition flex items-center justify-center"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Abmelden
</button>
</div>
</div>
</div>
</transition>
</template>
<!-- Toast Notifications -->
<transition name="slide-up">
<div v-if="toast.show" class="toast-container">
<div :class="['toast', 'toast-' + toast.type]">
{{ toast.message }}
</div>
</div>
</transition>
</div>
`
};
createApp(App).mount('#app');

View File

@@ -0,0 +1,639 @@
/**
* LoginScreen Component
*
* Displays the login form for the PWA with 2FA support.
* Features:
* - Username/password authentication
* - 2FA verification with OTP auto-detection (Web OTP API for Android, autocomplete for iOS)
* - Remember me option
*/
import { login, verify2FA, resend2FA } from '/mobile/shared/auth.js';
export default {
name: 'LoginScreen',
emits: ['login', 'set-theme'],
props: {
theme: {
type: String,
default: 'system'
}
},
setup(props, { emit }) {
const { ref, onMounted, onUnmounted, nextTick } = Vue;
// Login form state
const username = ref('');
const password = ref('');
const rememberMe = ref(true);
const showPassword = ref(false);
// 2FA state
const show2FA = ref(false);
const otpCode = ref('');
const otpDigits = ref(['', '', '', '', '']);
const deliveryMethod = ref('');
const maskedTarget = ref('');
const resendCooldown = ref(0);
// General state
const error = ref('');
const success = ref('');
const loading = ref(false);
const showThemePicker = ref(!localStorage.getItem('theme'));
// OTP input refs
let otpInputRefs = [];
let otpAbortController = null;
let resendTimer = null;
// Handle login form submission
const handleSubmit = async () => {
if (!username.value || !password.value) {
error.value = 'Bitte Benutzername und Passwort eingeben';
return;
}
loading.value = true;
error.value = '';
try {
// Call login API directly
const result = await login({
username: username.value,
password: password.value,
rememberMe: rememberMe.value
});
if (result.requires2FA) {
// Show 2FA verification screen
show2FA.value = true;
deliveryMethod.value = result.deliveryMethod;
maskedTarget.value = result.maskedTarget;
success.value = result.message;
error.value = '';
// Start resend cooldown
startResendCooldown();
// Focus first OTP input after render
await nextTick();
focusOtpInput(0);
// Try Web OTP API for SMS
if (result.deliveryMethod === 'sms') {
startWebOTP();
}
} else if (result.success) {
// Direct login success (no 2FA) - notify parent
emit('login', { _2faSuccess: true, user: result.user });
} else {
error.value = result.message || 'Login fehlgeschlagen';
}
} catch (e) {
error.value = 'Ein Fehler ist aufgetreten';
} finally {
loading.value = false;
}
};
// Handle 2FA verification
const handleVerify2FA = async () => {
const code = otpDigits.value.join('');
if (code.length !== 5) {
error.value = 'Bitte gib den 5-stelligen Code ein';
return;
}
loading.value = true;
error.value = '';
success.value = '';
try {
const result = await verify2FA(code);
if (result.success) {
// Emit the successful result to parent (which handles navigation)
emit('login', { _2faSuccess: true, user: result.user });
} else {
error.value = result.message || 'Ungültiger Code';
if (result.expired || result.codeExpired) {
// Session or code expired - go back to login
resetTo2FA();
}
}
} catch (e) {
error.value = 'Ein Fehler ist aufgetreten';
} finally {
loading.value = false;
}
};
// Handle resend 2FA code
const handleResend = async () => {
if (resendCooldown.value > 0) return;
loading.value = true;
error.value = '';
try {
const result = await resend2FA();
if (result.success) {
success.value = result.message || 'Neuer Code wurde gesendet';
startResendCooldown();
// Clear OTP inputs
otpDigits.value = ['', '', '', '', ''];
focusOtpInput(0);
// Restart Web OTP if SMS
if (deliveryMethod.value === 'sms') {
startWebOTP();
}
} else {
error.value = result.message || 'Code konnte nicht gesendet werden';
if (result.expired) {
resetTo2FA();
}
}
} catch (e) {
error.value = 'Ein Fehler ist aufgetreten';
} finally {
loading.value = false;
}
};
// Go back to login form
const backToLogin = () => {
show2FA.value = false;
otpDigits.value = ['', '', '', '', ''];
error.value = '';
success.value = '';
abortWebOTP();
};
// Reset after session expired
const resetTo2FA = () => {
show2FA.value = false;
password.value = '';
otpDigits.value = ['', '', '', '', ''];
error.value = 'Sitzung abgelaufen. Bitte erneut anmelden.';
};
// Start resend cooldown (30 seconds)
const startResendCooldown = () => {
resendCooldown.value = 30;
if (resendTimer) clearInterval(resendTimer);
resendTimer = setInterval(() => {
resendCooldown.value--;
if (resendCooldown.value <= 0) {
clearInterval(resendTimer);
}
}, 1000);
};
// OTP input handlers
const focusOtpInput = (index) => {
const inputs = document.querySelectorAll('.otp-input');
if (inputs[index]) {
inputs[index].focus();
}
};
const handleOtpInput = (index, event) => {
const value = event.target.value;
// Only allow digits
if (!/^\d*$/.test(value)) {
event.target.value = otpDigits.value[index];
return;
}
// Handle paste of full code
if (value.length > 1) {
const digits = value.replace(/\D/g, '').slice(0, 5).split('');
digits.forEach((digit, i) => {
if (i < 5) otpDigits.value[i] = digit;
});
focusOtpInput(Math.min(digits.length, 4));
// Auto-submit if complete
if (otpDigits.value.join('').length === 5) {
handleVerify2FA();
}
return;
}
otpDigits.value[index] = value;
// Move to next input
if (value && index < 4) {
focusOtpInput(index + 1);
}
// Auto-submit when complete
if (otpDigits.value.join('').length === 5) {
handleVerify2FA();
}
};
const handleOtpKeydown = (index, event) => {
// Handle backspace
if (event.key === 'Backspace' && !otpDigits.value[index] && index > 0) {
focusOtpInput(index - 1);
}
};
const handleOtpPaste = (event) => {
event.preventDefault();
const pastedData = event.clipboardData.getData('text');
const digits = pastedData.replace(/\D/g, '').slice(0, 5).split('');
digits.forEach((digit, i) => {
if (i < 5) otpDigits.value[i] = digit;
});
focusOtpInput(Math.min(digits.length, 4));
// Auto-submit if complete
if (otpDigits.value.join('').length === 5) {
handleVerify2FA();
}
};
// Web OTP API for automatic SMS code detection (Android)
const startWebOTP = async () => {
if (!('OTPCredential' in window)) {
console.log('Web OTP API not supported');
return;
}
abortWebOTP();
otpAbortController = new AbortController();
try {
const otp = await navigator.credentials.get({
otp: { transport: ['sms'] },
signal: otpAbortController.signal
});
if (otp && otp.code) {
// Extract 5-digit code from SMS
const code = otp.code.replace(/\D/g, '').slice(0, 5);
if (code.length === 5) {
code.split('').forEach((digit, i) => {
otpDigits.value[i] = digit;
});
// Auto-submit
handleVerify2FA();
}
}
} catch (err) {
if (err.name !== 'AbortError') {
console.log('Web OTP error:', err);
}
}
};
const abortWebOTP = () => {
if (otpAbortController) {
otpAbortController.abort();
otpAbortController = null;
}
};
// Theme picker
const selectTheme = (newTheme) => {
emit('set-theme', newTheme);
showThemePicker.value = false;
};
// Cleanup
onUnmounted(() => {
abortWebOTP();
if (resendTimer) clearInterval(resendTimer);
});
return {
// Login state
username,
password,
rememberMe,
showPassword,
// 2FA state
show2FA,
otpDigits,
deliveryMethod,
maskedTarget,
resendCooldown,
// General state
error,
success,
loading,
showThemePicker,
// Methods
handleSubmit,
handleVerify2FA,
handleResend,
backToLogin,
handleOtpInput,
handleOtpKeydown,
handleOtpPaste,
selectTheme
};
},
template: `
<div class="min-h-screen flex items-center justify-center p-4 relative overflow-hidden">
<!-- Animated Network Background -->
<div class="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
<!-- Fiber grid pattern -->
<div class="absolute inset-0 opacity-40" style="background-image:
linear-gradient(to right, rgba(0, 83, 132, 0.15) 1px, transparent 1px),
linear-gradient(to bottom, rgba(0, 83, 132, 0.15) 1px, transparent 1px);
background-size: 50px 50px;"></div>
<!-- Glowing nodes with enhanced animation -->
<div class="absolute w-3 h-3 bg-cyan-400 rounded-full network-node" style="top: 12%; left: 18%; box-shadow: 0 0 25px 8px rgba(34, 211, 238, 0.6);"></div>
<div class="absolute w-2.5 h-2.5 bg-blue-400 rounded-full network-node" style="top: 22%; left: 78%; animation-delay: 0.5s; box-shadow: 0 0 20px 6px rgba(96, 165, 250, 0.6);"></div>
<div class="absolute w-3 h-3 bg-cyan-300 rounded-full network-node-slow" style="top: 72%; left: 12%; animation-delay: 1s; box-shadow: 0 0 25px 8px rgba(103, 232, 249, 0.6);"></div>
<div class="absolute w-2.5 h-2.5 bg-blue-300 rounded-full network-node" style="top: 82%; left: 85%; animation-delay: 0.3s; box-shadow: 0 0 20px 6px rgba(147, 197, 253, 0.6);"></div>
<div class="absolute w-2 h-2 bg-cyan-400 rounded-full network-node-slow" style="top: 42%; left: 8%; animation-delay: 0.7s; box-shadow: 0 0 15px 5px rgba(34, 211, 238, 0.5);"></div>
<div class="absolute w-2.5 h-2.5 bg-blue-400 rounded-full network-node" style="top: 32%; left: 92%; animation-delay: 1.2s; box-shadow: 0 0 20px 6px rgba(96, 165, 250, 0.6);"></div>
<div class="absolute w-2 h-2 bg-cyan-300 rounded-full network-node" style="top: 58%; left: 88%; animation-delay: 0.9s; box-shadow: 0 0 15px 5px rgba(103, 232, 249, 0.5);"></div>
<div class="absolute w-2 h-2 bg-blue-300 rounded-full network-node-slow" style="top: 88%; left: 45%; animation-delay: 0.4s; box-shadow: 0 0 15px 5px rgba(147, 197, 253, 0.5);"></div>
<div class="absolute w-1.5 h-1.5 bg-cyan-400 rounded-full network-node" style="top: 5%; left: 55%; animation-delay: 1.5s; box-shadow: 0 0 12px 4px rgba(34, 211, 238, 0.5);"></div>
<div class="absolute w-1.5 h-1.5 bg-blue-400 rounded-full network-node-slow" style="top: 95%; left: 25%; animation-delay: 0.8s; box-shadow: 0 0 12px 4px rgba(96, 165, 250, 0.5);"></div>
<!-- Connection lines (SVG) with animations -->
<svg class="absolute inset-0 w-full h-full network-lines" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none">
<defs>
<linearGradient id="lineGrad1" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:rgb(34, 211, 238);stop-opacity:0" />
<stop offset="50%" style="stop-color:rgb(34, 211, 238);stop-opacity:1" />
<stop offset="100%" style="stop-color:rgb(34, 211, 238);stop-opacity:0" />
</linearGradient>
<linearGradient id="lineGrad2" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:rgb(96, 165, 250);stop-opacity:0" />
<stop offset="50%" style="stop-color:rgb(96, 165, 250);stop-opacity:1" />
<stop offset="100%" style="stop-color:rgb(96, 165, 250);stop-opacity:0" />
</linearGradient>
<linearGradient id="lineGrad3" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:rgb(103, 232, 249);stop-opacity:0" />
<stop offset="50%" style="stop-color:rgb(103, 232, 249);stop-opacity:1" />
<stop offset="100%" style="stop-color:rgb(103, 232, 249);stop-opacity:0" />
</linearGradient>
</defs>
<!-- Main network connections -->
<line x1="18%" y1="12%" x2="78%" y2="22%" stroke="url(#lineGrad1)" stroke-width="1.5"/>
<line x1="78%" y1="22%" x2="85%" y2="82%" stroke="url(#lineGrad2)" stroke-width="1.5"/>
<line x1="18%" y1="12%" x2="12%" y2="72%" stroke="url(#lineGrad1)" stroke-width="1.5"/>
<line x1="12%" y1="72%" x2="85%" y2="82%" stroke="url(#lineGrad2)" stroke-width="1.5"/>
<line x1="8%" y1="42%" x2="18%" y2="12%" stroke="url(#lineGrad3)" stroke-width="1.5"/>
<line x1="92%" y1="32%" x2="78%" y2="22%" stroke="url(#lineGrad2)" stroke-width="1.5"/>
<!-- Additional connections -->
<line x1="12%" y1="72%" x2="45%" y2="88%" stroke="url(#lineGrad1)" stroke-width="1.5"/>
<line x1="45%" y1="88%" x2="85%" y2="82%" stroke="url(#lineGrad3)" stroke-width="1.5"/>
<line x1="88%" y1="58%" x2="92%" y2="32%" stroke="url(#lineGrad2)" stroke-width="1.5"/>
<line x1="88%" y1="58%" x2="85%" y2="82%" stroke="url(#lineGrad1)" stroke-width="1.5"/>
<line x1="8%" y1="42%" x2="12%" y2="72%" stroke="url(#lineGrad3)" stroke-width="1.5"/>
<line x1="55%" y1="5%" x2="78%" y2="22%" stroke="url(#lineGrad1)" stroke-width="1.5"/>
<line x1="55%" y1="5%" x2="18%" y2="12%" stroke="url(#lineGrad2)" stroke-width="1.5"/>
<line x1="25%" y1="95%" x2="45%" y2="88%" stroke="url(#lineGrad3)" stroke-width="1.5"/>
<line x1="25%" y1="95%" x2="12%" y2="72%" stroke="url(#lineGrad1)" stroke-width="1.5"/>
<!-- Cross connections -->
<line x1="18%" y1="12%" x2="88%" y2="58%" stroke="url(#lineGrad2)" stroke-width="1"/>
<line x1="8%" y1="42%" x2="78%" y2="22%" stroke="url(#lineGrad3)" stroke-width="1"/>
<line x1="12%" y1="72%" x2="92%" y2="32%" stroke="url(#lineGrad1)" stroke-width="1"/>
</svg>
<!-- Flowing data lines overlay -->
<svg class="absolute inset-0 w-full h-full opacity-50" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none">
<line x1="18%" y1="12%" x2="78%" y2="22%" stroke="rgb(34, 211, 238)" stroke-width="2" class="network-line-flow"/>
<line x1="12%" y1="72%" x2="85%" y2="82%" stroke="rgb(96, 165, 250)" stroke-width="2" class="network-line-flow" style="animation-delay: 2s;"/>
<line x1="8%" y1="42%" x2="18%" y2="12%" stroke="rgb(103, 232, 249)" stroke-width="2" class="network-line-flow" style="animation-delay: 4s;"/>
<line x1="55%" y1="5%" x2="78%" y2="22%" stroke="rgb(34, 211, 238)" stroke-width="2" class="network-line-flow" style="animation-delay: 1s;"/>
<line x1="45%" y1="88%" x2="85%" y2="82%" stroke="rgb(96, 165, 250)" stroke-width="2" class="network-line-flow" style="animation-delay: 3s;"/>
</svg>
<!-- Subtle radial glow -->
<div class="absolute inset-0" style="background: radial-gradient(ellipse at 30% 20%, rgba(0, 83, 132, 0.2) 0%, transparent 50%);"></div>
<div class="absolute inset-0" style="background: radial-gradient(ellipse at 70% 80%, rgba(34, 211, 238, 0.15) 0%, transparent 40%);"></div>
<div class="absolute inset-0" style="background: radial-gradient(ellipse at 90% 40%, rgba(96, 165, 250, 0.1) 0%, transparent 35%);"></div>
</div>
<!-- Theme Picker Modal -->
<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-2xl p-6 w-full max-w-xs text-center shadow-2xl border border-slate-200 dark:border-slate-700">
<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-100 text-slate-800 font-semibold rounded-xl hover:bg-slate-200 transition flex items-center justify-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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>
Hell
</button>
<button @click="selectTheme('dark')" class="w-full px-4 py-3 bg-slate-700 text-white font-semibold rounded-xl hover:bg-slate-600 transition flex items-center justify-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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>
Dunkel
</button>
<button @click="selectTheme('system')" class="w-full mt-1 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300 transition">
Automatisch (System)
</button>
</div>
</div>
</div>
</transition>
<!-- Login/2FA Form Container -->
<div class="relative bg-white/95 dark:bg-slate-800/95 backdrop-blur-sm rounded-2xl shadow-2xl p-6 w-full max-w-sm border border-white/20 dark:border-slate-700/50">
<div class="mb-5">
<img src="/assets/images/xinon-full-transparent.png" class="h-14 mx-auto dark:hidden" alt="Logo">
<img src="/assets/images/xinon-full-transparent-white.png" class="h-14 mx-auto hidden dark:block" alt="Logo">
</div>
<!-- 2FA Verification Screen -->
<template v-if="show2FA">
<div class="text-center mb-6">
<div class="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4">
<svg v-if="deliveryMethod === 'sms'" xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<h2 class="text-xl font-bold text-slate-800 dark:text-white">
Verifizierung
</h2>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-2">
Code wurde gesendet an<br>
<span class="font-medium text-slate-700 dark:text-slate-300">{{ maskedTarget }}</span>
</p>
</div>
<!-- OTP Input -->
<div class="mb-6">
<div class="flex justify-center space-x-2">
<input
v-for="(digit, index) in otpDigits"
:key="index"
type="text"
inputmode="numeric"
:autocomplete="index === 0 ? 'one-time-code' : 'off'"
maxlength="5"
class="otp-input w-12 h-14 text-center text-2xl font-bold border-2 border-slate-300 rounded-lg focus:border-primary focus:ring-2 focus:ring-primary/30 transition dark:bg-slate-700 dark:border-slate-600 dark:text-white"
:value="digit"
@input="handleOtpInput(index, $event)"
@keydown="handleOtpKeydown(index, $event)"
@paste="handleOtpPaste"
>
</div>
<p class="text-xs text-slate-400 dark:text-slate-500 text-center mt-3">
Code ist 5 Minuten gültig
</p>
</div>
<!-- Success Message -->
<div v-if="success" class="mb-4 p-3 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg">
<p class="text-sm text-green-600 dark:text-green-400">{{ success }}</p>
</div>
<!-- Error Message -->
<div v-if="error" class="mb-4 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>
<!-- Verify Button -->
<button
@click="handleVerify2FA"
:disabled="loading || otpDigits.join('').length !== 5"
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 verifiziert...' : 'Verifizieren' }}
</button>
<!-- Resend and Back buttons -->
<div class="mt-4 flex flex-col items-center space-y-3">
<button
@click="handleResend"
:disabled="resendCooldown > 0 || loading"
class="text-sm text-primary hover:underline disabled:text-slate-400 disabled:no-underline"
>
{{ resendCooldown > 0 ? 'Neuer Code in ' + resendCooldown + 's' : 'Neuen Code senden' }}
</button>
<button
@click="backToLogin"
class="text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300"
>
Zurück zur Anmeldung
</button>
</div>
</template>
<!-- Login Form -->
<template v-else>
<form @submit.prevent="handleSubmit" class="space-y-4">
<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-xl 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>
<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-xl 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>
<!-- Beautiful Toggle Switch -->
<div class="flex items-center justify-between py-1">
<span class="text-sm text-slate-600 dark:text-slate-300">Angemeldet bleiben</span>
<button
type="button"
@click="rememberMe = !rememberMe"
:class="[
'relative w-11 h-6 rounded-full transition-colors duration-200',
rememberMe ? 'bg-primary' : 'bg-slate-300 dark:bg-slate-600'
]"
>
<span :class="[
'absolute top-0.5 w-5 h-5 bg-white rounded-full shadow-md transition-transform duration-200',
rememberMe ? 'left-5' : 'left-0.5'
]"></span>
</button>
</div>
<div v-if="error" class="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-xl">
<p class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
</div>
<button
type="submit"
:disabled="loading"
class="w-full py-3 px-4 bg-primary text-white font-bold rounded-xl hover:bg-primary/90 focus:ring-4 focus:ring-primary/30 transition 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>
</template>
<div class="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700 text-center">
<p class="text-xs text-slate-400 dark:text-slate-500">
powered by <span class="font-semibold">XINON</span>
</p>
</div>
</div>
</div>
`
};

View File

@@ -0,0 +1,66 @@
/**
* MainMenu Component
*
* Displays the main module menu for the MobileApp.
* Shows available modules like "Lager" that the user can access.
*/
export default {
name: 'MainMenu',
emits: ['navigate'],
props: {
user: Object
},
setup(props, { emit }) {
// Available modules
const modules = [
{
id: 'Lager',
name: 'Lager',
icon: 'warehouse',
color: 'bg-blue-500',
iconColor: 'text-blue-500'
}
// Future modules can be added here
];
const openModule = (moduleId) => {
emit('navigate', moduleId, null);
};
return {
modules,
openModule
};
},
template: `
<div class="p-3">
<!-- Module List -->
<div class="bg-white dark:bg-slate-800 rounded-xl overflow-hidden shadow-sm">
<button
v-for="(module, index) in modules"
:key="module.id"
@click="openModule(module.id)"
:class="[
'w-full flex items-center px-4 py-3.5 hover:bg-slate-50 dark:hover:bg-slate-700/50 active:bg-slate-100 dark:active:bg-slate-700 transition text-left',
index !== modules.length - 1 ? 'border-b border-slate-100 dark:border-slate-700' : ''
]"
>
<div :class="[module.color, 'w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0']">
<svg v-if="module.icon === 'warehouse'" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 14v3m4-3v3m4-3v3M3 21h18M3 10h18M3 7l9-4 9 4M4 10h16v11H4V10z" />
</svg>
</div>
<span class="ml-3 font-medium text-slate-800 dark:text-white flex-1">
{{ module.name }}
</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
`
};

View File

@@ -0,0 +1,26 @@
{
"name": "Xinon Mobile",
"short_name": "Xinon",
"description": "Mobile-optimierte Tools für Xinon",
"start_url": "/MobileApp",
"scope": "/MobileApp",
"display": "standalone",
"orientation": "portrait",
"background_color": "#f1f5f9",
"theme_color": "#005384",
"icons": [
{
"src": "/assets/images/xinon-sm.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/assets/images/xinon-sm.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"categories": ["business", "productivity"]
}

View File

@@ -0,0 +1,166 @@
/**
* Lager Module
*
* Main module for warehouse management.
* Shows submodules: Inventur (stocktake)
*/
import StocktakeList from '/mobile/modules/lager/inventur/StocktakeList.js';
import Scanner from '/mobile/modules/lager/inventur/Scanner.js';
import MovementForm from '/mobile/modules/lager/movement/MovementForm.js';
export default {
name: 'LagerModule',
emits: ['navigate', 'toast'],
props: {
user: Object,
submodule: String,
simpleMode: Boolean
},
components: {
StocktakeList,
Scanner,
MovementForm
},
setup(props, { emit }) {
const { ref, computed, watch } = Vue;
// Submodules available in Lager
const submodules = [
{
id: 'Inventur',
name: 'Inventur',
icon: 'clipboard',
color: 'bg-green-500'
},
{
id: 'Movement',
name: 'Lagerbewegung',
icon: 'arrows',
color: 'bg-blue-500'
}
];
// Scanner state
const selectedStocktake = ref(null);
const showScanner = ref(false);
// Current view based on submodule
const currentView = computed(() => {
if (!props.submodule) return 'menu';
if (props.submodule.toLowerCase() === 'inventur') {
return showScanner.value ? 'scanner' : 'inventur';
}
if (props.submodule.toLowerCase() === 'movement') {
return 'movement';
}
return 'menu';
});
// Watch for submodule changes
watch(() => props.submodule, (newVal) => {
if (!newVal) {
showScanner.value = false;
selectedStocktake.value = null;
}
});
const openSubmodule = (submoduleId) => {
emit('navigate', 'Lager', submoduleId);
};
const goBack = () => {
if (showScanner.value) {
showScanner.value = false;
selectedStocktake.value = null;
}
};
const openScanner = (stocktake) => {
selectedStocktake.value = stocktake;
showScanner.value = true;
};
const closeScanner = () => {
showScanner.value = false;
selectedStocktake.value = null;
};
const showToast = (message, type) => {
emit('toast', message, type);
};
return {
submodules,
selectedStocktake,
showScanner,
currentView,
openSubmodule,
goBack,
openScanner,
closeScanner,
showToast
};
},
template: `
<div class="h-full">
<!-- Submodule Menu -->
<template v-if="currentView === 'menu'">
<div class="p-3">
<div class="bg-white dark:bg-slate-800 rounded-xl overflow-hidden shadow-sm">
<button
v-for="(sub, index) in submodules"
:key="sub.id"
@click="openSubmodule(sub.id)"
:class="[
'w-full flex items-center px-4 py-3.5 hover:bg-slate-50 dark:hover:bg-slate-700/50 active:bg-slate-100 dark:active:bg-slate-700 transition text-left',
index !== submodules.length - 1 ? 'border-b border-slate-100 dark:border-slate-700' : ''
]"
>
<div :class="[sub.color, 'w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0']">
<svg v-if="sub.icon === 'clipboard'" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-white" 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 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
<svg v-else-if="sub.icon === 'arrows'" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
</div>
<span class="ml-3 font-medium text-slate-800 dark:text-white flex-1">
{{ sub.name }}
</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</template>
<!-- Inventur: Stocktake List -->
<StocktakeList
v-else-if="currentView === 'inventur'"
:user="user"
@select="openScanner"
/>
<!-- Inventur: Scanner -->
<Scanner
v-else-if="currentView === 'scanner'"
:stocktake="selectedStocktake"
:user="user"
@close="closeScanner"
@toast="showToast"
/>
<!-- Movement: Movement Form -->
<MovementForm
v-else-if="currentView === 'movement'"
:user="user"
:simple-mode="simpleMode"
@toast="showToast"
/>
</div>
`
};

View File

@@ -0,0 +1,437 @@
/**
* Scanner Component (Inventur)
*
* The main scanning interface for stocktakes.
* Uses the new API path: /MobileApp/Lager/Inventur/{action}
*/
// Inventur-specific API
const inventurApi = {
get: (endpoint) => fetch(`/MobileApp/Lager/Inventur/${endpoint}`).then(r => r.json()),
post: (endpoint, data) => fetch(`/MobileApp/Lager/Inventur/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}).then(r => r.json())
};
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');
const isLoading = ref(false);
// Scanner
const scanner = ref(null);
const isScannerActive = ref(false);
const scannerError = ref('');
// Article
const scannedArticle = ref(null);
const quantity = ref('1');
const rack = ref('');
const shelf = ref('');
// Search
const searchQuery = ref('');
const searchResults = ref([]);
const categories = ref([]);
const selectedCategory = ref(0);
const isSearching = ref(false);
// History
const recentScans = ref([]);
const isLoadingHistory = ref(false);
// Warning
const alreadyScannedWarning = ref(null);
// Keypad
const showKeypad = ref(false);
// Computed
const canSubmit = computed(() => {
return scannedArticle.value && parseFloat(quantity.value) > 0 && !isLoading.value;
});
// Scanner functions
const startScanner = async () => {
scannerError.value = '';
try {
scanner.value = new Html5Qrcode('qr-reader');
await scanner.value.start(
{ facingMode: 'environment' },
{ fps: 10, qrbox: { width: 250, height: 250 }, aspectRatio: 1.0 },
onScanSuccess,
() => {}
);
isScannerActive.value = true;
} catch (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) {}
isScannerActive.value = false;
}
};
const onScanSuccess = async (decodedText) => {
await stopScanner();
await lookupArticle(decodedText);
};
// Article lookup
const lookupArticle = async (code) => {
isLoading.value = true;
alreadyScannedWarning.value = null;
try {
const result = await inventurApi.get(`getArticle?code=${encodeURIComponent(code)}`);
if (result.success) {
scannedArticle.value = result.article;
const checkResult = await inventurApi.get(
`checkAlreadyScanned?stocktakeId=${props.stocktake.id}&articleId=${result.article.id}`
);
if (checkResult.success && checkResult.alreadyScanned) {
alreadyScannedWarning.value = checkResult.existingItem;
}
quantity.value = '1';
} else {
emit('toast', result.message || 'Artikel nicht gefunden', 'error');
await startScanner();
}
} catch (e) {
emit('toast', 'Fehler beim Laden des Artikels', 'error');
await startScanner();
} finally {
isLoading.value = false;
}
};
// Submit
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 inventurApi.post('submitScan', payload);
if (result.success) {
emit('toast', result.message, 'success');
scannedArticle.value = null;
quantity.value = '1';
rack.value = '';
shelf.value = '';
alreadyScannedWarning.value = null;
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 inventurApi.get('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 inventurApi.get(`searchArticles?${params}`);
if (result.success) searchResults.value = result.articles;
} catch (e) {} finally {
isSearching.value = false;
}
};
const selectSearchResult = async (article) => {
await stopScanner();
scannedArticle.value = article;
quantity.value = '1';
currentTab.value = 'scan';
const checkResult = await inventurApi.get(
`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 inventurApi.get(`getMyScans?stocktakeId=${props.stocktake.id}`);
if (result.success) recentScans.value = result.items;
} catch (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 = () => {
quantity.value = quantity.value.length > 1 ? quantity.value.slice(0, -1) : '0';
};
const clearQuantity = () => { quantity.value = '0'; };
// Navigation
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 {
currentTab, isLoading, isScannerActive, scannerError,
scannedArticle, quantity, rack, shelf,
searchQuery, searchResults, categories, selectedCategory, isSearching,
recentScans, isLoadingHistory,
alreadyScannedWarning, showKeypad, canSubmit,
startScanner, stopScanner, submitScan, searchArticles, selectSearchResult,
loadHistory, appendDigit, deleteDigit, clearQuantity,
handleClose, switchTab, cancelScan
};
},
template: `
<div class="flex flex-col h-full">
<!-- Title bar with close -->
<div class="flex items-center justify-between px-3 py-2 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700">
<span class="text-sm font-medium text-slate-600 dark:text-slate-300 truncate flex-1">{{ stocktake.title || 'Inventur #' + stocktake.stocktakeNumber }}</span>
<button @click="handleClose" class="ml-2 p-1.5 rounded-full text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 transition">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Tabs -->
<div class="flex bg-slate-100 dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700 px-3 py-2">
<button @click="switchTab('scan')" :class="[currentTab === 'scan' ? 'bg-white dark:bg-slate-800 shadow-sm' : 'text-slate-500 dark:text-slate-400']" class="flex-1 py-2 text-sm font-medium rounded-lg transition">Scannen</button>
<button @click="switchTab('search')" :class="[currentTab === 'search' ? 'bg-white dark:bg-slate-800 shadow-sm' : 'text-slate-500 dark:text-slate-400']" class="flex-1 py-2 text-sm font-medium rounded-lg transition mx-1">Suche</button>
<button @click="switchTab('history')" :class="[currentTab === 'history' ? 'bg-white dark:bg-slate-800 shadow-sm' : 'text-slate-500 dark:text-slate-400']" class="flex-1 py-2 text-sm font-medium rounded-lg transition">Verlauf</button>
</div>
<!-- Content -->
<main class="flex-grow overflow-y-auto bg-slate-50 dark:bg-slate-900">
<!-- SCAN TAB -->
<div v-if="currentTab === 'scan'" class="p-4 space-y-4">
<!-- Scanner -->
<div v-if="!scannedArticle" class="space-y-4">
<div id="qr-reader" class="w-full rounded-lg overflow-hidden bg-black"></div>
<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">
<!-- 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 -->
<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>
<!-- Rack/Shelf -->
<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>
<!-- 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">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">Ü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">
{{ 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>
<!-- Keypad -->
<transition name="slide-up">
<div v-if="showKeypad" class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-end">
<div class="bg-white dark:bg-slate-800 w-full rounded-t-2xl p-4 safe-area-bottom">
<div class="flex justify-between items-center mb-4">
<button @click="clearQuantity" class="px-4 py-2 text-red-500 font-medium">C</button>
<div class="text-2xl font-bold text-slate-800 dark:text-white">{{ quantity }}</div>
<button @click="showKeypad = false" class="px-4 py-2 text-primary font-medium">Fertig</button>
</div>
<div class="grid grid-cols-3 gap-2">
<button v-for="d in ['1','2','3','4','5','6','7','8','9','.','0']" :key="d" @click="appendDigit(d)" class="py-4 text-xl font-bold bg-slate-100 dark:bg-slate-700 rounded-lg active:bg-slate-200 dark:active:bg-slate-600 text-slate-800 dark:text-white">{{ d }}</button>
<button @click="deleteDigit" class="py-4 text-xl font-bold bg-slate-100 dark:bg-slate-700 rounded-lg active:bg-slate-200 dark:active:bg-slate-600 text-slate-800 dark:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2M3 12l6.414 6.414a2 2 0 001.414.586H19a2 2 0 002-2V7a2 2 0 00-2-2h-8.172a2 2 0 00-1.414.586L3 12z" />
</svg>
</button>
</div>
</div>
</div>
</transition>
</div>
`
};

View File

@@ -0,0 +1,151 @@
/**
* StocktakeList Component (Inventur)
*
* Displays a list of active stocktakes.
* Uses the new API path: /MobileApp/Lager/Inventur/{action}
*/
import { api } from '/mobile/shared/auth.js';
// Override API base for Inventur
const inventurApi = {
get: (endpoint) => fetch(`/MobileApp/Lager/Inventur/${endpoint}`).then(r => r.json()),
post: (endpoint, data) => fetch(`/MobileApp/Lager/Inventur/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}).then(r => r.json())
};
export default {
name: 'StocktakeList',
emits: ['select'],
props: {
user: Object
},
setup(props, { emit }) {
const { ref, onMounted } = Vue;
const stocktakes = ref([]);
const isLoading = ref(true);
const error = ref('');
const fetchStocktakes = async () => {
isLoading.value = true;
error.value = '';
try {
const result = await inventurApi.get('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);
};
onMounted(() => {
fetchStocktakes();
});
return {
stocktakes,
isLoading,
error,
fetchStocktakes,
selectStocktake
};
},
template: `
<div class="h-full flex flex-col">
<!-- Refresh bar -->
<div class="flex items-center justify-between px-3 py-2 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700">
<span class="text-sm font-medium text-slate-600 dark:text-slate-300">Aktive Inventuren</span>
<button
@click="fetchStocktakes"
class="p-2 rounded-full text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 transition"
:disabled="isLoading"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" :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>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto p-3">
<!-- Loading -->
<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-xl shadow-sm 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 -->
<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 -->
<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="bg-white dark:bg-slate-800 rounded-xl overflow-hidden shadow-sm">
<button
v-for="(stocktake, index) in stocktakes"
:key="stocktake.id"
@click="selectStocktake(stocktake)"
:class="[
'w-full text-left px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-700/50 active:bg-slate-100 dark:active:bg-slate-700 transition',
index !== stocktakes.length - 1 ? 'border-b border-slate-100 dark:border-slate-700' : ''
]"
>
<div class="flex justify-between items-start">
<div class="flex-1 min-w-0">
<h3 class="font-medium text-slate-800 dark:text-white truncate">
{{ stocktake.title || 'Inventur #' + stocktake.stocktakeNumber }}
</h3>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
{{ stocktake.locationName }}
</p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-slate-400 flex-shrink-0 ml-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
<div class="flex items-center mt-2 text-xs text-slate-400 dark:text-slate-500">
<span class="font-medium text-slate-600 dark:text-slate-400">{{ stocktake.totalScannedItems || 0 }}</span>
<span class="ml-1">Artikel</span>
<span class="mx-2">·</span>
<span>{{ stocktake.startedAt || 'Nicht gestartet' }}</span>
</div>
</button>
</div>
</div>
</div>
`
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,199 @@
/**
* MobileApp Shared Authentication Module
*
* Provides authentication utilities for all mobile PWAs:
* - checkAuth() - Check if user is authenticated
* - login() - Authenticate user
* - logout() - Clear session
* - api - Generic API helper
*/
// Base API path for all MobileApp endpoints
const API_BASE = '/MobileApp';
// Shared auth state (can be imported by components)
export const authState = {
user: null,
isAuthenticated: false
};
/**
* Check if user is currently authenticated
* @returns {Promise<{authenticated: boolean, user?: object}>}
*/
export async function checkAuth() {
try {
const res = await fetch(`${API_BASE}/auth/check`, {
credentials: 'same-origin'
});
const data = await res.json();
authState.isAuthenticated = data.authenticated;
authState.user = data.user || null;
return data;
} catch (e) {
console.error('Auth check failed:', e);
authState.isAuthenticated = false;
authState.user = null;
return { authenticated: false };
}
}
/**
* Authenticate user with credentials
* @param {object} credentials - { username, password, rememberMe }
* @returns {Promise<{success: boolean, user?: object, message?: string}>}
*/
export async function login({ username, password, rememberMe = true }) {
try {
const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ username, password, rememberMe })
});
const data = await res.json();
if (data.success) {
authState.isAuthenticated = true;
authState.user = data.user;
}
return data;
} catch (e) {
console.error('Login failed:', e);
return { success: false, message: 'Netzwerkfehler' };
}
}
/**
* Verify 2FA code
* @param {string} code - 5-digit verification code
* @returns {Promise<{success: boolean, user?: object, message?: string}>}
*/
export async function verify2FA(code) {
try {
const res = await fetch(`${API_BASE}/auth/verify2fa`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ code })
});
const data = await res.json();
if (data.success) {
authState.isAuthenticated = true;
authState.user = data.user;
}
return data;
} catch (e) {
console.error('2FA verification failed:', e);
return { success: false, message: 'Netzwerkfehler' };
}
}
/**
* Resend 2FA code
* @returns {Promise<{success: boolean, message?: string}>}
*/
export async function resend2FA() {
try {
const res = await fetch(`${API_BASE}/auth/resend2fa`, {
method: 'POST',
credentials: 'same-origin'
});
return await res.json();
} catch (e) {
console.error('Resend 2FA failed:', e);
return { success: false, message: 'Netzwerkfehler' };
}
}
/**
* Logout current user
* @returns {Promise<{success: boolean}>}
*/
export async function logout() {
try {
await fetch(`${API_BASE}/auth/logout`, {
method: 'POST',
credentials: 'same-origin'
});
} catch (e) {
console.error('Logout request failed:', e);
}
authState.isAuthenticated = false;
authState.user = null;
return { success: true };
}
/**
* Generic API helper for app-specific endpoints
* Usage: api.get('WarehouseStocktake/getActiveStocktakes')
* api.post('WarehouseStocktake/submitScan', { ... })
*/
export const api = {
/**
* GET request
* @param {string} endpoint - Endpoint path (e.g., 'WarehouseStocktake/getArticle?code=123')
* @returns {Promise<object>}
*/
get: async (endpoint) => {
try {
const res = await fetch(`${API_BASE}/${endpoint}`, {
credentials: 'same-origin'
});
// Check for auth errors
if (res.status === 401) {
authState.isAuthenticated = false;
authState.user = null;
return { success: false, error: 'Not authenticated', authError: true };
}
return await res.json();
} catch (e) {
console.error(`API GET ${endpoint} failed:`, e);
return { success: false, error: 'Netzwerkfehler' };
}
},
/**
* POST request with JSON body
* @param {string} endpoint - Endpoint path
* @param {object} data - Request body
* @returns {Promise<object>}
*/
post: async (endpoint, data = {}) => {
try {
const res = await fetch(`${API_BASE}/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(data)
});
// Check for auth errors
if (res.status === 401) {
authState.isAuthenticated = false;
authState.user = null;
return { success: false, error: 'Not authenticated', authError: true };
}
return await res.json();
} catch (e) {
console.error(`API POST ${endpoint} failed:`, e);
return { success: false, error: 'Netzwerkfehler' };
}
}
};
// Export API_BASE for components that need to build URLs
export { API_BASE };

View File

@@ -0,0 +1,324 @@
/**
* MobileApp Shared Base Styles
*
* Common styles for all mobile PWAs including:
* - Dark mode support
* - PWA-specific optimizations
* - Common animations
* - Utility classes
*/
/* ==================== ROOT & DARK MODE ==================== */
:root {
--color-primary: #005384;
--color-secondary: #fac41b;
--color-success: #22c55e;
--color-danger: #ef4444;
--color-warning: #f59e0b;
}
/* Dark mode is toggled by adding 'dark' class to <html> */
.dark {
color-scheme: dark;
}
/* ==================== PWA OPTIMIZATIONS ==================== */
html, body {
/* Prevents rubber-band scroll on iOS and pull-to-refresh on Android */
overscroll-behavior: none;
/* Prevent text selection on double tap */
-webkit-user-select: none;
user-select: none;
/* Smooth font rendering */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* Prevent zoom on input focus (iOS) */
touch-action: manipulation;
}
/* Allow text selection in inputs and textareas */
input, textarea, [contenteditable] {
-webkit-user-select: text;
user-select: text;
}
/* Safe area insets for notched devices */
.safe-area-top {
padding-top: env(safe-area-inset-top);
}
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
/* ==================== ANIMATIONS ==================== */
/* Slide transition for panels */
.slide-enter-active,
.slide-leave-active {
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.slide-enter-from,
.slide-leave-to {
transform: translateX(100%);
}
/* Slide up transition for modals */
.slide-up-enter-active,
.slide-up-leave-active {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.slide-up-enter-from,
.slide-up-leave-to {
transform: translateY(100%);
}
/* Slide down transition for top sheets */
.slide-down-enter-active,
.slide-down-leave-active {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.slide-down-enter-from,
.slide-down-leave-to {
transform: translateY(-100%);
}
/* Fade transition */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease-in-out;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* Overlay transition */
.overlay-enter-active,
.overlay-leave-active {
transition: opacity 0.35s ease;
}
.overlay-enter-from,
.overlay-leave-to {
opacity: 0;
}
/* Scale in transition */
.scale-enter-active,
.scale-leave-active {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.scale-enter-from,
.scale-leave-to {
transform: scale(0.95);
opacity: 0;
}
/* Spinner animation */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spin {
animation: spin 1s linear infinite;
}
/* Pulse animation for loading states */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.pulse {
animation: pulse 1.5s ease-in-out infinite;
}
/* Network background animations */
@keyframes node-glow {
0%, 100% {
transform: scale(1);
opacity: 0.8;
}
50% {
transform: scale(1.4);
opacity: 1;
}
}
@keyframes node-glow-slow {
0%, 100% {
transform: scale(1);
opacity: 0.7;
}
50% {
transform: scale(1.3);
opacity: 1;
}
}
@keyframes line-pulse {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.6; }
}
@keyframes line-flow {
0% { stroke-dashoffset: 1000; }
100% { stroke-dashoffset: 0; }
}
.network-node {
animation: node-glow 2s ease-in-out infinite;
}
.network-node-slow {
animation: node-glow-slow 3s ease-in-out infinite;
}
.network-lines {
animation: line-pulse 3s ease-in-out infinite;
}
.network-line-flow {
stroke-dasharray: 20 30;
animation: line-flow 8s linear infinite;
}
/* ==================== PANEL EFFECTS ==================== */
/* Background blur effect when panel is open */
.panel-open {
transform: scale(0.95);
filter: blur(4px);
opacity: 0.7;
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1),
filter 0.35s,
opacity 0.35s;
}
.list-container {
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1),
filter 0.35s,
opacity 0.35s;
}
/* ==================== OVERLAY ==================== */
.overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.4);
transition: opacity 0.35s ease;
z-index: 15;
}
/* ==================== TOAST NOTIFICATIONS ==================== */
.toast-container {
position: fixed;
bottom: calc(1rem + env(safe-area-inset-bottom));
left: 1rem;
right: 1rem;
z-index: 100;
display: flex;
flex-direction: column;
gap: 0.5rem;
pointer-events: none;
}
.toast {
padding: 0.75rem 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
text-align: center;
pointer-events: auto;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.toast-success {
background-color: var(--color-success);
color: white;
}
.toast-error {
background-color: var(--color-danger);
color: white;
}
.toast-warning {
background-color: var(--color-warning);
color: white;
}
/* ==================== FORM ELEMENTS ==================== */
/* Prevent iOS zoom on input focus */
input[type="text"],
input[type="password"],
input[type="email"],
input[type="number"],
input[type="tel"],
input[type="search"],
textarea,
select {
font-size: 16px !important;
}
/* Remove tap highlight on mobile */
button, a, input, select, textarea {
-webkit-tap-highlight-color: transparent;
}
/* Better focus styles for accessibility */
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* ==================== UTILITIES ==================== */
/* Hide scrollbar but allow scrolling */
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
/* Loading overlay */
.loading-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
/* Numeric keypad input */
.numeric-input {
font-variant-numeric: tabular-nums;
letter-spacing: 0.05em;
}

84
public/mobile/sw.js Normal file
View File

@@ -0,0 +1,84 @@
/**
* MobileApp Service Worker
* Provides basic caching for the PWA shell and assets.
*/
const CACHE_NAME = 'xinon-mobile-v1';
const ASSETS_TO_CACHE = [
'/MobileApp',
'/mobile/app.js',
'/mobile/shared/auth.js',
'/mobile/shared/base.css',
'/mobile/components/LoginScreen.js',
'/mobile/components/MainMenu.js',
'/mobile/modules/lager/LagerModule.js',
'/mobile/modules/lager/inventur/StocktakeList.js',
'/mobile/modules/lager/inventur/Scanner.js',
'/assets/images/xinon-full-transparent.png',
'/assets/images/xinon-full-transparent-white.png',
'/assets/images/xinon-sm.png'
];
// Install: cache assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(ASSETS_TO_CACHE))
.then(() => self.skipWaiting())
);
});
// Activate: clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name !== CACHE_NAME)
.map(name => caches.delete(name))
);
}).then(() => self.clients.claim())
);
});
// Fetch: network-first for API, cache-first for assets
self.addEventListener('fetch', (event) => {
// Only handle GET requests
if (event.request.method !== 'GET') return;
const url = new URL(event.request.url);
// API calls: network only
if (url.pathname.startsWith('/MobileApp/') &&
url.pathname !== '/MobileApp' &&
url.pathname !== '/MobileApp/') {
return;
}
// Everything else: cache-first, falling back to network
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) {
// Return cached, but update in background
fetch(event.request).then(response => {
if (response.ok) {
caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, response);
});
}
}).catch(() => {});
return cached;
}
return fetch(event.request).then(response => {
if (response.ok && url.origin === location.origin) {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, clone);
});
}
return response;
});
})
);
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,109 @@
/**
* 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;
});
})
);
});

View File

@@ -0,0 +1,314 @@
#!/usr/bin/php
<?php
require("../../config/config.php");
define('FRONKDB_SQLDEBUG', false);
error_reporting(E_ALL & ~(E_NOTICE | E_STRICT | E_DEPRECATED));
require_once(LIBDIR."/mvcfronk/mfRouter/mfRouter.php");
require_once(LIBDIR."/mvcfronk/mfBase/mfBaseModel.php");
require_once(LIBDIR."/mvcfronk/mfBase/mfBaseController.php");
$layout = \Layout::singleton();
$me = new User(1);
define("INTERNAL_USER_ID", $me->id);
define("INTERNAL_USER_USERNAME", $me->username);
echo "========================================\n";
echo "Creating 20 mock manual invoices...\n";
echo "========================================\n\n";
$db = FronkDB::singleton();
// Get random customers with valid data for invoicing
$customerSql = "SELECT * FROM Address
WHERE customer_number IS NOT NULL
AND customer_number > 0
AND (company IS NOT NULL OR firstname IS NOT NULL)
AND street IS NOT NULL
AND zip IS NOT NULL
AND city IS NOT NULL
ORDER BY RAND()
LIMIT 50";
$customerRes = $db->query($customerSql);
$customers = [];
while ($row = $db->fetch_object($customerRes)) {
$customers[] = $row;
}
if (empty($customers)) {
echo "ERROR: No valid customers found in database!\n";
exit(1);
}
echo "Found " . count($customers) . " random customers to use.\n\n";
// Get last 20 shipping notes for position data
$sql = "SELECT * FROM WarehouseShippingNote ORDER BY `create` DESC LIMIT 20";
$res = $db->query($sql);
$shippingNotes = [];
while ($row = $db->fetch_object($res)) {
$shippingNotes[] = new WarehouseShippingNote($row);
}
$count = 0;
$errors = 0;
foreach ($shippingNotes as $index => $shippingNote) {
echo "Processing shipping note #{$shippingNote->id}...\n";
// Pick a random customer
$customer = $customers[array_rand($customers)];
$address = new Address($customer->id);
if (!$address || !$address->id) {
echo " - Skipping: Could not load customer address\n";
$errors++;
continue;
}
// Build positions from shipping note
$positions = json_decode($shippingNote->positions, true) ?: [];
$enrichedPositions = [];
foreach ($positions as $position) {
if (isset($position['article'])) {
$article = WarehouseArticleModel::get($position['article']);
if (!$article) continue;
$prices = json_decode($article->cheapestSellPrice, true) ?: [];
$price = 0;
foreach ($prices as $p) {
if (isset($p['price'])) {
$price = $p['price'];
break;
}
}
// Use random price if no price found
if ($price == 0) {
$price = rand(10, 500);
}
$enrichedPositions[] = [
'product_name' => $article->articleNumber . " | " . $article->title,
'product_info' => $article->description ?: '',
'amount' => $position['amount'] ?: 1,
'unit' => $article->unit ?: 'Stk.',
'price' => $price,
'discount' => 0,
'vatrate' => 20,
'article_id' => $article->id
];
} elseif (isset($position['articlePacket'])) {
$packet = WarehouseArticlePacketModel::get($position['articlePacket']);
if (!$packet) continue;
$enrichedPositions[] = [
'product_name' => $packet->title,
'product_info' => $packet->description ?? '',
'amount' => $position['amount'] ?: 1,
'unit' => 'Pau.',
'price' => rand(50, 300),
'discount' => 0,
'vatrate' => 20
];
} elseif (isset($position['articleText'])) {
$enrichedPositions[] = [
'product_name' => $position['articleText'],
'product_info' => '',
'amount' => $position['amount'] ?? 1,
'unit' => 'Stk.',
'price' => rand(10, 100),
'discount' => 0,
'vatrate' => 20
];
}
}
// Add hours entries
$hoursEntries = json_decode($shippingNote->hoursEntries, true) ?: [];
foreach ($hoursEntries as $hoursEntry) {
$hourCount = floatval(str_replace(",", ".", $hoursEntry['hourCount'] ?? 0));
if ($hourCount <= 0) continue;
$userName = 'Mitarbeiter';
if (!empty($hoursEntry['userId']) && is_numeric($hoursEntry['userId'])) {
$user = UserModel::getOne($hoursEntry['userId']);
$userName = $user ? $user->name : 'Mitarbeiter';
} elseif (!empty($hoursEntry['userId_text'])) {
$userName = $hoursEntry['userId_text'];
}
$enrichedPositions[] = [
'product_name' => 'Arbeitsstunden - ' . $userName,
'product_info' => 'Datum: ' . (isset($hoursEntry['date']) ? date('d.m.Y', strtotime($hoursEntry['date'])) : date('d.m.Y')),
'amount' => $hourCount,
'unit' => 'h',
'price' => 60,
'discount' => 0,
'vatrate' => 20
];
}
// If still no positions, create some mock positions
if (empty($enrichedPositions)) {
$mockPositions = [
['name' => 'Beratungsleistung', 'unit' => 'h', 'price' => 85],
['name' => 'Installationsarbeiten', 'unit' => 'Pau.', 'price' => 250],
['name' => 'Netzwerkkabel Cat6', 'unit' => 'm', 'price' => 3.50],
['name' => 'Router TP-Link', 'unit' => 'Stk.', 'price' => 89.90],
['name' => 'Montage vor Ort', 'unit' => 'h', 'price' => 65],
];
// Add 1-3 random mock positions
$numPositions = rand(1, 3);
for ($i = 0; $i < $numPositions; $i++) {
$mock = $mockPositions[array_rand($mockPositions)];
$enrichedPositions[] = [
'product_name' => $mock['name'],
'product_info' => 'Mock-Position für Testzwecke',
'amount' => rand(1, 10),
'unit' => $mock['unit'],
'price' => $mock['price'],
'discount' => rand(0, 1) ? rand(5, 15) : 0,
'vatrate' => 20
];
}
}
// Use random invoice date within last 90 days
$randomDaysAgo = rand(0, 90);
$invoiceDate = strtotime("-{$randomDaysAgo} days");
// Create invoice data
$invoiceData = [
'invoice_number' => ManualInvoiceModel::getNextInvoiceNumber(),
'invoice_date' => $invoiceDate,
'owner_id' => $address->id,
'billingaddress_id' => $address->id,
'customer_number' => $address->customer_number,
'company' => $address->company,
'firstname' => $address->firstname,
'lastname' => $address->lastname,
'street' => $address->street,
'zip' => $address->zip,
'city' => $address->city,
'country' => $address->country ?: 'Österreich',
'email' => $address->email,
'uid' => $address->uid,
'fibu_account_number' => $address->fibu_account_number,
'fibu_payment_due' => $address->fibu_payment_due ?: 14,
'fibu_payment_skonto' => $address->fibu_payment_skonto ?: 0,
'fibu_payment_skonto_rate' => $address->fibu_payment_skonto_rate ?: 0,
'billing_type' => $address->billing_type ?: 'invoice',
'billing_delivery' => $address->billing_delivery ?: 'email',
'bank_account_bank' => $address->bank_account_bank,
'bank_account_owner' => $address->bank_account_owner,
'bank_account_iban' => $address->bank_account_iban,
'bank_account_bic' => $address->bank_account_bic,
'sepa_date' => $address->sepa_date ? (is_numeric($address->sepa_date) ? date('Y-m-d', $address->sepa_date) : $address->sepa_date) : null,
'leistungszeitraum' => date('m/Y', $invoiceDate),
'einleitender_text' => 'Testrechnung basierend auf Lieferschein #' . $shippingNote->id,
'externe_referenz' => 'TEST-LS-' . $shippingNote->id,
'gesamtrabatt' => rand(0, 1) ? rand(0, 10) : 0,
'total' => 0,
'total_gross' => 0,
'vatgroup_id' => rand(1, 3),
'status' => 'erstellt',
'lock' => 0,
'exported' => 0,
'create_by' => 1,
'edit_by' => 1,
'create' => time(),
'edit' => time()
];
// Create the invoice
$invoiceId = ManualInvoiceModel::create($invoiceData);
if (!$invoiceId) {
echo " - Error creating invoice\n";
$errors++;
continue;
}
// Create positions
$total = 0;
$totalGross = 0;
$gesamtrabatt = floatval($invoiceData['gesamtrabatt']);
foreach ($enrichedPositions as $pos) {
$amount = floatval($pos['amount']);
$price = floatval($pos['price']);
$discount = floatval($pos['discount'] ?? 0);
$vatrate = floatval($pos['vatrate'] ?? 20);
// Validate amount is within reasonable bounds
if ($amount <= 0 || $amount > 999999) {
$amount = 1;
}
if ($price < 0 || $price > 999999) {
$price = 0;
}
$priceTotal = $amount * $price * (1 - $discount / 100);
$priceTotalAfterGesamtrabatt = $priceTotal * (1 - $gesamtrabatt / 100);
$priceGross = $priceTotalAfterGesamtrabatt * (1 + $vatrate / 100);
// Use direct SQL to bypass model validation for mock data
$posProduct = $db->escape($pos['product_name']);
$posInfo = $db->escape($pos['product_info'] ?? '');
$posProductId = intval($pos['article_id'] ?? 0);
$posUnit = $db->escape($pos['unit'] ?? 'Stk.');
$posTime = time();
// Ensure values are numeric and within DB limits
$amount = round($amount, 2);
$price = round($price, 2);
$priceTotal = round($priceTotal, 2);
$priceGross = round($priceGross, 2);
$insertSql = "INSERT INTO ManualInvoiceposition
(manualinvoice_id, position_group, product_id, product_name, product_info, amount, unit, price, discount, vatrate, price_total, price_gross, matchcode, fibu_cost_account, fibu_cost_account_legacy, fibu_taxcode, contract_id, billing_id, create_by, edit_by, `create`, edit)
VALUES
($invoiceId, NULL, $posProductId, '$posProduct', '$posInfo', $amount, '$posUnit', $price, $discount, $vatrate, $priceTotal, $priceGross, NULL, NULL, NULL, NULL, 0, NULL, 1, 1, $posTime, $posTime)";
try {
$db->query($insertSql);
} catch (Throwable $e) {
echo " Warning: Position skipped (amount=$amount, price=$price): " . $e->getMessage() . "\n";
continue;
}
$total += $priceTotal;
$totalGross += $priceGross;
}
// Apply gesamtrabatt to total
$totalAfterRabatt = $total * (1 - $gesamtrabatt / 100);
// Update invoice totals using direct SQL (bypass model validation)
$db->query("UPDATE ManualInvoice SET total = " . floatval($totalAfterRabatt) . ", total_gross = " . floatval($totalGross) . " WHERE id = " . intval($invoiceId));
// Create journal entry using direct SQL
$journalText = $db->escape('Mock-Rechnung erstellt (basierend auf LS #' . $shippingNote->id . ')');
$journalTime = time();
$db->query("INSERT INTO ManualInvoiceJournal (manualinvoiceId, text, statusChange, createBy, `create`)
VALUES ($invoiceId, '$journalText', 'erstellt', 1, $journalTime)");
$invoiceNumber = $invoiceData['invoice_number'];
$customerName = trim(($address->company ?: '') . ' ' . $address->firstname . ' ' . $address->lastname);
echo " - Created invoice #{$invoiceId} ({$invoiceNumber})\n";
echo " Customer: {$customerName}\n";
echo " Positions: " . count($enrichedPositions) . ", Total: €" . number_format($totalAfterRabatt, 2) . "\n";
$count++;
}
echo "\n========================================\n";
echo "Mock invoice creation complete!\n";
echo "Created: {$count} invoices\n";
echo "Errors/Skipped: {$errors}\n";
echo "========================================\n";
echo "\nNOTE: No emails were sent. These are test invoices only.\n";

View File

@@ -7,7 +7,7 @@ use ADBRimoImport\ADBAddressHelper;
//use ADBRimoImport\importer\CitycomImporter;
//require 'vendor/autoload.php';
require("../../config/config.php");
require(__DIR__ . "/../../config/config.php");
require("ADBAddressHelper/address_helper.php");
define('FRONKDB_SQLDEBUG', false);

View File

@@ -14,9 +14,33 @@ $pgPort = '5432';
$pgDb = QGIS_DBNAME;
$pgUser = QGIS_DBUSER;
$pgPass = QGIS_DBPASS;
$targetSchema = '"ON Leibnitz"';
$targetTable = 'Preorders';
$campaigns = [
[
'targetSchema' => '"ON Leibnitz"',
'campaignId' => 99
],
[
'targetSchema' => '"ON Semriach"',
'campaignId' => 101
],
[
'targetSchema' => '"ON Bad Gleichenberg"',
'campaignId' => 108
],
[
'targetSchema' => '"ON Straden"',
'campaignId' => 107
],
[
'targetSchema' => '"ON St.Anna am Aigen"',
'campaignId' => 106
]
];
define("INTERNAL_USER_ID", 154);
class PreorderSyncWrapper extends PreorderController {
@@ -32,24 +56,6 @@ class PreorderSyncWrapper extends PreorderController {
}
}
$apiParams = [
'mod' => 'Preorder',
'action' => 'api',
'do' => 'getFilteredPreorders',
'filter' => [
'preordercampaign_id' => 99
]
];
new PreorderSyncWrapper($apiParams);
$response = PreorderSyncWrapper::$capturedResult;
if (!$response || !isset($response['status']) || $response['status'] !== 'OK') {
die("Fehler beim Abrufen der Daten oder keine Daten erhalten.\n");
}
$preorders = $response['result']['preorders'] ?? [];
try {
$dsn = "pgsql:host=$pgHost;port=$pgPort;dbname=$pgDb";
$pdo = new PDO($dsn, $pgUser, $pgPass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
@@ -57,9 +63,33 @@ try {
die("Verbindung zu PostgreSQL fehlgeschlagen: " . $e->getMessage() . "\n");
}
$pdo->exec("CREATE SCHEMA IF NOT EXISTS $targetSchema");
foreach ($campaigns as $campaign) {
$targetSchema = $campaign['targetSchema'];
$campaignId = $campaign['campaignId'];
$createTableSql = <<<SQL
$apiParams = [
'mod' => 'Preorder',
'action' => 'api',
'do' => 'getFilteredPreorders',
'filter' => [
'preordercampaign_id' => $campaignId
]
];
PreorderSyncWrapper::$capturedResult = null;
new PreorderSyncWrapper($apiParams);
$response = PreorderSyncWrapper::$capturedResult;
if (!$response || !isset($response['status']) || $response['status'] !== 'OK') {
echo "Fehler beim Abrufen der Daten oder keine Daten erhalten fuer Schema $targetSchema (ID: $campaignId).\n";
continue;
}
$preorders = $response['result']['preorders'] ?? [];
$pdo->exec("CREATE SCHEMA IF NOT EXISTS $targetSchema");
$createTableSql = <<<SQL
CREATE TABLE IF NOT EXISTS $targetSchema."$targetTable" (
id INTEGER PRIMARY KEY,
type VARCHAR(50),
@@ -82,18 +112,18 @@ CREATE TABLE IF NOT EXISTS $targetSchema."$targetTable" (
CREATE INDEX IF NOT EXISTS idx_preorders_geom ON $targetSchema."$targetTable" USING GIST (geom);
SQL;
$pdo->exec($createTableSql);
$pdo->exec($createTableSql);
$pdo->exec("ALTER TABLE $targetSchema.\"$targetTable\" ADD COLUMN IF NOT EXISTS company VARCHAR(255)");
$pdo->exec("ALTER TABLE $targetSchema.\"$targetTable\" ADD COLUMN IF NOT EXISTS firstname VARCHAR(255)");
$pdo->exec("ALTER TABLE $targetSchema.\"$targetTable\" ADD COLUMN IF NOT EXISTS lastname VARCHAR(255)");
$pdo->exec("ALTER TABLE $targetSchema.\"$targetTable\" ADD COLUMN IF NOT EXISTS phone VARCHAR(100)");
$pdo->exec("ALTER TABLE $targetSchema.\"$targetTable\" ADD COLUMN IF NOT EXISTS email VARCHAR(255)");
$pdo->exec("ALTER TABLE $targetSchema.\"$targetTable\" ADD COLUMN IF NOT EXISTS status_code INTEGER");
$pdo->exec("ALTER TABLE $targetSchema.\"$targetTable\" ADD COLUMN IF NOT EXISTS status_id INTEGER");
$pdo->exec("ALTER TABLE $targetSchema.\"$targetTable\" ADD COLUMN IF NOT EXISTS oaid VARCHAR(255)");
$pdo->exec("ALTER TABLE $targetSchema.\"$targetTable\" ADD COLUMN IF NOT EXISTS company VARCHAR(255)");
$pdo->exec("ALTER TABLE $targetSchema.\"$targetTable\" ADD COLUMN IF NOT EXISTS firstname VARCHAR(255)");
$pdo->exec("ALTER TABLE $targetSchema.\"$targetTable\" ADD COLUMN IF NOT EXISTS lastname VARCHAR(255)");
$pdo->exec("ALTER TABLE $targetSchema.\"$targetTable\" ADD COLUMN IF NOT EXISTS phone VARCHAR(100)");
$pdo->exec("ALTER TABLE $targetSchema.\"$targetTable\" ADD COLUMN IF NOT EXISTS email VARCHAR(255)");
$pdo->exec("ALTER TABLE $targetSchema.\"$targetTable\" ADD COLUMN IF NOT EXISTS status_code INTEGER");
$pdo->exec("ALTER TABLE $targetSchema.\"$targetTable\" ADD COLUMN IF NOT EXISTS status_id INTEGER");
$pdo->exec("ALTER TABLE $targetSchema.\"$targetTable\" ADD COLUMN IF NOT EXISTS oaid VARCHAR(255)");
$sqlUpsert = <<<SQL
$sqlUpsert = <<<SQL
INSERT INTO $targetSchema."$targetTable"
(id, type, type_label, strasse, hausnummer, plz, ort, geom, company, firstname, lastname, phone, email, status_code, status_id, oaid, updated_at)
VALUES
@@ -133,16 +163,16 @@ WHERE
"$targetTable".oaid IS DISTINCT FROM EXCLUDED.oaid;
SQL;
$stmt = $pdo->prepare($sqlUpsert);
$stmt = $pdo->prepare($sqlUpsert);
$processedIds = [];
$countUpsert = 0;
$countUnchanged = 0;
$countSkipped = 0;
$processedIds = [];
$countUpsert = 0;
$countUnchanged = 0;
$countSkipped = 0;
$pdo->beginTransaction();
$pdo->beginTransaction();
foreach ($preorders as $po) {
foreach ($preorders as $po) {
$id = $po->id;
$gps_lat = $po->gps_lat;
$gps_long = $po->gps_long;
@@ -182,23 +212,18 @@ foreach ($preorders as $po) {
} else {
$countUnchanged++;
}
}
}
$deletedCount = 0;
if (!empty($processedIds)) {
$deletedCount = 0;
if (!empty($processedIds)) {
$inQuery = implode(',', array_map('intval', $processedIds));
$deleteSql = "DELETE FROM $targetSchema.\"$targetTable\" WHERE id NOT IN ($inQuery)";
$deletedCount = $pdo->exec($deleteSql);
} else {
} else {
if (count($preorders) == 0) {
}
}
$pdo->commit();
}
$pdo->commit();
//echo "Sync fertig.\n";
//echo "Neu erstellt oder aktualisiert: $countUpsert\n";
//echo "Unverändert (kein Update nötig): $countUnchanged\n";
//echo "Ohne Koordinaten (übersprungen): $countSkipped\n";
//echo "Gelöscht (nicht mehr in Quelle): $deletedCount\n";