gdc_cmod/app/Views/shared/script_validation.php
mahdahar 2843ddd392 Migrate PDF generation from legacy spooler_db to CI4 + node_spooler
BREAKING CHANGE: Remove public/spooler_db/ legacy system

Changes:
- Migrate validation preview from http://glenlis/spooler_db/main_dev.php to CI4 /report/{accessnumber}
- Add ReportController::preview() for HTML preview in validation dialog
- Add ReportController::generatePdf() to queue PDF generation via node_spooler at http://glenlis:3030
- Add ReportController::checkPdfStatus() to poll spooler job status
- Add ReportController::postToSpooler() helper for curl requests to spooler API
- Add routes: GET /report/(:num)/preview, GET /report/(:num)/pdf, GET /report/status/(:any)
- Delete public/spooler_db/ directory (40+ legacy files)
- Compact node_spooler/README.md from 577 to 342 lines

Technical Details:
- New architecture: CI4 Controller -> node_spooler (port 3030) -> Chrome CDP (port 42020)
- API endpoints: POST /api/pdf/generate, GET /api/pdf/status/:jobId, GET /api/queue/stats
- Features: Max 5 concurrent jobs, max 100 in queue, auto-cleanup after 60 min
- Error handling: Chrome crash detection, manual error review in data/error/
- PDF infrastructure ready, frontend PDF buttons to be updated later in production

Migration verified:
- No external code references spooler_db
- All assets duplicated in public/assets/report/
- Syntax checks passed for ReportController.php and Routes.php

Refs: node_spooler/README.md
2026-02-03 11:33:55 +07:00

217 lines
5.7 KiB
PHP

document.addEventListener('alpine:init', () => {
Alpine.data("validatePage", () => ({
// validate page specific
unvalidatedList: [],
isLoading: false,
isValidating: false,
// Date filter - missing in original code!
filter: { date1: "", date2: "" },
filterTable: "",
// Sorting & Pagination (shared with dashboard)
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.unvalidatedTotalPages) this.currentPage++;
},
prevPage() {
if (this.currentPage > 1) this.currentPage--;
},
get unvalidatedTotalPages() {
return Math.ceil(this.unvalidatedFiltered.length / this.pageSize) || 1;
},
get unvalidatedSorted() {
return this.unvalidatedFiltered.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 unvalidatedPaginated() {
const start = (this.currentPage - 1) * this.pageSize;
const end = start + this.pageSize;
return this.unvalidatedSorted.slice(start, end);
},
get unvalidatedFiltered() {
if (!this.filterTable) return this.unvalidatedList;
const searchTerm = this.filterTable.toLowerCase();
return this.unvalidatedList.filter(item =>
Object.values(item).some(value =>
String(value).toLowerCase().includes(searchTerm)
)
);
},
get unvalidatedCount() {
return this.unvalidatedList.length;
},
init() {
this.today = new Date().toISOString().slice(0, 10);
this.filter.date1 = this.today;
this.filter.date2 = this.today;
this.$watch('filterTable', () => {
this.currentPage = 1;
});
// Auto-fetch on page load
this.fetchUnvalidated();
// Keyboard shortcuts for dialog
document.addEventListener('keydown', (e) => {
if (this.isDialogValOpen) {
// N key - skip to next
if (e.key === 'n' || e.key === 'N') {
if (!e.target.closest('input, textarea, button')) {
e.preventDefault();
this.skipToNext();
}
}
}
});
},
fetchUnvalidated() {
this.isLoading = true;
this.unvalidatedList = [];
let param = new URLSearchParams(this.filter).toString();
fetch(`${BASEURL}/api/validate/unvalidated?${param}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}).then(res => res.json()).then(data => {
this.unvalidatedList = data.data ?? [];
}).finally(() => {
this.isLoading = false;
});
},
resetUnvalidated() {
this.filter.date1 = this.today;
this.filter.date2 = this.today;
this.fetchUnvalidated();
},
/*
* validate dialog methods
*/
valAccessnumber: null,
valItem: null,
currentIndex: 0,
isDialogValOpen: false,
toast: { show: false, message: '', type: 'success' },
showToast(message, type = 'success') {
this.toast = { show: true, message, type };
setTimeout(() => {
this.toast.show = false;
}, 2000);
},
openValDialogByIndex(index) {
const filtered = this.unvalidatedFiltered;
if (index < 0 || index >= filtered.length) {
this.showToast('No more requests', 'error');
this.closeValDialog();
return;
}
const item = filtered[index];
this.currentIndex = index;
this.valAccessnumber = item.SP_ACCESSNUMBER;
this.valItem = item;
this.isDialogValOpen = true;
// Focus validate button after dialog renders - use setTimeout for reliability
setTimeout(() => {
const btn = document.getElementById('validate-btn');
if (btn) btn.focus();
}, 50);
},
openValDialog(accessnumber) {
// Find index by accessnumber
const filtered = this.unvalidatedFiltered;
const index = filtered.findIndex(item => item.SP_ACCESSNUMBER === accessnumber);
if (index !== -1) {
this.openValDialogByIndex(index);
} else {
this.openValDialogByIndex(0);
}
},
closeValDialog() {
this.isDialogValOpen = false;
this.valAccessnumber = null;
this.valItem = null;
},
skipToNext() {
const nextIndex = (this.currentIndex + 1) % this.unvalidatedFiltered.length;
this.closeValDialog();
// Use setTimeout for reliable focus after dialog re-renders
setTimeout(() => this.openValDialogByIndex(nextIndex), 50);
},
validate(accessnumber, userid) {
if (!confirm(`Validate request ${accessnumber}?`)) return;
this.isValidating = true;
fetch(`${BASEURL}/api/requests/validate/${accessnumber}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userid: `${userid}` })
}).then(response => response.json()).then(data => {
// Show toast
this.showToast(`Validated: ${accessnumber}`);
// Remove validated item from local list
this.unvalidatedList = this.unvalidatedList.filter(
item => item.SP_ACCESSNUMBER !== accessnumber
);
// Auto-advance to next request
const filteredLength = this.unvalidatedFiltered.length;
if (filteredLength > 0) {
const nextIndex = Math.min(this.currentIndex, filteredLength - 1);
this.closeValDialog();
// Use setTimeout for reliable focus after dialog re-renders
setTimeout(() => this.openValDialogByIndex(nextIndex), 50);
} else {
this.closeValDialog();
this.showToast('All requests validated!');
}
if (data.message && data.message.includes('already validate')) {
alert(data.message);
}
}).catch(() => {
this.showToast('Validation failed', 'error');
}).finally(() => {
this.isValidating = false;
});
},
getPreviewUrl() {
return `<?=base_url()?>report/${this.valAccessnumber}`;
},
}));
});