Files
smanab/App-Jurnal/components/App.tsx

610 lines
27 KiB
TypeScript
Executable File

import React, { useState, useEffect } from 'react';
import { LayoutDashboard, PlusCircle, Settings, Menu, Wifi, Printer, Save, Upload, Image as ImageIcon, ClipboardList, LogOut, UserCircle, Edit } from 'lucide-react';
import Dashboard from './Dashboard';
import JournalForm from './JournalForm';
import ClassJournalForm from './ClassJournalForm';
import RecapView from './RecapView';
import LoginView from './LoginView';
import { fetchData, calculateStats, getApiUrl, setApiUrl, saveSettings } from '../services/apiService';
import { ViewState, DashboardStats, AppData, SchoolSettings, AuthUser } from '../types';
import { DEFAULT_LOGO, DEFAULT_KOP } from '../assets/defaultImages';
// Utility to format Google Drive links for display
const formatGoogleDriveImageUrl = (url: string) => {
if (!url) return '';
if (url.startsWith('data:image')) return url;
try {
const idMatch = url.match(/(?:id=|\/d\/)([\w-]+)/);
if (idMatch && idMatch[1]) {
return `https://lh3.googleusercontent.com/d/${idMatch[1]}`;
}
return url;
} catch (e) {
return url;
}
};
const App: React.FC = () => {
const [view, setView] = useState<ViewState>(ViewState.DASHBOARD);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [data, setData] = useState<AppData>({ entries: [], classEntries: [], students: [], teachers: [], subjects: [], settings: { semester: '', academicYear: '', headmasterName: '', headmasterNip: '', logoUrl: '', kopUrl: '' } });
const [stats, setStats] = useState<DashboardStats>({ totalEntries: 0, totalStudents: 0, totalTeachers: 0, weeklyActivity: [], classPerformance: [] });
const [loading, setLoading] = useState(true);
const [sheetUrl, setSheetUrl] = useState(getApiUrl());
// Auth State
const [currentUser, setCurrentUser] = useState<AuthUser | null>(null);
// Settings Form State
const [formSettings, setFormSettings] = useState<SchoolSettings>({
semester: '', academicYear: '', headmasterName: '', headmasterNip: '', logoUrl: '', kopUrl: ''
});
const [isSavingSettings, setIsSavingSettings] = useState(false);
// Edit Mode States for Settings
const [editLogo, setEditLogo] = useState(false);
const [editKop, setEditKop] = useState(false);
// Initial Load
useEffect(() => {
loadData();
}, []);
// Sync form settings when data loads
useEffect(() => {
if (data.settings) {
setFormSettings({
...data.settings,
logoUrl: data.settings.logoUrl || DEFAULT_LOGO,
kopUrl: data.settings.kopUrl || DEFAULT_KOP
});
}
}, [data.settings]);
const loadData = async () => {
setLoading(true);
const fetchedData = await fetchData();
// Ensure defaults if empty
if (!fetchedData.settings.logoUrl) fetchedData.settings.logoUrl = DEFAULT_LOGO;
if (!fetchedData.settings.kopUrl) fetchedData.settings.kopUrl = DEFAULT_KOP;
setData(fetchedData);
setStats(calculateStats(fetchedData.entries, fetchedData.students, fetchedData.teachers));
setLoading(false);
};
const handleLogin = (user: AuthUser) => {
setCurrentUser(user);
// Set default view based on role
if (user.role === 'SEKRETARIS') {
setView(ViewState.CLASS_JOURNAL_FORM);
} else {
setView(ViewState.DASHBOARD);
}
};
const handleLogout = () => {
if (window.confirm('Apakah Anda yakin ingin keluar dari aplikasi?')) {
// Force reset state
setCurrentUser(null);
setIsSidebarOpen(false);
setView(ViewState.DASHBOARD);
// Hard reload to ensure clean state and return to login
window.location.reload();
}
};
const handleConnectApi = (e: React.FormEvent) => {
e.preventDefault();
setApiUrl(sheetUrl);
alert('URL API disimpan. Memuat ulang data...');
loadData();
};
const handleSaveSchoolSettings = async (e: React.FormEvent) => {
e.preventDefault();
setIsSavingSettings(true);
const success = await saveSettings(formSettings);
if (success) {
alert('Pengaturan berhasil disimpan! Perubahan akan terlihat setelah refresh data.');
setData(prev => ({ ...prev, settings: formSettings }));
// Reset edit modes
setEditLogo(false);
setEditKop(false);
} else {
alert('Gagal menyimpan pengaturan. Pastikan internet lancar dan Script Google Sheet mendukung penyimpanan.');
}
setIsSavingSettings(false);
};
const handleSettingChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setFormSettings({ ...formSettings, [e.target.name]: e.target.value });
};
// Helper function to compress image
const compressImage = (file: File, maxWidth: number): Promise<string> => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
const img = new Image();
img.src = event.target?.result as string;
img.onload = () => {
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
if (width > maxWidth) {
height = (height * maxWidth) / width;
width = maxWidth;
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx?.drawImage(img, 0, 0, width, height);
// Compress to JPEG with 0.7 quality to save space
const dataUrl = canvas.toDataURL('image/jpeg', 0.7);
resolve(dataUrl);
};
};
});
};
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>, field: 'logoUrl' | 'kopUrl') => {
const file = e.target.files?.[0];
if (file) {
// Validasi awal ukuran file (sebelum kompresi)
if (file.size > 2 * 1024 * 1024) { // 2MB limit for raw file
alert('File terlalu besar! Mohon pilih gambar di bawah 2MB.');
return;
}
try {
// Determine max width based on type
// Logo smaller (200px), Kop wider (800px)
const maxWidth = field === 'logoUrl' ? 200 : 800;
const compressedBase64 = await compressImage(file, maxWidth);
setFormSettings(prev => ({ ...prev, [field]: compressedBase64 }));
} catch (error) {
console.error("Error compressing image", error);
alert("Gagal memproses gambar.");
}
}
};
// Filter Menus based on Role
const getNavItems = () => {
if (!currentUser) return [];
const allItems = [
{ id: ViewState.DASHBOARD, icon: LayoutDashboard, label: "Dashboard", roles: ['ADMIN', 'GURU'] },
{ id: ViewState.JOURNAL_FORM, icon: PlusCircle, label: "Input Jurnal Guru", roles: ['ADMIN', 'GURU'] },
{ id: ViewState.CLASS_JOURNAL_FORM, icon: ClipboardList, label: "Input Jurnal Kelas", roles: ['ADMIN', 'SEKRETARIS'] },
{ id: ViewState.RECAP, icon: Printer, label: "Rekapitulasi Laporan", roles: ['ADMIN', 'GURU', 'SEKRETARIS'] },
{ id: ViewState.SETTINGS, icon: Settings, label: "Pengaturan", roles: ['ADMIN'] },
];
return allItems.filter(item => item.roles.includes(currentUser.role));
};
// Show Loading State
if (loading) {
return (
<div className="min-h-screen bg-slate-50 flex flex-col items-center justify-center text-slate-400 gap-4">
<div className="animate-spin h-12 w-12 border-4 border-school-900 border-t-accent-yellow rounded-full"></div>
<p className="font-medium text-school-900 animate-pulse">Menyiapkan Aplikasi...</p>
</div>
);
}
// Show Login View if not authenticated
if (!currentUser) {
return (
<LoginView
teachers={data.teachers}
students={data.students}
settings={data.settings}
onLogin={handleLogin}
/>
);
}
const navItems = getNavItems();
return (
<div className="min-h-screen bg-slate-50 flex font-sans text-slate-800">
{/* Mobile Overlay */}
{isSidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 md:hidden"
onClick={() => setIsSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={`fixed md:static inset-y-0 left-0 z-50 w-72 bg-school-900 text-white shadow-2xl transform transition-transform duration-300 ease-in-out flex flex-col ${isSidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'
}`}
>
<div className="p-6 flex flex-col items-center justify-center gap-4 border-b border-school-800 text-center">
<img
src={formatGoogleDriveImageUrl(data.settings.logoUrl || DEFAULT_LOGO)}
alt="Logo Sekolah"
className="h-24 w-24 object-contain bg-white rounded-2xl p-2 shadow-lg"
onError={(e) => e.currentTarget.style.display = 'none'}
/>
<div className="w-full">
<h1 className="font-heading font-bold text-lg tracking-tight leading-tight mb-1">Aplikasi Jurnal Kelas &<br />Mengajar</h1>
<p className="text-xs text-blue-200 font-medium">SMA Negeri 1 Abiansemal</p>
</div>
</div>
{/* User Profile Snippet */}
<div className="p-6 bg-school-800/50 flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-white/10 flex items-center justify-center">
<UserCircle className="text-accent-yellow" size={24} />
</div>
<div className="overflow-hidden">
<p className="text-sm font-bold text-white truncate">{currentUser.name}</p>
<p className="text-xs text-blue-200 uppercase font-semibold tracking-wider">{currentUser.role}</p>
{currentUser.className && <p className="text-[10px] text-accent-yellow">{currentUser.className}</p>}
</div>
</div>
<nav className="mt-2 space-y-1 flex-1 overflow-y-auto">
{navItems.map(item => {
const Icon = item.icon;
return (
<button
key={item.id}
type="button"
onClick={() => {
setView(item.id);
setIsSidebarOpen(false);
}}
className={`w-full flex items-center gap-3 px-6 py-4 transition-all ${view === item.id
? 'bg-school-800 text-accent-yellow border-r-4 border-accent-yellow'
: 'text-blue-100 hover:bg-school-800 hover:text-white'
}`}
>
<Icon size={20} />
<span className="font-medium">{item.label}</span>
</button>
)
})}
</nav>
<div className="p-4 border-t border-school-800">
<button
type="button"
onClick={handleLogout}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-red-300 hover:bg-red-600 hover:text-white transition-all font-bold group focus:outline-none focus:ring-2 focus:ring-red-500"
>
<LogOut size={20} className="group-hover:scale-110 transition-transform" />
<span>Logout</span>
</button>
</div>
<div className="p-6 bg-school-950">
<div className="bg-school-800 rounded-xl p-4 border border-school-700">
<p className="text-xs text-blue-200 mb-2 font-semibold uppercase tracking-wider">Koneksi Database</p>
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-green-400 animate-pulse"></div>
<span className="text-xs font-bold text-white flex items-center gap-1">
<Wifi size={12} /> Terhubung ke Database
</span>
</div>
</div>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 flex flex-col h-screen overflow-hidden">
{/* Topbar Mobile */}
<header className="bg-white shadow-sm p-4 flex items-center justify-between md:hidden z-30">
<div className="flex items-center gap-2">
<img
src={formatGoogleDriveImageUrl(data.settings.logoUrl || DEFAULT_LOGO)}
className="h-8 w-8 object-contain"
alt="Logo"
/>
<div className="flex flex-col">
<span className="font-bold text-school-900 text-sm leading-tight">Aplikasi Jurnal</span>
<span className="text-xs text-slate-500">{currentUser.role} Mode</span>
</div>
</div>
<button onClick={() => setIsSidebarOpen(true)} className="text-slate-600">
<Menu size={24} />
</button>
</header>
{/* Content Area */}
<div className="flex-1 overflow-auto p-4 md:p-8 scroll-smooth">
<>
{view === ViewState.DASHBOARD && currentUser.role !== 'SEKRETARIS' && (
<Dashboard stats={stats} recentEntries={data.entries} />
)}
{view === ViewState.JOURNAL_FORM && currentUser.role !== 'SEKRETARIS' && (
<JournalForm
onSuccess={() => {
loadData();
setView(ViewState.DASHBOARD);
}}
students={data.students}
teachers={data.teachers}
subjects={data.subjects}
currentUser={currentUser}
/>
)}
{view === ViewState.CLASS_JOURNAL_FORM && (
<ClassJournalForm
currentUser={currentUser}
onSuccess={() => {
loadData();
// If Secretary, stay or maybe show recap? Just reload data for now.
}}
students={data.students}
teachers={data.teachers}
subjects={data.subjects}
/>
)}
{view === ViewState.RECAP && (
<RecapView
currentUser={currentUser}
entries={data.entries}
classEntries={data.classEntries}
teachers={data.teachers}
settings={data.settings}
onRefresh={loadData}
/>
)}
{view === ViewState.SETTINGS && currentUser.role === 'ADMIN' && (
<div className="max-w-4xl mx-auto space-y-8 pb-20">
{/* School Configuration Card */}
<div className="bg-white rounded-2xl shadow-lg border border-slate-100 overflow-hidden">
<div className="bg-school-900 p-6 text-white flex items-center gap-3">
<Settings size={24} className="text-accent-yellow" />
<div>
<h2 className="font-heading text-xl font-bold">Pengaturan Data Sekolah</h2>
<p className="text-blue-200 text-sm">Upload Logo dan Kop Surat untuk Laporan PDF</p>
</div>
</div>
<form onSubmit={handleSaveSchoolSettings} className="p-6 md:p-8 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-semibold text-slate-600">Semester</label>
<select
name="semester"
value={formSettings.semester}
onChange={handleSettingChange}
className="w-full px-4 py-3 rounded-xl border border-slate-200 focus:border-school-800 outline-none bg-white"
>
<option value="Ganjil">Ganjil</option>
<option value="Genap">Genap</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold text-slate-600">Tahun Pelajaran</label>
<input
type="text"
name="academicYear"
value={formSettings.academicYear}
onChange={handleSettingChange}
placeholder="2024/2025"
className="w-full px-4 py-3 rounded-xl border border-slate-200 focus:border-school-800 outline-none"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-semibold text-slate-600">Nama Kepala Sekolah</label>
<input
type="text"
name="headmasterName"
value={formSettings.headmasterName}
onChange={handleSettingChange}
className="w-full px-4 py-3 rounded-xl border border-slate-200 focus:border-school-800 outline-none"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold text-slate-600">NIP Kepala Sekolah</label>
<input
type="text"
name="headmasterNip"
value={formSettings.headmasterNip}
onChange={handleSettingChange}
className="w-full px-4 py-3 rounded-xl border border-slate-200 focus:border-school-800 outline-none"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-4">
{/* Logo Upload Section */}
<div className="space-y-3 bg-slate-50 p-4 rounded-xl border border-slate-200">
<label className="text-sm font-bold text-slate-700 flex items-center gap-2">
<ImageIcon size={18} className="text-school-900" /> Logo Sekolah
</label>
<div className="flex flex-col items-center justify-center gap-4">
<div className="h-32 w-32 bg-white rounded-lg border-2 border-dashed border-slate-300 flex items-center justify-center overflow-hidden relative p-2">
{formSettings.logoUrl ? (
<img src={formatGoogleDriveImageUrl(formSettings.logoUrl)} alt="Logo Preview" className="w-full h-full object-contain" />
) : (
<span className="text-xs text-slate-400">No Logo</span>
)}
</div>
<div className="w-full flex flex-col items-center">
{!formSettings.logoUrl || editLogo ? (
<div className="w-full animate-fade-in">
<label htmlFor="logo-upload" className="cursor-pointer flex items-center justify-center gap-2 w-full py-2 bg-white border border-slate-300 rounded-lg text-sm font-bold text-slate-600 hover:bg-slate-50 transition shadow-sm">
<Upload size={14} /> Pilih Logo
</label>
<input
id="logo-upload"
type="file"
accept="image/*"
onChange={(e) => handleFileUpload(e, 'logoUrl')}
className="hidden"
/>
{/* Fallback Text Input */}
<input
type="text"
name="logoUrl"
value={formSettings.logoUrl}
onChange={handleSettingChange}
placeholder="Atau paste URL Google Drive..."
className="mt-2 w-full text-xs p-2 border border-slate-200 rounded focus:border-school-800 outline-none"
/>
{formSettings.logoUrl && (
<button
type="button"
onClick={() => setEditLogo(false)}
className="mt-2 text-xs text-red-500 hover:underline w-full text-center"
>
Batal Ubah
</button>
)}
</div>
) : (
<button
type="button"
onClick={() => setEditLogo(true)}
className="flex items-center justify-center gap-2 w-full py-2 bg-white border border-slate-300 rounded-lg text-sm font-bold text-school-900 hover:bg-slate-50 transition shadow-sm"
>
<Edit size={14} /> Ubah Logo
</button>
)}
</div>
</div>
</div>
{/* Kop Upload Section */}
<div className="space-y-3 bg-slate-50 p-4 rounded-xl border border-slate-200">
<label className="text-sm font-bold text-slate-700 flex items-center gap-2">
<ImageIcon size={18} className="text-school-900" /> Kop Surat (Header Laporan)
</label>
<div className="flex flex-col items-center justify-center gap-4">
<div className="h-32 w-full bg-white rounded-lg border-2 border-dashed border-slate-300 flex items-center justify-center overflow-hidden relative p-2">
{formSettings.kopUrl ? (
<img src={formatGoogleDriveImageUrl(formSettings.kopUrl)} alt="Kop Preview" className="w-full h-full object-contain" />
) : (
<span className="text-xs text-slate-400">No Header</span>
)}
</div>
<div className="w-full flex flex-col items-center">
{!formSettings.kopUrl || editKop ? (
<div className="w-full animate-fade-in">
<label htmlFor="kop-upload" className="cursor-pointer flex items-center justify-center gap-2 w-full py-2 bg-white border border-slate-300 rounded-lg text-sm font-bold text-slate-600 hover:bg-slate-50 transition shadow-sm">
<Upload size={14} /> Pilih Kop
</label>
<input
id="kop-upload"
type="file"
accept="image/*"
onChange={(e) => handleFileUpload(e, 'kopUrl')}
className="hidden"
/>
{/* Fallback Text Input */}
<input
type="text"
name="kopUrl"
value={formSettings.kopUrl}
onChange={handleSettingChange}
placeholder="Atau paste URL Google Drive..."
className="mt-2 w-full text-xs p-2 border border-slate-200 rounded focus:border-school-800 outline-none"
/>
{formSettings.kopUrl && (
<button
type="button"
onClick={() => setEditKop(false)}
className="mt-2 text-xs text-red-500 hover:underline w-full text-center"
>
Batal Ubah
</button>
)}
</div>
) : (
<button
type="button"
onClick={() => setEditKop(true)}
className="flex items-center justify-center gap-2 w-full py-2 bg-white border border-slate-300 rounded-lg text-sm font-bold text-school-900 hover:bg-slate-50 transition shadow-sm"
>
<Edit size={14} /> Ubah Kop
</button>
)}
</div>
</div>
</div>
</div>
<div className="pt-4 border-t border-slate-100">
<button
type="submit"
disabled={isSavingSettings}
className={`flex items-center justify-center gap-2 w-full md:w-auto px-8 py-3 rounded-xl font-bold text-school-900 bg-accent-yellow hover:bg-yellow-400 transition shadow-md ${isSavingSettings ? 'opacity-75 cursor-not-allowed' : ''}`}
>
<Save size={20} />
{isSavingSettings ? 'Menyimpan Perubahan...' : 'Simpan Pengaturan & Aset'}
</button>
</div>
</form>
</div>
{/* API Configuration Card */}
<div className="bg-white rounded-2xl shadow-lg border border-slate-100 overflow-hidden">
<div className="p-6 md:p-8">
<h3 className="font-heading font-bold text-lg text-slate-800 mb-4 flex items-center gap-2">
<Wifi size={20} className="text-school-800" /> Konfigurasi API Endpoint
</h3>
<div className="bg-blue-50 border-l-4 border-school-900 p-4 mb-6 text-sm text-blue-900">
<p className="font-bold mb-1">Google Apps Script URL</p>
<p>Pastikan script Anda mendukung parameter <code>?op=getData</code> dan aksi <code>simpanJurnal</code>, <code>simpanJurnalKelas</code>, & <code>simpanSettings</code>.</p>
</div>
<form onSubmit={handleConnectApi} className="flex flex-col md:flex-row gap-4">
<input
type="url"
className="flex-1 px-4 py-3 rounded-xl border border-slate-300 focus:border-school-900 outline-none"
placeholder="https://script.google.com/macros/s/..."
value={sheetUrl}
onChange={(e) => setSheetUrl(e.target.value)}
/>
<button
type="submit"
className="bg-school-900 text-white px-6 py-3 rounded-xl font-bold hover:bg-school-800 transition shadow-lg"
>
Hubungkan
</button>
</form>
</div>
</div>
</div>
)}
</>
</div>
</main>
</div>
);
};
export default App;