feat: implement patient data integration with SIMRS API and add modular queue management components
This commit is contained in:
@@ -13,7 +13,7 @@ nuxtApp.hook('page:finish', () => {
|
||||
|
||||
// Naikkan versi ini setiap kali ada perubahan struktur state (schema migration).
|
||||
// Saat versi tidak cocok, semua cache Pinia akan dibersihkan otomatis.
|
||||
const STORE_SCHEMA_VERSION = 3;
|
||||
const STORE_SCHEMA_VERSION = 4;
|
||||
const VERSION_KEY = 'app-store-version';
|
||||
|
||||
// Daftar semua localStorage key yang dikelola oleh Pinia stores
|
||||
|
||||
@@ -3,17 +3,31 @@
|
||||
<v-card-text class="pa-4">
|
||||
<div class="section-header mb-3">
|
||||
<div class="section-label">SEDANG DIPROSES</div>
|
||||
<v-btn
|
||||
v-if="patient"
|
||||
class="call-button text-white"
|
||||
color="primary-500"
|
||||
variant="flat"
|
||||
size="small"
|
||||
@click="$emit('call')"
|
||||
>
|
||||
<v-icon start size="18">mdi-bullhorn</v-icon>
|
||||
Panggil
|
||||
</v-btn>
|
||||
<div class="d-flex align-center">
|
||||
<v-btn
|
||||
v-if="patient"
|
||||
variant="outlined"
|
||||
height="36"
|
||||
rounded="lg"
|
||||
class="data-pasien-btn bg-white mr-3 text-none font-weight-bold"
|
||||
@click="showPatientDataDialog = true"
|
||||
>
|
||||
<v-icon start size="18">mdi-account-cog-outline</v-icon>
|
||||
Data Pasien
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="patient"
|
||||
class="call-button text-white text-none font-weight-bold"
|
||||
color="primary-500"
|
||||
variant="flat"
|
||||
height="36"
|
||||
rounded="lg"
|
||||
@click="$emit('call')"
|
||||
>
|
||||
<v-icon start size="18">mdi-microphone</v-icon>
|
||||
Panggil
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="patient" class="patient-details" :class="`patient-details-${theme}`">
|
||||
@@ -138,11 +152,30 @@
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<!-- Patient Data Dialog -->
|
||||
<PatientDataDialog
|
||||
v-if="patient"
|
||||
v-model="showPatientDataDialog"
|
||||
:patient="patient"
|
||||
@linked="handlePatientLinked"
|
||||
/>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import PatientDataDialog from './PatientDataDialog.vue';
|
||||
|
||||
const showPatientDataDialog = ref(false);
|
||||
|
||||
/**
|
||||
* Menangani event ketika antrean berhasil dihubungkan dengan data pasien di SIMRS
|
||||
* Memunculkan notifikasi (snackbar) bahwa proses berhasil.
|
||||
*/
|
||||
const handlePatientLinked = () => {
|
||||
emit('linked', 'Data pasien berhasil dihubungkan');
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
patient: {
|
||||
@@ -178,6 +211,11 @@ defineEmits(['action', 'change-klinik', 'process-next', 'call', 'open-klinik-rua
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.data-pasien-btn {
|
||||
color: var(--color-primary-500) !important;
|
||||
border-color: var(--color-primary-500) !important;
|
||||
}
|
||||
|
||||
.current-patient-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-neutral-500);
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
v-model="isOpen"
|
||||
max-width="600px"
|
||||
persistent
|
||||
scrollable
|
||||
>
|
||||
<v-card class="patient-data-dialog" rounded="xl">
|
||||
<!-- Header -->
|
||||
<v-card-title class="dialog-header d-flex justify-space-between align-center px-4 py-3 text-white">
|
||||
<span class="text-subtitle-1 font-weight-medium">Data Pasien</span>
|
||||
<v-btn icon="mdi-close" variant="text" size="small" color="white" @click="closeDialog" />
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-6">
|
||||
<!-- Antrean Number Info -->
|
||||
<div class="text-center mb-6">
|
||||
<div class="text-subtitle-1 text-grey-darken-1 mb-1">Nomor Antrian</div>
|
||||
<div class="text-h3 font-weight-bold mb-1">{{ antrianNumber }}</div>
|
||||
<div class="text-caption text-grey-darken-1 mb-2">
|
||||
Barcode: <span class="font-weight-bold">{{ patient?.barcode || '-' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="alreadyLinked || isLinked" class="text-success mt-3 text-body-1 font-weight-medium d-flex align-center justify-center">
|
||||
<v-icon start size="20" color="success">mdi-check-circle</v-icon>
|
||||
{{ isLinked ? 'Berhasil dihubungkan ke RM baru' : `Sudah terhubung dengan RM: ${patient?.noRM}` }}
|
||||
</div>
|
||||
<div v-else-if="searchError" class="text-error mt-3 text-body-1 font-weight-medium d-flex align-center justify-center">
|
||||
<v-icon start size="20">mdi-close-circle</v-icon>
|
||||
{{ searchError }}
|
||||
</div>
|
||||
<div v-else-if="!patientData" class="text-error mt-3 text-body-1 font-weight-medium d-flex align-center justify-center">
|
||||
<v-icon start size="20">mdi-alert-circle</v-icon>
|
||||
Nomor Antrean belum terhubung ke pasien
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Form -->
|
||||
<div class="form-section pa-5 border rounded-xl mb-6">
|
||||
<div class="section-title text-caption font-weight-bold mb-3 text-uppercase text-navy-custom" style="letter-spacing: 0.5px;">
|
||||
Hubungkan Antrean
|
||||
</div>
|
||||
<v-text-field
|
||||
v-model="searchQuery"
|
||||
placeholder="Cari Nomor BPJS atau Nomor RM atau NIK.."
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
bg-color="white"
|
||||
class="search-input"
|
||||
@keyup.enter="searchPatient"
|
||||
>
|
||||
<template #append-inner>
|
||||
<div class="d-flex align-center h-100 pr-1">
|
||||
<v-btn
|
||||
variant="flat"
|
||||
class="search-btn bg-navy-custom text-white text-none font-weight-bold"
|
||||
height="36"
|
||||
min-width="80"
|
||||
rounded="lg"
|
||||
:loading="isLoading"
|
||||
@click="searchPatient"
|
||||
>
|
||||
Cari
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</div>
|
||||
|
||||
<!-- Patient Info Display -->
|
||||
<v-expand-transition>
|
||||
<div v-if="patientData" class="info-section">
|
||||
<div class="section-title text-caption font-weight-bold text-primary mb-3 text-uppercase">
|
||||
Informasi Data Diri
|
||||
</div>
|
||||
|
||||
<div class="info-grid">
|
||||
<!-- Nama Lengkap -->
|
||||
<div class="info-field">
|
||||
<div class="text-caption text-grey-darken-1 mb-1">Nama Lengkap</div>
|
||||
<div class="info-box">{{ patientData.nama }}</div>
|
||||
</div>
|
||||
|
||||
<!-- TTL -->
|
||||
<div class="info-field">
|
||||
<div class="text-caption text-grey-darken-1 mb-1">Tempat / Tanggal Lahir</div>
|
||||
<div class="info-box">{{ patientData.ttl }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Jenis Kelamin -->
|
||||
<div class="info-field">
|
||||
<div class="text-caption text-grey-darken-1 mb-1">Jenis Kelamin</div>
|
||||
<div class="info-box">{{ patientData.jenisKelamin }}</div>
|
||||
</div>
|
||||
|
||||
<!-- No Telepon -->
|
||||
<div class="info-field">
|
||||
<div class="text-caption text-grey-darken-1 mb-1">Nomor Telepon</div>
|
||||
<div class="info-box">{{ patientData.noTelepon }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Alamat -->
|
||||
<div class="info-field full-width">
|
||||
<div class="text-caption text-grey-darken-1 mb-1">Alamat</div>
|
||||
<div class="info-box">{{ patientData.alamat }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
</v-card-text>
|
||||
|
||||
<!-- Footer Actions -->
|
||||
<v-card-actions v-if="patientData" class="pa-4 bg-grey-lighten-4 justify-end">
|
||||
<v-btn
|
||||
variant="flat"
|
||||
class="bg-navy-custom text-white px-6 text-none"
|
||||
prepend-icon="mdi-sync"
|
||||
:loading="isLinking"
|
||||
@click="linkPatient"
|
||||
>
|
||||
Hubungkan
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
patient: any;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'linked'): void;
|
||||
}>();
|
||||
|
||||
// State
|
||||
const isOpen = ref(props.modelValue);
|
||||
const searchQuery = ref('');
|
||||
const isLoading = ref(false);
|
||||
const isLinking = ref(false);
|
||||
const isLinked = ref(false);
|
||||
const searchError = ref('');
|
||||
|
||||
const antrianNumber = computed(() => {
|
||||
return props.patient?.noAntrian ? props.patient.noAntrian.split(' |')[0] : '';
|
||||
});
|
||||
|
||||
const alreadyLinked = computed(() => {
|
||||
return !!props.patient?.noRM;
|
||||
});
|
||||
|
||||
// Tipe data mock
|
||||
interface MockPatient {
|
||||
nama: string;
|
||||
ttl: string;
|
||||
jenisKelamin: string;
|
||||
noTelepon: string;
|
||||
alamat: string;
|
||||
}
|
||||
|
||||
const patientData = ref<MockPatient | null>(null);
|
||||
|
||||
// Watchers
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
isOpen.value = newVal;
|
||||
if (newVal) {
|
||||
// Reset state saat modal dibuka
|
||||
searchQuery.value = '';
|
||||
patientData.value = null;
|
||||
isLinked.value = false;
|
||||
searchError.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
watch(isOpen, (newVal) => {
|
||||
emit('update:modelValue', newVal);
|
||||
});
|
||||
|
||||
// Methods
|
||||
const resetState = () => {
|
||||
searchQuery.value = '';
|
||||
patientData.value = null;
|
||||
isLinked.value = false;
|
||||
isLoading.value = false;
|
||||
isLinking.value = false;
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Simulasi pencarian data pasien ke SIMRS.
|
||||
* NANTI BISA DIGANTI DENGAN $fetch KE SERVICE API ASLI.
|
||||
*/
|
||||
const searchPatient = async () => {
|
||||
if (!searchQuery.value.trim()) return;
|
||||
|
||||
isLoading.value = true;
|
||||
patientData.value = null;
|
||||
|
||||
try {
|
||||
// Reset state error sebelum pencarian
|
||||
searchError.value = '';
|
||||
|
||||
// TODO: Ganti URL '/api/simrs/patient/' di bawah ini dengan URL API Service SIMRS yang sebenarnya
|
||||
const response = await $fetch(`/api/simrs/patient/${searchQuery.value}`);
|
||||
|
||||
// @ts-ignore
|
||||
patientData.value = response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Gagal mencari data pasien:', error);
|
||||
searchError.value = error.data?.statusMessage || `Pasien dengan nomor ${searchQuery.value} tidak ditemukan.`;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Simulasi menghubungkan nomor antrean dengan data pasien di SIMRS.
|
||||
*/
|
||||
const linkPatient = async () => {
|
||||
isLinking.value = true;
|
||||
|
||||
try {
|
||||
// TODO: Ganti dengan API call asli untuk menghubungkan antrean
|
||||
// await $fetch('/api/antrean/link', { method: 'POST', body: { ... } });
|
||||
|
||||
// Simulasi delay
|
||||
await new Promise(resolve => setTimeout(resolve, 600));
|
||||
|
||||
isLinked.value = true;
|
||||
emit('linked');
|
||||
closeDialog();
|
||||
} catch (error) {
|
||||
console.error('Gagal menghubungkan data pasien:', error);
|
||||
} finally {
|
||||
isLinking.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.bg-navy-custom {
|
||||
background-color: #003482 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.text-navy-custom {
|
||||
color: #003482 !important;
|
||||
}
|
||||
|
||||
.patient-data-dialog {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
|
||||
.dialog-header {
|
||||
background-color: #003482 !important;
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: #E53935;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
:deep(.v-field) {
|
||||
border-radius: 8px;
|
||||
}
|
||||
:deep(.v-field__append-inner) {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
border-radius: 6px;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
background-color: #F8F9FA;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid #E0E0E0;
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
|
||||
.info-field {
|
||||
&.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background-color: #ECEFF1;
|
||||
border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
min-height: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -170,6 +170,11 @@ export const useQueueSync = (deps: QueueSyncDeps) => {
|
||||
|
||||
let _autoSyncInterval: any = null;
|
||||
|
||||
/**
|
||||
* Memulai interval auto-sync (fallback) untuk memastikan data tetap segar
|
||||
* meskipun koneksi WebSocket terputus atau gagal menerima event.
|
||||
* Berjalan setiap 30 detik berdasarkan active interest.
|
||||
*/
|
||||
const startAutoSync = () => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (_autoSyncInterval) return;
|
||||
|
||||
@@ -63,6 +63,36 @@ Format output:
|
||||
|
||||
<!-- Tambahkan entry baru di bawah baris ini, urutan terbaru di atas -->
|
||||
|
||||
## 2026-07-10 — Fitur Hubungkan Data Pasien ke SIMRS
|
||||
|
||||
**Sprint/Phase:** Phase 7 — UI/UX Feature
|
||||
**Durasi:** ~1.5 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Membuat UI Pop-up Modal baru `PatientDataDialog.vue` untuk mengaitkan nomor antrean yang berjalan dengan data dari SIMRS.
|
||||
- Menambahkan tombol **Data Pasien** (ikon *user-cog*) di komponen `CurrentPatientCard.vue` berdampingan dengan tombol **Panggil**.
|
||||
- Mengimplementasikan state management lokal untuk pop-up, termasuk simulasi proses API (*loading bar*, *mock data*, dan *success notification*).
|
||||
- Menyesuaikan warna komponen secara presisi menggunakan hex code `#003482` dengan *SCSS override* untuk menembus batasan tema *default*.
|
||||
- Meningkatkan *border-radius* modal ke `xl` untuk sudut yang lebih melengkung (*rounded*) sesuai desain UI/UX.
|
||||
- Mengubah logika *mock data* pada *method* `searchPatient` menjadi `$fetch` (*API call*) asinkron yang menembak internal API Nuxt (`/api/simrs/patient/[id]`).
|
||||
- Mengimplementasikan endpoint API backend (menggunakan *Nitro/H3*) di folder `server/api/simrs/patient/[id].get.ts` sebagai *Dummy JSON Provider* untuk di-*replace* dengan layanan asli nanti.
|
||||
- Melengkapi fungsi-fungsi yang baru dibuat dengan komentar JSDoc.
|
||||
|
||||
### Keputusan Teknis
|
||||
> **Dummy API via Nitro**: Dengan memindahkan *mock data* dari dalam komponen Vue ke `server/api/`, arsitektur kode simulasi jadi lebih mirip dengan *production*. Saat pindah ke *Service API* sungguhan nanti, front-end developer hanya perlu mengganti URL string `$fetch` tanpa harus membongkar ulang struktur logika _async_ `try-catch`-nya.
|
||||
> **Pemisahan Komponen**: Daripada menggabungkan form SIMRS ke dalam `CurrentPatientCard`, diputuskan untuk membuat file baru `PatientDataDialog.vue`. Ini menjaga agar file loket tidak terlalu membengkak (modular) dan mudah dikelola atau diganti endpoint API-nya nanti.
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Warna tombol garis luar (*outlined*) selalu kembali ke warna hitam (*fallback*) dan tidak menyerap properti `color="primary"`. | Menambahkan *style SCSS* kustom secara paksa (`!important`) untuk mengisi `border-color` dan `color` sesuai `hex code` `#003482` dari desain. | `CurrentPatientCard.vue`, `PatientDataDialog.vue` |
|
||||
|
||||
### Besok
|
||||
- [ ] Mengganti endpoint `/api/simrs/patient/` internal ini dengan URL *Service API* SIMRS yang sebenarnya saat endpoint backend sudah di-*deploy*.
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-10 — Migrasi API Endpoint & Project Knowledge Base
|
||||
|
||||
**Sprint/Phase:** Phase 7 — Infrastruktur & Dokumentasi
|
||||
@@ -720,6 +750,22 @@ Format output:
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-13 — Konsolidasi Loket Eksekutif
|
||||
|
||||
**Sprint/Phase:** Phase 7
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Mengubah `localLoketData` pada `loketStore.js` dari 14 loket menjadi 1 loket tunggal (`LOKET EKS`) untuk melayani semua klinik eksekutif (ID 1000 hingga 1022).
|
||||
- Menaikkan `STORE_SCHEMA_VERSION` pada `app.vue` (menjadi v4) untuk memicu pembersihan *cache localStorage* pada pengguna agar konfigurasi loket tunggal segera diterapkan.
|
||||
|
||||
### Keputusan Teknis
|
||||
- Mengubah default pembuatan array untuk `localLoketData` menjadi 1 objek.
|
||||
- Properti `pelayanan` (hak akses layanan) diberikan semua ID layanan dari 1000 - 1022.
|
||||
- Tidak ada penambahan/perubahan logika UI karena antrean dan grid UI otomatis menyesuaikan jumlah `loketStore`.
|
||||
|
||||
---
|
||||
|
||||
<!-- Tambahkan entry baru di atas baris ini, urutan terbaru di atas -->
|
||||
|
||||
---
|
||||
|
||||
@@ -258,5 +258,6 @@ docs: update DEVPLAN
|
||||
|
||||
| Versi | Tanggal | Author | Perubahan |
|
||||
|-------|---------|--------|-----------|
|
||||
| 1.1.1 | 2026-07-13 | Agent | Refactor `loketStore.js` untuk menggabungkan loket eksekutif menjadi 1 loket tunggal (melayani semua) |
|
||||
| 1.1.0 | 2026-07-10 | Akbar | Phase 7: migrasi API, knowledge base, update docs |
|
||||
| 1.0.0 | 2026-05-25 | Akbar | Initial plan — direkonstruksi berdasarkan timeline aktual |
|
||||
@@ -0,0 +1,181 @@
|
||||
# Integrasi Hak Akses (Permissions) di UI beserta Konfigurasi CRUD
|
||||
|
||||
## ✅ Hasil Pengecekan Kesiapan Project
|
||||
1. **Navigasi (*Menu/Routing*)**: Sudah tersedia di `stores/navItems1.ts` (`defaultNavItems`). Array ini akan menjadi sumber daftar halaman di dialog "Edit Hak Akses" — dengan catatan penting di bagian struktur data (lihat poin 2 di bawah).
|
||||
2. **Library Komponen**: Vuetify 3 sudah tersedia, tinggal memasang `<v-checkbox>` / `<v-switch>` di tabel dialog Edit Hak Akses.
|
||||
3. **Mock Backend API**: `server/api/hak-akses/index.ts` sudah bisa menahan format data CRUD yang akan dikirim dari UI.
|
||||
|
||||
Karena API backend sesungguhnya belum siap, kita pakai **Nuxt Local API (Mock Backend)** sebagai *Backend-for-Frontend* sementara, dengan arsitektur *toggling* API sejak awal agar migrasi ke backend asli mulus.
|
||||
|
||||
> ⚠️ **Catatan penting**: Toggle mock/real API ini menyelesaikan masalah *sumber data*, bukan masalah *keamanan*. Semua pengecekan `v-permission` dan middleware di bawah ini berjalan di client — lihat bagian **Keamanan** sebelum dianggap selesai.
|
||||
|
||||
---
|
||||
|
||||
## Bagan Alur Sistem Hak Akses (Permission Flow)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
User([Pengguna]) --> Login(Berhasil Login via Keycloak)
|
||||
Login --> Auth[useAuth.ts: Dapat Data Roles & Groups]
|
||||
Auth --> PermStore[permissionStore.ts memuat Permissions]
|
||||
|
||||
PermStore --> CheckFlag{USE_MOCK_API ?}
|
||||
|
||||
CheckFlag -- TRUE --> MockAPI[Nuxt Local API: /api/hak-akses]
|
||||
CheckFlag -- FALSE --> RealAPI[Real Backend: /api/v1/permission]
|
||||
|
||||
MockAPI --> FetchFail{Fetch gagal?}
|
||||
RealAPI --> FetchFail
|
||||
FetchFail -- Ya --> DenyDefault[Deny-by-default: anggap tanpa izin]
|
||||
FetchFail -- Tidak --> PermState(Permission State Disimpan)
|
||||
|
||||
PermState --> Router[Vue Router Middleware - client]
|
||||
PermState --> ServerCheck[Server Middleware/Plugin - SSR guard]
|
||||
PermState --> Sidebar[Sidebar Navigation]
|
||||
|
||||
Router -- canAccess: False --> Deny[Redirect ke Error/403]
|
||||
Router -- canAccess: True --> Page[Buka Halaman]
|
||||
ServerCheck -- canAccess: False --> Deny
|
||||
|
||||
Sidebar -- canView: False --> HideMenu[Sembunyikan Menu]
|
||||
|
||||
Page --> Directive[v-permission directive pada Komponen]
|
||||
Directive -- canDelete: False --> HideBtn[Tombol Hapus Hilang/Disabled]
|
||||
Directive -- canAdd: True --> ShowBtn[Tombol Tambah Tampil]
|
||||
|
||||
Page -.-> BackendGuard[[Backend API tetap validasi ulang izin]]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### 1. Seamless Backend Migration Strategy (Persiapan API Asli)
|
||||
- `stores/permissionStore.ts` dan composable pemanggil membaca konfigurasi *runtime* (`useRuntimeConfig()`).
|
||||
- Tambahkan flag `USE_MOCK_PERMISSION_API: true`.
|
||||
- Saat backend siap, cukup ubah `NUXT_PUBLIC_USE_MOCK_PERMISSION_API=false` di `.env`.
|
||||
- **Tambahan — contract test**: buat satu skema/interface TypeScript (idealnya divalidasi dengan `zod`) yang dipakai bersama oleh mock API dan dipakai untuk memvalidasi response real API nanti. Ini memastikan klaim "tinggal ganti flag" benar-benar teruji, bukan asumsi.
|
||||
|
||||
### 2. Upgrade Local API Mock (`server/api/hak-akses/index.ts`)
|
||||
- Perbarui `data/mock/hakAkses.json` agar field `hakAksesMenu` berisi boolean: `canAccess`, `canView`, `canAdd`, `canEdit`, `canDelete`.
|
||||
- **Perubahan struktur — pakai key stabil, bukan label**: setiap entri `hakAksesMenu` menyimpan `menuKey` (mengacu ke `key`/`routeName` unik di `navItems1.ts`), bukan `name` (label tampilan). Label bisa berubah/di-rename tanpa memutus mapping izin.
|
||||
|
||||
```json
|
||||
{
|
||||
"menuKey": "master-klinik-ruang",
|
||||
"name": "Master Klinik Ruang",
|
||||
"canAccess": true,
|
||||
"canView": true,
|
||||
"canAdd": false,
|
||||
"canEdit": false,
|
||||
"canDelete": false
|
||||
}
|
||||
```
|
||||
|
||||
- Validasi payload masuk dengan skema (zod) di endpoint mock, supaya struktur tidak diam-diam berubah antara UI dan backend.
|
||||
|
||||
### 3. Memperbarui `Setting/HakAkses.vue` (UI Konfigurasi CRUD)
|
||||
- Dialog Edit melooping `navItems1.ts`, menampilkan matriks checkbox untuk `canAccess`, `canView`, `canAdd`, `canEdit`, `canDelete`, dikunci ke `menuKey` masing-masing.
|
||||
- Payload POST mengarah ke API Mock/Real sesuai flag.
|
||||
- **Aturan precedence eksplisit** (harus didefinisikan sebelum coding, karena ada `role`, `group`, dan `isGroupBased` sekaligus):
|
||||
1. Jika user punya override individual (role-based) → pakai itu.
|
||||
2. Jika tidak ada override individual dan `isGroupBased: true` → pakai izin dari group.
|
||||
3. Jika keduanya tidak ada → deny-by-default.
|
||||
- Tuliskan aturan ini sebagai komentar di `permissionStore.ts`, bukan hanya di dokumen, supaya tidak jadi sumber bug tersembunyi saat logic berkembang.
|
||||
|
||||
### 4. Vue Custom Directive `v-permission`
|
||||
- File baru `plugins/permission.ts` untuk registrasi directive.
|
||||
- Dukungan penggunaan:
|
||||
- Single: `<v-btn v-permission="'canEdit'">Edit</v-btn>`
|
||||
- Multiple (AND): `<v-btn v-permission="['canEdit', 'canDelete']">...</v-btn>`
|
||||
- **Mode hide vs disable**: tambahkan modifier, misal `v-permission:disable="'canDelete'"`, agar tombol bisa di-disable dengan tooltip ("Anda tidak punya izin") alih-alih hilang total tanpa penjelasan — pilih sesuai konteks UX per halaman.
|
||||
|
||||
### 5. `stores/permissionStore.ts` (State Management)
|
||||
- Mengambil data izin dari Local/Real API saat login.
|
||||
- Menyimpan state global.
|
||||
- **Deny-by-default**: jika fetch permission gagal (network error, token expired), state dianggap "tanpa izin sama sekali", bukan default terbuka.
|
||||
- **Refresh strategy**: tentukan apakah perubahan hak akses oleh admin berlaku langsung (polling/refetch berkala) atau baru berlaku setelah re-login. Pilih salah satu secara eksplisit dan dokumentasikan, jangan dibiarkan implisit.
|
||||
|
||||
### 6. Middleware & Dynamic Sidebar
|
||||
- **Client middleware (`middleware/permissions.ts`)**: mencegat rute jika `canAccess` false.
|
||||
- **Server-side guard**: karena project ini SSR (Nuxt), tambahkan pengecekan di server middleware/plugin juga — bukan hanya client — untuk mencegah *flash of unauthorized content* (halaman sempat ter-render sebelum redirect).
|
||||
- **Sidebar (`stores/navItems1.ts`)**: filter menu berdasarkan `canView`, dikunci ke `menuKey`.
|
||||
|
||||
### 7. Keamanan Backend (wajib, non-negotiable)
|
||||
- `v-permission` dan middleware di atas adalah **UX**, bukan kontrol akses sesungguhnya — keduanya berjalan di client dan bisa dilewati siapa saja yang memanggil API langsung.
|
||||
- Backend API asli **wajib** memvalidasi ulang setiap permission di server berdasarkan identitas user dari token, tidak pernah mempercayai payload/izin yang dikirim dari frontend.
|
||||
|
||||
---
|
||||
|
||||
## Verification Plan
|
||||
|
||||
### 1. Verifikasi API Lokal via cURL
|
||||
Pastikan API Lokal mampu membaca dan menyimpan JSON berformat CRUD dengan `menuKey`:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/api/hak-akses" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"role": "admin",
|
||||
"group": "LOKET",
|
||||
"namaTipeUser": "Admin Loket",
|
||||
"isGroupBased": true,
|
||||
"hakAksesMenu": [
|
||||
{
|
||||
"menuKey": "master-klinik-ruang",
|
||||
"name": "Master Klinik Ruang",
|
||||
"canAccess": true,
|
||||
"canView": true,
|
||||
"canAdd": false,
|
||||
"canEdit": false,
|
||||
"canDelete": false
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### 2. Manual UI Verification
|
||||
1. Buka **Setting > Hak Akses**, atur hak untuk Role tertentu (centang `canView`, matikan `canDelete`).
|
||||
2. Login sebagai akun dengan Role tersebut.
|
||||
3. Buka halaman target.
|
||||
4. **Validasi**: Halaman terbuka, tombol "Delete" tersembunyi/disabled sesuai mode `v-permission`.
|
||||
5. **Validasi fetch gagal**: Simulasikan permission API error (mis. matikan endpoint sementara) → pastikan sistem deny-by-default, bukan default terbuka.
|
||||
6. **Validasi kesiapan API asli**: Cek logika precedence dan toggle flag di `permissionStore.ts`.
|
||||
|
||||
### 3. Automated Testing (baru)
|
||||
- **Unit test** untuk `permissionStore.ts`: precedence role vs group, deny-by-default saat fetch gagal, evaluasi `canAccess`/`canView`/dst.
|
||||
- **Contract test**: bandingkan skema response Mock API vs Real API (setelah Real API tersedia) menggunakan skema TypeScript/zod yang sama, untuk memastikan switch flag benar-benar tanpa perubahan kode lain.
|
||||
- **Directive test**: pastikan `v-permission` menyembunyikan/disable elemen dengan benar untuk kombinasi single dan multiple permission.
|
||||
|
||||
---
|
||||
|
||||
## Strategi Selama Backend Asli Belum Tersedia
|
||||
|
||||
Karena tim belum bisa mengimplementasikan validasi izin di server sungguhan, mock API diperlakukan sebagai **kontrak (contract-first)**, bukan sekadar penyimpanan data sementara. Tujuannya: frontend sudah teruji terhadap semua skenario yang nanti jadi tanggung jawab backend asli, dan tidak perlu dirombak saat migrasi.
|
||||
|
||||
### 1. Mock API mengikuti skema, bukan menerima apa saja
|
||||
- Definisikan interface TypeScript / skema `zod` untuk request dan response `hak-akses` **sekarang**, bukan menunggu backend asli.
|
||||
- Mock API menolak (400) payload yang tidak sesuai skema.
|
||||
- Skema ini menjadi kontrak yang wajib dipatuhi backend asli nanti — perbedaan struktur akan ketahuan lewat contract test, bukan saat production.
|
||||
|
||||
### 2. Simulasikan tanggung jawab yang nanti dipegang backend
|
||||
- Tambahkan endpoint mock `/api/hak-akses/check` yang bisa mensimulasikan penolakan server (403) karena user tidak punya izin — supaya UI dan middleware sudah teruji menangani penolakan dari server, bukan hanya dari state client.
|
||||
- Simulasikan juga kegagalan fetch (delay/error 500) untuk memverifikasi deny-by-default benar-benar berjalan.
|
||||
|
||||
### 3. Tandai eksplisit bagian yang "sementara tidak aman"
|
||||
Beri komentar `TODO(security)` di titik-titik yang wajib diperkuat saat backend asli terpasang, contoh:
|
||||
```ts
|
||||
// TODO(security): saat backend asli terpasang, endpoint ini WAJIB
|
||||
// memvalidasi ulang permission dari token JWT/session di server,
|
||||
// jangan percaya payload role/group yang dikirim dari client.
|
||||
```
|
||||
Ini mencegah asumsi keliru saat handoff bahwa "karena UI sudah mengatur tampilan sesuai izin, backend tidak perlu memvalidasi ulang".
|
||||
|
||||
### 4. Definition of Done — Migrasi ke Backend Asli
|
||||
Checklist ini harus tercentang semua **sebelum** flag `USE_MOCK_PERMISSION_API` dimatikan (`false`) di production:
|
||||
- [ ] Endpoint real API memvalidasi permission berdasarkan identitas dari token (JWT/session), bukan dari body request yang dikirim client.
|
||||
- [ ] Response real API lolos contract test terhadap skema yang sama dengan mock API (field, tipe data, struktur `hakAksesMenu` identik).
|
||||
- [ ] Skenario penolakan server (403) dan fetch gagal (500/timeout) sudah diuji terhadap real API, tidak hanya terhadap mock.
|
||||
- [ ] Ada audit log untuk setiap perubahan hak akses (siapa mengubah, kapan, dari-ke apa) — direkomendasikan mengingat ini fitur kontrol akses.
|
||||
- [ ] Rate limiting pada endpoint pengubahan hak akses, untuk mencegah penyalahgunaan.
|
||||
- [ ] Semua komentar `TODO(security)` di kode sudah diselesaikan atau dipindahkan menjadi tiket tersendiri yang dilacak.
|
||||
@@ -103,11 +103,47 @@ const ruangStore = useRuangStore();
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
/**
|
||||
* Computed property untuk mendapatkan daftar klinik dengan menggabungkan
|
||||
* semua poli Eksekutif menjadi satu entitas "GRAND PAVILIUN".
|
||||
* @returns {Array} Daftar klinik yang sudah digabung dan diurutkan.
|
||||
*/
|
||||
const klinikRuangList = computed(() => {
|
||||
const list = masterStore.ruangData || [];
|
||||
return [...list].sort((a, b) => a.namaKlinik.localeCompare(b.namaKlinik));
|
||||
const regulerList = list.filter(k => k.jenisLayanan !== 'Eksekutif');
|
||||
const eksekutifList = list.filter(k => k.jenisLayanan === 'Eksekutif');
|
||||
|
||||
const result = [...regulerList];
|
||||
|
||||
if (eksekutifList.length > 0) {
|
||||
// Combine all Eksekutif rooms for the preview
|
||||
const allRooms = eksekutifList.flatMap(k => k.ruangList || []);
|
||||
// Filter unique room names
|
||||
const uniqueRoomNames = new Set();
|
||||
const uniqueRooms = [];
|
||||
allRooms.forEach(r => {
|
||||
if (!uniqueRoomNames.has(r.namaRuang)) {
|
||||
uniqueRoomNames.add(r.namaRuang);
|
||||
uniqueRooms.push(r);
|
||||
}
|
||||
});
|
||||
|
||||
result.push({
|
||||
kodeKlinik: 'EKS',
|
||||
namaKlinik: 'GRAND PAVILIUN',
|
||||
jenisLayanan: 'Eksekutif',
|
||||
ruangList: uniqueRooms
|
||||
});
|
||||
}
|
||||
|
||||
return result.sort((a, b) => a.namaKlinik.localeCompare(b.namaKlinik));
|
||||
});
|
||||
|
||||
/**
|
||||
* Navigasi ke halaman detail antrean klinik ruang tertentu.
|
||||
* @param {string} kodeKlinik - Kode unik klinik (contoh: 'AN', 'EKS').
|
||||
* @param {string} jenisLayanan - Jenis layanan ('Reguler' atau 'Eksekutif').
|
||||
*/
|
||||
const navigateToKlinik = (kodeKlinik, jenisLayanan) => {
|
||||
// Include jenisLayanan as query parameter to differentiate same clinic code with different service types
|
||||
router.push({
|
||||
@@ -116,6 +152,9 @@ const navigateToKlinik = (kodeKlinik, jenisLayanan) => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigasi ke halaman master setting klinik ruang.
|
||||
*/
|
||||
const navigateToSettings = () => {
|
||||
router.push('/setting/masterklinikruang');
|
||||
};
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
@call="handleCallPatient"
|
||||
@open-klinik-ruang="openKlinikRuangDialog"
|
||||
@open-penunjang="openPenunjangDialog"
|
||||
@linked="handlePatientLinked"
|
||||
/>
|
||||
|
||||
<QueueActionsCard
|
||||
@@ -1451,12 +1452,20 @@ const buatAntreanKlinikRuang = async (klinikRuang, ruang) => {
|
||||
snackbarColor.value = result.success ? "success" : "error";
|
||||
snackbar.value = true;
|
||||
|
||||
|
||||
if (result.success) {
|
||||
broadcastUpdate();
|
||||
}
|
||||
|
||||
closeKlinikRuangDialog();
|
||||
};
|
||||
|
||||
// --- SIMRS Integration Handlers ---
|
||||
const handlePatientLinked = (message) => {
|
||||
snackbarText.value = message;
|
||||
snackbarColor.value = "success";
|
||||
snackbar.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -209,8 +209,14 @@ const klinikData = computed(() => {
|
||||
}
|
||||
|
||||
// Filter by kodeKlinik AND jenisLayanan if provided
|
||||
let filteredRuang
|
||||
if (jenisLayanan.value) {
|
||||
let filteredRuang = []
|
||||
if (jenisLayanan.value === 'Eksekutif' && kode === 'EKS') {
|
||||
return {
|
||||
kodeKlinik: 'EKS',
|
||||
namaKlinik: 'GRAND PAVILIUN',
|
||||
jenisLayanan: 'Eksekutif'
|
||||
}
|
||||
} else if (jenisLayanan.value) {
|
||||
filteredRuang = allRuang.filter(r =>
|
||||
r.kodeKlinik === kode && r.jenisLayanan === jenisLayanan.value
|
||||
)
|
||||
@@ -280,7 +286,15 @@ watch(() => queueStore.lastKlinikCall, (newCall) => {
|
||||
|
||||
const callKode = String(newCall.kodeKlinik || '').toUpperCase();
|
||||
const myKode = String(kodeKlinik.value || '').toUpperCase();
|
||||
const isMatch = callKode === myKode;
|
||||
|
||||
let isMatch = false;
|
||||
if (myKode === 'EKS' && jenisLayanan.value === 'Eksekutif') {
|
||||
// For consolidated view, check if the called clinic is an Eksekutif clinic
|
||||
const targetClinic = (masterStore.ruangData || []).find(r => r.kodeKlinik === callKode || r.kodeKlinik === newCall.kodeKlinik);
|
||||
isMatch = targetClinic && targetClinic.jenisLayanan === 'Eksekutif';
|
||||
} else {
|
||||
isMatch = callKode === myKode;
|
||||
}
|
||||
|
||||
console.log('🔍 [Anjungan] Klinik Code Comparison:', {
|
||||
received: callKode,
|
||||
@@ -332,13 +346,19 @@ const klinikPatients = computed(() => {
|
||||
// Get patients from klinik-ruang stage (created from AdminKlinikRuang)
|
||||
// Filter out finished patients (status: 'processed' or 'selesai')
|
||||
// AND filter by room number to separate patients by jenisLayanan
|
||||
return queueStore.allPatients.filter(p =>
|
||||
p.processStage === 'klinik-ruang' &&
|
||||
p.kodeKlinik === kodeKlinik.value &&
|
||||
p.status !== 'processed' &&
|
||||
p.status !== 'selesai' &&
|
||||
validRoomNumbers.includes(p.nomorRuang) // Only show patients in rooms belonging to this jenisLayanan
|
||||
)
|
||||
return queueStore.allPatients.filter(p => {
|
||||
if (p.processStage !== 'klinik-ruang' || p.status === 'processed' || p.status === 'selesai') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For consolidated Eksekutif view, any valid Eksekutif room is a match, regardless of patient's original kodeKlinik
|
||||
if (kodeKlinik.value === 'EKS' && jenisLayanan.value === 'Eksekutif') {
|
||||
return validRoomNumbers.includes(p.nomorRuang);
|
||||
}
|
||||
|
||||
// For regular clinics, enforce exact kodeKlinik match
|
||||
return p.kodeKlinik === kodeKlinik.value && validRoomNumbers.includes(p.nomorRuang);
|
||||
})
|
||||
})
|
||||
|
||||
// Get ruang list for this specific klinik
|
||||
@@ -364,8 +384,11 @@ const ruangListForKlinik = computed(() => {
|
||||
}
|
||||
|
||||
// Filter by kodeKlinik AND jenisLayanan if provided
|
||||
let filtered
|
||||
if (jenisLayanan.value) {
|
||||
let filtered = []
|
||||
if (jenisLayanan.value === 'Eksekutif' && kodeKlinik.value === 'EKS') {
|
||||
// If it's the consolidated Eksekutif view, we want ALL Eksekutif rooms
|
||||
filtered = allRuang.filter(r => r.jenisLayanan === 'Eksekutif')
|
||||
} else if (jenisLayanan.value) {
|
||||
filtered = allRuang.filter(r =>
|
||||
r.kodeKlinik === kodeKlinik.value && r.jenisLayanan === jenisLayanan.value
|
||||
)
|
||||
|
||||
@@ -126,15 +126,50 @@ const clinicStore = useClinicStore();
|
||||
const ruangStore = useRuangStore();
|
||||
const route = useRoute();
|
||||
|
||||
// Simplified computed to use masterStore.ruangData (which is already grouped/processed by ruangStore)
|
||||
/**
|
||||
* Computed property untuk mendapatkan daftar klinik dengan menggabungkan
|
||||
* semua poli Eksekutif menjadi satu entitas "GRAND PAVILIUN".
|
||||
* @returns {Array} Daftar klinik yang sudah digabung dan diurutkan berdasarkan nama.
|
||||
*/
|
||||
const kliniksWithRuang = computed(() => {
|
||||
const list = masterStore.ruangData || [];
|
||||
return [...list].sort((a, b) => a.namaKlinik.localeCompare(b.namaKlinik));
|
||||
const regulerList = list.filter(k => k.jenisLayanan !== 'Eksekutif');
|
||||
const eksekutifList = list.filter(k => k.jenisLayanan === 'Eksekutif');
|
||||
|
||||
const result = [...regulerList];
|
||||
|
||||
if (eksekutifList.length > 0) {
|
||||
// Combine all Eksekutif rooms for the preview
|
||||
const allRooms = eksekutifList.flatMap(k => k.ruangList || []);
|
||||
// Filter unique room names
|
||||
const uniqueRoomNames = new Set();
|
||||
const uniqueRooms = [];
|
||||
allRooms.forEach(r => {
|
||||
if (!uniqueRoomNames.has(r.namaRuang)) {
|
||||
uniqueRoomNames.add(r.namaRuang);
|
||||
uniqueRooms.push(r);
|
||||
}
|
||||
});
|
||||
|
||||
result.push({
|
||||
id: 'EKS',
|
||||
kodeKlinik: 'EKS',
|
||||
namaKlinik: 'GRAND PAVILIUN',
|
||||
jenisLayanan: 'Eksekutif',
|
||||
ruangList: uniqueRooms
|
||||
});
|
||||
}
|
||||
|
||||
return result.sort((a, b) => a.namaKlinik.localeCompare(b.namaKlinik));
|
||||
});
|
||||
|
||||
// Deprecated: Pagination logic removed
|
||||
|
||||
|
||||
/**
|
||||
* Navigasi ke halaman anjungan antrean klinik ruang tertentu.
|
||||
* @param {Object} klinik - Objek data klinik yang dipilih.
|
||||
*/
|
||||
const navigateToKlinik = (klinik) => {
|
||||
// Build URL with jenisLayanan query parameter to differentiate same clinic codes
|
||||
const url = `/anjungan/antrianklinikruang/${klinik.kodeKlinik}`;
|
||||
@@ -142,6 +177,9 @@ const navigateToKlinik = (klinik) => {
|
||||
navigateTo({ path: url, query });
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigasi ke halaman master setting klinik ruang.
|
||||
*/
|
||||
const navigateToSettings = () => {
|
||||
navigateTo('/setting/masterklinikruang');
|
||||
};
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
<v-data-table
|
||||
v-model:page="page"
|
||||
:headers="headers"
|
||||
:items="masterStore.ruangData"
|
||||
:items="consolidatedRuangData"
|
||||
:items-per-page="itemsPerPage"
|
||||
:search="search"
|
||||
item-value="id"
|
||||
@@ -120,6 +120,7 @@
|
||||
|
||||
<template v-slot:item.layarInformasi="{ item }">
|
||||
<v-btn
|
||||
v-if="item.id !== 'EKS'"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="primary-600"
|
||||
@@ -129,6 +130,17 @@
|
||||
<v-icon size="16" left>mdi-eye</v-icon>
|
||||
Preview
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-else
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="primary-600"
|
||||
@click="previewUrl = '/anjungan/antrianklinikruang/EKS?jenisLayanan=Eksekutif'; previewDialog = true;"
|
||||
class="btn-preview mr-2"
|
||||
>
|
||||
<v-icon size="16" left>mdi-eye</v-icon>
|
||||
Preview
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.jenisLayanan="{ item }">
|
||||
@@ -158,26 +170,29 @@
|
||||
</template>
|
||||
|
||||
<template v-slot:item.aksi="{ item }">
|
||||
<v-btn
|
||||
size="small"
|
||||
color=" warning-600"
|
||||
@click="openEditDialog(item)"
|
||||
class="btn-edit mr-2"
|
||||
variant="flat"
|
||||
>
|
||||
<v-icon size="16" left>mdi-pencil</v-icon>
|
||||
Edit
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
color="error-600"
|
||||
@click="handleDelete(item)"
|
||||
class="btn-delete"
|
||||
variant="flat"
|
||||
>
|
||||
<v-icon size="16" left>mdi-delete</v-icon>
|
||||
Delete
|
||||
</v-btn>
|
||||
<template v-if="item.id !== 'EKS'">
|
||||
<v-btn
|
||||
size="small"
|
||||
color=" warning-600"
|
||||
@click="openEditDialog(item)"
|
||||
class="btn-edit mr-2"
|
||||
variant="flat"
|
||||
>
|
||||
<v-icon size="16" left>mdi-pencil</v-icon>
|
||||
Edit
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
color="error-600"
|
||||
@click="handleDelete(item)"
|
||||
class="btn-delete"
|
||||
variant="flat"
|
||||
>
|
||||
<v-icon size="16" left>mdi-delete</v-icon>
|
||||
Delete
|
||||
</v-btn>
|
||||
</template>
|
||||
<span v-else class="text-caption text-grey">Data Otomatis</span>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card-text>
|
||||
@@ -391,6 +406,46 @@ const itemsPerPage = ref(10);
|
||||
const search = ref('');
|
||||
const filteredTotal = ref(masterStore.ruangData.length);
|
||||
|
||||
/**
|
||||
* Menggabungkan data ruang klinik agar poli Eksekutif tampil sebagai satu kesatuan 'GRAND PAVILIUN'.
|
||||
* Memisahkan poli reguler dan mengekstrak semua ruangan dari poli eksekutif menjadi satu set ruangan unik.
|
||||
* @returns {Array} Array objek data ruang klinik yang sudah dikonsolidasi.
|
||||
*/
|
||||
const consolidatedRuangData = computed(() => {
|
||||
const list = masterStore.ruangData || [];
|
||||
const regulerList = list.filter(k => k.jenisLayanan !== 'Eksekutif');
|
||||
const eksekutifList = list.filter(k => k.jenisLayanan === 'Eksekutif');
|
||||
|
||||
const result = [...regulerList];
|
||||
|
||||
if (eksekutifList.length > 0) {
|
||||
// Combine all Eksekutif rooms
|
||||
const allRooms = eksekutifList.flatMap(k => k.ruangList || []);
|
||||
const uniqueRoomNames = new Set();
|
||||
const uniqueRooms = [];
|
||||
allRooms.forEach(r => {
|
||||
if (!uniqueRoomNames.has(r.namaRuang)) {
|
||||
uniqueRoomNames.add(r.namaRuang);
|
||||
uniqueRooms.push(r);
|
||||
}
|
||||
});
|
||||
|
||||
result.push({
|
||||
id: 'EKS',
|
||||
kodeKlinik: 'EKS',
|
||||
namaKlinik: 'GRAND PAVILIUN',
|
||||
jenisLayanan: 'Eksekutif',
|
||||
ruangList: uniqueRooms,
|
||||
_originalClinics: eksekutifList
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
/**
|
||||
* Watcher untuk memperbarui total entri yang difilter ketika jumlah data utama berubah (dan tidak sedang mencari).
|
||||
*/
|
||||
watch(() => masterStore.ruangData.length, (newLen) => {
|
||||
if (!search.value) filteredTotal.value = newLen;
|
||||
}, { immediate: true });
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Dummy API Endpoint untuk mencari data pasien dari SIMRS
|
||||
*
|
||||
* Endpoint ini merespons request GET dengan ID pasien.
|
||||
* Digunakan sebagai simulasi (mock) sebelum API Service SIMRS yang asli tersedia.
|
||||
*
|
||||
* @param event - Objek event request dari Nitro H3
|
||||
* @returns Object data pasien jika ID cocok, atau throw 404 Error jika tidak ditemukan
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = event.context.params?.id;
|
||||
|
||||
// Simulasi delay jaringan (biar ada efek loading di UI)
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
|
||||
// Data dummy (nanti ini akan didapatkan dari database SIMRS asli)
|
||||
if (id === '123456789') {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
nama: 'Andi Pratama',
|
||||
ttl: 'Surabaya, 15 Agustus 1990',
|
||||
jenisKelamin: 'Laki-laki',
|
||||
noTelepon: '081234567890',
|
||||
alamat: 'Jl. Merdeka No. 45, Klojen, Malang'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Jika pasien tidak ditemukan
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: `Pasien dengan nomor ${id} tidak ditemukan.`,
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ const SEED_ANJUNGAN = [
|
||||
const SEED_ANTREAN_MASUK_SCREENS = [
|
||||
{ namaScreen: 'Layar Antrean Masuk 1', nomorScreen: 'AM-001', loket: JSON.stringify([1, 2, 12, 14]) },
|
||||
{ namaScreen: 'Layar Antrean Masuk 2', nomorScreen: 'AM-002', loket: JSON.stringify([3, 4]) },
|
||||
{ namaScreen: 'Layar Antrean Masuk Eksekutif', nomorScreen: 'AM-EKS', loket: JSON.stringify([1000]) },
|
||||
];
|
||||
|
||||
const SEED_KLINIK_RUANG = [
|
||||
|
||||
+15
-29
@@ -79,35 +79,21 @@ export const useLoketStore = defineStore('loket', () => {
|
||||
const apiLoketData = ref([])
|
||||
|
||||
// Local Data (EKSEKUTIF) - ID 1000+ manual input
|
||||
// DI-persist ke localStorage. Default 14 loket hardcoded.
|
||||
const localLoketData = ref(Array.from({ length: 14 }, (_, i) => {
|
||||
// Distribute services securely
|
||||
// Loket 1-5: Single service
|
||||
// Loket 6-10: Dual services
|
||||
// Loket 11+: Single service
|
||||
let services = [];
|
||||
if (i < 5) {
|
||||
services = [1000 + i];
|
||||
} else if (i < 10) {
|
||||
services = [1000 + i, 1005 + i];
|
||||
} else {
|
||||
services = [1000 + i];
|
||||
}
|
||||
|
||||
return {
|
||||
id: 1000 + i,
|
||||
no: i + 1,
|
||||
namaLoket: `LOKET ${i + 1} EKS`,
|
||||
kuota: 500,
|
||||
pelayanan: services,
|
||||
pembayaran: ['EKSEKUTIF'], // Changed to array for consistency
|
||||
tipeLoket: 'EKSEKUTIF', // Add tipeLoket field for consistency
|
||||
keterangan: 'ONLINE',
|
||||
statusPelayanan: 'RAWAT JALAN',
|
||||
source: 'local',
|
||||
loketAktif: true
|
||||
};
|
||||
}))
|
||||
// DI-persist ke localStorage. Default 1 loket melayani semua EKS.
|
||||
const localLoketData = ref([{
|
||||
id: 1000,
|
||||
no: 1,
|
||||
namaLoket: `LOKET EKS`,
|
||||
kuota: 500,
|
||||
// Provide all EKS clinic IDs (1000-1022) so it serves all
|
||||
pelayanan: Array.from({ length: 23 }, (_, i) => 1000 + i),
|
||||
pembayaran: ['EKSEKUTIF'],
|
||||
tipeLoket: 'EKSEKUTIF',
|
||||
keterangan: 'ONLINE',
|
||||
statusPelayanan: 'RAWAT JALAN',
|
||||
source: 'local',
|
||||
loketAktif: true
|
||||
}])
|
||||
|
||||
// Merged data (computed) - Gabungan API + Local
|
||||
const loketData = computed(() => {
|
||||
|
||||
+15
-4
@@ -2782,6 +2782,7 @@ const fetchPatientsForLoket = async (loketId: string | number, force: boolean =
|
||||
shift: shift,
|
||||
klinik: clinic.name || clinic,
|
||||
kodeKlinik: clinic.kode || null, // Essential for filtering
|
||||
idKlinik: clinic.id || null, // Essential for Eksekutif loket matching
|
||||
fastTrack: isFastTrack ? "YA" : "TIDAK",
|
||||
pembayaran: paymentType,
|
||||
noRM: `RM-${barcode.slice(-6)}`,
|
||||
@@ -2804,12 +2805,17 @@ const fetchPatientsForLoket = async (loketId: string | number, force: boolean =
|
||||
|
||||
// Auto-assign Loket ID if not provided, based on Clinic Mapping
|
||||
// fulfill requirement: "adjust based on loket id depending on creation"
|
||||
if (!newPatient.loketId && newPatient.kodeKlinik) {
|
||||
if (!newPatient.loketId && (newPatient.kodeKlinik || newPatient.idKlinik)) {
|
||||
const allLokets = loketStore.lokets || [];
|
||||
if (isEksekutif) {
|
||||
const targetLoket = allLokets.find(l =>
|
||||
l.pelayanan && Array.isArray(l.pelayanan) &&
|
||||
(l.pelayanan.includes(newPatient.kodeKlinik) || l.pelayanan.includes(newPatient.kodeKlinik?.split('-')[0])) &&
|
||||
(
|
||||
l.pelayanan.includes(newPatient.kodeKlinik) ||
|
||||
l.pelayanan.includes(newPatient.kodeKlinik?.split('-')[0]) ||
|
||||
l.pelayanan.includes(newPatient.idKlinik) ||
|
||||
l.pelayanan.includes(String(newPatient.idKlinik))
|
||||
) &&
|
||||
(l.id >= 1000 || l.tipeLoket === 'EKSEKUTIF')
|
||||
);
|
||||
|
||||
@@ -2818,12 +2824,17 @@ const fetchPatientsForLoket = async (loketId: string | number, force: boolean =
|
||||
if (!newPatient.loket) newPatient.loket = targetLoket.namaLoket;
|
||||
console.log(`✅ Auto-assigned Eksekutif Ticket ${newPatient.noAntrian} to Loket ${targetLoket.id} (${targetLoket.namaLoket})`);
|
||||
} else {
|
||||
console.warn(`⚠️ Warning: No Eksekutif Loket found for clinic ${newPatient.kodeKlinik}. Ticket will not be routed to any loket.`);
|
||||
console.warn(`⚠️ Warning: No Eksekutif Loket found for clinic ID ${newPatient.idKlinik} or Code ${newPatient.kodeKlinik}. Ticket will not be routed to any loket.`);
|
||||
}
|
||||
} else {
|
||||
const targetLoket = allLokets.find(l =>
|
||||
l.pelayanan && Array.isArray(l.pelayanan) &&
|
||||
(l.pelayanan.includes(newPatient.kodeKlinik) || l.pelayanan.includes(newPatient.kodeKlinik?.split('-')[0]))
|
||||
(
|
||||
l.pelayanan.includes(newPatient.kodeKlinik) ||
|
||||
l.pelayanan.includes(newPatient.kodeKlinik?.split('-')[0]) ||
|
||||
l.pelayanan.includes(newPatient.idKlinik) ||
|
||||
l.pelayanan.includes(String(newPatient.idKlinik))
|
||||
)
|
||||
);
|
||||
|
||||
if (targetLoket) {
|
||||
|
||||
Reference in New Issue
Block a user