Introduces v2 role routes/views and moves request list filtering, sorting, and pagination to the backend for better performance. Also switches shared pages to a generated Tailwind CSS bundle with supporting npm assets.
600 lines
16 KiB
PHP
600 lines
16 KiB
PHP
document.addEventListener('alpine:init', () => {
|
|
Alpine.data("dashboard", () => ({
|
|
// dashboard
|
|
today: "",
|
|
filter: { date1: "", date2: "" },
|
|
rows: [],
|
|
isLoading: false,
|
|
counters: { Pend: 0, Coll: 0, Recv: 0, Inc: 0, Fin: 0, Total: 0 },
|
|
totalRows: 0,
|
|
|
|
// Toast queue to prevent DOM accumulation
|
|
_toastQueue: [],
|
|
_maxToasts: 3,
|
|
_abortController: null,
|
|
_fetchToken: 0,
|
|
|
|
selectedPrinter: localStorage.getItem('selectedPrinter') || 'zebracs2',
|
|
|
|
initSelectedPrinter() {
|
|
this.$watch('selectedPrinter', value => {
|
|
localStorage.setItem('selectedPrinter', value);
|
|
});
|
|
},
|
|
// 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',
|
|
|
|
// Sorting & Pagination
|
|
sortCol: 'REQDATE',
|
|
sortAsc: false,
|
|
currentPage: 1,
|
|
pageSize: 50,
|
|
totalPages: 1,
|
|
validatedCount: 0,
|
|
|
|
sort(col) {
|
|
if (this.sortCol === col) {
|
|
this.sortAsc = !this.sortAsc;
|
|
} else {
|
|
this.sortCol = col;
|
|
this.sortAsc = true;
|
|
}
|
|
this.currentPage = 1;
|
|
this.fetchList();
|
|
},
|
|
|
|
nextPage() {
|
|
if (this.currentPage < this.totalPages) {
|
|
this.currentPage++;
|
|
this.fetchList();
|
|
}
|
|
},
|
|
|
|
prevPage() {
|
|
if (this.currentPage > 1) {
|
|
this.currentPage--;
|
|
this.fetchList();
|
|
}
|
|
},
|
|
|
|
setFilterKey(key) {
|
|
this.filterKey = key;
|
|
this.currentPage = 1;
|
|
this.fetchList();
|
|
},
|
|
|
|
setFilterTable(value) {
|
|
this.filterTable = value;
|
|
this.currentPage = 1;
|
|
this.fetchList();
|
|
},
|
|
|
|
init() {
|
|
this.today = new Date().toISOString().slice(0, 10);
|
|
this.filter.date1 = this.today;
|
|
this.filter.date2 = this.today;
|
|
|
|
this.initSelectedPrinter();
|
|
|
|
// Initial load only - no auto-refresh
|
|
this.fetchList();
|
|
},
|
|
|
|
fetchList() {
|
|
if (this._abortController) {
|
|
this._abortController.abort();
|
|
}
|
|
|
|
this._abortController = new AbortController();
|
|
const token = ++this._fetchToken;
|
|
this.isLoading = true;
|
|
this.rows = [];
|
|
const param = new URLSearchParams({
|
|
...this.filter,
|
|
page: String(this.currentPage),
|
|
pageSize: String(this.pageSize),
|
|
sortCol: this.sortCol,
|
|
sortDir: this.sortAsc ? 'ASC' : 'DESC',
|
|
filterKey: this.filterKey,
|
|
search: this.filterTable,
|
|
}).toString();
|
|
|
|
fetch(`${BASEURL}/api/requests?${param}`, {
|
|
method: 'GET',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
signal: this._abortController.signal,
|
|
}).then(res => res.json()).then(data => {
|
|
if (token !== this._fetchToken) return;
|
|
|
|
this.rows = data.data ?? [];
|
|
this.counters = data.counters ?? { Pend: 0, Coll: 0, Recv: 0, Inc: 0, Fin: 0, Total: 0 };
|
|
this.validatedCount = Number(data.validatedCount ?? 0);
|
|
const pagination = data.pagination ?? {};
|
|
this.totalRows = Number(pagination.totalRows ?? 0);
|
|
this.totalPages = Number(pagination.totalPages ?? 1);
|
|
}).finally(() => {
|
|
if (token === this._fetchToken) {
|
|
this.isLoading = false;
|
|
}
|
|
}).catch(error => {
|
|
if (error && error.name === 'AbortError') return;
|
|
this.isLoading = false;
|
|
this.showToast('Failed to load requests', 'error');
|
|
});
|
|
},
|
|
|
|
reset() {
|
|
this.filter.date1 = this.today;
|
|
this.filter.date2 = this.today;
|
|
this.filterTable = '';
|
|
this.filterKey = 'Total';
|
|
this.currentPage = 1;
|
|
this.sortCol = 'REQDATE';
|
|
this.sortAsc = false;
|
|
this.fetchList();
|
|
},
|
|
|
|
/*
|
|
sample dialog
|
|
*/
|
|
item: '',
|
|
isDialogSampleOpen: false,
|
|
isSampleLoading: false,
|
|
|
|
openSampleDialog(accessnumber) {
|
|
this.isDialogSampleOpen = true;
|
|
this.fetchItem(accessnumber)
|
|
},
|
|
|
|
closeSampleDialog() {
|
|
this.isDialogSampleOpen = false;
|
|
this.item = null;
|
|
this.item = [];
|
|
},
|
|
|
|
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' : '?eng=0';
|
|
|
|
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;
|
|
// Clear iframe src to release memory
|
|
const iframe = this.$refs.previewIframe;
|
|
if (iframe) iframe.src = 'about:blank';
|
|
},
|
|
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');
|
|
}
|
|
},
|
|
|
|
// English Result Dialog
|
|
isDialogEngResultOpen: false,
|
|
engResultAccessnumber: null,
|
|
engResultItem: null,
|
|
isCreatingEngResult: false,
|
|
isEngResultIframeLoaded: false,
|
|
|
|
openEngResultDialog(item) {
|
|
this.engResultItem = item;
|
|
this.engResultAccessnumber = item.SP_ACCESSNUMBER;
|
|
this.isEngResultIframeLoaded = false;
|
|
this.isCreatingEngResult = false;
|
|
this.isDialogEngResultOpen = true;
|
|
},
|
|
|
|
closeEngResultDialog() {
|
|
this.isDialogEngResultOpen = false;
|
|
this.engResultAccessnumber = null;
|
|
this.engResultItem = null;
|
|
this.isEngResultIframeLoaded = false;
|
|
this.isCreatingEngResult = false;
|
|
// Clear iframe src to release memory
|
|
const iframe = this.$refs.engResultIframe;
|
|
if (iframe) iframe.src = 'about:blank';
|
|
},
|
|
|
|
getEngResultUrl() {
|
|
if (this.engResultAccessnumber) {
|
|
return `${BASEURL}report/${this.engResultAccessnumber}?eng=1`;
|
|
}
|
|
return '';
|
|
},
|
|
|
|
onEngResultIframeLoad() {
|
|
this.isEngResultIframeLoaded = true;
|
|
},
|
|
|
|
async confirmCreateEngResult() {
|
|
if (!this.engResultAccessnumber || this.isCreatingEngResult) return;
|
|
|
|
this.isCreatingEngResult = true;
|
|
try {
|
|
const res = await fetch(`${BASEURL}/api/requests/${this.engResultAccessnumber}/eng`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
const data = await res.json();
|
|
this.isCreatingEngResult = false;
|
|
|
|
if (data.status === 'success') {
|
|
this.showToast('English result created successfully', 'success');
|
|
this.closeEngResultDialog();
|
|
this.fetchList();
|
|
} else {
|
|
this.showToast(data.message || 'Failed to create English result', 'error');
|
|
}
|
|
} catch (e) {
|
|
this.isCreatingEngResult = false;
|
|
this.showToast('Failed to create English result', 'error');
|
|
}
|
|
},
|
|
|
|
showToast(message, type = 'success') {
|
|
// Limit concurrent toasts to prevent DOM accumulation
|
|
if (this._toastQueue.length >= this._maxToasts) {
|
|
const oldToast = this._toastQueue.shift();
|
|
if (oldToast && oldToast.parentNode) oldToast.remove();
|
|
}
|
|
|
|
const toast = document.createElement('div');
|
|
toast.className = `alert alert-${type} fixed top-4 right-4 z-50`;
|
|
toast.innerHTML = `<i class="fa ${type === 'error' ? 'fa-times-circle' : 'fa-check-circle'}"></i> ${message}`;
|
|
document.body.appendChild(toast);
|
|
this._toastQueue.push(toast);
|
|
|
|
setTimeout(() => {
|
|
toast.remove();
|
|
const index = this._toastQueue.indexOf(toast);
|
|
if (index > -1) this._toastQueue.splice(index, 1);
|
|
}, 2000);
|
|
},
|
|
|
|
destroy() {
|
|
// Clear large data arrays to free memory
|
|
this.rows = [];
|
|
this.totalRows = 0;
|
|
this.auditData = null;
|
|
this._cachedAuditEvents = [];
|
|
this.item = null;
|
|
this.previewItem = null;
|
|
this.engResultItem = null;
|
|
if (this._abortController) {
|
|
this._abortController.abort();
|
|
this._abortController = null;
|
|
}
|
|
// Clear any open dialogs and their iframe references
|
|
if (this.$refs.previewIframe) this.$refs.previewIframe.src = 'about:blank';
|
|
if (this.$refs.engResultIframe) this.$refs.engResultIframe.src = 'about:blank';
|
|
// Clear any remaining toasts
|
|
this._toastQueue.forEach(t => { if (t.parentNode) t.remove(); });
|
|
this._toastQueue = [];
|
|
},
|
|
}));
|
|
});
|