From 2bfea1d0d735fbc11d3129e8b494a50670a1e023 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Tue, 4 Nov 2025 12:30:52 +0100 Subject: [PATCH 001/123] Enhance IP address tooltip and add Ctrl+Click functionality for scanning --- public/js/pages/Radius/Radius.js | 126 +++- public/js/pages/Radius/RadiusUsers.js | 960 ++++++++++++++++++++++---- 2 files changed, 942 insertions(+), 144 deletions(-) diff --git a/public/js/pages/Radius/Radius.js b/public/js/pages/Radius/Radius.js index 95372024a..13891b3be 100644 --- a/public/js/pages/Radius/Radius.js +++ b/public/js/pages/Radius/Radius.js @@ -147,18 +147,128 @@ Vue.component('radius-processing-indicator', { /* ---------- Online state chip (fetches radacct when visible) ---------- */ Vue.component('radius-online-state', { - props: { username: String }, data: () => ({ data: null, observed: false, ob: null }), + props: { username: String }, + data: () => ({ + data: null, + observed: false, + ob: null, + isHovering: false, + ctrlPressed: false, + tooltipText: 'IP-Adresse kopieren' + }), template: `
- - + +
`, - mounted() { this.ob = new IntersectionObserver((en) => { if (en[0].isIntersecting && !this.observed) { this.observed = true; this.fetchState(); } }, { threshold: 0.1 }); if (this.$refs.root) this.ob.observe(this.$refs.root); }, - beforeDestroy() { this.ob?.disconnect(); }, + watch: { + data(newData) { + // Update tooltip text when data is loaded + if (newData && newData.ip) { + this.tooltipText = 'IP-Adresse kopieren'; + } else { + this.tooltipText = null; + } + } + }, + mounted() { + this.ob = new IntersectionObserver((en) => { if (en[0].isIntersecting && !this.observed) { this.observed = true; this.fetchState(); } }, { threshold: 0.1 }); + if (this.$refs.root) this.ob.observe(this.$refs.root); + // Listen for Ctrl/Meta key presses globally + document.addEventListener('keydown', this.handleKey); + document.addEventListener('keyup', this.handleKey); + }, + beforeDestroy() { + this.ob?.disconnect(); + // Clean up global listeners + document.removeEventListener('keydown', this.handleKey); + document.removeEventListener('keyup', this.handleKey); + }, methods: { - async fetchState() { try { const r = await fetch(`${window.TT_CONFIG['BASE_PATH']}/Radius/proxyUnsecureHTTPRequestToRadius?action2=fetchRadacct&username=${encodeURIComponent(this.username)}`); this.data = r.ok ? await r.json() : { online: false, ip: null }; } catch { this.data = { online: false, ip: null }; } }, - async copyIp(event) { if (!this.data?.ip) return; const c = event.currentTarget; if (!c || c.classList.contains('is-copied')) return; await window.RadiusUtils.copyToClipboard(this.data.ip); c.classList.add('is-copied'); const o = c.dataset.tooltip; if (o) c.dataset.tooltip = 'Kopiert!'; setTimeout(() => { c.classList.remove('is-copied'); if (o) c.dataset.tooltip = o; }, 1500); } + async fetchState() { + try { + const r = await fetch(`${window.TT_CONFIG['BASE_PATH']}/Radius/proxyUnsecureHTTPRequestToRadius?action2=fetchRadacct&username=${encodeURIComponent(this.username)}`); + this.data = r.ok ? await r.json() : { online: false, ip: null }; + } catch { + this.data = { online: false, ip: null }; + } + }, + async copyIp(event) { + if (!this.data?.ip) return; + const c = event.currentTarget; + if (!c || c.classList.contains('is-copied')) return; + await window.RadiusUtils.copyToClipboard(this.data.ip); + c.classList.add('is-copied'); + + // Temporarily change tooltip to "Kopiert!" + const originalTooltip = this.tooltipText; + this.tooltipText = 'Kopiert!'; + + setTimeout(() => { + c.classList.remove('is-copied'); + // Restore original tooltip + this.tooltipText = originalTooltip; + // Re-run updateTooltip in case Ctrl is still pressed + this.updateTooltip(); + }, 1500); + }, + // --- New methods for Ctrl+Click --- + handleKey(event) { + const newCtrlPressed = event.ctrlKey || event.metaKey; + if (newCtrlPressed !== this.ctrlPressed) { + this.ctrlPressed = newCtrlPressed; + // If hovering, update tooltip live + if (this.isHovering) { + this.updateTooltip(); + } + } + }, + onIpMouseOver(event) { + this.isHovering = true; + this.ctrlPressed = event.ctrlKey || event.metaKey; + this.updateTooltip(); + }, + onIpMouseOut() { + this.isHovering = false; + this.ctrlPressed = false; // Reset on mouse out + this.updateTooltip(); + }, + updateTooltip() { + if (!this.data?.ip) { + this.tooltipText = null; + } else if (this.isHovering && this.ctrlPressed) { + this.tooltipText = 'Scan starten & verbinden'; + } else { + this.tooltipText = 'IP-Adresse kopieren'; + } + }, + onClickIp(event) { + if (!this.data?.ip) return; + + if (event.ctrlKey || event.metaKey) { + // Ctrl+Click or Meta+Click + event.preventDefault(); + this.$emit('scan-ip', { ip: this.data.ip }); + } else { + // Normal click + this.copyIp(event); + } + } + // --- End new methods --- } }); @@ -247,4 +357,4 @@ Vue.component('radius', { methods: { switchView(v){ this.view=v; if(!this._initFlags||this._initFlags[v])return; let r=''; if(v==='free')r='freeView';else if(v==='unused')r='unusedView'; if(r){this.$nextTick(()=>{const c=this.$refs[r];if(c&&typeof c.initIfNeeded==='function'){c.initIfNeeded();this._initFlags[v]=true;}});}} } -}); \ No newline at end of file +}); diff --git a/public/js/pages/Radius/RadiusUsers.js b/public/js/pages/Radius/RadiusUsers.js index f2798be36..eeb4c7908 100644 --- a/public/js/pages/Radius/RadiusUsers.js +++ b/public/js/pages/Radius/RadiusUsers.js @@ -1,138 +1,826 @@ -/* ===== RadiusUsers.js ===== */ Vue.component('radius-users', { -    template: ` -     
-       
-         
-         
z.B. nat* für lazy Suche
-         
Prefixe: '=' exakt, '*' Verlauf (lazy), '*=' Verlauf (exakt)
-         
-         
-           
-            -            -         
-       
-       
-          -            -            -            -            -          -         
Suche läuft...{{ radiusUsers.length }} Treffer gefunden
-       
-        -         
-           
Status
{{ radacctData.online ? 'Online' : 'Offline' }}
-                        -                        -            -            -         
-       
-        -          -        -        -         
-           

Bitte geben Sie eine gültige E-Mail-Adresse ein.

-           
-         
-       
-     
-    `, -    data: () => ({ window: window, billAddrDisplay: '', billAddrCustnum: '', username: '', ip: '', info: '', searchMode: 'autocomplete', radiusUsers: [], checkOnlineState: false, isLoading: false, showRadacctModal: false, radacctData: null, searchCount: 0, hasSearched: false, visibleCount: 50, observer: null, showTransferModal: false, transferInitialLoading: false, transferMonthlyLoading: false, transferModalUsername: '', transferYear: new Date().getFullYear(), transferMonth: new Date().getMonth() + 1, transferYearlyData: null, transferMonthlyData: null, transferChartInstance: null, showYearDropdown: false, isSendingEmail: false, showEmailModal: false, recipientEmail: '' }), -    computed: { -        hasFilters() { return this.billAddrDisplay || this.username || this.ip || this.info; }, -        visibleUsers() { return this.radiusUsers.slice(0, this.visibleCount); }, -        availableYears() { const c = new Date().getFullYear(), s = 2021; if (s > c) return [c]; return Array.from({length: c - s + 1}, (_, i) => c - i); }, -        allMonths() { return Array.from({ length: 12 }, (_, i) => ({ month: i + 1, name: new Date(2000, i, 1).toLocaleString('de-DE', { month: 'short' }) })); }, -        isValidEmail() { if (!this.recipientEmail) return false; return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.recipientEmail); } -    }, -    mounted() { -        const urlParams = new URLSearchParams(window.location.search); -        const infoParam = urlParams.get('info'); -        if (infoParam) { -            this.info = infoParam; -            this.loadRadiusUsers(); -        } -        this.observer = new IntersectionObserver(([e]) => { if (e && e.isIntersecting) this.loadMore(); }, { root: this.$refs.tableWrap, threshold: 0.1 }); -        if (this.$refs.sentinel) this.observer.observe(this.$refs.sentinel); -    }, -    beforeDestroy() { if (this.observer) this.observer.disconnect(); if (this.transferChartInstance) this.transferChartInstance.destroy(); }, -    updated() { if (this.observer && this.$refs.sentinel) { this.observer.disconnect(); this.observer.observe(this.$refs.sentinel); } }, -    methods: { -        onAddrSelect({ custnum, display }) { this.billAddrCustnum = custnum || ''; this.billAddrDisplay = display || ''; }, -        onModeChange(newMode) { this.searchMode = newMode; }, -        async loadRadiusUsers() { this.isLoading = true; this.radiusUsers = []; this.hasSearched = true; this.visibleCount = 50; try { const p = new URLSearchParams({ username: this.username || '', info: this.info || '', ip: this.ip || '' }); if (this.searchMode === 'text') p.set('estmk_nr', this.billAddrDisplay || ''); else p.set('custnum', this.billAddrCustnum || ''); const r = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?${p.toString()}`); if (r.ok) { const u = await r.json(); if (Array.isArray(u) && u.length < 6) this.checkOnlineState = true; this.radiusUsers = Array.isArray(u) ? u : []; } } catch (e) { console.error(e); } this.isLoading = false; this.searchCount++; }, -        async fetchRadacctData(username) { this.showRadacctModal = true; this.radacctData = null; try { const r = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?action2=fetchRadacct&username=${encodeURIComponent(username)}`); if (r.ok) this.radacctData = await r.json(); } catch (e) { console.error(e); this.radacctData = {}; } }, -        async copy(text, event) { if (!event || !event.currentTarget) return; const btn = event.currentTarget; if (btn.classList.contains('is-copied')) return; await window.RadiusUtils.copyToClipboard(text); btn.classList.add('is-copied'); btn.disabled = true; setTimeout(() => { btn.classList.remove('is-copied'); btn.disabled = false; }, 1500); }, -        clearFilters() { this.billAddrDisplay = ''; this.billAddrCustnum = ''; this.username = ''; this.ip = ''; this.info = ''; this.radiusUsers = []; this.hasSearched = false; this.searchCount++; this.visibleCount = 50; }, -        loadMore() { if (this.visibleCount < this.radiusUsers.length) this.visibleCount += 50; }, -        // ===== NEW METHOD ===== -        async scanIp(ip) { -            if (!ip) return; -            if (window.notify) { -                window.notify('info', `Starte Scan für ${ip}...`); -            } -            try { -                const response = await fetch(`http://localhost:8094/scan?ip=${ip}`); -                if (!response.ok) { -                    throw new Error(`HTTP-Fehler! Status: ${response.status}`); -                } -                const data = await response.json(); + template: ` +
+
+
+ +
+
+
z.B. nat* für lazy Suche
+
+
+
Prefixe: '=' exakt, '*' Verlauf (lazy), '*=' Verlauf (exakt) +
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+ + + + + + +
Suche läuft...{{ radiusUsers.length }} Treffer gefunden
+
+ +
+
Status +
+
{{ radacctData.online ? 'Online' : 'Offline' }} +
+
+
+
+
+
+   +
IP +
+
{{ radacctData.ip }} + +
+
+
+
+
+
+   +
Username + +
+ + +
+
+ + + + +
+
+
+

+ Bitte geben Sie eine gültige E-Mail-Adresse ein.

+
+ + +
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ `, + data: () => ({ + window: window, + billAddrDisplay: '', + billAddrCustnum: '', + username: '', + ip: '', + info: '', + searchMode: 'autocomplete', + radiusUsers: [], + checkOnlineState: false, + isLoading: false, + showRadacctModal: false, + radacctData: null, + searchCount: 0, + hasSearched: false, + visibleCount: 50, + observer: null, + showTransferModal: false, + transferInitialLoading: false, + transferMonthlyLoading: false, + transferModalUsername: '', + transferYear: new Date().getFullYear(), + transferMonth: new Date().getMonth() + 1, + transferYearlyData: null, + transferMonthlyData: null, + transferChartInstance: null, + showYearDropdown: false, + isSendingEmail: false, + showEmailModal: false, + recipientEmail: '', + showExtensionIdModal: false, + extensionId: 'jglijfiddilckddlmbnlojmmlahboffh' + }), + computed: { + hasFilters() { + return this.billAddrDisplay || this.username || this.ip || this.info; + }, + visibleUsers() { + return this.radiusUsers.slice(0, this.visibleCount); + }, + availableYears() { + const c = new Date().getFullYear(), s = 2021; + if (s > c) return [c]; + return Array.from({length: c - s + 1}, (_, i) => c - i); + }, + allMonths() { + return Array.from({length: 12}, (_, i) => ({ + month: i + 1, + name: new Date(2000, i, 1).toLocaleString('de-DE', {month: 'short'}) + })); + }, + isValidEmail() { + if (!this.recipientEmail) return false; + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.recipientEmail); + } + }, + mounted() { + const urlParams = new URLSearchParams(window.location.search); + const infoParam = urlParams.get('info'); + if (infoParam) { + this.info = infoParam; + this.loadRadiusUsers(); + } + this.observer = new IntersectionObserver(([e]) => { + if (e && e.isIntersecting) this.loadMore(); + }, {root: this.$refs.tableWrap, threshold: 0.1}); + if (this.$refs.sentinel) this.observer.observe(this.$refs.sentinel); -                if (data.status === 'success' && data.url) { -                    window.open(data.url, '_blank'); -                } else if (data.status === 'not_found') { -                    if (window.notify) { -                        window.notify('warning', `Kein Gerät für ${ip} gefunden.`); -                    } -                } else { -                    throw new Error('Ungültige oder unerwartete Antwort vom Scan-Server.'); -                } -            } catch (error) { -                console.error('IP-Scan fehlgeschlagen:', error); -                if (window.notify) { -                    window.notify('error', `Scan für ${ip} fehlgeschlagen.`); -                } -            } -        }, -        // ===== END NEW METHOD ===== -        async openTransferModal(username) { this.showTransferModal = true; this.transferModalUsername = username; this.transferYear = new Date().getFullYear(); this.transferMonth = new Date().getMonth() + 1; await this.fetchTransferYearData(); }, -        closeTransferModal() { this.showTransferModal = false; this.transferModalUsername = ''; this.transferYearlyData = null; this.transferMonthlyData = null; this.showYearDropdown = false; this.showEmailModal = false; this.recipientEmail = ''; this.isSendingEmail = false; if (this.transferChartInstance) { this.transferChartInstance.destroy(); this.transferChartInstance = null; } }, -        async fetchTransferYearData() { this.transferInitialLoading = true; this.transferYearlyData = null; try { const r = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?action2=transferStatistic&username=${this.transferModalUsername}&year=${this.transferYear}&month=0`); if (r.ok) { const d = await r.json(); if(d && d.monthlySummary) { this.transferYearlyData = d; const last = [...d.monthlySummary].reverse().find(m => m.grandTotalBytes > 0); this.transferMonth = last ? last.month : new Date().getMonth() + 1; await this.fetchTransferMonthData(); }} else this.transferYearlyData = null; } catch (e) { console.error(e); this.transferYearlyData = null; } this.transferInitialLoading = false; }, -        async fetchTransferMonthData() { this.transferMonthlyLoading = true; this.transferMonthlyData = null; if (this.transferChartInstance) this.transferChartInstance.destroy(); try { const r = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?action2=transferStatistic&username=${this.transferModalUsername}&year=${this.transferYear}&month=${this.transferMonth}`); this.transferMonthlyData = r.ok ? await r.json() : null; } catch (e) { console.error(e); this.transferMonthlyData = null; } this.transferMonthlyLoading = false; this.$nextTick(() => { if(this.showTransferModal) this.renderTransferChart(); }); }, -        prepareEmailModal() { if (this.transferInitialLoading || !this.transferYearlyData || !this.transferYearlyData.yearlySummary || this.transferYearlyData.yearlySummary.grandTotalBytes === 0) return; this.recipientEmail = ''; this.showEmailModal = true; }, -        async sendTransferEmail() { if (!this.transferMonthlyData || !this.transferChartInstance || !this.isValidEmail) return; this.isSendingEmail = true; try { const chartImageBase64 = this.transferChartInstance.toBase64Image(); const payload = { username: this.transferModalUsername, year: this.transferYear, month: this.transferMonth, monthlySummary: this.transferMonthlyData.summary, monthlyDetails: this.transferMonthlyData.details, chartImage: chartImageBase64, recipient: this.recipientEmail }; const res = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/sendCustomerEmail`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (res.ok) { if (window.notify) window.notify('success', 'E-Mail wurde erfolgreich versendet.'); this.showEmailModal = false; } else { throw new Error('Server responded with an error.'); } } catch (e) { console.error("Failed to send transfer email:", e); if (window.notify) window.notify('error', 'Fehler beim Senden der E-Mail.'); } finally { this.isSendingEmail = false; } }, -        isMonthDisabled(month) { if (this.transferInitialLoading || this.transferMonthlyLoading) return true; if (!this.transferYearlyData?.monthlySummary) return true; const m = this.transferYearlyData.monthlySummary.find(m => m.month === month); return !m || m.grandTotalBytes === 0; }, -        selectYear(year) { this.showYearDropdown = false; if (this.transferYear !== year) this.changeTransferYear(year); }, -        async changeTransferYear(year) { this.transferYear = year; await this.fetchTransferYearData(); }, -        async changeTransferMonth(month) { this.transferMonth = month; await this.fetchTransferMonthData(); }, -        processChartData(details) { if (!details || !details.length) return { labels: [], datasets: [] }; const daily = details.reduce((a, s) => { const d = s.startTime.split(' ')[0]; if (!a[d]) a[d] = { downloadBytes: 0, uploadBytes: 0 }; a[d].downloadBytes += Number(s.downloadBytes) || 0; a[d].uploadBytes += Number(s.uploadBytes) || 0; return a; }, {}); const dates = Object.keys(daily).sort((a, b) => new Date(a) - new Date(b)); return { labels: dates, datasets: [ { label: 'Download', data: dates.map(d => daily[d].downloadBytes), borderColor: 'rgba(15, 157, 88, 0.8)', backgroundColor: 'rgba(15, 157, 88, 0.1)', fill: true, tension: 0.3, pointRadius: 2, borderWidth: 1.5 }, { label: 'Upload', data: dates.map(d => daily[d].uploadBytes), borderColor: 'rgba(0, 83, 132, 0.8)', backgroundColor: 'rgba(0, 83, 132, 0.1)', fill: true, tension: 0.3, pointRadius: 2, borderWidth: 1.5 } ] }; }, -        renderTransferChart() { if (this.transferChartInstance) this.transferChartInstance.destroy(); if (!this.$refs.transferChartCanvas || !this.transferMonthlyData?.details?.length || !window.Chart) return; const d = this.processChartData(this.transferMonthlyData.details); if (!d.labels.length) return; const chartBackgroundColorPlugin = { id: 'customCanvasBackgroundColor', beforeDraw: (chart) => { const { ctx } = chart; ctx.save(); ctx.globalCompositeOperation = 'destination-over'; ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, chart.width, chart.height); ctx.restore(); } }; this.transferChartInstance = new Chart(this.$refs.transferChartCanvas.getContext('2d'), { type: 'line', data: d, options: { responsive: true, maintainAspectRatio: false, scales: { x: { type: 'time', time: { unit: 'day', tooltipFormat: 'DD.MM.YYYY', displayFormats: { day: 'DD.MM' } }, grid: { display: false }, ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 15 } }, y: { beginAtZero: true, ticks: { callback: (v) => window.RadiusUtils.formatBytes(v, 0) }, grid: { color: 'rgba(0,0,0,0.05)' } } }, plugins: { tooltip: { callbacks: { label: (c) => `${c.dataset.label || ''}: ${window.RadiusUtils.formatBytes(c.parsed.y)}` } }, legend: { position: 'bottom', labels: { usePointStyle: true, boxWidth: 8, padding: 20 } } }, interaction: { mode: 'index', intersect: false } }, plugins: [chartBackgroundColorPlugin] }); } -    } -}); + const savedExtensionId = localStorage.getItem('radiusExtensionId'); + if (savedExtensionId) { + this.extensionId = savedExtensionId; + } + window.addEventListener('keydown', this.handleKeydown); + }, + beforeDestroy() { + if (this.observer) this.observer.disconnect(); + if (this.transferChartInstance) this.transferChartInstance.destroy(); + window.removeEventListener('keydown', this.handleKeydown); + }, + updated() { + if (this.observer && this.$refs.sentinel) { + this.observer.disconnect(); + this.observer.observe(this.$refs.sentinel); + } + }, + methods: { + handleKeydown(e) { + console.log(e); + if (e.code === 'KeyE' && e.ctrlKey && e.altKey) { + e.preventDefault(); + this.openExtensionIdModal(); + } + }, + openExtensionIdModal() { + this.showExtensionIdModal = true; + }, + saveExtensionId() { + localStorage.setItem('radiusExtensionId', this.extensionId); + this.showExtensionIdModal = false; + window.notify('success', 'Extension ID gespeichert.'); + }, + onAddrSelect({custnum, display}) { + this.billAddrCustnum = custnum || ''; + this.billAddrDisplay = display || ''; + }, + onModeChange(newMode) { + this.searchMode = newMode; + }, + async loadRadiusUsers() { + this.isLoading = true; + this.radiusUsers = []; + this.hasSearched = true; + this.visibleCount = 50; + try { + const p = new URLSearchParams({ + username: this.username || '', + info: this.info || '', + ip: this.ip || '' + }); + if (this.searchMode === 'text') p.set('estmk_nr', this.billAddrDisplay || ''); else p.set('custnum', this.billAddrCustnum || ''); + const r = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?${p.toString()}`); + if (r.ok) { + const u = await r.json(); + if (Array.isArray(u) && u.length < 6) this.checkOnlineState = true; + this.radiusUsers = Array.isArray(u) ? u : []; + } + } catch (e) { + console.error(e); + } + this.isLoading = false; + + const urlParams = new URLSearchParams(window.location.search); + const connectParam = urlParams.get('connect'); + if (connectParam && connectParam.toLowerCase() === 'true' && this.radiusUsers.length === 1) { + const r = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?action2=fetchRadacct&username=${encodeURIComponent(this.radiusUsers[0].username)}`); + if (r.ok) { + const radacct = await r.json(); + if (radacct && radacct.ip) { + this.onScanIp({ip: radacct.ip}, this.radiusUsers[0]); + } + } + } + + this.searchCount++; + }, + async fetchRadacctData(username) { + this.showRadacctModal = true; + this.radacctData = null; + try { + const r = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?action2=fetchRadacct&username=${encodeURIComponent(username)}`); + if (r.ok) this.radacctData = await r.json(); + } catch (e) { + console.error(e); + this.radacctData = {}; + } + }, + async copy(text, event) { + if (!event || !event.currentTarget) return; + const btn = event.currentTarget; + if (btn.classList.contains('is-copied')) return; + await window.RadiusUtils.copyToClipboard(text); + btn.classList.add('is-copied'); + btn.disabled = true; + setTimeout(() => { + btn.classList.remove('is-copied'); + btn.disabled = false; + }, 1500); + }, + clearFilters() { + this.billAddrDisplay = ''; + this.billAddrCustnum = ''; + this.username = ''; + this.ip = ''; + this.info = ''; + this.radiusUsers = []; + this.hasSearched = false; + this.searchCount++; + this.visibleCount = 50; + }, + loadMore() { + if (this.visibleCount < this.radiusUsers.length) this.visibleCount += 50; + }, + async onScanIp(payload, item) { + const {ip} = payload; + const info = item.info; + if (!ip) return; + + window.notify('info', `Starte Scan für ${ip}...`); + + try { + const response = await fetch(`http://localhost:8094/scan?ip=${ip}`); + + if (!response.ok) { + window.notify('error', `Scan-Server-Fehler: ${response.status}`); + return; + } + + const data = await response.json(); + + if (data.status === 'success' && data.url) { + const extensionId = this.extensionId; + const message = { + type: "START_RADIUS_SCAN", + payload: { + ip: ip, + url: data.url, + info: info + } + }; + + if (window.chrome && chrome.runtime && chrome.runtime.sendMessage) { + try { + chrome.runtime.sendMessage(extensionId, message, (response) => { + if (chrome.runtime.lastError) { + console.warn("Senden an Erweiterung fehlgeschlagen:", chrome.runtime.lastError.message); + window.notify('warning', 'Scan-Daten konnten nicht an die Erweiterung gesendet werden.'); + } else { + console.log("Erweiterung hat geantwortet:", response); + } + }); + } catch (e) { + console.error("Fehler beim Senden an die Erweiterung:", e); + window.notify('error', 'Fehler beim Senden an die Erweiterung.'); + } + } else { + console.warn("Chrome Extension Messaging API nicht verfügbar."); + window.notify('warning', 'Chrome Messaging API nicht gefunden.'); + } + + } else if (data.status === 'not_found') { + window.notify('warning', `Kein Gerät für ${ip} gefunden.`); + } else { + window.notify('error', 'Ungültige Antwort vom Scan-Server.'); + } + } catch (error) { + console.error('IP-Scan fehlgeschlagen:', error); + window.notify('error', `Scan für ${ip} fehlgeschlagen.`); + } + }, + async openTransferModal(username) { + this.showTransferModal = true; + this.transferModalUsername = username; + this.transferYear = new Date().getFullYear(); + this.transferMonth = new Date().getMonth() + 1; + await this.fetchTransferYearData(); + }, + closeTransferModal() { + this.showTransferModal = false; + this.transferModalUsername = ''; + this.transferYearlyData = null; + this.transferMonthlyData = null; + this.showYearDropdown = false; + this.showEmailModal = false; + this.recipientEmail = ''; + this.isSendingEmail = false; + if (this.transferChartInstance) { + this.transferChartInstance.destroy(); + this.transferChartInstance = null; + } + }, + async fetchTransferYearData() { + this.transferInitialLoading = true; + this.transferYearlyData = null; + try { + const r = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?action2=transferStatistic&username=${this.transferModalUsername}&year=${this.transferYear}&month=0`); + if (r.ok) { + const d = await r.json(); + if (d && d.monthlySummary) { + this.transferYearlyData = d; + const last = [...d.monthlySummary].reverse().find(m => m.grandTotalBytes > 0); + this.transferMonth = last ? last.month : new Date().getMonth() + 1; + await this.fetchTransferMonthData(); + } + } else this.transferYearlyData = null; + } catch (e) { + console.error(e); + this.transferYearlyData = null; + } + this.transferInitialLoading = false; + }, + async fetchTransferMonthData() { + this.transferMonthlyLoading = true; + this.transferMonthlyData = null; + if (this.transferChartInstance) this.transferChartInstance.destroy(); + try { + const r = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?action2=transferStatistic&username=${this.transferModalUsername}&year=${this.transferYear}&month=${this.transferMonth}`); + this.transferMonthlyData = r.ok ? await r.json() : null; + } catch (e) { + console.error(e); + this.transferMonthlyData = null; + } + this.transferMonthlyLoading = false; + this.$nextTick(() => { + if (this.showTransferModal) this.renderTransferChart(); + }); + }, + prepareEmailModal() { + if (this.transferInitialLoading || !this.transferYearlyData || !this.transferYearlyData.yearlySummary || this.transferYearlyData.yearlySummary.grandTotalBytes === 0) return; + this.recipientEmail = ''; + this.showEmailModal = true; + }, + async sendTransferEmail() { + if (!this.transferMonthlyData || !this.transferChartInstance || !this.isValidEmail) return; + this.isSendingEmail = true; + try { + const chartImageBase64 = this.transferChartInstance.toBase64Image(); + const payload = { + username: this.transferModalUsername, + year: this.transferYear, + month: this.transferMonth, + monthlySummary: this.transferMonthlyData.summary, + monthlyDetails: this.transferMonthlyData.details, + chartImage: chartImageBase64, + recipient: this.recipientEmail + }; + const res = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/sendCustomerEmail`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(payload) + }); + if (res.ok) { + window.notify('success', 'E-Mail wurde erfolgreich versendet.'); + this.showEmailModal = false; + } else { + throw new Error('Server responded with an error.'); + } + } catch (e) { + console.error("Failed to send transfer email:", e); + window.notify('error', 'Fehler beim Senden der E-Mail.'); + } finally { + this.isSendingEmail = false; + } + }, + isMonthDisabled(month) { + if (this.transferInitialLoading || this.transferMonthlyLoading) return true; + if (!this.transferYearlyData?.monthlySummary) return true; + const m = this.transferYearlyData.monthlySummary.find(m => m.month === month); + return !m || m.grandTotalBytes === 0; + }, + selectYear(year) { + this.showYearDropdown = false; + if (this.transferYear !== year) this.changeTransferYear(year); + }, + async changeTransferYear(year) { + this.transferYear = year; + await this.fetchTransferYearData(); + }, + async changeTransferMonth(month) { + this.transferMonth = month; + await this.fetchTransferMonthData(); + }, + processChartData(details) { + if (!details || !details.length) return {labels: [], datasets: []}; + const daily = details.reduce((a, s) => { + const d = s.startTime.split(' ')[0]; + if (!a[d]) a[d] = {downloadBytes: 0, uploadBytes: 0}; + a[d].downloadBytes += Number(s.downloadBytes) || 0; + a[d].uploadBytes += Number(s.uploadBytes) || 0; + return a; + }, {}); + const dates = Object.keys(daily).sort((a, b) => new Date(a) - new Date(b)); + return { + labels: dates, + datasets: [{ + label: 'Download', + data: dates.map(d => daily[d].downloadBytes), + borderColor: 'rgba(15, 157, 88, 0.8)', + backgroundColor: 'rgba(15, 157, 88, 0.1)', + fill: true, + tension: 0.3, + pointRadius: 2, + borderWidth: 1.5 + }, { + label: 'Upload', + data: dates.map(d => daily[d].uploadBytes), + borderColor: 'rgba(0, 83, 132, 0.8)', + backgroundColor: 'rgba(0, 83, 132, 0.1)', + fill: true, + tension: 0.3, + pointRadius: 2, + borderWidth: 1.5 + }] + }; + }, + renderTransferChart() { + if (this.transferChartInstance) this.transferChartInstance.destroy(); + if (!this.$refs.transferChartCanvas || !this.transferMonthlyData?.details?.length || !window.Chart) return; + const d = this.processChartData(this.transferMonthlyData.details); + if (!d.labels.length) return; + const chartBackgroundColorPlugin = { + id: 'customCanvasBackgroundColor', beforeDraw: (chart) => { + const {ctx} = chart; + ctx.save(); + ctx.globalCompositeOperation = 'destination-over'; + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, chart.width, chart.height); + ctx.restore(); + } + }; + this.transferChartInstance = new Chart(this.$refs.transferChartCanvas.getContext('2d'), { + type: 'line', + data: d, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + x: { + type: 'time', + time: {unit: 'day', tooltipFormat: 'DD.MM.YYYY', displayFormats: {day: 'DD.MM'}}, + grid: {display: false}, + ticks: {maxRotation: 0, autoSkip: true, maxTicksLimit: 15} + }, + y: { + beginAtZero: true, + ticks: {callback: (v) => window.RadiusUtils.formatBytes(v, 0)}, + grid: {color: 'rgba(0,0,0,0.05)'} + } + }, + plugins: { + tooltip: {callbacks: {label: (c) => `${c.dataset.label || ''}: ${window.RadiusUtils.formatBytes(c.parsed.y)}`}}, + legend: {position: 'bottom', labels: {usePointStyle: true, boxWidth: 8, padding: 20}} + }, + interaction: {mode: 'index', intersect: false} + }, + plugins: [chartBackgroundColorPlugin] + }); + } + } +}); \ No newline at end of file From d074605be2f6e365f83b7b2325d51436fa1f66f0 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Tue, 4 Nov 2025 12:52:08 +0000 Subject: [PATCH 002/123] Update RadiusUsers.js --- public/js/pages/Radius/RadiusUsers.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/js/pages/Radius/RadiusUsers.js b/public/js/pages/Radius/RadiusUsers.js index eeb4c7908..ca066fd8c 100644 --- a/public/js/pages/Radius/RadiusUsers.js +++ b/public/js/pages/Radius/RadiusUsers.js @@ -597,7 +597,7 @@ Vue.component('radius-users', { if (data.status === 'success' && data.url) { const extensionId = this.extensionId; const message = { - type: "START_RADIUS_SCAN", + type: "INITIATE_ROUTER_LOGIN", payload: { ip: ip, url: data.url, @@ -823,4 +823,4 @@ Vue.component('radius-users', { }); } } -}); \ No newline at end of file +}); From f9a89f79b0b4212d0ad1f672680b3f492079d731 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Tue, 4 Nov 2025 16:31:37 +0100 Subject: [PATCH 003/123] Clear Bookstack article cache on first load --- public/plugins/bookstack/bookstackIntegration.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/public/plugins/bookstack/bookstackIntegration.js b/public/plugins/bookstack/bookstackIntegration.js index 59523a5bb..05417a8f6 100644 --- a/public/plugins/bookstack/bookstackIntegration.js +++ b/public/plugins/bookstack/bookstackIntegration.js @@ -1,3 +1,8 @@ +if (!localStorage.getItem('bookstack_cache_cleared')) { + localStorage.removeItem('bookstack_article_Radius'); + localStorage.setItem('bookstack_cache_cleared', 'true'); +} + document.addEventListener('DOMContentLoaded', async () => { const linkEl = document.getElementById('bookstackLink'); if (!linkEl) return; From f8306cfb7fe5e5f17a8510a4920ac4cf8942f019 Mon Sep 17 00:00:00 2001 From: Frank Schubert Date: Wed, 5 Nov 2025 17:51:20 +0100 Subject: [PATCH 004/123] Added rimo update button in AdressDB/Index --- Layout/default/AddressDB/Index.php | 5 ++++ application/AddressDB/AddressDBController.php | 24 +++++++++++++++---- lib/mvcfronk/mfBase/mfBaseController.php | 14 ++++++++++- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/Layout/default/AddressDB/Index.php b/Layout/default/AddressDB/Index.php index 0eb0cb05f..da16d14d0 100644 --- a/Layout/default/AddressDB/Index.php +++ b/Layout/default/AddressDB/Index.php @@ -189,6 +189,11 @@ can("ADBExtended") || $me->isAdmin()): ?> "> Doppelte Homes + isAdmin()): ?> + + diff --git a/application/AddressDB/AddressDBController.php b/application/AddressDB/AddressDBController.php index f8e5d4dc4..5de50426e 100644 --- a/application/AddressDB/AddressDBController.php +++ b/application/AddressDB/AddressDBController.php @@ -108,7 +108,13 @@ class AddressDBController extends mfBaseController { } $this->layout()->set("ortschaften", ADBOrtschaftModel::search($filter_filter)); } - + + if($this->request->rimoAddressUpdate) { + $this->updateAddressesInRimo(ADBHausnummerModel::search($addressdb_filter)); + unset($filter["rimoAddressUpdate"]); + $qs = http_build_query($filter); + $this->redirect("AddressDB", "index", $filter); + } } @@ -209,6 +215,13 @@ class AddressDBController extends mfBaseController { return $new_filter; } + + private function updateAddressesInRimo($addresses) { + foreach($addresses as $address) { + $address->updateAddressInRimo(); + } + $this->layout()->setFlash(count($addresses)." Adressen in Rimo aktualisiert", "success"); + } protected function viewAction() { $this->layout()->setTemplate("AddressDB/View"); @@ -941,12 +954,15 @@ class AddressDBController extends mfBaseController { if($updated) { $hausnummer->save(["no_aftersave" => true]); - if($do_rimo_update) { - $hausnummer->updateAddressInRimo(); - } $u++; } + if($do_rimo_update) { + // reload to make sure we have the latest data in caches + $hausnummer = new ADBHausnummer($hausnummer->id); + $hausnummer->updateAddressInRimo(); + } + $i++; } diff --git a/lib/mvcfronk/mfBase/mfBaseController.php b/lib/mvcfronk/mfBase/mfBaseController.php index a9cece530..80c8cf7ef 100644 --- a/lib/mvcfronk/mfBase/mfBaseController.php +++ b/lib/mvcfronk/mfBase/mfBaseController.php @@ -260,7 +260,8 @@ class mfBaseController if ($params) { if (is_array($params) && count($params)) { - $url .= (MFUSEFANCYURLS) ? "/?" : "&"; + $qs = http_build_query($params); + /*$url .= (MFUSEFANCYURLS) ? "/?" : "&"; foreach ($params as $k => $v) { $v = urlencode($v); @@ -273,10 +274,21 @@ class mfBaseController } $url = preg_replace('/&$/', '', $url); + */ } else { + $qs = $params; + /* $url .= (MFUSEFANCYURLS) ? "/?" : "&"; $url .= $params; + */ } + $url = rtrim($url, "&?"); + if(MFUSEFANCYURLS) { + $url .= "/".$qs; + } else { + $url .= "&".substr($qs, 1); + } + } if ($anker) { $url .= "#$anker"; From 27ec0712f7ae33220e2ed76ef9bdc1cc9783cfa0 Mon Sep 17 00:00:00 2001 From: Frank Schubert Date: Thu, 6 Nov 2025 12:54:47 +0100 Subject: [PATCH 005/123] Fixed redirect in mfBaseController --- lib/mvcfronk/mfBase/mfBaseController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mvcfronk/mfBase/mfBaseController.php b/lib/mvcfronk/mfBase/mfBaseController.php index 80c8cf7ef..573febfb5 100644 --- a/lib/mvcfronk/mfBase/mfBaseController.php +++ b/lib/mvcfronk/mfBase/mfBaseController.php @@ -284,7 +284,7 @@ class mfBaseController } $url = rtrim($url, "&?"); if(MFUSEFANCYURLS) { - $url .= "/".$qs; + $url .= "/?".$qs; } else { $url .= "&".substr($qs, 1); } From 99d3ad0032a873d868e39b966e8388b8b867b5bc Mon Sep 17 00:00:00 2001 From: Frank Schubert Date: Thu, 6 Nov 2025 13:03:16 +0100 Subject: [PATCH 006/123] PreorderBilling: Not creating billing record when enduser_setup is zero --- application/PreorderBilling/PreorderBillingController.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/application/PreorderBilling/PreorderBillingController.php b/application/PreorderBilling/PreorderBillingController.php index e4b8222f2..2785de0f4 100644 --- a/application/PreorderBilling/PreorderBillingController.php +++ b/application/PreorderBilling/PreorderBillingController.php @@ -398,6 +398,11 @@ class PreorderBillingController extends mfBaseController { return true; // already billed } + if($price->price_setup <= 0.01 && $price->price_setup >= 0.00000) { + $this->log->debug(__METHOD__.": Preorder ".$preorder->id." / ".$preorder->oaid." enduser_setup price is 0 so skipping..."); + return true; + } + // search for customer $customer_data = [ "company" => trim($preorder->company), From 033a465b27003955f57bff0df79e87e70585d384 Mon Sep 17 00:00:00 2001 From: Frank Schubert Date: Fri, 7 Nov 2025 13:43:30 +0100 Subject: [PATCH 007/123] Changed Unit status update in Citycom import --- scripts/adb-rimo-import/importer/citycom.php | 23 +++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/scripts/adb-rimo-import/importer/citycom.php b/scripts/adb-rimo-import/importer/citycom.php index c6751bdcc..8f47cc36b 100644 --- a/scripts/adb-rimo-import/importer/citycom.php +++ b/scripts/adb-rimo-import/importer/citycom.php @@ -134,20 +134,13 @@ class CitycomImporter { $unit->save(); } - if($unit->status->code < 300) { - $status_300 = \ADBStatusModel::getFirst(["code" => 300]); - if(!$status_300) { - die("ADB Status 300 not found"); - } - - $unit->status_id = $status_300->id; + /*if($unit->status->code < 241) { + $unit->setNewStatusCode(241); $unit->save(); - } + }*/ if($hausnummer->status->code < 241) { - $status_code_241 = \ADBStatusModel::getFirst(["code" => 241]); - - $hausnummer->status_id = $status_code_241->id; + $hausnummer->setNewStatusCode(241); $hausnummer->save(); } } @@ -235,9 +228,13 @@ class CitycomImporter { if($ont_sn || $ont_gpid) { if($preorder->status->code < 300) { - $status_300 = \PreorderstatusModel::getFirst(["code" => 300]); - $preorder->status_id = $status_300->id; + $preorder->setNewStatusCode(300); $preorder->save(); + + } + if($unit->status->code < 300) { + $unit->setNewStatusCode(300); + $unit->save(); } } From a0d259081e7942934ca514362592d3a027d8bc55 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Sun, 9 Nov 2025 20:28:25 +0100 Subject: [PATCH 008/123] added deferred changes --- Layout/default/ConstructionConsent/Index.php | 14 ++++++-------- .../ConstructionConsentController.php | 7 +++++-- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/Layout/default/ConstructionConsent/Index.php b/Layout/default/ConstructionConsent/Index.php index c2f319e5c..1403e3042 100644 --- a/Layout/default/ConstructionConsent/Index.php +++ b/Layout/default/ConstructionConsent/Index.php @@ -304,14 +304,6 @@ $pagination_entity_name = "Zustimmungserklärungen"; - -
@@ -344,6 +336,12 @@ $pagination_entity_name = "Zustimmungserklärungen";
+ + +
+
+ +
diff --git a/application/ConstructionConsent/ConstructionConsentController.php b/application/ConstructionConsent/ConstructionConsentController.php index df64be157..af6a16030 100644 --- a/application/ConstructionConsent/ConstructionConsentController.php +++ b/application/ConstructionConsent/ConstructionConsentController.php @@ -1043,10 +1043,12 @@ class ConstructionConsentController extends mfBaseController { private function generateStats($baseFilter = array()): array { function getFilteredCount($wantedFilter, $filterValue, $baseFilter) { - if (!empty($baseFilter[$wantedFilter]) && $baseFilter[$wantedFilter] != $filterValue) return 0; + if ($wantedFilter !== 'deferred' && !empty($baseFilter[$wantedFilter]) && $baseFilter[$wantedFilter] != $filterValue) return 0; return ConstructionConsent::count(array_merge($baseFilter, [$wantedFilter => $filterValue])); } + $baseFilter["deferred"] = "NULL"; + return [ "all" => ConstructionConsent::count($baseFilter), "street" => getFilteredCount("object_type", "street", $baseFilter), @@ -1058,7 +1060,8 @@ class ConstructionConsentController extends mfBaseController { "status_light_blue" => getFilteredCount("status_light", "blue", $baseFilter), "status_light_red" => getFilteredCount("status_light", "red", $baseFilter), "status_light_yellow" => getFilteredCount("status_light", "yellow", $baseFilter), - "status_light_green" => getFilteredCount("status_light", "green", $baseFilter) + "status_light_green" => getFilteredCount("status_light", "green", $baseFilter), + "status_deferred" => getFilteredCount("deferred", "!NULL", $baseFilter), ]; } From b7d7289c7f938f2a1b3885bc64683a40edefd671 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Mon, 10 Nov 2025 08:18:23 +0000 Subject: [PATCH 009/123] Update WarehouseShippingNoteController.php --- .../WarehouseShippingNote/WarehouseShippingNoteController.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/WarehouseShippingNote/WarehouseShippingNoteController.php b/application/WarehouseShippingNote/WarehouseShippingNoteController.php index 8a3f75472..fad042a77 100644 --- a/application/WarehouseShippingNote/WarehouseShippingNoteController.php +++ b/application/WarehouseShippingNote/WarehouseShippingNoteController.php @@ -7,7 +7,7 @@ class WarehouseShippingNoteController extends TTCrud { //@formatter:off protected array $columns = [ ['key' => 'id', 'text' => 'LS-Nr.', 'required' => false, 'modal' => false, 'table' => ['class' => 'text-nowrap']], - ['key' => 'billingAddressId', 'text' => 'Rechnungsadresse', 'type' => 'autocomplete', 'table' => ['class' => 'text-nowrap', 'filter' => 'autocomplete'], 'modal' => ['apiUrl' => 'Address/api?do=findAddress', 'items' => '/Address/Api?do=findAddress', 'type' => 'autocomplete']], + ['key' => 'billingAddressId', 'text' => 'Rechnungsadresse', 'type' => 'autocomplete', 'table' => false, 'modal' => ['apiUrl' => 'Address/api?do=findAddress', 'items' => '/Address/Api?do=findAddress', 'type' => 'autocomplete']], ['key' => 'status', 'text' => 'Status', 'required' => true, 'table' => ['filter' => 'iconSelect'], 'modal' => ['type' => 'iconSelect', 'items' => [ ['value' => 'new', 'text' => 'Neu', 'icon' => 'fas fa-star text-primary'], ['value' => 'in_progress', 'text' => 'In Bearbeitung', 'icon' => 'fas fa-cog text-warning'], @@ -21,7 +21,7 @@ class WarehouseShippingNoteController extends TTCrud { ['key' => 'deliveryAddressLine', 'text' => 'L.-Adr.', 'required' => true], ['key' => 'deliveryAddressPLZ', 'text' => 'L.-Adr. PLZ', 'required' => true], ['key' => 'deliveryAddressEMail', 'text' => 'L.-Adr. EMail', 'required' => false, 'table' => false], - ['key' => 'note', 'text' => 'Art der Arbeit', 'required' => true, 'table' => false], + ['key' => 'note', 'text' => 'Art der Arbeit', 'required' => true], ['key' => 'positions', 'text' => 'Positionen', 'required' => true, 'table' => false, 'modal' => false], ['key' => 'create', 'text' => 'Erstellt', 'required' => false, 'modal' => ['visible' => false], 'table' => ['filter' => 'date']], ['key' => 'createBy', 'text' => 'Erstellt von', 'required' => false, 'type' => 'autocomplete', 'table' => ['class' => 'text-nowrap', 'filter' => 'select'], 'modal' => ['items' => [], 'type' => 'select',]], From 55082eb2b28f3688dfebcc536cbe84136aeeb717 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Mon, 10 Nov 2025 08:19:03 +0000 Subject: [PATCH 010/123] Update WarehouseShippingNoteController.php --- .../WarehouseShippingNote/WarehouseShippingNoteController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/WarehouseShippingNote/WarehouseShippingNoteController.php b/application/WarehouseShippingNote/WarehouseShippingNoteController.php index fad042a77..0d0d90c89 100644 --- a/application/WarehouseShippingNote/WarehouseShippingNoteController.php +++ b/application/WarehouseShippingNote/WarehouseShippingNoteController.php @@ -7,6 +7,7 @@ class WarehouseShippingNoteController extends TTCrud { //@formatter:off protected array $columns = [ ['key' => 'id', 'text' => 'LS-Nr.', 'required' => false, 'modal' => false, 'table' => ['class' => 'text-nowrap']], + ['key' => 'note', 'text' => 'Art der Arbeit', 'required' => true], ['key' => 'billingAddressId', 'text' => 'Rechnungsadresse', 'type' => 'autocomplete', 'table' => false, 'modal' => ['apiUrl' => 'Address/api?do=findAddress', 'items' => '/Address/Api?do=findAddress', 'type' => 'autocomplete']], ['key' => 'status', 'text' => 'Status', 'required' => true, 'table' => ['filter' => 'iconSelect'], 'modal' => ['type' => 'iconSelect', 'items' => [ ['value' => 'new', 'text' => 'Neu', 'icon' => 'fas fa-star text-primary'], @@ -21,7 +22,6 @@ class WarehouseShippingNoteController extends TTCrud { ['key' => 'deliveryAddressLine', 'text' => 'L.-Adr.', 'required' => true], ['key' => 'deliveryAddressPLZ', 'text' => 'L.-Adr. PLZ', 'required' => true], ['key' => 'deliveryAddressEMail', 'text' => 'L.-Adr. EMail', 'required' => false, 'table' => false], - ['key' => 'note', 'text' => 'Art der Arbeit', 'required' => true], ['key' => 'positions', 'text' => 'Positionen', 'required' => true, 'table' => false, 'modal' => false], ['key' => 'create', 'text' => 'Erstellt', 'required' => false, 'modal' => ['visible' => false], 'table' => ['filter' => 'date']], ['key' => 'createBy', 'text' => 'Erstellt von', 'required' => false, 'type' => 'autocomplete', 'table' => ['class' => 'text-nowrap', 'filter' => 'select'], 'modal' => ['items' => [], 'type' => 'select',]], From 7ab04712b5ce1471076a991cbd1cf50c46aeabd3 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Tue, 11 Nov 2025 15:45:41 +0100 Subject: [PATCH 011/123] Add highlight class for groups with excess preorder count --- public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.js | 1 + 1 file changed, 1 insertion(+) diff --git a/public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.js b/public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.js index ecb29c303..1336d42a2 100644 --- a/public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.js +++ b/public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.js @@ -242,6 +242,7 @@ Vue.component('PreorderRimoTypeMap', { let tooltipInnerClass = ''; if (rimoType !== 'greenfield' && group.wohneinheit_count > 0 && group.wohneinheit_count === group.preorder_count) tooltipInnerClass = ' marker-label-saturated'; else if (rimoType === 'greenfield' && group.preorder_count > 0) tooltipInnerClass = ' marker-label-highlight'; + else if (group.preorder_count > group.wohneinheit_count && group.wohneinheit_count > 0) tooltipInnerClass = ' marker-label-highlight'; return { lat: group.gps_lat, lng: group.gps_long, rimoType, hausnummerId: group.hausnummer_id, options: { From dda503c615c4f3d87e256d85c9738fecd31dd97e Mon Sep 17 00:00:00 2001 From: Frank Schubert Date: Tue, 11 Nov 2025 18:32:46 +0100 Subject: [PATCH 012/123] Added button to create Netzbau Object from AdressDB --- Layout/default/AddressDB/Index.php | 4 + Layout/default/Building/Form.php | 9 +- application/Building/BuildingController.php | 136 ++++++++++++++++---- application/Building/BuildingModel.php | 1 + 4 files changed, 121 insertions(+), 29 deletions(-) diff --git a/Layout/default/AddressDB/Index.php b/Layout/default/AddressDB/Index.php index da16d14d0..5256baeb6 100644 --- a/Layout/default/AddressDB/Index.php +++ b/Layout/default/AddressDB/Index.php @@ -335,6 +335,10 @@ $address->id])?>"> $address->id])?>" class="pl-1"> $address->id])?>" onclick="if(!confirm('Addresse und alle Wohneinheiten wirklich löschen?')) return false;"> + + is("Admin")): ?> + $address->id])?>" target="_blank"> + diff --git a/Layout/default/Building/Form.php b/Layout/default/Building/Form.php index dddc0cf82..666997cac 100644 --- a/Layout/default/Building/Form.php +++ b/Layout/default/Building/Form.php @@ -28,6 +28,9 @@
+ + +
@@ -70,7 +73,11 @@
- + units == "from_adb"): ?> + + + +
diff --git a/application/Building/BuildingController.php b/application/Building/BuildingController.php index 1999bb46e..4cc2c1786 100644 --- a/application/Building/BuildingController.php +++ b/application/Building/BuildingController.php @@ -177,6 +177,9 @@ class BuildingController extends mfBaseController { protected function saveAction() { $r = $this->request; $id = $r->id; + + $adb_hausnummer_id = null; + //var_dump($r);exit; if(is_numeric($id) && $id > 0) { $mode = "edit"; @@ -187,6 +190,9 @@ class BuildingController extends mfBaseController { } } else { $mode = "add"; + if($r->adb_hausnummer_id) { + $adb_hausnummer_id = $r->adb_hausnummer_id; + } } if(!$r->network_id || !$r->type_id) { @@ -215,10 +221,17 @@ class BuildingController extends mfBaseController { $data['contact'] = trim($r->contact); $data['phone'] = trim($r->phone); $data['email'] = trim($r->email); - $data['units'] = trim($r->units); + $data['description'] = trim($r->description); $data['note'] = trim($r->note); - + + if($adb_hausnummer_id) { + $data["adb_hausnummer_id"] = $adb_hausnummer_id; + $data['units'] = 0; + } else { + $data['units'] = trim($r->units); + } + if($this->me->is(["Admin", "netowner"])) { if($r->gps_lat) $data['gps_lat'] = trim($r->gps_lat); if($r->gps_long) $data['gps_long'] = trim($r->gps_long); @@ -235,7 +248,7 @@ class BuildingController extends mfBaseController { // check if building exists already $checkBuilding = BuildingModel::search(['=street' => $data['street'], '=city' => $data['city'], '=zip' => $data['zip']]); - + if($checkBuilding) { $this->layout()->setFlash("Objekt ist bereits vorhanden!", "error"); $this->layout()->set("building", $building); @@ -283,30 +296,49 @@ class BuildingController extends mfBaseController { } // Anschlüsse anlegen - - if(!$building->terminations && $building->units > 0) { - for($i = 1; $i <= $building->units; $i++) { - $data = []; - $data['building_id'] = $building->id; - $data['code'] = $building->code . "." . sprintf("%03d", $i); - - if($building->units == 1) { - $data['contact'] = $building->contact; - $data['phone'] = $building->phone; - $data['email'] = $building->email; - } - /* - // no more lineworker_id in Termination - if($building->lineworker_id) { - $data['lineworker_id'] = $building->lineworker_id; - }*/ - if($building->oaid) { - $data['oaid'] = $building->oaid. "." . sprintf("%03d", $i); - } - - $term = TerminationModel::create($data); - $term->save(); - } + + if($mode == "add" && $adb_hausnummer_id) { + $i = 1; + foreach(ADBWohneinheitModel::search(["hausnummer_id" => $adb_hausnummer_id]) as $wohneinheit) { + $data = []; + $data['building_id'] = $building->id; + + $oaid_parts = explode(".", $wohneinheit->oaid); + if($wohneinheit->oaid && array_key_exists(1, $oaid_parts)) { + $data['code'] = $building->oaid . "." . $oaid_parts[1]; + } else { + $data['code'] = $building->code . "." . sprintf("%04d", $i); + } + $data['oaid'] = $data['code']; + + $term = TerminationModel::create($data); + $term->save(); + + $i++; + } + } elseif(!$building->terminations && $building->units > 0) { + for($i = 1; $i <= $building->units; $i++) { + $data = []; + $data['building_id'] = $building->id; + $data['code'] = $building->code . "." . sprintf("%03d", $i); + + if($building->units == 1) { + $data['contact'] = $building->contact; + $data['phone'] = $building->phone; + $data['email'] = $building->email; + } + /* + // no more lineworker_id in Termination + if($building->lineworker_id) { + $data['lineworker_id'] = $building->lineworker_id; + }*/ + if($building->oaid) { + $data['oaid'] = $building->oaid. "." . sprintf("%03d", $i); + } + + $term = TerminationModel::create($data); + $term->save(); + } } @@ -343,7 +375,55 @@ class BuildingController extends mfBaseController { $this->layout()->setFlash("Objekt gelöscht", "success"); $this->redirect("Building"); } - + + protected function createFromAdbAction() { + $adb_hausnummer_id = $this->request->adb_hausnummer_id; + + if(!$adb_hausnummer_id) { + $this->layout()->setFlash("AddressDB Adresse nicht gefunden.", "error"); + $this->redirect("AddressDB"); + } + + $hausnummer = new ADBHausnummer($adb_hausnummer_id); + if(!$hausnummer->id) { + $this->layout()->setFlash("AddressDB Adresse nicht gefunden.", "error"); + $this->redirect("AddressDB"); + } + + $network = NetworkModel::getFirst(["adb_netzgebiet_id" => $hausnummer->netzgebiet_id]); + if(!$network) { + $this->layout()->setFlash("AddressDB-Netzgebiet ist keinem Netzbau-Netzgebiet zugeordnet!", "error"); + $this->redirect("AddressDB"); + } + + $building_data = []; + $building_data["adb_hausnummer_id"] = $hausnummer->id; + $building_data["network_id"] = $network->id; + $building_data["type_id"] = ($hausnummer->tool_building_type <= 1) ? 1 : 3; // 1 = EFH | 2 = MPH + $building_data["status_id"] = 1; + + if($hausnummer->oaid) { + if(!BuildingModel::getFirst(["code" => $hausnummer->oaid])) { + $building_data["code"] = $hausnummer->oaid; + } + $building_data["oaid"] = $hausnummer->oaid; + } + + $building_data["street"] = $hausnummer->strasse->name . " ".$hausnummer->hausnummer; + $building_data["city"] = $hausnummer->strasse->gemeinde->name; + $building_data["zip"] = $hausnummer->plz->plz; + $building_data["gps_lat"] = $hausnummer->gps_lat; + $building_data["gps_long"] = $hausnummer->gps_long; + $building_data["units"] = "from_adb"; + $building_data["note"] = "Created from ADB Address {$hausnummer->id}"; + + $building = BuildingModel::create($building_data); + $this->layout()->set("building", $building); + $this->layout()->set("adb_hausnummer_id", $hausnummer->id); + + return $this->addAction(); + } + protected function apiAction() { if(!$this->me->is(["Admin","netowner","pipeplanner"])) { diff --git a/application/Building/BuildingModel.php b/application/Building/BuildingModel.php index 7e0891345..19e55f9a5 100644 --- a/application/Building/BuildingModel.php +++ b/application/Building/BuildingModel.php @@ -1,6 +1,7 @@ Date: Tue, 11 Nov 2025 18:33:25 +0100 Subject: [PATCH 013/123] added migration to resize oaid in Building/Termination --- ...20251111171837_termination_resize_code.php | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 db/migrations/20251111171837_termination_resize_code.php diff --git a/db/migrations/20251111171837_termination_resize_code.php b/db/migrations/20251111171837_termination_resize_code.php new file mode 100644 index 000000000..6507c09d6 --- /dev/null +++ b/db/migrations/20251111171837_termination_resize_code.php @@ -0,0 +1,36 @@ +getEnvironment() == "thetool") { + $table = $this->table("Termination"); + $table->changeColumn("code", "string", ["length" => 64, "null" => true, "default" => null]); + $table->update(); + + $table = $this->table("Building"); + $table->changeColumn("code", "string", ["length" => 64, "null" => true, "default" => null]); + $table->changeColumn("oaid", "string", ["length" => 255, "null" => true, "default" => null]); + $table->update(); + } + + if($this->getEnvironment() == "addressdb") { + + } + } + + public function down(): void + { + if($this->getEnvironment() == "thetool") { + + } + + if($this->getEnvironment() == "addressdb") { + + } + } +} From d00f340a254f38dcb6430075dbb9b74c362df7ae Mon Sep 17 00:00:00 2001 From: Frank Schubert Date: Tue, 11 Nov 2025 20:03:28 +0100 Subject: [PATCH 014/123] Added Call detail CSV to invoice list --- Layout/default/Invoice/Index.php | 3 +- application/Invoice/InvoiceController.php | 106 ++++++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/Layout/default/Invoice/Index.php b/Layout/default/Invoice/Index.php index 5f269cc04..32fcff61e 100644 --- a/Layout/default/Invoice/Index.php +++ b/Layout/default/Invoice/Index.php @@ -342,7 +342,8 @@ $pagination_entity_name = "Rechnungen"; billing_type == "sepa") ? "SEPA" : "Überweisung"?> billing_delivery == "email") ? "Email" : "Papier"?> - $invoice->id])?>" title="CSV-Download"> + $invoice->id])?>" title="CSV-Download"> + $invoice->id])?>" title="CSV-Download"> diff --git a/application/Invoice/InvoiceController.php b/application/Invoice/InvoiceController.php index 1871ffd2e..f860ed672 100644 --- a/application/Invoice/InvoiceController.php +++ b/application/Invoice/InvoiceController.php @@ -212,6 +212,112 @@ class InvoiceController extends mfBaseController { exit; } + protected function downloadInvoiceVoiceDetailsAction() { + $id = $this->request->id; + if (!is_numeric($id) || !$id) { + $this->layout()->setFlash("Rechnung nicht gefunden", "error"); + $this->redirect("Invoice"); + } + + $invoice = new Invoice($id); + if (!$invoice->id) { + $this->layout()->setFlash("Rechnung nicht gefunden", "error"); + $this->redirect("Invoice"); + } + + $csv = "Startzeit;Abgehende Nummer;Zielnummer;Zone;Dauer;Kosten\n"; + + $destinations_cache = []; + + foreach($invoice->voicenumbers as $voicenumber) { + $start_date = new DateTime($voicenumber->start_date); + //$start_date->setTimezone(new DateTimeZone("Europe/Vienna")); + $start_date->setTime(0,0,0); + $end_date = new DateTime($voicenumber->end_date); + //$end_date->setTimezone(new DateTimeZone("Europe/Vienna")); + $end_date->setTime(23,59,59); + + foreach(VoiceCallHistoryModel::getVoiceCallHistoryAsEntity([ + "voice_account" => $voicenumber->voicenumber, + "start" => [ + "from" => $start_date->getTimestamp(), + "to" => $end_date->getTimestamp() + ], + "billable" => 1, + ], null, 0, ["key" => "start", "order" => "ASC"]) as $call) { + if(!$call->contract_id) continue; + + //$voiceplan = new Voiceplan($voicenumber->voiceplan_id); + $voiceplan = VoiceplanModel::getFirst(["name" => $voicenumber->voiceplan]); + if(!$voiceplan) { + $this->log->warning(__METHOD__.": Voiceplan not found"); + }; + + $number = $voicenumber->voicenumber; + $dest_nummer = $call->destination; + if (substr($dest_nummer, 0, 2) == "00") { + $dest_nummer = substr($dest_nummer, 2); + } + + if (substr($dest_nummer, 0, 1) == "+") { + $dest_nummer = substr($dest_nummer, 1); + } + + if (array_key_exists($dest_nummer, $destinations_cache)) { + $destination = $destinations_cache[$dest_nummer]; + } else { + $destination = $voiceplan->getDestinationByNumber($dest_nummer); + if (!$destination) { + die("Destination für Zielrufnummer " . $call->destination . " nicht gefunden"); + } + $destinations_cache[$dest_nummer] = $destination; + } + //var_dump($destination); + + $zone = $destination->voiceplanzone; + + if (!$zone) { + die("Keine Zone für Destination " . $dest_nummer . " gefunden"); + } + + //var_dump($zone);exit; + + // inc_first - first minimumm duration to bill + // inc - subsequent minimum duration to bill + $inc_first = $zone->increment_first; + $inc = $zone->increment; + + $billable_duration = $call->duration; + if ($billable_duration <= 0) continue; + + // calculate price of first duration unit + // then subtract first minimum duration from duration + $sec_price = $zone->price / 60; + $call_price = $inc_first * $sec_price; + $billable_duration -= $inc_first; + + // calculate price of remaining duration and make sure to bill in full duration units + if ($billable_duration > 0) { + $multi = ceil($billable_duration / $inc); + $call_price += ($multi * $inc) * $sec_price; + } + + $csv .= '"'.$call->start.'";'; + $csv .= '"'.$call->source.'"; '; + $csv .= '"'.$call->destination.'"; '; + $csv .= '"'.$zone->name.'"; '; + $csv .= $call->duration.'; '; + $csv .= '"'.str_replace(".",",", $call_price).'"'; + $csv .= "\n"; + } + + } + + header("Content-type: text/csv; charset=utf-8"); + header('Content-disposition: attachment; filename="'.$invoice->invoice_number.'-egn.csv"'); + echo $csv; + exit; + } protected function createJob() { $r = $this->request; From 937b71244debecf75aa64a6f3480ebe3cbd44005 Mon Sep 17 00:00:00 2001 From: Frank Schubert Date: Tue, 11 Nov 2025 21:41:32 +0100 Subject: [PATCH 015/123] Fixed invoicing only one of several voicezones with same name --- application/Billing/BillingController.php | 10 +++-- .../BillingVoicenumberModel.php | 1 + application/Invoice/InvoiceController.php | 39 ++++++++++++------- ...201335_billing_voicenumber_add_zone_id.php | 31 +++++++++++++++ 4 files changed, 64 insertions(+), 17 deletions(-) create mode 100644 db/migrations/20251111201335_billing_voicenumber_add_zone_id.php diff --git a/application/Billing/BillingController.php b/application/Billing/BillingController.php index 0425519d3..9f0694e9f 100644 --- a/application/Billing/BillingController.php +++ b/application/Billing/BillingController.php @@ -153,6 +153,7 @@ class BillingController extends mfBaseController { $v = 0; $today = new DateTime("now"); + //$today = new DateTime("2026-01-09"); $today->setTime(0,0,0); $now_year = date("Y"); @@ -191,7 +192,7 @@ class BillingController extends mfBaseController { $contract_search = [ "finish_date<" => mktime(2,0,0,$now_month, $now_day, $now_year), "cancel_date_null_or_gte" => mktime(0,0,0,$now_month, 1, $now_year), - //"owner_id" => 1221 + "owner_id" => 7719 ]; foreach(ContractModel::search($contract_search) as $contract) { @@ -715,6 +716,7 @@ class BillingController extends mfBaseController { } if (!array_key_exists($zone->id, $voicebills[$number][$call_date_start])) { $voicebills[$number][$call_date_start][$zone->id] = [ + //"zone_id" => $zone->id, "zone_name" => $zone->name, "voiceplan" => $voiceplan->name, "duration" => 0, @@ -738,8 +740,8 @@ class BillingController extends mfBaseController { // save to BillingVoicenumber foreach($voicebills as $vbnumber => $zones) { - foreach($zones as $zone_id => $zone) { - foreach($zone as $zone_start_date => $vb) { + foreach($zones as $zone_start_date => $zone) { + foreach($zone as $zone_id => $vb) { $vbdata = []; $vbdata["billing_id"] = $billing->id; $vbdata["contract_id"] = $contract->id; @@ -747,6 +749,7 @@ class BillingController extends mfBaseController { $vbdata["start_date"] = $vb["start_date"]; $vbdata["end_date"] = $vb["end_date"]; $vbdata["voiceplan"] = $vb["voiceplan"]; + $vbdata["zone_id"] = $zone_id; $vbdata["zone"] = $vb["zone_name"]; $vbdata["call_count"] = $vb["count"]; $vbdata["duration"] = $vb["duration"]; @@ -756,6 +759,7 @@ class BillingController extends mfBaseController { $vbdata["increment_first"] = $vb["increment_first"]; $bill_voice = BillingVoicenumberModel::create($vbdata); + $this->log->debug(__METHOD__.": billingvoicenumber record created: ".print_r($vbdata, true)); if(!$bill_voice->save()) { var_dump($vbdata); die("Error saving Billing Voicenumber!"); diff --git a/application/BillingVoicenumber/BillingVoicenumberModel.php b/application/BillingVoicenumber/BillingVoicenumberModel.php index f44184c85..f205854a8 100644 --- a/application/BillingVoicenumber/BillingVoicenumberModel.php +++ b/application/BillingVoicenumber/BillingVoicenumberModel.php @@ -7,6 +7,7 @@ class BillingVoicenumberModel { public $start_date; public $end_date; public $voiceplan; + public $zone_id; public $zone; public $call_count; public $duration; diff --git a/application/Invoice/InvoiceController.php b/application/Invoice/InvoiceController.php index f860ed672..6d44febed 100644 --- a/application/Invoice/InvoiceController.php +++ b/application/Invoice/InvoiceController.php @@ -227,8 +227,9 @@ class InvoiceController extends mfBaseController { $csv = "Startzeit;Abgehende Nummer;Zielnummer;Zone;Dauer;Kosten\n"; + $total = 0; $destinations_cache = []; - + //var_dump($invoice->voicenumbers);exit; foreach($invoice->voicenumbers as $voicenumber) { $start_date = new DateTime($voicenumber->start_date); //$start_date->setTimezone(new DateTimeZone("Europe/Vienna")); @@ -237,6 +238,8 @@ class InvoiceController extends mfBaseController { //$end_date->setTimezone(new DateTimeZone("Europe/Vienna")); $end_date->setTime(23,59,59); + $call_date_start = $start_date->format("Y-m-d"); + foreach(VoiceCallHistoryModel::getVoiceCallHistoryAsEntity([ "voice_account" => $voicenumber->voicenumber, "start" => [ @@ -251,7 +254,8 @@ class InvoiceController extends mfBaseController { $voiceplan = VoiceplanModel::getFirst(["name" => $voicenumber->voiceplan]); if(!$voiceplan) { $this->log->warning(__METHOD__.": Voiceplan not found"); - }; + exit; + } $number = $voicenumber->voicenumber; $dest_nummer = $call->destination; @@ -309,6 +313,9 @@ class InvoiceController extends mfBaseController { $csv .= $call->duration.'; '; $csv .= '"'.str_replace(".",",", $call_price).'"'; $csv .= "\n"; + + $total += $call_price; + } } @@ -486,34 +493,38 @@ class InvoiceController extends mfBaseController { $inc = reset($voicebills)->increment; $inc_first = reset($voicebills)->increment_first; + $zoneId2ZoneName = []; + $voice_rows = []; foreach ($voicebills as $voicebill) { $number = $voicebill->voicenumber; + $zone_id = $voicebill->zone_id; $zone = $voicebill->zone; $call_count = $voicebill->call_count; $duration = $voicebill->duration; $price = $voicebill->price; $price_total = $voicebill->price_total; + $zoneId2ZoneName[$zone_id] = $zone; if (!array_key_exists($number, $voice_rows)) { $voice_rows[$number] = []; } - if (!array_key_exists($zone, $voice_rows[$number])) { - $voice_rows[$number][$zone] = []; + if (!array_key_exists($zone_id, $voice_rows[$number])) { + $voice_rows[$number][$zone_id] = []; } - if (!array_key_exists($price, $voice_rows[$number][$zone])) { - $voice_rows[$number][$zone][$price] = []; - $voice_rows[$number][$zone][$price]["call_count"] = 0; - $voice_rows[$number][$zone][$price]["duration"] = 0; - $voice_rows[$number][$zone][$price]["price_total"] = 0; + if (!array_key_exists($price, $voice_rows[$number][$zone_id])) { + $voice_rows[$number][$zone_id][$price] = []; + $voice_rows[$number][$zone_id][$price]["call_count"] = 0; + $voice_rows[$number][$zone_id][$price]["duration"] = 0; + $voice_rows[$number][$zone_id][$price]["price_total"] = 0; } - $voice_rows[$number][$zone][$price]["call_count"] += $call_count; - $voice_rows[$number][$zone][$price]["duration"] = $duration; - $voice_rows[$number][$zone][$price]["price_total"] = $price_total; + $voice_rows[$number][$zone_id][$price]["call_count"] += $call_count; + $voice_rows[$number][$zone_id][$price]["duration"] = $duration; + $voice_rows[$number][$zone_id][$price]["price_total"] = $price_total; } //var_dump($voice_rows);exit; @@ -533,10 +544,10 @@ class InvoiceController extends mfBaseController { $invoice_voicenumber->voicenumberzones = []; - foreach ($zones as $zone => $prices) { + foreach ($zones as $zone_id => $prices) { foreach ($prices as $price => $row_values) { $zone_data = []; - $zone_data["zone"] = $zone; + $zone_data["zone"] = $zoneId2ZoneName[$zone_id]; $zone_data["call_count"] = $row_values["call_count"]; $zone_data["duration"] = $row_values["duration"]; $zone_data["price"] = $price; diff --git a/db/migrations/20251111201335_billing_voicenumber_add_zone_id.php b/db/migrations/20251111201335_billing_voicenumber_add_zone_id.php new file mode 100644 index 000000000..611f2108f --- /dev/null +++ b/db/migrations/20251111201335_billing_voicenumber_add_zone_id.php @@ -0,0 +1,31 @@ +getEnvironment() == "thetool") { + $table = $this->table("BillingVoicenumber"); + $table->addColumn("zone_id", "integer", ["null" => true, "default" => null, "after" => "voiceplan"]); + $table->update(); + } + + if($this->getEnvironment() == "addressdb") { + + } + } + + public function down(): void + { + if($this->getEnvironment() == "thetool") { + $this->table("BillingVoicenumber")->removeColumn("zone_id")->update(); + } + + if($this->getEnvironment() == "addressdb") { + + } + } +} From 9e550f4c4f76cb49e2aff19607257eb2b9ded26a Mon Sep 17 00:00:00 2001 From: Frank Schubert Date: Tue, 11 Nov 2025 21:42:52 +0100 Subject: [PATCH 016/123] Fixed invoicing only one of several voicezones with same name --- application/Billing/BillingController.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/application/Billing/BillingController.php b/application/Billing/BillingController.php index 9f0694e9f..7c4c0b915 100644 --- a/application/Billing/BillingController.php +++ b/application/Billing/BillingController.php @@ -153,7 +153,6 @@ class BillingController extends mfBaseController { $v = 0; $today = new DateTime("now"); - //$today = new DateTime("2026-01-09"); $today->setTime(0,0,0); $now_year = date("Y"); @@ -192,7 +191,7 @@ class BillingController extends mfBaseController { $contract_search = [ "finish_date<" => mktime(2,0,0,$now_month, $now_day, $now_year), "cancel_date_null_or_gte" => mktime(0,0,0,$now_month, 1, $now_year), - "owner_id" => 7719 + //"owner_id" => 7719 ]; foreach(ContractModel::search($contract_search) as $contract) { From 5b7c8d3c645dbdc326f4873074c7019ebe7c8ddb Mon Sep 17 00:00:00 2001 From: Frank Schubert Date: Tue, 11 Nov 2025 21:46:22 +0100 Subject: [PATCH 017/123] Fixed title attribute on egn download button --- Layout/default/Invoice/Index.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Layout/default/Invoice/Index.php b/Layout/default/Invoice/Index.php index 32fcff61e..f80df6423 100644 --- a/Layout/default/Invoice/Index.php +++ b/Layout/default/Invoice/Index.php @@ -343,7 +343,7 @@ $pagination_entity_name = "Rechnungen"; billing_delivery == "email") ? "Email" : "Papier"?> $invoice->id])?>" title="CSV-Download"> - $invoice->id])?>" title="CSV-Download"> + $invoice->id])?>" title="Download EGN"> From 63e78489f38c62af95aaefec65c0758c7fc7fb74 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Wed, 12 Nov 2025 12:53:20 +0000 Subject: [PATCH 018/123] Update WorkorderBaseController.php --- .../WorkorderBase/WorkorderBaseController.php | 44 +++++++++++++++---- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/application/WorkorderBase/WorkorderBaseController.php b/application/WorkorderBase/WorkorderBaseController.php index 5e1e2ca56..1811b6d1d 100644 --- a/application/WorkorderBase/WorkorderBaseController.php +++ b/application/WorkorderBase/WorkorderBaseController.php @@ -167,14 +167,40 @@ class WorkorderBaseController extends TTCrud $filters['preordercampaign_id'] = $tenantCampaigns; $newPreorders = PreorderModel::searchActive($filters); - foreach ($newPreorders as $preorder) { - if (!WorkorderModel::getFirst(['preorderId' => $preorder->id])) { - WorkorderModel::create([ - 'preorderId' => $preorder->id, 'clusterId' => $preorder->preordercampaign_id, - 'status' => 'new', 'create' => time(), 'createBy' => 0 // System User - ]); - } - } +foreach ($newPreorders as $preorder) { + $existingWorkorder = WorkorderModel::getFirst(['preorderId' => $preorder->id]); + + if ($existingWorkorder) { + if ($existingWorkorder->status === 'archived') { + $oldStatus = $existingWorkorder->status; + + WorkorderModel::update($existingWorkorder->id, [ + 'status' => 'new', + 'companyId' => null, + 'civilEngineeringCompanyId' => null, + 'deadlineDate' => null, + 'appointmentDate' => null, + 'clusterId' => $preorder->preordercampaign_id, + ]); + + WorkorderJournalModel::create([ + 'workorderId' => $existingWorkorder->id, + 'text' => 'Arbeitsauftrag wurde automatisch reaktiviert, da die zugehörige Vorbestellung wieder den Kriterien entspricht.', + 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('new'), + 'create' => time(), + 'createBy' => 1, + ]); + } + } else { + WorkorderModel::create([ + 'preorderId' => $preorder->id, + 'clusterId' => $preorder->preordercampaign_id, + 'status' => 'new', + 'create' => time(), + 'createBy' => 1 + ]); + } +} } file_put_contents($lockFile, time()); } @@ -243,4 +269,4 @@ class WorkorderBaseController extends TTCrud file_put_contents($lockFile, time()); } //endregion -} \ No newline at end of file +} From 0b88c73bcf1506f452104224e0d5e4b5291b230e Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Wed, 12 Nov 2025 12:55:55 +0000 Subject: [PATCH 019/123] Update WorkorderBaseController.php --- .../WorkorderBase/WorkorderBaseController.php | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/application/WorkorderBase/WorkorderBaseController.php b/application/WorkorderBase/WorkorderBaseController.php index 1811b6d1d..a5f203b85 100644 --- a/application/WorkorderBase/WorkorderBaseController.php +++ b/application/WorkorderBase/WorkorderBaseController.php @@ -173,15 +173,16 @@ foreach ($newPreorders as $preorder) { if ($existingWorkorder) { if ($existingWorkorder->status === 'archived') { $oldStatus = $existingWorkorder->status; - - WorkorderModel::update($existingWorkorder->id, [ - 'status' => 'new', - 'companyId' => null, - 'civilEngineeringCompanyId' => null, - 'deadlineDate' => null, - 'appointmentDate' => null, - 'clusterId' => $preorder->preordercampaign_id, - ]); + + $new = (array) $existingWorkorder; + + $new['status'] = 'new'; + $new['companyId'] = null; + $new['civilEngineeringCompanyId'] = null; + $new['deadlineDate'] = null; + $new['appointmentDate'] = null; + $new['clusterId'] = $preorder->preordercampaign_id; + WorkorderModel::update($new); WorkorderJournalModel::create([ 'workorderId' => $existingWorkorder->id, From 988fad1a673b37fba80cccd0dbf6c08b35ec5f0b Mon Sep 17 00:00:00 2001 From: Frank Schubert Date: Wed, 12 Nov 2025 14:30:05 +0100 Subject: [PATCH 020/123] TT_PREORDER_RIMO_STATUS_MATRIX now has netowner specific config --- application/AddressDB/AddressDB.php | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/application/AddressDB/AddressDB.php b/application/AddressDB/AddressDB.php index 3c5856bd7..39db020c9 100644 --- a/application/AddressDB/AddressDB.php +++ b/application/AddressDB/AddressDB.php @@ -130,9 +130,13 @@ class AddressDB { } + $netowner = $hausnummer->getNetowner(); + if(!$netowner || !$netowner->id) { + $log->debug(__METHOD__.": Unable to determine netowner for Hausnummer ".$hausnummer->id); + return true; + } - - $status_matrix = array_reverse(TT_PREORDER_RIMO_STATUS_MATRIX); + $status_matrix = array_reverse(TT_PREORDER_RIMO_STATUS_MATRIX[$netowner->id]); $log->debug(__METHOD__.": b_ex_state: ".$b_ex_state); $log->debug(__METHOD__.": b_op_state: ".$b_op_state); @@ -218,7 +222,6 @@ class AddressDB { if($hausnummer->status_id != $old_status) { $hausnummer->save(); } - } //$wohneinheit = new ADBWohneinheit($wohneinheit->id); @@ -234,9 +237,18 @@ class AddressDB { } + if(array_key_exists("hf", $matrix)) { + $hausnummer_flag = $matrix["hf"]; + if($hausnummer_flag) { + $log->debug(__METHOD__.": new Hausnummer (".$wohneinheit->id.") statusflag: ".$matrix["hf"]); + $hausnummer->setStatusflag($hausnummer_flag, 1); + } + } - break; + + // commented, for 140/141 + //break; } return true; } From d084235f88abd125ad8ec6e49a7f0d6c119cb0d7 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Wed, 12 Nov 2025 14:46:19 +0100 Subject: [PATCH 021/123] fixed workorder stuff --- .../WorkorderBase/WorkorderBaseController.php | 62 +++++++++---------- 1 file changed, 30 insertions(+), 32 deletions(-) diff --git a/application/WorkorderBase/WorkorderBaseController.php b/application/WorkorderBase/WorkorderBaseController.php index a5f203b85..dac63aa6b 100644 --- a/application/WorkorderBase/WorkorderBaseController.php +++ b/application/WorkorderBase/WorkorderBaseController.php @@ -167,41 +167,39 @@ class WorkorderBaseController extends TTCrud $filters['preordercampaign_id'] = $tenantCampaigns; $newPreorders = PreorderModel::searchActive($filters); -foreach ($newPreorders as $preorder) { - $existingWorkorder = WorkorderModel::getFirst(['preorderId' => $preorder->id]); + foreach ($newPreorders as $preorder) { + $existingWorkorder = (array) WorkorderModel::getFirst(['preorderId' => $preorder->id]); - if ($existingWorkorder) { - if ($existingWorkorder->status === 'archived') { - $oldStatus = $existingWorkorder->status; + if ($existingWorkorder) { + if ($existingWorkorder['status'] === 'archived') { + $oldStatus = $existingWorkorder['status']; + $new = (array) $existingWorkorder; - $new = (array) $existingWorkorder; + $new['status'] = 'new'; + $new['companyId'] = null; + $new['civilEngineeringCompanyId'] = null; + $new['deadlineDate'] = null; + $new['appointmentDate'] = null; + $new['clusterId'] = $preorder->preordercampaign_id; - $new['status'] = 'new'; - $new['companyId'] = null; - $new['civilEngineeringCompanyId'] = null; - $new['deadlineDate'] = null; - $new['appointmentDate'] = null; - $new['clusterId'] = $preorder->preordercampaign_id; - WorkorderModel::update($new); - - WorkorderJournalModel::create([ - 'workorderId' => $existingWorkorder->id, - 'text' => 'Arbeitsauftrag wurde automatisch reaktiviert, da die zugehörige Vorbestellung wieder den Kriterien entspricht.', - 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('new'), - 'create' => time(), - 'createBy' => 1, - ]); - } - } else { - WorkorderModel::create([ - 'preorderId' => $preorder->id, - 'clusterId' => $preorder->preordercampaign_id, - 'status' => 'new', - 'create' => time(), - 'createBy' => 1 - ]); - } -} + WorkorderJournalModel::create([ + 'workorderId' => $existingWorkorder['id'], + 'text' => 'Arbeitsauftrag wurde automatisch reaktiviert, da die zugehörige Vorbestellung wieder den Kriterien entspricht.', + 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('new'), + 'create' => time(), + 'createBy' => 1, + ]); + } + } else { + WorkorderModel::create([ + 'preorderId' => $preorder->id, + 'clusterId' => $preorder->preordercampaign_id, + 'status' => 'new', + 'create' => time(), + 'createBy' => 1 + ]); + } + } } file_put_contents($lockFile, time()); } From a0f5a22ced00f96d169b360e2785597b1bff2ab6 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Wed, 12 Nov 2025 14:48:56 +0100 Subject: [PATCH 022/123] fixed workorder stuff --- application/WorkorderBase/WorkorderBaseController.php | 1 + 1 file changed, 1 insertion(+) diff --git a/application/WorkorderBase/WorkorderBaseController.php b/application/WorkorderBase/WorkorderBaseController.php index dac63aa6b..4ef7f3c82 100644 --- a/application/WorkorderBase/WorkorderBaseController.php +++ b/application/WorkorderBase/WorkorderBaseController.php @@ -181,6 +181,7 @@ class WorkorderBaseController extends TTCrud $new['deadlineDate'] = null; $new['appointmentDate'] = null; $new['clusterId'] = $preorder->preordercampaign_id; + WorkorderModel::update($new); WorkorderJournalModel::create([ 'workorderId' => $existingWorkorder['id'], From 4abba987b99201642de0a276988dad864ae22d24 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Thu, 13 Nov 2025 05:19:05 +0000 Subject: [PATCH 023/123] Update WarehouseEShopOrderController.php --- .../WarehouseEShopOrder/WarehouseEShopOrderController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/WarehouseEShopOrder/WarehouseEShopOrderController.php b/application/WarehouseEShopOrder/WarehouseEShopOrderController.php index 6300603d0..b1d2e9062 100644 --- a/application/WarehouseEShopOrder/WarehouseEShopOrderController.php +++ b/application/WarehouseEShopOrder/WarehouseEShopOrderController.php @@ -151,7 +151,7 @@ class WarehouseEShopOrderController extends TTCrud { 'deliveryAddressPLZ' => $order->deliveryAddressPLZ, 'deliveryAddressCity' => $order->deliveryAddressCity, 'deliveryAddressEMail' => '', - 'note' => 'Erstellung aus Shop Bestellung #' . $id, + 'note' => 'Erstellung aus Shop Bestellung #' . $id . " | Externe Referenz: " . $order->extRef, 'status' => 'new', 'positions' => $positions, 'textElements' => '[]', From be2c22ca50644501dc8b51ac0aa7ead47cc2f74c Mon Sep 17 00:00:00 2001 From: Frank Schubert Date: Thu, 13 Nov 2025 13:11:13 +0100 Subject: [PATCH 024/123] Fixed uploading documents in closed Orders --- Layout/default/Order/Form.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Layout/default/Order/Form.php b/Layout/default/Order/Form.php index 7b28f713c..a513c694e 100644 --- a/Layout/default/Order/Form.php +++ b/Layout/default/Order/Form.php @@ -1860,7 +1860,7 @@ } - reader.readAsText(selectedFile); + reader.readAsArrayBuffer(selectedFile); }); From 7dc87250d30af672b3a3364fe1e2bfaf7e2334b0 Mon Sep 17 00:00:00 2001 From: Frank Schubert Date: Fri, 14 Nov 2025 15:52:02 +0100 Subject: [PATCH 025/123] Fixed rimo misspelling in Wettmannstaetten Import --- .../Network/Network-C03030-wettmannstaetten.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/adb-rimo-import/ADBAddressHelper/Network/Network-C03030-wettmannstaetten.php b/scripts/adb-rimo-import/ADBAddressHelper/Network/Network-C03030-wettmannstaetten.php index e6d082d8c..e3af0dfc7 100644 --- a/scripts/adb-rimo-import/ADBAddressHelper/Network/Network-C03030-wettmannstaetten.php +++ b/scripts/adb-rimo-import/ADBAddressHelper/Network/Network-C03030-wettmannstaetten.php @@ -21,6 +21,8 @@ class Network_C03030 { //if($strasse_name == "Radlpass Straße") $strasse_name = "Radlpaßstraße"; if($strasse_name == "Wettmannstätten") $strasse_name = "Wettmannstätten"; + if($ort_name == "Wettmannstätten") $ort_name = "Wettmannstätten"; + if($strasse_name == "Schönaich GST") return false; if($strasse_name == "Schönaich" && $ort_name == "Deutschlandsberg") { From fadf3ccd8d4fed026355b1412138eae20423a421 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Sun, 16 Nov 2025 19:18:50 +0100 Subject: [PATCH 026/123] added mph workorders to thetool --- .../WorkorderMph/WorkorderMphModel.php | 21 + .../WorkorderMphAdminController.php | 311 ++++++++++ .../WorkorderMphBaseController.php | 304 ++++++++++ .../WorkorderMphCompanyController.php | 256 +++++++++ .../WorkorderMphDocumentationModel.php | 12 + .../WorkorderMphJournalModel.php | 12 + .../WorkorderMphWohneinheitModel.php | 14 + ...1116120000_create_workorder_mph_tables.php | 95 +++ .../WorkorderMphAdmin/WorkorderMphAdmin.js | 237 ++++++++ .../WorkorderMphBase/WorkorderMphBase.css | 208 +++++++ .../WorkorderMphBase/WorkorderMphBase.js | 543 ++++++++++++++++++ .../WorkorderMphCompany.js | 237 ++++++++ 12 files changed, 2250 insertions(+) create mode 100644 application/WorkorderMph/WorkorderMphModel.php create mode 100644 application/WorkorderMphAdmin/WorkorderMphAdminController.php create mode 100644 application/WorkorderMphBase/WorkorderMphBaseController.php create mode 100644 application/WorkorderMphCompany/WorkorderMphCompanyController.php create mode 100644 application/WorkorderMphDocumentation/WorkorderMphDocumentationModel.php create mode 100644 application/WorkorderMphJournal/WorkorderMphJournalModel.php create mode 100644 application/WorkorderMphWohneinheit/WorkorderMphWohneinheitModel.php create mode 100644 db/migrations/20251116120000_create_workorder_mph_tables.php create mode 100644 public/js/pages/WorkorderMphAdmin/WorkorderMphAdmin.js create mode 100644 public/js/pages/WorkorderMphBase/WorkorderMphBase.css create mode 100644 public/js/pages/WorkorderMphBase/WorkorderMphBase.js create mode 100644 public/js/pages/WorkorderMphCompany/WorkorderMphCompany.js diff --git a/application/WorkorderMph/WorkorderMphModel.php b/application/WorkorderMph/WorkorderMphModel.php new file mode 100644 index 000000000..5f5688f8e --- /dev/null +++ b/application/WorkorderMph/WorkorderMphModel.php @@ -0,0 +1,21 @@ + 'id', 'text' => 'Auftrags-Nr.', 'table' => ['sortable' => true, 'filter' => 'numberRange']], + ['key' => 'hausnummerInfo', 'text' => 'Adresse', 'modal' => false, 'table' => ['filter' => 'search', 'sortable' => false]], + ['key' => 'netzgebietName', 'text' => 'Netzgebiet', 'modal' => false, 'table' => ['filter' => 'search', 'sortable' => true]], + ['key' => 'companyName', 'text' => 'Firma', 'modal' => false, 'table' => ['filter' => 'search', 'sortable' => true]], + ['key' => 'wohneinheitCount', 'text' => 'WE', 'modal' => false, 'table' => ['sortable' => false]], + ['key' => 'additionalInfo', 'text' => 'Notiz', 'modal' => false, 'table' => ['sortable' => true]], + ['key' => 'deadlineDate', 'text' => 'Deadline', 'modal' => false, 'table' => ['filter' => 'date', 'sortable' => true]], + ['key' => 'appointmentDate', 'text' => 'Termin', 'modal' => false, 'table' => ['filter' => 'date', 'sortable' => true]], + ]; + + protected function prepareCrudConfig() + { + $hausnummerInfoColIdx = array_search('hausnummerInfo', array_column($this->columns, 'key')); + array_splice($this->columns, $hausnummerInfoColIdx + 1, 0, [$this->statusColumn]); + } + + public function indexAction() + { + $this->createWorkordersFromHausnummer(); + parent::indexAction(); + } + + protected function getAction() + { + $pagination = $this->postData['pagination'] ?? ['page' => 1, 'per_page' => 10]; + $filters = $this->postData['filters'] ?? []; + $order = $this->postData['order'] ?? []; + + $db = FronkDB::singleton(); + $fronkDbName = FRONKDB_DBNAME; + $addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb'; + + $whereClauses = "WHERE 1=1"; + + if (empty($filters['status'])) { + $whereClauses .= " AND w.status NOT IN ('completed', 'cancelled', 'archived')"; + } else { + $whereClauses .= Helper::generateFilterCondition($filters['status'], 'w.status', true); + } + + if (!empty($filters['id'])) $whereClauses .= Helper::generateFilterCondition($filters['id'], 'w.id', true); + if (!empty($filters['hausnummerInfo'])) { + $searchColumns = "str.name|hn.hausnummer|hn.stiege|plz.plz|ort.name|w.additionalInfo"; + $whereClauses .= Helper::generateFilterCondition($filters['hausnummerInfo'], $searchColumns); + } + if (!empty($filters['netzgebietName'])) $whereClauses .= Helper::generateFilterCondition($filters['netzgebietName'], 'ng.name'); + if (!empty($filters['companyName'])) $whereClauses .= Helper::generateFilterCondition($filters['companyName'], 'c.name'); + if (!empty($filters['deadlineDate'])) $whereClauses .= Helper::generateFilterCondition($filters['deadlineDate'], 'w.deadlineDate'); + if (!empty($filters['additionalInfo'])) $whereClauses .= Helper::generateFilterCondition($filters['additionalInfo'], 'w.additionalInfo'); + + $sql = " + SELECT + w.id, w.status, w.deadlineDate, w.appointmentDate, w.companyId, w.additionalInfo, + IFNULL(c.name, 'Nicht zugewiesen') as companyName, + CONCAT_WS(' ', str.name, hn.hausnummer, hn.stiege) as hausnummerInfo, + str.name as street, hn.hausnummer, hn.stiege, plz.plz, ort.name as city, + IFNULL(ng.name, '-') as netzgebietName, + (SELECT COUNT(*) FROM `$addressDbName`.`Wohneinheit` we WHERE we.hausnummer_id = hn.id) as wohneinheitCount + FROM `$fronkDbName`.`WorkorderMph` w + LEFT JOIN `$fronkDbName`.`WorkorderCompany` c ON w.companyId = c.id + LEFT JOIN `$addressDbName`.`Hausnummer` hn ON w.hausnummerId = hn.id + LEFT JOIN `$addressDbName`.`Strasse` str ON hn.strasse_id = str.id + LEFT JOIN `$addressDbName`.`Plz` plz ON hn.plz_id = plz.id + LEFT JOIN `$addressDbName`.`Ortschaft` ort ON hn.ortschaft_id = ort.id + LEFT JOIN `$addressDbName`.`Netzgebiet` ng ON hn.netzgebiet_id = ng.id + $whereClauses + "; + + $orderBy = ""; + if (!empty($order['key'])) { + $sortableColumns = ['id', 'status', 'deadlineDate', 'companyName', 'additionalInfo', 'appointmentDate', 'netzgebietName']; + if (in_array($order['key'], $sortableColumns)) { + $sortOrder = (strtoupper($order['order']) === 'DESC') ? 'DESC' : 'ASC'; + $orderBy = " ORDER BY " . $db->escape($order['key']) . " " . $sortOrder; + } + } + if (empty($orderBy)) $orderBy = " ORDER BY CASE WHEN w.deadlineDate IS NULL THEN 1 ELSE 0 END, w.deadlineDate ASC"; + + $sql .= $orderBy; + + // Get total count + $countSql = "SELECT COUNT(*) as count FROM `$fronkDbName`.`WorkorderMph` w + LEFT JOIN `$fronkDbName`.`WorkorderCompany` c ON w.companyId = c.id + LEFT JOIN `$addressDbName`.`Hausnummer` hn ON w.hausnummerId = hn.id + LEFT JOIN `$addressDbName`.`Strasse` str ON hn.strasse_id = str.id + LEFT JOIN `$addressDbName`.`Plz` plz ON hn.plz_id = plz.id + LEFT JOIN `$addressDbName`.`Ortschaft` ort ON hn.ortschaft_id = ort.id + LEFT JOIN `$addressDbName`.`Netzgebiet` ng ON hn.netzgebiet_id = ng.id + $whereClauses"; + $totalCount = $db->query($countSql)->fetch_assoc()['count']; + + // Add pagination + if ($pagination['per_page'] !== null) { + $sql .= " LIMIT " . intval($pagination['per_page']) . " OFFSET " . intval(($pagination['page'] - 1) * $pagination['per_page']); + } + + $result = $db->query($sql); + $rows = $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + + self::returnJson([ + 'rows' => $rows, + 'pagination' => [ + 'page' => $pagination['page'], + 'per_page' => $pagination['per_page'], + 'total_rows' => $totalCount, + 'total_pages' => ceil($totalCount / $pagination['per_page']), + 'filtered_available' => $totalCount + ] + ]); + } + + protected function getCompaniesAction() + { + $companies = WorkorderCompanyModel::getAll(); + self::returnJson(array_map(fn($c) => ['value' => $c->id, 'text' => $c->name], $companies)); + } + + protected function assignWorkorderAction() + { + if (empty($this->postData['workorderId']) || empty($this->postData['companyId'])) self::sendError("Erforderliche Felder fehlen."); + $deadline = !empty($this->postData['deadlineDate']) ? $this->postData['deadlineDate'] : strtotime('+6 weeks'); + + $workorder = WorkorderMphModel::get($this->postData['workorderId']); + if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden."); + + $oldStatus = $workorder->status; + $oldCompanyId = $workorder->companyId; + + $workorder->companyId = $this->postData['companyId']; + $workorder->status = 'assigned'; + $workorder->assignmentDate = time(); + $workorder->deadlineDate = $deadline; + + WorkorderMphModel::update((array)$workorder); + + $company = WorkorderCompanyModel::get($this->postData['companyId']); + $statusChange = $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('assigned'); + + WorkorderMphJournalModel::create([ + 'workorderMphId' => $workorder->id, + 'text' => "Arbeitsauftrag zugewiesen an: " . ($company ? $company->name : "Firma ID " . $this->postData['companyId']), + 'statusChange' => $statusChange, + 'create' => time(), + 'createBy' => $this->user->id, + ]); + + self::returnJson(['success' => true, 'message' => 'Arbeitsauftrag erfolgreich zugewiesen.']); + } + + protected function updateDeadlineAction() + { + if (empty($this->postData['workorderId']) || empty($this->postData['deadlineDate'])) self::sendError("Erforderliche Felder fehlen."); + + $workorder = WorkorderMphModel::get($this->postData['workorderId']); + if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden."); + + $workorder->deadlineDate = $this->postData['deadlineDate']; + WorkorderMphModel::update((array)$workorder); + WorkorderMphJournalModel::create([ + 'workorderMphId' => $workorder->id, + 'text' => 'Deadline geändert auf ' . date('d.m.Y', $this->postData['deadlineDate']) . '.', + 'create' => time(), + 'createBy' => $this->user->id + ]); + self::returnJson(['success' => true, 'message' => 'Deadline erfolgreich aktualisiert.']); + } + + protected function acceptDocumentationAction() + { + if (empty($this->postData['workorderId'])) self::sendError("Arbeitsauftrags-ID fehlt."); + $workorder = WorkorderMphModel::get($this->postData['workorderId']); + if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden."); + if ($workorder->status !== 'documented') self::sendError("Die Dokumentation muss zuerst von der Firma als fertig markiert werden."); + + $oldStatus = $workorder->status; + $workorder->status = 'completed'; + WorkorderMphModel::update((array)$workorder); + WorkorderMphJournalModel::create([ + 'workorderMphId' => $workorder->id, + 'text' => 'Dokumentation akzeptiert und Arbeitsauftrag abgeschlossen.', + 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('completed'), + 'create' => time(), + 'createBy' => $this->user->id, + ]); + self::returnJson(['success' => true, 'message' => 'Dokumentation akzeptiert und Arbeitsauftrag abgeschlossen.']); + } + + /** + * Background task: Creates WorkorderMph from Hausnummer with >2 Wohneinheiten + * and RIMO state not in grossplaning/not2connect + */ + private function createWorkordersFromHausnummer() + { + $lockFile = TEMP_DIR . "/task_create_workorder_mph.lock"; + if (file_exists($lockFile) && (time() - intval(file_get_contents($lockFile))) < 300) { + return; // Run only every 5 minutes + } + + $db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME); + + // Build netzgebiet filter + $netzgebietIds = defined('TT_WORKORDER_MPH_NETZGEBIET_IDS') ? TT_WORKORDER_MPH_NETZGEBIET_IDS : []; + $netzgebietFilter = ''; + if (!empty($netzgebietIds)) { + $escapedIds = array_map(fn($id) => $db->escape($id), $netzgebietIds); + $netzgebietFilter = " AND hn.netzgebiet_id IN (" . implode(',', $escapedIds) . ")"; + } + + // Find Hausnummer with >2 Wohneinheiten and state not in grossplaning/not2connect + $sql = " + SELECT hn.id, hn.netzgebiet_id, COUNT(we.id) as we_count + FROM Hausnummer hn + LEFT JOIN Wohneinheit we ON hn.id = we.hausnummer_id + WHERE hn.rimo_ex_state NOT IN ('grossplaning', 'not2connect') + AND hn.rimo_op_state NOT IN ('grossplaning', 'not2connect') + $netzgebietFilter + GROUP BY hn.id + HAVING we_count > 2 + "; + + $result = $db->query($sql); + $hausnummern = $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + + // Get valid hausnummer IDs + $validHausnummerIds = array_column($hausnummern, 'id'); + + foreach ($hausnummern as $hn) { + // Check if WorkorderMph already exists + $existing = WorkorderMphModel::getFirst(['hausnummerId' => $hn['id']]); + + if (!$existing) { + // Create new WorkorderMph + WorkorderMphModel::create([ + 'hausnummerId' => $hn['id'], + 'status' => 'new', + 'create' => time(), + 'createBy' => 1 // System user + ]); + } elseif ($existing->status === 'archived') { + // Reactivate archived workorder + $existing->status = 'new'; + $existing->companyId = null; + $existing->deadlineDate = null; + $existing->appointmentDate = null; + WorkorderMphModel::update((array)$existing); + + WorkorderMphJournalModel::create([ + 'workorderMphId' => $existing->id, + 'text' => 'Arbeitsauftrag wurde automatisch reaktiviert.', + 'statusChange' => $this->getStatusText('archived') . " -> " . $this->getStatusText('new'), + 'create' => time(), + 'createBy' => 1, + ]); + } + } + + // Archive workorders for Hausnummer that are no longer in allowed netzgebiete or don't meet criteria + if (!empty($netzgebietIds)) { + $allWorkorders = WorkorderMphModel::getAll(['status' => ['new', 'assigned', 'scheduled', 'in_progress']]); + foreach ($allWorkorders as $workorder) { + if (!in_array($workorder->hausnummerId, $validHausnummerIds)) { + $workorder->status = 'archived'; + WorkorderMphModel::update((array)$workorder); + + WorkorderMphJournalModel::create([ + 'workorderMphId' => $workorder->id, + 'text' => 'Arbeitsauftrag automatisch archiviert (Netzgebiet deaktiviert oder Kriterien nicht mehr erfüllt).', + 'statusChange' => 'active -> archived', + 'create' => time(), + 'createBy' => 1, + ]); + } + } + } + + file_put_contents($lockFile, time()); + } + + protected function cancelWorkorderAction() + { + if (empty($this->postData['workorderId'])) self::sendError("Arbeitsauftrags-ID fehlt."); + + $workorder = WorkorderMphModel::get($this->postData['workorderId']); + if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden."); + + $oldStatus = $workorder->status; + $workorder->status = 'cancelled'; + WorkorderMphModel::update((array)$workorder); + + $reason = !empty($this->postData['reason']) ? $this->postData['reason'] : 'Kein Grund angegeben'; + + WorkorderMphJournalModel::create([ + 'workorderMphId' => $workorder->id, + 'text' => "Arbeitsauftrag storniert. Grund: " . $reason, + 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('cancelled'), + 'create' => time(), + 'createBy' => $this->user->id, + ]); + + self::returnJson(['success' => true, 'message' => 'Arbeitsauftrag wurde storniert.']); + } +} diff --git a/application/WorkorderMphBase/WorkorderMphBaseController.php b/application/WorkorderMphBase/WorkorderMphBaseController.php new file mode 100644 index 000000000..03b3811b0 --- /dev/null +++ b/application/WorkorderMphBase/WorkorderMphBaseController.php @@ -0,0 +1,304 @@ + 'status', 'text' => 'Status', 'modal' => false, 'table' => ['filter' => 'iconSelect', 'filterOptions' => [ + ['value' => 'new', 'text' => 'Neu', 'icon' => 'fas fa-star text-primary'], + ['value' => 'assigned', 'text' => 'Zugewiesen', 'icon' => 'fas fa-user-check text-info'], + ['value' => 'scheduled', 'text' => 'Geplant', 'icon' => 'fas fa-calendar-check text-warning'], + ['value' => 'in_progress', 'text' => 'In Bearbeitung', 'icon' => 'fas fa-cog text-warning'], + ['value' => 'documented', 'text' => 'Dokumentiert', 'icon' => 'fas fa-file-alt text-success'], + ['value' => 'completed', 'text' => 'Abgeschlossen', 'icon' => 'fas fa-check-double text-secondary'], + ['value' => 'cancelled', 'text' => 'Abgebrochen', 'icon' => 'fas fa-ban text-danger'], + ['value' => 'archived', 'text' => 'Archiviert', 'icon' => 'fas fa-archive text-muted'], + ]] + ]; + + protected array $additionalJS = ["js/pages/WorkorderMphBase/WorkorderMphBase.js"]; + protected array $additionalHead = [""]; + + // Wohneinheit status options + protected array $wohneinheitStatuses = [ + ['value' => 1, 'text' => '10 - new'], + ['value' => 12, 'text' => '241 - BEP installed (MD)'], + ['value' => 13, 'text' => '242 - Inhouse cabling finished'], + ['value' => 18, 'text' => '243 - Cable in stairwell'], + ['value' => 14, 'text' => '244 - BEP installed (SD)'], + ['value' => 15, 'text' => '245 - Installation Approved'], + ['value' => 16, 'text' => '300 - ONT installed'], + ]; + + protected function getStatusText(string $statusKey): string + { + $statusMap = array_column($this->statusColumn['table']['filterOptions'] ?? [], 'text', 'value'); + return $statusMap[$statusKey] ?? ucfirst(str_replace('_', ' ', $statusKey)); + } + + protected function getWohneinheitStatusText(int $statusValue): string + { + $statusMap = array_column($this->wohneinheitStatuses, 'text', 'value'); + return $statusMap[$statusValue] ?? "Status $statusValue"; + } + + //region SHARED ACTIONS + /** + * Fetches documentation and journal entries for a given workorder. + */ + protected function getDocumentationAction() + { + if (empty($this->request->workorderMphId)) self::sendError("Arbeitsauftrags-ID fehlt."); + + $docs = WorkorderMphDocumentationModel::getAll(['workorderMphId' => intval($this->request->workorderMphId)], null, 0, ['key' => 'create', 'order' => 'ASC']); + $journals = WorkorderMphJournalModel::getAll(['workorderMphId' => intval($this->request->workorderMphId)], null, 0, ['key' => 'create', 'order' => 'DESC']); + + $responseDocs = []; + $typeCounts = []; + + foreach ($docs as $doc) { + $file = new File($doc->fileId); + $documentTypeKey = $doc->documentType; + $typeCounts[$documentTypeKey] = ($typeCounts[$documentTypeKey] ?? 0) + 1; + $originalFilename = $file->orig_filename ?? $file->filename; + $extension = pathinfo($originalFilename, PATHINFO_EXTENSION); + $newFilename = "{$documentTypeKey}_{$typeCounts[$documentTypeKey]}." . strtolower($extension); + + $responseDocs[] = [ + 'id' => $doc->id, + 'fileId' => $doc->fileId, + 'fileName' => $newFilename, + 'description' => $doc->description, + 'documentType' => $documentTypeKey, + 'userName' => UserModel::getOne($doc->createBy)->name ?? 'Unbekannt', + 'mimetype' => $file->mimetype ?? 'application/octet-stream', + 'create' => $doc->create + ]; + } + + foreach ($journals as $journal) { + $journal->createByName = UserModel::getOne($journal->createBy)->name ?? 'Unbekannt'; + } + + self::returnJson(['docs' => $responseDocs, 'journals' => $journals]); + } + + /** + * Adds a new entry to a workorder's journal. + */ + protected function addJournalAction() + { + $post = json_decode(file_get_contents('php://input'), true); + if (empty($post['workorderMphId']) || empty(trim($post['text']))) self::sendError("Arbeitsauftrags-ID und Text sind erforderlich."); + + WorkorderMphJournalModel::create([ + 'workorderMphId' => $post['workorderMphId'], + 'text' => $post['text'], + 'createBy' => $this->user->id, + 'create' => time() + ]); + + $journals = WorkorderMphJournalModel::getAll(['workorderMphId' => intval($post['workorderMphId'])], null, 0, ['key' => 'create', 'order' => 'DESC']); + foreach ($journals as $journal) { + $journal->createByName = UserModel::getOne($journal->createBy)->name ?? 'Unbekannt'; + } + self::returnJson(['success' => true, 'message' => 'Journaleintrag hinzugefügt.', 'journals' => $journals]); + } + + /** + * Updates the additional info field for a workorder. + */ + protected function updateAdditionalInfoAction() + { + $post = json_decode(file_get_contents('php://input'), true); + if (empty($post['workorderMphId'])) self::sendError("Arbeitsauftrags-ID fehlt."); + $workorder = WorkorderMphModel::get($post['workorderMphId']); + if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden."); + + $oldInfo = $workorder->additionalInfo; + $newInfo = $post['additionalInfo'] ?? null; + $workorder->additionalInfo = $newInfo; + WorkorderMphModel::update((array)$workorder); + + WorkorderMphJournalModel::create([ + 'workorderMphId' => $workorder->id, + 'text' => "Zusatzinfo geändert.\nAlt: '{$oldInfo}'\nNeu: '{$newInfo}'", + 'create' => time(), + 'createBy' => $this->user->id, + ]); + self::returnJson(['success' => true, 'message' => 'Zusatzinfo aktualisiert.', 'newInfo' => $newInfo]); + } + + /** + * Get all Wohneinheiten for a specific workorder with their statuses and notes + */ + protected function getWohneinheitenAction() + { + if (empty($this->request->workorderMphId)) self::sendError("Arbeitsauftrags-ID fehlt."); + + $workorderMphId = intval($this->request->workorderMphId); + $workorder = WorkorderMphModel::get($workorderMphId); + if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden."); + + // Get all Wohneinheiten for this Hausnummer from addressdb + $db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME); + $hausnummerId = $db->escape($workorder->hausnummerId); + + $sql = "SELECT w.id, w.bezeichner, w.contact + FROM Wohneinheit w + WHERE w.hausnummer_id = $hausnummerId + ORDER BY w.bezeichner"; + $result = $db->query($sql); + $wohneinheiten = $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + + // Get existing WorkorderMphWohneinheit records + $existingRecords = WorkorderMphWohneinheitModel::getAll(['workorderMphId' => $workorderMphId]); + $recordsMap = []; + foreach ($existingRecords as $record) { + $recordsMap[$record->wohneinheitId] = $record; + } + + // Merge data + $response = []; + foreach ($wohneinheiten as $we) { + $record = $recordsMap[$we['id']] ?? null; + $response[] = [ + 'wohneinheitId' => intval($we['id']), + 'bezeichner' => $we['bezeichner'], + 'contact' => $we['contact'], + 'status' => $record ? $record->status : 1, + 'note' => $record ? $record->note : null, + 'recordId' => $record ? $record->id : null, + ]; + } + + self::returnJson(['wohneinheiten' => $response, 'statusOptions' => $this->wohneinheitStatuses]); + } + + /** + * Update status and note for a specific Wohneinheit + */ + protected function updateWohneinheitAction() + { + $post = json_decode(file_get_contents('php://input'), true); + if (empty($post['workorderMphId']) || empty($post['wohneinheitId'])) { + self::sendError("Arbeitsauftrags-ID und Wohneinheit-ID sind erforderlich."); + } + + $workorderMphId = intval($post['workorderMphId']); + $wohneinheitId = intval($post['wohneinheitId']); + $status = intval($post['status'] ?? 1); + $note = $post['note'] ?? null; + + // Check if record exists + $existing = WorkorderMphWohneinheitModel::getFirst([ + 'workorderMphId' => $workorderMphId, + 'wohneinheitId' => $wohneinheitId + ]); + + $oldStatus = $existing ? $existing->status : 1; + $oldNote = $existing ? $existing->note : null; + + if ($existing) { + $existing->status = $status; + $existing->note = $note; + $existing->edit = time(); + $existing->editBy = $this->user->id; + WorkorderMphWohneinheitModel::update((array)$existing); + } else { + WorkorderMphWohneinheitModel::create([ + 'workorderMphId' => $workorderMphId, + 'wohneinheitId' => $wohneinheitId, + 'status' => $status, + 'note' => $note, + 'create' => time(), + 'createBy' => $this->user->id + ]); + } + + // Add journal entry if status or note changed + if ($oldStatus !== $status || $oldNote !== $note) { + $changes = []; + if ($oldStatus !== $status) { + $changes[] = "Status: " . $this->getWohneinheitStatusText($oldStatus) . " → " . $this->getWohneinheitStatusText($status); + } + if ($oldNote !== $note) { + $changes[] = "Notiz aktualisiert"; + } + + WorkorderMphJournalModel::create([ + 'workorderMphId' => $workorderMphId, + 'text' => "Wohneinheit $wohneinheitId: " . implode(', ', $changes), + 'create' => time(), + 'createBy' => $this->user->id, + ]); + + // If status is 241 (BEP MD) or 300 (ONT installed), set statusflag 200 on Wohneinheit + if (in_array($status, [12, 16])) { // 12=241 BEP MD, 16=300 ONT + $this->setWohneinheitStatusflag($wohneinheitId, 200); + } + } + + self::returnJson(['success' => true, 'message' => 'Wohneinheit aktualisiert.']); + } + + /** + * Set statusflag on Wohneinheit in addressdb + */ + private function setWohneinheitStatusflag(int $wohneinheitId, int $statusflagId) + { + $db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME); + $weId = $db->escape($wohneinheitId); + $sfId = $db->escape($statusflagId); + + // Check if statusflag already exists + $checkSql = "SELECT COUNT(*) as count FROM WohneinheitStatusflagValue WHERE wohneinheit_id = $weId AND statusflag_id = $sfId"; + $result = $db->query($checkSql); + $exists = $result->fetch_assoc()['count'] > 0; + + if (!$exists) { + $insertSql = "INSERT INTO WohneinheitStatusflagValue (wohneinheit_id, statusflag_id, create, createBy) + VALUES ($weId, $sfId, " . time() . ", " . $this->user->id . ")"; + $db->query($insertSql); + } + } + + /** + * Update checkbox documentation fields + */ + protected function updateCheckboxesAction() + { + $post = json_decode(file_get_contents('php://input'), true); + if (empty($post['workorderMphId'])) self::sendError("Arbeitsauftrags-ID fehlt."); + + $workorder = WorkorderMphModel::get($post['workorderMphId']); + if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden."); + + $changes = []; + $checkboxFields = ['easement', 'btb', 'fttxLocationSupplied', 'conduitToHuepLaid', 'huepMounted', 'dropCableAvailable']; + + foreach ($checkboxFields as $field) { + if (array_key_exists($field, $post)) { + $oldValue = $workorder->$field; + $newValue = $post[$field] ? 1 : 0; + if ($oldValue !== $newValue) { + $workorder->$field = $newValue; + $changes[] = "$field: " . ($newValue ? 'ja' : 'nein'); + } + } + } + + if (!empty($changes)) { + WorkorderMphModel::update((array)$workorder); + + WorkorderMphJournalModel::create([ + 'workorderMphId' => $workorder->id, + 'text' => "Dokumentation aktualisiert:\n" . implode("\n", $changes), + 'create' => time(), + 'createBy' => $this->user->id, + ]); + } + + self::returnJson(['success' => true, 'message' => 'Dokumentation aktualisiert.']); + } + //endregion +} diff --git a/application/WorkorderMphCompany/WorkorderMphCompanyController.php b/application/WorkorderMphCompany/WorkorderMphCompanyController.php new file mode 100644 index 000000000..6ba353c0a --- /dev/null +++ b/application/WorkorderMphCompany/WorkorderMphCompanyController.php @@ -0,0 +1,256 @@ + 'id', 'text' => 'Auftrags-Nr.', 'table' => ['sortable' => true]], + ['key' => 'hausnummerInfo', 'text' => 'Adresse', 'modal' => false, 'table' => ['sortable' => false]], + ['key' => 'wohneinheitCount', 'text' => 'WE', 'modal' => false, 'table' => ['sortable' => false]], + ['key' => 'additionalInfo', 'text' => 'Notiz', 'modal' => false, 'table' => ['sortable' => true]], + ['key' => 'deadlineDate', 'text' => 'Deadline', 'modal' => false, 'table' => ['filter' => 'date', 'sortable' => true]], + ['key' => 'appointmentDate', 'text' => 'Termin', 'modal' => false, 'table' => ['filter' => 'date', 'sortable' => true]], + ]; + protected array $additionalJSVariables = ['COMPANY_ID' => '0', 'IS_COMPANY_VIEW' => true]; + + protected function prepareCrudConfig() + { + $hausnummerInfoColIdx = array_search('hausnummerInfo', array_column($this->columns, 'key')); + array_splice($this->columns, $hausnummerInfoColIdx + 1, 0, [$this->statusColumn]); + + $company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]); + $this->additionalJSVariables['COMPANY_ID'] = $company ? $company->id : 0; + } + + protected function getAction() + { + $pagination = $this->postData['pagination'] ?? ['page' => 1, 'per_page' => 10]; + $filters = $this->postData['filters'] ?? []; + $order = $this->postData['order'] ?? []; + + $company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]); + if (!$company) { + self::returnJson(['rows' => [], 'pagination' => array_merge($pagination, ['total_rows' => 0, 'total_pages' => 0, 'filtered_available' => 0])]); + return; + } + + $db = FronkDB::singleton(); + $fronkDbName = FRONKDB_DBNAME; + $addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb'; + + $whereClauses = "WHERE w.companyId = " . intval($company->id); + + if (empty($filters['status'])) { + $whereClauses .= " AND w.status NOT IN ('completed', 'cancelled', 'archived')"; + } else { + $whereClauses .= Helper::generateFilterCondition($filters['status'], 'w.status', true); + } + + if (!empty($filters['id'])) $whereClauses .= Helper::generateFilterCondition($filters['id'], 'w.id', true); + if (!empty($filters['hausnummerInfo'])) { + $searchColumns = "str.name|hn.hausnummer|hn.stiege|plz.plz|ort.name|w.additionalInfo"; + $whereClauses .= Helper::generateFilterCondition($filters['hausnummerInfo'], $searchColumns); + } + if (!empty($filters['deadlineDate'])) $whereClauses .= Helper::generateFilterCondition($filters['deadlineDate'], 'w.deadlineDate'); + if (!empty($filters['appointmentDate'])) $whereClauses .= Helper::generateFilterCondition($filters['appointmentDate'], 'w.appointmentDate'); + if (!empty($filters['additionalInfo'])) $whereClauses .= Helper::generateFilterCondition($filters['additionalInfo'], 'w.additionalInfo'); + + $sql = " + SELECT + w.id, w.status, w.deadlineDate, w.appointmentDate, w.additionalInfo, + CONCAT_WS(' ', str.name, hn.hausnummer, hn.stiege) as hausnummerInfo, + str.name as street, hn.hausnummer, hn.stiege, plz.plz, ort.name as city, + (SELECT COUNT(*) FROM `$addressDbName`.`Wohneinheit` we WHERE we.hausnummer_id = hn.id) as wohneinheitCount + FROM `$fronkDbName`.`WorkorderMph` w + LEFT JOIN `$addressDbName`.`Hausnummer` hn ON w.hausnummerId = hn.id + LEFT JOIN `$addressDbName`.`Strasse` str ON hn.strasse_id = str.id + LEFT JOIN `$addressDbName`.`Plz` plz ON hn.plz_id = plz.id + LEFT JOIN `$addressDbName`.`Ortschaft` ort ON hn.ortschaft_id = ort.id + $whereClauses + "; + + $orderBy = ""; + if (!empty($order['key'])) { + $sortableColumns = ['id', 'status', 'deadlineDate', 'additionalInfo', 'appointmentDate']; + if (in_array($order['key'], $sortableColumns)) { + $sortOrder = (strtoupper($order['order']) === 'DESC') ? 'DESC' : 'ASC'; + $orderBy = " ORDER BY " . $db->escape($order['key']) . " " . $sortOrder; + } + } + if (empty($orderBy)) $orderBy = " ORDER BY CASE WHEN w.deadlineDate IS NULL THEN 1 ELSE 0 END, w.deadlineDate ASC"; + + $sql .= $orderBy; + + // Get total count + $countSql = "SELECT COUNT(*) as count FROM `$fronkDbName`.`WorkorderMph` w + LEFT JOIN `$addressDbName`.`Hausnummer` hn ON w.hausnummerId = hn.id + LEFT JOIN `$addressDbName`.`Strasse` str ON hn.strasse_id = str.id + LEFT JOIN `$addressDbName`.`Plz` plz ON hn.plz_id = plz.id + LEFT JOIN `$addressDbName`.`Ortschaft` ort ON hn.ortschaft_id = ort.id + $whereClauses"; + $totalCount = $db->query($countSql)->fetch_assoc()['count']; + + // Add pagination + if ($pagination['per_page'] !== null) { + $sql .= " LIMIT " . intval($pagination['per_page']) . " OFFSET " . intval(($pagination['page'] - 1) * $pagination['per_page']); + } + + $result = $db->query($sql); + $rows = $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + + self::returnJson([ + 'rows' => $rows, + 'pagination' => [ + 'page' => $pagination['page'], + 'per_page' => $pagination['per_page'], + 'total_rows' => $totalCount, + 'total_pages' => ceil($totalCount / $pagination['per_page']), + 'filtered_available' => $totalCount + ] + ]); + } + + public function getWorkorderByIdAction() + { + if (empty($this->request->id)) self::sendError("ID fehlt"); + $workorder = WorkorderMphModel::get($this->request->id); + if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden"); + self::returnJson((array)$workorder); + } + + protected function scheduleAppointmentAction() + { + if (empty($this->postData['workorderId']) || empty($this->postData['appointmentDate'])) self::sendError("Erforderliche Felder fehlen."); + $workorder = WorkorderMphModel::get($this->postData['workorderId']); + if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden"); + if ((int)date('H', $this->postData['appointmentDate']) >= 23 || (int)date('H', $this->postData['appointmentDate']) < 1) self::sendError("Bitte geben Sie eine Uhrzeit an!"); + + $oldStatus = $workorder->status; + $workorder->appointmentDate = $this->postData['appointmentDate']; + $workorder->status = 'scheduled'; + WorkorderMphModel::update((array)$workorder); + + WorkorderMphJournalModel::create([ + 'workorderMphId' => $workorder->id, + 'text' => 'Termin festgelegt auf: ' . date('d.m.Y H:i', $this->postData['appointmentDate']), + 'statusChange' => $oldStatus !== 'scheduled' ? $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('scheduled') : null, + 'create' => time(), + 'createBy' => $this->user->id, + ]); + self::returnJson(['success' => true, 'message' => 'Termin erfolgreich gespeichert.']); + } + + protected function rescheduleAppointmentAction() + { + if (empty($this->postData['workorderId']) || empty($this->postData['appointmentDate']) || empty($this->postData['reason'])) self::sendError("Erforderliche Felder fehlen."); + $workorder = WorkorderMphModel::get($this->postData['workorderId']); + if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden."); + if ((int)date('H', $this->postData['appointmentDate']) >= 23 || (int)date('H', $this->postData['appointmentDate']) < 1) self::sendError("Bitte geben Sie eine Uhrzeit an!"); + + $oldDateFormatted = $workorder->appointmentDate ? date('d.m.Y H:i', $workorder->appointmentDate) : 'N/A'; + $newDateFormatted = date('d.m.Y H:i', $this->postData['appointmentDate']); + $workorder->appointmentDate = $this->postData['appointmentDate']; + WorkorderMphModel::update((array)$workorder); + + WorkorderMphJournalModel::create([ + 'workorderMphId' => $workorder->id, + 'text' => "Termin verschoben von {$oldDateFormatted} auf {$newDateFormatted}. Grund: " . $this->postData['reason'], + 'create' => time(), + 'createBy' => $this->user->id, + ]); + self::returnJson(['success' => true, 'message' => 'Termin erfolgreich verschoben.']); + } + + protected function startWorkAction() + { + if (empty($this->postData['workorderId'])) self::sendError("Arbeitsauftrags-ID fehlt."); + $workorder = WorkorderMphModel::get($this->postData['workorderId']); + if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden."); + + $oldStatus = $workorder->status; + $workorder->status = 'in_progress'; + WorkorderMphModel::update((array)$workorder); + + WorkorderMphJournalModel::create([ + 'workorderMphId' => $workorder->id, + 'text' => 'Arbeit begonnen.', + 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('in_progress'), + 'create' => time(), + 'createBy' => $this->user->id, + ]); + self::returnJson(['success' => true, 'message' => 'Arbeit wurde gestartet.']); + } + + protected function completeWorkorderAction() + { + if (empty($this->postData['workorderId'])) self::sendError("Arbeitsauftrags-ID fehlt."); + $workorder = WorkorderMphModel::get($this->postData['workorderId']); + if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden."); + + // Validate that all required Wohneinheiten have notes + $wohneinheiten = WorkorderMphWohneinheitModel::getAll(['workorderMphId' => $workorder->id]); + foreach ($wohneinheiten as $we) { + if (empty($we->note)) { + self::sendError("Bitte fügen Sie für jede Wohneinheit eine Notiz hinzu, bevor Sie den Auftrag abschließen."); + } + } + + $oldStatus = $workorder->status; + $workorder->status = 'documented'; + WorkorderMphModel::update((array)$workorder); + + WorkorderMphJournalModel::create([ + 'workorderMphId' => $workorder->id, + 'text' => 'Arbeitsauftrag abgeschlossen und dokumentiert.', + 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('documented'), + 'create' => time(), + 'createBy' => $this->user->id, + ]); + self::returnJson(['success' => true, 'message' => 'Arbeitsauftrag erfolgreich abgeschlossen.']); + } + + protected function uploadDocumentationAction() + { + if (empty($_FILES['file']) || empty($_POST['workorderMphId'])) self::sendError("Datei und Arbeitsauftrags-ID sind erforderlich."); + + $workorderMphId = intval($_POST['workorderMphId']); + $workorder = WorkorderMphModel::get($workorderMphId); + if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden."); + + $documentType = $_POST['documentType'] ?? 'photo'; + $description = $_POST['description'] ?? null; + + // Upload file using mfUpload + $upload = new mfUpload($_FILES['file']); + if (!$upload->upload()) { + self::sendError("Datei-Upload fehlgeschlagen."); + } + + $file = $upload->getFile(); + + WorkorderMphDocumentationModel::create([ + 'workorderMphId' => $workorderMphId, + 'fileId' => $file->id, + 'description' => $description, + 'documentType' => $documentType, + 'create' => time(), + 'createBy' => $this->user->id + ]); + + self::returnJson(['success' => true, 'message' => 'Dokument erfolgreich hochgeladen.', 'fileId' => $file->id]); + } + + protected function deleteDocumentationAction() + { + if (empty($this->postData['documentationId'])) self::sendError("Dokumentations-ID fehlt."); + + $doc = WorkorderMphDocumentationModel::get($this->postData['documentationId']); + if (!$doc) self::sendError("Dokumentation nicht gefunden."); + + WorkorderMphDocumentationModel::delete($doc->id); + self::returnJson(['success' => true, 'message' => 'Dokumentation gelöscht.']); + } +} diff --git a/application/WorkorderMphDocumentation/WorkorderMphDocumentationModel.php b/application/WorkorderMphDocumentation/WorkorderMphDocumentationModel.php new file mode 100644 index 000000000..2e0c9460b --- /dev/null +++ b/application/WorkorderMphDocumentation/WorkorderMphDocumentationModel.php @@ -0,0 +1,12 @@ +getEnvironment() == "thetool") { + $table = $this->table("WorkerPermission"); + $table->addColumn("canWorkorderMphAdmin", "enum", [ + "null" => false, + "values" => ['false', 'true'], + "default" => "false", + "after" => "canWorkorderAdmin" + ]); + $table->update(); + + $workorderMph = $this->table('WorkorderMph', ['id' => 'id', 'primary_key' => 'id']); + $workorderMph + ->addColumn('hausnummerId', 'integer', ['null' => false]) + ->addColumn('companyId', 'integer', ['null' => true]) + ->addColumn('status', 'string', ['limit' => 50, 'null' => false, 'default' => 'new']) + ->addColumn('assignmentDate', 'integer', ['null' => true]) + ->addColumn('deadlineDate', 'integer', ['null' => true]) + ->addColumn('appointmentDate', 'integer', ['null' => true]) + ->addColumn('additionalInfo', 'text', ['null' => true]) + ->addColumn('easement', 'boolean', ['null' => true, 'default' => null, 'comment' => 'Leitungsrecht']) + ->addColumn('btb', 'boolean', ['null' => true, 'default' => null]) + ->addColumn('fttxLocationSupplied', 'boolean', ['null' => true, 'default' => null, 'comment' => 'FTTx Location mit Leerrohr versorgt']) + ->addColumn('conduitToHuepLaid', 'boolean', ['null' => true, 'default' => null, 'comment' => 'Leerrohr bis HÜP/HAK verlegt']) + ->addColumn('huepMounted', 'boolean', ['null' => true, 'default' => null, 'comment' => 'HÜP/HAK montiert']) + ->addColumn('dropCableAvailable', 'boolean', ['null' => true, 'default' => null, 'comment' => 'Dropkabel vorhanden']) + ->addColumn('create', 'integer', ['null' => false]) + ->addColumn('createBy', 'integer', ['null' => false]) + ->addIndex(['hausnummerId'], ['name' => 'hausnummerId_idx']) + ->addIndex(['companyId'], ['name' => 'companyId_mph_idx']) + ->addIndex(['status'], ['name' => 'status_mph_idx']) + ->create(); + + $workorderMphWohneinheit = $this->table('WorkorderMphWohneinheit', ['id' => 'id', 'primary_key' => 'id']); + $workorderMphWohneinheit + ->addColumn('workorderMphId', 'integer', ['null' => false]) + ->addColumn('wohneinheitId', 'integer', ['null' => false]) + ->addColumn('status', 'integer', ['null' => false, 'default' => 1, 'comment' => '1=new, 12=241 BEP MD, 13=242 Inhouse, 18=243 Stairwell, 14=244 BEP SD, 15=245 Approved, 16=300 ONT']) + ->addColumn('note', 'text', ['null' => true]) + ->addColumn('create', 'integer', ['null' => false]) + ->addColumn('createBy', 'integer', ['null' => false]) + ->addColumn('edit', 'integer', ['null' => true]) + ->addColumn('editBy', 'integer', ['null' => true]) + ->addIndex(['workorderMphId'], ['name' => 'workorderMphId_idx']) + ->addIndex(['wohneinheitId'], ['name' => 'wohneinheitId_idx']) + ->addIndex(['workorderMphId', 'wohneinheitId'], ['unique' => true, 'name' => 'workorder_wohneinheit_unique']) + ->create(); + + $workorderMphJournal = $this->table('WorkorderMphJournal', ['id' => false, 'primary_key' => ['id']]); + $workorderMphJournal + ->addColumn('id', 'integer', ['identity' => true, 'signed' => true]) + ->addColumn('workorderMphId', 'integer', ['null' => false]) + ->addColumn('text', 'text', ['null' => true]) + ->addColumn('fileIds', 'json', ['null' => true]) + ->addColumn('statusChange', 'string', ['limit' => 255, 'null' => true]) + ->addColumn('create', 'integer', ['null' => false]) + ->addColumn('createBy', 'integer', ['null' => false]) + ->addIndex(['workorderMphId'], ['name' => 'workorderMphId_idx']) + ->create(); + + $workorderMphDocumentation = $this->table('WorkorderMphDocumentation', ['id' => 'id', 'primary_key' => 'id']); + $workorderMphDocumentation + ->addColumn('workorderMphId', 'integer', ['null' => false]) + ->addColumn('fileId', 'integer', ['null' => false]) + ->addColumn('description', 'text', ['null' => true]) + ->addColumn('documentType', 'string', ['limit' => 100, 'null' => false]) + ->addColumn('create', 'integer', ['null' => false]) + ->addColumn('createBy', 'integer', ['null' => false]) + ->addIndex(['workorderMphId'], ['name' => 'workorderMphId_idx']) + ->create(); + } + } + + public function down(): void + { + if ($this->getEnvironment() == "thetool") { + $this->table('WorkorderMphDocumentation')->drop()->save(); + $this->table('WorkorderMphJournal')->drop()->save(); + $this->table('WorkorderMphWohneinheit')->drop()->save(); + $this->table('WorkorderMph')->drop()->save(); + + $table = $this->table("WorkerPermission"); + $table->removeColumn("canWorkorderMphAdmin"); + $table->save(); + } + } +} diff --git a/public/js/pages/WorkorderMphAdmin/WorkorderMphAdmin.js b/public/js/pages/WorkorderMphAdmin/WorkorderMphAdmin.js new file mode 100644 index 000000000..3d67c69d1 --- /dev/null +++ b/public/js/pages/WorkorderMphAdmin/WorkorderMphAdmin.js @@ -0,0 +1,237 @@ +// WorkorderMphAdmin.js +Vue.component('workorder-mph-admin', { + template: ` + + + + + + + + + + + + + + + + + + +

Soll der Auftrag #{{ cancelWorkorderModalData.id }} wirklich storniert werden?

+ +
+
+ `, + data() { + return { + window, + editingWorkorderId: null, + editingDeadlineId: null, + editingAdditionalInfoId: null, + tempAdditionalInfo: '', + companies: [], + companiesLoading: false, + cancelWorkorderModalData: null, + crudConfig: { + ...window.TT_CONFIG.CRUD_CONFIG, + selectable: false, + expandable: true, + customRowClass: (row) => { + if (['completed', 'new', 'cancelled', 'archived'].includes(row.status)) return 'tt-mph-workorder-irrelevant'; + const deadlineDate = moment.unix(row.deadlineDate); + if (!deadlineDate.isValid()) return 'tt-mph-workorder-irrelevant'; + const daysLeft = deadlineDate.diff(moment(), 'days'); + if (daysLeft <= 7) return 'tt-mph-workorder-urgent'; + if (daysLeft <= 21) return 'tt-mph-workorder-medium'; + return 'tt-mph-workorder-ontrack'; + } + } + } + }, + methods: { + getStatusColumn(status) { + const column = this.crudConfig.columns.find(c => c.key === 'status'); + return column.table.filterOptions.find(opt => opt.value === status) || {}; + }, + formatDate(timestamp, withTime = false) { + if (!timestamp) return '–'; + return window.moment.unix(timestamp).format(withTime ? 'DD.MM.YYYY HH:mm' : 'DD.MM.YYYY'); + }, + async loadCompanies() { + if (this.companies.length > 0) return; + this.companiesLoading = true; + try { + const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphAdmin/getCompanies`); + this.companies = data; + } catch (e) { + window.notify('error', 'Firmenliste konnte nicht geladen werden.'); + } finally { + this.companiesLoading = false; + } + }, + async startCompanyEdit(row) { + await this.loadCompanies(); + this.editingWorkorderId = row.id; + }, + async assignCompany(workorder, companyId) { + if (!companyId) { + this.editingWorkorderId = null; + return; + } + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphAdmin/assignWorkorder`, { + workorderId: workorder.id, + companyId: companyId + }); + if (data.success) { + window.notify('success', data.message); + this.$refs.table.$refs.table.refreshTable(); + } else { + window.notify('error', data.message || 'Ein Fehler ist aufgetreten.'); + } + this.editingWorkorderId = null; + }, + async updateDeadline(workorder, newDate) { + if (!newDate) { + this.editingDeadlineId = null; + return; + } + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphAdmin/updateDeadline`, { + workorderId: workorder.id, + deadlineDate: newDate + }); + if (data.success) { + window.notify('success', data.message); + this.$refs.table.$refs.table.refreshTable(); + } else { + window.notify('error', data.message || 'Ein Fehler ist aufgetreten.'); + } + } catch (e) { + window.notify('error', 'Netzwerkfehler.'); + } finally { + this.editingDeadlineId = null; + } + }, + startAdditionalInfoEdit(row) { + this.editingAdditionalInfoId = row.id; + this.tempAdditionalInfo = row.additionalInfo || ''; + this.$nextTick(() => this.$refs.editTextarea?.$el.querySelector('textarea').focus()); + }, + cancelEdit() { + this.editingAdditionalInfoId = null; + this.tempAdditionalInfo = ''; + }, + async updateAdditionalInfo(row) { + if (row.additionalInfo === this.tempAdditionalInfo) { + this.cancelEdit(); + return; + } + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphAdmin/updateAdditionalInfo`, { + workorderMphId: row.id, + additionalInfo: this.tempAdditionalInfo + }); + if (data.success) { + window.notify('success', data.message); + row.additionalInfo = data.newInfo; + } else { + window.notify('error', data.message || 'Update fehlgeschlagen.'); + } + } catch (e) { + window.notify('error', 'Netzwerkfehler.'); + } finally { + this.cancelEdit(); + } + }, + async cancelWorkorder() { + const { id, reason } = this.cancelWorkorderModalData; + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphAdmin/cancelWorkorder`, { + workorderId: id, + reason: reason + }); + if (data.success) { + window.notify('success', data.message); + this.$refs.table.$refs.table.refreshTable(); + this.cancelWorkorderModalData = null; + } else { + window.notify('error', data.message || 'Stornierung fehlgeschlagen.'); + } + } + } +}); diff --git a/public/js/pages/WorkorderMphBase/WorkorderMphBase.css b/public/js/pages/WorkorderMphBase/WorkorderMphBase.css new file mode 100644 index 000000000..ef63a4ac6 --- /dev/null +++ b/public/js/pages/WorkorderMphBase/WorkorderMphBase.css @@ -0,0 +1,208 @@ +/* + * CSS for WorkorderMph Table Row Highlighting + */ + +/* Urgent: Deadline passed or less than 1 week away */ +.table-hover .tt-mph-workorder-urgent:hover, +.tt-mph-workorder-urgent { + background-color: #fbe9e7 !important; /* Soft Red */ +} + +/* Medium: Deadline less than 3 weeks away */ +.table-hover .tt-mph-workorder-medium:hover, +.tt-mph-workorder-medium { + background-color: #fff8e1 !important; /* Soft Yellow */ +} + +/* On Track: Deadline more than 3 weeks away */ +.table-hover .tt-mph-workorder-ontrack:hover, +.tt-mph-workorder-ontrack { + background-color: #e8f5e9 !important; /* Soft Green */ +} + +/* Irrelevant: No deadline or status makes it not applicable */ +.table-hover .tt-mph-workorder-irrelevant:hover, +.tt-mph-workorder-irrelevant { + background-color: #fafafa !important; /* Very light grey */ +} + +.table-hover .tt-mph-workorder-high:hover, +.tt-mph-workorder-high { + background-color: #f8d7da !important; /* A slightly more intense red for high priority issues */ +} + +/* + * Wohneinheit Manager - Dense Table Layout + */ +.wohneinheit-manager .we-table { + display: table; + width: 100%; + border-collapse: collapse; +} + +.wohneinheit-manager .we-row { + display: table-row; + border-bottom: 1px solid #e9ecef; + transition: background-color 0.15s ease; +} + +.wohneinheit-manager .we-row:hover { + background-color: #f8f9fa; +} + +.wohneinheit-manager .we-cell { + display: table-cell; + padding: 8px 12px; + vertical-align: middle; +} + +.wohneinheit-manager .we-bezeichner { + width: 25%; + font-size: 0.9rem; +} + +.wohneinheit-manager .we-status { + width: 20%; +} + +.wohneinheit-manager .we-note { + width: 40%; +} + +.wohneinheit-manager .we-actions { + width: 15%; + text-align: right; +} + +.contact-info { + font-size: 0.85rem; + margin-top: 4px; + padding-left: 4px; + border-left: 2px solid #007bff; +} + +.workorder-mph-button { + padding: 2px !important; +} + +/* + * Custom Checkboxes - Compact & Beautiful + */ +.custom-checkboxes-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 8px 16px; +} + +.custom-checkbox-item { + display: flex; + align-items: center; + padding: 8px 12px; + background: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + margin: 0; + user-select: none; +} + +.custom-checkbox-item:hover:not(.disabled) { + background: #e9ecef; + border-color: #007bff; + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.custom-checkbox-item.disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.custom-checkbox-item input[type="checkbox"] { + position: absolute; + opacity: 0; + cursor: pointer; +} + +.custom-checkbox-item .checkmark { + position: relative; + height: 20px; + width: 20px; + background-color: #fff; + border: 2px solid #adb5bd; + border-radius: 4px; + margin-right: 10px; + flex-shrink: 0; + transition: all 0.2s ease; +} + +.custom-checkbox-item input[type="checkbox"]:checked ~ .checkmark { + background-color: #28a745; + border-color: #28a745; +} + +.custom-checkbox-item .checkmark:after { + content: ""; + position: absolute; + display: none; + left: 6px; + top: 2px; + width: 5px; + height: 10px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +.custom-checkbox-item input[type="checkbox"]:checked ~ .checkmark:after { + display: block; +} + +.custom-checkbox-item .checkbox-label { + font-size: 0.9rem; + font-weight: 500; + color: #495057; +} + +.custom-checkbox-item input[type="checkbox"]:checked ~ .checkbox-label { + color: #28a745; +} + +/* + * Required Documents Checklist + */ +.required-docs-checklist { + background: #f8f9fa; + border-radius: 6px; + padding: 8px; +} + +.doc-check-item { + display: flex; + align-items: center; + padding: 6px 10px; + margin-bottom: 4px; + background: white; + border-radius: 4px; + font-size: 0.9rem; + gap: 10px; +} + +.doc-check-item:last-child { + margin-bottom: 0; +} + +.doc-check-item i:first-child { + width: 20px; + text-align: center; +} + +.doc-check-item span { + flex: 1; + font-weight: 500; +} + +.doc-check-item .ml-auto { + margin-left: auto; +} diff --git a/public/js/pages/WorkorderMphBase/WorkorderMphBase.js b/public/js/pages/WorkorderMphBase/WorkorderMphBase.js new file mode 100644 index 000000000..f6ad3d113 --- /dev/null +++ b/public/js/pages/WorkorderMphBase/WorkorderMphBase.js @@ -0,0 +1,543 @@ +// WorkorderMphBase.js - Shared components for WorkorderMph module + +// Traffic light component (reused from WorkorderBase) +Vue.component('traffic-light-mph', { + props: ['deadline', 'status'], + computed: { + lightInfo() { + const deadlineDate = moment.unix(this.deadline); + const daysLeft = deadlineDate.diff(moment(), 'days'); + + if (['completed', 'new', 'cancelled'].includes(this.status)) return { color: '#cccccc', title: 'Status irrelevant für Dringlichkeit' }; + if (!deadlineDate.isValid()) return { color: '#cccccc', title: 'Keine Deadline gesetzt' }; + if (deadlineDate.isBefore(moment())) return { color: '#dc3545', title: 'Deadline überschritten' }; + if (daysLeft <= 7) return { color: '#dc3545', title: 'Dringend: Weniger als 1 Woche' }; + if (daysLeft <= 21) return { color: '#ffc107', title: 'Mittel: Weniger als 3 Wochen' }; + return { color: '#28a745', title: 'Im Plan: Mehr als 3 Wochen' }; + } + }, + template: `` +}); + +// Wohneinheit Status Manager Component +Vue.component('wohneinheit-status-manager', { + props: { + workorderMphId: { type: Number, required: true }, + isAdmin: { type: Boolean, default: false } + }, + template: ` +
+
+
Wohneinheiten Status
+
+
+
+
+ Keine Wohneinheiten gefunden. +
+
+
+
+ {{ we.bezeichner }} +
+ {{ we.contact }} +
+
+ Keine Kontaktinfo +
+
+
+ +
+
+ +
+
+ + + + +
+
+
+
+
+ `, + data: () => ({ + loading: true, + wohneinheiten: [], + statusOptions: [] + }), + methods: { + async fetchWohneinheiten() { + this.loading = true; + try { + const basePath = this.isAdmin ? '/WorkorderMphAdmin' : '/WorkorderMphCompany'; + const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}${basePath}/getWohneinheiten`, { + params: { workorderMphId: this.workorderMphId } + }); + this.wohneinheiten = data.wohneinheiten.map(we => ({ ...we, changed: false, saving: false })); + this.statusOptions = data.statusOptions || []; + } catch (e) { + window.notify('error', 'Wohneinheiten konnten nicht geladen werden.'); + console.error(e); + } finally { + this.loading = false; + } + }, + markAsChanged(we) { + we.changed = true; + }, + async saveWohneinheit(we) { + if (!we.note || !we.note.trim()) { + return window.notify('error', 'Bitte eine Notiz eingeben.'); + } + + we.saving = true; + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/updateWohneinheit`, { + workorderMphId: this.workorderMphId, + wohneinheitId: we.wohneinheitId, + status: we.status, + note: we.note + }); + if (data.success) { + window.notify('success', data.message); + we.changed = false; + this.$emit('wohneinheit-updated'); + } else { + window.notify('error', data.message || 'Speichern fehlgeschlagen.'); + } + } catch (e) { + window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.'); + } finally { + we.saving = false; + } + }, + getStatusText(statusValue) { + const option = this.statusOptions.find(opt => opt.value === statusValue); + return option ? option.text : ''; + } + }, + async mounted() { + await this.fetchWohneinheiten(); + } +}); + +// Checkbox Documentation Component +Vue.component('checkbox-documentation', { + props: { + workorderMphId: { type: Number, required: true }, + isAdmin: { type: Boolean, default: false } + }, + template: ` +
+
+
Dokumentation Checkboxen
+
+
+
+ + + + + + + + + + + +
+
+ +
+
+
+
+ `, + data: () => ({ + loading: true, + saving: false, + checkboxes: { + easement: false, + btb: false, + fttxLocationSupplied: false, + conduitToHuepLaid: false, + huepMounted: false, + dropCableAvailable: false + } + }), + methods: { + async fetchCheckboxes() { + this.loading = true; + try { + const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/getWorkorderById`, { + params: { id: this.workorderMphId } + }); + this.checkboxes = { + easement: !!data.easement, + btb: !!data.btb, + fttxLocationSupplied: !!data.fttxLocationSupplied, + conduitToHuepLaid: !!data.conduitToHuepLaid, + huepMounted: !!data.huepMounted, + dropCableAvailable: !!data.dropCableAvailable + }; + } catch (e) { + window.notify('error', 'Checkboxen konnten nicht geladen werden.'); + console.error(e); + } finally { + this.loading = false; + } + }, + async saveCheckboxes() { + this.saving = true; + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/updateCheckboxes`, { + workorderMphId: this.workorderMphId, + ...this.checkboxes + }); + if (data.success) { + window.notify('success', data.message); + this.$emit('checkboxes-updated'); + } else { + window.notify('error', data.message || 'Speichern fehlgeschlagen.'); + } + } catch (e) { + window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.'); + } finally { + this.saving = false; + } + } + }, + async mounted() { + await this.fetchCheckboxes(); + } +}); + +// WorkorderMph Details Manager +Vue.component('workorder-mph-details-manager', { + props: { + workorderMphId: { type: String, required: true }, + isAdmin: { type: Boolean, default: false } + }, + data: () => ({ + loading: true, + docs: [], + journals: [], + newJournalMessage: '', + addingJournalEntry: false, + uploading: false, + completing: false, + showCompleteModal: false, + showAcceptModal: false, + uploadData: { files: [], documentType: '', description: '' }, + wohneinheitenWithNotes: true, + requiredDocs: [ + { key: 'huep_photo', label: 'HÜP/HAK Foto', icon: 'fas fa-camera', example: 'Foto der installierten HÜP/HAK' }, + { key: 'bep_md_photo', label: 'BEP MD Foto', icon: 'fas fa-camera', example: 'Foto der BEP (MD) Installation' }, + { key: 'ont_photo', label: 'ONT Foto', icon: 'fas fa-camera', example: 'Foto der ONT Installation' }, + { key: 'cable_routing', label: 'Kabelverlegung', icon: 'fas fa-route', example: 'Fotos der Kabelverlegung im Treppenhaus' }, + { key: 'fttx_location', label: 'FTTx Location', icon: 'fas fa-map-marker-alt', example: 'Foto/Dokument der FTTx Location' }, + { key: 'signature', label: 'Unterschrift', icon: 'fas fa-signature', example: 'Unterschriebenes Übergabeprotokoll' }, + { key: 'other', label: 'Sonstige Dokumentation', icon: 'fas fa-file', example: 'Weitere relevante Dokumente' } + ] + }), + template: ` +
+
+
+
+
+
+
Auftrag abschließen
+

Dokumentieren Sie alle Wohneinheiten und laden Sie die erforderlichen Dokumente hoch.

+
+ + + Bitte fügen Sie für jede Wohneinheit eine Notiz hinzu und laden Sie Dokumente hoch. + +
+ Auftrag bereits abgeschlossen oder storniert. +
+
+
+ +
+
+
Prüfung & Freigabe
+

Prüfen Sie die hochgeladenen Dokumente:

+ +
+
+ + {{ doc.label }} + + +
+
+ + + Stellen Sie sicher, dass alle relevanten Dokumente vorhanden sind. + + + +
+
+ +
+
Journal
+
+
    +
  • + {{ formatDate(log.create) }} ({{ log.createByName }}): +
    {{ log.text }}
    +
  • +
+
Keine Journaleinträge.
+
+ +
+
+ +
+
+
+
Neues Dokument hochladen
+ +
+ +
+ + + {{ getDocExample(uploadData.documentType) }} + +
+
+ +
+ +
+ +
+
+ +
+ +
+ + Erlaubt: Bilder (JPG, PNG) und PDF +
+
+ +
+ +
+
+
+ + + +
+
+ + + Möchten Sie diesen Auftrag wirklich abschließen und zur Prüfung einreichen? + + + Soll die Dokumentation für diesen Arbeitsauftrag wirklich akzeptiert und der Auftrag abgeschlossen werden? + +
+ `, + computed: { + isReadOnly() { + return ['completed', 'cancelled'].includes(this.workorder?.status); + }, + canComplete() { + return this.wohneinheitenWithNotes && this.docs.length > 0; + } + }, + methods: { + formatDate(timestamp) { + return timestamp ? window.moment.unix(timestamp).format('DD.MM.YYYY HH:mm') : '–'; + }, + hasDocType(docType) { + return this.docs.some(doc => doc.documentType === docType); + }, + getDocExample(docType) { + const doc = this.requiredDocs.find(d => d.key === docType); + return doc ? doc.example : ''; + }, + async fetchData() { + this.loading = true; + try { + const basePath = this.isAdmin ? '/WorkorderMphAdmin' : '/WorkorderMphCompany'; + const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}${basePath}/getDocumentation`, { + params: { workorderMphId: this.workorderMphId } + }); + this.docs = data.docs || []; + this.journals = data.journals || []; + } catch (e) { + window.notify('error', 'Details konnten nicht geladen werden.'); + this.docs = []; + this.journals = []; + } finally { + this.loading = false; + } + }, + async addJournalEntry() { + if (!this.newJournalMessage.trim()) return window.notify('error', 'Bitte eine Nachricht eingeben.'); + + this.addingJournalEntry = true; + try { + const basePath = this.isAdmin ? '/WorkorderMphAdmin' : '/WorkorderMphCompany'; + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}${basePath}/addJournal`, { + workorderMphId: this.workorderMphId, + text: this.newJournalMessage + }); + if (data.success) { + window.notify('success', data.message); + this.journals = data.journals || []; + this.newJournalMessage = ''; + } else { + window.notify('error', data.message || 'Eintrag fehlgeschlagen.'); + } + } catch (e) { + window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.'); + } finally { + this.addingJournalEntry = false; + } + }, + handleFileUpload(event) { + this.uploadData.files = event.target.files; + }, + async uploadFiles() { + if (!this.uploadData.files?.length) return window.notify('error', 'Bitte eine oder mehrere Dateien auswählen.'); + + this.uploading = true; + const formData = new FormData(); + formData.append('workorderMphId', this.workorderMphId); + formData.append('documentType', this.uploadData.documentType); + formData.append('description', this.uploadData.description); + for (const file of this.uploadData.files) { + formData.append('file', file); + } + + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/uploadDocumentation`, formData); + if (data.success) { + window.notify('success', data.message); + this.$refs.fileInput.value = ''; + this.uploadData = { files: [], documentType: 'photo', description: '' }; + await this.fetchData(); + } else { + window.notify('error', data.error || 'Upload fehlgeschlagen.'); + } + } catch (e) { + window.notify('error', 'Ein Netzwerkfehler ist beim Upload aufgetreten.'); + } finally { + this.uploading = false; + } + }, + async deleteDocumentation(file) { + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/deleteDocumentation`, { + documentationId: file.id + }); + if (data.success) { + window.notify('success', data.message); + await this.fetchData(); + } else { + window.notify('error', data.message || 'Löschen fehlgeschlagen.'); + } + } catch (e) { + window.notify('error', 'Netzwerkfehler beim Löschen.'); + } + }, + async completeWorkorder() { + this.completing = true; + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/completeWorkorder`, { + workorderId: this.workorderMphId + }); + if (data.success) { + window.notify('success', data.message); + this.$emit('workorder-completed'); + this.showCompleteModal = false; + } else { + window.notify('error', data.message || 'Abschluss fehlgeschlagen.'); + } + } catch (e) { + window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.'); + } finally { + this.completing = false; + } + }, + async acceptDocumentation() { + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphAdmin/acceptDocumentation`, { + workorderId: this.workorderMphId + }); + if (data.success) { + window.notify('success', data.message); + this.$emit('documentation-accepted'); + this.showAcceptModal = false; + } else { + window.notify('error', data.message || 'Akzeptieren fehlgeschlagen.'); + } + } catch (e) { + window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.'); + } + } + }, + async mounted() { + await this.fetchData(); + } +}); diff --git a/public/js/pages/WorkorderMphCompany/WorkorderMphCompany.js b/public/js/pages/WorkorderMphCompany/WorkorderMphCompany.js new file mode 100644 index 000000000..039d9a8e6 --- /dev/null +++ b/public/js/pages/WorkorderMphCompany/WorkorderMphCompany.js @@ -0,0 +1,237 @@ +// WorkorderMphCompany.js +Vue.component('workorder-mph-company', { + template: ` + + + + + + + + + + + + + + + + +

