diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ff36027 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,36 @@ +# Nuxt dev/build outputs +.output +.data +.nuxt +.nitro +.cache +dist + +# Database files +data/ +*.db +*.sqlite +*.sqlite3 + +# Node dependencies +node_modules + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# IDEs and OS files +.DS_Store +.fleet +.idea +.vscode +Thumbs.db + +# Local env files +.env +.env.* +!.env.example +!.env.docker.example diff --git a/.env example b/.env example index e3291ad..803126b 100644 --- a/.env example +++ b/.env example @@ -79,3 +79,9 @@ EXTERNAL_API_TIMEOUT=10000 # Digunakan di pages/verifikasiAkun/VerifikasiAkun.vue EKSTRAK_EXPERTISE_URL="http://10.10.123.218/ekstrakexpertise" VERIFICATION_API_BASE_URL="http://10.10.123.140:8089/api/v1" + +# Simplified Modular API URLs (New Config) +ANTRIAN_API_URL="http://10.10.123.140:8089/api/v1" +VISIT_API_URL="http://10.10.123.135:8084/api/v1" +WS_API_URL="ws://10.10.123.135:8084/api/v1/ws" + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5575c60 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,51 @@ +# Dockerfile for web-antrean Nuxt.js Application +# Multi-stage build for optimized image size and native modules support + +# Stage 1: Build Stage +FROM node:20-alpine AS builder + +# Install build dependencies for compiling native addons (like better-sqlite3) +RUN apk add --no-cache python3 make g++ gcc libc-dev + +# Set working directory +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies (including devDependencies for build) +RUN npm ci + +# Copy application files +COPY . . + +# Build the application for production +RUN npm run build + +# Stage 2: Production Stage +FROM node:20-alpine AS runner + +# Install runtime dependencies (like libc6-compat for compatibility with precompiled binaries) +RUN apk add --no-cache libc6-compat + +# Set working directory +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Copy built output and compiled node_modules from builder stage +# This avoids needing to install build tools (g++, make, python) in this slim production stage +COPY --from=builder /app/.output /app/.output +COPY --from=builder /app/node_modules /app/node_modules + +# Expose port +EXPOSE 3000 + +# Set environment variables +ENV NODE_ENV=production +ENV HOST=0.0.0.0 +ENV PORT=3000 + +# Start the application +CMD ["node", ".output/server/index.mjs"] diff --git a/README.Docker.md b/README.Docker.md new file mode 100644 index 0000000..3f8e5fa --- /dev/null +++ b/README.Docker.md @@ -0,0 +1,101 @@ +# Panduan Deployment Docker - web-antrean + +Panduan ini berisi langkah-langkah untuk menjalankan aplikasi `web-antrean` (Nuxt 3) menggunakan Docker & Docker Compose. + +--- + +## 📦 Prasyarat (Prerequisites) + +Sebelum memulai, pastikan Anda telah menginstal: +- **Docker Desktop** (atau Docker Engine di Linux) +- **Docker Compose** (biasanya otomatis terinstal dengan Docker Desktop) + +--- + +## 🚀 Quick Start (Mulai Cepat) + +### 1. Siapkan File `.env` +Pastikan file `.env` sudah ada di root folder `web-antrean` dengan konfigurasi yang benar. Anda bisa menyalin dari template yang disediakan: +```bash +cp .env.docker.example .env +``` +> [!IMPORTANT] +> Pastikan variabel `HOST` diatur ke `0.0.0.0` agar aplikasi dapat menerima request dari luar container Docker. +> Sesuaikan `AUTH_ORIGIN` dan `POST_LOGOUT_REDIRECT_URI` dengan alamat/domain server Anda (misal: `http://10.10.150.175:3000`). + +### 2. Bangun dan Jalankan Container +Jalankan perintah berikut di terminal untuk melakukan build image sekaligus menjalankan container di background: +```bash +docker-compose up -d --build +``` + +### 3. Akses Aplikasi +Setelah container berhasil berjalan, aplikasi dapat diakses di browser melalui: +- **http://localhost:3000** (atau IP server Anda, misal: **http://10.10.150.175:3000**) + +--- + +## 🔧 Perintah Docker yang Sering Digunakan (Useful Commands) + +### Menjalankan Container (Jalankan di background) +```bash +docker-compose up -d +``` + +### Mematikan Container +```bash +docker-compose down +``` + +### Membangun Ulang & Menjalankan Ulang (Jika ada perubahan kode) +```bash +docker-compose up -d --build +``` + +### Melihat Log Aplikasi secara Real-time +```bash +docker-compose logs -f web-antrean +``` + +### Masuk ke Terminal di Dalam Container +```bash +docker exec -it web-antrean sh +``` + +--- + +## 💾 Persistensi Database (SQLite) + +Aplikasi `web-antrean` menggunakan `better-sqlite3` untuk menyimpan data sinkronisasi pengguna di `/app/data/users.db`. + +Agar data ini tidak hilang saat container dihapus atau dibangun ulang, kami menggunakan **Volume Mounting** di `docker-compose.yml`: +```yaml +volumes: + - ./data:/app/data +``` +Ini akan otomatis memetakan folder local `./data` di host server Anda ke folder `/app/data` di dalam container. File database Anda akan tersimpan dengan aman di folder local `./data`. + +--- + +## 🔍 Troubleshooting (Penyelesaian Masalah) + +### 1. Masalah dengan Native Module compilation (`better-sqlite3`) +Jika image gagal dibangun karena error kompilasi C/C++, Dockerfile kami telah dikonfigurasi menggunakan **multi-stage build** dengan base image `node:20-alpine` dan menginstal package compiler (`make`, `g++`, `gcc`, `python3`) di builder stage. Ini menjamin proses build aman dan hasil image final tetap berukuran kecil (~150MB - 200MB). + +### 2. Cek Status Container dan Health Check +Container ini dilengkapi dengan health check otomatis. Anda bisa mengecek statusnya dengan: +```bash +docker ps +``` +Jika statusnya `Up (healthy)`, berarti server Nuxt Anda telah siap dan dapat diakses. + +### 3. Hapus Cache Docker jika Mengalami Error Aneh +```bash +docker-compose down --rmi all --volumes +``` + +--- + +## 🛡️ Catatan Keamanan +- Jangan pernah meng-commit file `.env` ke Git repository. +- Selalu gunakan `NUXT_AUTH_SECRET` yang aman dan unik untuk setiap environment (Development/Production). diff --git a/composables/usePermissions.ts b/composables/usePermissions.ts index fb8c3b9..c32048b 100644 --- a/composables/usePermissions.ts +++ b/composables/usePermissions.ts @@ -17,7 +17,10 @@ export const usePermission = () => { */ const fetchPermission = async (path: string) => { const { group, role } = parsePath(path); - const url = `http://10.10.123.140:8089/api/permission?roles=${role}&groups=${group}`; + const config = useRuntimeConfig(); + const apiBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1'; + const origin = new URL(apiBase).origin; + const url = `${origin}/api/permission?roles=${role}&groups=${group}`; const { data, error } = await useFetch(url, { method: "GET", diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a83d52f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,47 @@ +services: + web-antrean: + build: + context: . + dockerfile: Dockerfile + container_name: web-antrean + ports: + - "3000:3000" + env_file: + - .env + environment: + - NODE_ENV=production + - HOST=0.0.0.0 + - PORT=3000 + - NUXT_AUTH_SECRET=${NUXT_AUTH_SECRET} + - NUXT_KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID} + - NUXT_KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET} + - NUXT_KEYCLOAK_ISSUER=${KEYCLOAK_ISSUER} + - NUXT_KEYCLOAK_LOGOUT_URI=${KEYCLOAK_LOGOUT_URI} + - NUXT_POST_LOGOUT_REDIRECT_URI=${POST_LOGOUT_REDIRECT_URI} + - NUXT_PUBLIC_AUTH_URL=${AUTH_ORIGIN} + - NUXT_EXTERNAL_API_BASE_URL=${EXTERNAL_API_BASE_URL} + - NUXT_PUBLIC_VERIFICATION_API_BASE_URL=${VERIFICATION_API_BASE_URL} + - NUXT_PUBLIC_ANTRIAN_API_URL=${ANTRIAN_API_URL} + - NUXT_PUBLIC_VISIT_API_URL=${VISIT_API_URL} + - NUXT_PUBLIC_WS_API_URL=${WS_API_URL} + - NUXT_SESSION_DURATION_HOURS=${SESSION_DURATION_HOURS} + - NUXT_OAUTH_STATE_DURATION_MINUTES=${OAUTH_STATE_DURATION_MINUTES} + volumes: + # Mount local data directory to persist the sqlite users database + - ./data:/app/data + restart: unless-stopped + networks: + - antrean-network + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/', (r) => {process.exit(r.statusCode < 500 ? 0 : 1)})"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + +networks: + antrean-network: + driver: bridge + +volumes: + data: diff --git a/nuxt.config.ts b/nuxt.config.ts index 977068f..6b373ab 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -82,9 +82,9 @@ export default defineNuxtConfig({ authUrl: process.env.AUTH_ORIGIN, // authUrl: process.env.AUTH_ORIGIN || "http://10.10.150.175:3001", // authUrl: process.env.AUTH_ORIGIN || "http://localhost:3001", - wsBaseUrl: process.env.WS_BASE_URL || 'ws://10.10.123.135:8084/api/v1/ws', - verificationApiBaseUrl: process.env.VERIFICATION_API_BASE_URL || 'http://10.10.123.140:8089/api/v1', - + wsBaseUrl: process.env.WS_API_URL || process.env.WS_BASE_URL || 'ws://10.10.123.135:8084/api/v1/ws', + verificationApiBaseUrl: process.env.ANTRIAN_API_URL || process.env.VERIFICATION_API_BASE_URL || 'http://10.10.123.140:8089/api/v1', + externalApiBaseUrl: process.env.VISIT_API_URL || (process.env.EXTERNAL_API_BASE_URL ? `${process.env.EXTERNAL_API_BASE_URL}/api/v1` : 'http://10.10.123.135:8084/api/v1'), }, }, @@ -121,17 +121,7 @@ export default defineNuxtConfig({ }; })(), - routeRules: { - '/stats-api/**': { - proxy: 'http://10.10.123.135:8084/api/v1/**' - }, - '/visit-api/**': { - proxy: 'http://10.10.123.135:8084/api/v1/**' - }, - '/klinik-api/**': { - proxy: 'http://10.10.123.140:8089/api/v1/**' - }, - }, + vite: { css: { diff --git a/pages/AdminKlinikRuang/[kodeKlinik].vue b/pages/AdminKlinikRuang/[kodeKlinik].vue index 114ccc1..768c551 100644 --- a/pages/AdminKlinikRuang/[kodeKlinik].vue +++ b/pages/AdminKlinikRuang/[kodeKlinik].vue @@ -1716,8 +1716,8 @@ const handleCallPatientByTipe = async (ruang, tipeLayanan) => { visit_code: patient.barcode || patient.visitCode, visit_status_id: [visitStatusId] }; - console.log(`📤 [AdminKlinikRuang] Sending call status (idvisit ${visitStatusId}) to API:`, apiPayload); - const apiResponse = await fetch('http://10.10.123.135:8084/api/v1/visit/status/finish', { + const visitApiBase = '/visit-api'; + const apiResponse = await fetch(`${visitApiBase}/visit/status/finish`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(apiPayload) diff --git a/pages/AdminLoket/[id].vue b/pages/AdminLoket/[id].vue index c1baa1c..600d01b 100644 --- a/pages/AdminLoket/[id].vue +++ b/pages/AdminLoket/[id].vue @@ -492,8 +492,9 @@ const apiQuota = ref(null); // Fetch latest quota data specifically for this loket const fetchQuotaFromAPI = async () => { try { + const apiBase = '/klinik-api'; const response = await fetch( - "http://10.10.123.140:8089/api/v1/klinik/loket", + `${apiBase}/klinik/loket`, ); const data = await response.json(); @@ -1202,8 +1203,9 @@ const buatAntreanKlinikRuang = async (klinikRuang, ruang) => { console.log("📤 Sending visit ticket to API:", visitTicketBody); + const visitApiBase = '/visit-api'; const visitResponse = await fetch( - "http://10.10.123.135:8084/api/v1/visit/ticket/klinik", + `${visitApiBase}/visit/ticket/klinik`, { method: "POST", headers: { diff --git a/pages/Anjungan/AntrianKlinikRuang/[kodeKlinik].vue b/pages/Anjungan/AntrianKlinikRuang/[kodeKlinik].vue index a1c3d54..029a73e 100644 --- a/pages/Anjungan/AntrianKlinikRuang/[kodeKlinik].vue +++ b/pages/Anjungan/AntrianKlinikRuang/[kodeKlinik].vue @@ -466,12 +466,23 @@ const statistics = computed(() => { } const all = klinikPatients.value + + // A patient is considered active (sedang dilayani) if they are currently being processed in a room + const activePatients = all.filter(p => { + return Object.values(queueStore.currentProcessingPatient || {}).some( + proc => proc && String(proc.no) === String(p.no) + ) + }) + + // All other patients in the list are considered waiting (menunggu) + const waitingPatients = all.filter(p => { + return !activePatients.some(act => act.no === p.no) + }) + return { total: all.length, - // Counting 'anjungan', 'menunggu', 'waiting', 'pending', 'terlambat' as waiting - anjungan: all.filter(p => ['anjungan', 'menunggu', 'waiting', 'pending', 'terlambat'].includes(p.status)).length, - // Counting 'di-loket' and 'pemeriksaan' as active/processing - active: all.filter(p => ['di-loket', 'pemeriksaan'].includes(p.status)).length + anjungan: waitingPatients.length, + active: activePatients.length } }) diff --git a/pages/Anjungan/AntrianLoket/[id].vue b/pages/Anjungan/AntrianLoket/[id].vue index d722f65..4394183 100644 --- a/pages/Anjungan/AntrianLoket/[id].vue +++ b/pages/Anjungan/AntrianLoket/[id].vue @@ -225,6 +225,7 @@ const uniqueSessionSuffix = ref(process.client ? Math.random().toString(36).subs const currentTime = ref('') const currentDate = ref('') let timeInterval = null +let pollInterval = null let broadcastChannel = null const broadcastedPatient = ref(null) @@ -605,11 +606,21 @@ const displayedClinics = computed(() => { } // Prioritas 2: Persistence - If no active Hero call for this clinic, - // find the patient who was explicitly called by admin and is still in servingQueues (di-loket) + // find the patient who was explicitly called by admin OR has idvisit === 8 or 7 (from database) if (!currentQueue) { const calledServingQueues = servingQueues - .filter(q => q.calledByAdmin) // Use calledByAdmin for explicit loket call - .sort((a, b) => new Date(b.lastCalledAt) - new Date(a.lastCalledAt)) + .filter(q => q.calledByAdmin || q.idvisit === 8 || q.idvisit === 7) + .sort((a, b) => { + // Priority: idvisit === 8 or 7 has higher precedence + const isDbActiveA = a.idvisit === 8 || a.idvisit === 7; + const isDbActiveB = b.idvisit === 8 || b.idvisit === 7; + if (isDbActiveA && !isDbActiveB) return -1; + if (!isDbActiveA && isDbActiveB) return 1; + + const timeA = new Date(a.lastCalledAt || a.createdAt || 0).getTime(); + const timeB = new Date(b.lastCalledAt || b.createdAt || 0).getTime(); + return timeB - timeA; + }) if (calledServingQueues.length > 0) { currentQueue = calledServingQueues[0] @@ -623,8 +634,8 @@ const displayedClinics = computed(() => { return q.no !== currentQueue?.no }) - // Separate di-loket and pending grids - const diLoketQueuesInGrid = servingQueues.filter(q => q.no !== currentQueue?.no && q.status !== 'pending'); + // Separate di-loket and pending grids (exclude currently served patients from grid) + const diLoketQueuesInGrid = servingQueues.filter(q => q.no !== currentQueue?.no && q.status !== 'pending' && q.idvisit !== 8 && q.idvisit !== 7); const pendingQueues = servingQueues.filter(q => q.status === 'pending'); return { @@ -691,22 +702,9 @@ const currentCalledQueue = computed(() => { // Hanya tampilkan jika sudah dipanggil eksplisit oleh admin (calledByAdmin === true) if (currentProcessingPatient.value) { const processingPatient = currentProcessingPatient.value - - // Cek apakah data ini milik loket ini (berdasarkan metadata loketId) - // Jika tidak ada loketId (misal data lama), fallback ke true untuk Admin 1 const patientLoketId = processingPatient.loketId ? String(processingPatient.loketId) : "1" - if (patientLoketId !== targetLoketId) { - return null - } - // Cek apakah sudah dipanggil oleh admin - if (!processingPatient.calledByAdmin) { - return null - } - - // Pastikan pasien memiliki noAntrian dan status 'di-loket' (sudah check-in) - if (processingPatient.noAntrian && processingPatient.status === 'di-loket') { - // Dapatkan nama klinik dari nomor antrian + if (patientLoketId === targetLoketId && processingPatient.calledByAdmin && processingPatient.noAntrian && processingPatient.status === 'di-loket') { const klinikName = getKlinikNameFromPatient(processingPatient) return { ...processingPatient, @@ -714,6 +712,17 @@ const currentCalledQueue = computed(() => { } } } + + // Prioritas 2: Database state - Find patient in the fetched list who is currently being served (idvisit === 8 or 7) + const allPatientsList = filteredPatientsForLoket.value + const dbActive = allPatientsList.find(p => p.idvisit === 8 || p.idvisit === 7) + if (dbActive) { + const klinikName = getKlinikNameFromPatient(dbActive) + return { + ...dbActive, + klinik: klinikName || dbActive.klinik || 'Klinik' + } + } return null }) @@ -831,7 +840,7 @@ const isCalled = (queue) => { // Statistics const statistics = computed(() => { const all = filteredPatientsForLoket.value - const active = all.filter(p => p.status === 'di-loket').length + const active = currentCalledQueue.value ? 1 : 0 return { total: all.length, active: active @@ -1028,16 +1037,26 @@ onMounted(async () => { // Register interest in this specific loket for scoped WebSocket refreshes queueStore.registerInterest(loketId.value); - onUnmounted(() => { - // Unregister interest when leaving the page - queueStore.unregisterInterest(loketId.value); - }); + // Polling fallback (safety net for cross-browser / cross-device sync) + let lastPollTime = Date.now(); + pollInterval = setInterval(async () => { + const wsConnected = queueStore.isWsConnected; + const now = Date.now(); + const timeSinceLastPoll = now - lastPollTime; + + // Poll if WebSocket is disconnected (every 4s) OR as a safety net (every 15s) + if (!wsConnected || timeSinceLastPoll >= 15000) { + lastPollTime = now; + await fetchAllData(); + } + }, 4000); }); onUnmounted(() => { if (timeInterval) clearInterval(timeInterval) + if (pollInterval) clearInterval(pollInterval) if (broadcastChannel) broadcastChannel.close() - // wsInstance is now global + queueStore.unregisterInterest(loketId.value); }) diff --git a/pages/Setting/Edit-Loket/[id].vue b/pages/Setting/Edit-Loket/[id].vue index 426f64f..7f57bc4 100644 --- a/pages/Setting/Edit-Loket/[id].vue +++ b/pages/Setting/Edit-Loket/[id].vue @@ -45,7 +45,7 @@