document.addEventListener('alpine:init', () => { Alpine.data("dashboard", () => ({ // dashboard today: "", filter: { date1: "", date2: "" }, list: [], isLoading: false, counters: { Pend: 0, Coll: 0, Recv: 0, Inc: 0, Fin: 0, Total: 0 }, selectedPrinter: 'lab', // PDF Generation Dialog isGenerateDialogOpen: false, generateAccessnumber: null, generateLang: 0, statusColor: { Pend: 'bg-white text-black font-bold', PartColl: 'bg-[#ff99aa] text-black font-bold', Coll: 'bg-[#d63031] text-white font-bold', PartRecv: 'bg-[#a0c0d9] text-black font-bold', Recv: 'bg-[#0984e3] text-white font-bold', Inc: 'bg-[#ffff00] text-black font-bold', Fin: 'bg-[#008000] text-white font-bold', }, statusRowBg: { Pend: 'bg-white text-black', PartColl: 'bg-[#ff99aa] text-black', Coll: 'bg-[#d63031] text-white', PartRecv: 'bg-[#a0c0d9] text-black', Recv: 'bg-[#0984e3] text-white', Inc: 'bg-[#ffff00] text-black', Fin: 'bg-[#008000] text-white', }, filterTable: "", filterKey: 'Total', statusMap: { Total: [], Pend: ['Pend'], Coll: ['Coll', 'PartColl'], Recv: ['Recv', 'PartRecv'], Inc: ['Inc'], Fin: ['Fin'], }, // Sorting & Pagination sortCol: 'REQDATE', sortAsc: false, currentPage: 1, pageSize: 30, // Cached computed properties to prevent memory leak filtered: [], sorted: [], paginated: [], totalPages: 1, validatedCount: 0, sort(col) { if (this.sortCol === col) { this.sortAsc = !this.sortAsc; } else { this.sortCol = col; this.sortAsc = true; } }, nextPage() { if (this.currentPage < this.totalPages) this.currentPage++; }, prevPage() { if (this.currentPage > 1) this.currentPage--; }, // Compute methods - called only when dependencies change computeFiltered() { let filteredList = this.list; if (this.filterKey === 'Validated') { filteredList = filteredList.filter(item => this.isValidated(item)); } else { const validStatuses = this.statusMap[this.filterKey]; if (validStatuses.length > 0) { filteredList = filteredList.filter(item => validStatuses.includes(item.STATS)); } } if (this.filterTable) { const searchTerm = this.filterTable.toLowerCase(); filteredList = filteredList.filter(item => Object.values(item).some(value => String(value).toLowerCase().includes(searchTerm) ) ); } this.filtered = filteredList; }, computeSorted() { this.sorted = this.filtered.slice().sort((a, b) => { let modifier = this.sortAsc ? 1 : -1; if (a[this.sortCol] < b[this.sortCol]) return -1 * modifier; if (a[this.sortCol] > b[this.sortCol]) return 1 * modifier; return 0; }); }, computePaginated() { const start = (this.currentPage - 1) * this.pageSize; const end = start + this.pageSize; this.paginated = this.sorted.slice(start, end); }, computeTotalPages() { this.totalPages = Math.ceil(this.filtered.length / this.pageSize) || 1; }, init() { this.today = new Date().toISOString().slice(0, 10); this.filter.date1 = this.today; this.filter.date2 = this.today; const defaultPrinter = 'get("userrole")]["sampleDialog"]["defaultPrinter"] ?? "lab" ?>'; this.selectedPrinter = defaultPrinter || 'lab'; // Watchers for reactive updates this.$watch('list', () => { this.computeFiltered(); this.computeValidatedCount(); }); this.$watch('filterKey', () => this.computeFiltered()); this.$watch('filterTable', () => { this.currentPage = 1; this.computeFiltered(); }); this.$watch('filtered', () => { this.computeSorted(); this.computeTotalPages(); }); this.$watch('sortCol', () => this.computeSorted()); this.$watch('sortAsc', () => this.computeSorted()); this.$watch('sorted', () => this.computePaginated()); this.$watch('currentPage', () => this.computePaginated()); this.$watch('auditData', () => { this.computeAuditEvents(); }); // Initial load only - no auto-refresh this.fetchList(); }, fetchList() { this.isLoading = true; this.list = []; let statusOrder = { Pend: 1, PartColl: 2, Coll: 3, PartRecv: 4, Recv: 5, Inc: 6, Fin: 7 }; let param = new URLSearchParams(this.filter).toString(); for (let k in this.counters) { this.counters[k] = 0; } fetch(`${BASEURL}/api/requests?${param}`, { method: 'GET', headers: { 'Content-Type': 'application/json' }, }).then(res => res.json()).then(data => { this.list = data.data ?? []; this.filterKey = 'Total'; this.list.forEach(item => { if (this.counters[item.STATS] !== undefined) { this.counters[item.STATS]++; this.counters.Total++; } else { if (item.STATS == 'PartColl') { this.counters.Coll++; } else if (item.STATS == 'PartRecv') { this.counters.Recv++; } this.counters.Total++; } }); this.list.sort((a, b) => { let codeA = statusOrder[a.STATS] ?? 0; let codeB = statusOrder[b.STATS] ?? 0; return codeA - codeB; }); }).finally(() => { this.isLoading = false; }); }, reset() { this.filter.date1 = this.today; this.filter.date2 = this.today; this.fetchList(); }, isValidated(item) { return item.ISVAL == 1 && item.ISPENDING != 1; }, computeValidatedCount() { this.validatedCount = this.list.filter(r => this.isValidated(r)).length; }, /* sample dialog */ item: '', isDialogSampleOpen: false, isSampleLoading: false, openSampleDialog(accessnumber) { this.isDialogSampleOpen = true; this.fetchItem(accessnumber) }, closeSampleDialog() { this.isDialogSampleOpen = false; this.item = null; }, fetchItem(accessnumber) { this.isSampleLoading = true; this.item = []; fetch(`${BASEURL}/api/samples/${accessnumber}`, { method: 'GET', headers: { 'Content-Type': 'application/json' } }) .then(res => res.json()).then(data => { this.item = data.data ?? {}; if (!Array.isArray(this.item.samples)) this.item.samples = []; }).finally(() => { this.isSampleLoading = false; }); }, collect(sampcode, accessnumber) { fetch(`${BASEURL}/api/samples/collect/${accessnumber}/${sampcode}`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }) .then(res => res.json()).then(data => { this.fetchItem(accessnumber); }); }, unreceive(sampcode, accessnumber) { if (!confirm(`Unreceive sample ${sampcode} from request ${accessnumber}?`)) { return; } fetch(`${BASEURL}/api/samples/unreceive/${accessnumber}/${sampcode}`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }) .then(res => res.json()).then(data => { this.fetchItem(accessnumber); }); }, /* unvalidate dialog */ isDialogUnvalOpen: false, unvalReason: '', unvalAccessnumber: null, openUnvalDialog(accessnumber) { this.unvalReason = ''; this.isDialogUnvalOpen = true; this.unvalAccessnumber = accessnumber; }, unvalidate(accessnumber, userid) { if (!confirm(`Unvalidate request ${accessnumber}?`)) { return; } fetch(`${BASEURL}/api/requests/validate/${accessnumber}`, { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userid: `${userid}`, comment: this.unvalReason.trim() }) }).then(response => { this.closeUnvalDialog(); this.fetchList(); console.log(`Unvalidate clicked for ${accessnumber}, by user ${userid}`); }); }, closeUnvalDialog() { this.isDialogUnvalOpen = false; }, /* audit dialog */ isDialogAuditOpen: false, auditData: null, auditAccessnumber: null, auditTab: 'all', // Cached audit events to prevent memory leak _cachedAuditEvents: [], openAuditDialog(accessnumber) { this.auditAccessnumber = accessnumber; this.auditData = null; this._cachedAuditEvents = []; this.auditTab = 'all'; this.isDialogAuditOpen = true; this.fetchAudit(accessnumber); }, closeAuditDialog() { this.isDialogAuditOpen = false; this.auditData = null; this._cachedAuditEvents = []; this.auditAccessnumber = null; }, fetchAudit(accessnumber) { fetch(`${BASEURL}/api/requests/${accessnumber}/audit`, { method: 'GET', headers: { 'Content-Type': 'application/json' } }).then(res => res.json()).then(data => { this.auditData = data.data; this.computeAuditEvents(); }); }, computeAuditEvents() { if (!this.auditData) { this._cachedAuditEvents = []; return; } let events = []; let id = 0; this.auditData.validation.forEach(v => { let desc = `Validated by ${v.user}`; if (v.type === 'UNVAL') { desc = `Unvalidated by ${v.user}`; } events.push({ id: id++, category: 'validation', type: v.type, description: desc, datetime: v.datetime, user: v.user, reason: v.reason || null }); }); this.auditData.sample_collection.forEach(s => { events.push({ id: id++, category: 'sample', type: s.action === 'COLLECTED' ? 'COLLECT' : 'UNRECEIVE', description: `Tube ${s.tubenumber}: ${s.action} by ${s.user}`, datetime: s.datetime, user: s.user, reason: null }); }); this.auditData.tube_received?.forEach(t => { events.push({ id: id++, category: 'receive', type: 'RECEIVED', description: `${t.sampletype} received by ${t.user}`, datetime: t.datetime, user: t.user, reason: null }); }); this.auditData.pdf_generation?.forEach(p => { let desc = ''; if (p.type === 'PRINT') { desc = `Printed report (${p.status})`; } else if (p.type === 'GEN_PDF') { desc = `PDF Generated (${p.status})`; } else if (p.type === 'REGEN_PDF') { desc = `PDF Regenerated (${p.status})`; } events.push({ id: id++, category: 'pdf', type: p.type, description: desc, datetime: p.datetime, user: p.user, reason: null }); }); this._cachedAuditEvents = events.sort((a, b) => { if (!a.datetime) return 1; if (!b.datetime) return -1; return new Date(a.datetime) - new Date(b.datetime); }); }, get getAllAuditEvents() { return this._cachedAuditEvents; }, get getFilteredAuditEvents() { if (this.auditTab === 'all') return this._cachedAuditEvents; return this._cachedAuditEvents.filter(e => e.category === this.auditTab); }, /* PDF Generation Dialog methods */ openGenerateDialog(accessnumber) { this.generateAccessnumber = accessnumber; this.generateLang = 0; this.isGenerateDialogOpen = true; }, closeGenerateDialog() { this.isGenerateDialogOpen = false; this.generateAccessnumber = null; }, async generatePdfFromDialog() { const eng = this.generateLang === 1 ? '?eng=1' : ''; try { const res = await fetch(`${BASEURL}report/${this.generateAccessnumber}/pdf${eng}`); const data = await res.json(); if (data.success) { this.showToast(`${data.lang} PDF queued for download`, 'success'); } else { this.showToast('PDF generation failed', 'error'); } } catch (e) { this.showToast('PDF generation failed - try again', 'error'); } }, printAllLabels(accessnumber) { const printer = this.selectedPrinter || 'lab'; fetch(`${BASEURL}/label/all/${accessnumber}/${printer}`, { method: 'GET' }) .then(res => { if (res.ok) { this.showToast('All labels printed', 'success'); } else { this.showToast('Print failed', 'error'); } }) .catch(() => this.showToast('Print failed', 'error')); }, printCollectionLabel(accessnumber) { const printer = this.selectedPrinter || 'lab'; fetch(`${BASEURL}/label/coll/${accessnumber}/${printer}`, { method: 'GET' }) .then(res => { if (res.ok) { this.showToast('Collection label printed', 'success'); } else { this.showToast('Print failed', 'error'); } }) .catch(() => this.showToast('Print failed', 'error')); }, printSampleLabel(accessnumber, sampcode) { const printer = this.selectedPrinter || 'lab'; fetch(`${BASEURL}/label/dispatch/${accessnumber}/${sampcode}/${printer}`, { method: 'GET' }) .then(res => { if (res.ok) { this.showToast('Sample label printed', 'success'); } else { this.showToast('Print failed', 'error'); } }) .catch(() => this.showToast('Print failed', 'error')); }, /* preview dialog methods */ isDialogPreviewOpen: false, previewAccessnumber: null, previewItem: null, isPreviewIframeLoaded: false, isPreviewValidating: false, openPreviewDialog(item) { this.previewItem = item; this.previewAccessnumber = item.SP_ACCESSNUMBER; this.isPreviewIframeLoaded = false; this.isPreviewValidating = false; this.isDialogPreviewOpen = true; }, closePreviewDialog() { this.isDialogPreviewOpen = false; this.previewItem = null; this.previewAccessnumber = null; this.isPreviewIframeLoaded = false; }, getPreviewUrl() { if(this.previewAccessnumber != null) { return `${BASEURL}report/${this.previewAccessnumber}`; } }, onPreviewIframeLoad() { this.isPreviewIframeLoaded = true; }, async validateFromPreview(accessnumber, userid) { if (!this.isPreviewIframeLoaded || this.isPreviewValidating) return; this.isPreviewValidating = true; try { const res = await fetch(`${BASEURL}/api/requests/validate/${accessnumber}`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }); const data = await res.json(); this.isPreviewValidating = false; if (data.val) { this.showToast(`Validated (val${data.val}): ${accessnumber}`, 'success'); // Generate PDF if second validation (val2) succeeded if (data.val === 2) { try { const pdfRes = await fetch(`${BASEURL}report/${accessnumber}/pdf`); const pdfData = await pdfRes.json(); if (pdfData.success) { this.showToast(`${pdfData.lang} PDF queued for download`, 'success'); } else { this.showToast('PDF generation failed', 'error'); } } catch (e) { this.showToast('PDF generation failed', 'error'); } } this.fetchList(); this.closePreviewDialog(); } else if (data.message && data.message.includes('already validate')) { this.showToast('You have already validated this request', 'error'); } else { this.showToast(data.message || 'Validation failed', 'error'); } } catch (e) { this.isPreviewValidating = false; this.showToast('Validation failed', 'error'); } }, showToast(message, type = 'success') { const toast = document.createElement('div'); toast.className = `alert alert-${type} fixed top-4 right-4 z-50`; toast.innerHTML = ` ${message}`; document.body.appendChild(toast); setTimeout(() => toast.remove(), 2000); }, })); });