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
**Last Updated:** 20260204
**Last Updated:** 20260205
Pending:
- 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:
- Update User Role levels (Standardize roles: Superuser, Admin, Lab, Phlebo, CS)
@ -35,4 +33,6 @@ Completed:
- Auto generate PDF on second val
- Validate delay when loading result
- 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,
'validation' => [],
'sample_collection' => [],
'tube_received' => []
'tube_received' => [],
'pdf_generation' => []
];
$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]);
}
}

View File

@ -20,14 +20,16 @@ class ReportController extends BaseController
if ($ispdf == 0) {
$ispdf = $this->request->getVar('ispdf') ?? 0;
}
if ($eng == 0) {
// Read REPORT_LANG from CM_REQUESTS if not provided
$engQuery = $this->request->getVar('eng');
if ($engQuery !== null) {
$eng = (int) $engQuery;
} elseif ($eng == 0) {
$sql = "SELECT REPORT_LANG FROM GDC_CMOD.dbo.CM_REQUESTS WHERE ACCESSNUMBER=?";
$row = $this->db->query($sql, [$accessnumber])->getRowArray();
$eng = $row['REPORT_LANG'] ?? 0;
}
return $this->renderReport($accessnumber, $eng, $ispdf, false);
}
@ -36,14 +38,16 @@ class ReportController extends BaseController
if ($ispdf == 0) {
$ispdf = $this->request->getVar('ispdf') ?? 0;
}
if ($eng == 0) {
// Read REPORT_LANG from CM_REQUESTS if not provided
$engQuery = $this->request->getVar('eng');
if ($engQuery !== null) {
$eng = (int) $engQuery;
} elseif ($eng == 0) {
$sql = "SELECT REPORT_LANG FROM GDC_CMOD.dbo.CM_REQUESTS WHERE ACCESSNUMBER=?";
$row = $this->db->query($sql, [$accessnumber])->getRowArray();
$eng = $row['REPORT_LANG'] ?? 0;
}
return $this->renderReport($accessnumber, $eng, $ispdf, true);
}
@ -116,12 +120,25 @@ class ReportController extends BaseController
try {
$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([
'success' => true,
'jobId' => $jobId,
'message' => 'PDF queued for generation',
'status' => 'queued',
'lang' => $eng == 1 ? 'English' : 'Indonesian'
'lang' => $eng == 1 ? 'English' : 'Indonesian',
'isRegen' => ($stepType === 'REGEN_PDF')
]);
} catch (\Exception $e) {
log_message('error', "PDF generation failed: " . $e->getMessage());

View File

@ -26,6 +26,9 @@
<button @click="auditTab = 'sample'"
:class="auditTab === 'sample' ? 'btn-active btn-primary text-white' : 'btn-ghost'"
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 class="space-y-3">
@ -52,14 +55,16 @@
'bg-success': event.category === 'validation' && event.type !== 'UNVAL',
'bg-info': event.category === 'sample',
'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"
:class="{
'fa-check': event.category === 'validation' && event.type !== 'UNVAL',
'fa-vial': event.category === 'sample',
'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>
</div>
<div class="bg-base-200 rounded-lg p-3 shadow-sm">
@ -70,7 +75,8 @@
'badge-success': event.category === 'validation' && event.type !== 'UNVAL',
'badge-info': event.category === 'sample',
'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>
<p class="font-medium text-sm" x-text="event.description"></p>

View File

@ -30,79 +30,24 @@
<div class="flex-1"></div>
<button
@click="generatePdfFromDialog()"
:disabled="pdfStatus === 'queued' || pdfStatus === 'processing'"
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>
</div>
<!-- PDF Display Area -->
<!-- Report Display Area -->
<div class="flex-1 flex flex-col min-h-0">
<!-- Loading State -->
<template x-if="pdfStatus === 'queued' || pdfStatus === 'processing'">
<div class="flex-1 flex items-center justify-center">
<div class="text-center">
<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>
<iframe
:src="`${BASEURL}/report/${generateAccessnumber}?eng=${generateLang}`"
class="w-full h-full border-0 rounded-lg"
style="min-height: 60vh;"></iframe>
</div>
<!-- Footer -->
<div class="flex justify-between items-center mt-4 pt-4 border-t border-base-300">
<div class="text-sm text-base-content/60">
<template x-if="pdfJobId">
<span>Job ID: <span class="font-mono" x-text="pdfJobId"></span></span>
</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 class="flex justify-end items-center mt-4 pt-4 border-t border-base-300 gap-2">
<button @click="closeGenerateDialog()" class="btn btn-sm">
Close
</button>
</div>
</div>
</div>
</div>

View File

@ -12,10 +12,6 @@ document.addEventListener('alpine:init', () => {
isGenerateDialogOpen: false,
generateAccessnumber: null,
generateLang: 0,
pdfStatus: 'idle',
pdfJobId: null,
pdfUrl: '',
pdfPollInterval: null,
statusColor: {
Pend: 'bg-white 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) => {
if (!a.datetime) return 1;
if (!b.datetime) return -1;
@ -333,34 +349,15 @@ document.addEventListener('alpine:init', () => {
openGenerateDialog(accessnumber) {
this.generateAccessnumber = accessnumber;
this.generateLang = 0;
this.pdfStatus = 'idle';
this.pdfJobId = null;
this.pdfUrl = '';
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() {
this.isGenerateDialogOpen = false;
this.generateAccessnumber = null;
if (this.pdfPollInterval) {
clearInterval(this.pdfPollInterval);
this.pdfPollInterval = null;
}
},
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' : '';
try {
@ -368,51 +365,15 @@ document.addEventListener('alpine:init', () => {
const data = await res.json();
if (data.success) {
this.pdfJobId = data.jobId;
this.pdfStatus = 'queued';
this.showToast(`${data.lang} PDF queued for generation`, 'success');
this.startPdfStatusPolling();
this.showToast(`${data.lang} PDF queued for download`, 'success');
} else {
this.pdfStatus = 'failed';
this.showToast('PDF generation failed', 'error');
}
} catch (e) {
this.pdfStatus = 'failed';
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',