feat: implement Grand Paviliun dashboard with specialized room monitoring, queue management, and modular components

This commit is contained in:
Fanrouver
2026-07-10 09:42:30 +07:00
parent 61772105fd
commit d0eee7d062
14 changed files with 1052 additions and 44 deletions

No files matched your search

@@ -0,0 +1,174 @@
<template>
<div class="theme-grand-paviliun gp-container">
<!-- Header -->
<div class="gp-header">
<div class="header-left">
<v-icon size="24" color="white" class="mr-3">mdi-domain</v-icon>
<div>
<h1 class="header-title">Klinik Admin Grand Paviliun</h1>
<p class="header-date">{{ currentDate }}</p>
</div>
</div>
<div class="header-right">
<v-btn color="#F97316" variant="flat" class="text-white font-weight-bold">
<v-icon start size="18">mdi-account-cog</v-icon>
KELOLA PASIEN
</v-btn>
</div>
</div>
<!-- Search Bar -->
<div class="gp-search-bar">
<v-text-field
v-model="searchQuery"
placeholder="Cari pasien di semua ruang (barcode, nomor antrian, nama...)"
density="compact"
hide-details
prepend-inner-icon="mdi-magnify"
variant="outlined"
bg-color="white"
class="search-input"
></v-text-field>
</div>
<!-- Main Content -->
<div class="gp-main-content">
<div class="kanban-board">
<GpRoomColumn
v-for="clinic in filteredClinics"
:key="clinic.kodeKlinik"
:clinic-name="clinic.namaKlinik"
:current-patient="clinic.currentPatient"
:queue-patients="clinic.queuePatients"
:room-options="clinic.roomOptions"
@pemeriksaan-awal="(p, r) => handlePemeriksaanAwal(p, clinic.kodeKlinik, r)"
@panggil-pemeriksaan="(p, r) => handlePanggilPemeriksaan(p, clinic.kodeKlinik, r)"
@action-pending="p => handleAction(p, 'pending', clinic.kodeKlinik)"
@action-selesai="p => handleAction(p, 'selesai', clinic.kodeKlinik)"
@process-patient="p => handleProcess(p, clinic.kodeKlinik, clinic.roomOptions?.[0]?.nomorRuang || '1')"
/>
</div>
<!-- Right Sidebar -->
<GpMonitorSidebar :rooms="monitorRooms" />
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import GpRoomColumn from '../GrandPaviliun/GpRoomColumn.vue';
import GpMonitorSidebar from '../GrandPaviliun/GpMonitorSidebar.vue';
import { useGrandPaviliun } from '@/composables/useGrandPaviliun';
import '@/assets/scss/eksekutif.scss';
const {
clinics,
monitorRooms,
fetchEksekutifData,
handlePemeriksaanAwal,
handlePanggilPemeriksaan,
processPatientAction
} = useGrandPaviliun();
const searchQuery = ref('');
const filteredClinics = computed(() => {
if (!searchQuery.value) return clinics.value;
const lowerSearch = searchQuery.value.toLowerCase();
// Filter by patient barcode, queue number, or name in the clinic columns
return clinics.value.map(clinic => {
const isCurrentMatch = clinic.currentPatient && (
(clinic.currentPatient.noAntrian || '').toLowerCase().includes(lowerSearch) ||
(clinic.currentPatient.barcode || '').toLowerCase().includes(lowerSearch) ||
(clinic.currentPatient.name || '').toLowerCase().includes(lowerSearch)
);
const matchedQueue = clinic.queuePatients.filter(p =>
(p.noAntrian || '').toLowerCase().includes(lowerSearch) ||
(p.barcode || '').toLowerCase().includes(lowerSearch) ||
(p.name || '').toLowerCase().includes(lowerSearch)
);
return {
...clinic,
currentPatient: isCurrentMatch ? clinic.currentPatient : null,
queuePatients: matchedQueue
};
}).filter(c => c.currentPatient || c.queuePatients.length > 0);
});
// Action Handlers
const handleAction = async (patient, action, kodeKlinik) => {
// Use the current room of the patient or fallback to '1'
const nomorRuang = patient.nomorRuang || '1';
await processPatientAction(patient, action, kodeKlinik, nomorRuang);
};
const handleProcess = async (patient, kodeKlinik, fallbackRuang) => {
const action = patient.status === 'pending' ? 'waiting' : 'proses';
await processPatientAction(patient, action, kodeKlinik, fallbackRuang);
};
// Date Formatting
const currentDate = computed(() => {
const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
return new Date().toLocaleDateString('id-ID', options);
});
// Load data when mounted
fetchEksekutifData();
</script>
<style scoped>
.gp-container {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
background-color: var(--gp-bg, #F5F7FA);
}
.gp-header {
background-color: var(--gp-primary, #3F51B5);
color: white;
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left {
display: flex;
align-items: center;
}
.header-title {
font-size: 18px;
font-weight: 700;
margin: 0;
line-height: 1.2;
}
.header-date {
font-size: 12px;
opacity: 0.8;
margin: 0;
}
.gp-search-bar {
padding: 12px 24px;
background: white;
border-bottom: 1px solid var(--gp-border, #e5e7eb);
}
.search-input {
max-width: 100%;
}
.gp-main-content {
flex: 1;
display: flex;
overflow: hidden;
}
.kanban-board {
flex: 1;
display: flex;
overflow-x: auto;
padding: 16px 24px;
align-items: stretch;
}
</style>
@@ -0,0 +1,135 @@
<template>
<div class="gp-current-patient">
<div class="header-row">
<span class="label">SEDANG DIPROSES</span>
<div class="badges">
<button class="action-badge" @click="$emit('action-pending')"><GpStatusBadge status="PENDING" /></button>
<button class="action-badge ml-1" @click="$emit('action-selesai')"><GpStatusBadge status="SELESAI" /></button>
</div>
</div>
<div class="patient-big-info">
<div class="queue-number">{{ queueNumber || '-' }}</div>
<div class="room-indicator" v-if="roomName">{{ roomName }}</div>
</div>
<div class="action-section">
<label class="input-label">Tujukan ke ruang*</label>
<v-select
v-model="selectedRoom"
:items="roomOptions"
item-title="namaRuang"
item-value="nomorRuang"
placeholder="Pilih Ruang Pemeriksaan"
density="compact"
variant="outlined"
hide-details
class="room-select mb-3"
></v-select>
<div class="action-buttons">
<v-btn
color="#3F51B5"
variant="flat"
class="flex-1 text-white text-none font-weight-bold"
size="small"
@click="$emit('pemeriksaan-awal')"
>
<v-icon start size="16">mdi-stethoscope</v-icon>
Pemeriksaan Awal
</v-btn>
<v-btn
color="#3F51B5"
variant="flat"
class="flex-1 text-white text-none font-weight-bold ml-2"
size="small"
@click="$emit('panggil-pemeriksaan')"
>
<v-icon start size="16">mdi-bullhorn</v-icon>
Panggil Pemeriksaan
</v-btn>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue';
import GpStatusBadge from './GpStatusBadge.vue';
const props = defineProps({
queueNumber: { type: String, default: '' },
roomName: { type: String, default: '' },
roomOptions: { type: Array, default: () => [] }
});
const emit = defineEmits(['pemeriksaan-awal', 'panggil-pemeriksaan', 'action-pending', 'action-selesai', 'update:selectedRoom']);
const selectedRoom = ref(props.roomOptions?.[0]?.nomorRuang || null);
watch(selectedRoom, (newVal) => {
emit('update:selectedRoom', newVal);
});
</script>
<style scoped>
.gp-current-patient {
background: white;
padding: 16px;
border-bottom: 1px solid var(--gp-border, #e5e7eb);
}
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.label {
font-size: 10px;
font-weight: 700;
color: var(--gp-text-secondary, #6b7280);
}
.patient-big-info {
text-align: center;
margin-bottom: 24px;
}
.queue-number {
font-size: 36px;
font-weight: 800;
color: var(--gp-text-primary, #1f2937);
line-height: 1.2;
}
.room-indicator {
font-size: 14px;
font-weight: 700;
color: var(--gp-room-number, #F97316);
margin-top: 4px;
text-transform: uppercase;
}
.input-label {
display: block;
font-size: 11px;
font-weight: 600;
color: var(--gp-text-secondary, #6b7280);
margin-bottom: 4px;
}
.room-select {
/* vuetify default is fine */
}
.action-buttons {
display: flex;
width: 100%;
}
.flex-1 {
flex: 1;
}
.action-badge {
background: none;
border: none;
padding: 0;
cursor: pointer;
outline: none;
}
.action-badge:hover {
opacity: 0.8;
}
</style>
@@ -0,0 +1,98 @@
<template>
<div class="gp-monitor-sidebar">
<div class="sidebar-header">
<v-icon size="18" class="mr-2" color="#3F51B5">mdi-monitor-dashboard</v-icon>
<span class="title">MONITOR RUANG</span>
</div>
<div class="stats-row">
<div class="stat-box box-success">
<div class="stat-label text-success">TERSEDIA</div>
<div class="stat-value text-success">{{ availableCount }}</div>
</div>
<div class="stat-box box-danger ml-2">
<div class="stat-label text-danger">SIBUK</div>
<div class="stat-value text-danger">{{ busyCount }}</div>
</div>
</div>
<div class="rooms-list">
<GpRoomMonitorCard
v-for="room in rooms"
:key="room.id"
:room-name="room.name"
:active-patient="room.activePatient"
:clinic-name="room.clinicName"
/>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import GpRoomMonitorCard from './GpRoomMonitorCard.vue';
const props = defineProps({
rooms: { type: Array, default: () => [] }
});
const availableCount = computed(() => props.rooms.filter(r => !r.activePatient).length);
const busyCount = computed(() => props.rooms.filter(r => r.activePatient).length);
</script>
<style scoped>
.gp-monitor-sidebar {
width: 300px;
background: white;
height: 100%;
display: flex;
flex-direction: column;
border-left: 1px solid var(--gp-border, #e5e7eb);
}
.sidebar-header {
padding: 16px;
display: flex;
align-items: center;
border-bottom: 1px solid var(--gp-border, #e5e7eb);
}
.title {
font-size: 14px;
font-weight: 700;
color: var(--gp-text-primary, #1f2937);
}
.stats-row {
display: flex;
padding: 16px;
border-bottom: 1px solid var(--gp-border, #e5e7eb);
}
.stat-box {
flex: 1;
padding: 12px;
border-radius: 2px;
}
.box-success {
background: #ECFDF5;
border: 1px solid #D1FAE5;
}
.box-danger {
background: #FFF7ED;
border: 1px solid #FFEDD5;
}
.stat-label {
font-size: 10px;
font-weight: 700;
margin-bottom: 4px;
}
.stat-value {
font-size: 20px;
font-weight: 700;
}
.text-success { color: var(--gp-success, #10b981); }
.text-danger { color: var(--gp-room-number, #F97316); } /* Orange */
.rooms-list {
padding: 16px;
overflow-y: auto;
flex: 1;
}
</style>
+63
View File
@@ -0,0 +1,63 @@
<template>
<div class="gp-queue-list-container">
<div class="list-header">
<span class="title">DAFTAR ANTRIAN</span>
<span class="count-badge">{{ patients.length }}</span>
</div>
<div class="list-body">
<GpQueueListItem
v-for="patient in patients"
:key="patient.id || patient.noAntrian"
:queue-number="patient.noAntrian"
:status="patient.status"
:is-active="patient.isActive"
@view-detail="$emit('view-detail', patient)"
@process-patient="$emit('process-patient', patient)"
/>
</div>
</div>
</template>
<script setup>
import GpQueueListItem from './GpQueueListItem.vue';
defineProps({
patients: { type: Array, default: () => [] }
});
defineEmits(['view-detail', 'process-patient']);
</script>
<style scoped>
.gp-queue-list-container {
display: flex;
flex-direction: column;
flex: 1;
background: white;
min-height: 200px;
}
.list-header {
padding: 12px 16px;
background: #EEF2F6; /* Light blue-gray from design */
border-bottom: 1px solid var(--gp-border, #e5e7eb);
display: flex;
align-items: center;
gap: 8px;
}
.title {
font-size: 10px;
font-weight: 700;
color: var(--gp-text-secondary, #6b7280);
}
.count-badge {
background: #D1D5DB; /* Gray */
color: white;
border-radius: 12px;
padding: 2px 8px;
font-size: 10px;
font-weight: 700;
}
.list-body {
overflow-y: auto;
}
</style>
@@ -0,0 +1,47 @@
<template>
<div :class="['gp-queue-list-item', { active: isActive }]">
<div class="item-left">
<span class="queue-number">{{ queueNumber }}</span>
</div>
<div class="item-right">
<GpStatusBadge v-if="status && !isActive" :status="status" />
<v-btn v-if="!isActive" icon="mdi-play" size="x-small" variant="text" color="success" class="ml-1" @click="$emit('process-patient')" />
<v-btn v-if="isActive" icon="mdi-eye" size="x-small" variant="text" color="#3F51B5" @click="$emit('view-detail')" />
</div>
</div>
</template>
<script setup>
import GpStatusBadge from './GpStatusBadge.vue';
defineProps({
queueNumber: { type: String, required: true },
status: { type: String, default: '' },
isActive: { type: Boolean, default: false }
});
defineEmits(['view-detail', 'process-patient']);
</script>
<style scoped>
.gp-queue-list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--gp-border, #e5e7eb);
background-color: var(--gp-surface, #fff);
transition: background-color 0.2s ease;
}
.gp-queue-list-item:last-child {
border-bottom: none;
}
.gp-queue-list-item.active {
background-color: #E3EBFF; /* Light blue highlighting active patient */
}
.queue-number {
font-size: 14px;
font-weight: 700;
color: var(--gp-text-primary, #1f2937);
}
</style>
+95
View File
@@ -0,0 +1,95 @@
<template>
<div class="gp-room-column">
<div class="column-header">
<v-icon size="20" class="mr-2" color="white">mdi-domain</v-icon>
<span class="column-title">{{ clinicName.toUpperCase() }}</span>
</div>
<div class="column-content">
<template v-if="currentPatient || queuePatients.length > 0">
<GpCurrentPatient
v-if="currentPatient"
:queue-number="currentPatient.noAntrian"
:room-name="currentPatient.roomIndicator"
:room-options="roomOptions"
@pemeriksaan-awal="$emit('pemeriksaan-awal', currentPatient, $event)"
@panggil-pemeriksaan="$emit('panggil-pemeriksaan', currentPatient, $event)"
@action-pending="$emit('action-pending', currentPatient)"
@action-selesai="$emit('action-selesai', currentPatient)"
/>
<GpQueueList
:patients="queuePatients"
@view-detail="p => $emit('view-detail', p)"
@process-patient="p => $emit('process-patient', p)"
/>
</template>
<div v-else class="empty-column-state">
<v-icon size="48" color="#D1D5DB" class="mb-4">mdi-account-off</v-icon>
<div class="empty-text">TIDAK ADA PASIEN YANG DIPROSES</div>
</div>
</div>
</div>
</template>
<script setup>
import GpCurrentPatient from './GpCurrentPatient.vue';
import GpQueueList from './GpQueueList.vue';
defineProps({
clinicName: { type: String, required: true },
currentPatient: { type: Object, default: null },
queuePatients: { type: Array, default: () => [] },
roomOptions: { type: Array, default: () => [] }
});
defineEmits(['pemeriksaan-awal', 'panggil-pemeriksaan', 'view-detail', 'action-pending', 'action-selesai', 'process-patient']);
</script>
<style scoped>
.gp-room-column {
width: 380px;
min-width: 380px;
background: var(--gp-bg, #F5F7FA);
border-radius: 4px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
margin-right: 16px;
}
.column-header {
background: var(--gp-primary, #3F51B5);
color: white;
padding: 12px 16px;
display: flex;
align-items: center;
}
.column-title {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.5px;
}
.column-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: white; /* Make the whole column body white */
}
.empty-column-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px;
text-align: center;
background: white;
}
.empty-text {
font-size: 11px;
font-weight: 700;
color: var(--gp-text-secondary, #6b7280);
}
</style>
@@ -0,0 +1,71 @@
<template>
<div class="gp-room-monitor-card" :class="{ 'is-busy': isBusy }">
<div class="card-header">
<span class="room-name">{{ roomName }}</span>
<span class="room-status" :class="isBusy ? 'text-danger' : 'text-success'">
{{ isBusy ? 'SIBUK' : 'TERSEDIA' }}
</span>
</div>
<div class="card-body">
<span v-if="isBusy" class="active-patient">{{ activePatient }} - {{ clinicName }}</span>
<span v-else class="waiting-text">Menunggu pasien...</span>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
roomName: { type: String, required: true },
activePatient: { type: String, default: null }, // e.g. "AI002"
clinicName: { type: String, default: '' },
});
const isBusy = computed(() => !!props.activePatient);
</script>
<style scoped>
.gp-room-monitor-card {
background: var(--gp-surface, #fff);
border: 1px solid var(--gp-border, #e5e7eb);
border-left: 4px solid var(--gp-success, #10b981);
border-radius: 2px;
padding: 12px;
margin-bottom: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.02);
}
.gp-room-monitor-card.is-busy {
border-left-color: var(--gp-room-number, #F97316); /* Orange */
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.room-name {
font-size: 13px;
font-weight: 700;
color: var(--gp-text-primary, #1f2937);
}
.room-status {
font-size: 10px;
font-weight: 700;
}
.text-success { color: var(--gp-success, #10b981); }
.text-danger { color: var(--gp-room-number, #F97316); } /* Matching image orange */
.card-body {
font-size: 13px;
}
.active-patient {
color: var(--gp-primary, #3F51B5);
font-weight: 700;
}
.waiting-text {
color: var(--gp-text-secondary, #6b7280);
font-style: italic;
font-size: 12px;
}
</style>
@@ -0,0 +1,40 @@
<template>
<span :class="['gp-badge', badgeClass]">
{{ label }}
</span>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
status: { type: String, required: true },
text: { type: String, default: '' }
});
const label = computed(() => props.text || props.status.toUpperCase());
const badgeClass = computed(() => {
const s = props.status.toLowerCase();
if (s === 'pending' || s === 'sibuk') return 'gp-badge-danger';
if (s === 'selesai' || s === 'tersedia' || s === 'proses') return 'gp-badge-success';
return 'gp-badge-default';
});
</script>
<style scoped>
.gp-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 6px;
border-radius: 2px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
color: white;
}
.gp-badge-danger { background-color: var(--gp-danger, #ef4444); }
.gp-badge-success { background-color: var(--gp-success, #10b981); }
.gp-badge-default { background-color: var(--gp-text-secondary, #6b7280); }
</style>