Compare commits

..
11 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
12 changed files with 543 additions and 149 deletions
+13 -14
View File
@@ -14,12 +14,12 @@ func main() {
// 1. Konek Database // 1. Konek Database
config.ConnectDatabase() config.ConnectDatabase()
// 2. AutoMigrate (Tambahkan ClassSchedule di sini agar dikenali GORM) // 2. AutoMigrate
config.DB.AutoMigrate(&models.User{}, &models.Room{}, &models.Booking{}, &models.ClassSchedule{}) config.DB.AutoMigrate(&models.User{}, &models.Room{}, &models.Booking{}, &models.ClassSchedule{})
r := gin.Default() r := gin.Default()
// 3. CORS Middleware (Agar Frontend bisa masuk) // 3. CORS Middleware (Panggil fungsinya di sini)
r.Use(CORSMiddleware()) r.Use(CORSMiddleware())
r.GET("/", func(c *gin.Context) { r.GET("/", func(c *gin.Context) {
@@ -58,35 +58,34 @@ func main() {
// Admin (Manage Rooms) // Admin (Manage Rooms)
protected.PUT("/admin/rooms/:id/status", controllers.UpdateRoomStatus) 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.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) r.POST("/api/sensor/energy", controllers.UpdateRoomPower)
// RUTE BARU: Untuk menerima ketukan pintu dari Hardware ESP32
r.POST("/api/hardware/verify", controllers.VerifyHardwareCode) r.POST("/api/hardware/verify", controllers.VerifyHardwareCode)
// PASTIKAN BARIS INI ADA DI SINI:
r.POST("/api/hardware/control", controllers.ControlHardware) r.POST("/api/hardware/control", controllers.ControlHardware)
r.GET("/api/hardware/status", controllers.GetHardwareStatus) r.GET("/api/hardware/status", controllers.GetHardwareStatus)
r.GET("/api/hardware/power-status", controllers.GetPowerStatus) r.GET("/api/hardware/power-status", controllers.GetPowerStatus)
r.POST("/api/power/global", controllers.GlobalPowerControl) r.POST("/api/power/global", controllers.GlobalPowerControl)
r.Run(":8080") r.Run(":8080")
} }
// ... (fungsi CORSMiddleware tetap sama) ... // =========================================================================
// FUNGSI MIDDLEWARE HARUS DITULIS DI LUAR FUNGSI MAIN
// =========================================================================
func CORSMiddleware() gin.HandlerFunc { func CORSMiddleware() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*") c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") 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-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" { if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204) c.AbortWithStatus(204)
+19 -5
View File
@@ -45,7 +45,7 @@ func CreateBooking(c *gin.Context) {
return return
} }
// 🌟 PERBAIKAN 1: Ambil Role User dari Token // 🌟 Ambil Role User dari Token
userRoleInterface, _ := c.Get("role") userRoleInterface, _ := c.Get("role")
userRole, _ := userRoleInterface.(string) userRole, _ := userRoleInterface.(string)
@@ -55,6 +55,21 @@ func CreateBooking(c *gin.Context) {
return 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) // Overlap Check (Mencegah bentrok jadwal)
var count int64 var count int64
config.DB.Model(&models.Booking{}).Where( config.DB.Model(&models.Booking{}).Where(
@@ -67,7 +82,7 @@ func CreateBooking(c *gin.Context) {
return return
} }
// 🌟 PERBAIKAN 1 Lanjutan: Penentuan Status Otomatis // Penentuan Status Otomatis
statusPeminjaman := "Pending" // Default untuk student statusPeminjaman := "Pending" // Default untuk student
if userRole == "lecturer" { if userRole == "lecturer" {
statusPeminjaman = "Approved" // Dosen otomatis disetujui! statusPeminjaman = "Approved" // Dosen otomatis disetujui!
@@ -80,7 +95,7 @@ func CreateBooking(c *gin.Context) {
StartTime: input.StartTime, StartTime: input.StartTime,
EndTime: input.EndTime, EndTime: input.EndTime,
Purpose: input.Purpose, Purpose: input.Purpose,
Status: statusPeminjaman, // <-- Gunakan variabel statusPeminjaman Status: statusPeminjaman,
} }
if err := config.DB.Create(&booking).Error; err != nil { 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) { func GetMyBookings(c *gin.Context) {
var bookings []models.Booking var bookings []models.Booking
@@ -150,7 +165,6 @@ func UpdateBookingStatus(c *gin.Context) {
booking.Status = input.Status booking.Status = input.Status
// 🌟 PERBAIKAN 2: Menghapus blok Save dan JSON ganda yang bikin error
if err := config.DB.Save(&booking).Error; err != nil { if err := config.DB.Save(&booking).Error; err != nil {
fmt.Println("🔥 ERROR DARI DATABASE:", err.Error()) fmt.Println("🔥 ERROR DARI DATABASE:", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+58 -32
View File
@@ -42,7 +42,7 @@ var DeviceStatusCache = map[string]string{
} }
// ========================================================================= // =========================================================================
// FUNGSI 1: VERIFIKASI TOKEN & KODE MATA KULIAH (ON-DEMAND) // FUNGSI 1: VERIFIKASI TOKEN & KODE MATA KULIAH (DOUBLE-LAYER PROTECTION)
// ========================================================================= // =========================================================================
func VerifyHardwareCode(c *gin.Context) { func VerifyHardwareCode(c *gin.Context) {
var req VerifyRequest var req VerifyRequest
@@ -63,11 +63,11 @@ func VerifyHardwareCode(c *gin.Context) {
// ========================================================= // =========================================================
if tokenInput == "CS2026" { if tokenInput == "CS2026" {
fmt.Printf("[VERIFY] Master Token CS digunakan\n") fmt.Printf("[VERIFY] Master Token CS digunakan\n")
sisaMenit = 120 sisaMenit = 60
isTokenValid = true isTokenValid = true
} else if tokenInput == "ADM999" { } else if tokenInput == "ADM999" {
fmt.Printf("[VERIFY] Master Token Admin digunakan\n") fmt.Printf("[VERIFY] Master Token Admin digunakan\n")
sisaMenit = 720 // 24 Jam sisaMenit = 1440 // 12 jam
isTokenValid = true isTokenValid = true
} else { } else {
// ========================================================= // =========================================================
@@ -77,15 +77,14 @@ func VerifyHardwareCode(c *gin.Context) {
errBooking := config.DB.First(&booking, "redeem_code = ? AND status = 'Approved'", tokenInput).Error errBooking := config.DB.First(&booking, "redeem_code = ? AND status = 'Approved'", tokenInput).Error
if errBooking == nil { if errBooking == nil {
// Cek apakah hari ini (tanggal) sama dengan hari booking
if booking.StartTime.Format("2006-01-02") != sekarang.Format("2006-01-02") { if booking.StartTime.Format("2006-01-02") != sekarang.Format("2006-01-02") {
c.JSON(http.StatusForbidden, gin.H{"error": "Peminjaman bukan untuk hari ini"}) c.JSON(http.StatusForbidden, gin.H{"error": "Peminjaman bukan untuk hari ini!"})
return return
} }
toleransiMasuk := booking.StartTime.Add(-15 * time.Minute) toleransiMasuk := booking.StartTime.Add(-15 * time.Minute)
if sekarang.Before(toleransiMasuk) { if sekarang.Before(toleransiMasuk) {
c.JSON(http.StatusForbidden, gin.H{"error": "Belum waktunya. Bisa masuk 15 mnt sblm jam peminjaman!"}) c.JSON(http.StatusForbidden, gin.H{"error": "Belum waktunya. Bisa masuk 15 menit sebelum kelas!"})
return return
} }
if sekarang.After(booking.EndTime) { if sekarang.After(booking.EndTime) {
@@ -101,40 +100,74 @@ func VerifyHardwareCode(c *gin.Context) {
} else { } else {
// ========================================================= // =========================================================
// 3. CEK KODE MATA KULIAH (ON-DEMAND FLEKSIBEL) // 3. CEK KODE MATA KULIAH DENGAN DOUBLE-LAYER PROTECTION
// ========================================================= // =========================================================
var schedule models.ClassSchedule var reqSchedule models.ClassSchedule
// Abaikan hari dan jam, cukup cek apakah kode matkul eksis di database // Cari apakah Kode MK yang diketik eksis di database (contoh: "EE153")
errSchedule := config.DB.First(&schedule, "kode_mk = ?", tokenInput).Error errSchedule := config.DB.First(&reqSchedule, "kode_mk = ?", tokenInput).Error
if errSchedule == nil { if errSchedule == nil {
// KODE MATKUL EKSIS. // PROTEKSI LAPIS 1: Cek apakah ada yang sedang Booking via Web sekarang
// CEK BENTROK: Apakah ada orang yang SEDANG booking via web sekarang?
var countBookingAktif int64 var countBookingAktif int64
config.DB.Model(&models.Booking{}).Where( config.DB.Model(&models.Booking{}).Where(
"room_id = ? AND status = 'Approved' AND start_time <= ? AND end_time >= ?", "room_id = ? AND status = 'Approved' AND start_time <= ? AND end_time >= ?",
schedule.RoomID, sekarang, sekarang, reqSchedule.RoomID, sekarang, sekarang,
).Count(&countBookingAktif) ).Count(&countBookingAktif)
if countBookingAktif > 0 { if countBookingAktif > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "Ruangan sedang dipakai peminjam resmi (Web)!"}) c.JSON(http.StatusConflict, gin.H{"error": "Akses Ditolak: Ruangan sedang dipakai peminjam resmi (Web)!"})
return return
} }
// Jika aman (tidak ada yang booking), berikan akses 120 Menit! // PROTEKSI LAPIS 2: Cek apakah ada JADWAL KELAS LAIN yang sedang berjalan di jam ini
sisaMenit = 120 // 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 isTokenValid = true
} }
} }
if !isTokenValid { if !isTokenValid {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token atau Kode MK tidak ditemukan / salah!"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "Token Web atau Kode MK tidak ditemukan!"})
return return
} }
} }
// ========================================================= // =========================================================
// 4. JIKA VALID (Master / Matkul / Web), BUKA GEMBOK VIA MQTT // 4. JIKA VALID, BUKA GEMBOK VIA MQTT & HTTP LOKAL
// ========================================================= // =========================================================
DeviceStatusCache["lampu1"] = "on" DeviceStatusCache["lampu1"] = "on"
DeviceStatusCache["lampu2"] = "on" DeviceStatusCache["lampu2"] = "on"
@@ -152,6 +185,7 @@ func VerifyHardwareCode(c *gin.Context) {
client := mqtt.NewClient(opts) client := mqtt.NewClient(opts)
if tokenMQTT := client.Connect(); tokenMQTT.Wait() && tokenMQTT.Error() == nil { if tokenMQTT := client.Connect(); tokenMQTT.Wait() && tokenMQTT.Error() == nil {
client.Publish("sclass/d101/auth", 0, true, "UNLOCK").Wait() 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/lampu1", 0, false, "on").Wait()
client.Publish("sclass/d101/lampu2", 0, false, "on").Wait() client.Publish("sclass/d101/lampu2", 0, false, "on").Wait()
client.Disconnect(250) client.Disconnect(250)
@@ -360,14 +394,10 @@ func GlobalPowerControl(c *gin.Context) {
} }
apiURL := fmt.Sprintf("%s/api/services/switch/%s", haURL, service) apiURL := fmt.Sprintf("%s/api/services/switch/%s", haURL, service)
mcbs := []string{ // 🌟 PERBAIKAN: Hapus array dan loop, cukup gunakan 1 target saja
"switch.wifi_smart_meter_pro_switch", // Umum targetSwitch := "switch.wifi_smart_meter_pro_switch"
"switch.wifi_smart_meter_pro_2_switch", // AC1
"switch.wifi_smart_meter_pro_3_switch", // AC2
}
for i, mcb := range mcbs { payload := map[string]string{"entity_id": targetSwitch}
payload := map[string]string{"entity_id": mcb}
jsonPayload, _ := json.Marshal(payload) jsonPayload, _ := json.Marshal(payload)
reqHA, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonPayload)) reqHA, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonPayload))
@@ -382,13 +412,9 @@ func GlobalPowerControl(c *gin.Context) {
} }
} }
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" { if req.Action == "off" {
DeviceStatusCache["lampu1"] = "off" DeviceStatusCache["lampu1"] = "off"
DeviceStatusCache["lampu2"] = "off" DeviceStatusCache["lampu2"] = "off"
@@ -398,7 +424,7 @@ func GlobalPowerControl(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"status": "success", "status": "success",
"message": fmt.Sprintf("Seluruh daya ruangan berhasil di-%s secara berurutan", req.Action), "message": fmt.Sprintf("Daya utama berhasil di-%s", req.Action),
}) })
} }
+109 -6
View File
@@ -2,15 +2,26 @@ package controllers
import ( import (
"net/http" "net/http"
"github.com/gin-gonic/gin"
// UBAH DUA BARIS INI MENJADI s-class-backend
"s-class-backend/config" "s-class-backend/config"
"s-class-backend/models" "s-class-backend/models"
"github.com/gin-gonic/gin"
) )
// 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) { func GetSchedules(c *gin.Context) {
var schedules []models.ClassSchedule var schedules []models.ClassSchedule
@@ -20,8 +31,100 @@ func GetSchedules(c *gin.Context) {
return return
} }
// Kirim data ke frontend (React)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"data": schedules, "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"` 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"` 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"` 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'}`}> <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} /> <CalendarDays size={22} />
</div> </div>
<span>Jadwal Kuliah</span> <span>Edit Jadwal</span>
</Link> </Link>
</nav> </nav>
+2 -8
View File
@@ -115,13 +115,11 @@ export default function PowerMonitoringPage() {
if (!window.confirm(confirmMsg)) return; if (!window.confirm(confirmMsg)) return;
} }
// Jika yang ditekan bukan D101, ubah UI saja (simulasi)
if (roomName !== "Kelas D101") { if (roomName !== "Kelas D101") {
setRoomDeviceStatus(prev => ({ ...prev, [roomIdKey]: { ...prev[roomIdKey], [deviceName]: !currentStatus } })); setRoomDeviceStatus(prev => ({ ...prev, [roomIdKey]: { ...prev[roomIdKey], [deviceName]: !currentStatus } }));
return; return;
} }
// Logika khusus D101 (Tembak ke Golang)
let backendDevice = ""; let backendDevice = "";
if (deviceName === 'AC') backendDevice = "ac"; if (deviceName === 'AC') backendDevice = "ac";
else if (deviceName === 'Proyektor') backendDevice = "projector"; 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) => { const handleGlobalPower = async (roomName: string, roomId: number, currentRelayStatus: boolean) => {
if (roomName === "Kelas D101") { if (roomName === "Kelas D101") {
const actionType = currentRelayStatus ? "off" : "on"; const actionType = currentRelayStatus ? "off" : "on";
const confirmMessage = currentRelayStatus const confirmMessage = currentRelayStatus
? `PERINGATAN FATAL: Anda yakin ingin MEMUTUS 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 3 MCB Daya di ${roomName}? (Proses berurutan ~3 detik)`; : `RESTORE POWER: Anda yakin ingin MENYALAKAN KEMBALI MCB Daya di ${roomName}?`;
if (!window.confirm(confirmMessage)) return; if (!window.confirm(confirmMessage)) return;
// Ubah UI seketika
setRooms(prev => prev.map(r => r.name === roomName ? { ...r, isRelayOn: !currentRelayStatus } : r)); setRooms(prev => prev.map(r => r.name === roomName ? { ...r, isRelayOn: !currentRelayStatus } : r));
try { try {
@@ -172,7 +168,6 @@ export default function PowerMonitoringPage() {
fetchPowerStatus(); fetchPowerStatus();
fetchDeviceStatus(); fetchDeviceStatus();
} else { } else {
// Rollback UI jika gagal
setRooms(prev => prev.map(r => r.name === roomName ? { ...r, isRelayOn: currentRelayStatus } : r)); setRooms(prev => prev.map(r => r.name === roomName ? { ...r, isRelayOn: currentRelayStatus } : r));
alert("Gagal mengontrol MCB utama. Cek koneksi server."); alert("Gagal mengontrol MCB utama. Cek koneksi server.");
} }
@@ -181,7 +176,6 @@ export default function PowerMonitoringPage() {
alert("Terjadi kesalahan jaringan saat menghubungi server."); alert("Terjadi kesalahan jaringan saat menghubungi server.");
} }
} else { } else {
// Dummy untuk kelas lain
const actionType = currentRelayStatus ? "Mematikan" : "Menyalakan"; const actionType = currentRelayStatus ? "Mematikan" : "Menyalakan";
if (window.confirm(`Simulasi ${actionType} daya di ${roomName}?`)) { if (window.confirm(`Simulasi ${actionType} daya di ${roomName}?`)) {
setRooms(prev => prev.map(r => r.name === roomName ? { ...r, isRelayOn: !currentRelayStatus, power: 0 } : r)); setRooms(prev => prev.map(r => r.name === roomName ? { ...r, isRelayOn: !currentRelayStatus, power: 0 } : r));
+62 -16
View File
@@ -9,13 +9,46 @@ export default function AdminDashboard() {
const [roomStats, setRoomStats] = useState<any[]>([]); const [roomStats, setRoomStats] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// STATE BARU: Khusus menyimpan TOTAL DAYA D101
const [d101TotalPower, setD101TotalPower] = useState<number>(0);
useEffect(() => { useEffect(() => {
fetchAdminData(); fetchAdminData();
// Refresh data konsumsi daya setiap 10 detik agar terlihat real-time fetchD101Power(); // Panggil fungsi daya saat pertama kali render
const interval = setInterval(fetchAdminData, 10000);
return () => clearInterval(interval); // 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 () => { const fetchAdminData = async () => {
try { try {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
@@ -37,7 +70,7 @@ export default function AdminDashboard() {
const roomsWithDummyPower = (roomRes.data.data || []).map((room: any) => ({ const roomsWithDummyPower = (roomRes.data.data || []).map((room: any) => ({
...room, ...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) power_consumption: Math.floor(Math.random() * 15)
})); }));
setRoomStats(roomsWithDummyPower); 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) => { const handleAction = async (id: string, status: string) => {
try { try {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
@@ -76,22 +108,39 @@ export default function AdminDashboard() {
<Activity className="text-blue-500" /> Real-time Room Energy <Activity className="text-blue-500" /> Real-time Room Energy
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* TAMBAHKAN PENGURUTAN ABJAD DI SINI */}
{[...roomStats] {[...roomStats]
.sort((a, b) => a.name.localeCompare(b.name)) .sort((a, b) => a.name.localeCompare(b.name))
.map((room) => ( .map((room) => {
<div key={room.room_id} className="bg-white p-5 rounded-xl border border-gray-100 shadow-sm">
// 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"> <div className="flex justify-between items-start mb-3">
<span className="text-sm font-bold text-gray-400 uppercase tracking-tight">{room.name}</span> <span className={`text-sm font-bold uppercase tracking-tight ${isD101 ? 'text-blue-700' : 'text-gray-400'}`}>
<div className={`h-3 w-3 rounded-full ${room.power_consumption > 5 ? 'bg-orange-500 animate-pulse' : 'bg-gray-300'}`} /> {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>
<div className="flex items-end gap-2"> <div className="flex items-end gap-2">
<span className="text-3xl font-black text-gray-800">{room.power_consumption || 0}</span> <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> <span className="text-gray-500 font-bold mb-1">Watts</span>
</div> </div>
<p className="text-[10px] text-gray-400 mt-2 font-medium">Last updated: Just now</p>
<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> </div>
</div> </div>
@@ -116,12 +165,10 @@ export default function AdminDashboard() {
</thead> </thead>
<tbody className="divide-y divide-gray-50"> <tbody className="divide-y divide-gray-50">
{pendingBookings.map((b) => ( {pendingBookings.map((b) => (
// PERBAIKAN 2: Gunakan booking_id sebagai key
<tr key={b.booking_id} className="hover:bg-gray-50/50 transition-colors"> <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 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> <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"> <td className="p-4 text-sm text-gray-600">
<div className="font-bold"> <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'})} {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 text-sm text-gray-600 truncate max-w-60">{b.purpose}</td>
<td className="p-4"> <td className="p-4">
<div className="flex justify-center gap-2"> <div className="flex justify-center gap-2">
{/* PERBAIKAN 4: Gunakan b.booking_id di fungsi onClick */}
<button <button
onClick={() => handleAction(b.booking_id, 'Approved')} onClick={() => handleAction(b.booking_id, 'Approved')}
className="p-2 bg-green-50 text-green-600 rounded-lg hover:bg-green-100 transition-colors" 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>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <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] {[...rooms]
.sort((a, b) => a.name.localeCompare(b.name)) .sort((a, b) => a.name.localeCompare(b.name))
.map((room) => ( .map((room) => (
+219 -20
View File
@@ -2,12 +2,26 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import axios from "axios"; 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() { export default function SchedulesPage() {
const [schedules, setSchedules] = useState<any[]>([]); const [schedules, setSchedules] = useState<any[]>([]);
const [loading, setLoading] = useState(true); 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 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"]; 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) => { const getMatkulDiSlot = (hari: string, jam: string) => {
return schedules.find((s) => { return schedules.find((s) => {
if (s.hari !== hari) return false; if (s.hari !== hari) return false;
@@ -53,12 +152,20 @@ export default function SchedulesPage() {
<CalendarDays size={28} /> <CalendarDays size={28} />
</div> </div>
<div> <div>
<h2 className="text-2xl font-bold text-gray-800">Jadwal Kuliah Statis</h2> <h2 className="text-2xl font-bold text-gray-800">Edit Jadwal</h2>
<p className="text-gray-500 text-sm mt-1">Pantau kepadatan ruang kelas D101 dan kelola jadwal otomatis.</p> <p className="text-gray-500 text-sm mt-1">Pantau dan Kelola Jadwal Ruangan.</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <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 */} {/* Toggle View Mode */}
<div className="bg-gray-100 p-1 rounded-lg flex items-center border border-gray-200"> <div className="bg-gray-100 p-1 rounded-lg flex items-center border border-gray-200">
<button <button
@@ -74,7 +181,6 @@ export default function SchedulesPage() {
<Calendar size={14} /> Mode Kalender <Calendar size={14} /> Mode Kalender
</button> </button>
</div> </div>
</div> </div>
</div> </div>
@@ -88,8 +194,8 @@ export default function SchedulesPage() {
<th className="p-4">Kode MK</th> <th className="p-4">Kode MK</th>
<th className="p-4">Mata Kuliah</th> <th className="p-4">Mata Kuliah</th>
<th className="p-4">Ruangan</th> <th className="p-4">Ruangan</th>
<th className="p-4">Hari</th> <th className="p-4">Hari & Waktu</th>
<th className="p-4">Waktu</th> <th className="p-4">SKS</th>
<th className="p-4 text-center">Aksi</th> <th className="p-4 text-center">Aksi</th>
</tr> </tr>
</thead> </thead>
@@ -97,24 +203,38 @@ export default function SchedulesPage() {
{schedules.length > 0 ? ( {schedules.length > 0 ? (
schedules.map((sched) => ( schedules.map((sched) => (
<tr key={sched.schedule_id} className="hover:bg-blue-50/30 transition-colors"> <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 font-bold text-blue-700 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-semibold text-gray-800">{sched.nama_mk}</td>
<td className="p-4 text-sm font-bold text-blue-600 flex items-center gap-1.5 mt-2"> <td className="p-4 text-sm font-bold text-gray-600 flex items-center gap-1.5 mt-2">
<MapPin size={14} /> Kelas D101 <MapPin size={14} className="text-blue-500" /> 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> </td>
<td className="p-4 text-sm text-gray-600"> <td className="p-4 text-sm text-gray-600">
<div className="flex items-center gap-1.5 font-medium"> <div className="font-bold text-gray-700">{sched.hari}</div>
<Clock size={14} className="text-gray-400" /> <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)} {sched.jam_mulai.substring(0, 5)} - {sched.jam_selesai.substring(0, 5)}
</div> </div>
</td> </td>
<td className="p-4 text-center"> <td className="p-4 text-sm font-bold text-gray-600 text-center">
<button className="p-2 bg-red-50 text-red-500 rounded-lg hover:bg-red-100 transition-colors"> <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} /> <Trash2 size={16} />
</button> </button>
</div>
</td> </td>
</tr> </tr>
)) ))
@@ -149,7 +269,11 @@ export default function SchedulesPage() {
return ( return (
<div key={hari} className="h-full"> <div key={hari} className="h-full">
{mtk ? ( {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-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> <span className="text-[10px] font-semibold text-blue-500">{mtk.kode_mk}</span>
</div> </div>
@@ -167,6 +291,81 @@ 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> </div>
); );
} }
+15 -3
View File
@@ -92,11 +92,23 @@ export default function AddBookingPage() {
onChange={(e) => setSelectedRoomId(e.target.value)} onChange={(e) => setSelectedRoomId(e.target.value)}
> >
<option value="" disabled>-- Pilih Ruangan yang Tersedia --</option> <option value="" disabled>-- Pilih Ruangan yang Tersedia --</option>
{rooms.map(room => (
<option key={room.room_id} value={room.room_id}> {/* PERBAIKAN: Sortir Ruangan & Disable yang berstatus Maintenance */}
{room.name} (Kapasitas: {room.capacity} | {room.floor}) {[...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> </option>
))} ))}
</select> </select>
</div> </div>
</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" 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} /> <Clock size={18} />
Lihat Riwayat Booking Riwayat Peminjaman
</button> </button>
</div> </div>