Compare commits

...
13 Commits
Author SHA1 Message Date
[Valentino Heman Budiarto] 387b098aad . 2026-06-23 00:25:52 +07:00
[Valentino Heman Budiarto] 50795d806e . 2026-06-22 23:58:15 +07:00
[Valentino Heman Budiarto] 122115b5e9 update timer auto cut off mcb 2026-06-22 23:45:01 +07:00
[Valentino Heman Budiarto] e210d2a424 . 2026-06-22 18:34:26 +07:00
[Valentino Heman Budiarto] 29ea2c6589 edit jadwal dari sisi admin 2026-06-22 18:08:36 +07:00
[Valentino Heman Budiarto] 86682ce7c3 jadwal 2026-06-22 16:50:24 +07:00
[Valentino Heman Budiarto] 427ce3d5b3 . 2026-06-22 16:26:44 +07:00
[Valentino Heman Budiarto] 9141b48056 . 2026-06-22 16:19:20 +07:00
[Valentino Heman Budiarto] 4d6e825f16 . 2026-06-22 16:07:24 +07:00
[Valentino Heman Budiarto] 102ac2c50a . 2026-06-22 10:58:16 +07:00
[Valentino Heman Budiarto] 241d228e9c menambahkan jumlah sks 2026-06-22 08:36:27 +07:00
[Valentino Heman Budiarto] 3c0adda955 UBAH MENIT CS dan ADMIN 2026-06-19 08:17:54 +07:00
[Valentino Heman Budiarto] 7497b37ff2 update hardware.go 2026-06-18 20:39:21 +07:00
12 changed files with 587 additions and 201 deletions
+13 -14
View File
@@ -14,12 +14,12 @@ func main() {
// 1. Konek Database
config.ConnectDatabase()
// 2. AutoMigrate (Tambahkan ClassSchedule di sini agar dikenali GORM)
// 2. AutoMigrate
config.DB.AutoMigrate(&models.User{}, &models.Room{}, &models.Booking{}, &models.ClassSchedule{})
r := gin.Default()
// 3. CORS Middleware (Agar Frontend bisa masuk)
// 3. CORS Middleware (Panggil fungsinya di sini)
r.Use(CORSMiddleware())
r.GET("/", func(c *gin.Context) {
@@ -58,35 +58,34 @@ func main() {
// Admin (Manage Rooms)
protected.PUT("/admin/rooms/:id/status", controllers.UpdateRoomStatus)
// 🌟 RUTE BARU: Jadwal Kuliah (Untuk Halaman Web Admin)
// Jadwal Kuliah (Untuk Halaman Web Admin)
protected.GET("/schedules", controllers.GetSchedules)
protected.POST("/schedules", controllers.CreateSchedule)
protected.PUT("/schedules/:id", controllers.UpdateSchedule)
protected.DELETE("/schedules/:id", controllers.DeleteSchedule)
}
// 5. Jalur IoT ESP32
// 5. Jalur IoT ESP32 & Kontrol Daya
r.POST("/api/sensor/energy", controllers.UpdateRoomPower)
// RUTE BARU: Untuk menerima ketukan pintu dari Hardware ESP32
r.POST("/api/hardware/verify", controllers.VerifyHardwareCode)
// PASTIKAN BARIS INI ADA DI SINI:
r.POST("/api/hardware/control", controllers.ControlHardware)
r.GET("/api/hardware/status", controllers.GetHardwareStatus)
r.GET("/api/hardware/power-status", controllers.GetPowerStatus)
r.POST("/api/power/global", controllers.GlobalPowerControl)
r.Run(":8080")
}
// ... (fungsi CORSMiddleware tetap sama) ...
// =========================================================================
// FUNGSI MIDDLEWARE HARUS DITULIS DI LUAR FUNGSI MAIN
// =========================================================================
func CORSMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, PATCH")
// Pastikan DELETE sudah masuk di sini
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, PATCH, DELETE")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
+20 -6
View File
@@ -45,7 +45,7 @@ func CreateBooking(c *gin.Context) {
return
}
// 🌟 PERBAIKAN 1: Ambil Role User dari Token
// 🌟 Ambil Role User dari Token
userRoleInterface, _ := c.Get("role")
userRole, _ := userRoleInterface.(string)
@@ -55,6 +55,21 @@ func CreateBooking(c *gin.Context) {
return
}
// =========================================================
// 🌟 PERBAIKAN: PROTEKSI STATUS MAINTENANCE RUANGAN
// =========================================================
var targetRoom models.Room
if err := config.DB.First(&targetRoom, input.RoomID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Ruangan tidak ditemukan di database"})
return
}
if targetRoom.Status == "Maintenance" {
c.JSON(http.StatusForbidden, gin.H{"error": "Akses Ditolak: Ruangan ini sedang dalam perawatan teknis dan tidak dapat dipesan saat ini."})
return
}
// =========================================================
// Overlap Check (Mencegah bentrok jadwal)
var count int64
config.DB.Model(&models.Booking{}).Where(
@@ -67,7 +82,7 @@ func CreateBooking(c *gin.Context) {
return
}
// 🌟 PERBAIKAN 1 Lanjutan: Penentuan Status Otomatis
// Penentuan Status Otomatis
statusPeminjaman := "Pending" // Default untuk student
if userRole == "lecturer" {
statusPeminjaman = "Approved" // Dosen otomatis disetujui!
@@ -80,7 +95,7 @@ func CreateBooking(c *gin.Context) {
StartTime: input.StartTime,
EndTime: input.EndTime,
Purpose: input.Purpose,
Status: statusPeminjaman, // <-- Gunakan variabel statusPeminjaman
Status: statusPeminjaman,
}
if err := config.DB.Create(&booking).Error; err != nil {
@@ -95,7 +110,7 @@ func CreateBooking(c *gin.Context) {
})
}
// 🌟 PERBAIKAN 3: Fungsi khusus untuk mengambil riwayat pribadi
// Fungsi khusus untuk mengambil riwayat pribadi
func GetMyBookings(c *gin.Context) {
var bookings []models.Booking
@@ -150,7 +165,6 @@ func UpdateBookingStatus(c *gin.Context) {
booking.Status = input.Status
// 🌟 PERBAIKAN 2: Menghapus blok Save dan JSON ganda yang bikin error
if err := config.DB.Save(&booking).Error; err != nil {
fmt.Println("🔥 ERROR DARI DATABASE:", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -255,4 +269,4 @@ func GetAllBookings(c *gin.Context) {
"status": "success",
"data": bookings,
})
}
}
+134 -116
View File
@@ -7,9 +7,11 @@ import (
"net/http"
"os"
"strconv" // Digunakan untuk mengubah teks Watt menjadi angka
"strings" // Digunakan untuk manipulasi string (ToUpper/Trim)
"time"
"s-class-backend/config" // Import database
"s-class-backend/models" // Import models untuk query database
mqtt "github.com/eclipse/paho.mqtt.golang"
"github.com/gin-gonic/gin"
@@ -25,7 +27,6 @@ type VerifyRequest struct {
Token string `json:"token"`
}
// Tambahan untuk kontrol daya global (ON/OFF)
type GlobalPowerRequest struct {
Action string `json:"action"` // "on" atau "off"
}
@@ -41,7 +42,7 @@ var DeviceStatusCache = map[string]string{
}
// =========================================================================
// FUNGSI 1: VERIFIKASI TOKEN (Bisa Master & Mahasiswa)
// FUNGSI 1: VERIFIKASI TOKEN & KODE MATA KULIAH (DOUBLE-LAYER PROTECTION)
// =========================================================================
func VerifyHardwareCode(c *gin.Context) {
var req VerifyRequest
@@ -51,8 +52,9 @@ func VerifyHardwareCode(c *gin.Context) {
return
}
tokenInput := req.Token
sekarang := time.Now()
tokenInput := strings.ToUpper(strings.TrimSpace(req.Token))
loc, _ := time.LoadLocation("Asia/Jakarta")
sekarang := time.Now().In(loc)
sisaMenit := 0
isTokenValid := false
@@ -65,71 +67,107 @@ func VerifyHardwareCode(c *gin.Context) {
isTokenValid = true
} else if tokenInput == "ADM999" {
fmt.Printf("[VERIFY] Master Token Admin digunakan\n")
sisaMenit = 999
sisaMenit = 1440 // 12 jam
isTokenValid = true
} else {
// =========================================================
// 2. PENGECEKAN KELOMPOK TOKEN MAHASISWA (Cek Database & Waktu)
// 2. CEK TOKEN BOOKING VIA WEB (6 DIGIT)
// =========================================================
var jamMulai time.Time
var jamSelesai time.Time
type ResultTime struct {
JamMulai time.Time `gorm:"column:start_time"`
JamSelesai time.Time `gorm:"column:end_time"`
}
var result ResultTime
var booking models.Booking
errBooking := config.DB.First(&booking, "redeem_code = ? AND status = 'Approved'", tokenInput).Error
// Cek Bookings
errBooking := config.DB.Table("bookings").
Select("start_time, end_time").
Where("redeem_code = ?", tokenInput).
Scan(&result).Error
if errBooking == nil {
if booking.StartTime.Format("2006-01-02") != sekarang.Format("2006-01-02") {
c.JSON(http.StatusForbidden, gin.H{"error": "Peminjaman bukan untuk hari ini!"})
return
}
if errBooking == nil && !result.JamSelesai.IsZero() {
jamMulai = result.JamMulai
jamSelesai = result.JamSelesai
toleransiMasuk := booking.StartTime.Add(-15 * time.Minute)
if sekarang.Before(toleransiMasuk) {
c.JSON(http.StatusForbidden, gin.H{"error": "Belum waktunya. Bisa masuk 15 menit sebelum kelas!"})
return
}
if sekarang.After(booking.EndTime) {
c.JSON(http.StatusForbidden, gin.H{"error": "Waktu peminjaman sudah habis!"})
return
}
sisaMenit = int(booking.EndTime.Sub(sekarang).Minutes())
if sisaMenit <= 0 {
sisaMenit = 1
}
isTokenValid = true
} else {
// Cek Jadwal Kelas Tetap
errSchedule := config.DB.Table("class_schedules").
Select("start_time, end_time").
Where("kode_mk = ?", tokenInput).
Scan(&result).Error
if errSchedule == nil && !result.JamSelesai.IsZero() {
jamMulai = result.JamMulai
jamSelesai = result.JamSelesai
} else {
// =========================================================
// 3. CEK KODE MATA KULIAH DENGAN DOUBLE-LAYER PROTECTION
// =========================================================
var reqSchedule models.ClassSchedule
// Cari apakah Kode MK yang diketik eksis di database (contoh: "EE153")
errSchedule := config.DB.First(&reqSchedule, "kode_mk = ?", tokenInput).Error
if errSchedule == nil {
// PROTEKSI LAPIS 1: Cek apakah ada yang sedang Booking via Web sekarang
var countBookingAktif int64
config.DB.Model(&models.Booking{}).Where(
"room_id = ? AND status = 'Approved' AND start_time <= ? AND end_time >= ?",
reqSchedule.RoomID, sekarang, sekarang,
).Count(&countBookingAktif)
if countBookingAktif > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "Akses Ditolak: Ruangan sedang dipakai peminjam resmi (Web)!"})
return
}
// PROTEKSI LAPIS 2: Cek apakah ada JADWAL KELAS LAIN yang sedang berjalan di jam ini
// Ambil nama hari ini dalam Bahasa Indonesia
hariMap := map[time.Weekday]string{
time.Sunday: "Minggu", time.Monday: "Senin", time.Tuesday: "Selasa",
time.Wednesday: "Rabu", time.Thursday: "Kamis", time.Friday: "Jumat", time.Saturday: "Sabtu",
}
hariIni := hariMap[sekarang.Weekday()]
jamSekarang := sekarang.Format("15:04:05")
var activeClass models.ClassSchedule
errActive := config.DB.Where(
"room_id = ? AND hari = ? AND jam_mulai <= ? AND jam_selesai >= ?",
reqSchedule.RoomID, hariIni, jamSekarang, jamSekarang,
).First(&activeClass).Error
if errActive == nil {
// Ada kelas yang sedang berlangsung saat ini.
// Cek apakah kelas tersebut BERBEDA dengan Kode MK yang diinputkan pengguna
// 🌟 PERBAIKAN: Ubah KodeMk menjadi KodeMK (K huruf besar)
if activeClass.KodeMK != reqSchedule.KodeMK {
c.JSON(http.StatusConflict, gin.H{
// 🌟 PERBAIKAN: Ubah NamaMk menjadi NamaMK (K huruf besar)
"error": "Akses Ditolak: Ruangan sedang digunakan oleh jadwal kelas " + activeClass.NamaMK,
})
return
}
// Jika kodenya SAMA (Dosen menginputkan kodenya di jam yang memang miliknya), abaikan & beri izin masuk.
}
// JIKA LOLOS SEMUA PROTEKSI: Berikan akses waktu berdasarkan jumlah SKS
// 🌟 PERBAIKAN: Pastikan ini sesuai dengan struct di models (JumlahSks atau JumlahSKS)
durasiMenit := reqSchedule.JumlahSks * 50
if durasiMenit <= 0 {
durasiMenit = 100 // Nilai aman (default 2 SKS) jika database kosong
}
sisaMenit = durasiMenit
isTokenValid = true
}
}
if !isTokenValid {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token salah atau tidak ditemukan"})
return
}
jamMulaiHariIni := time.Date(sekarang.Year(), sekarang.Month(), sekarang.Day(),
jamMulai.Hour(), jamMulai.Minute(), jamMulai.Second(), 0, sekarang.Location())
jamSelesaiHariIni := time.Date(sekarang.Year(), sekarang.Month(), sekarang.Day(),
jamSelesai.Hour(), jamSelesai.Minute(), jamSelesai.Second(), 0, sekarang.Location())
batasMasukAwal := jamMulaiHariIni.Add(-15 * time.Minute)
if sekarang.Before(batasMasukAwal) {
c.JSON(http.StatusForbidden, gin.H{"error": "Jadwal kelas belum dimulai"})
return
}
selisihWaktu := jamSelesaiHariIni.Sub(sekarang)
sisaMenit = int(selisihWaktu.Minutes())
if sisaMenit <= 0 {
c.JSON(http.StatusForbidden, gin.H{"error": "Waktu peminjaman sudah habis"})
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token Web atau Kode MK tidak ditemukan!"})
return
}
}
// =========================================================
// 3. JIKA VALID (Master / Mahasiswa), BUKA GEMBOK VIA MQTT
// 4. JIKA VALID, BUKA GEMBOK VIA MQTT & HTTP LOKAL
// =========================================================
DeviceStatusCache["lampu1"] = "on"
DeviceStatusCache["lampu2"] = "on"
@@ -147,6 +185,7 @@ func VerifyHardwareCode(c *gin.Context) {
client := mqtt.NewClient(opts)
if tokenMQTT := client.Connect(); tokenMQTT.Wait() && tokenMQTT.Error() == nil {
client.Publish("sclass/d101/auth", 0, true, "UNLOCK").Wait()
// Opsional: Jika masih mengandalkan MQTT untuk lampu
client.Publish("sclass/d101/lampu1", 0, false, "on").Wait()
client.Publish("sclass/d101/lampu2", 0, false, "on").Wait()
client.Disconnect(250)
@@ -157,7 +196,7 @@ func VerifyHardwareCode(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "success",
"message": "Token Valid",
"message": "Akses Diberikan",
"duration_minutes": sisaMenit,
})
}
@@ -172,7 +211,6 @@ func ControlHardware(c *gin.Context) {
return
}
// 🌟 BLOK 1: KONTROL GEMBOK / OTORISASI RELAY
if req.Device == "auth" {
broker := os.Getenv("MQTT_BROKER")
user := os.Getenv("MQTT_USER")
@@ -191,7 +229,6 @@ func ControlHardware(c *gin.Context) {
return
}
// --- BLOK 2: KONTROL LAMPU ---
if req.Device == "lampu1" || req.Device == "lampu2" {
broker := os.Getenv("MQTT_BROKER")
user := os.Getenv("MQTT_USER")
@@ -219,7 +256,6 @@ func ControlHardware(c *gin.Context) {
return
}
// --- BLOK 3: KONTROL AC & PROYEKTOR ---
if req.Device == "ac" || req.Device == "projector" {
haURL := os.Getenv("HA_URL")
haToken := os.Getenv("HA_TOKEN")
@@ -264,7 +300,7 @@ func ControlHardware(c *gin.Context) {
}
// =========================================================================
// HELPER 1: MENGAMBIL ANGKA DARI HOME ASSISTANT (Untuk Daya/Watt)
// HELPER 1 & 2: HTTP GET KE HOME ASSISTANT
// =========================================================================
func fetchHAState(haURL string, haToken string, entityID string) float64 {
apiURL := fmt.Sprintf("%s/api/states/%s", haURL, entityID)
@@ -294,9 +330,6 @@ func fetchHAState(haURL string, haToken string, entityID string) float64 {
return val
}
// =========================================================================
// HELPER 2: MENGAMBIL STATUS TEKS DARI HOME ASSISTANT (Untuk ON/OFF)
// =========================================================================
func fetchHAStringState(haURL string, haToken string, entityID string) string {
apiURL := fmt.Sprintf("%s/api/states/%s", haURL, entityID)
@@ -325,7 +358,7 @@ func fetchHAStringState(haURL string, haToken string, entityID string) string {
}
// =========================================================================
// FUNGSI 3: MENDAPATKAN STATUS DAYA DARI 3 MCB SEKALIGUS
// FUNGSI 3: MENDAPATKAN STATUS DAYA
// =========================================================================
func GetPowerStatus(c *gin.Context) {
haURL := os.Getenv("HA_URL")
@@ -343,92 +376,78 @@ func GetPowerStatus(c *gin.Context) {
}
// =========================================================================
// FUNGSI 4: GLOBAL POWER CONTROL (Sequential ON/OFF dengan Jeda 1 Detik)
// FUNGSI 4: GLOBAL POWER CONTROL
// =========================================================================
func GlobalPowerControl(c *gin.Context) {
var req GlobalPowerRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Format data tidak valid"})
return
}
var req GlobalPowerRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Format data tidak valid"})
return
}
haURL := os.Getenv("HA_URL")
haToken := os.Getenv("HA_TOKEN")
haURL := os.Getenv("HA_URL")
haToken := os.Getenv("HA_TOKEN")
service := "turn_off"
if req.Action == "on" {
service = "turn_on"
}
apiURL := fmt.Sprintf("%s/api/services/switch/%s", haURL, service)
service := "turn_off"
if req.Action == "on" {
service = "turn_on"
}
apiURL := fmt.Sprintf("%s/api/services/switch/%s", haURL, service)
mcbs := []string{
"switch.wifi_smart_meter_pro_switch", // Umum
"switch.wifi_smart_meter_pro_2_switch", // AC1
"switch.wifi_smart_meter_pro_3_switch", // AC2
}
// 🌟 PERBAIKAN: Hapus array dan loop, cukup gunakan 1 target saja
targetSwitch := "switch.wifi_smart_meter_pro_switch"
for i, mcb := range mcbs {
payload := map[string]string{"entity_id": mcb}
jsonPayload, _ := json.Marshal(payload)
payload := map[string]string{"entity_id": targetSwitch}
jsonPayload, _ := json.Marshal(payload)
reqHA, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonPayload))
if err == nil {
reqHA.Header.Set("Authorization", "Bearer "+haToken)
reqHA.Header.Set("Content-Type", "application/json")
reqHA, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonPayload))
if err == nil {
reqHA.Header.Set("Authorization", "Bearer "+haToken)
reqHA.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(reqHA)
if err == nil && resp != nil {
resp.Body.Close()
}
}
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(reqHA)
if err == nil && resp != nil {
resp.Body.Close()
}
}
fmt.Printf("[MCB] Sinyal %s dikirim ke %s\n", req.Action, mcb)
fmt.Printf("[MCB] Sinyal %s dikirim ke %s\n", req.Action, targetSwitch)
if i < len(mcbs)-1 {
time.Sleep(1 * time.Second)
}
}
// Mematikan status virtual di dashboard
if req.Action == "off" {
DeviceStatusCache["lampu1"] = "off"
DeviceStatusCache["lampu2"] = "off"
DeviceStatusCache["ac"] = "off"
DeviceStatusCache["projector"] = "off"
}
if req.Action == "off" {
DeviceStatusCache["lampu1"] = "off"
DeviceStatusCache["lampu2"] = "off"
DeviceStatusCache["ac"] = "off"
DeviceStatusCache["projector"] = "off"
}
c.JSON(http.StatusOK, gin.H{
"status": "success",
"message": fmt.Sprintf("Seluruh daya ruangan berhasil di-%s secara berurutan", req.Action),
})
c.JSON(http.StatusOK, gin.H{
"status": "success",
"message": fmt.Sprintf("Daya utama berhasil di-%s", req.Action),
})
}
// =========================================================================
// FUNGSI 5: MENGIRIM STATUS REAL-TIME KE WEB FRONTEND (SENSOR FUSION)
// FUNGSI 5: SENSOR FUSION FRONTEND
// =========================================================================
func GetHardwareStatus(c *gin.Context) {
haURL := os.Getenv("HA_URL")
haToken := os.Getenv("HA_TOKEN")
// 1. Cek status fisik MCB di Home Assistant
mcbUmum := fetchHAStringState(haURL, haToken, "switch.wifi_smart_meter_pro_switch")
mcbAC1 := fetchHAStringState(haURL, haToken, "switch.wifi_smart_meter_pro_2_switch")
// 2. Ambil data dari Cache (Perintah terakhir dari Web/ESP32 melalui IR/MQTT)
statusLampu1 := DeviceStatusCache["lampu1"]
statusLampu2 := DeviceStatusCache["lampu2"]
statusProyektor := DeviceStatusCache["projector"]
statusAC := DeviceStatusCache["ac"]
// 3. LOGIKA SENSOR FUSION (Penggabungan Fisik & Virtual)
// Jika listrik MCB fisik terputus, maka otomatis perangkat di jalur tersebut PASTI MATI (off),
// mengabaikan apapun status terakhir yang ada di memori Cache.
if mcbUmum == "off" {
statusLampu1 = "off"
statusLampu2 = "off"
statusProyektor = "off"
// Sinkronkan kembali cache agar tidak ada "state" nyangkut
DeviceStatusCache["lampu1"] = "off"
DeviceStatusCache["lampu2"] = "off"
DeviceStatusCache["projector"] = "off"
@@ -439,7 +458,6 @@ func GetHardwareStatus(c *gin.Context) {
DeviceStatusCache["ac"] = "off"
}
// Kirim data yang sudah divalidasi ke Web dan Layar ESP32
c.JSON(http.StatusOK, gin.H{
"status": "success",
"data": map[string]string{
@@ -449,4 +467,4 @@ func GetHardwareStatus(c *gin.Context) {
"ac": statusAC,
},
})
}
}
+109 -6
View File
@@ -2,15 +2,26 @@ package controllers
import (
"net/http"
"s-class-backend/config"
"s-class-backend/models"
"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
// Struktur data untuk menerima input dari Frontend (Form Modal)
type ScheduleInput struct {
KodeMK string `json:"kode_mk" binding:"required"`
NamaMK string `json:"nama_mk" binding:"required"`
RoomID int `json:"room_id" binding:"required"` // 🌟 PERBAIKAN: Ubah uint menjadi int
Hari string `json:"hari" binding:"required"`
JamMulai string `json:"jam_mulai" binding:"required"`
JamSelesai string `json:"jam_selesai" binding:"required"`
JumlahSks int `json:"jumlah_sks" binding:"required"`
}
// =========================================================
// 1. GET SCHEDULES (Menampilkan Data ke Tabel/Kalender)
// =========================================================
func GetSchedules(c *gin.Context) {
var schedules []models.ClassSchedule
@@ -20,8 +31,100 @@ func GetSchedules(c *gin.Context) {
return
}
// Kirim data ke frontend (React)
c.JSON(http.StatusOK, gin.H{
"data": schedules,
})
}
// =========================================================
// 2. CREATE SCHEDULE (Menambah Jadwal Baru)
// =========================================================
func CreateSchedule(c *gin.Context) {
var input ScheduleInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Format data tidak valid: " + err.Error()})
return
}
newSchedule := models.ClassSchedule{
KodeMK: input.KodeMK,
NamaMK: input.NamaMK,
RoomID: input.RoomID,
Hari: input.Hari,
JamMulai: input.JamMulai,
JamSelesai: input.JamSelesai,
JumlahSks: input.JumlahSks,
}
if err := config.DB.Create(&newSchedule).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Gagal menyimpan jadwal baru ke database"})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "Jadwal berhasil ditambahkan",
"data": newSchedule,
})
}
// =========================================================
// 3. UPDATE SCHEDULE (Mengedit Jadwal Saat UTS/Berubah)
// =========================================================
func UpdateSchedule(c *gin.Context) {
scheduleID := c.Param("id")
var input ScheduleInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Format data tidak valid"})
return
}
var schedule models.ClassSchedule
// Cari jadwal lama di database berdasarkan ID
if err := config.DB.First(&schedule, scheduleID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Jadwal tidak ditemukan"})
return
}
// Timpa data lama dengan inputan baru dari Admin
schedule.KodeMK = input.KodeMK
schedule.NamaMK = input.NamaMK
schedule.RoomID = input.RoomID
schedule.Hari = input.Hari
schedule.JamMulai = input.JamMulai
schedule.JamSelesai = input.JamSelesai
schedule.JumlahSks = input.JumlahSks
// Simpan perubahan ke database
if err := config.DB.Save(&schedule).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Gagal memperbarui jadwal"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Jadwal berhasil diperbarui",
"data": schedule,
})
}
// =========================================================
// 4. DELETE SCHEDULE (Menghapus Jadwal)
// =========================================================
func DeleteSchedule(c *gin.Context) {
scheduleID := c.Param("id")
var schedule models.ClassSchedule
// Pastikan data yang mau dihapus itu memang ada
if err := config.DB.First(&schedule, scheduleID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Jadwal tidak ditemukan"})
return
}
// Hapus secara permanen dari database
if err := config.DB.Delete(&schedule).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Gagal menghapus jadwal"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Jadwal berhasil dihapus"})
}
+1
View File
@@ -60,4 +60,5 @@ type ClassSchedule struct {
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"`
JumlahSks int `gorm:"column:jumlah_sks" json:"jumlah_sks"`
}
+1 -1
View File
@@ -123,7 +123,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<div className={`p-2 rounded-lg transition-colors ${pathname === '/admin/schedules' ? 'bg-white/60 shadow-sm' : 'bg-white/30 group-hover:bg-white/50'}`}>
<CalendarDays size={22} />
</div>
<span>Jadwal Kuliah</span>
<span>Edit Jadwal</span>
</Link>
</nav>
+2 -8
View File
@@ -115,13 +115,11 @@ export default function PowerMonitoringPage() {
if (!window.confirm(confirmMsg)) return;
}
// Jika yang ditekan bukan D101, ubah UI saja (simulasi)
if (roomName !== "Kelas D101") {
setRoomDeviceStatus(prev => ({ ...prev, [roomIdKey]: { ...prev[roomIdKey], [deviceName]: !currentStatus } }));
return;
}
// Logika khusus D101 (Tembak ke Golang)
let backendDevice = "";
if (deviceName === 'AC') backendDevice = "ac";
else if (deviceName === 'Proyektor') backendDevice = "projector";
@@ -147,17 +145,15 @@ export default function PowerMonitoringPage() {
}
};
// FUNGSI GLOBAL POWER (ON/OFF KHUSUS D101)
const handleGlobalPower = async (roomName: string, roomId: number, currentRelayStatus: boolean) => {
if (roomName === "Kelas D101") {
const actionType = currentRelayStatus ? "off" : "on";
const confirmMessage = currentRelayStatus
? `PERINGATAN FATAL: Anda yakin ingin MEMUTUS 3 MCB Daya di ${roomName}? (Proses berurutan ~3 detik)`
: `RESTORE POWER: Anda yakin ingin MENYALAKAN KEMBALI 3 MCB Daya di ${roomName}? (Proses berurutan ~3 detik)`;
? `PERINGATAN FATAL: Anda yakin ingin MEMUTUS MCB Daya Utama di ${roomName}?`
: `RESTORE POWER: Anda yakin ingin MENYALAKAN KEMBALI MCB Daya di ${roomName}?`;
if (!window.confirm(confirmMessage)) return;
// Ubah UI seketika
setRooms(prev => prev.map(r => r.name === roomName ? { ...r, isRelayOn: !currentRelayStatus } : r));
try {
@@ -172,7 +168,6 @@ export default function PowerMonitoringPage() {
fetchPowerStatus();
fetchDeviceStatus();
} else {
// Rollback UI jika gagal
setRooms(prev => prev.map(r => r.name === roomName ? { ...r, isRelayOn: currentRelayStatus } : r));
alert("Gagal mengontrol MCB utama. Cek koneksi server.");
}
@@ -181,7 +176,6 @@ export default function PowerMonitoringPage() {
alert("Terjadi kesalahan jaringan saat menghubungi server.");
}
} else {
// Dummy untuk kelas lain
const actionType = currentRelayStatus ? "Mematikan" : "Menyalakan";
if (window.confirm(`Simulasi ${actionType} daya di ${roomName}?`)) {
setRooms(prev => prev.map(r => r.name === roomName ? { ...r, isRelayOn: !currentRelayStatus, power: 0 } : r));
+68 -22
View File
@@ -9,13 +9,46 @@ export default function AdminDashboard() {
const [roomStats, setRoomStats] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
// STATE BARU: Khusus menyimpan TOTAL DAYA D101
const [d101TotalPower, setD101TotalPower] = useState<number>(0);
useEffect(() => {
fetchAdminData();
// Refresh data konsumsi daya setiap 10 detik agar terlihat real-time
const interval = setInterval(fetchAdminData, 10000);
return () => clearInterval(interval);
fetchD101Power(); // Panggil fungsi daya saat pertama kali render
// Refresh data admin (ruangan & booking) setiap 10 detik
const intervalAdmin = setInterval(fetchAdminData, 10000);
// Refresh data konsumsi daya D101 setiap 3 detik (mengikuti halaman monitoring)
const intervalPower = setInterval(fetchD101Power, 3000);
return () => {
clearInterval(intervalAdmin);
clearInterval(intervalPower);
};
}, []);
// =========================================================================
// FUNGSI BARU: Tarik Data Daya (Power / Watt) KHUSUS D101
// =========================================================================
const fetchD101Power = async () => {
try {
const response = await fetch("http://172.17.172.17:8080/api/hardware/power-status");
if (response.ok) {
const data = await response.json();
// Kalkulasi total watt
const umum = parseFloat(data.umum) || 0;
const ac1 = parseFloat(data.ac1) || 0;
const ac2 = parseFloat(data.ac2) || 0;
const totalD101 = umum + ac1 + ac2;
setD101TotalPower(totalD101);
}
} catch (err) {
console.error("Gagal mengambil data daya D101:", err);
}
};
const fetchAdminData = async () => {
try {
const token = localStorage.getItem("token");
@@ -37,7 +70,7 @@ export default function AdminDashboard() {
const roomsWithDummyPower = (roomRes.data.data || []).map((room: any) => ({
...room,
// Simulasi daya acak antara 0 sampai 15 Watt untuk keperluan UI
// Simulasi daya acak antara 0 sampai 15 Watt (D101 akan di-override di bagian render)
power_consumption: Math.floor(Math.random() * 15)
}));
setRoomStats(roomsWithDummyPower);
@@ -49,7 +82,6 @@ export default function AdminDashboard() {
}
};
// PERBAIKAN 1: Ubah tipe id menjadi 'string' karena kita menggunakan UUID
const handleAction = async (id: string, status: string) => {
try {
const token = localStorage.getItem("token");
@@ -76,22 +108,39 @@ export default function AdminDashboard() {
<Activity className="text-blue-500" /> Real-time Room Energy
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* TAMBAHKAN PENGURUTAN ABJAD DI SINI */}
{[...roomStats]
.sort((a, b) => a.name.localeCompare(b.name))
.map((room) => (
<div key={room.room_id} className="bg-white p-5 rounded-xl border border-gray-100 shadow-sm">
<div className="flex justify-between items-start mb-3">
<span className="text-sm font-bold text-gray-400 uppercase tracking-tight">{room.name}</span>
<div className={`h-3 w-3 rounded-full ${room.power_consumption > 5 ? 'bg-orange-500 animate-pulse' : 'bg-gray-300'}`} />
</div>
<div className="flex items-end gap-2">
<span className="text-3xl font-black text-gray-800">{room.power_consumption || 0}</span>
<span className="text-gray-500 font-bold mb-1">Watts</span>
</div>
<p className="text-[10px] text-gray-400 mt-2 font-medium">Last updated: Just now</p>
</div>
))}
.map((room) => {
// LOGIKA INJEKSI: Timpa nilai dummy dengan daya asli jika ini Kelas D101
const isD101 = room.name === "Kelas D101";
const displayedPower = isD101 ? d101TotalPower : room.power_consumption;
const isPowerActive = displayedPower > 0;
return (
<div key={room.room_id} className={`bg-white p-5 rounded-xl border shadow-sm transition-all
${isD101 ? 'border-blue-200 border-l-4 border-l-blue-500 bg-blue-50/10' : 'border-gray-100'}`}>
<div className="flex justify-between items-start mb-3">
<span className={`text-sm font-bold uppercase tracking-tight ${isD101 ? 'text-blue-700' : 'text-gray-400'}`}>
{room.name} {isD101 && <span className="ml-2 text-[8px] bg-blue-500 text-white px-2 py-0.5 rounded-full">REAL SENSOR</span>}
</span>
<div className={`h-3 w-3 rounded-full ${isPowerActive ? (isD101 ? 'bg-green-500 animate-pulse' : 'bg-orange-500 animate-pulse') : 'bg-gray-300'}`} />
</div>
<div className="flex items-end gap-2">
<span className={`text-3xl font-black ${isD101 ? 'text-blue-700' : 'text-gray-800'}`}>
{displayedPower.toFixed(1)}
</span>
<span className="text-gray-500 font-bold mb-1">Watts</span>
</div>
<p className="text-[10px] text-gray-400 mt-2 font-medium">
{isD101 ? "Live auto-update via IoT" : "Last updated: Just now"}
</p>
</div>
);
})}
</div>
</div>
@@ -116,12 +165,10 @@ export default function AdminDashboard() {
</thead>
<tbody className="divide-y divide-gray-50">
{pendingBookings.map((b) => (
// PERBAIKAN 2: Gunakan booking_id sebagai key
<tr key={b.booking_id} className="hover:bg-gray-50/50 transition-colors">
<td className="p-4 font-bold text-gray-800 text-sm">{b.user?.full_name || "Tanpa Nama"}</td>
<td className="p-4 text-sm font-medium text-gray-600">{b.room?.name || "Ruangan Tidak Diketahui"}</td>
{/* PERBAIKAN 3: Format Waktu Mulai dan Selesai yang Rapi */}
<td className="p-4 text-sm text-gray-600">
<div className="font-bold">
{new Date(b.start_time).toLocaleTimeString('id-ID', {hour: '2-digit', minute:'2-digit'})} - {new Date(b.end_time).toLocaleTimeString('id-ID', {hour: '2-digit', minute:'2-digit'})}
@@ -134,7 +181,6 @@ export default function AdminDashboard() {
<td className="p-4 text-sm text-gray-600 truncate max-w-60">{b.purpose}</td>
<td className="p-4">
<div className="flex justify-center gap-2">
{/* PERBAIKAN 4: Gunakan b.booking_id di fungsi onClick */}
<button
onClick={() => handleAction(b.booking_id, 'Approved')}
className="p-2 bg-green-50 text-green-600 rounded-lg hover:bg-green-100 transition-colors"
+1 -1
View File
@@ -66,7 +66,7 @@ export default function ManageRoomsPage() {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* TAMBAHKAN [...rooms].sort(...) SEBELUM .map */}
{/* [...rooms].sort memastikan urutan kelas selalu rapi dari D101, D102, dst */}
{[...rooms]
.sort((a, b) => a.name.localeCompare(b.name))
.map((room) => (
+222 -23
View File
@@ -2,12 +2,26 @@
import { useEffect, useState } from "react";
import axios from "axios";
import { CalendarDays, Plus, Trash2, Clock, MapPin, LayoutList, Calendar } from "lucide-react";
import { CalendarDays, Plus, Trash2, Clock, MapPin, LayoutList, Calendar, Edit, X, Save } from "lucide-react";
export default function SchedulesPage() {
const [schedules, setSchedules] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [viewMode, setViewMode] = useState<"table" | "calendar">("table"); // State untuk pindah mode
const [viewMode, setViewMode] = useState<"table" | "calendar">("table");
// STATE UNTUK MODAL FORM (TAMBAH & EDIT)
const [isModalOpen, setIsModalOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [formData, setFormData] = useState({
schedule_id: null as number | null,
kode_mk: "",
nama_mk: "",
room_id: 1, // Default Kelas D101
hari: "Senin",
jam_mulai: "",
jam_selesai: "",
jumlah_sks: 2,
});
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"];
@@ -31,7 +45,92 @@ export default function SchedulesPage() {
}
};
// Helper untuk mengecek apakah ada matkul di hari dan jam tertentu (Untuk Mode Kalender)
// =========================================================================
// FUNGSI CRUD (TAMBAH, EDIT, HAPUS)
// =========================================================================
// 1. Buka Modal untuk Tambah Baru
const handleAddClick = () => {
setFormData({
schedule_id: null,
kode_mk: "",
nama_mk: "",
room_id: 1,
hari: "Senin",
jam_mulai: "07:00",
jam_selesai: "08:40",
jumlah_sks: 2,
});
setIsModalOpen(true);
};
// 2. Buka Modal untuk Edit Data Lama
const handleEditClick = (sched: any) => {
setFormData({
schedule_id: sched.schedule_id,
kode_mk: sched.kode_mk,
nama_mk: sched.nama_mk,
room_id: sched.room_id,
hari: sched.hari,
jam_mulai: sched.jam_mulai.substring(0, 5), // Potong detik jika ada (07:00:00 -> 07:00)
jam_selesai: sched.jam_selesai.substring(0, 5),
jumlah_sks: sched.jumlah_sks || 2,
});
setIsModalOpen(true);
};
// 3. Simpan Data (Create atau Update ke Backend)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSaving(true);
try {
const token = localStorage.getItem("token");
const headers = { Authorization: `Bearer ${token}` };
// Format ulang jam agar aman (tambahkan detik 00)
const payload = {
...formData,
jam_mulai: formData.jam_mulai.length === 5 ? `${formData.jam_mulai}:00` : formData.jam_mulai,
jam_selesai: formData.jam_selesai.length === 5 ? `${formData.jam_selesai}:00` : formData.jam_selesai,
jumlah_sks: Number(formData.jumlah_sks)
};
if (formData.schedule_id) {
// UPDATE (PUT)
await axios.put(`http://172.17.172.17:8080/api/schedules/${formData.schedule_id}`, payload, { headers });
alert("Jadwal berhasil diperbarui!");
} else {
// CREATE BARU (POST)
await axios.post("http://172.17.172.17:8080/api/schedules", payload, { headers });
alert("Jadwal baru berhasil ditambahkan!");
}
setIsModalOpen(false);
fetchSchedules(); // Refresh data di tabel
} catch (err: any) {
console.error("Gagal menyimpan jadwal:", err);
alert("Gagal menyimpan data: " + (err.response?.data?.error || err.message));
} finally {
setIsSaving(false);
}
};
// 4. Hapus Data (Delete)
const handleDelete = async (id: number, namaMK: string) => {
if (!window.confirm(`Apakah Anda yakin ingin menghapus jadwal mata kuliah ${namaMK}?`)) return;
try {
const token = localStorage.getItem("token");
await axios.delete(`http://172.17.172.17:8080/api/schedules/${id}`, {
headers: { Authorization: `Bearer ${token}` }
});
alert("Jadwal berhasil dihapus!");
fetchSchedules();
} catch (err: any) {
alert("Gagal menghapus jadwal.");
}
};
const getMatkulDiSlot = (hari: string, jam: string) => {
return schedules.find((s) => {
if (s.hari !== hari) return false;
@@ -53,12 +152,20 @@ export default function SchedulesPage() {
<CalendarDays size={28} />
</div>
<div>
<h2 className="text-2xl font-bold text-gray-800">Jadwal Kuliah Statis</h2>
<p className="text-gray-500 text-sm mt-1">Pantau kepadatan ruang kelas D101 dan kelola jadwal otomatis.</p>
<h2 className="text-2xl font-bold text-gray-800">Edit Jadwal</h2>
<p className="text-gray-500 text-sm mt-1">Pantau dan Kelola Jadwal Ruangan.</p>
</div>
</div>
<div className="flex items-center gap-3">
{/* Tombol Tambah Jadwal */}
<button
onClick={handleAddClick}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-bold hover:bg-blue-700 transition-all shadow-sm"
>
<Plus size={16} /> Tambah Jadwal
</button>
{/* Toggle View Mode */}
<div className="bg-gray-100 p-1 rounded-lg flex items-center border border-gray-200">
<button
@@ -74,7 +181,6 @@ export default function SchedulesPage() {
<Calendar size={14} /> Mode Kalender
</button>
</div>
</div>
</div>
@@ -88,8 +194,8 @@ export default function SchedulesPage() {
<th className="p-4">Kode MK</th>
<th className="p-4">Mata Kuliah</th>
<th className="p-4">Ruangan</th>
<th className="p-4">Hari</th>
<th className="p-4">Waktu</th>
<th className="p-4">Hari & Waktu</th>
<th className="p-4">SKS</th>
<th className="p-4 text-center">Aksi</th>
</tr>
</thead>
@@ -97,24 +203,38 @@ export default function SchedulesPage() {
{schedules.length > 0 ? (
schedules.map((sched) => (
<tr key={sched.schedule_id} className="hover:bg-blue-50/30 transition-colors">
<td className="p-4 font-bold text-gray-800 text-sm">{sched.kode_mk}</td>
<td className="p-4 text-sm font-semibold text-gray-600">{sched.nama_mk}</td>
<td className="p-4 text-sm font-bold text-blue-600 flex items-center gap-1.5 mt-2">
<MapPin size={14} /> Kelas D101
</td>
<td className="p-4 text-sm font-bold text-gray-700">
<span className="bg-gray-100 px-2 py-1 rounded-md">{sched.hari}</span>
<td className="p-4 font-bold text-blue-700 text-sm">{sched.kode_mk}</td>
<td className="p-4 text-sm font-semibold text-gray-800">{sched.nama_mk}</td>
<td className="p-4 text-sm font-bold text-gray-600 flex items-center gap-1.5 mt-2">
<MapPin size={14} className="text-blue-500" /> Kelas D101
</td>
<td className="p-4 text-sm text-gray-600">
<div className="flex items-center gap-1.5 font-medium">
<Clock size={14} className="text-gray-400" />
<div className="font-bold text-gray-700">{sched.hari}</div>
<div className="flex items-center gap-1.5 font-medium text-xs mt-0.5">
<Clock size={12} className="text-gray-400" />
{sched.jam_mulai.substring(0, 5)} - {sched.jam_selesai.substring(0, 5)}
</div>
</td>
<td className="p-4 text-center">
<button className="p-2 bg-red-50 text-red-500 rounded-lg hover:bg-red-100 transition-colors">
<Trash2 size={16} />
</button>
<td className="p-4 text-sm font-bold text-gray-600 text-center">
<span className="bg-gray-100 px-2 py-1 rounded-md border">{sched.jumlah_sks}</span>
</td>
<td className="p-4">
<div className="flex items-center justify-center gap-2">
<button
onClick={() => handleEditClick(sched)}
className="p-2 bg-yellow-50 text-yellow-600 rounded-lg hover:bg-yellow-100 transition-colors"
title="Edit Jadwal"
>
<Edit size={16} />
</button>
<button
onClick={() => handleDelete(sched.schedule_id, sched.nama_mk)}
className="p-2 bg-red-50 text-red-500 rounded-lg hover:bg-red-100 transition-colors"
title="Hapus Jadwal"
>
<Trash2 size={16} />
</button>
</div>
</td>
</tr>
))
@@ -149,7 +269,11 @@ export default function SchedulesPage() {
return (
<div key={hari} className="h-full">
{mtk ? (
<div className="bg-blue-50 border-l-4 border-blue-600 p-2 rounded text-left shadow-xs h-full flex flex-col justify-between">
<div
onClick={() => handleEditClick(mtk)}
className="bg-blue-50 border-l-4 border-blue-600 p-2 rounded text-left shadow-xs h-full flex flex-col justify-between cursor-pointer hover:bg-blue-100 transition-colors"
title="Klik untuk Edit"
>
<span className="text-xs font-bold text-blue-800 block truncate">{mtk.nama_mk}</span>
<span className="text-[10px] font-semibold text-blue-500">{mtk.kode_mk}</span>
</div>
@@ -166,7 +290,82 @@ export default function SchedulesPage() {
</div>
</div>
)}
{/* ==================================== MODAL FORM CRUD ==================================== */}
{isModalOpen && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl w-full max-w-lg shadow-xl overflow-hidden">
<div className="flex justify-between items-center p-5 border-b border-gray-100 bg-gray-50/50">
<h3 className="font-bold text-lg text-gray-800">
{formData.schedule_id ? "Edit Jadwal Kuliah" : "Tambah Jadwal Kuliah Baru"}
</h3>
<button onClick={() => setIsModalOpen(false)} className="text-gray-400 hover:text-red-500">
<X size={20} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-gray-600 mb-1">Kode MK</label>
<input type="text" required value={formData.kode_mk} onChange={(e) => setFormData({...formData, kode_mk: e.target.value.toUpperCase()})}
className="w-full border border-gray-300 rounded-lg p-2.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none uppercase"
placeholder="Contoh: EE153"
/>
</div>
<div>
<label className="block text-xs font-bold text-gray-600 mb-1">Jumlah SKS</label>
<input type="number" min="1" max="6" required value={formData.jumlah_sks} onChange={(e) => setFormData({...formData, jumlah_sks: Number(e.target.value)})}
className="w-full border border-gray-300 rounded-lg p-2.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none"
/>
</div>
</div>
<div>
<label className="block text-xs font-bold text-gray-600 mb-1">Nama Mata Kuliah</label>
<input type="text" required value={formData.nama_mk} onChange={(e) => setFormData({...formData, nama_mk: e.target.value})}
className="w-full border border-gray-300 rounded-lg p-2.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none"
placeholder="Contoh: Elektronika dan Sistem Digital"
/>
</div>
<div>
<label className="block text-xs font-bold text-gray-600 mb-1">Hari Pelaksanaan</label>
<select value={formData.hari} onChange={(e) => setFormData({...formData, hari: e.target.value})}
className="w-full border border-gray-300 rounded-lg p-2.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none bg-white">
{hariUrutan.map(h => <option key={h} value={h}>{h}</option>)}
<option value="Sabtu">Sabtu</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-gray-600 mb-1">Jam Mulai</label>
<input type="time" required value={formData.jam_mulai} onChange={(e) => setFormData({...formData, jam_mulai: e.target.value})}
className="w-full border border-gray-300 rounded-lg p-2.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none"
/>
</div>
<div>
<label className="block text-xs font-bold text-gray-600 mb-1">Jam Selesai</label>
<input type="time" required value={formData.jam_selesai} onChange={(e) => setFormData({...formData, jam_selesai: e.target.value})}
className="w-full border border-gray-300 rounded-lg p-2.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none"
/>
</div>
</div>
<div className="pt-4 flex gap-3">
<button type="button" onClick={() => setIsModalOpen(false)} className="flex-1 py-2.5 rounded-lg text-sm font-bold text-gray-600 bg-gray-100 hover:bg-gray-200 transition-colors">
Batal
</button>
<button type="submit" disabled={isSaving} className={`flex-1 py-2.5 rounded-lg text-sm font-bold text-white transition-colors flex items-center justify-center gap-2 ${isSaving ? 'bg-blue-400' : 'bg-blue-600 hover:bg-blue-700'}`}>
<Save size={16} /> {isSaving ? "Menyimpan..." : "Simpan Jadwal"}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
+15 -3
View File
@@ -92,11 +92,23 @@ export default function AddBookingPage() {
onChange={(e) => setSelectedRoomId(e.target.value)}
>
<option value="" disabled>-- Pilih Ruangan yang Tersedia --</option>
{rooms.map(room => (
<option key={room.room_id} value={room.room_id}>
{room.name} (Kapasitas: {room.capacity} | {room.floor})
{/* PERBAIKAN: Sortir Ruangan & Disable yang berstatus Maintenance */}
{[...rooms]
.sort((a, b) => a.name.localeCompare(b.name))
.map((room) => (
<option
key={room.room_id}
value={room.room_id}
disabled={room.status === 'Maintenance'}
className={room.status === 'Maintenance' ? 'text-gray-400 bg-gray-100' : 'text-gray-900'}
>
{room.name} {room.status === 'Maintenance'
? '[TIDAK TERSEDIA - SEDANG PERAWATAN]'
: `(Kapasitas: ${room.capacity} | ${room.floor})`}
</option>
))}
</select>
</div>
</div>
+1 -1
View File
@@ -119,7 +119,7 @@ export default function Dashboard() {
className="flex items-center gap-2 bg-white border border-gray-300 text-gray-700 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300 px-4 py-2 rounded-lg text-sm font-medium shadow-sm transition-all"
>
<Clock size={18} />
Lihat Riwayat Booking
Riwayat Peminjaman
</button>
</div>