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:
parent
8b4c71d1a3
commit
46dc493af1
8
TODO.md
8
TODO.md
@ -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)
|
||||||
@ -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]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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());
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
@ -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',
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user