riwayat schedule dan manual

This commit is contained in:
rendygaafk 2025-05-03 14:48:44 +07:00
parent 5d76037f94
commit aa86766800
27 changed files with 2792 additions and 2480 deletions

View File

@ -0,0 +1,20 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
class BellRingEvent implements ShouldBroadcast {
use Dispatchable, InteractsWithSockets;
public $data;
public function __construct(array $data) {
$this->data = $data;
}
public function broadcastOn() {
return new Channel('bell-channel');
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\BellHistory;
use Illuminate\Http\Request;
class BellController extends Controller
{
/**
* Menyimpan data event bel dari ESP32
*/
public function storeScheduleEvent(Request $request)
{
$validated = $request->validate([
'hari' => 'required|in:Senin,Selasa,Rabu,Kamis,Jumat,Sabtu,Minggu',
'waktu' => 'required|date_format:H:i:s',
'file_number' => 'required|string|size:4',
'volume' => 'sometimes|integer|min:0|max:30',
'repeat' => 'sometimes|integer|min:1|max:5'
]);
// Tambahkan trigger_type secara otomatis
$validated['trigger_type'] = 'schedule';
$validated['ring_time'] = now();
$history = BellHistory::create($validated);
return response()->json([
'success' => true,
'data' => $history
], 201);
}
/**
* Mengambil data history untuk ESP32 (jika diperlukan)
*/
public function getHistory(Request $request)
{
$histories = BellHistory::orderBy('ring_time', 'desc')
->limit(50)
->get();
return response()->json([
'success' => true,
'data' => $histories
]);
}
}

View File

@ -4,12 +4,13 @@
use App\Models\Announcement;
use App\Models\Ruangan;
use App\Services\MqttService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use PhpMqtt\Client\Facades\MQTT;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Storage;
use Carbon\Carbon;
class AnnouncementController extends Controller
{
@ -17,36 +18,89 @@ class AnnouncementController extends Controller
const MODE_REGULER = 'reguler';
const MODE_TTS = 'tts';
// Relay constants
const RELAY_ON = 'ON';
const RELAY_OFF = 'OFF';
// TTS API constants
const TTS_API_URL = 'http://api.voicerss.org/';
const TTS_API_KEY = '90927de8275148d79080facd20fb486c';
const TTS_DEFAULT_VOICE = 'id-id';
const TTS_DEFAULT_SPEED = 0; // -10 to 10
const TTS_DEFAULT_SPEED = 0;
const TTS_DEFAULT_FORMAT = 'wav';
/**
* Show the announcement form
*/
protected $mqttService;
protected $mqttConfig;
public function __construct(MqttService $mqttService)
{
$this->mqttService = $mqttService;
$this->mqttConfig = config('mqtt');
$this->initializeMqttSubscriptions();
}
protected function initializeMqttSubscriptions()
{
try {
$this->mqttService->subscribe(
$this->mqttConfig['topics']['responses']['announcement_ack'],
function (string $topic, string $message) {
$this->handleAnnouncementAck($message);
}
);
$this->mqttService->subscribe(
$this->mqttConfig['topics']['responses']['announcement_error'],
function (string $topic, string $message) {
$this->handleAnnouncementError($message);
}
);
$this->mqttService->subscribe(
$this->mqttConfig['topics']['responses']['relay_status'],
function (string $topic, string $message) {
$this->handleRelayStatusUpdate($message);
}
);
} catch (\Exception $e) {
Log::error('MQTT Subscription Error: ' . $e->getMessage());
}
}
public function index()
{
$ruangan = Ruangan::with(['kelas', 'jurusan'])->get();
$announcements = Announcement::with(['user', 'ruangans'])
$announcements = Announcement::with(['ruangans'])
->latest()
->paginate(10);
try {
$mqttStatus = $this->mqttService->isConnected() ? 'Connected' : 'Disconnected';
} catch (\Exception $e) {
$mqttStatus = 'Disconnected';
Log::error('MQTT check failed: ' . $e->getMessage());
}
return view('admin.announcement.index', [
'ruangan' => $ruangan,
'ruangans' => $ruangan,
'announcements' => $announcements,
'modes' => [self::MODE_REGULER, self::MODE_TTS]
'modes' => [self::MODE_REGULER, self::MODE_TTS],
'relayStates' => [self::RELAY_ON, self::RELAY_OFF],
'mqttStatus' => $mqttStatus
]);
}
/**
* Handle the announcement request
*/
public function store(Request $request)
{
$validator = $this->validateRequest($request);
$validator = Validator::make($request->all(), [
'mode' => 'required|in:reguler,tts',
'ruangans' => 'required|array',
'ruangans.*' => 'exists:ruangan,id',
'relay_action' => 'required_if:mode,reguler|in:ON,OFF',
'tts_text' => 'required_if:mode,tts|string|max:1000',
'tts_voice' => 'required_if:mode,tts',
'tts_speed' => 'required_if:mode,tts|integer|min:-10|max:10',
]);
if ($validator->fails()) {
return redirect()->back()
@ -54,152 +108,119 @@ public function store(Request $request)
->withInput();
}
$mode = $request->input('mode');
try {
if ($mode === self::MODE_REGULER) {
$this->handleRegularAnnouncement($request);
$message = 'Pengumuman reguler berhasil dikirim ke ruangan terpilih.';
} elseif ($mode === self::MODE_TTS) {
$this->handleTTSAnnouncement($request);
$message = 'Pengumuman TTS berhasil diproses dan dikirim.';
$announcement = new Announcement();
$announcement->mode = $request->mode;
if ($request->mode === self::MODE_REGULER) {
$announcement->message = $request->relay_action === self::RELAY_ON
? 'Aktivasi Relay Ruangan'
: 'Deaktivasi Relay Ruangan';
$announcement->is_active = $request->relay_action === self::RELAY_ON;
$announcement->relay_state = $request->relay_action;
} else {
$audioContent = $this->generateTTS(
$request->tts_text,
$request->tts_voice,
$request->tts_speed
);
if (!$audioContent) {
throw new \Exception('Failed to generate TTS audio');
}
return redirect()->route('admin.announcement.index')->with('success', $message);
$fileName = 'tts/' . now()->format('YmdHis') . '.wav';
Storage::disk('public')->put($fileName, $audioContent);
$announcement->message = $request->tts_text;
$announcement->audio_path = $fileName;
$announcement->voice = $request->tts_voice;
$announcement->speed = $request->tts_speed;
$announcement->relay_state = self::RELAY_OFF; // Default untuk TTS
}
$announcement->sent_at = now();
$announcement->status = 'pending';
if (!$announcement->save()) {
throw new \Exception('Failed to save announcement');
}
$existingRuangan = Ruangan::whereIn('id', $request->ruangans)->pluck('id');
if ($existingRuangan->count() != count($request->ruangans)) {
throw new \Exception('Some selected ruangan not found');
}
$announcement->ruangans()->sync($existingRuangan);
$this->publishAnnouncement($announcement);
return redirect()->route('announcement.index')
->with('success', 'Pengumuman berhasil dikirim');
} catch (\Exception $e) {
Log::error('Announcement error: ' . $e->getMessage());
Log::error('Announcement Error: ' . $e->getMessage());
if (isset($announcement) && $announcement->exists) {
$announcement->delete();
}
return redirect()->back()
->with('error', 'Terjadi kesalahan saat memproses pengumuman: ' . $e->getMessage())
->with('error', 'Gagal: ' . $e->getMessage())
->withInput();
}
}
/**
* Validate the announcement request
*/
private function validateRequest(Request $request)
protected function publishAnnouncement(Announcement $announcement)
{
$rules = [
'mode' => 'required|in:reguler,tts',
];
if ($request->input('mode') === self::MODE_REGULER) {
$rules['ruangan'] = 'required|array';
$rules['ruangan.*'] = 'exists:ruangan,id';
// Remove message requirement for regular mode
} elseif ($request->input('mode') === self::MODE_TTS) {
$rules['tts_text'] = 'required|string|max:1000';
$rules['tts_voice'] = 'nullable|string';
$rules['tts_speed'] = 'nullable|integer|min:-10|max:10';
$rules['tts_ruangan'] = 'required|array';
$rules['tts_ruangan.*'] = 'exists:ruangan,id';
}
return Validator::make($request->all(), $rules);
}
/**
* Handle regular announcement
*/
private function handleRegularAnnouncement(Request $request)
{
$ruanganIds = $request->input('ruangan');
$ruangan = Ruangan::whereIn('id', $ruanganIds)->get();
$payload = [
'type' => 'reguler',
'action' => 'activate', // Add action type
'ruangan' => $ruangan->pluck('nama_ruangan')->toArray(),
'timestamp' => now()->toDateTimeString()
];
$this->publishToMQTT($payload);
// Simpan ke database tanpa message
$announcement = Announcement::create([
'mode' => self::MODE_REGULER,
'message' => 'Aktivasi ruangan', // Default message or empty
'ruangan' => $ruangan->pluck('nama_ruangan')->toArray(),
'user_id' => auth()->id,
'sent_at' => now()
]);
// Attach ruangan ke pengumuman
$announcement->ruangans()->attach($ruanganIds);
Log::info('Regular announcement sent', [
'mode' => $announcement->mode,
'announcement_id' => $announcement->id,
'ruangan' => $ruanganIds
]);
}
private function handleTTSAnnouncement(Request $request)
{
$text = $request->input('tts_text');
$ruanganIds = $request->input('tts_ruangan');
$voice = $request->input('tts_voice', self::TTS_DEFAULT_VOICE);
$speed = $request->input('tts_speed', self::TTS_DEFAULT_SPEED);
$audioContent = $this->generateTTS($text, $voice, $speed);
if (!$audioContent) {
throw new \Exception('Gagal menghasilkan audio TTS');
}
// Simpan file audio
$fileName = 'tts/' . now()->format('YmdHis') . '.wav';
Storage::disk('public')->put($fileName, $audioContent);
$ruangan = Ruangan::whereIn('id', $ruanganIds)->get();
$payload = [
'type' => 'tts',
'audio_url' => asset('storage/' . $fileName),
'ruangan' => $ruangan->pluck('nama_ruangan')->toArray(),
'ruangans' => $announcement->ruangans->pluck('nama_ruangan')->toArray(),
'timestamp' => now()->toDateTimeString()
];
$this->publishToMQTT($payload);
if ($announcement->mode === self::MODE_REGULER) {
$payload['relay_state'] = $announcement->relay_state;
// Simpan ke database
$announcement = Announcement::create([
'mode' => self::MODE_TTS,
'message' => $text,
'audio_path' => $fileName,
'voice' => $voice,
'speed' => $speed,
'ruangan' => $ruangan->pluck('nama_ruangan')->toArray(),
'user_id' => auth()->id,
'sent_at' => now()
]);
// Kirim perintah relay ke masing-masing ruangan
foreach ($announcement->ruangans as $ruangan) {
$topic = $ruangan->mqtt_topic ?? "ruangan/{$ruangan->id}/relay/control";
// Attach ruangan ke pengumuman
$announcement->ruangans()->attach($ruanganIds);
$this->mqttService->publish(
$topic,
json_encode([
'state' => $announcement->relay_state,
'announcement_id' => $announcement->id
]),
1 // QoS level
);
Log::info('TTS announcement sent', [
'announcement_id' => $announcement->id, // Diubah dari id() ke id
'ruangan' => $ruanganIds,
'text' => $text,
'voice' => $voice,
'speed' => $speed
]);
// Update status relay di database
$ruangan->update(['relay_state' => $announcement->relay_state]);
}
} else {
$payload['message'] = $announcement->message;
$payload['audio_url'] = asset('storage/' . $announcement->audio_path);
$payload['voice'] = $announcement->voice;
$payload['speed'] = $announcement->speed;
}
/**
* Generate TTS audio using VoiceRSS API
*/
private function generateTTS($text, $voice, $speed)
// Publis ke topic announcement umum
$this->mqttService->publish(
$this->mqttConfig['topics']['commands']['announcement'],
json_encode($payload),
1
);
}
protected function generateTTS($text, $voice, $speed)
{
try {
$response = Http::get(self::TTS_API_URL, [
'key' => self::TTS_API_KEY,
'hl' => $voice,
'src' => $text,
'r' => $speed,
'c' => self::TTS_DEFAULT_FORMAT,
'f' => '8khz_8bit_mono' // Lower quality for faster transmission
'f' => '8khz_8bit_mono'
]);
if ($response->successful()) {
@ -208,126 +229,82 @@ private function generateTTS($text, $voice, $speed)
Log::error('TTS API Error: ' . $response->body());
return null;
} catch (\Exception $e) {
Log::error('TTS Generation Error: ' . $e->getMessage());
return null;
}
}
/**
* Publish message to MQTT broker
*/
private function publishToMQTT(array $payload)
protected function handleAnnouncementAck(string $message)
{
try {
$mqtt = MQTT::connection();
$mqtt->publish('announcement/channel', json_encode($payload), 0);
$mqtt->disconnect();
$data = json_decode($message, true);
if (isset($data['announcement_id'])) {
Announcement::where('id', $data['announcement_id'])
->update(['status' => 'delivered']);
Log::info('Announcement delivered', $data);
}
} catch (\Exception $e) {
Log::error('MQTT Publish Error: ' . $e->getMessage());
throw new \Exception('Gagal mengirim pesan ke MQTT broker');
Log::error('ACK Handler Error: ' . $e->getMessage());
}
}
/**
* Get available TTS voices
*/
public function getTTSVoices()
{
return [
'id-id' => 'Indonesian',
'en-us' => 'English (US)',
'en-gb' => 'English (UK)',
'ja-jp' => 'Japanese',
'es-es' => 'Spanish',
'fr-fr' => 'French',
'de-de' => 'German'
];
}
/**
* Show announcement history
*/
public function history(Request $request)
{
$search = $request->input('search');
$mode = $request->input('mode');
$announcements = Announcement::with(['user', 'ruangans'])
->when($search, function($query) use ($search) {
return $query->where('message', 'like', "%{$search}%")
->orWhereHas('ruangans', function($q) use ($search) {
$q->where('nama_ruangan', 'like', "%{$search}%");
})
->orWhereHas('user', function($q) use ($search) {
$q->where('name', 'like', "%{$search}%");
});
})
->when($mode, function($query) use ($mode) {
return $query->where('mode', $mode);
})
->latest()
->paginate(10);
return view('announcement.history', [
'announcements' => $announcements,
'search' => $search,
'mode' => $mode,
'modes' => [self::MODE_REGULER, self::MODE_TTS]
]);
}
/**
* Show announcement detail
*/
public function show(Announcement $announcement)
{
return view('announcement.show', [
'announcement' => $announcement->load(['user', 'ruangans'])
]);
}
/**
* Delete an announcement
*/
public function destroy(Announcement $announcement)
protected function handleAnnouncementError(string $message)
{
try {
// Hapus file audio jika ada
if ($announcement->audio_path && Storage::disk('public')->exists($announcement->audio_path)) {
Storage::disk('public')->delete($announcement->audio_path);
$data = json_decode($message, true);
if (isset($data['announcement_id'])) {
Announcement::where('id', $data['announcement_id'])
->update([
'status' => 'failed',
'error_message' => $data['error'] ?? 'Unknown error'
]);
Log::error('Announcement failed', $data);
}
$announcement->delete();
return redirect()->route('announcement.history')
->with('success', 'Pengumuman berhasil dihapus');
} catch (\Exception $e) {
Log::error('Error deleting announcement: ' . $e->getMessage());
return redirect()->back()
->with('error', 'Gagal menghapus pengumuman: ' . $e->getMessage());
Log::error('Error Handler Error: ' . $e->getMessage());
}
}
protected function handleRelayStatusUpdate(string $message)
{
try {
$data = json_decode($message, true);
if (isset($data['ruangan_id'], $data['state'])) {
Ruangan::where('id', $data['ruangan_id'])
->update(['relay_state' => $data['state']]);
Log::info('Relay status updated', $data);
}
} catch (\Exception $e) {
Log::error('Relay Status Handler Error: ' . $e->getMessage());
}
}
/**
* TTS Preview endpoint
*/
public function ttsPreview(Request $request)
{
$validated = $request->validate([
$validator = Validator::make($request->all(), [
'text' => 'required|string|max:1000',
'voice' => 'nullable|string',
'speed' => 'nullable|integer|min:-10|max:10'
'voice' => 'required|string',
'speed' => 'required|integer|min:-10|max:10'
]);
if ($validator->fails()) {
return response()->json([
'error' => $validator->errors()->first()
], 400);
}
try {
$audioContent = $this->generateTTS(
$validated['text'],
$validated['voice'] ?? self::TTS_DEFAULT_VOICE,
$validated['speed'] ?? self::TTS_DEFAULT_SPEED
$request->text,
$request->voice,
$request->speed
);
if (!$audioContent) {
return response()->json(['message' => 'Gagal menghasilkan audio'], 500);
throw new \Exception('Failed to generate TTS audio');
}
$fileName = 'tts/previews/' . uniqid() . '.wav';
@ -336,5 +313,44 @@ public function ttsPreview(Request $request)
return response()->json([
'audio_url' => asset('storage/' . $fileName)
]);
} catch (\Exception $e) {
Log::error('TTS Preview Error: ' . $e->getMessage());
return response()->json([
'error' => 'Failed to generate preview'
], 500);
}
}
public function history(Request $request)
{
$search = $request->input('search');
$mode = $request->input('mode');
$relayState = $request->input('relay_state');
$announcements = Announcement::with(['ruangans'])
->when($search, function($query) use ($search) {
return $query->where('message', 'like', "%{$search}%")
->orWhereHas('ruangans', function($q) use ($search) {
$q->where('nama_ruangan', 'like', "%{$search}%");
});
})
->when($mode, function($query) use ($mode) {
return $query->where('mode', $mode);
})
->when($relayState, function($query) use ($relayState) {
return $query->where('relay_state', $relayState);
})
->latest()
->paginate(10);
return view('admin.announcement.history', [
'announcements' => $announcements,
'search' => $search,
'mode' => $mode,
'relay_state' => $relayState,
'modes' => [self::MODE_REGULER, self::MODE_TTS],
'relayStates' => [self::RELAY_ON, self::RELAY_OFF]
]);
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers;
use App\Models\BellHistory;
use Illuminate\Http\Request;
class BellHistoryController extends Controller
{
public function history()
{
$histories = BellHistory::orderBy('ring_time', 'desc')
->paginate(20);
return view('admin.bel.history', compact('histories'));
}
public function filterHistory(Request $request)
{
$query = BellHistory::query();
if ($request->has('hari')) {
$query->where('hari', $request->hari);
}
if ($request->has('trigger_type')) {
$query->where('trigger_type', $request->trigger_type);
}
$histories = $query->orderBy('ring_time', 'desc')
->paginate(20);
return view('admin.bel.history', compact('histories'));
}
public function destroy($id)
{
$history = BellHistory::findOrFail($id);
$history->delete();
return redirect()->route('bel.history.index')
->with('success', 'History bel berhasil dihapus');
}
}

View File

@ -14,71 +14,125 @@ class BelController extends Controller
{
protected $mqttService;
protected $mqttConfig;
protected const DAY_MAP = [
'Monday' => 'Senin',
'Tuesday' => 'Selasa',
'Wednesday' => 'Rabu',
'Thursday' => 'Kamis',
'Friday' => 'Jumat',
'Saturday' => 'Sabtu',
'Sunday' => 'Minggu'
];
protected const DAY_ORDER = [
'Senin' => 1,
'Selasa' => 2,
'Rabu' => 3,
'Kamis' => 4,
'Jumat' => 5,
'Sabtu' => 6,
'Minggu' => 7
];
public function __construct(MqttService $mqttService)
{
$this->mqttService = $mqttService;
$this->mqttConfig = config('mqtt');
$this->initializeMqttSubscriptions();
}
protected function initializeMqttSubscriptions()
protected function initializeMqttSubscriptions(): void
{
try {
// Subscribe ke topik status response
$this->mqttService->subscribe(
$this->mqttConfig['topics']['responses']['status'],
function (string $topic, string $message) {
$this->handleStatusResponse($message);
}
);
$topics = $this->mqttConfig['topics']['responses'];
$events = $this->mqttConfig['topics']['events'];
// Subscribe ke topik acknowledgment
$this->mqttService->subscribe(
$this->mqttConfig['topics']['responses']['ack'],
function (string $topic, string $message) {
$this->handleAckResponse($message);
}
);
$this->mqttService->subscribe($topics['status'], fn($t, $m) => $this->handleStatusResponse($m));
$this->mqttService->subscribe($topics['ack'], fn($t, $m) => $this->handleAckResponse($m));
$this->mqttService->subscribe($events['bell_manual'], fn($t, $m) => $this->handleBellEvent($m, 'manual'));
$this->mqttService->subscribe($events['bell_schedule'], fn($t, $m) => $this->handleBellEvent($m, 'schedule'));
// Tambahkan subscribe untuk topik bell ring
$this->mqttService->subscribe(
$this->mqttConfig['topics']['responses']['bell_ring'],
function (string $topic, string $message) {
$this->handleBellRing($message);
}
);
Log::info('Successfully subscribed to MQTT topics');
} catch (\Exception $e) {
Log::error('Failed to initialize MQTT subscriptions: ' . $e->getMessage());
}
}
protected function handleStatusResponse(string $message)
protected function handleBellEvent(string $message, string $triggerType): void
{
Log::debug("Processing {$triggerType} bell", ['raw_message' => $message]);
try {
$data = json_decode($message, true, 512, JSON_THROW_ON_ERROR);
$requiredFields = ['hari', 'waktu', 'file_number'];
foreach ($requiredFields as $field) {
if (!isset($data[$field])) {
throw new \Exception("Field {$field} tidak ditemukan");
}
}
$history = BellHistory::create([
'hari' => $data['hari'],
'waktu' => $this->normalizeWaktu($data['waktu']),
'file_number' => $data['file_number'],
'trigger_type' => $triggerType,
'ring_time' => now(),
'volume' => $data['volume'] ?? 15,
'repeat' => $data['repeat'] ?? 1
]);
Log::info("Bell {$triggerType} tersimpan", [
'id' => $history->id,
'hari' => $data['hari'],
'file' => $data['file_number']
]);
} catch (\Exception $e) {
Log::error("Gagal menyimpan bell {$triggerType}", [
'error' => $e->getMessage(),
'message' => $message,
'trace' => $e->getTraceAsString()
]);
}
}
private function normalizeWaktu(?string $time): string
{
if (empty($time)) {
return '00:00:00';
}
$parts = explode(':', $time);
$hour = min(23, max(0, (int)($parts[0] ?? 0)));
$minute = min(59, max(0, (int)($parts[1] ?? 0)));
$second = min(59, max(0, (int)($parts[2] ?? 0)));
return sprintf('%02d:%02d:%02d', $hour, $minute, $second);
}
protected function handleStatusResponse(string $message): void
{
try {
$data = json_decode($message, true);
// Validasi payload
if (!is_array($data)) {
Log::error('Invalid status data format');
return;
throw new \Exception('Invalid status data format');
}
// Pastikan semua kunci penting ada
$requiredKeys = ['rtc', 'dfplayer', 'rtc_time', 'last_communication', 'last_sync'];
foreach ($requiredKeys as $key) {
if (!array_key_exists($key, $data)) {
Log::error("Missing required key in status data: {$key}");
return;
throw new \Exception("Missing required key: {$key}");
}
}
// Simpan data ke database
Status::updateOrCreate(
['id' => 1],
[
'rtc' => $data['rtc'] ?? false,
'dfplayer' => $data['dfplayer'] ?? false,
'rtc_time' => $data['rtc_time'] ?? null,
'last_communication' => Carbon::createFromTimestamp($data['last_communication'] ?? 0),
'last_sync' => Carbon::createFromTimestamp($data['last_sync'] ?? 0)
'rtc' => $data['rtc'],
'dfplayer' => $data['dfplayer'],
'rtc_time' => $data['rtc_time'],
'last_communication' => Carbon::createFromTimestamp($data['last_communication']),
'last_sync' => Carbon::createFromTimestamp($data['last_sync'])
]
);
} catch (\Exception $e) {
@ -86,25 +140,24 @@ protected function handleStatusResponse(string $message)
}
}
protected function handleAckResponse(string $message)
protected function handleAckResponse(string $message): void
{
try {
$data = json_decode($message, true);
if (isset($data['action'])) {
if (!isset($data['action'])) {
return;
}
$action = $data['action'];
$message = $data['message'] ?? '';
if ($action === 'sync_ack') {
Status::updateOrCreate(
['id' => 1],
['last_sync' => Carbon::now()]
);
Status::updateOrCreate(['id' => 1], ['last_sync' => Carbon::now()]);
Log::info('Schedule sync acknowledged: ' . $message);
} elseif ($action === 'ring_ack') {
Log::info('Bell ring acknowledged: ' . $message);
}
}
} catch (\Exception $e) {
Log::error('Error handling ack response: ' . $e->getMessage());
}
@ -113,7 +166,6 @@ protected function handleAckResponse(string $message)
public function index(Request $request)
{
try {
// Ambil data utama terlepas dari koneksi MQTT
$query = JadwalBel::query();
if ($request->filled('hari')) {
@ -127,42 +179,36 @@ public function index(Request $request)
});
}
$schedules = $query->orderBy('hari')->orderBy('waktu')->paginate(10);
$status = Status::firstOrCreate(['id' => 1]);
// Jadwal hari ini
$todaySchedules = JadwalBel::where('hari', Carbon::now()->isoFormat('dddd'))
->orderBy('waktu')
->get();
// Jadwal berikutnya
$nextSchedule = JadwalBel::where('hari', Carbon::now()->isoFormat('dddd'))
->where('waktu', '>', Carbon::now()->format('H:i:s'))
->orderBy('waktu')
->first();
// Cek koneksi MQTT tanpa menghentikan eksekusi jika error
try {
$mqttStatus = $this->mqttService->isConnected() ? 'Connected' : 'Disconnected';
} catch (\Exception $e) {
$mqttStatus = 'Disconnected';
Log::error('MQTT check failed: ' . $e->getMessage());
}
$today = Carbon::now()->isoFormat('dddd');
$currentTime = Carbon::now()->format('H:i:s');
return view('admin.bel.index', [
'schedules' => $schedules,
'todaySchedules' => $todaySchedules,
'nextSchedule' => $nextSchedule,
'status' => $status,
'mqttStatus' => $mqttStatus
'schedules' => $query->orderBy('hari')->orderBy('waktu')->paginate(10),
'todaySchedules' => JadwalBel::where('hari', $today)
->orderBy('waktu')
->get(),
'nextSchedule' => JadwalBel::where('hari', $today)
->where('waktu', '>', $currentTime)
->orderBy('waktu')
->first(),
'status' => Status::firstOrCreate(['id' => 1]),
'mqttStatus' => $this->getMqttStatus()
]);
} catch (\Exception $e) {
Log::error('Error in index method: ' . $e->getMessage());
return back()->with('error', 'Terjadi kesalahan saat memuat data jadwal');
}
}
protected function getMqttStatus(): string
{
try {
return $this->mqttService->isConnected() ? 'Connected' : 'Disconnected';
} catch (\Exception $e) {
Log::error('MQTT check failed: ' . $e->getMessage());
return 'Disconnected';
}
}
public function create()
{
@ -178,7 +224,6 @@ public function store(Request $request)
try {
$schedule = JadwalBel::create($validated);
$this->syncSchedules();
$this->logActivity('Jadwal dibuat', $schedule);
@ -190,18 +235,14 @@ public function store(Request $request)
]);
} catch (\Exception $e) {
Log::error('Gagal menambah jadwal: ' . $e->getMessage());
return back()
->withInput()
->with('error', 'Gagal menambah jadwal: ' . $e->getMessage());
return back()->withInput()->with('error', 'Gagal menambah jadwal: ' . $e->getMessage());
}
}
public function edit($id)
{
$schedule = JadwalBel::findOrFail($id);
return view('admin.bel.edit', [
'schedule' => $schedule,
'schedule' => JadwalBel::findOrFail($id),
'days' => JadwalBel::DAYS
]);
}
@ -213,7 +254,6 @@ public function update(Request $request, $id)
try {
$schedule->update($validated);
$this->syncSchedules();
$this->logActivity('Jadwal diperbarui', $schedule);
@ -224,20 +264,16 @@ public function update(Request $request, $id)
'scroll_to' => 'schedule-'.$schedule->id
]);
} catch (\Exception $e) {
Log::error('Gagal update jadwal ID '.$schedule->id.': ' . $e->getMessage());
return back()
->withInput()
->with('error', 'Gagal memperbarui jadwal: ' . $e->getMessage());
Log::error('Gagal update jadwal ID '.$id.': ' . $e->getMessage());
return back()->withInput()->with('error', 'Gagal memperbarui jadwal: ' . $e->getMessage());
}
}
public function destroy($id)
{
$schedule = JadwalBel::findOrFail($id);
try {
$schedule = JadwalBel::findOrFail($id);
$schedule->delete();
$this->syncSchedules();
$this->logActivity('Jadwal dihapus', $schedule);
@ -245,9 +281,8 @@ public function destroy($id)
->route('bel.index')
->with('success', 'Jadwal berhasil dihapus');
} catch (\Exception $e) {
Log::error('Gagal hapus jadwal ID '.$schedule->id.': ' . $e->getMessage());
return back()
->with('error', 'Gagal menghapus jadwal: ' . $e->getMessage());
Log::error('Gagal hapus jadwal ID '.$id.': ' . $e->getMessage());
return back()->with('error', 'Gagal menghapus jadwal: ' . $e->getMessage());
}
}
@ -262,8 +297,7 @@ public function deleteAll()
->with('success', 'Semua jadwal berhasil dihapus');
} catch (\Exception $e) {
Log::error('Gagal hapus semua jadwal: ' . $e->getMessage());
return back()
->with('error', 'Gagal menghapus semua jadwal: ' . $e->getMessage());
return back()->with('error', 'Gagal menghapus semua jadwal: ' . $e->getMessage());
}
}
@ -272,31 +306,19 @@ public function toggleStatus($id)
try {
$schedule = JadwalBel::findOrFail($id);
$newStatus = !$schedule->is_active;
$schedule->is_active = $newStatus;
$schedule->save();
$schedule->update(['is_active' => $newStatus]);
// Return JSON response for AJAX requests
if (request()->wantsJson()) {
return response()->json([
'success' => true,
'message' => 'Status jadwal berhasil diubah',
'is_active' => $newStatus
]);
}
// Fallback for non-AJAX requests
return redirect()->back()->with('success', 'Status jadwal berhasil diubah');
} catch (\Exception $e) {
if (request()->wantsJson()) {
return response()->json([
'success' => false,
'message' => 'Gagal mengubah status: ' . $e->getMessage()
], 500);
}
return redirect()->back()->with('error', 'Gagal mengubah status');
}
}
public function activateAll()
@ -341,68 +363,70 @@ public function ring(Request $request)
{
$validated = $request->validate([
'file_number' => 'required|string|size:4',
'repeat' => 'sometimes|integer|min:1|max:10',
'volume' => 'sometimes|integer|min:0|max:30'
'volume' => 'sometimes|integer|min:1|max:30',
]);
// Konversi nama hari ke format enum Indonesia
$dayMap = [
'Monday' => 'Senin',
'Tuesday' => 'Selasa',
'Wednesday' => 'Rabu',
'Thursday' => 'Kamis',
'Friday' => 'Jumat',
'Saturday' => 'Sabtu',
'Sunday' => 'Minggu',
];
$hari = $dayMap[now()->format('l')]; // format('l') = nama hari dalam Bahasa Inggris
try {
// Catat ke history terlebih dahulu
BellHistory::create([
'hari' => Carbon::now()->isoFormat('dddd'),
'waktu' => Carbon::now()->format('H:i:s'),
$bellData = [
'hari' => $hari,
'waktu' => now()->format('H:i:s'),
'file_number' => $validated['file_number'],
'trigger_type' => BellHistory::TRIGGER_MANUAL,
'ring_time' => Carbon::now(),
'volume' => $validated['volume'] ?? 15,
'repeat' => $validated['repeat'] ?? 1
]);
'volume' => $validated['volume'],
];
// Kirim perintah ke MQTT
$message = json_encode([
'action' => 'ring',
'timestamp' => Carbon::now()->toDateTimeString(),
'file_number' => $validated['file_number'],
'repeat' => $validated['repeat'] ?? 1,
'volume' => $validated['volume'] ?? 15,
'trigger_type' => BellHistory::TRIGGER_MANUAL
]);
BellHistory::create(array_merge($bellData, [
'trigger_type' => 'manual',
'ring_time' => now()
]));
$this->mqttService->publish(
$this->mqttConfig['topics']['commands']['ring'],
$message,
json_encode($validated),
1
);
return response()->json([
'success' => true,
'message' => 'Perintah bel berhasil dikirim',
'data' => [
'file_number' => $validated['file_number'],
'timestamp' => Carbon::now()->toDateTimeString()
]
'message' => 'Bel manual berhasil diaktifkan',
'data' => $bellData
]);
} catch (\Exception $e) {
Log::error('Gagal mengirim bel manual: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'Gagal mengirim perintah bel: ' . $e->getMessage()
'message' => 'Gagal mengaktifkan bel: ' . $e->getMessage()
], 500);
}
}
public function status()
{
try {
$message = json_encode([
'action' => 'get_status',
'timestamp' => Carbon::now()->toDateTimeString()
]);
$this->mqttService->publish(
$this->mqttConfig['topics']['commands']['status'],
$message,
json_encode([
'action' => 'get_status',
'timestamp' => Carbon::now()->toDateTimeString()
]),
1
);
$status = Status::firstOrCreate(['id' => 1]);
return response()->json([
'success' => true,
'message' => 'Permintaan status terkirim',
@ -413,7 +437,7 @@ public function status()
'last_communication' => $status->last_communication,
'last_sync' => $status->last_sync,
'mqtt_status' => $this->mqttService->isConnected(),
'status' => $status->status ?? 'unknown' // Default value jika kolom kosong
'status' => $status->status ?? 'unknown'
]
]);
} catch (\Exception $e) {
@ -425,58 +449,28 @@ public function status()
}
}
protected function syncJadwalToEsp($schedules)
{
try {
$message = json_encode([
'action' => 'sync',
'timestamp' => Carbon::now()->toDateTimeString(),
'schedules' => $schedules
]);
$this->mqttService->publish(
$this->mqttConfig['topics']['commands']['sync'],
$message,
1
);
Log::info("Sync schedules sent to MQTT", ['count' => count($schedules)]);
} catch (\Exception $e) {
Log::error('Error syncing schedules to MQTT: ' . $e->getMessage());
}
}
public function syncSchedule()
{
try {
$schedules = JadwalBel::active()
->get()
->map(function ($item) {
return [
->map(fn($item) => [
'hari' => $item->hari,
'waktu' => Carbon::parse($item->waktu)->format('H:i'), // Pastikan format waktu sesuai
'waktu' => Carbon::parse($item->waktu)->format('H:i'),
'file_number' => $item->file_number
];
});
$message = json_encode([
'action' => 'sync',
'timestamp' => Carbon::now()->toDateTimeString(),
'schedules' => $schedules
]);
Log::info("Sync message sent to MQTT", ['message' => $message]); // Debugging
$this->mqttService->publish(
$this->mqttConfig['topics']['commands']['sync'],
$message,
json_encode([
'action' => 'sync',
'timestamp' => Carbon::now()->toDateTimeString(),
'schedules' => $schedules
]),
1
);
Status::updateOrCreate(
['id' => 1],
['last_sync' => Carbon::now()]
);
Status::updateOrCreate(['id' => 1], ['last_sync' => Carbon::now()]);
return response()->json([
'success' => true,
@ -495,20 +489,26 @@ public function syncSchedule()
}
}
protected function syncSchedules()
protected function syncSchedules(): void
{
try {
$schedules = JadwalBel::active()
->get()
->map(function ($item) {
return [
->map(fn($item) => [
'hari' => $item->hari,
'waktu' => Carbon::parse($item->waktu)->format('H:i:s'),
'file_number' => $item->file_number
];
});
]);
$this->syncJadwalToEsp($schedules);
$this->mqttService->publish(
$this->mqttConfig['topics']['commands']['sync'],
json_encode([
'action' => 'sync',
'timestamp' => Carbon::now()->toDateTimeString(),
'schedules' => $schedules
]),
1
);
Log::info("Auto sync: " . count($schedules) . " jadwal");
} catch (\Exception $e) {
@ -518,36 +518,11 @@ protected function syncSchedules()
public function getNextSchedule()
{
$now = now();
// Mapping for English to Indonesian day names
$dayMap = [
'Monday' => 'Senin',
'Tuesday' => 'Selasa',
'Wednesday' => 'Rabu',
'Thursday' => 'Kamis',
'Friday' => 'Jumat',
'Saturday' => 'Sabtu',
'Sunday' => 'Minggu'
];
$currentDayEnglish = $now->format('l'); // Get English day name (e.g. "Saturday")
$currentDay = $dayMap[$currentDayEnglish] ?? $currentDayEnglish; // Convert to Indonesian
$now = Carbon::now();
$currentDay = self::DAY_MAP[$now->format('l')] ?? $now->format('l');
$currentTime = $now->format('H:i:s');
// Correct day order (Monday-Sunday)
$dayOrder = [
'Senin' => 1,
'Selasa' => 2,
'Rabu' => 3,
'Kamis' => 4,
'Jumat' => 5,
'Sabtu' => 6,
'Minggu' => 7
];
// 1. First try to find today's upcoming ACTIVE schedules
// 1. Try to find today's upcoming ACTIVE schedules
$todaysSchedule = JadwalBel::where('is_active', true)
->where('hari', $currentDay)
->where('waktu', '>', $currentTime)
@ -564,39 +539,33 @@ public function getNextSchedule()
->orderBy('waktu')
->get();
$currentDayValue = $dayOrder[$currentDay] ?? 0;
$currentDayValue = self::DAY_ORDER[$currentDay] ?? 0;
$closestSchedule = null;
$minDayDiff = 8; // More than 7 days
$minDayDiff = 8;
foreach ($allSchedules as $schedule) {
$scheduleDayValue = $dayOrder[$schedule->hari] ?? 0;
// Calculate days difference
$scheduleDayValue = self::DAY_ORDER[$schedule->hari] ?? 0;
$dayDiff = ($scheduleDayValue - $currentDayValue + 7) % 7;
// If same day but time passed, add 7 days
if ($dayDiff === 0 && $schedule->waktu <= $currentTime) {
$dayDiff = 7;
}
// Find schedule with smallest day difference
if ($dayDiff < $minDayDiff) {
$minDayDiff = $dayDiff;
$closestSchedule = $schedule;
}
}
if ($closestSchedule) {
return $this->formatScheduleResponse($closestSchedule);
}
return response()->json([
return $closestSchedule
? $this->formatScheduleResponse($closestSchedule)
: response()->json([
'success' => false,
'message' => 'Tidak ada jadwal aktif yang akan datang'
]);
}
private function formatScheduleResponse($schedule)
private function formatScheduleResponse(JadwalBel $schedule)
{
return response()->json([
'success' => true,
@ -609,7 +578,59 @@ private function formatScheduleResponse($schedule)
]);
}
protected function validateSchedule(Request $request)
// public function history(Request $request)
// {
// try {
// $query = BellHistory::query()->latest('ring_time');
// if ($request->filled('date')) {
// $query->whereDate('ring_time', $request->date);
// }
// if ($request->filled('search')) {
// $query->where(function($q) use ($request) {
// $q->where('hari', 'like', '%'.$request->search.'%')
// ->orWhere('file_number', 'like', '%'.$request->search.'%')
// ->orWhere('trigger_type', 'like', '%'.$request->search.'%');
// });
// }
// return view('admin.bel.history', [
// 'histories' => $query->paginate(15)
// ]);
// } catch (\Exception $e) {
// Log::error('Error fetching history: ' . $e->getMessage());
// return back()->with('error', 'Gagal memuat riwayat bel');
// }
// }
public function logEvent(Request $request)
{
$validated = $request->validate([
'hari' => 'required|string',
'waktu' => 'required|date_format:H:i:s',
'file_number' => 'required|string|size:4',
'trigger_type' => 'required|in:schedule,manual',
'volume' => 'sometimes|integer|min:1|max:30',
'repeat' => 'sometimes|integer|min:1|max:5'
]);
try {
return response()->json([
'success' => true,
'message' => 'Bell event logged',
'data' => BellHistory::create($validated)
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Failed to log bell event',
'error' => $e->getMessage()
], 500);
}
}
protected function validateSchedule(Request $request): array
{
return $request->validate([
'hari' => 'required|in:' . implode(',', JadwalBel::DAYS),
@ -619,79 +640,8 @@ protected function validateSchedule(Request $request)
]);
}
protected function logActivity($action, JadwalBel $schedule)
protected function logActivity(string $action, JadwalBel $schedule): void
{
Log::info("{$action} - ID: {$schedule->id}, Hari: {$schedule->hari}, Waktu: {$schedule->waktu}, File: {$schedule->file_number}");
}
protected function logMqttActivity($action, $message)
{
$this->mqttService->publish(
$this->mqttConfig['topics']['system']['logs'],
json_encode([
'action' => $action,
'message' => $message,
'timestamp' => Carbon::now()->toDateTimeString()
]),
0
);
}
protected function handleBellRing(string $message)
{
try {
$data = json_decode($message, true);
// Validasi data yang diterima
if (!isset($data['hari']) || !isset($data['waktu']) || !isset($data['file_number']) || !isset($data['trigger_type'])) {
Log::error('Invalid bell ring data format');
return;
}
// Simpan ke history
BellHistory::create([
'hari' => $data['hari'],
'waktu' => $data['waktu'],
'file_number' => $data['file_number'],
'trigger_type' => $data['trigger_type'],
'ring_time' => Carbon::now(),
'volume' => $data['volume'] ?? 15, // Default volume 15
'repeat' => $data['repeat'] ?? 1 // Default repeat 1x
]);
Log::info('Bell ring recorded to history', ['data' => $data]);
} catch (\Exception $e) {
Log::error('Error handling bell ring: ' . $e->getMessage());
}
}
public function history(Request $request)
{
try {
$query = BellHistory::query()->latest('ring_time');
if ($request->filled('date')) {
$query->whereDate('ring_time', $request->date);
}
if ($request->filled('search')) {
$query->where(function($q) use ($request) {
$q->where('hari', 'like', '%'.$request->search.'%')
->orWhere('file_number', 'like', '%'.$request->search.'%')
->orWhere('trigger_type', 'like', '%'.$request->search.'%');
});
}
$histories = $query->paginate(15);
return view('admin.bel.history', [
'histories' => $histories
]);
} catch (\Exception $e) {
Log::error('Error fetching history: ' . $e->getMessage());
return back()->with('error', 'Gagal memuat riwayat bel');
}
}
}

View File

@ -9,7 +9,7 @@ class Announcement extends Model
{
use HasFactory;
protected $table = 'announcements';
protected $table = 'announcements'; // Pastikan konsisten
protected $fillable = [
'mode',
@ -17,34 +17,41 @@ class Announcement extends Model
'audio_path',
'voice',
'speed',
'ruangan',
'user_id',
'sent_at'
'is_active',
'status',
'error_message',
'sent_at',
'relay_state' // Tambahkan ini
];
protected $attributes = [
'is_active' => true,
'status' => 'pending',
'relay_state' => 'OFF' // Default value
];
protected $casts = [
'ruangan' => 'array',
'is_active' => 'boolean',
'sent_at' => 'datetime'
];
/**
* Relasi ke user yang membuat pengumuman
*/
public function user()
// Tambahkan aksesor untuk relay
public function getRelayStateDescriptionAttribute()
{
return $this->belongsTo(User::class);
return $this->relay_state === 'ON' ? 'Relay Menyala' : 'Relay Mati';
}
/**
* Relasi many-to-many ke ruangan
* Relationship with Ruangan (many-to-many)
*/
public function ruangans()
{
return $this->belongsToMany(Ruangan::class, 'announcement_ruangan');
return $this->belongsToMany(Ruangan::class, 'announcement_ruangan')
->withTimestamps();
}
/**
* Scope untuk pengumuman reguler
* Scope for regular announcements
*/
public function scopeReguler($query)
{
@ -52,7 +59,7 @@ public function scopeReguler($query)
}
/**
* Scope untuk pengumuman TTS
* Scope for TTS announcements
*/
public function scopeTts($query)
{
@ -60,21 +67,23 @@ public function scopeTts($query)
}
/**
* Scope untuk pencarian
* Scope for delivered announcements
*/
public function scopeSearch($query, $search)
public function scopeDelivered($query)
{
return $query->where('message', 'like', "%{$search}%")
->orWhereHas('ruangans', function($q) use ($search) {
$q->where('nama_ruangan', 'like', "%{$search}%");
})
->orWhereHas('user', function($q) use ($search) {
$q->where('name', 'like', "%{$search}%");
});
return $query->where('status', 'delivered');
}
/**
* Accessor untuk audio URL
* Scope for failed announcements
*/
public function scopeFailed($query)
{
return $query->where('status', 'failed');
}
/**
* Accessor for audio URL
*/
public function getAudioUrlAttribute()
{
@ -82,10 +91,34 @@ public function getAudioUrlAttribute()
}
/**
* Format tanggal pengiriman
* Accessor for formatted sent time
*/
public function getFormattedSentAtAttribute()
{
return $this->sent_at->format('d M Y H:i:s');
}
/**
* Accessor untuk pesan aktivasi
*/
public function getActivationMessageAttribute()
{
return $this->is_active ? 'Aktivasi Ruangan' : 'Deaktivasi Ruangan';
}
/**
* Cek apakah pengumuman reguler
*/
public function isReguler()
{
return $this->mode === 'reguler';
}
/**
* Cek apakah pengumuman TTS
*/
public function isTts()
{
return $this->mode === 'tts';
}
}

View File

@ -4,35 +4,51 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
class BellHistory extends Model
{
use HasFactory;
const TRIGGER_SCHEDULE = 'schedule';
const TRIGGER_MANUAL = 'manual';
protected $fillable = [
'hari',
'waktu',
'file_number',
'trigger_type',
'ring_time',
'volume',
'repeat'
'repeat',
'ring_time'
];
protected $casts = [
'ring_time' => 'datetime',
'volume' => 'integer',
'repeat' => 'integer'
'waktu' => 'datetime:H:i:s', // Format waktu konsisten
];
public static function getTriggerTypes()
// Validasi otomatis saat create/update
public static function rules()
{
return [
self::TRIGGER_SCHEDULE => 'Jadwal',
self::TRIGGER_MANUAL => 'Manual'
'hari' => 'required|in:Senin,Selasa,Rabu,Kamis,Jumat,Sabtu,Minggu',
'waktu' => 'required|date_format:H:i:s',
'file_number' => 'required|string|size:4|regex:/^[0-9]{4}$/',
'trigger_type' => 'required|in:schedule,manual',
'volume' => 'sometimes|integer|min:0|max:30',
'repeat' => 'sometimes|integer|min:1|max:5'
];
}
// Normalisasi waktu sebelum simpan
public function setWaktuAttribute($value)
{
try {
$this->attributes['waktu'] = Carbon::createFromFormat('H:i:s', $value)->format('H:i:s');
} catch (\Exception $e) {
// Fallback ke format default jika parsing gagal
$this->attributes['waktu'] = $value;
}
}
}

View File

@ -14,11 +14,18 @@ class Ruangan extends Model
protected $fillable = [
'nama_ruangan',
'id_kelas',
'id_jurusan'
'id_jurusan',
'relay_state', // Ubah dari status_relay menjadi relay_state
'mqtt_topic' // Tambahkan kolom untuk custom MQTT topic
];
protected $casts = [
'relay_state' => 'string' // Ubah menjadi string untuk menyimpan 'ON'/'OFF'
];
/**
* Relasi ke model Kelas
* Relationship with Kelas
*/
public function kelas()
{
@ -26,17 +33,15 @@ public function kelas()
}
/**
* Relasi ke model Jurusan
* Relationship with Jurusan
*/
public function jurusan()
{
return $this->belongsTo(Jurusan::class, 'id_jurusan');
}
// Di app/Models/Ruangan.php
/**
* Relasi many-to-many ke announcements
* Relationship with Announcements (many-to-many)
*/
public function announcements()
{
@ -44,7 +49,7 @@ public function announcements()
}
/**
* Accessor untuk nama ruangan
* Accessor for uppercase room name
*/
public function getNamaRuanganAttribute($value)
{
@ -52,24 +57,18 @@ public function getNamaRuanganAttribute($value)
}
/**
* Mutator untuk nama ruangan
* Scope for active relay status
*/
public function setNamaRuanganAttribute($value)
public function scopeRelayActive($query)
{
$this->attributes['nama_ruangan'] = strtolower($value);
return $query->where('status_relay', true);
}
/**
* Scope untuk pencarian ruangan
* Scope for inactive relay status
*/
public function scopeSearch($query, $term)
public function scopeRelayInactive($query)
{
return $query->where('nama_ruangan', 'like', "%{$term}%")
->orWhereHas('kelas', function($q) use ($term) {
$q->where('nama_kelas', 'like', "%{$term}%");
})
->orWhereHas('jurusan', function($q) use ($term) {
$q->where('nama_jurusan', 'like', "%{$term}%");
});
return $query->where('status_relay', false);
}
}

View File

@ -6,41 +6,168 @@
use PhpMqtt\Client\ConnectionSettings;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Cache;
use App\Models\BellHistory;
use App\Events\BellRingEvent;
use Carbon\Carbon;
use Illuminate\Support\Facades\Validator;
class MqttService
{
protected $client;
protected $config;
protected $isConnected = false;
protected $reconnectAttempts = 0;
protected $lastActivityTime;
protected $connectionLock = false;
protected MqttClient $client;
protected array $config;
protected bool $isConnected = false;
protected int $reconnectAttempts = 0;
protected bool $connectionLock = false;
protected array $subscriptions = [];
protected const MAX_RECONNECT_ATTEMPTS = 5;
protected const RECONNECT_DELAY = 5;
public function __construct()
{
$this->config = config('mqtt.connections.bel_sekolah');
$this->config = config('mqtt');
$this->initializeConnection();
}
protected function initializeConnection(): void
{
try {
$connectionConfig = $this->getConnectionConfig();
$this->client = new MqttClient(
$this->config['host'],
$this->config['port'],
$this->config['client_id'] . '_' . uniqid()
$connectionConfig['host'],
$connectionConfig['port'],
$this->generateClientId($connectionConfig)
);
$this->connect();
$this->subscribeToBellTopics();
} catch (\Exception $e) {
Log::error('MQTT Initialization failed: ' . $e->getMessage());
$this->scheduleReconnect();
}
}
protected function getConnectionConfig(): array
{
return $this->config['connections'][$this->config['default_connection']];
}
protected function generateClientId(array $config): string
{
return $config['client_id'] . '_' . uniqid();
}
protected function subscribeToBellTopics(): void
{
$topics = [
'bell_schedule' => fn($t, $m) => $this->handleBellNotification($m, 'bell_schedule'),
'bell_manual' => fn($t, $m) => $this->handleBellNotification($m, 'bell_manual')
];
foreach ($topics as $type => $callback) {
$this->subscribe($this->config['topics']['events'][$type], $callback);
}
}
protected function handleBellNotification(string $message, string $triggerType): void
{
Log::debug("Processing {$triggerType} bell event", compact('message', 'triggerType'));
try {
$data = $this->validateBellData($message, $triggerType);
$history = $this->createBellHistory($data, $triggerType);
$this->logBellEvent($history, $triggerType);
$this->dispatchBellEvent($history);
} catch (\JsonException $e) {
Log::error("Invalid JSON format in bell notification", [
'error' => $e->getMessage(),
'message' => $message
]);
} catch (\InvalidArgumentException $e) {
Log::error("Validation failed for bell event", [
'error' => $e->getMessage(),
'data' => $data ?? null
]);
} catch (\Exception $e) {
Log::error("Failed to process bell notification", [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'trigger_type' => $triggerType
]);
}
}
protected function validateBellData(string $message, string $triggerType): array
{
$data = json_decode($message, true, 512, JSON_THROW_ON_ERROR);
$rules = [
'hari' => 'required|in:Senin,Selasa,Rabu,Kamis,Jumat,Sabtu,Minggu',
'waktu' => 'required|date_format:H:i:s',
'file_number' => 'required|string|size:4|regex:/^[0-9]{4}$/',
'volume' => 'sometimes|integer|min:0|max:30',
'repeat' => 'sometimes|integer|min:1|max:5'
];
$validator = Validator::make($data, $rules);
if ($validator->fails()) {
throw new \InvalidArgumentException(
'Invalid bell data: ' . $validator->errors()->first()
);
}
return $data;
}
protected function createBellHistory(array $data, string $triggerType): BellHistory
{
return BellHistory::create([
'hari' => $data['hari'],
'waktu' => $this->normalizeTime($data['waktu']),
'file_number' => $data['file_number'],
'trigger_type' => $triggerType === 'bell_schedule' ? 'schedule' : 'manual',
'volume' => $data['volume'] ?? 15,
'repeat' => $data['repeat'] ?? 1,
'ring_time' => now()
]);
}
protected function logBellEvent(BellHistory $history, string $triggerType): void
{
Log::info("Bell event saved successfully", [
'id' => $history->id,
'type' => $triggerType,
'hari' => $history->hari,
'waktu' => $history->waktu,
'file' => $history->file_number
]);
}
protected function dispatchBellEvent(BellHistory $history): void
{
event(new BellRingEvent([
'id' => $history->id,
'hari' => $history->hari,
'waktu' => $history->waktu,
'file_number' => $history->file_number,
'trigger_type' => $history->trigger_type,
'ring_time' => $history->ring_time->toDateTimeString()
]));
}
private function normalizeTime(string $time): string
{
try {
return Carbon::createFromFormat('H:i:s', $time)->format('H:i:s');
} catch (\Exception $e) {
$parts = explode(':', $time);
return sprintf("%02d:%02d:%02d", $parts[0] ?? 0, $parts[1] ?? 0, $parts[2] ?? 0);
}
}
public function connect(): bool
{
// Prevent multiple simultaneous connection attempts
if ($this->connectionLock) {
return false;
}
@ -53,131 +180,114 @@ public function connect(): bool
return true;
}
$connectionSettings = (new ConnectionSettings)
->setKeepAliveInterval($this->config['connection_settings']['keep_alive_interval'])
->setConnectTimeout($this->config['connection_settings']['connect_timeout'])
->setLastWillTopic($this->config['connection_settings']['last_will']['topic'])
->setLastWillMessage($this->config['connection_settings']['last_will']['message'])
->setLastWillQualityOfService($this->config['connection_settings']['last_will']['quality_of_service'])
->setRetainLastWill($this->config['connection_settings']['last_will']['retain'])
->setUseTls(false);
$connectionSettings = $this->createConnectionSettings();
$this->client->connect($connectionSettings, true);
$this->isConnected = true;
$this->reconnectAttempts = 0;
$this->lastActivityTime = time();
// Store connection status in cache for UI
Cache::put('mqtt_status', 'connected', 60);
Log::info('MQTT Connected successfully to ' . $this->config['host']);
$this->connectionLock = false;
$this->handleSuccessfulConnection();
return true;
} catch (\Exception $e) {
$this->handleConnectionFailure($e);
return false;
} finally {
$this->connectionLock = false;
}
}
protected function createConnectionSettings(): ConnectionSettings
{
$connectionConfig = $this->getConnectionConfig()['connection_settings'];
$lastWill = $connectionConfig['last_will'];
return (new ConnectionSettings)
->setUsername($connectionConfig['username'] ?? null)
->setPassword($connectionConfig['password'] ?? null)
->setKeepAliveInterval($connectionConfig['keep_alive_interval'])
->setConnectTimeout($connectionConfig['connect_timeout'])
->setLastWillTopic($lastWill['topic'])
->setLastWillMessage($lastWill['message'])
->setLastWillQualityOfService($lastWill['quality_of_service'])
->setRetainLastWill($lastWill['retain'])
->setUseTls(false);
}
protected function handleSuccessfulConnection(): void
{
$this->isConnected = true;
$this->reconnectAttempts = 0;
$this->resubscribeToTopics();
Cache::put('mqtt_status', 'connected', 60);
Log::info('MQTT Connected successfully');
}
protected function resubscribeToTopics(): void
{
foreach ($this->subscriptions as $topic => $callback) {
$this->client->subscribe($topic, $callback);
}
}
protected function handleConnectionFailure(\Exception $e): void
{
Log::error('MQTT Connection failed: ' . $e->getMessage());
$this->handleDisconnection();
$this->connectionLock = false;
}
public function subscribe(string $topic, callable $callback, int $qos = 0): bool
{
$this->subscriptions[$topic] = $callback;
if (!$this->isConnected) {
return false;
}
try {
$this->client->subscribe($topic, $callback, $qos);
Log::debug("MQTT Subscribed to {$topic}");
return true;
} catch (\Exception $e) {
Log::error("MQTT Subscribe failed to {$topic}: " . $e->getMessage());
return false;
}
}
protected function checkConnection(): void
public function publish(string $topic, string $message, int $qos = 0, bool $retain = false): bool
{
try {
// Simple ping test with short timeout
$this->client->publish($this->config['connection_settings']['last_will']['topic'], 'ping', 0, false);
$this->lastActivityTime = time();
} catch (\Exception $e) {
$this->handleDisconnection();
}
}
public function ensureConnected(): bool
{
if (!$this->isConnected) {
return $this->connect();
}
// Check if connection is stale
if ((time() - $this->lastActivityTime) > $this->config['connection_settings']['keep_alive_interval']) {
$this->checkConnection();
}
return $this->isConnected;
}
protected function handleDisconnection(): void
{
$this->isConnected = false;
Cache::put('mqtt_status', 'disconnected', 60);
Log::warning('MQTT Disconnection detected');
$this->scheduleReconnect();
}
protected function scheduleReconnect(): void
{
$maxAttempts = $this->config['connection_settings']['auto_reconnect']['max_reconnect_attempts'] ?? 5;
$delay = $this->config['connection_settings']['auto_reconnect']['delay_between_reconnect_attempts'] ?? 2;
if ($this->reconnectAttempts < $maxAttempts) {
$this->reconnectAttempts++;
$actualDelay = $delay * $this->reconnectAttempts;
Log::info("Attempting MQTT reconnect ({$this->reconnectAttempts}/{$maxAttempts}) in {$actualDelay} seconds");
sleep($actualDelay);
$this->connect();
} else {
Log::error('MQTT Max reconnect attempts reached');
Cache::put('mqtt_status', 'disconnected', 60);
}
}
public function isConnected(): bool
{
return $this->isConnected;
}
public function publish($topic, $message, $qos = 0, $retain = false): bool
{
if (!$this->ensureConnected()) {
// Store message in queue if disconnected
$this->storeMessageInQueue($topic, $message, $qos, $retain);
if (!$this->isConnected && !$this->connect()) {
$this->queueMessage($topic, $message, $qos, $retain);
return false;
}
try {
$this->client->publish($topic, $message, $qos, $retain);
$this->lastActivityTime = time();
Log::debug("MQTT Published to {$topic}: {$message}");
Log::debug("MQTT Published to {$topic}");
return true;
} catch (\Exception $e) {
$this->handleDisconnection();
Log::error("MQTT Publish failed to {$topic}: " . $e->getMessage());
// Store message in queue if failed
$this->storeMessageInQueue($topic, $message, $qos, $retain);
$this->handlePublishFailure($e, $topic, $message, $qos, $retain);
return false;
}
}
protected function storeMessageInQueue($topic, $message, $qos, $retain): void
protected function handlePublishFailure(\Exception $e, string $topic, string $message, int $qos, bool $retain): void
{
Log::error("MQTT Publish failed to {$topic}: " . $e->getMessage());
$this->handleDisconnection();
$this->queueMessage($topic, $message, $qos, $retain);
}
protected function queueMessage(string $topic, string $message, int $qos, bool $retain): void
{
$queue = Cache::get('mqtt_message_queue', []);
$queue[] = [
'topic' => $topic,
'message' => $message,
'qos' => $qos,
'retain' => $retain,
$queue[] = compact('topic', 'message', 'qos', 'retain') + [
'attempts' => 0,
'timestamp' => time(),
'timestamp' => now()->toDateTimeString(),
];
Cache::put('mqtt_message_queue', $queue, 3600);
}
public function processMessageQueue(): void
{
if (!$this->isConnected()) {
if (!$this->isConnected) {
return;
}
@ -186,17 +296,16 @@ public function processMessageQueue(): void
foreach ($queue as $message) {
if ($message['attempts'] >= 3) {
continue; // Skip messages that failed too many times
continue;
}
try {
$this->client->publish(
$this->publish(
$message['topic'],
$message['message'],
$message['qos'],
$message['retain']
);
$this->lastActivityTime = time();
} catch (\Exception $e) {
$message['attempts']++;
$remainingMessages[] = $message;
@ -206,105 +315,42 @@ public function processMessageQueue(): void
Cache::put('mqtt_message_queue', $remainingMessages, 3600);
}
public function subscribe($topic, $callback, $qos = 0): bool
public function isConnected(): bool
{
if (!$this->ensureConnected()) {
return false;
return $this->isConnected;
}
try {
$this->client->subscribe($topic, $callback, $qos);
$this->lastActivityTime = time();
Log::info("MQTT Subscribed to {$topic}");
return true;
} catch (\Exception $e) {
$this->handleDisconnection();
Log::error("MQTT Subscribe failed to {$topic}: " . $e->getMessage());
return false;
}
}
public function loop(bool $allowSleep = true): void
protected function handleDisconnection(): void
{
if ($this->isConnected()) {
try {
$this->client->loop($allowSleep);
$this->lastActivityTime = time();
$this->processMessageQueue();
} catch (\Exception $e) {
$this->handleDisconnection();
}
} else {
$this->connect();
}
}
public function disconnect(): void
{
if ($this->isConnected) {
try {
$this->client->disconnect();
$this->isConnected = false;
Cache::put('mqtt_status', 'disconnected', 60);
Log::info('MQTT Disconnected gracefully');
} catch (\Exception $e) {
Log::error('MQTT Disconnection error: ' . $e->getMessage());
}
}
$this->scheduleReconnect();
}
public static function quickPublish($topic, $message, $qos = 0, $retain = false): bool
protected function scheduleReconnect(): void
{
try {
$config = config('mqtt.connections.bel_sekolah');
$reconnectConfig = $this->getConnectionConfig()['connection_settings']['auto_reconnect'];
$mqtt = new MqttClient(
$config['host'],
$config['port'],
'quick-publish-' . uniqid()
);
$connectionSettings = (new ConnectionSettings)
->setConnectTimeout($config['connection_settings']['connect_timeout'] ?? 2)
->setUseTls(false);
$mqtt->connect($connectionSettings, true);
$mqtt->publish($topic, $message, $qos, $retain);
$mqtt->disconnect();
return true;
} catch (\Throwable $e) {
Log::error('Quick MQTT publish failed: ' . $e->getMessage());
return false;
}
if (!$reconnectConfig['enabled']) {
return;
}
if ($this->reconnectAttempts < $reconnectConfig['max_reconnect_attempts']) {
$this->reconnectAttempts++;
$delay = $reconnectConfig['delay_between_reconnect_attempts'] * $this->reconnectAttempts;
Log::info("MQTT Reconnect attempt {$this->reconnectAttempts} in {$delay} seconds");
sleep($delay);
$this->connect();
} else {
Log::error('MQTT Max reconnect attempts reached');
}
}
public function __destruct()
{
// Disconnect only if explicitly needed
if ($this->isConnected) {
$this->disconnect();
$this->client->disconnect();
}
}
public function sendAnnouncement($payload)
{
$topic = 'bel/sekolah/pengumuman';
// Publish utama
$this->publish($topic, json_encode($payload), 1, false);
// Jika TTS, kirim perintah stop setelah delay
if ($payload['type'] === 'tts' && $payload['auto_stop'] ?? false) {
$stopPayload = [
'type' => 'stop_tts',
'target_ruangans' => $payload['target_ruangans'],
'timestamp' => now()->toDateTimeString()
];
$this->publish($topic, json_encode($stopPayload), 1, false);
}
}
}

View File

@ -5,7 +5,7 @@
use PhpMqtt\Client\MqttClient;
return [
'default_connection' => 'sekolah',
'default_connection' => 'bel_sekolah',
'connections' => [
'bel_sekolah' => [
@ -14,6 +14,11 @@
'client_id' => env('MQTT_CLIENT_ID', 'laravel_bel_' . bin2hex(random_bytes(4))),
'use_clean_session' => false,
'connection_settings' => [
'auto_reconnect' => [
'enabled' => true,
'max_reconnect_attempts' => 5,
'delay_between_reconnect_attempts' => 3,
],
'last_will' => [
'topic' => 'bel/sekolah/status/backend',
'message' => json_encode(['status' => 'offline']),
@ -30,19 +35,30 @@
// Topic configuration
'topics' => [
'commands' => [
'announcement' => 'announcement/command',
'ring' => 'bel/sekolah/command/ring',
'sync' => 'bel/sekolah/command/sync',
'status' => 'bel/sekolah/command/status',
'relay_control' => 'ruangan/+/relay/control',
],
'responses' => [
'announcement_ack' => 'announcement/response/ack',
'announcement_error' => 'announcement/response/error',
'status' => 'bel/sekolah/response/status',
'ack' => 'bel/sekolah/response/ack',
'bell_ring' => 'bel/sekolah/response/ring',
'relay_status' => 'ruangan/+/relay/status',
],
'announcements' => [
'general' => 'bel/sekolah/pengumuman',
'emergency' => 'bel/sekolah/emergency',
'feedback' => 'bel/sekolah/status/pengumuman',
'events' => [ // [!++ Add this section ++!]
'bell_schedule' => 'bel/sekolah/events/schedule',
'bell_manual' => 'bel/sekolah/events/manual'
],
],
// QoS Levels
'qos_levels' => [
'default' => 0,
'announcement' => 1,
'relay_control' => 1, // QoS 1 untuk kontrol relay
'status_updates' => 1,
],
];

View File

@ -34,10 +34,4 @@
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
'voicerss' => [
'api_key' => env('VOICERSS_API_KEY'),
],
];

View File

@ -11,16 +11,15 @@ public function up(): void
Schema::create('ruangan', function (Blueprint $table) {
$table->id();
$table->string('nama_ruangan');
// Foreign keys
$table->foreignId('id_kelas')->constrained('kelas')->onDelete('cascade');
$table->foreignId('id_jurusan')->constrained('jurusan')->onDelete('cascade');
$table->string('relay_state')->default('OFF'); // Ubah tipe data
$table->string('mqtt_topic')->nullable(); // Untuk custom topic per ruangan
$table->timestamps();
// Tambahkan index untuk pencarian
$table->index('nama_ruangan');
$table->index(['id_kelas', 'id_jurusan']);
$table->index('relay_state');
});
}

View File

@ -10,18 +10,21 @@ public function up()
{
Schema::create('announcements', function (Blueprint $table) {
$table->id();
$table->string('mode'); // 'reguler' atau 'tts'
$table->string('mode');
$table->text('message');
$table->string('audio_path')->nullable();
$table->string('voice')->nullable();
$table->integer('speed')->nullable();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->boolean('is_active')->default(true);
$table->string('status')->default('pending');
$table->text('error_message')->nullable();
$table->string('relay_state')->default('OFF'); // Tambahkan kolom ini
$table->timestamp('sent_at')->useCurrent();
$table->timestamps();
$table->index('mode');
$table->index('sent_at');
$table->index('user_id');
$table->index('relay_state'); // Tambahkan index
});
// Perbaikan utama: Explicitly specify table names
@ -29,6 +32,7 @@ public function up()
$table->id();
$table->foreignId('announcement_id')->constrained('announcements')->onDelete('cascade');
$table->foreignId('ruangan_id')->constrained('ruangan')->onDelete('cascade');
$table->string('relay_state_at_time')->nullable(); // State saat pengumuman dikirim
$table->timestamps();
$table->unique(['announcement_id', 'ruangan_id']);

View File

@ -10,14 +10,54 @@ public function up()
{
Schema::create('bell_histories', function (Blueprint $table) {
$table->id();
$table->string('hari');
// Hari dalam format Indonesia
$table->enum('hari', [
'Senin',
'Selasa',
'Rabu',
'Kamis',
'Jumat',
'Sabtu',
'Minggu'
]);
// Waktu dalam format HH:MM:SS
$table->time('waktu');
$table->string('file_number', 4);
$table->enum('trigger_type', ['schedule', 'manual']);
$table->integer('volume');
$table->integer('repeat');
$table->timestamp('ring_time');
// Nomor file 4 digit (0001-9999)
$table->char('file_number', 4);
// Tipe trigger
$table->enum('trigger_type', ['schedule', 'manual'])
->default('schedule');
// Volume 0-30
$table->unsignedTinyInteger('volume')
->default(15);
// Repeat 1-5 kali
$table->unsignedTinyInteger('repeat')
->default(1);
// Waktu bel berbunyi
$table->timestamp('ring_time')
->useCurrent();
$table->timestamps();
// Optimasi query
$table->index('ring_time');
$table->index(['hari', 'waktu']);
$table->index('file_number');
// Constraint untuk memastikan data unik
$table->unique([
'hari',
'waktu',
'file_number',
'ring_time'
], 'bell_event_unique');
});
}

View File

@ -17,7 +17,8 @@ public function run(): void
UserTableSeeder::class,
JurusanSeeder::class,
KelasSeeder::class,
SiswaTableSeeder::class
SiswaTableSeeder::class,
StatusSeeder::class
]);
}
}

View File

@ -1,48 +0,0 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use App\Models\Ruangan;
use App\Models\Kelas;
use App\Models\Jurusan;
class RuanganSeeder extends Seeder
{
public function run(): void
{
// Hapus semua data ruangan sebelum menyisipkan data baru
Ruangan::truncate();
// Ambil semua ID kelas dan jurusan
$kelasIds = Kelas::pluck('id')->toArray();
$jurusanIds = Jurusan::pluck('id')->toArray();
$data = [];
foreach ($kelasIds as $kelasId) {
foreach ($jurusanIds as $jurusanId) {
// Ekstrak angka dari nama kelas
$kelasNumber = (int) preg_replace('/[^0-9]/', '', Kelas::find($kelasId)->nama_kelas);
$jurusanNumber = array_search($jurusanId, $jurusanIds) + 1;
$nama_ruangan = "{$kelasNumber}.{$jurusanNumber}";
// Pastikan kombinasi kelas_id dan jurusan_id unik
if (!Ruangan::where('kelas_id', $kelasId)
->where('jurusan_id', $jurusanId)
->exists()) {
$data[] = [
'nama_ruangan' => $nama_ruangan,
'kelas_id' => $kelasId,
'jurusan_id' => $jurusanId,
];
}
}
}
// Masukkan data ke tabel ruangan
foreach ($data as $item) {
Ruangan::create($item);
}
}
}

View File

@ -1,3 +1,66 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.btn-blue {
@apply bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center transition duration-300 shadow-md hover:shadow-lg text-sm;
}
.btn-purple {
@apply bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-lg flex items-center transition duration-300 shadow-md hover:shadow-lg text-sm;
}
.btn-yellow {
@apply bg-yellow-500 hover:bg-yellow-600 text-white px-4 py-2 rounded-lg flex items-center transition duration-300 shadow hover:shadow-md text-sm;
}
.btn-green {
@apply bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-lg flex items-center transition duration-300 shadow hover:shadow-md text-sm;
}
.btn-gray {
@apply bg-gray-100 hover:bg-gray-200 text-gray-800 px-4 py-2 rounded-lg inline-flex items-center transition duration-300 border border-gray-300 text-sm;
}
.dropdown-item {
@apply block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 w-full text-left flex items-center;
}
.select-input {
@apply block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 py-2 text-sm;
}
.search-input {
@apply block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 pl-10 py-2 text-sm;
}
.btn-primary {
@apply px-4 py-2 bg-gradient-to-r from-blue-500 to-indigo-600 text-white rounded-xl hover:from-blue-600 hover:to-indigo-700 transition duration-300 shadow-md hover:shadow-lg flex items-center;
}
.btn-secondary {
@apply px-4 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-xl text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-all duration-300 flex items-center shadow-sm hover:shadow-md;
}
.btn-reset {
@apply px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-xl hover:bg-gray-200 dark:hover:bg-gray-600 transition duration-300 flex items-center;
}
.form-input {
@apply block w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-xl shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 dark:focus:ring-blue-700 transition duration-300 bg-white dark:bg-gray-700 text-gray-900 dark:text-white;
}
.form-select {
@apply block w-full pl-3 pr-10 py-2 border border-gray-300 dark:border-gray-600 rounded-xl shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 dark:focus:ring-blue-700 transition duration-300 bg-white dark:bg-gray-700 text-gray-900 dark:text-white;
}
.badge-blue {
@apply px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-100;
}
.badge-purple {
@apply px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full bg-gradient-to-r from-purple-100 to-purple-200 dark:from-purple-900 dark:to-purple-800 text-purple-800 dark:text-purple-200;
}
.badge-green {
@apply px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full bg-gradient-to-r from-green-100 to-green-200 dark:from-green-900 dark:to-green-800 text-green-800 dark:text-green-200;
}
.badge-gray {
@apply px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded-full;
}

View File

@ -1,117 +1,269 @@
@extends('layouts.dashboard')
@section('title', 'Riwayat Pengumuman')
@section('content')
<div class="container-fluid">
<div class="row mb-4">
<div class="col-md-12">
<h2 class="fw-bold">Riwayat Pengumuman</h2>
<div class="container mx-auto px-4 py-8">
<!-- Header Section -->
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-8">
<div>
<h1 class="text-3xl font-bold text-gray-800">Riwayat Pengumuman</h1>
<p class="text-gray-600 mt-2">Daftar seluruh pengumuman yang pernah dikirim</p>
</div>
<div class="mt-4 md:mt-0 flex space-x-3">
<a href="{{ route('announcement.index') }}"
class="flex items-center px-5 py-2.5 bg-indigo-50 text-indigo-700 rounded-lg hover:bg-indigo-100 transition duration-300 shadow-sm">
<i class="fas fa-arrow-left mr-2"></i> Kembali
</a>
</div>
</div>
<div class="row mb-4">
<div class="col-md-12">
<div class="card shadow">
<div class="card-body">
<form action="{{ route('admin.announcement.history') }}" method="GET" class="row g-3">
<div class="col-md-5">
<input type="text" name="search" class="form-control" placeholder="Cari pengumuman..." value="{{ request('search') }}">
</div>
<div class="col-md-3">
<select name="mode" class="form-select">
<option value="">Semua Mode</option>
<option value="reguler" {{ request('mode') === 'reguler' ? 'selected' : '' }}>Reguler</option>
<option value="tts" {{ request('mode') === 'tts' ? 'selected' : '' }}>TTS</option>
</select>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary w-100">
<i class="fas fa-search me-2"></i> Filter
<!-- Filter Section -->
<div class="bg-white rounded-xl shadow-lg overflow-hidden mb-6 border border-gray-100">
<div class="p-6">
<form action="{{ route('admin.announcement.history') }}" method="GET">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Search Input -->
<div>
<label for="search" class="block text-gray-700 font-medium mb-2">Cari</label>
<div class="relative">
<input type="text" name="search" id="search" value="{{ request('search') }}"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition duration-200 pr-10"
placeholder="Cari pengumuman atau ruangan...">
<button type="submit" class="absolute right-3 top-3 text-gray-400 hover:text-gray-600">
<i class="fas fa-search"></i>
</button>
</div>
<div class="col-md-2">
<a href="{{ route('admin.announcement.history') }}" class="btn btn-outline-secondary w-100">
<i class="fas fa-sync-alt me-2"></i> Reset
</div>
<!-- Mode Filter -->
<div>
<label for="mode" class="block text-gray-700 font-medium mb-2">Jenis Pengumuman</label>
<div class="relative">
<select name="mode" id="mode"
class="appearance-none w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition duration-200 pr-10 bg-white">
<option value="">Semua Jenis</option>
<option value="reguler" {{ request('mode') == 'reguler' ? 'selected' : '' }}>Aktivasi Ruangan</option>
<option value="tts" {{ request('mode') == 'tts' ? 'selected' : '' }}>Pengumuman Suara (TTS)</option>
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-3 text-gray-700">
<i class="fas fa-chevron-down"></i>
</div>
</div>
</div>
<!-- Reset Button -->
<div class="flex items-end">
<a href="{{ route('admin.announcement.history') }}"
class="px-4 py-2.5 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition duration-300 flex items-center">
<i class="fas fa-sync-alt mr-2"></i> Reset Filter
</a>
</div>
</div>
</form>
</div>
</div>
<!-- Announcements Table -->
<div class="bg-white rounded-xl shadow-lg overflow-hidden border border-gray-100">
<!-- Table Header -->
<div class="bg-gradient-to-r from-gray-50 to-gray-100 px-6 py-4 border-b border-gray-200">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center">
<h2 class="text-lg font-semibold text-gray-800">Daftar Pengumuman</h2>
<div class="mt-2 md:mt-0 text-sm">
Menampilkan {{ $announcements->count() }} dari {{ $announcements->total() }} pengumuman
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div class="card shadow">
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover" id="announcements-table">
<thead class="table-dark">
<!-- Table Body -->
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th width="5%">#</th>
<th width="15%">Waktu</th>
<th width="10%">Mode</th>
<th width="20%">Ruangan Tujuan</th>
<th>Isi Pengumuman</th>
<th width="15%">Pengirim</th>
<th width="10%">Aksi</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Jenis
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Konten
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Ruangan Tujuan
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Waktu
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Aksi
</th>
</tr>
</thead>
<tbody>
<tbody class="bg-white divide-y divide-gray-200">
@forelse($announcements as $announcement)
<tr>
<td>{{ $loop->iteration }}</td>
<td>{{ $announcement->sent_at->format('d M Y H:i') }}</td>
<td>
<span class="badge bg-{{ $announcement->mode === 'reguler' ? 'primary' : 'success' }}">
{{ strtoupper($announcement->mode) }}
</span>
</td>
<td>
@foreach($announcement->ruangans as $ruangan)
<span class="badge bg-secondary mb-1">{{ $ruangan->nama_ruangan }}</span>
@endforeach
</td>
<td>
@if($announcement->mode === 'tts')
<i class="fas fa-volume-up text-success me-2"></i>
<small>TTS: {{ Str::limit($announcement->message, 50) }}</small>
@if($announcement->audio_path)
<a href="{{ $announcement->audio_url }}" target="_blank" class="btn btn-sm btn-outline-primary ms-2">
<i class="fas fa-play"></i>
</a>
@endif
<tr class="hover:bg-gray-50 transition duration-150">
<!-- Jenis -->
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
@if($announcement->mode === 'reguler')
<div class="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center mr-3">
<i class="fas fa-door-open text-blue-600 text-sm"></i>
</div>
<span class="text-sm font-medium text-gray-900">Aktivasi</span>
@else
{{ Str::limit($announcement->message, 50) }}
<div class="w-8 h-8 rounded-full bg-purple-100 flex items-center justify-center mr-3">
<i class="fas fa-volume-up text-purple-600 text-sm"></i>
</div>
<span class="text-sm font-medium text-gray-900">TTS</span>
@endif
</div>
</td>
<!-- Konten -->
<td class="px-6 py-4">
<div class="text-sm text-gray-900 max-w-xs truncate">
@if($announcement->mode === 'tts')
{{ $announcement->message }}
@else
{{ $announcement->is_active ? 'Aktivasi ruangan' : 'Deaktivasi ruangan' }}
<div class="text-xs text-gray-500 mt-1">
Status: {{ $announcement->is_active ? 'AKTIF' : 'NONAKTIF' }}
</div>
@endif
</div>
@if($announcement->mode === 'tts')
<div class="mt-1">
<audio controls class="h-8">
<source src="{{ asset('storage/' . $announcement->audio_path) }}" type="audio/wav">
</audio>
</div>
@endif
</td>
<td>{{ $announcement->user->name }}</td>
<td>
<a href="{{ route('admin.announcement.show', $announcement->id) }}" class="btn btn-sm btn-info">
<i class="fas fa-eye"></i>
<!-- Ruangan -->
<td class="px-6 py-4">
<div class="text-sm text-gray-500">
{{ $announcement->ruangans->count() }} ruangan
</div>
<div class="text-xs text-gray-400 mt-1">
@foreach($announcement->ruangans->take(3) as $ruangan)
{{ $ruangan->nama_ruangan }}@if(!$loop->last), @endif
@endforeach
@if($announcement->ruangans->count() > 3)
+{{ $announcement->ruangans->count() - 3 }} lainnya
@endif
</div>
</td>
<!-- Waktu -->
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-900">
{{ $announcement->sent_at->format('d M Y') }}
</div>
<div class="text-xs text-gray-500">
{{ $announcement->sent_at->format('H:i') }}
</div>
</td>
<!-- Status -->
<td class="px-6 py-4 whitespace-nowrap">
@if($announcement->status === 'delivered')
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
<i class="fas fa-check-circle mr-1"></i> Terkirim
</span>
@elseif($announcement->status === 'failed')
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
<i class="fas fa-times-circle mr-1"></i> Gagal
</span>
@else
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800">
<i class="fas fa-clock mr-1"></i> Proses
</span>
@endif
</td>
<!-- Aksi -->
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="dropdown relative inline-block">
<button class="dropdown-toggle p-1 rounded-full hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition duration-200">
<i class="fas fa-ellipsis-v"></i>
</button>
<div class="dropdown-menu absolute right-0 mt-1 w-40 bg-white rounded-md shadow-lg py-1 z-10 hidden border border-gray-200">
<a href="{{ route('admin.announcement.show', $announcement->id) }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-eye mr-2"></i> Detail
</a>
<form action="{{ route('admin.announcement.destroy', $announcement->id) }}" method="POST" class="d-inline">
<form action="{{ route('admin.announcement.destroy', $announcement->id) }}" method="POST" class="block w-full">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Hapus pengumuman ini?')">
<i class="fas fa-trash"></i>
<button type="button" onclick="confirmDelete(this)" class="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-gray-100">
<i class="fas fa-trash-alt mr-2"></i> Hapus
</button>
</form>
</div>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="text-center">Belum ada pengumuman</td>
<td colspan="6" class="px-6 py-12 text-center">
<div class="mx-auto w-24 h-24 bg-gray-100 rounded-full flex items-center justify-center mb-4">
<i class="fas fa-bullhorn text-3xl text-gray-400"></i>
</div>
<h3 class="text-lg font-medium text-gray-700">Belum Ada Pengumuman</h3>
<p class="text-gray-500 mt-1">Tidak ada riwayat pengumuman yang ditemukan</p>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
<div class="card-footer">
{{ $announcements->links() }}
</div>
</div>
<!-- Pagination -->
<div class="bg-gray-50 px-6 py-4 border-t border-gray-200">
{{ $announcements->appends(request()->query())->links() }}
</div>
</div>
</div>
<!-- SweetAlert CDN -->
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<!-- Custom Script -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Confirm delete function
function confirmDelete(form) {
Swal.fire({
title: 'Hapus Pengumuman?',
text: "Anda tidak akan bisa mengembalikan data ini!",
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#6366f1',
cancelButtonColor: '#d33',
confirmButtonText: 'Ya, Hapus!',
cancelButtonText: 'Batal',
backdrop: 'rgba(99, 102, 241, 0.1)'
}).then((result) => {
if (result.isConfirmed) {
form.closest('form').submit();
}
});
}
// Dropdown menu handler
document.addEventListener('click', function(e) {
if (!e.target.closest('.dropdown')) {
document.querySelectorAll('.dropdown-menu').forEach(menu => {
menu.classList.add('hidden');
});
} else {
const dropdown = e.target.closest('.dropdown');
const menu = dropdown.querySelector('.dropdown-menu');
menu.classList.toggle('hidden');
}
});
});
</script>
@endsection

File diff suppressed because it is too large Load Diff

View File

@ -1,173 +1,225 @@
@extends('layouts.dashboard')
@section('title', 'Riwayat Bel Sekolah')
@section('content')
<div class="p-4 sm:p-6">
<!-- Header Section -->
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 gap-4">
<div class="flex items-center space-x-3">
<div class="p-3 rounded-xl bg-gradient-to-r from-blue-500 to-indigo-600 shadow-lg">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</div>
<div class="container mx-auto px-4 py-8">
<!-- Header with animated gradient -->
<div class="flex justify-between items-center mb-8">
<div>
<h1 class="text-2xl font-bold text-gray-800 dark:text-white">Riwayat Bunyi Bel</h1>
<p class="text-sm text-gray-500 dark:text-gray-400">Catatan lengkap semua aktivitas bel sekolah</p>
<h1 class="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-indigo-800 animate-gradient-x">
Riwayat Bel Sekolah
</h1>
<p class="text-gray-600 mt-1">Log aktivasi bel sekolah otomatis dan manual</p>
</div>
</div>
<div class="flex items-center space-x-2">
<a href="{{ route('bel.index') }}" class="px-4 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-xl text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-all duration-300 flex items-center shadow-sm hover:shadow-md">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
<div class="flex items-center space-x-4">
<a href="{{ route('bel.index') }}" class="btn-blue flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clip-rule="evenodd" />
</svg>
Kembali
</a>
</div>
</div>
<!-- Filters Section -->
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6 transition-all duration-300">
<form id="filter-form" action="{{ route('bel.history') }}" method="GET" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<!-- Search Box -->
<div class="md:col-span-2">
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Pencarian</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
<div class="animate-bounce">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
</div>
<input type="text" id="search" name="search" value="{{ request('search') }}" placeholder="Cari berdasarkan file/hari..."
class="block w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-xl shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 dark:focus:ring-blue-700 transition duration-300 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
</div>
</div>
<!-- Trigger Type Filter -->
<div>
<label for="trigger_type" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Jenis Trigger</label>
<select id="trigger_type" name="trigger_type" class="block w-full pl-3 pr-10 py-2 border border-gray-300 dark:border-gray-600 rounded-xl shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 dark:focus:ring-blue-700 transition duration-300 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="">Semua Jenis</option>
<!-- Filter Card with modern design -->
<div class="bg-white rounded-xl shadow-md p-6 mb-8 border border-gray-100">
<h3 class="text-lg font-semibold text-gray-800 mb-4 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-blue-500" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" clip-rule="evenodd" />
</svg>
Filter
</h3>
<form action="{{ route('bel.history.filter') }}" method="GET" class="grid grid-cols-1 md:grid-cols-12 gap-4 items-end">
<!-- Hari Filter -->
<div class="md:col-span-4">
<label for="hari" class="block text-sm font-medium text-gray-700 mb-1 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Hari
</label>
<div class="relative">
<select name="hari" id="hari" class="block w-full pl-8 pr-3 py-2.5 text-base border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 rounded-lg shadow-sm transition duration-150">
<option value="">Semua Hari</option>
@foreach(['Senin','Selasa','Rabu','Kamis','Jumat','Sabtu','Minggu'] as $day)
<option value="{{ $day }}" {{ request('hari') == $day ? 'selected' : '' }}>
{{ $day }}
</option>
@endforeach
</select>
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
</div>
</div>
<!-- Trigger Filter -->
<div class="md:col-span-4">
<label for="trigger_type" class="block text-sm font-medium text-gray-700 mb-1 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Trigger
</label>
<div class="relative">
<select name="trigger_type" id="trigger_type" class="block w-full pl-8 pr-3 py-2.5 text-base border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 rounded-lg shadow-sm transition duration-150">
<option value="">Semua Tipe</option>
<option value="schedule" {{ request('trigger_type') == 'schedule' ? 'selected' : '' }}>Jadwal</option>
<option value="manual" {{ request('trigger_type') == 'manual' ? 'selected' : '' }}>Manual</option>
</select>
</div>
<!-- Date Filter -->
<div>
<label for="date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tanggal</label>
<input type="date" id="date" name="date" value="{{ request('date') }}"
class="block w-full pl-3 pr-10 py-2 border border-gray-300 dark:border-gray-600 rounded-xl shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 dark:focus:ring-blue-700 transition duration-300 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
</div>
</div>
<div class="flex justify-between items-center">
<div class="text-sm text-gray-500 dark:text-gray-400">
Total Data: <span class="font-medium">{{ $histories->total() }}</span>
</div>
<div class="flex space-x-2">
@if(request()->hasAny(['search', 'trigger_type', 'date']))
<a href="{{ route('bel.history') }}" class="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-xl hover:bg-gray-200 dark:hover:bg-gray-600 transition duration-300 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
Reset
</a>
@endif
<button type="submit" class="px-4 py-2 bg-gradient-to-r from-blue-500 to-indigo-600 text-white rounded-xl hover:from-blue-600 hover:to-indigo-700 transition duration-300 shadow-md hover:shadow-lg flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="md:col-span-4 flex space-x-3">
<button type="submit" class="flex-1 bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white px-4 py-2.5 rounded-lg shadow-md hover:shadow-lg transition-all duration-300 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
Terapkan Filter
</button>
</div>
<a href="{{ route('bel.history.index') }}" class="bg-white border border-gray-300 hover:bg-gray-50 text-gray-700 px-4 py-2.5 rounded-lg shadow-sm hover:shadow-md transition-all duration-300 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd" />
</svg>
Reset
</a>
</div>
</form>
</div>
<!-- Bell History Table -->
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden transition-all duration-300">
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-xl p-6 shadow-md border border-blue-100">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-600">TOTAL AKTIVITAS</p>
<h3 class="text-2xl font-bold text-gray-800 mt-1">{{ $histories->total() }}</h3>
</div>
<div class="p-3 rounded-full bg-blue-100 text-blue-600">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
</div>
</div>
<div class="bg-gradient-to-br from-green-50 to-teal-50 rounded-xl p-6 shadow-md border border-green-100">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-600">TERJADWAL</p>
<h3 class="text-2xl font-bold text-gray-800 mt-1">{{ $histories->where('trigger_type', 'schedule')->count() }}</h3>
</div>
<div class="p-3 rounded-full bg-green-100 text-green-600">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
</div>
</div>
<div class="bg-gradient-to-br from-purple-50 to-pink-50 rounded-xl p-6 shadow-md border border-purple-100">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-600">MANUAL</p>
<h3 class="text-2xl font-bold text-gray-800 mt-1">{{ $histories->where('trigger_type', 'manual')->count() }}</h3>
</div>
<div class="p-3 rounded-full bg-purple-100 text-purple-600">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</div>
</div>
</div>
</div>
<!-- Table Card -->
<div class="bg-white rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Waktu</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Hari</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Jam</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">File</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Trigger</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Volume</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Pengulangan</th>
<th scope="col" class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Waktu Bel</th>
<th scope="col" class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Hari</th>
<th scope="col" class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">File</th>
<th scope="col" class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Trigger</th>
<th scope="col" class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Volume</th>
<th scope="col" class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Repeat</th>
<th scope="col" class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Aksi</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
<tbody class="bg-white divide-y divide-gray-200">
@forelse($histories as $history)
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition duration-150">
<tr class="hover:bg-gray-50 transition duration-150 group">
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ $history->ring_time->format('d M Y') }}
<div class="flex items-center">
<div class="flex-shrink-0 h-10 w-10 flex items-center justify-center rounded-full bg-blue-50 text-blue-600 mr-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd" />
</svg>
</div>
<div>
<div class="text-sm font-medium text-gray-900">{{ $history->ring_time->format('d M Y') }}</div>
<div class="text-sm text-gray-500">{{ $history->ring_time->format('H:i:s') }}</div>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $history->ring_time->format('H:i:s') }}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-100">
<span class="px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800">
{{ $history->hari }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{{ $history->waktu }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full bg-gradient-to-r from-purple-100 to-purple-200 dark:from-purple-900 dark:to-purple-800 text-purple-800 dark:text-purple-200">
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono font-medium text-indigo-600">
{{ $history->file_number }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full
{{ $history->trigger_type == 'schedule' ? 'bg-green-100 text-green-800' : 'bg-blue-100 text-blue-800' }}">
{{ ucfirst($history->trigger_type) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
@if($history->trigger_type == 'schedule')
<span class="px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full bg-gradient-to-r from-green-100 to-green-200 dark:from-green-900 dark:to-green-800 text-green-800 dark:text-green-200">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Jadwal
</span>
@else
<span class="px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full bg-gradient-to-r from-blue-100 to-blue-200 dark:from-blue-900 dark:to-blue-800 text-blue-800 dark:text-blue-200">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
</svg>
Manual
</span>
@endif
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1 text-gray-500 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15.536a5 5 0 001.414 1.414m4.242-12.728a9 9 0 012.728 2.728" />
</svg>
<span class="text-sm font-medium text-gray-900 dark:text-white">
{{ $history->volume ?? '15' }}
</span>
<div class="flex text-center">
<span class="text-sm font-medium text-gray-700">{{ $history->volume }}%</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
<span class="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded-full">
{{ $history->repeat ?? '1' }}x
</span>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-medium">
<span class="px-2 py-1 bg-gray-100 rounded-md">{{ $history->repeat }}x</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<form action="{{ route('bel.history.destroy', $history->id) }}" method="POST" class="inline delete-form">
@csrf
@method('DELETE')
<button type="submit" class="text-red-600 hover:text-red-900 flex items-center group-hover:animate-pulse">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
Hapus
</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-6 py-12 text-center">
<div class="flex flex-col items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 text-gray-400 mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-gray-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Tidak ada data riwayat</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Belum ada aktivitas bel yang tercatat</p>
<h3 class="text-lg font-medium text-gray-700 mb-1">Tidak ada data riwayat</h3>
<p class="text-gray-500 max-w-md text-center">Belum ada aktivasi bel yang tercatat. Aktivasi bel akan muncul di sini setelah dijalankan.</p>
</div>
</td>
</tr>
@ -178,70 +230,87 @@ class="block w-full pl-3 pr-10 py-2 border border-gray-300 dark:border-gray-600
<!-- Pagination -->
@if($histories->hasPages())
<div class="bg-white dark:bg-gray-800 px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex flex-col sm:flex-row items-center justify-between">
<div class="text-sm text-gray-500 dark:text-gray-400 mb-2 sm:mb-0">
Menampilkan <span class="font-medium">{{ $histories->firstItem() }}</span> sampai <span class="font-medium">{{ $histories->lastItem() }}</span> dari <span class="font-medium">{{ $histories->total() }}</span> entri
</div>
<div class="flex space-x-1">
{{ $histories->links('vendor.pagination.tailwind') }}
</div>
<div class="bg-white px-6 py-4 border-t border-gray-200">
{{ $histories->links() }}
</div>
@endif
</div>
</div>
@endsection
<!-- Include SweetAlert2 -->
@push('styles')
<style>
.animate-gradient-x {
background-size: 200% 200%;
animation: gradient-x 3s ease infinite;
}
@keyframes gradient-x {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.animate-bounce {
animation: bounce 2s infinite;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
</style>
@endpush
@push('scripts')
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Live Clock
function updateClock() {
const now = new Date();
const clockElement = document.getElementById('liveClock');
if (clockElement) {
clockElement.textContent =
now.getHours().toString().padStart(2, '0') + ':' +
now.getMinutes().toString().padStart(2, '0') + ':' +
now.getSeconds().toString().padStart(2, '0');
}
}
setInterval(updateClock, 1000);
updateClock();
document.addEventListener('DOMContentLoaded', function() {
// Enhanced SweetAlert for delete confirmation
const deleteForms = document.querySelectorAll('.delete-form');
// Toast notification
const Toast = Swal.mixin({
toast: true,
position: 'top-end',
showConfirmButton: false,
timer: 3000,
deleteForms.forEach(form => {
form.addEventListener('submit', function(e) {
e.preventDefault();
Swal.fire({
title: 'Konfirmasi Penghapusan',
text: 'Data riwayat bel yang dihapus tidak dapat dikembalikan',
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
confirmButtonText: 'Ya, Hapus!',
cancelButtonText: 'Batal',
backdrop: `
rgba(0,0,123,0.4)
url("/images/nyan-cat.gif")
left top
no-repeat
`,
showClass: {
popup: 'animate__animated animate__fadeInDown'
},
hideClass: {
popup: 'animate__animated animate__fadeOutUp'
}
}).then((result) => {
if (result.isConfirmed) {
Swal.fire({
title: 'Menghapus...',
html: 'Sedang memproses penghapusan data',
timer: 2000,
timerProgressBar: true,
didOpen: (toast) => {
toast.addEventListener('mouseenter', Swal.stopTimer)
toast.addEventListener('mouseleave', Swal.resumeTimer)
didOpen: () => {
Swal.showLoading()
}
}).then(() => {
this.submit();
});
}
});
// // Show success message if exists
// @if(session('success'))
// Toast.fire({
// icon: 'success',
// title: '{{ session('success') }}',
// background: '#f0fdf4',
// iconColor: '#16a34a',
// color: '#166534'
// });
// @endif
// @if(session('error'))
// Toast.fire({
// icon: 'error',
// title: '{{ session('error') }}',
// background: '#fef2f2',
// iconColor: '#dc2626',
// color: '#991b1b'
// });
// @endif
});
});
});
});
</script>
@endsection
@endpush

View File

@ -4,9 +4,8 @@
@section('content')
<div class="container mx-auto px-4 py-8">
<!-- Header Section with Breadcrumbs -->
<div class="mb-6">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<!-- Header Section -->
<div class="mb-6 flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h1 class="text-2xl md:text-3xl font-bold text-gray-800">Manajemen Jadwal Bel Sekolah</h1>
<p class="text-sm text-gray-600">Jadwal Bel Otomatis dan Kontrol Sistem</p>
@ -22,16 +21,15 @@
<span class="text-gray-500">{{ now()->format('d M Y') }}</span>
</div>
<!-- History Button -->
<a href="{{ route('bel.history') }}" class="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-lg flex items-center transition duration-300 shadow-md hover:shadow-lg">
<!-- Action Buttons -->
<a href="{{ route('bel.history.index') }}" class="inline-flex items-center px-4 py-2 bg-purple-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-purple-700 focus:bg-purple-700 active:bg-purple-900 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 transition ease-in-out duration-150">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd" />
</svg>
Riwayat
</a>
<!-- Create Button -->
<a href="{{ route('bel.create') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center transition duration-300 shadow-md hover:shadow-lg">
<a href="{{ route('bel.create') }}" class="btn-blue">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd" />
</svg>
@ -40,7 +38,7 @@
<!-- Bulk Actions Dropdown -->
<div class="relative inline-block">
<button onclick="toggleDropdown()" class="bg-gray-100 hover:bg-gray-200 text-gray-800 px-4 py-2 rounded-lg inline-flex items-center transition duration-300 border border-gray-300">
<button onclick="toggleDropdown()" class="btn-gray">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path d="M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM16 12a2 2 0 100-4 2 2 0 000 4z" />
</svg>
@ -51,19 +49,19 @@
</button>
<div id="actionDropdown" class="hidden absolute right-0 z-10 mt-2 w-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none border border-gray-200">
<div class="py-1">
<button onclick="activateAll()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 w-full text-left flex items-center">
<button onclick="activateAll()" class="dropdown-item">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 text-green-500" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
Aktifkan Semua
</button>
<button onclick="deactivateAll()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 w-full text-left flex items-center">
<button onclick="deactivateAll()" class="dropdown-item">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 text-gray-500" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
NonAktifkan Semua
</button>
<button onclick="confirmDeleteAll()" class="block px-4 py-2 text-sm text-red-600 hover:bg-gray-100 w-full text-left flex items-center">
<button onclick="confirmDeleteAll()" class="dropdown-item text-red-600">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
@ -74,57 +72,38 @@
</div>
</div>
</div>
</div>
<!-- System Status Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5 mb-8">
<!-- MQTT Status Card -->
<div id="mqttCard" class="bg-white rounded-xl shadow-sm p-5 border-l-4 border-green-500 hover:shadow-md transition duration-200">
<div class="flex items-start">
<div id="mqttIconBg" class="p-3 rounded-full bg-green-100 flex-shrink-0">
<svg id="mqttIconSvg" class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
</div>
<div class="ml-4">
<h3 class="text-sm font-medium text-gray-500">MQTT Connection</h3>
<p id="mqttStatusText" class="text-lg font-semibold text-green-600">Connected</p>
<p id="mqttStatusDetails" class="text-xs text-gray-500 mt-1">Terkahir diPerbarui: {{ now()->format('H:i:s') }}</p>
</div>
</div>
</div>
@include('admin.bel.partials.status-card', [
'id' => 'mqttCard',
'icon' => 'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01',
'title' => 'MQTT Connection',
'statusId' => 'mqttStatusText',
'status' => 'Connected',
'detailsId' => 'mqttStatusDetails',
'details' => 'Terkahir diPerbarui: '.now()->format('H:i:s')
])
<!-- RTC Status Card -->
<div id="rtcCard" class="bg-white rounded-xl shadow-sm p-5 border-l-4 border-green-500 hover:shadow-md transition duration-200">
<div class="flex items-start">
<div id="rtcIcon" class="p-3 rounded-full bg-green-100 flex-shrink-0">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="ml-4">
<h3 class="text-sm font-medium text-gray-500">RTC Module</h3>
<p id="rtcStatusText" class="text-lg font-semibold text-green-600">Connected</p>
<p id="rtcTimeText" class="text-xs text-gray-500 mt-1">{{ now()->format('Y-m-d\ H:i:s') }}</p>
</div>
</div>
</div>
@include('admin.bel.partials.status-card', [
'id' => 'rtcCard',
'icon' => 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
'title' => 'RTC Module',
'statusId' => 'rtcStatusText',
'status' => 'Connected',
'detailsId' => 'rtcTimeText',
'details' => now()->format('Y-m-d H:i:s')
])
<!-- DFPlayer Status Card -->
<div id="dfplayerCard" class="bg-white rounded-xl shadow-sm p-5 border-l-4 border-green-500 hover:shadow-md transition duration-200">
<div class="flex items-start">
<div id="dfplayerIcon" class="p-3 rounded-full bg-green-100 flex-shrink-0">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
</svg>
</div>
<div class="ml-4">
<h3 class="text-sm font-medium text-gray-500">Audio Player</h3>
<p id="dfplayerStatusText" class="text-lg font-semibold text-green-600">Connected</p>
<p id="dfplayerDetails" class="text-xs text-gray-500 mt-1">50 Audio File Tersedia</p>
</div>
</div>
</div>
@include('admin.bel.partials.status-card', [
'id' => 'dfplayerCard',
'icon' => 'M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3',
'title' => 'Audio Player',
'statusId' => 'dfplayerStatusText',
'status' => 'Connected',
'detailsId' => 'dfplayerDetails',
'details' => '50 Audio File Tersedia'
])
<!-- Next Bell Countdown -->
<div class="bg-white rounded-xl shadow-sm p-5 border-l-4 border-blue-500 hover:shadow-md transition duration-200">
@ -137,11 +116,7 @@
<div class="ml-4">
<h3 class="text-sm font-medium text-gray-500">Jadwal Bel Berikutnya</h3>
<p class="text-lg font-semibold text-gray-700" id="nextScheduleCountdown">
@if($nextSchedule)
Menghitung ...
@else
Tidak Ada Jadwal Bel Yang Akan Datang
@endif
{{ $nextSchedule ? 'Menghitung ...' : 'Tidak Ada Jadwal Bel Yang Akan Datang' }}
</p>
<p class="text-xs text-gray-500 mt-1" id="nextScheduleTime">
@if($nextSchedule)
@ -201,7 +176,7 @@
<!-- Day Filter -->
<div class="w-full md:w-auto">
<label for="hari" class="block text-sm font-medium text-gray-700 mb-1">Filter Hari</label>
<select name="hari" id="hari" onchange="this.form.submit()" class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm">
<select name="hari" id="hari" onchange="this.form.submit()" class="select-input">
<option value="">Semua Hari</option>
@foreach (\App\Models\JadwalBel::DAYS as $day)
<option value="{{ $day }}" {{ request('hari') === $day ? 'selected' : '' }}>
@ -217,7 +192,7 @@
<div class="relative">
<input type="text" id="search" name="search" placeholder="Cari Jadwal..."
value="{{ request('search') }}"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 pl-10 text-sm">
class="search-input">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd" />
@ -241,7 +216,7 @@ class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 f
<!-- Quick Actions -->
<div class="flex flex-wrap items-center gap-2">
<!-- Manual Ring Button -->
<button onclick="showRingModal()" class="bg-yellow-500 hover:bg-yellow-600 text-white px-4 py-2 rounded-lg flex items-center transition duration-300 shadow hover:shadow-md text-sm">
<button onclick="showRingModal()" class="btn-yellow">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 01-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
@ -249,7 +224,7 @@ class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 f
</button>
<!-- Sync Button -->
<button onclick="syncSchedules()" class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-lg flex items-center transition duration-300 shadow hover:shadow-md text-sm">
<button onclick="syncSchedules()" class="btn-green">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd" />
</svg>
@ -334,7 +309,7 @@ class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 f
</svg>
<h3 class="text-lg font-medium mb-1">Tidak Ada Jadwal Tersedia</h3>
<p class="max-w-md text-center mb-4">Anda Belum Membuat Jadwal Bel. Klik Button Diatas Untuk Menambahkan Jadwal Bel</p>
<a href="{{ route('bel.create') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center transition duration-300 shadow-md hover:shadow-lg">
<a href="{{ route('bel.create') }}" class="btn-blue">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd" />
</svg>
@ -360,562 +335,9 @@ class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 f
<!-- Include SweetAlert2 -->
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
// Enhanced JavaScript with better organization and error handling
// ======================
// UTILITY FUNCTIONS
// ======================
function showLoading(title = 'Process...') {
Swal.fire({
title: title,
html: 'Harap Tunggu Sementara Kami Memproses Permintaan Anda...',
allowOutsideClick: false,
didOpen: () => {
Swal.showLoading();
}
});
}
function showToast(icon, title) {
const Toast = Swal.mixin({
toast: true,
position: 'top-end',
showConfirmButton: false,
timer: 3000,
timerProgressBar: true,
didOpen: (toast) => {
toast.addEventListener('mouseenter', Swal.stopTimer)
toast.addEventListener('mouseleave', Swal.resumeTimer)
}
});
Toast.fire({ icon, title });
}
// ======================
// UI FUNCTIONS
// ======================
// Live Clock
function updateClock() {
const now = new Date();
document.getElementById('liveClock').textContent =
now.getHours().toString().padStart(2, '0') + ':' +
now.getMinutes().toString().padStart(2, '0') + ':' +
now.getSeconds().toString().padStart(2, '0');
}
setInterval(updateClock, 1000);
// Toggle Dropdown
function toggleDropdown() {
document.getElementById('actionDropdown').classList.toggle('hidden');
}
// Close dropdown when clicking outside
window.addEventListener('click', function(e) {
if (!e.target.closest('.relative.inline-block')) {
document.getElementById('actionDropdown').classList.add('hidden');
}
});
// ======================
// SCHEDULE MANAGEMENT
// ======================
// Delete confirmation
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.delete-btn').forEach(button => {
button.addEventListener('click', function(e) {
e.preventDefault();
const form = this.closest('form');
Swal.fire({
title: 'Hapus Jadwal?',
text: "Ini Tidak Dapat Di Batalkan!",
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
confirmButtonText: 'Ya ',
cancelButtonText: 'TIdak',
showLoaderOnConfirm: true,
preConfirm: () => {
return fetch(form.action, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({ _method: 'DELETE' })
})
.then(response => {
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
})
.catch(error => {
Swal.showValidationMessage(
`Request failed: ${error}`
);
});
},
allowOutsideClick: () => !Swal.isLoading()
}).then((result) => {
if (result.isConfirmed) {
showToast('success', result.value.message || 'Jadwal Bel Berhasil Di Hapus');
setTimeout(() => {
window.location.reload();
}, 1000);
}
});
});
});
});
// Bulk delete confirmation
function confirmDeleteAll() {
Swal.fire({
title: 'Hapus Semua Jadwwal?',
text: "Anda Akan Mengahpus Semua Jadwal!",
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
confirmButtonText: 'Ya, Hapus Semua',
cancelButtonText: 'Cancel',
showLoaderOnConfirm: true,
preConfirm: () => {
return fetch("{{ route('bel.delete-all') }}", {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({ _method: 'DELETE' })
})
.then(response => {
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
})
.catch(error => {
Swal.showValidationMessage(
`Request failed: ${error}`
);
});
},
allowOutsideClick: () => !Swal.isLoading()
}).then((result) => {
if (result.isConfirmed) {
showToast('success', result.value.message || 'Semua Jadwal Berhasil Di Hapus');
setTimeout(() => {
window.location.reload();
}, 1000);
}
});
}
// Activate all schedules
function activateAll() {
showLoading('Aktifkan Semua Jadwal...');
fetch("{{ route('api.bel.activate-all') }}", {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
}
})
.then(response => response.json())
.then(data => {
Swal.close();
if (data.success) {
showToast('success', data.message || 'Semua Jadwal Berhasil Di Aktifkan');
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
showToast('error', data.message || 'Jadwal Gagal Di Aktifkan');
}
})
.catch(error => {
Swal.fire({
icon: 'error',
title: 'Error',
text: error.message
});
});
}
// Deactivate all schedules
function deactivateAll() {
showLoading('Menonaktifkan Semua Jadwal...');
fetch("{{ route('api.bel.deactivate-all') }}", {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
}
})
.then(response => response.json())
.then(data => {
Swal.close();
if (data.success) {
showToast('success', data.message || 'Semua Jadwal Berhasil Di Nonaktifkan');
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
showToast('error', data.message || 'Jadwal Gagal Di Nonaktifkan');
}
})
.catch(error => {
Swal.fire({
icon: 'error',
title: 'Error',
text: error.message
});
});
}
// ======================
// BELL CONTROL FUNCTIONS
// ======================
// Ring bell modal
function showRingModal() {
Swal.fire({
title: 'Manual Bell Ring',
html: `
<div class="text-left">
<div class="mb-4">
<label for="swal-file-number" class="block text-sm font-medium text-gray-700 mb-1">Sound File</label>
<select id="swal-file-number" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 py-2 text-sm">
<option value="">Select Sound File</option>
@for($i = 1; $i <= 50; $i++)
<option value="{{ sprintf('%04d', $i) }}">File {{ sprintf('%04d', $i) }}</option>
@endfor
</select>
</div>
<div class="mb-4">
<label for="swal-volume" class="block text-sm font-medium text-gray-700 mb-1">Volume (1-30)</label>
<input type="number" id="swal-volume" min="1" max="30" value="20"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 py-2 text-sm">
</div>
</div>
`,
showCancelButton: true,
confirmButtonText: 'Ring Bell',
cancelButtonText: 'Cancel',
focusConfirm: false,
preConfirm: () => {
const fileNumber = document.getElementById('swal-file-number').value;
const volume = document.getElementById('swal-volume').value;
if (!fileNumber) {
Swal.showValidationMessage('Pilih File');
return false;
}
if (volume < 1 || volume > 30) {
Swal.showValidationMessage('Volume Antar 1 - 30');
return false;
}
return { file_number: fileNumber, volume: volume };
}
}).then((result) => {
if (result.isConfirmed) {
ringBell(result.value.file_number, result.value.volume);
}
});
}
// Ring bell function
function ringBell(fileNumber, volume = 20) {
showLoading('Ringing bell...');
fetch("{{ route('api.bel.ring') }}", {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
file_number: fileNumber,
volume: volume
})
})
.then(response => response.json())
.then(data => {
Swal.close();
if (data.success) {
showToast('success', 'Bel Berhasil Di Bunyikan');
} else {
showToast('error', data.message || 'Bel Gagal Di Bunyikan');
}
})
.catch(error => {
Swal.fire({
icon: 'error',
title: 'Error',
text: error.message
});
});
}
// Sync schedules function
function syncSchedules() {
showLoading('Sinkronasi Jadwal Bel Dengan Device...');
fetch("{{ route('api.bel.sync') }}", {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
}
})
.then(response => response.json())
.then(data => {
Swal.close();
if (data.success) {
showToast('success', data.message || 'Sinkronisasi Jadwal Berhasil');
} else {
showToast('error', data.message || 'Sinkronisasi Jadwal Gagal');
}
})
.catch(error => {
Swal.fire({
icon: 'error',
title: 'Error',
text: error.message
});
});
}
// Export schedules function
function exportSchedules() {
showLoading('Preparing export...');
// Get current filter parameters
const params = new URLSearchParams({
hari: document.getElementById('hari').value || '',
search: document.getElementById('search').value || '',
export: 'true'
});
window.location.href = `{{ route('bel.index') }}?${params.toString()}`;
Swal.close();
}
// ======================
// DEVICE STATUS FUNCTIONS
// ======================
// Get live status
function getLiveStatus() {
fetch("{{ route('api.bel.status') }}", {
method: 'GET',
headers: {
'Accept': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
updateDeviceStatus(data.data);
}
})
.catch(error => console.error('Status update error:', error));
}
// Update device status
function updateDeviceStatus(data) {
// Update MQTT Status
if (data.mqtt_status !== undefined) {
const mqttCard = document.querySelector('#mqttCard');
const mqttStatusText = document.querySelector('#mqttStatusText');
const mqttIconBg = document.querySelector('#mqttIconBg');
const mqttIconSvg = document.querySelector('#mqttIconSvg');
const isConnected = data.mqtt_status === true || data.mqtt_status === 'Connected';
mqttCard.classList.toggle('border-green-500', isConnected);
mqttCard.classList.toggle('border-red-500', !isConnected);
mqttIconBg.classList.toggle('bg-green-100', isConnected);
mqttIconBg.classList.toggle('bg-red-100', !isConnected);
mqttIconSvg.classList.toggle('text-green-600', isConnected);
mqttIconSvg.classList.toggle('text-red-600', !isConnected);
mqttStatusText.textContent = isConnected ? 'Connected' : 'Disconnected';
mqttStatusText.classList.toggle('text-green-600', isConnected);
mqttStatusText.classList.toggle('text-red-600', !isConnected);
if (data.mqtt_last_update) {
document.querySelector('#mqttStatusDetails').textContent =
`Last updated: ${new Date(data.mqtt_last_update).toLocaleTimeString()}`;
}
}
// Update RTC Status
if (data.rtc !== undefined) {
const rtcCard = document.querySelector('#rtcCard');
const rtcIcon = document.querySelector('#rtcIcon');
const rtcStatusText = document.querySelector('#rtcStatusText');
const rtcTimeText = document.querySelector('#rtcTimeText');
const isConnected = data.rtc === true;
rtcCard.classList.toggle('border-green-500', isConnected);
rtcCard.classList.toggle('border-red-500', !isConnected);
rtcIcon.classList.toggle('bg-green-100', isConnected);
rtcIcon.classList.toggle('bg-red-100', !isConnected);
rtcIcon.querySelector('svg').classList.toggle('text-green-600', isConnected);
rtcIcon.querySelector('svg').classList.toggle('text-red-600', !isConnected);
rtcStatusText.textContent = isConnected ? 'Connected' : 'Disconnected';
rtcStatusText.classList.toggle('text-green-600', isConnected);
rtcStatusText.classList.toggle('text-red-600', !isConnected);
if (data.rtc_time) {
rtcTimeText.textContent = new Date(data.rtc_time).toLocaleString();
}
}
// Update DFPlayer Status
if (data.dfplayer !== undefined) {
const dfplayerCard = document.querySelector('#dfplayerCard');
const dfplayerIcon = document.querySelector('#dfplayerIcon');
const dfplayerStatusText = document.querySelector('#dfplayerStatusText');
const isConnected = data.dfplayer === true;
dfplayerCard.classList.toggle('border-green-500', isConnected);
dfplayerCard.classList.toggle('border-red-500', !isConnected);
dfplayerIcon.classList.toggle('bg-green-100', isConnected);
dfplayerIcon.classList.toggle('bg-red-100', !isConnected);
dfplayerIcon.querySelector('svg').classList.toggle('text-green-600', isConnected);
dfplayerIcon.querySelector('svg').classList.toggle('text-red-600', !isConnected);
dfplayerStatusText.textContent = isConnected ? 'Connected' : 'Disconnected';
dfplayerStatusText.classList.toggle('text-green-600', isConnected);
dfplayerStatusText.classList.toggle('text-red-600', !isConnected);
if (data.dfplayer_files) {
document.querySelector('#dfplayerDetails').textContent =
`${data.dfplayer_files} sound files available`;
}
}
}
// ======================
// NEXT SCHEDULE COUNTDOWN
// ======================
function updateNextSchedule() {
fetch("{{ route('api.bel.next-schedule') }}")
.then(response => {
if (!response.ok) throw new Error('Gagal Memuat Jadwal');
return response.json();
})
.then(data => {
const countdownEl = document.getElementById('nextScheduleCountdown');
const timeEl = document.getElementById('nextScheduleTime');
if (data.success && data.next_schedule && data.next_schedule.is_active) {
const now = new Date();
const [hours, minutes, seconds] = data.next_schedule.time.split(':').map(Number);
// Create target time
let targetTime = new Date();
targetTime.setHours(hours, minutes, seconds || 0, 0);
// Adjust day if needed
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const currentDayIndex = now.getDay();
const targetDayName = data.next_schedule.hari;
const targetDayIndex = days.indexOf(targetDayName);
let daysToAdd = (targetDayIndex - currentDayIndex + 7) % 7;
if (daysToAdd === 0 && targetTime <= now) {
daysToAdd = 7; // Next week same day
}
targetTime.setDate(now.getDate() + daysToAdd);
// Update display
timeEl.textContent = `${targetDayName}, ${data.next_schedule.time} (File ${data.next_schedule.file_number})`;
// Countdown function
const updateCountdown = () => {
const diff = targetTime - new Date();
if (diff > 0) {
const h = Math.floor(diff / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
const s = Math.floor((diff % 60000) / 1000);
countdownEl.innerHTML = `
<span class="text-blue-600 font-medium">${h.toString().padStart(2, '0')}</span>h
<span class="text-blue-600 font-medium">${m.toString().padStart(2, '0')}</span>m
<span class="text-blue-600 font-medium">${s.toString().padStart(2, '0')}</span>s
`;
} else {
countdownEl.innerHTML = '<span class="text-green-600 font-bold">IN PROGRESS</span>';
clearInterval(window.countdownInterval);
setTimeout(updateNextSchedule, 5000);
}
};
// Clear previous interval and start new one
if (window.countdownInterval) clearInterval(window.countdownInterval);
updateCountdown();
window.countdownInterval = setInterval(updateCountdown, 1000);
} else {
countdownEl.textContent = 'Tidak Ada Jadwal Aktif';
timeEl.textContent = '';
if (window.countdownInterval) clearInterval(window.countdownInterval);
}
})
.catch(error => {
console.error('Error:', error);
document.getElementById('nextScheduleCountdown').textContent = 'Error loading Jadwal';
document.getElementById('nextScheduleTime').textContent = '';
});
}
// ======================
// INITIALIZATION
// ======================
document.addEventListener('DOMContentLoaded', function() {
updateClock();
updateNextSchedule();
getLiveStatus();
// Refresh every minute to stay accurate
setInterval(updateNextSchedule, 60000);
setInterval(getLiveStatus, 30000); // Update status every 30 seconds
// Add animation to status cards on hover
document.querySelectorAll('#mqttCard, #rtcCard, #dfplayerCard').forEach(card => {
card.addEventListener('mouseenter', () => {
card.querySelector('svg').classList.add('animate-pulse');
});
card.addEventListener('mouseleave', () => {
card.querySelector('svg').classList.remove('animate-pulse');
});
});
});
</script>
<!-- Include JavaScript -->
@include('admin.bel.partials.scripts')
@endsection
@push('styles')
@vite('resources/css/app.css')
@endpush

View File

@ -0,0 +1,15 @@
<div class="px-6 py-8 text-center">
<div class="flex flex-col items-center justify-center text-gray-500">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
<h3 class="text-lg font-medium mb-1">Tidak Ada Jadwal Tersedia</h3>
<p class="max-w-md text-center mb-4">Anda Belum Membuat Jadwal Bel. Klik Button Diatas Untuk Menambahkan Jadwal Bel</p>
<a href="{{ route('bel.create') }}" class="btn-blue">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd" />
</svg>
Tambah Jadwal
</a>
</div>
</div>

View File

@ -0,0 +1,550 @@
<script>
// Enhanced JavaScript with better organization and error handling
// ======================
// UTILITY FUNCTIONS
// ======================
function showLoading(title = 'Process...') {
Swal.fire({
title: title,
html: 'Harap Tunggu Sementara Kami Memproses Permintaan Anda...',
allowOutsideClick: false,
didOpen: () => {
Swal.showLoading();
}
});
}
function showToast(icon, title) {
const Toast = Swal.mixin({
toast: true,
position: 'top-end',
showConfirmButton: false,
timer: 3000,
timerProgressBar: true,
didOpen: (toast) => {
toast.addEventListener('mouseenter', Swal.stopTimer)
toast.addEventListener('mouseleave', Swal.resumeTimer)
}
});
Toast.fire({ icon, title });
}
// ======================
// UI FUNCTIONS
// ======================
// Live Clock
function updateClock() {
const now = new Date();
document.getElementById('liveClock').textContent =
now.getHours().toString().padStart(2, '0') + ':' +
now.getMinutes().toString().padStart(2, '0') + ':' +
now.getSeconds().toString().padStart(2, '0');
}
setInterval(updateClock, 1000);
// Toggle Dropdown
function toggleDropdown() {
document.getElementById('actionDropdown').classList.toggle('hidden');
}
// Close dropdown when clicking outside
window.addEventListener('click', function(e) {
if (!e.target.closest('.relative.inline-block')) {
document.getElementById('actionDropdown').classList.add('hidden');
}
});
// ======================
// SCHEDULE MANAGEMENT
// ======================
// Delete confirmation
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.delete-btn').forEach(button => {
button.addEventListener('click', function(e) {
e.preventDefault();
const form = this.closest('form');
Swal.fire({
title: 'Hapus Jadwal?',
text: "Ini Tidak Dapat Di Batalkan!",
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
confirmButtonText: 'Ya ',
cancelButtonText: 'TIdak',
showLoaderOnConfirm: true,
preConfirm: () => {
return fetch(form.action, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({ _method: 'DELETE' })
})
.then(response => {
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
})
.catch(error => {
Swal.showValidationMessage(
`Request failed: ${error}`
);
});
},
allowOutsideClick: () => !Swal.isLoading()
}).then((result) => {
if (result.isConfirmed) {
showToast('success', result.value.message || 'Jadwal Bel Berhasil Di Hapus');
setTimeout(() => {
window.location.reload();
}, 1000);
}
});
});
});
});
// Bulk delete confirmation
function confirmDeleteAll() {
Swal.fire({
title: 'Hapus Semua Jadwwal?',
text: "Anda Akan Mengahpus Semua Jadwal!",
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
confirmButtonText: 'Ya, Hapus Semua',
cancelButtonText: 'Cancel',
showLoaderOnConfirm: true,
preConfirm: () => {
return fetch("{{ route('bel.delete-all') }}", {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({ _method: 'DELETE' })
})
.then(response => {
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
})
.catch(error => {
Swal.showValidationMessage(
`Request failed: ${error}`
);
});
},
allowOutsideClick: () => !Swal.isLoading()
}).then((result) => {
if (result.isConfirmed) {
showToast('success', result.value.message || 'Semua Jadwal Berhasil Di Hapus');
setTimeout(() => {
window.location.reload();
}, 1000);
}
});
}
// Activate all schedules
function activateAll() {
showLoading('Aktifkan Semua Jadwal...');
fetch("{{ route('api.bel.activate-all') }}", {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
}
})
.then(response => response.json())
.then(data => {
Swal.close();
if (data.success) {
showToast('success', data.message || 'Semua Jadwal Berhasil Di Aktifkan');
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
showToast('error', data.message || 'Jadwal Gagal Di Aktifkan');
}
})
.catch(error => {
Swal.fire({
icon: 'error',
title: 'Error',
text: error.message
});
});
}
// Deactivate all schedules
function deactivateAll() {
showLoading('Menonaktifkan Semua Jadwal...');
fetch("{{ route('api.bel.deactivate-all') }}", {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
}
})
.then(response => response.json())
.then(data => {
Swal.close();
if (data.success) {
showToast('success', data.message || 'Semua Jadwal Berhasil Di Nonaktifkan');
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
showToast('error', data.message || 'Jadwal Gagal Di Nonaktifkan');
}
})
.catch(error => {
Swal.fire({
icon: 'error',
title: 'Error',
text: error.message
});
});
}
// ======================
// BELL CONTROL FUNCTIONS
// ======================
// Ring bell modal
function showRingModal() {
Swal.fire({
title: 'Manual Bell Ring',
html: `
<div class="text-left">
<div class="mb-4">
<label for="swal-file-number" class="block text-sm font-medium text-gray-700 mb-1">Sound File</label>
<select id="swal-file-number" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 py-2 text-sm">
<option value="">Select Sound File</option>
@for($i = 1; $i <= 50; $i++)
<option value="{{ sprintf('%04d', $i) }}">File {{ sprintf('%04d', $i) }}</option>
@endfor
</select>
</div>
<div class="mb-4">
<label for="swal-volume" class="block text-sm font-medium text-gray-700 mb-1">Volume (1-30)</label>
<input type="number" id="swal-volume" min="1" max="30" value="20"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 py-2 text-sm">
</div>
</div>
`,
showCancelButton: true,
confirmButtonText: 'Ring Bell',
cancelButtonText: 'Cancel',
focusConfirm: false,
preConfirm: () => {
const fileNumber = document.getElementById('swal-file-number').value;
const volume = document.getElementById('swal-volume').value;
if (!fileNumber) {
Swal.showValidationMessage('Pilih File');
return false;
}
if (volume < 1 || volume > 30) {
Swal.showValidationMessage('Volume Antar 1 - 30');
return false;
}
return { file_number: fileNumber, volume: volume };
}
}).then((result) => {
if (result.isConfirmed) {
ringBell(result.value.file_number, result.value.volume);
}
});
}
// Ring bell function
function ringBell(fileNumber, volume = 20) {
showLoading('Ringing bell...');
fetch("{{ route('api.bel.ring') }}", {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
file_number: fileNumber,
volume: volume
})
})
.then(response => response.json())
.then(data => {
Swal.close();
if (data.success) {
showToast('success', 'Bel Berhasil Di Bunyikan');
} else {
showToast('error', data.message || 'Bel Gagal Di Bunyikan');
}
})
.catch(error => {
Swal.fire({
icon: 'error',
title: 'Error',
text: error.message
});
});
}
// Sync schedules function
function syncSchedules() {
showLoading('Sinkronasi Jadwal Bel Dengan Device...');
fetch("{{ route('api.bel.sync') }}", {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
}
})
.then(response => response.json())
.then(data => {
Swal.close();
if (data.success) {
showToast('success', data.message || 'Sinkronisasi Jadwal Berhasil');
} else {
showToast('error', data.message || 'Sinkronisasi Jadwal Gagal');
}
})
.catch(error => {
Swal.fire({
icon: 'error',
title: 'Error',
text: error.message
});
});
}
// ======================
// DEVICE STATUS FUNCTIONS
// ======================
// Get live status
function getLiveStatus() {
fetch("{{ route('api.bel.status') }}", {
method: 'GET',
headers: {
'Accept': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
updateDeviceStatus(data.data);
}
})
.catch(error => console.error('Status update error:', error));
}
// Update device status
function updateDeviceStatus(data) {
// Update MQTT Status
if (data.mqtt_status !== undefined) {
const mqttCard = document.querySelector('#mqttCard');
const mqttStatusText = document.querySelector('#mqttStatusText');
const mqttIconBg = document.querySelector('#mqttIconBg');
const mqttIconSvg = document.querySelector('#mqttIconSvg');
const isConnected = data.mqtt_status === true || data.mqtt_status === 'Connected';
mqttCard.classList.toggle('border-green-500', isConnected);
mqttCard.classList.toggle('border-red-500', !isConnected);
mqttIconBg.classList.toggle('bg-green-100', isConnected);
mqttIconBg.classList.toggle('bg-red-100', !isConnected);
mqttIconSvg.classList.toggle('text-green-600', isConnected);
mqttIconSvg.classList.toggle('text-red-600', !isConnected);
mqttStatusText.textContent = isConnected ? 'Connected' : 'Disconnected';
mqttStatusText.classList.toggle('text-green-600', isConnected);
mqttStatusText.classList.toggle('text-red-600', !isConnected);
if (data.mqtt_last_update) {
document.querySelector('#mqttStatusDetails').textContent =
`Last updated: ${new Date(data.mqtt_last_update).toLocaleTimeString()}`;
}
}
// Update RTC Status
if (data.rtc !== undefined) {
const rtcCard = document.querySelector('#rtcCard');
const rtcIcon = document.querySelector('#rtcIcon');
const rtcStatusText = document.querySelector('#rtcStatusText');
const rtcTimeText = document.querySelector('#rtcTimeText');
const isConnected = data.rtc === true;
rtcCard.classList.toggle('border-green-500', isConnected);
rtcCard.classList.toggle('border-red-500', !isConnected);
rtcIcon.classList.toggle('bg-green-100', isConnected);
rtcIcon.classList.toggle('bg-red-100', !isConnected);
rtcIcon.querySelector('svg').classList.toggle('text-green-600', isConnected);
rtcIcon.querySelector('svg').classList.toggle('text-red-600', !isConnected);
rtcStatusText.textContent = isConnected ? 'Connected' : 'Disconnected';
rtcStatusText.classList.toggle('text-green-600', isConnected);
rtcStatusText.classList.toggle('text-red-600', !isConnected);
if (data.rtc_time) {
rtcTimeText.textContent = new Date(data.rtc_time).toLocaleString();
}
}
// Update DFPlayer Status
if (data.dfplayer !== undefined) {
const dfplayerCard = document.querySelector('#dfplayerCard');
const dfplayerIcon = document.querySelector('#dfplayerIcon');
const dfplayerStatusText = document.querySelector('#dfplayerStatusText');
const isConnected = data.dfplayer === true;
dfplayerCard.classList.toggle('border-green-500', isConnected);
dfplayerCard.classList.toggle('border-red-500', !isConnected);
dfplayerIcon.classList.toggle('bg-green-100', isConnected);
dfplayerIcon.classList.toggle('bg-red-100', !isConnected);
dfplayerIcon.querySelector('svg').classList.toggle('text-green-600', isConnected);
dfplayerIcon.querySelector('svg').classList.toggle('text-red-600', !isConnected);
dfplayerStatusText.textContent = isConnected ? 'Connected' : 'Disconnected';
dfplayerStatusText.classList.toggle('text-green-600', isConnected);
dfplayerStatusText.classList.toggle('text-red-600', !isConnected);
if (data.dfplayer_files) {
document.querySelector('#dfplayerDetails').textContent =
`${data.dfplayer_files} sound files available`;
}
}
}
// ======================
// NEXT SCHEDULE COUNTDOWN
// ======================
function updateNextSchedule() {
fetch("{{ route('api.bel.next-schedule') }}")
.then(response => {
if (!response.ok) throw new Error('Gagal Memuat Jadwal');
return response.json();
})
.then(data => {
const countdownEl = document.getElementById('nextScheduleCountdown');
const timeEl = document.getElementById('nextScheduleTime');
if (data.success && data.next_schedule && data.next_schedule.is_active) {
// Get current time in UTC+7 (Indonesia timezone)
const now = new Date();
const utc = now.getTime() + (now.getTimezoneOffset() * 60000);
const indonesiaTime = new Date(utc + (3600000 * 7));
const [hours, minutes, seconds] = data.next_schedule.time.split(':').map(Number);
// Create target time in Indonesia timezone
let targetTime = new Date(indonesiaTime);
targetTime.setHours(hours, minutes, seconds || 0, 0);
// Adjust day if needed
const days = ['Minggu', 'Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu'];
const currentDayIndex = indonesiaTime.getDay();
const targetDayName = data.next_schedule.hari;
const targetDayIndex = days.indexOf(targetDayName);
let daysToAdd = (targetDayIndex - currentDayIndex + 7) % 7;
if (daysToAdd === 0 && targetTime <= indonesiaTime) {
daysToAdd = 7; // Next week same day
}
targetTime.setDate(indonesiaTime.getDate() + daysToAdd);
// Update display
timeEl.textContent = `${targetDayName}, ${data.next_schedule.time} (File ${data.next_schedule.file_number})`;
// Countdown function
const updateCountdown = () => {
// Get current Indonesia time for comparison
const current = new Date();
const currentUTC = current.getTime() + (current.getTimezoneOffset() * 60000);
const currentIndonesiaTime = new Date(currentUTC + (3600000 * 7));
const diff = targetTime - currentIndonesiaTime;
if (diff > 0) {
const h = Math.floor(diff / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
const s = Math.floor((diff % 60000) / 1000);
countdownEl.innerHTML = `
<span class="text-blue-600 font-medium">${h.toString().padStart(2, '0')}</span>h
<span class="text-blue-600 font-medium">${m.toString().padStart(2, '0')}</span>m
<span class="text-blue-600 font-medium">${s.toString().padStart(2, '0')}</span>s
`;
} else {
countdownEl.innerHTML = '<span class="text-green-600 font-bold">SEDANG BERLANGSUNG</span>';
clearInterval(window.countdownInterval);
setTimeout(updateNextSchedule, 5000);
}
};
// Clear previous interval and start new one
if (window.countdownInterval) clearInterval(window.countdownInterval);
updateCountdown();
window.countdownInterval = setInterval(updateCountdown, 1000);
} else {
countdownEl.textContent = 'Tidak Ada Jadwal Aktif';
timeEl.textContent = '';
if (window.countdownInterval) clearInterval(window.countdownInterval);
}
})
.catch(error => {
console.error('Error:', error);
document.getElementById('nextScheduleCountdown').textContent = 'Error loading Jadwal';
document.getElementById('nextScheduleTime').textContent = '';
});
}
// ======================
// INITIALIZATION
// ======================
document.addEventListener('DOMContentLoaded', function() {
updateClock();
updateNextSchedule();
// Refresh every minute to stay accurate
setInterval(updateNextSchedule, 60000);
setInterval(getLiveStatus, 60000); // Update status every 30 seconds
// Add animation to status cards on hover
document.querySelectorAll('#mqttCard, #rtcCard, #dfplayerCard').forEach(card => {
card.addEventListener('mouseenter', () => {
card.querySelector('svg').classList.add('animate-pulse');
});
card.addEventListener('mouseleave', () => {
card.querySelector('svg').classList.remove('animate-pulse');
});
});
});
</script>

View File

@ -0,0 +1,14 @@
<div id="{{ $id }}" class="bg-white rounded-xl shadow-sm p-5 border-l-4 border-green-500 hover:shadow-md transition duration-200">
<div class="flex items-start">
<div id="{{ $id }}Icon" class="p-3 rounded-full bg-green-100 flex-shrink-0">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ $icon }}" />
</svg>
</div>
<div class="ml-4">
<h3 class="text-sm font-medium text-gray-500">{{ $title }}</h3>
<p id="{{ $statusId }}" class="text-lg font-semibold text-green-600">{{ $status }}</p>
<p id="{{ $detailsId }}" class="text-xs text-gray-500 mt-1">{{ $details }}</p>
</div>
</div>
</div>

