630 lines
18 KiB
TypeScript
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
|
|
};
|
|
};
|