Files
web-antrean/composables/useThermalPrint.ts
T
2026-01-08 12:40:26 +07:00

523 lines
13 KiB
TypeScript

import { ref } from 'vue';
import QRCode from 'qrcode';
export interface ThermalPrintData {
noAntrian: string;
barcode: string;
klinik: string;
shift: string;
pembayaran: string;
tanggal: string;
waktu: string;
namaDokter?: string;
status?: 'ALLOWED' | 'NOT_ALLOWED';
}
export const useThermalPrint = () => {
const isPrinting = ref(false);
/**
* Generate QR Code data URL
*/
const generateQRCode = async (data: string): Promise<string> => {
try {
const qrDataUrl = await QRCode.toDataURL(data, {
errorCorrectionLevel: 'M',
type: 'image/png',
quality: 1,
margin: 2,
width: 200, // Ukuran QR Code untuk thermal printer 80mm
color: {
dark: '#000000',
light: '#FFFFFF'
}
});
return qrDataUrl;
} catch (error) {
console.error('Error generating QR Code:', error);
throw error;
}
};
/**
* Format date untuk tampilan
*/
const formatDate = (dateString: string): string => {
try {
const date = dateString ? new Date(dateString) : new Date();
if (isNaN(date.getTime())) {
return new Date().toLocaleDateString('id-ID', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
return date.toLocaleDateString('id-ID', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
} catch (error) {
return new Date().toLocaleDateString('id-ID', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
};
/**
* Format time untuk tampilan
*/
const formatTime = (dateString: string): string => {
try {
const date = dateString ? new Date(dateString) : new Date();
if (isNaN(date.getTime())) {
return new Date().toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit'
});
}
return date.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit'
});
} catch (error) {
return new Date().toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit'
});
}
};
/**
* Generate HTML untuk thermal print
*/
const generatePrintHTML = async (data: ThermalPrintData): Promise<string> => {
// QR Code data: barcode|status (ALLOWED atau NOT_ALLOWED)
const qrData = `${data.barcode}|${data.status || 'NOT_ALLOWED'}`;
const qrCodeImage = await generateQRCode(qrData);
// Format nomor antrian (hilangkan bagian "| Onsite - barcode")
const noAntrianDisplay = data.noAntrian.split(' |')[0];
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Nomor Antrian - ${noAntrianDisplay}</title>
<style>
@page {
size: 80mm auto;
margin: 0;
padding: 0;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 80mm;
max-width: 80mm;
margin: 0;
padding: 2mm 4mm 6mm 4mm;
font-family: 'Courier New', monospace;
font-size: 12px;
font-weight: bold;
line-height: 1.2;
background: white;
color: black;
}
.ticket-container {
width: 100%;
text-align: center;
border: 2px dashed #000;
padding: 2mm 4mm 4mm 4mm;
background: white;
margin-bottom: 0;
}
.header {
margin-bottom: 3mm;
border-bottom: 2px solid #000;
padding-bottom: 2mm;
margin-top: 0;
}
.hospital-name {
font-size: 14px;
font-weight: bold;
margin-bottom: 1mm;
text-transform: uppercase;
letter-spacing: 1px;
}
.hospital-address {
font-size: 10px;
font-weight: bold;
margin-bottom: 1mm;
}
.ticket-title {
font-size: 12px;
font-weight: bold;
margin-top: 2mm;
text-transform: uppercase;
}
.qr-code-section {
margin: 3mm 0;
text-align: center;
}
.qr-code {
width: 50mm;
height: 50mm;
margin: 0 auto;
border: 1px solid #000;
padding: 2mm;
background: white;
}
.qr-code img {
width: 100%;
height: 100%;
display: block;
}
.barcode-number {
font-size: 10px;
font-weight: bold;
margin-top: 1mm;
word-break: break-all;
}
.info-section {
margin-top: 3mm;
text-align: left;
border-top: 1px dashed #000;
padding-top: 2mm;
padding-left: 3mm;
padding-right: 3mm;
}
.info-row {
margin-bottom: 1.5mm;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 4mm;
padding: 0;
}
.info-label {
font-weight: bold;
font-size: 10px;
flex: 0 0 38%;
text-transform: uppercase;
word-break: keep-all;
}
.info-value {
font-size: 10px;
font-weight: bold;
flex: 1;
text-align: right;
word-break: break-word;
}
.no-antrian {
font-size: 18px;
font-weight: bold;
margin: 2mm 0;
letter-spacing: 2px;
text-transform: uppercase;
}
.klinik-name {
font-size: 14px;
font-weight: bold;
margin: 2mm 0;
text-transform: uppercase;
}
.footer {
margin-top: 3mm;
padding-top: 2mm;
padding-bottom: 0;
margin-bottom: 6mm;
border-top: 2px solid #000;
font-size: 9px;
font-weight: bold;
text-align: center;
}
.footer-text {
margin-bottom: 1mm;
font-weight: bold;
}
.footer-text:last-child {
margin-bottom: 0;
}
/* Feed lines untuk auto cutter (space setelah footer) */
.cut-section {
margin-top: 0;
padding-top: 0;
height: 0;
display: block;
page-break-after: always;
visibility: hidden;
}
@media print {
body {
margin: 0;
padding: 2mm 4mm 6mm 4mm;
}
.ticket-container {
border: none;
padding: 2mm 4mm 4mm 4mm;
margin-bottom: 0;
margin-top: 0;
}
/* Feed setelah print untuk auto cutter */
.ticket-container::after {
content: '';
display: block;
height: 10mm;
min-height: 10mm;
page-break-after: always;
}
@page {
size: 80mm auto;
margin: 2mm 0 6mm 0;
/* Margin top kecil, margin bottom besar untuk feed auto cutter */
}
/* Feed tambahan di akhir untuk memastikan kertas cukup untuk dipotong */
.cut-section {
height: 0 !important;
min-height: 0 !important;
display: block !important;
page-break-after: always !important;
visibility: hidden !important;
}
/* Pastikan tidak ada page break di tengah content */
.ticket-container > * {
page-break-inside: avoid;
}
}
</style>
</head>
<body>
<div class="ticket-container">
<div class="header">
<div class="hospital-name">RSUD DR. SAIFUL ANWAR</div>
<div class="hospital-address">Jl. Jaksa Agung Suprapto No.2, Malang</div>
<div class="ticket-title">Tiket Antrian</div>
</div>
<div class="no-antrian">${noAntrianDisplay}</div>
<div class="klinik-name">${data.klinik}</div>
<div class="qr-code-section">
<div class="qr-code">
<img src="${qrCodeImage}" alt="QR Code" />
</div>
<div class="barcode-number">${data.barcode}</div>
</div>
<div class="info-section">
<div class="info-row">
<span class="info-label">Tanggal:</span>
<span class="info-value">${data.tanggal}</span>
</div>
<div class="info-row">
<span class="info-label">Waktu:</span>
<span class="info-value">${data.waktu}</span>
</div>
<div class="info-row">
<span class="info-label">Shift:</span>
<span class="info-value">${data.shift}</span>
</div>
<div class="info-row">
<span class="info-label">Pembayaran:</span>
<span class="info-value">${data.pembayaran}</span>
</div>
${data.namaDokter ? `
<div class="info-row">
<span class="info-label">Dokter:</span>
<span class="info-value">${data.namaDokter}</span>
</div>
` : ''}
</div>
<div class="footer">
<div class="footer-text">Tunjukkan tiket ini saat check-in</div>
<div class="footer-text">QR Code digunakan untuk check-in pasien</div>
<div class="footer-text">Terima kasih</div>
</div>
<!-- Feed lines untuk auto cutter (space setelah footer untuk cutter) -->
<div class="cut-section"></div>
<!-- ESC/POS Auto Cut Command Comment (untuk printer driver yang support) -->
<!-- ESC/POS Commands for EPSON M352A: -->
<!-- ESC i = Full Cut (0x1B 0x69) -->
<!-- ESC m = Partial Cut (0x1B 0x6D) -->
<!-- GS V 0 = Full Cut (0x1D 0x56 0x00) -->
<!-- GS V 1 = Partial Cut (0x1D 0x56 0x01) -->
</div>
</body>
</html>
`;
return html;
};
/**
* Print thermal ticket
*/
const printTicket = async (data: ThermalPrintData): Promise<boolean> => {
try {
isPrinting.value = true;
// Generate HTML
const html = await generatePrintHTML(data);
// Create print window
const printWindow = window.open('', '_blank', 'width=300,height=400');
if (!printWindow) {
throw new Error('Tidak dapat membuka window print. Pastikan pop-up diizinkan.');
}
printWindow.document.write(html);
printWindow.document.close();
// Wait for images to load
await new Promise((resolve) => {
printWindow.onload = () => {
setTimeout(() => {
printWindow.focus();
// Add event listener untuk afterprint (auto cut trigger)
const handleAfterPrint = () => {
console.log('Print completed. Auto cutter should activate.');
console.log('Note: For EPSON M352A, ensure printer settings have auto cut enabled.');
console.log('ESC/POS Cut Command: ESC i (0x1B 0x69) for full cut');
};
printWindow.addEventListener('afterprint', handleAfterPrint);
// Print ke printer default
// Catatan: Window.print() akan menggunakan printer default yang sudah di-set di sistem
// Jika user ingin mengubah printer, mereka perlu mengubah default printer di Windows/OS Settings terlebih dahulu
printWindow.print();
// Note: Untuk printer thermal EPSON M352A, auto cutter akan otomatis aktif jika:
// 1. Driver printer sudah dikonfigurasi untuk auto cut
// 2. Printer settings di Windows/OS sudah enable auto cut
// 3. CSS feed lines di atas sudah cukup untuk trigger auto cut
//
// ESC/POS Commands yang didukung:
// - ESC i (0x1B 0x69): Full Cut
// - ESC m (0x1B 0x6D): Partial Cut
// - GS V 0 (0x1D 0x56 0x00): Full Cut
// - GS V 1 (0x1D 0x56 0x01): Partial Cut
// Close window after print (optional)
// Delay lebih lama untuk memastikan print dan cut selesai
setTimeout(() => {
printWindow.removeEventListener('afterprint', handleAfterPrint);
printWindow.close();
}, 1500);
resolve(true);
}, 500);
};
});
return true;
} catch (error) {
console.error('Error printing ticket:', error);
throw error;
} finally {
isPrinting.value = false;
}
};
/**
* Print ticket dengan data dari queueStore patient object
*/
const printTicketFromPatient = async (patient: any): Promise<boolean> => {
// Determine tanggal kunjungan
let tanggalKunjungan: string;
if (patient.visitDate) {
// Jika ada visitDate (untuk jadwal lain), gunakan itu
tanggalKunjungan = formatDate(patient.visitDate);
} else if (patient.visitType === 'SEKARANG' || !patient.visitType) {
// Jika kunjungan hari ini
tanggalKunjungan = formatDate(new Date().toISOString());
} else {
// Fallback ke tanggal sekarang
tanggalKunjungan = formatDate(new Date().toISOString());
}
// Determine waktu
let waktu: string;
if (patient.createdAt) {
waktu = formatTime(patient.createdAt);
} else if (patient.jamPanggil) {
// Gunakan jamPanggil jika ada
waktu = patient.jamPanggil;
} else {
waktu = formatTime(new Date().toISOString());
}
// Determine status - pasien baru dari anjungan biasanya status 'menunggu' (NOT_ALLOWED)
// Hanya setelah dipanggil oleh admin loket, status menjadi 'waiting' (ALLOWED)
const status: 'ALLOWED' | 'NOT_ALLOWED' = patient.status === 'waiting' ? 'ALLOWED' : 'NOT_ALLOWED';
const printData: ThermalPrintData = {
noAntrian: patient.noAntrian || '',
barcode: patient.barcode || '',
klinik: patient.klinik || '',
shift: patient.shift || 'Shift 1',
pembayaran: patient.pembayaran || '',
tanggal: tanggalKunjungan,
waktu: waktu,
namaDokter: patient.namaDokter || undefined,
status: status
};
return await printTicket(printData);
};
return {
isPrinting,
printTicket,
printTicketFromPatient,
generateQRCode
};
};