View File

@ -0,0 +1,36 @@
@if($todaySchedules->count() > 0)
<div class="bg-blue-50 border-l-4 border-blue-500 p-4 mb-8 rounded-r-lg">
<div class="flex justify-between items-center mb-3">
<h3 class="text-lg font-medium text-blue-800 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd" />
</svg>
Today's Schedule ({{ \Carbon\Carbon::now()->isoFormat('dddd, D MMMM YYYY') }})
</h3>
<span class="bg-blue-100 text-blue-800 text-xs px-3 py-1 rounded-full font-medium">
{{ $todaySchedules->count() }} {{ $todaySchedules->count() > 1 ? 'Schedules' : 'Schedule' }}
</span>
</div>
<div class="mt-3 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
@foreach($todaySchedules as $schedule)
<div class="bg-white p-4 rounded-lg shadow-sm border border-blue-100 hover:shadow-md transition duration-200 flex justify-between items-center">
<div>
<span class="font-medium text-gray-800">{{ $schedule->formatted_time }}</span>
<span class="text-xs text-gray-500 block mt-1">File {{ $schedule->file_number }}</span>
</div>
<div class="flex items-center">
<span class="px-2 py-1 text-xs rounded-full font-medium {{ $schedule->is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800' }}">
{{ $schedule->is_active ? 'Active' : 'Inactive' }}
</span>
@if($schedule->is_now)
<span class="ml-2 px-2 py-1 text-xs rounded-full font-medium bg-blue-100 text-blue-800 animate-pulse">
SEKARANG
</span>
@endif
</div>
</div>
@endforeach
</div>
</div>
@endif

