312 lines
17 KiB
PHP
312 lines
17 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('content')
|
|
|
|
<script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.11/index.global.min.js'></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
|
|
<style>
|
|
/* Styling kalender agar sesuai dengan Tailwind */
|
|
.fc-theme-standard .fc-scrollgrid { border-color: #e2e8f0; border-radius: 0.75rem; overflow: hidden; }
|
|
.fc-theme-standard td, .fc-theme-standard th { border-color: #e2e8f0; }
|
|
.fc .fc-toolbar-title { font-size: 1.125rem; font-weight: 700; color: #1e293b; }
|
|
.fc .fc-button-primary { background-color: #2563eb; border-color: #2563eb; font-weight: 600; text-transform: capitalize; border-radius: 0.5rem;}
|
|
.fc .fc-button-primary:hover { background-color: #1d4ed8; border-color: #1d4ed8; }
|
|
.fc .fc-button-primary:disabled { background-color: #94a3b8; border-color: #94a3b8; }
|
|
.fc-day-today { background-color: #f8fafc !important; }
|
|
.fc-event { cursor: pointer; border-radius: 4px; padding: 2px 4px; border: none; font-size: 0.75rem; font-weight: bold;}
|
|
</style>
|
|
|
|
<div class="max-w-7xl mx-auto px-4 py-8">
|
|
|
|
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-slate-800">Command Center Training</h1>
|
|
<p class="text-sm text-slate-500">Pantau pemenuhan kompetensi dan aktivitas CBT Karyawan secara real-time.</p>
|
|
</div>
|
|
|
|
<form action="{{ route('admin.dashboard') }}" method="GET" class="flex items-center space-x-3 bg-white p-2 rounded-xl shadow-sm border border-slate-200">
|
|
<select name="month" class="bg-slate-50 border-none text-sm font-bold text-slate-700 rounded-lg focus:ring-0 cursor-pointer">
|
|
@foreach(range(1, 12) as $m)
|
|
<option value="{{ $m }}" {{ $selectedMonth == $m ? 'selected' : '' }}>
|
|
{{ date('F', mktime(0, 0, 0, $m, 1)) }}
|
|
</option>
|
|
@endforeach
|
|
</select>
|
|
<select name="year" class="bg-slate-50 border-none text-sm font-bold text-slate-700 rounded-lg focus:ring-0 cursor-pointer">
|
|
@foreach(range(date('Y')-2, date('Y')+1) as $y)
|
|
<option value="{{ $y }}" {{ $selectedYear == $y ? 'selected' : '' }}>{{ $y }}</option>
|
|
@endforeach
|
|
</select>
|
|
<button type="submit" class="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
|
|
|
<div class="lg:col-span-2 space-y-6">
|
|
|
|
<div class="bg-slate-900 rounded-2xl shadow-lg border border-slate-800 overflow-hidden"
|
|
x-data="liveMonitoring()"
|
|
x-init="fetchData(); setInterval(() => fetchData(), 5000);">
|
|
|
|
<div class="p-5 border-b border-slate-700 flex justify-between items-center bg-slate-800/50">
|
|
<div class="flex items-center space-x-3">
|
|
<div class="relative flex h-3 w-3">
|
|
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
|
<span class="relative inline-flex rounded-full h-3 w-3 bg-emerald-500"></span>
|
|
</div>
|
|
<h2 class="text-sm font-bold text-white uppercase tracking-wider">Live Exam Monitoring</h2>
|
|
</div>
|
|
<div class="text-xs font-bold text-slate-400 bg-slate-800 px-3 py-1 rounded-full border border-slate-700">
|
|
<span x-text="activeTrainees.length"></span> Karyawan Online
|
|
</div>
|
|
</div>
|
|
|
|
<div class="p-0 max-h-[300px] overflow-y-auto custom-scrollbar">
|
|
<table class="w-full text-left text-sm text-slate-300">
|
|
<tbody class="divide-y divide-slate-800/50">
|
|
<tr x-show="activeTrainees.length === 0">
|
|
<td class="px-6 py-8 text-center text-slate-500">
|
|
Tidak ada karyawan yang sedang mengerjakan ujian saat ini.
|
|
</td>
|
|
</tr>
|
|
|
|
<template x-for="trainee in activeTrainees" :key="trainee.attempt_id">
|
|
<tr class="hover:bg-slate-800/50 transition-colors group">
|
|
<td class="px-5 py-4">
|
|
<div class="font-bold text-white" x-text="trainee.first_name + ' ' + (trainee.last_name || '')"></div>
|
|
<div class="text-xs text-indigo-400 mt-0.5" x-text="trainee.dept_name"></div>
|
|
</td>
|
|
<td class="px-5 py-4">
|
|
<div class="text-xs font-bold text-slate-300 truncate w-48" x-text="trainee.exam_title"></div>
|
|
</td>
|
|
<td class="px-5 py-4 text-center">
|
|
<span class="font-mono text-xs font-bold px-2.5 py-1 rounded-md border"
|
|
:class="trainee.time_left_seconds > 300 ? 'bg-emerald-900/50 text-emerald-400 border-emerald-800' : 'bg-red-900/50 text-red-400 border-red-800 animate-pulse'"
|
|
x-text="formatTime(trainee.time_left_seconds)">
|
|
</span>
|
|
</td>
|
|
<td class="px-5 py-4 text-right">
|
|
<button @click="forceSubmit(trainee.attempt_id)" class="opacity-0 group-hover:opacity-100 px-3 py-1 bg-red-600 hover:bg-red-500 text-white text-[10px] font-bold rounded transition-all">
|
|
FORCE SUBMIT
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden">
|
|
<div class="p-5 border-b border-slate-100 flex justify-between items-center bg-red-50/30">
|
|
<div class="flex items-center space-x-2">
|
|
<svg class="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
|
<h2 class="text-base font-bold text-slate-800">Training Terlewat (Overdue)</h2>
|
|
</div>
|
|
<span class="px-3 py-1 bg-red-100 text-red-700 text-xs font-bold rounded-full">{{ $totalOverdue }} Kasus Bulan Ini</span>
|
|
</div>
|
|
|
|
<div class="p-0">
|
|
<table class="w-full text-left text-sm text-slate-600">
|
|
<tbody class="divide-y divide-slate-50">
|
|
@forelse($overdueTrainings as $overdue)
|
|
<tr class="hover:bg-slate-50 transition-colors">
|
|
<td class="px-5 py-3">
|
|
<div class="font-bold text-slate-700">{{ $overdue->first_name }} {{ $overdue->last_name }}</div>
|
|
<div class="text-[10px] uppercase font-bold text-slate-400 mt-0.5">{{ $overdue->dept_name }}</div>
|
|
</td>
|
|
<td class="px-5 py-3 font-medium text-slate-600">{{ $overdue->title }}</td>
|
|
<td class="px-5 py-3 text-right">
|
|
<span class="text-xs font-bold text-red-500">
|
|
Tenggat: {{ \Carbon\Carbon::parse($overdue->deadline_date)->format('d M Y') }}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
@empty
|
|
<tr>
|
|
<td colspan="3" class="px-6 py-8 text-center text-emerald-600 font-bold bg-emerald-50/50">
|
|
🎉 Luar biasa! Tidak ada training yang terlewat bulan ini.
|
|
</td>
|
|
</tr>
|
|
@endforelse
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div class="lg:col-span-1">
|
|
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden h-full flex flex-col">
|
|
<div class="p-6 border-b border-slate-100 flex justify-between items-center">
|
|
<div>
|
|
<h2 class="text-base font-bold text-slate-800">Pemenuhan Training Dept.</h2>
|
|
<p class="text-[11px] text-slate-500 mt-0.5">Klik pada batang grafik untuk melihat detail</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="p-4 flex-grow relative w-full h-[320px] flex items-center justify-center">
|
|
@if(count($deptFulfillment) > 0)
|
|
<canvas id="deptFulfillmentChart"></canvas>
|
|
@else
|
|
<div class="text-center text-slate-400 text-sm flex flex-col items-center">
|
|
<svg class="w-10 h-10 mb-2 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path></svg>
|
|
Belum ada data bulan ini
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div> <div class="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden">
|
|
<div class="p-6 border-b border-slate-100 flex flex-col md:flex-row justify-between items-start md:items-center bg-slate-50 gap-4">
|
|
<div>
|
|
<h2 class="text-lg font-bold text-slate-800">Kalender Penugasan Training</h2>
|
|
<p class="text-sm text-slate-500">
|
|
@if(auth()->user()->hasRole('superadmin'))
|
|
Menampilkan jadwal training untuk <span class="font-bold text-blue-600">Seluruh Karyawan (Global)</span>.
|
|
@else
|
|
Menampilkan jadwal training khusus untuk departemen <span class="font-bold text-blue-600">{{ auth()->user()->department->name ?? 'Anda' }}</span>.
|
|
@endif
|
|
</p>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap gap-3 text-xs font-bold text-slate-500">
|
|
<div class="flex items-center"><span class="w-3 h-3 rounded-full bg-emerald-500 mr-1.5"></span> Lulus</div>
|
|
<div class="flex items-center"><span class="w-3 h-3 rounded-full bg-blue-500 mr-1.5"></span> Dikerjakan</div>
|
|
<div class="flex items-center"><span class="w-3 h-3 rounded-full bg-amber-500 mr-1.5"></span> Pending</div>
|
|
<div class="flex items-center"><span class="w-3 h-3 rounded-full bg-red-500 mr-1.5"></span> Terlewat</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="p-6">
|
|
<div id="trainingCalendar"></div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<script>
|
|
// 1. Inisialisasi Alpine JS untuk Live Monitoring
|
|
document.addEventListener('alpine:init', () => {
|
|
Alpine.data('liveMonitoring', () => ({
|
|
activeTrainees: [],
|
|
|
|
fetchData() {
|
|
// Perbaikan penulisan string URL agar aman
|
|
fetch("{{ route('admin.api.live-exams') }}")
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
this.activeTrainees = data;
|
|
})
|
|
.catch(error => console.error('Error fetching live data:', error));
|
|
},
|
|
|
|
formatTime(seconds) {
|
|
if (seconds <= 0) return "WAKTU HABIS";
|
|
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
|
|
const s = (seconds % 60).toString().padStart(2, '0');
|
|
return m + ":" + s;
|
|
},
|
|
|
|
forceSubmit(attemptId) {
|
|
if(confirm('Peringatan: Anda akan memaksa menghentikan ujian karyawan ini secara sepihak. Lanjutkan?')) {
|
|
alert('Sinyal penghentian ujian #' + attemptId + ' terkirim!');
|
|
this.fetchData();
|
|
}
|
|
}
|
|
}))
|
|
});
|
|
|
|
// 2. Inisialisasi FullCalendar JS
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
var calendarEl = document.getElementById('trainingCalendar');
|
|
|
|
var eventsData = @json($calendarData ?? []);
|
|
|
|
var calendar = new FullCalendar.Calendar(calendarEl, {
|
|
initialView: 'dayGridMonth',
|
|
headerToolbar: {
|
|
left: 'prev,next today',
|
|
center: 'title',
|
|
right: 'dayGridMonth,timeGridWeek,listWeek'
|
|
},
|
|
events: eventsData,
|
|
height: 650,
|
|
eventClick: function(info) {
|
|
alert('Detail Penugasan:\n' + info.event.title + '\nTenggat Waktu: ' + info.event.endStr.split('T')[0]);
|
|
}
|
|
});
|
|
|
|
calendar.render();
|
|
});
|
|
|
|
// 3. Inisialisasi Chart.js untuk Pemenuhan Departemen
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
if(document.getElementById('deptFulfillmentChart')) {
|
|
const ctx = document.getElementById('deptFulfillmentChart').getContext('2d');
|
|
const deptData = @json($deptFulfillment ?? []);
|
|
|
|
const labels = deptData.map(d => d.name);
|
|
const percentages = deptData.map(d => d.percentage);
|
|
const ids = deptData.map(d => d.id);
|
|
|
|
const bgColors = percentages.map(p => {
|
|
if (p < 50) return 'rgba(239, 68, 68, 0.8)';
|
|
if (p < 80) return 'rgba(245, 158, 11, 0.8)';
|
|
return 'rgba(16, 185, 129, 0.8)';
|
|
});
|
|
|
|
const chart = new Chart(ctx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
label: 'Persentase Lulus (%)',
|
|
data: percentages,
|
|
backgroundColor: bgColors,
|
|
borderRadius: 6,
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
indexAxis: 'y',
|
|
plugins: {
|
|
legend: { display: false },
|
|
tooltip: {
|
|
callbacks: {
|
|
label: function(context) {
|
|
const index = context.dataIndex;
|
|
return `Lulus: ${deptData[index].passed}/${deptData[index].assigned} (${context.raw}%)`;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
x: { min: 0, max: 100, ticks: { stepSize: 20 } },
|
|
y: { grid: { display: false } }
|
|
},
|
|
onClick: (e, activeElements) => {
|
|
if (activeElements.length > 0) {
|
|
const index = activeElements[0].index;
|
|
const deptId = ids[index];
|
|
const month = '{{ $selectedMonth }}';
|
|
const year = '{{ $selectedYear }}';
|
|
|
|
window.location.href = `/admin/dashboard/department/${deptId}/fulfillment?month=${month}&year=${year}`;
|
|
}
|
|
},
|
|
onHover: (event, chartElement) => {
|
|
event.native.target.style.cursor = chartElement[0] ? 'pointer' : 'default';
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
</script>
|
|
@endsection |