implemented inventur and changed warehousearticle/category

This commit is contained in:
2025-12-15 23:47:16 +01:00
parent b43925d37e
commit 3000c9e2e7
18 changed files with 2711 additions and 13 deletions

View File

@@ -0,0 +1,615 @@
<?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; }
}
</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 lastScan = ref(null);
const recentScans = ref([]);
const progress = reactive({ totalScanned: 0, myScanned: 0 });
const theme = ref(localStorage.getItem('theme') || 'system');
// Form state
const manualForm = reactive({
show: false,
article: null,
quantity: 1,
rack: '',
shelf: '',
note: '',
searchQuery: '',
searchResults: [],
searching: 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;
});
// === 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 selectStocktake = async (stocktake) => {
selectedStocktake.value = stocktake;
currentScreen.value = 'scanner';
await fetchMyScans();
await fetchProgress();
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;
} catch (err) {
console.error('Scanner start error:', err);
alert('Kamera konnte nicht gestartet werden. Bitte Berechtigung erteilen.');
}
};
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) {
manualForm.article = res.data.article;
manualForm.quantity = 1;
manualForm.rack = '';
manualForm.shelf = '';
manualForm.note = '';
manualForm.show = true;
await stopScanner();
} 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 submitScan = async () => {
if (!manualForm.article || manualForm.quantity <= 0) {
showToast('Bitte Menge angeben', 'error');
return;
}
try {
const res = await api.post('/submitScan', {
stocktakeId: selectedStocktake.value.id,
articleId: manualForm.article.id,
quantity: manualForm.quantity,
rack: manualForm.rack || null,
shelf: manualForm.shelf || null,
note: manualForm.note || null,
});
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
progress.totalScanned++;
progress.myScanned++;
// Close form and restart scanner
manualForm.show = false;
manualForm.article = null;
await nextTick();
startScanner();
} else {
showToast(res.data.message || 'Fehler beim Speichern', 'error');
}
} catch (e) {
showToast('Netzwerkfehler', 'error');
}
};
const cancelScan = async () => {
manualForm.show = false;
manualForm.article = null;
await nextTick();
startScanner();
};
const openManualEntry = async () => {
await stopScanner();
manualForm.show = true;
manualForm.article = null;
manualForm.searchQuery = '';
manualForm.searchResults = [];
};
const searchArticles = async () => {
if (manualForm.searchQuery.length < 2) {
manualForm.searchResults = [];
return;
}
manualForm.searching = true;
try {
const res = await api.get('/searchArticles', { params: { query: manualForm.searchQuery } });
if (res.data.success) {
manualForm.searchResults = res.data.articles;
}
} catch (e) {
console.error('Search error:', e);
} finally {
manualForm.searching = false;
}
};
const selectSearchResult = (article) => {
manualForm.article = article;
manualForm.quantity = 1;
manualForm.searchQuery = '';
manualForm.searchResults = [];
};
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);
}
};
// 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,
recentScans, progress, theme, manualForm, toast,
selectStocktake, backToList, submitScan, cancelScan,
openManualEntry, selectSearchResult, setTheme, logout,
};
},
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 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>
<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">
<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>
<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="cancelScan" 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">
<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>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Menge ({{ manualForm.article.unit }}) *
</label>
<input v-model.number="manualForm.quantity" type="number" inputmode="decimal" min="0.01" step="0.01"
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>
<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>
<div class="flex space-x-3 pt-4">
<button @click="cancelScan" 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>
<button @click="submitScan" class="flex-1 py-3 bg-green-600 text-white rounded-xl font-bold">
Speichern
</button>
</div>
</div>
</div>
</transition>
<!-- Recent Scans List -->
<div v-if="!manualForm.show && recentScans.length" class="flex-1 bg-white dark:bg-slate-800 overflow-auto">
<div class="px-4 py-2 bg-slate-50 dark:bg-slate-700/50 sticky top-0">
<p class="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Letzte Scans</p>
</div>
<div class="divide-y dark:divide-slate-700">
<div v-for="(item, index) in recentScans" :key="item.id"
:class="{ 'success-flash': item.flash }"
class="px-4 py-3 flex justify-between items-center">
<div class="min-w-0 flex-1">
<p class="font-medium text-slate-800 dark:text-white truncate">{{ item.articleTitle }}</p>
<p class="text-xs text-slate-500 dark:text-slate-400">
{{ item.articleNumber }}
<span v-if="item.rack || item.shelf" class="ml-2">
| {{ item.rack || '-' }} / {{ item.shelf || '-' }}
</span>
</p>
</div>
<div class="text-right ml-4">
<p class="font-bold text-primary dark:text-secondary">{{ item.countedQuantity }} {{ item.unit }}</p>
<p class="text-xs text-slate-400">{{ item.scannedAt }}</p>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- Toast Notification -->
<transition name="fade">
<div v-if="toast.show"
:class="toast.type === 'success' ? 'bg-green-600' : 'bg-red-600'"
class="fixed bottom-20 left-4 right-4 p-4 rounded-xl text-white text-center font-medium shadow-lg z-50">
{{ toast.message }}
</div>
</transition>
</div>
`
});
app.mount('#app');
</script>
</body>
</html>

View File

@@ -0,0 +1,40 @@
<?php
use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions;
// QR code options - small padding, high quality
$options = new QROptions([
'outputType' => QRCode::OUTPUT_IMAGE_PNG,
'scale' => 10,
'quietzoneSize' => 1,
]);
// Generate QR code data - encode article ID for Inventur scanning
$qrData = "WA:" . $articleId . ":" . $articleNumber;
$qrCodeBase64 = (new QRCode($options))->render($qrData);
?>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; }
html, body { height: 25mm; width: 50mm; }
body { font-family: Arial, sans-serif; color: #000; }
table { border-collapse: collapse; }
</style>
</head>
<body>
<table cellpadding="0" cellspacing="0" border="0" style="width: 50mm; height: 25mm;">
<tr>
<td style="width: 22mm; height: 25mm; vertical-align: middle; text-align: center;">
<img src="<?php echo $qrCodeBase64; ?>" style="width: 21mm; height: 21mm;">
</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-bottom: 1mm;">
<div style="font-size: 10px; font-weight: bold; color: #000; text-align: center;"><?php echo htmlspecialchars($articleNumber); ?></div>
<div style="font-size: 7px; color: #000; margin-top: 1px; word-wrap: break-word; overflow: hidden; text-align: center;"><?php echo htmlspecialchars($articleTitle); ?></div>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -184,6 +184,7 @@
<?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")): ?><li><a href="<?=self::getUrl("WarehouseDistributor")?>"><i class="far fa-fw fa-cogs text-info"></i> Administration</a></li><?php endif; ?>

View File

@@ -2,7 +2,7 @@
class WarehouseArticleController extends TTCrud {
protected string $headerTitle = 'Artikel';
protected $createText = 'Artikel erstellen';
protected $createText = false;
protected string $singleText = 'Artikel';
protected bool $reopenOnCreate = true;
@@ -12,7 +12,7 @@ class WarehouseArticleController extends TTCrud {
['key' => 'articleNumber', 'text' => 'Nr.', 'required' => true],
['key' => 'description', 'text' => 'Beschreibung', 'required' => true,'modal' => ['type' => 'textarea'], 'table' => ['sortable' => false]],
['key' => 'category_id', 'text' => 'Kategorie', 'required' => true, 'modal' => ['type' => 'select', 'items' => []], 'table' => ['filter' => 'select']],
['key' => 'unit', 'text' => 'Einheit', 'required' => true,'modal' => ['type' => 'select', 'items' => [['value' => 'Stk.', 'text' => 'Stk.'], ['value' => 'Pau.', 'text' => 'Pau.'], ['value' => 'm.', 'text' => 'm.'], ['value' => 'Std.', 'text' => 'Std.'], ['value' => 'km', 'text' => 'km']]], 'table' => false],
['key' => 'unit', 'text' => 'Einheit', 'required' => true,'modal' => ['type' => 'select', 'items' => [['value' => 'Stk.', 'text' => 'Stk.'], ['value' => 'Pau.', 'text' => 'Pau.'], ['value' => 'm.', 'text' => 'm.'], ['value' => 'Std.', 'text' => 'Std.'], ['value' => 'km', 'text' => 'km']]], 'table' => ['filter' => 'select', 'filterOptions' => [['value' => 'Stk.', 'text' => 'Stk.'], ['value' => 'Pau.', 'text' => 'Pau.'], ['value' => 'm.', 'text' => 'm.'], ['value' => 'Std.', 'text' => 'Std.'], ['value' => 'km', 'text' => 'km']]]],
['key' => 'revenueAccount', 'text' => 'Erlöskonto', 'required' => true,'modal' => ['type' => 'select', 'items' => [['value' => 0, 'text' => 'Dienstleistungen'], ['value' => 1, 'text' => 'Handelswaren']]], 'table' => false],
['key' => 'cheapestPurchasePrice', 'text' => 'Einkauf', 'modal' => false, 'table' => ['class' => 'text-center', 'suffix' => ' €']],
['key' => 'cheapestSellPrice', 'text' => 'Verkauf', 'modal' => false, 'table' => ['class' => 'text-center', 'suffix' => ' €']],
@@ -32,10 +32,13 @@ class WarehouseArticleController extends TTCrud {
protected array $autocompleteColumns = ['articleNumber', 'title', 'description'];
protected array $permissionCheck = ['WarehouseUser'];
protected array $additionalActions = [['key' => 'openHistory','title' => 'Historie','class' => 'fas fa-history text-secondary']];
protected array $additionalActions = [
['key' => 'printLabel','title' => 'Label drucken','class' => 'fas fa-print text-secondary'],
['key' => 'openHistory','title' => 'Historie','class' => 'fas fa-history text-secondary']
];
// @formatter:on
protected array $additionalJSVariables = ['WAREHOUSE_ADMIN' => true];
protected array $additionalJSVariables = ['WAREHOUSE_ADMIN' => true, 'HIDE_PAGE_TITLE' => true];
protected function prepareCrudConfig() {
$categories = array_map(fn($category) => ['value' => $category->id, 'text' => $category->name], WarehouseCategory::getAll());
@@ -131,6 +134,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 = $category->articleNumberPrefix;
$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 +201,29 @@ 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 50mm --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();
}
}

View File

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

View File

@@ -9,7 +9,7 @@ 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]],
@@ -18,11 +18,45 @@ class WarehouseCategoryController extends TTCrud {
protected array $additionalActions = [['key' => 'openHistory', 'title' => 'Historie', 'class' => 'fas fa-history text-primary']];
protected function beforeCreate(): bool {
$this->postData['articleNumberPrefix'] = $this->getNextFreePrefix();
return true;
}
protected function beforeUpdate($postData): bool {
// Preserve existing prefix - don't allow changes
$existing = WarehouseCategory::get($postData['id']);
if ($existing) {
$this->postData['articleNumberPrefix'] = $existing->articleNumberPrefix;
}
(new WarehouseHistoryController)->create($postData, $this->mod);
return true;
}
private function getNextFreePrefix(): string {
$db = FronkDB::singleton();
$result = $db->query("SELECT articleNumberPrefix FROM WarehouseCategory WHERE articleNumberPrefix IS NOT NULL ORDER BY CAST(articleNumberPrefix AS UNSIGNED) DESC LIMIT 1");
$row = $db->fetch_array($result);
if ($row && $row['articleNumberPrefix']) {
$lastPrefix = intval($row['articleNumberPrefix']);
// Skip special ranges (9900+)
if ($lastPrefix >= 9900) {
// Find highest non-special prefix
$result = $db->query("SELECT articleNumberPrefix FROM WarehouseCategory WHERE articleNumberPrefix IS NOT NULL AND CAST(articleNumberPrefix AS UNSIGNED) < 9900 ORDER BY CAST(articleNumberPrefix AS UNSIGNED) DESC LIMIT 1");
$row = $db->fetch_array($result);
$lastPrefix = $row ? intval($row['articleNumberPrefix']) : 1800;
}
$nextPrefix = $lastPrefix + 100;
// Skip 9900+ range
if ($nextPrefix >= 9900) $nextPrefix = 9900;
} else {
$nextPrefix = 1900;
}
return str_pad($nextPrefix, 4, '0', STR_PAD_LEFT);
}
protected function getHistoryAction() {
self::returnJson((new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns));
}

View File

@@ -0,0 +1,413 @@
<?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, 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` DESC");
$formattedItems = [];
while ($row = $result->fetch_assoc()) {
$formattedItems[] = [
'id' => (int)$row['id'],
'articleId' => (int)$row['articleId'],
'articleNumber' => $row['articleNumber'] ?? '',
'articleTitle' => $row['articleTitle'] ?? 'Unbekannt',
'countedQuantity' => (float)$row['countedQuantity'],
'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'],
];
}
$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,
]);
}
/**
* 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;
}
$items = WarehouseStocktakeItemModel::getAll(['stocktakeId' => $id]);
$rows = [];
foreach ($items as $item) {
$article = $item->getArticle();
$scannedBy = $item->getScannedByUser();
$rows[] = [
'Artikel-Nr.' => $article ? $article->articleNumber : '',
'Artikel' => $article ? $article->title : 'Unbekannt',
'Menge' => $item->countedQuantity,
'Regal' => $item->rack ?? '',
'Fach' => $item->shelf ?? '',
'Notiz' => $item->note ?? '',
'Gescannt am' => $item->scannedAt ? date('d.m.Y H:i', $item->scannedAt) : '',
'Gescannt von' => $scannedBy ? $scannedBy->name : '',
];
}
$filename = "Inventur_{$stocktake->stocktakeNumber}_" . date('Y-m-d') . ".csv";
$csv = Helper::arrayToCsv($rows);
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
echo "\xEF\xBB\xBF"; // UTF-8 BOM
echo $csv;
exit;
}
/**
* Get history for a stocktake
*/
protected function getHistoryAction() {
$this->prepareCrudConfig();
self::returnJson((new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns));
}
/**
* Get logs for a stocktake
*/
protected function getLogsAction() {
$id = intval($this->request->id);
if (!$id) {
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
return;
}
$logs = WarehouseStocktakeLogModel::getLogsForStocktake($id);
$formattedLogs = [];
foreach ($logs as $log) {
$user = UserModel::get($log->userId);
$formattedLogs[] = [
'id' => $log->id,
'action' => $log->action,
'details' => $log->details ? json_decode($log->details, true) : null,
'userName' => $user ? $user->name : 'Unbekannt',
'create' => date('d.m.Y H:i:s', $log->create),
];
}
self::returnJson(['success' => true, 'logs' => $formattedLogs]);
}
}

