Download index.html from SMKBK1/testing: direct link, hf CLI and curl.
- Browser
- Download file 69.5 kB
-
https://huggingface.co/spaces/SMKBK1/testing/resolve/main/index.html
- Command line
-
hf download hf://spaces/SMKBK1/testing/index.html
-
curl -L -o index.html https://huggingface.co/spaces/SMKBK1/testing/resolve/main/index.html
69.5 kB
| <html lang="ms"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>E-Buku Kawalan Bersama</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script> | |
| <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script> | |
| <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet"> | |
| <style> | |
| body { font-family: 'Inter', sans-serif; } | |
| ::-webkit-scrollbar { width: 6px; height: 6px; } | |
| ::-webkit-scrollbar-track { background: #f1f5f9; } | |
| ::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 9999px; } | |
| ::-webkit-scrollbar-thumb:hover { background: #94a3b8; } | |
| </style> | |
| </head> | |
| <body class="bg-slate-100 min-h-screen text-slate-800 flex flex-col justify-between"> | |
| <div id="root" class="flex-grow"></div> | |
| <script type="text/babel"> | |
| const { useState, useEffect, useMemo, useCallback } = React; | |
| const Icon = ({ path, className = "w-5 h-5", viewBox = "0 0 24 24" }) => ( | |
| <svg className={className} fill="none" stroke="currentColor" viewBox={viewBox} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> | |
| {path} | |
| </svg> | |
| ); | |
| const BookOpen = (p) => <Icon {...p} path={<><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></>} />; | |
| const Calendar = (p) => <Icon {...p} path={<><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></>} />; | |
| const Users = (p) => <Icon {...p} path={<><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></>} />; | |
| const AlertTriangle = (p) => <Icon {...p} path={<><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></>} />; | |
| const PieChart = (p) => <Icon {...p} path={<><path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/></>} />; | |
| const Activity = (p) => <Icon {...p} path={<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>} />; | |
| const Clock = (p) => <Icon {...p} path={<><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></>} />; | |
| const CheckSquare = (p) => <Icon {...p} path={<><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></>} />; | |
| const Trash2 = (p) => <Icon {...p} path={<><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></>} />; | |
| const CloudOff = (p) => <Icon {...p} path={<><line x1="1" y1="1" x2="23" y2="23"/><path d="M22.61 16.95A5 5 0 0 0 18 10h-1.26a8 8 0 0 0-7.05-6M5 5a8 8 0 0 0-4 7h.17A5 5 0 0 0 5 22h13"/></>} />; | |
| const Copy = (p) => <Icon {...p} path={<><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></>} />; | |
| const LayoutDashboard = (p) => <Icon {...p} path={<><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="15" width="7" height="6"/></>} />; | |
| const UserX = (p) => <Icon {...p} path={<><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="18" y1="8" x2="23" y2="13"/><line x1="23" y1="8" x2="18" y2="13"/></>} />; | |
| const UserCheck = (p) => <Icon {...p} path={<><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><polyline points="17 11 19 13 23 9"/></>} />; | |
| const ShieldAlert = (p) => <Icon {...p} path={<><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></>} />; | |
| const ArrowRight = (p) => <Icon {...p} path={<><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></>} />; | |
| const GOOGLE_SHEETS_WEB_APP_URL = 'https://script.google.com/macros/s/AKfycbyW8HovLImrWBDRqG_nL50DrotcTuHQbYAm5kW2HNd1_PKLwZPO4l6P9pjGCdk4T4M/exec'; | |
| const LOGS_WEB_APP_URL = 'https://script.google.com/macros/s/AKfycbw8neGdrM0tScM5A2bCK4DamtkA9A_-TvCIgQ8PrcXJPxqEZ5nudr9_FLjp9zyStWBm/exec'; | |
| const DEFAULT_CLASSES = [ | |
| 'PA', | |
| '1A', '1B', '1C', '1D', '1E', | |
| '2A', '2B', '2C', '2D', '2E', | |
| '3A', '3B', '3C', '3D', '3E', | |
| '4A', '4B', '4C', '4D', '4E', | |
| '5A', '5B', '5C', '5D', '5E' | |
| ]; | |
| const getDynamicTimeSlots = (className) => { | |
| const isLowerForm = ['P', '1', '2', '3'].includes(className.charAt(0)); | |
| const slots = [ | |
| { id: 'GT', label: 'GT', time: '0650-0700', endHour: 7, endMin: 0 }, | |
| { id: '1', label: '1', time: '0700-0730', endHour: 7, endMin: 30 }, | |
| { id: '2', label: '2', time: '0730-0800', endHour: 8, endMin: 0 }, | |
| { id: '3', label: '3', time: '0800-0830', endHour: 8, endMin: 30 }, | |
| { id: '4', label: '4', time: '0830-0900', endHour: 9, endMin: 0 }, | |
| { id: '5', label: '5', time: '0900-0930', endHour: 9, endMin: 30 }, | |
| ]; | |
| if (isLowerForm) { | |
| slots.push({ id: 'R', label: 'REHAT', time: '0930-1000', endHour: 10, endMin: 0 }); | |
| slots.push({ id: '6', label: '6', time: '1000-1030', endHour: 10, endMin: 30 }); | |
| } else { | |
| slots.push({ id: '6', label: '6', time: '0930-1000', endHour: 10, endMin: 0 }); | |
| slots.push({ id: 'R', label: 'REHAT', time: '1000-1030', endHour: 10, endMin: 30 }); | |
| } | |
| slots.push( | |
| { id: '7', label: '7', time: '1030-1100', endHour: 11, endMin: 0 }, | |
| { id: '8', label: '8', time: '1100-1130', endHour: 11, endMin: 30 }, | |
| { id: '9', label: '9', time: '1130-1200', endHour: 12, endMin: 0 }, | |
| { id: '10', label: '10', time: '1200-1230', endHour: 12, endMin: 30 }, | |
| { id: '11', label: '11', time: '1230-1300', endHour: 13, endMin: 0 }, | |
| { id: '12', label: '12', time: '1300-1330', endHour: 13, endMin: 30 }, | |
| { id: '13', label: '13', time: '1330-1400', endHour: 14, endMin: 0 } | |
| ); | |
| return slots; | |
| }; | |
| const getCurrentActiveSlot = (className, hour, min) => { | |
| const slots = getDynamicTimeSlots(className); | |
| const currentMins = hour * 60 + min; | |
| for (const slot of slots) { | |
| const [startStr, endStr] = slot.time.split('-'); | |
| const startMins = parseInt(startStr.slice(0, 2)) * 60 + parseInt(startStr.slice(2, 4)); | |
| const endMins = parseInt(endStr.slice(0, 2)) * 60 + parseInt(endStr.slice(2, 4)); | |
| if (currentMins >= startMins && currentMins < endMins) { | |
| return slot; | |
| } | |
| } | |
| return null; | |
| }; | |
| const postLog = async (payload) => { | |
| try { | |
| await fetch(LOGS_WEB_APP_URL, { | |
| method: 'POST', | |
| mode: 'no-cors', | |
| headers: { 'Content-Type': 'text/plain' }, | |
| body: JSON.stringify(payload) | |
| }); | |
| return true; | |
| } catch (err) { | |
| console.error('Post log error:', err); | |
| return false; | |
| } | |
| }; | |
| const MALAYSIA_TIME_ZONE = 'Asia/Kuala_Lumpur'; | |
| const getMalaysiaParts = (date = new Date()) => { | |
| const parts = new Intl.DateTimeFormat('en-CA', { | |
| timeZone: MALAYSIA_TIME_ZONE, | |
| year: 'numeric', | |
| month: '2-digit', | |
| day: '2-digit', | |
| hour: '2-digit', | |
| minute: '2-digit', | |
| second: '2-digit', | |
| hourCycle: 'h23' | |
| }).formatToParts(date); | |
| const get = (type) => parts.find(p => p.type === type)?.value || '00'; | |
| return { | |
| year: Number(get('year')), | |
| month: Number(get('month')), | |
| day: Number(get('day')), | |
| hour: Number(get('hour')), | |
| minute: Number(get('minute')), | |
| second: Number(get('second')) | |
| }; | |
| }; | |
| const getMalaysiaDateId = (date = new Date()) => { | |
| const p = getMalaysiaParts(date); | |
| return `${p.day.toString().padStart(2, '0')}-${p.month.toString().padStart(2, '0')}-${p.year}`; | |
| }; | |
| const getMalaysiaDateInputValue = (date = new Date()) => { | |
| const p = getMalaysiaParts(date); | |
| return `${p.year}-${p.month.toString().padStart(2, '0')}-${p.day.toString().padStart(2, '0')}`; | |
| }; | |
| const getMalaysiaTimeString = (date = new Date()) => { | |
| const p = getMalaysiaParts(date); | |
| return `${p.hour.toString().padStart(2, '0')}:${p.minute.toString().padStart(2, '0')}`; | |
| }; | |
| const getMalaysiaDateObject = (date = new Date()) => { | |
| const p = getMalaysiaParts(date); | |
| return new Date(p.year, p.month - 1, p.day); | |
| }; | |
| function App() { | |
| const [isOnline, setIsOnline] = useState(false); | |
| const [activeTab, setActiveTab] = useState('class_view'); | |
| const [selectedClass, setSelectedClass] = useState('5B'); | |
| const [teachersData, setTeachersData] = useState([]); | |
| const [subjectsData, setSubjectsData] = useState([]); | |
| const [studentsData, setStudentsData] = useState({}); | |
| const [attendanceData, setAttendanceData] = useState({}); | |
| const [error, setError] = useState(null); | |
| const [isLoading, setIsLoading] = useState(true); | |
| const [allClassLogs, setAllClassLogs] = useState({}); | |
| const [activeSlot, setActiveSlot] = useState(null); | |
| const [isTeacherModalOpen, setIsTeacherModalOpen] = useState(false); | |
| const [isStudentModalOpen, setIsStudentModalOpen] = useState(false); | |
| const [itemToDelete, setItemToDelete] = useState(null); | |
| const [tempTeacher, setTempTeacher] = useState({ subject: '', name: '' }); | |
| const [selectedStudents, setSelectedStudents] = useState([]); | |
| const [teacherFilter, setTeacherFilter] = useState('all'); | |
| const [searchQuery, setSearchQuery] = useState(''); | |
| const [isSaving, setIsSaving] = useState(false); | |
| const [selectedDate, setSelectedDate] = useState(() => getMalaysiaDateObject()); | |
| const CLASSES = useMemo(() => { | |
| const sheetClasses = Object.keys(studentsData || {}); | |
| if (sheetClasses.length > 0) { | |
| return Array.from(new Set([...sheetClasses, ...DEFAULT_CLASSES])).sort((a, b) => { | |
| return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }); | |
| }); | |
| } | |
| return DEFAULT_CLASSES; | |
| }, [studentsData]); | |
| const currentTimeSlots = getDynamicTimeSlots(selectedClass); | |
| const now = new Date(); | |
| const malaysiaNow = getMalaysiaParts(now); | |
| const malaysiaToday = getMalaysiaDateObject(now); | |
| const isToday = selectedDate.getTime() === malaysiaToday.getTime(); | |
| const dateId = getMalaysiaDateId(selectedDate); | |
| const selectedDateInputValue = getMalaysiaDateInputValue(selectedDate); | |
| const selectedClassData = allClassLogs[selectedClass] || {}; | |
| const teacherLogs = selectedClassData.teacherLogs || {}; | |
| const studentLogs = selectedClassData.studentLogs || {}; | |
| useEffect(() => { | |
| const fetchStaticData = async () => { | |
| setIsLoading(true); | |
| try { | |
| const response = await fetch(GOOGLE_SHEETS_WEB_APP_URL); | |
| const data = await response.json(); | |
| if (data.status === 'success') { | |
| setTeachersData(data.teachers || []); | |
| setSubjectsData(data.subjects || []); | |
| setStudentsData(data.students || {}); | |
| setAttendanceData(data.attendance || {}); | |
| setError(null); | |
| } else { | |
| setError("Gagal memuatkan data sekolah."); | |
| } | |
| } catch (e) { | |
| setError("Gagal menyambung ke pangkalan data sekolah."); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| fetchStaticData(); | |
| }, []); | |
| const fetchLogs = useCallback(async (showLoading = false) => { | |
| try { | |
| const url = `${LOGS_WEB_APP_URL}?date=${dateId}&t=${Date.now()}`; | |
| const response = await fetch(url); | |
| const data = await response.json(); | |
| if (data.status === 'success') { | |
| setAllClassLogs(data.logs || {}); | |
| setIsOnline(true); | |
| setError(null); | |
| } else { | |
| setIsOnline(false); | |
| } | |
| } catch (e) { | |
| console.error('Fetch logs error:', e); | |
| setIsOnline(false); | |
| } | |
| }, [dateId]); | |
| useEffect(() => { | |
| fetchLogs(); | |
| const interval = setInterval(() => fetchLogs(), 30000); | |
| return () => clearInterval(interval); | |
| }, [fetchLogs]); | |
| const formattedDateStr = `${selectedDate.getDate().toString().padStart(2, '0')}/${(selectedDate.getMonth() + 1).toString().padStart(2, '0')}/${selectedDate.getFullYear()}`; | |
| const fallbackDateStr = `${selectedDate.getDate()}/${selectedDate.getMonth() + 1}/${selectedDate.getFullYear()}`; | |
| // Only use attendance data that matches the selected date — no fallback to other days | |
| const todaysAttendance = attendanceData[formattedDateStr] || attendanceData[fallbackDateStr] || null; | |
| const hasAttendanceData = !!todaysAttendance; | |
| const absenteesForClass = hasAttendanceData ? (todaysAttendance[selectedClass] || []) : []; | |
| const classStudents = studentsData[selectedClass] || []; | |
| const totalStudents = classStudents.length || 24; | |
| // If no attendance record for this date (e.g. Saturday / public holiday), show 0 present & 0 absent | |
| const absentCount = hasAttendanceData ? absenteesForClass.length : 0; | |
| const presentCount = hasAttendanceData ? (totalStudents - absentCount) : 0; | |
| const attendancePercentage = hasAttendanceData && totalStudents > 0 | |
| ? Math.round((presentCount / totalStudents) * 100) | |
| : 0; | |
| let schoolTotalEnrolled = 0; | |
| let schoolTotalAbsent = 0; | |
| let schoolTotalPresent = 0; | |
| CLASSES.forEach(cls => { | |
| const cStudents = studentsData[cls] || []; | |
| const cEnrolled = cStudents.length || 24; | |
| schoolTotalEnrolled += cEnrolled; | |
| if (hasAttendanceData) { | |
| const cAbsents = todaysAttendance[cls] || []; | |
| schoolTotalAbsent += cAbsents.length; | |
| schoolTotalPresent += (cEnrolled - cAbsents.length); | |
| } | |
| }); | |
| const schoolAttendancePercentage = hasAttendanceData && schoolTotalEnrolled > 0 | |
| ? Math.round((schoolTotalPresent / schoolTotalEnrolled) * 100) | |
| : 0; | |
| const exitCounts = {}; | |
| Object.values(studentLogs).forEach(slotLogs => { | |
| if (Array.isArray(slotLogs)) { | |
| slotLogs.forEach(log => { | |
| if (log.names) { | |
| log.names.forEach(name => { | |
| exitCounts[name] = (exitCounts[name] || 0) + 1; | |
| }); | |
| } | |
| }); | |
| } | |
| }); | |
| const frequentExits = Object.entries(exitCounts) | |
| .sort((a, b) => b[1] - a[1]) | |
| .slice(0, 3); | |
| const currentHour = malaysiaNow.hour; | |
| const currentMin = malaysiaNow.minute; | |
| const activeTimeSlotObj = isToday ? getCurrentActiveSlot(selectedClass, currentHour, currentMin) : null; | |
| const activeTimeSlotId = activeTimeSlotObj ? activeTimeSlotObj.id : null; | |
| const lastSlot = currentTimeSlots[currentTimeSlots.length - 1]; | |
| const isAfterSchool = isToday | |
| ? (currentHour > lastSlot.endHour || (currentHour === lastSlot.endHour && currentMin >= lastSlot.endMin)) | |
| : (selectedDate < now); | |
| const missedPeriods = currentTimeSlots.filter(slot => { | |
| if (slot.id === 'R') return false; | |
| if (!isToday) { | |
| // For past/future dates: treat as missed if no teacher log | |
| return !teacherLogs[slot.id]; | |
| } | |
| const hasEnded = currentHour > slot.endHour || (currentHour === slot.endHour && currentMin >= slot.endMin); | |
| return hasEnded && !teacherLogs[slot.id]; | |
| }); | |
| const classesTeacherStatus = CLASSES.map(cls => { | |
| const activeSlot = isToday ? getCurrentActiveSlot(cls, currentHour, currentMin) : null; | |
| const clsLogs = allClassLogs[cls] || {}; | |
| const clsTeacherLogs = clsLogs.teacherLogs || {}; | |
| let hasTeacher = false; | |
| let currentTeacherLog = null; | |
| let isRecess = false; | |
| if (activeSlot) { | |
| if (activeSlot.id === 'R') { | |
| isRecess = true; | |
| } else if (clsTeacherLogs[activeSlot.id] && clsTeacherLogs[activeSlot.id].name) { | |
| hasTeacher = true; | |
| currentTeacherLog = clsTeacherLogs[activeSlot.id]; | |
| } | |
| } | |
| const cStudents = studentsData[cls] || []; | |
| const cAbsents = hasAttendanceData ? (todaysAttendance[cls] || []) : []; | |
| const cEnrolled = cStudents.length || 24; | |
| const cAbsentCount = hasAttendanceData ? cAbsents.length : 0; | |
| const cPresentCount = hasAttendanceData ? (cEnrolled - cAbsentCount) : 0; | |
| const cPercentage = hasAttendanceData && cEnrolled > 0 | |
| ? Math.round((cPresentCount / cEnrolled) * 100) | |
| : 0; | |
| return { | |
| className: cls, | |
| activeSlot, | |
| hasTeacher, | |
| currentTeacherLog, | |
| isRecess, | |
| enrolled: cEnrolled, | |
| present: cPresentCount, | |
| absent: cAbsentCount, | |
| percentage: cPercentage, | |
| absentees: cAbsents | |
| }; | |
| }); | |
| const noTeacherClassesCount = classesTeacherStatus.filter(c => c.activeSlot && !c.isRecess && !c.hasTeacher).length; | |
| const openTeacherModal = (slot) => { | |
| if (slot.id === 'R') return; | |
| setActiveSlot(slot); | |
| setTempTeacher(teacherLogs[slot.id] || { subject: '', name: '' }); | |
| setIsTeacherModalOpen(true); | |
| }; | |
| const saveTeacherLog = async () => { | |
| if (!tempTeacher.name || isSaving || !activeSlot) return; | |
| setIsSaving(true); | |
| const slotId = activeSlot.id; | |
| const teacher = { ...tempTeacher }; | |
| const cls = selectedClass; | |
| const updatedTeacherLogs = { ...teacherLogs, [slotId]: teacher }; | |
| setAllClassLogs(prev => ({ | |
| ...prev, | |
| [cls]: { ...(prev[cls] || {}), teacherLogs: updatedTeacherLogs, studentLogs: (prev[cls] || {}).studentLogs || {} } | |
| })); | |
| setIsTeacherModalOpen(false); | |
| await postLog({ | |
| className: cls, | |
| type: 'teacher', | |
| name: teacher.name, | |
| period: slotId, | |
| subject: teacher.subject, | |
| action: 'set', | |
| date: dateId | |
| }); | |
| setIsSaving(false); | |
| }; | |
| const openStudentModal = (slot) => { | |
| setActiveSlot(slot); | |
| setSelectedStudents([]); | |
| setIsStudentModalOpen(true); | |
| }; | |
| const saveStudentLog = async () => { | |
| if (selectedStudents.length === 0 || isSaving || !activeSlot) return; | |
| setIsSaving(true); | |
| const slotId = activeSlot.id; | |
| const students = [...selectedStudents]; | |
| const cls = selectedClass; | |
| const logId = Date.now(); | |
| const timeOutStr = getMalaysiaTimeString(); | |
| const newLog = { | |
| id: logId, | |
| names: students, | |
| timeOut: timeOutStr, | |
| timeIn: null | |
| }; | |
| const updatedSlotLogs = [...(studentLogs[slotId] || []), newLog]; | |
| const updatedStudentLogs = { ...studentLogs, [slotId]: updatedSlotLogs }; | |
| setAllClassLogs(prev => ({ | |
| ...prev, | |
| [cls]: { ...(prev[cls] || {}), teacherLogs: (prev[cls] || {}).teacherLogs || {}, studentLogs: updatedStudentLogs } | |
| })); | |
| setIsStudentModalOpen(false); | |
| await postLog({ | |
| className: cls, | |
| type: 'student', | |
| name: '', | |
| period: slotId, | |
| subject: teacherLogs[slotId]?.subject || '', | |
| action: 'exit', | |
| students: students, | |
| timeOut: timeOutStr, | |
| timeOutDateTime: new Date().toLocaleString('en-CA', { timeZone: MALAYSIA_TIME_ZONE, hour12: false }), | |
| timeZone: MALAYSIA_TIME_ZONE, | |
| logId: logId, | |
| date: dateId | |
| }); | |
| setIsSaving(false); | |
| }; | |
| const confirmDelete = async () => { | |
| if (!itemToDelete || isSaving) return; | |
| setIsSaving(true); | |
| const toDelete = itemToDelete; | |
| setItemToDelete(null); | |
| if (toDelete.type === 'student') { | |
| const { slotId, logId } = toDelete; | |
| const updatedLogs = (studentLogs[slotId] || []).filter(log => log.id !== logId); | |
| setAllClassLogs(prev => ({ | |
| ...prev, | |
| [selectedClass]: { ...selectedClassData, studentLogs: { ...studentLogs, [slotId]: updatedLogs } } | |
| })); | |
| await postLog({ | |
| className: selectedClass, | |
| type: 'student', | |
| period: slotId, | |
| action: 'delete', | |
| logId: logId, | |
| date: dateId | |
| }); | |
| } else if (toDelete.type === 'teacher') { | |
| const { slotId } = toDelete; | |
| const newTeacherLogs = { ...teacherLogs }; | |
| delete newTeacherLogs[slotId]; | |
| setAllClassLogs(prev => ({ | |
| ...prev, | |
| [selectedClass]: { ...selectedClassData, teacherLogs: newTeacherLogs } | |
| })); | |
| await postLog({ | |
| className: selectedClass, | |
| type: 'teacher', | |
| period: slotId, | |
| action: 'delete', | |
| date: dateId | |
| }); | |
| } | |
| setIsSaving(false); | |
| }; | |
| const handleCopyPrevious = async (e, currentSlotId) => { | |
| e.stopPropagation(); | |
| if (isSaving) return; | |
| const currentIndex = currentTimeSlots.findIndex(s => s.id === currentSlotId); | |
| let prevIndex = currentIndex - 1; | |
| while (prevIndex >= 0 && (currentTimeSlots[prevIndex].id === 'R' || !teacherLogs[currentTimeSlots[prevIndex].id])) { | |
| prevIndex--; | |
| } | |
| if (prevIndex >= 0) { | |
| const prevLog = teacherLogs[currentTimeSlots[prevIndex].id]; | |
| if (prevLog && prevLog.name) { | |
| setIsSaving(true); | |
| const updatedTeacherLogs = { ...teacherLogs, [currentSlotId]: { ...prevLog } }; | |
| setAllClassLogs(prev => ({ | |
| ...prev, | |
| [selectedClass]: { ...selectedClassData, teacherLogs: updatedTeacherLogs } | |
| })); | |
| await postLog({ | |
| className: selectedClass, | |
| type: 'teacher', | |
| name: prevLog.name, | |
| period: currentSlotId, | |
| subject: prevLog.subject, | |
| action: 'set', | |
| date: dateId | |
| }); | |
| setIsSaving(false); | |
| } | |
| } | |
| }; | |
| return ( | |
| <div className="min-h-screen bg-slate-100 p-2 sm:p-4 md:p-8 text-slate-800 font-sans"> | |
| <div className="max-w-5xl mx-auto bg-white rounded-2xl shadow-xl overflow-hidden border border-slate-200"> | |
| <div className="bg-indigo-700 text-white p-4 md:p-6 pb-2 md:pb-4 flex flex-col gap-4"> | |
| <div className="flex flex-col md:flex-row md:items-center justify-between gap-4"> | |
| <div> | |
| <div className="flex flex-wrap items-center gap-3"> | |
| <h1 className="text-xl md:text-2xl font-bold flex items-center gap-2"> | |
| <BookOpen className="w-6 h-6 md:w-7 md:h-7" /> E-Buku Kawalan | |
| </h1> | |
| {isOnline ? ( | |
| <span className="bg-emerald-500/20 text-emerald-300 px-2 py-0.5 rounded-full text-[10px] font-bold flex items-center gap-1 border border-emerald-500/30"> | |
| <span className="w-1.5 h-1.5 bg-emerald-400 rounded-full animate-pulse"></span> | |
| Disegerakkan Dalam Talian | |
| </span> | |
| ) : ( | |
| <span className="bg-orange-500/20 text-orange-300 px-2 py-0.5 rounded-full text-[10px] font-bold flex items-center gap-1 border border-orange-500/30"> | |
| <CloudOff className="w-3 h-3" /> | |
| Mod Tempatan | |
| </span> | |
| )} | |
| </div> | |
| <p className="text-indigo-200 text-xs mt-1"> | |
| Sistem Pengurusan Kehadiran & Rekod Guru Seia Sekata | <span className="font-semibold text-white">Hak cipta SMK BANDAR KUCHING NO.1</span> | |
| </p> | |
| </div> | |
| <div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 text-sm"> | |
| <div className="bg-indigo-800/60 backdrop-blur-md px-3 py-2 rounded-lg border border-indigo-500/30 flex items-center gap-2 justify-center flex-wrap"> | |
| <Calendar className="w-4 h-4 text-indigo-300 flex-shrink-0" /> | |
| <input | |
| type="date" | |
| value={selectedDateInputValue} | |
| onChange={(e) => { | |
| if (!e.target.value) return; | |
| const [y, m, d] = e.target.value.split('-').map(Number); | |
| const newDate = new Date(y, m - 1, d); | |
| newDate.setHours(0, 0, 0, 0); | |
| setSelectedDate(newDate); | |
| }} | |
| className="bg-indigo-900 border border-indigo-400/40 rounded px-2 py-1 text-white font-bold focus:outline-none cursor-pointer text-sm [color-scheme:dark]" | |
| title="Pilih tarikh" | |
| /> | |
| <span className="text-indigo-200 text-xs hidden sm:inline whitespace-nowrap"> | |
| {selectedDate.toLocaleDateString('ms-MY', { weekday: 'long' })} | |
| </span> | |
| {!isToday && ( | |
| <button | |
| type="button" | |
| onClick={() => { | |
| setSelectedDate(getMalaysiaDateObject()); | |
| }} | |
| className="text-[10px] font-bold bg-white/20 hover:bg-white/30 px-1.5 py-0.5 rounded text-white whitespace-nowrap" | |
| title="Kembali ke hari ini" | |
| > | |
| Hari Ini | |
| </button> | |
| )} | |
| </div> | |
| {activeTab === 'class_view' && ( | |
| <div className="bg-indigo-800/60 backdrop-blur-md px-3 py-2 rounded-lg border border-indigo-500/30 flex items-center gap-2 justify-center"> | |
| <span className="font-semibold">Kelas:</span> | |
| <select | |
| value={selectedClass} | |
| onChange={(e) => setSelectedClass(e.target.value)} | |
| className="bg-indigo-900 border border-indigo-400/40 rounded px-2 py-1 text-white font-bold focus:outline-none cursor-pointer w-full sm:w-auto" | |
| > | |
| {CLASSES.map(cls => ( | |
| <option key={cls} value={cls}>{cls}</option> | |
| ))} | |
| </select> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-2 border-t border-indigo-600/50 pt-3 mt-1"> | |
| <button | |
| onClick={() => setActiveTab('class_view')} | |
| className={`flex items-center gap-2 px-4 py-2 rounded-lg text-xs md:text-sm font-bold transition-all ${ | |
| activeTab === 'class_view' | |
| ? 'bg-white text-indigo-800 shadow-md' | |
| : 'bg-indigo-800/50 text-indigo-200 hover:bg-indigo-800 hover:text-white' | |
| }`} | |
| > | |
| <Users className="w-4 h-4" /> Buku Kawalan Kelas ({selectedClass}) | |
| </button> | |
| <button | |
| onClick={() => setActiveTab('school_summary')} | |
| className={`flex items-center gap-2 px-4 py-2 rounded-lg text-xs md:text-sm font-bold transition-all relative ${ | |
| activeTab === 'school_summary' | |
| ? 'bg-white text-indigo-800 shadow-md' | |
| : 'bg-indigo-800/50 text-indigo-200 hover:bg-indigo-800 hover:text-white' | |
| }`} | |
| > | |
| <LayoutDashboard className="w-4 h-4" /> Ringkasan Sekolah & Guru | |
| {noTeacherClassesCount > 0 && ( | |
| <span className="bg-red-500 text-white text-[10px] px-1.5 py-0.2 rounded-full font-black animate-pulse"> | |
| {noTeacherClassesCount} | |
| </span> | |
| )} | |
| </button> | |
| </div> | |
| </div> | |
| {error && ( | |
| <div className="bg-red-50 border-l-4 border-red-500 p-4 m-4 md:m-6 mb-0 text-red-700 flex items-start gap-3 rounded-r-lg text-sm"> | |
| <AlertTriangle className="w-5 h-5 flex-shrink-0 text-red-500 mt-0.5" /> | |
| <div> | |
| <p className="font-bold">Sambungan Gagal</p> | |
| <p className="text-xs text-red-600 mt-0.5">{error}</p> | |
| </div> | |
| </div> | |
| )} | |
| {activeTab === 'school_summary' && ( | |
| <div className="p-4 md:p-6 bg-slate-50 min-h-[600px] space-y-6"> | |
| <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4"> | |
| <div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm"> | |
| <div className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">Kadar Kehadiran Sekolah</div> | |
| <div className="flex items-baseline gap-2"> | |
| <span className={`text-2xl md:text-3xl font-black ${schoolAttendancePercentage < 85 ? 'text-orange-500' : 'text-emerald-600'}`}> | |
| {schoolAttendancePercentage}% | |
| </span> | |
| </div> | |
| <div className="w-full bg-slate-100 rounded-full h-2 mt-2"> | |
| <div | |
| className={`h-2 rounded-full ${schoolAttendancePercentage < 85 ? 'bg-orange-500' : 'bg-emerald-500'}`} | |
| style={{ width: `${schoolAttendancePercentage}%` }} | |
| ></div> | |
| </div> | |
| </div> | |
| <div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm flex items-center justify-between"> | |
| <div> | |
| <div className="text-xs font-bold text-slate-500 uppercase tracking-wider">Jumlah Enrolmen</div> | |
| <div className="text-2xl font-black text-slate-800 mt-1">{schoolTotalEnrolled}</div> | |
| <div className="text-[11px] text-slate-400">{CLASSES.length} Kelas</div> | |
| </div> | |
| <div className="w-10 h-10 rounded-full bg-indigo-50 flex items-center justify-center text-indigo-600"> | |
| <Users className="w-5 h-5" /> | |
| </div> | |
| </div> | |
| <div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm flex items-center justify-between"> | |
| <div> | |
| <div className="text-xs font-bold text-slate-500 uppercase tracking-wider">Pelajar Hadir</div> | |
| <div className="text-2xl font-black text-emerald-600 mt-1">{schoolTotalPresent}</div> | |
| <div className="text-[11px] text-emerald-600 font-semibold">Ada di Sekolah</div> | |
| </div> | |
| <div className="w-10 h-10 rounded-full bg-emerald-50 flex items-center justify-center text-emerald-600"> | |
| <UserCheck className="w-5 h-5" /> | |
| </div> | |
| </div> | |
| <div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm flex items-center justify-between"> | |
| <div> | |
| <div className="text-xs font-bold text-slate-500 uppercase tracking-wider">Pelajar Tidak Hadir</div> | |
| <div className="text-2xl font-black text-red-600 mt-1">{schoolTotalAbsent}</div> | |
| <div className="text-[11px] text-red-500 font-semibold">Seluruh Sekolah</div> | |
| </div> | |
| <div className="w-10 h-10 rounded-full bg-red-50 flex items-center justify-center text-red-600"> | |
| <UserX className="w-5 h-5" /> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="bg-white rounded-xl border border-slate-200 shadow-sm p-4 md:p-6 space-y-4"> | |
| <div className="flex flex-col md:flex-row md:items-center justify-between gap-3 border-b border-slate-100 pb-4"> | |
| <div> | |
| <h2 className="text-lg font-bold text-slate-800 flex items-center gap-2"> | |
| <Activity className="w-5 h-5 text-indigo-600" /> Status Guru {isToday ? 'Semasa (Real-Time)' : 'Mengikut Tarikh Dipilih'} | |
| </h2> | |
| <p className="text-xs text-slate-500 mt-0.5"> | |
| {isToday ? ( | |
| <> | |
| Memaparkan status guru mengajar di setiap kelas mengikut waktu jam sekarang ( | |
| <span className="font-bold text-indigo-700"> | |
| {now.toLocaleTimeString('ms-MY', { hour: '2-digit', minute: '2-digit' })} | |
| </span>) | |
| </> | |
| ) : ( | |
| <> | |
| Memaparkan rekod guru untuk tarikh{' '} | |
| <span className="font-bold text-indigo-700"> | |
| {selectedDate.toLocaleDateString('ms-MY', { weekday: 'long', day: 'numeric', month: 'short', year: 'numeric' })} | |
| </span> | |
| {' '}(bukan hari ini) | |
| </> | |
| )} | |
| </p> | |
| </div> | |
| <div className="flex flex-wrap items-center gap-2"> | |
| <button | |
| onClick={() => setTeacherFilter('all')} | |
| className={`px-3 py-1.5 rounded-lg text-xs font-bold flex items-center gap-1.5 transition-colors ${ | |
| teacherFilter === 'all' | |
| ? 'bg-slate-800 text-white' | |
| : 'bg-slate-100 text-slate-600 hover:bg-slate-200' | |
| }`} | |
| > | |
| Semua Kelas ({CLASSES.length}) | |
| </button> | |
| <button | |
| onClick={() => setTeacherFilter('no_teacher')} | |
| className={`px-3 py-1.5 rounded-lg text-xs font-bold flex items-center gap-1.5 transition-colors ${ | |
| teacherFilter === 'no_teacher' | |
| ? 'bg-red-600 text-white' | |
| : 'bg-red-50 text-red-700 border border-red-200 hover:bg-red-100' | |
| }`} | |
| > | |
| <ShieldAlert className="w-3.5 h-3.5" /> Tiada Guru ({noTeacherClassesCount}) | |
| </button> | |
| <button | |
| onClick={() => setTeacherFilter('has_teacher')} | |
| className={`px-3 py-1.5 rounded-lg text-xs font-bold flex items-center gap-1.5 transition-colors ${ | |
| teacherFilter === 'has_teacher' | |
| ? 'bg-emerald-600 text-white' | |
| : 'bg-emerald-50 text-emerald-700 border border-emerald-200 hover:bg-emerald-100' | |
| }`} | |
| > | |
| <UserCheck className="w-3.5 h-3.5" /> Ada Guru ({CLASSES.length - noTeacherClassesCount}) | |
| </button> | |
| </div> | |
| </div> | |
| {noTeacherClassesCount > 0 ? ( | |
| <div className="bg-red-50 border border-red-200 text-red-800 p-3.5 rounded-xl flex items-center justify-between gap-3 text-xs md:text-sm font-semibold"> | |
| <div className="flex items-center gap-2"> | |
| <span className="w-2.5 h-2.5 bg-red-600 rounded-full animate-ping"></span> | |
| <span>Terdapat <strong>{noTeacherClassesCount} kelas</strong> yang belum mendaftar guru pada waktu ini!</span> | |
| </div> | |
| <button | |
| onClick={() => setTeacherFilter('no_teacher')} | |
| className="underline text-red-700 hover:text-red-900 font-bold whitespace-nowrap" | |
| > | |
| Tunjuk Kelas | |
| </button> | |
| </div> | |
| ) : ( | |
| <div className="bg-emerald-50 border border-emerald-200 text-emerald-800 p-3 rounded-xl flex items-center gap-2 text-xs font-semibold"> | |
| <UserCheck className="w-4 h-4 text-emerald-600" /> | |
| <span>Semua kelas beroperasi dengan guru bertugas pada waktu sekarang.</span> | |
| </div> | |
| )} | |
| <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3 pt-2"> | |
| {classesTeacherStatus | |
| .filter(cStatus => { | |
| if (searchQuery && !cStatus.className.toLowerCase().includes(searchQuery.toLowerCase())) { | |
| return false; | |
| } | |
| if (teacherFilter === 'no_teacher') { | |
| return cStatus.activeSlot && !cStatus.isRecess && !cStatus.hasTeacher; | |
| } | |
| if (teacherFilter === 'has_teacher') { | |
| return cStatus.isRecess || cStatus.hasTeacher; | |
| } | |
| return true; | |
| }) | |
| .map(cStatus => { | |
| const isNoTeacher = cStatus.activeSlot && !cStatus.isRecess && !cStatus.hasTeacher; | |
| return ( | |
| <div | |
| key={cStatus.className} | |
| className={`p-4 rounded-xl border transition-all ${ | |
| isNoTeacher | |
| ? 'bg-red-50/70 border-red-200 shadow-sm' | |
| : cStatus.isRecess | |
| ? 'bg-amber-50/60 border-amber-200' | |
| : cStatus.hasTeacher | |
| ? 'bg-emerald-50/50 border-emerald-200' | |
| : 'bg-slate-50 border-slate-200' | |
| }`} | |
| > | |
| <div className="flex items-center justify-between mb-2"> | |
| <span className="font-black text-slate-800 text-lg flex items-center gap-1.5"> | |
| Kelas {cStatus.className} | |
| </span> | |
| {isNoTeacher ? ( | |
| <span className="bg-red-600 text-white text-[10px] font-bold px-2 py-0.5 rounded-full uppercase tracking-wider flex items-center gap-1 animate-pulse"> | |
| <ShieldAlert className="w-3 h-3" /> Tiada Guru | |
| </span> | |
| ) : cStatus.isRecess ? ( | |
| <span className="bg-amber-500 text-white text-[10px] font-bold px-2 py-0.5 rounded-full uppercase tracking-wider"> | |
| Rehat | |
| </span> | |
| ) : cStatus.hasTeacher ? ( | |
| <span className="bg-emerald-600 text-white text-[10px] font-bold px-2 py-0.5 rounded-full uppercase tracking-wider flex items-center gap-1"> | |
| <UserCheck className="w-3 h-3" /> Ada Guru | |
| </span> | |
| ) : ( | |
| <span className="bg-slate-200 text-slate-600 text-[10px] font-bold px-2 py-0.5 rounded-full"> | |
| Luar Waktu | |
| </span> | |
| )} | |
| </div> | |
| <div className="text-xs text-slate-500 mb-2"> | |
| {cStatus.activeSlot ? ( | |
| <span>Waktu {cStatus.activeSlot.label} ({cStatus.activeSlot.time})</span> | |
| ) : ( | |
| <span className="italic text-slate-400">Luar waktu persekolahan</span> | |
| )} | |
| </div> | |
| <div className="min-h-[44px] flex flex-col justify-center bg-white p-2.5 rounded-lg border border-slate-100"> | |
| {cStatus.hasTeacher && cStatus.currentTeacherLog ? ( | |
| <div> | |
| <div className="text-xs font-bold text-indigo-700 truncate"> | |
| {cStatus.currentTeacherLog.name} | |
| </div> | |
| <div className="text-[11px] text-slate-500 font-medium"> | |
| Subjek: {cStatus.currentTeacherLog.subject || '-'} | |
| </div> | |
| </div> | |
| ) : cStatus.isRecess ? ( | |
| <div className="text-xs font-semibold text-amber-600 italic"> | |
| Pelajar sedang rehat | |
| </div> | |
| ) : isNoTeacher ? ( | |
| <div className="text-xs font-bold text-red-600 flex items-center gap-1"> | |
| <AlertTriangle className="w-3.5 h-3.5 text-red-500 flex-shrink-0" /> | |
| <span>Belum mendaftar masuk</span> | |
| </div> | |
| ) : ( | |
| <div className="text-xs text-slate-400 italic"> | |
| Tiada maklumat | |
| </div> | |
| )} | |
| </div> | |
| <button | |
| onClick={() => { | |
| setSelectedClass(cStatus.className); | |
| setActiveTab('class_view'); | |
| }} | |
| className="w-full mt-3 py-1.5 px-3 bg-white hover:bg-slate-100 text-indigo-600 border border-indigo-100 rounded-lg text-xs font-bold flex items-center justify-center gap-1 transition-colors" | |
| > | |
| Pergi Ke Kelas <ArrowRight className="w-3.5 h-3.5" /> | |
| </button> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {activeTab === 'class_view' && ( | |
| <> | |
| <div className="p-4 md:p-6 grid grid-cols-1 lg:grid-cols-2 gap-6 border-b border-slate-100 bg-white"> | |
| <div className="space-y-3"> | |
| <h2 className="text-xs font-bold uppercase tracking-wider text-slate-500"> | |
| Kehadiran {isToday ? 'Hari Ini' : 'Tarikh Dipilih'} ({selectedClass}) | |
| </h2> | |
| <div className="grid grid-cols-2 gap-3"> | |
| <div className="bg-slate-50 p-3 rounded-xl border border-slate-200 flex items-center justify-between"> | |
| <span className="text-xs md:text-sm font-medium text-slate-600">Hadir:</span> | |
| <span className="text-lg md:text-xl font-black text-emerald-600">{presentCount}</span> | |
| </div> | |
| <div className="bg-slate-50 p-3 rounded-xl border border-slate-200 flex items-center justify-between"> | |
| <span className="text-xs md:text-sm font-medium text-slate-600">Tidak Hadir:</span> | |
| <span className="text-lg md:text-xl font-black text-red-600">{absentCount}</span> | |
| </div> | |
| </div> | |
| <div className="bg-slate-50 p-3 rounded-xl border border-slate-200 flex items-center justify-between"> | |
| <span className="text-xs md:text-sm font-medium text-slate-600">Jumlah Daftar:</span> | |
| <span className="text-base md:text-lg font-bold text-slate-800">{totalStudents}</span> | |
| </div> | |
| {!hasAttendanceData && ( | |
| <p className="text-[11px] text-amber-600 font-medium italic">Tiada data kehadiran untuk tarikh ini</p> | |
| )} | |
| </div> | |
| <div> | |
| <h2 className="text-xs font-bold uppercase tracking-wider text-slate-500 mb-3">Senarai Tidak Hadir</h2> | |
| <div className="bg-slate-50 p-4 rounded-xl border border-slate-200 min-h-[120px] flex flex-col justify-center"> | |
| {isLoading ? ( | |
| <div className="text-center text-slate-400 text-sm italic">Memuatkan data...</div> | |
| ) : !hasAttendanceData ? ( | |
| <div className="flex flex-col items-center justify-center text-slate-400 italic"> | |
| <span className="text-sm">Tiada data kehadiran</span> | |
| <span className="text-[11px] mt-1">(contoh: hari cuti / hujung minggu)</span> | |
| </div> | |
| ) : absenteesForClass && absenteesForClass.length > 0 ? ( | |
| <ul className="text-sm space-y-1.5 max-h-32 overflow-y-auto pr-1"> | |
| {absenteesForClass.map((student, idx) => ( | |
| <li key={idx} className="text-red-600 font-medium flex items-center text-xs"> | |
| <span className="w-1.5 h-1.5 bg-red-500 rounded-full mr-2 flex-shrink-0"></span> | |
| {student} | |
| </li> | |
| ))} | |
| </ul> | |
| ) : ( | |
| <div className="flex flex-col items-center justify-center text-slate-400 italic"> | |
| <span className="text-sm">Semua pelajar hadir</span> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| <div className="p-4 md:p-6 bg-slate-50 border-b border-slate-200"> | |
| <h2 className="text-xs font-bold uppercase tracking-wider text-slate-500 mb-4 flex items-center gap-2"> | |
| <PieChart className="w-4 h-4" /> Analisis Semasa Kelas {selectedClass} | |
| </h2> | |
| <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"> | |
| <div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm"> | |
| <div className="text-xs font-bold text-slate-500 mb-1"> | |
| Kadar Kehadiran {hasAttendanceData ? `(${presentCount}/${totalStudents})` : ''} | |
| </div> | |
| <div className="flex items-baseline gap-2 mb-2"> | |
| <span className={`text-2xl md:text-3xl font-black ${ | |
| !hasAttendanceData ? 'text-slate-400' : attendancePercentage < 80 ? 'text-orange-500' : 'text-emerald-500' | |
| }`}> | |
| {hasAttendanceData ? `${attendancePercentage}%` : '—'} | |
| </span> | |
| </div> | |
| <div className="w-full bg-slate-100 rounded-full h-2.5"> | |
| <div | |
| className={`h-2.5 rounded-full ${ | |
| !hasAttendanceData ? 'bg-slate-300' : attendancePercentage < 80 ? 'bg-orange-500' : 'bg-emerald-500' | |
| }`} | |
| style={{ width: `${hasAttendanceData ? attendancePercentage : 0}%` }} | |
| ></div> | |
| </div> | |
| {!hasAttendanceData && ( | |
| <p className="text-[10px] text-slate-400 italic mt-1">Tiada data</p> | |
| )} | |
| </div> | |
| <div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm"> | |
| <div className="text-xs font-bold text-slate-500 mb-3 flex items-center gap-1"> | |
| <Activity className="w-3.5 h-3.5" /> Kerap Keluar Kelas | |
| </div> | |
| {frequentExits.length > 0 ? ( | |
| <ul className="text-[11px] space-y-2"> | |
| {frequentExits.map(([name, count]) => ( | |
| <li key={name} className="flex justify-between items-center border-b border-slate-50 pb-1"> | |
| <span className="truncate font-medium text-slate-700 pr-2">{name}</span> | |
| <span className="bg-orange-100 text-orange-700 px-2 py-0.5 rounded-full font-bold whitespace-nowrap"> | |
| {count} kali | |
| </span> | |
| </li> | |
| ))} | |
| </ul> | |
| ) : ( | |
| <div className="text-[11px] text-slate-400 italic mt-2">Tiada pergerakan.</div> | |
| )} | |
| </div> | |
| <div className={`p-4 rounded-xl border shadow-sm sm:col-span-2 md:col-span-1 ${missedPeriods.length > 0 ? 'bg-rose-50 border-rose-200' : 'bg-emerald-50 border-emerald-200'}`}> | |
| <div className={`text-xs font-bold mb-2 flex items-center gap-1 ${missedPeriods.length > 0 ? 'text-rose-700' : 'text-emerald-700'}`}> | |
| <Clock className="w-3.5 h-3.5"/> Log Belum Diisi | |
| {missedPeriods.length > 0 && ( | |
| <span className="ml-auto bg-rose-600 text-white text-[10px] px-1.5 py-0.5 rounded-full font-black">{missedPeriods.length}</span> | |
| )} | |
| </div> | |
| {missedPeriods.length === 0 ? ( | |
| <span className="text-[10px] text-emerald-600 font-bold bg-emerald-100 px-2 py-0.5 rounded">Semua log lengkap ✓</span> | |
| ) : isAfterSchool && missedPeriods.length > 6 ? ( | |
| <div className="space-y-2"> | |
| <p className="text-[11px] text-rose-600 font-medium"> | |
| {missedPeriods.length} waktu belum dilog hari ini | |
| </p> | |
| <div className="flex flex-wrap gap-1"> | |
| {missedPeriods.map(p => ( | |
| <button | |
| key={p.id} | |
| onClick={() => openTeacherModal(p)} | |
| className="px-2 py-0.5 bg-white border border-rose-200 rounded text-rose-600 text-[10px] font-bold hover:bg-rose-100 hover:border-rose-400 transition-colors cursor-pointer" | |
| title="Klik untuk set guru" | |
| > | |
| {p.label} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="flex flex-wrap gap-1.5"> | |
| {missedPeriods.map(p => ( | |
| <button | |
| key={p.id} | |
| onClick={() => openTeacherModal(p)} | |
| className="px-2 py-0.5 bg-white border border-rose-200 rounded text-rose-600 text-[10px] font-bold hover:bg-rose-100 hover:border-rose-400 transition-colors cursor-pointer" | |
| title="Klik untuk set guru" | |
| > | |
| Waktu {p.label} | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| <div className="p-4 md:p-6 bg-slate-50/30"> | |
| <div className="hidden md:grid grid-cols-[100px_100px_220px_1fr] gap-4 p-3 border-b border-slate-200 text-xs font-bold uppercase tracking-wider text-slate-500 bg-slate-50 rounded-t-lg"> | |
| <div>Waktu</div> | |
| <div>Mata Pelajaran</div> | |
| <div>Nama Guru</div> | |
| <div>Pelajar di luar kelas</div> | |
| </div> | |
| <div className="space-y-4 md:space-y-0 md:divide-y md:divide-slate-200"> | |
| {currentTimeSlots.map((slot) => { | |
| const isRecess = slot.id === 'R'; | |
| const tLog = teacherLogs[slot.id]; | |
| const sLogs = studentLogs[slot.id] || []; | |
| const isCurrentActiveSlot = slot.id === activeTimeSlotId; | |
| return ( | |
| <div key={slot.id} className={`bg-white md:bg-transparent rounded-xl border shadow-sm md:shadow-none p-4 md:p-3 md:grid md:grid-cols-[100px_100px_220px_1fr] md:gap-4 md:items-center transition-all ${ | |
| isCurrentActiveSlot | |
| ? 'border-2 border-indigo-500 md:border-2 md:border-indigo-500 bg-indigo-100 md:bg-indigo-100 shadow-md ring-2 ring-indigo-300/50 z-10 relative' | |
| : isRecess | |
| ? 'border-slate-200 md:border-transparent bg-orange-50 md:bg-orange-50/50' | |
| : 'border-slate-200 md:border-transparent hover:bg-slate-50' | |
| }`}> | |
| <div className="flex justify-between items-center md:block mb-3 md:mb-0 border-b border-slate-100 md:border-0 pb-3 md:pb-0"> | |
| <span className="md:hidden text-xs font-bold text-slate-400 uppercase">Waktu</span> | |
| <div> | |
| <div className="flex items-center gap-2"> | |
| <div className={`font-bold text-lg md:text-sm ${isRecess ? 'text-orange-700' : (isCurrentActiveSlot ? 'text-indigo-800' : 'text-slate-800')}`}> | |
| {slot.label} | |
| </div> | |
| {isCurrentActiveSlot && ( | |
| <span className="bg-indigo-600 text-white text-[9px] px-1.5 py-0.5 rounded uppercase font-black tracking-wider animate-pulse shadow-sm"> | |
| Semasa | |
| </span> | |
| )} | |
| </div> | |
| <div className={`text-xs ${isRecess ? 'text-orange-500' : (isCurrentActiveSlot ? 'text-indigo-600 font-semibold' : 'text-slate-500')}`}> | |
| {slot.time} | |
| </div> | |
| </div> | |
| </div> | |
| <div className="flex justify-between items-center md:block mb-3 md:mb-0"> | |
| <span className="md:hidden text-xs font-bold text-slate-400 uppercase">Subjek</span> | |
| <div className="font-medium text-slate-700 md:text-sm"> | |
| {isRecess ? '-' : (tLog ? tLog.subject : '-')} | |
| </div> | |
| </div> | |
| <div className="flex justify-between items-center md:block mb-3 md:mb-0"> | |
| <span className="md:hidden text-xs font-bold text-slate-400 uppercase">Guru</span> | |
| {isRecess ? ( | |
| <span className="text-orange-600 font-medium text-sm italic">Waktu Rehat</span> | |
| ) : ( | |
| <div className="flex items-center justify-end md:justify-start gap-1 group"> | |
| {tLog && tLog.name ? ( | |
| <> | |
| <button onClick={() => openTeacherModal(slot)} className="text-right md:text-left"> | |
| <span className="font-bold text-indigo-700 text-sm bg-indigo-50 md:bg-transparent px-2 py-1 md:p-0 rounded-lg hover:text-indigo-900 transition-colors"> | |
| {tLog.name} | |
| </span> | |
| </button> | |
| <button | |
| onClick={(e) => handleCopyPrevious(e, slot.id)} | |
| className="text-indigo-400 hover:text-indigo-600 p-1 transition-colors ml-1" | |
| title="Salin rekod sebelumnya" | |
| > | |
| <Copy className="w-4 h-4" /> | |
| </button> | |
| <button | |
| onClick={(e) => { e.stopPropagation(); setItemToDelete({ type: 'teacher', slotId: slot.id }); }} | |
| className="text-red-400 hover:text-red-600 p-1 md:opacity-0 group-hover:opacity-100 transition-opacity" | |
| title="Padam rekod guru" | |
| > | |
| <Trash2 className="w-4 h-4" /> | |
| </button> | |
| </> | |
| ) : ( | |
| <> | |
| <button onClick={() => openTeacherModal(slot)} className="text-right md:text-left"> | |
| <span className="text-indigo-600 hover:text-indigo-800 font-semibold text-xs flex items-center justify-end md:justify-start gap-1 bg-indigo-50 md:bg-transparent px-3 py-1.5 md:p-0 rounded-lg"> | |
| <Users className="w-3.5 h-3.5" /> Set Guru | |
| </span> | |
| </button> | |
| <button | |
| onClick={(e) => handleCopyPrevious(e, slot.id)} | |
| className="text-indigo-400 hover:text-indigo-600 p-1 transition-colors ml-1" | |
| title="Salin rekod sebelumnya" | |
| > | |
| <Copy className="w-4 h-4" /> | |
| </button> | |
| </> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| <div className="mt-3 pt-3 border-t border-slate-100 md:border-0 md:mt-0 md:pt-0"> | |
| <span className="md:hidden text-xs font-bold text-slate-400 uppercase mb-3 block">Pelajar Keluar</span> | |
| <div className="flex flex-col gap-2"> | |
| {sLogs.map(log => ( | |
| <div key={log.id} className="flex flex-wrap items-center justify-between bg-slate-100/80 p-2.5 rounded-lg text-xs group border border-slate-200/60"> | |
| <div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2"> | |
| <span className="font-bold text-slate-700">{log.names.join(', ')}</span> | |
| <span className="bg-orange-100 text-orange-700 px-1.5 py-0.5 rounded text-[10px] font-bold w-max"> | |
| Keluar: {String(log.timeOut || '').match(/\d{1,2}:\d{2}/)?.[0] || log.timeOut} | |
| </span> | |
| </div> | |
| <button | |
| onClick={() => setItemToDelete({ type: 'student', slotId: slot.id, logId: log.id })} | |
| className="text-red-400 hover:text-red-600 p-1 md:opacity-0 group-hover:opacity-100 transition-opacity" | |
| title="Padam rekod" | |
| > | |
| <Trash2 className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| ))} | |
| {!isRecess && ( | |
| <button onClick={() => openStudentModal(slot)} className="text-indigo-500 hover:text-indigo-700 bg-indigo-50/50 md:bg-transparent border border-indigo-100 md:border-0 rounded-lg p-2 md:p-0 font-medium text-xs text-center md:text-left w-full md:w-max mt-1"> | |
| + Tambah pelajar keluar... | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| {isTeacherModalOpen && ( | |
| <div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-end sm:items-center justify-center p-4 z-50"> | |
| <div className="bg-white rounded-t-2xl sm:rounded-2xl p-6 w-full max-w-sm"> | |
| <h3 className="font-bold text-lg mb-4">Set Guru: Waktu {activeSlot?.label}</h3> | |
| <label className="block text-xs font-bold text-slate-500 mb-1">Mata Pelajaran</label> | |
| <select | |
| value={tempTeacher.subject} | |
| onChange={e => setTempTeacher({...tempTeacher, subject: e.target.value})} | |
| className="w-full border border-slate-300 rounded-lg p-3 mb-4 text-sm focus:ring-2 focus:ring-indigo-500 outline-none" | |
| > | |
| <option value="">-- Pilih --</option> | |
| {subjectsData.map(sub => <option key={sub} value={sub}>{sub}</option>)} | |
| </select> | |
| <label className="block text-xs font-bold text-slate-500 mb-1">Nama Guru</label> | |
| <select | |
| value={tempTeacher.name} | |
| onChange={e => setTempTeacher({...tempTeacher, name: e.target.value})} | |
| className="w-full border border-slate-300 rounded-lg p-3 mb-6 text-sm focus:ring-2 focus:ring-indigo-500 outline-none" | |
| > | |
| <option value="">-- Pilih --</option> | |
| {teachersData.map(t => <option key={t} value={t}>{t}</option>)} | |
| </select> | |
| <div className="flex gap-3"> | |
| <button onClick={() => setIsTeacherModalOpen(false)} disabled={isSaving} className="flex-1 py-3 text-sm font-bold text-slate-600 bg-slate-100 rounded-xl hover:bg-slate-200 disabled:opacity-50">Batal</button> | |
| <button onClick={saveTeacherLog} disabled={isSaving || !tempTeacher.name} className="flex-1 py-3 text-sm font-bold text-white bg-indigo-600 rounded-xl hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"> | |
| {isSaving ? 'Menyimpan...' : 'Simpan'} | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {isStudentModalOpen && ( | |
| <div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-end sm:items-center justify-center p-4 z-50"> | |
| <div className="bg-white rounded-t-2xl sm:rounded-2xl p-6 w-full max-w-md h-[85vh] sm:h-auto sm:max-h-[85vh] flex flex-col"> | |
| <h3 className="font-bold text-lg mb-1">Rekod Keluar: Waktu {activeSlot?.label}</h3> | |
| <p className="text-[11px] text-slate-500 mb-4">Pelajar tidak hadir telah dikelabukan.</p> | |
| <div className="overflow-y-auto flex-1 border border-slate-200 rounded-xl mb-4"> | |
| {classStudents.map(student => { | |
| const isAbsent = absenteesForClass?.includes(student); | |
| const isSelected = selectedStudents.includes(student); | |
| return ( | |
| <div | |
| key={student} | |
| onClick={() => { | |
| if (isAbsent) return; | |
| setSelectedStudents(prev => | |
| isSelected ? prev.filter(s => s !== student) : [...prev, student] | |
| ); | |
| }} | |
| className={`p-3.5 border-b border-slate-100 last:border-0 text-sm cursor-pointer flex items-center gap-3 transition-colors | |
| ${isAbsent ? 'opacity-40 cursor-not-allowed bg-slate-50' : 'hover:bg-slate-50 active:bg-slate-100'} | |
| `} | |
| > | |
| <div className={`w-5 h-5 rounded border flex items-center justify-center flex-shrink-0 transition-colors | |
| ${isSelected ? 'bg-indigo-600 border-indigo-600' : 'border-slate-300'} | |
| `}> | |
| {isSelected && <CheckSquare className="w-3.5 h-3.5 text-white" />} | |
| </div> | |
| <span className={isSelected ? 'font-bold text-indigo-700' : 'text-slate-700'}> | |
| {student} {isAbsent && <span className="text-xs font-normal italic text-slate-500">(Tidak Hadir)</span>} | |
| </span> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| <div className="flex gap-3 mt-auto pt-2"> | |
| <button onClick={() => setIsStudentModalOpen(false)} className="flex-1 py-3 text-sm font-bold text-slate-600 bg-slate-100 rounded-xl hover:bg-slate-200">Batal</button> | |
| <button onClick={saveStudentLog} disabled={selectedStudents.length === 0 || isSaving} className="flex-1 py-3 text-sm font-bold text-white bg-indigo-600 rounded-xl hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"> | |
| {isSaving ? 'Menyimpan...' : 'Rekod Keluar'} | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {itemToDelete && ( | |
| <div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 z-50"> | |
| <div className="bg-white rounded-2xl p-6 w-full max-w-sm text-center"> | |
| <div className="w-12 h-12 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4"> | |
| <Trash2 className="w-6 h-6 text-red-600" /> | |
| </div> | |
| <h3 className="font-bold text-lg mb-2 text-slate-800">Padam Rekod?</h3> | |
| <p className="text-sm text-slate-500 mb-6"> | |
| {itemToDelete.type === 'teacher' | |
| ? "Adakah anda pasti untuk memadam rekod guru ini?" | |
| : "Adakah anda pasti untuk memadam rekod pelajar keluar ini?"} | |
| </p> | |
| <div className="flex gap-3"> | |
| <button onClick={() => setItemToDelete(null)} className="flex-1 py-3 text-sm font-bold text-slate-600 bg-slate-100 rounded-xl hover:bg-slate-200">Batal</button> | |
| <button onClick={confirmDelete} className="flex-1 py-3 text-sm font-bold text-white bg-red-600 rounded-xl hover:bg-red-700">Ya, Padam</button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| const root = ReactDOM.createRoot(document.getElementById('root')); | |
| root.render(<App />); | |
| </script> | |
| <footer className="text-center py-4 bg-slate-200 text-slate-600 text-xs font-semibold"> | |
| Hak Cipta © SMK BANDAR KUCHING NO.1. Hak Cipta Terpelihara. | |
| </footer> | |
| </body> | |
| </html> |