353 lines
19 KiB
PHP
353 lines
19 KiB
PHP
<?php
|
|
/**
|
|
* Realtime Executive KPI Dashboard View (Glassmorphism)
|
|
*/
|
|
?>
|
|
<div x-data="dashboardApp" class="space-y-6">
|
|
|
|
<!-- Header & Branch Selector -->
|
|
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 glass-card p-6 rounded-2xl border">
|
|
<div>
|
|
<h1 class="text-2xl font-bold tracking-tight bg-gradient-to-r from-emerald-400 to-cyan-400 bg-clip-text text-transparent">
|
|
แดชบอร์ดผู้บริหาร & สถิติการให้บริการ (Executive KPI)
|
|
</h1>
|
|
<p class="text-sm text-subtle mt-1">อัปเดตแบบ Realtime พร้อมระบบคำนวณภาระงานหมอนวดด้วย AI (Workload Balancing)</p>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<select x-model="selectedBranch" @change="fetchData()" class="glass-input text-sm font-medium py-2 px-4 rounded-xl">
|
|
<option value="1">สาขาทองหล่อ (สำนักงานใหญ่)</option>
|
|
<option value="2">สาขาสยามสแควร์</option>
|
|
<option value="3">สาขาเชียงใหม่</option>
|
|
</select>
|
|
<button @click="fetchData()" class="p-2.5 rounded-xl bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 hover:bg-emerald-500/20 transition-all" title="รีเฟรชข้อมูล">
|
|
<i class="bi bi-arrow-clockwise text-lg" :class="loading ? 'animate-spin' : ''"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 4 Executive KPI Stat Cards -->
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
|
|
<!-- 1. Total Queues Today -->
|
|
<div class="glass-card p-5 rounded-2xl border relative overflow-hidden group hover:border-emerald-500/50 transition-all">
|
|
<div class="flex justify-between items-start mb-3">
|
|
<span class="text-sm font-medium text-subtle">คิวรับบริการวันนี้</span>
|
|
<div class="w-10 h-10 rounded-xl bg-emerald-500/10 flex items-center justify-center text-emerald-400 text-lg group-hover:scale-110 transition-transform">
|
|
<i class="bi bi-people-fill"></i>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-baseline gap-2">
|
|
<span class="text-3xl font-bold font-mono" x-text="stats.total_queues || 0">0</span>
|
|
<span class="text-xs text-emerald-400 font-medium">+12% จากเมื่อวาน</span>
|
|
</div>
|
|
<div class="w-full bg-slate-700/30 h-1.5 rounded-full mt-4 overflow-hidden">
|
|
<div class="bg-gradient-to-r from-emerald-500 to-cyan-500 h-full rounded-full" style="width: 75%"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 2. Average Wait Time -->
|
|
<div class="glass-card p-5 rounded-2xl border relative overflow-hidden group hover:border-cyan-500/50 transition-all">
|
|
<div class="flex justify-between items-start mb-3">
|
|
<span class="text-sm font-medium text-subtle">เวลารอเฉลี่ย (เป้าหมาย <15 นาที)</span>
|
|
<div class="w-10 h-10 rounded-xl bg-cyan-500/10 flex items-center justify-center text-cyan-400 text-lg group-hover:scale-110 transition-transform">
|
|
<i class="bi bi-stopwatch-fill"></i>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-baseline gap-2">
|
|
<span class="text-3xl font-bold font-mono text-cyan-400" x-text="stats.avg_wait_time || '0.0'">0.0</span>
|
|
<span class="text-sm font-medium text-subtle">นาที</span>
|
|
</div>
|
|
<p class="text-xs text-emerald-400 mt-4 flex items-center gap-1">
|
|
<i class="bi bi-check-circle-fill"></i> อยู่ในเกณฑ์มาตรฐาน SLA
|
|
</p>
|
|
</div>
|
|
|
|
<!-- 3. Estimated Revenue Today -->
|
|
<div class="glass-card p-5 rounded-2xl border relative overflow-hidden group hover:border-amber-500/50 transition-all">
|
|
<div class="flex justify-between items-start mb-3">
|
|
<span class="text-sm font-medium text-subtle">รายได้ประมาณการวันนี้</span>
|
|
<div class="w-10 h-10 rounded-xl bg-amber-500/10 flex items-center justify-center text-amber-400 text-lg group-hover:scale-110 transition-transform">
|
|
<i class="bi bi-currency-bitcoin"></i>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-baseline gap-2">
|
|
<span class="text-3xl font-bold font-mono text-amber-400" x-text="formatCurrency(stats.estimated_revenue || 0)">0</span>
|
|
<span class="text-sm font-medium text-subtle">บาท</span>
|
|
</div>
|
|
<p class="text-xs text-subtle mt-4">รวม PromptPay และเงินสด</p>
|
|
</div>
|
|
|
|
<!-- 4. Room Occupancy Rate -->
|
|
<div class="glass-card p-5 rounded-2xl border relative overflow-hidden group hover:border-teal-500/50 transition-all">
|
|
<div class="flex justify-between items-start mb-3">
|
|
<span class="text-sm font-medium text-subtle">อัตราการใช้งานห้องนวด</span>
|
|
<div class="w-10 h-10 rounded-xl bg-teal-500/10 flex items-center justify-center text-teal-400 text-lg group-hover:scale-110 transition-transform">
|
|
<i class="bi bi-door-open-fill"></i>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-baseline gap-2">
|
|
<span class="text-3xl font-bold font-mono text-teal-400" x-text="stats.room_occupancy_percent || 0">0</span>
|
|
<span class="text-sm font-medium text-subtle">%</span>
|
|
</div>
|
|
<div class="w-full bg-slate-700/30 h-1.5 rounded-full mt-4 overflow-hidden">
|
|
<div class="bg-gradient-to-r from-teal-500 to-emerald-500 h-full rounded-full" :style="`width: ${stats.room_occupancy_percent || 60}%`"></div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- Charts Row: Workload Balancer & Peak Hours -->
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
|
|
<!-- Left: Therapist Workload Balancing (Horizontal Bar Chart) -->
|
|
<div class="glass-card p-6 rounded-2xl border flex flex-col justify-between">
|
|
<div>
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h3 class="font-bold text-lg">สมดุลภาระงานหมอนวด (Workload Balancer)</h3>
|
|
<p class="text-xs text-subtle">คำนวณชั่วโมงปฏิบัติงานและคะแนนความเหนื่อยล้าด้วย AI</p>
|
|
</div>
|
|
<span class="px-2.5 py-1 rounded-full text-xs font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">AI Optimized</span>
|
|
</div>
|
|
<div class="h-64 relative w-full">
|
|
<canvas id="workloadChart"></canvas>
|
|
</div>
|
|
</div>
|
|
<div class="mt-4 pt-3 border-t border-slate-500/20 flex justify-between text-xs text-subtle">
|
|
<span><i class="bi bi-circle-fill text-emerald-400 mr-1"></i> ปฏิบัติงานแล้ว (นาที)</span>
|
|
<span><i class="bi bi-circle-fill text-slate-600 mr-1"></i> โควตาคงเหลือต่อวัน</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right: Peak Arrival Hours (Line/Area Chart) -->
|
|
<div class="glass-card p-6 rounded-2xl border flex flex-col justify-between">
|
|
<div>
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h3 class="font-bold text-lg">ช่วงเวลาหนาแน่นผู้รับบริการ (Peak Hours)</h3>
|
|
<p class="text-xs text-subtle">สถิติปริมาณผู้ป่วยและผู้รับบริการแบ่งตามช่วงเวลา 09:00 - 20:00</p>
|
|
</div>
|
|
<span class="px-2.5 py-1 rounded-full text-xs font-semibold bg-cyan-500/10 text-cyan-400 border border-cyan-500/20">Realtime Feed</span>
|
|
</div>
|
|
<div class="h-64 relative w-full">
|
|
<canvas id="peakChart"></canvas>
|
|
</div>
|
|
</div>
|
|
<div class="mt-4 pt-3 border-t border-slate-500/20 text-xs text-subtle text-right">
|
|
<span>อัปเดตข้อมูลล่าสุด: <strong x-text="lastUpdated"></strong></span>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- Realtime Room Status Grid -->
|
|
<div class="glass-card p-6 rounded-2xl border">
|
|
<div class="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h3 class="font-bold text-lg">สถานะห้องนวดและการรักษา (Room Status Grid)</h3>
|
|
<p class="text-xs text-subtle">ติดตามผู้รับบริการและหมอนวดประจำห้องแบบเรียลไทม์</p>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<span class="px-3 py-1 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"><i class="bi bi-circle-fill text-[8px] mr-1"></i> ว่าง (Available)</span>
|
|
<span class="px-3 py-1 rounded-full text-xs font-medium bg-amber-500/10 text-amber-400 border border-amber-500/20"><i class="bi bi-circle-fill text-[8px] mr-1"></i> กำลังนวด (In Use)</span>
|
|
<span class="px-3 py-1 rounded-full text-xs font-medium bg-cyan-500/10 text-cyan-400 border border-cyan-500/20"><i class="bi bi-circle-fill text-[8px] mr-1"></i> ทำความสะอาด (Cleaning)</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<template x-for="room in rooms" :key="room.room_id">
|
|
<div class="p-4 rounded-xl border transition-all relative overflow-hidden"
|
|
:class="{
|
|
'bg-emerald-500/5 border-emerald-500/30': room.status === 'Available',
|
|
'bg-amber-500/10 border-amber-500/40 shadow-lg shadow-amber-500/10': room.status === 'In_Use',
|
|
'bg-cyan-500/5 border-cyan-500/30': room.status === 'Cleaning'
|
|
}">
|
|
<div class="flex justify-between items-center mb-2">
|
|
<span class="font-bold text-lg font-mono" x-text="room.room_no">R-101</span>
|
|
<span class="px-2 py-0.5 rounded text-[10px] font-bold tracking-wide uppercase"
|
|
:class="{
|
|
'bg-emerald-500 text-white': room.status === 'Available',
|
|
'bg-amber-500 text-white animate-pulse': room.status === 'In_Use',
|
|
'bg-cyan-500 text-white': room.status === 'Cleaning'
|
|
}"
|
|
x-text="room.status === 'Available' ? 'ว่าง' : (room.status === 'In_Use' ? 'กำลังนวด' : 'ทำความสะอาด')"></span>
|
|
</div>
|
|
|
|
<!-- Room Details -->
|
|
<div class="space-y-1 my-3 min-h-[60px]">
|
|
<p class="text-xs font-medium truncate" x-text="room.patient_name ? `ผู้ป่วย: ${room.patient_name}` : 'ยังไม่มีผู้รับบริการในห้อง'"></p>
|
|
<p class="text-xs text-subtle truncate" x-text="room.service_name ? `บริการ: ${room.service_name}` : '-'"></p>
|
|
<p class="text-xs text-emerald-400 truncate" x-text="room.therapist_name ? `หมอนวด: ${room.therapist_name}` : '-'"></p>
|
|
</div>
|
|
|
|
<div class="pt-2 border-t border-slate-500/20 flex justify-between items-center text-[11px] text-subtle">
|
|
<span>ประเภท: <strong x-text="room.room_type">VIP</strong></span>
|
|
<span x-show="room.status === 'In_Use'" class="text-amber-400 font-mono font-bold"><i class="bi bi-clock"></i> <span x-text="room.queue_no || '-'"></span></span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<script>
|
|
document.addEventListener('alpine:init', () => {
|
|
Alpine.data('dashboardApp', () => ({
|
|
selectedBranch: 1,
|
|
loading: false,
|
|
lastUpdated: '-',
|
|
stats: {
|
|
total_queues: 28,
|
|
avg_wait_time: 12.5,
|
|
estimated_revenue: 14500,
|
|
room_occupancy_percent: 75
|
|
},
|
|
rooms: [],
|
|
workloadChartInstance: null,
|
|
peakChartInstance: null,
|
|
|
|
init() {
|
|
this.fetchData();
|
|
// Auto refresh every 30 seconds
|
|
setInterval(() => this.fetchData(true), 30000);
|
|
},
|
|
|
|
formatCurrency(val) {
|
|
return Number(val).toLocaleString('th-TH');
|
|
},
|
|
|
|
async fetchData(silent = false) {
|
|
if (!silent) this.loading = true;
|
|
try {
|
|
// Fetch from KPI API or fall back to realistic demo data if offline/uninitialized
|
|
const kpiRes = await this.apiFetch(`/reports/kpi?branch_id=${this.selectedBranch}`, { silent });
|
|
const roomsRes = await this.apiFetch(`/rooms?branch_id=${this.selectedBranch}`, { silent });
|
|
|
|
if (kpiRes && kpiRes.success) {
|
|
this.stats = {
|
|
total_queues: kpiRes.data.kpi_summary?.total_queues_today || 28,
|
|
avg_wait_time: kpiRes.data.kpi_summary?.avg_wait_time_mins || 12.5,
|
|
estimated_revenue: kpiRes.data.kpi_summary?.total_revenue_today || 14500,
|
|
room_occupancy_percent: kpiRes.data.kpi_summary?.room_occupancy_percent || 75
|
|
};
|
|
this.renderWorkloadChart(kpiRes.data.therapist_workloads || []);
|
|
} else {
|
|
this.renderWorkloadChart(this.getMockWorkloads());
|
|
}
|
|
|
|
if (roomsRes && roomsRes.success) {
|
|
this.rooms = roomsRes.data;
|
|
} else {
|
|
this.rooms = this.getMockRooms();
|
|
}
|
|
|
|
this.renderPeakChart();
|
|
this.lastUpdated = new Date().toLocaleTimeString('th-TH');
|
|
} catch (err) {
|
|
console.warn('[Dashboard] Using Mock Data due to server availability');
|
|
this.rooms = this.getMockRooms();
|
|
this.renderWorkloadChart(this.getMockWorkloads());
|
|
this.renderPeakChart();
|
|
this.lastUpdated = new Date().toLocaleTimeString('th-TH');
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
renderWorkloadChart(data) {
|
|
const ctx = document.getElementById('workloadChart');
|
|
if (!ctx) return;
|
|
if (this.workloadChartInstance) this.workloadChartInstance.destroy();
|
|
|
|
const labels = data.map(d => d.therapist_name || `หมอนวด T-${d.therapist_id}`);
|
|
const completedMins = data.map(d => Number(d.total_minutes_today || d.completed_mins || 120));
|
|
const maxMins = data.map(d => 480 - Number(d.total_minutes_today || d.completed_mins || 120));
|
|
|
|
this.workloadChartInstance = new Chart(ctx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: labels.length ? labels : ['นวดแผนไทย กอ', 'นวดแผนไทย ขอ', 'นวดแผนไทย คอ', 'นวดสปา งอ'],
|
|
datasets: [
|
|
{
|
|
label: 'ปฏิบัติงานแล้ว (นาที)',
|
|
data: completedMins.length ? completedMins : [240, 180, 300, 120],
|
|
backgroundColor: '#10b981',
|
|
borderRadius: 6
|
|
},
|
|
{
|
|
label: 'โควตาคงเหลือ (นาที)',
|
|
data: maxMins.length ? maxMins : [240, 300, 180, 360],
|
|
backgroundColor: 'rgba(148, 163, 184, 0.2)',
|
|
borderRadius: 6
|
|
}
|
|
]
|
|
},
|
|
options: {
|
|
indexAxis: 'y',
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
scales: {
|
|
x: { stacked: true, grid: { color: 'rgba(255, 255, 255, 0.05)' }, ticks: { color: '#94a3b8' } },
|
|
y: { stacked: true, grid: { display: false }, ticks: { color: '#94a3b8', font: { family: 'Sarabun', size: 12 } } }
|
|
},
|
|
plugins: { legend: { display: false } }
|
|
}
|
|
});
|
|
},
|
|
|
|
renderPeakChart() {
|
|
const ctx = document.getElementById('peakChart');
|
|
if (!ctx) return;
|
|
if (this.peakChartInstance) this.peakChartInstance.destroy();
|
|
|
|
this.peakChartInstance = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: ['09:00', '10:00', '11:00', '12:00', '13:00', '14:00', '15:00', '16:00', '17:00', '18:00', '19:00', '20:00'],
|
|
datasets: [{
|
|
label: 'ปริมาณผู้รับบริการ (คน)',
|
|
data: [3, 5, 8, 12, 10, 15, 18, 22, 25, 20, 14, 8],
|
|
borderColor: '#06b6d4',
|
|
backgroundColor: 'rgba(6, 182, 212, 0.15)',
|
|
fill: true,
|
|
tension: 0.4,
|
|
pointBackgroundColor: '#10b981',
|
|
pointRadius: 4
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
scales: {
|
|
x: { grid: { color: 'rgba(255, 255, 255, 0.05)' }, ticks: { color: '#94a3b8' } },
|
|
y: { grid: { color: 'rgba(255, 255, 255, 0.05)' }, ticks: { color: '#94a3b8' } }
|
|
},
|
|
plugins: { legend: { display: false } }
|
|
}
|
|
});
|
|
},
|
|
|
|
getMockRooms() {
|
|
return [
|
|
{ room_id: 1, room_no: 'R-101 (นวดไทย 1)', room_type: 'VIP Private', status: 'In_Use', patient_name: 'คุณสมชาย รักสุขภาพ', service_name: 'นวดแผนไทยราชสำนัก (120 น.)', therapist_name: 'หมอสมศรี (T-102)', queue_no: 'A003' },
|
|
{ room_id: 2, room_no: 'R-102 (นวดไทย 2)', room_type: 'Standard', status: 'Available', patient_name: null, service_name: null, therapist_name: null, queue_no: null },
|
|
{ room_id: 3, room_no: 'R-103 (นวดฝ่าเท้า 1)', room_type: 'Foot Spa', status: 'In_Use', patient_name: 'คุณวิภาวี ดีเลิศ', service_name: 'นวดกดจุดฝ่าเท้า (60 น.)', therapist_name: 'หมอใจดี (T-105)', queue_no: 'B005' },
|
|
{ room_id: 4, room_no: 'R-104 (นวดน้ำมัน 1)', room_type: 'VIP Aroma', status: 'Cleaning', patient_name: 'รอทำความสะอาดเตียง', service_name: '-', therapist_name: '-', queue_no: null },
|
|
{ room_id: 5, room_no: 'R-105 (ประคบสมุนไพร)', room_type: 'Herbal Room', status: 'Available', patient_name: null, service_name: null, therapist_name: null, queue_no: null },
|
|
{ room_id: 6, room_no: 'R-106 (สปาตัว)', room_type: 'VIP Spa', status: 'In_Use', patient_name: 'คุณอนันต์ มั่งคั่ง', service_name: 'นวดน้ำมันอโรมาเธอราปี (90 น.)', therapist_name: 'หมอสุชาดา (T-101)', queue_no: 'A004' }
|
|
];
|
|
},
|
|
|
|
getMockWorkloads() {
|
|
return [
|
|
{ therapist_name: 'หมอสุชาดา (T-101)', completed_mins: 300, active_queues: 1 },
|
|
{ therapist_name: 'หมอสมศรี (T-102)', completed_mins: 240, active_queues: 1 },
|
|
{ therapist_name: 'หมอใจดี (T-105)', completed_mins: 180, active_queues: 1 },
|
|
{ therapist_name: 'หมอวิไลพร (T-108)', completed_mins: 120, active_queues: 0 }
|
|
];
|
|
}
|
|
}));
|
|
});
|
|
</script>
|