gdc_cmod/app/Views/shared/script_requests.php
mahdahar 0b4fdcfe5f feat: Extend audit trail with tube received tracking and enhance PDF workflow
Major Updates:

1. Extended Audit Trail System
   - Added tube received events tracking from SP_TUBES table (TUBESTATUS=4)
   - New audit tab "Receive" in dialog_audit.php to display tube reception history
   - ApiRequestsAuditController now fetches and returns tube received events with:
     * Sample type, tube status, collection date, and user information
   - Audit events sorted chronologically combining validation, sampling, and receiving events

2. Enhanced PDF Generation Workflow
   - Created new PdfHelper library with methods for PDF generation and posting to spooler
   - Reports can now be generated via GET /report/{accessnumber}/pdf endpoint
   - Updated PDF spooler API endpoint from port 3030 to 3000
   - Added retry PDF button with spinner animation for failed generations
   - Fixed PDF status check to use correct spooler endpoint

3. Validation UI Improvements
   - Added toast notification showing PDF queued after second validation (val2)
   - Retry PDF button appears when val1 and val2 are complete
   - Toast notifications success/error states with auto-dismiss after 2 seconds
   - Loading state with spinning icon during PDF retry operation

4. Report Template Fixes
   - Fixed typo in Val2 By display (added missing ":")
   - Consistent formatting with Val1 By : and Val2 By :

5. Documentation Updates
   - TODO.md updated with:
     * Auto generate PDF (in progress)
     * Print Eng Result (pending)
     * Add Receive to Audit (completed)

6. Cleanup
   - Removed legacy Node.js spooler implementation (node_spooler directory)
   - Deleted P0_log.txt (SQL setup scripts no longer needed in repo)
   - Cleaned up .gitignore to remove stale node_spooler entries

Files Changed:
- app/Controllers/ApiRequestsAuditController.php (tube received audit)
- app/Controllers/ReportController.php (port update: 3030 → 3000)
- app/Libraries/PdfHelper.php (new library)
- app/Views/report/template.php (typo fix)
- app/Views/shared/content_requests.php (retry PDF button)
- app/Views/shared/dialog_audit.php (receive tab)
- app/Views/shared/script_requests.php (retry handler, tube events)
- app/Views/shared/script_validation.php (enhanced toast)
- TODO.md (pending/completed tasks)
- .gitignore (cleanup)
- Deleted: node_spooler/* (legacy implementation)
- Deleted: P0_log.txt (no longer needed)
2026-02-04 11:09:42 +07:00

344 lines
9.3 KiB
PHP

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 },
retryingPdf: {},
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,
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--;
},
get totalPages() {
return Math.ceil(this.filtered.length / this.pageSize) || 1;
},
get sorted() {
return 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;
});
},
get paginated() {
const start = (this.currentPage - 1) * this.pageSize;
const end = start + this.pageSize;
return this.sorted.slice(start, end);
},
init() {
this.today = new Date().toISOString().slice(0, 10);
// Production: default to today
this.filter.date1 = this.today;
this.filter.date2 = this.today;
this.$watch('filterTable', () => {
this.currentPage = 1;
});
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;
},
get filtered() {
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)
)
);
}
return filteredList;
},
get validatedCount() {
return 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;
},
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}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ samplenumber: sampcode, userid: '<?= session('userid'); ?>' })
})
.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}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ samplenumber: sampcode, userid: '<?= session('userid'); ?>' })
})
.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',
openAuditDialog(accessnumber) {
this.auditAccessnumber = accessnumber;
this.auditData = null;
this.auditTab = 'all';
this.isDialogAuditOpen = true;
this.fetchAudit(accessnumber);
},
closeAuditDialog() {
this.isDialogAuditOpen = false;
this.auditData = null;
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;
});
},
get getAllAuditEvents() {
if (!this.auditData) 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
});
});
return events.sort((a, b) => {
if (!a.datetime) return 1;
if (!b.datetime) return -1;
return new Date(a.datetime) - new Date(b.datetime);
});
},
get getFilteredAuditEvents() {
if (this.auditTab === 'all') return this.getAllAuditEvents;
return this.getAllAuditEvents.filter(e => e.category === this.auditTab);
},
async retryPdf(accessnumber) {
if (this.retryingPdf[accessnumber]) return;
this.retryingPdf[accessnumber] = true;
try {
const res = await fetch(`${BASEURL}/report/${accessnumber}/pdf`);
const data = await res.json();
if (data.success) {
this.showToast('PDF queued for generation', 'success');
} else {
throw new Error(data.error || 'Unknown error');
}
} catch (e) {
this.showToast('PDF generation failed - try again', 'error');
} finally {
this.retryingPdf[accessnumber] = false;
}
},
showToast(message, type = 'success') {
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);
setTimeout(() => toast.remove(), 2000);
},
}));
});