2373 lines
138 KiB
TypeScript
Executable File
2373 lines
138 KiB
TypeScript
Executable File
|
||
import React, { useState, useMemo, useEffect } from 'react';
|
||
import { JournalEntry, ClassJournalEntry, Teacher, SchoolSettings, AuthUser } from '../types';
|
||
import { Printer, FileText, Search, Download, Image as ImageIcon, ExternalLink, Loader2, User, Shield, Edit2, Trash2, X, Save, AlertTriangle, Calendar, Filter, RefreshCw, ChevronDown, CheckCircle2, BarChart3, TrendingUp, Users, Info } from 'lucide-react';
|
||
import { updateJournalEntry, deleteJournalEntry } from '../services/apiService';
|
||
import jsPDF from 'jspdf';
|
||
import 'jspdf-autotable';
|
||
|
||
interface RecapViewProps {
|
||
entries: JournalEntry[];
|
||
classEntries: ClassJournalEntry[];
|
||
teachers: Teacher[];
|
||
settings: SchoolSettings;
|
||
currentUser: AuthUser;
|
||
onRefresh?: () => void;
|
||
}
|
||
|
||
// Helper to get today's date in YYYY-MM-DD format (local timezone)
|
||
const getTodayDate = () => {
|
||
const today = new Date();
|
||
const year = today.getFullYear();
|
||
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||
const day = String(today.getDate()).padStart(2, '0');
|
||
return `${year}-${month}-${day}`;
|
||
};
|
||
|
||
// Helper to format Date to YYYY-MM-DD in local timezone
|
||
const formatDateToLocalString = (d: Date) => {
|
||
const year = d.getFullYear();
|
||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||
const day = String(d.getDate()).padStart(2, '0');
|
||
return `${year}-${month}-${day}`;
|
||
};
|
||
|
||
// Helper to convert Google Drive links to embeddable direct links
|
||
const formatGoogleDriveImageUrl = (url: string) => {
|
||
if (!url) return '';
|
||
// If it's already a base64 string, return as is
|
||
if (url.startsWith('data:image')) return url;
|
||
|
||
try {
|
||
// Extract ID from typical Google Drive URLs
|
||
const idMatch = url.match(/(?:id=|\/d\/)([\w-]+)/);
|
||
if (idMatch && idMatch[1]) {
|
||
// Using lh3.googleusercontent.com is often more reliable for embedding than drive.google.com/uc
|
||
return `https://lh3.googleusercontent.com/d/${idMatch[1]}`;
|
||
}
|
||
return url;
|
||
} catch (e) {
|
||
return url;
|
||
}
|
||
};
|
||
|
||
// Helper to fetch image as Base64 for PDF embedding
|
||
const getBase64ImageFromURL = (url: string): Promise<string> => {
|
||
return new Promise((resolve, reject) => {
|
||
const img = new Image();
|
||
img.setAttribute('crossOrigin', 'anonymous');
|
||
img.onload = () => {
|
||
const canvas = document.createElement("canvas");
|
||
// Resize to reasonable thumbnail size to save PDF size
|
||
// If it is KOP (usually wide), allow larger width
|
||
const isWide = img.width > img.height;
|
||
const maxDim = isWide ? 1200 : 800;
|
||
|
||
const scale = Math.min(1, maxDim / Math.max(img.width, img.height));
|
||
canvas.width = img.width * scale;
|
||
canvas.height = img.height * scale;
|
||
|
||
const ctx = canvas.getContext("2d");
|
||
if (ctx) {
|
||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||
const dataURL = canvas.toDataURL("image/jpeg", 0.85);
|
||
resolve(dataURL);
|
||
} else {
|
||
reject(new Error("Canvas context failed"));
|
||
}
|
||
};
|
||
img.onerror = (error) => {
|
||
reject(error);
|
||
};
|
||
img.src = url;
|
||
});
|
||
};
|
||
|
||
const RecapView: React.FC<RecapViewProps> = ({ entries, classEntries, teachers, settings, currentUser, onRefresh }) => {
|
||
// Initialize state based on User Role directly to ensure instant rendering
|
||
const [activeTab, setActiveTab] = useState<'GURU' | 'KELAS'>(() => {
|
||
if (currentUser.role === 'SEKRETARIS') return 'KELAS';
|
||
return 'GURU'; // Default for GURU and ADMIN
|
||
});
|
||
|
||
const [selectedFilter, setSelectedFilter] = useState<string>(() => {
|
||
if (currentUser.role === 'GURU') return currentUser.name;
|
||
if (currentUser.role === 'SEKRETARIS') return currentUser.className || '';
|
||
return 'ALL'; // Admin sees all by default
|
||
});
|
||
|
||
// Date filter states for Admin
|
||
const [dateFrom, setDateFrom] = useState<string>(() => {
|
||
if (currentUser.role === 'ADMIN') return getTodayDate();
|
||
return '';
|
||
});
|
||
const [dateTo, setDateTo] = useState<string>(() => {
|
||
if (currentUser.role === 'ADMIN') return getTodayDate();
|
||
return '';
|
||
});
|
||
|
||
// Search query for Admin
|
||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||
|
||
// Show filter panel for Admin
|
||
const [showAdvancedFilters, setShowAdvancedFilters] = useState<boolean>(false);
|
||
|
||
// Admin View Mode: 'journal' for journal list, 'statistics' for statistics view, 'attendance' for teacher attendance report
|
||
const [adminViewMode, setAdminViewMode] = useState<'journal' | 'statistics' | 'attendance'>('journal');
|
||
|
||
// Statistics period filter
|
||
const [statsPeriod, setStatsPeriod] = useState<'week' | 'month'>('week');
|
||
|
||
// Teacher Attendance Report filters
|
||
const [attendancePeriod, setAttendancePeriod] = useState<'daily' | 'weekly' | 'monthly'>('daily');
|
||
const [attendanceDate, setAttendanceDate] = useState<string>(getTodayDate());
|
||
|
||
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
|
||
|
||
// Admin action states
|
||
const [editingEntry, setEditingEntry] = useState<any>(null);
|
||
const [deletingEntry, setDeletingEntry] = useState<any>(null);
|
||
const [isProcessing, setIsProcessing] = useState(false);
|
||
|
||
// Pagination state
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||
|
||
// Multi-selection state
|
||
const [selectedEntryIds, setSelectedEntryIds] = useState<Set<string>>(new Set());
|
||
|
||
// Reset selection and page when tab or filters change
|
||
useEffect(() => {
|
||
setSelectedEntryIds(new Set());
|
||
setCurrentPage(1);
|
||
}, [activeTab, selectedFilter, dateFrom, dateTo, searchQuery]);
|
||
|
||
// When switching to attendance view, sync the attendance date with current filter
|
||
useEffect(() => {
|
||
if (adminViewMode === 'attendance' && dateFrom) {
|
||
setAttendanceDate(dateFrom);
|
||
setAttendancePeriod('daily');
|
||
}
|
||
}, [adminViewMode, dateFrom]);
|
||
|
||
const toggleSelectAll = () => {
|
||
if (selectedEntryIds.size === filteredEntries.length) {
|
||
setSelectedEntryIds(new Set());
|
||
} else {
|
||
setSelectedEntryIds(new Set(filteredEntries.map(e => e.id)));
|
||
}
|
||
};
|
||
|
||
const toggleSelectEntry = (id: string) => {
|
||
const newSelection = new Set(selectedEntryIds);
|
||
if (newSelection.has(id)) {
|
||
newSelection.delete(id);
|
||
} else {
|
||
newSelection.add(id);
|
||
}
|
||
setSelectedEntryIds(newSelection);
|
||
};
|
||
|
||
const getTargetEntries = () => {
|
||
if (selectedEntryIds.size === 0) return filteredEntries;
|
||
return filteredEntries.filter(e => selectedEntryIds.has(e.id));
|
||
};
|
||
|
||
// Keep sync in case props change, but initial state handles the "automatic" requirement
|
||
useEffect(() => {
|
||
if (currentUser.role === 'GURU') {
|
||
setActiveTab('GURU');
|
||
setSelectedFilter(currentUser.name);
|
||
} else if (currentUser.role === 'SEKRETARIS' && currentUser.className) {
|
||
setActiveTab('KELAS');
|
||
setSelectedFilter(currentUser.className);
|
||
} else if (currentUser.role === 'ADMIN') {
|
||
// Admin: set to today's date on mount
|
||
const today = getTodayDate();
|
||
setDateFrom(today);
|
||
setDateTo(today);
|
||
setSelectedFilter('ALL');
|
||
}
|
||
}, [currentUser]);
|
||
|
||
// Sync attendanceDate with dateFrom when panel filter changes (for consistency)
|
||
useEffect(() => {
|
||
if (dateFrom && attendancePeriod === 'daily') {
|
||
setAttendanceDate(dateFrom);
|
||
}
|
||
}, [dateFrom]);
|
||
|
||
// Sync dateFrom/dateTo with attendanceDate when attendance date picker changes (for consistency)
|
||
useEffect(() => {
|
||
if (attendancePeriod === 'daily' && attendanceDate && adminViewMode === 'attendance') {
|
||
setDateFrom(attendanceDate);
|
||
setDateTo(attendanceDate);
|
||
}
|
||
}, [attendanceDate, adminViewMode]);
|
||
|
||
// Get unique classes from BOTH sources (Teacher Jurnal & Class Jurnal)
|
||
const classes = useMemo(() => {
|
||
if (activeTab === 'GURU') {
|
||
const c = new Set(entries.map(e => e.className).filter(Boolean));
|
||
return Array.from(c).sort();
|
||
} else {
|
||
const c = new Set(classEntries.map(e => e.className).filter(Boolean));
|
||
return Array.from(c).sort();
|
||
}
|
||
}, [entries, classEntries, activeTab]);
|
||
|
||
// Count journals for today (for Admin dashboard info)
|
||
const todayStats = useMemo(() => {
|
||
const today = getTodayDate();
|
||
const guruToday = entries.filter(e => e.date === today).length;
|
||
const kelasToday = classEntries.filter(e => e.date === today).length;
|
||
return { guruToday, kelasToday, total: guruToday + kelasToday };
|
||
}, [entries, classEntries]);
|
||
|
||
// ========== TEACHER STATISTICS CALCULATIONS ==========
|
||
|
||
// Helper to get start of week (Monday)
|
||
const getStartOfWeek = (date: Date) => {
|
||
const d = new Date(date);
|
||
const day = d.getDay();
|
||
const diff = d.getDate() - day + (day === 0 ? -6 : 1); // Adjust when day is Sunday
|
||
d.setDate(diff);
|
||
d.setHours(0, 0, 0, 0);
|
||
return d;
|
||
};
|
||
|
||
// Helper to get week number
|
||
const getWeekNumber = (date: Date) => {
|
||
const startOfYear = new Date(date.getFullYear(), 0, 1);
|
||
const pastDaysOfYear = (date.getTime() - startOfYear.getTime()) / 86400000;
|
||
return Math.ceil((pastDaysOfYear + startOfYear.getDay() + 1) / 7);
|
||
};
|
||
|
||
// Weekly Statistics - Last 4 weeks
|
||
const weeklyStats = useMemo(() => {
|
||
const today = new Date();
|
||
const weeks: { weekLabel: string; startDate: Date; endDate: Date; teachers: { name: string; count: number; dates: string[] }[] }[] = [];
|
||
|
||
for (let i = 0; i < 4; i++) {
|
||
const weekStart = getStartOfWeek(new Date(today.getTime() - i * 7 * 24 * 60 * 60 * 1000));
|
||
const weekEnd = new Date(weekStart.getTime() + 6 * 24 * 60 * 60 * 1000);
|
||
weekEnd.setHours(23, 59, 59, 999);
|
||
|
||
const weekStartStr = formatDateToLocalString(weekStart);
|
||
const weekEndStr = formatDateToLocalString(weekEnd);
|
||
|
||
// Filter entries for this week
|
||
const weekEntries = entries.filter(e => e.date >= weekStartStr && e.date <= weekEndStr);
|
||
|
||
// Group by teacher
|
||
const teacherMap = new Map<string, { count: number; dates: Set<string> }>();
|
||
weekEntries.forEach(e => {
|
||
if (!teacherMap.has(e.teacherName)) {
|
||
teacherMap.set(e.teacherName, { count: 0, dates: new Set() });
|
||
}
|
||
const teacher = teacherMap.get(e.teacherName)!;
|
||
teacher.count++;
|
||
teacher.dates.add(e.date);
|
||
});
|
||
|
||
const teacherStats = Array.from(teacherMap.entries())
|
||
.map(([name, data]) => ({
|
||
name,
|
||
count: data.count,
|
||
dates: Array.from(data.dates).sort()
|
||
}))
|
||
.sort((a, b) => b.count - a.count);
|
||
|
||
const weekLabel = i === 0 ? 'Minggu Ini' : i === 1 ? 'Minggu Lalu' : `${i} Minggu Lalu`;
|
||
|
||
weeks.push({
|
||
weekLabel,
|
||
startDate: weekStart,
|
||
endDate: weekEnd,
|
||
teachers: teacherStats
|
||
});
|
||
}
|
||
|
||
return weeks;
|
||
}, [entries]);
|
||
|
||
// Monthly Statistics - Last 3 months
|
||
const monthlyStats = useMemo(() => {
|
||
const today = new Date();
|
||
const months: { monthLabel: string; year: number; month: number; teachers: { name: string; count: number; uniqueDays: number }[] }[] = [];
|
||
|
||
for (let i = 0; i < 3; i++) {
|
||
const targetDate = new Date(today.getFullYear(), today.getMonth() - i, 1);
|
||
const year = targetDate.getFullYear();
|
||
const month = targetDate.getMonth();
|
||
|
||
const monthStart = new Date(year, month, 1);
|
||
const monthEnd = new Date(year, month + 1, 0);
|
||
|
||
const monthStartStr = formatDateToLocalString(monthStart);
|
||
const monthEndStr = formatDateToLocalString(monthEnd);
|
||
|
||
// Filter entries for this month
|
||
const monthEntries = entries.filter(e => e.date >= monthStartStr && e.date <= monthEndStr);
|
||
|
||
// Group by teacher
|
||
const teacherMap = new Map<string, { count: number; dates: Set<string> }>();
|
||
monthEntries.forEach(e => {
|
||
if (!teacherMap.has(e.teacherName)) {
|
||
teacherMap.set(e.teacherName, { count: 0, dates: new Set() });
|
||
}
|
||
const teacher = teacherMap.get(e.teacherName)!;
|
||
teacher.count++;
|
||
teacher.dates.add(e.date);
|
||
});
|
||
|
||
const teacherStats = Array.from(teacherMap.entries())
|
||
.map(([name, data]) => ({
|
||
name,
|
||
count: data.count,
|
||
uniqueDays: data.dates.size
|
||
}))
|
||
.sort((a, b) => b.count - a.count);
|
||
|
||
const monthLabel = i === 0 ? 'Bulan Ini' : targetDate.toLocaleString('id-ID', { month: 'long', year: 'numeric' });
|
||
|
||
months.push({
|
||
monthLabel,
|
||
year,
|
||
month,
|
||
teachers: teacherStats
|
||
});
|
||
}
|
||
|
||
return months;
|
||
}, [entries]);
|
||
|
||
// Get all teachers who haven't submitted journals this week/month
|
||
const teachersWithoutJournals = useMemo(() => {
|
||
const currentPeriodTeachers = statsPeriod === 'week'
|
||
? new Set(weeklyStats[0]?.teachers.map(t => t.name) || [])
|
||
: new Set(monthlyStats[0]?.teachers.map(t => t.name) || []);
|
||
|
||
return teachers.filter(t => !currentPeriodTeachers.has(t.name));
|
||
}, [teachers, weeklyStats, monthlyStats, statsPeriod]);
|
||
|
||
// ========== TEACHER ATTENDANCE STATISTICS (FROM CLASS JOURNAL) ==========
|
||
|
||
// Helper to get week range
|
||
const getWeekRange = (date: Date) => {
|
||
const start = getStartOfWeek(date);
|
||
const end = new Date(start.getTime() + 6 * 24 * 60 * 60 * 1000);
|
||
return { start, end };
|
||
};
|
||
|
||
// Helper to get month range
|
||
const getMonthRange = (date: Date) => {
|
||
const start = new Date(date.getFullYear(), date.getMonth(), 1);
|
||
const end = new Date(date.getFullYear(), date.getMonth() + 1, 0);
|
||
return { start, end };
|
||
};
|
||
|
||
// Helper to format date (moved here to be available for useMemo below)
|
||
const formatDate = (dateString: string) => {
|
||
if (!dateString) return '-';
|
||
try {
|
||
const options: Intl.DateTimeFormatOptions = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
|
||
return new Date(dateString).toLocaleDateString('id-ID', options);
|
||
} catch (e) {
|
||
return dateString;
|
||
}
|
||
};
|
||
|
||
// Teacher Attendance Statistics based on classEntries
|
||
const teacherAttendanceStats = useMemo(() => {
|
||
let dateStart: Date;
|
||
let dateEnd: Date;
|
||
let periodLabel: string;
|
||
|
||
const baseDate = new Date(attendanceDate);
|
||
|
||
if (attendancePeriod === 'daily') {
|
||
dateStart = new Date(attendanceDate);
|
||
dateStart.setHours(0, 0, 0, 0);
|
||
dateEnd = new Date(attendanceDate);
|
||
dateEnd.setHours(23, 59, 59, 999);
|
||
periodLabel = formatDate(attendanceDate);
|
||
} else if (attendancePeriod === 'weekly') {
|
||
const range = getWeekRange(baseDate);
|
||
dateStart = range.start;
|
||
dateEnd = range.end;
|
||
periodLabel = `${dateStart.toLocaleDateString('id-ID', { day: 'numeric', month: 'short' })} - ${dateEnd.toLocaleDateString('id-ID', { day: 'numeric', month: 'short', year: 'numeric' })}`;
|
||
} else {
|
||
const range = getMonthRange(baseDate);
|
||
dateStart = range.start;
|
||
dateEnd = range.end;
|
||
periodLabel = baseDate.toLocaleDateString('id-ID', { month: 'long', year: 'numeric' });
|
||
}
|
||
|
||
// Use local date formatting to avoid timezone issues
|
||
const startStr = formatDateToLocalString(dateStart);
|
||
const endStr = formatDateToLocalString(dateEnd);
|
||
|
||
// Filter class entries for the selected period
|
||
// Normalize the entry date to YYYY-MM-DD format for consistent comparison
|
||
let periodEntries = classEntries.filter(e => {
|
||
const entryDate = e.date ? e.date.split('T')[0] : '';
|
||
return entryDate >= startStr && entryDate <= endStr;
|
||
});
|
||
|
||
console.log(`[Kehadiran Guru] Period: ${startStr} to ${endStr}, Entries found: ${periodEntries.length}`);
|
||
|
||
// Deduplicate entries by ID to prevent double counting if there are duplicates in the data source
|
||
const uniqueEntriesMap = new Map();
|
||
periodEntries.forEach(e => {
|
||
if (e.id) uniqueEntriesMap.set(e.id, e);
|
||
else uniqueEntriesMap.set(Math.random().toString(), e); // Fallback for items without ID
|
||
});
|
||
const finalEntries = Array.from(uniqueEntriesMap.values());
|
||
|
||
// Group by teacher and count presence status
|
||
const teacherMap = new Map<string, {
|
||
hadir: number;
|
||
tugas: number;
|
||
tidakHadir: number;
|
||
total: number;
|
||
details: { date: string; className: string; subject: string; status: string; jam: string }[]
|
||
}>();
|
||
|
||
finalEntries.forEach(entry => {
|
||
const teacherName = entry.teacherName;
|
||
if (!teacherMap.has(teacherName)) {
|
||
teacherMap.set(teacherName, { hadir: 0, tugas: 0, tidakHadir: 0, total: 0, details: [] });
|
||
}
|
||
const teacher = teacherMap.get(teacherName)!;
|
||
teacher.total++;
|
||
|
||
const status = entry.teacherPresence || 'Hadir';
|
||
if (status === 'Hadir') teacher.hadir++;
|
||
else if (status === 'Tugas') teacher.tugas++;
|
||
else teacher.tidakHadir++;
|
||
|
||
teacher.details.push({
|
||
date: entry.date,
|
||
className: entry.className,
|
||
subject: entry.subject,
|
||
status: status,
|
||
jam: `${entry.startTime}-${entry.endTime}`
|
||
});
|
||
});
|
||
|
||
// Convert to array and sort by total entries
|
||
const teacherStats = Array.from(teacherMap.entries())
|
||
.map(([name, data]) => ({
|
||
name,
|
||
...data,
|
||
hadirPercentage: data.total > 0 ? Math.round((data.hadir / data.total) * 100) : 0
|
||
}))
|
||
.sort((a, b) => b.total - a.total);
|
||
|
||
// Calculate summary stats - COUNT UNIQUE TEACHERS, not entries
|
||
// Priority: If teacher has at least 1 "Hadir" → count as Hadir
|
||
// If teacher has at least 1 "Tugas" (but no Hadir) → count as Tugas
|
||
// If ALL entries are "Tidak Hadir" → count as Tidak Hadir
|
||
let uniqueHadir = 0;
|
||
let uniqueTugas = 0;
|
||
let uniqueTidakHadir = 0;
|
||
|
||
teacherStats.forEach(teacher => {
|
||
if (teacher.hadir > 0) {
|
||
uniqueHadir++;
|
||
} else if (teacher.tugas > 0) {
|
||
uniqueTugas++;
|
||
} else if (teacher.tidakHadir > 0) {
|
||
uniqueTidakHadir++;
|
||
}
|
||
});
|
||
|
||
const summary = {
|
||
totalEntries: finalEntries.length,
|
||
totalTeachers: teacherStats.length,
|
||
totalHadir: uniqueHadir,
|
||
totalTugas: uniqueTugas,
|
||
totalTidakHadir: uniqueTidakHadir
|
||
};
|
||
|
||
return {
|
||
periodLabel,
|
||
startDate: startStr,
|
||
endDate: endStr,
|
||
teachers: teacherStats,
|
||
summary
|
||
};
|
||
}, [classEntries, attendancePeriod, attendanceDate]);
|
||
|
||
// Filter Logic - Unified for all roles
|
||
const filteredEntries = useMemo(() => {
|
||
let source: any[] = activeTab === 'GURU' ? [...entries] : [...classEntries];
|
||
|
||
// Apply role-based initial filter
|
||
if (currentUser.role === 'GURU') {
|
||
source = source.filter(e => e.teacherName === currentUser.name);
|
||
} else if (currentUser.role === 'SEKRETARIS') {
|
||
source = source.filter(e => e.className === currentUser.className);
|
||
} else if (currentUser.role === 'ADMIN' && selectedFilter !== 'ALL') {
|
||
if (activeTab === 'GURU') {
|
||
source = source.filter(e => e.teacherName === selectedFilter);
|
||
} else {
|
||
source = source.filter(e => e.className === selectedFilter);
|
||
}
|
||
}
|
||
|
||
// Apply date filter (now for everyone)
|
||
if (dateFrom) {
|
||
source = source.filter(e => e.date >= dateFrom);
|
||
}
|
||
if (dateTo) {
|
||
source = source.filter(e => e.date <= dateTo);
|
||
}
|
||
|
||
// Apply search query
|
||
if (searchQuery.trim()) {
|
||
const query = searchQuery.toLowerCase().trim();
|
||
source = source.filter(e => {
|
||
const searchableText = [
|
||
e.teacherName,
|
||
e.className,
|
||
e.topic || '',
|
||
e.subject || '',
|
||
e.notes || '',
|
||
].join(' ').toLowerCase();
|
||
return searchableText.includes(query);
|
||
});
|
||
}
|
||
|
||
// Sort by date descending
|
||
return source.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||
}, [activeTab, selectedFilter, entries, classEntries, dateFrom, dateTo, searchQuery, currentUser]);
|
||
|
||
// Pagination Logic
|
||
const totalPages = Math.ceil(filteredEntries.length / itemsPerPage);
|
||
const paginatedEntries = useMemo(() => {
|
||
const start = (currentPage - 1) * itemsPerPage;
|
||
return filteredEntries.slice(start, start + itemsPerPage);
|
||
}, [filteredEntries, currentPage]);
|
||
|
||
// Quick date filter presets for Admin
|
||
const setDatePreset = (preset: 'today' | 'week' | 'month' | 'all') => {
|
||
const today = new Date();
|
||
switch (preset) {
|
||
case 'today':
|
||
const todayStr = getTodayDate();
|
||
setDateFrom(todayStr);
|
||
setDateTo(todayStr);
|
||
break;
|
||
case 'week':
|
||
const weekAgo = new Date(today);
|
||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||
setDateFrom(formatDateToLocalString(weekAgo));
|
||
setDateTo(getTodayDate());
|
||
break;
|
||
case 'month':
|
||
const monthAgo = new Date(today);
|
||
monthAgo.setMonth(monthAgo.getMonth() - 1);
|
||
setDateFrom(formatDateToLocalString(monthAgo));
|
||
setDateTo(getTodayDate());
|
||
break;
|
||
case 'all':
|
||
setDateFrom('');
|
||
setDateTo('');
|
||
break;
|
||
}
|
||
};
|
||
|
||
// Helper to format absent students string
|
||
const getAbsentString = (entry: any) => {
|
||
if (!entry.attendanceDetails || entry.attendanceDetails.length === 0) return '-';
|
||
// If string "NIHIL"
|
||
if (entry.attendanceDetails === "NIHIL") return "-";
|
||
|
||
const absents = Array.isArray(entry.attendanceDetails)
|
||
? entry.attendanceDetails.filter((d: any) => d.status !== 'H')
|
||
: [];
|
||
|
||
if (absents.length === 0) return '-';
|
||
return absents.map((d: any) => `${d.studentName} (${d.status})`).join(', ');
|
||
};
|
||
|
||
// --- ADMIN ACTION HANDLERS ---
|
||
const handleEditSubmit = async () => {
|
||
if (!editingEntry) return;
|
||
setIsProcessing(true);
|
||
const type = activeTab === 'GURU' ? 'teacher' : 'class';
|
||
const success = await updateJournalEntry(editingEntry.id, editingEntry, type);
|
||
setIsProcessing(false);
|
||
|
||
if (success) {
|
||
alert('✅ Jurnal berhasil diperbarui!');
|
||
setEditingEntry(null);
|
||
onRefresh?.();
|
||
} else {
|
||
alert('❌ Gagal menyimpan perubahan. Silakan coba lagi.');
|
||
}
|
||
};
|
||
|
||
const handleDelete = async () => {
|
||
if (!deletingEntry) return;
|
||
setIsProcessing(true);
|
||
const type = activeTab === 'GURU' ? 'teacher' : 'class';
|
||
const success = await deleteJournalEntry(deletingEntry.id, type);
|
||
setIsProcessing(false);
|
||
|
||
if (success) {
|
||
alert('✅ Jurnal berhasil dihapus!');
|
||
setDeletingEntry(null);
|
||
onRefresh?.();
|
||
} else {
|
||
alert('❌ Gagal menghapus jurnal. Silakan coba lagi.');
|
||
}
|
||
};
|
||
|
||
const handleEditFieldChange = (field: string, value: any) => {
|
||
setEditingEntry((prev: any) => ({ ...prev, [field]: value }));
|
||
};
|
||
|
||
// --- PRINT STATISTICS ---
|
||
const handlePrintStatistics = () => {
|
||
const printWindow = window.open('', '', 'height=600,width=1024');
|
||
if (!printWindow) return;
|
||
|
||
const today = new Date();
|
||
const dateStr = `Badung, ${today.getDate()} ${today.toLocaleString('id-ID', { month: 'long' })} ${today.getFullYear()}`;
|
||
|
||
// Prepare Header (Kop)
|
||
const kopUrl = formatGoogleDriveImageUrl(settings.kopUrl);
|
||
const headerContent = kopUrl
|
||
? `<img src="${kopUrl}" style="width: 100%; max-height: 5cm; object-fit: contain; margin-bottom: 10px; display: block; margin-left: auto; margin-right: auto;" />`
|
||
: `<div style="text-align: center; font-weight: bold; margin-bottom: 20px; border-bottom: 3px double black; padding-bottom: 10px;">
|
||
<h3 style="margin:0">PEMERINTAH PROVINSI BALI</h3>
|
||
<h2 style="margin:5px 0">SMA NEGERI 1 ABIANSEMAL</h2>
|
||
<p style="margin:0; font-weight: normal; font-size: 12px;">Alamat: Jalan Raya Abiansemal, Badung, Bali</p>
|
||
</div>`;
|
||
|
||
let tableContent = '';
|
||
|
||
if (statsPeriod === 'week') {
|
||
// Weekly Statistics
|
||
weeklyStats.forEach((week, weekIdx) => {
|
||
const startStr = week.startDate.toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' });
|
||
const endStr = week.endDate.toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' });
|
||
|
||
tableContent += `
|
||
<div style="margin-bottom: 30px; page-break-inside: avoid;">
|
||
<h4 style="margin: 10px 0; color: #1e3a5f; border-bottom: 2px solid #1e3a5f; padding-bottom: 5px;">
|
||
${week.weekLabel} (${startStr} - ${endStr})
|
||
</h4>
|
||
<table class="data">
|
||
<thead>
|
||
<tr>
|
||
<th style="width: 8%">No.</th>
|
||
<th style="width: 45%">Nama Guru</th>
|
||
<th style="width: 20%">Jumlah Jurnal</th>
|
||
<th style="width: 27%">Hari Mengisi</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${week.teachers.length > 0 ? week.teachers.map((t, idx) => `
|
||
<tr>
|
||
<td style="text-align: center">${idx + 1}</td>
|
||
<td>${t.name}</td>
|
||
<td style="text-align: center; font-weight: bold; color: #16a34a">${t.count}</td>
|
||
<td style="font-size: 10px">${t.dates.length} hari</td>
|
||
</tr>
|
||
`).join('') : `
|
||
<tr>
|
||
<td colspan="4" style="text-align: center; color: #888; font-style: italic">Belum ada data jurnal</td>
|
||
</tr>
|
||
`}
|
||
</tbody>
|
||
</table>
|
||
<p style="font-size: 11px; color: #666; margin-top: 5px;">Total: ${week.teachers.length} guru telah mengisi jurnal</p>
|
||
</div>
|
||
`;
|
||
});
|
||
} else {
|
||
// Monthly Statistics
|
||
monthlyStats.forEach((month, monthIdx) => {
|
||
const monthName = new Date(month.year, month.month).toLocaleString('id-ID', { month: 'long', year: 'numeric' });
|
||
|
||
tableContent += `
|
||
<div style="margin-bottom: 30px; page-break-inside: avoid;">
|
||
<h4 style="margin: 10px 0; color: #1e3a5f; border-bottom: 2px solid #1e3a5f; padding-bottom: 5px;">
|
||
${month.monthLabel} (${monthName})
|
||
</h4>
|
||
<table class="data">
|
||
<thead>
|
||
<tr>
|
||
<th style="width: 8%">No.</th>
|
||
<th style="width: 40%">Nama Guru</th>
|
||
<th style="width: 25%">Jumlah Jurnal</th>
|
||
<th style="width: 27%">Hari Aktif Mengisi</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${month.teachers.length > 0 ? month.teachers.map((t, idx) => `
|
||
<tr>
|
||
<td style="text-align: center">${idx + 1}</td>
|
||
<td>${t.name}</td>
|
||
<td style="text-align: center; font-weight: bold; color: #16a34a">${t.count}</td>
|
||
<td style="text-align: center">${t.uniqueDays} hari</td>
|
||
</tr>
|
||
`).join('') : `
|
||
<tr>
|
||
<td colspan="4" style="text-align: center; color: #888; font-style: italic">Belum ada data jurnal</td>
|
||
</tr>
|
||
`}
|
||
</tbody>
|
||
</table>
|
||
<p style="font-size: 11px; color: #666; margin-top: 5px;">Total: ${month.teachers.length} guru telah mengisi jurnal</p>
|
||
</div>
|
||
`;
|
||
});
|
||
}
|
||
|
||
// Teachers without journals section
|
||
const noJournalSection = teachersWithoutJournals.length > 0 ? `
|
||
<div style="margin-top: 30px; padding: 15px; background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px;">
|
||
<h4 style="margin: 0 0 10px 0; color: #dc2626;">⚠️ Guru yang Belum Mengisi Jurnal (${statsPeriod === 'week' ? 'Minggu Ini' : 'Bulan Ini'})</h4>
|
||
<p style="font-size: 12px; color: #666; margin: 0;">
|
||
${teachersWithoutJournals.map(t => t.name).join(', ')}
|
||
</p>
|
||
</div>
|
||
` : '';
|
||
|
||
printWindow.document.write(`
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<title>Statistik Kehadiran Guru - ${statsPeriod === 'week' ? 'Mingguan' : 'Bulanan'}</title>
|
||
<style>
|
||
body { font-family: Arial, sans-serif; font-size: 12px; color: #000; -webkit-print-color-adjust: exact; box-sizing: border-box; }
|
||
table.data { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
|
||
table.data th, table.data td { border: 1px solid #000; padding: 8px 10px; vertical-align: middle; }
|
||
table.data th { background-color: #f0f0f0; font-weight: bold; text-align: center; }
|
||
.header-title { text-align: center; margin-bottom: 25px; font-weight: bold; font-size: 14px; margin-top: 15px; }
|
||
.footer { margin-top: 30px; float: right; width: 250px; text-align: left; page-break-inside: avoid; }
|
||
|
||
@media print {
|
||
@page { size: A4 portrait; margin: 20mm; }
|
||
body { padding: 0; margin: 0; }
|
||
}
|
||
|
||
@media screen {
|
||
body { padding: 20mm; max-width: 210mm; margin: auto; box-shadow: 0 0 10px rgba(0,0,0,0.1); }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
${headerContent}
|
||
|
||
<div class="header-title">
|
||
STATISTIK PENGISIAN JURNAL MENGAJAR GURU<br>
|
||
${statsPeriod === 'week' ? 'LAPORAN MINGGUAN' : 'LAPORAN BULANAN'}<br>
|
||
SEMESTER ${settings.semester.toUpperCase()} - TAHUN PELAJARAN ${settings.academicYear}
|
||
</div>
|
||
|
||
${tableContent}
|
||
|
||
${noJournalSection}
|
||
|
||
<div class="footer">
|
||
<p>${dateStr}</p>
|
||
<p>Kepala SMA Negeri 1 Abiansemal,</p>
|
||
<br><br><br>
|
||
<p style="font-weight: bold; text-decoration: underline;">${settings.headmasterName || '(.........................)'}</p>
|
||
<p>NIP. ${settings.headmasterNip || '-'}</p>
|
||
</div>
|
||
|
||
<script>
|
||
window.onload = function() {
|
||
setTimeout(function() {
|
||
window.print();
|
||
}, 1000);
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|
||
`);
|
||
printWindow.document.close();
|
||
};
|
||
|
||
// --- PRINT HTML GENERATION LOGIC ---
|
||
const handlePrint = () => {
|
||
const targetEntries = getTargetEntries();
|
||
if (!targetEntries.length) return;
|
||
|
||
const printWindow = window.open('', '', 'height=600,width=1024');
|
||
if (!printWindow) return;
|
||
|
||
const title = activeTab === 'GURU' ? `JURNAL MENGAJAR GURU` : `JURNAL KELAS ${selectedFilter}`;
|
||
const today = new Date();
|
||
const dateStr = `Badung, ${today.getDate()} ${today.toLocaleString('id-ID', { month: 'long' })} ${today.getFullYear()}`;
|
||
|
||
// Prepare Header (Kop)
|
||
const kopUrl = formatGoogleDriveImageUrl(settings.kopUrl);
|
||
const headerContent = kopUrl
|
||
? `<img src="${kopUrl}" style="width: 100%; max-height: 5cm; object-fit: contain; margin-bottom: 10px; display: block; margin-left: auto; margin-right: auto;" />`
|
||
: `<div style="text-align: center; font-weight: bold; margin-bottom: 20px; border-bottom: 3px double black; padding-bottom: 10px;">
|
||
<h3 style="margin:0">PEMERINTAH PROVINSI BALI</h3>
|
||
<h2 style="margin:5px 0">SMA NEGERI 1 ABIANSEMAL</h2>
|
||
<p style="margin:0; font-weight: normal; font-size: 12px;">Alamat: Jalan Raya Abiansemal, Badung, Bali</p>
|
||
</div>`;
|
||
|
||
// Identity Info
|
||
let identityContent = '';
|
||
if (activeTab === 'GURU') {
|
||
identityContent = `
|
||
<table style="width: 100%; margin-bottom: 15px; font-size: 12px; font-family: Arial;">
|
||
<tr><td style="width: 150px; font-weight: bold;">Nama Guru</td><td>: ${selectedFilter}</td></tr>
|
||
<tr><td style="font-weight: bold;">Mata Pelajaran</td><td>: ${filteredEntries[0]?.subject || '-'}</td></tr>
|
||
</table>
|
||
`;
|
||
} else {
|
||
const dateRange = filteredEntries.length > 0
|
||
? `${formatDate(filteredEntries[0].date)} s/d ${formatDate(filteredEntries[filteredEntries.length - 1].date)}`
|
||
: '-';
|
||
identityContent = `
|
||
<table style="width: 100%; margin-bottom: 15px; font-size: 12px; font-family: Arial;">
|
||
<tr><td style="width: 150px; font-weight: bold;">Kelas</td><td>: ${selectedFilter}</td></tr>
|
||
<tr><td style="font-weight: bold;">Hari / Tanggal</td><td>: ${dateRange}</td></tr>
|
||
</table>
|
||
`;
|
||
}
|
||
|
||
// Table Content
|
||
let tableHead = '';
|
||
if (activeTab === 'GURU') {
|
||
tableHead = `<tr>
|
||
<th style="width: 5%;">No.</th>
|
||
<th style="width: 12%;">Hari/ Tanggal</th>
|
||
<th style="width: 8%;">Kelas</th>
|
||
<th style="width: 8%;">Jam</th>
|
||
<th style="width: 20%;">Materi</th>
|
||
<th style="width: 20%;">Siswa Tidak Hadir</th>
|
||
<th style="width: 15%;">Foto Mengajar</th>
|
||
</tr>`;
|
||
} else {
|
||
tableHead = `<tr>
|
||
<th style="width: 5%;">No.</th>
|
||
<th style="width: 15%;">Hari/ Tanggal</th>
|
||
<th style="width: 20%;">Nama Guru</th>
|
||
<th style="width: 10%;">Jam Ke</th>
|
||
<th style="width: 10%;">Kehadiran</th>
|
||
<th style="width: 25%;">Siswa Tidak Hadir</th>
|
||
<th style="width: 15%;">Keterangan</th>
|
||
</tr>`;
|
||
}
|
||
|
||
const tableBody = targetEntries.map((entry, idx) => {
|
||
const absentStr = getAbsentString(entry);
|
||
if (activeTab === 'GURU') {
|
||
// IMPORTANT: Convert to embeddable URL
|
||
const displayUrl = formatGoogleDriveImageUrl(entry.photoUrl || '');
|
||
|
||
const imgHtml = displayUrl
|
||
? `<img src="${displayUrl}" alt="Foto" style="width: 4cm; height: 3cm; object-fit: cover; border-radius: 4px;" loading="lazy" />`
|
||
: '<span style="color:#ccc">-</span>';
|
||
|
||
return `
|
||
<tr>
|
||
<td style="text-align: center;">${idx + 1}</td>
|
||
<td>${formatDate(entry.date)}</td>
|
||
<td style="text-align: center; font-weight: bold;">${entry.className}</td>
|
||
<td style="text-align: center;">${entry.startTime} - ${entry.endTime}</td>
|
||
<td>${entry.topic}</td>
|
||
<td style="color: #dc2626; font-size: 11px;">${absentStr}</td>
|
||
<td style="text-align: center; padding: 5px;">${imgHtml}</td>
|
||
</tr>
|
||
`;
|
||
} else {
|
||
// Class Journal Row
|
||
return `
|
||
<tr>
|
||
<td style="text-align: center;">${idx + 1}</td>
|
||
<td>${formatDate(entry.date)}</td>
|
||
<td>${entry.teacherName}</td>
|
||
<td style="text-align: center;">${entry.startTime} - ${entry.endTime}</td>
|
||
<td style="text-align: center; font-weight: bold;">${entry.teacherPresence || '-'}</td>
|
||
<td style="color: #dc2626; font-size: 11px;">${absentStr}</td>
|
||
<td>${entry.notes || '-'}</td>
|
||
</tr>
|
||
`;
|
||
}
|
||
}).join('');
|
||
|
||
// Document HTML
|
||
printWindow.document.write(`
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<title>Cetak Laporan - ${selectedFilter}</title>
|
||
<style>
|
||
/* Default Styles for Screen and Print */
|
||
body { font-family: Arial, sans-serif; font-size: 12px; color: #000; -webkit-print-color-adjust: exact; box-sizing: border-box; }
|
||
table.data { width: 100%; border-collapse: collapse; margin-bottom: 20px; table-layout: fixed; }
|
||
table.data th, table.data td { border: 1px solid #000; padding: 6px 8px; vertical-align: middle; word-wrap: break-word; }
|
||
table.data th { background-color: #f0f0f0; font-weight: bold; text-align: center; text-transform: none; }
|
||
.header-title { text-align: center; margin-bottom: 20px; font-weight: bold; font-size: 14px; margin-top: 10px; }
|
||
.footer { margin-top: 20px; float: right; width: 250px; text-align: left; page-break-inside: avoid; }
|
||
|
||
/* Print Specific Styles - Ensures margins on ALL pages */
|
||
@media print {
|
||
@page {
|
||
size: A4 landscape;
|
||
margin: 20mm; /* This enforces physical margin on every page */
|
||
}
|
||
body {
|
||
padding: 0;
|
||
margin: 0;
|
||
}
|
||
thead { display: table-header-group; } /* Repeat headers */
|
||
tr { page-break-inside: avoid; }
|
||
}
|
||
|
||
/* Screen Preview Style */
|
||
@media screen {
|
||
body { padding: 20mm; max-width: 297mm; margin: auto; box-shadow: 0 0 10px rgba(0,0,0,0.1); }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
${headerContent}
|
||
|
||
<div class="header-title">
|
||
${title}<br>
|
||
SEMESTER ${settings.semester.toUpperCase()}<br>
|
||
TAHUN PELAJARAN ${settings.academicYear}
|
||
</div>
|
||
|
||
${identityContent}
|
||
|
||
<table class="data">
|
||
<thead>${tableHead}</thead>
|
||
<tbody>${tableBody}</tbody>
|
||
</table>
|
||
|
||
<div class="footer">
|
||
<p>${dateStr}</p>
|
||
<p>Kepala SMA Negeri 1 Abiansemal,</p>
|
||
<br><br><br>
|
||
<p style="font-weight: bold; text-decoration: underline;">${settings.headmasterName || '(.........................)'}</p>
|
||
<p>NIP. ${settings.headmasterNip || '-'}</p>
|
||
</div>
|
||
|
||
<script>
|
||
// Wait for images to load before printing
|
||
window.onload = function() {
|
||
setTimeout(function() {
|
||
window.print();
|
||
}, 1500); // Delay to ensure KOP and Photos load
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|
||
`);
|
||
printWindow.document.close();
|
||
};
|
||
|
||
// --- PDF GENERATION LOGIC ---
|
||
const generatePDF = async () => {
|
||
const targetEntries = getTargetEntries();
|
||
if (!targetEntries.length) return;
|
||
setIsGeneratingPdf(true);
|
||
|
||
try {
|
||
const targetEntries = getTargetEntries();
|
||
const doc = new jsPDF('l', 'mm', 'a4'); // Landscape, mm, A4
|
||
|
||
// --- 1. Page Setup & Margins ---
|
||
const docWidth = doc.internal.pageSize.getWidth(); // 297mm
|
||
// const docHeight = doc.internal.pageSize.getHeight(); // 210mm
|
||
const margin = 20; // 2cm margin all sides
|
||
|
||
// Initial Y Position starts at margin
|
||
let yPos = margin;
|
||
|
||
// --- 2. HEADER (KOP) ---
|
||
// Requested Size: 25.78 cm x 5.27 cm -> 257.8 mm x 52.7 mm
|
||
const kopWidth = 257.8;
|
||
const kopHeight = 52.7;
|
||
|
||
// Center the Kop horizontally relative to the page width
|
||
const xKop = (docWidth - kopWidth) / 2;
|
||
|
||
if (settings.kopUrl) {
|
||
try {
|
||
const formattedKopUrl = formatGoogleDriveImageUrl(settings.kopUrl);
|
||
const kopBase64 = await getBase64ImageFromURL(formattedKopUrl);
|
||
|
||
doc.addImage(kopBase64, 'JPEG', xKop, yPos, kopWidth, kopHeight);
|
||
yPos += kopHeight + 5; // Add padding below Kop
|
||
} catch (e) {
|
||
console.error("Error adding KOP image", e);
|
||
// Fallback Text
|
||
doc.setFontSize(14);
|
||
doc.setFont('helvetica', 'bold');
|
||
doc.text("PEMERINTAH PROVINSI BALI", docWidth / 2, yPos + 10, { align: 'center' });
|
||
doc.text("SMA NEGERI 1 ABIANSEMAL", docWidth / 2, yPos + 20, { align: 'center' });
|
||
yPos += 30;
|
||
}
|
||
} else {
|
||
// Default Text Header
|
||
doc.setFontSize(14);
|
||
doc.setFont('helvetica', 'bold');
|
||
doc.text("PEMERINTAH PROVINSI BALI", docWidth / 2, yPos + 10, { align: 'center' });
|
||
doc.text("SMA NEGERI 1 ABIANSEMAL", docWidth / 2, yPos + 20, { align: 'center' });
|
||
yPos += 30;
|
||
}
|
||
|
||
// --- 3. TITLES ---
|
||
doc.setFontSize(11);
|
||
doc.setFont('helvetica', 'bold');
|
||
if (activeTab === 'GURU') {
|
||
doc.text("JURNAL MENGAJAR GURU", docWidth / 2, yPos, { align: 'center' });
|
||
} else {
|
||
doc.text(`JURNAL KELAS ${selectedFilter}`, docWidth / 2, yPos, { align: 'center' });
|
||
}
|
||
yPos += 5;
|
||
|
||
doc.setFont('helvetica', 'normal');
|
||
doc.setFontSize(10);
|
||
doc.text(`SEMESTER ${settings.semester.toUpperCase()}`, docWidth / 2, yPos, { align: 'center' });
|
||
yPos += 5;
|
||
doc.text(`TAHUN PELAJARAN ${settings.academicYear}`, docWidth / 2, yPos, { align: 'center' });
|
||
yPos += 10;
|
||
|
||
// --- 4. IDENTITY SECTION ---
|
||
doc.setFontSize(10);
|
||
doc.setFont('helvetica', 'normal');
|
||
const identityX = margin + 5; // Slight offset from margin
|
||
|
||
if (activeTab === 'GURU') {
|
||
doc.text(`Nama Guru : ${selectedFilter}`, identityX, yPos);
|
||
yPos += 5;
|
||
const mapel = targetEntries[0]?.subject || '-';
|
||
doc.text(`Mata Pelajaran : ${mapel}`, identityX, yPos);
|
||
} else {
|
||
const dateText = targetEntries.length > 0
|
||
? `${formatDate(targetEntries[0].date)} s/d ${formatDate(targetEntries[targetEntries.length - 1].date)}`
|
||
: '-';
|
||
doc.text(`Hari/ Tanggal : ${dateText}`, identityX, yPos);
|
||
}
|
||
yPos += 8;
|
||
|
||
// --- 5. PREPARE DATA ---
|
||
let tableHeaders = [];
|
||
let tableData = [];
|
||
|
||
if (activeTab === 'GURU') {
|
||
tableHeaders = [['No.', 'Hari/ Tanggal', 'Kelas', 'Jam', 'Materi', 'Siswa Tidak Hadir', 'Foto Mengajar']];
|
||
|
||
// Pre-fetch images for PDF
|
||
tableData = await Promise.all(targetEntries.map(async (entry, index) => {
|
||
let photoContent: any = '-';
|
||
|
||
if (entry.photoUrl) {
|
||
const url = formatGoogleDriveImageUrl(entry.photoUrl);
|
||
try {
|
||
const base64 = await getBase64ImageFromURL(url);
|
||
photoContent = base64;
|
||
} catch (error) {
|
||
photoContent = 'Foto Error';
|
||
}
|
||
}
|
||
|
||
return [
|
||
index + 1,
|
||
formatDate(entry.date),
|
||
entry.className,
|
||
`${entry.startTime}-${entry.endTime}`,
|
||
entry.topic,
|
||
getAbsentString(entry),
|
||
photoContent
|
||
];
|
||
}));
|
||
|
||
} else {
|
||
tableHeaders = [['No.', 'Hari/ Tanggal', 'Nama Guru', 'Jam', 'Kehadiran', 'Siswa Tidak Hadir', 'Keterangan']];
|
||
tableData = targetEntries.map((entry, index) => [
|
||
index + 1,
|
||
formatDate(entry.date),
|
||
entry.teacherName,
|
||
`${entry.startTime}-${entry.endTime}`,
|
||
entry.teacherPresence || '-',
|
||
getAbsentString(entry),
|
||
entry.notes || '-'
|
||
]);
|
||
}
|
||
|
||
// --- 6. DRAW TABLE ---
|
||
(doc as any).autoTable({
|
||
startY: yPos,
|
||
head: tableHeaders,
|
||
body: tableData,
|
||
theme: 'grid',
|
||
// Set explicit margins: 2cm all around. This ensures Page 2+ respects the margin.
|
||
margin: { top: margin, right: margin, bottom: margin, left: margin },
|
||
headStyles: {
|
||
fillColor: [240, 240, 240],
|
||
textColor: [0, 0, 0],
|
||
lineWidth: 0.1,
|
||
lineColor: [0, 0, 0],
|
||
halign: 'center',
|
||
valign: 'middle',
|
||
fontStyle: 'bold'
|
||
},
|
||
styles: {
|
||
fontSize: 9,
|
||
cellPadding: 2,
|
||
lineColor: [0, 0, 0],
|
||
lineWidth: 0.1,
|
||
textColor: [0, 0, 0],
|
||
valign: 'middle',
|
||
overflow: 'linebreak'
|
||
},
|
||
// Define Column Widths - Ensure they sum up to roughly usable width (257mm)
|
||
columnStyles: activeTab === 'GURU' ? {
|
||
0: { cellWidth: 10, halign: 'center' },
|
||
1: { cellWidth: 28 }, // Reduced to make space
|
||
2: { cellWidth: 18, halign: 'center' },
|
||
3: { cellWidth: 18, halign: 'center' },
|
||
4: { cellWidth: 'auto' }, // Materi takes remaining space
|
||
5: { cellWidth: 40, textColor: [220, 38, 38] }, // Absen red
|
||
// Width 42mm to accommodate 40mm (4cm) photo with padding
|
||
6: { cellWidth: 42, halign: 'center' }
|
||
} : {
|
||
0: { cellWidth: 10, halign: 'center' },
|
||
1: { cellWidth: 35 },
|
||
2: { cellWidth: 55 },
|
||
3: { cellWidth: 22, halign: 'center' },
|
||
4: { cellWidth: 25, halign: 'center' },
|
||
5: { cellWidth: 55, textColor: [220, 38, 38] },
|
||
6: { cellWidth: 'auto' } // Keterangan fills the rest ensuring full margin width
|
||
},
|
||
|
||
// --- 7. IMAGE RENDERING (Fixed Size 4cm x 3cm) ---
|
||
didDrawCell: (data: any) => {
|
||
if (activeTab === 'GURU' && data.column.index === 6 && data.cell.section === 'body') {
|
||
const content = data.cell.raw;
|
||
if (typeof content === 'string' && content.startsWith('data:image')) {
|
||
|
||
// Specific Dimensions as requested: Width 4cm, Height 3cm
|
||
const imgWidth = 40; // mm
|
||
const imgHeight = 30; // mm
|
||
|
||
// Center the image in the cell
|
||
const xPos = data.cell.x + (data.cell.width - imgWidth) / 2;
|
||
const yPos = data.cell.y + (data.cell.height - imgHeight) / 2;
|
||
|
||
doc.addImage(content, 'JPEG', xPos, yPos, imgWidth, imgHeight);
|
||
}
|
||
}
|
||
},
|
||
// Force row height to be sufficient for the 3cm image
|
||
didParseCell: (data: any) => {
|
||
if (activeTab === 'GURU' && data.column.index === 6 && data.section === 'body') {
|
||
const rawContent = data.cell.raw;
|
||
if (typeof rawContent === 'string' && rawContent.startsWith('data:image')) {
|
||
// Set height slightly larger than 30mm to avoid clipping
|
||
data.row.height = 32;
|
||
|
||
// CRITICAL FIX: Clear the text content so the base64 string isn't printed
|
||
data.cell.text = [''];
|
||
// Backup: make text invisible just in case
|
||
data.cell.styles.textColor = [255, 255, 255];
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// --- 8. FOOTER (SIGNATURE) ---
|
||
let finalY = (doc as any).lastAutoTable.finalY + 10;
|
||
|
||
// Check if footer fits on current page, considering bottom margin
|
||
const pageHeight = doc.internal.pageSize.getHeight();
|
||
const footerHeight = 40; // Approx height of signature block
|
||
|
||
if (finalY + footerHeight > pageHeight - margin) {
|
||
doc.addPage();
|
||
finalY = margin + 10;
|
||
}
|
||
|
||
const today = new Date();
|
||
const dateStrFooter = `Badung, ${today.getDate()} ${today.toLocaleString('id-ID', { month: 'long' })} ${today.getFullYear()}`;
|
||
|
||
// Align right side (margin right: 20mm)
|
||
// Assuming width around 60-70mm for signature block
|
||
const footerX = docWidth - margin - 65;
|
||
|
||
doc.text(dateStrFooter, footerX, finalY);
|
||
doc.text("Kepala SMA Negeri 1 Abiansemal,", footerX, finalY + 5);
|
||
|
||
doc.text(`(${settings.headmasterName || '.........................'})`, footerX, finalY + 25);
|
||
doc.text(`NIP. ${settings.headmasterNip || '.........................'}`, footerX, finalY + 30);
|
||
|
||
doc.save(`Jurnal_${activeTab}_${selectedFilter}.pdf`);
|
||
|
||
} catch (error) {
|
||
console.error("PDF Generation Error:", error);
|
||
alert("Gagal membuat PDF. Pastikan gambar dapat diakses (tidak diblokir CORS).");
|
||
} finally {
|
||
setIsGeneratingPdf(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6 animate-fade-in pb-20">
|
||
<div className="bg-white rounded-2xl shadow-lg p-8 border border-slate-100">
|
||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-8">
|
||
<div className="flex items-center gap-4">
|
||
<div className="p-3 bg-school-900 text-white rounded-xl">
|
||
<Printer size={24} />
|
||
</div>
|
||
<div>
|
||
<h2 className="font-heading text-2xl font-bold text-school-900">Rekapitulasi Jurnal</h2>
|
||
<p className="text-slate-500">Filter, Cetak, dan Export data jurnal mengajar</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Admin View Mode Toggle */}
|
||
{currentUser.role === 'ADMIN' && (
|
||
<div className="bg-slate-100 p-1 rounded-xl flex flex-wrap">
|
||
<button
|
||
onClick={() => setAdminViewMode('journal')}
|
||
className={`py-2.5 px-5 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${adminViewMode === 'journal' ? 'bg-white text-school-900 shadow-sm' : 'text-slate-500 hover:text-school-700'}`}
|
||
>
|
||
<FileText size={16} />
|
||
Data Jurnal
|
||
</button>
|
||
<button
|
||
onClick={() => setAdminViewMode('statistics')}
|
||
className={`py-2.5 px-5 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${adminViewMode === 'statistics' ? 'bg-white text-school-900 shadow-sm' : 'text-slate-500 hover:text-school-700'}`}
|
||
>
|
||
<BarChart3 size={16} />
|
||
Statistik Guru
|
||
</button>
|
||
<button
|
||
onClick={() => setAdminViewMode('attendance')}
|
||
className={`py-2.5 px-5 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${adminViewMode === 'attendance' ? 'bg-white text-school-900 shadow-sm' : 'text-slate-500 hover:text-school-700'}`}
|
||
>
|
||
<Users size={16} />
|
||
Kehadiran Guru
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Filter Controls */}
|
||
{currentUser.role === 'ADMIN' && adminViewMode === 'journal' ? (
|
||
/* ========== ADMIN ENHANCED FILTERS ========== */
|
||
<div className="space-y-6 mb-8">
|
||
{/* Top Row: Stats Cards */}
|
||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||
<div className="bg-gradient-to-br from-emerald-50 to-emerald-100 border border-emerald-200 rounded-xl p-4 flex items-center gap-4">
|
||
<div className="bg-emerald-500 text-white p-3 rounded-lg">
|
||
<CheckCircle2 size={24} />
|
||
</div>
|
||
<div>
|
||
<p className="text-xs font-medium text-emerald-600 uppercase tracking-wide">Jurnal Guru Hari Ini</p>
|
||
<p className="text-2xl font-bold text-emerald-800">{todayStats.guruToday}</p>
|
||
</div>
|
||
</div>
|
||
<div className="bg-gradient-to-br from-blue-50 to-blue-100 border border-blue-200 rounded-xl p-4 flex items-center gap-4">
|
||
<div className="bg-blue-500 text-white p-3 rounded-lg">
|
||
<FileText size={24} />
|
||
</div>
|
||
<div>
|
||
<p className="text-xs font-medium text-blue-600 uppercase tracking-wide">Jurnal Kelas Hari Ini</p>
|
||
<p className="text-2xl font-bold text-blue-800">{todayStats.kelasToday}</p>
|
||
</div>
|
||
</div>
|
||
<div className="bg-gradient-to-br from-purple-50 to-purple-100 border border-purple-200 rounded-xl p-4 flex items-center gap-4">
|
||
<div className="bg-purple-500 text-white p-3 rounded-lg">
|
||
<Calendar size={24} />
|
||
</div>
|
||
<div>
|
||
<p className="text-xs font-medium text-purple-600 uppercase tracking-wide">Total Hari Ini</p>
|
||
<p className="text-2xl font-bold text-purple-800">{todayStats.total}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tab Switch */}
|
||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||
<div className="bg-slate-100 p-1 rounded-xl flex">
|
||
<button
|
||
onClick={() => { setActiveTab('GURU'); setSelectedFilter('ALL'); }}
|
||
className={`py-2.5 px-6 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${activeTab === 'GURU' ? 'bg-white text-school-900 shadow-sm' : 'text-slate-500 hover:text-school-700'
|
||
}`}
|
||
>
|
||
<User size={16} />
|
||
Jurnal Guru
|
||
</button>
|
||
<button
|
||
onClick={() => { setActiveTab('KELAS'); setSelectedFilter('ALL'); }}
|
||
className={`py-2.5 px-6 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${activeTab === 'KELAS' ? 'bg-white text-school-900 shadow-sm' : 'text-slate-500 hover:text-school-700'
|
||
}`}
|
||
>
|
||
<FileText size={16} />
|
||
Jurnal Kelas
|
||
</button>
|
||
</div>
|
||
|
||
{/* Quick Date Presets */}
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="text-sm text-slate-500 font-medium">Filter Cepat:</span>
|
||
<button
|
||
onClick={() => setDatePreset('today')}
|
||
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all border ${dateFrom === getTodayDate() && dateTo === getTodayDate() ? 'bg-school-900 text-white border-school-900' : 'bg-white text-slate-600 border-slate-200 hover:border-school-800'}`}
|
||
>
|
||
Hari Ini
|
||
</button>
|
||
<button
|
||
onClick={() => setDatePreset('week')}
|
||
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all border bg-white text-slate-600 border-slate-200 hover:border-school-800`}
|
||
>
|
||
7 Hari
|
||
</button>
|
||
<button
|
||
onClick={() => setDatePreset('month')}
|
||
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all border bg-white text-slate-600 border-slate-200 hover:border-school-800`}
|
||
>
|
||
30 Hari
|
||
</button>
|
||
<button
|
||
onClick={() => setDatePreset('all')}
|
||
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all border ${!dateFrom && !dateTo ? 'bg-school-900 text-white border-school-900' : 'bg-white text-slate-600 border-slate-200 hover:border-school-800'}`}
|
||
>
|
||
Semua
|
||
</button>
|
||
<button
|
||
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
|
||
className="px-3 py-1.5 rounded-lg text-xs font-bold transition-all border bg-slate-100 text-slate-700 border-slate-200 hover:bg-slate-200 flex items-center gap-1"
|
||
>
|
||
<Filter size={12} />
|
||
{showAdvancedFilters ? 'Sembunyikan' : 'Filter Lanjutan'}
|
||
<ChevronDown size={12} className={`transition-transform ${showAdvancedFilters ? 'rotate-180' : ''}`} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Advanced Filters Panel */}
|
||
{showAdvancedFilters && (
|
||
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4 space-y-4 animate-fade-in">
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||
{/* Date From */}
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-600 mb-1.5">Dari Tanggal</label>
|
||
<div className="relative">
|
||
<Calendar className="absolute left-3 top-2.5 text-slate-400" size={16} />
|
||
<input
|
||
type="date"
|
||
value={dateFrom}
|
||
onChange={(e) => setDateFrom(e.target.value)}
|
||
className="w-full pl-10 pr-3 py-2 rounded-lg border border-slate-200 focus:border-school-800 focus:ring-2 focus:ring-school-100 outline-none text-sm"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Date To */}
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-600 mb-1.5">Sampai Tanggal</label>
|
||
<div className="relative">
|
||
<Calendar className="absolute left-3 top-2.5 text-slate-400" size={16} />
|
||
<input
|
||
type="date"
|
||
value={dateTo}
|
||
onChange={(e) => setDateTo(e.target.value)}
|
||
className="w-full pl-10 pr-3 py-2 rounded-lg border border-slate-200 focus:border-school-800 focus:ring-2 focus:ring-school-100 outline-none text-sm"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Teacher/Class Selection */}
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-600 mb-1.5">
|
||
{activeTab === 'GURU' ? 'Guru' : 'Kelas'}
|
||
</label>
|
||
<select
|
||
value={selectedFilter}
|
||
onChange={(e) => setSelectedFilter(e.target.value)}
|
||
className="w-full px-3 py-2 rounded-lg border border-slate-200 focus:border-school-800 focus:ring-2 focus:ring-school-100 outline-none text-sm appearance-none bg-white"
|
||
>
|
||
<option value="ALL">-- Semua {activeTab === 'GURU' ? 'Guru' : 'Kelas'} --</option>
|
||
{activeTab === 'GURU'
|
||
? teachers.map((t, i) => <option key={i} value={t.name}>{t.name}</option>)
|
||
: classes.map((c, i) => <option key={i} value={c}>{c}</option>)
|
||
}
|
||
</select>
|
||
</div>
|
||
|
||
{/* Search */}
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-600 mb-1.5">Cari</label>
|
||
<div className="relative">
|
||
<Search className="absolute left-3 top-2.5 text-slate-400" size={16} />
|
||
<input
|
||
type="text"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
placeholder="Cari materi, guru, kelas..."
|
||
className="w-full pl-10 pr-3 py-2 rounded-lg border border-slate-200 focus:border-school-800 focus:ring-2 focus:ring-school-100 outline-none text-sm"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Reset Filters Button */}
|
||
<div className="flex justify-end">
|
||
<button
|
||
onClick={() => {
|
||
setDateFrom(getTodayDate());
|
||
setDateTo(getTodayDate());
|
||
setSelectedFilter('ALL');
|
||
setSearchQuery('');
|
||
}}
|
||
className="px-4 py-2 text-sm font-medium text-slate-600 hover:text-school-900 flex items-center gap-2 transition"
|
||
>
|
||
<RefreshCw size={14} />
|
||
Reset Filter
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : currentUser.role === 'ADMIN' && adminViewMode === 'statistics' ? (
|
||
/* ========== ADMIN STATISTICS VIEW ========== */
|
||
<div className="space-y-6 mb-8">
|
||
{/* Statistics Header */}
|
||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-3 bg-gradient-to-br from-violet-500 to-purple-600 text-white rounded-xl">
|
||
<TrendingUp size={24} />
|
||
</div>
|
||
<div>
|
||
<h3 className="font-bold text-xl text-school-900">Statistik Kehadiran Guru</h3>
|
||
<p className="text-sm text-slate-500">Rekap guru yang mengisi jurnal {statsPeriod === 'week' ? 'mingguan' : 'bulanan'}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
{/* Period Toggle */}
|
||
<div className="bg-slate-100 p-1 rounded-lg flex">
|
||
<button
|
||
onClick={() => setStatsPeriod('week')}
|
||
className={`py-2 px-4 rounded-md text-sm font-bold transition-all ${statsPeriod === 'week' ? 'bg-white text-school-900 shadow-sm' : 'text-slate-500 hover:text-school-700'}`}
|
||
>
|
||
Mingguan
|
||
</button>
|
||
<button
|
||
onClick={() => setStatsPeriod('month')}
|
||
className={`py-2 px-4 rounded-md text-sm font-bold transition-all ${statsPeriod === 'month' ? 'bg-white text-school-900 shadow-sm' : 'text-slate-500 hover:text-school-700'}`}
|
||
>
|
||
Bulanan
|
||
</button>
|
||
</div>
|
||
|
||
{/* Print Button */}
|
||
<button
|
||
onClick={handlePrintStatistics}
|
||
className="bg-school-900 hover:bg-school-800 text-white px-4 py-2 rounded-lg font-bold text-sm transition shadow-lg flex items-center gap-2"
|
||
>
|
||
<Printer size={16} />
|
||
Cetak Statistik
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Statistics Cards Grid */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||
{statsPeriod === 'week' ? (
|
||
/* Weekly Statistics */
|
||
weeklyStats.map((week, weekIdx) => (
|
||
<div key={weekIdx} className={`bg-white border rounded-xl overflow-hidden shadow-sm ${weekIdx === 0 ? 'border-emerald-300 ring-2 ring-emerald-100' : 'border-slate-200'}`}>
|
||
<div className={`px-4 py-3 ${weekIdx === 0 ? 'bg-gradient-to-r from-emerald-500 to-green-500 text-white' : 'bg-slate-100'}`}>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h4 className={`font-bold ${weekIdx === 0 ? 'text-white' : 'text-slate-700'}`}>{week.weekLabel}</h4>
|
||
<p className={`text-xs ${weekIdx === 0 ? 'text-emerald-100' : 'text-slate-500'}`}>
|
||
{week.startDate.toLocaleDateString('id-ID', { day: 'numeric', month: 'short' })} - {week.endDate.toLocaleDateString('id-ID', { day: 'numeric', month: 'short', year: 'numeric' })}
|
||
</p>
|
||
</div>
|
||
<div className={`text-right ${weekIdx === 0 ? 'text-white' : 'text-slate-700'}`}>
|
||
<p className="text-2xl font-bold">{week.teachers.length}</p>
|
||
<p className="text-xs">Guru Aktif</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="p-4 max-h-64 overflow-y-auto">
|
||
{week.teachers.length > 0 ? (
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="text-left text-xs text-slate-500 uppercase border-b">
|
||
<th className="pb-2">Nama Guru</th>
|
||
<th className="pb-2 text-center">Jurnal</th>
|
||
<th className="pb-2 text-center">Hari</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-100">
|
||
{week.teachers.map((teacher, tIdx) => (
|
||
<tr key={tIdx} className="hover:bg-slate-50">
|
||
<td className="py-2 font-medium text-slate-700">{teacher.name}</td>
|
||
<td className="py-2 text-center">
|
||
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-emerald-100 text-emerald-700 font-bold text-sm">
|
||
{teacher.count}
|
||
</span>
|
||
</td>
|
||
<td className="py-2 text-center text-slate-500">{teacher.dates.length}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
) : (
|
||
<div className="text-center py-8 text-slate-400">
|
||
<FileText size={32} className="mx-auto mb-2 opacity-50" />
|
||
<p className="text-sm">Belum ada jurnal</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))
|
||
) : (
|
||
/* Monthly Statistics */
|
||
monthlyStats.map((month, monthIdx) => (
|
||
<div key={monthIdx} className={`bg-white border rounded-xl overflow-hidden shadow-sm ${monthIdx === 0 ? 'border-blue-300 ring-2 ring-blue-100' : 'border-slate-200'}`}>
|
||
<div className={`px-4 py-3 ${monthIdx === 0 ? 'bg-gradient-to-r from-blue-500 to-indigo-500 text-white' : 'bg-slate-100'}`}>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h4 className={`font-bold ${monthIdx === 0 ? 'text-white' : 'text-slate-700'}`}>{month.monthLabel}</h4>
|
||
<p className={`text-xs ${monthIdx === 0 ? 'text-blue-100' : 'text-slate-500'}`}>
|
||
{new Date(month.year, month.month).toLocaleString('id-ID', { month: 'long', year: 'numeric' })}
|
||
</p>
|
||
</div>
|
||
<div className={`text-right ${monthIdx === 0 ? 'text-white' : 'text-slate-700'}`}>
|
||
<p className="text-2xl font-bold">{month.teachers.length}</p>
|
||
<p className="text-xs">Guru Aktif</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="p-4 max-h-64 overflow-y-auto">
|
||
{month.teachers.length > 0 ? (
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="text-left text-xs text-slate-500 uppercase border-b">
|
||
<th className="pb-2">Nama Guru</th>
|
||
<th className="pb-2 text-center">Jurnal</th>
|
||
<th className="pb-2 text-center">Hari Aktif</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-100">
|
||
{month.teachers.map((teacher, tIdx) => (
|
||
<tr key={tIdx} className="hover:bg-slate-50">
|
||
<td className="py-2 font-medium text-slate-700">{teacher.name}</td>
|
||
<td className="py-2 text-center">
|
||
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 text-blue-700 font-bold text-sm">
|
||
{teacher.count}
|
||
</span>
|
||
</td>
|
||
<td className="py-2 text-center text-slate-500">{teacher.uniqueDays}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
) : (
|
||
<div className="text-center py-8 text-slate-400">
|
||
<FileText size={32} className="mx-auto mb-2 opacity-50" />
|
||
<p className="text-sm">Belum ada jurnal</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
{/* Teachers Without Journals Warning */}
|
||
{teachersWithoutJournals.length > 0 && (
|
||
<div className="bg-gradient-to-br from-red-50 to-orange-50 border border-red-200 rounded-xl p-5">
|
||
<div className="flex items-start gap-3">
|
||
<div className="p-2 bg-red-100 rounded-lg">
|
||
<AlertTriangle size={20} className="text-red-600" />
|
||
</div>
|
||
<div className="flex-1">
|
||
<h4 className="font-bold text-red-800 mb-1">
|
||
⚠️ Guru Belum Mengisi Jurnal ({statsPeriod === 'week' ? 'Minggu Ini' : 'Bulan Ini'})
|
||
</h4>
|
||
<p className="text-sm text-red-600 mb-3">
|
||
{teachersWithoutJournals.length} dari {teachers.length} guru belum mengisi jurnal mengajar
|
||
</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
{teachersWithoutJournals.slice(0, 10).map((teacher, idx) => (
|
||
<span key={idx} className="inline-flex items-center gap-1 px-3 py-1 bg-white border border-red-200 rounded-full text-xs font-medium text-red-700">
|
||
<User size={12} />
|
||
{teacher.name}
|
||
</span>
|
||
))}
|
||
{teachersWithoutJournals.length > 10 && (
|
||
<span className="inline-flex items-center px-3 py-1 bg-red-100 rounded-full text-xs font-bold text-red-700">
|
||
+{teachersWithoutJournals.length - 10} lainnya
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Summary Stats */}
|
||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||
<div className="bg-slate-50 rounded-xl p-4 text-center">
|
||
<p className="text-3xl font-bold text-school-900">{teachers.length}</p>
|
||
<p className="text-xs text-slate-500 uppercase tracking-wide">Total Guru</p>
|
||
</div>
|
||
<div className="bg-emerald-50 rounded-xl p-4 text-center">
|
||
<p className="text-3xl font-bold text-emerald-700">
|
||
{statsPeriod === 'week' ? weeklyStats[0]?.teachers.length || 0 : monthlyStats[0]?.teachers.length || 0}
|
||
</p>
|
||
<p className="text-xs text-emerald-600 uppercase tracking-wide">Guru Aktif</p>
|
||
</div>
|
||
<div className="bg-red-50 rounded-xl p-4 text-center">
|
||
<p className="text-3xl font-bold text-red-700">{teachersWithoutJournals.length}</p>
|
||
<p className="text-xs text-red-600 uppercase tracking-wide">Belum Mengisi</p>
|
||
</div>
|
||
<div className="bg-blue-50 rounded-xl p-4 text-center">
|
||
<p className="text-3xl font-bold text-blue-700">
|
||
{statsPeriod === 'week'
|
||
? weeklyStats[0]?.teachers.reduce((sum, t) => sum + t.count, 0) || 0
|
||
: monthlyStats[0]?.teachers.reduce((sum, t) => sum + t.count, 0) || 0
|
||
}
|
||
</p>
|
||
<p className="text-xs text-blue-600 uppercase tracking-wide">Total Jurnal</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : currentUser.role === 'ADMIN' && adminViewMode === 'attendance' ? (
|
||
/* ========== ADMIN TEACHER ATTENDANCE VIEW ========== */
|
||
<div className="space-y-6 mb-8">
|
||
{/* Attendance Header */}
|
||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-3 bg-gradient-to-br from-teal-500 to-cyan-600 text-white rounded-xl">
|
||
<Users size={24} />
|
||
</div>
|
||
<div>
|
||
<h3 className="font-bold text-xl text-school-900">Laporan Kehadiran Guru</h3>
|
||
<p className="text-sm text-slate-500 font-medium text-emerald-600">Berdasarkan Jumlah Baris Data Jurnal Kelas</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Print Button */}
|
||
<button
|
||
onClick={() => {
|
||
const printWindow = window.open('', '', 'height=600,width=1024');
|
||
if (!printWindow) return;
|
||
|
||
const kopUrl = settings.kopUrl ? formatGoogleDriveImageUrl(settings.kopUrl) : '';
|
||
const headerContent = kopUrl
|
||
? `<img src="${kopUrl}" style="width: 100%; max-height: 5cm; object-fit: contain; margin-bottom: 10px; display: block; margin-left: auto; margin-right: auto;" />`
|
||
: `<div style="text-align: center; font-weight: bold; margin-bottom: 20px; border-bottom: 3px double black; padding-bottom: 10px;">
|
||
<h3 style="margin:0">PEMERINTAH PROVINSI BALI</h3>
|
||
<h2 style="margin:5px 0">SMA NEGERI 1 ABIANSEMAL</h2>
|
||
<p style="margin:0; font-weight: normal; font-size: 12px;">Alamat: Jalan Raya Abiansemal, Badung, Bali</p>
|
||
</div>`;
|
||
|
||
const today = new Date();
|
||
const dateStr = `Badung, ${today.getDate()} ${today.toLocaleString('id-ID', { month: 'long' })} ${today.getFullYear()}`;
|
||
|
||
const tableRows = teacherAttendanceStats.teachers.map((t, idx) => `
|
||
<tr>
|
||
<td style="text-align: center">${idx + 1}</td>
|
||
<td>${t.name}</td>
|
||
<td style="text-align: center; background: #d1fae5; color: #065f46; font-weight: bold">${t.hadir}</td>
|
||
<td style="text-align: center; background: #dbeafe; color: #1e40af; font-weight: bold">${t.tugas}</td>
|
||
<td style="text-align: center; background: #fee2e2; color: #991b1b; font-weight: bold">${t.tidakHadir}</td>
|
||
<td style="text-align: center; font-weight: bold">${t.total}</td>
|
||
<td style="text-align: center">${t.hadirPercentage}%</td>
|
||
</tr>
|
||
`).join('');
|
||
|
||
printWindow.document.write(`
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<title>Laporan Kehadiran Guru - ${teacherAttendanceStats.periodLabel}</title>
|
||
<style>
|
||
body { font-family: Arial, sans-serif; font-size: 12px; color: #000; -webkit-print-color-adjust: exact; }
|
||
table.data { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
|
||
table.data th, table.data td { border: 1px solid #000; padding: 8px; vertical-align: middle; }
|
||
table.data th { background-color: #f0f0f0; font-weight: bold; text-align: center; }
|
||
.header-title { text-align: center; margin-bottom: 25px; font-weight: bold; font-size: 14px; margin-top: 15px; }
|
||
.footer { margin-top: 30px; float: right; width: 250px; text-align: left; }
|
||
.summary-box { display: flex; justify-content: space-around; margin-bottom: 20px; padding: 10px; background: #f8f8f8; border-radius: 8px; }
|
||
.summary-item { text-align: center; }
|
||
.summary-value { font-size: 20px; font-weight: bold; }
|
||
@media print { @page { size: A4 portrait; margin: 20mm; } body { padding: 0; margin: 0; } }
|
||
@media screen { body { padding: 20mm; max-width: 210mm; margin: auto; box-shadow: 0 0 10px rgba(0,0,0,0.1); } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
${headerContent}
|
||
<div class="header-title">
|
||
LAPORAN KEHADIRAN GURU<br>
|
||
BERDASARKAN JURNAL KELAS<br>
|
||
PERIODE: ${teacherAttendanceStats.periodLabel.toUpperCase()}<br>
|
||
SEMESTER ${settings.semester.toUpperCase()} - TAHUN PELAJARAN ${settings.academicYear}
|
||
</div>
|
||
|
||
<div class="summary-box">
|
||
<div class="summary-item">
|
||
<div class="summary-value" style="color: #059669">${teacherAttendanceStats.summary.totalHadir}</div>
|
||
<div>Hadir</div>
|
||
</div>
|
||
<div class="summary-item">
|
||
<div class="summary-value" style="color: #2563eb">${teacherAttendanceStats.summary.totalTugas}</div>
|
||
<div>Tugas</div>
|
||
</div>
|
||
<div class="summary-item">
|
||
<div class="summary-value" style="color: #dc2626">${teacherAttendanceStats.summary.totalTidakHadir}</div>
|
||
<div>Tidak Hadir</div>
|
||
</div>
|
||
<div class="summary-item">
|
||
<div class="summary-value">${teacherAttendanceStats.summary.totalEntries}</div>
|
||
<div>Total Entri</div>
|
||
</div>
|
||
</div>
|
||
|
||
<table class="data">
|
||
<thead>
|
||
<tr>
|
||
<th style="width: 5%">No.</th>
|
||
<th style="width: 35%">Nama Guru</th>
|
||
<th style="width: 12%">Hadir</th>
|
||
<th style="width: 12%">Tugas</th>
|
||
<th style="width: 12%">Tidak Hadir</th>
|
||
<th style="width: 12%">Total</th>
|
||
<th style="width: 12%">% Hadir</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${tableRows || '<tr><td colspan="7" style="text-align: center; color: #888">Tidak ada data</td></tr>'}
|
||
</tbody>
|
||
</table>
|
||
|
||
<p style="font-size: 11px; color: #666">Catatan: Data kehadiran berdasarkan pencatatan Jurnal Kelas oleh Sekretaris. Total ${teacherAttendanceStats.summary.totalTeachers} guru tercatat.</p>
|
||
|
||
<div class="footer">
|
||
<p>${dateStr}</p>
|
||
<p>Kepala SMA Negeri 1 Abiansemal,</p>
|
||
<br><br><br>
|
||
<p style="font-weight: bold; text-decoration: underline;">${settings.headmasterName || '(.........................)'}</p>
|
||
<p>NIP. ${settings.headmasterNip || '-'}</p>
|
||
</div>
|
||
|
||
<script>window.onload = function() { setTimeout(function() { window.print(); }, 1000); }</script>
|
||
</body>
|
||
</html>
|
||
`);
|
||
printWindow.document.close();
|
||
}}
|
||
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-teal-500 to-cyan-600 text-white font-bold rounded-xl hover:from-teal-600 hover:to-cyan-700 transition-all shadow-lg shadow-teal-500/25"
|
||
>
|
||
<Printer size={18} />
|
||
Cetak Laporan
|
||
</button>
|
||
</div>
|
||
|
||
{/* Period Filter */}
|
||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
|
||
<div className="bg-slate-100 p-1 rounded-xl flex">
|
||
<button
|
||
onClick={() => setAttendancePeriod('daily')}
|
||
className={`py-2 px-4 rounded-lg text-sm font-bold transition-all ${attendancePeriod === 'daily' ? 'bg-white text-school-900 shadow-sm' : 'text-slate-500 hover:text-school-700'}`}
|
||
>
|
||
Harian
|
||
</button>
|
||
<button
|
||
onClick={() => setAttendancePeriod('weekly')}
|
||
className={`py-2 px-4 rounded-lg text-sm font-bold transition-all ${attendancePeriod === 'weekly' ? 'bg-white text-school-900 shadow-sm' : 'text-slate-500 hover:text-school-700'}`}
|
||
>
|
||
Mingguan
|
||
</button>
|
||
<button
|
||
onClick={() => setAttendancePeriod('monthly')}
|
||
className={`py-2 px-4 rounded-lg text-sm font-bold transition-all ${attendancePeriod === 'monthly' ? 'bg-white text-school-900 shadow-sm' : 'text-slate-500 hover:text-school-700'}`}
|
||
>
|
||
Bulanan
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<Calendar size={18} className="text-slate-400" />
|
||
<input
|
||
type="date"
|
||
value={attendanceDate}
|
||
onChange={(e) => setAttendanceDate(e.target.value)}
|
||
className="px-3 py-2 rounded-lg border border-slate-200 text-sm outline-none focus:border-teal-500 focus:ring-2 focus:ring-teal-100"
|
||
/>
|
||
<span className="text-sm text-slate-500 hidden sm:inline">
|
||
→ {teacherAttendanceStats.periodLabel}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Summary Cards */}
|
||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4">
|
||
<div className="bg-gradient-to-br from-emerald-50 to-green-100 border border-emerald-200 rounded-xl p-4 text-center">
|
||
<p className="text-3xl font-bold text-emerald-700">{teacherAttendanceStats.summary.totalHadir}</p>
|
||
<p className="text-xs text-emerald-600 uppercase tracking-wide font-medium">Hadir</p>
|
||
</div>
|
||
<div className="bg-gradient-to-br from-blue-50 to-indigo-100 border border-blue-200 rounded-xl p-4 text-center">
|
||
<p className="text-3xl font-bold text-blue-700">{teacherAttendanceStats.summary.totalTugas}</p>
|
||
<p className="text-xs text-blue-600 uppercase tracking-wide font-medium">Tugas</p>
|
||
</div>
|
||
<div className="bg-gradient-to-br from-red-50 to-rose-100 border border-red-200 rounded-xl p-4 text-center">
|
||
<p className="text-3xl font-bold text-red-700">{teacherAttendanceStats.summary.totalTidakHadir}</p>
|
||
<p className="text-xs text-red-600 uppercase tracking-wide font-medium">Tidak Hadir</p>
|
||
</div>
|
||
<div className="bg-gradient-to-br from-slate-50 to-gray-100 border border-slate-200 rounded-xl p-4 text-center">
|
||
<p className="text-3xl font-bold text-slate-700">{teacherAttendanceStats.summary.totalEntries}</p>
|
||
<p className="text-xs text-slate-600 uppercase tracking-wide font-medium">Total Entri</p>
|
||
</div>
|
||
<div className="bg-gradient-to-br from-violet-50 to-purple-100 border border-violet-200 rounded-xl p-4 text-center">
|
||
<p className="text-3xl font-bold text-violet-700">{teacherAttendanceStats.summary.totalTeachers}</p>
|
||
<p className="text-xs text-violet-600 uppercase tracking-wide font-medium">Guru Tercatat</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Teachers Attendance Table */}
|
||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
|
||
<div className="px-5 py-4 bg-gradient-to-r from-slate-50 to-slate-100 border-b border-slate-200">
|
||
<h4 className="font-bold text-slate-800">Detail Kehadiran per Guru</h4>
|
||
<p className="text-xs text-slate-500">Periode: {teacherAttendanceStats.periodLabel}</p>
|
||
</div>
|
||
|
||
{teacherAttendanceStats.teachers.length > 0 ? (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="text-left text-xs text-slate-600 uppercase border-b bg-slate-50">
|
||
<th className="px-4 py-3 text-center w-12">No</th>
|
||
<th className="px-4 py-3">Nama Guru</th>
|
||
<th className="px-4 py-3 text-center bg-emerald-50">Hadir</th>
|
||
<th className="px-4 py-3 text-center bg-blue-50">Tugas</th>
|
||
<th className="px-4 py-3 text-center bg-red-50">Tidak Hadir</th>
|
||
<th className="px-4 py-3 text-center">Total</th>
|
||
<th className="px-4 py-3 text-center">% Kehadiran</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-100">
|
||
{teacherAttendanceStats.teachers.map((teacher, idx) => (
|
||
<tr key={idx} className="hover:bg-slate-50 transition-colors">
|
||
<td className="px-4 py-3 text-center text-slate-500">{idx + 1}</td>
|
||
<td className="px-4 py-3 font-medium text-slate-800">{teacher.name}</td>
|
||
<td className="px-4 py-3 text-center bg-emerald-50/50">
|
||
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-emerald-100 text-emerald-700 font-bold text-sm">
|
||
{teacher.hadir}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-3 text-center bg-blue-50/50">
|
||
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 text-blue-700 font-bold text-sm">
|
||
{teacher.tugas}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-3 text-center bg-red-50/50">
|
||
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-red-100 text-red-700 font-bold text-sm">
|
||
{teacher.tidakHadir}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-3 text-center font-bold text-slate-700">{teacher.total}</td>
|
||
<td className="px-4 py-3 text-center">
|
||
<div className="flex items-center justify-center gap-2">
|
||
<div className="w-16 h-2 bg-slate-200 rounded-full overflow-hidden">
|
||
<div
|
||
className={`h-full rounded-full ${teacher.hadirPercentage >= 80 ? 'bg-emerald-500' : teacher.hadirPercentage >= 50 ? 'bg-yellow-500' : 'bg-red-500'}`}
|
||
style={{ width: `${teacher.hadirPercentage}%` }}
|
||
/>
|
||
</div>
|
||
<span className={`font-bold text-xs ${teacher.hadirPercentage >= 80 ? 'text-emerald-600' : teacher.hadirPercentage >= 50 ? 'text-yellow-600' : 'text-red-600'}`}>
|
||
{teacher.hadirPercentage}%
|
||
</span>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
) : (
|
||
<div className="text-center py-12 text-slate-400">
|
||
<Users size={48} className="mx-auto mb-3 opacity-50" />
|
||
<p className="text-lg font-medium">Tidak ada data kehadiran</p>
|
||
<p className="text-sm">Belum ada entri jurnal kelas untuk periode ini</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Info Note */}
|
||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 flex items-start gap-3">
|
||
<Info size={20} className="text-blue-500 flex-shrink-0 mt-0.5" />
|
||
<div className="text-sm text-blue-700">
|
||
<p className="font-medium">Tentang Laporan Kehadiran Guru</p>
|
||
<p className="text-blue-600">Data kehadiran guru diambil dari field "Kehadiran Guru" yang diisi oleh Sekretaris Kelas saat mengisi Jurnal Kelas. Status kehadiran terdiri dari: <strong>Hadir</strong>, <strong>Tugas</strong> (memberikan tugas tanpa hadir langsung), dan <strong>Tidak Hadir</strong>.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
/* ========== NON-ADMIN FILTERS (Enhanced with Week/Month/Date) ========== */
|
||
<div className="space-y-4 mb-8">
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200 flex items-center gap-2 font-bold text-school-900">
|
||
{activeTab === 'GURU' ? <Shield size={18} className="text-accent-green" /> : <Shield size={18} className="text-accent-yellow" />}
|
||
<span className="text-xs uppercase">{activeTab === 'GURU' ? 'LAPORAN GURU' : 'LAPORAN KELAS'}</span>
|
||
</div>
|
||
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200 flex items-center gap-3 text-slate-600">
|
||
<User size={18} className="text-school-900" />
|
||
<span className="text-xs font-bold truncate">
|
||
{selectedFilter || currentUser.name}
|
||
</span>
|
||
</div>
|
||
<div className="lg:col-span-2 flex items-center gap-2">
|
||
<div className="flex-1">
|
||
<input
|
||
type="date"
|
||
value={dateFrom}
|
||
onChange={(e) => setDateFrom(e.target.value)}
|
||
className="w-full px-3 py-2 rounded-lg border border-slate-200 text-sm outline-none focus:border-school-800"
|
||
/>
|
||
</div>
|
||
<span className="text-slate-400">s/d</span>
|
||
<div className="flex-1">
|
||
<input
|
||
type="date"
|
||
value={dateTo}
|
||
onChange={(e) => setDateTo(e.target.value)}
|
||
className="w-full px-3 py-2 rounded-lg border border-slate-200 text-sm outline-none focus:border-school-800"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<button
|
||
onClick={() => setDatePreset('today')}
|
||
className={`px-4 py-2 rounded-lg text-xs font-bold border transition ${dateFrom === getTodayDate() && dateTo === getTodayDate() ? 'bg-school-900 text-white border-school-900' : 'bg-white text-slate-600 border-slate-200 hover:border-school-800'}`}
|
||
>
|
||
Hari Ini
|
||
</button>
|
||
<button
|
||
onClick={() => setDatePreset('week')}
|
||
className={`px-4 py-2 rounded-lg text-xs font-bold border transition bg-white text-slate-600 border-slate-200 hover:border-school-800`}
|
||
>
|
||
Minggu Ini (7 Hari)
|
||
</button>
|
||
<button
|
||
onClick={() => setDatePreset('month')}
|
||
className={`px-4 py-2 rounded-lg text-xs font-bold border transition bg-white text-slate-600 border-slate-200 hover:border-school-800`}
|
||
>
|
||
Bulan Ini (30 Hari)
|
||
</button>
|
||
<button
|
||
onClick={() => setDatePreset('all')}
|
||
className={`px-4 py-2 rounded-lg text-xs font-bold border transition bg-white text-slate-600 border-slate-200 hover:border-school-800`}
|
||
>
|
||
Semua Waktu
|
||
</button>
|
||
<div className="flex-1 min-w-[200px] relative">
|
||
<Search className="absolute left-3 top-2.5 text-slate-400" size={16} />
|
||
<input
|
||
type="text"
|
||
placeholder="Cari materi..."
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
className="w-full pl-10 pr-3 py-2 rounded-lg border border-slate-200 text-sm outline-none focus:border-school-800"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Results & Action - Only show for Journal view */}
|
||
{((currentUser.role === 'ADMIN' && adminViewMode === 'journal') || (currentUser.role !== 'ADMIN' && selectedFilter)) && (
|
||
<div className="space-y-6">
|
||
<div className="flex flex-wrap items-center justify-between bg-blue-50 p-4 rounded-xl border border-blue-100 gap-4">
|
||
<div className="flex items-center gap-3">
|
||
<FileText className="text-school-900" />
|
||
<div>
|
||
<p className="text-sm font-bold text-school-900">
|
||
{filteredEntries.length} Data Ditemukan
|
||
</p>
|
||
<p className="text-xs text-blue-600">
|
||
Siap untuk dicetak atau diexport
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-3">
|
||
<button
|
||
onClick={handlePrint}
|
||
disabled={filteredEntries.length === 0}
|
||
className={`${selectedEntryIds.size > 0 ? 'bg-amber-50 border-amber-500 text-amber-700' : 'bg-white border-school-900 text-school-900'} border-2 hover:bg-school-50 px-4 py-2 rounded-lg font-bold text-sm transition shadow-sm flex items-center gap-2`}
|
||
>
|
||
<Printer size={16} /> {selectedEntryIds.size > 0 ? `Cetak Terpilih (${selectedEntryIds.size})` : 'Cetak Laporan'}
|
||
</button>
|
||
<button
|
||
onClick={generatePDF}
|
||
disabled={filteredEntries.length === 0 || isGeneratingPdf}
|
||
className={`${selectedEntryIds.size > 0 ? 'bg-emerald-600 hover:bg-emerald-700' : 'bg-school-900 hover:bg-school-800'} text-white px-4 py-2 rounded-lg font-bold text-sm transition shadow-lg flex items-center gap-2 ${isGeneratingPdf ? 'opacity-75 cursor-wait' : ''}`}
|
||
>
|
||
{isGeneratingPdf ? <Loader2 size={16} className="animate-spin" /> : <Download size={16} />}
|
||
{isGeneratingPdf ? 'Memproses...' : (selectedEntryIds.size > 0 ? `Export Terpilih (${selectedEntryIds.size})` : 'Export PDF')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Preview Table */}
|
||
<div className="overflow-x-auto border border-slate-200 rounded-xl">
|
||
<table className="w-full text-sm text-left">
|
||
<thead className="bg-slate-50 text-slate-600 font-bold uppercase text-xs">
|
||
<tr>
|
||
<th className="p-3 text-center">
|
||
<input
|
||
type="checkbox"
|
||
className="w-4 h-4 rounded text-school-900 focus:ring-school-800"
|
||
checked={filteredEntries.length > 0 && selectedEntryIds.size === filteredEntries.length}
|
||
onChange={toggleSelectAll}
|
||
/>
|
||
</th>
|
||
<th className="p-3 text-center">No</th>
|
||
<th className="p-3">Tanggal</th>
|
||
{activeTab === 'GURU' ? (
|
||
<>
|
||
{currentUser.role === 'ADMIN' && selectedFilter === 'ALL' && (
|
||
<th className="p-3">Guru</th>
|
||
)}
|
||
<th className="p-3 text-center">Kelas</th>
|
||
<th className="p-3 text-center">Jam Ke</th>
|
||
<th className="p-3">Materi</th>
|
||
<th className="p-3">Siswa Absen</th>
|
||
<th className="p-3 text-center">Foto Mengajar</th>
|
||
</>
|
||
) : (
|
||
<>
|
||
<th className="p-3">Guru</th>
|
||
{currentUser.role === 'ADMIN' && selectedFilter === 'ALL' && (
|
||
<th className="p-3 text-center">Kelas</th>
|
||
)}
|
||
<th className="p-3 text-center">Jam Ke</th>
|
||
<th className="p-3 text-center">Kehadiran Guru</th>
|
||
<th className="p-3">Siswa Absen</th>
|
||
<th className="p-3">Keterangan</th>
|
||
</>
|
||
)}
|
||
{(currentUser.role === 'ADMIN' || currentUser.role === 'SEKRETARIS') && (
|
||
<th className="p-3 text-center">Aksi</th>
|
||
)}
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-100">
|
||
{paginatedEntries.map((entry, idx) => (
|
||
<tr key={idx} className={`${selectedEntryIds.has(entry.id) ? 'bg-blue-50' : 'hover:bg-slate-50'} transition-colors`}>
|
||
<td className="p-3 text-center">
|
||
<input
|
||
type="checkbox"
|
||
className="w-4 h-4 rounded text-school-900 focus:ring-school-800"
|
||
checked={selectedEntryIds.has(entry.id)}
|
||
onChange={() => toggleSelectEntry(entry.id)}
|
||
/>
|
||
</td>
|
||
<td className="p-3 text-center">{(currentPage - 1) * itemsPerPage + idx + 1}</td>
|
||
<td className="p-3 whitespace-nowrap">{formatDate(entry.date)}</td>
|
||
|
||
{activeTab === 'GURU' ? (
|
||
<>
|
||
{currentUser.role === 'ADMIN' && selectedFilter === 'ALL' && (
|
||
<td className="p-3 font-bold text-slate-700">{entry.teacherName}</td>
|
||
)}
|
||
<td className="p-3 font-bold text-blue-700 text-center">{entry.className}</td>
|
||
<td className="p-3 text-center">{entry.startTime}-{entry.endTime}</td>
|
||
<td className="p-3">{entry.topic}</td>
|
||
<td className="p-3 text-xs text-red-600 font-medium">{getAbsentString(entry)}</td>
|
||
<td className="p-3 text-center">
|
||
{entry.photoUrl ? (
|
||
<div className="flex flex-col items-center gap-1">
|
||
<a href={formatGoogleDriveImageUrl(entry.photoUrl)} target="_blank" rel="noreferrer" className="relative block h-10 w-10 rounded overflow-hidden border border-slate-200 hover:border-blue-400 transition">
|
||
<img
|
||
src={formatGoogleDriveImageUrl(entry.photoUrl)}
|
||
alt="Bukti"
|
||
className="h-full w-full object-cover"
|
||
onError={(e) => (e.currentTarget.style.display = 'none')}
|
||
/>
|
||
</a>
|
||
</div>
|
||
) : (
|
||
<span className="text-xs text-slate-300">-</span>
|
||
)}
|
||
</td>
|
||
</>
|
||
) : (
|
||
<>
|
||
<td className="p-3 font-bold text-slate-700">{entry.teacherName}</td>
|
||
{currentUser.role === 'ADMIN' && selectedFilter === 'ALL' && (
|
||
<td className="p-3 font-bold text-blue-700 text-center">{entry.className}</td>
|
||
)}
|
||
<td className="p-3 text-center">{entry.startTime}-{entry.endTime}</td>
|
||
<td className="p-3 text-center font-bold">{entry.teacherPresence || '-'}</td>
|
||
<td className="p-3 text-xs text-red-600 font-medium">{getAbsentString(entry)}</td>
|
||
<td className="p-3">{entry.notes || '-'}</td>
|
||
</>
|
||
)}
|
||
{(currentUser.role === 'ADMIN' || currentUser.role === 'SEKRETARIS') && (
|
||
<td className="p-3 text-center">
|
||
<div className="flex items-center justify-center gap-2">
|
||
<button
|
||
onClick={() => setEditingEntry({ ...entry })}
|
||
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition"
|
||
title="Edit Jurnal"
|
||
>
|
||
<Edit2 size={16} />
|
||
</button>
|
||
<button
|
||
onClick={() => setDeletingEntry(entry)}
|
||
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition"
|
||
title="Hapus Jurnal"
|
||
>
|
||
<Trash2 size={16} />
|
||
</button>
|
||
</div>
|
||
</td>
|
||
)}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Pagination Controls - Always show for better UX */}
|
||
<div className="flex flex-col sm:flex-row items-center justify-between border-t border-slate-100 pt-6 gap-4">
|
||
<div className="flex items-center gap-4">
|
||
<p className="text-sm text-slate-500">
|
||
Menampilkan <span className="font-bold text-slate-700">{filteredEntries.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0}</span> - <span className="font-bold text-slate-700">{Math.min(currentPage * itemsPerPage, filteredEntries.length)}</span> dari <span className="font-bold text-slate-700">{filteredEntries.length}</span> data
|
||
</p>
|
||
{/* Items per page selector */}
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm text-slate-500">Tampilkan:</span>
|
||
<select
|
||
value={itemsPerPage}
|
||
onChange={(e) => {
|
||
setItemsPerPage(Number(e.target.value));
|
||
setCurrentPage(1); // Reset to first page when changing items per page
|
||
}}
|
||
className="px-3 py-1.5 border border-slate-200 rounded-lg text-sm font-bold text-slate-700 bg-white focus:border-school-800 focus:ring-2 focus:ring-school-100 outline-none cursor-pointer"
|
||
>
|
||
<option value={5}>5</option>
|
||
<option value={10}>10</option>
|
||
<option value={15}>15</option>
|
||
<option value={25}>25</option>
|
||
<option value={50}>50</option>
|
||
<option value={9999}>Semua</option>
|
||
</select>
|
||
<span className="text-sm text-slate-500">per halaman</span>
|
||
</div>
|
||
</div>
|
||
{totalPages > 1 && (
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||
disabled={currentPage === 1}
|
||
className="px-4 py-2 border border-slate-200 rounded-xl text-sm font-bold text-slate-600 hover:bg-slate-50 disabled:opacity-50 transition shadow-sm"
|
||
>
|
||
Sebelumnya
|
||
</button>
|
||
<div className="flex gap-1">
|
||
{[...Array(totalPages)].map((_, i) => {
|
||
// Show only limited page numbers if there are too many
|
||
if (totalPages > 5 && Math.abs(currentPage - (i + 1)) > 2) return null;
|
||
return (
|
||
<button
|
||
key={i}
|
||
onClick={() => setCurrentPage(i + 1)}
|
||
className={`w-10 h-10 rounded-xl text-sm font-bold transition ${currentPage === i + 1 ? 'bg-school-900 text-white shadow-md' : 'hover:bg-slate-100 text-slate-600'}`}
|
||
>
|
||
{i + 1}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<button
|
||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||
disabled={currentPage === totalPages}
|
||
className="px-4 py-2 border border-slate-200 rounded-xl text-sm font-bold text-slate-600 hover:bg-slate-50 disabled:opacity-50 transition shadow-sm"
|
||
>
|
||
Selanjutnya
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{/* Edit Modal */}
|
||
{editingEntry && (
|
||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" onClick={() => !isProcessing && setEditingEntry(null)}>
|
||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||
<div className="bg-school-900 text-white p-4 rounded-t-2xl flex items-center justify-between">
|
||
<h3 className="font-bold flex items-center gap-2"><Edit2 size={18} /> Edit Jurnal</h3>
|
||
<button onClick={() => !isProcessing && setEditingEntry(null)} className="p-1 hover:bg-white/20 rounded">
|
||
<X size={20} />
|
||
</button>
|
||
</div>
|
||
<div className="p-6 space-y-4">
|
||
{activeTab === 'GURU' ? (
|
||
<>
|
||
<div>
|
||
<label className="block text-sm font-semibold text-slate-600 mb-1">Materi</label>
|
||
<input
|
||
type="text"
|
||
value={editingEntry.topic || ''}
|
||
onChange={(e) => handleEditFieldChange('topic', e.target.value)}
|
||
className="w-full px-4 py-2 rounded-lg border border-slate-200 focus:border-school-800 outline-none"
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-semibold text-slate-600 mb-1">Jam Mulai</label>
|
||
<input
|
||
type="text"
|
||
value={editingEntry.startTime || ''}
|
||
onChange={(e) => handleEditFieldChange('startTime', e.target.value)}
|
||
className="w-full px-4 py-2 rounded-lg border border-slate-200 focus:border-school-800 outline-none"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-semibold text-slate-600 mb-1">Jam Selesai</label>
|
||
<input
|
||
type="text"
|
||
value={editingEntry.endTime || ''}
|
||
onChange={(e) => handleEditFieldChange('endTime', e.target.value)}
|
||
className="w-full px-4 py-2 rounded-lg border border-slate-200 focus:border-school-800 outline-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-semibold text-slate-600 mb-1">Keterangan</label>
|
||
<textarea
|
||
value={editingEntry.notes || ''}
|
||
onChange={(e) => handleEditFieldChange('notes', e.target.value)}
|
||
rows={2}
|
||
className="w-full px-4 py-2 rounded-lg border border-slate-200 focus:border-school-800 outline-none"
|
||
/>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div>
|
||
<label className="block text-sm font-semibold text-slate-600 mb-1">Kehadiran Guru</label>
|
||
<select
|
||
value={editingEntry.teacherPresence || 'Hadir'}
|
||
onChange={(e) => handleEditFieldChange('teacherPresence', e.target.value)}
|
||
className="w-full px-4 py-2 rounded-lg border border-slate-200 focus:border-school-800 outline-none"
|
||
>
|
||
<option value="Hadir">Hadir</option>
|
||
<option value="Tugas">Tugas</option>
|
||
<option value="Tidak Hadir">Tidak Hadir</option>
|
||
</select>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-semibold text-slate-600 mb-1">Jam Mulai</label>
|
||
<input
|
||
type="text"
|
||
value={editingEntry.startTime || ''}
|
||
onChange={(e) => handleEditFieldChange('startTime', e.target.value)}
|
||
className="w-full px-4 py-2 rounded-lg border border-slate-200 focus:border-school-800 outline-none"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-semibold text-slate-600 mb-1">Jam Selesai</label>
|
||
<input
|
||
type="text"
|
||
value={editingEntry.endTime || ''}
|
||
onChange={(e) => handleEditFieldChange('endTime', e.target.value)}
|
||
className="w-full px-4 py-2 rounded-lg border border-slate-200 focus:border-school-800 outline-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-semibold text-slate-600 mb-1">Keterangan</label>
|
||
<textarea
|
||
value={editingEntry.notes || ''}
|
||
onChange={(e) => handleEditFieldChange('notes', e.target.value)}
|
||
rows={2}
|
||
className="w-full px-4 py-2 rounded-lg border border-slate-200 focus:border-school-800 outline-none"
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
<div className="flex gap-3 pt-4 border-t">
|
||
<button
|
||
onClick={() => setEditingEntry(null)}
|
||
disabled={isProcessing}
|
||
className="flex-1 py-2 px-4 rounded-lg border border-slate-300 text-slate-600 font-bold hover:bg-slate-50 transition"
|
||
>
|
||
Batal
|
||
</button>
|
||
<button
|
||
onClick={handleEditSubmit}
|
||
disabled={isProcessing}
|
||
className="flex-1 py-2 px-4 rounded-lg bg-school-900 text-white font-bold hover:bg-school-800 transition flex items-center justify-center gap-2"
|
||
>
|
||
{isProcessing ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||
{isProcessing ? 'Menyimpan...' : 'Simpan'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Delete Confirmation Dialog */}
|
||
{deletingEntry && (
|
||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" onClick={() => !isProcessing && setDeletingEntry(null)}>
|
||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md" onClick={e => e.stopPropagation()}>
|
||
<div className="p-6 text-center">
|
||
<div className="mx-auto h-16 w-16 bg-red-100 rounded-full flex items-center justify-center mb-4">
|
||
<AlertTriangle size={32} className="text-red-600" />
|
||
</div>
|
||
<h3 className="text-xl font-bold text-slate-800 mb-2">Hapus Jurnal?</h3>
|
||
<p className="text-slate-500 mb-6">
|
||
Apakah Anda yakin ingin menghapus jurnal <span className="font-bold text-slate-700">{formatDate(deletingEntry.date)}</span>?
|
||
<br /><span className="text-red-500 text-sm">Tindakan ini tidak dapat dibatalkan.</span>
|
||
</p>
|
||
<div className="flex gap-3">
|
||
<button
|
||
onClick={() => setDeletingEntry(null)}
|
||
disabled={isProcessing}
|
||
className="flex-1 py-3 px-4 rounded-xl border border-slate-300 text-slate-600 font-bold hover:bg-slate-50 transition"
|
||
>
|
||
Batal
|
||
</button>
|
||
<button
|
||
onClick={handleDelete}
|
||
disabled={isProcessing}
|
||
className="flex-1 py-3 px-4 rounded-xl bg-red-600 text-white font-bold hover:bg-red-700 transition flex items-center justify-center gap-2"
|
||
>
|
||
{isProcessing ? <Loader2 size={16} className="animate-spin" /> : <Trash2 size={16} />}
|
||
{isProcessing ? 'Menghapus...' : 'Hapus'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default RecapView;
|