438 lines
19 KiB
TypeScript
Executable File
438 lines
19 KiB
TypeScript
Executable File
|
|
import React, { useState, useEffect, useMemo } from 'react';
|
|
import { Save, UserCheck, Clock, BookOpen, Calendar, Users, CheckCircle, RotateCcw, UserX, ClipboardList, User } from 'lucide-react';
|
|
import { saveClassJournalEntry } from '../services/apiService';
|
|
import { Student, Teacher, AttendanceDetail, AuthUser, Class } from '../types';
|
|
|
|
interface ClassJournalFormProps {
|
|
onSuccess: () => void;
|
|
students: Student[];
|
|
teachers: Teacher[];
|
|
subjects: string[];
|
|
classes?: Class[]; // Optional: classes from database
|
|
currentUser: AuthUser;
|
|
}
|
|
|
|
const ClassJournalForm: React.FC<ClassJournalFormProps> = ({ onSuccess, students, teachers, subjects, classes = [], currentUser }) => {
|
|
const [loading, setLoading] = useState(false);
|
|
const [selectedClass, setSelectedClass] = useState<string>('');
|
|
|
|
// Form State
|
|
const [formData, setFormData] = useState({
|
|
teacherName: '',
|
|
subject: '',
|
|
date: new Date().toISOString().split('T')[0],
|
|
startTime: '1',
|
|
endTime: '2',
|
|
teacherPresence: 'Hadir' as 'Hadir' | 'Tugas' | 'Tidak Hadir',
|
|
notes: '',
|
|
classSecretary: ''
|
|
});
|
|
|
|
// Pre-fill class if user is Secretary
|
|
useEffect(() => {
|
|
if (currentUser.role === 'SEKRETARIS' && currentUser.className) {
|
|
setSelectedClass(currentUser.className);
|
|
setFormData(prev => ({ ...prev, classSecretary: currentUser.name }));
|
|
}
|
|
}, [currentUser]);
|
|
|
|
// Attendance State: studentId -> Status
|
|
const [attendanceMap, setAttendanceMap] = useState<Record<string, 'H' | 'S' | 'I' | 'A' | 'D'>>({});
|
|
|
|
// Get Unique Classes - prefer classes prop, fallback to extracting from students
|
|
const availableClasses = useMemo(() => {
|
|
// If classes prop is provided and has data, use it
|
|
if (classes && classes.length > 0) {
|
|
return classes.map(c => c.name).sort();
|
|
}
|
|
// Fallback: extract unique class names from students
|
|
const classSet = new Set(students.map(s => s.className).filter(Boolean));
|
|
return Array.from(classSet).sort();
|
|
}, [classes, students]);
|
|
|
|
const classStudents = useMemo(() => {
|
|
if (!selectedClass) return [];
|
|
return students
|
|
.filter(s => s.className === selectedClass)
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
}, [selectedClass, students]);
|
|
|
|
// Reset attendance and secretary when class changes
|
|
useEffect(() => {
|
|
const newMap: Record<string, any> = {};
|
|
classStudents.forEach(s => {
|
|
newMap[s.id] = 'H';
|
|
});
|
|
setAttendanceMap(newMap);
|
|
|
|
// Only reset secretary if not logged in as one
|
|
if (currentUser.role !== 'SEKRETARIS') {
|
|
setFormData(prev => ({ ...prev, classSecretary: '' }));
|
|
}
|
|
}, [classStudents, currentUser.role]);
|
|
|
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
|
const { name, value } = e.target;
|
|
setFormData(prev => ({ ...prev, [name]: value }));
|
|
};
|
|
|
|
const handleAttendanceChange = (studentId: string, status: 'H' | 'S' | 'I' | 'A' | 'D') => {
|
|
setAttendanceMap(prev => ({
|
|
...prev,
|
|
[studentId]: status
|
|
}));
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!formData.teacherName || !selectedClass || !formData.subject || !formData.classSecretary) {
|
|
alert('Mohon lengkapi data Guru, Mapel, Kelas, dan Sekretaris.');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
|
|
let present = 0, sakit = 0, izin = 0, alpha = 0, disp = 0;
|
|
const attendanceDetailsList: AttendanceDetail[] = [];
|
|
|
|
classStudents.forEach(student => {
|
|
const status = attendanceMap[student.id] || 'H';
|
|
if (status === 'H') present++;
|
|
else if (status === 'S') { sakit++; }
|
|
else if (status === 'I') { izin++; }
|
|
else if (status === 'A') { alpha++; }
|
|
else if (status === 'D') { disp++; }
|
|
|
|
if (status !== 'H') {
|
|
attendanceDetailsList.push({
|
|
studentName: student.name,
|
|
status: status
|
|
});
|
|
}
|
|
});
|
|
|
|
const attendancePayload = attendanceDetailsList.length > 0 ? attendanceDetailsList : "NIHIL";
|
|
|
|
const success = await saveClassJournalEntry({
|
|
teacherName: formData.teacherName,
|
|
subject: formData.subject,
|
|
className: selectedClass,
|
|
teacherPresence: formData.teacherPresence,
|
|
classSecretary: formData.classSecretary,
|
|
...formData,
|
|
studentsPresent: present + disp,
|
|
sakit, izin, alpha, dispen: disp,
|
|
attendanceDetails: attendancePayload,
|
|
});
|
|
|
|
setLoading(false);
|
|
if (success) {
|
|
alert('Jurnal Kelas berhasil disimpan!');
|
|
onSuccess();
|
|
} else {
|
|
alert('Gagal menyimpan jurnal. Cek koneksi internet.');
|
|
}
|
|
};
|
|
|
|
const periods = Array.from({ length: 10 }, (_, i) => i + 1);
|
|
|
|
return (
|
|
<div className="max-w-5xl mx-auto animate-fade-in pb-20">
|
|
<div className="bg-white rounded-2xl shadow-lg overflow-hidden border border-slate-100">
|
|
<div className="bg-indigo-900 p-6 text-white flex items-center justify-between">
|
|
<div>
|
|
<h2 className="font-heading text-2xl font-bold">Input Jurnal Kelas</h2>
|
|
<p className="text-indigo-200 text-sm">Diisi oleh Sekretaris atau Piket Kelas</p>
|
|
</div>
|
|
<div className="h-12 w-12 bg-white/10 rounded-full flex items-center justify-center backdrop-blur-sm">
|
|
<ClipboardList className="text-accent-yellow" size={24} />
|
|
</div>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="p-6 md:p-8 space-y-8">
|
|
|
|
{/* Header Grid */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-semibold text-slate-600 flex items-center gap-2">
|
|
<Calendar size={16} /> Tanggal
|
|
</label>
|
|
<input
|
|
type="date"
|
|
name="date"
|
|
required
|
|
className="w-full px-4 py-3 rounded-xl border border-slate-200 focus:border-indigo-800 focus:ring-2 focus:ring-indigo-100 transition-all outline-none"
|
|
value={formData.date}
|
|
onChange={handleChange}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-semibold text-slate-600 flex items-center gap-2">
|
|
<Users size={16} /> Kelas
|
|
</label>
|
|
<select
|
|
value={selectedClass}
|
|
onChange={(e) => setSelectedClass(e.target.value)}
|
|
disabled={currentUser.role === 'SEKRETARIS'}
|
|
className={`w-full px-4 py-3 rounded-xl border border-slate-200 focus:border-indigo-800 focus:ring-2 focus:ring-indigo-100 transition-all outline-none bg-white ${currentUser.role === 'SEKRETARIS' ? 'bg-slate-100 text-slate-500 cursor-not-allowed' : ''}`}
|
|
required
|
|
>
|
|
<option value="">-- Pilih Kelas --</option>
|
|
{availableClasses.map((c, idx) => (
|
|
<option key={idx} value={c}>{c}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{selectedClass && (
|
|
<div className="space-y-2 animate-fade-in">
|
|
<label className="text-sm font-semibold text-slate-600 flex items-center gap-2">
|
|
<User size={16} /> Sekretaris Kelas (Pengisi Jurnal)
|
|
</label>
|
|
<select
|
|
name="classSecretary"
|
|
value={formData.classSecretary}
|
|
onChange={handleChange}
|
|
disabled={currentUser.role === 'SEKRETARIS'}
|
|
className={`w-full px-4 py-3 rounded-xl border border-slate-200 focus:border-indigo-800 focus:ring-2 focus:ring-indigo-100 transition-all outline-none bg-white ${currentUser.role === 'SEKRETARIS' ? 'bg-slate-100 text-slate-500' : ''}`}
|
|
required
|
|
>
|
|
<option value="">-- Pilih Nama Sekretaris --</option>
|
|
{classStudents.map((s, idx) => (
|
|
<option key={idx} value={s.name}>{s.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
|
|
{/* Placeholder if no class selected to maintain grid layout */}
|
|
{!selectedClass && <div className="hidden md:block"></div>}
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-semibold text-slate-600 flex items-center gap-2">
|
|
<UserCheck size={16} /> Nama Guru
|
|
</label>
|
|
<select
|
|
name="teacherName"
|
|
value={formData.teacherName}
|
|
onChange={handleChange}
|
|
className="w-full px-4 py-3 rounded-xl border border-slate-200 focus:border-indigo-800 focus:ring-2 focus:ring-indigo-100 transition-all outline-none bg-white"
|
|
required
|
|
>
|
|
<option value="">-- Pilih Guru --</option>
|
|
{teachers.map((t, idx) => (
|
|
<option key={idx} value={t.name}>{t.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-semibold text-slate-600 flex items-center gap-2">
|
|
<BookOpen size={16} /> Mata Pelajaran
|
|
</label>
|
|
<select
|
|
name="subject"
|
|
value={formData.subject}
|
|
onChange={handleChange}
|
|
className="w-full px-4 py-3 rounded-xl border border-slate-200 focus:border-indigo-800 focus:ring-2 focus:ring-indigo-100 transition-all outline-none bg-white"
|
|
required
|
|
>
|
|
<option value="">-- Pilih Mata Pelajaran --</option>
|
|
{subjects.map((s, idx) => (
|
|
<option key={idx} value={s}>{s}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Presence & Time */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div className="space-y-3">
|
|
<label className="text-sm font-semibold text-slate-600 flex items-center gap-2">
|
|
<UserX size={16} /> Kehadiran Guru
|
|
</label>
|
|
<div className="flex gap-4">
|
|
{['Hadir', 'Tugas', 'Tidak Hadir'].map((status) => (
|
|
<label key={status} className={`flex-1 cursor-pointer border rounded-xl p-3 flex items-center justify-center gap-2 transition-all ${formData.teacherPresence === status ? 'bg-indigo-50 border-indigo-500 text-indigo-900 font-bold shadow-sm' : 'border-slate-200 hover:bg-slate-50'}`}>
|
|
<input
|
|
type="radio"
|
|
name="teacherPresence"
|
|
value={status}
|
|
checked={formData.teacherPresence === status}
|
|
onChange={handleChange}
|
|
className="hidden"
|
|
/>
|
|
{status === 'Hadir' && <CheckCircle size={18} className="text-green-500" />}
|
|
{status === 'Tugas' && <ClipboardList size={18} className="text-blue-500" />}
|
|
{status === 'Tidak Hadir' && <UserX size={18} className="text-red-500" />}
|
|
{status}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-semibold text-slate-600 flex items-center gap-2">
|
|
<Clock size={16} /> Jam Ke- (Mulai - Selesai)
|
|
</label>
|
|
<div className="flex items-center gap-2">
|
|
<select
|
|
name="startTime"
|
|
className="w-full px-3 py-3 rounded-xl border border-slate-200 outline-none text-center bg-white"
|
|
value={formData.startTime}
|
|
onChange={handleChange}
|
|
>
|
|
{periods.map(p => (
|
|
<option key={p} value={p}>{p}</option>
|
|
))}
|
|
</select>
|
|
<span className="text-slate-400 font-bold">-</span>
|
|
<select
|
|
name="endTime"
|
|
className="w-full px-3 py-3 rounded-xl border border-slate-200 outline-none text-center bg-white"
|
|
value={formData.endTime}
|
|
onChange={handleChange}
|
|
>
|
|
{periods.map(p => (
|
|
<option key={p} value={p}>{p}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<hr className="border-slate-100" />
|
|
|
|
{/* Student Attendance Table */}
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-lg font-heading font-bold text-school-900 flex items-center gap-2">
|
|
<CheckCircle size={20} className="text-accent-green" /> Presensi Siswa
|
|
</h3>
|
|
<div className="text-sm text-slate-500">
|
|
Total Siswa: <span className="font-bold text-school-900">{classStudents.length}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{!selectedClass ? (
|
|
<div className="bg-slate-50 rounded-xl p-8 text-center text-slate-500 border border-dashed border-slate-300">
|
|
Silahkan pilih kelas terlebih dahulu untuk memuat daftar siswa.
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto rounded-xl border border-slate-200 shadow-sm">
|
|
<table className="w-full text-sm text-left">
|
|
<thead className="bg-slate-50 text-slate-600 font-bold uppercase text-xs tracking-wider">
|
|
<tr>
|
|
<th className="p-3 w-10 text-center">No</th>
|
|
<th className="p-3">Nama Siswa</th>
|
|
<th className="p-3 text-center w-20 bg-yellow-50">Sakit</th>
|
|
<th className="p-3 text-center w-20 bg-blue-50">Izin</th>
|
|
<th className="p-3 text-center w-20 bg-red-50">Alpha</th>
|
|
<th className="p-3 text-center w-20 bg-purple-50">Disp.</th>
|
|
<th className="p-3 text-center w-10">Reset</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100">
|
|
{classStudents.map((student, index) => {
|
|
const status = attendanceMap[student.id] || 'H';
|
|
const isAbsent = status !== 'H';
|
|
|
|
return (
|
|
<tr key={student.id} className={`hover:bg-gray-50 transition-colors ${isAbsent ? 'bg-red-50/10' : ''}`}>
|
|
<td className="p-3 text-center text-slate-500">{index + 1}</td>
|
|
<td className="p-3 font-medium text-slate-700">
|
|
{student.name}
|
|
<div className="text-[10px] text-slate-400">{student.nis}</div>
|
|
</td>
|
|
<td className="p-3 text-center bg-yellow-50/30">
|
|
<input
|
|
type="radio"
|
|
name={`att-${student.id}`}
|
|
checked={status === 'S'}
|
|
onChange={() => handleAttendanceChange(student.id, 'S')}
|
|
className="w-5 h-5 accent-yellow-500 cursor-pointer"
|
|
/>
|
|
</td>
|
|
<td className="p-3 text-center bg-blue-50/30">
|
|
<input
|
|
type="radio"
|
|
name={`att-${student.id}`}
|
|
checked={status === 'I'}
|
|
onChange={() => handleAttendanceChange(student.id, 'I')}
|
|
className="w-5 h-5 accent-blue-500 cursor-pointer"
|
|
/>
|
|
</td>
|
|
<td className="p-3 text-center bg-red-50/30">
|
|
<input
|
|
type="radio"
|
|
name={`att-${student.id}`}
|
|
checked={status === 'A'}
|
|
onChange={() => handleAttendanceChange(student.id, 'A')}
|
|
className="w-5 h-5 accent-red-500 cursor-pointer"
|
|
/>
|
|
</td>
|
|
<td className="p-3 text-center bg-purple-50/30">
|
|
<input
|
|
type="radio"
|
|
name={`att-${student.id}`}
|
|
checked={status === 'D'}
|
|
onChange={() => handleAttendanceChange(student.id, 'D')}
|
|
className="w-5 h-5 accent-purple-500 cursor-pointer"
|
|
/>
|
|
</td>
|
|
<td className="p-3 text-center">
|
|
{isAbsent && (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleAttendanceChange(student.id, 'H')}
|
|
className="p-1 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-full transition"
|
|
title="Reset ke Hadir"
|
|
>
|
|
<RotateCcw size={16} />
|
|
</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-semibold text-slate-600">Keterangan Tambahan</label>
|
|
<textarea
|
|
name="notes"
|
|
rows={2}
|
|
placeholder="Catatan kegiatan kelas atau kejadian penting..."
|
|
className="w-full px-4 py-3 rounded-xl border border-slate-200 focus:border-indigo-800 focus:ring-2 focus:ring-indigo-100 transition-all outline-none resize-none"
|
|
value={formData.notes}
|
|
onChange={handleChange}
|
|
/>
|
|
</div>
|
|
|
|
<div className="pt-4">
|
|
<button
|
|
type="submit"
|
|
disabled={loading}
|
|
className={`w-full py-4 rounded-xl font-heading font-bold text-lg text-white shadow-lg transform transition-all hover:-translate-y-1 flex items-center justify-center gap-2 ${loading ? 'bg-slate-400 cursor-not-allowed' : 'bg-indigo-600 hover:bg-indigo-700 hover:shadow-indigo-500/30'
|
|
}`}
|
|
>
|
|
{loading ? 'Menyimpan Data...' : (
|
|
<><Save size={20} /> SIMPAN JURNAL KELAS</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ClassJournalForm;
|