Pengumuman Controller,model,migrate,index

This commit is contained in:
rendygaafk 2025-04-26 03:27:15 +07:00
parent 49c40fef45
commit 5d76037f94
15 changed files with 1301 additions and 1339 deletions

View File

@ -2,386 +2,339 @@
namespace App\Http\Controllers;
use App\Models\Announcement;
use App\Models\Ruangan;
use App\Services\MqttService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use PhpMqtt\Client\Facades\MQTT;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
use App\Models\Announcement;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Storage;
class AnnouncementController extends Controller
{
protected $mqttService;
// Mode constants
const MODE_REGULER = 'reguler';
const MODE_TTS = 'tts';
public function __construct(MqttService $mqttService)
{
$this->mqttService = $mqttService;
}
// 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_FORMAT = 'wav';
/**
* Display the announcement interface
* Show the announcement form
*/
public function index()
{
$ruangans = Ruangan::pluck('nama_ruangan')->toArray();
$activeAnnouncements = Announcement::where('is_active', true)->get();
if (empty($ruangans)) {
$ruangans = ['ruang1', 'ruang2', 'ruang3'];
}
return view('admin.announcements.index', compact('ruangans', 'activeAnnouncements'));
}
/**
* Display announcement history page
*/
public function history()
{
return view('admin.announcements.history');
}
/**
* Get MQTT connection status
*/
public function mqttStatus()
{
return response()->json([
'connected' => $this->mqttService->isConnected(),
'status' => Cache::get('mqtt_status', 'disconnected')
]);
}
/**
* Check for active announcements
*/
public function checkActive()
{
$active = Cache::get('active_announcement', false);
$ruangan = Ruangan::with(['kelas', 'jurusan'])->get();
$announcements = Announcement::with(['user', 'ruangans'])
->latest()
->paginate(10);
return response()->json([
'active' => $active,
'type' => $active ? Cache::get('active_announcement_type') : null,
'remaining' => $active ? (Cache::get('active_announcement_end') - time()) : null
return view('admin.announcement.index', [
'ruangan' => $ruangan,
'announcements' => $announcements,
'modes' => [self::MODE_REGULER, self::MODE_TTS]
]);
}
/**
* Get active announcements
* Handle the announcement request
*/
public function activeAnnouncements()
public function store(Request $request)
{
$announcements = Announcement::with('ruangans')
->where('is_active', true)
->orderBy('sent_at', 'desc')
->get()
->map(function($item) {
return [
'id' => $item->id,
'type' => $item->type,
'content' => $item->content,
'target_ruangans' => $item->target_ruangans,
'sent_at' => $item->sent_at,
'status' => $item->status,
'ruangans' => $item->ruangans
];
});
$validator = $this->validateRequest($request);
return response()->json($announcements);
}
/**
* Get announcement history (for API)
*/
public function getHistory(Request $request)
{
$perPage = 10;
$query = Announcement::where('is_active', false)
->orderBy('sent_at', 'desc');
// Filter by type
if ($request->filter && in_array($request->filter, ['tts', 'manual'])) {
$query->where('type', $request->filter);
if ($validator->fails()) {
return redirect()->back()
->withErrors($validator)
->withInput();
}
// Filter by status
if ($request->status && in_array($request->status, ['completed', 'stopped'])) {
$query->where('status', $request->status);
}
$mode = $request->input('mode');
// Search
if ($request->search) {
$query->where('content', 'like', '%'.$request->search.'%');
}
$paginated = $query->paginate($perPage);
return response()->json([
'data' => $paginated->items(),
'total' => $paginated->total(),
'from' => $paginated->firstItem(),
'to' => $paginated->lastItem(),
'current_page' => $paginated->currentPage(),
'last_page' => $paginated->lastPage()
]);
}
/**
* Get announcement details
*/
public function announcementDetails($id)
{
$announcement = Announcement::with('ruangans')->findOrFail($id);
return response()->json([
'id' => $announcement->id,
'type' => $announcement->type,
'content' => $announcement->content,
'target_ruangans' => $announcement->target_ruangans,
'sent_at' => $announcement->sent_at,
'status' => $announcement->status,
'audio_url' => $announcement->audio_url,
'ruangans' => $announcement->ruangans
]);
}
/**
* Send announcement to MQTT
*/
public function send(Request $request)
{
// Validasi input
$validated = $request->validate([
'type' => 'required|in:tts,manual',
'content' => 'nullable|required_if:type,tts|string|max:500',
'ruangans' => 'required|array|min:1',
'ruangans.*' => 'string',
]);
if ($validated['type'] === 'tts' && empty(config('services.voicerss.key'))) {
return response()->json([
'success' => false,
'message' => 'Layanan Text-to-Speech tidak tersedia saat ini.'
], 400);
}
try {
// Persiapkan data awal
$announcementData = [
'type' => $validated['type'],
'target_ruangans' => $validated['ruangans'],
'sent_at' => now(),
'is_active' => true,
'status' => 'processing'
];
// Handle TTS
if ($validated['type'] === 'tts') {
$announcementData['content'] = $validated['content'];
// Generate audio dan simpan URL
$audioUrl = $this->generateTtsAudio($validated['content']);
$announcementData['audio_url'] = $audioUrl;
// TTS langsung dianggap selesai
$announcementData['is_active'] = false;
$announcementData['status'] = 'completed';
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.';
}
// Buat pengumuman
$announcement = Announcement::create($announcementData);
// Siapkan payload MQTT
$payload = [
'type' => $validated['type'],
'target_ruangans' => $validated['ruangans'],
'timestamp' => now()->toDateTimeString(),
'announcement_id' => $announcement->id
];
// Tambahkan data khusus TTS
if ($validated['type'] === 'tts') {
$payload['content'] = $validated['content'];
$payload['audio_url'] = $audioUrl; // Gunakan variabel yang sudah digenerate
}
// Kirim via MQTT
$this->mqttService->sendAnnouncement($payload);
return response()->json([
'success' => true,
'message' => 'Pengumuman berhasil dikirim!',
'audio_url' => $audioUrl ?? null // Sertakan audio_url dalam response
]);
return redirect()->route('admin.announcement.index')->with('success', $message);
} catch (\Exception $e) {
Log::error('Announcement error: ' . $e->getMessage());
// Hapus record jika gagal setelah create
if (isset($announcement)) {
$announcement->delete();
}
return redirect()->back()
->with('error', 'Terjadi kesalahan saat memproses pengumuman: ' . $e->getMessage())
->withInput();
}
}
return response()->json([
'success' => false,
'message' => 'Gagal mengirim pengumuman: ' . $e->getMessage(),
], 500);
/**
* Validate the announcement request
*/
private function validateRequest(Request $request)
{
$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);
}
/**
* Stop manual audio routing
* Handle regular announcement
*/
public function stopManual()
private function handleRegularAnnouncement(Request $request)
{
try {
Announcement::where('is_active', true)
->where('type', 'manual')
->update([
'is_active' => false,
'status' => 'completed'
]);
$payload = [
'type' => 'manual',
'action' => 'deactivate_relay',
'timestamp' => now()->toDateTimeString(),
];
$this->mqttService->publish('bel/sekolah/pengumuman', json_encode($payload), 1, false);
// Clear active announcement cache
Cache::forget('active_announcement');
Cache::forget('active_announcement_type');
return response()->json([
'success' => true,
'message' => 'Relay ruangan berhasil dimatikan!',
]);
} catch (\Exception $e) {
Log::error('Stop manual error: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'Gagal mematikan relay: ' . $e->getMessage(),
], 500);
}
}
/**
* Stop specific announcement
*/
public function stopAnnouncement(Request $request)
{
try {
$announcement = Announcement::findOrFail($request->id);
$announcement->update([
'is_active' => false,
'status' => 'stopped'
]);
if ($announcement->type === 'manual') {
$payload = [
'type' => 'manual',
'action' => 'deactivate_relay',
'announcement_id' => $announcement->id,
'timestamp' => now()->toDateTimeString(),
];
$this->mqttService->publish('bel/sekolah/pengumuman', json_encode($payload), 1, false);
}
return response()->json([
'success' => true,
'message' => 'Pengumuman berhasil dihentikan!',
]);
} catch (\Exception $e) {
Log::error('Stop announcement error: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'Gagal menghentikan pengumuman: ' . $e->getMessage(),
], 500);
}
}
/**
* Generate TTS audio using VoiceRSS
*/
private function generateTtsAudio($text)
{
$apiKey = config('services.voicerss.key');
$ruanganIds = $request->input('ruangan');
// Validate API key configuration
if (empty($apiKey)) {
Log::error('VoiceRSS API Key not configured');
return $this->getTtsFallback($text); // Use fallback instead of throwing exception
$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', [
'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(),
'timestamp' => now()->toDateTimeString()
];
$this->publishToMQTT($payload);
// 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()
]);
// Attach ruangan ke pengumuman
$announcement->ruangans()->attach($ruanganIds);
Log::info('TTS announcement sent', [
'announcement_id' => $announcement->id, // Diubah dari id() ke id
'ruangan' => $ruanganIds,
'text' => $text,
'voice' => $voice,
'speed' => $speed
]);
}
/**
* Generate TTS audio using VoiceRSS API
*/
private function generateTTS($text, $voice, $speed)
{
try {
// Generate unique filename
$filename = 'tts/'.md5($text.'_'.microtime()).'.mp3';
$storagePath = Storage::disk('public')->path($filename);
// Create directory if not exists
Storage::disk('public')->makeDirectory('tts');
$response = Http::timeout(20)
->retry(3, 500)
->get('https://api.voicerss.org/', [
'key' => $apiKey,
'hl' => 'id-id',
'src' => $text,
'r' => '0',
'c' => 'mp3',
'f' => '44khz_16bit_stereo',
]);
if (!$response->successful()) {
Log::error('VoiceRSS API Error', [
'status' => $response->status(),
'response' => $response->body()
]);
return $this->getTtsFallback($text);
}
// Save audio file
Storage::disk('public')->put($filename, $response->body());
// Verify file was saved
if (!Storage::disk('public')->exists($filename)) {
Log::error('Failed to save TTS file', ['path' => $filename]);
return $this->getTtsFallback($text);
}
return Storage::url($filename);
} catch (\Exception $e) {
Log::error('TTS Generation Failed', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
$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
]);
return $this->getTtsFallback($text);
if ($response->successful()) {
return $response->body();
}
Log::error('TTS API Error: ' . $response->body());
return null;
} catch (\Exception $e) {
Log::error('TTS Generation Error: ' . $e->getMessage());
return null;
}
}
private function getTtsFallback($text)
/**
* Publish message to MQTT broker
*/
private function publishToMQTT(array $payload)
{
try {
// Create simple fallback audio
$filename = 'tts/fallback_'.md5($text).'.mp3';
$path = Storage::disk('public')->path($filename);
// Generate basic audio file using shell_exec or other method
if (!Storage::disk('public')->exists($filename)) {
$command = "text2wave -o {$path} -eval '(language_indonesian)'";
shell_exec("echo \"{$text}\" | {$command}");
$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');
$announcements = Announcement::with(['user', 'ruangans'])
->when($search, function($query) use ($search) {
return $query->where('message', 'like', "%{$search}%")
->orWhereHas('ruangans', function($q) use ($search) {
$q->where('nama_ruangan', 'like', "%{$search}%");
})
->orWhereHas('user', function($q) use ($search) {
$q->where('name', 'like', "%{$search}%");
});
})
->when($mode, function($query) use ($mode) {
return $query->where('mode', $mode);
})
->latest()
->paginate(10);
return view('announcement.history', [
'announcements' => $announcements,
'search' => $search,
'mode' => $mode,
'modes' => [self::MODE_REGULER, self::MODE_TTS]
]);
}
/**
* Show announcement detail
*/
public function show(Announcement $announcement)
{
return view('announcement.show', [
'announcement' => $announcement->load(['user', 'ruangans'])
]);
}
/**
* Delete an announcement
*/
public function destroy(Announcement $announcement)
{
try {
// Hapus file audio jika ada
if ($announcement->audio_path && Storage::disk('public')->exists($announcement->audio_path)) {
Storage::disk('public')->delete($announcement->audio_path);
}
return Storage::url($filename);
$announcement->delete();
return redirect()->route('announcement.history')
->with('success', 'Pengumuman berhasil dihapus');
} catch (\Exception $e) {
Log::error('Fallback TTS failed', ['error' => $e->getMessage()]);
return null; // Return null if both main and fallback fail
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)
]);
}
}

