riwayat schedule dan manual
This commit is contained in:
parent
5d76037f94
commit
aa86766800
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -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,287 +108,249 @@ 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');
|
||||
}
|
||||
|
||||
$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
|
||||
}
|
||||
|
||||
return redirect()->route('admin.announcement.index')->with('success', $message);
|
||||
$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;
|
||||
}
|
||||
|
||||
// Publis ke topic announcement umum
|
||||
$this->mqttService->publish(
|
||||
$this->mqttConfig['topics']['commands']['announcement'],
|
||||
json_encode($payload),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate TTS audio using VoiceRSS API
|
||||
*/
|
||||
private function generateTTS($text, $voice, $speed)
|
||||
protected function generateTTS($text, $voice, $speed)
|
||||
{
|
||||
$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'
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->body();
|
||||
}
|
||||
|
||||
Log::error('TTS API Error: ' . $response->body());
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function handleAnnouncementAck(string $message)
|
||||
{
|
||||
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
|
||||
]);
|
||||
$data = json_decode($message, true);
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->body();
|
||||
if (isset($data['announcement_id'])) {
|
||||
Announcement::where('id', $data['announcement_id'])
|
||||
->update(['status' => 'delivered']);
|
||||
|
||||
Log::info('Announcement delivered', $data);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('ACK Handler Error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
protected function handleAnnouncementError(string $message)
|
||||
{
|
||||
try {
|
||||
$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);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
public function ttsPreview(Request $request)
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'text' => 'required|string|max:1000',
|
||||
'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(
|
||||
$request->text,
|
||||
$request->voice,
|
||||
$request->speed
|
||||
);
|
||||
|
||||
if (!$audioContent) {
|
||||
throw new \Exception('Failed to generate TTS audio');
|
||||
}
|
||||
|
||||
Log::error('TTS API Error: ' . $response->body());
|
||||
return null;
|
||||
$fileName = 'tts/previews/' . uniqid() . '.wav';
|
||||
Storage::disk('public')->put($fileName, $audioContent);
|
||||
|
||||
return response()->json([
|
||||
'audio_url' => asset('storage/' . $fileName)
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('TTS Generation Error: ' . $e->getMessage());
|
||||
return null;
|
||||
Log::error('TTS Preview Error: ' . $e->getMessage());
|
||||
return response()->json([
|
||||
'error' => 'Failed to generate preview'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish message to MQTT broker
|
||||
*/
|
||||
private function publishToMQTT(array $payload)
|
||||
{
|
||||
try {
|
||||
$mqtt = MQTT::connection();
|
||||
$mqtt->publish('announcement/channel', json_encode($payload), 0);
|
||||
$mqtt->disconnect();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('MQTT Publish Error: ' . $e->getMessage());
|
||||
throw new \Exception('Gagal mengirim pesan ke MQTT broker');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
$relayState = $request->input('relay_state');
|
||||
|
||||
$announcements = Announcement::with(['user', 'ruangans'])
|
||||
$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}%");
|
||||
})
|
||||
->orWhereHas('user', function($q) use ($search) {
|
||||
$q->where('name', '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('announcement.history', [
|
||||
return view('admin.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)
|
||||
{
|
||||
try {
|
||||
// Hapus file audio jika ada
|
||||
if ($announcement->audio_path && Storage::disk('public')->exists($announcement->audio_path)) {
|
||||
Storage::disk('public')->delete($announcement->audio_path);
|
||||
}
|
||||
|
||||
$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());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TTS Preview endpoint
|
||||
*/
|
||||
public function ttsPreview(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'text' => 'required|string|max:1000',
|
||||
'voice' => 'nullable|string',
|
||||
'speed' => 'nullable|integer|min:-10|max:10'
|
||||
]);
|
||||
|
||||
$audioContent = $this->generateTTS(
|
||||
$validated['text'],
|
||||
$validated['voice'] ?? self::TTS_DEFAULT_VOICE,
|
||||
$validated['speed'] ?? self::TTS_DEFAULT_SPEED
|
||||
);
|
||||
|
||||
if (!$audioContent) {
|
||||
return response()->json(['message' => 'Gagal menghasilkan audio'], 500);
|
||||
}
|
||||
|
||||
$fileName = 'tts/previews/' . uniqid() . '.wav';
|
||||
Storage::disk('public')->put($fileName, $audioContent);
|
||||
|
||||
return response()->json([
|
||||
'audio_url' => asset('storage/' . $fileName)
|
||||
'relay_state' => $relayState,
|
||||
'modes' => [self::MODE_REGULER, self::MODE_TTS],
|
||||
'relayStates' => [self::RELAY_ON, self::RELAY_OFF]
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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,24 +140,23 @@ 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'])) {
|
||||
$action = $data['action'];
|
||||
$message = $data['message'] ?? '';
|
||||
if (!isset($data['action'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($action === 'sync_ack') {
|
||||
Status::updateOrCreate(
|
||||
['id' => 1],
|
||||
['last_sync' => Carbon::now()]
|
||||
);
|
||||
Log::info('Schedule sync acknowledged: ' . $message);
|
||||
} elseif ($action === 'ring_ack') {
|
||||
Log::info('Bell ring acknowledged: ' . $message);
|
||||
}
|
||||
$action = $data['action'];
|
||||
$message = $data['message'] ?? '';
|
||||
|
||||
if ($action === 'sync_ack') {
|
||||
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,30 +306,18 @@ public function toggleStatus($id)
|
|||
try {
|
||||
$schedule = JadwalBel::findOrFail($id);
|
||||
$newStatus = !$schedule->is_active;
|
||||
$schedule->is_active = $newStatus;
|
||||
$schedule->save();
|
||||
|
||||
// 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');
|
||||
$schedule->update(['is_active' => $newStatus]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Status jadwal berhasil diubah',
|
||||
'is_active' => $newStatus
|
||||
]);
|
||||
} 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');
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Gagal mengubah status: ' . $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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',
|
||||
]);
|
||||
|
||||
try {
|
||||
// Catat ke history terlebih dahulu
|
||||
BellHistory::create([
|
||||
'hari' => Carbon::now()->isoFormat('dddd'),
|
||||
'waktu' => Carbon::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
|
||||
]);
|
||||
// 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
|
||||
|
||||
// Kirim perintah ke MQTT
|
||||
$message = json_encode([
|
||||
'action' => 'ring',
|
||||
'timestamp' => Carbon::now()->toDateTimeString(),
|
||||
try {
|
||||
$bellData = [
|
||||
'hari' => $hari,
|
||||
'waktu' => now()->format('H:i:s'),
|
||||
'file_number' => $validated['file_number'],
|
||||
'repeat' => $validated['repeat'] ?? 1,
|
||||
'volume' => $validated['volume'] ?? 15,
|
||||
'trigger_type' => BellHistory::TRIGGER_MANUAL
|
||||
]);
|
||||
'volume' => $validated['volume'],
|
||||
];
|
||||
|
||||
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 [
|
||||
'hari' => $item->hari,
|
||||
'waktu' => Carbon::parse($item->waktu)->format('H:i'), // Pastikan format waktu sesuai
|
||||
'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
|
||||
->map(fn($item) => [
|
||||
'hari' => $item->hari,
|
||||
'waktu' => Carbon::parse($item->waktu)->format('H:i'),
|
||||
'file_number' => $item->file_number
|
||||
]);
|
||||
|
||||
$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 [
|
||||
'hari' => $item->hari,
|
||||
'waktu' => Carbon::parse($item->waktu)->format('H:i:s'),
|
||||
'file_number' => $item->file_number
|
||||
];
|
||||
});
|
||||
->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([
|
||||
'success' => false,
|
||||
'message' => 'Tidak ada jadwal aktif yang akan datang'
|
||||
]);
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
Log::error('MQTT Connection failed: ' . $e->getMessage());
|
||||
$this->handleDisconnection();
|
||||
$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();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
return $this->isConnected;
|
||||
}
|
||||
|
||||
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->isConnected = false;
|
||||
Cache::put('mqtt_status', 'disconnected', 60);
|
||||
$this->scheduleReconnect();
|
||||
}
|
||||
|
||||
protected function scheduleReconnect(): void
|
||||
{
|
||||
$reconnectConfig = $this->getConnectionConfig()['connection_settings']['auto_reconnect'];
|
||||
|
||||
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 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function quickPublish($topic, $message, $qos = 0, $retain = false): bool
|
||||
{
|
||||
try {
|
||||
$config = config('mqtt.connections.bel_sekolah');
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
],
|
||||
];
|
|
@ -34,10 +34,4 @@
|
|||
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
|
||||
],
|
||||
],
|
||||
|
||||
'voicerss' => [
|
||||
'api_key' => env('VOICERSS_API_KEY'),
|
||||
],
|
||||
|
||||
|
||||
];
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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']);
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -17,7 +17,8 @@ public function run(): void
|
|||
UserTableSeeder::class,
|
||||
JurusanSeeder::class,
|
||||
KelasSeeder::class,
|
||||
SiswaTableSeeder::class
|
||||
SiswaTableSeeder::class,
|
||||
StatusSeeder::class
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
</a>
|
||||
</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>
|
||||
</form>
|
||||
</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>
|
||||
</div>
|
||||
</form>
|
||||
</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">
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@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
|
||||
@else
|
||||
{{ Str::limit($announcement->message, 50) }}
|
||||
@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>
|
||||
</a>
|
||||
<form action="{{ route('admin.announcement.destroy', $announcement->id) }}" method="POST" class="d-inline">
|
||||
@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>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" class="text-center">Belum ada pengumuman</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
{{ $announcements->links() }}
|
||||
<!-- 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>
|
||||
|
||||
<!-- Table Body -->
|
||||
<div class="overflow-x-auto">
|
||||
<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 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 class="bg-white divide-y divide-gray-200">
|
||||
@forelse($announcements as $announcement)
|
||||
<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
|
||||
<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>
|
||||
|
||||
<!-- 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="block w-full">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<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="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>
|
||||
|
||||
<!-- 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
|
@ -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>
|
||||
<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>
|
||||
</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-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 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 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>
|
||||
</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" />
|
||||
</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>
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
<option value="schedule" {{ request('trigger_type') == 'schedule' ? 'selected' : '' }}>Jadwal</option>
|
||||
<option value="manual" {{ request('trigger_type') == 'manual' ? 'selected' : '' }}>Manual</option>
|
||||
<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>
|
||||
|
||||
<!-- 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 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>
|
||||
|
||||
<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" />
|
||||
</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" />
|
||||
<!-- 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 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>
|
||||
Terapkan Filter
|
||||
</button>
|
||||
</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>
|
||||
<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>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $history->ring_time->format('H:i:s') }}
|
||||
<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>
|
||||
</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 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-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">
|
||||
{{ $history->file_number }}
|
||||
<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>
|
||||
|
||||
<!-- Include SweetAlert2 -->
|
||||
<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();
|
||||
|
||||
// Toast notification
|
||||
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)
|
||||
}
|
||||
});
|
||||
|
||||
// // 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
|
||||
|
||||
@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() {
|
||||
// Enhanced SweetAlert for delete confirmation
|
||||
const deleteForms = document.querySelectorAll('.delete-form');
|
||||
|
||||
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: () => {
|
||||
Swal.showLoading()
|
||||
}
|
||||
}).then(() => {
|
||||
this.submit();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@endpush
|
|
@ -4,72 +4,69 @@
|
|||
|
||||
@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">
|
||||
<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>
|
||||
<!-- 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>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<!-- Live Clock -->
|
||||
<div class="hidden md:flex items-center gap-2 text-gray-600 bg-white px-4 py-2 rounded-lg shadow-sm border border-gray-200">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-blue-500" 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>
|
||||
<span id="liveClock" class="font-medium">{{ now()->format('H:i:s') }}</span>
|
||||
<span class="text-gray-500">{{ now()->format('d M Y') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<!-- Live Clock -->
|
||||
<div class="hidden md:flex items-center gap-2 text-gray-600 bg-white px-4 py-2 rounded-lg shadow-sm border border-gray-200">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-blue-500" 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>
|
||||
<span id="liveClock" class="font-medium">{{ now()->format('H:i:s') }}</span>
|
||||
<span class="text-gray-500">{{ now()->format('d M Y') }}</span>
|
||||
</div>
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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">
|
||||
<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>
|
||||
|
||||
<!-- Bulk Actions Dropdown -->
|
||||
<div class="relative inline-block">
|
||||
<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 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" />
|
||||
<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>
|
||||
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">
|
||||
<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" />
|
||||
Aksi Massal
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 ml-1" 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>
|
||||
Tambah Jadwal
|
||||
</a>
|
||||
|
||||
<!-- 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">
|
||||
<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>
|
||||
Aksi Massal
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 ml-1" 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>
|
||||
</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">
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
Hapus Semua
|
||||
</button>
|
||||
</div>
|
||||
</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="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="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="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>
|
||||
Hapus Semua
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -78,53 +75,35 @@
|
|||
|
||||
<!-- 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
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
|
|
@ -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']);
|
||||
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue