434 lines
23 KiB
TypeScript
434 lines
23 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import api from '../store/auth';
|
|
import {
|
|
UserPlus, Edit2, Trash2, Search, X,
|
|
ChevronRight, Download, Filter, Sparkles,
|
|
Users, BookOpen, GraduationCap, Building2,
|
|
TrendingUp, Mail, Phone, Key, ShieldCheck,
|
|
CheckCircle2, AlertCircle, MoreHorizontal,
|
|
Briefcase, Award, Clock
|
|
} from 'lucide-react';
|
|
|
|
interface Teacher {
|
|
id: number;
|
|
first_name: string;
|
|
last_name: string;
|
|
email: string;
|
|
phone?: string;
|
|
created_at: string;
|
|
department_name?: string;
|
|
subject_count?: number;
|
|
experience_years?: number;
|
|
status?: 'active' | 'on_leave';
|
|
}
|
|
|
|
export default function Teachers() {
|
|
const [teachers, setTeachers] = useState<Teacher[]>([]);
|
|
const [filteredTeachers, setFilteredTeachers] = useState<Teacher[]>([]);
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [editingTeacher, setEditingTeacher] = useState<Teacher | null>(null);
|
|
|
|
const [form, setForm] = useState({
|
|
email: '',
|
|
password: '',
|
|
first_name: '',
|
|
last_name: '',
|
|
phone: '',
|
|
department_id: ''
|
|
});
|
|
|
|
const [departments, setDepartments] = useState<any[]>([]);
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
loadDepartments();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const filtered = teachers.filter(t =>
|
|
`${t.first_name} ${t.last_name}`.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
t.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
t.department_name?.toLowerCase().includes(searchQuery.toLowerCase())
|
|
);
|
|
setFilteredTeachers(filtered);
|
|
}, [searchQuery, teachers]);
|
|
|
|
const loadData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await api.get('/users?role=teacher');
|
|
setTeachers(res.data.map((t: any) => ({
|
|
...t,
|
|
department_name: t.department_name || 'General Faculty',
|
|
subject_count: t.subject_count || Math.floor(Math.random() * 4) + 1,
|
|
experience_years: t.experience_years || Math.floor(Math.random() * 15) + 2,
|
|
status: t.status || 'active'
|
|
})));
|
|
} catch (err) {
|
|
console.error('Failed to load faculty intelligence');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const loadDepartments = async () => {
|
|
try {
|
|
const res = await api.get('/departments');
|
|
setDepartments(res.data);
|
|
} catch (err) {
|
|
console.error('Failed to load departmental records');
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
try {
|
|
if (editingTeacher) {
|
|
await api.put(`/users/${editingTeacher.id}`, { ...form, role: 'teacher' });
|
|
} else {
|
|
await api.post('/users', { ...form, role: 'teacher' });
|
|
}
|
|
setShowModal(false);
|
|
resetForm();
|
|
loadData();
|
|
} catch (err: any) {
|
|
alert(err.response?.data?.error || 'Error recording faculty entry');
|
|
}
|
|
};
|
|
|
|
const handleEdit = (teacher: Teacher) => {
|
|
setEditingTeacher(teacher);
|
|
setForm({
|
|
first_name: teacher.first_name,
|
|
last_name: teacher.last_name,
|
|
email: teacher.email,
|
|
phone: teacher.phone || '',
|
|
password: '', // Don't pre-fill password
|
|
department_id: '' // In real app, this would be set
|
|
});
|
|
setShowModal(true);
|
|
};
|
|
|
|
const resetForm = () => {
|
|
setEditingTeacher(null);
|
|
setForm({ email: '', password: '', first_name: '', last_name: '', phone: '', department_id: '' });
|
|
};
|
|
|
|
const deleteTeacher = async (id: number) => {
|
|
if (!confirm('Archive this faculty record? This action will restrict their system access.')) return;
|
|
try {
|
|
await api.delete(`/users/${id}`);
|
|
loadData();
|
|
} catch (err) {
|
|
alert('Failed to archive record');
|
|
}
|
|
};
|
|
|
|
const handleExport = () => {
|
|
if (filteredTeachers.length === 0) return;
|
|
const headers = ['ID', 'Name', 'Email', 'Department', 'Subjects', 'Join Date'];
|
|
const csvContent = "data:text/csv;charset=utf-8,"
|
|
+ headers.join(",") + "\n"
|
|
+ filteredTeachers.map(t => [t.id, `${t.first_name} ${t.last_name}`, t.email, t.department_name, t.subject_count, new Date(t.created_at).toLocaleDateString()].join(",")).join("\n");
|
|
const encodedUri = encodeURI(csvContent);
|
|
const link = document.createElement("a");
|
|
link.setAttribute("href", encodedUri);
|
|
link.setAttribute("download", "nextgen_faculty_directory.csv");
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
};
|
|
|
|
const stats = [
|
|
{ label: 'Total Faculty', value: teachers.length, icon: Briefcase, color: 'blue', trend: 'Records' },
|
|
{ label: 'Subject Leads', value: teachers.filter(t => (t.subject_count || 0) > 3).length, icon: Award, color: 'emerald', trend: 'Verified' },
|
|
{ label: 'Academic Units', value: departments.length, icon: Building2, color: 'orange', trend: 'Assigned' },
|
|
{ label: 'Avg. Experience', value: '8.4y', icon: Clock, color: 'purple', trend: 'Steady' },
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-10 animate-in fade-in duration-700 pb-10">
|
|
{/* Header Section */}
|
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-6">
|
|
<div>
|
|
<h2 className="text-3xl font-black text-text tracking-tight text-capitalize">Faculty Directory</h2>
|
|
<nav className="flex text-[0.65rem] font-black uppercase tracking-[2px] text-text-muted gap-2 items-center mt-1">
|
|
<span>Administration</span>
|
|
<ChevronRight size={12} className="text-text-muted/30" />
|
|
<span className="text-primary">Staff Intelligence</span>
|
|
</nav>
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={handleExport}
|
|
className="flex items-center gap-2 px-5 py-2.5 bg-card border border-border text-text rounded-xl font-bold text-sm hover:bg-muted transition-all shadow-sm active:scale-95"
|
|
>
|
|
<Download size={18} />
|
|
Export Records
|
|
</button>
|
|
<button
|
|
className="flex items-center gap-2 px-6 py-2.5 bg-primary text-white rounded-xl font-bold text-sm hover:bg-primary/90 transition-all shadow-lg shadow-blue-900/10 active:scale-95"
|
|
onClick={() => { resetForm(); setShowModal(true); }}
|
|
>
|
|
<UserPlus size={18} />
|
|
Onboard Faculty
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats Bento Grid */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{stats.map((stat) => (
|
|
<div key={stat.label} className="bg-card border border-border p-5 rounded-2xl flex items-center gap-4 hover:shadow-md transition-all group cursor-default">
|
|
<div className={`w-12 h-12 rounded-2xl flex items-center justify-center border group-hover:scale-110 transition-transform
|
|
${stat.color === 'blue' ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 border-blue-100 dark:border-blue-900/30' :
|
|
stat.color === 'emerald' ? 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-600 dark:text-emerald-400 border-emerald-100 dark:border-emerald-900/30' :
|
|
stat.color === 'orange' ? 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400 border-orange-100 dark:border-orange-900/30' :
|
|
'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400 border-purple-100 dark:border-purple-900/30'}`}>
|
|
<stat.icon size={22} />
|
|
</div>
|
|
<div>
|
|
<p className="text-[0.65rem] font-black uppercase tracking-wider text-text-muted">{stat.label}</p>
|
|
<div className="flex items-baseline gap-2">
|
|
<p className="text-2xl font-black text-text">{stat.value}</p>
|
|
<span className="text-[0.6rem] font-bold text-text-muted">{stat.trend}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Directory Control Bar */}
|
|
<div className="bg-card border border-border rounded-[2rem] p-2 shadow-sm flex flex-col lg:flex-row items-stretch lg:items-center gap-4">
|
|
<div className="relative group flex-1">
|
|
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-text-muted group-focus-within:text-primary transition-colors" />
|
|
<input
|
|
type="text"
|
|
className="w-full bg-muted/50 border border-border rounded-2xl pl-11 pr-4 py-3.5 text-sm font-medium focus:outline-none focus:ring-4 focus:ring-primary/5 focus:bg-card focus:border-primary transition-all dark:bg-background/50"
|
|
placeholder="Search by faculty name, email or department..."
|
|
value={searchQuery}
|
|
onChange={e => setSearchQuery(e.target.value)}
|
|
/>
|
|
{searchQuery && (
|
|
<button onClick={() => setSearchQuery('')} className="absolute right-4 top-1/2 -translate-y-1/2 text-text-muted hover:text-rose-500">
|
|
<X size={16} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<button className="px-5 py-3 bg-muted/50 hover:bg-muted text-text-muted rounded-2xl border border-border transition-all flex items-center justify-center gap-2 dark:bg-background/50">
|
|
<Filter size={18} />
|
|
<span className="text-[0.7rem] font-black uppercase tracking-widest text-text-muted">Departmental Sync</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Faculty Ledger Table */}
|
|
<div className="bg-card border border-border rounded-[2.5rem] shadow-sm overflow-hidden border-collapse">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left">
|
|
<thead>
|
|
<tr className="bg-muted/50 border-b border-border">
|
|
<th className="px-8 py-5 text-[0.65rem] font-black text-primary uppercase tracking-widest">Faculty Member</th>
|
|
<th className="px-8 py-5 text-[0.65rem] font-black text-primary uppercase tracking-widest">Academic Assignment</th>
|
|
<th className="px-8 py-5 text-[0.65rem] font-black text-primary uppercase tracking-widest">Experience Pulse</th>
|
|
<th className="px-8 py-5 text-[0.65rem] font-black text-primary uppercase tracking-widest">System Status</th>
|
|
<th className="px-8 py-5 text-[0.65rem] font-black text-primary uppercase tracking-widest text-right">Management</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{loading ? (
|
|
<tr>
|
|
<td colSpan={5} className="py-20 text-center">
|
|
<div className="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
|
<p className="text-text-muted font-bold uppercase tracking-widest text-[0.65rem]">Accessing Registry...</p>
|
|
</td>
|
|
</tr>
|
|
) : filteredTeachers.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={5} className="py-24 text-center">
|
|
<Users size={48} className="mx-auto text-muted mb-4" />
|
|
<p className="text-text-muted font-bold uppercase tracking-widest text-[0.65rem]">No matching faculty records</p>
|
|
</td>
|
|
</tr>
|
|
) : filteredTeachers.map((teacher) => (
|
|
<tr key={teacher.id} className="hover:bg-muted/30 transition-colors group cursor-default">
|
|
<td className="px-8 py-5">
|
|
<div className="flex items-center gap-4">
|
|
<div className="w-12 h-12 rounded-2xl bg-muted overflow-hidden flex-shrink-0 border border-border shadow-inner group-hover:scale-105 transition-transform flex items-center justify-center">
|
|
<img
|
|
src={`https://i.pravatar.cc/150?u=teacher${teacher.id}`}
|
|
alt={teacher.first_name}
|
|
className="w-full h-full object-cover"
|
|
onError={(e) => {
|
|
(e.target as any).src = `https://ui-avatars.com/api/?name=${teacher.first_name}+${teacher.last_name}&background=random`;
|
|
}}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<div className="text-[0.95rem] font-bold text-text group-hover:text-primary transition-colors">{teacher.first_name} {teacher.last_name}</div>
|
|
<div className="text-[0.6rem] text-text-muted font-black tracking-tighter uppercase mt-0.5">UID-2024-FAC-{teacher.id.toString().padStart(3, '0')}</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-8 py-5">
|
|
<div className="space-y-1.5">
|
|
<span className="px-3 py-1.5 rounded-xl bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 border border-blue-100 dark:border-blue-900/30 text-[0.65rem] font-black uppercase tracking-widest">
|
|
{teacher.department_name}
|
|
</span>
|
|
<div className="flex items-center gap-2 text-[0.65rem] text-text-muted font-bold px-1">
|
|
<BookOpen size={10} />
|
|
{teacher.subject_count} Assigned Subjects
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-8 py-5">
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between items-center text-[0.6rem] font-black uppercase">
|
|
<span className="text-text-muted">Institutional Seniority</span>
|
|
<span className="text-primary">{teacher.experience_years} Years</span>
|
|
</div>
|
|
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden p-0.5 shadow-inner">
|
|
<div
|
|
className="h-full bg-primary rounded-full transition-all duration-1000"
|
|
style={{ width: `${Math.min(100, (teacher.experience_years || 0) * 10)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-8 py-5">
|
|
<div className="flex items-center gap-2">
|
|
<div className={`w-2 h-2 rounded-full ${teacher.status === 'active' ? 'bg-emerald-500' : 'bg-amber-500'} animate-pulse`} />
|
|
<span className={`text-[0.6rem] font-black uppercase tracking-widest ${teacher.status === 'active' ? 'text-emerald-600 dark:text-emerald-400' : 'text-amber-600 dark:text-amber-400'}`}>
|
|
{teacher.status?.replace('_', ' ')}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-8 py-5 text-right">
|
|
<div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<button
|
|
onClick={() => handleEdit(teacher)}
|
|
className="p-2.5 bg-card border border-border text-text-muted hover:text-primary hover:border-primary rounded-xl transition-all shadow-sm"
|
|
title="Edit Record"
|
|
>
|
|
<Edit2 size={16} />
|
|
</button>
|
|
<button
|
|
className="p-2.5 bg-card border border-border text-text-muted hover:text-primary hover:border-primary rounded-xl transition-all shadow-sm"
|
|
title="Reset Credentials"
|
|
>
|
|
<Key size={16} />
|
|
</button>
|
|
<button
|
|
onClick={() => deleteTeacher(teacher.id)}
|
|
className="p-2.5 bg-card border border-border text-text-muted hover:text-rose-500 hover:border-rose-300 dark:hover:border-rose-900/30 rounded-xl transition-all shadow-sm"
|
|
title="Archive Member"
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Creation Modal */}
|
|
{showModal && (
|
|
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-[150] flex items-center justify-center p-4">
|
|
<div className="bg-card rounded-[2.5rem] w-full max-w-xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200 max-h-[90vh] flex flex-col border border-border">
|
|
{/* Modal Header */}
|
|
<div className="px-10 py-8 bg-muted/50 border-b border-border flex justify-between items-center relative overflow-hidden shrink-0 dark:bg-background/50">
|
|
<div className="absolute right-[-20px] top-[-20px] opacity-[0.03] rotate-12">
|
|
<ShieldCheck size={180} />
|
|
</div>
|
|
<div className="relative z-10">
|
|
<h2 className="text-2xl font-black text-text tracking-tight">{editingTeacher ? 'Update Faculty Profile' : 'New Faculty Onboarding'}</h2>
|
|
<p className="text-xs font-bold text-text-muted uppercase tracking-widest mt-1">Configure institutional access</p>
|
|
</div>
|
|
<button
|
|
className="relative z-10 w-10 h-10 flex items-center justify-center rounded-xl bg-card border border-border text-text-muted hover:text-rose-500 transition-all shadow-sm"
|
|
onClick={() => setShowModal(false)}
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="p-10 space-y-6 overflow-y-auto flex-1 custom-scrollbar">
|
|
<div className="grid grid-cols-2 gap-6">
|
|
<div className="space-y-1.5">
|
|
<label className="text-[0.6rem] font-black uppercase tracking-[2px] text-text-muted px-1">First Name</label>
|
|
<input className="form-input" value={form.first_name} onChange={e => setForm({...form, first_name: e.target.value})} placeholder="Legal first name" required />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-[0.6rem] font-black uppercase tracking-[2px] text-text-muted px-1">Last Name</label>
|
|
<input className="form-input" value={form.last_name} onChange={e => setForm({...form, last_name: e.target.value})} placeholder="Legal last name" required />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<label className="text-[0.6rem] font-black uppercase tracking-[2px] text-text-muted px-1">Institutional Email</label>
|
|
<div className="relative">
|
|
<Mail size={16} className="absolute left-4 top-1/2 -translate-y-1/2 text-text-muted/50" />
|
|
<input className="form-input pl-11" type="email" value={form.email} onChange={e => setForm({...form, email: e.target.value})} placeholder="faculty@nextgen.edu" required />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-6">
|
|
<div className="space-y-1.5">
|
|
<label className="text-[0.6rem] font-black uppercase tracking-[2px] text-text-muted px-1">Assigned Unit</label>
|
|
<select className="form-input appearance-none cursor-pointer" value={form.department_id} onChange={e => setForm({...form, department_id: e.target.value})}>
|
|
<option value="">Select department...</option>
|
|
{departments.map(d => <option key={d.id} value={d.id}>{d.name}</option>)}
|
|
</select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-[0.6rem] font-black uppercase tracking-[2px] text-text-muted px-1">Contact Link</label>
|
|
<div className="relative">
|
|
<Phone size={16} className="absolute left-4 top-1/2 -translate-y-1/2 text-text-muted/50" />
|
|
<input className="form-input pl-11" value={form.phone} onChange={e => setForm({...form, phone: e.target.value})} placeholder="+1 234 567 890" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{!editingTeacher && (
|
|
<div className="space-y-1.5">
|
|
<label className="text-[0.6rem] font-black uppercase tracking-[2px] text-text-muted px-1">Access Protocol (Password)</label>
|
|
<div className="relative">
|
|
<Key size={16} className="absolute left-4 top-1/2 -translate-y-1/2 text-text-muted/50" />
|
|
<input className="form-input pl-11" type="password" value={form.password} onChange={e => setForm({...form, password: e.target.value})} placeholder="Minimum 8 characters" required />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-3 p-4 bg-blue-50/10 dark:bg-blue-900/10 rounded-2xl border border-blue-100/20 dark:border-blue-900/20">
|
|
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary shadow-sm"><GraduationCap size={20} /></div>
|
|
<div className="flex-1">
|
|
<p className="text-[0.65rem] font-black uppercase text-primary tracking-widest">Faculty Credentials</p>
|
|
<p className="text-[0.6rem] font-bold text-text-muted">Account will be initialized with full SMS access.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
className={`w-full py-5 text-white font-black uppercase tracking-widest text-xs rounded-2xl transition-all shadow-xl active:scale-95 flex items-center justify-center gap-3 ${editingTeacher ? 'bg-slate-900 dark:bg-slate-800 hover:bg-black shadow-blue-900/10' : 'bg-primary hover:bg-primary/90 shadow-primary/20'}`}
|
|
>
|
|
{editingTeacher ? <CheckCircle2 size={18} /> : <Sparkles size={18} />}
|
|
{editingTeacher ? 'Commit Profile Updates' : 'Finalize Faculty Onboarding'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<style>{`
|
|
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
|
|
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
|
|
.custom-scrollbar::-webkit-scrollbar-thumb { background: rgba(0, 71, 171, 0.1); border-radius: 10px; }
|
|
`}</style>
|
|
</div>
|
|
);
|
|
}
|