Aktueller Termin: {{ formatDate(rescheduleModalData.currentDate, true) }}

+ + +
+ + +

Möchten Sie diesen Auftrag wirklich abschließen und zur Prüfung einreichen?

+
+ + Bitte stellen Sie sicher, dass alle Wohneinheiten dokumentiert sind und alle erforderlichen Dokumente hochgeladen wurden. +
+
+
+ `, + data() { + return { + window, + editingAppointmentId: null, + rescheduleModalData: null, + completeModalData: null, + crudConfig: { + ...window.TT_CONFIG.CRUD_CONFIG, + selectable: false, + expandable: true, + customRowClass: (row) => { + if (['completed', 'new', 'cancelled'].includes(row.status)) return 'tt-mph-workorder-irrelevant'; + const deadlineDate = moment.unix(row.deadlineDate); + if (!deadlineDate.isValid()) return 'tt-mph-workorder-irrelevant'; + const daysLeft = deadlineDate.diff(moment(), 'days'); + if (daysLeft <= 7) return 'tt-mph-workorder-urgent'; + if (daysLeft <= 21) return 'tt-mph-workorder-medium'; + return 'tt-mph-workorder-ontrack'; + } + } + } + }, + methods: { + getStatusColumn(status) { + const column = this.crudConfig.columns.find(c => c.key === 'status'); + return column.table.filterOptions.find(opt => opt.value === status) || {}; + }, + formatDate(timestamp, withTime = false) { + if (!timestamp) return '–'; + return window.moment.unix(timestamp).format(withTime ? 'DD.MM.YYYY HH:mm' : 'DD.MM.YYYY'); + }, + canSchedule(row) { + return ['assigned', 'scheduled'].includes(row.status); + }, + async scheduleAppointment(row, newDate) { + if (!newDate) { + this.editingAppointmentId = null; + return; + } + + const hour = parseInt(moment.unix(newDate).format('H')); + if (hour >= 23 || hour < 1) { + window.notify('error', 'Bitte geben Sie eine Uhrzeit zwischen 01:00 und 22:59 an!'); + return; + } + + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/scheduleAppointment`, { + workorderId: row.id, + appointmentDate: newDate + }); + if (data.success) { + window.notify('success', data.message); + this.$refs.table.$refs.table.refreshTable(); + } else { + window.notify('error', data.message || 'Ein Fehler ist aufgetreten.'); + } + } catch (e) { + window.notify('error', 'Netzwerkfehler.'); + } finally { + this.editingAppointmentId = null; + } + }, + openRescheduleModal(row) { + this.rescheduleModalData = { + workorderId: row.id, + currentDate: row.appointmentDate, + newDate: null, + reason: '' + }; + }, + async rescheduleAppointment() { + if (!this.rescheduleModalData.newDate || !this.rescheduleModalData.reason) { + return window.notify('error', 'Bitte füllen Sie alle Felder aus.'); + } + + const hour = parseInt(moment.unix(this.rescheduleModalData.newDate).format('H')); + if (hour >= 23 || hour < 1) { + window.notify('error', 'Bitte geben Sie eine Uhrzeit zwischen 01:00 und 22:59 an!'); + return; + } + + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/rescheduleAppointment`, { + workorderId: this.rescheduleModalData.workorderId, + appointmentDate: this.rescheduleModalData.newDate, + reason: this.rescheduleModalData.reason + }); + if (data.success) { + window.notify('success', data.message); + this.$refs.table.$refs.table.refreshTable(); + this.rescheduleModalData = null; + } else { + window.notify('error', data.message || 'Ein Fehler ist aufgetreten.'); + } + } catch (e) { + window.notify('error', 'Netzwerkfehler.'); + } + }, + async startWork(row) { + if (!confirm(`Möchten Sie mit der Arbeit an Auftrag #${row.id} beginnen?`)) return; + + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/startWork`, { + workorderId: row.id + }); + if (data.success) { + window.notify('success', data.message); + this.$refs.table.$refs.table.refreshTable(); + } else { + window.notify('error', data.message || 'Ein Fehler ist aufgetreten.'); + } + } catch (e) { + window.notify('error', 'Netzwerkfehler.'); + } + }, + openCompleteModal(row) { + this.completeModalData = { workorderId: row.id }; + }, + async completeWorkorder() { + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/completeWorkorder`, { + workorderId: this.completeModalData.workorderId + }); + if (data.success) { + window.notify('success', data.message); + this.$refs.table.$refs.table.refreshTable(); + this.completeModalData = null; + } else { + window.notify('error', data.message || 'Abschluss fehlgeschlagen.'); + } + } catch (e) { + window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.'); + } + }, + async checkAllWohneinheitenHaveNotes(workorderId) { + // This is called when a wohneinheit is updated + // Could be used to enable/disable the complete button + } + } +}); From e2a92fc0e675ad06e8f77a16e17527ef222343c3 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Sun, 16 Nov 2025 20:31:24 +0100 Subject: [PATCH 027/123] Enhance visibility condition for faulty Hausnummer IDs in PreorderModel --- application/Preorder/PreorderModel.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/application/Preorder/PreorderModel.php b/application/Preorder/PreorderModel.php index a4a5bf6b8..adb334f6e 100644 --- a/application/Preorder/PreorderModel.php +++ b/application/Preorder/PreorderModel.php @@ -1423,6 +1423,23 @@ ORDER BY // add time debug $startTime = microtime(true); + $campaign = PreordercampaignModel::getFirst(['id' => $safeCampaignId]); + $faultyHausnummerIds = []; + if ($campaign && !empty($campaign->rimo_type_map_faults)) { + $faults = json_decode($campaign->rimo_type_map_faults, true); + foreach ($faults as $fault) { + if (empty($fault['done'])) { + $faultyHausnummerIds[] = (int)$fault['hausnummer_id']; + } + } + } + + $visibilityCondition = "AND (h.visibility IS NULL OR h.visibility != 'private')"; + if (!empty($faultyHausnummerIds)) { + $ids = implode(',', $faultyHausnummerIds); + $visibilityCondition = "AND ((h.visibility IS NULL OR h.visibility != 'private') OR h.id IN ({$ids}))"; + } + $sql = " SELECT h.id AS hausnummer_id, h.gps_lat, h.gps_long, h.rimo_type, h.rimo_op_state, h.rimo_ex_state, h.hausnummer, @@ -1444,6 +1461,7 @@ ORDER BY JOIN `{$fronkDbName}`.`Network` n ON pc.network_id = n.id WHERE pc.id = {$safeCampaignId} ) AND h.gps_lat IS NOT NULL AND h.gps_long IS NOT NULL + {$visibilityCondition} GROUP BY h.id ORDER BY h.id "; From a0791b3b6880207b8ce73da6e05d912fab0e5302 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Sun, 16 Nov 2025 21:59:10 +0000 Subject: [PATCH 028/123] Update 20251116120000_create_workorder_mph_tables.php --- db/migrations/20251116120000_create_workorder_mph_tables.php | 1 - 1 file changed, 1 deletion(-) diff --git a/db/migrations/20251116120000_create_workorder_mph_tables.php b/db/migrations/20251116120000_create_workorder_mph_tables.php index 6baadd5b1..372d96bd2 100644 --- a/db/migrations/20251116120000_create_workorder_mph_tables.php +++ b/db/migrations/20251116120000_create_workorder_mph_tables.php @@ -13,7 +13,6 @@ final class CreateWorkorderMphTables extends AbstractMigration "null" => false, "values" => ['false', 'true'], "default" => "false", - "after" => "canWorkorderAdmin" ]); $table->update(); From a09085ec5c012934bfc7d541f162151a68cd6997 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Mon, 17 Nov 2025 08:36:25 +0100 Subject: [PATCH 029/123] Add modal for configuring Chrome Extension ID and enhance CPE provisioning functionality --- .../CpeprovisioningController.php | 53 +++++++ .../Cpeprovisioning/CpeprovisioningModel.php | 7 + .../pages/Cpeprovisioning/Cpeprovisioning.css | 2 +- .../pages/Cpeprovisioning/Cpeprovisioning.js | 136 ++++++++++++++++-- public/js/pages/Radius/RadiusUsers.js | 2 +- 5 files changed, 190 insertions(+), 10 deletions(-) diff --git a/application/Cpeprovisioning/CpeprovisioningController.php b/application/Cpeprovisioning/CpeprovisioningController.php index 7ef383c45..3ca148fe2 100644 --- a/application/Cpeprovisioning/CpeprovisioningController.php +++ b/application/Cpeprovisioning/CpeprovisioningController.php @@ -529,6 +529,7 @@ class CpeprovisioningController extends mfBaseController { // Pass API URLs and initial data to the frontend "CPE_PROV_API_GET_URL" => $this->getUrl("Cpeprovisioning", "apiGet"), "CPE_PROV_API_SAVE_URL" => $this->getUrl("Cpeprovisioning", "apiSave"), + "CPE_PROV_API_TEST_ACS_VLAN_URL" => $this->getUrl("Cpeprovisioning", "getAcsVlan"), "CPE_PROV_PRINT_PDF_URL" => $this->getUrl("Cpeprovisioning", "printPDF"), "ORDER_URL" => $this->getUrl("Order"), "NETWORKS" => NetworkModel::getAll(), @@ -565,6 +566,58 @@ class CpeprovisioningController extends mfBaseController { ); } + protected function getAcsVlanAction() { + $apiKey = $_SERVER['HTTP_X_API_KEY'] ?? null; + $isApiCall = defined('TT_CPE_PROV_ACS_API_KEY') && $apiKey && $apiKey === TT_CPE_PROV_ACS_API_KEY; + $isLoggedInUser = $this->me && $this->me->id; + + if (!$isApiCall && !$isLoggedInUser) { + http_response_code(403); + self::returnJson(['success' => false, 'message' => 'Forbidden']); + return; + } + + try { + $p = json_decode(file_get_contents('php://input'), true); + $mac = $p['mac'] ?? null; + + if (empty($mac)) { + throw new Exception("MAC address is required."); + } + + $cpe = CpeprovisioningModel::getFirst(['mac' => $mac]); + if (!$cpe || !$cpe->termination_id) { + throw new Exception("No active provisioning entry found for this MAC address."); + } + + $term = new Termination($cpe->termination_id); + $product = $cpe->orderproduct; + if (!$term->id || !$product->id) { + throw new Exception("Could not load termination or product details."); + } + + $attrs = $product->product->attributes; + + $vlanPublicDefault = $term->getPop()->vlan_public ?? $attrs['vlan_default_public']->value ?? null; + $vlanNatDefault = $term->getPop()->vlan_nat ?? $attrs['vlan_default_nat']->value ?? null; + $vlanIpv6Default = $term->getPop()->vlan_ipv6 ?? $attrs['vlan_default_ipv6']->value ?? null; + + // For the test, we just return the first available VLAN that would be assigned. + // The logic can be expanded if a specific type is requested. + $assignedVlan = $vlanPublicDefault ?? $vlanNatDefault ?? $vlanIpv6Default; + + if ($assignedVlan) { + self::returnJson(['success' => true, 'vlan_id' => $assignedVlan]); + } else { + throw new Exception("No default VLAN could be determined for this product/POP combination."); + } + + } catch (Exception $e) { + http_response_code(400); + self::returnJson(['success' => false, 'message' => $e->getMessage()]); + } + } + private function fixCpeData($data) { if (!$data) return []; $data->shipping = (bool)$data->shipping; diff --git a/application/Cpeprovisioning/CpeprovisioningModel.php b/application/Cpeprovisioning/CpeprovisioningModel.php index 19d17cfe6..fb79df3c3 100644 --- a/application/Cpeprovisioning/CpeprovisioningModel.php +++ b/application/Cpeprovisioning/CpeprovisioningModel.php @@ -181,6 +181,13 @@ class CpeprovisioningModel { } } + if(array_key_exists("mac", $filter)) { + $mac = FronkDB::singleton()->escape($filter['mac']); + if($mac) { + $where .= " AND mac='$mac'"; + } + } + //var_dump($filter, $where);exit; return $where; } diff --git a/public/js/pages/Cpeprovisioning/Cpeprovisioning.css b/public/js/pages/Cpeprovisioning/Cpeprovisioning.css index 431fe7c92..0456872a3 100644 --- a/public/js/pages/Cpeprovisioning/Cpeprovisioning.css +++ b/public/js/pages/Cpeprovisioning/Cpeprovisioning.css @@ -83,7 +83,7 @@ body { .cpe-card-content { padding: 0.75rem 1rem; display: grid; - grid-template-columns: minmax(280px, 1.25fr) minmax(280px, 1.25fr) minmax(280px, 1.5fr); + grid-template-columns: repeat(4, minmax(280px, 1fr)); /* Changed to 4 columns */ gap: 1rem 1.5rem; } diff --git a/public/js/pages/Cpeprovisioning/Cpeprovisioning.js b/public/js/pages/Cpeprovisioning/Cpeprovisioning.js index ec93ede76..ac3150a99 100644 --- a/public/js/pages/Cpeprovisioning/Cpeprovisioning.js +++ b/public/js/pages/Cpeprovisioning/Cpeprovisioning.js @@ -87,7 +87,7 @@ Vue.component('Cpeprovisioning', {
-
+
Produkt & VLANs

{{ item.product_name }} {{ item.product_code }}

@@ -103,6 +103,22 @@ Vue.component('Cpeprovisioning', {

+
+ +
+
Aktionen
+
+ + +
@@ -125,6 +141,20 @@ Vue.component('Cpeprovisioning', {
+ + +
+
+
+
+
+
+ +
+
+
`, data() { @@ -143,7 +173,9 @@ Vue.component('Cpeprovisioning', { delayOptions: [ { value: '1', text: 'Nicht anzeigen' }, { value: '0', text: 'Anzeigen' } ], page: 1, pagination: {}, - debouncedFetchData: null + debouncedFetchData: null, + extensionId: 'jglijfiddilckddlmbnlojmmlahboffh', + showExtensionIdModal: false } }, computed: { @@ -157,8 +189,35 @@ Vue.component('Cpeprovisioning', { }, created() { this.debouncedFetchData = _.debounce(this.fetchData.bind(this, true), 400); + const savedExtensionId = localStorage.getItem('radiusExtensionId'); + if (savedExtensionId) { + this.extensionId = savedExtensionId; + } + window.addEventListener('keydown', this.handleKeydown); + }, + beforeDestroy() { + window.removeEventListener('keydown', this.handleKeydown); }, methods: { + handleKeydown(e) { + if (e.code === 'KeyE' && e.ctrlKey && e.altKey) { + e.preventDefault(); + this.openExtensionIdModal(); + } + }, + openExtensionIdModal() { + this.showExtensionIdModal = true; + }, + saveExtensionId() { + localStorage.setItem('radiusExtensionId', this.extensionId); + this.showExtensionIdModal = false; + window.notify('success', 'Extension ID gespeichert.'); + }, + isValidMac(mac) { + if (!mac) return false; + const macRegex = /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/; + return macRegex.test(mac); + }, async fetchData(isNewSearch = false) { if (isNewSearch) { this.page = 1; @@ -215,15 +274,15 @@ Vue.component('Cpeprovisioning', { markDirty(item) { this.$set(item, 'isDirty', true); }, - async checkShipping(item) { - await this.$nextTick(); + checkShipping(item) { if (item.cpe_data.shipping && item.cpe_data.routertype) { const shippingData = this.window.TT_CONFIG.ROUTER_SHIPPING_DATA[item.cpe_data.routertype]; if (shippingData) { - this.$set(item.cpe_data, 'ship_weight', shippingData.weight); - this.$set(item.cpe_data, 'ship_length', shippingData.length); - this.$set(item.cpe_data, 'ship_width', shippingData.width); - this.$set(item.cpe_data, 'ship_height', shippingData.height); + item.cpe_data.ship_weight = shippingData.weight; + item.cpe_data.ship_length = shippingData.length; + item.cpe_data.ship_width = shippingData.width; + item.cpe_data.ship_height = shippingData.height; + item.cpe_data = { ...item.cpe_data }; // Trigger reactivity this.window.notify('success', 'Versanddaten wurden automatisch ausgefüllt.'); } } else if (!item.cpe_data.shipping) { @@ -231,6 +290,66 @@ Vue.component('Cpeprovisioning', { item.cpe_data.ship_length = ''; item.cpe_data.ship_width = ''; item.cpe_data.ship_height = ''; + item.cpe_data = { ...item.cpe_data }; // Trigger reactivity + } + }, + isVlanSelected(item) { + return item.vlans && Object.values(item.vlans).some(v => v.checked); + }, + createRadiusUser(item) { + const message = { + type: "INITIATE_CREATE_RADIUS_USER", + payload: { + customerName: item.customer, + address: item.owner_full_address, + servicePin: item.spin, + routerMac: item.cpe_data.mac, + customerNumber: item.owner_customer_number + } + }; + + if (window.chrome && chrome.runtime && chrome.runtime.sendMessage) { + try { + chrome.runtime.sendMessage(this.extensionId, message, (response) => { + if (chrome.runtime.lastError) { + console.warn("Senden an Erweiterung fehlgeschlagen:", chrome.runtime.lastError.message); + window.notify('warning', 'Daten konnten nicht an die Erweiterung gesendet werden. (Drücke STRG + ALT + E zum Konfigurieren)'); + } else { + console.log("Erweiterung hat geantwortet:", response); + window.notify('success', 'Radius Anlage an Erweiterung übergeben.'); + } + }); + } catch (e) { + console.error("Fehler beim Senden an die Erweiterung:", e); + window.notify('error', 'Fehler beim Senden an die Erweiterung.'); + } + } else { + console.warn("Chrome Extension Messaging API nicht verfügbar."); + window.notify('warning', 'Chrome Messaging API nicht gefunden.'); + } + }, + async testAcsVlan(item) { + const button = this.$el.querySelector(`[data-orderproduct-id="${item.orderproduct_id}"] .btn-info`); + if (button) { + button.disabled = true; + } + + try { + const { data } = await axios.post(window.TT_CONFIG.CPE_PROV_API_TEST_ACS_VLAN_URL, { + mac: item.cpe_data.mac + }); + + if (data.success) { + window.notify('success', `ACS VLAN Zuweisung erfolgreich: VLAN ${data.vlan_id}`); + } else { + window.notify('error', data.message || 'Fehler bei der ACS VLAN Zuweisung.'); + } + } catch (error) { + window.notify('error', 'Ein unerwarteter Fehler ist aufgetreten.'); + } finally { + if (button) { + button.disabled = false; + } } }, _buildSavePayload(item) { @@ -275,5 +394,6 @@ Vue.component('Cpeprovisioning', { }, mounted() { this.fetchData(true); + window.addEventListener('keydown', this.handleKeydown); } }); \ No newline at end of file diff --git a/public/js/pages/Radius/RadiusUsers.js b/public/js/pages/Radius/RadiusUsers.js index ca066fd8c..0507dd526 100644 --- a/public/js/pages/Radius/RadiusUsers.js +++ b/public/js/pages/Radius/RadiusUsers.js @@ -610,7 +610,7 @@ Vue.component('radius-users', { chrome.runtime.sendMessage(extensionId, message, (response) => { if (chrome.runtime.lastError) { console.warn("Senden an Erweiterung fehlgeschlagen:", chrome.runtime.lastError.message); - window.notify('warning', 'Scan-Daten konnten nicht an die Erweiterung gesendet werden.'); + window.notify('warning', 'Scan-Daten konnten nicht an die Erweiterung gesendet werden. (Drücke STRG + ALT + E zum Konfigurieren)'); } else { console.log("Erweiterung hat geantwortet:", response); } From 035208af5e37e34082b706d5f11bd231e060c6a4 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Mon, 17 Nov 2025 14:27:48 +0100 Subject: [PATCH 030/123] Swap components in WorkorderMphAdmin.js and add OAID display in WorkorderMphBase.js --- public/js/pages/WorkorderMphAdmin/WorkorderMphAdmin.js | 4 ++-- public/js/pages/WorkorderMphBase/WorkorderMphBase.js | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/public/js/pages/WorkorderMphAdmin/WorkorderMphAdmin.js b/public/js/pages/WorkorderMphAdmin/WorkorderMphAdmin.js index 3d67c69d1..1362210ad 100644 --- a/public/js/pages/WorkorderMphAdmin/WorkorderMphAdmin.js +++ b/public/js/pages/WorkorderMphAdmin/WorkorderMphAdmin.js @@ -78,10 +78,10 @@ Vue.component('workorder-mph-admin', {