From 8d35ec81d800cae53c568e362622ee540539140f Mon Sep 17 00:00:00 2001 From: "[Valentino Heman Budiarto]" <[hemanvalentino@gmail.com]> Date: Sun, 17 May 2026 21:34:19 +0700 Subject: [PATCH] update 17 mei --- backend/cmd/main.go | 19 +- backend/controllers/hardwarecontroller.go | 120 ++++++++++++ backend/controllers/schedulecontroller.go | 27 +++ backend/models/entity.go | 15 ++ frontend/app/admin/layout.tsx | 40 +++- frontend/app/admin/schedules/page.tsx | 175 ++++++++++++++++++ .../app/dashboard/bookings/calendar/page.tsx | 98 ++++++---- 7 files changed, 448 insertions(+), 46 deletions(-) create mode 100644 backend/controllers/hardwarecontroller.go create mode 100644 backend/controllers/schedulecontroller.go create mode 100644 frontend/app/admin/schedules/page.tsx diff --git a/backend/cmd/main.go b/backend/cmd/main.go index ba8c3c4..90fca3b 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -14,8 +14,8 @@ func main() { // 1. Konek Database config.ConnectDatabase() - // 2. AutoMigrate (Membuat tabel jika belum ada) - config.DB.AutoMigrate(&models.User{}, &models.Room{}, &models.Booking{}) + // 2. AutoMigrate (Tambahkan ClassSchedule di sini agar dikenali GORM) + config.DB.AutoMigrate(&models.User{}, &models.Room{}, &models.Booking{}, &models.ClassSchedule{}) r := gin.Default() @@ -35,6 +35,7 @@ func main() { r.POST("/api/verify-code", controllers.VerifyRedeemCode) + // --- RUTE YANG DILINDUNGI TOKEN (UNTUK WEB) --- protected := r.Group("/api") protected.Use(middleware.AuthMiddleware()) { @@ -51,14 +52,20 @@ func main() { // Bookings protected.POST("/bookings", controllers.CreateBooking) protected.GET("/bookings", controllers.GetAllBookings) - protected.PUT("/bookings/:id/status", controllers.UpdateBookingStatus) // <-- Cukup tulis satu kali saja + protected.PUT("/bookings/:id/status", controllers.UpdateBookingStatus) // Admin (Manage Rooms) - protected.PUT("/admin/rooms/:id/status", controllers.UpdateRoomStatus) // <-- Pastikan baris ini ada + protected.PUT("/admin/rooms/:id/status", controllers.UpdateRoomStatus) + + // 🌟 RUTE BARU: Jadwal Kuliah (Untuk Halaman Web Admin) + protected.GET("/schedules", controllers.GetSchedules) } - // 5. Jalur IoT ESP32 (Di luar protected) + // 5. Jalur IoT ESP32 (Di luar protected karena ESP32 tidak pakai sistem Login Token) r.POST("/api/sensor/energy", controllers.UpdateRoomPower) + + // 🌟 RUTE BARU: Untuk menerima ketukan pintu dari Hardware ESP32 + r.POST("/api/hardware/verify", controllers.VerifyHardwareCode) r.Run(":8080") } @@ -76,4 +83,4 @@ func CORSMiddleware() gin.HandlerFunc { } c.Next() } -} +} \ No newline at end of file diff --git a/backend/controllers/hardwarecontroller.go b/backend/controllers/hardwarecontroller.go new file mode 100644 index 0000000..9941cf9 --- /dev/null +++ b/backend/controllers/hardwarecontroller.go @@ -0,0 +1,120 @@ +package controllers + +import ( + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "s-class-backend/config" + "s-class-backend/models" +) + +// Struct untuk menerima data dari ESP32 +type VerifyRequest struct { + RoomID int `json:"room_id"` + KodeMK string `json:"kode_mk"` +} + +// Helper untuk mengubah nama hari ke bahasa Indonesia +func getHariIndonesia(d time.Weekday) string { + days := []string{"Minggu", "Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu"} + return days[d] +} + +func VerifyHardwareCode(c *gin.Context) { + var req VerifyRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Format data tidak valid"}) + return + } + + // 1. Dapatkan waktu sekarang di zona waktu Surabaya (WIB / GMT+7) + loc, _ := time.LoadLocation("Asia/Jakarta") + now := time.Now().In(loc) + hariIni := getHariIndonesia(now.Weekday()) + jamSekarangStr := now.Format("15:04:05") + + // ========================================================================= + // SKENARIO A: Dosen masuk sesuai jadwal perkuliahan + // ========================================================================= + var schedule models.ClassSchedule + err := config.DB.Where( + "room_id = ? AND kode_mk = ? AND hari = ? AND jam_mulai <= ? AND jam_selesai >= ?", + req.RoomID, req.KodeMK, hariIni, jamSekarangStr, jamSekarangStr, + ).First(&schedule).Error + + if err == nil { + // Ditemukan jadwal yang cocok! Izinkan relay menyala. + c.JSON(http.StatusOK, gin.H{ + "status": "success", + "relay": "ON", + "message": fmt.Sprintf("Akses diterima. Jadwal %s sedang berlangsung.", schedule.NamaMK), + }) + return + } + + // ========================================================================= + // SKENARIO B: Akses Dadakan (Di luar jadwal / Kode MK tidak cocok jam ini) + // ========================================================================= + + // CEK 1: Apakah ada jadwal KULIAH LAIN yang sedang berlangsung saat ini? + var activeSchedule models.ClassSchedule + errActive := config.DB.Where( + "room_id = ? AND hari = ? AND jam_mulai <= ? AND jam_selesai >= ?", + req.RoomID, hariIni, jamSekarangStr, jamSekarangStr, + ).First(&activeSchedule).Error + + if errActive == nil { + c.JSON(http.StatusForbidden, gin.H{ + "error": fmt.Sprintf("Akses ditolak. Ruangan sedang digunakan untuk jadwal: %s", activeSchedule.NamaMK), + }) + return + } + + // CEK 2: Apakah ada BOOKING MAHASISWA (Approved) yang sedang berlangsung? + // Asumsi tabel booking memiliki status 'Approved', 'Pending', dll. + var activeBooking models.Booking + errBooking := config.DB.Where( + "room_id = ? AND status = 'Approved' AND start_time <= ? AND end_time >= ?", + req.RoomID, now, now, // Sesuaikan jika format DB menggunakan timestamp ISO + ).First(&activeBooking).Error + + if errBooking == nil { + c.JSON(http.StatusForbidden, gin.H{ + "error": "Akses ditolak. Ruangan sedang dipinjam oleh mahasiswa.", + }) + return + } + + // CEK 3: Pengecekan Aturan 10 Menit untuk Jadwal Berikutnya + var nextSchedule models.ClassSchedule + errNext := config.DB.Where( + "room_id = ? AND hari = ? AND jam_mulai > ?", + req.RoomID, hariIni, jamSekarangStr, + ).Order("jam_mulai asc").First(&nextSchedule).Error + + if errNext == nil { + // Parse jam mulai jadwal berikutnya + nextTime, _ := time.Parse("15:04:05", nextSchedule.JamMulai) + currentTime, _ := time.Parse("15:04:05", jamSekarangStr) + + // Hitung selisih dalam menit + diffMinutes := nextTime.Sub(currentTime).Minutes() + + if diffMinutes <= 10 { + c.JSON(http.StatusForbidden, gin.H{ + "error": fmt.Sprintf("Akses ditolak. Kelas %s akan segera dimulai dalam %.0f menit. Ruangan harus dikosongkan.", nextSchedule.NamaMK, diffMinutes), + }) + return + } + } + + // Jika lolos semua rintangan di atas, ruangan dipastikan KOSONG dan AMAN. + // Akses dadakan diizinkan! + c.JSON(http.StatusOK, gin.H{ + "status": "success", + "relay": "ON", + "message": "Akses dadakan diizinkan. Tidak ada tabrakan jadwal terdekat.", + }) +} \ No newline at end of file diff --git a/backend/controllers/schedulecontroller.go b/backend/controllers/schedulecontroller.go new file mode 100644 index 0000000..725b216 --- /dev/null +++ b/backend/controllers/schedulecontroller.go @@ -0,0 +1,27 @@ +package controllers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + // UBAH DUA BARIS INI MENJADI s-class-backend + "s-class-backend/config" + "s-class-backend/models" +) + +// Fungsi untuk mengambil semua jadwal dari database +func GetSchedules(c *gin.Context) { + var schedules []models.ClassSchedule + + // Mengambil semua data jadwal dan diurutkan berdasarkan Hari dan Jam Mulai + if err := config.DB.Order("hari, jam_mulai").Find(&schedules).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Gagal mengambil data jadwal kuliah"}) + return + } + + // Kirim data ke frontend (React) + c.JSON(http.StatusOK, gin.H{ + "data": schedules, + }) +} diff --git a/backend/models/entity.go b/backend/models/entity.go index 2980e8e..9bf03bf 100644 --- a/backend/models/entity.go +++ b/backend/models/entity.go @@ -46,3 +46,18 @@ type Booking struct { CreatedAt time.Time `json:"created_at"` } + +func (ClassSchedule) TableName() string { + return "class_schedules" +} + +type ClassSchedule struct { + ScheduleID int `gorm:"primaryKey;autoIncrement;column:schedule_id" json:"schedule_id"` + KodeMK string `gorm:"type:varchar(50);not null;column:kode_mk" json:"kode_mk"` + NamaMK string `gorm:"type:varchar(100);not null;column:nama_mk" json:"nama_mk"` + RoomID int `gorm:"not null;column:room_id" json:"room_id"` + Room Room `gorm:"foreignKey:RoomID" json:"room"` + Hari string `gorm:"type:varchar(20);not null;column:hari" json:"hari"` + JamMulai string `gorm:"type:time;not null;column:jam_mulai" json:"jam_mulai"` + JamSelesai string `gorm:"type:time;not null;column:jam_selesai" json:"jam_selesai"` +} diff --git a/frontend/app/admin/layout.tsx b/frontend/app/admin/layout.tsx index 2186d6b..c692b7e 100644 --- a/frontend/app/admin/layout.tsx +++ b/frontend/app/admin/layout.tsx @@ -8,7 +8,8 @@ import { LayoutDashboard, Activity, ShieldCheck, - Settings2 + Settings2, + CalendarDays } from "lucide-react"; export default function AdminLayout({ children }: { children: React.ReactNode }) { @@ -16,18 +17,31 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) const pathname = usePathname(); const [user, setUser] = useState(null); - // State isSidebarOpen sudah kita buang sepenuhnya + // 1. TAMBAHAN BARU: Gembok Loading untuk mencegah glitch redirect + const [isAuthorized, setIsAuthorized] = useState(false); useEffect(() => { const userData = localStorage.getItem("user"); - if (userData) { + + if (!userData) { + router.push("/login"); + return; + } + + try { const parsed = JSON.parse(userData); - if (parsed.role !== 'admin') { + + // 2. PERBAIKAN: Baca role dengan aman, ubah semua ke huruf kecil + const userRole = parsed.role || parsed.Role || ""; + + if (userRole.toLowerCase() !== 'admin') { router.push("/dashboard"); } else { setUser(parsed); + setIsAuthorized(true); // Buka gembok karena dia benar-benar admin! } - } else { + } catch (error) { + console.error("Gagal membaca data user", error); router.push("/login"); } }, [router]); @@ -37,6 +51,15 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) router.push("/login"); }; + // 3. CEGATAN RENDER: Jika belum divalidasi, jangan render apapun selain layar loading + if (!isAuthorized) { + return ( +
+
Memverifikasi Otoritas Admin...
+
+ ); + } + return (
@@ -96,6 +119,13 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) Manage Rooms + +
+ +
+ Jadwal Kuliah + + diff --git a/frontend/app/admin/schedules/page.tsx b/frontend/app/admin/schedules/page.tsx new file mode 100644 index 0000000..207313d --- /dev/null +++ b/frontend/app/admin/schedules/page.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { useEffect, useState } from "react"; +import axios from "axios"; +import { CalendarDays, Plus, Trash2, Clock, MapPin, LayoutList, Calendar } from "lucide-react"; + +export default function SchedulesPage() { + const [schedules, setSchedules] = useState([]); + const [loading, setLoading] = useState(true); + const [viewMode, setViewMode] = useState<"table" | "calendar">("table"); // State untuk pindah mode + + const hariUrutan = ["Senin", "Selasa", "Rabu", "Kamis", "Jumat"]; + const listJam = ["07:00", "08:00", "09:00", "10:00", "11:00", "12:00", "13:00", "14:00", "15:00", "16:00"]; + + useEffect(() => { + fetchSchedules(); + }, []); + + const fetchSchedules = async () => { + try { + const token = localStorage.getItem("token"); + const res = await axios.get("http://localhost:8080/api/schedules", { + headers: { Authorization: `Bearer ${token}` } + }); + setSchedules(res.data.data || []); + } catch (err: any) { + console.error("Gagal ambil jadwal:", err); + setSchedules([]); + } finally { + setLoading(false); + } + }; + + // Helper untuk mengecek apakah ada matkul di hari dan jam tertentu (Untuk Mode Kalender) + const getMatkulDiSlot = (hari: string, jam: string) => { + return schedules.find((s) => { + if (s.hari !== hari) return false; + const jamMulai = s.jam_mulai.substring(0, 5); + const jamSelesai = s.jam_selesai.substring(0, 5); + return jam >= jamMulai && jam < jamSelesai; + }); + }; + + if (loading) return
Memuat Jadwal Kuliah...
; + + return ( +
+ + {/* Header Halaman */} +
+
+
+ +
+
+

Jadwal Kuliah Statis

+

Pantau kepadatan ruang kelas D101 dan kelola jadwal otomatis.

+
+
+ +
+ {/* Toggle View Mode */} +
+ + +
+ + +
+
+ + {/* ==================================== MODE TABEL ==================================== */} + {viewMode === "table" && ( +
+
+ + + + + + + + + + + + + {schedules.length > 0 ? ( + schedules.map((sched) => ( + + + + + + + + + )) + ) : ( + + + + )} + +
Kode MKMata KuliahRuanganHariWaktuAksi
{sched.kode_mk}{sched.nama_mk} + Kelas D101 + + {sched.hari} + +
+ + {sched.jam_mulai.substring(0, 5)} - {sched.jam_selesai.substring(0, 5)} +
+
+ +
Belum ada jadwal kuliah terdaftar.
+
+
+ )} + + {/* ==================================== MODE KALENDER ==================================== */} + {viewMode === "calendar" && ( +
+
+
Waktu
+ {hariUrutan.map((h) =>
{h}
)} +
+ +
+ {listJam.map((jam) => ( +
+
+ {jam} +
+ + {hariUrutan.map((hari) => { + const mtk = getMatkulDiSlot(hari, jam); + return ( +
+ {mtk ? ( +
+ {mtk.nama_mk} + {mtk.kode_mk} +
+ ) : ( +
+ Kosong +
+ )} +
+ ); + })} +
+ ))} +
+
+ )} + +
+ ); +} \ No newline at end of file diff --git a/frontend/app/dashboard/bookings/calendar/page.tsx b/frontend/app/dashboard/bookings/calendar/page.tsx index b05e94c..a03e733 100644 --- a/frontend/app/dashboard/bookings/calendar/page.tsx +++ b/frontend/app/dashboard/bookings/calendar/page.tsx @@ -7,6 +7,7 @@ import { CalendarDays, MapPin, Clock, ChevronLeft, ChevronRight, Calendar as Cal export default function CalendarViewPage() { const [bookings, setBookings] = useState([]); const [rooms, setRooms] = useState([]); + const [schedules, setSchedules] = useState([]); // 1. STATE BARU UNTUK JADWAL KULIAH const [loading, setLoading] = useState(true); // --- STATE UNTUK NAVIGASI MINGGU --- @@ -50,19 +51,21 @@ export default function CalendarViewPage() { d.setHours(0, 0, 0, 0); setStartDate(d); }; - // ------------------------------------------ useEffect(() => { const fetchData = async () => { try { const token = localStorage.getItem("token"); - const [roomsRes, bookingsRes] = await Promise.all([ + // 2. FETCH DATA SCHEDULES BERSAMAAN DENGAN ROOMS & BOOKINGS + const [roomsRes, bookingsRes, schedulesRes] = await Promise.all([ axios.get("http://localhost:8080/api/rooms", { headers: { Authorization: `Bearer ${token}` } }), - axios.get("http://localhost:8080/api/bookings", { headers: { Authorization: `Bearer ${token}` } }) + axios.get("http://localhost:8080/api/bookings", { headers: { Authorization: `Bearer ${token}` } }), + axios.get("http://localhost:8080/api/schedules", { headers: { Authorization: `Bearer ${token}` } }) ]); setRooms(roomsRes.data.data || []); setBookings(bookingsRes.data.data || []); + setSchedules(schedulesRes.data.data || []); } catch (error) { console.error("Gagal memuat data", error); } finally { @@ -72,7 +75,7 @@ export default function CalendarViewPage() { fetchData(); }, []); - // Fungsi memfilter booking + // Fungsi memfilter booking dinamis const getBookingsForCell = (roomId: number, dateObj: Date) => { return bookings.filter(b => { if (b.room_id !== roomId) return false; @@ -85,6 +88,14 @@ export default function CalendarViewPage() { }); }; + // 3. FUNGSI BARU: Memfilter jadwal statis berdasarkan hari + const getSchedulesForCell = (roomId: number, dateObj: Date) => { + const namaHari = ["Minggu", "Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu"]; + const hariIni = namaHari[dateObj.getDay()]; + + return schedules.filter(s => s.room_id === roomId && s.hari === hariIni); + }; + // Format Jam const formatTime = (isoString: string) => { return new Date(isoString).toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' }); @@ -119,7 +130,6 @@ export default function CalendarViewPage() {
- {/* TULISAN RENTANG TANGGAL DINAMIS (Lebar Fix w-44 untuk menghilangkan warning Tailwind) */}