398 lines
19 KiB
TypeScript
Executable File
398 lines
19 KiB
TypeScript
Executable File
import React, { useState, useMemo } from 'react';
|
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, Legend } from 'recharts';
|
|
import { DashboardStats, JournalEntry, ClassJournalEntry } from '../types';
|
|
import { Calendar as CalendarIcon, Users, Book, TrendingUp, ChevronLeft, ChevronRight, GraduationCap, Briefcase, BarChart2, Filter, X, Clock } from 'lucide-react';
|
|
|
|
interface DashboardProps {
|
|
stats: DashboardStats;
|
|
teacherEntries: JournalEntry[];
|
|
classEntries: ClassJournalEntry[];
|
|
}
|
|
|
|
const Dashboard: React.FC<DashboardProps> = ({ stats, teacherEntries, classEntries }) => {
|
|
// Use "today" as default selected date if it has entries, otherwise null or today
|
|
const [currentDate, setCurrentDate] = useState(new Date());
|
|
const [selectedDate, setSelectedDate] = useState<Date | null>(new Date()); // Default to today
|
|
|
|
const months = ["Januari", "Februari", "Maret", "April", "Mei", "Juni", "Juli", "Agustus", "September", "Oktober", "November", "Desember"];
|
|
|
|
const getCalendarDays = () => {
|
|
const year = currentDate.getFullYear();
|
|
const month = currentDate.getMonth();
|
|
const firstDay = new Date(year, month, 1).getDay();
|
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
|
|
|
const days = [];
|
|
for (let i = 0; i < firstDay; i++) days.push(null);
|
|
for (let i = 1; i <= daysInMonth; i++) days.push(i);
|
|
return days;
|
|
};
|
|
|
|
const changeMonth = (offset: number) => {
|
|
setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + offset, 1));
|
|
};
|
|
|
|
const hasEntryOnDate = (day: number) => {
|
|
const year = currentDate.getFullYear();
|
|
const month = currentDate.getMonth() + 1;
|
|
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
|
return teacherEntries.some(e => e.date === dateStr) || classEntries.some(e => e.date === dateStr);
|
|
};
|
|
|
|
const isSelectedDay = (day: number) => {
|
|
if (!selectedDate) return false;
|
|
return (
|
|
selectedDate.getDate() === day &&
|
|
selectedDate.getMonth() === currentDate.getMonth() &&
|
|
selectedDate.getFullYear() === currentDate.getFullYear()
|
|
);
|
|
};
|
|
|
|
const handleDayClick = (day: number) => {
|
|
if (!day) return;
|
|
const newDate = new Date(currentDate.getFullYear(), currentDate.getMonth(), day);
|
|
setSelectedDate(newDate);
|
|
};
|
|
|
|
// 1. "Kehadiran Guru per hari" Dashboard Logic
|
|
const dailyStats = useMemo(() => {
|
|
const targetDate = selectedDate || new Date();
|
|
const dateStr = targetDate.toISOString().split('T')[0];
|
|
|
|
// Count teachers present in class entries for this date
|
|
const presentTeachers = new Set(
|
|
classEntries
|
|
.filter(ce => ce.date === dateStr && (ce.teacherPresence === 'Hadir' || ce.teacherPresence === 'Tugas'))
|
|
.map(ce => ce.teacherName)
|
|
).size;
|
|
|
|
// Latest activities for this day (from both teacher and class journals)
|
|
const dailyActivities = [
|
|
...teacherEntries.filter(te => te.date === dateStr).map(te => ({ ...te, type: 'teacher' })),
|
|
...classEntries.filter(ce => ce.date === dateStr).map(ce => ({ ...ce, type: 'class' }))
|
|
].sort((a, b) => new Date(b.id.split('-')[1]).getTime() - new Date(a.id.split('-')[1]).getTime());
|
|
|
|
// Class performance for this specific day
|
|
const classPerfMap: Record<string, { present: number, absent: number }> = {};
|
|
classEntries.filter(ce => ce.date === dateStr).forEach(ce => {
|
|
if (!classPerfMap[ce.className]) classPerfMap[ce.className] = { present: 0, absent: 0 };
|
|
classPerfMap[ce.className].present += Number(ce.studentsPresent) || 0;
|
|
classPerfMap[ce.className].absent += Number(ce.studentsAbsent) || 0;
|
|
});
|
|
|
|
const dailyClassPerformance = Object.keys(classPerfMap).map(name => ({
|
|
name,
|
|
present: classPerfMap[name].present,
|
|
absent: classPerfMap[name].absent
|
|
})).sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
return {
|
|
presentTeachers,
|
|
dailyActivities,
|
|
dailyClassPerformance,
|
|
dateStr
|
|
};
|
|
}, [selectedDate, teacherEntries, classEntries]);
|
|
|
|
// Transform Weekly Stats for Chart (Calculated per date trend)
|
|
const weeklyData = useMemo(() => {
|
|
const daysMap = ['Min', 'Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab'];
|
|
const now = new Date();
|
|
const currentWeekData = daysMap.map((day, idx) => {
|
|
// Find date for this day of current week
|
|
const diff = idx - now.getDay();
|
|
const targetDay = new Date(now);
|
|
targetDay.setDate(now.getDate() + diff);
|
|
const targetDateStr = targetDay.toISOString().split('T')[0];
|
|
|
|
const count = classEntries.filter(ce =>
|
|
ce.date === targetDateStr &&
|
|
(ce.teacherPresence === 'Hadir' || ce.teacherPresence === 'Tugas')
|
|
).length;
|
|
|
|
return { day, count };
|
|
});
|
|
return currentWeekData;
|
|
}, [classEntries]);
|
|
|
|
return (
|
|
<div className="space-y-8 animate-in pb-20">
|
|
{/* Header Stats Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
<div className="group relative overflow-hidden rounded-2xl p-6 text-white shadow-lg transition-all duration-300 hover:shadow-xl hover:-translate-y-1"
|
|
style={{ background: 'linear-gradient(135deg, #1e3a5f 0%, #3b6ea5 100%)' }}>
|
|
<div className="relative flex items-center gap-4">
|
|
<div className="p-3 bg-white/20 rounded-xl backdrop-blur-sm">
|
|
<Book size={28} className="text-amber-300" />
|
|
</div>
|
|
<div>
|
|
<p className="text-blue-200 text-sm font-medium">Total Jurnal Guru</p>
|
|
<h3 className="text-3xl font-heading font-bold">{stats.totalEntries}</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="group relative overflow-hidden rounded-2xl p-6 text-white shadow-lg transition-all duration-300 hover:shadow-xl hover:-translate-y-1"
|
|
style={{ background: 'linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%)' }}>
|
|
<div className="relative flex items-center gap-4">
|
|
<div className="p-3 bg-white/20 rounded-xl backdrop-blur-sm">
|
|
<Clock size={28} className="text-white" />
|
|
</div>
|
|
<div>
|
|
<p className="text-white/80 text-sm font-medium">Kehadiran Guru (Harian)</p>
|
|
<h3 className="text-3xl font-heading font-bold">{dailyStats.presentTeachers}</h3>
|
|
<p className="text-[10px] text-white/60">Tgl: {dailyStats.dateStr}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="group relative overflow-hidden rounded-2xl p-6 text-white shadow-lg transition-all duration-300 hover:shadow-xl hover:-translate-y-1"
|
|
style={{ background: 'linear-gradient(135deg, #059669 0%, #34d399 100%)' }}>
|
|
<div className="relative flex items-center gap-4">
|
|
<div className="p-3 bg-white/20 rounded-xl backdrop-blur-sm">
|
|
<TrendingUp size={28} className="text-emerald-200" />
|
|
</div>
|
|
<div>
|
|
<p className="text-emerald-200 text-sm font-medium">Hari Aktif (Sistem)</p>
|
|
<h3 className="text-3xl font-heading font-bold">{stats.weeklyActivity.filter(w => w.count > 0).length}</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
|
|
{/* Left Column: Charts */}
|
|
<div className="xl:col-span-2 space-y-8">
|
|
{/* Teacher Activity Chart based on Class Journal */}
|
|
<div className="bg-white p-6 rounded-2xl shadow-lg border border-slate-100">
|
|
<div className="mb-6">
|
|
<h3 className="font-heading font-bold text-lg text-school-900 flex items-center gap-2">
|
|
<TrendingUp size={20} /> Grafik Kehadiran Guru Mengajar
|
|
</h3>
|
|
<p className="text-sm text-slate-500">Jumlah kehadiran guru berdasarkan entri sekretaris minggu ini</p>
|
|
</div>
|
|
<div className="h-64 w-full">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={weeklyData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
|
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" />
|
|
<XAxis dataKey="day" axisLine={false} tickLine={false} tick={{ fill: '#64748b', fontSize: 12 }} dy={10} />
|
|
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#64748b', fontSize: 12 }} />
|
|
<Tooltip
|
|
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
|
|
cursor={{ fill: '#f1f5f9' }}
|
|
/>
|
|
<Bar dataKey="count" fill="#1e3a8a" radius={[4, 4, 0, 0]} barSize={40}>
|
|
{weeklyData.map((entry, index) => (
|
|
<Cell key={`cell-${index}`} fill={index === new Date().getDay() ? '#facc15' : '#1e3a8a'} />
|
|
))}
|
|
</Bar>
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Class Performance Chart for Selected Date */}
|
|
<div className="bg-white p-6 rounded-2xl shadow-lg border border-slate-100">
|
|
<div className="mb-6">
|
|
<h3 className="font-heading font-bold text-lg text-school-900 flex items-center gap-2">
|
|
<BarChart2 size={20} /> Grafik Statistik Per Kelas ({dailyStats.dateStr})
|
|
</h3>
|
|
<p className="text-sm text-slate-500">Perbandingan kehadiran siswa per kelas pada tanggal yang dipilih</p>
|
|
</div>
|
|
{dailyStats.dailyClassPerformance.length > 0 ? (
|
|
<div className="h-80 w-full">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={dailyStats.dailyClassPerformance} margin={{ top: 20, right: 30, left: -10, bottom: 5 }}>
|
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" />
|
|
<XAxis
|
|
dataKey="name"
|
|
axisLine={false}
|
|
tickLine={false}
|
|
tick={{ fill: '#64748b', fontSize: 10 }}
|
|
dy={10}
|
|
interval={0}
|
|
angle={-45}
|
|
textAnchor="end"
|
|
height={70}
|
|
/>
|
|
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#64748b', fontSize: 12 }} />
|
|
<Tooltip contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }} />
|
|
<Legend verticalAlign="top" wrapperStyle={{ paddingBottom: '20px' }} />
|
|
<Bar name="Hadir" dataKey="present" stackId="a" fill="#22c55e" barSize={25} />
|
|
<Bar name="Tidak Hadir" dataKey="absent" stackId="a" fill="#ef4444" radius={[4, 4, 0, 0]} barSize={25} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
) : (
|
|
<div className="h-40 flex items-center justify-center text-slate-400 bg-slate-50 rounded-xl">
|
|
Tidak ada data kehadiran kelas untuk tanggal ini
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Column: Calendar & Filtered Activity List */}
|
|
<div className="space-y-8">
|
|
{/* Calendar Widget */}
|
|
<div className="bg-white p-6 rounded-2xl shadow-lg border border-slate-100">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h3 className="font-heading font-bold text-lg text-school-900 flex items-center gap-2">
|
|
<CalendarIcon size={20} /> Kalender
|
|
</h3>
|
|
<div className="flex items-center gap-2">
|
|
<button onClick={() => changeMonth(-1)} className="p-1 hover:bg-slate-100 rounded-full">
|
|
<ChevronLeft size={20} />
|
|
</button>
|
|
<span className="text-xs font-bold text-slate-700 w-24 text-center lowercase first-letter:uppercase">
|
|
{months[currentDate.getMonth()]} {currentDate.getFullYear()}
|
|
</span>
|
|
<button onClick={() => changeMonth(1)} className="p-1 hover:bg-slate-100 rounded-full">
|
|
<ChevronRight size={20} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-7 gap-1 text-center mb-2">
|
|
{['M', 'S', 'S', 'R', 'K', 'J', 'S'].map((d, i) => (
|
|
<span key={i} className="text-[10px] font-bold text-slate-400">{d}</span>
|
|
))}
|
|
</div>
|
|
<div className="grid grid-cols-7 gap-1">
|
|
{getCalendarDays().map((day, idx) => {
|
|
const isSelected = day ? isSelectedDay(day) : false;
|
|
const hasEntry = day ? hasEntryOnDate(day) : false;
|
|
|
|
return (
|
|
<div
|
|
key={idx}
|
|
onClick={() => day && handleDayClick(day)}
|
|
className={`aspect-square flex flex-col items-center justify-center rounded-lg text-xs transition-all cursor-pointer
|
|
${isSelected ? 'bg-school-900 text-white shadow-md' : 'hover:bg-blue-50'}
|
|
${hasEntry && !isSelected ? 'text-school-900 font-bold border border-blue-100 bg-blue-50/50' : ''}
|
|
${!day ? 'opacity-0 cursor-default' : ''}
|
|
`}
|
|
>
|
|
{day}
|
|
{hasEntry && (
|
|
<div className={`h-1 w-1 rounded-full mt-0.5 ${isSelected ? 'bg-accent-yellow' : 'bg-blue-500'}`}></div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* New Requirement: Daily Activity List */}
|
|
<div className="bg-white p-6 rounded-2xl shadow-lg border border-slate-100">
|
|
<div className="mb-4">
|
|
<h3 className="font-heading font-bold text-lg text-school-900">Aktivitas Terbaru</h3>
|
|
<p className="text-[10px] text-slate-500">Filter: {dailyStats.dateStr}</p>
|
|
</div>
|
|
<div className="space-y-4 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
|
|
{dailyStats.dailyActivities.length === 0 ? (
|
|
<div className="text-center py-8">
|
|
<Clock size={32} className="mx-auto text-slate-200 mb-2" />
|
|
<p className="text-sm text-slate-400">Tidak ada aktivitas pada tanggal ini</p>
|
|
</div>
|
|
) : (
|
|
dailyStats.dailyActivities.map((entry: any) => (
|
|
<div key={entry.id} className="flex items-start gap-3 pb-3 border-b border-slate-50 last:border-0 group">
|
|
<div className={`h-9 w-9 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0 transition-colors ${entry.type === 'teacher' ? 'bg-blue-50 text-blue-600 group-hover:bg-blue-600 group-hover:text-white' : 'bg-amber-50 text-amber-600 group-hover:bg-amber-600 group-hover:text-white'
|
|
}`}>
|
|
{entry.className.split(' ')[0]}
|
|
</div>
|
|
<div className="overflow-hidden">
|
|
<p className="text-sm font-bold text-slate-700 truncate">{entry.subject}</p>
|
|
<p className="text-[10px] text-slate-500 truncate">{entry.teacherName}</p>
|
|
<p className="text-[9px] text-slate-400 mt-0.5 italic flex items-center gap-1">
|
|
{entry.type === 'teacher' ? 'Jurnal Guru' : 'Jurnal Kelas'} • {entry.startTime}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
{selectedDate && (
|
|
<button
|
|
onClick={() => setSelectedDate(new Date())}
|
|
className="w-full mt-4 py-2 border-2 border-dashed border-slate-200 rounded-xl text-xs font-bold text-slate-400 hover:border-school-800 hover:text-school-800 transition"
|
|
>
|
|
Kembali ke Hari Ini
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Detail Table */}
|
|
<div className="bg-white p-6 rounded-2xl shadow-lg border border-slate-100">
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between mb-6 gap-4">
|
|
<div>
|
|
<h3 className="font-heading font-bold text-lg text-school-900 flex items-center gap-2">
|
|
<Filter size={20} className="text-accent-yellow" />
|
|
Detail Aktivitas Jurnal ({dailyStats.dateStr})
|
|
</h3>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left border-collapse">
|
|
<thead>
|
|
<tr className="border-b border-slate-200 text-slate-500 text-[10px] uppercase tracking-wider bg-slate-50/50">
|
|
<th className="p-4 font-semibold rounded-tl-xl text-center">Tipe</th>
|
|
<th className="p-4 font-semibold">Guru</th>
|
|
<th className="p-4 font-semibold">Kelas</th>
|
|
<th className="p-4 font-semibold">Materi/Catatan</th>
|
|
<th className="p-4 font-semibold text-center rounded-tr-xl">Status/Siswa</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="text-[11px]">
|
|
{dailyStats.dailyActivities.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={5} className="p-12 text-center text-slate-300">
|
|
Tidak ada aktivitas.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
dailyStats.dailyActivities.map((entry: any) => (
|
|
<tr key={entry.id} className="border-b border-slate-50 hover:bg-slate-50 transition-colors">
|
|
<td className="p-4 text-center">
|
|
<span className={`px-2 py-0.5 rounded-full text-[9px] font-bold ${entry.type === 'teacher' ? 'bg-blue-100 text-blue-700' : 'bg-amber-100 text-amber-700'
|
|
}`}>
|
|
{entry.type === 'teacher' ? 'Guru' : 'Kelas'}
|
|
</span>
|
|
</td>
|
|
<td className="p-4 text-slate-600 font-medium">{entry.teacherName}</td>
|
|
<td className="p-4">
|
|
<span className="px-2 py-0.5 rounded-md bg-slate-100 text-slate-700 font-bold">
|
|
{entry.className}
|
|
</span>
|
|
</td>
|
|
<td className="p-4 text-slate-500 max-w-[200px] truncate">
|
|
{entry.type === 'teacher' ? entry.topic : entry.notes || '-'}
|
|
</td>
|
|
<td className="p-4 text-center">
|
|
{entry.type === 'teacher' ? (
|
|
<div className="flex items-center justify-center gap-1 font-bold">
|
|
<span className="text-green-600">{entry.studentsPresent}</span>
|
|
<span className="text-slate-200">/</span>
|
|
<span className="text-red-500">{entry.studentsAbsent}</span>
|
|
</div>
|
|
) : (
|
|
<span className={`${entry.teacherPresence === 'Hadir' ? 'text-green-600' : 'text-red-600'} font-bold`}>
|
|
{entry.teacherPresence}
|
|
</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Dashboard;
|