Files
web-antrean/composables/useThermalPrint.ts
T
2026-02-02 11:33:19 +07:00

630 lines
18 KiB
TypeScript

import { ref } from 'vue';
import QRCode from 'qrcode';
export interface ThermalPrintData {
noAntrian: string;
barcode: string;
klinik: string;
ruang?: string;
nomorRuang?: string;
shift: string;
pembayaran: string;
tanggal: string;
waktu: string;
namaDokter?: string;
status?: 'ALLOWED' | 'NOT_ALLOWED';
}
export const useThermalPrint = () => {
const isPrinting = ref(false);
// NOTE: generateBarcode telah dihapus dari useThermalPrint
// Gunakan generateBarcode dari queueStore.js untuk konsistensi
// Semua barcode generation harus melalui queueStore.generateBarcode()
/**
* Generate QR Code data URL
*/
const generateQRCode = async (data: string): Promise<string> => {
try {
const qrDataUrl = await QRCode.toDataURL(data, {
errorCorrectionLevel: 'M', // Medium error correction (better for standard cameras)
type: 'image/png',
quality: 1,
margin: 2, // Added margin (quiet zone) for better scanner detection
width: 150, // Slightly larger width for better clarity
version: 3,
color: {
dark: '#000000',
light: '#FFFFFF'
}
});
return qrDataUrl;
} catch (error) {
console.error('Error generating QR Code:', error);
throw error;
}
};
/**
* Format date untuk tampilan
* Format: senin, 12 Jan 2026
*/
const formatDate = (dateString: string): string => {
try {
let date = dateString ? new Date(dateString) : new Date();
if (isNaN(date.getTime())) {
date = new Date();
}
// Array nama hari dalam bahasa Indonesia (lowercase)
const days = ['Minggu', 'Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu'];
// Array nama bulan singkat (title case)
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'];
const dayName = days[date.getDay()];
const day = date.getDate();
const month = months[date.getMonth()];
const year = date.getFullYear(); // Tahun 4 digit
return `${dayName}, ${day} ${month} ${year}`;
} catch (error) {
// Fallback jika error
const date = new Date();
const days = ['minggu', 'senin', 'selasa', 'rabu', 'kamis', 'jumat', 'sabtu'];
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'];
const dayName = days[date.getDay()];
const day = date.getDate();
const month = months[date.getMonth()];
const year = date.getFullYear();
return `${dayName}, ${day} ${month} ${year}`;
}
};
/**
* Format time untuk tampilan
*/
const formatTime = (dateString: string): string => {
try {
let date = dateString ? new Date(dateString) : new Date();
if (isNaN(date.getTime())) {
date = new Date();
}
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${hours}:${minutes}`;
} catch (error) {
const date = new Date();
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${hours}:${minutes}`;
}
};
/**
* Generate HTML untuk thermal print
*/
const generatePrintHTML = async (data: ThermalPrintData): Promise<string> => {
// QR Code data: hanya barcode saja (status akan dicek real-time saat check-in dari queueStore)
// Format: BARCODE (contoh: 250811100163)
// Alasan: Status bisa berubah setelah tiket di-print (dari 'menunggu' jadi 'waiting' setelah dipanggil)
// Status akan dicek langsung dari queueStore saat scan QR untuk akurasi real-time
// Normalize barcode - pastikan tidak ada whitespace dan valid
const qrData = String(data.barcode || '').trim();
if (!qrData) {
throw new Error('Barcode tidak valid untuk QR code');
}
console.log('📱 Generating QR code for ticket with barcode:', qrData);
const qrCodeImage = await generateQRCode(qrData);
console.log('✅ QR code generated successfully');
// Format nomor antrian (hilangkan bagian "| Onsite - barcode")
const noAntrianDisplay = data.noAntrian.split(' |')[0];
// Format informasi ruang: "Poli Anak - Ruang A"
let ruangInfo = '';
if (data.klinik && data.nomorRuang) {
// Konversi nomor ruang ke abjad (1 = A, 2 = B, 3 = C, dst)
const ruangNumber = parseInt(data.nomorRuang) || 1;
const ruangLetter = String.fromCharCode(64 + ruangNumber); // 64 = '@', 65 = 'A', 66 = 'B', dst
ruangInfo = `${data.klinik} - Ruang ${ruangLetter}`;
} else if (data.klinik && data.ruang) {
// Fallback jika hanya ada nama ruang
ruangInfo = `${data.klinik} - ${data.ruang}`;
} else if (data.klinik) {
// Hanya klinik jika tidak ada info ruang
ruangInfo = data.klinik;
}
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;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
body {
width: 80mm;
max-width: 80mm;
margin: 0;
padding: 2mm 4mm 6mm 4mm;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 12px;
font-weight: bold;
line-height: 1.0;
background: white;
color: black;
}
.ticket-table {
width: 100%;
border-collapse: collapse;
border: 2px dashed #000;
margin: 0;
background: white;
}
.ticket-table td {
padding: 0;
border: none;
}
.ticket-content {
padding: 2mm 4mm 4mm 4mm;
text-align: center;
}
.header {
margin-bottom: 2mm;
border-bottom: 2px solid #000;
padding-bottom: 1mm;
margin-top: 0;
}
.hospital-name {
font-size: 14px;
font-weight: bold;
margin-bottom: 0.5mm;
text-transform: uppercase;
letter-spacing: 1px;
line-height: 1.0;
}
.hospital-address {
font-size: 10px;
font-weight: bold;
margin-bottom: 0.5mm;
line-height: 1.0;
}
.ticket-title {
font-size: 12px;
font-weight: bold;
margin-top: 1mm;
text-transform: uppercase;
line-height: 1.0;
}
.qr-code-section {
margin-top: 0;
margin-bottom: 0.5mm;
text-align: center;
}
.qr-code {
width: 40mm;
height: 40mm;
margin: 0 auto;
border: none;
padding: 0.5mm;
background: white;
}
.qr-code img {
width: 100%;
height: 100%;
display: block;
}
.barcode-number {
font-size: 10px;
font-weight: bold;
margin-top: -0.5mm;
word-break: break-all;
line-height: 1.0;
}
.info-section {
margin-top: 2mm;
text-align: left;
border-top: 1px dashed #000;
padding-top: 1mm;
padding-left: 3mm;
padding-right: 3mm;
}
.info-row {
margin-bottom: 0.8mm;
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 0;
line-height: 1.0;
}
.info-label-text {
font-weight: bold;
font-size: 10px;
text-align: left;
text-transform: uppercase;
word-break: keep-all;
line-height: 1.0;
}
.info-colon {
font-weight: bold;
font-size: 10px;
margin-left: auto;
padding-right: 2mm;
line-height: 1.0;
}
.info-label-wrapper {
display: flex;
width: 22mm;
flex-shrink: 0;
align-items: flex-start;
gap: 1mm;
}
.info-value {
font-size: 10px;
font-weight: bold;
text-align: right;
word-break: break-word;
line-height: 1.0;
}
.no-antrian {
font-size: 28px;
font-weight: bold;
margin: 0.3mm 0;
letter-spacing: 2px;
text-transform: uppercase;
line-height: 1.0;
}
.klinik-name {
font-size: 12px;
font-weight: bold;
margin-top: 0.3mm;
margin-bottom: 2mm;
padding-bottom: 0;
text-transform: uppercase;
overflow: visible;
line-height: 1.0;
}
.footer {
margin-top: 2mm;
padding-top: 1mm;
padding-bottom: 0;
margin-bottom: 6mm;
border-top: 2px solid #000;
font-size: 10px;
font-weight: bold;
text-align: center;
line-height: 1.0;
}
.footer-text {
margin-bottom: 0.5mm;
font-weight: bold;
line-height: 1.0;
}
.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-table {
border: none;
}
.ticket-content {
padding: 2mm 4mm 4mm 4mm;
}
/* Feed setelah print untuk auto cutter */
.ticket-table::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-content > * {
page-break-inside: avoid;
}
}
</style>
</head>
<body>
<table class="ticket-table" cellpadding="0" cellspacing="0">
<tr>
<td class="ticket-content">
<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 Antrean</div>
</div>
<div class="no-antrian">${noAntrianDisplay}</div>
${ruangInfo ? `<div class="klinik-name">${ruangInfo}</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">
<div class="info-label-wrapper">
<span class="info-label-text">Tanggal</span><span class="info-colon">:</span>
</div>
<span class="info-value">${data.tanggal}</span>
</div>
<div class="info-row">
<div class="info-label-wrapper">
<span class="info-label-text">Waktu</span><span class="info-colon">:</span>
</div>
<span class="info-value">${data.waktu}</span>
</div>
<div class="info-row">
<div class="info-label-wrapper">
<span class="info-label-text">Shift</span><span class="info-colon">:</span>
</div>
<span class="info-value">${data.shift}</span>
</div>
<div class="info-row">
<div class="info-label-wrapper">
<span class="info-label-text">Pembayaran</span><span class="info-colon">:</span>
</div>
<span class="info-value">${data.pembayaran}</span>
</div>
${data.namaDokter ? `
<div class="info-row">
<div class="info-label-wrapper">
<span class="info-label-text">Dokter</span><span class="info-colon">:</span>
</div>
<span class="info-value">${data.namaDokter}</span>
</div>
` : ''}
</div>
<div class="footer">
<div class="footer-text">Tunjukkan tiket ini saat check-in</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) -->
</td>
</tr>
</table>
</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());
}
// Status tidak lagi disertakan dalam QR code
// Status akan dicek real-time dari queueStore saat scan QR
// Format QR code: hanya BARCODE saja untuk fleksibilitas status yang bisa berubah
// Normalize barcode - pastikan format konsisten (trim whitespace)
const barcode = String(patient.barcode || '').trim();
if (!barcode) {
console.error('❌ Barcode pasien tidak valid untuk print ticket:', patient);
throw new Error('Barcode pasien tidak valid');
}
console.log('🖨️ Printing ticket for patient:', {
noAntrian: patient.noAntrian,
barcode: barcode,
status: patient.status,
klinik: patient.klinik
});
const printData: ThermalPrintData = {
noAntrian: patient.noAntrian || '',
barcode: barcode,
klinik: patient.klinik || '',
ruang: patient.ruang || undefined,
nomorRuang: patient.nomorRuang || undefined,
shift: patient.shift || 'Shift 1',
pembayaran: patient.pembayaran || '',
tanggal: tanggalKunjungan,
waktu: waktu,
namaDokter: patient.namaDokter || undefined,
// Status tidak digunakan lagi, hanya untuk kompatibilitas interface
status: undefined
};
return await printTicket(printData);
};
return {
isPrinting,
printTicket,
printTicketFromPatient,
generateQRCode
};
};