369 lines
18 KiB
TypeScript
Executable File
369 lines
18 KiB
TypeScript
Executable File
|
|
import React, { useState, useEffect, useRef } from 'react';
|
|
import { GraduationCap, Plus, Pencil, Trash2, Upload, Download, X, Save, Search, Filter } from 'lucide-react';
|
|
|
|
interface Student {
|
|
id: number;
|
|
name: string;
|
|
nis: string;
|
|
class_name: string;
|
|
gender: string;
|
|
}
|
|
|
|
interface ManageStudentsProps {
|
|
onClose?: () => void;
|
|
classes: any[];
|
|
onRefresh: () => void;
|
|
}
|
|
|
|
const ManageStudents: React.FC<ManageStudentsProps> = ({ onClose, classes: classList, onRefresh }) => {
|
|
const [students, setStudents] = useState<Student[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [filterClass, setFilterClass] = useState('');
|
|
const [showForm, setShowForm] = useState(false);
|
|
const [editingStudent, setEditingStudent] = useState<Student | null>(null);
|
|
const [formData, setFormData] = useState({ name: '', nis: '', class_name: '', gender: '' });
|
|
const [saving, setSaving] = useState(false);
|
|
const [importResult, setImportResult] = useState<string | null>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
|
|
|
useEffect(() => {
|
|
fetchStudents();
|
|
}, []);
|
|
|
|
const fetchStudents = async () => {
|
|
try {
|
|
const response = await fetch(`${API_URL}/students`);
|
|
const result = await response.json();
|
|
if (result.status === 'success') {
|
|
setStudents(result.data);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching students:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setSaving(true);
|
|
|
|
try {
|
|
const url = editingStudent
|
|
? `${API_URL}/students/${editingStudent.id}`
|
|
: `${API_URL}/students`;
|
|
|
|
const response = await fetch(url, {
|
|
method: editingStudent ? 'PUT' : 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(formData)
|
|
});
|
|
|
|
const result = await response.json();
|
|
if (result.status === 'success') {
|
|
alert(result.message);
|
|
fetchStudents();
|
|
onRefresh();
|
|
setShowForm(false);
|
|
setEditingStudent(null);
|
|
setFormData({ name: '', nis: '', class_name: '', gender: '' });
|
|
} else {
|
|
alert(result.message);
|
|
}
|
|
} catch (error) {
|
|
alert('Gagal menyimpan data');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: number) => {
|
|
if (!confirm('Yakin ingin menghapus siswa ini?')) return;
|
|
|
|
try {
|
|
const response = await fetch(`${API_URL}/students/${id}`, { method: 'DELETE' });
|
|
const result = await response.json();
|
|
if (result.status === 'success') {
|
|
fetchStudents();
|
|
onRefresh();
|
|
}
|
|
} catch (error) {
|
|
alert('Gagal menghapus data');
|
|
}
|
|
};
|
|
|
|
const handleEdit = (student: Student) => {
|
|
setEditingStudent(student);
|
|
setFormData({
|
|
name: student.name,
|
|
nis: student.nis || '',
|
|
class_name: student.class_name,
|
|
gender: student.gender || ''
|
|
});
|
|
setShowForm(true);
|
|
};
|
|
|
|
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
try {
|
|
const response = await fetch(`${API_URL}/students/import`, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
const result = await response.json();
|
|
setImportResult(result.message);
|
|
fetchStudents();
|
|
onRefresh();
|
|
} catch (error) {
|
|
setImportResult('Gagal import data');
|
|
}
|
|
|
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
};
|
|
|
|
const downloadTemplate = () => {
|
|
window.open(`${API_URL}/students/template`, '_blank');
|
|
};
|
|
|
|
// Ensure classList and students are always arrays
|
|
const safeClassList = Array.isArray(classList) ? classList : [];
|
|
const safeStudents = Array.isArray(students) ? students : [];
|
|
|
|
const filteredStudents = safeStudents.filter(s => {
|
|
if (!s || !s.name) return false;
|
|
const matchesSearch = s.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
(s.nis && s.nis.includes(searchTerm));
|
|
const matchesClass = !filterClass || s.class_name === filterClass;
|
|
return matchesSearch && matchesClass;
|
|
});
|
|
|
|
return (
|
|
<div className="bg-white rounded-2xl shadow-lg border border-slate-100 overflow-hidden">
|
|
<div className="bg-school-900 p-6 text-white flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<GraduationCap size={24} className="text-accent-yellow" />
|
|
<div>
|
|
<h2 className="font-heading text-xl font-bold">Kelola Data Siswa</h2>
|
|
<p className="text-blue-200 text-sm">Tambah, edit, hapus, atau import dari Excel</p>
|
|
</div>
|
|
</div>
|
|
{onClose && (
|
|
<button onClick={onClose} className="text-white/70 hover:text-white">
|
|
<X size={24} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="p-6 space-y-4">
|
|
{/* Actions Bar */}
|
|
<div className="flex flex-wrap gap-3 items-center justify-between">
|
|
<div className="flex gap-3 flex-1 min-w-[300px]">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
|
<input
|
|
type="text"
|
|
placeholder="Cari siswa..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="w-full pl-10 pr-4 py-2 rounded-xl border border-slate-200 focus:border-school-800 outline-none"
|
|
/>
|
|
</div>
|
|
<div className="relative">
|
|
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
|
<select
|
|
value={filterClass}
|
|
onChange={(e) => setFilterClass(e.target.value)}
|
|
className="pl-10 pr-8 py-2 rounded-xl border border-slate-200 focus:border-school-800 outline-none appearance-none bg-white"
|
|
>
|
|
<option value="">Semua Kelas</option>
|
|
{safeClassList.map(c => (
|
|
<option key={c.id || c.name} value={c.name}>{c.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={downloadTemplate}
|
|
className="flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-xl hover:bg-slate-200 transition font-medium"
|
|
>
|
|
<Download size={18} /> Template
|
|
</button>
|
|
<label className="flex items-center gap-2 px-4 py-2 bg-green-500 text-white rounded-xl hover:bg-green-600 transition font-medium cursor-pointer">
|
|
<Upload size={18} /> Import Excel
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".xlsx,.xls"
|
|
onChange={handleImport}
|
|
className="hidden"
|
|
/>
|
|
</label>
|
|
<button
|
|
onClick={() => { setShowForm(true); setEditingStudent(null); setFormData({ name: '', nis: '', class_name: '', gender: '' }); }}
|
|
className="flex items-center gap-2 px-4 py-2 bg-school-900 text-white rounded-xl hover:bg-school-800 transition font-medium"
|
|
>
|
|
<Plus size={18} /> Tambah Siswa
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Import Result */}
|
|
{importResult && (
|
|
<div className="bg-blue-50 border border-blue-200 text-blue-800 px-4 py-3 rounded-xl flex justify-between items-center">
|
|
<span>{importResult}</span>
|
|
<button onClick={() => setImportResult(null)}><X size={18} /></button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Form Modal */}
|
|
{showForm && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white rounded-2xl p-6 w-full max-w-md">
|
|
<h3 className="text-lg font-bold mb-4">{editingStudent ? 'Edit Siswa' : 'Tambah Siswa Baru'}</h3>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-600 mb-1">Nama Siswa *</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
value={formData.name}
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
className="w-full px-4 py-2 rounded-xl border border-slate-200 focus:border-school-800 outline-none"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-600 mb-1">NIS</label>
|
|
<input
|
|
type="text"
|
|
value={formData.nis}
|
|
onChange={(e) => setFormData({ ...formData, nis: e.target.value })}
|
|
className="w-full px-4 py-2 rounded-xl border border-slate-200 focus:border-school-800 outline-none"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-600 mb-1">Kelas *</label>
|
|
<select
|
|
required
|
|
value={formData.class_name}
|
|
onChange={(e) => setFormData({ ...formData, class_name: e.target.value })}
|
|
className="w-full px-4 py-2 rounded-xl border border-slate-200 focus:border-school-800 outline-none bg-white font-medium"
|
|
>
|
|
<option value="">Pilih Kelas</option>
|
|
{safeClassList.map(c => (
|
|
<option key={c.id || c.name} value={c.name}>{c.name} (Kelas {c.grade})</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-600 mb-1">Jenis Kelamin</label>
|
|
<select
|
|
value={formData.gender}
|
|
onChange={(e) => setFormData({ ...formData, gender: e.target.value })}
|
|
className="w-full px-4 py-2 rounded-xl border border-slate-200 focus:border-school-800 outline-none"
|
|
>
|
|
<option value="">Pilih</option>
|
|
<option value="L">Laki-laki</option>
|
|
<option value="P">Perempuan</option>
|
|
</select>
|
|
</div>
|
|
<div className="flex gap-2 pt-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowForm(false)}
|
|
className="flex-1 px-4 py-2 bg-slate-100 text-slate-700 rounded-xl hover:bg-slate-200 transition font-medium"
|
|
>
|
|
Batal
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={saving}
|
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-school-900 text-white rounded-xl hover:bg-school-800 transition font-medium disabled:opacity-50"
|
|
>
|
|
<Save size={18} /> {saving ? 'Menyimpan...' : 'Simpan'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Table */}
|
|
{loading ? (
|
|
<div className="text-center py-8 text-slate-400">Memuat data...</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="bg-slate-50 border-b border-slate-200">
|
|
<th className="text-left px-4 py-3 font-semibold text-slate-600">No</th>
|
|
<th className="text-left px-4 py-3 font-semibold text-slate-600">Nama Siswa</th>
|
|
<th className="text-left px-4 py-3 font-semibold text-slate-600">NIS</th>
|
|
<th className="text-left px-4 py-3 font-semibold text-slate-600">Kelas</th>
|
|
<th className="text-center px-4 py-3 font-semibold text-slate-600">L/P</th>
|
|
<th className="text-center px-4 py-3 font-semibold text-slate-600">Aksi</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredStudents.map((student, index) => (
|
|
<tr key={student.id} className="border-b border-slate-100 hover:bg-slate-50">
|
|
<td className="px-4 py-3 text-slate-500">{index + 1}</td>
|
|
<td className="px-4 py-3 font-medium">{student.name}</td>
|
|
<td className="px-4 py-3 text-slate-600">{student.nis || '-'}</td>
|
|
<td className="px-4 py-3 text-slate-600">{student.class_name}</td>
|
|
<td className="px-4 py-3 text-center">
|
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${student.gender === 'L' ? 'bg-blue-100 text-blue-700' :
|
|
student.gender === 'P' ? 'bg-pink-100 text-pink-700' : 'bg-slate-100 text-slate-500'
|
|
}`}>
|
|
{student.gender || '-'}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex justify-center gap-2">
|
|
<button
|
|
onClick={() => handleEdit(student)}
|
|
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition"
|
|
>
|
|
<Pencil size={16} />
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(student.id)}
|
|
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition"
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{filteredStudents.length === 0 && (
|
|
<tr>
|
|
<td colSpan={6} className="px-4 py-8 text-center text-slate-400">
|
|
Tidak ada data siswa
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
<div className="text-sm text-slate-500 pt-2">
|
|
Total: {filteredStudents.length} siswa {filterClass && `di ${filterClass}`}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ManageStudents;
|