update flow anjungan,checkin,admin,dan klinik ruang

This commit is contained in:
bagus-arie05
2026-01-07 11:35:55 +07:00
parent 9e399c9f64
commit 155e3d9ea7
7 changed files with 842 additions and 63 deletions
+79 -9
View File
@@ -12,6 +12,10 @@
<span class="quota-label">Tersedia</span>
<span class="quota-value quota-available">{{ availableQuota }}</span>
</div>
<div class="quota-item">
<span class="quota-label">Menunggu</span>
<span class="quota-value quota-waiting">{{ menungguCount }}</span>
</div>
<div class="quota-item full-width">
<v-progress-linear
:model-value="quotaPercentage"
@@ -21,6 +25,7 @@
class="mt-1"
/>
<span class="quota-used">Terpakai: {{ usedQuota }}</span>
<span class="quota-callable">Bisa dipanggil: {{ callableQuota }}</span>
</div>
</div>
@@ -53,6 +58,10 @@ const props = defineProps({
type: Number,
default: 0
},
menungguCount: {
type: Number,
default: 0
},
hasNext: {
type: Boolean,
default: false
@@ -61,6 +70,11 @@ const props = defineProps({
const availableQuota = computed(() => props.totalQuota - props.usedQuota);
const quotaPercentage = computed(() => (props.usedQuota / props.totalQuota) * 100);
// Kuota yang bisa dipanggil = Tersedia - Menunggu
const callableQuota = computed(() => {
const callable = availableQuota.value - props.menungguCount;
return Math.max(0, callable);
});
defineEmits(['call']);
</script>
@@ -70,6 +84,18 @@ defineEmits(['call']);
border-radius: 12px;
border: 1px solid var(--color-neutral-500);
background: var(--color-neutral-100);
width: 100% !important;
max-width: 100% !important;
overflow: hidden !important;
box-sizing: border-box !important;
display: block !important;
}
.queue-actions-card :deep(.v-card-text) {
padding: 16px !important;
box-sizing: border-box !important;
width: 100% !important;
max-width: 100% !important;
}
.section-label {
@@ -81,20 +107,37 @@ defineEmits(['call']);
}
.quota-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
display: grid !important;
grid-template-columns: 1fr 1fr 1fr !important;
gap: 6px !important;
margin-bottom: 12px !important;
width: 100% !important;
box-sizing: border-box !important;
grid-auto-flow: row !important;
grid-auto-rows: auto !important;
}
.quota-item {
background: var(--color-neutral-300);
border-radius: 8px;
padding: 12px;
text-align: center;
background: var(--color-neutral-300) !important;
border-radius: 8px !important;
padding: 10px 6px !important;
text-align: center !important;
min-width: 0 !important;
max-width: 100% !important;
width: 100% !important;
box-sizing: border-box !important;
overflow: hidden !important;
word-wrap: break-word !important;
display: flex !important;
flex-direction: column !important;
justify-content: center !important;
align-items: center !important;
flex: 1 1 0% !important;
}
.quota-item.full-width {
grid-column: 1 / -1;
grid-column: 1 / 4 !important;
margin-top: 8px !important;
}
.quota-label {
@@ -107,15 +150,29 @@ defineEmits(['call']);
.quota-value {
display: block;
font-size: 24px;
font-size: 22px;
font-weight: 800;
color: var(--color-neutral-900);
line-height: 1.2;
white-space: nowrap;
}
.quota-available {
color: var(--color-success-600);
}
.quota-waiting {
color: var(--color-warning-600);
}
.quota-callable {
display: block;
font-size: 11px;
color: var(--color-primary-600);
margin-top: 4px;
font-weight: 600;
}
.quota-used {
display: block;
font-size: 11px;
@@ -139,5 +196,18 @@ defineEmits(['call']);
.call-buttons {
grid-template-columns: repeat(2, 1fr);
}
.quota-info {
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.quota-item {
padding: 8px;
}
.quota-value {
font-size: 20px;
}
}
</style>
+14 -20
View File
@@ -18,16 +18,6 @@
{{ status.label }} ({{ status.count }})
</v-chip>
</v-chip-group>
<!-- Search Field -->
<v-text-field
v-model="searchModel"
placeholder="Cari barcode, nomor antrian..."
density="compact"
hide-details
class="search-field"
prepend-inner-icon="mdi-magnify"
/>
</div>
<!-- Advanced Filters -->
@@ -200,6 +190,10 @@ const props = defineProps({
type: Number,
default: 0
},
waitingCount: {
type: Number,
default: 0
},
showDiproses: {
type: Boolean,
default: true
@@ -295,6 +289,16 @@ const statusOptions = computed(() => {
});
}
// Tambahkan kategori "Menunggu" (pasien yang sudah dipanggil tapi belum check-in)
if (props.waitingCount > 0) {
baseOptions.push({
value: 'waiting',
label: 'Menunggu',
count: props.waitingCount,
icon: 'mdi-clock-outline'
});
}
baseOptions.push(
{ value: 'terlambat', label: props.statusLabels.terlambat, count: props.terlambatCount },
{ value: 'pending', label: props.statusLabels.pending, count: props.pendingCount }
@@ -459,11 +463,6 @@ const handleAction = (patient, action) => {
border-color: var(--color-secondary-600);
}
.search-field {
max-width: 300px;
min-width: 250px;
}
.advanced-filters {
display: flex;
gap: 12px;
@@ -543,11 +542,6 @@ const handleAction = (patient, action) => {
min-width: 100%;
}
.search-field {
max-width: 100%;
min-width: 100%;
}
.advanced-filters {
flex-direction: column;
}
+6 -1
View File
@@ -55,9 +55,13 @@ export const useQueue = (adminType = "loket") => {
});
const waitingPatients = computed(() => stagePatients.value.waiting || []);
// Pasien yang belum dipanggil (status "menunggu")
const menungguPatients = computed(() => stagePatients.value.menunggu || []);
const nextPatient = computed(() => {
return waitingPatients.value[0] || null;
// Prioritaskan pasien menunggu, baru waiting
return menungguPatients.value[0] || waitingPatients.value[0] || null;
});
// Total pasien hanya untuk stage admin ini
@@ -185,6 +189,7 @@ export const useQueue = (adminType = "loket") => {
terlambatPatients,
pendingPatients,
waitingPatients,
menungguPatients,
nextPatient,
totalPasien,
quotaUsed,
+276 -5
View File
@@ -42,12 +42,12 @@
<v-btn
block
class="py-6"
color="primary-600"
color="warning"
:disabled="!currentProcessingPatient"
@click="showKlinikDialog = true"
@click="openKlinikRuangDialog()"
>
<v-icon start>mdi-hospital-building</v-icon>
Buat Antrean Klinik
<v-icon start>mdi-door</v-icon>
Buat Antrean Ruang
</v-btn>
</v-col>
@@ -74,7 +74,7 @@
<v-card-text class="pa-4">
<!-- Header with Filters -->
<div class="data-header mb-4">
<div class="section-label">DATA PASIEN</div>
<div class="section-label">CARI PASIEN</div>
<div class="filters">
<!-- Search Field -->
@@ -137,6 +137,90 @@
@select="changeKlinik"
/>
<!-- Dialog Klinik Ruang -->
<v-dialog v-model="showKlinikRuangDialog" max-width="900px" persistent scrollable>
<v-card class="dialog-card">
<v-card-title class="dialog-header dialog-header-warning">
<span class="headline-4">Pilih Klinik Ruang</span>
<v-btn icon variant="text" size="small" class="btn-close" @click="closeKlinikRuangDialog">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-divider/>
<v-card-text class="dialog-content">
<div v-if="currentProcessingPatient" class="patient-card mb-4">
<h4 class="headline-5 mb-3">Informasi Pasien</h4>
<v-row dense>
<v-col cols="6">
<div class="detail-item">
<span class="caption-2 text-muted">Barcode</span>
<span class="body-2 text-semibold">{{ currentProcessingPatient.barcode }}</span>
</div>
</v-col>
<v-col cols="6">
<div class="detail-item">
<span class="caption-2 text-muted">No. Antrian</span>
<span class="body-2 text-semibold">{{ currentProcessingPatient.noAntrian }}</span>
</div>
</v-col>
</v-row>
</div>
<v-text-field
v-model="klinikRuangSearch"
placeholder="Cari Klinik Ruang..."
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="compact"
hide-details
class="mb-4"
/>
<v-expansion-panels class="expansion-panels">
<v-expansion-panel
v-for="klinikRuang in filteredKlinikRuang"
:key="klinikRuang.id"
class="expansion-panel"
>
<v-expansion-panel-title class="expansion-title">
<div class="expansion-header">
<v-chip size="small" color="warning" class="mr-2">
{{ klinikRuang.kodeKlinik }}
</v-chip>
<span class="body-2 text-semibold">{{ klinikRuang.namaKlinik }}</span>
</div>
<template #actions>
<v-chip size="x-small" variant="outlined">
{{ klinikRuang.ruangList.length }} Ruang
</v-chip>
</template>
</v-expansion-panel-title>
<v-expansion-panel-text>
<div class="ruang-list">
<div
v-for="ruang in klinikRuang.ruangList"
:key="ruang.nomorRuang"
class="ruang-item"
@click="buatAntreanKlinikRuang(klinikRuang, ruang)"
>
<v-icon color="warning" size="20">mdi-door</v-icon>
<div class="ruang-info">
<span class="body-3 text-semibold">Ruang {{ ruang.nomorRuang }} - {{ ruang.namaRuang }}</span>
<span class="caption-2 text-muted">Screen: {{ ruang.nomorScreen }}</span>
</div>
<v-icon size="20" color="grey">mdi-chevron-right</v-icon>
</div>
</div>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card-text>
</v-card>
</v-dialog>
<!-- Snackbar -->
<AppSnackbar
v-model="snackbar"
@@ -149,6 +233,8 @@
<script setup>
import { ref, computed } from "vue";
import { useQueue } from "@/composables/useQueue";
import { useQueueStore } from "@/stores/queueStore";
import { useMasterStore } from "@/stores/masterStore";
import PageHeader from "@/components/common/PageHeader.vue";
import CurrentPatientCard from "@/components/features/queue/CurrentPatientCard.vue";
import QueueActionsCard from "@/components/features/queue/QueueActionsCard.vue";
@@ -156,6 +242,9 @@ import PatientDataTable from "@/components/features/queue/TabelPatientData.vue";
import SelectionDialog from "@/components/common/SelectionDialog.vue";
import AppSnackbar from "@/components/common/AppSnackbar.vue";
const masterStore = useMasterStore();
const queueStore = useQueueStore();
const {
snackbar,
snackbarText,
@@ -198,6 +287,24 @@ const selectedStatus = ref("all");
const searchQuery = ref("");
const selectedFastTrack = ref(null);
// Dialog Klinik Ruang
const showKlinikRuangDialog = ref(false);
const klinikRuangSearch = ref("");
// Get klinik ruang data from masterStore
const klinikRuangList = computed(() => {
return masterStore.ruangData || [];
});
const filteredKlinikRuang = computed(() => {
if (!klinikRuangSearch.value) return klinikRuangList.value;
return klinikRuangList.value.filter(
(k) =>
k.namaKlinik.toLowerCase().includes(klinikRuangSearch.value.toLowerCase()) ||
k.kodeKlinik.toLowerCase().includes(klinikRuangSearch.value.toLowerCase())
);
});
// Fast Track options from all patients
const fastTrackOptions = computed(() => {
const normalizedValues = allPatients.value
@@ -276,6 +383,32 @@ const handleCallPatient = () => {
// Placeholder for text-to-speech integration
}
};
const openKlinikRuangDialog = () => {
showKlinikRuangDialog.value = true;
};
const closeKlinikRuangDialog = () => {
showKlinikRuangDialog.value = false;
klinikRuangSearch.value = "";
};
const buatAntreanKlinikRuang = (klinikRuang, ruang) => {
if (!currentProcessingPatient.value) return;
const result = queueStore.createAntreanKlinikRuang(
klinikRuang,
ruang,
currentProcessingPatient.value,
"klinik"
);
snackbarText.value = result.message;
snackbarColor.value = result.success ? "success" : "error";
snackbar.value = true;
closeKlinikRuangDialog();
};
</script>
<style scoped lang="scss">
@@ -339,6 +472,144 @@ const handleCallPatient = () => {
background: var(--color-neutral-100);
}
/* Dialog Klinik Ruang Styles */
.dialog-card {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.dialog-header {
background: linear-gradient(135deg, var(--color-warning-600) 0%, var(--color-primary-700) 100%);
color: var(--color-neutral-100);
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.dialog-header-warning {
background: linear-gradient(135deg, var(--color-warning-600) 0%, var(--color-primary-700) 100%);
}
.headline-4 {
font-size: 20px;
line-height: 28px;
font-weight: 600;
margin: 0;
}
.headline-5 {
font-size: 18px;
line-height: 24px;
font-weight: 600;
margin: 0;
color: var(--color-neutral-900);
}
.btn-close {
color: var(--color-neutral-100) !important;
}
.dialog-content {
padding: 24px !important;
background: var(--color-neutral-300);
}
.patient-card {
background: var(--color-neutral-100);
border-radius: 12px;
padding: 20px;
border: 1px solid var(--color-neutral-400);
}
.detail-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.caption-2 {
font-size: 12px;
line-height: 16px;
font-weight: 400;
}
.body-2 {
font-size: 16px;
line-height: 24px;
font-weight: 400;
}
.body-3 {
font-size: 14px;
line-height: 20px;
font-weight: 400;
}
.text-semibold {
font-weight: 600 !important;
}
.text-muted {
color: var(--color-neutral-700);
}
.expansion-panels {
background: transparent;
}
.expansion-panel {
background: var(--color-neutral-100);
border: 1px solid var(--color-neutral-400);
border-radius: 12px !important;
margin-bottom: 12px;
overflow: hidden;
}
.expansion-panel::before {
box-shadow: none !important;
}
.expansion-title {
background: var(--color-neutral-100);
min-height: 60px;
}
.expansion-header {
display: flex;
align-items: center;
}
.ruang-list {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0;
}
.ruang-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--color-neutral-300);
border: 1px solid var(--color-neutral-400);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.ruang-item:hover {
border-color: var(--color-warning-600);
background: rgba(255, 185, 95, 0.15);
}
.ruang-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
@media (max-width: 960px) {
.loket-container {
padding: 12px;
+367 -8
View File
@@ -30,14 +30,15 @@
class="mt-3"
:total-quota="150"
:used-quota="quotaUsed"
:menunggu-count="menungguCount"
:has-next="!!nextPatient"
@call="handleCall"
/>
<!-- Create Queue Buttons -->
<div class="create-buttons mt-3">
<v-row no-gutters>
<v-col cols="6" class="pr-2">
<v-row dense>
<v-col cols="4">
<v-btn
block
class="py-6"
@@ -50,7 +51,20 @@
</v-btn>
</v-col>
<v-col cols="6" class="pl-2">
<v-col cols="4">
<v-btn
block
class="py-6"
color="warning"
:disabled="!currentProcessingPatient"
@click="openKlinikRuangDialog()"
>
<v-icon start>mdi-door</v-icon>
Buat Antrean Ruang
</v-btn>
</v-col>
<v-col cols="4">
<v-btn
block
class="py-6 text-white"
@@ -73,7 +87,7 @@
<v-card-text class="pa-4">
<!-- Header with Filters -->
<div class="data-header mb-4">
<div class="section-label">DATA PASIEN</div>
<div class="section-label">CARI PASIEN</div>
<div class="filters">
<!-- Search Field -->
@@ -95,6 +109,7 @@
v-model:selected-fast-track="selectedFastTrack"
:di-loket-count="diLoketCount"
:diproses-count="currentProcessingPatient ? 1 : 0"
:waiting-count="waitingCount"
:terlambat-count="(terlambatPatients || []).length"
:pending-count="(pendingPatients || []).length"
:show-diproses="false"
@@ -136,6 +151,90 @@
@select="changeKlinik"
/>
<!-- Dialog Klinik Ruang -->
<v-dialog v-model="showKlinikRuangDialog" max-width="900px" persistent scrollable>
<v-card class="dialog-card">
<v-card-title class="dialog-header dialog-header-warning">
<span class="headline-4">Pilih Klinik Ruang</span>
<v-btn icon variant="text" size="small" class="btn-close" @click="closeKlinikRuangDialog">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-divider/>
<v-card-text class="dialog-content">
<div v-if="currentProcessingPatient" class="patient-card mb-4">
<h4 class="headline-5 mb-3">Informasi Pasien</h4>
<v-row dense>
<v-col cols="6">
<div class="detail-item">
<span class="caption-2 text-muted">Barcode</span>
<span class="body-2 text-semibold">{{ currentProcessingPatient.barcode }}</span>
</div>
</v-col>
<v-col cols="6">
<div class="detail-item">
<span class="caption-2 text-muted">No. Antrian</span>
<span class="body-2 text-semibold">{{ currentProcessingPatient.noAntrian }}</span>
</div>
</v-col>
</v-row>
</div>
<v-text-field
v-model="klinikRuangSearch"
placeholder="Cari Klinik Ruang..."
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="compact"
hide-details
class="mb-4"
/>
<v-expansion-panels class="expansion-panels">
<v-expansion-panel
v-for="klinikRuang in filteredKlinikRuang"
:key="klinikRuang.id"
class="expansion-panel"
>
<v-expansion-panel-title class="expansion-title">
<div class="expansion-header">
<v-chip size="small" color="warning" class="mr-2">
{{ klinikRuang.kodeKlinik }}
</v-chip>
<span class="body-2 text-semibold">{{ klinikRuang.namaKlinik }}</span>
</div>
<template #actions>
<v-chip size="x-small" variant="outlined">
{{ klinikRuang.ruangList.length }} Ruang
</v-chip>
</template>
</v-expansion-panel-title>
<v-expansion-panel-text>
<div class="ruang-list">
<div
v-for="ruang in klinikRuang.ruangList"
:key="ruang.nomorRuang"
class="ruang-item"
@click="buatAntreanKlinikRuang(klinikRuang, ruang)"
>
<v-icon color="warning" size="20">mdi-door</v-icon>
<div class="ruang-info">
<span class="body-3 text-semibold">Ruang {{ ruang.nomorRuang }} - {{ ruang.namaRuang }}</span>
<span class="caption-2 text-muted">Screen: {{ ruang.nomorScreen }}</span>
</div>
<v-icon size="20" color="grey">mdi-chevron-right</v-icon>
</div>
</div>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card-text>
</v-card>
</v-dialog>
<!-- Snackbar -->
<AppSnackbar
v-model="snackbar"
@@ -148,6 +247,8 @@
<script setup>
import { ref, computed } from "vue";
import { useQueue } from "@/composables/useQueue";
import { useQueueStore } from "@/stores/queueStore";
import { useMasterStore } from "@/stores/masterStore";
import PageHeader from "@/components/common/PageHeader.vue";
import CurrentPatientCard from "@/components/features/queue/CurrentPatientCard.vue";
import QueueActionsCard from "@/components/features/queue/QueueActionsCard.vue";
@@ -155,7 +256,10 @@ import PatientDataTable from "@/components/features/queue/TabelPatientData.vue";
import SelectionDialog from "@/components/common/SelectionDialog.vue";
import AppSnackbar from "@/components/common/AppSnackbar.vue";
const {
const masterStore = useMasterStore();
const queueStore = useQueueStore();
const {
snackbar,
snackbarText,
snackbarColor,
@@ -169,6 +273,8 @@ const {
diLoketPatients,
terlambatPatients,
pendingPatients,
waitingPatients,
menungguPatients,
nextPatient,
quotaUsed,
filteredKliniks,
@@ -197,6 +303,24 @@ const selectedStatus = ref("all");
const searchQuery = ref("");
const selectedFastTrack = ref(null);
// Dialog Klinik Ruang
const showKlinikRuangDialog = ref(false);
const klinikRuangSearch = ref("");
// Get klinik ruang data from masterStore
const klinikRuangList = computed(() => {
return masterStore.ruangData || [];
});
const filteredKlinikRuang = computed(() => {
if (!klinikRuangSearch.value) return klinikRuangList.value;
return klinikRuangList.value.filter(
(k) =>
k.namaKlinik.toLowerCase().includes(klinikRuangSearch.value.toLowerCase()) ||
k.kodeKlinik.toLowerCase().includes(klinikRuangSearch.value.toLowerCase())
);
});
// Fast Track options from all patients
const fastTrackOptions = computed(() => {
const normalizedValues = allPatientsForStage.value
@@ -207,25 +331,39 @@ const fastTrackOptions = computed(() => {
});
// Combine all patients with status - PRESERVE ALL PROPERTIES
// Hanya tampilkan pasien yang sudah dipanggil (bukan status "menunggu")
const allPatientsForStage = computed(() => {
const currentPatientNo = currentProcessingPatient.value?.no;
// Pasien yang sudah di loket (sudah check-in)
const diLoket = (diLoketPatients.value || []).map((p) => ({
...p, // Spread all properties first
// If this patient is currently being processed, mark as "diproses", otherwise "diloket"
status: p.no === currentPatientNo ? "diproses" : "diloket",
}));
// Pasien terlambat
const terlambat = (terlambatPatients.value || []).map((p) => ({
...p,
status: "terlambat",
}));
// Pasien pending
const pending = (pendingPatients.value || []).map((p) => ({
...p,
status: "pending",
}));
// Pasien dengan status "waiting" (sudah dipanggil tapi belum check-in)
// Ini adalah kategori baru "Menunggu" di tabel Admin Loket
const waiting = (waitingPatients.value || []).map((p) => ({
...p,
status: "waiting", // Tampilkan sebagai "waiting" atau "menunggu" di UI
}));
// Do not show waiting patients in the table until called
const combined = [...diLoket, ...terlambat, ...pending];
// Jangan tampilkan pasien dengan status "menunggu" (belum dipanggil) di tabel
// Hanya tampilkan pasien yang sudah dipanggil oleh admin loket
const combined = [...diLoket, ...waiting, ...terlambat, ...pending];
// Debug: check if fastTrack property exists
console.log("📊 AdminLoket - allPatientsForStage:", combined.length);
@@ -248,6 +386,16 @@ const diLoketCount = computed(() => {
return (diLoketPatients.value || []).filter(p => p.no !== currentPatientNo).length;
});
// Count menunggu patients (belum dipanggil)
const menungguCount = computed(() => {
return (menungguPatients.value || []).length;
});
// Count waiting patients (sudah dipanggil tapi belum check-in)
const waitingCount = computed(() => {
return (waitingPatients.value || []).length;
});
// Next queue info for CurrentPatientCard
const nextQueueInfo = computed(() => {
const currentPatientNo = currentProcessingPatient.value?.no;
@@ -288,6 +436,32 @@ const handleCallPatient = () => {
// Placeholder for text-to-speech integration
}
};
const openKlinikRuangDialog = () => {
showKlinikRuangDialog.value = true;
};
const closeKlinikRuangDialog = () => {
showKlinikRuangDialog.value = false;
klinikRuangSearch.value = "";
};
const buatAntreanKlinikRuang = (klinikRuang, ruang) => {
if (!currentProcessingPatient.value) return;
const result = queueStore.createAntreanKlinikRuang(
klinikRuang,
ruang,
currentProcessingPatient.value,
"loket"
);
snackbarText.value = result.message;
snackbarColor.value = result.success ? "success" : "error";
snackbar.value = true;
closeKlinikRuangDialog();
};
</script>
<style scoped lang="scss">
@@ -295,19 +469,66 @@ const handleCallPatient = () => {
background: var(--color-neutral-300);
min-height: 100vh;
padding: 16px;
overflow-x: hidden; /* Prevent horizontal scroll */
width: 100%;
max-width: 100vw;
box-sizing: border-box;
}
.content-grid {
margin: 0 -8px;
margin: 0;
width: 100%;
box-sizing: border-box;
}
.content-grid > .v-col {
padding: 8px;
box-sizing: border-box;
min-width: 0; /* Prevent flex item overflow */
}
.content-grid > .v-col:first-child {
padding-left: 0;
}
.content-grid > .v-col:last-child {
padding-right: 0;
}
.create-buttons {
width: 100%;
box-sizing: border-box;
overflow: hidden;
}
.create-buttons :deep(.v-row) {
margin: 0;
width: 100%;
box-sizing: border-box;
}
.create-buttons :deep(.v-col) {
padding: 0 4px;
box-sizing: border-box;
flex: 0 0 33.333333%;
max-width: 33.333333%;
}
.create-buttons :deep(.v-col:first-child) {
padding-left: 0;
}
.create-buttons :deep(.v-col:last-child) {
padding-right: 0;
}
.create-buttons .v-btn {
text-transform: none;
font-weight: 600;
width: 100%;
max-width: 100%;
box-sizing: border-box;
min-width: 0;
}
.sticky-wrapper {
@@ -375,6 +596,144 @@ const handleCallPatient = () => {
background: var(--color-neutral-100);
}
/* Dialog Klinik Ruang Styles */
.dialog-card {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.dialog-header {
background: linear-gradient(135deg, var(--color-warning-600) 0%, var(--color-primary-700) 100%);
color: var(--color-neutral-100);
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.dialog-header-warning {
background: linear-gradient(135deg, var(--color-warning-600) 0%, var(--color-primary-700) 100%);
}
.headline-4 {
font-size: 20px;
line-height: 28px;
font-weight: 600;
margin: 0;
}
.headline-5 {
font-size: 18px;
line-height: 24px;
font-weight: 600;
margin: 0;
color: var(--color-neutral-900);
}
.btn-close {
color: var(--color-neutral-100) !important;
}
.dialog-content {
padding: 24px !important;
background: var(--color-neutral-300);
}
.patient-card {
background: var(--color-neutral-100);
border-radius: 12px;
padding: 20px;
border: 1px solid var(--color-neutral-400);
}
.detail-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.caption-2 {
font-size: 12px;
line-height: 16px;
font-weight: 400;
}
.body-2 {
font-size: 16px;
line-height: 24px;
font-weight: 400;
}
.body-3 {
font-size: 14px;
line-height: 20px;
font-weight: 400;
}
.text-semibold {
font-weight: 600 !important;
}
.text-muted {
color: var(--color-neutral-700);
}
.expansion-panels {
background: transparent;
}
.expansion-panel {
background: var(--color-neutral-100);
border: 1px solid var(--color-neutral-400);
border-radius: 12px !important;
margin-bottom: 12px;
overflow: hidden;
}
.expansion-panel::before {
box-shadow: none !important;
}
.expansion-title {
background: var(--color-neutral-100);
min-height: 60px;
}
.expansion-header {
display: flex;
align-items: center;
}
.ruang-list {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0;
}
.ruang-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--color-neutral-300);
border: 1px solid var(--color-neutral-400);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.ruang-item:hover {
border-color: var(--color-warning-600);
background: rgba(255, 185, 95, 0.15);
}
.ruang-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
@media (max-width: 960px) {
.loket-container {
padding: 12px;
@@ -222,7 +222,7 @@ const displayedRuang = computed(() => {
return ruangListForKlinik.value.map(ruang => {
const queues = klinikPatients.value
.filter(p => p.klinik === ruang.namaKlinik)
.filter(p => p.klinik === ruang.namaKlinik && (p.ruang === ruang.namaRuang || !p.ruang))
.sort((a, b) => {
const statusPriority = {
'di-loket': 1,
+99 -19
View File
@@ -222,6 +222,7 @@ export const useQueueStore = defineStore('queue', () => {
return {
all: patients,
waiting: patients.filter(p => p.status === 'waiting'),
menunggu: patients.filter(p => p.status === 'menunggu'), // Pasien yang belum dipanggil
diLoket: patients.filter(p => p.status === 'di-loket'),
terlambat: patients.filter(p => p.status === 'terlambat'),
pending: patients.filter(p => p.status === 'pending'),
@@ -253,7 +254,10 @@ export const useQueueStore = defineStore('queue', () => {
};
const targetStage = stageMap[adminType];
// Prioritaskan pasien dengan status 'menunggu' (yang belum dipanggil)
const nextPatient = allPatients.value.find(p =>
p.status === 'menunggu' && p.processStage === targetStage
) || allPatients.value.find(p =>
p.status === 'waiting' && p.processStage === targetStage
);
@@ -261,17 +265,28 @@ export const useQueueStore = defineStore('queue', () => {
return { success: false, message: "Tidak ada pasien selanjutnya" };
}
if (quotaUsed.value >= 150) {
return { success: false, message: "Quota sudah penuh" };
}
// Hitung kuota yang tersedia
const menungguCount = allPatients.value.filter(p =>
p.status === 'menunggu' && p.processStage === targetStage
).length;
const diLoketCount = allPatients.value.filter(p =>
p.status === 'di-loket' && p.processStage === targetStage
).length;
const availableQuota = 150 - diLoketCount;
// Update dengan cara yang Vue reactive
const index = allPatients.value.findIndex(p => p.no === nextPatient.no);
if (index !== -1) {
allPatients.value[index] = { ...allPatients.value[index], status: "di-loket" };
if (availableQuota <= 0) {
return { success: false, message: "Kuota sudah penuh" };
}
quotaUsed.value++;
if (menungguCount === 0) {
return { success: false, message: "Tidak ada pasien yang menunggu untuk dipanggil" };
}
// Update status dari 'menunggu' menjadi 'waiting' (sudah dipanggil, bisa check-in)
const index = allPatients.value.findIndex(p => p.no === nextPatient.no);
if (index !== -1) {
allPatients.value[index] = { ...allPatients.value[index], status: "waiting" };
}
return {
success: true,
@@ -287,30 +302,49 @@ export const useQueueStore = defineStore('queue', () => {
};
const targetStage = stageMap[adminType];
// Prioritaskan pasien dengan status 'menunggu' (yang belum dipanggil)
const menungguList = allPatients.value.filter(p =>
p.status === 'menunggu' && p.processStage === targetStage
);
const waitingList = allPatients.value.filter(p =>
p.status === 'waiting' && p.processStage === targetStage
);
const patientsToCall = waitingList.slice(0, count);
// Gabungkan: menunggu dulu, baru waiting
const combinedList = [...menungguList, ...waitingList];
if (patientsToCall.length === 0) {
if (combinedList.length === 0) {
return { success: false, message: "Tidak ada pasien yang menunggu" };
}
if (quotaUsed.value + patientsToCall.length > 150) {
return { success: false, message: "Quota tidak mencukupi" };
// Hitung kuota yang tersedia
const diLoketCount = allPatients.value.filter(p =>
p.status === 'di-loket' && p.processStage === targetStage
).length;
const availableQuota = 150 - diLoketCount;
// Kuota yang bisa dipanggil = min(count yang diminta, jumlah pasien menunggu, kuota tersedia)
const maxCallable = Math.min(count, menungguList.length, availableQuota);
if (maxCallable <= 0) {
if (menungguList.length === 0) {
return { success: false, message: "Tidak ada pasien yang menunggu untuk dipanggil" };
}
if (availableQuota <= 0) {
return { success: false, message: "Kuota sudah penuh. Tidak bisa memanggil pasien lagi" };
}
}
// Update dengan cara yang Vue reactive
const patientsToCall = combinedList.slice(0, maxCallable);
// Update status dari 'menunggu' menjadi 'waiting' (sudah dipanggil, bisa check-in)
patientsToCall.forEach((patient) => {
const index = allPatients.value.findIndex(p => p.no === patient.no);
if (index !== -1) {
allPatients.value[index] = { ...allPatients.value[index], status: "di-loket" };
allPatients.value[index] = { ...allPatients.value[index], status: "waiting" };
}
});
quotaUsed.value += patientsToCall.length;
return {
success: true,
message: `Memanggil ${patientsToCall.length} pasien ke loket`,
@@ -495,6 +529,41 @@ export const useQueueStore = defineStore('queue', () => {
};
};
const createAntreanKlinikRuang = (klinikRuang, ruang, patient = null, adminType = 'klinik') => {
const newNo = allPatients.value.length + 1;
const timestamp = new Date();
const barcode = patient ? patient.barcode : `250811${String(timestamp.getTime()).slice(-6)}`;
const newPatient = {
no: newNo,
jamPanggil: `${String(timestamp.getHours()).padStart(2, "0")}:${String(
timestamp.getMinutes()
).padStart(2, "0")}`,
barcode: barcode,
noAntrian: `KR${String(newNo).padStart(4, "0")} | ${klinikRuang.namaKlinik} - Ruang ${ruang.nomorRuang} - ${barcode}`,
shift: "Shift 1",
klinik: klinikRuang.namaKlinik,
ruang: ruang.namaRuang,
kodeKlinik: klinikRuang.kodeKlinik,
nomorRuang: ruang.nomorRuang,
nomorScreen: ruang.nomorScreen,
fastTrack: "TIDAK",
pembayaran: patient ? patient.pembayaran : "UMUM",
status: "waiting",
processStage: "klinik",
createdAt: timestamp.toISOString(),
referencePatient: patient ? patient.noAntrian : null,
};
allPatients.value.push(newPatient);
return {
success: true,
message: `Antrean ${klinikRuang.namaKlinik} Ruang ${ruang.nomorRuang} berhasil dibuat dan akan ditampilkan di layar antrian`,
patient: newPatient,
};
};
const changeKlinik = (patient, newKlinik, adminType = 'loket') => {
const patientIndex = allPatients.value.findIndex((p) => p.no === patient.no);
@@ -548,8 +617,9 @@ export const useQueueStore = defineStore('queue', () => {
// 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';
// Status awal untuk pasien dari anjungan adalah "menunggu" (belum dipanggil)
// Hanya setelah dipanggil oleh admin loket, status berubah menjadi "waiting" (bisa check-in)
const status = 'menunggu';
const newPatient = {
no: newNo,
@@ -641,7 +711,16 @@ export const useQueueStore = defineStore('queue', () => {
const patient = allPatients.value[patientIndex];
console.log('✅ Patient found:', patient);
// Only allow check-in if status is waiting or pending
// Only allow check-in if status is waiting (sudah dipanggil) or pending
// Pasien dengan status "menunggu" belum bisa check-in (belum dipanggil)
if (patient.status === 'menunggu') {
console.log('⚠️ Patient status is menunggu (belum dipanggil):', patient.status);
return {
success: false,
message: `Pasien belum dipanggil oleh admin loket. Mohon menunggu hingga nomor antrean Anda dipanggil.`
};
}
if (patient.status !== 'waiting' && patient.status !== 'pending') {
console.log('⚠️ Patient status is not waiting/pending:', patient.status);
return {
@@ -685,6 +764,7 @@ export const useQueueStore = defineStore('queue', () => {
processPatient,
createAntreanKlinik,
createAntreanPenunjang,
createAntreanKlinikRuang,
changeKlinik,
processNextQueue,
getPatientsByStage,