update anjungan, store management

This commit is contained in:
bagus-arie05
2026-01-06 14:51:28 +07:00
parent 71b9de8d21
commit 6b38359a90
27 changed files with 7395 additions and 10986 deletions

No files matched your search

@@ -1,7 +1,20 @@
<template>
<v-card class="current-patient-card" elevation="0">
<v-card-text class="pa-4">
<div class="section-label mb-3">SEDANG DIPROSES</div>
<div class="section-header mb-3">
<div class="section-label">SEDANG DIPROSES</div>
<v-btn
v-if="patient"
class="call-button"
color="primary-600"
variant="flat"
size="small"
@click="$emit('call')"
>
<v-icon start size="18">mdi-bullhorn</v-icon>
Panggil
</v-btn>
</div>
<div v-if="patient" class="patient-details" :class="`patient-details-${theme}`">
<div class="patient-number mb-2">{{ patient.noAntrian.split(" |")[0] }}</div>
@@ -80,7 +93,7 @@ defineProps({
}
});
defineEmits(['action', 'change-klinik', 'process-next']);
defineEmits(['action', 'change-klinik', 'process-next', 'call']);
</script>
<style scoped lang="scss">
@@ -90,6 +103,12 @@ defineEmits(['action', 'change-klinik', 'process-next']);
background: var(--color-neutral-100);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.section-label {
font-size: 11px;
font-weight: 700;
@@ -98,6 +117,12 @@ defineEmits(['action', 'change-klinik', 'process-next']);
text-transform: uppercase;
}
.call-button {
text-transform: none;
font-weight: 600;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.patient-details {
border-radius: 8px;
padding: 16px;
+105
View File
@@ -0,0 +1,105 @@
// composables/useAPI.ts
// Generic API composable untuk semua API calls
// Base composable yang bisa di-extend untuk specific resources
export interface APIResponse<T = any> {
success: boolean;
data?: T;
message?: string;
error?: string;
}
export const useAPI = () => {
const config = useRuntimeConfig();
const baseURL = config.public.apiBaseUrl || '/api';
/**
* Generic GET request
*/
const get = async <T = any>(endpoint: string, options: any = {}): Promise<T> => {
try {
const response = await $fetch<APIResponse<T>>(`${baseURL}${endpoint}`, {
method: 'GET',
...options,
});
if (response.success && response.data !== undefined) {
return response.data;
}
throw new Error(response.message || 'Failed to fetch data');
} catch (error: any) {
console.error(`❌ Error GET ${endpoint}:`, error);
throw error;
}
};
/**
* Generic POST request
*/
const post = async <T = any>(endpoint: string, body: any, options: any = {}): Promise<T> => {
try {
const response = await $fetch<APIResponse<T>>(`${baseURL}${endpoint}`, {
method: 'POST',
body,
...options,
});
if (response.success && response.data !== undefined) {
return response.data;
}
throw new Error(response.message || 'Failed to create data');
} catch (error: any) {
console.error(`❌ Error POST ${endpoint}:`, error);
throw error;
}
};
/**
* Generic PATCH request
*/
const patch = async <T = any>(endpoint: string, body: any, options: any = {}): Promise<T> => {
try {
const response = await $fetch<APIResponse<T>>(`${baseURL}${endpoint}`, {
method: 'PATCH',
body,
...options,
});
if (response.success && response.data !== undefined) {
return response.data;
}
throw new Error(response.message || 'Failed to update data');
} catch (error: any) {
console.error(`❌ Error PATCH ${endpoint}:`, error);
throw error;
}
};
/**
* Generic DELETE request
*/
const del = async <T = any>(endpoint: string, options: any = {}): Promise<T> => {
try {
const response = await $fetch<APIResponse<T>>(`${baseURL}${endpoint}`, {
method: 'DELETE',
...options,
});
if (response.success) {
return response.data as T;
}
throw new Error(response.message || 'Failed to delete data');
} catch (error: any) {
console.error(`❌ Error DELETE ${endpoint}:`, error);
throw error;
}
};
return {
get,
post,
patch,
delete: del,
};
};
+160
View File
@@ -0,0 +1,160 @@
// composables/useClinicAPI.ts
// Composable untuk API calls terkait klinik/poli
import { useAPI } from './useAPI';
export interface Clinic {
id: number;
kode: string;
name: string;
subtitle?: string;
icon?: string;
shift?: string;
schedule?: string;
available?: boolean;
doctors?: string[];
shifts?: Array<{ name: string; quota: number }>;
totalQuota?: number;
jamShiftList?: Array<{ dari: string; sampai: string; kuota: number }>;
autoShift?: boolean;
jadwalKlinik?: string[];
}
export const useClinicAPI = () => {
const api = useAPI();
/**
* Fetch all clinics from database
*/
const fetchAllClinics = async (): Promise<Clinic[]> => {
try {
return await api.get<Clinic[]>('/clinics');
} catch (error: any) {
console.error('❌ Error fetching clinics:', error);
throw error;
}
};
/**
* Fetch single clinic by ID
*/
const fetchClinic = async (id: number): Promise<Clinic | null> => {
try {
return await api.get<Clinic>(`/clinics/${id}`);
} catch (error: any) {
console.error('❌ Error fetching clinic:', error);
return null;
}
};
/**
* Fetch clinics by kode
*/
const fetchClinicByKode = async (kode: string): Promise<Clinic | null> => {
try {
return await api.get<Clinic>(`/clinics/kode/${kode}`);
} catch (error: any) {
console.error('❌ Error fetching clinic by kode:', error);
return null;
}
};
/**
* Create new clinic
*/
const createClinic = async (clinicData: Partial<Clinic>): Promise<Clinic> => {
try {
return await api.post<Clinic>('/clinics', clinicData);
} catch (error: any) {
console.error('❌ Error creating clinic:', error);
throw error;
}
};
/**
* Update clinic
*/
const updateClinic = async (id: number, updates: Partial<Clinic>): Promise<Clinic> => {
try {
return await api.patch<Clinic>(`/clinics/${id}`, updates);
} catch (error: any) {
console.error('❌ Error updating clinic:', error);
throw error;
}
};
/**
* Delete clinic
*/
const deleteClinic = async (id: number): Promise<boolean> => {
try {
await api.delete(`/clinics/${id}`);
return true;
} catch (error: any) {
console.error('❌ Error deleting clinic:', error);
return false;
}
};
/**
* Get clinics for dropdown (formatted for dropdown use)
*/
const fetchClinicsForDropdown = async (): Promise<Clinic[]> => {
try {
const clinics = await fetchAllClinics();
// Format sesuai kebutuhan dropdown
return clinics.map(clinic => ({
id: clinic.id,
name: clinic.name,
kode: clinic.kode,
icon: clinic.icon,
available: clinic.available ?? true,
}));
} catch (error: any) {
console.error('❌ Error fetching clinics for dropdown:', error);
return [];
}
};
/**
* Sync local clinics with database
*/
const syncWithDatabase = async (localClinics: Clinic[]): Promise<Clinic[]> => {
try {
const dbClinics = await fetchAllClinics();
// Merge strategy: prefer database data
const merged = new Map<number, Clinic>();
// Add database clinics
dbClinics.forEach(clinic => {
merged.set(clinic.id, clinic);
});
// Add local clinics that don't exist in DB
localClinics.forEach(localClinic => {
if (!merged.has(localClinic.id)) {
merged.set(localClinic.id, localClinic);
}
});
return Array.from(merged.values());
} catch (error: any) {
console.error('❌ Error syncing clinics with database:', error);
// Return local clinics as fallback
return localClinics;
}
};
return {
fetchAllClinics,
fetchClinic,
fetchClinicByKode,
createClinic,
updateClinic,
deleteClinic,
fetchClinicsForDropdown,
syncWithDatabase,
};
};
+193
View File
@@ -0,0 +1,193 @@
// composables/useQueueAPI.ts
// Composable untuk API calls terkait antrian pasien
export interface Patient {
no: number;
jamPanggil: string;
barcode: string;
noAntrian: string;
shift: string;
klinik: string;
fastTrack: string;
pembayaran: string;
status: 'waiting' | 'pending' | 'di-loket' | 'di-klinik' | 'selesai' | 'terlambat';
processStage: 'loket' | 'klinik' | 'penunjang';
createdAt: string;
registrationType?: 'online' | 'onsite';
visitType?: string;
visitDate?: string;
}
export interface QueueAPIResponse<T = any> {
success: boolean;
data?: T;
message?: string;
error?: string;
}
export const useQueueAPI = () => {
const config = useRuntimeConfig();
const baseURL = config.public.apiBaseUrl || '/api/queue';
/**
* Fetch all patients from database
*/
const fetchAllPatients = async (): Promise<Patient[]> => {
try {
const response = await $fetch<QueueAPIResponse<Patient[]>>(`${baseURL}/patients`, {
method: 'GET',
});
if (response.success && response.data) {
return response.data;
}
throw new Error(response.message || 'Failed to fetch patients');
} catch (error: any) {
console.error('❌ Error fetching patients:', error);
throw error;
}
};
/**
* Fetch single patient by ID or barcode
*/
const fetchPatient = async (idOrBarcode: string): Promise<Patient | null> => {
try {
const response = await $fetch<QueueAPIResponse<Patient>>(`${baseURL}/patients/${idOrBarcode}`, {
method: 'GET',
});
if (response.success && response.data) {
return response.data;
}
return null;
} catch (error: any) {
console.error('❌ Error fetching patient:', error);
return null;
}
};
/**
* Create new patient (register from Anjungan)
*/
const createPatient = async (patientData: Partial<Patient>): Promise<Patient> => {
try {
const response = await $fetch<QueueAPIResponse<Patient>>(`${baseURL}/patients`, {
method: 'POST',
body: patientData,
});
if (response.success && response.data) {
return response.data;
}
throw new Error(response.message || 'Failed to create patient');
} catch (error: any) {
console.error('❌ Error creating patient:', error);
throw error;
}
};
/**
* Update patient status (check-in, process, etc)
*/
const updatePatient = async (
idOrBarcode: string,
updates: Partial<Patient>
): Promise<Patient> => {
try {
const response = await $fetch<QueueAPIResponse<Patient>>(
`${baseURL}/patients/${idOrBarcode}`,
{
method: 'PATCH',
body: updates,
}
);
if (response.success && response.data) {
return response.data;
}
throw new Error(response.message || 'Failed to update patient');
} catch (error: any) {
console.error('❌ Error updating patient:', error);
throw error;
}
};
/**
* Check-in patient (update status to di-loket)
*/
const checkInPatient = async (idOrBarcode: string): Promise<Patient> => {
return updatePatient(idOrBarcode, { status: 'di-loket' });
};
/**
* Process patient at loket (update status and processStage)
*/
const processPatientAtLoket = async (
idOrBarcode: string,
updates: { status?: string; processStage?: string }
): Promise<Patient> => {
return updatePatient(idOrBarcode, updates);
};
/**
* Sync local state with database
*/
const syncWithDatabase = async (localPatients: Patient[]): Promise<Patient[]> => {
try {
// Fetch latest from database
const dbPatients = await fetchAllPatients();
// Merge strategy: prefer database data, but keep local if newer
const merged = new Map<string, Patient>();
// Add database patients
dbPatients.forEach(patient => {
merged.set(patient.barcode, patient);
});
// Add local patients that don't exist in DB or are newer
localPatients.forEach(localPatient => {
const existing = merged.get(localPatient.barcode);
if (!existing || new Date(localPatient.createdAt) > new Date(existing.createdAt)) {
merged.set(localPatient.barcode, localPatient);
}
});
return Array.from(merged.values());
} catch (error: any) {
console.error('❌ Error syncing with database:', error);
// Return local patients as fallback
return localPatients;
}
};
/**
* Batch sync: save multiple patients to database
*/
const batchSyncPatients = async (patients: Patient[]): Promise<boolean> => {
try {
const response = await $fetch<QueueAPIResponse>(`${baseURL}/patients/batch`, {
method: 'POST',
body: { patients },
});
return response.success || false;
} catch (error: any) {
console.error('❌ Error batch syncing patients:', error);
return false;
}
};
return {
fetchAllPatients,
fetchPatient,
createPatient,
updatePatient,
checkInPatient,
processPatientAtLoket,
syncWithDatabase,
batchSyncPatients,
};
};
File diff suppressed because it is too large Load diff
+2 -2
View File
@@ -48,7 +48,7 @@ export default defineNuxtConfig({
// @ts-expect-error
config.server.https = true;
// @ts-expect-error
config.server.host = '10.10.150.175';
config.server.host = '10.10.150.114';
// @ts-expect-error
config.server.port = 3001;
} catch (e) {
@@ -96,7 +96,7 @@ export default defineNuxtConfig({
devServer: {
port: 3001,
host: '10.10.150.175'
host: '10.10.150.114'
},
vite: {
+40
View File
@@ -28,6 +28,7 @@
"nuxt": "^3.17.7",
"nuxt-qrcode": "^0.4.8",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.7.1",
"qrcode": "^1.5.4",
"qrcode.vue": "^3.6.0",
"typescript": "^5.8.3",
@@ -39,6 +40,7 @@
"devDependencies": {
"@nuxtjs/google-fonts": "^3.2.0",
"@types/node": "^24.7.2",
"@vitejs/plugin-basic-ssl": "^2.1.0",
"sass": "^1.93.3",
"sass-embedded": "^1.89.2",
"vite-plugin-vuetify": "^2.1.2",
@@ -6085,6 +6087,19 @@
"@fortawesome/fontawesome-svg-core": "^7.0.0"
}
},
"node_modules/@vitejs/plugin-basic-ssl": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.0.tgz",
"integrity": "sha512-dOxxrhgyDIEUADhb/8OlV9JIqYLgos03YorAueTIeOUskLJSEsfwCByjbu98ctXitUN3znXKp0bYD/WHSudCeA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"peerDependencies": {
"vite": "^6.0.0 || ^7.0.0"
}
},
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.1.tgz",
@@ -13843,6 +13858,31 @@
}
}
},
"node_modules/pinia-plugin-persistedstate": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/pinia-plugin-persistedstate/-/pinia-plugin-persistedstate-4.7.1.tgz",
"integrity": "sha512-WHOqh2esDlR3eAaknPbqXrkkj0D24h8shrDPqysgCFR6ghqP/fpFfJmMPJp0gETHsvrh9YNNg6dQfo2OEtDnIQ==",
"license": "MIT",
"dependencies": {
"defu": "^6.1.4"
},
"peerDependencies": {
"@nuxt/kit": ">=3.0.0",
"@pinia/nuxt": ">=0.10.0",
"pinia": ">=3.0.0"
},
"peerDependenciesMeta": {
"@nuxt/kit": {
"optional": true
},
"@pinia/nuxt": {
"optional": true
},
"pinia": {
"optional": true
}
}
},
"node_modules/pkg-types": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
+2 -1
View File
@@ -6,7 +6,7 @@
"build": "nuxt build",
"_command_dev": "nuxt dev -o --host --port 3001",
"_command_dev2": "nuxt dev -o --port 3001",
"_command_dev3": "nuxt dev -o --host 10.10.150.175 --port 3001",
"_command_dev3": "nuxt dev -o --host 10.10.150.114 --port 3001",
"dev": "nuxt dev -o",
"dev:https": "nuxt dev -o --host localhost --port 3001",
"generate": "nuxt generate",
@@ -35,6 +35,7 @@
"nuxt": "^3.17.7",
"nuxt-qrcode": "^0.4.8",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.7.1",
"qrcode": "^1.5.4",
"qrcode.vue": "^3.6.0",
"typescript": "^5.8.3",
+12 -68
View File
@@ -23,6 +23,7 @@
@action="handlePatientAction"
@change-klinik="showChangeKlinikDialog = true"
@process-next="handleProcessNext"
@call="handleCallPatient"
/>
<!-- Queue Actions Card -->
@@ -76,38 +77,6 @@
<div class="section-label">DATA PASIEN</div>
<div class="filters">
<!-- Status Filter -->
<v-chip-group v-model="selectedStatus" mandatory class="status-filter">
<v-chip value="all" :class="{ 'active-chip': selectedStatus === 'all' }">
Semua ({{ allPatients.length }})
</v-chip>
<v-chip value="diloket" :class="{ 'active-chip': selectedStatus === 'diloket' }">
Di Klinik ({{ (diLoketPatients || []).length }})
</v-chip>
<v-chip value="terlambat" :class="{ 'active-chip': selectedStatus === 'terlambat' }">
Terlambat ({{ (terlambatPatients || []).length }})
</v-chip>
<v-chip value="pending" :class="{ 'active-chip': selectedStatus === 'pending' }">
Pending ({{ (pendingPatients || []).length }})
</v-chip>
</v-chip-group>
<!-- Fast Track Filter -->
<v-select
v-model="selectedFastTrack"
:items="fastTrackOptions"
label="Filter Fast Track"
density="compact"
hide-details
clearable
class="fast-track-filter"
variant="outlined"
>
<template #prepend-inner>
<v-icon size="20">mdi-flash</v-icon>
</template>
</v-select>
<!-- Search Field -->
<v-text-field
v-model="searchQuery"
@@ -130,6 +99,7 @@
:pending-count="(pendingPatients || []).length"
:status-labels="statusLabels"
:show-diproses="false"
:fast-track-options="fastTrackOptions"
@action="handleTableAction"
/>
</v-card-text>
@@ -297,6 +267,15 @@ const handleTableAction = (item, action) => {
const handleProcessNext = () => {
processNextQueue();
};
const handleCallPatient = () => {
// TODO: Integrate text-to-speech library here
// Example: speak(`Nomor antrian ${currentProcessingPatient.value?.noAntrian.split(" |")[0]}, silakan menuju ke loket`)
if (currentProcessingPatient.value) {
console.log('Calling patient:', currentProcessingPatient.value);
// Placeholder for text-to-speech integration
}
};
</script>
<style scoped lang="scss">
@@ -335,36 +314,6 @@ const handleProcessNext = () => {
flex-wrap: wrap;
}
.status-filter {
flex: 1;
min-width: 300px;
}
.status-filter .v-chip {
font-size: 12px;
font-weight: 600;
height: 32px;
border: 1px solid var(--color-neutral-500);
background: var(--color-neutral-100);
color: var(--color-neutral-600);
transition: all 0.2s ease;
}
.status-filter .v-chip:hover {
background: var(--color-neutral-300);
}
.status-filter .v-chip.active-chip {
background: var(--color-secondary-600);
color: var(--color-neutral-100);
border-color: var(--color-secondary-600);
}
.fast-track-filter {
min-width: 180px;
max-width: 220px;
}
.search-field {
max-width: 300px;
min-width: 250px;
@@ -406,12 +355,7 @@ const handleProcessNext = () => {
align-items: stretch;
}
.status-filter {
min-width: 100%;
}
.search-field,
.fast-track-filter {
.search-field {
max-width: 100%;
min-width: 100%;
}
+10
View File
@@ -22,6 +22,7 @@
@action="handlePatientAction"
@change-klinik="showChangeKlinikDialog = true"
@process-next="handleProcessNext"
@call="handleCallPatient"
/>
<!-- Queue Actions Card -->
@@ -278,6 +279,15 @@ const handleTableAction = (item, action) => {
const handleProcessNext = () => {
processNextQueue();
};
const handleCallPatient = () => {
// TODO: Integrate text-to-speech library here
// Example: speak(`Nomor antrian ${currentProcessingPatient.value?.noAntrian.split(" |")[0]}, silakan menuju ke loket`)
if (currentProcessingPatient.value) {
console.log('Calling patient:', currentProcessingPatient.value);
// Placeholder for text-to-speech integration
}
};
</script>
<style scoped lang="scss">
+454 -27
View File
@@ -121,8 +121,9 @@
<v-dialog
v-model="showVisitTypeDialog"
max-width="400"
max-width="900"
@click:outside="showVisitTypeDialog = false"
persistent
>
<v-card class="dialog-card">
<v-card-title class="dialog-header">
@@ -147,13 +148,67 @@
<v-divider class="my-4"></v-divider>
<div class="dialog-info">
<div class="doctor-list-section">
<p><strong>Dokter yang Tersedia:</strong></p>
<ul class="doctor-list">
<li v-for="(doctor, index) in dialogDoctors" :key="index">
{{ doctor }}
</li>
</ul>
<!-- Section Pemilihan Dokter untuk Pasien Eksekutif -->
<div v-if="isEksekutif" class="doctor-selection-section mb-4">
<p class="mb-3"><strong>Pilih Dokter:</strong></p>
<div class="doctor-buttons-container">
<div
v-for="(doctor, index) in dialogDoctors"
:key="index"
class="doctor-button-wrapper"
>
<v-btn
:class="{
'doctor-button': true,
'doctor-button-selected': selectedDoctor === doctor
}"
variant="outlined"
size="large"
block
@click="selectedDoctor = doctor"
>
<v-icon
v-if="selectedDoctor === doctor"
size="18"
color="success-600"
class="doctor-icon"
>
mdi-check-circle
</v-icon>
<v-icon
v-else
size="18"
color="neutral-600"
class="doctor-icon"
>
mdi-circle-outline
</v-icon>
<span class="doctor-name">{{ doctor }}</span>
</v-btn>
</div>
<p v-if="dialogDoctors.length === 0" class="text-error text-caption mt-2">
Tidak ada dokter tersedia untuk klinik ini
</p>
</div>
</div>
<!-- Section Info Dokter untuk Non-Eksekutif (hanya tampil) -->
<div v-else class="doctor-list-section">
<p class="mb-3"><strong>Dokter yang Tersedia:</strong></p>
<div class="doctor-info-grid">
<div
v-for="(doctor, index) in dialogDoctors"
:key="index"
class="doctor-info-item"
>
<div class="doctor-info-card">
<v-icon size="18" color="primary-600" class="doctor-info-icon">
mdi-doctor
</v-icon>
<span class="doctor-info-name">{{ doctor }}</span>
</div>
</div>
</div>
</div>
<div class="quota-section">
@@ -190,14 +245,17 @@
size="large"
block
@click="selectVisitType('SEKARANG')"
:disabled="isShift1Full(selectedClinic)"
:class="{ 'btn-disabled': isShift1Full(selectedClinic) }"
:disabled="isShift1Full(selectedClinic) || (isEksekutif && !selectedDoctor)"
:class="{ 'btn-disabled': isShift1Full(selectedClinic) || (isEksekutif && !selectedDoctor) }"
>
DAFTAR SEKARANG
</v-btn>
<p v-if="isShift1Full(selectedClinic)" class="disabled-hint">
Kuota hari ini habis
</p>
<p v-else-if="isEksekutif && !selectedDoctor" class="disabled-hint">
Pilih dokter terlebih dahulu
</p>
</v-col>
<v-col cols="6">
<v-btn
@@ -206,9 +264,14 @@
size="large"
block
@click="selectVisitType('JADWAL_LAIN')"
:disabled="isEksekutif && !selectedDoctor"
:class="{ 'btn-disabled': isEksekutif && !selectedDoctor }"
>
JADWAL LAIN
</v-btn>
<p v-if="isEksekutif && !selectedDoctor" class="disabled-hint">
Pilih dokter terlebih dahulu
</p>
</v-col>
</v-row>
</v-card-text>
@@ -267,9 +330,71 @@
</v-card>
</v-dialog>
<!-- Dialog Pemilihan Dokter untuk Pasien Eksekutif -->
<v-dialog
v-model="showDoctorSelectionDialog"
max-width="600"
@click:outside="showDoctorSelectionDialog = false"
persistent
>
<v-card class="dialog-card">
<v-card-title class="dialog-header">
<v-icon class="mr-2">mdi-doctor</v-icon>
Pilih Dokter
</v-card-title>
<v-divider />
<v-card-text class="pa-6" v-if="selectedClinic">
<div class="dialog-content">
<p class="mb-4">
<strong>Klinik:</strong> {{ selectedClinic.name }}
</p>
<v-select
v-model="selectedDoctor"
:items="dialogDoctors"
label="Pilih Dokter"
variant="outlined"
density="compact"
required
:rules="[v => !!v || 'Pilih dokter terlebih dahulu']"
class="mb-3"
>
<template v-slot:item="{ props, item }">
<v-list-item v-bind="props">
<v-list-item-title>{{ item.raw }}</v-list-item-title>
</v-list-item>
</template>
</v-select>
<p v-if="dialogDoctors.length === 0" class="text-error text-caption">
Tidak ada dokter tersedia untuk klinik ini
</p>
</div>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn
variant="outlined"
color="neutral-600"
@click="showDoctorSelectionDialog = false"
>
Batal
</v-btn>
<v-btn
color="success-600"
class="text-white"
variant="flat"
@click="confirmDoctorSelection"
:disabled="!selectedDoctor || dialogDoctors.length === 0"
>
Konfirmasi
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog
v-model="showBookingFormDialog"
max-width="500"
max-width="600"
@click:outside="showBookingFormDialog = false"
>
<v-card class="dialog-card">
@@ -309,7 +434,23 @@
variant="outlined"
density="compact"
required
class="mb-3"
></v-select>
<!-- Field dokter untuk pasien Eksekutif (sudah dipilih di dialog sebelumnya) -->
<v-text-field
v-if="isEksekutif && bookingForm.doctor"
:model-value="bookingForm.doctor"
label="Dokter yang Dipilih"
variant="outlined"
density="compact"
readonly
class="mb-3"
>
<template v-slot:prepend-inner>
<v-icon size="20" color="success-600">mdi-check-circle</v-icon>
</template>
</v-text-field>
</v-form>
</v-card-text>
<v-card-actions class="pa-4">
@@ -359,6 +500,7 @@ import { ref, computed, onMounted, nextTick } from "vue";
import { useRoute } from "#app";
import { useAnjunganStore } from "@/stores/anjunganStore";
import { useClinicStore } from "@/stores/clinicStore";
import { useQueueStore } from "@/stores/queueStore";
definePageMeta({
layout: false,
@@ -367,6 +509,7 @@ definePageMeta({
const route = useRoute();
const anjunganStore = useAnjunganStore();
const clinicStore = useClinicStore();
const queueStore = useQueueStore();
const anjunganId = computed(() => {
const raw = route.params.id;
@@ -642,7 +785,10 @@ const filteredClinics = computed(() => {
const showVisitTypeDialog = ref(false);
const showPaymentTypeDialog = ref(false);
const showBookingFormDialog = ref(false);
const showDoctorSelectionDialog = ref(false);
const selectedClinic = ref(null);
const selectedDoctor = ref(null);
const pendingPaymentType = ref(null);
const snackbar = ref(false);
const snackbarText = ref("");
const snackbarColor = ref("success");
@@ -651,6 +797,12 @@ const bookingForm = ref({
date: new Date().toISOString().substring(0, 10),
shift: "Shift 1",
payment: null,
doctor: null,
});
// Cek apakah jenis pasien adalah Eksekutif
const isEksekutif = computed(() => {
return anjunganData.value?.jenisPasien === 'Eksekutif';
});
// tampilkan doctor info dengan prioritas API
@@ -735,10 +887,18 @@ const selectClinic = (clinic) => {
if (!clinic || !clinic.available) return;
selectedClinic.value = clinic;
// Reset selected doctor ketika memilih klinik baru
selectedDoctor.value = null;
showVisitTypeDialog.value = true;
};
const selectVisitType = (type) => {
// Validasi: untuk pasien Eksekutif, dokter harus dipilih
if (isEksekutif.value && !selectedDoctor.value) {
showSnackbar("Mohon pilih dokter terlebih dahulu", "error");
return;
}
showVisitTypeDialog.value = false;
if (type === "SEKARANG") {
@@ -752,6 +912,7 @@ const selectVisitType = (type) => {
date: tomorrowStr,
shift: "",
payment: null,
doctor: isEksekutif.value ? selectedDoctor.value : null,
};
showBookingFormDialog.value = true;
}
@@ -759,9 +920,50 @@ const selectVisitType = (type) => {
const selectPaymentType = (paymentType) => {
showPaymentTypeDialog.value = false;
const message = `Pendaftaran **${selectedClinic.value.name}** untuk kunjungan **HARI INI** dengan pembayaran **${paymentType}** berhasil diproses.`;
showSnackbar(message, "success");
// Untuk pasien Eksekutif, dokter sudah dipilih di dialog sebelumnya
// Untuk Reguler/BPJS, tidak perlu dokter
const namaDokter = isEksekutif.value ? selectedDoctor.value : null;
registerPatient('SEKARANG', paymentType, namaDokter);
};
// Fungsi ini tidak lagi digunakan karena dokter dipilih di dialog konfirmasi
// Tapi tetap dipertahankan untuk kompatibilitas jika ada referensi lain
const confirmDoctorSelection = () => {
if (!selectedDoctor.value) {
showSnackbar("Mohon pilih dokter terlebih dahulu", "error");
return;
}
showDoctorSelectionDialog.value = false;
// Register dengan dokter yang dipilih untuk kunjungan hari ini
registerPatient('SEKARANG', pendingPaymentType.value, selectedDoctor.value);
};
const registerPatient = (visitType, paymentType, namaDokter) => {
// Register patient to queueStore
const result = queueStore.registerPatientFromAnjungan(
selectedClinic.value,
paymentType,
visitType,
null,
'Shift 1',
namaDokter
);
if (result.success) {
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");
} else {
showSnackbar(result.message || "Gagal melakukan pendaftaran", "error");
}
selectedClinic.value = null;
selectedDoctor.value = null;
pendingPaymentType.value = null;
};
const submitBooking = () => {
@@ -774,15 +976,47 @@ const submitBooking = () => {
return;
}
// Validasi dokter untuk pasien Eksekutif
if (isEksekutif.value && !bookingForm.value.doctor) {
showSnackbar("Mohon pilih dokter terlebih dahulu.", "error");
return;
}
showBookingFormDialog.value = false;
const message = `Jadwal kunjungan **${selectedClinic.value.name}** pada ${bookingForm.value.date} - ${bookingForm.value.shift} (**${bookingForm.value.payment}**) berhasil disimpan.`;
showSnackbar(message, "success");
// Register patient to queueStore for future visit
const paymentType = bookingForm.value.payment === 'BPJS' ? 'BPJS' : 'UMUM';
const namaDokter = isEksekutif.value ? bookingForm.value.doctor : null;
const result = queueStore.registerPatientFromAnjungan(
selectedClinic.value,
paymentType,
'JADWAL_LAIN',
bookingForm.value.date,
bookingForm.value.shift,
namaDokter
);
if (result.success) {
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");
} else {
showSnackbar(result.message || "Gagal menyimpan jadwal", "error");
}
selectedClinic.value = null;
selectedDoctor.value = null;
bookingForm.value.doctor = null;
};
const backToList = () => {
navigateTo("/anjungan/anjungan");
};
const navigateToCheckIn = () => {
navigateTo("/check-in-pasien/check-in");
};
</script>
<style scoped lang="scss">
@@ -1077,25 +1311,207 @@ const backToList = () => {
}
.doctor-list-section {
margin-bottom: 12px;
margin-bottom: 16px;
p {
margin-bottom: 8px;
margin-bottom: 12px;
font-size: 15px;
color: var(--color-neutral-800);
}
}
.doctor-list {
list-style: none;
padding: 0;
margin: 0;
.doctor-info-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 8px;
}
li {
padding: 6px 12px;
margin: 4px 0;
background: var(--color-primary-100);
border-radius: 6px;
.doctor-info-item {
width: 100%;
}
.doctor-info-card {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 10px;
background: var(--color-primary-100);
border-radius: 8px;
border: 1px solid var(--color-primary-300);
min-height: 50px;
}
.doctor-info-icon {
flex-shrink: 0;
}
.doctor-info-name {
flex: 1;
text-align: left;
font-size: 13px;
line-height: 1.3;
color: var(--color-neutral-800);
white-space: normal;
word-wrap: break-word;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
/* Responsive untuk layar lebih kecil - doctor info grid */
@media (max-width: 768px) {
.doctor-info-grid {
grid-template-columns: repeat(2, 1fr);
gap: 6px;
}
.doctor-info-card {
padding: 10px 8px;
min-height: 45px;
}
.doctor-info-name {
font-size: 12px;
}
}
@media (max-width: 480px) {
.doctor-info-grid {
grid-template-columns: repeat(2, 1fr);
gap: 6px;
}
.doctor-info-card {
padding: 10px 6px;
min-height: 45px;
}
.doctor-info-name {
font-size: 11px;
}
}
.doctor-selection-section {
margin-bottom: 16px;
p {
margin-bottom: 12px;
font-size: 15px;
color: var(--color-neutral-800);
font-size: 13px;
}
}
.doctor-buttons-container {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 8px;
}
.doctor-button-wrapper {
width: 100%;
}
.doctor-button {
justify-content: center !important;
text-transform: none !important;
text-align: center !important;
padding: 16px 10px !important;
border: 2px solid var(--color-neutral-500) !important;
background-color: var(--color-neutral-100) !important;
color: var(--color-neutral-800) !important;
font-weight: 500 !important;
font-size: 13px !important;
line-height: 1.3 !important;
border-radius: 8px !important;
transition: all 0.2s ease !important;
min-height: 90px !important;
height: auto !important;
flex-direction: column !important;
touch-action: manipulation !important;
-webkit-tap-highlight-color: transparent !important;
.v-btn__content {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 !important;
}
&:hover {
background-color: var(--color-primary-100) !important;
border-color: var(--color-primary-600) !important;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
&:active {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
}
.doctor-button-selected {
background-color: var(--color-success-200) !important;
border-color: var(--color-success-600) !important;
color: var(--color-success-800) !important;
font-weight: 600 !important;
box-shadow: 0 2px 8px rgba(0, 146, 98, 0.2) !important;
&:hover {
background-color: var(--color-success-300) !important;
border-color: var(--color-success-700) !important;
box-shadow: 0 3px 10px rgba(0, 146, 98, 0.3) !important;
}
}
.doctor-icon {
flex-shrink: 0;
}
.doctor-name {
width: 100%;
text-align: center;
white-space: normal;
word-wrap: break-word;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
/* Responsive untuk layar lebih kecil */
@media (max-width: 768px) {
.doctor-buttons-container {
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.doctor-button {
padding: 14px 8px !important;
font-size: 12px !important;
min-height: 85px !important;
}
}
@media (max-width: 480px) {
.doctor-buttons-container {
grid-template-columns: repeat(2, 1fr);
gap: 6px;
}
.doctor-button {
padding: 12px 6px !important;
font-size: 11px !important;
min-height: 80px !important;
}
}
@@ -1263,4 +1679,15 @@ const backToList = () => {
padding-right: 60px;
}
}
.navigation-footer {
border-radius: 12px;
border: 1px solid var(--color-neutral-500);
background: var(--color-neutral-100);
}
.navigation-footer .v-btn {
text-transform: none;
font-weight: 600;
}
</style>
File diff suppressed because it is too large Load diff
File diff suppressed because it is too large Load diff
File diff suppressed because it is too large Load diff
@@ -1,717 +0,0 @@
<template>
<v-app class="bg-modern">
<v-main class="no-overflow">
<v-container fluid class="no-scroll-container pa-2 pa-md-4">
<v-row align="start" justify="center" class="fill-height">
<v-col cols="12" sm="11" md="10" lg="8" xl="7" class="d-flex flex-column">
<!-- Main Card dengan Glassmorphism -->
<v-card class="main-card" elevation="0">
<!-- Header Minimalis -->
<div class="header-modern">
<div class="header-content">
<div class="icon-circle">
<v-icon size="36" color="white">mdi-hospital-building</v-icon>
</div>
<div class="header-text">
<h1 class="title-modern">Check-in Pasien</h1>
<p class="subtitle-modern">Sistem Antrean Rumah Sakit</p>
</div>
</div>
<!-- Tabs Minimalis -->
<v-tabs
v-model="tab"
align-tabs="center"
class="tabs-modern mt-3"
bg-color="transparent"
slider-color="#FB8C00"
height="40"
>
<v-tab value="scan" class="tab-modern">
<v-icon size="20" class="mr-2">mdi-qrcode-scan</v-icon>
<span>Scan QR</span>
</v-tab>
<v-tab value="manual" class="tab-modern">
<v-icon size="20" class="mr-2">mdi-keyboard</v-icon>
<span>Manual</span>
</v-tab>
<v-tab value="generate" class="tab-modern">
<v-icon size="20" class="mr-2">mdi-qrcode</v-icon>
<span>Generate QR</span>
</v-tab>
</v-tabs>
</div>
<v-card-text class="content-modern pa-4 pa-md-6">
<v-window v-model="tab">
<!-- Tab Scan QR -->
<v-window-item value="scan">
<QRScanTab
:is-scanning="isScanning"
:has-camera="hasCamera"
:camera-checking="cameraChecking"
:camera-ready="cameraReady"
:primary-color="primaryColor"
@start-scanning="startScanning"
@stop-scanning="stopScanning"
@test-camera="testCamera"
@open-history="openHistoryDialog"
@open-qr-history="openQRHistoryDialog"
/>
</v-window-item>
<!-- Tab Manual -->
<v-window-item value="manual">
<ManualInputTab
v-model="manualInput"
:primary-color="primaryColor"
:secondary-color="secondaryColor"
@submit="checkInManual"
@open-history="openHistoryDialog"
ref="manualInputTabRef"
/>
</v-window-item>
<!-- Tab Generate QR -->
<v-window-item value="generate">
<div class="tab-content">
<!-- Status Header -->
<div class="status-header mb-3">
<div class="status-icon-wrapper">
<v-icon :color="primaryColor" size="24">mdi-qrcode</v-icon>
</div>
<div class="status-text">
<h3 class="status-title">Generate QR Code untuk Testing</h3>
<p class="status-subtitle">Buat QR code yang bisa Anda scan di tab "Scan QR"</p>
</div>
</div>
<!-- Quick Preset Buttons -->
<div class="mb-3">
<p class="text-caption text-grey text-center mb-2">Quick Test QR Codes:</p>
<v-row dense>
<v-col cols="6">
<v-btn
variant="outlined"
color="success"
size="small"
@click="generateQuickQR(generateRandomPatientId(), 'ALLOWED')"
class="text-none"
block
>
<v-icon start size="16">mdi-check-circle</v-icon>
Test ALLOWED
</v-btn>
</v-col>
<v-col cols="6">
<v-btn
variant="outlined"
color="warning"
size="small"
@click="generateQuickQR(generateRandomPatientId(), 'NOT_ALLOWED')"
class="text-none"
block
>
<v-icon start size="16">mdi-clock-alert</v-icon>
Test NOT_ALLOWED
</v-btn>
</v-col>
</v-row>
</div>
<!-- Form Generate -->
<v-form @submit.prevent="generateQRCode">
<v-text-field
v-model="generatePatientId"
label="ID Pasien"
placeholder="Contoh: P-123456"
prepend-inner-icon="mdi-identifier"
variant="outlined"
:color="primaryColor"
class="input-modern mb-3"
density="comfortable"
clearable
hide-details="auto"
>
<template v-slot:append-inner>
<v-btn
icon
size="small"
variant="text"
@click="generateRandomId()"
title="Generate Random ID"
>
<v-icon size="20">mdi-refresh</v-icon>
</v-btn>
</template>
</v-text-field>
<v-select
v-model="generateStatus"
label="Status Check-in"
:items="statusOptions"
prepend-inner-icon="mdi-shield-check"
variant="outlined"
:color="primaryColor"
class="input-modern mb-4"
density="comfortable"
hide-details="auto"
></v-select>
<div class="d-flex justify-center">
<v-btn
class="btn-primary-modern btn-centered"
size="large"
type="submit"
elevation="0"
:disabled="!generatePatientId"
>
<v-icon start size="20">mdi-qrcode-plus</v-icon>
Generate QR Code
</v-btn>
</div>
</v-form>
<!-- QR Code Display -->
<div v-if="generatedQRData" class="qr-display mt-6">
<v-card variant="outlined" class="pa-4">
<div class="text-center">
<p class="text-subtitle-2 text-grey mb-3">QR Code Anda:</p>
<div id="qrcode" class="qr-code-container mb-4"></div>
<v-chip :color="generateStatus === 'ALLOWED' ? 'success' : 'warning'" class="mb-3">
<v-icon start>{{ generateStatus === 'ALLOWED' ? 'mdi-check' : 'mdi-clock-alert' }}</v-icon>
{{ generateStatus === 'ALLOWED' ? 'Diizinkan Check-in' : 'Belum Diizinkan' }}
</v-chip>
<p class="text-body-2 text-grey mb-4">
Data: {{ generatedQRData }}
</p>
<!-- Action Buttons -->
<v-row dense>
<v-col cols="4">
<v-btn
variant="outlined"
:color="primaryColor"
block
size="small"
@click="downloadQR"
class="text-none"
>
<v-icon start size="18">mdi-download</v-icon>
Download
</v-btn>
</v-col>
<v-col cols="4">
<v-btn
variant="outlined"
:color="primaryColor"
block
size="small"
@click="copyQRToClipboard"
class="text-none"
>
<v-icon start size="18">mdi-content-copy</v-icon>
Copy
</v-btn>
</v-col>
<v-col cols="4">
<v-btn
variant="outlined"
:color="primaryColor"
block
size="small"
@click="shareQR"
class="text-none"
>
<v-icon start size="18">mdi-share-variant</v-icon>
Share
</v-btn>
</v-col>
</v-row>
</div>
</v-card>
<!-- Instructions -->
<v-alert
type="success"
variant="tonal"
class="mt-4 text-body-2"
>
<template v-slot:prepend>
<v-icon>mdi-information</v-icon>
</template>
<strong>Cara menggunakan untuk Testing:</strong>
<ol class="ml-4 mt-2">
<li>Gunakan tombol <strong>"Test ALLOWED"</strong> atau <strong>"Test NOT_ALLOWED"</strong> untuk generate QR cepat, atau isi form manual</li>
<li>Klik <strong>"Download"</strong> untuk menyimpan QR code ke komputer</li>
<li>Buka file QR code yang didownload (bisa di HP atau layar lain)</li>
<li>Pindah ke tab <strong>"Scan QR"</strong> dan scan QR code tersebut</li>
<li>Atau gunakan <strong>"Copy"</strong> untuk menyalin QR ke clipboard dan paste di aplikasi lain</li>
</ol>
</v-alert>
</div>
</div>
</v-window-item>
</v-window>
</v-card-text>
</v-card>
<!-- Stats Footer Minimalis -->
<div class="stats-footer-modern mt-3">
<v-row dense>
<v-col cols="4">
<div class="stat-card-modern">
<div class="stat-icon-modern">
<v-icon :color="primaryColor" size="20">mdi-clock-outline</v-icon>
</div>
<div class="stat-value">{{ todayCount }}</div>
<div class="stat-label">Hari Ini</div>
</div>
</v-col>
<v-col cols="4">
<div class="stat-card-modern">
<div class="stat-icon-modern">
<v-icon color="success" size="20">mdi-check-circle</v-icon>
</div>
<div class="stat-value">{{ completedCount }}</div>
<div class="stat-label">Selesai</div>
</div>
</v-col>
<v-col cols="4">
<div class="stat-card-modern">
<div class="stat-icon-modern">
<v-icon :color="secondaryColor" size="20">mdi-account-group</v-icon>
</div>
<div class="stat-value">{{ pendingCount }}</div>
<div class="stat-label">Menunggu</div>
</div>
</v-col>
</v-row>
</div>
</v-col>
</v-row>
</v-container>
</v-main>
<!-- Check-in Result Dialog -->
<CheckInDialog
v-model="infoDialog"
:last-check-in-result="lastCheckInResult"
:info-message="infoMessage"
:info-action="infoAction"
:scanned-data="scannedData"
@close="handleInfoAction"
/>
<!-- Enhanced Snackbar -->
<v-snackbar
v-model="snackbarShow"
:color="snackbar.color"
:timeout="snackbar.timeout"
location="bottom right"
rounded="pill"
elevation="24"
class="custom-snackbar"
>
<div class="d-flex align-center">
<v-avatar :color="snackbar.color" size="32" class="mr-3">
<v-icon size="20" color="white">{{ snackbar.icon }}</v-icon>
</v-avatar>
<div>
<div class="font-weight-bold">{{ snackbar.title }}</div>
<div class="text-body-2">{{ snackbar.message }}</div>
</div>
</div>
</v-snackbar>
<!-- History Dialog -->
<HistoryDialog
v-model="historyDialog"
:search="historySearch"
:date-filter="historyDateFilter"
:status-filter="historyStatusFilter"
:filtered-history="filteredHistory"
:history="checkInHistory"
:status-options="historyStatusOptions"
:primary-color="primaryColor"
:get-status-color="getStatusColor"
:get-status-icon="getStatusIcon"
:get-status-text="getStatusText"
:get-status-class="getStatusClass"
:format-date-time="formatDateTime"
:format-date="formatDate"
@update:search="historySearch = $event"
@update:date-filter="historyDateFilter = $event"
@update:status-filter="historyStatusFilter = $event"
@delete-item="handleDeleteHistoryItem"
@clear="handleClearHistory"
/>
<!-- QR History Dialog -->
<QRHistoryDialog
v-model="qrHistoryDialog"
:search="historySearch"
:date-filter="historyDateFilter"
:filtered-history="filteredQRHistory"
:history="scannedQRHistory"
:primary-color="primaryColor"
@update:search="historySearch = $event"
@update:date-filter="historyDateFilter = $event"
@use-data="useQRData"
@delete-item="handleDeleteQRHistoryItem"
@clear="handleClearQRHistory"
/>
</v-app>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import type { TabValue, InfoAction, CheckInHistoryItem } from '~/types/checkin';
import { PRIMARY_COLOR, SECONDARY_COLOR, QR_STATUS_OPTIONS } from '~/constants/checkin';
import { useSnackbar } from '~/composables/useSnackbar';
import { useCheckInHistory } from '~/composables/useCheckInHistory';
import { useQRScanner } from '~/composables/useQRScanner';
import { useCheckIn } from '~/composables/useCheckIn';
import { useQRGenerator } from '~/composables/useQRGenerator';
import CheckInDialog from '~/components/checkin/CheckInDialog.vue';
import HistoryDialog from '~/components/checkin/HistoryDialog.vue';
import QRHistoryDialog from '~/components/checkin/QRHistoryDialog.vue';
import ManualInputTab from '~/components/checkin/ManualInputTab.vue';
import QRScanTab from '~/components/checkin/QRScanTab.vue';
definePageMeta({
middleware:['auth'],
layout: false,
})
// TypeScript declaration for QRCode
declare global {
interface Window {
QRCode: typeof import('qrcode').default;
}
}
// --- DESAIN & TEMA ---
const primaryColor = ref(PRIMARY_COLOR);
const secondaryColor = ref(SECONDARY_COLOR);
// --- LOGIKA ---
const tab = ref<TabValue>('scan');
const infoDialog = ref(false);
const infoMessage = ref('');
const infoAction = ref<InfoAction>('kembali');
const scannedData = ref<string | null>(null);
const manualInput = ref('');
const manualInputTabRef = ref<InstanceType<typeof ManualInputTab> | null>(null);
// lastCheckInResult sudah dipindahkan ke useCheckIn composable
// Timer untuk auto-close dialog
let dialogAutoCloseTimer: ReturnType<typeof setTimeout> | null = null;
// Fungsi untuk auto-close dialog setelah 15 detik
const autoCloseDialog = () => {
// Clear timer sebelumnya jika ada
if (dialogAutoCloseTimer) {
clearTimeout(dialogAutoCloseTimer);
}
// Set timer untuk menutup dialog setelah 15 detik
dialogAutoCloseTimer = setTimeout(() => {
if (infoDialog.value) {
infoDialog.value = false;
dialogAutoCloseTimer = null;
}
}, 15000); // 15 detik
};
// Snackbar composable (harus didefinisikan sebelum useQRScanner)
const { snackbar, snackbarShow, showSnackbar } = useSnackbar();
// History composable
const {
checkInHistory,
scannedQRHistory,
historySearch,
historyDateFilter,
historyStatusFilter,
filteredHistory,
filteredQRHistory,
loadHistory,
saveToHistory,
deleteHistoryItem,
clearHistory,
loadScannedQRHistory,
saveScannedQRData,
clearQRHistory,
deleteQRHistoryItem,
getStatusColor,
getStatusIcon,
getStatusText,
getStatusClass,
formatDateTime,
formatDate,
historyStatusOptions,
} = useCheckInHistory();
// Computed untuk stats footer
const todayCount = computed(() => {
const today = new Date().toDateString()
return checkInHistory.value.filter((item: CheckInHistoryItem) => {
const itemDate = new Date(item.checkInDate).toDateString()
return itemDate === today
}).length
})
const completedCount = computed(() => {
return checkInHistory.value.filter((item: CheckInHistoryItem) =>
item.status === 'success' || item.status === 'ALLOWED'
).length
})
const pendingCount = computed(() => {
return checkInHistory.value.filter((item: CheckInHistoryItem) =>
item.status === 'pending' || item.status === 'NOT_ALLOWED'
).length
})
// Check-in composable (didefinisikan dulu, saveSuccessfulScan akan di-set nanti)
let saveSuccessfulScanRef: ((qrData: string) => void) | null = null
const {
lastCheckInResult,
processQRCode,
checkInManual: checkInManualComposable,
} = useCheckIn({
showSnackbar,
saveToHistory,
saveSuccessfulScan: (qrData: string) => {
// Delegate ke saveSuccessfulScan dari useQRScanner
if (saveSuccessfulScanRef) {
saveSuccessfulScanRef(qrData)
}
},
onCheckInSuccess: (result) => {
infoMessage.value = result.message
infoAction.value = result.action
infoDialog.value = true
},
autoCloseDialog,
})
// QR Scanner composable (setelah useCheckIn agar bisa akses processQRCode)
const {
isScanning,
hasCamera,
cameraChecking,
cameraReady,
checkCameraAvailability,
testCamera,
startScanning,
stopScanning,
saveSuccessfulScan,
} = useQRScanner(
(decodedText: string) => {
// Callback ketika QR berhasil di-scan
scannedData.value = decodedText
processQRCode(decodedText)
// Simpan ke history scanned QR
saveScannedQRData(decodedText)
},
showSnackbar
)
// Set saveSuccessfulScanRef setelah useQRScanner didefinisikan
saveSuccessfulScanRef = saveSuccessfulScan
// History Dialog
const historyDialog = ref(false);
const qrHistoryDialog = ref(false);
// Generate random patient ID function (untuk digunakan di composable dan quick test buttons)
const generateRandomPatientId = () => {
// Generate random 6-digit number
const randomNum = Math.floor(100000 + Math.random() * 900000);
return `P-${randomNum}`;
};
// QR Generator composable
const {
generatePatientId,
generateStatus,
generatedQRData,
generateRandomId,
generateQuickQR,
generateQRCode,
downloadQR,
copyQRToClipboard,
shareQR,
} = useQRGenerator({
showSnackbar,
generateRandomPatientId,
});
const statusOptions = [...QR_STATUS_OPTIONS] as Array<{ title: string; value: string }>;
// Watch tab changes untuk stop scanner saat pindah tab
watch(tab, (newTab: TabValue) => {
if (newTab !== 'scan' && isScanning.value) {
stopScanning();
}
// Check camera when switching to scan tab
if (newTab === 'scan' && !hasCamera.value && !cameraChecking.value) {
checkCameraAvailability();
}
});
// Check camera on mount
onMounted(() => {
if (typeof window !== 'undefined' && typeof navigator !== 'undefined') {
if (navigator.mediaDevices && typeof navigator.mediaDevices.getUserMedia === 'function') {
checkCameraAvailability();
} else {
console.warn('MediaDevices API not available. Make sure you are using HTTPS or localhost.');
hasCamera.value = false;
cameraChecking.value = false;
showSnackbar('Warning', 'MediaDevices API tidak tersedia. Pastikan menggunakan HTTPS atau localhost.', 'warning', 'mdi-alert');
}
} else {
hasCamera.value = false;
cameraChecking.value = false;
}
});
// Cleanup saat component unmount
onUnmounted(() => {
if (isScanning.value) {
stopScanning();
}
// Clear dialog auto-close timer
if (dialogAutoCloseTimer) {
clearTimeout(dialogAutoCloseTimer);
dialogAutoCloseTimer = null;
}
});
// onDetect sudah dipindahkan ke useCheckIn composable sebagai processQRCode
const handleInfoAction = async () => {
// Clear timer jika dialog ditutup secara manual
if (dialogAutoCloseTimer) {
clearTimeout(dialogAutoCloseTimer);
dialogAutoCloseTimer = null;
}
infoDialog.value = false;
// Scanner tetap berjalan setelah dialog ditutup
// Ini memungkinkan user untuk segera memproses QR code berikutnya
// tanpa perlu memulai scanner lagi
};
// performCheckIn sudah dipindahkan ke useCheckIn composable
const checkInManual = async () => {
try {
if (!manualInput.value || !manualInput.value.trim()) {
showSnackbar('Error', 'Mohon isi nomor antrean atau ID pasien', 'error', 'mdi-alert');
return;
}
const patientId = manualInput.value.trim();
// Validate form if available
if (manualInputTabRef.value?.form) {
try {
const isValid = manualInputTabRef.value.form.validate();
if (!isValid) {
return;
}
} catch (e) {
// If validate fails, continue anyway
console.warn('Form validation error:', e);
}
}
await checkInManualComposable(
patientId,
() => {
// onSuccess callback
try {
manualInput.value = '';
if (manualInputTabRef.value?.form) {
try {
manualInputTabRef.value.form.reset();
} catch (e) {
// Ignore reset errors
console.warn('Form reset error:', e);
}
}
} catch (e) {
console.warn('Error in success callback:', e);
}
},
() => {
// onError callback - tidak perlu melakukan apa-apa karena error sudah ditangani di composable
}
);
} catch (error) {
console.error('Error in checkInManual:', error);
showSnackbar('Error', 'Terjadi kesalahan saat melakukan check-in. Silakan coba lagi.', 'error', 'mdi-alert');
}
};
// generateQRCode, generateQuickQR, downloadQR, copyQRToClipboard, shareQR sudah dipindahkan ke useQRGenerator composable
// History Functions - semua sudah dipindahkan ke useCheckInHistory composable
const openHistoryDialog = () => {
loadHistory();
historyDialog.value = true;
};
const openQRHistoryDialog = () => {
loadScannedQRHistory();
qrHistoryDialog.value = true;
};
const handleDeleteHistoryItem = (index: number) => {
deleteHistoryItem(index);
showSnackbar('Berhasil', 'Riwayat berhasil dihapus', 'success', 'mdi-check');
};
const handleClearHistory = () => {
clearHistory();
showSnackbar('Berhasil', 'Semua riwayat berhasil dihapus', 'success', 'mdi-check');
};
const handleDeleteQRHistoryItem = (index: number) => {
deleteQRHistoryItem(index);
showSnackbar('Berhasil', 'Riwayat QR scan berhasil dihapus', 'success', 'mdi-check');
};
const handleClearQRHistory = () => {
clearQRHistory();
showSnackbar('Berhasil', 'Semua riwayat QR scan berhasil dihapus', 'success', 'mdi-check');
};
const useQRData = (qrData: string) => {
qrHistoryDialog.value = false;
scannedData.value = qrData;
processQRCode(qrData);
};
// filteredHistory, filteredQRHistory, dan helper functions sudah dipindahkan ke useCheckInHistory composable
// loadSuccessfulScans sudah dipanggil di dalam useQRScanner composable saat initialization
</script>
<style scoped lang="scss">
@import '~/assets/scss/checkin/variables';
@import '~/assets/scss/checkin/components';
@import '~/assets/scss/checkin/dialogs';
</style>
+1 -1
View File
@@ -264,7 +264,7 @@ const headers = ref([
{ title: 'Actions', value: 'actions', sortable: false, width: 'auto' },
]);
const jenisPasienOptions = ['Reguler', 'BPJS'];
const jenisPasienOptions = ['Reguler', 'BPJS', 'Eksekutif'];
const anjunganItems = computed(() => anjunganStore.getAllAnjungan);
+8
View File
@@ -0,0 +1,8 @@
import { createPersistedState } from 'pinia-plugin-persistedstate'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.$pinia.use(createPersistedState({
storage: typeof window !== 'undefined' ? localStorage : undefined,
}))
})
+212
View File
@@ -0,0 +1,212 @@
// server/api/queue/patients.ts
// Contoh implementasi API endpoints untuk antrian pasien
// NOTE: Ini adalah contoh - sesuaikan dengan database dan framework yang digunakan
import type { H3Event } from 'h3'
// Mock database (ganti dengan database real)
const mockDB: any[] = []
interface Patient {
no: number
jamPanggil: string
barcode: string
noAntrian: string
shift: string
klinik: string
fastTrack: string
pembayaran: string
status: string
processStage: string
createdAt: string
registrationType?: string
visitType?: string
visitDate?: string
}
// GET /api/queue/patients - Get all patients
export default defineEventHandler(async (event: H3Event) => {
try {
// TODO: Replace with actual database query
// const patients = await db.query('SELECT * FROM patients ORDER BY created_at DESC')
// Mock response
const patients = mockDB
return {
success: true,
data: patients,
message: 'Patients fetched successfully',
}
} catch (error: any) {
throw createError({
statusCode: 500,
message: error.message || 'Failed to fetch patients',
})
}
})
// GET /api/queue/patients/:id - Get single patient
export default defineEventHandler(async (event: H3Event) => {
const idOrBarcode = getRouterParam(event, 'id')
try {
// TODO: Replace with actual database query
// const patient = await db.query('SELECT * FROM patients WHERE barcode = ? OR id = ?', [idOrBarcode, idOrBarcode])
const patient = mockDB.find(
(p: Patient) => p.barcode === idOrBarcode || p.no.toString() === idOrBarcode
)
if (!patient) {
throw createError({
statusCode: 404,
message: 'Patient not found',
})
}
return {
success: true,
data: patient,
}
} catch (error: any) {
if (error.statusCode) throw error
throw createError({
statusCode: 500,
message: error.message || 'Failed to fetch patient',
})
}
})
// POST /api/queue/patients - Create new patient
export default defineEventHandler(async (event: H3Event) => {
const body = await readBody(event)
try {
// Validate required fields
if (!body.clinic || !body.paymentType) {
throw createError({
statusCode: 400,
message: 'Missing required fields: clinic, paymentType',
})
}
// Generate patient data
const newPatient: Patient = {
no: mockDB.length + 1,
jamPanggil: new Date().toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' }),
barcode: `250811${String(Date.now()).slice(-6)}${String(mockDB.length + 1).padStart(3, '0')}`,
noAntrian: `UM${String(mockDB.length + 1).padStart(4, '0')} | Onsite - ${barcode}`,
shift: body.shift || 'Shift 1',
klinik: body.clinic,
fastTrack: 'TIDAK',
pembayaran: body.paymentType,
status: body.visitType === 'SEKARANG' ? 'waiting' : 'pending',
processStage: 'loket',
createdAt: new Date().toISOString(),
registrationType: 'onsite',
visitType: body.visitType || 'SEKARANG',
visitDate: body.visitDate || new Date().toISOString().substring(0, 10),
}
// TODO: Insert to database
// await db.query('INSERT INTO patients SET ?', newPatient)
mockDB.push(newPatient)
return {
success: true,
data: newPatient,
message: 'Patient created successfully',
}
} catch (error: any) {
if (error.statusCode) throw error
throw createError({
statusCode: 500,
message: error.message || 'Failed to create patient',
})
}
})
// PATCH /api/queue/patients/:id - Update patient
export default defineEventHandler(async (event: H3Event) => {
const idOrBarcode = getRouterParam(event, 'id')
const body = await readBody(event)
try {
// TODO: Replace with actual database query
// const patient = await db.query('SELECT * FROM patients WHERE barcode = ? OR id = ?', [idOrBarcode, idOrBarcode])
const patientIndex = mockDB.findIndex(
(p: Patient) => p.barcode === idOrBarcode || p.no.toString() === idOrBarcode
)
if (patientIndex === -1) {
throw createError({
statusCode: 404,
message: 'Patient not found',
})
}
// Update patient
const updatedPatient = {
...mockDB[patientIndex],
...body,
updatedAt: new Date().toISOString(),
}
// TODO: Update in database
// await db.query('UPDATE patients SET ? WHERE barcode = ?', [body, idOrBarcode])
mockDB[patientIndex] = updatedPatient
return {
success: true,
data: updatedPatient,
message: 'Patient updated successfully',
}
} catch (error: any) {
if (error.statusCode) throw error
throw createError({
statusCode: 500,
message: error.message || 'Failed to update patient',
})
}
})
// POST /api/queue/patients/batch - Batch sync
export default defineEventHandler(async (event: H3Event) => {
const body = await readBody(event)
const { patients } = body
try {
if (!Array.isArray(patients)) {
throw createError({
statusCode: 400,
message: 'patients must be an array',
})
}
// TODO: Batch insert/update to database
// await db.query('INSERT INTO patients VALUES ? ON DUPLICATE KEY UPDATE ...', [patients])
patients.forEach((patient: Patient) => {
const index = mockDB.findIndex((p: Patient) => p.barcode === patient.barcode)
if (index === -1) {
mockDB.push(patient)
} else {
mockDB[index] = patient
}
})
return {
success: true,
message: `Synced ${patients.length} patients`,
}
} catch (error: any) {
if (error.statusCode) throw error
throw createError({
statusCode: 500,
message: error.message || 'Failed to batch sync',
})
}
})
+6
View File
@@ -83,5 +83,11 @@ export const useAnjunganStore = defineStore('anjungan', () => {
updateAnjungan,
deleteAnjungan,
};
}, {
persist: {
key: 'anjungan-store-state',
storage: typeof window !== 'undefined' ? localStorage : undefined,
paths: ['anjunganItems'],
},
});
+173 -25
View File
@@ -3,7 +3,8 @@ import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useClinicStore = defineStore('clinic', () => {
// Data clinics dari Anjungan.vue
// Data clinics - Single source of truth untuk semua data klinik
// Includes basic info (name, kode, icon, doctors, shifts) + master config (totalQuota, jamShiftList, jadwalKlinik, autoShift)
const clinics = ref([
{
id: 1,
@@ -18,7 +19,12 @@ export const useClinicStore = defineStore('clinic', () => {
shifts: [
{ name: "Shift 1", quota: 15 },
{ name: "Shift 2", quota: 20 }
]
],
totalQuota: 1000,
jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 1000 }],
autoShift: false,
jadwalKlinik: ['Senin', 'Rabu', 'Jumat'],
},
{
id: 2,
@@ -33,7 +39,15 @@ export const useClinicStore = defineStore('clinic', () => {
shifts: [
{ name: "Shift 1", quota: 0 },
{ name: "Shift 2", quota: 12 }
]
],
// Master config
totalQuota: 1500,
jamShiftList: [
{ dari: '07:00', sampai: '11:00', kuota: 1000 },
{ dari: '13:00', sampai: '16:00', kuota: 500 }
],
autoShift: false,
jadwalKlinik: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at'],
},
{
id: 3,
@@ -48,7 +62,12 @@ export const useClinicStore = defineStore('clinic', () => {
shifts: [
{ name: "Shift 1", quota: 8 },
{ name: "Shift 2", quota: 10 }
]
],
// Master config
totalQuota: 800,
jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 800 }],
autoShift: false,
jadwalKlinik: ['Senin', 'Rabu', 'Jumat'],
},
{
id: 4,
@@ -63,7 +82,12 @@ export const useClinicStore = defineStore('clinic', () => {
shifts: [
{ name: "Shift 1", quota: 10 },
{ name: "Shift 2", quota: 8 }
]
],
// Master config
totalQuota: 600,
jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 600 }],
autoShift: false,
jadwalKlinik: ['Senin', 'Rabu'],
},
{
id: 5,
@@ -78,7 +102,16 @@ export const useClinicStore = defineStore('clinic', () => {
shifts: [
{ name: "Shift 1", quota: 18 },
{ name: "Shift 2", quota: 15 }
]
],
// Master config
totalQuota: 2100,
jamShiftList: [
{ dari: '07:00', sampai: '11:00', kuota: 1000 },
{ dari: '13:00', sampai: '16:00', kuota: 700 },
{ dari: '18:00', sampai: '20:00', kuota: 400 }
],
autoShift: true,
jadwalKlinik: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at'],
},
{
id: 6,
@@ -93,7 +126,12 @@ export const useClinicStore = defineStore('clinic', () => {
shifts: [
{ name: "Shift 1", quota: 12 },
{ name: "Shift 2", quota: 10 }
]
],
// Master config
totalQuota: 500,
jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 500 }],
autoShift: false,
jadwalKlinik: ['Senin', 'Rabu', 'Jumat'],
},
{
id: 7,
@@ -105,7 +143,12 @@ export const useClinicStore = defineStore('clinic', () => {
schedule: "",
available: false,
doctors: [],
shifts: []
shifts: [],
// Master config
totalQuota: 750,
jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 750 }],
autoShift: false,
jadwalKlinik: ['Senin', 'Selasa', 'Rabu'],
},
{
id: 8,
@@ -120,7 +163,12 @@ export const useClinicStore = defineStore('clinic', () => {
shifts: [
{ name: "Shift 1", quota: 25 },
{ name: "Shift 2", quota: 20 }
]
],
// Master config
totalQuota: 900,
jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 900 }],
autoShift: false,
jadwalKlinik: ['Senin', 'Rabu', 'Jumat'],
},
{
id: 9,
@@ -135,7 +183,15 @@ export const useClinicStore = defineStore('clinic', () => {
shifts: [
{ name: "Shift 1", quota: 14 },
{ name: "Shift 2", quota: 12 }
]
],
// Master config
totalQuota: 1200,
jamShiftList: [
{ dari: '07:00', sampai: '11:00', kuota: 800 },
{ dari: '13:00', sampai: '16:00', kuota: 400 }
],
autoShift: false,
jadwalKlinik: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at'],
},
{
id: 10,
@@ -150,7 +206,12 @@ export const useClinicStore = defineStore('clinic', () => {
shifts: [
{ name: "Shift 1", quota: 10 },
{ name: "Shift 2", quota: 8 }
]
],
// Master config
totalQuota: 700,
jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 700 }],
autoShift: false,
jadwalKlinik: ['Senin', 'Rabu'],
},
{
id: 11,
@@ -165,7 +226,15 @@ export const useClinicStore = defineStore('clinic', () => {
shifts: [
{ name: "Shift 1", quota: 16 },
{ name: "Shift 2", quota: 14 }
]
],
// Master config
totalQuota: 1000,
jamShiftList: [
{ dari: '07:00', sampai: '11:00', kuota: 600 },
{ dari: '13:00', sampai: '16:00', kuota: 400 }
],
autoShift: false,
jadwalKlinik: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at'],
},
{
id: 12,
@@ -180,7 +249,12 @@ export const useClinicStore = defineStore('clinic', () => {
shifts: [
{ name: "Shift 1", quota: 8 },
{ name: "Shift 2", quota: 6 }
]
],
// Master config (default values, not in masterStore)
totalQuota: 0,
jamShiftList: [],
autoShift: false,
jadwalKlinik: [],
},
{
id: 13,
@@ -195,7 +269,12 @@ export const useClinicStore = defineStore('clinic', () => {
shifts: [
{ name: "Shift 1", quota: 10 },
{ name: "Shift 2", quota: 8 }
]
],
// Master config (default values, not in masterStore)
totalQuota: 0,
jamShiftList: [],
autoShift: false,
jadwalKlinik: [],
},
{
id: 14,
@@ -210,7 +289,12 @@ export const useClinicStore = defineStore('clinic', () => {
shifts: [
{ name: "Shift 1", quota: 12 },
{ name: "Shift 2", quota: 10 }
]
],
// Master config
totalQuota: 600,
jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 600 }],
autoShift: false,
jadwalKlinik: ['Selasa', 'Kamis'],
},
{
id: 15,
@@ -225,7 +309,12 @@ export const useClinicStore = defineStore('clinic', () => {
shifts: [
{ name: "Shift 1", quota: 15 },
{ name: "Shift 2", quota: 12 }
]
],
// Master config
totalQuota: 800,
jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 800 }],
autoShift: false,
jadwalKlinik: ['Senin', 'Rabu', 'Jumat'],
},
{
id: 16,
@@ -240,7 +329,12 @@ export const useClinicStore = defineStore('clinic', () => {
shifts: [
{ name: "Shift 1", quota: 20 },
{ name: "Shift 2", quota: 15 }
]
],
// Master config (default values, not in masterStore)
totalQuota: 0,
jamShiftList: [],
autoShift: false,
jadwalKlinik: [],
},
{
id: 17,
@@ -255,7 +349,12 @@ export const useClinicStore = defineStore('clinic', () => {
shifts: [
{ name: "Shift 1", quota: 10 },
{ name: "Shift 2", quota: 8 }
]
],
// Master config (default values, not in masterStore)
totalQuota: 0,
jamShiftList: [],
autoShift: false,
jadwalKlinik: [],
},
{
id: 18,
@@ -270,7 +369,12 @@ export const useClinicStore = defineStore('clinic', () => {
shifts: [
{ name: "Shift 1", quota: 14 },
{ name: "Shift 2", quota: 12 }
]
],
// Master config
totalQuota: 700,
jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 700 }],
autoShift: false,
jadwalKlinik: ['Senin', 'Rabu'],
},
{
id: 19,
@@ -285,7 +389,12 @@ export const useClinicStore = defineStore('clinic', () => {
shifts: [
{ name: "Shift 1", quota: 12 },
{ name: "Shift 2", quota: 10 }
]
],
// Master config (default values, not in masterStore)
totalQuota: 0,
jamShiftList: [],
autoShift: false,
jadwalKlinik: [],
},
{
id: 20,
@@ -300,7 +409,12 @@ export const useClinicStore = defineStore('clinic', () => {
shifts: [
{ name: "Shift 1", quota: 8 },
{ name: "Shift 2", quota: 6 }
]
],
// Master config
totalQuota: 500,
jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 500 }],
autoShift: false,
jadwalKlinik: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at'],
},
{
id: 21,
@@ -315,7 +429,12 @@ export const useClinicStore = defineStore('clinic', () => {
shifts: [
{ name: "Shift 1", quota: 16 },
{ name: "Shift 2", quota: 14 }
]
],
// Master config
totalQuota: 600,
jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 600 }],
autoShift: false,
jadwalKlinik: ['Senin', 'Rabu', 'Jumat'],
},
{
id: 22,
@@ -327,7 +446,12 @@ export const useClinicStore = defineStore('clinic', () => {
schedule: "Mulai Pukul 07:00",
available: false,
doctors: [],
shifts: []
shifts: [],
// Master config
totalQuota: 700,
jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 700 }],
autoShift: false,
jadwalKlinik: ['Senin', 'Rabu'],
},
{
id: 23,
@@ -342,7 +466,12 @@ export const useClinicStore = defineStore('clinic', () => {
shifts: [
{ name: "Shift 1", quota: 12 },
{ name: "Shift 2", quota: 10 }
]
],
// Master config (default values, not in masterStore)
totalQuota: 0,
jamShiftList: [],
autoShift: false,
jadwalKlinik: [],
},
]);
@@ -371,12 +500,31 @@ export const useClinicStore = defineStore('clinic', () => {
}));
};
// Actions untuk update master config (sync dengan masterStore operations)
const updateClinicMasterConfig = (kode, masterConfig) => {
const clinic = clinics.value.find(c => c.kode === kode);
if (clinic) {
clinic.totalQuota = masterConfig.totalQuota || clinic.totalQuota;
clinic.jamShiftList = masterConfig.jamShiftList || clinic.jamShiftList;
clinic.autoShift = masterConfig.autoShift !== undefined ? masterConfig.autoShift : clinic.autoShift;
clinic.jadwalKlinik = masterConfig.jadwalKlinik || clinic.jadwalKlinik;
return { success: true, clinic };
}
return { success: false, message: 'Clinic not found' };
};
return {
clinics,
getAllClinics,
getClinicByName,
getClinicByKode,
getClinicsForDropdown,
updateClinicMasterConfig,
};
}, {
persist: {
key: 'clinic-store-state',
storage: typeof window !== 'undefined' ? localStorage : undefined,
paths: ['clinics'],
},
});
+6
View File
@@ -276,4 +276,10 @@ export const useKlinikRuangStore = defineStore('klinikRuang', () => {
removeRuangFromKlinik,
searchKlinikRuang,
};
}, {
persist: {
key: 'klinikruang-store-state',
storage: typeof window !== 'undefined' ? localStorage : undefined,
paths: ['klinikRuangList'],
},
});
+125
View File
@@ -0,0 +1,125 @@
// stores/loketStore.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { useClinicStore } from './clinicStore';
export const useLoketStore = defineStore('loket', () => {
const clinicStore = useClinicStore();
// State - Loket Data
const loketData = ref([
{
id: 1,
no: 1,
namaLoket: "Loket 1",
kuota: 500,
pelayanan: ["RT", "RM", "TD"],
pembayaran: "JKN",
keterangan: "ONLINE",
statusPelayanan: "RAWAT JALAN",
},
{
id: 2,
no: 2,
namaLoket: "Loket 2",
kuota: 666,
pelayanan: ["JW", "SR"],
pembayaran: "JKN",
keterangan: "ONLINE",
statusPelayanan: "RAWAT JALAN",
},
{
id: 3,
no: 3,
namaLoket: "Loket 3",
kuota: 666,
pelayanan: ["AS", "JT"],
pembayaran: "JKN",
keterangan: "ONLINE",
statusPelayanan: "RAWAT JALAN",
},
{
id: 4,
no: 4,
namaLoket: "Loket 4",
kuota: 3676,
pelayanan: ["KK", "PR"],
pembayaran: "JKN",
keterangan: "ONLINE",
statusPelayanan: "RAWAT JALAN",
},
]);
// Computed - Available services (reference dari clinicStore)
const availableServices = computed(() => {
const clinics = clinicStore.getAllClinics.value || [];
return clinics.map(c => ({
id: c.kode,
nama: c.name,
kode: c.kode,
}));
});
// Actions - CRUD Operations
const addLoket = (loketPayload) => {
const newId = Math.max(...loketData.value.map(l => l.id), 0) + 1;
const newNo = loketData.value.length + 1;
const newLoket = {
id: newId,
no: newNo,
...loketPayload,
};
loketData.value.push(newLoket);
return { success: true, message: `Loket ${newLoket.namaLoket} berhasil ditambahkan`, data: newLoket };
};
const updateLoket = (loketPayload) => {
const index = loketData.value.findIndex(l => l.id === loketPayload.id);
if (index !== -1) {
loketData.value[index] = {
...loketData.value[index],
...loketPayload,
};
return { success: true, message: `Loket ${loketPayload.namaLoket} berhasil diupdate` };
}
return { success: false, message: 'Loket tidak ditemukan' };
};
const deleteLoket = (loketId) => {
const index = loketData.value.findIndex(l => l.id === loketId);
if (index !== -1) {
const loketName = loketData.value[index].namaLoket;
loketData.value.splice(index, 1);
loketData.value.forEach((l, idx) => {
l.no = idx + 1;
});
return { success: true, message: `Loket ${loketName} berhasil dihapus` };
}
return { success: false, message: 'Loket tidak ditemukan' };
};
const getLoketById = (id) => {
return loketData.value.find(l => l.id === id);
};
return {
// State
loketData,
availableServices,
// Actions
addLoket,
updateLoket,
deleteLoket,
getLoketById,
};
}, {
persist: {
key: 'loket-store-state',
storage: typeof window !== 'undefined' ? localStorage : undefined,
paths: ['loketData'],
},
});
+124 -581
View File
@@ -1,85 +1,44 @@
// stores/masterStore.js - Integrated dengan Penunjang
// stores/masterStore.js - Backward Compatibility Layer
// NOTE: Phase 3 - masterStore sekarang hanya sebagai compatibility layer
// Data sebenarnya ada di: loketStore, ruangStore, penunjangStore, clinicStore
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { computed } from 'vue';
import { useClinicStore } from './clinicStore';
import { useLoketStore } from './loketStore';
import { useRuangStore } from './ruangStore';
import { usePenunjangStore } from './penunjangStore';
export const useMasterStore = defineStore('master', () => {
// Store referensi utama daftar klinik (single source of truth)
// Store references
const clinicStore = useClinicStore();
const loketStore = useLoketStore();
const ruangStore = useRuangStore();
const penunjangStore = usePenunjangStore();
// ============================================
// MASTER KLINIK
// MASTER KLINIK (Backward Compatibility)
// ============================================
const klinikData = ref([
{
id: 1,
no: 1,
kode: 'AN',
nama: 'ANAK',
shift: 1,
totalQuota: 1000,
jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 1000 }],
autoShift: false,
jadwalKlinik: ['Senin', 'Rabu', 'Jumat'],
},
{
id: 2,
no: 2,
kode: 'AS',
nama: 'ANESTESI',
shift: 2,
totalQuota: 1500,
jamShiftList: [
{ dari: '07:00', sampai: '11:00', kuota: 1000 },
{ dari: '13:00', sampai: '16:00', kuota: 500 }
],
autoShift: false,
jadwalKlinik: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at'],
},
{
id: 3,
no: 3,
kode: 'BD',
nama: 'BEDAH',
shift: 1,
totalQuota: 800,
jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 800 }],
autoShift: false,
jadwalKlinik: ['Senin', 'Rabu', 'Jumat'],
},
{
id: 4,
no: 4,
kode: 'GI',
nama: 'GIGI DAN MULUT',
shift: 3,
totalQuota: 2100,
jamShiftList: [
{ dari: '07:00', sampai: '11:00', kuota: 1000 },
{ dari: '13:00', sampai: '16:00', kuota: 700 },
{ dari: '18:00', sampai: '20:00', kuota: 400 }
],
autoShift: true,
jadwalKlinik: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at'],
},
{ id: 5, no: 5, kode: 'GR', nama: 'GERIATRI', shift: 1, totalQuota: 600, jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 600 }], autoShift: false, jadwalKlinik: ['Senin', 'Rabu'] },
{ id: 6, no: 6, kode: 'GZ', nama: 'GIZI', shift: 1, totalQuota: 500, jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 500 }], autoShift: false, jadwalKlinik: ['Senin', 'Rabu', 'Jumat'] },
{ id: 7, no: 7, kode: 'HO', nama: 'HOM', shift: 1, totalQuota: 750, jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 750 }], autoShift: false, jadwalKlinik: ['Senin', 'Selasa', 'Rabu'] },
{ id: 8, no: 8, kode: 'IP', nama: 'IPD', shift: 1, totalQuota: 900, jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 900 }], autoShift: false, jadwalKlinik: ['Senin', 'Rabu', 'Jumat'] },
{ id: 9, no: 9, kode: 'JT', nama: 'JANTUNG', shift: 2, totalQuota: 1200, jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 800 }, { dari: '13:00', sampai: '16:00', kuota: 400 }], autoShift: false, jadwalKlinik: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at'] },
{ id: 10, no: 10, kode: 'JW', nama: 'JIWA', shift: 1, totalQuota: 700, jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 700 }], autoShift: false, jadwalKlinik: ['Senin', 'Rabu'] },
{ id: 11, no: 11, kode: 'KK', nama: 'KULIT KELAMIN', shift: 1, totalQuota: 600, jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 600 }], autoShift: false, jadwalKlinik: ['Selasa', 'Kamis'] },
{ id: 12, no: 12, kode: 'MT', nama: 'MATA', shift: 1, totalQuota: 800, jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 800 }], autoShift: false, jadwalKlinik: ['Senin', 'Rabu', 'Jumat'] },
{ id: 13, no: 13, kode: 'OB', nama: 'KANDUNGAN', shift: 2, totalQuota: 1000, jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 600 }, { dari: '13:00', sampai: '16:00', kuota: 400 }], autoShift: false, jadwalKlinik: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at'] },
{ id: 14, no: 14, kode: 'PR', nama: 'PARU', shift: 1, totalQuota: 700, jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 700 }], autoShift: false, jadwalKlinik: ['Senin', 'Rabu'] },
{ id: 15, no: 15, kode: 'RT', nama: 'RADIOTERAPI', shift: 1, totalQuota: 500, jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 500 }], autoShift: false, jadwalKlinik: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at'] },
{ id: 16, no: 16, kode: 'RM', nama: 'REHAB MEDIK', shift: 1, totalQuota: 600, jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 600 }], autoShift: false, jadwalKlinik: ['Senin', 'Rabu', 'Jumat'] },
{ id: 17, no: 17, kode: 'SR', nama: 'SARAF', shift: 1, totalQuota: 700, jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 700 }], autoShift: false, jadwalKlinik: ['Senin', 'Rabu'] },
]);
// NOTE: klinikData sekarang adalah computed yang reference clinicStore (single source of truth)
// Ini untuk backward compatibility dengan pages yang masih menggunakan masterStore.klinikData
// Format: { id, no, kode, nama, shift, totalQuota, jamShiftList, autoShift, jadwalKlinik }
const klinikData = computed(() => {
return clinicStore.clinics.map((clinic, index) => {
// Convert clinic data ke format masterStore (untuk backward compatibility)
return {
id: clinic.id,
no: index + 1,
kode: clinic.kode,
nama: clinic.name, // Convert 'name' to 'nama' for compatibility
shift: typeof clinic.shift === 'string' ? parseInt(clinic.shift.replace(/[^0-9]/g, '')) || 1 : clinic.shift || 1,
totalQuota: clinic.totalQuota || 0,
jamShiftList: clinic.jamShiftList || [],
autoShift: clinic.autoShift || false,
jadwalKlinik: clinic.jadwalKlinik || [],
};
});
});
// Computed - Get klinik list for dropdowns
// Sumber data kode & nama klinik diambil dari clinicStore (1 pintu),
// lalu difilter hanya untuk kode yang terdaftar di master klinik ini.
const klinikList = computed(() => {
const availableCodes = new Set(klinikData.value.map((k) => k.kode));
@@ -97,551 +56,132 @@ export const useMasterStore = defineStore('master', () => {
}));
});
// Actions - Klinik
// Actions - Klinik (delegate to clinicStore)
const addKlinik = (klinikPayload) => {
const newId = Math.max(...klinikData.value.map(k => k.id), 0) + 1;
const newNo = klinikData.value.length + 1;
const totalQuota = klinikPayload.jamShiftList.reduce((total, shift) => {
return total + (parseInt(shift.kuota) || 0);
}, 0);
const newKlinik = {
id: newId,
no: newNo,
// Map masterStore format ke clinicStore format
const clinicPayload = {
...klinikPayload,
totalQuota: totalQuota,
name: klinikPayload.nama, // Convert 'nama' to 'name'
shift: `Shift ${klinikPayload.shift}`, // Convert number to string format
};
klinikData.value.push(newKlinik);
return { success: true, message: `Klinik ${newKlinik.nama} berhasil ditambahkan`, data: newKlinik };
return clinicStore.addClinic(clinicPayload);
};
const updateKlinik = (klinikPayload) => {
const index = klinikData.value.findIndex(k => k.id === klinikPayload.id);
if (index !== -1) {
const totalQuota = klinikPayload.jamShiftList.reduce((total, shift) => {
return total + (parseInt(shift.kuota) || 0);
}, 0);
klinikData.value[index] = {
...klinikData.value[index],
...klinikPayload,
totalQuota: totalQuota,
};
return { success: true, message: `Klinik ${klinikPayload.nama} berhasil diupdate` };
}
return { success: false, message: 'Klinik tidak ditemukan' };
// Map masterStore format ke clinicStore format
const clinicUpdates = {
...klinikPayload,
name: klinikPayload.nama, // Convert 'nama' to 'name'
shift: `Shift ${klinikPayload.shift}`, // Convert number to string format
};
return clinicStore.updateClinic(klinikPayload.id, clinicUpdates);
};
const deleteKlinik = (klinikId) => {
const index = klinikData.value.findIndex(k => k.id === klinikId);
if (index !== -1) {
const klinikName = klinikData.value[index].nama;
klinikData.value.splice(index, 1);
klinikData.value.forEach((k, idx) => {
k.no = idx + 1;
});
return { success: true, message: `Klinik ${klinikName} berhasil dihapus` };
}
return { success: false, message: 'Klinik tidak ditemukan' };
// Deletion should ideally happen directly in clinicStore
return { success: false, message: 'Penghapusan klinik harus dilakukan melalui Clinic Store.' };
};
const getKlinikById = (id) => {
return klinikData.value.find(k => k.id === id);
// Menggunakan getter dari clinicStore
const clinic = clinicStore.clinics.find(c => c.id === id);
if (clinic) {
return {
id: clinic.id,
no: klinikData.value.findIndex(k => k.id === clinic.id) + 1, // Recalculate 'no'
kode: clinic.kode,
nama: clinic.name,
shift: typeof clinic.shift === 'string' ? parseInt(clinic.shift.replace(/[^0-9]/g, '')) || 1 : clinic.shift || 1,
totalQuota: clinic.totalQuota,
jamShiftList: clinic.jamShiftList,
autoShift: clinic.autoShift,
jadwalKlinik: clinic.jadwalKlinik,
};
}
return undefined;
};
const getKlinikByKode = (kode) => {
return klinikData.value.find(k => k.kode === kode);
};
// ============================================
// MASTER LOKET
// ============================================
const loketData = ref([
{
id: 1,
no: 1,
namaLoket: "Loket 1",
kuota: 500,
pelayanan: ["RT", "RM", "TD"],
pembayaran: "JKN",
keterangan: "ONLINE",
statusPelayanan: "RAWAT JALAN",
},
{
id: 2,
no: 2,
namaLoket: "Loket 2",
kuota: 666,
pelayanan: ["JW", "SR"],
pembayaran: "JKN",
keterangan: "ONLINE",
statusPelayanan: "RAWAT JALAN",
},
{
id: 3,
no: 3,
namaLoket: "Loket 3",
kuota: 666,
pelayanan: ["AS", "JT"],
pembayaran: "JKN",
keterangan: "ONLINE",
statusPelayanan: "RAWAT JALAN",
},
{
id: 4,
no: 4,
namaLoket: "Loket 4",
kuota: 3676,
pelayanan: ["KK", "PR"],
pembayaran: "JKN",
keterangan: "ONLINE",
statusPelayanan: "RAWAT JALAN",
},
]);
const availableServices = computed(() =>
klinikData.value.map(k => ({
id: k.kode,
nama: k.nama,
}))
);
// Actions - Loket
const addLoket = (loketPayload) => {
const newId = Math.max(...loketData.value.map(l => l.id), 0) + 1;
const newNo = loketData.value.length + 1;
const newLoket = {
id: newId,
no: newNo,
...loketPayload,
};
loketData.value.push(newLoket);
return { success: true, message: `Loket ${newLoket.namaLoket} berhasil ditambahkan`, data: newLoket };
};
const updateLoket = (loketPayload) => {
const index = loketData.value.findIndex(l => l.id === loketPayload.id);
if (index !== -1) {
loketData.value[index] = {
...loketData.value[index],
...loketPayload,
// Menggunakan getter dari clinicStore
const clinic = clinicStore.getClinicConfigByKode(kode);
if (clinic) {
return {
id: clinic.id,
no: klinikData.value.findIndex(k => k.kode === clinic.kode) + 1, // Recalculate 'no'
kode: clinic.kode,
nama: clinic.name,
shift: typeof clinic.shift === 'string' ? parseInt(clinic.shift.replace(/[^0-9]/g, '')) || 1 : clinic.shift || 1,
totalQuota: clinic.totalQuota,
jamShiftList: clinic.jamShiftList,
autoShift: clinic.autoShift,
jadwalKlinik: clinic.jadwalKlinik,
};
return { success: true, message: `Loket ${loketPayload.namaLoket} berhasil diupdate` };
}
return { success: false, message: 'Loket tidak ditemukan' };
};
const deleteLoket = (loketId) => {
const index = loketData.value.findIndex(l => l.id === loketId);
if (index !== -1) {
const loketName = loketData.value[index].namaLoket;
loketData.value.splice(index, 1);
loketData.value.forEach((l, idx) => {
l.no = idx + 1;
});
return { success: true, message: `Loket ${loketName} berhasil dihapus` };
}
return { success: false, message: 'Loket tidak ditemukan' };
};
const getLoketById = (id) => {
return loketData.value.find(l => l.id === id);
return undefined;
};
// ============================================
// MASTER KLINIK RUANG
// MASTER LOKET (Backward Compatibility)
// ============================================
const ruangData = ref([
{
id: 1,
no: 1,
kodeKlinik: 'AN',
namaKlinik: 'ANAK',
namaRuang: 'R. TINDAKAN',
ruangList: [
{ nomorRuang: '1', namaRuang: 'R. TINDAKAN', nomorScreen: '101' }
]
},
{
id: 2,
no: 2,
kodeKlinik: 'AS',
namaKlinik: 'ANESTESI',
namaRuang: 'Ruang 1, Ruang 2',
ruangList: [
{ nomorRuang: '1', namaRuang: 'Ruang 1', nomorScreen: '201' },
{ nomorRuang: '2', namaRuang: 'Ruang 2', nomorScreen: '202' }
]
},
{
id: 3,
no: 3,
kodeKlinik: 'GI',
namaKlinik: 'GIGI DAN MULUT',
namaRuang: 'Ruang 1, Ruang 2, Ruang 3',
ruangList: [
{ nomorRuang: '1', namaRuang: 'Ruang 1', nomorScreen: '501' },
{ nomorRuang: '2', namaRuang: 'Ruang 2', nomorScreen: '502' },
{ nomorRuang: '3', namaRuang: 'Ruang 3', nomorScreen: '503' }
]
},
]);
// Reference dari loketStore
const loketData = computed(() => loketStore.loketData);
const availableServices = computed(() => loketStore.availableServices);
// Computed
const totalKlinikRuang = computed(() => ruangData.value.length);
const totalRuangan = computed(() => {
return ruangData.value.reduce((total, klinik) => {
return total + klinik.ruangList.length;
}, 0);
});
// Actions - Ruang
const addRuang = (ruangPayload) => {
const newId = Math.max(...ruangData.value.map(r => r.id), 0) + 1;
const newNo = ruangData.value.length + 1;
const namaRuangDisplay = ruangPayload.ruangList
.map(r => r.namaRuang)
.filter(n => n)
.join(', ');
const newRuang = {
id: newId,
no: newNo,
...ruangPayload,
namaRuang: namaRuangDisplay,
};
ruangData.value.push(newRuang);
return { success: true, message: `Ruang klinik ${newRuang.namaKlinik} berhasil ditambahkan`, data: newRuang };
};
const updateRuang = (ruangPayload) => {
const index = ruangData.value.findIndex(r => r.id === ruangPayload.id);
if (index !== -1) {
const namaRuangDisplay = ruangPayload.ruangList
.map(r => r.namaRuang)
.filter(n => n)
.join(', ');
ruangData.value[index] = {
...ruangData.value[index],
...ruangPayload,
namaRuang: namaRuangDisplay,
};
return { success: true, message: `Ruang klinik ${ruangPayload.namaKlinik} berhasil diupdate` };
}
return { success: false, message: 'Ruang tidak ditemukan' };
};
const deleteRuang = (ruangId) => {
const index = ruangData.value.findIndex(r => r.id === ruangId);
if (index !== -1) {
const ruangName = `${ruangData.value[index].namaKlinik} - ${ruangData.value[index].namaRuang}`;
ruangData.value.splice(index, 1);
ruangData.value.forEach((r, idx) => {
r.no = idx + 1;
});
return { success: true, message: `Ruang ${ruangName} berhasil dihapus` };
}
return { success: false, message: 'Ruang tidak ditemukan' };
};
const getRuangByKlinik = (kodeKlinik) => {
return ruangData.value.filter(r => r.kodeKlinik === kodeKlinik);
};
// Get all ruang list (for antrian display)
const getAllRuangList = computed(() => {
const allRuang = [];
ruangData.value.forEach(klinik => {
klinik.ruangList.forEach(ruang => {
allRuang.push({
kodeKlinik: klinik.kodeKlinik,
namaKlinik: klinik.namaKlinik,
...ruang
});
});
});
return allRuang;
});
// Actions - Loket (delegate to loketStore)
const addLoket = (loketPayload) => loketStore.addLoket(loketPayload);
const updateLoket = (loketPayload) => loketStore.updateLoket(loketPayload);
const deleteLoket = (loketId) => loketStore.deleteLoket(loketId);
const getLoketById = (id) => loketStore.getLoketById(id);
// ============================================
// MASTER PENUNJANG
// MASTER KLINIK RUANG (Backward Compatibility)
// ============================================
const penunjangData = ref([
{
id: 1,
no: 1,
kode: 'LAB',
nama: 'LABORATORIUM',
jenis: 'Medis',
shift: 3,
totalQuota: 150,
jamShiftList: [
{ dari: '07:00', sampai: '15:00', kuota: 60 },
{ dari: '13:00', sampai: '21:00', kuota: 50 },
{ dari: '21:00', sampai: '07:00', kuota: 40 }
],
autoShift: true,
jadwalOperasional: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at', 'Sabtu', 'Minggu'],
status: 'Aktif',
estimasiLayanan: 30,
keterangan: 'Pemeriksaan darah lengkap, urine, dan pemeriksaan laboratorium lainnya'
},
{
id: 2,
no: 2,
kode: 'RAD',
nama: 'RADIOLOGI',
jenis: 'Medis',
shift: 2,
totalQuota: 100,
jamShiftList: [
{ dari: '07:00', sampai: '15:00', kuota: 60 },
{ dari: '13:00', sampai: '21:00', kuota: 40 }
],
autoShift: true,
jadwalOperasional: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at', 'Sabtu'],
status: 'Aktif',
estimasiLayanan: 45,
keterangan: 'Rontgen, CT Scan, MRI, USG, dan pemeriksaan radiologi lainnya'
},
{
id: 3,
no: 3,
kode: 'PAT',
nama: 'PATOLOGI ANATOMI',
jenis: 'Medis',
shift: 1,
totalQuota: 50,
jamShiftList: [
{ dari: '07:00', sampai: '15:00', kuota: 50 }
],
autoShift: false,
jadwalOperasional: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at'],
status: 'Aktif',
estimasiLayanan: 60,
keterangan: 'Pemeriksaan jaringan dan sitologi'
},
{
id: 4,
no: 4,
kode: 'RHB',
nama: 'REHABILITASI MEDIK',
jenis: 'Medis',
shift: 2,
totalQuota: 80,
jamShiftList: [
{ dari: '07:00', sampai: '13:00', kuota: 40 },
{ dari: '13:00', sampai: '19:00', kuota: 40 }
],
autoShift: true,
jadwalOperasional: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at'],
status: 'Aktif',
estimasiLayanan: 30,
keterangan: 'Fisioterapi, terapi okupasi, dan terapi wicara'
},
{
id: 5,
no: 5,
kode: 'RME',
nama: 'REKAM MEDIS',
jenis: 'Non-Medis',
shift: 2,
totalQuota: 120,
jamShiftList: [
{ dari: '07:00', sampai: '15:00', kuota: 70 },
{ dari: '13:00', sampai: '21:00', kuota: 50 }
],
autoShift: true,
jadwalOperasional: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at', 'Sabtu'],
status: 'Aktif',
estimasiLayanan: 15,
keterangan: 'Pengurusan dokumen rekam medis dan surat keterangan'
},
{
id: 6,
no: 6,
kode: 'GIZ',
nama: 'GIZI',
jenis: 'Non-Medis',
shift: 2,
totalQuota: 60,
jamShiftList: [
{ dari: '07:00', sampai: '13:00', kuota: 30 },
{ dari: '13:00', sampai: '19:00', kuota: 30 }
],
autoShift: true,
jadwalOperasional: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at'],
status: 'Aktif',
estimasiLayanan: 20,
keterangan: 'Konsultasi gizi dan diet'
},
{
id: 7,
no: 7,
kode: 'LND',
nama: 'LAUNDRY',
jenis: 'Non-Medis',
shift: 3,
totalQuota: 200,
jamShiftList: [
{ dari: '06:00', sampai: '14:00', kuota: 80 },
{ dari: '14:00', sampai: '22:00', kuota: 70 },
{ dari: '22:00', sampai: '06:00', kuota: 50 }
],
autoShift: true,
jadwalOperasional: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at', 'Sabtu', 'Minggu'],
status: 'Aktif',
estimasiLayanan: 10,
keterangan: 'Layanan laundry linen rumah sakit'
},
{
id: 8,
no: 8,
kode: 'INF',
nama: 'PELAYANAN INFORMASI',
jenis: 'Non-Medis',
shift: 2,
totalQuota: 150,
jamShiftList: [
{ dari: '07:00', sampai: '15:00', kuota: 80 },
{ dari: '13:00', sampai: '21:00', kuota: 70 }
],
autoShift: true,
jadwalOperasional: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at', 'Sabtu', 'Minggu'],
status: 'Aktif',
estimasiLayanan: 10,
keterangan: 'Informasi umum rumah sakit dan bantuan navigasi'
},
{
id: 9,
no: 9,
kode: 'FRM',
nama: 'FARMASI',
jenis: 'Medis',
shift: 3,
totalQuota: 200,
jamShiftList: [
{ dari: '07:00', sampai: '15:00', kuota: 80 },
{ dari: '13:00', sampai: '21:00', kuota: 70 },
{ dari: '21:00', sampai: '07:00', kuota: 50 }
],
autoShift: true,
jadwalOperasional: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at', 'Sabtu', 'Minggu'],
status: 'Aktif',
estimasiLayanan: 20,
keterangan: 'Pelayanan obat dan konsultasi farmasi'
}
]);
// Reference dari ruangStore
const ruangData = computed(() => ruangStore.ruangData);
const totalKlinikRuang = computed(() => ruangStore.totalKlinikRuang);
const totalRuangan = computed(() => ruangStore.totalRuangan);
const getAllRuangList = computed(() => ruangStore.getAllRuangList);
// Computed - Penunjang
const totalPenunjang = computed(() => penunjangData.value.length);
const activePenunjang = computed(() => penunjangData.value.filter(p => p.status === 'Aktif'));
const penunjangMedis = computed(() => penunjangData.value.filter(p => p.jenis === 'Medis'));
const penunjangNonMedis = computed(() => penunjangData.value.filter(p => p.jenis === 'Non-Medis'));
// Actions - Ruang (delegate to ruangStore)
const addRuang = (ruangPayload) => ruangStore.addRuang(ruangPayload);
const updateRuang = (ruangPayload) => ruangStore.updateRuang(ruangPayload);
const deleteRuang = (ruangId) => ruangStore.deleteRuang(ruangId);
const getRuangByKlinik = (kodeKlinik) => ruangStore.getRuangByKlinik(kodeKlinik);
// Get penunjang list for dropdowns
const penunjangList = computed(() =>
penunjangData.value
.filter(p => p.status === 'Aktif')
.map(p => ({
id: p.id,
kode: p.kode,
nama: p.nama
}))
);
// ============================================
// MASTER PENUNJANG (Backward Compatibility)
// ============================================
// Reference dari penunjangStore
const penunjangData = computed(() => penunjangStore.penunjangData);
const totalPenunjang = computed(() => penunjangStore.totalPenunjang);
const activePenunjang = computed(() => penunjangStore.activePenunjang);
const penunjangMedis = computed(() => penunjangStore.penunjangMedis);
const penunjangNonMedis = computed(() => penunjangStore.penunjangNonMedis);
const penunjangList = computed(() => penunjangStore.penunjangList);
// Actions - Penunjang
const addPenunjang = (penunjangPayload) => {
const newId = Math.max(...penunjangData.value.map(p => p.id), 0) + 1;
const newNo = penunjangData.value.length + 1;
const totalQuota = penunjangPayload.jamShiftList.reduce((total, shift) => {
return total + (parseInt(shift.kuota) || 0);
}, 0);
const newPenunjang = {
id: newId,
no: newNo,
...penunjangPayload,
totalQuota: totalQuota,
};
penunjangData.value.push(newPenunjang);
return { success: true, message: `Penunjang ${newPenunjang.nama} berhasil ditambahkan`, data: newPenunjang };
};
const updatePenunjang = (penunjangPayload) => {
const index = penunjangData.value.findIndex(p => p.id === penunjangPayload.id);
if (index !== -1) {
const totalQuota = penunjangPayload.jamShiftList.reduce((total, shift) => {
return total + (parseInt(shift.kuota) || 0);
}, 0);
penunjangData.value[index] = {
...penunjangData.value[index],
...penunjangPayload,
totalQuota: totalQuota,
};
return { success: true, message: `Penunjang ${penunjangPayload.nama} berhasil diupdate` };
}
return { success: false, message: 'Penunjang tidak ditemukan' };
};
const deletePenunjang = (penunjangId) => {
const index = penunjangData.value.findIndex(p => p.id === penunjangId);
if (index !== -1) {
const penunjangName = penunjangData.value[index].nama;
penunjangData.value.splice(index, 1);
penunjangData.value.forEach((p, idx) => {
p.no = idx + 1;
});
return { success: true, message: `Penunjang ${penunjangName} berhasil dihapus` };
}
return { success: false, message: 'Penunjang tidak ditemukan' };
};
const getPenunjangById = (id) => {
return penunjangData.value.find(p => p.id === id);
};
const getPenunjangByKode = (kode) => {
return penunjangData.value.find(p => p.kode === kode);
};
// Get active penunjang list (untuk Admin Penunjang)
const getActivePenunjangList = () => {
return penunjangData.value
.filter(p => p.status === 'Aktif')
.map(p => ({ id: p.id, name: p.nama, kode: p.kode }));
};
// Actions - Penunjang (delegate to penunjangStore)
const addPenunjang = (penunjangPayload) => penunjangStore.addPenunjang(penunjangPayload);
const updatePenunjang = (penunjangPayload) => penunjangStore.updatePenunjang(penunjangPayload);
const deletePenunjang = (penunjangId) => penunjangStore.deletePenunjang(penunjangId);
const getPenunjangById = (id) => penunjangStore.getPenunjangById(id);
const getPenunjangByKode = (kode) => penunjangStore.getPenunjangByKode(kode);
const getActivePenunjangList = () => penunjangStore.getActivePenunjangList();
// ============================================
// UTILITY FUNCTIONS
// ============================================
const getKlinikNameByKode = (kode) => {
// Utamakan nama dari clinicStore agar konsisten di seluruh aplikasi
if (typeof clinicStore.getClinicByKode === 'function') {
const clinic = clinicStore.getClinicByKode(kode);
if (clinic) {
return clinic.name;
}
// Single source of truth: selalu ambil dari clinicStore
const clinic = clinicStore.getClinicByKode(kode);
if (clinic) {
return clinic.name;
}
// Fallback ke data lokal master jika belum terdaftar di clinicStore
const klinik = klinikData.value.find((k) => k.kode === kode);
return klinik ? klinik.nama : kode;
// Fallback jika tidak ditemukan
return kode;
};
const getPenunjangNameByKode = (kode) => {
const penunjang = penunjangData.value.find(p => p.kode === kode);
return penunjang ? penunjang.nama : kode;
return penunjangStore.getPenunjangNameByKode(kode);
};
return {
@@ -712,4 +252,7 @@ export const useMasterStore = defineStore('master', () => {
getKlinikNameByKode,
getPenunjangNameByKode,
};
});
}, {
// NOTE: masterStore tidak perlu persist karena semua data sudah di-persist di stores masing-masing
persist: false,
});
+289
View File
@@ -0,0 +1,289 @@
// stores/penunjangStore.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const usePenunjangStore = defineStore('penunjang', () => {
// State - Penunjang Data
const penunjangData = ref([
{
id: 1,
no: 1,
kode: 'LAB',
nama: 'LABORATORIUM',
jenis: 'Medis',
shift: 3,
totalQuota: 150,
jamShiftList: [
{ dari: '07:00', sampai: '15:00', kuota: 60 },
{ dari: '13:00', sampai: '21:00', kuota: 50 },
{ dari: '21:00', sampai: '07:00', kuota: 40 }
],
autoShift: true,
jadwalOperasional: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at', 'Sabtu', 'Minggu'],
status: 'Aktif',
estimasiLayanan: 30,
keterangan: 'Pemeriksaan darah lengkap, urine, dan pemeriksaan laboratorium lainnya'
},
{
id: 2,
no: 2,
kode: 'RAD',
nama: 'RADIOLOGI',
jenis: 'Medis',
shift: 2,
totalQuota: 100,
jamShiftList: [
{ dari: '07:00', sampai: '15:00', kuota: 60 },
{ dari: '13:00', sampai: '21:00', kuota: 40 }
],
autoShift: true,
jadwalOperasional: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at', 'Sabtu'],
status: 'Aktif',
estimasiLayanan: 45,
keterangan: 'Rontgen, CT Scan, MRI, USG, dan pemeriksaan radiologi lainnya'
},
{
id: 3,
no: 3,
kode: 'PAT',
nama: 'PATOLOGI ANATOMI',
jenis: 'Medis',
shift: 1,
totalQuota: 50,
jamShiftList: [
{ dari: '07:00', sampai: '15:00', kuota: 50 }
],
autoShift: false,
jadwalOperasional: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at'],
status: 'Aktif',
estimasiLayanan: 60,
keterangan: 'Pemeriksaan jaringan dan sitologi'
},
{
id: 4,
no: 4,
kode: 'RHB',
nama: 'REHABILITASI MEDIK',
jenis: 'Medis',
shift: 2,
totalQuota: 80,
jamShiftList: [
{ dari: '07:00', sampai: '13:00', kuota: 40 },
{ dari: '13:00', sampai: '19:00', kuota: 40 }
],
autoShift: true,
jadwalOperasional: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at'],
status: 'Aktif',
estimasiLayanan: 30,
keterangan: 'Fisioterapi, terapi okupasi, dan terapi wicara'
},
{
id: 5,
no: 5,
kode: 'RME',
nama: 'REKAM MEDIS',
jenis: 'Non-Medis',
shift: 2,
totalQuota: 120,
jamShiftList: [
{ dari: '07:00', sampai: '15:00', kuota: 70 },
{ dari: '13:00', sampai: '21:00', kuota: 50 }
],
autoShift: true,
jadwalOperasional: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at', 'Sabtu'],
status: 'Aktif',
estimasiLayanan: 15,
keterangan: 'Pengurusan dokumen rekam medis dan surat keterangan'
},
{
id: 6,
no: 6,
kode: 'GIZ',
nama: 'GIZI',
jenis: 'Non-Medis',
shift: 2,
totalQuota: 60,
jamShiftList: [
{ dari: '07:00', sampai: '13:00', kuota: 30 },
{ dari: '13:00', sampai: '19:00', kuota: 30 }
],
autoShift: true,
jadwalOperasional: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at'],
status: 'Aktif',
estimasiLayanan: 20,
keterangan: 'Konsultasi gizi dan diet'
},
{
id: 7,
no: 7,
kode: 'LND',
nama: 'LAUNDRY',
jenis: 'Non-Medis',
shift: 3,
totalQuota: 200,
jamShiftList: [
{ dari: '06:00', sampai: '14:00', kuota: 80 },
{ dari: '14:00', sampai: '22:00', kuota: 70 },
{ dari: '22:00', sampai: '06:00', kuota: 50 }
],
autoShift: true,
jadwalOperasional: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at', 'Sabtu', 'Minggu'],
status: 'Aktif',
estimasiLayanan: 10,
keterangan: 'Layanan laundry linen rumah sakit'
},
{
id: 8,
no: 8,
kode: 'INF',
nama: 'PELAYANAN INFORMASI',
jenis: 'Non-Medis',
shift: 2,
totalQuota: 150,
jamShiftList: [
{ dari: '07:00', sampai: '15:00', kuota: 80 },
{ dari: '13:00', sampai: '21:00', kuota: 70 }
],
autoShift: true,
jadwalOperasional: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at', 'Sabtu', 'Minggu'],
status: 'Aktif',
estimasiLayanan: 10,
keterangan: 'Informasi umum rumah sakit dan bantuan navigasi'
},
{
id: 9,
no: 9,
kode: 'FRM',
nama: 'FARMASI',
jenis: 'Medis',
shift: 3,
totalQuota: 200,
jamShiftList: [
{ dari: '07:00', sampai: '15:00', kuota: 80 },
{ dari: '13:00', sampai: '21:00', kuota: 70 },
{ dari: '21:00', sampai: '07:00', kuota: 50 }
],
autoShift: true,
jadwalOperasional: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at', 'Sabtu', 'Minggu'],
status: 'Aktif',
estimasiLayanan: 20,
keterangan: 'Pelayanan obat dan konsultasi farmasi'
}
]);
// Computed
const totalPenunjang = computed(() => penunjangData.value.length);
const activePenunjang = computed(() => penunjangData.value.filter(p => p.status === 'Aktif'));
const penunjangMedis = computed(() => penunjangData.value.filter(p => p.jenis === 'Medis'));
const penunjangNonMedis = computed(() => penunjangData.value.filter(p => p.jenis === 'Non-Medis'));
// Get penunjang list for dropdowns
// Standardized to use 'name' for consistency (keeping 'nama' for backward compatibility)
const penunjangList = computed(() =>
penunjangData.value
.filter(p => p.status === 'Aktif')
.map(p => ({
id: p.id,
kode: p.kode,
nama: p.nama, // Keep 'nama' for internal consistency
name: p.nama // Add 'name' alias for external compatibility
}))
);
// Actions - CRUD Operations
const addPenunjang = (penunjangPayload) => {
const newId = Math.max(...penunjangData.value.map(p => p.id), 0) + 1;
const newNo = penunjangData.value.length + 1;
const totalQuota = penunjangPayload.jamShiftList.reduce((total, shift) => {
return total + (parseInt(shift.kuota) || 0);
}, 0);
const newPenunjang = {
id: newId,
no: newNo,
...penunjangPayload,
totalQuota: totalQuota,
};
penunjangData.value.push(newPenunjang);
return { success: true, message: `Penunjang ${newPenunjang.nama} berhasil ditambahkan`, data: newPenunjang };
};
const updatePenunjang = (penunjangPayload) => {
const index = penunjangData.value.findIndex(p => p.id === penunjangPayload.id);
if (index !== -1) {
const totalQuota = penunjangPayload.jamShiftList.reduce((total, shift) => {
return total + (parseInt(shift.kuota) || 0);
}, 0);
penunjangData.value[index] = {
...penunjangData.value[index],
...penunjangPayload,
totalQuota: totalQuota,
};
return { success: true, message: `Penunjang ${penunjangPayload.nama} berhasil diupdate` };
}
return { success: false, message: 'Penunjang tidak ditemukan' };
};
const deletePenunjang = (penunjangId) => {
const index = penunjangData.value.findIndex(p => p.id === penunjangId);
if (index !== -1) {
const penunjangName = penunjangData.value[index].nama;
penunjangData.value.splice(index, 1);
penunjangData.value.forEach((p, idx) => {
p.no = idx + 1;
});
return { success: true, message: `Penunjang ${penunjangName} berhasil dihapus` };
}
return { success: false, message: 'Penunjang tidak ditemukan' };
};
const getPenunjangById = (id) => {
return penunjangData.value.find(p => p.id === id);
};
const getPenunjangByKode = (kode) => {
return penunjangData.value.find(p => p.kode === kode);
};
// Get active penunjang list (untuk Admin Penunjang)
const getActivePenunjangList = () => {
return penunjangData.value
.filter(p => p.status === 'Aktif')
.map(p => ({ id: p.id, name: p.nama, kode: p.kode }));
};
// Utility function
const getPenunjangNameByKode = (kode) => {
const penunjang = penunjangData.value.find(p => p.kode === kode);
return penunjang ? penunjang.nama : kode;
};
return {
// State
penunjangData,
totalPenunjang,
activePenunjang,
penunjangMedis,
penunjangNonMedis,
penunjangList,
// Actions
addPenunjang,
updatePenunjang,
deletePenunjang,
getPenunjangById,
getPenunjangByKode,
getActivePenunjangList,
getPenunjangNameByKode,
};
}, {
persist: {
key: 'penunjang-store-state',
storage: typeof window !== 'undefined' ? localStorage : undefined,
paths: ['penunjangData'],
},
});
+161 -13
View File
@@ -2,9 +2,11 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { useClinicStore } from './clinicStore';
import { usePenunjangStore } from './penunjangStore';
export const useQueueStore = defineStore('queue', () => {
const clinicStore = useClinicStore();
const penunjangStore = usePenunjangStore();
// Seed data for easy reset during dev
const seedPatients = [
{
@@ -167,9 +169,9 @@ export const useQueueStore = defineStore('queue', () => {
const cloneSeed = () => seedPatients.map(p => ({ ...p }));
// State
// Initialize state - will be automatically hydrated from localStorage by pinia-plugin-persistedstate
// If localStorage has data, it will override these defaults
const allPatients = ref(cloneSeed());
const quotaUsed = ref(5);
const currentProcessingPatient = ref({
loket: null,
@@ -193,17 +195,17 @@ export const useQueueStore = defineStore('queue', () => {
}));
});
const penunjangs = ref([
{ id: 1, name: "LABORATORIUM" },
{ id: 2, name: "RADIOLOGI" },
{ id: 3, name: "USG" },
{ id: 4, name: "CT SCAN" },
{ id: 5, name: "MRI" },
{ id: 6, name: "EKG" },
{ id: 7, name: "ENDOSKOPI" },
{ id: 8, name: "FISIOTERAPI" },
{ id: 9, name: "FARMASI" },
]);
// Penunjang data - reference dari penunjangStore (single source of truth)
// Menggunakan computed untuk reactive reference
const penunjangs = computed(() => {
// Get penunjang list from penunjangStore dan map ke format yang diharapkan
const penunjangList = penunjangStore.penunjangList || [];
return penunjangList.map(p => ({
id: p.id,
name: p.nama || p.name, // Support both nama and name for backward compatibility
kode: p.kode
}));
});
// Computed - Filter berdasarkan process stage dan status
const getPatientsByStage = (stage) => {
@@ -528,6 +530,144 @@ export const useQueueStore = defineStore('queue', () => {
currentProcessingPatient.value[adminType] = patient;
};
// Register patient from Anjungan (onsite registration)
const registerPatientFromAnjungan = (clinic, paymentType, visitType = 'SEKARANG', visitDate = null, shift = 'Shift 1', namaDokter = null) => {
const newNo = allPatients.value.length > 0
? Math.max(...allPatients.value.map(p => p.no)) + 1
: 1;
const timestamp = new Date();
const visitDateTime = visitDate ? new Date(visitDate) : timestamp;
const jamPanggil = visitDate
? `${String(visitDateTime.getHours()).padStart(2, "0")}:${String(visitDateTime.getMinutes()).padStart(2, "0")}`
: `${String(timestamp.getHours()).padStart(2, "0")}:${String(timestamp.getMinutes()).padStart(2, "0")}`;
// Generate barcode (simulasi - bisa diganti dengan API call)
const barcode = `250811${String(Date.now()).slice(-6)}${String(newNo).padStart(3, "0")}`;
// Format nomor antrean: UMXXXX | Onsite - barcode
const noAntrian = `UM${String(newNo).padStart(4, "0")} | Onsite - ${barcode}`;
// Determine status based on visit type
const status = visitType === 'SEKARANG' ? 'waiting' : 'pending';
const newPatient = {
no: newNo,
jamPanggil: jamPanggil,
barcode: barcode,
noAntrian: noAntrian,
shift: shift,
klinik: clinic.name || clinic,
fastTrack: "TIDAK", // Default, bisa diubah nanti
pembayaran: paymentType,
status: status,
processStage: "loket",
createdAt: timestamp.toISOString(),
registrationType: 'onsite',
visitType: visitType,
visitDate: visitDate || timestamp.toISOString().substring(0, 10),
namaDokter: namaDokter || null, // Nama dokter hanya untuk pasien Eksekutif
};
allPatients.value.push(newPatient);
return {
success: true,
message: `Pendaftaran ${clinic.name || clinic} untuk kunjungan ${visitType === 'SEKARANG' ? 'HARI INI' : visitDate} dengan pembayaran ${paymentType} berhasil diproses.`,
patient: newPatient,
};
};
// Check-in patient (update status from waiting to di-loket)
const checkInPatient = (patientIdOrBarcode) => {
console.log('🔍 checkInPatient called with:', patientIdOrBarcode);
console.log('📊 Total patients in store:', allPatients.value.length);
console.log('📋 First few patients:', allPatients.value.slice(0, 3).map(p => ({
no: p.no,
noAntrian: p.noAntrian,
barcode: p.barcode,
status: p.status
})));
// Clean input - remove whitespace and convert to uppercase for comparison
const cleanInput = String(patientIdOrBarcode).trim().toUpperCase();
// Try to find by multiple criteria:
// 1. Exact barcode match
// 2. Parse as number and match with no
// 3. Extract number from input (e.g., "UM0014" -> "0014" or "14") and match with noAntrian
// 4. Check if noAntrian includes the input
const patientIndex = allPatients.value.findIndex(p => {
// Exact barcode match
if (p.barcode === cleanInput || p.barcode === patientIdOrBarcode) {
console.log('✅ Found by barcode:', p.barcode);
return true;
}
// Try parsing as number
const parsedNo = parseInt(cleanInput.replace(/[^0-9]/g, '')) || parseInt(patientIdOrBarcode);
if (!isNaN(parsedNo) && p.no === parsedNo) {
console.log('✅ Found by no:', p.no);
return true;
}
// Check if noAntrian includes the input (case insensitive)
const noAntrianUpper = (p.noAntrian || '').toUpperCase();
if (noAntrianUpper.includes(cleanInput) || noAntrianUpper.includes(patientIdOrBarcode)) {
console.log('✅ Found by noAntrian:', p.noAntrian);
return true;
}
// Try to extract number from noAntrian (e.g., "UM0014 | Onsite - ..." -> "0014")
const noAntrianNumber = noAntrianUpper.match(/([A-Z]+)(\d+)/);
if (noAntrianNumber) {
const extractedNumber = noAntrianNumber[2];
const inputNumber = cleanInput.replace(/[^0-9]/g, '');
if (inputNumber && extractedNumber.includes(inputNumber) || inputNumber.includes(extractedNumber)) {
console.log('✅ Found by extracted number from noAntrian');
return true;
}
}
return false;
});
if (patientIndex === -1) {
console.log('❌ Patient not found. Searched for:', cleanInput);
console.log('📋 Available noAntrian values:', allPatients.value.map(p => p.noAntrian));
return { success: false, message: "Pasien tidak ditemukan" };
}
const patient = allPatients.value[patientIndex];
console.log('✅ Patient found:', patient);
// Only allow check-in if status is waiting or pending
if (patient.status !== 'waiting' && patient.status !== 'pending') {
console.log('⚠️ Patient status is not waiting/pending:', patient.status);
return {
success: false,
message: `Pasien tidak dapat check-in. Status saat ini: ${patient.status}`
};
}
// Update status to di-loket
allPatients.value[patientIndex] = {
...allPatients.value[patientIndex],
status: "di-loket",
};
console.log('✅ Check-in successful, patient status updated to di-loket');
return {
success: true,
message: `Check-in berhasil. Pasien ${patient.noAntrian.split(" |")[0]} siap diproses di loket.`,
patient: allPatients.value[patientIndex],
};
};
// State persistence is handled automatically by pinia-plugin-persistedstate
// No need for manual watch or save logic
return {
// State
allPatients,
@@ -551,5 +691,13 @@ export const useQueueStore = defineStore('queue', () => {
getTotalPasienByStage,
getCurrentProcessing,
setCurrentProcessing,
registerPatientFromAnjungan,
checkInPatient,
};
}, {
persist: {
key: 'queue-store-state',
storage: typeof window !== 'undefined' ? localStorage : undefined,
paths: ['allPatients', 'quotaUsed', 'currentProcessingPatient'],
},
});
+313
View File
@@ -0,0 +1,313 @@
// stores/ruangStore.js
// Merged dari masterStore.ruangData + klinikruangstore.klinikRuangList
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { useClinicStore } from './clinicStore';
export const useRuangStore = defineStore('ruang', () => {
const clinicStore = useClinicStore();
// State/Computed - List Master Klinik (disinkronkan dengan clinicStore)
const masterKlinikList = computed(() => {
const baseList = typeof clinicStore.getClinicsForDropdown === 'function'
? clinicStore.getClinicsForDropdown()
: [];
return baseList.map((c) => ({
kode: c.kode,
nama: c.name,
id: c.id,
}));
});
// State - Ruang Data (merged dari masterStore.ruangData + klinikruangstore.klinikRuangList)
const ruangData = ref([
{
id: 1,
no: 1,
kodeKlinik: 'AN',
namaKlinik: 'ANAK',
namaRuang: 'R. TINDAKAN',
ruangList: [
{ nomorRuang: '1', namaRuang: 'R. TINDAKAN', nomorScreen: '101' }
]
},
{
id: 2,
no: 2,
kodeKlinik: 'AS',
namaKlinik: 'ANESTESI',
namaRuang: 'Ruang 1, Ruang 2',
ruangList: [
{ nomorRuang: '1', namaRuang: 'Ruang 1', nomorScreen: '201' },
{ nomorRuang: '2', namaRuang: 'Ruang 2', nomorScreen: '202' }
]
},
{
id: 3,
no: 3,
kodeKlinik: 'BD',
namaKlinik: 'BEDAH',
namaRuang: 'Ruang Konsultasi',
ruangList: [
{ nomorRuang: '1', namaRuang: 'Ruang Konsultasi', nomorScreen: '301' }
]
},
{
id: 4,
no: 4,
kodeKlinik: 'GR',
namaKlinik: 'GERIATRI',
namaRuang: 'Ruang Pemeriksaan',
ruangList: [
{ nomorRuang: '1', namaRuang: 'Ruang Pemeriksaan', nomorScreen: '401' }
]
},
{
id: 5,
no: 5,
kodeKlinik: 'GI',
namaKlinik: 'GIGI DAN MULUT',
namaRuang: 'Ruang 1, Ruang 2, Ruang 3',
ruangList: [
{ nomorRuang: '1', namaRuang: 'Ruang 1', nomorScreen: '501' },
{ nomorRuang: '2', namaRuang: 'Ruang 2', nomorScreen: '502' },
{ nomorRuang: '3', namaRuang: 'Ruang 3', nomorScreen: '503' }
]
},
]);
// Computed
const totalKlinikRuang = computed(() => ruangData.value.length);
const totalRuangan = computed(() => {
return ruangData.value.reduce((total, klinik) => {
return total + klinik.ruangList.length;
}, 0);
});
// Get all ruang list (for antrian display)
const getAllRuangList = computed(() => {
const allRuang = [];
ruangData.value.forEach(klinik => {
klinik.ruangList.forEach(ruang => {
allRuang.push({
kodeKlinik: klinik.kodeKlinik,
namaKlinik: klinik.namaKlinik,
...ruang
});
});
});
return allRuang;
});
// Getters
const getKlinikByCode = (kode) => {
return ruangData.value.find(k => k.kodeKlinik === kode);
};
const getRuangByKlinik = (kodeKlinik) => {
return ruangData.value.filter(r => r.kodeKlinik === kodeKlinik);
};
// Actions - CRUD Operations
const createKlinikRuang = (data) => {
const newId = Math.max(...ruangData.value.map(r => r.id), 0) + 1;
const newNo = ruangData.value.length + 1;
// Generate nama ruang untuk display
const namaRuangDisplay = data.ruangList
.map(r => r.namaRuang)
.filter(n => n)
.join(', ');
const newKlinikRuang = {
id: newId,
no: newNo,
kodeKlinik: data.kodeKlinik,
namaKlinik: data.namaKlinik,
namaRuang: namaRuangDisplay,
ruangList: data.ruangList
};
ruangData.value.push(newKlinikRuang);
return {
success: true,
message: `Klinik Ruang ${data.namaKlinik} berhasil ditambahkan`,
data: newKlinikRuang
};
};
const updateKlinikRuang = (id, data) => {
const index = ruangData.value.findIndex(r => r.id === id);
if (index === -1) {
return {
success: false,
message: 'Klinik Ruang tidak ditemukan'
};
}
// Generate nama ruang untuk display
const namaRuangDisplay = data.ruangList
.map(r => r.namaRuang)
.filter(n => n)
.join(', ');
ruangData.value[index] = {
...ruangData.value[index],
kodeKlinik: data.kodeKlinik,
namaKlinik: data.namaKlinik,
namaRuang: namaRuangDisplay,
ruangList: data.ruangList
};
return {
success: true,
message: `Klinik Ruang ${data.namaKlinik} berhasil diupdate`,
data: ruangData.value[index]
};
};
const deleteKlinikRuang = (id) => {
const index = ruangData.value.findIndex(r => r.id === id);
if (index === -1) {
return {
success: false,
message: 'Klinik Ruang tidak ditemukan'
};
}
const deletedKlinik = ruangData.value[index];
ruangData.value.splice(index, 1);
// Reorder numbers
ruangData.value.forEach((r, idx) => {
r.no = idx + 1;
});
return {
success: true,
message: `Klinik Ruang ${deletedKlinik.namaKlinik} berhasil dihapus`
};
};
const addRuangToKlinik = (klinikId, ruangDataItem) => {
const klinik = ruangData.value.find(k => k.id === klinikId);
if (!klinik) {
return {
success: false,
message: 'Klinik tidak ditemukan'
};
}
klinik.ruangList.push(ruangDataItem);
// Update display name
klinik.namaRuang = klinik.ruangList
.map(r => r.namaRuang)
.filter(n => n)
.join(', ');
return {
success: true,
message: `Ruang ${ruangDataItem.namaRuang} berhasil ditambahkan`,
data: klinik
};
};
const removeRuangFromKlinik = (klinikId, ruangIndex) => {
const klinik = ruangData.value.find(k => k.id === klinikId);
if (!klinik) {
return {
success: false,
message: 'Klinik tidak ditemukan'
};
}
if (klinik.ruangList.length <= 1) {
return {
success: false,
message: 'Minimal harus ada 1 ruangan'
};
}
const removedRuang = klinik.ruangList[ruangIndex];
klinik.ruangList.splice(ruangIndex, 1);
// Update display name
klinik.namaRuang = klinik.ruangList
.map(r => r.namaRuang)
.filter(n => n)
.join(', ');
return {
success: true,
message: `Ruang ${removedRuang.namaRuang} berhasil dihapus`,
data: klinik
};
};
const searchKlinikRuang = (searchTerm) => {
if (!searchTerm) return ruangData.value;
const term = searchTerm.toLowerCase();
return ruangData.value.filter(k =>
k.kodeKlinik.toLowerCase().includes(term) ||
k.namaKlinik.toLowerCase().includes(term) ||
k.namaRuang.toLowerCase().includes(term)
);
};
// Alias methods untuk backward compatibility dengan masterStore
const addRuang = (ruangPayload) => {
return createKlinikRuang(ruangPayload);
};
const updateRuang = (ruangPayload) => {
return updateKlinikRuang(ruangPayload.id, ruangPayload);
};
const deleteRuang = (ruangId) => {
return deleteKlinikRuang(ruangId);
};
return {
// State
masterKlinikList,
ruangData,
// Computed
totalKlinikRuang,
totalRuangan,
getAllRuangList,
// Getters
getKlinikByCode,
getRuangByKlinik,
// Actions (new names)
createKlinikRuang,
updateKlinikRuang,
deleteKlinikRuang,
addRuangToKlinik,
removeRuangFromKlinik,
searchKlinikRuang,
// Alias for backward compatibility
addRuang,
updateRuang,
deleteRuang,
};
}, {
persist: {
key: 'ruang-store-state',
storage: typeof window !== 'undefined' ? localStorage : undefined,
paths: ['ruangData'],
},
});
+6
View File
@@ -86,5 +86,11 @@ export const useScreenStore = defineStore('screen', () => {
updateScreen,
deleteScreen,
};
}, {
persist: {
key: 'screen-store-state',
storage: typeof window !== 'undefined' ? localStorage : undefined,
paths: ['screenItems'],
},
});