View File

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

View File

@@ -0,0 +1,194 @@
<?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: WH:articleId:articleNumber
$articleId = null;
if (preg_match('/^WH:(\d+):/', $code, $matches)) {
$articleId = intval($matches[1]);
} else {
// Try to find by article number
$article = WarehouseArticleModel::getFirst(['articleNumber' => $code]);
if ($article) {
$articleId = $article->id;
}
}
if (!$articleId) {
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
return;
}
$article = WarehouseArticleModel::get($articleId);
if (!$article) {
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
return;
}
self::returnJson([
'success' => true,
'article' => [
'id' => $article->id,
'articleNumber' => $article->articleNumber,
'title' => $article->title,
'description' => $article->description ?? '',
'unit' => $article->unit ?? 'Stk.',
]
]);
}
}

View File

@@ -0,0 +1,38 @@
<?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 $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(): ?UserModel {
if (!$this->scannedBy) return null;
return UserModel::get($this->scannedBy);
}
}

View File

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

View File

@@ -0,0 +1,374 @@
<?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: WH:articleId:articleNumber
if (preg_match('/^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 = WarehouseCategoryModel::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
*/
protected function searchArticlesAction() {
$query = $this->request->query;
if (!$query || strlen($query) < 2) {
self::returnJson(['success' => true, 'articles' => []]);
return;
}
$db = FronkDB::singleton();
$escapedQuery = $db->escape($query);
$result = $db->query("SELECT id, articleNumber, title, unit
FROM WarehouseArticle
WHERE (articleNumber LIKE '%{$escapedQuery}%' OR title LIKE '%{$escapedQuery}%')
AND (isEndOfLife IS NULL OR isEndOfLife = 0)
LIMIT 20");
$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]);
}
/**
* 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;
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;
}
// 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->user->id}
WHERE id = {$existing->id}");
$itemId = $existing->id;
$finalQuantity = $newQuantity;
$isUpdate = true;
} else {
// Create new entry
$db->query("INSERT INTO WarehouseStocktakeItem
(stocktakeId, articleId, countedQuantity, rack, shelf, note, scannedAt, scannedBy, createBy, `create`)
VALUES ({$stocktakeId}, {$articleId}, {$quantity},
" . ($rack ? "'{$db->escape($rack)}'" : "NULL") . ",
" . ($shelf ? "'{$db->escape($shelf)}'" : "NULL") . ",
" . ($note ? "'{$db->escape($note)}'" : "NULL") . ",
" . time() . ", {$this->user->id}, {$this->user->id}, " . time() . ")");
$itemId = $db->insert_id;
$finalQuantity = $quantity;
$isUpdate = false;
}
// Update stocktake progress
$stocktake->updateProgress();
// Log the scan
WarehouseStocktakeLogModel::log($stocktakeId, 'scanned', $itemId, [
'articleId' => $articleId,
'articleNumber' => $article->articleNumber,
'articleTitle' => $article->title,
'quantity' => $quantity,
'totalQuantity' => $finalQuantity,
'rack' => $rack,
'shelf' => $shelf,
'isUpdate' => $isUpdate,
]);
self::returnJson([
'success' => true,
'message' => $isUpdate
? "Menge für '{$article->title}' erhöht auf {$finalQuantity}"
: "'{$article->title}' hinzugefügt ({$quantity} {$article->unit})",
'item' => [
'id' => $itemId,
'articleId' => $articleId,
'articleNumber' => $article->articleNumber,
'articleTitle' => $article->title,
'countedQuantity' => $finalQuantity,
'unit' => $article->unit ?? 'Stk.',
'rack' => $rack,
'shelf' => $shelf,
'isUpdate' => $isUpdate,
]
]);
}
/**
* Get recent scans for current user in a stocktake
*/
protected function getMyScansAction() {
$stocktakeId = intval($this->request->stocktakeId);
if (!$stocktakeId) {
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
return;
}
$db = FronkDB::singleton();
$result = $db->query("SELECT si.*, wa.articleNumber, wa.title as articleTitle, wa.unit
FROM WarehouseStocktakeItem si
JOIN WarehouseArticle wa ON wa.id = si.articleId
WHERE si.stocktakeId = {$stocktakeId}
AND si.scannedBy = {$this->user->id}
ORDER BY si.scannedAt DESC
LIMIT 50");
$items = [];
while ($row = $result->fetch_assoc()) {
$items[] = [
'id' => intval($row['id']),
'articleId' => intval($row['articleId']),
'articleNumber' => $row['articleNumber'],
'articleTitle' => $row['articleTitle'],
'countedQuantity' => floatval($row['countedQuantity']),
'unit' => $row['unit'] ?? 'Stk.',
'rack' => $row['rack'],
'shelf' => $row['shelf'],
'scannedAt' => $row['scannedAt'] ? date('H:i', $row['scannedAt']) : null,
];
}
self::returnJson(['success' => true, 'items' => $items]);
}
/**
* Get progress stats
*/
protected function getProgressAction() {
$stocktakeId = intval($this->request->stocktakeId);
if (!$stocktakeId) {
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
return;
}
$stocktake = WarehouseStocktakeModel::get($stocktakeId);
if (!$stocktake) {
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
return;
}
$db = FronkDB::singleton();
// Total scanned items
$totalResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId}");
$totalRow = $totalResult->fetch_assoc();
$totalScanned = intval($totalRow['count']);
// My scanned items
$myResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId} AND scannedBy = {$this->user->id}");
$myRow = $myResult->fetch_assoc();
$myScanned = intval($myRow['count']);
self::returnJson([
'success' => true,
'progress' => [
'totalScanned' => $totalScanned,
'myScanned' => $myScanned,
'status' => $stocktake->status,
]
]);
}
}

