Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/web-antrean into Antrean-Code

This commit is contained in:
bagus-arie05
2026-02-12 11:06:04 +07:00
4 changed files with 223 additions and 126 deletions
+46 -16
View File
@@ -1,9 +1,9 @@
// composables/useWebSocket.ts
import { ref, computed, onUnmounted, getCurrentInstance } from 'vue'
import { ref, computed, onUnmounted, getCurrentInstance, toValue } from 'vue'
export interface WebSocketConfig {
url: string
clientId: string
url: string | any
clientId: string | any
onMessage?: (data: any) => void
onOpen?: () => void
onClose?: () => void
@@ -24,8 +24,11 @@ export const useWebSocket = (config: WebSocketConfig) => {
const reconnectAttempts = ref(0)
const reconnectTimer = ref<NodeJS.Timeout | null>(null)
const currentUrl = computed(() => toValue(config.url))
const currentClientId = computed(() => toValue(config.clientId))
const wsUrl = computed(() => {
let baseUrl = config.url
let baseUrl = currentUrl.value
// Automatically upgrade to secure protocols if caller uses HTTPS
if (typeof window !== 'undefined' && window.location.protocol === 'https:') {
@@ -37,21 +40,44 @@ export const useWebSocket = (config: WebSocketConfig) => {
}
const url = new URL(baseUrl)
url.searchParams.set('client_id', config.clientId)
url.searchParams.set('client_id', currentClientId.value)
return url.toString()
})
const clearHandlers = (wsInstance: WebSocket | null) => {
if (wsInstance) {
wsInstance.onopen = null
wsInstance.onmessage = null
wsInstance.onclose = null
wsInstance.onerror = null
}
}
const connect = () => {
try {
// Close existing connection if any
if (ws.value && ws.value.readyState !== WebSocket.CLOSED) {
ws.value.close()
// Clear any pending reconnect timer first
if (reconnectTimer.value) {
clearTimeout(reconnectTimer.value)
reconnectTimer.value = null
}
ws.value = new WebSocket(wsUrl.value)
// Close existing connection if any, and CLEAN HANDLERS to prevent recursion
if (ws.value) {
if (ws.value.readyState !== WebSocket.CLOSED) {
console.log('🔌 Closing existing WebSocket before new connection...')
clearHandlers(ws.value)
ws.value.close()
}
ws.value = null
}
const connectionUrl = wsUrl.value
console.log('🔌 Connecting to WebSocket:', connectionUrl)
ws.value = new WebSocket(connectionUrl)
ws.value.onopen = () => {
console.log('WebSocket connected:', config.clientId)
console.log('WebSocket connected:', currentClientId.value)
isConnected.value = true
reconnectAttempts.value = 0
config.onOpen?.()
@@ -67,16 +93,17 @@ export const useWebSocket = (config: WebSocketConfig) => {
}
ws.value.onclose = () => {
console.log('WebSocket closed:', config.clientId)
console.log('WebSocket closed:', currentClientId.value)
isConnected.value = false
config.onClose?.()
// Attempt to reconnect
// Attempt to reconnect only if we didn't just reach max attempts
// The reconnectTimer is cleared in disconnect() and at start of connect()
if (reconnectAttempts.value < (config.maxReconnectAttempts || 5)) {
reconnectAttempts.value++
const interval = config.reconnectInterval || 3000
console.log(`⏳ Reconnecting in ${interval}ms... Attempt ${reconnectAttempts.value}`)
reconnectTimer.value = setTimeout(() => {
console.log(`Reconnecting... Attempt ${reconnectAttempts.value}`)
connect()
}, interval)
} else {
@@ -85,11 +112,11 @@ export const useWebSocket = (config: WebSocketConfig) => {
}
ws.value.onerror = (error) => {
console.error('WebSocket error:', error)
console.error('⚠️ WebSocket error:', error)
config.onError?.(error)
}
} catch (error) {
console.error('Error creating WebSocket:', error)
console.error('Error creating WebSocket:', error)
config.onError?.(error as Event)
}
}
@@ -100,10 +127,13 @@ export const useWebSocket = (config: WebSocketConfig) => {
reconnectTimer.value = null
}
if (ws.value) {
console.log('🔌 Manual disconnect: Cleaning handlers and closing...')
clearHandlers(ws.value)
ws.value.close()
ws.value = null
}
isConnected.value = false
reconnectAttempts.value = 0
}
const sendMessage = (message: any) => {
@@ -125,7 +155,7 @@ export const useWebSocket = (config: WebSocketConfig) => {
postUrl = config.fallbackPostUrl
} else {
// Derive post URL from WebSocket URL (legacy behavior)
let baseUrl = config.url
let baseUrl = currentUrl.value
if (baseUrl.startsWith('ws://')) {
baseUrl = baseUrl.replace('ws://', 'http://')
} else if (baseUrl.startsWith('wss://')) {
+8 -8
View File
@@ -1798,8 +1798,9 @@ watch(
background-color: var(--color-neutral-100);
border: 1px solid var(--color-neutral-300);
border-radius: 8px;
overflow: hidden;
overflow-y: auto;
margin-bottom: 20px;
max-height: 420px;
}
.doctor-info-unit {
@@ -1814,9 +1815,8 @@ watch(
font-size: 13px;
line-height: 1.4;
color: var(--color-neutral-800);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
white-space: normal;
word-wrap: break-word;
flex: 1;
}
@@ -1852,6 +1852,10 @@ watch(
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 8px;
max-height: 540px;
overflow-y: auto;
padding-right: 4px;
align-content: start;
}
.doctor-button-wrapper {
@@ -1924,10 +1928,6 @@ watch(
white-space: normal;
word-wrap: break-word;
line-height: 1.3;
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
/* Responsive untuk layar lebih kecil */
+168 -101
View File
@@ -111,23 +111,42 @@
<span class="text-caption text-grey"
>Preview kamera sedang berjalan</span
>
<v-spacer></v-spacer>
<v-btn
icon
variant="text"
size="small"
:color="primaryColor"
@click="switchCamera"
title="Ganti Kamera"
>
<v-icon>mdi-camera-flip</v-icon>
</v-btn>
</div>
<div
id="qr-reader"
class="qr-reader-wrapper"
:class="{ 'is-front': isFrontCamera }"
role="region"
aria-label="Area pemindaian QR code"
aria-live="polite"
:aria-busy="cameraChecking"
>
<div class="scanner-loading-overlay" v-if="!cameraReady">
<v-progress-circular
indeterminate
color="white"
size="48"
></v-progress-circular>
<p class="text-white mt-4">Memuat kamera...</p>
</div>
></div>
<!-- Hidden input for Photo Mode -->
<input
type="file"
ref="fileInput"
style="display: none"
accept="image/*"
@change="handleFileSelect"
/>
<!-- Moved overlay outside to prevent Vue/DOM conflict -->
<div class="scanner-loading-overlay" v-show="!cameraReady && isScanning">
<v-progress-circular
indeterminate
color="white"
size="48"
></v-progress-circular>
<p class="text-white mt-4">Memuat kamera...</p>
</div>
<div class="scanner-instruction">
<v-icon :color="primaryColor" size="16"
@@ -1703,6 +1722,10 @@ const qrCodeId = "qr-reader";
let lastScannedQR: string | null = null;
let lastScanTime: number = 0;
const SCAN_DEBOUNCE_MS = 2000; // 2 detik debounce untuk mencegah scan berulang
const currentFacingMode = ref<"user" | "environment">("environment");
const isFrontCamera = computed(() => currentFacingMode.value === "user");
const fileInput = ref<HTMLInputElement | null>(null);
const isProcessingFile = ref(false);
// Daftar QR code yang sudah berhasil di-scan (untuk mencegah double antrian)
const SUCCESSFUL_SCANS_KEY = "successful_qr_scans";
@@ -2124,23 +2147,15 @@ const startScanning = async () => {
if (html5QrCode) {
cameraReady.value = false;
// Konfigurasi kamera untuk mobile dan desktop
const cameraConfig = isMobile.value
? { facingMode: "environment" } // Mobile: gunakan kamera belakang
: { facingMode: "user" }; // Desktop: gunakan kamera depan (webcam)
// Konfigurasi kamera dinamis
const cameraConfig = { facingMode: currentFacingMode.value };
// Video constraints yang dioptimalkan untuk kualitas tinggi
const baseVideoConstraints = isMobile.value
? {
facingMode: "environment",
width: { ideal: 1280, min: 640, max: 1920 },
height: { ideal: 720, min: 480, max: 1080 },
}
: {
facingMode: "user",
width: { ideal: 1920, min: 1280, max: 1920 },
height: { ideal: 1080, min: 720, max: 1080 },
};
// Video constraints yang dioptimalkan
const baseVideoConstraints = {
facingMode: currentFacingMode.value,
width: { ideal: 1920, min: 1280 },
height: { ideal: 1080, min: 720 },
};
// Tambahkan advanced constraints hanya jika didukung
const videoConstraints: any = { ...baseVideoConstraints };
@@ -2155,17 +2170,14 @@ const startScanning = async () => {
await html5QrCode.start(
cameraConfig,
{
fps: 15, // Reduced FPS improved stability on mobile
qrbox: function (
viewfinderWidth: number,
viewfinderHeight: number,
) {
fps: 20, // Increased for better responsiveness
qrbox: (viewfinderWidth: number, viewfinderHeight: number) => {
const minEdgeSize = Math.min(viewfinderWidth, viewfinderHeight);
// Use 70% of the viewfinder for the qrbox to ensure it's comfortably inside
const qrboxSize = Math.floor(minEdgeSize * 0.7);
// Set to 72% for optimal balance between target size and quiet zone
const qrboxSize = Math.floor(minEdgeSize * 0.72);
return {
width: Math.max(qrboxSize, 200),
height: Math.max(qrboxSize, 200),
width: Math.max(qrboxSize, 250),
height: Math.max(qrboxSize, 250),
};
},
aspectRatio: 1.0,
@@ -2173,8 +2185,8 @@ const startScanning = async () => {
videoConstraints: videoConstraints,
rememberLastUsedCamera: true,
showTorchButtonIfSupported: true,
// Tambahkan opsi untuk meningkatkan deteksi
verbose: false, // Set true untuk debugging
useBarCodeDetectorIfSupported: true, // Native scanning if available
verbose: false, // Set true for debugging
},
(decodedText: string, decodedResult: any) => {
// QR Code berhasil di-scan
@@ -2230,23 +2242,14 @@ const startScanning = async () => {
await html5QrCode.start(
{ facingMode: "user" },
{
fps: 30,
qrbox: function (
viewfinderWidth: number,
viewfinderHeight: number,
) {
const minEdgeSize = Math.min(
viewfinderWidth,
viewfinderHeight,
);
const percentageSize = Math.floor(minEdgeSize * 0.85);
const fixedSize = 300;
const qrboxSize = Math.max(percentageSize, fixedSize);
const finalSize = Math.min(qrboxSize, minEdgeSize * 0.95);
fps: 20,
qrbox: (viewfinderWidth: number, viewfinderHeight: number) => {
const minEdgeSize = Math.min(viewfinderWidth, viewfinderHeight);
// Optimized 72%
const qrboxSize = Math.floor(minEdgeSize * 0.72);
return {
width: Math.max(finalSize, 250),
height: Math.max(finalSize, 250),
width: Math.max(qrboxSize, 250),
height: Math.max(qrboxSize, 250),
};
},
aspectRatio: 1.0,
@@ -2257,6 +2260,7 @@ const startScanning = async () => {
height: { ideal: 720, min: 480, max: 1080 },
},
rememberLastUsedCamera: true,
useBarCodeDetectorIfSupported: true,
verbose: false,
},
(decodedText: string, decodedResult: any) => {
@@ -2311,41 +2315,25 @@ const startScanning = async () => {
await html5QrCode.start(
{ deviceId: { exact: deviceId } },
{
fps: 30,
qrbox: function (
viewfinderWidth: number,
viewfinderHeight: number,
) {
const minEdgeSize = Math.min(
viewfinderWidth,
viewfinderHeight,
);
const percentageSize = Math.floor(minEdgeSize * 0.8);
const fixedSize = isMobile.value ? 300 : 400;
const qrboxSize = Math.max(percentageSize, fixedSize);
const finalSize = Math.min(qrboxSize, minEdgeSize * 0.8);
fps: 20,
qrbox: (viewfinderWidth: number, viewfinderHeight: number) => {
const minEdgeSize = Math.min(viewfinderWidth, viewfinderHeight);
// Optimized 72%
const qrboxSize = Math.floor(minEdgeSize * 0.85);
return {
width: Math.max(finalSize, 250),
height: Math.max(finalSize, 250),
width: Math.max(qrboxSize, 250),
height: Math.max(qrboxSize, 250),
};
},
aspectRatio: 1.0,
disableFlip: false,
videoConstraints: {
deviceId: { exact: deviceId },
width: {
ideal: isMobile.value ? 1280 : 1920,
min: isMobile.value ? 640 : 1280,
max: isMobile.value ? 1920 : 1920,
},
height: {
ideal: isMobile.value ? 720 : 1080,
min: isMobile.value ? 480 : 720,
max: isMobile.value ? 1080 : 1080,
},
width: { ideal: isMobile.value ? 1280 : 1920 },
height: { ideal: isMobile.value ? 720 : 1080 },
},
rememberLastUsedCamera: true,
useBarCodeDetectorIfSupported: true,
verbose: false,
},
(decodedText: string, decodedResult: any) => {
@@ -2424,23 +2412,28 @@ const startScanning = async () => {
const stopScanning = async () => {
try {
if (html5QrCode) {
// Stop scanner
if (isScanning.value) {
await html5QrCode.stop();
await html5QrCode.clear();
}
// Note: Media tracks are automatically stopped when html5QrCode.stop() is called
// Immediate state update
const scanner = html5QrCode;
html5QrCode = null;
isScanning.value = false;
cameraReady.value = false;
// Stop and clear
try {
if (scanner.getState && scanner.getState() > 1) { // 1 is stopped, 2 is scanning, 3 is paused
await scanner.stop();
}
await scanner.clear();
} catch (err) {
console.warn("Scanner shutdown error (non-critical):", err);
}
showSnackbar("Info", "Scanner dihentikan", "info", "mdi-camera-off");
}
} catch (err: any) {
console.error("Error stopping scanner:", err);
console.error("Error in stopScanning:", err);
isScanning.value = false;
cameraReady.value = false;
// Force cleanup
html5QrCode = null;
}
};
@@ -2913,6 +2906,80 @@ const matchNoAntrian = (input: string, noAntrian: string): boolean => {
return false;
};
// --- FUNGSI TAMBAHAN SCANNER ---
/**
* Berpindah antara kamera depan dan belakang
*/
const switchCamera = async () => {
if (!isScanning.value) return;
const newMode = currentFacingMode.value === "user" ? "environment" : "user";
console.log(`Switching camera to: ${newMode}`);
// Stop scanner dulu
if (html5QrCode) {
try {
await html5QrCode.stop();
} catch (err) {
console.warn("Error stopping for camera switch:", err);
}
}
// Update mode dan restart
currentFacingMode.value = newMode;
cameraReady.value = false;
await startScanning();
};
/**
* Trigger pemilih file (Photo Mode)
*/
const triggerFileSelect = () => {
if (fileInput.value) {
fileInput.value.click();
}
};
/**
* Handle scan dari file gambar (Photo Mode)
*/
const handleFileSelect = async (event: Event) => {
const input = event.target as HTMLInputElement;
if (!input.files || input.files.length === 0) return;
const file = input.files[0];
isProcessingFile.value = true;
try {
// Pastikan scanner ter-inisialisasi
if (!html5QrCode) {
const { Html5Qrcode: Html5QrcodeClass } = await import("html5-qrcode");
html5QrCode = new Html5QrcodeClass(qrCodeId);
}
console.log("Processing file scan...");
const decodedText = await html5QrCode.scanFile(file, true);
if (decodedText) {
console.log("✅ QR Code detected from file:", decodedText);
handleQRScanSuccess(decodedText);
}
} catch (err) {
console.error("Error scanning file:", err);
showSnackbar(
"Error",
"Gagal mendeteksi QR code dari foto. Pastikan foto jelas dan terang.",
"error",
"mdi-alert-circle",
);
} finally {
isProcessingFile.value = false;
// Reset input agar bisa pilih file yang sama lagi jika perlu
if (input) input.value = "";
}
};
const onDetect = async (decodedText: string) => {
// Format QR Code yang didukung:
// 1. Format thermal print: hanya BARCODE saja (contoh: 250811100163)
@@ -5158,14 +5225,14 @@ onUnmounted(() => {
height: 100% !important;
border-radius: 16px;
display: block !important;
object-fit: cover;
object-fit: contain;
background: #000;
aspect-ratio: 1 / 1;
transform: scaleX(-1); /* Mirror effect */
-webkit-transform: scaleX(-1); /* Safari support */
/* Reduce brightness to prevent overexposure/bloom */
filter: brightness(0.75) contrast(1.1);
-webkit-filter: brightness(0.75) contrast(1.1);
}
.qr-reader-wrapper.is-front :deep(video) {
transform: scaleX(-1); /* Mirror effect only for front camera */
-webkit-transform: scaleX(-1);
}
.qr-reader-wrapper :deep(canvas) {
@@ -5212,12 +5279,12 @@ onUnmounted(() => {
width: 100% !important;
height: 100% !important;
aspect-ratio: 1 / 1;
object-fit: cover;
transform: scaleX(-1); /* Mirror effect */
-webkit-transform: scaleX(-1); /* Safari support */
/* Reduce brightness to prevent overexposure/bloom */
filter: brightness(0.75) contrast(1.1);
-webkit-filter: brightness(0.75) contrast(1.1);
object-fit: contain;
}
.qr-reader-wrapper.is-front :deep(#qr-reader__scan_region video) {
transform: scaleX(-1); /* Mirror effect only for front camera */
-webkit-transform: scaleX(-1);
}
.scanner-status {
+1 -1
View File
@@ -327,7 +327,7 @@ export const useQueueStore = defineStore('queue', () => {
const { connect, disconnect, sendViaPost, isConnected } = useWebSocket({
url: wsBaseUrl,
clientId: wsClientId.value,
clientId: wsClientId,
onOpen: () => {
console.log('✅ [queueStore] WebSocket connected');
isWsConnected.value = true;