feat: Add PDF generation audit tracking and simplify result dialog

- Add PDF generation events (GEN_PDF, REGEN_PDF) to AUDIT_REQUESTS table
- Track PDF print/generate/regenerate with timestamp and language
- Fix language parameter handling in ReportController (engQuery vs engQuery typo)
- Simplify result dialog to show report in iframe instead of async PDF loading
- Add PDF tab to audit dialog showing generation history
This commit is contained in:
mahdahar 2026-02-05 14:12:17 +07:00
parent 8b4c71d1a3
commit 46dc493af1
6 changed files with 88 additions and 143 deletions

View File

@ -1,11 +1,9 @@
# Project Checklist: Glen RME & Lab Management System # Project Checklist: Glen RME & Lab Management System
**Last Updated:** 20260204 **Last Updated:** 20260205
Pending: Pending:
- Test and fix Reprint label - Test and fix Reprint label
- Test and fix PDF Generation
- Print Result Audit (Track when result reports are printed/exported, log user and timestamp)
Completed: Completed:
- Update User Role levels (Standardize roles: Superuser, Admin, Lab, Phlebo, CS) - Update User Role levels (Standardize roles: Superuser, Admin, Lab, Phlebo, CS)
@ -35,4 +33,6 @@ Completed:
- Auto generate PDF on second val - Auto generate PDF on second val
- Validate delay when loading result - Validate delay when loading result
- Reprint Label (Add functionality to reprint labels) - Reprint Label (Add functionality to reprint labels)
- Create Eng Result UI UX on request dashboard - Create Eng Result UI UX on request dashboard
- Test and fix PDF Generation
- Print Result Audit (Track when result reports are printed/exported, log user and timestamp)

View File

@ -13,7 +13,8 @@ class ApiRequestsAuditController extends BaseController {
'accessnumber' => $accessnumber, 'accessnumber' => $accessnumber,
'validation' => [], 'validation' => [],
'sample_collection' => [], 'sample_collection' => [],
'tube_received' => [] 'tube_received' => [],
'pdf_generation' => []
]; ];
$sqlAudit = "SELECT EVENT_TYPE, USERID, EVENT_AT, REASON $sqlAudit = "SELECT EVENT_TYPE, USERID, EVENT_AT, REASON
@ -63,6 +64,21 @@ class ApiRequestsAuditController extends BaseController {
]; ];
} }
$sqlRequests = "SELECT STEPTYPE, STEPDATE, STEPSTATUS
FROM GDC_CMOD.dbo.AUDIT_REQUESTS
WHERE ACCESSNUMBER = ? AND STEPTYPE IN ('PRINT', 'GEN_PDF', 'REGEN_PDF')
ORDER BY STEPDATE ASC";
$requestRows = $db->query($sqlRequests, [$accessnumber])->getResultArray();
foreach ($requestRows as $row) {
$result['pdf_generation'][] = [
'type' => $row['STEPTYPE'],
'status' => trim($row['STEPSTATUS']),
'datetime' => $row['STEPDATE'] ? date('Y-m-d H:i:s', strtotime($row['STEPDATE'])) : null,
'user' => session()->get('userid')
];
}
return $this->respond(['status' => 'success', 'data' => $result]); return $this->respond(['status' => 'success', 'data' => $result]);
} }
} }

View File

