198 lines
12 KiB
TypeScript
198 lines
12 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { ModuleId } from '@/lib/types';
|
|
import { FRAME_ENTRIES } from '@/lib/appData';
|
|
import { fetchFrameEntries, fetchQuizResults, fetchAttendance, fetchStaffUsers } from '@/lib/db';
|
|
import {
|
|
BarChart3, Users, Shield, Heart, Clock, TrendingUp,
|
|
TrendingDown, AlertTriangle, CheckCircle, Calendar,
|
|
BookOpen, Eye, ArrowRight, Loader2, Database, ClipboardCheck
|
|
} from 'lucide-react';
|
|
|
|
|
|
interface DirectorDashboardProps {
|
|
setCurrentModule: (id: ModuleId) => void;
|
|
}
|
|
|
|
const DirectorDashboard: React.FC<DirectorDashboardProps> = ({ setCurrentModule }) => {
|
|
const [timeRange, setTimeRange] = useState<'week' | 'month' | 'quarter'>('month');
|
|
const [loading, setLoading] = useState(true);
|
|
const [frameEntries, setFrameEntries] = useState(FRAME_ENTRIES);
|
|
const [quizResults, setQuizResults] = useState<any[]>([]);
|
|
const [attendanceRecords, setAttendanceRecords] = useState<any[]>([]);
|
|
const [staffCount, setStaffCount] = useState(0);
|
|
|
|
useEffect(() => { loadAllData(); }, []);
|
|
|
|
const loadAllData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [frames, quizzes, attendance, staff] = await Promise.all([
|
|
fetchFrameEntries(), fetchQuizResults(), fetchAttendance(), fetchStaffUsers()
|
|
]);
|
|
if (frames.length > 0) setFrameEntries(frames);
|
|
setQuizResults(quizzes);
|
|
setAttendanceRecords(attendance);
|
|
setStaffCount(staff.length || 10);
|
|
} catch { /* fallback to defaults */ }
|
|
setLoading(false);
|
|
};
|
|
|
|
const presentCount = attendanceRecords.filter(r => r.status === 'present').length;
|
|
const totalAttendance = attendanceRecords.length || 1;
|
|
const attendanceRate = Math.round((presentCount / totalAttendance) * 100);
|
|
const quizCompletionRate = staffCount > 0 ? Math.round((quizResults.length / staffCount) * 100) : 0;
|
|
|
|
const overviewCards = [
|
|
{ label: 'Staff Attendance', value: `${attendanceRate}%`, change: `${attendanceRecords.length} records`, trend: 'up', icon: <Clock size={20} />, color: 'from-orange-400 to-orange-600', module: 'attendance' as ModuleId },
|
|
{ label: 'De-escalation Completion', value: `${quizResults.length}/${staffCount}`, change: `${quizCompletionRate}%`, trend: quizCompletionRate > 50 ? 'up' : 'down', icon: <Shield size={20} />, color: 'from-blue-400 to-blue-600', module: 'qbs' as ModuleId },
|
|
|
|
{ label: 'F.R.A.M.E. Entries', value: frameEntries.length.toString(), change: 'Total entries', trend: 'up', icon: <Eye size={20} />, color: 'from-amber-400 to-amber-600', module: 'frame' as ModuleId },
|
|
{ label: 'Staff Members', value: staffCount.toString(), change: 'Active', trend: 'up', icon: <Users size={20} />, color: 'from-purple-400 to-purple-600', module: 'attendance' as ModuleId },
|
|
];
|
|
|
|
const riskAreas = [
|
|
{ issue: `${staffCount - quizResults.length} staff haven't completed de-escalation quiz`, severity: staffCount - quizResults.length > 3 ? 'high' : 'medium', module: 'qbs' as ModuleId },
|
|
|
|
{ issue: 'Fire drill response time needs improvement (Bldg B)', severity: 'medium', module: 'safety' as ModuleId },
|
|
{ issue: `${attendanceRecords.filter(r => r.status === 'absent').length} absences recorded this period`, severity: attendanceRecords.filter(r => r.status === 'absent').length > 3 ? 'high' : 'low', module: 'attendance' as ModuleId },
|
|
];
|
|
|
|
const severityColors: Record<string, string> = { high: 'bg-red-100 text-red-700 border-red-200', medium: 'bg-amber-100 text-amber-700 border-amber-200', low: 'bg-blue-100 text-blue-700 border-blue-200' };
|
|
|
|
const weeklyFrameThemes = frameEntries.slice(0, 3).map(entry => ({
|
|
week: entry.weekOf,
|
|
fTheme: entry.formal.slice(0, 60) + '...', rTheme: entry.recognition.slice(0, 60) + '...',
|
|
aTheme: entry.application.slice(0, 60) + '...', mTheme: entry.management.slice(0, 60) + '...',
|
|
eTheme: entry.emotional.slice(0, 60) + '...',
|
|
}));
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-20">
|
|
<div className="text-center"><Loader2 size={32} className="animate-spin text-purple-500 mx-auto mb-3" /><p className="text-sm text-gray-500">Loading director dashboard...</p></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-gray-800 flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-purple-500 to-purple-700 flex items-center justify-center"><BarChart3 size={20} className="text-white" /></div>
|
|
Director Dashboard
|
|
</h2>
|
|
<p className="text-sm text-gray-500 mt-1 flex items-center gap-2">
|
|
Campus oversight, compliance tracking, and risk management
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-emerald-100 text-emerald-700 rounded-lg text-[10px] font-semibold"><Database size={10} /> Live Database</span>
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2 bg-white rounded-xl border border-violet-100 p-1">
|
|
{(['week', 'month', 'quarter'] as const).map(range => (
|
|
<button key={range} onClick={() => setTimeRange(range)} className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${timeRange === range ? 'bg-purple-500 text-white shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}>
|
|
{range.charAt(0).toUpperCase() + range.slice(1)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{overviewCards.map((card, i) => (
|
|
<button key={i} onClick={() => setCurrentModule(card.module)} className="bg-white rounded-2xl border border-violet-100 shadow-sm p-5 text-left hover:shadow-md transition-all hover:-translate-y-0.5 group">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className={`w-10 h-10 rounded-xl bg-gradient-to-br ${card.color} flex items-center justify-center text-white`}>{card.icon}</div>
|
|
<div className={`flex items-center gap-1 text-xs font-semibold ${card.trend === 'up' ? 'text-emerald-600' : 'text-red-600'}`}>
|
|
{card.trend === 'up' ? <TrendingUp size={12} /> : <TrendingDown size={12} />} {card.change}
|
|
</div>
|
|
</div>
|
|
<p className="text-2xl font-bold text-gray-800">{card.value}</p>
|
|
<p className="text-xs text-gray-500 mt-0.5">{card.label}</p>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
<div className="lg:col-span-2 space-y-6">
|
|
<div className="bg-white rounded-2xl border border-violet-100 shadow-sm p-5">
|
|
<h3 className="font-semibold text-gray-800 mb-4 flex items-center gap-2"><AlertTriangle size={18} className="text-amber-500" /> Risk Areas</h3>
|
|
<div className="space-y-3">
|
|
{riskAreas.map((risk, i) => (
|
|
<button key={i} onClick={() => setCurrentModule(risk.module)} className={`w-full text-left p-4 rounded-xl border ${severityColors[risk.severity]} flex items-center justify-between hover:shadow-sm transition-all`}>
|
|
<div className="flex items-center gap-3">
|
|
<span className={`px-2 py-1 rounded-lg text-[10px] font-bold uppercase ${severityColors[risk.severity]}`}>{risk.severity}</span>
|
|
<p className="text-sm font-medium text-gray-700">{risk.issue}</p>
|
|
</div>
|
|
<ArrowRight size={14} className="text-gray-400" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-2xl border border-violet-100 shadow-sm p-5">
|
|
<h3 className="font-semibold text-gray-800 mb-4 flex items-center gap-2"><Users size={18} className="text-violet-500" /> Quiz Results from Database</h3>
|
|
{quizResults.length > 0 ? (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead><tr className="bg-gray-50"><th className="text-left p-3 font-medium text-gray-500">Staff</th><th className="text-center p-3 font-medium text-gray-500">Role</th><th className="text-center p-3 font-medium text-gray-500">Score</th><th className="text-center p-3 font-medium text-gray-500">Date</th></tr></thead>
|
|
<tbody>
|
|
{quizResults.map((r: any, i: number) => (
|
|
<tr key={i} className="border-t border-gray-50">
|
|
<td className="p-3 font-medium text-gray-700">{r.user_name}</td>
|
|
<td className="p-3 text-center text-xs text-gray-500 capitalize">{r.user_role}</td>
|
|
<td className="p-3 text-center"><span className={`px-2 py-0.5 rounded-lg text-xs font-semibold ${r.score === r.total_questions ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'}`}>{r.score}/{r.total_questions}</span></td>
|
|
<td className="p-3 text-center text-xs text-gray-400">{new Date(r.completed_at).toLocaleDateString()}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-gray-400 text-center py-4">No quiz results yet. Staff will appear here after completing quizzes.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div className="bg-white rounded-2xl border border-amber-100 shadow-sm p-5">
|
|
<h3 className="font-semibold text-gray-800 mb-3 flex items-center gap-2"><Eye size={16} className="text-amber-500" /> F.R.A.M.E. Tracker</h3>
|
|
<div className="space-y-3">
|
|
{weeklyFrameThemes.map((week, i) => (
|
|
<div key={i} className={`p-3 rounded-xl ${i === 0 ? 'bg-amber-50 border border-amber-200' : 'bg-gray-50'}`}>
|
|
<p className="text-xs font-semibold text-gray-700 mb-1.5">{week.week}</p>
|
|
<div className="space-y-1">
|
|
{[{ letter: 'F', text: week.fTheme, color: 'text-violet-600' }, { letter: 'R', text: week.rTheme, color: 'text-amber-600' }, { letter: 'A', text: week.aTheme, color: 'text-emerald-600' }, { letter: 'M', text: week.mTheme, color: 'text-blue-600' }, { letter: 'E', text: week.eTheme, color: 'text-pink-600' }].map(item => (
|
|
<div key={item.letter} className="flex items-start gap-1.5"><span className={`font-bold text-[10px] ${item.color} w-3`}>{item.letter}</span><span className="text-[10px] text-gray-500 line-clamp-1">{item.text}</span></div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<button onClick={() => setCurrentModule('frame')} className="w-full mt-3 text-sm text-amber-600 hover:text-amber-800 font-medium flex items-center justify-center gap-1">View All Entries <ArrowRight size={14} /></button>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-2xl border border-violet-100 shadow-sm p-5">
|
|
<h3 className="font-semibold text-gray-800 mb-3">Quick Actions</h3>
|
|
<div className="space-y-2">
|
|
{[
|
|
{ label: 'Walk-Through Check-In', module: 'walkthrough' as ModuleId, color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200', icon: <ClipboardCheck size={14} /> },
|
|
{ label: 'Create F.R.A.M.E. Entry', module: 'frame' as ModuleId, color: 'bg-amber-100 text-amber-700 hover:bg-amber-200', icon: null },
|
|
{ label: 'View QBS Compliance', module: 'qbs' as ModuleId, color: 'bg-blue-100 text-blue-700 hover:bg-blue-200', icon: null },
|
|
{ label: 'Check Attendance', module: 'attendance' as ModuleId, color: 'bg-orange-100 text-orange-700 hover:bg-orange-200', icon: null },
|
|
{ label: 'Schedule Alert', module: 'internal-comm' as ModuleId, color: 'bg-rose-100 text-rose-700 hover:bg-rose-200', icon: null },
|
|
{ label: 'Review Safety', module: 'safety' as ModuleId, color: 'bg-red-100 text-red-700 hover:bg-red-200', icon: null },
|
|
].map((action, i) => (
|
|
<button key={i} onClick={() => setCurrentModule(action.module)} className={`w-full text-left px-4 py-2.5 rounded-xl text-sm font-medium transition-all flex items-center gap-2 ${action.color}`}>
|
|
{action.icon}
|
|
{action.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default DirectorDashboard;
|