View File

@ -9,28 +9,83 @@ class Announcement extends Model
{
use HasFactory;
protected $table = 'announcements';
protected $fillable = [
'type',
'content',
'target_ruangans', // Menggunakan 'ruangan' bukan 'target_ruangans'
'duration',
'sent_at',
'is_active',
'status',
'audio_url'
'mode',
'message',
'audio_path',
'voice',
'speed',
'ruangan',
'user_id',
'sent_at'
];
protected $casts = [
'target_ruangans' => 'array',
'sent_at' => 'datetime',
'is_active' => 'boolean',
'ruangan' => 'array',
'sent_at' => 'datetime'
];
/**
* Relasi ke model Ruangan
* Relasi ke user yang membuat pengumuman
*/
public function user()
{
return $this->belongsTo(User::class);
}
/**
* Relasi many-to-many ke ruangan
*/
public function ruangans()
{
return Ruangan::whereIn('nama', $this->ruangan)->get();
return $this->belongsToMany(Ruangan::class, 'announcement_ruangan');
}
/**
* Scope untuk pengumuman reguler
*/
public function scopeReguler($query)
{
return $query->where('mode', 'reguler');
}
/**
* Scope untuk pengumuman TTS
*/
public function scopeTts($query)
{
return $query->where('mode', 'tts');
}
/**
* Scope untuk pencarian
*/
public function scopeSearch($query, $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}%");
});
}
/**
* Accessor untuk audio URL
*/
public function getAudioUrlAttribute()
{
return $this->audio_path ? asset('storage/' . $this->audio_path) : null;
}
/**
* Format tanggal pengiriman
*/
public function getFormattedSentAtAttribute()
{
return $this->sent_at->format('d M Y H:i:s');
}
}

View File

