implemented inventur and changed warehousearticle/category
This commit is contained in:
615
Layout/default/VueViews/WarehouseStocktakePWA.php
Normal file
615
Layout/default/VueViews/WarehouseStocktakePWA.php
Normal 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>
|
||||
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: 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>
|
||||
@@ -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; ?>
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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));
|
||||
}
|
||||
|
||||
413
application/WarehouseStocktake/WarehouseStocktakeController.php
Normal file
413
application/WarehouseStocktake/WarehouseStocktakeController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
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;
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,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,
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
368
public/js/pages/WarehouseStocktake/WarehouseStocktake.js
Normal file
368
public/js/pages/WarehouseStocktake/WarehouseStocktake.js
Normal 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');
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user