View File

@ -4,6 +4,7 @@
use App\Http\Controllers\BelController;
use App\Http\Controllers\PresensiController;
use App\Http\Controllers\SiswaController;
use App\Http\Controllers\API\BellController;
Route::prefix('bel')->group(function () {
Route::post('/ring', [BelController::class, 'ring'])->name('api.bel.ring');
@ -13,8 +14,13 @@
Route::put('/{id}/toggle-status', [BelController::class, 'toggleStatus'])->name('api.bel.toggle-status');
Route::post('/activate-all', [BelController::class, 'activateAll'])->name('api.bel.activate-all');
Route::post('/deactivate-all', [BelController::class, 'deactivateAll'])->name('api.bel.deactivate-all');
});
// Endpoint untuk menerima data bel dari ESP32
Route::post('/bell-events', [BellController::class, 'storeScheduleEvent']);
Route::get('/bell-history', [BellController::class, 'getHistory']);
#Presensi
Route::post('/presensi', [PresensiController::class, 'store']);

View File

@ -13,6 +13,7 @@
use App\Http\Controllers\SiswaController;
use App\Http\Controllers\AnnouncementController;
use App\Http\Controllers\RuanganController;
use App\Http\Controllers\BellHistoryController;
// Public routes
Route::get('/', [IndexController::class, 'index'])->name('index');
@ -97,7 +98,12 @@
Route::put('/{id}', 'update')->name('bel.update');
Route::delete('/{id}', 'destroy')->name('bel.delete');
Route::delete('/', 'deleteAll')->name('bel.delete-all');
Route::get('/history', 'history')->name('bel.history');
// Pindahkan history ke dalam group bel untuk konsistensi
Route::prefix('history')->controller(BellHistoryController::class)->group(function () {
Route::get('/', 'history')->name('bel.history.index');
Route::get('/filter', 'filterHistory')->name('bel.history.filter');
Route::delete('/{id}', 'destroy')->name('bel.history.destroy');
});
});
// Announcement System
@ -107,7 +113,7 @@
Route::get('/history', 'history')->name('announcement.history');
Route::get('/{announcement}', 'show')->name('announcement.show');
Route::delete('/{announcement}', 'destroy')->name('announcement.destroy');
Route::post('/tts-preview', 'ttsPreview')->name('announcement.ttsPreview');
Route::post('/tts-preview', 'ttsPreview')->name('announcement.tts-preview');
});
});
});