@ -33,6 +33,15 @@ public function jurusan()
return $this->belongsTo(Jurusan::class, 'id_jurusan');
}
// Di app/Models/Ruangan.php
/**
* Relasi many-to-many ke announcements
*/
public function announcements()
{
return $this->belongsToMany(Announcement::class, 'announcement_ruangan');
}
/**
* Accessor untuk nama ruangan

View File

@ -1,21 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateRoomsTable extends Migration
{
public function up()
{
Schema::create('rooms', function (Blueprint $table) {
$table->id();
$table->string('name')->unique(); // Nama ruangan harus unik
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('rooms');
}
}

View File

@ -1,28 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('announcements', function (Blueprint $table) {
$table->id();
$table->string('type'); // 'tts' or 'manual'
$table->text('content')->nullable();
$table->json('target_ruangans')->nullable();
$table->timestamp('sent_at');
$table->boolean('is_active')->default(false);
$table->string('status')->default('pending');
$table->string('audio_url')->nullable();
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('announcements');
}
};

View File

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('announcements', function (Blueprint $table) {
$table->id();
$table->string('mode'); // 'reguler' atau 'tts'
$table->text('message');
$table->string('audio_path')->nullable();
$table->string('voice')->nullable();
$table->integer('speed')->nullable();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->timestamp('sent_at')->useCurrent();
$table->timestamps();
$table->index('mode');
$table->index('sent_at');
$table->index('user_id');
});
// Perbaikan utama: Explicitly specify table names
Schema::create('announcement_ruangan', function (Blueprint $table) {
$table->id();
$table->foreignId('announcement_id')->constrained('announcements')->onDelete('cascade');
$table->foreignId('ruangan_id')->constrained('ruangan')->onDelete('cascade');
$table->timestamps();
$table->unique(['announcement_id', 'ruangan_id']);
});
}
public function down()
{
Schema::dropIfExists('announcement_ruangan');
Schema::dropIfExists('announcements');
}
};

View File

@ -0,0 +1,117 @@
@extends('layouts.dashboard')
@section('content')
<div class="container-fluid">
<div class="row mb-4">
<div class="col-md-12">
<h2 class="fw-bold">Riwayat Pengumuman</h2>
</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
</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>
</form>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div class="card shadow">
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover" id="announcements-table">
<thead class="table-dark">
<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() }}
</div>
</div>
</div>
</div>
</div>
@endsection

View File

@ -0,0 +1,692 @@
@extends('layouts.dashboard')
@section('title', 'Manajemen Pengumuman')
@section('content')
<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">Sistem Pengumuman Digital</h1>
<p class="text-gray-600 mt-2">Kelola dan kirim pengumuman ke ruangan terpilih</p>
</div>
<div class="mt-4 md:mt-0 flex space-x-3">
<a href="{{ route('announcement.history') }}"
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-history mr-2"></i> Riwayat
</a>
<button id="help-btn" class="flex items-center px-5 py-2.5 bg-gray-50 text-gray-700 rounded-lg hover:bg-gray-100 transition duration-300 shadow-sm">
<i class="fas fa-question-circle mr-2"></i> Bantuan
</button>
</div>
</div>
<!-- Main Card -->
<div class="bg-white rounded-xl shadow-lg overflow-hidden mb-8 border border-gray-100">
<!-- Card Header -->
<div class="bg-gradient-to-r from-blue-600 to-indigo-700 px-6 py-4">
<h2 class="text-xl font-semibold text-white">Formulir Pengumuman Baru</h2>
</div>
<!-- Card Body -->
<div class="p-6">
<form id="announcementForm" action="{{ route('announcement.store') }}" method="POST">
@csrf
<!-- Mode Selection -->
<div class="mb-8">
<label class="block text-gray-700 font-medium mb-3">Jenis Pengumuman</label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<label class="mode-option">
<input type="radio" name="mode" value="reguler" checked
class="absolute opacity-0 h-0 w-0 mode-selector" data-mode="reguler">
<div class="border-2 border-gray-200 rounded-xl p-5 flex items-center hover:border-blue-400 transition duration-200 cursor-pointer h-full">
<div class="mr-4">
<div class="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center">
<i class="fas fa-align-left text-blue-600 text-lg"></i>
</div>
</div>
<div>
<h3 class="font-medium text-gray-800">Pengumuman Teks</h3>
<p class="text-sm text-gray-500 mt-1">Kirim pengumuman berupa teks biasa</p>
</div>
</div>
</label>
<label class="mode-option">
<input type="radio" name="mode" value="tts"
class="absolute opacity-0 h-0 w-0 mode-selector" data-mode="tts">
<div class="border-2 border-gray-200 rounded-xl p-5 flex items-center hover:border-purple-400 transition duration-200 cursor-pointer h-full">
<div class="mr-4">
<div class="w-10 h-10 rounded-full bg-purple-100 flex items-center justify-center">
<i class="fas fa-volume-up text-purple-600 text-lg"></i>
</div>
</div>
<div>
<h3 class="font-medium text-gray-800">Pengumuman Suara (TTS)</h3>
<p class="text-sm text-gray-500 mt-1">Konversi teks ke suara otomatis</p>
</div>
</div>
</label>
</div>
</div>
<!-- Regular Announcement Form -->
<div id="reguler-form" class="mode-form">
<div class="mb-6">
<label for="message" class="block text-gray-700 font-medium mb-2">Konten Pengumuman</label>
<div class="relative">
<textarea name="message" id="message" rows="4" maxlength="500"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition duration-200"
placeholder="Tulis isi pengumuman Anda di sini..."></textarea>
<div class="absolute bottom-2 right-2 bg-white px-2 text-xs text-gray-500 rounded">
<span id="char-count">0</span>/500
</div>
</div>
</div>
</div>
<!-- TTS Announcement Form -->
<div id="tts-form" class="mode-form hidden">
<div class="mb-6">
<label for="tts_text" class="block text-gray-700 font-medium mb-2">Teks untuk Suara</label>
<div class="relative">
<textarea name="tts_text" id="tts_text" rows="4" maxlength="1000"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition duration-200"
placeholder="Masukkan teks yang akan diubah menjadi suara..."></textarea>
<div class="absolute bottom-2 right-2 bg-white px-2 text-xs text-gray-500 rounded">
<span id="tts-char-count">0</span>/1000
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div>
<label for="tts_voice" class="block text-gray-700 font-medium mb-2">Pilihan Suara</label>
<div class="relative">
<select name="tts_voice" id="tts_voice"
class="appearance-none w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition duration-200 pr-10 bg-white">
<option value="id-id">Bahasa Indonesia</option>
<option value="en-us">English (Amerika)</option>
<option value="en-gb">English (Inggris)</option>
<option value="ja-jp">日本語 (Jepang)</option>
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-3 text-gray-700">
<i class="fas fa-chevron-down"></i>
</div>
</div>
</div>
<div>
<label for="tts_speed" class="block text-gray-700 font-medium mb-2">Kecepatan Bicara</label>
<div class="flex items-center space-x-4">
<i class="fas fa-turtle text-gray-400"></i>
<input type="range" name="tts_speed" id="tts_speed" min="-10" max="10" value="0"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-purple-600">
<i class="fas fa-hare text-gray-400"></i>
</div>
<div class="flex justify-between text-xs text-gray-500 mt-1 px-1">
<span>Lambat</span>
<span>Normal</span>
<span>Cepat</span>
</div>
</div>
</div>
<div class="mb-6">
<button type="button" id="preview-tts"
class="px-5 py-2.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition duration-300 shadow-md flex items-center">
<i class="fas fa-play-circle mr-2"></i> Dengarkan Preview
</button>
<div class="mt-3 flex items-center">
<audio id="tts-preview" controls class="w-full max-w-md hidden"></audio>
<div id="preview-loading" class="hidden items-center text-purple-600 ml-3">
<i class="fas fa-spinner fa-spin mr-2"></i>
<span>Memproses audio...</span>
</div>
</div>
</div>
</div>
<!-- Ruangan Selection -->
<div class="mb-8">
<div class="flex justify-between items-center mb-3">
<label class="block text-gray-700 font-medium">Tujuan Pengumuman</label>
<div class="flex space-x-3">
<button type="button" id="select-all"
class="text-sm text-blue-600 hover:text-blue-800 flex items-center">
<i class="fas fa-check-circle mr-1"></i> Pilih Semua
</button>
<button type="button" id="deselect-all"
class="text-sm text-gray-600 hover:text-gray-800 flex items-center">
<i class="fas fa-times-circle mr-1"></i> Hapus Semua
</button>
</div>
</div>
<div class="border border-gray-200 rounded-lg p-4">
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
@foreach($ruangan as $room)
<label class="room-label flex items-center space-x-3 p-3 rounded-lg hover:bg-gray-50 transition duration-200 cursor-pointer">
<input type="checkbox" name="ruangan[]" value="{{ $room->id }}"
class="form-checkbox h-5 w-5 text-blue-600 rounded room-checkbox transition duration-200">
<div>
<span class="font-medium text-gray-800">{{ $room->nama_ruangan }}</span>
<div class="flex items-center text-xs text-gray-500 mt-1">
<i class="fas fa-door-open mr-1"></i>
<span>{{ $room->kelas->nama_kelas ?? '-' }} {{ $room->jurusan->nama_jurusan ?? '-' }}</span>
</div>
</div>
</label>
@endforeach
</div>
</div>
</div>
<!-- Submit Button -->
<div class="flex justify-end pt-4 border-t border-gray-100">
<button type="submit" id="submit-btn"
class="px-8 py-3 bg-gradient-to-r from-blue-600 to-indigo-700 text-white rounded-lg hover:from-blue-700 hover:to-indigo-800 transition duration-300 shadow-lg flex items-center">
<i class="fas fa-paper-plane mr-2"></i> Kirim Pengumuman
</button>
</div>
</form>
</div>
</div>
<!-- Recent Announcements Section -->
<div class="bg-white rounded-xl shadow-lg overflow-hidden border border-gray-100">
<!-- Section 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">Pengumuman Terakhir</h2>
<div class="mt-2 md:mt-0 text-sm">
Menampilkan {{ $announcements->count() }} dari {{ $announcements->total() }} pengumuman
</div>
</div>
</div>
<!-- Section Body -->
<div class="p-6">
@if($announcements->isEmpty())
<div class="text-center py-12">
<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">Mulailah dengan membuat pengumuman baru</p>
</div>
@else
<div class="space-y-4">
@foreach($announcements as $announcement)
<div class="announcement-card border border-gray-200 rounded-xl p-5 hover:shadow-md transition duration-200">
<div class="flex justify-between items-start">
<div class="flex items-start space-x-4">
<div class="mt-1">
@if($announcement->mode === 'reguler')
<div class="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center">
<i class="fas fa-align-left text-blue-600"></i>
</div>
@else
<div class="w-10 h-10 rounded-full bg-purple-100 flex items-center justify-center">
<i class="fas fa-volume-up text-purple-600"></i>
</div>
@endif
</div>
<div>
<h3 class="font-medium text-gray-800 line-clamp-2">
{{ $announcement->message }}
</h3>
<div class="flex flex-wrap items-center mt-2 text-sm text-gray-500 space-x-4">
<div class="flex items-center">
<i class="fas fa-user-circle mr-1.5"></i>
<span>{{ $announcement->user->name }}</span>
</div>
<div class="flex items-center">
<i class="fas fa-clock mr-1.5"></i>
<span>{{ $announcement->sent_at->diffForHumans() }}</span>
</div>
<div class="flex items-center">
<i class="fas fa-door-open mr-1.5"></i>
<span>{{ count($announcement->ruangan) }} ruangan</span>
</div>
</div>
</div>
</div>
<div class="dropdown relative">
<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('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('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>
</div>
</div>
@endforeach
</div>
<!-- Pagination -->
<div class="mt-6">
{{ $announcements->links('vendor.pagination.tailwind') }}
</div>
@endif
</div>
</div>
</div>
<!-- Help Modal -->
<div id="help-modal" class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex items-center justify-center p-4">
<div class="bg-white rounded-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div class="border-b border-gray-200 p-5 flex justify-between items-center">
<h3 class="text-xl font-semibold text-gray-800">Panduan Penggunaan</h3>
<button id="close-help" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<div class="p-5">
<div class="space-y-6">
<div>
<h4 class="font-medium text-lg text-gray-800 mb-2 flex items-center">
<span class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center mr-3 text-blue-600">
<i class="fas fa-info-circle"></i>
</span>
Tentang Sistem Pengumuman
</h4>
<p class="text-gray-600 pl-11">
Sistem ini memungkinkan Anda mengirim pengumuman ke ruangan terpilih dalam dua format: teks biasa atau suara (TTS).
Pengumuman akan langsung diterima oleh perangkat di ruangan tujuan.
</p>
</div>
<div>
<h4 class="font-medium text-lg text-gray-800 mb-2 flex items-center">
<span class="w-8 h-8 bg-purple-100 rounded-full flex items-center justify-center mr-3 text-purple-600">
<i class="fas fa-volume-up"></i>
</span>
Mode Text-to-Speech (TTS)
</h4>
<ul class="text-gray-600 pl-11 space-y-2 list-disc list-inside">
<li>Gunakan untuk pengumuman audio yang akan dibacakan oleh sistem</li>
<li>Anda bisa memilih jenis suara dan kecepatan bicara</li>
<li>Gunakan tombol preview untuk mendengarkan sebelum mengirim</li>
<li>Maksimal 1000 karakter per pengumuman</li>
</ul>
</div>
<div>
<h4 class="font-medium text-lg text-gray-800 mb-2 flex items-center">
<span class="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center mr-3 text-green-600">
<i class="fas fa-lightbulb"></i>
</span>
Tips Penggunaan
</h4>
<ul class="text-gray-600 pl-11 space-y-2 list-disc list-inside">
<li>Untuk pengumuman penting, gunakan mode TTS untuk memastikan didengar</li>
<li>Gunakan bahasa yang jelas dan singkat</li>
<li>Periksa kembali ruangan tujuan sebelum mengirim</li>
<li>Anda bisa melihat riwayat pengumuman di menu Riwayat</li>
</ul>
</div>
</div>
</div>
<div class="border-t border-gray-200 p-5 flex justify-end">
<button id="close-help-btn" class="px-5 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition duration-200">
Mengerti
</button>
</div>
</div>
</div>
<!-- SweetAlert CDN -->
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<!-- Custom Script -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Mode selector with enhanced UI
const modeOptions = document.querySelectorAll('.mode-option');
modeOptions.forEach(option => {
option.addEventListener('click', function() {
modeOptions.forEach(opt => {
opt.querySelector('div').classList.remove('border-blue-400', 'border-purple-400', 'bg-blue-50', 'bg-purple-50');
});
const selectedMode = this.querySelector('input').dataset.mode;
const optionDiv = this.querySelector('div');
if (selectedMode === 'reguler') {
optionDiv.classList.add('border-blue-400', 'bg-blue-50');
} else {
optionDiv.classList.add('border-purple-400', 'bg-purple-50');
}
// Toggle forms
document.querySelectorAll('.mode-form').forEach(form => {
form.classList.toggle('hidden', form.id !== `${selectedMode}-form`);
});
// Update room checkboxes name based on mode
document.querySelectorAll('.room-checkbox').forEach(checkbox => {
checkbox.name = selectedMode === 'tts' ? 'tts_ruangan[]' : 'ruangan[]';
});
});
});
// Initialize first mode as selected
document.querySelector('.mode-option input[checked]').dispatchEvent(new Event('click'));
// Character counter with progress circle
const setupCharCounter = (textarea, counter, max) => {
textarea.addEventListener('input', function() {
const count = this.value.length;
counter.textContent = count;
// Update progress color
const percentage = (count / max) * 100;
if (percentage > 90) {
counter.classList.add('text-red-500');
counter.classList.remove('text-yellow-500', 'text-gray-500');
} else if (percentage > 70) {
counter.classList.add('text-yellow-500');
counter.classList.remove('text-red-500', 'text-gray-500');
} else {
counter.classList.add('text-gray-500');
counter.classList.remove('text-red-500', 'text-yellow-500');
}
});
};
setupCharCounter(document.getElementById('message'), document.getElementById('char-count'), 500);
setupCharCounter(document.getElementById('tts_text'), document.getElementById('tts-char-count'), 1000);
// Room selection with enhanced UI
document.getElementById('select-all').addEventListener('click', function() {
document.querySelectorAll('.room-checkbox').forEach(checkbox => {
checkbox.checked = true;
checkbox.closest('.room-label').classList.add('bg-blue-50', 'border-blue-200');
});
});
document.getElementById('deselect-all').addEventListener('click', function() {
document.querySelectorAll('.room-checkbox').forEach(checkbox => {
checkbox.checked = false;
checkbox.closest('.room-label').classList.remove('bg-blue-50', 'border-blue-200');
});
});
// Room label click handler
document.querySelectorAll('.room-label').forEach(label => {
label.addEventListener('click', function(e) {
if (!e.target.classList.contains('room-checkbox')) {
const checkbox = this.querySelector('.room-checkbox');
checkbox.checked = !checkbox.checked;
if (checkbox.checked) {
this.classList.add('bg-blue-50', 'border-blue-200');
} else {
this.classList.remove('bg-blue-50', 'border-blue-200');
}
}
});
});
// TTS Preview with enhanced UI
document.getElementById('preview-tts').addEventListener('click', function() {
const text = document.getElementById('tts_text').value.trim();
const voice = document.getElementById('tts_voice').value;
const speed = document.getElementById('tts_speed').value;
if (!text) {
Swal.fire({
icon: 'warning',
title: 'Teks Kosong',
text: 'Silakan masukkan teks untuk dipreview',
confirmButtonColor: '#6366f1',
backdrop: 'rgba(99, 102, 241, 0.1)'
});
return;
}
// Show loading
const previewBtn = this;
const loadingIndicator = document.getElementById('preview-loading');
const audioPlayer = document.getElementById('tts-preview');
previewBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i> Memproses...';
previewBtn.disabled = true;
loadingIndicator.classList.remove('hidden');
audioPlayer.classList.add('hidden');
// Call TTS API
fetch("{{ route('announcement.ttsPreview') }}", {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
},
body: JSON.stringify({
text: text,
voice: voice,
speed: speed
})
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
if (data.audio_url) {
audioPlayer.src = data.audio_url;
audioPlayer.classList.remove('hidden');
loadingIndicator.classList.add('hidden');
audioPlayer.play();
// Show success toast
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: 'success',
title: 'Preview audio siap!'
});
} else {
throw new Error(data.message || 'Gagal menghasilkan audio');
}
})
.catch(error => {
console.error('Error:', error);
Swal.fire({
icon: 'error',
title: 'Gagal Membuat Preview',
text: error.message || 'Terjadi kesalahan saat menghasilkan audio',
confirmButtonColor: '#6366f1',
backdrop: 'rgba(99, 102, 241, 0.1)'
});
})
.finally(() => {
previewBtn.innerHTML = '<i class="fas fa-play-circle mr-2"></i> Dengarkan Preview';
previewBtn.disabled = false;
loadingIndicator.classList.add('hidden');
});
});
// Form submission with enhanced validation
document.getElementById('announcementForm').addEventListener('submit', function(e) {
e.preventDefault();
const form = this;
const formData = new FormData(form);
const selectedRooms = document.querySelectorAll('.room-checkbox:checked');
const mode = document.querySelector('input[name="mode"]:checked').value;
const messageField = mode === 'reguler' ? 'message' : 'tts_text';
const message = formData.get(messageField);
// Validation
if (selectedRooms.length === 0) {
Swal.fire({
icon: 'warning',
title: 'Ruangan Belum Dipilih',
text: 'Silakan pilih setidaknya satu ruangan tujuan',
confirmButtonColor: '#6366f1',
backdrop: 'rgba(99, 102, 241, 0.1)'
});
// Scroll to room selection
document.querySelector('.room-checkbox').closest('.mb-8').scrollIntoView({
behavior: 'smooth',
block: 'center'
});
return;
}
if (!message || message.trim().length === 0) {
Swal.fire({
icon: 'warning',
title: 'Konten Kosong',
text: `Silakan isi ${mode === 'reguler' ? 'pesan pengumuman' : 'teks untuk TTS'}`,
confirmButtonColor: '#6366f1',
backdrop: 'rgba(99, 102, 241, 0.1)'
});
return;
}
// Confirmation dialog
Swal.fire({
title: 'Konfirmasi Pengiriman',
html: `
<div class="text-left">
<p class="mb-2">Anda akan mengirim pengumuman <span class="font-semibold">${mode === 'reguler' ? 'teks' : 'suara (TTS)'}</span> ke:</p>
<ul class="list-disc list-inside mb-4 max-h-40 overflow-y-auto">
${Array.from(selectedRooms).map(room => {
const roomLabel = room.closest('.room-label').querySelector('span').textContent;
return `<li>${roomLabel}</li>`;
}).join('')}
</ul>
<p class="text-sm text-gray-500">Pastikan informasi sudah benar sebelum mengirim</p>
</div>
`,
icon: 'question',
showCancelButton: true,
confirmButtonColor: '#6366f1',
cancelButtonColor: '#d33',
confirmButtonText: 'Ya, Kirim Sekarang',
cancelButtonText: 'Periksa Kembali',
backdrop: 'rgba(99, 102, 241, 0.1)',
width: '32rem'
}).then((result) => {
if (result.isConfirmed) {
// Show loading
const submitBtn = document.getElementById('submit-btn');
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i> Mengirim...';
submitBtn.disabled = true;
// Submit form
fetch(form.action, {
method: 'POST',
body: formData,
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
})
.then(response => {
if (response.redirected) {
window.location.href = response.url;
} else {
return response.json();
}
})
.catch(error => {
console.error('Error:', error);
Swal.fire({
icon: 'error',
title: 'Gagal Mengirim',
text: error.message || 'Terjadi kesalahan saat mengirim pengumuman',
confirmButtonColor: '#6366f1',
backdrop: 'rgba(99, 102, 241, 0.1)'
});
})
.finally(() => {
submitBtn.innerHTML = '<i class="fas fa-paper-plane mr-2"></i> Kirim Pengumuman';
submitBtn.disabled = false;
});
}
});
});
// Help modal
const helpModal = document.getElementById('help-modal');
document.getElementById('help-btn').addEventListener('click', () => {
helpModal.classList.remove('hidden');
document.body.style.overflow = 'hidden';
});
document.getElementById('close-help').addEventListener('click', () => {
helpModal.classList.add('hidden');
document.body.style.overflow = 'auto';
});
document.getElementById('close-help-btn').addEventListener('click', () => {
helpModal.classList.add('hidden');
document.body.style.overflow = 'auto';
});
// Dropdown menu for announcement cards
document.querySelectorAll('.dropdown-toggle').forEach(toggle => {
toggle.addEventListener('click', function(e) {
e.stopPropagation();
const menu = this.nextElementSibling;
document.querySelectorAll('.dropdown-menu').forEach(m => {
if (m !== menu) m.classList.add('hidden');
});
menu.classList.toggle('hidden');
});
});
// Close dropdowns when clicking outside
document.addEventListener('click', function() {
document.querySelectorAll('.dropdown-menu').forEach(menu => {
menu.classList.add('hidden');
});
});
// 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();
}
});
};
});
</script>
@endsection

View File

@ -0,0 +1,80 @@
@extends('layouts.dashboard')
@section('content')
<div class="container-fluid">
<div class="row mb-4">
<div class="col-md-12">
<h2 class="fw-bold">Detail Pengumuman</h2>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div class="card shadow">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">Informasi Pengumuman</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-6">
<strong>Waktu:</strong> {{ $announcement->sent_at->format('d M Y H:i:s') }}
</div>
<div class="col-md-6">
<strong>Mode:</strong>
<span class="badge bg-{{ $announcement->mode === 'reguler' ? 'primary' : 'success' }}">
{{ strtoupper($announcement->mode) }}
</span>
</div>
</div>
<div class="mb-3">
<strong>Ruangan Tujuan:</strong>
<div class="mt-2">
@foreach($announcement->ruangans as $ruangan)
<span class="badge bg-secondary mb-1">{{ $ruangan->nama_ruangan }}</span>
@endforeach
</div>
</div>
<div class="mb-3">
<strong>Isi Pengumuman:</strong>
<div class="p-3 bg-light rounded mt-2">
@if($announcement->mode === 'tts')
<p>{{ $announcement->message }}</p>
@if($announcement->audio_path)
<audio controls class="w-100 mt-3">
<source src="{{ $announcement->audio_url }}" type="audio/wav">
Browser Anda tidak mendukung pemutar audio.
</audio>
@endif
<div class="mt-2">
<small class="text-muted">
<strong>Suara:</strong> {{ $announcement->voice }} |
<strong>Kecepatan:</strong> {{ $announcement->speed }}
</small>
</div>
@else
<p>{{ $announcement->message }}</p>
@endif
</div>
</div>
<div class="row">
<div class="col-md-6">
<strong>Dibuat Oleh:</strong> {{ $announcement->user->name }}
</div>
<div class="col-md-6">
<strong>Dibuat Pada:</strong> {{ $announcement->created_at->format('d M Y H:i:s') }}
</div>
</div>
</div>
<div class="card-footer text-end">
<a href="{{ route('admin.announcement.index') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left me-2"></i> Kembali
</a>
</div>
</div>
</div>
</div>
</div>
@endsection

View File

@ -1,395 +0,0 @@
@extends('layouts.dashboard')
@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-blue-100/80 shadow-inner">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-blue-600" 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>
</div>
<div>
<h1 class="text-2xl font-bold text-gray-800">Riwayat Pengumuman</h1>
<p class="text-sm text-gray-500">Daftar semua pengumuman yang pernah dikirim</p>
</div>
</div>
<div class="flex items-center space-x-2">
<a href="{{ route('announcements.index') }}" class="px-3 py-1 rounded-lg text-sm font-medium bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 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>
Kembali ke Pengumuman
</a>
</div>
</div>
<!-- Filters Section -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 mb-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Search Box -->
<div>
<label for="search" class="block text-sm font-medium text-gray-700 mb-1">Cari Pengumuman</label>
<div class="relative">
<input type="text" id="search" name="search" placeholder="Kata kunci..." class="block w-full pl-4 pr-10 py-2 border border-gray-300 rounded-xl shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 transition duration-150 ease-in-out">
<div class="absolute inset-y-0 right-0 pr-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>
</div>
</div>
<!-- Type Filter -->
<div>
<label for="type-filter" class="block text-sm font-medium text-gray-700 mb-1">Jenis Pengumuman</label>
<select id="type-filter" class="block w-full pl-3 pr-10 py-2 border border-gray-300 rounded-xl shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 transition duration-150 ease-in-out">
<option value="">Semua Jenis</option>
<option value="tts">Text-to-Speech</option>
<option value="manual">Manual</option>
</select>
</div>
<!-- Status Filter -->
<div>
<label for="status-filter" class="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select id="status-filter" class="block w-full pl-3 pr-10 py-2 border border-gray-300 rounded-xl shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 transition duration-150 ease-in-out">
<option value="">Semua Status</option>
<option value="completed">Selesai</option>
<option value="stopped">Dihentikan</option>
</select>
</div>
</div>
</div>
<!-- Announcement History Table -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
<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">ID</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">Jenis</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Isi</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ruangan</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 id="history-table-body" class="bg-white divide-y divide-gray-200">
<!-- Data will be loaded via AJAX -->
<tr>
<td colspan="7" class="px-6 py-4 text-center text-sm text-gray-500">
Memuat data...
</td>
</tr>
</tbody>
</table>
</div>
<div class="bg-white px-6 py-4 border-t border-gray-200 flex items-center justify-between">
<div class="text-sm text-gray-500" id="pagination-info">
Menampilkan 0 sampai 0 dari 0 entri
</div>
<div class="flex space-x-2" id="pagination-controls">
<button id="prev-page" disabled class="px-3 py-1 rounded-lg text-sm font-medium bg-gray-100 text-gray-500 cursor-not-allowed">
Sebelumnya
</button>
<button id="next-page" disabled class="px-3 py-1 rounded-lg text-sm font-medium bg-gray-100 text-gray-500 cursor-not-allowed">
Selanjutnya
</button>
</div>
</div>
</div>
</div>
<!-- Details Modal -->
<div id="details-modal" class="hidden fixed inset-0 overflow-y-auto z-50">
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
</div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div class="inline-block align-bottom bg-white rounded-2xl shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full">
<div class="bg-gradient-to-r from-blue-500 to-blue-600 px-6 py-4 rounded-t-2xl">
<h3 class="text-lg font-semibold text-white" id="modal-title">Detail Pengumuman</h3>
</div>
<div class="px-6 py-4">
<div class="space-y-4">
<div>
<h4 class="text-sm font-medium text-gray-500">ID Pengumuman</h4>
<p class="mt-1 text-sm text-gray-900" id="detail-id">-</p>
</div>
<div>
<h4 class="text-sm font-medium text-gray-500">Waktu Pengiriman</h4>
<p class="mt-1 text-sm text-gray-900" id="detail-time">-</p>
</div>
<div>
<h4 class="text-sm font-medium text-gray-500">Jenis</h4>
<p class="mt-1 text-sm text-gray-900" id="detail-type">-</p>
</div>
<div>
<h4 class="text-sm font-medium text-gray-500">Status</h4>
<p class="mt-1 text-sm text-gray-900" id="detail-status">-</p>
</div>
<div>
<h4 class="text-sm font-medium text-gray-500">Isi Pengumuman</h4>
<p class="mt-1 text-sm text-gray-900" id="detail-content">-</p>
</div>
<div>
<h4 class="text-sm font-medium text-gray-500">Ruangan Tujuan</h4>
<div class="mt-1 flex flex-wrap gap-2" id="detail-rooms">
<!-- Room badges will appear here -->
</div>
</div>
<div id="audio-section" class="hidden">
<h4 class="text-sm font-medium text-gray-500">Audio</h4>
<audio controls class="mt-2 w-full" id="audio-player">
Your browser does not support the audio element.
</audio>
</div>
</div>
</div>
<div class="bg-gray-50 px-6 py-4 rounded-b-2xl flex justify-end">
<button type="button" id="close-modal" class="px-4 py-2 bg-white border border-gray-300 rounded-xl text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Tutup
</button>
</div>
</div>
</div>
</div>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script>
$(document).ready(function() {
const baseUrl = '/admin/pengumuman';
let currentPage = 1;
let lastPage = 1;
let loading = false;
// Initialize the page
function init() {
loadHistory();
setupEventListeners();
}
// Set up event listeners
function setupEventListeners() {
// Filter changes
$('#search, #type-filter, #status-filter').on('change keyup', function() {
currentPage = 1;
loadHistory();
});
// Pagination controls
$('#prev-page').click(function() {
if (currentPage > 1) {
currentPage--;
loadHistory();
}
});
$('#next-page').click(function() {
if (currentPage < lastPage) {
currentPage++;
loadHistory();
}
});
// Modal close button
$('#close-modal').click(function() {
$('#details-modal').addClass('hidden');
});
}
// Load history data
function loadHistory() {
if (loading) return;
loading = true;
// Show loading state
$('#history-table-body').html(`
<tr>
<td colspan="7" class="px-6 py-4 text-center text-sm text-gray-500">
Memuat data...
</td>
</tr>
`);
const filters = {
search: $('#search').val(),
filter: $('#type-filter').val(),
status: $('#status-filter').val(),
page: currentPage
};
$.get(`${baseUrl}/history`, filters, function(response) {
renderTable(response);
updatePagination(response);
}).fail(function() {
$('#history-table-body').html(`
<tr>
<td colspan="7" class="px-6 py-4 text-center text-sm text-red-500">
Gagal memuat data. Silakan coba lagi.
</td>
</tr>
`);
}).always(function() {
loading = false;
});
}
// Render table data
function renderTable(data) {
if (data.data.length === 0) {
$('#history-table-body').html(`
<tr>
<td colspan="7" class="px-6 py-4 text-center text-sm text-gray-500">
Tidak ada data yang ditemukan
</td>
</tr>
`);
return;
}
let html = '';
data.data.forEach(item => {
// Format time
const sentAt = new Date(item.sent_at);
const timeString = sentAt.toLocaleString('id-ID', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
// Format type
const typeBadge = item.type === 'tts' ?
'<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800">TTS</span>' :
'<span class="px-2 py-1 text-xs rounded-full bg-amber-100 text-amber-800">Manual</span>';
// Format status
let statusBadge;
if (item.status === 'completed') {
statusBadge = '<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800">Selesai</span>';
} else {
statusBadge = '<span class="px-2 py-1 text-xs rounded-full bg-red-100 text-red-800">Dihentikan</span>';
}
// Truncate content if too long
const content = item.content ?
(item.content.length > 50 ? item.content.substring(0, 50) + '...' : item.content) :
'-';
// Room badges (show first 2 only)
let roomsHtml = '';
if (item.target_ruangans && item.target_ruangans.length > 0) {
const roomsToShow = item.target_ruangans.slice(0, 2);
roomsToShow.forEach(room => {
roomsHtml += `<span class="inline-block px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 mr-1">${room}</span>`;
});
if (item.target_ruangans.length > 2) {
roomsHtml += `<span class="inline-block px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800">+${item.target_ruangans.length - 2}</span>`;
}
}
html += `
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${item.id}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${timeString}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${typeBadge}</td>
<td class="px-6 py-4 text-sm text-gray-500">${content}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${roomsHtml || '-'}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${statusBadge}</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button onclick="showDetails(${item.id})" class="text-blue-600 hover:text-blue-900 mr-3">Detail</button>
</td>
</tr>
`;
});
$('#history-table-body').html(html);
}
// Update pagination controls
function updatePagination(data) {
lastPage = data.last_page;
// Update pagination info
$('#pagination-info').text(
`Menampilkan ${data.from || 0} sampai ${data.to || 0} dari ${data.total || 0} entri`
);
// Update previous button
$('#prev-page').prop('disabled', currentPage === 1);
if (currentPage === 1) {
$('#prev-page').addClass('bg-gray-100 text-gray-500 cursor-not-allowed')
.removeClass('bg-white text-gray-700 hover:bg-gray-50');
} else {
$('#prev-page').addClass('bg-white text-gray-700 hover:bg-gray-50')
.removeClass('bg-gray-100 text-gray-500 cursor-not-allowed');
}
// Update next button
$('#next-page').prop('disabled', currentPage === lastPage);
if (currentPage === lastPage) {
$('#next-page').addClass('bg-gray-100 text-gray-500 cursor-not-allowed')
.removeClass('bg-white text-gray-700 hover:bg-gray-50');
} else {
$('#next-page').addClass('bg-white text-gray-700 hover:bg-gray-50')
.removeClass('bg-gray-100 text-gray-500 cursor-not-allowed');
}
}
// Show announcement details
window.showDetails = function(id) {
$.get(`${baseUrl}/${id}`, function(response) {
// Format time
const sentAt = new Date(response.sent_at);
const timeString = sentAt.toLocaleString('id-ID', {
day: '2-digit',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
// Set basic info
$('#detail-id').text(response.id);
$('#detail-time').text(timeString);
$('#detail-type').text(response.type === 'tts' ? 'Text-to-Speech' : 'Manual');
$('#detail-status').text(response.status === 'completed' ? 'Selesai' : 'Dihentikan');
$('#detail-content').text(response.content || '-');
// Set rooms
let roomsHtml = '';
if (response.target_ruangans && response.target_ruangans.length > 0) {
response.target_ruangans.forEach(room => {
roomsHtml += `<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">${room}</span>`;
});
}
$('#detail-rooms').html(roomsHtml || '-');
// Handle audio for TTS
if (response.type === 'tts' && response.audio_url) {
$('#audio-section').removeClass('hidden');
$('#audio-player').attr('src', response.audio_url);
} else {
$('#audio-section').addClass('hidden');
}
// Show modal
$('#details-modal').removeClass('hidden');
}).fail(function() {
alert('Gagal memuat detail pengumuman');
});
}
// Initialize the page
init();
});
</script>
@endsection

View File

@ -1,543 +0,0 @@
@extends('layouts.dashboard')
@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-blue-100/80 shadow-inner">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-blue-600" 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>
</div>
<div>
<h1 class="text-2xl font-bold text-gray-800">Sistem Pengumuman</h1>
<p class="text-sm text-gray-500">Kelola pengumuman untuk seluruh ruangan</p>
</div>
</div>
<div class="flex items-center space-x-2">
<div id="mqtt-status" class="flex items-center px-3 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
<span class="w-2 h-2 rounded-full bg-gray-400 mr-2"></span>
<span>Memeriksa koneksi...</span>
</div>
<a href="{{ route('announcements.history') }}" class="px-3 py-1 rounded-lg text-sm font-medium bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 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>
Riwayat
</a>
</div>
</div>
<!-- Notification System -->
<div id="notification-container">
@if(session('success'))
<div class="bg-green-100 border-l-4 border-green-500 text-green-700 p-4 mb-6 rounded-md shadow-sm animate-fade-in">
{{ session('success') }}
</div>
@endif
@if(session('error'))
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6 rounded-md shadow-sm animate-fade-in">
{{ session('error') }}
</div>
@endif
</div>
<!-- Main Content Grid -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 animate-fade-in">
<!-- Announcement Form Card -->
<div class="lg:col-span-2">
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden transition-all duration-300 hover:shadow-md">
<div class="bg-gradient-to-r from-blue-500 to-blue-600 px-6 py-4">
<h2 class="text-xl font-semibold text-white flex items-center">
<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="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
Buat Pengumuman Baru
</h2>
</div>
<div class="p-6">
<form id="announcement-form">
@csrf
<!-- Announcement Type -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-3">Mode Pengumuman</label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<label class="relative cursor-pointer">
<input type="radio" name="type" value="tts" class="peer absolute opacity-0" checked>
<div class="p-4 border-2 border-gray-200 rounded-xl transition-all duration-200 peer-checked:border-blue-500 peer-checked:bg-blue-50 peer-checked:ring-1 peer-checked:ring-blue-200 hover:border-blue-300">
<div class="flex items-center">
<div class="flex-shrink-0 h-5 w-5 rounded-full border-2 border-gray-300 peer-checked:border-blue-500 peer-checked:bg-blue-500 peer-checked:border-4 transition-all duration-200 mr-3"></div>
<div>
<h3 class="font-medium text-gray-800">Text-to-Speech</h3>
<p class="text-sm text-gray-500">Sistem akan membacakan teks otomatis</p>
</div>
</div>
</div>
</label>
<label class="relative cursor-pointer">
<input type="radio" name="type" value="manual" class="peer absolute opacity-0">
<div class="p-4 border-2 border-gray-200 rounded-xl transition-all duration-200 peer-checked:border-blue-500 peer-checked:bg-blue-50 peer-checked:ring-1 peer-checked:ring-blue-200 hover:border-blue-300">
<div class="flex items-center">
<div class="flex-shrink-0 h-5 w-5 rounded-full border-2 border-gray-300 peer-checked:border-blue-500 peer-checked:bg-blue-500 peer-checked:border-4 transition-all duration-200 mr-3"></div>
<div>
<h3 class="font-medium text-gray-800">Pengumuman Manual</h3>
<p class="text-sm text-gray-500">Aktifkan relay audio di ruangan terpilih</p>
</div>
</div>
</div>
</label>
</div>
</div>
<!-- Target Rooms -->
<div class="mb-6">
<div class="flex justify-between items-center mb-3">
<label class="block text-sm font-medium text-gray-700">Ruangan Tujuan</label>
<button type="button" id="select-all" class="text-xs text-blue-600 hover:text-blue-800 font-medium transition-colors duration-200">Pilih Semua</button>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
@foreach($ruangans as $ruangan)
<label class="relative cursor-pointer">
<input type="checkbox" name="ruangans[]" value="{{ $ruangan }}" class="peer absolute opacity-0">
<div class="px-3 py-2 border border-gray-200 rounded-lg text-center transition-all duration-150 peer-checked:bg-blue-50 peer-checked:border-blue-300 peer-checked:text-blue-700 peer-checked:font-medium hover:bg-gray-50">
{{ $ruangan }}
</div>
</label>
@endforeach
</div>
</div>
<!-- TTS Content -->
<div id="tts-fields" class="mb-6 transition-all duration-300">
<label class="block text-sm font-medium text-gray-700 mb-2">Isi Pengumuman</label>
<div class="relative">
<textarea name="content" rows="4" class="block w-full px-4 py-3 border border-gray-300 rounded-xl shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 transition duration-150 ease-in-out" placeholder="Ketikkan teks pengumuman..."></textarea>
<div class="absolute bottom-3 right-3 flex items-center text-xs text-gray-500 bg-white px-2 rounded-full transition-opacity duration-200">
<span id="char-count">0</span>/500
</div>
</div>
</div>
<!-- Submit Button -->
<div class="flex justify-end pt-4">
<button type="submit" id="submit-btn" class="px-5 py-2.5 bg-gradient-to-r from-blue-500 to-blue-600 rounded-xl text-sm font-medium text-white hover:from-blue-600 hover:to-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 shadow-sm transition-all duration-300 flex items-center justify-center transform hover:scale-105">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
Kirim Pengumuman
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Right Sidebar -->
<div class="space-y-6">
<!-- System Status Card -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
<div class="bg-gradient-to-r from-green-500 to-green-600 px-6 py-4">
<h2 class="text-xl font-semibold text-white flex items-center">
<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="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Status Sistem
</h2>
</div>
<div class="p-6 space-y-4">
<!-- Active Announcement Indicator -->
<div id="active-indicator" class="flex items-start">
<div class="flex-shrink-0 mt-1">
<div class="h-3 w-3 rounded-full bg-gray-300"></div>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-700">Tidak ada pengumuman aktif</p>
<p class="text-xs text-gray-500">Siap menerima pengumuman baru</p>
</div>
</div>
<!-- Active Rooms Indicator -->
<div id="active-rooms-indicator" class="hidden">
<div class="flex items-center justify-between mb-2">
<p class="text-sm font-medium text-gray-700">Ruangan Aktif</p>
<span class="px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-800">
<span id="active-count">0</span> aktif
</span>
</div>
<div id="active-rooms-badges" class="flex flex-wrap gap-2">
<!-- Badge ruangan akan muncul di sini -->
</div>
</div>
</div>
</div>
<!-- Manual Control Card -->
<div id="stop-manual-section" class="hidden bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
<div class="bg-gradient-to-r from-amber-500 to-orange-500 px-6 py-4">
<h2 class="text-xl font-semibold text-white flex items-center">
<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="M5 13l4 4L19 7" />
</svg>
Kontrol Ruangan
</h2>
</div>
<div class="p-6">
<div class="bg-amber-50 border-l-4 border-amber-400 p-4 rounded-lg mb-4">
<div class="flex">
<div class="flex-shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-amber-500" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-amber-700">
<span class="font-medium">Perhatian!</span> Relay audio sedang aktif di ruangan terpilih.
</p>
</div>
</div>
</div>
<button id="stop-routing-btn" class="w-full px-4 py-3 bg-gradient-to-r from-amber-500 to-orange-500 rounded-xl text-sm font-medium text-white hover:from-amber-600 hover:to-orange-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-amber-500 shadow-sm transition-all duration-300 flex items-center justify-center transform hover:scale-105">
<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="M5 13l4 4L19 7" />
</svg>
Matikan Semua Ruangan
</button>
</div>
</div>
</div>
</div>
</div>
<style>
.animate-fade-in {
animation: fadeIn 0.5s ease-in-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
$(document).ready(function() {
$.ajaxSetup({
headers: {
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
}
});
// Base URL for API endpoints
const baseUrl = '/admin/pengumuman';
// Initialize UI state
function initUI() {
// Check MQTT status immediately
updateMqttStatus();
// Check for active announcements
checkActiveAnnouncement();
// Trigger change event to set initial view
$('input[name="type"]').trigger('change');
// Load active announcements
loadActiveAnnouncements();
}
// MQTT Status Indicator
function updateMqttStatus() {
$.ajax({
url: `${baseUrl}/mqtt-status`,
method: 'GET',
beforeSend: function() {
$('#mqtt-status span:first').removeClass('bg-green-500 bg-red-500').addClass('bg-gray-400');
$('#mqtt-status span:last').text('Memeriksa...');
},
success: function(response) {
const statusEl = $('#mqtt-status');
const statusDot = statusEl.find('span:first');
const statusText = statusEl.find('span:last');
statusDot.removeClass('bg-gray-400');
if (response.connected) {
statusDot.addClass('bg-green-500');
statusText.text('Terhubung');
} else {
statusDot.addClass('bg-red-500');
statusText.text('Terputus');
}
},
error: function() {
$('#mqtt-status span:first').removeClass('bg-green-500 bg-red-500').addClass('bg-gray-400');
$('#mqtt-status span:last').text('Gagal memeriksa');
}
});
}
// Character counter
$('textarea[name="content"]').on('input', function() {
const count = $(this).val().length;
$('#char-count').text(count);
$('#char-count').toggleClass('text-red-500', count > 500);
}).trigger('input');
// Toggle fields based on announcement type
$('input[name="type"]').change(function() {
const isManual = $(this).val() === 'manual';
$('#tts-fields').toggleClass('hidden', isManual);
$('#submit-btn').html(`
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
${isManual ? '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />' : '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />'}
</svg>
${isManual ? 'Hidupkan Ruangan' : 'Kirim Pengumuman'}
`);
});
// Select all rooms
$('#select-all').click(function() {
const checkboxes = $('input[name="ruangans[]"]');
const allChecked = checkboxes.length === checkboxes.filter(':checked').length;
checkboxes.prop('checked', !allChecked).trigger('change');
$(this).text(allChecked ? 'Pilih Semua' : 'Batalkan Semua');
});
// Form submission
$('#announcement-form').submit(function(e) {
e.preventDefault();
const formData = $(this).serializeArray();
const isManual = $('input[name="type"]:checked').val() === 'manual';
const selectedRuangans = $('input[name="ruangans[]"]:checked').map(function() {
return $(this).val();
}).get();
if (selectedRuangans.length === 0) {
showAlert('error', 'Peringatan', 'Silakan pilih minimal satu ruangan');
return;
}
const confirmMessage = isManual
? `Anda akan menghidupkan relay audio di ${selectedRuangans.length} ruangan`
: `Anda akan mengirim pengumuman TTS ke ${selectedRuangans.length} ruangan`;
showConfirmation(
'Konfirmasi',
confirmMessage,
'',
function() {
const submitBtn = $('#submit-btn');
const buttonText = isManual ? 'Menghidupkan...' : 'Mengirim...';
submitBtn.prop('disabled', true).html(`
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
${buttonText}
`);
$.ajax({
url: `${baseUrl}/send`,
method: 'POST',
data: formData,
success: function(response) {
showAlert('success', 'Berhasil!', response.message);
if (isManual) {
$('#stop-manual-section').removeClass('hidden');
updateActiveRooms(selectedRuangans);
}
loadActiveAnnouncements();
},
error: handleAjaxError,
complete: function() {
submitBtn.prop('disabled', false).html(`
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
${isManual ? '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />' : '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />'}
</svg>
${isManual ? 'Hidupkan Ruangan' : 'Kirim Pengumuman'}
`);
}
});
}
);
});
// Stop routing button
$('#stop-routing-btn').click(function(e) {
e.preventDefault();
showConfirmation(
'Konfirmasi',
'Anda yakin ingin mematikan semua relay ruangan?',
'',
function() {
const stopBtn = $('#stop-routing-btn');
stopBtn.prop('disabled', true).html(`
<svg class="animate-spin -ml-1 mr-2 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Memproses...
`);
$.post(`${baseUrl}/stop-manual`, function(response) {
showAlert('success', 'Berhasil!', response.message);
$('#stop-manual-section').addClass('hidden');
loadActiveAnnouncements();
}).fail(handleAjaxError).always(function() {
stopBtn.prop('disabled', false).html(`
<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="M5 13l4 4L19 7" />
</svg>
Matikan Semua Ruangan
`);
});
}
);
});
// Check if there's an active manual announcement
function checkActiveAnnouncement() {
$.get(`${baseUrl}/check-active`, function(response) {
if (response.active && response.type === 'manual') {
$('input[name="type"][value="manual"]').prop('checked', true).trigger('change');
$('#stop-manual-section').removeClass('hidden');
}
});
}
// Load active announcements
function loadActiveAnnouncements() {
$.get(`${baseUrl}/active-announcements`, function(response) {
updateActiveStatus(response);
});
}
// Update active status UI
function updateActiveStatus(activeAnnouncements) {
const activeIndicator = $('#active-indicator');
const activeRoomsIndicator = $('#active-rooms-indicator');
const activeRoomsBadges = $('#active-rooms-badges');
if (activeAnnouncements.length === 0) {
activeIndicator.html(`
<div class="flex-shrink-0 mt-1">
<div class="h-3 w-3 rounded-full bg-gray-300"></div>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-700">Tidak ada pengumuman aktif</p>
<p class="text-xs text-gray-500">Siap menerima pengumuman baru</p>
</div>
`);
activeRoomsIndicator.addClass('hidden');
return;
}
// Count unique active rooms
const allRooms = [];
activeAnnouncements.forEach(announcement => {
allRooms.push(...announcement.target_ruangans);
});
const uniqueRooms = [...new Set(allRooms)];
// Update main indicator
activeIndicator.html(`
<div class="flex-shrink-0 mt-1">
<div class="h-3 w-3 rounded-full bg-green-500 animate-pulse"></div>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-700">${activeAnnouncements.length} pengumuman aktif</p>
<p class="text-xs text-gray-500">${uniqueRooms.length} ruangan terpengaruh</p>
</div>
`);
// Update active rooms badges
activeRoomsIndicator.removeClass('hidden');
$('#active-count').text(uniqueRooms.length);
let badgesHtml = '';
uniqueRooms.forEach(room => {
badgesHtml += `
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
${room}
</span>
`;
});
activeRoomsBadges.html(badgesHtml);
}
// Update active rooms display
function updateActiveRooms(ruangans) {
const activeRoomsIndicator = $('#active-rooms-indicator');
const activeRoomsBadges = $('#active-rooms-badges');
activeRoomsIndicator.removeClass('hidden');
$('#active-count').text(ruangans.length);
let badgesHtml = '';
ruangans.forEach(ruangan => {
badgesHtml += `
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
${ruangan}
</span>
`;
});
activeRoomsBadges.html(badgesHtml);
}
// Helper function to show alert
function showAlert(icon, title, text) {
Swal.fire({
icon: icon,
title: title,
text: text,
timer: 2000,
showConfirmButton: false
});
}
// Helper function to show confirmation dialog
function showConfirmation(title, text, html, confirmCallback) {
Swal.fire({
title: title,
text: text,
html: html,
icon: 'question',
showCancelButton: true,
confirmButtonText: 'Ya, Lanjutkan',
cancelButtonText: 'Batal',
reverseButtons: true,
customClass: {
confirmButton: 'px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-md',
cancelButton: 'px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 rounded-md'
}
}).then((result) => {
if (result.isConfirmed) {
confirmCallback();
}
});
}
// Helper function to handle AJAX errors
function handleAjaxError(xhr) {
const errorMsg = xhr.responseJSON?.message || 'Terjadi kesalahan saat memproses permintaan';
showAlert('error', 'Gagal!', errorMsg);
}
// Initialize the UI
initUI();
// Polling for updates every 30 seconds
setInterval(function() {
updateMqttStatus();
loadActiveAnnouncements();
}, 30000);
});
</script>
@endsection

View File

@ -114,7 +114,7 @@ class="dropdown-btn w-full text-left p-2 bg-gray-100 rounded-lg flex justify-bet
<li><a href="{{ route('bel.index') }}"
class="block p-2 rounded-lg {{ request()->is('admin/bel') ? 'bg-blue-100 text-blue-600' : 'bg-gray-50 text-gray-800' }}">Bel</a>
</li>
<li><a href="{{ route('announcements.index') }}"
<li><a href="{{ route('announcement.index') }}"
class="block p-2 rounded-lg {{ request()->is('admin/pengumuman') ? 'bg-blue-100 text-blue-600' : 'bg-gray-50 text-gray-800' }}">Pengumuman</a>
</li>
</ul>

View File

@ -101,13 +101,13 @@
});
// Announcement System
Route::prefix('pengumuman')->group(function () {
Route::get('/', [AnnouncementController::class, 'index'])->name('announcements.index');
Route::post('/send', [AnnouncementController::class, 'send'])->name('announcements.send');
Route::post('/stop-manual', [AnnouncementController::class, 'stopManual'])->name('announcements.stop-manual');
Route::get('/active', [AnnouncementController::class, 'checkActive'])->name('announcements.active');
Route::get('/mqtt-status', [AnnouncementController::class, 'mqttStatus'])->name('announcements.mqttStatus');
Route::get('/history', [AnnouncementController::class, 'history'])->name('announcements.history');
Route::prefix('announcement')->controller(AnnouncementController::class)->group(function () {
Route::get('/', 'index')->name('announcement.index');
Route::post('/', 'store')->name('announcement.store');
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');
});
});
});