View File

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

View File

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

View File

@@ -1,3 +1,13 @@
/* 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;
@@ -6,8 +16,62 @@
/*
* Modal Layout
*/
.modal-body {
.modal-dialog.modal-xl .modal-body {
overflow-x: hidden;
overflow-y: auto;
max-height: calc(100vh - 200px);
}
.modal-dialog.modal-xl .modal-content {
max-height: calc(100vh - 50px);
}
/* Disabled checkbox styling */
.wa-checkbox-item.disabled {
opacity: 0.6;
cursor: not-allowed;
pointer-events: none;
}
/* Disabled form controls styling */
.wa-modal-content .form-control:disabled,
.wa-modal-content .form-control[disabled],
.wa-modal-content textarea:disabled,
.wa-modal-content textarea[disabled] {
background-color: #e9ecef !important;
cursor: not-allowed !important;
opacity: 0.7;
}
.wa-modal-content .tt-select-modern.disabled .tt-select-trigger,
.wa-modal-content .tt-select-trigger[disabled],
.wa-modal-content .tt-select-trigger.disabled {
background-color: #e9ecef !important;
cursor: not-allowed !important;
opacity: 0.7;
pointer-events: none;
}
.wa-modal-content .form-group.disabled {
opacity: 0.7;
pointer-events: none;
}
/* Disabled field styling */
.wa-field-disabled {
opacity: 0.5;
position: relative;
}
.wa-field-disabled::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
cursor: not-allowed;
z-index: 5;
}
.wa-modal-content {

View File

@@ -331,7 +331,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 +339,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 +351,7 @@ Vue.component('warehouse-article-modal', {
label="Kategorie"
v-model="formData.category_id"
:options="categoryOptions"
@input="onCategoryChange"
required
sm/>
</div>
@@ -358,12 +359,13 @@ Vue.component('warehouse-article-modal', {
<tt-input
label="Artikel-Nummer"
v-model="formData.articleNumber"
placeholder="z.B. 1234"
placeholder="Wird automatisch generiert"
required
disabled
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 +373,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"
@@ -389,7 +391,7 @@ Vue.component('warehouse-article-modal', {
<warehouse-article-distributor v-if="isEditMode" :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">
@@ -571,6 +573,19 @@ Vue.component('warehouse-article-modal', {
isSbidiShopHide: false
};
},
async onCategoryChange(categoryId) {
if (!categoryId || this.isEditMode) 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() {
if (!this.isValid) return;
this.saving = true;
@@ -618,10 +633,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 +683,11 @@ Vue.component('warehouse-article', {
if (Object.keys(table.filters).length === 0) table.filters = {};
table.refreshTable();
}
},
methods: {
printLabel(event) {
const url = window.TT_CONFIG.BASE_PATH + "/WarehouseArticle/printLabel?id=" + event.id;
window.open(url, '_blank');
}
}
});

View File

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

View File

@@ -0,0 +1,368 @@
// 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-3">
<div class="card stat-card card-primary h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="card-subtitle">Inventur</h6>
<h4 class="card-title">{{ stocktake.title }}</h4>
</div>
<i class="fas fa-clipboard-list fa-2x"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card card-success h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="card-subtitle">Gescannte Artikel</h6>
<h4 class="card-title">{{ stocktake.totalScannedItems }}</h4>
</div>
<i class="fas fa-barcode fa-2x"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card card-info h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="card-subtitle">Lagerort</h6>
<h4 class="card-title">{{ stocktake.locationName }}</h4>
</div>
<i class="fas fa-warehouse fa-2x"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card h-100" :class="statusCardClass">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="card-subtitle">Status</h6>
<h4 class="card-title">{{ statusText }}</h4>
</div>
<i class="fas fa-info-circle fa-2x"></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: 80px;">Menge</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>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.id">
<td><code class="text-primary">{{ item.articleNumber }}</code></td>
<td>{{ item.articleTitle }}</td>
<td class="text-end"><strong class="text-success">{{ item.countedQuantity }}</strong></td>
<td>{{ item.rack || '-' }}</td>
<td>{{ item.shelf || '-' }}</td>
<td class="text-nowrap">{{ item.scannedAt || '-' }}</td>
<td>{{ item.scannedBy || '-' }}</td>
</tr>
</tbody>
</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: [],
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;
}
} 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;
}
}
},
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');
}
}
});