diff --git a/components/common/PageHeader.vue b/components/common/PageHeader.vue index 3e16713..256a128 100644 --- a/components/common/PageHeader.vue +++ b/components/common/PageHeader.vue @@ -51,8 +51,8 @@ defineProps({ }, theme: { type: String, - default: 'primary', // 'primary', 'secondary', 'success', or 'accent' - validator: (value) => ['primary', 'secondary', 'success', 'accent'].includes(value) + default: 'primary', // 'primary', 'secondary', 'success', 'accent', or 'warning' + validator: (value) => ['primary', 'secondary', 'success', 'accent', 'warning'].includes(value) } }); @@ -69,6 +69,8 @@ $success-600: #009262; $success-700: #1B6E53; $accent-600: #8B5CF6; $accent-700: #7C3AED; +$warning-600: #FF9800; +$warning-700: #F57C00; $font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; $font-weight-regular: 400; $font-weight-semibold: 600; @@ -97,6 +99,11 @@ $font-weight-semibold: 600; box-shadow: 0 4px 16px rgba(139, 92, 246, 0.3); } +.page-header-warning { + background: linear-gradient(135deg, #FF9800 0%, #F57C00 100%); + box-shadow: 0 4px 16px rgba(255, 152, 0, 0.3); +} + .header-content { display: flex; align-items: center; @@ -159,4 +166,8 @@ $font-weight-semibold: 600; .add-btn-accent { color: $accent-600 !important; } + +.add-btn-warning { + color: $warning-600 !important; +} \ No newline at end of file diff --git a/components/features/queue/PatientCard.vue b/components/features/queue/PatientCard.vue index 57efa81..7c0f7c6 100644 --- a/components/features/queue/PatientCard.vue +++ b/components/features/queue/PatientCard.vue @@ -49,6 +49,22 @@ +
+ Tipe Layanan: + + {{ patient.tipeLayanan }} + +
+ +
+ Ruang: + {{ patient.ruang }} +
+
Pembayaran: {{ patient.pembayaran }} diff --git a/composables/useThermalPrint.ts b/composables/useThermalPrint.ts new file mode 100644 index 0000000..d177832 --- /dev/null +++ b/composables/useThermalPrint.ts @@ -0,0 +1,522 @@ +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 => { + 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 => { + // 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 = ` + + + + + Nomor Antrian - ${noAntrianDisplay} + + + +
+
+
RSUD DR. SAIFUL ANWAR
+
Jl. Jaksa Agung Suprapto No.2, Malang
+
Tiket Antrian
+
+ +
${noAntrianDisplay}
+ +
${data.klinik}
+ +
+
+ QR Code +
+
${data.barcode}
+
+ +
+
+ Tanggal: + ${data.tanggal} +
+ +
+ Waktu: + ${data.waktu} +
+ +
+ Shift: + ${data.shift} +
+ +
+ Pembayaran: + ${data.pembayaran} +
+ + ${data.namaDokter ? ` +
+ Dokter: + ${data.namaDokter} +
+ ` : ''} +
+ + + + +
+ + + + + + + +
+ + + `; + + return html; + }; + + /** + * Print thermal ticket + */ + const printTicket = async (data: ThermalPrintData): Promise => { + 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 => { + // 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 + }; +}; diff --git a/pages/AdminKlinikRuang/README.md b/pages/AdminKlinikRuang/README.md new file mode 100644 index 0000000..ddd8e48 --- /dev/null +++ b/pages/AdminKlinikRuang/README.md @@ -0,0 +1,97 @@ +# Panduan Alur Pemrosesan Pasien - Admin Klinik Ruang + +## Overview +Halaman Admin Klinik Ruang digunakan untuk mengelola antrian pasien di setiap ruang klinik. Pasien yang sudah dibuat antrean klinik ruang dari Admin Loket atau Admin Klinik akan muncul di halaman ini untuk di-generate tiket baru. + +## Alur Pemrosesan Pasien + +### 1. Daftar Pasien Perlu Generate Tiket +- Pasien yang sudah dibuat antrean klinik ruang dari **Admin Loket** atau **Admin Klinik** akan muncul di bagian "DAFTAR PASIEN PERLU GENERATE TIKET" +- Pasien dikelompokkan berdasarkan **ruang** yang telah dipilih sebelumnya oleh admin +- Hanya menampilkan **nomor antrian** untuk menghemat tempat +- Klik ikon **mata (👁️)** untuk melihat detail lengkap pasien +- Klik tombol **"Generate"** untuk mengisi field generate tiket dengan nomor antrian pasien tersebut + +### 2. Generate Tiket Baru +- Setelah klik tombol "Generate", nomor antrian pasien akan otomatis terisi di field "Masukan Barcode" +- Atau scan barcode/nomor antrian pasien secara manual di field "Masukan Barcode" lalu tekan **Enter** +- Sistem akan menampilkan dialog untuk memilih: + - **Ruang**: Pilih ruang tujuan (jika ada beberapa ruang) + - **Tipe Layanan**: Pilih antara "Pemeriksaan Awal" atau "Tindakan" +- Setelah konfirmasi, sistem akan generate nomor antrian baru: + - **PA001, PA002, ...** untuk Pemeriksaan Awal + - **TD001, TD002, ...** untuk Tindakan +- Nomor antrian baru akan di-generate otomatis per ruang dan tipe layanan + +### 3. Proses Antrian +- Setelah generate tiket, pasien akan muncul di antrian sesuai **tipe layanan** (Pemeriksaan Awal atau Tindakan) +- Klik **"Proses Antrian Berikutnya"** untuk memproses antrian pertama yang menunggu +- Atau klik langsung pada **card pasien** untuk memproses pasien tertentu + +### 4. Panggil & Proses Pasien +- Pasien yang sedang diproses akan muncul di bagian **"SEDANG DIPROSES"** +- Admin dapat: + - **Pilih Tipe Layanan**: Ubah antara "Pemeriksaan Awal" atau "Tindakan" dengan klik tombol + - **Panggil**: Klik tombol "Panggil" untuk memanggil pasien (dapat dipanggil berkali-kali) + - **Selesai**: Tandai pasien selesai diproses + - **Terlambat**: Tandai pasien terlambat + - **Pending**: Tandai pasien pending + +### 5. Panggil Antrian Berikutnya +- Untuk setiap tipe layanan (Pemeriksaan Awal/Tindakan), admin dapat memanggil antrian berikutnya +- Klik tombol **"Panggil"** di header section setiap tipe layanan +- Pasien dapat dipanggil **berkali-kali** tanpa batas +- Pasien yang dipanggil akan muncul di layar **Anjungan Klinik Ruang** + +## Tips & Catatan Penting + +### Tips: +- ✅ Pasien yang sudah dibuat antrean klinik ruang akan **otomatis dikelompokkan** berdasarkan ruang yang dipilih +- ✅ Nomor antrian baru (PA/TD) akan di-generate **otomatis per ruang dan tipe layanan** +- ✅ Setiap pasien dapat dipanggil **berkali-kali tanpa batas** +- ✅ Informasi pasien yang dipanggil akan muncul di **layar Anjungan Klinik Ruang** +- ✅ Gunakan tombol **"Generate"** untuk mempercepat proses generate tiket + +### Catatan: +- Pasien yang sudah di-generate tiket akan pindah dari "Daftar Pasien Perlu Generate Tiket" ke antrian sesuai tipe layanan +- Status pasien dapat berubah: waiting → di-loket → processed +- Setiap ruang memiliki antrian terpisah untuk Pemeriksaan Awal dan Tindakan + +## Struktur Data Pasien + +### Pasien Belum Generate Tiket: +- `processStage`: "klinik" atau "klinik-ruang" +- `tipeLayanan`: null (belum ada) +- `ruang`: nama ruang yang dipilih +- `nomorRuang`: nomor ruang +- `status`: "waiting", "di-loket", "terlambat", atau "pending" + +### Pasien Sudah Generate Tiket: +- `processStage`: "klinik-ruang" +- `tipeLayanan`: "Pemeriksaan Awal" atau "Tindakan" +- `noAntrian`: format "PA001" atau "TD001" + info klinik dan ruang +- `status`: "waiting", "di-loket", "terlambat", atau "pending" + +## Fitur Utama + +1. **Scan Barcode**: Auto-detect barcode dengan mengabaikan huruf di depan (jika ada) +2. **Generate Tiket**: Generate nomor antrian baru per ruang dan tipe layanan +3. **Panggil Pasien**: Panggil pasien berkali-kali tanpa batas +4. **Lihat Detail**: Lihat detail lengkap pasien dengan klik ikon mata +5. **Kelola Antrian**: Proses, selesai, terlambat, atau pending pasien +6. **Pisah Tipe Layanan**: Antrian terpisah untuk Pemeriksaan Awal dan Tindakan + +## Troubleshooting + +**Q: Pasien tidak muncul di daftar?** +A: Pastikan pasien sudah dibuat antrean klinik ruang dari Admin Loket/Admin Klinik dan ruang yang dipilih sesuai. + +**Q: Nomor antrian tidak ter-generate?** +A: Pastikan sudah memilih ruang dan tipe layanan di dialog, lalu klik "Buat Antrean". + +**Q: Pasien tidak bisa dipanggil?** +A: Pastikan pasien sudah di-generate tiket dan status-nya "waiting" atau "di-loket". + +**Q: Pasien muncul di ruang yang salah?** +A: Pastikan ruang yang dipilih saat create antrean klinik ruang sesuai dengan ruang yang diinginkan. + diff --git a/pages/AdminKlinikRuang/[kodeKlinik].vue b/pages/AdminKlinikRuang/[kodeKlinik].vue new file mode 100644 index 0000000..db06682 --- /dev/null +++ b/pages/AdminKlinikRuang/[kodeKlinik].vue @@ -0,0 +1,1086 @@ + + + + + + diff --git a/pages/AdminKlinikRuang/index.vue b/pages/AdminKlinikRuang/index.vue new file mode 100644 index 0000000..db5bee7 --- /dev/null +++ b/pages/AdminKlinikRuang/index.vue @@ -0,0 +1,112 @@ + + + + + + diff --git a/pages/Anjungan/Anjungan/[id].vue b/pages/Anjungan/Anjungan/[id].vue index 79e6d38..5853818 100644 --- a/pages/Anjungan/Anjungan/[id].vue +++ b/pages/Anjungan/Anjungan/[id].vue @@ -474,6 +474,90 @@ + + + + + mdi-printer + Cetak Nomor Antrian + + + +
+
+ mdi-ticket-confirmation +

Tiket Berhasil Dibuat

+

+ Nomor antrean Anda: {{ lastRegisteredPatient.noAntrian.split(" |")[0] }} +

+
+ + + +
+
+ Klinik: + {{ lastRegisteredPatient.klinik }} +
+
+ Shift: + {{ lastRegisteredPatient.shift }} +
+
+ Pembayaran: + {{ lastRegisteredPatient.pembayaran }} +
+
+ Barcode: + {{ lastRegisteredPatient.barcode }} +
+
+ + + +
+ Cetak tiket ini untuk digunakan saat check-in. QR Code pada tiket dapat di-scan untuk proses check-in. +
+
+
+
+ + + + Lewati + + + {{ isPrinting ? 'Mencetak...' : 'Cetak Tiket' }} + + +
+
+ { const raw = route.params.id; @@ -786,6 +872,8 @@ const showVisitTypeDialog = ref(false); const showPaymentTypeDialog = ref(false); const showBookingFormDialog = ref(false); const showDoctorSelectionDialog = ref(false); +const showPrintDialog = ref(false); +const lastRegisteredPatient = ref(null); const selectedClinic = ref(null); const selectedDoctor = ref(null); const pendingPaymentType = ref(null); @@ -942,7 +1030,7 @@ const confirmDoctorSelection = () => { registerPatient('SEKARANG', pendingPaymentType.value, selectedDoctor.value); }; -const registerPatient = (visitType, paymentType, namaDokter) => { +const registerPatient = async (visitType, paymentType, namaDokter) => { // Register patient to queueStore const result = queueStore.registerPatientFromAnjungan( selectedClinic.value, @@ -957,6 +1045,12 @@ const registerPatient = (visitType, paymentType, namaDokter) => { const doctorInfo = namaDokter ? ` dengan dokter ${namaDokter}` : ''; const message = `Pendaftaran ${selectedClinic.value.name} untuk kunjungan HARI INI dengan pembayaran ${paymentType}${doctorInfo} berhasil diproses. Nomor antrean: ${result.patient.noAntrian.split(" |")[0]}`; showSnackbar(message, "success"); + + // Simpan data pasien untuk print + lastRegisteredPatient.value = result.patient; + + // Tampilkan dialog print + showPrintDialog.value = true; } else { showSnackbar(result.message || "Gagal melakukan pendaftaran", "error"); } @@ -966,7 +1060,7 @@ const registerPatient = (visitType, paymentType, namaDokter) => { pendingPaymentType.value = null; }; -const submitBooking = () => { +const submitBooking = async () => { if ( !bookingForm.value.date || !bookingForm.value.shift || @@ -1001,6 +1095,12 @@ const submitBooking = () => { const doctorInfo = namaDokter ? ` dengan dokter ${namaDokter}` : ''; const message = `Jadwal kunjungan ${selectedClinic.value.name} pada ${bookingForm.value.date} - ${bookingForm.value.shift} (${paymentType})${doctorInfo} berhasil disimpan. Nomor antrean: ${result.patient.noAntrian.split(" |")[0]}`; showSnackbar(message, "success"); + + // Simpan data pasien untuk print + lastRegisteredPatient.value = result.patient; + + // Tampilkan dialog print + showPrintDialog.value = true; } else { showSnackbar(result.message || "Gagal menyimpan jadwal", "error"); } @@ -1010,6 +1110,25 @@ const submitBooking = () => { bookingForm.value.doctor = null; }; +const handlePrintTicket = async () => { + if (!lastRegisteredPatient.value) return; + + try { + await printTicketFromPatient(lastRegisteredPatient.value); + showSnackbar("Tiket berhasil dicetak", "success"); + showPrintDialog.value = false; + lastRegisteredPatient.value = null; + } catch (error) { + console.error("Error printing ticket:", error); + showSnackbar("Gagal mencetak tiket. Silakan coba lagi.", "error"); + } +}; + +const skipPrint = () => { + showPrintDialog.value = false; + lastRegisteredPatient.value = null; +}; + const backToList = () => { navigateTo("/anjungan/anjungan"); }; @@ -1310,6 +1429,31 @@ const navigateToCheckIn = () => { } } +.info-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid var(--color-neutral-200); + + &:last-child { + border-bottom: none; + } +} + +.info-label { + font-weight: 600; + color: var(--color-neutral-700); + flex: 0 0 40%; +} + +.info-value { + color: var(--color-neutral-900); + flex: 1; + text-align: right; + word-break: break-word; +} + .doctor-list-section { margin-bottom: 16px; diff --git a/pages/Anjungan/AntrianKlinikRuang/[kodeKlinik].vue b/pages/Anjungan/AntrianKlinikRuang/[kodeKlinik].vue index 5f8d7d4..3066d2d 100644 --- a/pages/Anjungan/AntrianKlinikRuang/[kodeKlinik].vue +++ b/pages/Anjungan/AntrianKlinikRuang/[kodeKlinik].vue @@ -49,30 +49,65 @@
- -
-
SEKARANG
-
- {{ ruang.currentQueue.noAntrian.split(' |')[0] }} -
-
- - -
-
SELANJUTNYA
-
-
- {{ queue.noAntrian.split(' |')[0] }} + +
+
PEMERIKSAAN AWAL
+
+
SEKARANG
+
+ {{ ruang.pemeriksaanAwal.currentQueue.noAntrian.split(' |')[0] }}
+
+
SELANJUTNYA
+
+
+ {{ queue.noAntrian.split(' |')[0] }} +
+
+
+
+ mdi-clock-outline +

Tidak Ada Antrian

+
- -
+ + + + +
+
TINDAKAN
+
+
SEKARANG
+
+ {{ ruang.tindakan.currentQueue.noAntrian.split(' |')[0] }} +
+
+
+
SELANJUTNYA
+
+
+ {{ queue.noAntrian.split(' |')[0] }} +
+
+
+
+ mdi-clock-outline +

Tidak Ada Antrian

+
+
+ + +
mdi-clock-outline

Tidak Ada Antrian

@@ -181,7 +216,11 @@ const currentDate = ref('') let timeInterval = null const klinikPatients = computed(() => { - return queueStore.getPatientsByStage('klinik').value.all + // Get patients from klinik-ruang stage (created from AdminKlinikRuang) + return queueStore.allPatients.filter(p => + p.processStage === 'klinik-ruang' && + p.kodeKlinik === kodeKlinik.value + ) }) // Get ruang list for this specific klinik @@ -221,8 +260,9 @@ const displayedRuang = computed(() => { } return ruangListForKlinik.value.map(ruang => { - const queues = klinikPatients.value - .filter(p => p.klinik === ruang.namaKlinik && (p.ruang === ruang.namaRuang || !p.ruang)) + // Get queues for this specific room + const allQueues = klinikPatients.value + .filter(p => p.nomorRuang === ruang.nomorRuang) .sort((a, b) => { const statusPriority = { 'di-loket': 1, @@ -234,18 +274,28 @@ const displayedRuang = computed(() => { const priorityDiff = (statusPriority[a.status] || 99) - (statusPriority[b.status] || 99) if (priorityDiff !== 0) return priorityDiff - const timeA = a.jamPanggil.split(':').map(Number) - const timeB = b.jamPanggil.split(':').map(Number) - return timeA[0] * 60 + timeA[1] - (timeB[0] * 60 + timeB[1]) + // Sort by queue number + const numA = parseInt(a.noAntrian.match(/\d+/)?.[0] || '999') + const numB = parseInt(b.noAntrian.match(/\d+/)?.[0] || '999') + return numA - numB }) - const currentQueue = queues.find(q => q.status === 'di-loket') || - (queues.find(q => q.status === 'waiting') || null) - - const nextQueues = queues.filter(q => - q.no !== currentQueue?.no && - (q.status === 'waiting' || q.status === 'di-loket') - ) + // Split by tipeLayanan + const pemeriksaanAwalQueues = allQueues.filter(q => q.tipeLayanan === 'Pemeriksaan Awal') + const tindakanQueues = allQueues.filter(q => q.tipeLayanan === 'Tindakan') + + // Get current and next queues for each tipeLayanan + const getCurrentAndNext = (queues) => { + const current = queues.find(q => q.status === 'di-loket') || null + const next = queues.filter(q => + q.no !== current?.no && + (q.status === 'waiting' || q.status === 'di-loket') + ).slice(0, 3) // Show max 3 next queues + return { current, next } + } + + const pemeriksaanAwal = getCurrentAndNext(pemeriksaanAwalQueues) + const tindakan = getCurrentAndNext(tindakanQueues) return { kodeKlinik: ruang.kodeKlinik, @@ -253,9 +303,17 @@ const displayedRuang = computed(() => { nomorRuang: ruang.nomorRuang, namaRuang: ruang.namaRuang, nomorScreen: ruang.nomorScreen, - currentQueue: currentQueue ? { ...currentQueue, ruang: ruang.namaRuang } : null, - nextQueues: nextQueues, - totalQueues: queues.length + pemeriksaanAwal: { + currentQueue: pemeriksaanAwal.current, + nextQueues: pemeriksaanAwal.next, + totalQueues: pemeriksaanAwalQueues.length + }, + tindakan: { + currentQueue: tindakan.current, + nextQueues: tindakan.next, + totalQueues: tindakanQueues.length + }, + totalQueues: allQueues.length } }) }) @@ -263,16 +321,16 @@ const displayedRuang = computed(() => { const currentCalledQueue = computed(() => { if (!kodeKlinik.value || !klinikData.value) return null + // Get most recently called patient (status di-loket) const calledQueues = klinikPatients.value - .filter(p => p.status === 'di-loket' && p.klinik === klinikData.value.namaKlinik) + .filter(p => p.status === 'di-loket') .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) if (calledQueues.length > 0) { const patient = calledQueues[0] - const ruang = ruangListForKlinik.value.find(r => r.namaKlinik === patient.klinik) return { ...patient, - ruang: ruang ? ruang.namaRuang : ruangListForKlinik.value[0]?.namaRuang || 'Ruang 1' + ruang: patient.ruang || patient.namaRuang || 'Ruang 1' } } @@ -284,7 +342,7 @@ const statistics = computed(() => { return { total: 0, waiting: 0, active: 0 } } - const all = klinikPatients.value.filter(p => p.klinik === klinikData.value.namaKlinik) + const all = klinikPatients.value return { total: all.length, waiting: all.filter(p => p.status === 'waiting').length, @@ -516,6 +574,31 @@ onUnmounted(() => { background: var(--color-success-100); } +.tipe-layanan-section { + margin-bottom: 8px; +} + +.tipe-label { + font-size: 12px; + font-weight: 700; + color: var(--color-success-700); + margin-bottom: 8px; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +.empty-state-small { + text-align: center; + padding: 16px 0; +} + +.empty-text-small { + font-size: 12px; + color: var(--color-neutral-600); + margin-top: 8px; + font-weight: 500; +} + .current-section { text-align: center; margin-bottom: 12px; diff --git a/stores/navItems1.ts b/stores/navItems1.ts index 68e6bfb..e7149f4 100644 --- a/stores/navItems1.ts +++ b/stores/navItems1.ts @@ -25,36 +25,37 @@ const defaultNavItems: NavItem[] = [ }, { id: 4, name: "Admin Loket", icon: "mdi-account-supervisor-outline", path: "/AdminLoket" }, { id: 5, name: "Admin Klinik", icon: "mdi-text-box-plus-outline", path: "/AdminKlinik" }, - { id: 6, name: "Admin Penunjang", icon: "mdi-plus-box-outline", path: "/AdminPenunjang" }, - { id: 7, name: "Buat Antrean", icon: "mdi-account-multiple-plus-outline", path: "/BuatAntrean" }, - { id: 8, name: "Monitoring Pasien", icon: "mdi-account-group-outline", path: "/MonitoringPasien/monitoringPasien" }, + { id: 6, name: "Admin Klinik Ruang", icon: "mdi-door-open", path: "/AdminKlinikRuang" }, + { id: 7, name: "Admin Penunjang", icon: "mdi-plus-box-outline", path: "/AdminPenunjang" }, + { id: 8, name: "Buat Antrean", icon: "mdi-account-multiple-plus-outline", path: "/BuatAntrean" }, + { id: 9, name: "Monitoring Pasien", icon: "mdi-account-group-outline", path: "/MonitoringPasien/monitoringPasien" }, { - id: 9, + id: 10, name: "Layar Informasi", icon: "mdi-monitor-multiple", path: "", children: [ - { id: 10, name: "Anjungan", path: "/anjungan/anjungan", icon: "mdi-circle-small" }, - { id: 11, name: "Klinik", path: "/anjungan/AntrianKlinik", icon: "mdi-circle-small" }, - { id: 12, name: "Klinik Ruang", path: "/anjungan/AntrianKlinikRuang", icon: "mdi-circle-small"}, - { id: 13, name: "Penunjang", path: "/anjungan/AntrianPenunjang", icon: "mdi-circle-small"}, + { id: 11, name: "Anjungan", path: "/anjungan/anjungan", icon: "mdi-circle-small" }, + { id: 12, name: "Klinik", path: "/anjungan/AntrianKlinik", icon: "mdi-circle-small" }, + { id: 13, name: "Klinik Ruang", path: "/anjungan/AntrianKlinikRuang", icon: "mdi-circle-small"}, + { id: 14, name: "Penunjang", path: "/anjungan/AntrianPenunjang", icon: "mdi-circle-small"}, ], }, { - id: 14, + id: 15, name: "Master Data", icon: "mdi-cog-outline", path: "", children: [ - { id: 15, name: "Hak Akses", path: "/setting/HakAkses", icon: "mdi-circle-small" }, - { id: 16, name: "User Login", path: "/setting/UserLogin", icon: "mdi-circle-small" }, - { id: 17, name: "Master Anjungan", path: "/setting/masteranjungan", icon: "mdi-circle-small" }, - { id: 18, name: "Master Loket", path: "/setting/masterloket", icon: "mdi-circle-small" }, - { id: 19, name: "Master Klinik", path: "/setting/masterklinik", icon: "mdi-circle-small" }, - { id: 20, name: "Master Klinik Ruang", path: "/setting/masterklinikruang", icon: "mdi-circle-small" }, - { id: 21, name: "Master Penunjang", path: "/setting/masterpenunjang", icon: "mdi-circle-small" }, - { id: 22, name: "Screen", path: "/setting/screen", icon: "mdi-circle-small" }, + { id: 16, name: "Hak Akses", path: "/setting/HakAkses", icon: "mdi-circle-small" }, + { id: 17, name: "User Login", path: "/setting/UserLogin", icon: "mdi-circle-small" }, + { id: 18, name: "Master Anjungan", path: "/setting/masteranjungan", icon: "mdi-circle-small" }, + { id: 19, name: "Master Loket", path: "/setting/masterloket", icon: "mdi-circle-small" }, + { id: 20, name: "Master Klinik", path: "/setting/masterklinik", icon: "mdi-circle-small" }, + { id: 21, name: "Master Klinik Ruang", path: "/setting/masterklinikruang", icon: "mdi-circle-small" }, + { id: 22, name: "Master Penunjang", path: "/setting/masterpenunjang", icon: "mdi-circle-small" }, + { id: 23, name: "Screen", path: "/setting/screen", icon: "mdi-circle-small" }, ], }, ]; diff --git a/stores/queueStore.js b/stores/queueStore.js index e00652b..bd3184e 100644 --- a/stores/queueStore.js +++ b/stores/queueStore.js @@ -564,6 +564,246 @@ export const useQueueStore = defineStore('queue', () => { }; }; + // Scan barcode dan generate antrean klinik ruang baru + const scanAndCreateAntreanKlinikRuang = (barcodeInput, klinikRuang, ruang, tipeLayanan = 'Pemeriksaan Awal') => { + // Clean input - remove whitespace and handle prefix letters + const cleanInput = String(barcodeInput).trim().toUpperCase(); + // Remove leading letters if any (e.g., "J200730100005" -> "200730100005") + const numericInput = cleanInput.replace(/^[A-Z]+/, ''); + + // Find patient by barcode or noAntrian + const sourcePatient = allPatients.value.find(p => { + // Exact barcode match + if (p.barcode === cleanInput || p.barcode === numericInput) return true; + + // Check if noAntrian includes the input + const noAntrianUpper = (p.noAntrian || '').toUpperCase(); + if (noAntrianUpper.includes(cleanInput) || noAntrianUpper.includes(numericInput)) return true; + + // Try extracting number from noAntrian + const noAntrianNumber = noAntrianUpper.match(/([A-Z]+)(\d+)/); + if (noAntrianNumber) { + const extractedNumber = noAntrianNumber[2]; + if (extractedNumber.includes(numericInput) || numericInput.includes(extractedNumber)) return true; + } + + return false; + }); + + if (!sourcePatient) { + return { success: false, message: "Pasien tidak ditemukan. Pastikan barcode/nomor antrian benar." }; + } + + // Check if patient already has antrean klinik ruang for this room + const existingAntrean = allPatients.value.find(p => + p.referencePatient === sourcePatient.noAntrian && + p.kodeKlinik === klinikRuang.kodeKlinik && + p.nomorRuang === ruang.nomorRuang && + p.processStage === 'klinik-ruang' + ); + + if (existingAntrean) { + return { + success: false, + message: `Pasien sudah memiliki antrean di ${klinikRuang.namaKlinik} Ruang ${ruang.nomorRuang}` + }; + } + + // Generate queue number for this specific room and tipeLayanan + const roomQueues = allPatients.value.filter(p => + p.kodeKlinik === klinikRuang.kodeKlinik && + p.nomorRuang === ruang.nomorRuang && + p.tipeLayanan === tipeLayanan && + p.processStage === 'klinik-ruang' + ); + + const queueNumber = roomQueues.length + 1; + const prefix = tipeLayanan === 'Pemeriksaan Awal' ? 'PA' : 'TD'; + const newNo = allPatients.value.length + 1; + const timestamp = new Date(); + + const newPatient = { + no: newNo, + jamPanggil: `${String(timestamp.getHours()).padStart(2, "0")}:${String( + timestamp.getMinutes() + ).padStart(2, "0")}`, + barcode: sourcePatient.barcode, + noAntrian: `${prefix}${String(queueNumber).padStart(3, "0")} | ${klinikRuang.namaKlinik} - ${ruang.namaRuang} - ${tipeLayanan}`, + noAntrianRuang: `${klinikRuang.namaKlinik} - ${ruang.namaRuang} | ${prefix}${String(queueNumber).padStart(3, "0")}`, + shift: sourcePatient.shift || "Shift 1", + klinik: klinikRuang.namaKlinik, + ruang: ruang.namaRuang, + kodeKlinik: klinikRuang.kodeKlinik, + nomorRuang: ruang.nomorRuang, + nomorScreen: ruang.nomorScreen, + tipeLayanan: tipeLayanan, + fastTrack: sourcePatient.fastTrack || "TIDAK", + pembayaran: sourcePatient.pembayaran || "UMUM", + status: "waiting", + processStage: "klinik-ruang", + createdAt: timestamp.toISOString(), + referencePatient: sourcePatient.noAntrian, + sourcePatientNo: sourcePatient.no, + }; + + allPatients.value.push(newPatient); + + return { + success: true, + message: `Antrean ${tipeLayanan} berhasil dibuat: ${newPatient.noAntrian.split(" |")[0]} untuk ${klinikRuang.namaKlinik} Ruang ${ruang.nomorRuang}`, + patient: newPatient, + }; + }; + + // Get patients by klinik and ruang + const getPatientsByKlinikRuang = (kodeKlinik, nomorRuang, tipeLayanan = null) => { + return computed(() => { + let patients = allPatients.value.filter(p => + p.kodeKlinik === kodeKlinik && + p.nomorRuang === nomorRuang && + p.processStage === 'klinik-ruang' + ); + + if (tipeLayanan) { + patients = patients.filter(p => p.tipeLayanan === tipeLayanan); + } + + return { + all: patients, + waiting: patients.filter(p => p.status === 'waiting'), + diLoket: patients.filter(p => p.status === 'di-loket'), + terlambat: patients.filter(p => p.status === 'terlambat'), + pending: patients.filter(p => p.status === 'pending'), + }; + }); + }; + + // Call next patient for a specific room and tipeLayanan + const callNextKlinikRuang = (kodeKlinik, nomorRuang, tipeLayanan, allowMultiple = false) => { + const patients = allPatients.value.filter(p => + p.kodeKlinik === kodeKlinik && + p.nomorRuang === nomorRuang && + p.tipeLayanan === tipeLayanan && + p.processStage === 'klinik-ruang' && + (p.status === 'waiting' || (allowMultiple && p.status === 'di-loket')) + ).sort((a, b) => { + // Sort by status priority first + const statusPriority = { + 'waiting': 1, + 'di-loket': 2 + }; + const priorityDiff = (statusPriority[a.status] || 99) - (statusPriority[b.status] || 99); + if (priorityDiff !== 0) return priorityDiff; + + // Then sort by queue number (extract from noAntrian) + const numA = parseInt(a.noAntrian.match(/\d+/)?.[0] || '999'); + const numB = parseInt(b.noAntrian.match(/\d+/)?.[0] || '999'); + return numA - numB; + }); + + if (patients.length === 0) { + return { success: false, message: `Tidak ada antrian ${tipeLayanan} yang menunggu di ruang ini` }; + } + + const nextPatient = patients[0]; + const patientIndex = allPatients.value.findIndex(p => p.no === nextPatient.no); + + if (patientIndex !== -1) { + // If allowMultiple, we can call even if already di-loket (just update timestamp) + // Otherwise, only update if status is waiting + if (allowMultiple || nextPatient.status === 'waiting') { + allPatients.value[patientIndex] = { + ...allPatients.value[patientIndex], + status: "di-loket", + lastCalledAt: new Date().toISOString() // Track last call time + }; + } + } + + return { + success: true, + message: `Memanggil pasien ${nextPatient.noAntrian.split(" |")[0]} untuk ${tipeLayanan}`, + patient: allPatients.value[patientIndex], + }; + }; + + // Process patient in klinik ruang (set as current processing) + const processPatientKlinikRuang = (patient, action, kodeKlinik, nomorRuang) => { + const patientIndex = allPatients.value.findIndex(p => p.no === patient.no); + + if (patientIndex === -1) { + return { success: false, message: "Pasien tidak ditemukan" }; + } + + const patientCode = patient.noAntrian.split(" |")[0]; + const key = `klinik-ruang-${kodeKlinik}-${nomorRuang}`; + let message = ""; + + switch (action) { + case "proses": + // Set as current processing for this room + if (!currentProcessingPatient.value[key]) { + // Use Vue's reactive assignment + currentProcessingPatient.value = { + ...currentProcessingPatient.value, + [key]: {} + }; + } + // Update nested property reactively + currentProcessingPatient.value[key] = { + ...currentProcessingPatient.value[key], + [patient.tipeLayanan]: allPatients.value[patientIndex] + }; + message = `Memproses pasien ${patientCode}`; + break; + + case "selesai": + allPatients.value[patientIndex] = { + ...allPatients.value[patientIndex], + status: "processed" + }; + // Clear current processing + if (currentProcessingPatient.value[key]) { + currentProcessingPatient.value[key] = { + ...currentProcessingPatient.value[key], + [patient.tipeLayanan]: null + }; + } + message = `Pasien ${patientCode} selesai diproses`; + break; + + case "terlambat": + allPatients.value[patientIndex] = { + ...allPatients.value[patientIndex], + status: "terlambat" + }; + if (currentProcessingPatient.value[key]) { + currentProcessingPatient.value[key] = { + ...currentProcessingPatient.value[key], + [patient.tipeLayanan]: null + }; + } + message = `Pasien ${patientCode} ditandai terlambat`; + break; + + case "pending": + allPatients.value[patientIndex] = { + ...allPatients.value[patientIndex], + status: "pending" + }; + if (currentProcessingPatient.value[key]) { + currentProcessingPatient.value[key] = { + ...currentProcessingPatient.value[key], + [patient.tipeLayanan]: null + }; + } + message = `Pasien ${patientCode} di-pending`; + break; + } + + return { success: true, message }; + }; + const changeKlinik = (patient, newKlinik, adminType = 'loket') => { const patientIndex = allPatients.value.findIndex((p) => p.no === patient.no); @@ -765,6 +1005,10 @@ export const useQueueStore = defineStore('queue', () => { createAntreanKlinik, createAntreanPenunjang, createAntreanKlinikRuang, + scanAndCreateAntreanKlinikRuang, + getPatientsByKlinikRuang, + callNextKlinikRuang, + processPatientKlinikRuang, changeKlinik, processNextQueue, getPatientsByStage,