Merge branch 'master' into fronkdev
This commit is contained in:
12
.claude/settings.local.json
Normal file
12
.claude/settings.local.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(docker-compose up:*)",
|
||||
"Bash(python:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(docker-compose exec:*)",
|
||||
"mcp__sequentialthinking__sequentialthinking"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
77
Layout/default/MobileApp/App.php
Normal file
77
Layout/default/MobileApp/App.php
Normal 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>
|
||||
78
Layout/default/MobileApp/WarehouseStocktake.php
Normal file
78
Layout/default/MobileApp/WarehouseStocktake.php
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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): ?>
|
||||
|
||||
935
Layout/default/VueViews/WarehouseStocktakePWA.php
Normal file
935
Layout/default/VueViews/WarehouseStocktakePWA.php
Normal 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>
|
||||
40
Layout/default/WarehouseArticle/LABEL.php
Normal file
40
Layout/default/WarehouseArticle/LABEL.php
Normal 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>
|
||||
54
Layout/default/WarehouseArticle/LABEL_BULK.php
Normal file
54
Layout/default/WarehouseArticle/LABEL_BULK.php
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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; ?>
|
||||
|
||||
|
||||
@@ -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."]);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
451
application/MobileApp/MobileAppController.php
Normal file
451
application/MobileApp/MobileAppController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
443
application/MobileApp/Modules/Lager/Inventur/InventurHandler.php
Normal file
443
application/MobileApp/Modules/Lager/Inventur/InventurHandler.php
Normal 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,
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
346
application/MobileApp/Modules/Lager/Movement/MovementHandler.php
Normal file
346
application/MobileApp/Modules/Lager/Movement/MovementHandler.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
113
application/MobileApp/Shared/MobileAppBaseHandler.php
Normal file
113
application/MobileApp/Shared/MobileAppBaseHandler.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
]));
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
258
application/WarehouseMovement/WarehouseMovementController.php
Normal file
258
application/WarehouseMovement/WarehouseMovementController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
137
application/WarehouseMovement/WarehouseMovementModel.php
Normal file
137
application/WarehouseMovement/WarehouseMovementModel.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
462
application/WarehouseStocktake/WarehouseStocktakeController.php
Normal file
462
application/WarehouseStocktake/WarehouseStocktakeController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
74
application/WarehouseStocktake/WarehouseStocktakeModel.php
Normal file
74
application/WarehouseStocktake/WarehouseStocktakeModel.php
Normal 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}");
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
25
docker/db-downloader/Dockerfile
Normal file
25
docker/db-downloader/Dockerfile
Normal 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
444
docker/db-downloader/app.py
Normal 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)
|
||||
5
docker/db-downloader/requirements.txt
Normal file
5
docker/db-downloader/requirements.txt
Normal 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
|
||||
457
docker/db-downloader/static/app.js
Normal file
457
docker/db-downloader/static/app.js
Normal 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, '"')})">
|
||||
<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');
|
||||
}
|
||||
187
docker/db-downloader/templates/index.html
Normal file
187
docker/db-downloader/templates/index.html
Normal 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>
|
||||
@@ -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'] ?? [];
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
public/img/markers/marker-pop-b.png
Normal file
BIN
public/img/markers/marker-pop-b.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
BIN
public/img/markers/marker-pop-bl.png
Normal file
BIN
public/img/markers/marker-pop-bl.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
BIN
public/img/markers/marker-pop-o.png
Normal file
BIN
public/img/markers/marker-pop-o.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
BIN
public/img/markers/marker-pop-v.png
Normal file
BIN
public/img/markers/marker-pop-v.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
@@ -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); }
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
10
public/js/pages/Pop/Pop.css
Normal file
10
public/js/pages/Pop/Pop.css
Normal file
@@ -0,0 +1,10 @@
|
||||
.fa-map-location-dot:before
|
||||
{
|
||||
color: #d80000;
|
||||
}
|
||||
.fa-map-location-dot:after
|
||||
{
|
||||
color: #147d00;
|
||||
opacity: 0.9;
|
||||
|
||||
}
|
||||
383
public/js/pages/Pop/PopMap.js
Normal file
383
public/js/pages/Pop/PopMap.js
Normal 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">×</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]});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
23
public/js/pages/WarehouseCategory/WarehouseCategory.js
Normal file
23
public/js/pages/WarehouseCategory/WarehouseCategory.js
Normal 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');
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
224
public/js/pages/WarehouseStocktake/WarehouseStocktake.css
Normal file
224
public/js/pages/WarehouseStocktake/WarehouseStocktake.css
Normal 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;
|
||||
}
|
||||
}
|
||||
401
public/js/pages/WarehouseStocktake/WarehouseStocktake.js
Normal file
401
public/js/pages/WarehouseStocktake/WarehouseStocktake.js
Normal 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
581
public/mobile/app.js
Normal 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');
|
||||
639
public/mobile/components/LoginScreen.js
Normal file
639
public/mobile/components/LoginScreen.js
Normal 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>
|
||||
`
|
||||
};
|
||||
66
public/mobile/components/MainMenu.js
Normal file
66
public/mobile/components/MainMenu.js
Normal 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>
|
||||
`
|
||||
};
|
||||
26
public/mobile/manifest.json
Normal file
26
public/mobile/manifest.json
Normal 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"]
|
||||
}
|
||||
166
public/mobile/modules/lager/LagerModule.js
Normal file
166
public/mobile/modules/lager/LagerModule.js
Normal 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>
|
||||
`
|
||||
};
|
||||
437
public/mobile/modules/lager/inventur/Scanner.js
Normal file
437
public/mobile/modules/lager/inventur/Scanner.js
Normal 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>
|
||||
`
|
||||
};
|
||||
151
public/mobile/modules/lager/inventur/StocktakeList.js
Normal file
151
public/mobile/modules/lager/inventur/StocktakeList.js
Normal 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>
|
||||
`
|
||||
};
|
||||
1308
public/mobile/modules/lager/movement/MovementForm.js
Normal file
1308
public/mobile/modules/lager/movement/MovementForm.js
Normal file
File diff suppressed because it is too large
Load Diff
199
public/mobile/shared/auth.js
Normal file
199
public/mobile/shared/auth.js
Normal 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 };
|
||||
324
public/mobile/shared/base.css
Normal file
324
public/mobile/shared/base.css
Normal 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
84
public/mobile/sw.js
Normal 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;
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
168
public/mobile/warehouse-stocktake/app.css
Normal file
168
public/mobile/warehouse-stocktake/app.css
Normal 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);
|
||||
}
|
||||
182
public/mobile/warehouse-stocktake/app.js
Normal file
182
public/mobile/warehouse-stocktake/app.js
Normal 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');
|
||||
207
public/mobile/warehouse-stocktake/components/LoginScreen.js
Normal file
207
public/mobile/warehouse-stocktake/components/LoginScreen.js
Normal 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>
|
||||
`
|
||||
};
|
||||
607
public/mobile/warehouse-stocktake/components/Scanner.js
Normal file
607
public/mobile/warehouse-stocktake/components/Scanner.js
Normal 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>
|
||||
`
|
||||
};
|
||||
266
public/mobile/warehouse-stocktake/components/StocktakeList.js
Normal file
266
public/mobile/warehouse-stocktake/components/StocktakeList.js
Normal 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>
|
||||
`
|
||||
};
|
||||
27
public/mobile/warehouse-stocktake/manifest.json
Normal file
27
public/mobile/warehouse-stocktake/manifest.json
Normal 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"
|
||||
}
|
||||
109
public/mobile/warehouse-stocktake/sw.js
Normal file
109
public/mobile/warehouse-stocktake/sw.js
Normal 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;
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
314
scripts/ManualInvoice/create-mock-invoices.php
Normal file
314
scripts/ManualInvoice/create-mock-invoices.php
Normal 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";
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user