@ -20,14 +20,16 @@ class ReportController extends BaseController
if ($ispdf == 0) { if ($ispdf == 0) {
$ispdf = $this->request->getVar('ispdf') ?? 0; $ispdf = $this->request->getVar('ispdf') ?? 0;
} }
if ($eng == 0) { $engQuery = $this->request->getVar('eng');
// Read REPORT_LANG from CM_REQUESTS if not provided if ($engQuery !== null) {
$eng = (int) $engQuery;
} elseif ($eng == 0) {
$sql = "SELECT REPORT_LANG FROM GDC_CMOD.dbo.CM_REQUESTS WHERE ACCESSNUMBER=?"; $sql = "SELECT REPORT_LANG FROM GDC_CMOD.dbo.CM_REQUESTS WHERE ACCESSNUMBER=?";
$row = $this->db->query($sql, [$accessnumber])->getRowArray(); $row = $this->db->query($sql, [$accessnumber])->getRowArray();
$eng = $row['REPORT_LANG'] ?? 0; $eng = $row['REPORT_LANG'] ?? 0;
} }
return $this->renderReport($accessnumber, $eng, $ispdf, false); return $this->renderReport($accessnumber, $eng, $ispdf, false);
} }
@ -36,14 +38,16 @@ class ReportController extends BaseController
if ($ispdf == 0) { if ($ispdf == 0) {
$ispdf = $this->request->getVar('ispdf') ?? 0; $ispdf = $this->request->getVar('ispdf') ?? 0;
} }
if ($eng == 0) { $engQuery = $this->request->getVar('eng');
// Read REPORT_LANG from CM_REQUESTS if not provided if ($engQuery !== null) {
$eng = (int) $engQuery;
} elseif ($eng == 0) {
$sql = "SELECT REPORT_LANG FROM GDC_CMOD.dbo.CM_REQUESTS WHERE ACCESSNUMBER=?"; $sql = "SELECT REPORT_LANG FROM GDC_CMOD.dbo.CM_REQUESTS WHERE ACCESSNUMBER=?";
$row = $this->db->query($sql, [$accessnumber])->getRowArray(); $row = $this->db->query($sql, [$accessnumber])->getRowArray();
$eng = $row['REPORT_LANG'] ?? 0; $eng = $row['REPORT_LANG'] ?? 0;
} }
return $this->renderReport($accessnumber, $eng, $ispdf, true); return $this->renderReport($accessnumber, $eng, $ispdf, true);
} }
@ -116,12 +120,25 @@ class ReportController extends BaseController
try { try {
$jobId = $this->postToSpooler($html, $filename, $collectionDate); $jobId = $this->postToSpooler($html, $filename, $collectionDate);
$sqlCheck = "SELECT COUNT(*) as cnt FROM GDC_CMOD.dbo.AUDIT_REQUESTS
WHERE ACCESSNUMBER = ? AND STEPTYPE IN ('GEN_PDF', 'REGEN_PDF')";
$result = $this->db->query($sqlCheck, [$accessnumber])->getRowArray();
$stepType = ($result['cnt'] > 0) ? 'REGEN_PDF' : 'GEN_PDF';
$stepStatus = $eng == 1 ? 'English' : 'Indonesian';
$sqlLog = "INSERT INTO GDC_CMOD.dbo.AUDIT_REQUESTS(ACCESSNUMBER, STEPDATE, STEPTYPE, STEPSTATUS)
VALUES (?, GETDATE(), ?, ?)";
$this->db->query($sqlLog, [$accessnumber, $stepType, $stepStatus]);
return $this->response->setJSON([ return $this->response->setJSON([
'success' => true, 'success' => true,
'jobId' => $jobId, 'jobId' => $jobId,
'message' => 'PDF queued for generation', 'message' => 'PDF queued for generation',
'status' => 'queued', 'status' => 'queued',
'lang' => $eng == 1 ? 'English' : 'Indonesian' 'lang' => $eng == 1 ? 'English' : 'Indonesian',
'isRegen' => ($stepType === 'REGEN_PDF')
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
log_message('error', "PDF generation failed: " . $e->getMessage()); log_message('error', "PDF generation failed: " . $e->getMessage());

View File

@ -26,6 +26,9 @@
<button @click="auditTab = 'sample'" <button @click="auditTab = 'sample'"
:class="auditTab === 'sample' ? 'btn-active btn-primary text-white' : 'btn-ghost'" :class="auditTab === 'sample' ? 'btn-active btn-primary text-white' : 'btn-ghost'"
class="btn btn-sm join-item">Sample</button> class="btn btn-sm join-item">Sample</button>
<button @click="auditTab = 'pdf'"
:class="auditTab === 'pdf' ? 'btn-active btn-secondary text-white' : 'btn-ghost'"
class="btn btn-sm join-item">PDF</button>
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
@ -52,14 +55,16 @@
'bg-success': event.category === 'validation' && event.type !== 'UNVAL', 'bg-success': event.category === 'validation' && event.type !== 'UNVAL',
'bg-info': event.category === 'sample', 'bg-info': event.category === 'sample',
'bg-warning': event.category === 'receive', 'bg-warning': event.category === 'receive',
'bg-error': event.category === 'validation' && event.type === 'UNVAL' 'bg-error': event.category === 'validation' && event.type === 'UNVAL',
'bg-secondary': event.category === 'pdf'
}"> }">
<i class="fa text-xs text-white" <i class="fa text-xs text-white"
:class="{ :class="{
'fa-check': event.category === 'validation' && event.type !== 'UNVAL', 'fa-check': event.category === 'validation' && event.type !== 'UNVAL',
'fa-vial': event.category === 'sample', 'fa-vial': event.category === 'sample',
'fa-check-circle': event.category === 'receive', 'fa-check-circle': event.category === 'receive',
'fa-times': event.category === 'validation' && event.type === 'UNVAL' 'fa-times': event.category === 'validation' && event.type === 'UNVAL',
'fa-file-pdf': event.category === 'pdf'
}"></i> }"></i>
</div> </div>
<div class="bg-base-200 rounded-lg p-3 shadow-sm"> <div class="bg-base-200 rounded-lg p-3 shadow-sm">
@ -70,7 +75,8 @@
'badge-success': event.category === 'validation' && event.type !== 'UNVAL', 'badge-success': event.category === 'validation' && event.type !== 'UNVAL',
'badge-info': event.category === 'sample', 'badge-info': event.category === 'sample',
'badge-warning': event.category === 'receive', 'badge-warning': event.category === 'receive',
'badge-error': event.category === 'validation' && event.type === 'UNVAL' 'badge-error': event.category === 'validation' && event.type === 'UNVAL',
'badge-secondary': event.category === 'pdf'
}" }"
x-text="event.type"></span> x-text="event.type"></span>
<p class="font-medium text-sm" x-text="event.description"></p> <p class="font-medium text-sm" x-text="event.description"></p>

View File

@ -30,79 +30,24 @@
<div class="flex-1"></div> <div class="flex-1"></div>
<button <button
@click="generatePdfFromDialog()" @click="generatePdfFromDialog()"
:disabled="pdfStatus === 'queued' || pdfStatus === 'processing'"
class="btn btn-primary"> class="btn btn-primary">
<i class="fa fa-file-pdf mr-1"></i> Generate PDF <i class="fa fa-file-pdf mr-1"></i> PDF
</button> </button>
</div> </div>
<!-- PDF Display Area --> <!-- Report Display Area -->
<div class="flex-1 flex flex-col min-h-0"> <div class="flex-1 flex flex-col min-h-0">
<!-- Loading State --> <iframe
<template x-if="pdfStatus === 'queued' || pdfStatus === 'processing'"> :src="`${BASEURL}/report/${generateAccessnumber}?eng=${generateLang}`"
<div class="flex-1 flex items-center justify-center"> class="w-full h-full border-0 rounded-lg"
<div class="text-center"> style="min-height: 60vh;"></iframe>
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="mt-4 text-lg">
Generating <span x-text="generateLang === 1 ? 'English' : 'Indonesian'"></span> PDF...
</p>
<p class="text-sm text-base-content/60 mt-2">Please wait</p>
</div>
</div>
</template>
<!-- Success State - Show PDF -->
<template x-if="pdfStatus === 'completed' && pdfUrl">
<iframe
:src="pdfUrl"
class="w-full h-full border-0 rounded-lg"
style="min-height: 60vh;"></iframe>
</template>
<!-- Idle State - Before First Generation -->
<template x-if="pdfStatus === 'idle'">
<div class="flex-1 flex items-center justify-center">
<div class="text-center text-base-content/60">
<i class="fa fa-file-pdf text-6xl mb-4 opacity-30"></i>
<p class="text-lg">Select language and click Generate PDF</p>
<p class="text-sm mt-2">Indonesian PDF is automatically generated after validation.</p>
</div>
</div>
</template>
<!-- Failed State -->
<template x-if="pdfStatus === 'failed'">
<div class="flex-1 flex items-center justify-center">
<div class="text-center text-error">
<i class="fa fa-exclamation-circle text-6xl mb-4"></i>
<p class="text-lg font-semibold">PDF Generation Failed</p>
<button
@click="generatePdfFromDialog()"
class="btn btn-sm btn-outline btn-error mt-4">
<i class="fa fa-sync-alt mr-1"></i> Retry
</button>
</div>
</div>
</template>
</div> </div>
<!-- Footer --> <!-- Footer -->
<div class="flex justify-between items-center mt-4 pt-4 border-t border-base-300"> <div class="flex justify-end items-center mt-4 pt-4 border-t border-base-300 gap-2">
<div class="text-sm text-base-content/60"> <button @click="closeGenerateDialog()" class="btn btn-sm">
<template x-if="pdfJobId"> Close
<span>Job ID: <span class="font-mono" x-text="pdfJobId"></span></span> </button>
</template>
</div>
<div class="flex gap-2">
<template x-if="pdfStatus === 'completed' && pdfUrl">
<a :href="pdfUrl" target="_blank" class="btn btn-sm btn-outline">
<i class="fa fa-external-link-alt mr-1"></i> Open in New Tab
</a>
</template>
<button @click="closeGenerateDialog()" class="btn btn-sm">
Close
</button>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -12,10 +12,6 @@ document.addEventListener('alpine:init', () => {
isGenerateDialogOpen: false, isGenerateDialogOpen: false,
generateAccessnumber: null, generateAccessnumber: null,
generateLang: 0, generateLang: 0,
pdfStatus: 'idle',
pdfJobId: null,
pdfUrl: '',
pdfPollInterval: null,
statusColor: { statusColor: {
Pend: 'bg-white text-black font-bold', Pend: 'bg-white text-black font-bold',
PartColl: 'bg-[#ff99aa] text-black font-bold', PartColl: 'bg-[#ff99aa] text-black font-bold',
@ -315,6 +311,26 @@ document.addEventListener('alpine:init', () => {
}); });
}); });
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
});
});
return events.sort((a, b) => { return events.sort((a, b) => {
if (!a.datetime) return 1; if (!a.datetime) return 1;
if (!b.datetime) return -1; if (!b.datetime) return -1;
@ -333,34 +349,15 @@ document.addEventListener('alpine:init', () => {
openGenerateDialog(accessnumber) { openGenerateDialog(accessnumber) {
this.generateAccessnumber = accessnumber; this.generateAccessnumber = accessnumber;
this.generateLang = 0; this.generateLang = 0;
this.pdfStatus = 'idle';
this.pdfJobId = null;
this.pdfUrl = '';
this.isGenerateDialogOpen = true; this.isGenerateDialogOpen = true;
// Try to show auto-generated Indo PDF immediately after VAL2
// Since it's generated immediately, assume it's ready
// The spooler will serve the latest PDF
this.pdfUrl = `http://glenlis:3000/api/pdf/view/${accessnumber}.pdf`;
this.pdfStatus = 'completed';
}, },
closeGenerateDialog() { closeGenerateDialog() {
this.isGenerateDialogOpen = false; this.isGenerateDialogOpen = false;
this.generateAccessnumber = null; this.generateAccessnumber = null;
if (this.pdfPollInterval) {
clearInterval(this.pdfPollInterval);
this.pdfPollInterval = null;
}
}, },
async generatePdfFromDialog() { async generatePdfFromDialog() {
if (this.pdfStatus === 'queued' || this.pdfStatus === 'processing') return;
this.pdfStatus = 'queued';
this.pdfJobId = null;
this.pdfUrl = '';
const eng = this.generateLang === 1 ? '?eng=1' : ''; const eng = this.generateLang === 1 ? '?eng=1' : '';
try { try {
@ -368,51 +365,15 @@ document.addEventListener('alpine:init', () => {
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
this.pdfJobId = data.jobId; this.showToast(`${data.lang} PDF queued for download`, 'success');
this.pdfStatus = 'queued';
this.showToast(`${data.lang} PDF queued for generation`, 'success');
this.startPdfStatusPolling();
} else { } else {
this.pdfStatus = 'failed';
this.showToast('PDF generation failed', 'error'); this.showToast('PDF generation failed', 'error');
} }
} catch (e) { } catch (e) {
this.pdfStatus = 'failed';
this.showToast('PDF generation failed - try again', 'error'); this.showToast('PDF generation failed - try again', 'error');
} }
}, },
startPdfStatusPolling() {
if (this.pdfPollInterval) clearInterval(this.pdfPollInterval);
this.pdfPollInterval = setInterval(() => {
this.checkPdfStatus();
}, 2000);
},
async checkPdfStatus() {
if (!this.pdfJobId || this.pdfStatus === 'completed' || this.pdfStatus === 'failed') return;
try {
const res = await fetch(`${BASEURL}/report/status/${this.pdfJobId}`);
const data = await res.json();
this.pdfStatus = data.status;
if (this.pdfStatus === 'processing') {
// Continue polling
} else if (this.pdfStatus === 'completed' && data.pdfUrl) {
this.pdfUrl = data.pdfUrl;
clearInterval(this.pdfPollInterval);
this.showToast('PDF generated successfully', 'success');
} else if (this.pdfStatus === 'failed') {
clearInterval(this.pdfPollInterval);
this.showToast('PDF generation failed', 'error');
}
} catch (e) {
console.error('PDF status check failed:', e);
}
},
selectedPrinter: 'lab', selectedPrinter: 'lab',