install TTS dan fix timing pada waktu pemanggilan pada laporan, menambahkan fitur tambahkan user pada admin, kemudian handle ambil antran berulang pada user

This commit is contained in:
Endyfadlullah 2025-08-11 02:17:15 +07:00
parent e0306d5579
commit 879ee41c76
32 changed files with 3801 additions and 733 deletions

323
README_INDONESIAN_TTS.md Normal file
View File

@ -0,0 +1,323 @@
# Indonesian TTS Integration Guide
## Overview
Repository ini mengintegrasikan [Indonesian TTS](https://github.com/Wikidepia/indonesian-tts) yang menggunakan Coqui TTS untuk menghasilkan suara Indonesia yang lebih natural dan akurat. Indonesian TTS ini cocok untuk sistem antrian Puskesmas karena:
- **Suara Natural**: Menggunakan model yang dilatih khusus untuk bahasa Indonesia
- **Pengucapan Akurat**: Menggunakan g2p-id untuk konversi grapheme ke phoneme
- **Multiple Speakers**: Tersedia 80+ speaker dengan berbagai karakteristik suara
- **Offline Capability**: Dapat berjalan tanpa internet setelah model diinstall
## Fitur Utama
### 1. Indonesian Pronunciation
- Konversi otomatis nomor antrian ke pengucapan Indonesia
- Contoh: "U5" → "U Lima", "A10" → "A Sepuluh"
- Mendukung angka 0-100 dengan pengucapan yang benar
### 2. Multiple TTS Options
- **Indonesian TTS** (Prioritas): Menggunakan model Coqui TTS Indonesia
- **Google TTS** (Fallback): Menggunakan Google Cloud TTS API
- **Browser TTS** (Fallback): Menggunakan Web Speech API browser
### 3. Audio Sequence Management
- Urutan audio: Attention Sound → TTS → Attention Sound
- Queue management untuk mencegah overlap
- Cross-page communication untuk admin → display
## Instalasi
### Prerequisites
1. **Python 3.8+**
2. **pip** (Python package manager)
3. **Laravel 8+** dengan PHP 8.0+
### Step 1: Install Coqui TTS
```bash
# Install Coqui TTS
pip install TTS
# Verify installation
tts --version
```
### Step 2: Download Model Files
1. Kunjungi [Indonesian TTS Releases](https://github.com/Wikidepia/indonesian-tts/releases)
2. Download file:
- `checkpoint.pth` (model file)
- `config.json` (configuration file)
3. Buat folder: `storage/app/tts/models/`
4. Simpan file di folder tersebut
### Step 3: Install g2p-id (Optional)
```bash
# Install g2p-id for better pronunciation
pip install g2p-id
# Verify installation
g2p-id --help
```
### Step 4: Test Installation
```bash
# Test TTS with sample text
tts --text "Halo dunia" \
--model_path storage/app/tts/models/checkpoint.pth \
--config_path storage/app/tts/models/config.json \
--speaker_idx wibowo \
--out_path test.wav
```
## Konfigurasi
### Environment Variables
Tambahkan ke file `.env`:
```env
# Indonesian TTS Configuration
INDONESIAN_TTS_ENABLED=true
INDONESIAN_TTS_MODEL_PATH=storage/app/tts/models/checkpoint.pth
INDONESIAN_TTS_CONFIG_PATH=storage/app/tts/models/config.json
INDONESIAN_TTS_DEFAULT_SPEAKER=wibowo
# Google TTS (Fallback)
GOOGLE_TTS_API_KEY=your_google_tts_api_key
```
### File Structure
```
storage/
├── app/
│ └── tts/
│ ├── models/
│ │ ├── checkpoint.pth
│ │ └── config.json
│ └── g2p-id
└── public/
└── audio/
└── queue_calls/
└── indonesian_tts_*.wav
```
## Penggunaan
### 1. Admin Panel
Akses halaman Indonesian TTS Settings:
```
/admin/indonesian-tts
```
Fitur yang tersedia:
- Status monitoring (Indonesian TTS, Coqui TTS, Model Files, Speakers)
- Test TTS dengan text custom
- Installation instructions
- Download model files
### 2. API Endpoints
```php
// Generate TTS audio
POST /admin/indonesian-tts/generate
{
"poli_name": "Poli Umum",
"queue_number": "U5"
}
// Create complete audio sequence
POST /admin/indonesian-tts/audio-sequence
{
"poli_name": "Poli Umum",
"queue_number": "U5"
}
// Check status
GET /admin/indonesian-tts/status
// Test TTS
POST /admin/indonesian-tts/test
{
"text": "Nomor antrian U Lima, silakan menuju ke Poli Umum"
}
```
### 3. Service Usage
```php
use App\Services\IndonesianTTSService;
$ttsService = new IndonesianTTSService();
// Generate TTS
$result = $ttsService->generateQueueCall('Poli Umum', 'U5');
// Create audio sequence
$sequence = $ttsService->createCompleteAudioSequence('Poli Umum', 'U5');
// Check status
$isAvailable = $ttsService->isIndonesianTTSAvailable();
$speakers = $ttsService->getAvailableSpeakers();
```
## Available Speakers
Model Indonesian TTS menyediakan 80+ speaker dengan karakteristik berbeda:
### Male Speakers
- `wibowo`: Suara pria natural, cocok untuk announcement
- `ardi`: Suara pria formal
- `budi`: Suara pria ramah
### Female Speakers
- `gadis`: Suara wanita natural, cocok untuk announcement
- `sari`: Suara wanita formal
- `rini`: Suara wanita ramah
### Regional Speakers
- `javanese_*`: Speaker dengan aksen Jawa
- `sundanese_*`: Speaker dengan aksen Sunda
## Troubleshooting
### Common Issues
1. **"tts command not found"**
```bash
# Reinstall TTS
pip uninstall TTS
pip install TTS
```
2. **"Model files not found"**
```bash
# Check file permissions
ls -la storage/app/tts/models/
chmod 644 storage/app/tts/models/*
```
3. **"Permission denied"**
```bash
# Fix directory permissions
chmod -R 755 storage/app/tts/
chown -R www-data:www-data storage/app/tts/
```
4. **"Audio generation failed"**
```bash
# Check Python environment
which python
which tts
# Test with simple command
tts --text "test" --model_path storage/app/tts/models/checkpoint.pth --config_path storage/app/tts/models/config.json --out_path test.wav
```
### Debug Mode
Aktifkan debug mode di `.env`:
```env
APP_DEBUG=true
LOG_LEVEL=debug
```
Cek log Laravel:
```bash
tail -f storage/logs/laravel.log
```
## Performance Optimization
### 1. Model Caching
```php
// Cache model loading
$ttsService = app(IndonesianTTSService::class);
```
### 2. Audio File Management
```bash
# Clean old audio files (older than 7 days)
find storage/app/public/audio/queue_calls/ -name "*.wav" -mtime +7 -delete
```
### 3. Memory Management
```php
// Limit concurrent TTS processes
$maxProcesses = 3;
```
## Security Considerations
1. **File Permissions**: Pastikan model files tidak dapat diakses publik
2. **Input Validation**: Validasi input text untuk mencegah injection
3. **Rate Limiting**: Batasi jumlah request TTS per menit
4. **Audio Sanitization**: Bersihkan nama file audio
## Monitoring
### Health Checks
```php
// Check TTS health
$health = [
'indonesian_tts' => $ttsService->isIndonesianTTSAvailable(),
'coqui_tts' => $ttsService->isCoquiTTSInstalled(),
'model_files' => file_exists($modelPath),
'speakers' => count($ttsService->getAvailableSpeakers())
];
```
### Metrics
- TTS generation success rate
- Audio file size and duration
- Speaker usage statistics
- Error rates and types
## Migration from Google TTS
Jika ingin migrasi dari Google TTS ke Indonesian TTS:
1. **Backup existing TTS configuration**
2. **Install Indonesian TTS** (ikuti guide di atas)
3. **Update service configuration**:
```php
// In TTSService.php
public function generateQueueCall($poliName, $queueNumber)
{
// Try Indonesian TTS first
$indonesianTTS = new IndonesianTTSService();
if ($indonesianTTS->isIndonesianTTSAvailable()) {
return $indonesianTTS->generateQueueCall($poliName, $queueNumber);
}
// Fallback to Google TTS
return parent::generateQueueCall($poliName, $queueNumber);
}
```
4. **Test thoroughly** dengan berbagai nomor antrian
5. **Monitor performance** dan error rates
## Support
Untuk bantuan lebih lanjut:
1. **Documentation**: [Indonesian TTS GitHub](https://github.com/Wikidepia/indonesian-tts)
2. **Issues**: Buat issue di repository project ini
3. **Community**: Coqui TTS Discord/Forum
## License
Indonesian TTS model memiliki lisensi terpisah. Pastikan untuk membaca dan mematuhi lisensi yang berlaku sebelum menggunakan untuk tujuan komersial.
---
**Note**: Indonesian TTS memerlukan resource yang cukup (RAM, CPU) untuk berjalan optimal. Pastikan server memiliki spesifikasi yang memadai.

View File

@ -1,194 +0,0 @@
# Fitur TTS (Text-to-Speech) untuk Sistem Antrian Puskesmas
## Overview
Fitur TTS telah ditambahkan ke sistem antrian puskesmas untuk memanggil antrian secara otomatis dengan suara. Ketika admin menekan tombol "Panggil", sistem akan memainkan urutan audio berikut:
1. **Attention Sound** - Bunyi perhatian
2. **TTS Announcement** - "Nomor antrian X, silakan menuju ke [nama poli]"
3. **Attention Sound** - Bunyi perhatian lagi
## Cara Kerja
### 1. Admin Panel
- Admin membuka halaman poli (Umum, Gigi, Jiwa, Tradisional)
- Klik tombol "Panggil" pada antrian yang menunggu
- Sistem akan:
- Update status antrian menjadi "dipanggil"
- Generate audio TTS sequence
- Kirim pesan ke halaman display (jika terbuka)
- Mainkan audio di browser admin sebagai fallback
### 2. Display Page
- Halaman display akan menerima pesan TTS
- Audio sequence akan diputar secara otomatis
- Urutan: Attention Sound → TTS → Attention Sound
## Komponen yang Ditambahkan
### 1. Service
- `app/Services/TTSService.php` - Service untuk mengelola TTS
### 2. Controller
- `app/Http/Controllers/TTSController.php` - Controller untuk endpoint TTS
### 3. Routes
```php
// Admin TTS Routes
Route::post('/admin/tts/generate', [TTSController::class, 'generateQueueCall']);
Route::post('/admin/tts/audio-sequence', [TTSController::class, 'getAudioSequence']);
Route::post('/admin/tts/play-sequence', [TTSController::class, 'playAudioSequence']);
// Public TTS Routes
Route::post('/tts/play-sequence', [TTSController::class, 'playAudioSequence']);
```
### 4. JavaScript
- TTS Audio Player class di halaman display
- Browser TTS fallback di halaman admin
## Konfigurasi
### 1. Google TTS API (Opsional)
Untuk kualitas TTS yang lebih baik, Anda bisa menggunakan Google TTS API:
1. Dapatkan API Key dari Google Cloud Console
2. Tambahkan ke file `.env`:
```env
GOOGLE_TTS_API_KEY=your_api_key_here
```
### 2. Browser TTS (Default)
Jika Google TTS API tidak tersedia, sistem akan menggunakan browser's built-in Speech Synthesis API.
## Testing
### 1. Tanpa Google TTS API
1. Biarkan `GOOGLE_TTS_API_KEY` kosong di `.env`
2. Sistem akan menggunakan browser TTS
3. Buka halaman admin dan klik "Panggil"
4. Audio akan diputar di browser
### 2. Dengan Google TTS API
1. Set `GOOGLE_TTS_API_KEY` di `.env`
2. Sistem akan generate file audio MP3
3. File disimpan di `public/storage/audio/queue_calls/`
## File Audio
### 1. Attention Sound
- Lokasi: `public/assets/music/call-to-attention-123107.mp3`
- Digunakan di awal dan akhir sequence
### 2. TTS Audio Files
- Lokasi: `public/storage/audio/queue_calls/`
- Format: `queue_call_{number}_{poli}_{timestamp}.mp3`
- Dibuat otomatis saat menggunakan Google TTS API
## Troubleshooting
### 1. Audio tidak diputar
- Periksa console browser untuk error
- Pastikan file attention sound ada
- Periksa permission folder storage
### 2. TTS tidak berfungsi
- Pastikan browser mendukung Speech Synthesis
- Periksa CSRF token untuk request AJAX
- Coba refresh halaman
### 3. Google TTS API error
- Periksa API key valid
- Pastikan Cloud Text-to-Speech API aktif
- Cek quota dan billing
## Fitur Tambahan
### 1. Audio Queue
- Multiple panggilan akan di-queue
- Tidak ada overlap audio
### 2. Cross-page Communication
- Admin page bisa kirim pesan ke display page
- Menggunakan `postMessage` API
### 3. Fallback System
- Google TTS → Browser TTS → Silent
- Memastikan sistem tetap berfungsi
## Keamanan
### 1. CSRF Protection
- Semua request TTS dilindungi CSRF token
- Validasi input untuk mencegah injection
### 2. File Storage
- Audio files disimpan di public storage
- Nama file menggunakan timestamp untuk uniqueness
## Performance
### 1. Audio Caching
- Browser akan cache audio files
- Mengurangi bandwidth usage
### 2. Async Processing
- TTS generation tidak blocking
- Audio playback asynchronous
## Monitoring
### 1. Console Logs
- Error dan warning di console browser
- Debug info untuk troubleshooting
### 2. Network Tab
- Monitor request ke TTS endpoints
- Check audio file downloads
## Future Enhancements
### 1. Multiple Languages
- Support untuk bahasa lain
- Configurable voice settings
### 2. Custom Audio
- Upload custom attention sounds
- Per-poli audio customization
### 3. Volume Control
- User-adjustable volume
- Per-device audio settings
### 4. Audio Analytics
- Track audio playback success
- Monitor TTS usage statistics

View File

@ -1,166 +0,0 @@
# TTS Voice Configuration untuk Bahasa Indonesia
## Google Cloud Text-to-Speech API
### Suara Indonesia yang Tersedia
Google TTS API menyediakan beberapa opsi suara untuk bahasa Indonesia:
#### 1. **Wavenet Voices (Lebih Natural)**
- `id-ID-Wavenet-A` - Suara wanita Indonesia yang natural dan fasih ⭐ **DIGUNAKAN SAAT INI**
- `id-ID-Wavenet-B` - Suara pria Indonesia yang natural
- `id-ID-Wavenet-C` - Suara wanita Indonesia alternatif
- `id-ID-Wavenet-D` - Suara pria Indonesia alternatif
#### 2. **Standard Voices (Lebih Cepat)**
- `id-ID-Standard-A` - Suara wanita Indonesia standar
- `id-ID-Standard-B` - Suara pria Indonesia standar
- `id-ID-Standard-C` - Suara wanita Indonesia alternatif
- `id-ID-Standard-D` - Suara pria Indonesia alternatif
### Konfigurasi Saat Ini
```php
'voice' => [
'languageCode' => 'id-ID',
'name' => 'id-ID-Wavenet-A', // Suara wanita Indonesia yang natural
'ssmlGender' => 'FEMALE'
],
'audioConfig' => [
'audioEncoding' => 'MP3',
'speakingRate' => 0.85, // Sedikit lebih cepat untuk alur yang natural
'pitch' => 0,
'volumeGainDb' => 0
]
```
### Keunggulan Wavenet vs Standard
**Wavenet Voices:**
- ✅ Suara lebih natural dan manusiawi
- ✅ Intonasi yang lebih baik
- ✅ Pengucapan yang lebih akurat
- ⚠️ Lebih lambat dalam generate
- ⚠️ Lebih mahal (2x lipat)
**Standard Voices:**
- ✅ Lebih cepat dalam generate
- ✅ Lebih murah
- ⚠️ Suara lebih robotik
- ⚠️ Intonasi kurang natural
## Browser Speech Synthesis API
### Konfigurasi Saat Ini
```javascript
utterance.lang = 'id-ID';
utterance.rate = 0.85; // Sedikit lebih cepat untuk alur yang natural
utterance.volume = 1.0;
// Mencoba memilih suara wanita Indonesia jika tersedia
const voices = speechSynthesis.getVoices();
const indonesianVoice = voices.find(voice =>
voice.lang === 'id-ID' &&
voice.name.toLowerCase().includes('female')
) || voices.find(voice => voice.lang === 'id-ID');
if (indonesianVoice) {
utterance.voice = indonesianVoice;
}
```
### Suara Browser yang Tersedia
Browser TTS bergantung pada sistem operasi:
**Windows:**
- Microsoft Zira Desktop (English, tapi bisa digunakan)
- Microsoft David Desktop (English, tapi bisa digunakan)
**macOS:**
- Siri (Female)
- Tom (Male)
**Linux:**
- Festival voices
- eSpeak voices
## Cara Mengubah Suara
### 1. Mengubah Google TTS Voice
Edit file `app/Services/TTSService.php`:
```php
'voice' => [
'languageCode' => 'id-ID',
'name' => 'id-ID-Wavenet-B', // Ganti dengan suara yang diinginkan
'ssmlGender' => 'FEMALE'
],
```
### 2. Mengubah Browser TTS Voice
Edit file `resources/views/display/index.blade.php` dan `resources/views/admin/poli/index.blade.php`:
```javascript
// Untuk memilih suara tertentu
const voices = speechSynthesis.getVoices();
const specificVoice = voices.find(voice => voice.name === 'Nama Suara Spesifik');
if (specificVoice) {
utterance.voice = specificVoice;
}
```
## Testing Suara
### 1. Test Google TTS
```bash
# Pastikan API key sudah diset
echo "GOOGLE_TTS_API_KEY=your_api_key_here" >> .env
# Test via artisan command (buat sendiri)
php artisan tts:test "Nomor antrian 001, silakan menuju ke Poli Umum"
```
### 2. Test Browser TTS
Buka browser console dan jalankan:
```javascript
// Test browser TTS
const utterance = new SpeechSynthesisUtterance("Nomor antrian 001, silakan menuju ke Poli Umum");
utterance.lang = 'id-ID';
utterance.rate = 0.85;
speechSynthesis.speak(utterance);
// Lihat suara yang tersedia
speechSynthesis.getVoices().forEach(voice => {
console.log(`${voice.name} - ${voice.lang}`);
});
```
## Rekomendasi
1. **Untuk Produksi:** Gunakan `id-ID-Wavenet-A` (suara wanita natural)
2. **Untuk Testing:** Gunakan `id-ID-Standard-A` (lebih cepat dan murah)
3. **Fallback:** Browser TTS dengan konfigurasi yang sudah dioptimalkan
## Troubleshooting
### Suara Google TTS Tidak Berfungsi
1. Periksa API key di `.env`
2. Pastikan billing Google Cloud aktif
3. Periksa quota API
### Suara Browser TTS Tidak Berfungsi
1. Periksa browser support untuk `speechSynthesis`
2. Pastikan sistem operasi memiliki TTS engine
3. Coba browser berbeda (Chrome, Firefox, Safari)
### Suara Terdengar Robotik
1. Gunakan Wavenet voices untuk Google TTS
2. Sesuaikan `speakingRate` (0.8 - 1.0)
3. Pastikan teks menggunakan bahasa Indonesia yang benar

View File

@ -1,63 +0,0 @@
# Konfigurasi TTS (Text-to-Speech)
## Setup Google Text-to-Speech API
Untuk menggunakan fitur TTS, Anda perlu mengatur Google Text-to-Speech API:
### 1. Dapatkan API Key
1. Kunjungi [Google Cloud Console](https://console.cloud.google.com/)
2. Buat project baru atau pilih project yang ada
3. Aktifkan Cloud Text-to-Speech API
4. Buat credentials (API Key)
5. Salin API Key
### 2. Tambahkan ke .env
Tambahkan baris berikut ke file `.env`:
```env
GOOGLE_TTS_API_KEY=your_google_tts_api_key_here
```
### 3. Fitur TTS
Fitur TTS akan memainkan urutan audio berikut:
1. **Attention Sound** - `call-to-attention-123107.mp3`
2. **TTS Poli** - "Nomor antrian X, silakan menuju ke [nama poli]"
3. **TTS Nomor** - "Nomor antrian X"
4. **Attention Sound** - `call-to-attention-123107.mp3`
### 4. Fallback TTS
Jika Google TTS API tidak tersedia, sistem akan menggunakan:
- Browser's built-in Speech Synthesis API
- File audio attention sound yang sudah ada
### 5. Cara Kerja
1. Admin klik button "Panggil" di halaman admin
2. Sistem mengupdate status antrian menjadi "dipanggil"
3. Sistem generate audio TTS sequence
4. Audio diputar di halaman display
5. Jika display page tidak terbuka, audio diputar di browser admin
### 6. File Audio
File audio TTS akan disimpan di:
```
public/storage/audio/queue_calls/
```
### 7. Testing
Untuk testing tanpa Google TTS API:
1. Biarkan `GOOGLE_TTS_API_KEY` kosong di .env
2. Sistem akan menggunakan browser TTS sebagai fallback
3. Audio attention sound tetap akan diputar
### 8. Troubleshooting
Jika TTS tidak berfungsi:
1. Periksa console browser untuk error
2. Pastikan file audio attention sound ada di `public/assets/music/`
3. Periksa permission folder `public/storage/audio/queue_calls/`
4. Pastikan CSRF token valid untuk request AJAX

View File

@ -0,0 +1,51 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use App\Models\Antrian;
class AntrianDipanggil implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public $antrian;
/**
* Create a new event instance.
*/
public function __construct(Antrian $antrian)
{
$this->antrian = $antrian;
}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
new Channel('antrian-display'),
];
}
/**
* Get the data to broadcast.
*/
public function broadcastWith(): array
{
return [
'poli_name' => $this->antrian->poli->nama_poli,
'queue_number' => $this->antrian->no_antrian,
'antrian_id' => $this->antrian->id
];
}
}

View File

@ -86,6 +86,45 @@ public function showUser(User $user)
return view('admin.users.show', compact('user'));
}
public function createUser()
{
return view('admin.users.create');
}
public function storeUser(Request $request)
{
$request->validate([
'nama' => 'required|string|max:255',
'alamat' => 'required|string',
'jenis_kelamin' => 'required|in:laki-laki,perempuan',
'no_hp' => 'required|string|max:20',
'no_ktp' => 'required|string|size:16|unique:users|regex:/^[0-9]+$/',
'pekerjaan' => 'required|string|max:100',
'password' => 'required|string|min:8|confirmed',
], [
'no_ktp.size' => 'Nomor KTP harus tepat 16 digit.',
'no_ktp.regex' => 'Nomor KTP hanya boleh berisi angka.',
'no_ktp.unique' => 'Nomor KTP sudah terdaftar.',
'password.confirmed' => 'Konfirmasi password tidak cocok.',
]);
try {
$user = User::create([
'nama' => $request->nama,
'alamat' => $request->alamat,
'jenis_kelamin' => $request->jenis_kelamin,
'no_hp' => $request->no_hp,
'no_ktp' => $request->no_ktp,
'pekerjaan' => $request->pekerjaan,
'password' => Hash::make($request->password),
]);
return redirect()->route('admin.users.index')->with('success', 'User berhasil ditambahkan!');
} catch (\Exception $e) {
return back()->withErrors(['error' => 'Terjadi kesalahan saat menambahkan user.'])->withInput();
}
}
public function updateUser(Request $request, User $user)
{
$request->validate([
@ -363,8 +402,11 @@ public function panggilAntrian(Request $request)
]);
}
// Update status to 'dipanggil'
$antrian->update(['status' => 'dipanggil']);
// Update status to 'dipanggil' and set call time
$antrian->update([
'status' => 'dipanggil',
'waktu_panggil' => now()
]);
// Record call history
RiwayatPanggilan::create([
@ -394,8 +436,11 @@ public function panggilAntrianById(Antrian $antrian)
]);
}
// Update status to 'dipanggil'
$antrian->update(['status' => 'dipanggil']);
// Update status to 'dipanggil' and set call time
$antrian->update([
'status' => 'dipanggil',
'waktu_panggil' => now()
]);
// Record call history
RiwayatPanggilan::create([
@ -403,17 +448,12 @@ public function panggilAntrianById(Antrian $antrian)
'waktu_panggilan' => now()
]);
// Generate TTS audio sequence
$ttsService = new TTSService();
$audioSequence = $ttsService->createCompleteAudioSequence(
$antrian->poli->nama_poli,
$antrian->no_antrian
);
// Broadcast event for display page
event(new \App\Events\AntrianDipanggil($antrian));
return response()->json([
'success' => true,
'message' => 'Antrian ' . $antrian->no_antrian . ' dipanggil',
'audio_sequence' => $audioSequence,
'poli_name' => $antrian->poli->nama_poli,
'queue_number' => $antrian->no_antrian
]);

View File

@ -33,6 +33,8 @@ public function addQueue(Request $request)
DB::beginTransaction();
$user = Auth::user();
$poli = Poli::find($request->poli_id);
$poliName = $poli->nama_poli;
// Check if user already has a queue today for the same poli
$existingQueue = Antrian::where('user_id', $user->id)
@ -42,13 +44,31 @@ public function addQueue(Request $request)
->first();
if ($existingQueue) {
// Get poli name for error message
$poliName = Poli::find($request->poli_id)->nama_poli;
// Check if request wants JSON response
if ($request->expectsJson()) {
return response()->json([
'success' => false,
'type' => 'existing_queue',
'message' => 'Anda sudah memiliki antrian di ' . $poliName . ' hari ini.',
'existing_queue' => [
'id' => $existingQueue->id,
'no_antrian' => $existingQueue->no_antrian,
'status' => $existingQueue->status,
'created_at' => $existingQueue->created_at->format('H:i'),
'poli_name' => $poliName
]
]);
}
// Fallback to redirect for non-AJAX requests
return redirect()->back()->with('error', 'Anda sudah memiliki antrian di ' . $poliName . ' hari ini.');
}
// Note: User CAN have multiple queues in different polis
// We only prevent duplicate queues in the same poli (checked above)
// Multi-queue cross-poli is allowed and encouraged
// Get poli info for prefix
$poli = Poli::find($request->poli_id);
$prefix = $this->getPoliPrefix($poli->nama_poli);
// Get next queue number for the poli
@ -76,12 +96,31 @@ public function addQueue(Request $request)
DB::commit();
// Get poli name for success message
$poliName = $poli->nama_poli;
if ($request->expectsJson()) {
return response()->json([
'success' => true,
'message' => 'Antrian berhasil diambil! Nomor antrian Anda di ' . $poliName . ': ' . $nextQueueNumber,
'antrian' => [
'id' => $antrian->id,
'no_antrian' => $nextQueueNumber,
'poli_name' => $poliName,
'status' => 'menunggu'
]
]);
}
return redirect()->back()->with('success', 'Antrian berhasil diambil! Nomor antrian Anda di ' . $poliName . ': ' . $nextQueueNumber);
} catch (\Exception $e) {
DB::rollback();
if ($request->expectsJson()) {
return response()->json([
'success' => false,
'message' => 'Terjadi kesalahan saat mengambil antrian: ' . $e->getMessage()
], 500);
}
return redirect()->back()->with('error', 'Terjadi kesalahan saat mengambil antrian: ' . $e->getMessage());
}
}

View File

@ -75,4 +75,100 @@ public function index()
'poliTradisionalNext'
));
}
public function checkNewCalls(Request $request)
{
$lastCheck = $request->get('last_check', 0);
$lastCheckTime = date('Y-m-d H:i:s', $lastCheck / 1000);
// Check for new calls since last check
$newCall = \App\Models\RiwayatPanggilan::with(['antrian.poli'])
->where('waktu_panggilan', '>', $lastCheckTime)
->whereDate('waktu_panggilan', today())
->orderBy('waktu_panggilan', 'desc')
->first();
if ($newCall) {
return response()->json([
'has_new_call' => true,
'antrian' => [
'poli_name' => $newCall->antrian->poli->nama_poli,
'queue_number' => $newCall->antrian->no_antrian,
'antrian_id' => $newCall->antrian->id
]
]);
}
return response()->json([
'has_new_call' => false
]);
}
public function getDisplayData()
{
// Current: sedang dipanggil per poli
$poliUmumCurrent = Antrian::where('poli_id', 1)
->where('status', 'dipanggil')
->whereDate('created_at', today())
->orderByDesc('updated_at')
->first();
$poliGigiCurrent = Antrian::where('poli_id', 2)
->where('status', 'dipanggil')
->whereDate('created_at', today())
->orderByDesc('updated_at')
->first();
$poliJiwaCurrent = Antrian::where('poli_id', 3)
->where('status', 'dipanggil')
->whereDate('created_at', today())
->orderByDesc('updated_at')
->first();
$poliTradisionalCurrent = Antrian::where('poli_id', 4)
->where('status', 'dipanggil')
->whereDate('created_at', today())
->orderByDesc('updated_at')
->first();
// Next: menunggu per poli (maks 3)
$poliUmumNext = Antrian::where('poli_id', 1)
->where('status', 'menunggu')
->whereDate('created_at', today())
->orderBy('created_at', 'asc')
->take(3)
->get();
$poliGigiNext = Antrian::where('poli_id', 2)
->where('status', 'menunggu')
->whereDate('created_at', today())
->orderBy('created_at', 'asc')
->take(3)
->get();
$poliJiwaNext = Antrian::where('poli_id', 3)
->where('status', 'menunggu')
->whereDate('created_at', today())
->orderBy('created_at', 'asc')
->take(3)
->get();
$poliTradisionalNext = Antrian::where('poli_id', 4)
->where('status', 'menunggu')
->whereDate('created_at', today())
->orderBy('created_at', 'asc')
->take(3)
->get();
return response()->json([
'poliUmumCurrent' => $poliUmumCurrent,
'poliGigiCurrent' => $poliGigiCurrent,
'poliJiwaCurrent' => $poliJiwaCurrent,
'poliTradisionalCurrent' => $poliTradisionalCurrent,
'poliUmumNext' => $poliUmumNext,
'poliGigiNext' => $poliGigiNext,
'poliJiwaNext' => $poliJiwaNext,
'poliTradisionalNext' => $poliTradisionalNext
]);
}
}

View File

@ -0,0 +1,154 @@
<?php
namespace App\Http\Controllers;
use App\Services\IndonesianTTSService;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\View\View;
class IndonesianTTSController extends Controller
{
private $indonesianTTSService;
public function __construct()
{
$this->indonesianTTSService = new IndonesianTTSService();
}
/**
* Show Indonesian TTS settings page
*/
public function index(): View
{
return view('admin.indonesian-tts.index');
}
/**
* Generate TTS audio for queue call
*/
public function generateQueueCall(Request $request): JsonResponse
{
$request->validate([
'poli_name' => 'required|string',
'queue_number' => 'required|string'
]);
$poliName = $request->input('poli_name');
$queueNumber = $request->input('queue_number');
$result = $this->indonesianTTSService->generateQueueCall($poliName, $queueNumber);
return response()->json($result);
}
/**
* Create complete audio sequence for queue call
*/
public function createAudioSequence(Request $request): JsonResponse
{
$request->validate([
'poli_name' => 'required|string',
'queue_number' => 'required|string'
]);
$poliName = $request->input('poli_name');
$queueNumber = $request->input('queue_number');
$audioSequence = $this->indonesianTTSService->createCompleteAudioSequence($poliName, $queueNumber);
return response()->json([
'success' => true,
'audio_sequence' => $audioSequence,
'poli_name' => $poliName,
'queue_number' => $queueNumber
]);
}
/**
* Check Indonesian TTS status
*/
public function checkStatus(): JsonResponse
{
$status = [
'indonesian_tts_available' => $this->indonesianTTSService->isIndonesianTTSAvailable(),
'coqui_tts_installed' => $this->indonesianTTSService->isCoquiTTSInstalled(),
'model_files_exist' => file_exists(storage_path('app/tts/models/checkpoint.pth')) &&
file_exists(storage_path('app/tts/models/config.json')),
'available_speakers' => $this->indonesianTTSService->getAvailableSpeakers(),
'installation_instructions' => $this->indonesianTTSService->installIndonesianTTS()
];
return response()->json($status);
}
/**
* Test Indonesian TTS with sample text
*/
public function testTTS(Request $request): JsonResponse
{
$request->validate([
'text' => 'required|string|max:500'
]);
$text = $request->input('text');
try {
$result = $this->indonesianTTSService->generateWithIndonesianTTS($text, 'test', 'Test');
return response()->json([
'success' => true,
'message' => 'TTS test berhasil',
'audio_url' => $result['audio_url'] ?? null,
'tts_type' => $result['tts_type'] ?? 'fallback'
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'TTS test gagal: ' . $e->getMessage()
]);
}
}
/**
* Get installation instructions
*/
public function getInstallationInstructions(): JsonResponse
{
$instructions = $this->indonesianTTSService->installIndonesianTTS();
return response()->json([
'success' => true,
'instructions' => $instructions
]);
}
/**
* Download Indonesian TTS model files
*/
public function downloadModelFiles(): JsonResponse
{
$modelUrls = [
'checkpoint.pth' => 'https://github.com/Wikidepia/indonesian-tts/releases/download/v1.2/checkpoint.pth',
'config.json' => 'https://github.com/Wikidepia/indonesian-tts/releases/download/v1.2/config.json'
];
$downloadInfo = [
'title' => 'Download Indonesian TTS Model Files',
'description' => 'Download file model yang diperlukan untuk Indonesian TTS',
'files' => $modelUrls,
'manual_steps' => [
'1. Kunjungi: https://github.com/Wikidepia/indonesian-tts/releases',
'2. Download file checkpoint.pth dan config.json',
'3. Buat folder: ' . storage_path('app/tts/models/'),
'4. Simpan file di folder tersebut',
'5. Pastikan permission file dapat dibaca oleh web server'
]
];
return response()->json([
'success' => true,
'download_info' => $downloadInfo
]);
}
}

View File

@ -74,22 +74,21 @@ public function getAudioSequence(Request $request)
public function playAudioSequence(Request $request)
{
$request->validate([
'antrian_id' => 'required|exists:antrians,id'
'poli_name' => 'required|string',
'queue_number' => 'required|string'
]);
try {
$antrian = Antrian::with('poli')->findOrFail($request->antrian_id);
$audioSequence = $this->ttsService->createCompleteAudioSequence(
$antrian->poli->nama_poli,
$antrian->no_antrian
$request->poli_name,
$request->queue_number
);
return response()->json([
'success' => true,
'audio_sequence' => $audioSequence,
'poli_name' => $antrian->poli->nama_poli,
'queue_number' => $antrian->no_antrian
'poli_name' => $request->poli_name,
'queue_number' => $request->queue_number
]);
} catch (\Exception $e) {
return response()->json([

View File

@ -46,4 +46,22 @@ public function riwayatPanggilan()
{
return $this->hasMany(RiwayatPanggilan::class);
}
public function getWaktuPanggilAttribute($value)
{
// If waktu_panggil is not set but we have call history, get it from there
if (!$value && $this->status === 'dipanggil') {
$latestCall = $this->riwayatPanggilan()->latest('waktu_panggilan')->first();
if ($latestCall) {
return $latestCall->waktu_panggilan;
}
}
// Ensure we return a Carbon instance for proper formatting
if ($value) {
return \Carbon\Carbon::parse($value);
}
return null;
}
}

View File

@ -0,0 +1,385 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Process;
class IndonesianTTSService
{
private $modelPath;
private $configPath;
private $g2pPath;
private $outputPath;
public function __construct()
{
// Path untuk model dan konfigurasi Indonesian TTS
$this->modelPath = storage_path('app/tts/models/checkpoint.pth');
$this->configPath = storage_path('app/tts/models/config.json');
$this->g2pPath = storage_path('app/tts/g2p-id');
$this->outputPath = storage_path('app/public/audio/queue_calls');
// Buat direktori jika belum ada
if (!file_exists($this->outputPath)) {
mkdir($this->outputPath, 0755, true);
}
}
/**
* Generate TTS audio using Indonesian TTS model
*/
public function generateQueueCall($poliName, $queueNumber)
{
try {
// Convert queue number to Indonesian pronunciation
$indonesianQueueNumber = $this->convertQueueNumberToIndonesian($queueNumber);
// Create the text to be spoken
$text = "Nomor antrian {$indonesianQueueNumber}, silakan menuju ke {$poliName}";
// Check if Indonesian TTS model is available
if ($this->isIndonesianTTSAvailable()) {
return $this->generateWithIndonesianTTS($text, $queueNumber, $poliName);
} else {
// Fallback to Google TTS or browser TTS
return $this->generateWithFallbackTTS($text);
}
} catch (\Exception $e) {
return [
'success' => false,
'message' => 'Error generating Indonesian TTS: ' . $e->getMessage()
];
}
}
/**
* Generate TTS using Indonesian TTS model
*/
public function generateWithIndonesianTTS($text, $queueNumber, $poliName)
{
try {
// Generate filename
$filename = "indonesian_tts_{$queueNumber}_{$poliName}_" . time() . ".wav";
$filepath = $this->outputPath . '/' . $filename;
// Prepare text for TTS (convert to phonemes if g2p-id is available)
$phonemeText = $this->convertToPhonemes($text);
// Run Coqui TTS command
$command = $this->buildTTSCmd($phonemeText, $filepath);
$result = Process::run($command);
if ($result->successful()) {
return [
'success' => true,
'audio_url' => asset('storage/audio/queue_calls/' . $filename),
'filename' => $filename,
'tts_type' => 'indonesian_tts'
];
} else {
throw new \Exception('TTS generation failed: ' . $result->errorOutput());
}
} catch (\Exception $e) {
// Fallback to other TTS methods
return $this->generateWithFallbackTTS($text);
}
}
/**
* Build TTS command for Coqui TTS
*/
private function buildTTSCmd($text, $outputPath)
{
$speaker = 'wibowo'; // Default speaker, can be made configurable
return [
'tts',
'--text',
$text,
'--model_path',
$this->modelPath,
'--config_path',
$this->configPath,
'--speaker_idx',
$speaker,
'--out_path',
$outputPath
];
}
/**
* Convert text to phonemes using g2p-id
*/
private function convertToPhonemes($text)
{
// If g2p-id is available, use it to convert to phonemes
if (file_exists($this->g2pPath)) {
try {
$result = Process::run([$this->g2pPath, $text]);
if ($result->successful()) {
return trim($result->output());
}
} catch (\Exception $e) {
// If g2p conversion fails, return original text
return $text;
}
}
// Return original text if g2p-id is not available
return $text;
}
/**
* Check if Indonesian TTS model is available
*/
public function isIndonesianTTSAvailable()
{
return file_exists($this->modelPath) &&
file_exists($this->configPath) &&
$this->isCoquiTTSInstalled();
}
/**
* Check if Coqui TTS is installed
*/
public function isCoquiTTSInstalled()
{
try {
$result = Process::run(['tts', '--version']);
return $result->successful();
} catch (\Exception $e) {
return false;
}
}
/**
* Fallback to Google TTS or browser TTS
*/
private function generateWithFallbackTTS($text)
{
// Use existing TTSService as fallback
$ttsService = new TTSService();
// Extract poli name and queue number from text for fallback
preg_match('/Nomor antrian (.+), silakan menuju ke ruang (.+)/', $text, $matches);
$queueNumber = $matches[1] ?? '';
$poliName = $matches[2] ?? '';
return $ttsService->generateQueueCall($poliName, $queueNumber);
}
/**
* Convert alphanumeric queue number to Indonesian pronunciation
* Example: "U5" becomes "U Lima", "A10" becomes "A Sepuluh"
*/
private function convertQueueNumberToIndonesian($queueNumber)
{
// Indonesian number words
$indonesianNumbers = [
'0' => 'Nol',
'1' => 'Satu',
'2' => 'Dua',
'3' => 'Tiga',
'4' => 'Empat',
'5' => 'Lima',
'6' => 'Enam',
'7' => 'Tujuh',
'8' => 'Delapan',
'9' => 'Sembilan',
'10' => 'Sepuluh',
'11' => 'Sebelas',
'12' => 'Dua Belas',
'13' => 'Tiga Belas',
'14' => 'Empat Belas',
'15' => 'Lima Belas',
'16' => 'Enam Belas',
'17' => 'Tujuh Belas',
'18' => 'Delapan Belas',
'19' => 'Sembilan Belas',
'20' => 'Dua Puluh',
'30' => 'Tiga Puluh',
'40' => 'Empat Puluh',
'50' => 'Lima Puluh',
'60' => 'Enam Puluh',
'70' => 'Tujuh Puluh',
'80' => 'Delapan Puluh',
'90' => 'Sembilan Puluh',
'100' => 'Seratus'
];
// If it's a pure number, convert it
if (is_numeric($queueNumber)) {
$number = (int) $queueNumber;
if (isset($indonesianNumbers[$number])) {
return $indonesianNumbers[$number];
} else {
// For numbers > 100, build the pronunciation
if ($number < 100) {
$tens = floor($number / 10) * 10;
$ones = $number % 10;
if ($ones == 0) {
return $indonesianNumbers[$tens];
} else {
return $indonesianNumbers[$tens] . ' ' . $indonesianNumbers[$ones];
}
} else {
return $number; // Fallback for large numbers
}
}
}
// For alphanumeric (like "U5", "A10"), convert the numeric part
$letters = '';
$numbers = '';
// Split into letters and numbers
for ($i = 0; $i < strlen($queueNumber); $i++) {
$char = $queueNumber[$i];
if (is_numeric($char)) {
$numbers .= $char;
} else {
$letters .= $char;
}
}
// If we have both letters and numbers
if ($letters && $numbers) {
$numberValue = (int) $numbers;
if (isset($indonesianNumbers[$numberValue])) {
return $letters . ' ' . $indonesianNumbers[$numberValue];
} else {
// For numbers > 100, build the pronunciation
if ($numberValue < 100) {
$tens = floor($numberValue / 10) * 10;
$ones = $numberValue % 10;
if ($ones == 0) {
return $letters . ' ' . $indonesianNumbers[$tens];
} else {
return $letters . ' ' . $indonesianNumbers[$tens] . ' ' . $indonesianNumbers[$ones];
}
} else {
return $queueNumber; // Fallback for large numbers
}
}
}
// If no conversion needed, return as is
return $queueNumber;
}
/**
* Create complete audio sequence for queue call
*/
public function createCompleteAudioSequence($poliName, $queueNumber)
{
$audioFiles = [];
// 1. Attention sound (4 seconds - actual file duration)
$attentionSound = asset('assets/music/call-to-attention-123107.mp3');
$audioFiles[] = [
'type' => 'attention',
'url' => $attentionSound,
'duration' => 4000 // 4 seconds - actual file duration
];
// 2. TTS for poli name and number (no final attention sound)
$ttsResult = $this->generateQueueCall($poliName, $queueNumber);
if ($ttsResult['success']) {
if (isset($ttsResult['use_browser_tts']) && $ttsResult['use_browser_tts']) {
// Use browser TTS
$audioFiles[] = [
'type' => 'browser_tts',
'text' => $ttsResult['text'],
'duration' => 8000 // 8 seconds - longer for natural speech
];
} else {
// Use generated audio file
$audioFiles[] = [
'type' => 'tts',
'url' => $ttsResult['audio_url'],
'duration' => 8000 // 8 seconds - longer for natural speech
];
}
}
return $audioFiles;
}
/**
* Get available speakers from Indonesian TTS model
*/
public function getAvailableSpeakers()
{
if (!$this->isIndonesianTTSAvailable()) {
return [];
}
try {
$result = Process::run([
'tts',
'--model_path',
$this->modelPath,
'--config_path',
$this->configPath,
'--list_speaker_idxs'
]);
if ($result->successful()) {
$output = $result->output();
// Parse speaker list from output
$speakers = [];
$lines = explode("\n", $output);
foreach ($lines as $line) {
if (preg_match('/^(\d+):\s*(.+)$/', $line, $matches)) {
$speakers[$matches[1]] = trim($matches[2]);
}
}
return $speakers;
}
} catch (\Exception $e) {
// Return default speakers if listing fails
return [
'wibowo' => 'Wibowo (Male)',
'ardi' => 'Ardi (Male)',
'gadis' => 'Gadis (Female)'
];
}
return [];
}
/**
* Install Indonesian TTS model
*/
public function installIndonesianTTS()
{
$instructions = [
'title' => 'Instalasi Indonesian TTS',
'steps' => [
'1. Install Coqui TTS:',
' pip install TTS',
'',
'2. Download model dari GitHub:',
' - Kunjungi: https://github.com/Wikidepia/indonesian-tts/releases',
' - Download file checkpoint.pth dan config.json',
' - Simpan di: ' . storage_path('app/tts/models/'),
'',
'3. Install g2p-id (opsional):',
' pip install g2p-id',
'',
'4. Test instalasi:',
' tts --version',
'',
'5. Test model:',
' tts --text "Halo dunia" --model_path ' . $this->modelPath . ' --config_path ' . $this->configPath . ' --out_path test.wav'
]
];
return $instructions;
}
}

View File

@ -15,14 +15,116 @@ public function __construct()
$this->apiKey = config('services.google.tts_api_key');
}
/**
* Convert alphanumeric queue number to Indonesian pronunciation
* Example: "U5" becomes "U Lima", "A10" becomes "A Sepuluh"
*/
private function convertQueueNumberToIndonesian($queueNumber)
{
// Indonesian number words
$indonesianNumbers = [
'0' => 'Nol',
'1' => 'Satu',
'2' => 'Dua',
'3' => 'Tiga',
'4' => 'Empat',
'5' => 'Lima',
'6' => 'Enam',
'7' => 'Tujuh',
'8' => 'Delapan',
'9' => 'Sembilan',
'10' => 'Sepuluh',
'11' => 'Sebelas',
'12' => 'Dua Belas',
'13' => 'Tiga Belas',
'14' => 'Empat Belas',
'15' => 'Lima Belas',
'16' => 'Enam Belas',
'17' => 'Tujuh Belas',
'18' => 'Delapan Belas',
'19' => 'Sembilan Belas',
'20' => 'Dua Puluh',
'30' => 'Tiga Puluh',
'40' => 'Empat Puluh',
'50' => 'Lima Puluh',
'60' => 'Enam Puluh',
'70' => 'Tujuh Puluh',
'80' => 'Delapan Puluh',
'90' => 'Sembilan Puluh',
'100' => 'Seratus'
];
// If it's a pure number, convert it
if (is_numeric($queueNumber)) {
$number = (int) $queueNumber;
if (isset($indonesianNumbers[$number])) {
return $indonesianNumbers[$number];
} else {
// For numbers > 100, build the pronunciation
if ($number < 100) {
$tens = floor($number / 10) * 10;
$ones = $number % 10;
if ($ones == 0) {
return $indonesianNumbers[$tens];
} else {
return $indonesianNumbers[$tens] . ' ' . $indonesianNumbers[$ones];
}
} else {
return $number; // Fallback for large numbers
}
}
}
// For alphanumeric (like "U5", "A10"), convert the numeric part
$letters = '';
$numbers = '';
// Split into letters and numbers
for ($i = 0; $i < strlen($queueNumber); $i++) {
$char = $queueNumber[$i];
if (is_numeric($char)) {
$numbers .= $char;
} else {
$letters .= $char;
}
}
// If we have both letters and numbers
if ($letters && $numbers) {
$numberValue = (int) $numbers;
if (isset($indonesianNumbers[$numberValue])) {
return $letters . ' ' . $indonesianNumbers[$numberValue];
} else {
// For numbers > 100, build the pronunciation
if ($numberValue < 100) {
$tens = floor($numberValue / 10) * 10;
$ones = $numberValue % 10;
if ($ones == 0) {
return $letters . ' ' . $indonesianNumbers[$tens];
} else {
return $letters . ' ' . $indonesianNumbers[$tens] . ' ' . $indonesianNumbers[$ones];
}
} else {
return $queueNumber; // Fallback for large numbers
}
}
}
// If no conversion needed, return as is
return $queueNumber;
}
/**
* Generate TTS audio for queue call
*/
public function generateQueueCall($poliName, $queueNumber)
{
try {
// Convert queue number to Indonesian pronunciation
$indonesianQueueNumber = $this->convertQueueNumberToIndonesian($queueNumber);
// Create the text to be spoken
$text = "Nomor antrian {$queueNumber}, silakan menuju ke {$poliName}";
$text = "antrian selanjutnya poli {$poliName} nomor {$indonesianQueueNumber}";
// Generate TTS audio
$audioContent = $this->synthesizeSpeech($text);
@ -108,15 +210,15 @@ public function createCompleteAudioSequence($poliName, $queueNumber)
{
$audioFiles = [];
// 1. Attention sound
// 1. Attention sound (4 seconds - actual file duration)
$attentionSound = asset('assets/music/call-to-attention-123107.mp3');
$audioFiles[] = [
'type' => 'attention',
'url' => $attentionSound,
'duration' => 2000 // 2 seconds
'duration' => 4000 // 4 seconds - actual file duration
];
// 2. TTS for poli name and number
// 2. TTS for poli name and number (no final attention sound)
$ttsResult = $this->generateQueueCall($poliName, $queueNumber);
if ($ttsResult['success']) {
if ($ttsResult['use_browser_tts']) {
@ -124,25 +226,18 @@ public function createCompleteAudioSequence($poliName, $queueNumber)
$audioFiles[] = [
'type' => 'browser_tts',
'text' => $ttsResult['text'],
'duration' => 4000 // 4 seconds
'duration' => 8000 // 8 seconds - longer for natural speech
];
} else {
// Use generated audio file
$audioFiles[] = [
'type' => 'tts',
'url' => $ttsResult['audio_url'],
'duration' => 4000 // 4 seconds
'duration' => 8000 // 8 seconds - longer for natural speech
];
}
}
// 3. Final attention sound
$audioFiles[] = [
'type' => 'attention',
'url' => $attentionSound,
'duration' => 2000 // 2 seconds
];
return $audioFiles;
}
}

View File

@ -10,7 +10,8 @@
"barryvdh/laravel-dompdf": "^3.1",
"laravel/framework": "^12.0",
"laravel/sanctum": "^4.2",
"laravel/tinker": "^2.10.1"
"laravel/tinker": "^2.10.1",
"pusher/pusher-php-server": "^7.2"
},
"require-dev": {
"fakerphp/faker": "^1.23",

154
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "efff1cd36f67794eb042a3b6c431b4a9",
"content-hash": "6a7319662ab12a24a54850a3e8a7b468",
"packages": [
{
"name": "barryvdh/laravel-dompdf",
@ -2873,6 +2873,97 @@
],
"time": "2025-05-08T08:14:37+00:00"
},
{
"name": "paragonie/sodium_compat",
"version": "v2.1.0",
"source": {
"type": "git",
"url": "https://github.com/paragonie/sodium_compat.git",
"reference": "a673d5f310477027cead2e2f2b6db5d8368157cb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/a673d5f310477027cead2e2f2b6db5d8368157cb",
"reference": "a673d5f310477027cead2e2f2b6db5d8368157cb",
"shasum": ""
},
"require": {
"php": "^8.1",
"php-64bit": "*"
},
"require-dev": {
"phpunit/phpunit": "^7|^8|^9",
"vimeo/psalm": "^4|^5"
},
"suggest": {
"ext-sodium": "Better performance, password hashing (Argon2i), secure memory management (memzero), and better security."
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0.x-dev"
}
},
"autoload": {
"files": [
"autoload.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"ISC"
],
"authors": [
{
"name": "Paragon Initiative Enterprises",
"email": "security@paragonie.com"
},
{
"name": "Frank Denis",
"email": "jedisct1@pureftpd.org"
}
],
"description": "Pure PHP implementation of libsodium; uses the PHP extension if it exists",
"keywords": [
"Authentication",
"BLAKE2b",
"ChaCha20",
"ChaCha20-Poly1305",
"Chapoly",
"Curve25519",
"Ed25519",
"EdDSA",
"Edwards-curve Digital Signature Algorithm",
"Elliptic Curve Diffie-Hellman",
"Poly1305",
"Pure-PHP cryptography",
"RFC 7748",
"RFC 8032",
"Salpoly",
"Salsa20",
"X25519",
"XChaCha20-Poly1305",
"XSalsa20-Poly1305",
"Xchacha20",
"Xsalsa20",
"aead",
"cryptography",
"ecdh",
"elliptic curve",
"elliptic curve cryptography",
"encryption",
"libsodium",
"php",
"public-key cryptography",
"secret-key cryptography",
"side-channel resistant"
],
"support": {
"issues": "https://github.com/paragonie/sodium_compat/issues",
"source": "https://github.com/paragonie/sodium_compat/tree/v2.1.0"
},
"time": "2024-09-04T12:51:01+00:00"
},
{
"name": "phpoption/phpoption",
"version": "1.9.3",
@ -3438,6 +3529,67 @@
},
"time": "2025-08-04T12:39:37+00:00"
},
{
"name": "pusher/pusher-php-server",
"version": "7.2.7",
"source": {
"type": "git",
"url": "https://github.com/pusher/pusher-http-php.git",
"reference": "148b0b5100d000ed57195acdf548a2b1b38ee3f7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/pusher/pusher-http-php/zipball/148b0b5100d000ed57195acdf548a2b1b38ee3f7",
"reference": "148b0b5100d000ed57195acdf548a2b1b38ee3f7",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"guzzlehttp/guzzle": "^7.2",
"paragonie/sodium_compat": "^1.6|^2.0",
"php": "^7.3|^8.0",
"psr/log": "^1.0|^2.0|^3.0"
},
"require-dev": {
"overtrue/phplint": "^2.3",
"phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "5.0-dev"
}
},
"autoload": {
"psr-4": {
"Pusher\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Library for interacting with the Pusher REST API",
"keywords": [
"events",
"messaging",
"php-pusher-server",
"publish",
"push",
"pusher",
"real time",
"real-time",
"realtime",
"rest",
"trigger"
],
"support": {
"issues": "https://github.com/pusher/pusher-http-php/issues",
"source": "https://github.com/pusher/pusher-http-php/tree/7.2.7"
},
"time": "2025-01-06T10:56:20+00:00"
},
{
"name": "ralouphie/getallheaders",
"version": "3.0.3",

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
}
};

237
install_indonesian_tts.sh Normal file
View File

@ -0,0 +1,237 @@
#!/bin/bash
# Indonesian TTS Installation Script
# This script automates the installation of Indonesian TTS for Puskesmas system
set -e
echo "🏥 Indonesian TTS Installation for Puskesmas System"
echo "=================================================="
echo ""
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Function to print colored output
print_status() {
echo -e "${BLUE}[INFO]${NC} $1"
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Check if running as root
if [[ $EUID -eq 0 ]]; then
print_error "This script should not be run as root"
exit 1
fi
# Check if Python is installed
print_status "Checking Python installation..."
if ! command -v python3 &> /dev/null; then
print_error "Python 3 is not installed. Please install Python 3.8+ first."
exit 1
fi
PYTHON_VERSION=$(python3 --version | cut -d' ' -f2)
print_success "Python $PYTHON_VERSION found"
# Check if pip is installed
print_status "Checking pip installation..."
if ! command -v pip3 &> /dev/null; then
print_error "pip3 is not installed. Please install pip first."
exit 1
fi
print_success "pip3 found"
# Install Coqui TTS
print_status "Installing Coqui TTS..."
if pip3 install TTS; then
print_success "Coqui TTS installed successfully"
else
print_error "Failed to install Coqui TTS"
exit 1
fi
# Verify TTS installation
print_status "Verifying TTS installation..."
if tts --version &> /dev/null; then
print_success "TTS command available"
else
print_error "TTS command not found. Installation may have failed."
exit 1
fi
# Create necessary directories
print_status "Creating directories..."
mkdir -p storage/app/tts/models
mkdir -p storage/app/public/audio/queue_calls
print_success "Directories created"
# Download model files
print_status "Downloading Indonesian TTS model files..."
MODEL_URL="https://github.com/Wikidepia/indonesian-tts/releases/download/v1.2"
CHECKPOINT_URL="$MODEL_URL/checkpoint.pth"
CONFIG_URL="$MODEL_URL/config.json"
# Download checkpoint.pth
print_status "Downloading checkpoint.pth..."
if curl -L -o storage/app/tts/models/checkpoint.pth "$CHECKPOINT_URL"; then
print_success "checkpoint.pth downloaded"
else
print_warning "Failed to download checkpoint.pth automatically"
print_status "Please download manually from: $CHECKPOINT_URL"
print_status "And save to: storage/app/tts/models/checkpoint.pth"
fi
# Download config.json
print_status "Downloading config.json..."
if curl -L -o storage/app/tts/models/config.json "$CONFIG_URL"; then
print_success "config.json downloaded"
else
print_warning "Failed to download config.json automatically"
print_status "Please download manually from: $CONFIG_URL"
print_status "And save to: storage/app/tts/models/config.json"
fi
# Install g2p-id (optional)
print_status "Installing g2p-id for better pronunciation..."
if pip3 install g2p-id; then
print_success "g2p-id installed successfully"
else
print_warning "Failed to install g2p-id. This is optional but recommended."
fi
# Set proper permissions
print_status "Setting file permissions..."
chmod -R 755 storage/app/tts/
chmod 644 storage/app/tts/models/* 2>/dev/null || true
print_success "Permissions set"
# Test the installation
print_status "Testing Indonesian TTS installation..."
TEST_TEXT="Halo dunia"
TEST_OUTPUT="test_indonesian_tts.wav"
if tts --text "$TEST_TEXT" \
--model_path storage/app/tts/models/checkpoint.pth \
--config_path storage/app/tts/models/config.json \
--speaker_idx wibowo \
--out_path "$TEST_OUTPUT" 2>/dev/null; then
print_success "Indonesian TTS test successful!"
# Check if audio file was created
if [ -f "$TEST_OUTPUT" ]; then
FILE_SIZE=$(du -h "$TEST_OUTPUT" | cut -f1)
print_success "Test audio file created: $TEST_OUTPUT ($FILE_SIZE)"
# Clean up test file
rm "$TEST_OUTPUT"
print_status "Test file cleaned up"
fi
else
print_warning "Indonesian TTS test failed. Please check the installation manually."
fi
# Create symbolic link for public access
print_status "Creating symbolic link for public access..."
if [ ! -L "public/storage" ]; then
php artisan storage:link
print_success "Storage link created"
else
print_status "Storage link already exists"
fi
# Update .env file
print_status "Updating environment configuration..."
# Check if .env exists
if [ -f ".env" ]; then
# Add Indonesian TTS configuration if not exists
if ! grep -q "INDONESIAN_TTS_ENABLED" .env; then
echo "" >> .env
echo "# Indonesian TTS Configuration" >> .env
echo "INDONESIAN_TTS_ENABLED=true" >> .env
echo "INDONESIAN_TTS_MODEL_PATH=storage/app/tts/models/checkpoint.pth" >> .env
echo "INDONESIAN_TTS_CONFIG_PATH=storage/app/tts/models/config.json" >> .env
echo "INDONESIAN_TTS_DEFAULT_SPEAKER=wibowo" >> .env
print_success "Environment variables added to .env"
else
print_status "Indonesian TTS environment variables already exist"
fi
else
print_warning ".env file not found. Please add Indonesian TTS configuration manually."
fi
# Final status check
print_status "Performing final status check..."
echo ""
echo "📋 Installation Summary:"
echo "========================"
# Check Python
if command -v python3 &> /dev/null; then
echo -e "✅ Python: $(python3 --version)"
else
echo -e "❌ Python: Not found"
fi
# Check TTS
if command -v tts &> /dev/null; then
echo -e "✅ Coqui TTS: $(tts --version 2>/dev/null | head -n1 || echo 'Installed')"
else
echo -e "❌ Coqui TTS: Not found"
fi
# Check model files
if [ -f "storage/app/tts/models/checkpoint.pth" ]; then
echo -e "✅ Model file: checkpoint.pth"
else
echo -e "❌ Model file: checkpoint.pth (missing)"
fi
if [ -f "storage/app/tts/models/config.json" ]; then
echo -e "✅ Config file: config.json"
else
echo -e "❌ Config file: config.json (missing)"
fi
# Check g2p-id
if command -v g2p-id &> /dev/null; then
echo -e "✅ g2p-id: Installed"
else
echo -e "⚠️ g2p-id: Not installed (optional)"
fi
echo ""
echo "🎉 Installation completed!"
echo ""
echo "📖 Next steps:"
echo "1. Access Indonesian TTS settings at: /admin/indonesian-tts"
echo "2. Test the TTS functionality"
echo "3. Configure speakers and preferences"
echo ""
echo "📚 Documentation: README_INDONESIAN_TTS.md"
echo "🐛 Troubleshooting: Check the documentation for common issues"
echo ""
echo "Happy coding! 🚀"

View File

@ -223,7 +223,9 @@ class="flex items-center px-4 py-3 rounded-xl text-gray-700 hover:bg-gray-50 tra
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-900">Antrian Terbaru</h3>
</div>
<div class="overflow-x-auto">
<!-- Desktop Table -->
<div class="hidden lg:block overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
@ -309,6 +311,102 @@ class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-red-100 text-
</tbody>
</table>
</div>
<!-- Mobile Table -->
<div class="lg:hidden">
@forelse($antrianTerbaru ?? [] as $antrian)
<div class="border-b border-gray-200 p-4 hover:bg-gray-50">
<!-- 4 Kolom Penting untuk Mobile -->
<div class="grid grid-cols-2 gap-4 mb-3">
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">No.
Antrian</p>
<span
class="text-lg font-semibold text-primary">{{ $antrian->no_antrian ?? 'N/A' }}</span>
</div>
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">Nama</p>
<div class="text-sm font-medium text-gray-900">
{{ $antrian->user?->nama ?? 'N/A' }}</div>
</div>
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">Poli</p>
<span
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">
{{ $antrian->poli->nama_poli ?? 'N/A' }}
</span>
</div>
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">Status
</p>
@if (($antrian->status ?? '') == 'menunggu')
<span
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">
Menunggu
</span>
@elseif(($antrian->status ?? '') == 'dipanggil')
<span
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">
Dipanggil
</span>
@elseif(($antrian->status ?? '') == 'selesai')
<span
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">
Selesai
</span>
@else
<span
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800">
Batal
</span>
@endif
</div>
</div>
<!-- Button Selengkapnya untuk Mobile -->
<div class="border-t border-gray-100 pt-3">
<button onclick="toggleDashboardDetails({{ $antrian->id }})"
class="text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center">
<span id="dashboard-btn-text-{{ $antrian->id }}">Selengkapnya</span>
<svg id="dashboard-icon-{{ $antrian->id }}"
class="w-4 h-4 ml-1 transform transition-transform" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<!-- Detail Tambahan (Hidden by default) -->
<div id="dashboard-details-{{ $antrian->id }}" class="hidden mt-3 space-y-2">
<div class="grid grid-cols-1 gap-2 text-sm">
<div>
<span class="font-medium text-gray-700">No. HP:</span>
<span
class="text-gray-600">{{ $antrian->user?->no_hp ?? 'N/A' }}</span>
</div>
<div>
<span class="font-medium text-gray-700">Waktu Daftar:</span>
<span
class="text-gray-600">{{ $antrian->created_at?->format('H:i') ?? 'N/A' }}</span>
</div>
</div>
</div>
</div>
</div>
@empty
<div class="p-8 text-center text-gray-500">
<svg class="w-12 h-12 mx-auto mb-4 text-gray-400" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z">
</path>
</svg>
<p class="text-lg font-medium">Belum ada antrian hari ini</p>
<p class="text-sm text-gray-400">Antrian akan muncul di sini setelah ada
pendaftaran</p>
</div>
@endforelse
</div>
</div>
</div>
</div>
@ -406,6 +504,25 @@ function confirmLogout() {
}
});
}
// Function to toggle dashboard details
function toggleDashboardDetails(antrianId) {
const detailsElement = document.getElementById(`dashboard-details-${antrianId}`);
const buttonTextElement = document.getElementById(`dashboard-btn-text-${antrianId}`);
const iconElement = document.getElementById(`dashboard-icon-${antrianId}`);
if (detailsElement.classList.contains('hidden')) {
detailsElement.classList.remove('hidden');
buttonTextElement.textContent = 'Sembunyikan';
iconElement.classList.remove('transform', 'rotate-180');
iconElement.classList.add('transform', 'rotate-0');
} else {
detailsElement.classList.add('hidden');
buttonTextElement.textContent = 'Selengkapnya';
iconElement.classList.remove('transform', 'rotate-0');
iconElement.classList.add('transform', 'rotate-180');
}
}
</script>
@endpush
@endsection

View File

@ -0,0 +1,536 @@
@extends('layouts.app')
@section('title', 'Indonesian TTS Settings')
@section('content')
<div class="min-h-screen bg-gray-50">
<!-- Top Navigation -->
<nav class="bg-white shadow-lg sticky top-0 z-40">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<button id="sidebar-toggle" class="lg:hidden text-gray-700 hover:text-primary mr-4">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
<div class="flex-shrink-0">
<h1 class="text-xl md:text-2xl font-bold text-primary">🏥 Admin Puskesmas</h1>
</div>
</div>
<div class="hidden md:flex items-center space-x-4">
<a href="{{ route('display') }}"
class="text-gray-700 hover:text-primary px-3 py-2 rounded-md text-sm font-medium transition duration-200">Display</a>
<span class="text-gray-700">Selamat datang, Admin</span>
<button onclick="confirmLogout()"
class="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-md text-sm font-medium transition duration-200">
Logout
</button>
</div>
</div>
</div>
</nav>
<div class="flex">
<!-- Sidebar -->
<aside id="sidebar"
class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-xl transform -translate-x-full lg:translate-x-0 lg:static lg:inset-0 transition duration-200 ease-in-out">
<div class="flex items-center justify-between h-16 px-6 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">Menu Admin</h2>
<button id="sidebar-close" class="lg:hidden text-gray-500 hover:text-gray-700">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12">
</path>
</svg>
</button>
</div>
<nav class="px-6 py-6">
<div class="space-y-6">
<!-- Dashboard -->
<div>
<a href="{{ route('admin.dashboard') }}"
class="flex items-center px-4 py-3 rounded-xl {{ request()->routeIs('admin.dashboard') ? 'bg-blue-50 text-blue-700 border border-blue-200' : 'text-gray-700 hover:bg-gray-50' }} transition duration-200">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 5a2 2 0 012-2h4a2 2 0 012 2v6H8V5z"></path>
</svg>
<span class="font-medium">Dashboard</span>
</a>
</div>
<!-- Daftar Antrian -->
<div>
<div
class="flex items-center px-4 py-2 text-sm font-semibold text-gray-500 uppercase tracking-wider">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2">
</path>
</svg>
Daftar Antrian
</div>
<div class="mt-3 space-y-1">
<a href="{{ route('admin.poli.umum') }}"
class="flex items-center px-4 py-2 rounded-lg {{ request()->routeIs('admin.poli.umum') ? 'bg-blue-50 text-blue-700' : 'text-gray-600 hover:bg-gray-50' }} transition duration-200">
<div class="w-2 h-2 bg-blue-500 rounded-full mr-3"></div>
<span class="text-sm">Poli Umum</span>
</a>
<a href="{{ route('admin.poli.gigi') }}"
class="flex items-center px-4 py-2 rounded-lg {{ request()->routeIs('admin.poli.gigi') ? 'bg-green-50 text-green-700' : 'text-gray-600 hover:bg-gray-50' }} transition duration-200">
<div class="w-2 h-2 bg-green-500 rounded-full mr-3"></div>
<span class="text-sm">Poli Gigi</span>
</a>
<a href="{{ route('admin.poli.jiwa') }}"
class="flex items-center px-4 py-2 rounded-lg {{ request()->routeIs('admin.poli.jiwa') ? 'bg-purple-50 text-purple-700' : 'text-gray-600 hover:bg-gray-50' }} transition duration-200">
<div class="w-2 h-2 bg-purple-500 rounded-full mr-3"></div>
<span class="text-sm">Poli Jiwa</span>
</a>
<a href="{{ route('admin.poli.tradisional') }}"
class="flex items-center px-4 py-2 rounded-lg {{ request()->routeIs('admin.poli.tradisional') ? 'bg-yellow-50 text-yellow-700' : 'text-gray-600 hover:bg-gray-50' }} transition duration-200">
<div class="w-2 h-2 bg-yellow-500 rounded-full mr-3"></div>
<span class="text-sm">Poli Tradisional</span>
</a>
</div>
</div>
<!-- Kelola User -->
<div>
<a href="{{ route('admin.users.index') }}"
class="flex items-center px-4 py-3 rounded-xl {{ request()->routeIs('admin.users.*') ? 'bg-blue-50 text-blue-700 border border-blue-200' : 'text-gray-700 hover:bg-gray-50' }} transition duration-200">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z">
</path>
</svg>
<span class="font-medium">Kelola User</span>
</a>
</div>
<!-- Laporan -->
<div>
<a href="{{ route('admin.laporan.index') }}"
class="flex items-center px-4 py-3 rounded-xl {{ request()->routeIs('admin.laporan.*') ? 'bg-blue-50 text-blue-700 border border-blue-200' : 'text-gray-700 hover:bg-gray-50' }} transition duration-200">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z">
</path>
</svg>
<span class="font-medium">Laporan</span>
</a>
</div>
<!-- Indonesian TTS Settings -->
<div>
<a href="{{ route('admin.indonesian-tts.index') }}"
class="flex items-center px-4 py-3 rounded-xl {{ request()->routeIs('admin.indonesian-tts.*') ? 'bg-green-50 text-green-700 border border-green-200' : 'text-gray-700 hover:bg-gray-50' }} transition duration-200">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z">
</path>
</svg>
<span class="font-medium">Indonesian TTS</span>
</a>
</div>
<!-- Display -->
<div>
<a href="{{ route('display') }}"
class="flex items-center px-4 py-3 rounded-xl text-gray-700 hover:bg-gray-50 transition duration-200">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z">
</path>
</svg>
<span class="font-medium">Display</span>
</a>
</div>
</div>
</nav>
</aside>
<!-- Overlay for mobile -->
<div id="sidebar-overlay" class="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden hidden"></div>
<!-- Main Content -->
<div class="flex-1 lg:ml-0">
<div class="px-4 sm:px-6 lg:px-8 py-6 md:py-8">
<!-- Header -->
<div class="mb-8 animate-fade-in">
<h1 class="text-3xl md:text-4xl font-bold text-gray-900 mb-2">Indonesian TTS Settings</h1>
<p class="text-gray-600 text-lg">Kelola pengaturan Text-to-Speech Indonesia</p>
</div>
<!-- Status Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<!-- Indonesian TTS Status -->
<div class="bg-white rounded-xl shadow-lg p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<div id="indonesian-tts-status"
class="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center">
<svg class="w-5 h-5 text-gray-500" 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"></path>
</svg>
</div>
</div>
<div class="ml-4">
<h3 class="text-lg font-semibold text-gray-900">Indonesian TTS</h3>
<p id="indonesian-tts-text" class="text-sm text-gray-500">Checking...</p>
</div>
</div>
</div>
<!-- Coqui TTS Status -->
<div class="bg-white rounded-xl shadow-lg p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<div id="coqui-tts-status"
class="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center">
<svg class="w-5 h-5 text-gray-500" 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"></path>
</svg>
</div>
</div>
<div class="ml-4">
<h3 class="text-lg font-semibold text-gray-900">Coqui TTS</h3>
<p id="coqui-tts-text" class="text-sm text-gray-500">Checking...</p>
</div>
</div>
</div>
<!-- Model Files Status -->
<div class="bg-white rounded-xl shadow-lg p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<div id="model-files-status"
class="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center">
<svg class="w-5 h-5 text-gray-500" 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"></path>
</svg>
</div>
</div>
<div class="ml-4">
<h3 class="text-lg font-semibold text-gray-900">Model Files</h3>
<p id="model-files-text" class="text-sm text-gray-500">Checking...</p>
</div>
</div>
</div>
<!-- Speakers Status -->
<div class="bg-white rounded-xl shadow-lg p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<div id="speakers-status"
class="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center">
<svg class="w-5 h-5 text-gray-500" 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"></path>
</svg>
</div>
</div>
<div class="ml-4">
<h3 class="text-lg font-semibold text-gray-900">Available Speakers</h3>
<p id="speakers-text" class="text-sm text-gray-500">Checking...</p>
</div>
</div>
</div>
</div>
<!-- Test TTS Section -->
<div class="bg-white rounded-2xl shadow-xl p-8 mb-8">
<h2 class="text-2xl font-bold text-gray-900 mb-6">Test Indonesian TTS</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="test-text" class="block text-sm font-medium text-gray-700 mb-2">Text untuk
Test</label>
<textarea id="test-text" rows="4"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Masukkan text untuk test TTS...">Nomor antrian U Lima, silakan menuju ke Poli Umum</textarea>
</div>
<div class="flex items-end">
<button id="test-tts-btn"
class="bg-blue-500 hover:bg-blue-600 text-white px-6 py-2 rounded-md font-medium transition duration-200">
Test TTS
</button>
</div>
</div>
<div id="test-result" class="mt-4 hidden">
<div class="bg-gray-50 rounded-lg p-4">
<h4 class="font-semibold text-gray-900 mb-2">Hasil Test:</h4>
<p id="test-result-text" class="text-sm text-gray-600"></p>
</div>
</div>
</div>
<!-- Installation Instructions -->
<div class="bg-white rounded-2xl shadow-xl p-8 mb-8">
<h2 class="text-2xl font-bold text-gray-900 mb-6">Instalasi Indonesian TTS</h2>
<div id="installation-instructions" class="prose max-w-none">
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z">
</path>
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800">Perhatian</h3>
<div class="mt-2 text-sm text-yellow-700">
<p>Indonesian TTS memerlukan instalasi Coqui TTS dan model files. Ikuti
langkah-langkah di bawah ini.</p>
</div>
</div>
</div>
</div>
<div id="installation-steps" class="space-y-4">
<!-- Installation steps will be loaded here -->
</div>
</div>
</div>
<!-- Download Model Files -->
<div class="bg-white rounded-2xl shadow-xl p-8">
<h2 class="text-2xl font-bold text-gray-900 mb-6">Download Model Files</h2>
<div id="download-info" class="space-y-4">
<!-- Download info will be loaded here -->
</div>
</div>
</div>
</div>
</div>
</div>
@push('scripts')
<script>
// Sidebar toggle for mobile
document.getElementById('sidebar-toggle').addEventListener('click', function() {
document.getElementById('sidebar').classList.remove('-translate-x-full');
document.getElementById('sidebar-overlay').classList.remove('hidden');
});
document.getElementById('sidebar-close').addEventListener('click', function() {
document.getElementById('sidebar').classList.add('-translate-x-full');
document.getElementById('sidebar-overlay').classList.add('hidden');
});
document.getElementById('sidebar-overlay').addEventListener('click', function() {
document.getElementById('sidebar').classList.add('-translate-x-full');
document.getElementById('sidebar-overlay').classList.add('hidden');
});
// Load status on page load
document.addEventListener('DOMContentLoaded', function() {
loadStatus();
loadInstallationInstructions();
loadDownloadInfo();
});
// Load Indonesian TTS status
function loadStatus() {
fetch('{{ route('admin.indonesian-tts.status') }}')
.then(response => response.json())
.then(data => {
updateStatusCard('indonesian-tts', data.indonesian_tts_available, 'Indonesian TTS');
updateStatusCard('coqui-tts', data.coqui_tts_installed, 'Coqui TTS');
updateStatusCard('model-files', data.model_files_exist, 'Model Files');
const speakersCount = Object.keys(data.available_speakers || {}).length;
updateStatusCard('speakers', speakersCount > 0, `${speakersCount} Speakers Available`);
})
.catch(error => {
console.error('Error loading status:', error);
});
}
// Update status card
function updateStatusCard(id, isAvailable, text) {
const statusElement = document.getElementById(`${id}-status`);
const textElement = document.getElementById(`${id}-text`);
if (isAvailable) {
statusElement.className = 'w-8 h-8 rounded-full bg-green-100 flex items-center justify-center';
statusElement.innerHTML =
'<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>';
textElement.textContent = text;
textElement.className = 'text-sm text-green-600';
} else {
statusElement.className = 'w-8 h-8 rounded-full bg-red-100 flex items-center justify-center';
statusElement.innerHTML =
'<svg class="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>';
textElement.textContent = 'Not Available';
textElement.className = 'text-sm text-red-600';
}
}
// Load installation instructions
function loadInstallationInstructions() {
fetch('{{ route('admin.indonesian-tts.install') }}')
.then(response => response.json())
.then(data => {
const container = document.getElementById('installation-steps');
const steps = data.instructions.steps;
let html = '<ol class="list-decimal list-inside space-y-2">';
steps.forEach(step => {
if (step.trim() === '') {
html += '</ol><ol class="list-decimal list-inside space-y-2 mt-4">';
} else {
html += `<li class="text-sm text-gray-700">${step}</li>`;
}
});
html += '</ol>';
container.innerHTML = html;
})
.catch(error => {
console.error('Error loading installation instructions:', error);
});
}
// Load download info
function loadDownloadInfo() {
fetch('{{ route('admin.indonesian-tts.download') }}')
.then(response => response.json())
.then(data => {
const container = document.getElementById('download-info');
const info = data.download_info;
let html = `
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<h3 class="text-lg font-semibold text-blue-900 mb-2">${info.title}</h3>
<p class="text-blue-700">${info.description}</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
`;
Object.entries(info.files).forEach(([filename, url]) => {
html += `
<div class="bg-gray-50 rounded-lg p-4">
<h4 class="font-semibold text-gray-900 mb-2">${filename}</h4>
<a href="${url}" target="_blank"
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Download
</a>
</div>
`;
});
html += '</div>';
html += '<div class="bg-gray-50 rounded-lg p-4">';
html += '<h4 class="font-semibold text-gray-900 mb-2">Langkah Manual:</h4>';
html += '<ol class="list-decimal list-inside space-y-1 text-sm text-gray-700">';
info.manual_steps.forEach(step => {
html += `<li>${step}</li>`;
});
html += '</ol></div>';
container.innerHTML = html;
})
.catch(error => {
console.error('Error loading download info:', error);
});
}
// Test TTS functionality
document.getElementById('test-tts-btn').addEventListener('click', function() {
const text = document.getElementById('test-text').value;
const button = this;
const resultDiv = document.getElementById('test-result');
const resultText = document.getElementById('test-result-text');
if (!text.trim()) {
Swal.fire({
icon: 'warning',
title: 'Text Kosong',
text: 'Masukkan text untuk test TTS'
});
return;
}
button.disabled = true;
button.textContent = 'Testing...';
fetch('{{ route('admin.indonesian-tts.test') }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({
text: text
})
})
.then(response => response.json())
.then(data => {
resultDiv.classList.remove('hidden');
if (data.success) {
resultText.textContent = `TTS berhasil dibuat! Type: ${data.tts_type}`;
resultText.className = 'text-sm text-green-600';
if (data.audio_url) {
resultText.innerHTML +=
`<br><audio controls class="mt-2"><source src="${data.audio_url}" type="audio/wav">Your browser does not support the audio element.</audio>`;
}
} else {
resultText.textContent = `TTS gagal: ${data.message}`;
resultText.className = 'text-sm text-red-600';
}
})
.catch(error => {
resultDiv.classList.remove('hidden');
resultText.textContent = `Error: ${error.message}`;
resultText.className = 'text-sm text-red-600';
})
.finally(() => {
button.disabled = false;
button.textContent = 'Test TTS';
});
});
function confirmLogout() {
Swal.fire({
title: 'Konfirmasi Logout',
text: 'Apakah Anda yakin ingin keluar dari sistem?',
icon: 'question',
showCancelButton: true,
confirmButtonText: 'Ya, Logout',
cancelButtonText: 'Batal',
confirmButtonColor: '#EF4444',
cancelButtonColor: '#6B7280'
}).then((result) => {
if (result.isConfirmed) {
const form = document.createElement('form');
form.method = 'POST';
form.action = '{{ route('logout') }}';
const csrfToken = document.createElement('input');
csrfToken.type = 'hidden';
csrfToken.name = '_token';
csrfToken.value = '{{ csrf_token() }}';
form.appendChild(csrfToken);
document.body.appendChild(form);
form.submit();
}
});
}
</script>
@endpush
@endsection

View File

@ -182,7 +182,8 @@ class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label for="tanggal_mulai" class="block text-sm font-medium text-gray-700 mb-1">Tanggal
Mulai</label>
<input type="date" id="tanggal_mulai" name="tanggal_mulai" value="{{ request('tanggal_mulai') }}"
<input type="date" id="tanggal_mulai" name="tanggal_mulai"
value="{{ request('tanggal_mulai') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
</div>
@ -190,7 +191,8 @@ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none foc
<div>
<label for="tanggal_akhir" class="block text-sm font-medium text-gray-700 mb-1">Tanggal
Akhir</label>
<input type="date" id="tanggal_akhir" name="tanggal_akhir" value="{{ request('tanggal_akhir') }}"
<input type="date" id="tanggal_akhir" name="tanggal_akhir"
value="{{ request('tanggal_akhir') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
</div>
@ -201,7 +203,8 @@ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none foc
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
<option value="">Semua Poli</option>
@foreach ($polis as $poli)
<option value="{{ $poli->id }}" {{ request('poli_id') == $poli->id ? 'selected' : '' }}>
<option value="{{ $poli->id }}"
{{ request('poli_id') == $poli->id ? 'selected' : '' }}>
{{ $poli->nama_poli }}
</option>
@endforeach
@ -214,11 +217,15 @@ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none foc
<select id="status" name="status"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
<option value="">Semua Status</option>
<option value="menunggu" {{ request('status') == 'menunggu' ? 'selected' : '' }}>Menunggu
<option value="menunggu" {{ request('status') == 'menunggu' ? 'selected' : '' }}>
Menunggu
</option>
<option value="sedang" {{ request('status') == 'sedang' ? 'selected' : '' }}>Sedang
</option>
<option value="selesai" {{ request('status') == 'selesai' ? 'selected' : '' }}>Selesai
</option>
<option value="batal" {{ request('status') == 'batal' ? 'selected' : '' }}>Batal
</option>
<option value="sedang" {{ request('status') == 'sedang' ? 'selected' : '' }}>Sedang</option>
<option value="selesai" {{ request('status') == 'selesai' ? 'selected' : '' }}>Selesai</option>
<option value="batal" {{ request('status') == 'batal' ? 'selected' : '' }}>Batal</option>
</select>
</div>
@ -229,9 +236,11 @@ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none foc
<select id="jenis_kelamin" name="jenis_kelamin"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
<option value="">Semua</option>
<option value="laki-laki" {{ request('jenis_kelamin') == 'laki-laki' ? 'selected' : '' }}>
<option value="laki-laki"
{{ request('jenis_kelamin') == 'laki-laki' ? 'selected' : '' }}>
Laki-laki</option>
<option value="perempuan" {{ request('jenis_kelamin') == 'perempuan' ? 'selected' : '' }}>
<option value="perempuan"
{{ request('jenis_kelamin') == 'perempuan' ? 'selected' : '' }}>
Perempuan</option>
</select>
</div>
@ -260,7 +269,8 @@ class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shad
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2">
</path>
@ -334,23 +344,32 @@ class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shad
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-900">Data Antrian</h3>
</div>
<div class="overflow-x-auto">
<!-- Desktop Table -->
<div class="hidden lg:block overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
No Antrian</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Nama Pasien</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Poli</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Tanggal</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Waktu Daftar</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Waktu Panggil</th>
</tr>
</thead>
@ -365,7 +384,8 @@ class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium b
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div>
<div class="text-sm font-medium text-gray-900">{{ $item->user->nama }}</div>
<div class="text-sm font-medium text-gray-900">{{ $item->user->nama }}
</div>
<div class="text-sm text-gray-500">{{ $item->user->no_ktp }}</div>
</div>
</td>
@ -418,6 +438,105 @@ class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium b
</tbody>
</table>
</div>
<!-- Mobile Table -->
<div class="lg:hidden">
@forelse($antrian as $item)
<div class="border-b border-gray-200 p-4 hover:bg-gray-50">
<!-- 4 Kolom Penting untuk Mobile -->
<div class="grid grid-cols-2 gap-4 mb-3">
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">No
Antrian</p>
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{{ $item->no_antrian }}
</span>
</div>
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">Nama
Pasien</p>
<div class="text-sm font-medium text-gray-900">{{ $item->user->nama }}</div>
</div>
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">Poli</p>
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
{{ $item->poli->nama_poli }}
</span>
</div>
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">Status
</p>
@if ($item->status == 'menunggu')
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
Menunggu
</span>
@elseif($item->status == 'sedang')
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
Sedang
</span>
@elseif($item->status == 'selesai')
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Selesai
</span>
@else
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
Batal
</span>
@endif
</div>
</div>
<!-- Button Selengkapnya untuk Mobile -->
<div class="border-t border-gray-100 pt-3">
<button onclick="toggleLaporanDetails({{ $item->id }})"
class="text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center">
<span id="laporan-btn-text-{{ $item->id }}">Selengkapnya</span>
<svg id="laporan-icon-{{ $item->id }}"
class="w-4 h-4 ml-1 transform transition-transform" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<!-- Detail Tambahan (Hidden by default) -->
<div id="laporan-details-{{ $item->id }}" class="hidden mt-3 space-y-2">
<div class="grid grid-cols-1 gap-2 text-sm">
<div>
<span class="font-medium text-gray-700">No. KTP:</span>
<span class="text-gray-600">{{ $item->user->no_ktp }}</span>
</div>
<div>
<span class="font-medium text-gray-700">Tanggal:</span>
<span
class="text-gray-600">{{ $item->created_at ? $item->created_at->format('d/m/Y') : '-' }}</span>
</div>
<div>
<span class="font-medium text-gray-700">Waktu Daftar:</span>
<span
class="text-gray-600">{{ $item->created_at ? $item->created_at->format('H:i') : '-' }}</span>
</div>
<div>
<span class="font-medium text-gray-700">Waktu Panggil:</span>
<span
class="text-gray-600">{{ $item->waktu_panggil ? $item->waktu_panggil->format('H:i') : '-' }}</span>
</div>
</div>
</div>
</div>
</div>
@empty
<div class="p-8 text-center text-gray-500">
Tidak ada data antrian yang ditemukan
</div>
@endforelse
</div>
</div>
</div>
</div>
@ -492,6 +611,23 @@ function confirmLogout() {
confirmButtonColor: '#EF4444'
});
@endif
// Function to toggle details visibility
function toggleLaporanDetails(id) {
const details = document.getElementById(`laporan-details-${id}`);
const buttonText = document.getElementById(`laporan-btn-text-${id}`);
const icon = document.getElementById(`laporan-icon-${id}`);
if (details.classList.contains('hidden')) {
details.classList.remove('hidden');
buttonText.textContent = 'Kurangi';
icon.classList.remove('transform', 'rotate-180');
} else {
details.classList.add('hidden');
buttonText.textContent = 'Selengkapnya';
icon.classList.add('transform', 'rotate-180');
}
}
</script>
@endpush
@endsection

View File

@ -153,7 +153,8 @@ class="flex items-center px-4 py-3 rounded-xl text-gray-700 hover:bg-gray-50 tra
<!-- Table -->
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="overflow-x-auto">
<!-- Desktop Table -->
<div class="hidden lg:block overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
@ -254,15 +255,132 @@ class="bg-red-500 hover:bg-red-600 text-white px-3 py-1 rounded-md text-xs font-
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z">
</path>
</svg>
<p class="text-lg font-medium">Belum ada antrian</p>
<p class="text-sm text-gray-400">Antrian akan muncul di sini setelah ada
pendaftaran</p>
<p class="text-lg font-medium">Tidak ada antrian</p>
<p class="text-sm text-gray-400">Belum ada antrian yang terdaftar</p>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<!-- Mobile Table -->
<div class="lg:hidden">
@forelse($antrians as $antrian)
<div class="border-b border-gray-200 p-4 hover:bg-gray-50">
<!-- 4 Kolom Penting untuk Mobile -->
<div class="grid grid-cols-2 gap-4 mb-3">
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">No
Antrian</p>
<p class="text-sm font-semibold text-gray-900">{{ $antrian->no_antrian }}</p>
</div>
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">Nama</p>
<p class="text-sm text-gray-900">{{ $antrian->user?->nama }}</p>
</div>
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">Status
</p>
@if ($antrian->status == 'menunggu')
<span
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">
Menunggu
</span>
@elseif($antrian->status == 'dipanggil')
<span
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">
Dipanggil
</span>
@elseif($antrian->status == 'selesai')
<span
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">
Selesai
</span>
@else
<span
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800">
Batal
</span>
@endif
</div>
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">Aksi</p>
@if ($antrian->status == 'menunggu')
<button
onclick="panggil('{{ route('admin.panggil-antrian-id', $antrian) }}')"
class="bg-blue-500 hover:bg-blue-600 text-white px-3 py-1 rounded-md text-xs font-medium transition duration-200">
Panggil
</button>
@elseif($antrian->status == 'dipanggil')
<div class="flex space-x-2">
<button
onclick="selesai('{{ route('admin.selesai-antrian') }}', {{ $antrian->id }})"
class="bg-green-500 hover:bg-green-600 text-white px-3 py-1 rounded-md text-xs font-medium transition duration-200">
Selesai
</button>
<button
onclick="batal('{{ route('admin.batal-antrian') }}', {{ $antrian->id }})"
class="bg-red-500 hover:bg-red-600 text-white px-3 py-1 rounded-md text-xs font-medium transition duration-200">
Batal
</button>
</div>
@else
<span class="text-gray-400 text-xs">-</span>
@endif
</div>
</div>
<!-- Button Selengkapnya untuk Mobile -->
<div class="border-t border-gray-100 pt-3">
<button onclick="toggleDetails({{ $antrian->id }})"
class="text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center">
<span id="btn-text-{{ $antrian->id }}">Selengkapnya</span>
<svg id="icon-{{ $antrian->id }}"
class="w-4 h-4 ml-1 transform transition-transform" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<!-- Detail Tambahan (Hidden by default) -->
<div id="details-{{ $antrian->id }}" class="hidden mt-3 space-y-2">
<div class="grid grid-cols-1 gap-2 text-sm">
<div>
<span class="font-medium text-gray-700">Alamat:</span>
<span class="text-gray-600">{{ $antrian->user?->alamat }}</span>
</div>
<div>
<span class="font-medium text-gray-700">Jenis Kelamin:</span>
<span
class="text-gray-600">{{ $antrian->user?->jenis_kelamin }}</span>
</div>
<div>
<span class="font-medium text-gray-700">Nomor HP:</span>
<span class="text-gray-600">{{ $antrian->user?->no_hp }}</span>
</div>
<div>
<span class="font-medium text-gray-700">Nomor KTP:</span>
<span class="text-gray-600">{{ $antrian->user?->no_ktp }}</span>
</div>
</div>
</div>
</div>
</div>
@empty
<div class="p-8 text-center text-gray-500">
<svg class="w-12 h-12 mx-auto mb-4 text-gray-400" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z">
</path>
</svg>
<p class="text-lg font-medium">Tidak ada antrian</p>
<p class="text-sm text-gray-400">Belum ada antrian yang terdaftar</p>
</div>
@endforelse
</div>
</div>
</div>
</div>
@ -305,27 +423,30 @@ function panggil(url) {
.then(r => r.json())
.then(d => {
if (d.success) {
// Play TTS on display page
if (d.audio_sequence && d.poli_name && d.queue_number) {
// Send message to display page if it's open
if (window.opener && !window.opener.closed) {
window.opener.postMessage({
type: 'TTS_CALL',
poliName: d.poli_name,
queueNumber: d.queue_number,
audioSequence: d.audio_sequence
}, '*');
}
// Also try to play locally if display is not open
playTTSLocally(d.poli_name, d.queue_number);
}
// Show success alert with sound
Swal.fire({
icon: 'success',
title: 'Berhasil',
text: d.message
}).then(() => location.reload());
title: 'Antrian Dipanggil!',
text: `Antrian selanjutnya poli ${d.poli_name} nomor ${d.queue_number}`,
confirmButtonText: 'OK',
confirmButtonColor: '#10B981',
timer: 3000,
timerProgressBar: true,
showClass: {
popup: 'animate__animated animate__fadeInDown'
},
hideClass: {
popup: 'animate__animated animate__fadeOutUp'
}
});
// Play notification sound
playNotificationSound();
// Reload page after delay
setTimeout(() => {
location.reload();
}, 2000);
} else {
Swal.fire({
icon: 'warning',
@ -342,6 +463,109 @@ function panggil(url) {
});
}
// Play notification sound
function playNotificationSound() {
const audio = new Audio(
'data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2/LDciUFLIHO8tiJNwgZaLvt559NEAxQp+PwtmMcBjiR1/LMeSwFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBSuBzvLZiTYIG2m98OScTgwOUarm7blmGgU7k9n1unEiBC13yO/eizEIHWq+8+OWT'
);
audio.play();
}
// Function to convert queue number to Indonesian pronunciation
function convertQueueNumberToIndonesian(queueNumber) {
// Indonesian number words
const indonesianNumbers = {
'0': 'Nol',
'1': 'Satu',
'2': 'Dua',
'3': 'Tiga',
'4': 'Empat',
'5': 'Lima',
'6': 'Enam',
'7': 'Tujuh',
'8': 'Delapan',
'9': 'Sembilan',
'10': 'Sepuluh',
'11': 'Sebelas',
'12': 'Dua Belas',
'13': 'Tiga Belas',
'14': 'Empat Belas',
'15': 'Lima Belas',
'16': 'Enam Belas',
'17': 'Tujuh Belas',
'18': 'Delapan Belas',
'19': 'Sembilan Belas',
'20': 'Dua Puluh',
'30': 'Tiga Puluh',
'40': 'Empat Puluh',
'50': 'Lima Puluh',
'60': 'Enam Puluh',
'70': 'Tujuh Puluh',
'80': 'Delapan Puluh',
'90': 'Sembilan Puluh',
'100': 'Seratus'
};
// If it's a pure number, convert it
if (!isNaN(queueNumber)) {
const number = parseInt(queueNumber);
if (indonesianNumbers[number]) {
return indonesianNumbers[number];
} else {
// For numbers > 100, build the pronunciation
if (number < 100) {
const tens = Math.floor(number / 10) * 10;
const ones = number % 10;
if (ones === 0) {
return indonesianNumbers[tens];
} else {
return indonesianNumbers[tens] + ' ' + indonesianNumbers[ones];
}
} else {
return queueNumber; // Fallback for large numbers
}
}
}
// For alphanumeric (like "U5", "A10"), convert the numeric part
let letters = '';
let numbers = '';
// Split into letters and numbers
for (let i = 0; i < queueNumber.length; i++) {
const char = queueNumber[i];
if (!isNaN(char)) {
numbers += char;
} else {
letters += char;
}
}
// If we have both letters and numbers
if (letters && numbers) {
const numberValue = parseInt(numbers);
if (indonesianNumbers[numberValue]) {
return letters + ' ' + indonesianNumbers[numberValue];
} else {
// For numbers > 100, build the pronunciation
if (numberValue < 100) {
const tens = Math.floor(numberValue / 10) * 10;
const ones = numberValue % 10;
if (ones === 0) {
return letters + ' ' + indonesianNumbers[tens];
} else {
return letters + ' ' + indonesianNumbers[tens] + ' ' + indonesianNumbers[ones];
}
} else {
return queueNumber; // Fallback for large numbers
}
}
}
// If no conversion needed, return as is
return queueNumber;
}
// Function to play TTS locally (fallback)
function playTTSLocally(poliName, queueNumber) {
// Create audio sequence manually
@ -353,7 +577,9 @@ function playTTSLocally(poliName, queueNumber) {
// After attention sound, use browser TTS for poli and number
setTimeout(() => {
const text = `Nomor antrian ${queueNumber}, silakan menuju ke ${poliName}`;
// Convert queue number to Indonesian pronunciation
const indonesianQueueNumber = convertQueueNumberToIndonesian(queueNumber);
const text = `Nomor antrian ${indonesianQueueNumber}, silakan menuju ke ${poliName}`;
if ('speechSynthesis' in window) {
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = 'id-ID';
@ -481,6 +707,22 @@ function confirmLogout() {
}
});
}
function toggleDetails(antrianId) {
const details = document.getElementById(`details-${antrianId}`);
const buttonText = document.getElementById(`btn-text-${antrianId}`);
const icon = document.getElementById(`icon-${antrianId}`);
if (details.classList.contains('hidden')) {
details.classList.remove('hidden');
buttonText.textContent = 'Kurangi';
icon.classList.remove('transform', 'rotate-180');
} else {
details.classList.add('hidden');
buttonText.textContent = 'Selengkapnya';
icon.classList.add('transform', 'rotate-180');
}
}
</script>
@endpush
@endsection

View File

@ -0,0 +1,329 @@
@extends('layouts.app')
@section('title', 'Tambah User Baru - Admin Dashboard')
@section('content')
<div class="min-h-screen bg-gray-50">
<!-- Top Navigation -->
<nav class="bg-white shadow-lg sticky top-0 z-40">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<button id="sidebar-toggle" class="lg:hidden text-gray-700 hover:text-primary mr-4">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
<div class="flex-shrink-0">
<h1 class="text-xl md:text-2xl font-bold text-primary">🏥 Admin Puskesmas</h1>
</div>
</div>
<div class="hidden md:flex items-center space-x-4">
<a href="{{ route('display') }}"
class="text-gray-700 hover:text-primary px-3 py-2 rounded-md text-sm font-medium transition duration-200">Display</a>
<span class="text-gray-700">Selamat datang, Admin</span>
<button onclick="confirmLogout()"
class="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-md text-sm font-medium transition duration-200">
Logout
</button>
</div>
</div>
</div>
</nav>
<div class="flex">
<!-- Sidebar -->
<aside id="sidebar"
class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-xl transform -translate-x-full lg:translate-x-0 lg:static lg:inset-0 transition duration-200 ease-in-out">
<div class="flex items-center justify-between h-16 px-6 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">Menu Admin</h2>
<button id="sidebar-close" class="lg:hidden text-gray-500 hover:text-gray-700">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12">
</path>
</svg>
</button>
</div>
<nav class="px-6 py-6">
<div class="space-y-6">
<!-- Dashboard -->
<div>
<a href="{{ route('admin.dashboard') }}"
class="flex items-center px-4 py-3 rounded-xl {{ request()->routeIs('admin.dashboard') ? 'bg-blue-50 text-blue-700 border border-blue-200' : 'text-gray-700 hover:bg-gray-50' }} transition duration-200">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 5a2 2 0 012-2h4a2 2 0 012 2v6H8V5z"></path>
</svg>
<span class="font-medium">Dashboard</span>
</a>
</div>
<!-- Daftar Antrian -->
<div>
<div
class="flex items-center px-4 py-2 text-sm font-semibold text-gray-500 uppercase tracking-wider">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2">
</path>
</svg>
Daftar Antrian
</div>
<div class="mt-3 space-y-1">
<a href="{{ route('admin.poli.umum') }}"
class="flex items-center px-4 py-2 rounded-lg {{ request()->routeIs('admin.poli.umum') ? 'bg-blue-50 text-blue-700' : 'text-gray-600 hover:bg-gray-50' }} transition duration-200">
<div class="w-2 h-2 bg-blue-500 rounded-full mr-3"></div>
<span class="text-sm">Poli Umum</span>
</a>
<a href="{{ route('admin.poli.gigi') }}"
class="flex items-center px-4 py-2 rounded-lg {{ request()->routeIs('admin.poli.gigi') ? 'bg-green-50 text-green-700' : 'text-gray-600 hover:bg-gray-50' }} transition duration-200">
<div class="w-2 h-2 bg-green-500 rounded-full mr-3"></div>
<span class="text-sm">Poli Gigi</span>
</a>
<a href="{{ route('admin.poli.jiwa') }}"
class="flex items-center px-4 py-2 rounded-lg {{ request()->routeIs('admin.poli.jiwa') ? 'bg-purple-50 text-purple-700' : 'text-gray-600 hover:bg-gray-50' }} transition duration-200">
<div class="w-2 h-2 bg-purple-500 rounded-full mr-3"></div>
<span class="text-sm">Poli Jiwa</span>
</a>
<a href="{{ route('admin.poli.tradisional') }}"
class="flex items-center px-4 py-2 rounded-lg {{ request()->routeIs('admin.poli.tradisional') ? 'bg-yellow-50 text-yellow-700' : 'text-gray-600 hover:bg-gray-50' }} transition duration-200">
<div class="w-2 h-2 bg-yellow-500 rounded-full mr-3"></div>
<span class="text-sm">Poli Tradisional</span>
</a>
</div>
</div>
<!-- Kelola User -->
<div>
<div
class="flex items-center px-4 py-2 text-sm font-semibold text-gray-500 uppercase tracking-wider">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z">
</path>
</svg>
Kelola User
</div>
<div class="mt-3 space-y-1">
<a href="{{ route('admin.users.index') }}"
class="flex items-center px-4 py-2 rounded-lg {{ request()->routeIs('admin.users.*') ? 'bg-blue-50 text-blue-700' : 'text-gray-600 hover:bg-gray-50' }} transition duration-200">
<div class="w-2 h-2 bg-blue-500 rounded-full mr-3"></div>
<span class="text-sm">Daftar User</span>
</a>
</div>
</div>
<!-- Laporan -->
<div>
<a href="{{ route('admin.laporan.index') }}"
class="flex items-center px-4 py-3 rounded-xl {{ request()->routeIs('admin.laporan.*') ? 'bg-blue-50 text-blue-700 border border-blue-200' : 'text-gray-700 hover:bg-gray-50' }} transition duration-200">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z">
</path>
</svg>
<span class="font-medium">Laporan</span>
</a>
</div>
<!-- Indonesian TTS -->
<div>
<a href="{{ route('admin.indonesian-tts.index') }}"
class="flex items-center px-4 py-3 rounded-xl {{ request()->routeIs('admin.indonesian-tts.*') ? 'bg-blue-50 text-blue-700 border border-blue-200' : 'text-gray-700 hover:bg-gray-50' }} transition duration-200">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z">
</path>
</svg>
<span class="font-medium">Indonesian TTS</span>
</a>
</div>
</div>
</nav>
</aside>
<!-- Main Content -->
<div class="flex-1 lg:ml-64">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="mb-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">Tambah User Baru</h1>
<p class="mt-2 text-gray-600">Tambahkan user baru ke sistem Puskesmas</p>
</div>
<a href="{{ route('admin.users.index') }}"
class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
Kembali
</a>
</div>
</div>
<!-- Form -->
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">Informasi User</h3>
</div>
<form action="{{ route('admin.users.store') }}" method="POST" class="p-6 space-y-6">
@csrf
@if ($errors->any())
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
<ul class="list-disc list-inside space-y-1">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Nama -->
<div>
<label for="nama" class="block text-sm font-medium text-gray-700 mb-2">Nama
Lengkap <span class="text-red-500">*</span></label>
<input type="text" name="nama" id="nama" value="{{ old('nama') }}"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<!-- No KTP -->
<div>
<label for="no_ktp" class="block text-sm font-medium text-gray-700 mb-2">Nomor KTP
<span class="text-red-500">*</span></label>
<input type="text" name="no_ktp" id="no_ktp" value="{{ old('no_ktp') }}"
maxlength="16" required
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="16 digit angka">
</div>
<!-- Jenis Kelamin -->
<div>
<label for="jenis_kelamin" class="block text-sm font-medium text-gray-700 mb-2">Jenis
Kelamin <span class="text-red-500">*</span></label>
<select name="jenis_kelamin" id="jenis_kelamin" required
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">Pilih Jenis Kelamin</option>
<option value="laki-laki"
{{ old('jenis_kelamin') == 'laki-laki' ? 'selected' : '' }}>Laki-laki</option>
<option value="perempuan"
{{ old('jenis_kelamin') == 'perempuan' ? 'selected' : '' }}>Perempuan</option>
</select>
</div>
<!-- No HP -->
<div>
<label for="no_hp" class="block text-sm font-medium text-gray-700 mb-2">Nomor HP
<span class="text-red-500">*</span></label>
<input type="text" name="no_hp" id="no_hp" value="{{ old('no_hp') }}"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<!-- Pekerjaan -->
<div>
<label for="pekerjaan" class="block text-sm font-medium text-gray-700 mb-2">Pekerjaan
<span class="text-red-500">*</span></label>
<input type="text" name="pekerjaan" id="pekerjaan"
value="{{ old('pekerjaan') }}" required
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<!-- Password -->
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-2">Password
<span class="text-red-500">*</span></label>
<input type="password" name="password" id="password" required
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Minimal 8 karakter">
</div>
<!-- Konfirmasi Password -->
<div>
<label for="password_confirmation"
class="block text-sm font-medium text-gray-700 mb-2">Konfirmasi Password <span
class="text-red-500">*</span></label>
<input type="password" name="password_confirmation" id="password_confirmation"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
<!-- Alamat -->
<div>
<label for="alamat" class="block text-sm font-medium text-gray-700 mb-2">Alamat Lengkap
<span class="text-red-500">*</span></label>
<textarea name="alamat" id="alamat" rows="3" required
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">{{ old('alamat') }}</textarea>
</div>
<!-- Action Buttons -->
<div class="flex justify-end space-x-3 pt-6 border-t border-gray-200">
<a href="{{ route('admin.users.index') }}"
class="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Batal
</a>
<button type="submit"
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Tambah User
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
// Sidebar toggle functionality
document.getElementById('sidebar-toggle').addEventListener('click', function() {
document.getElementById('sidebar').classList.toggle('-translate-x-full');
});
document.getElementById('sidebar-close').addEventListener('click', function() {
document.getElementById('sidebar').classList.add('-translate-x-full');
});
// Logout confirmation
function confirmLogout() {
if (confirm('Apakah Anda yakin ingin logout?')) {
document.createElement('form').submit();
}
}
// Auto-format KTP input
document.getElementById('no_ktp').addEventListener('input', function(e) {
this.value = this.value.replace(/[^0-9]/g, '');
});
// Show SweetAlert2 for session errors
@if (session('error'))
Swal.fire({
icon: 'error',
title: 'Error!',
text: '{{ session('error') }}',
confirmButtonColor: '#d33',
});
@endif
// Show SweetAlert2 for session success
@if (session('success'))
Swal.fire({
icon: 'success',
title: 'Berhasil!',
text: '{{ session('success') }}',
confirmButtonColor: '#28a745',
});
@endif
</script>
@endsection

View File

@ -155,16 +155,20 @@ class="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden hidden transition dur
<!-- Search Box -->
<div class="bg-white rounded-2xl shadow-xl p-6 mb-8">
<form method="GET" action="{{ route('admin.users.index') }}" class="flex flex-col md:flex-row gap-4">
<form method="GET" action="{{ route('admin.users.index') }}"
class="flex flex-col md:flex-row gap-4">
<div class="flex-1">
<label for="search" class="block text-sm font-medium text-gray-700 mb-2">Cari User</label>
<label for="search" class="block text-sm font-medium text-gray-700 mb-2">Cari
User</label>
<div class="relative">
<input type="text" id="search" name="search" value="{{ request('search') }}"
class="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition duration-200"
placeholder="Cari berdasarkan nama, KTP, HP, alamat, pekerjaan, atau jenis kelamin...">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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"></path>
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<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"></path>
</svg>
</div>
</div>
@ -173,7 +177,8 @@ class="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-xl focus:ring-2 foc
<button type="submit"
class="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-medium transition duration-200 flex items-center">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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"></path>
<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"></path>
</svg>
Cari
</button>
@ -187,10 +192,20 @@ class="px-6 py-3 border border-gray-300 rounded-xl text-gray-700 font-medium hov
<!-- Users Table -->
<div class="bg-white rounded-2xl shadow-xl overflow-hidden animate-slide-up">
<div class="px-6 py-4 border-b border-gray-200">
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<h3 class="text-lg font-semibold text-gray-900">Daftar User</h3>
<a href="{{ route('admin.users.create') }}"
class="inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md transition duration-200">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
Tambah User
</a>
</div>
<div class="overflow-x-auto">
<!-- Desktop Table -->
<div class="hidden lg:block overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 table-fixed">
<thead class="bg-gray-50">
<tr>
@ -266,6 +281,85 @@ class="text-green-600 hover:text-green-900">Reset Password</button>
</tbody>
</table>
</div>
<!-- Mobile Table -->
<div class="lg:hidden">
@forelse($users as $user)
<div class="border-b border-gray-200 p-4 hover:bg-gray-50">
<!-- 4 Kolom Penting untuk Mobile -->
<div class="grid grid-cols-2 gap-4 mb-3">
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">Nama</p>
<p class="text-sm font-medium text-gray-900">{{ $user->nama }}</p>
</div>
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">No. KTP
</p>
<p class="text-sm text-gray-900">{{ $user->no_ktp }}</p>
</div>
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">No. HP
</p>
<p class="text-sm text-gray-900">{{ $user->no_hp }}</p>
</div>
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">Jenis
Kelamin</p>
<span
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full {{ $user->jenis_kelamin == 'laki-laki' ? 'bg-blue-100 text-blue-800' : 'bg-pink-100 text-pink-800' }}">
{{ $user->jenis_kelamin == 'laki-laki' ? 'Laki-laki' : 'Perempuan' }}
</span>
</div>
</div>
<!-- Button Selengkapnya untuk Mobile -->
<div class="border-t border-gray-100 pt-3">
<button onclick="toggleUserDetails({{ $user->id }})"
class="text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center">
<span id="user-btn-text-{{ $user->id }}">Selengkapnya</span>
<svg id="user-icon-{{ $user->id }}"
class="w-4 h-4 ml-1 transform transition-transform" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<!-- Detail Tambahan (Hidden by default) -->
<div id="user-details-{{ $user->id }}" class="hidden mt-3 space-y-2">
<div class="grid grid-cols-1 gap-2 text-sm">
<div>
<span class="font-medium text-gray-700">Alamat:</span>
<span class="text-gray-600">{{ $user->alamat }}</span>
</div>
<div>
<span class="font-medium text-gray-700">Pekerjaan:</span>
<span class="text-gray-600">{{ $user->pekerjaan }}</span>
</div>
<div class="flex space-x-2 pt-2">
<button onclick="viewUser({{ $user->id }})"
class="text-blue-600 hover:text-blue-900 text-sm font-medium">Detail</button>
<button onclick="resetPassword({{ $user->id }})"
class="text-green-600 hover:text-green-900 text-sm font-medium">Reset
Password</button>
</div>
</div>
</div>
</div>
</div>
@empty
<div class="p-8 text-center text-gray-500">
<svg class="w-12 h-12 mx-auto mb-4 text-gray-400" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z">
</path>
</svg>
<p class="text-lg font-medium">Belum ada user</p>
<p class="text-sm text-gray-400">Tidak ada data user yang tersedia</p>
</div>
@endforelse
</div>
</div>
</div>
</div>
@ -529,6 +623,23 @@ function confirmLogout() {
timerProgressBar: true
});
@endif
// Function to toggle user details on mobile
function toggleUserDetails(userId) {
const detailsDiv = document.getElementById(`user-details-${userId}`);
const buttonText = document.getElementById(`user-btn-text-${userId}`);
const icon = document.getElementById(`user-icon-${userId}`);
if (detailsDiv.classList.contains('hidden')) {
detailsDiv.classList.remove('hidden');
buttonText.textContent = 'Lebih Sedikit';
icon.classList.remove('transform', 'rotate-180');
} else {
detailsDiv.classList.add('hidden');
buttonText.textContent = 'Selengkapnya';
icon.classList.add('transform', 'rotate-180');
}
}
</script>
@endpush
@endsection

View File

@ -141,7 +141,7 @@ class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-lg text-sm fon
</div>
</div>
<form action="{{ route('dashboard.add-queue') }}" method="POST" class="space-y-6">
<form id="addQueueForm" class="space-y-6">
@csrf
<div class="max-w-md">
<div>
@ -158,7 +158,7 @@ class="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:rin
</div>
<div class="flex justify-end">
<button type="submit"
<button type="submit" id="submitBtn"
class="bg-blue-600 hover:bg-blue-700 text-white px-8 py-3 rounded-xl font-semibold transition duration-200 transform hover:scale-105 shadow-lg">
<svg class="w-5 h-5 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
@ -175,7 +175,9 @@ class="bg-blue-600 hover:bg-blue-700 text-white px-8 py-3 rounded-xl font-semibo
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-900">Antrian Saya</h3>
</div>
<div class="overflow-x-auto">
<!-- Desktop Table -->
<div class="hidden lg:block overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
@ -239,27 +241,38 @@ class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-red-100 text-
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div class="flex space-x-2">
@if($antrian->status == 'menunggu')
@if ($antrian->status == 'menunggu')
<button onclick="batalAntrian({{ $antrian->id }})"
class="text-red-600 hover:text-red-900 text-xs">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
<svg class="w-4 h-4" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
Batal
</button>
@endif
@if($antrian->status == 'menunggu')
@if ($antrian->status == 'menunggu')
<a href="{{ route('user.antrian.cetak', $antrian->id) }}" target="_blank"
class="text-blue-600 hover:text-blue-900 text-xs">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"></path>
<svg class="w-4 h-4" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"
d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z">
</path>
</svg>
Cetak
</a>
@else
<span class="text-gray-400 text-xs cursor-not-allowed" title="Tidak dapat dicetak untuk status {{ ucfirst($antrian->status) }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"></path>
<span class="text-gray-400 text-xs cursor-not-allowed"
title="Tidak dapat dicetak untuk status {{ ucfirst($antrian->status) }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"
d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z">
</path>
</svg>
Cetak
</span>
@ -284,6 +297,117 @@ class="text-blue-600 hover:text-blue-900 text-xs">
</tbody>
</table>
</div>
<!-- Mobile Table -->
<div class="lg:hidden">
@forelse($antrianSaya ?? [] as $antrian)
<div class="border-b border-gray-200 p-4 hover:bg-gray-50">
<!-- 4 Kolom Penting untuk Mobile -->
<div class="grid grid-cols-2 gap-4 mb-3">
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">No. Antrian</p>
<span
class="text-lg font-semibold text-primary">{{ $antrian->no_antrian ?? 'N/A' }}</span>
</div>
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">Nama</p>
<div class="text-sm font-medium text-gray-900">{{ $antrian->user->nama ?? 'N/A' }}
</div>
</div>
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">Poli</p>
<span
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">
{{ $antrian->poli->nama_poli ?? 'N/A' }}
</span>
</div>
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">Status</p>
@if (($antrian->status ?? '') == 'menunggu')
<span
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">
Menunggu
</span>
@elseif(($antrian->status ?? '') == 'dipanggil')
<span
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">
Dipanggil
</span>
@elseif(($antrian->status ?? '') == 'selesai')
<span
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">
Selesai
</span>
@else
<span
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800">
Batal
</span>
@endif
</div>
</div>
<!-- Button Selengkapnya untuk Mobile -->
<div class="border-t border-gray-100 pt-3">
<button onclick="toggleUserDashboardDetails({{ $antrian->id }})"
class="text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center">
<span id="user-dashboard-btn-text-{{ $antrian->id }}">Selengkapnya</span>
<svg id="user-dashboard-icon-{{ $antrian->id }}"
class="w-4 h-4 ml-1 transform transition-transform" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<!-- Detail Tambahan (Hidden by default) -->
<div id="user-dashboard-details-{{ $antrian->id }}" class="hidden mt-3 space-y-2">
<div class="grid grid-cols-1 gap-2 text-sm">
<div>
<span class="font-medium text-gray-700">No. HP:</span>
<span class="text-gray-600">{{ $antrian->user->no_hp ?? 'N/A' }}</span>
</div>
<div>
<span class="font-medium text-gray-700">Waktu Daftar:</span>
<span
class="text-gray-600">{{ $antrian->created_at ? $antrian->created_at->format('H:i') : 'N/A' }}</span>
</div>
<div class="flex space-x-2 pt-2">
@if ($antrian->status == 'menunggu')
<button onclick="batalAntrian({{ $antrian->id }})"
class="text-red-600 hover:text-red-900 text-sm font-medium">
Batal
</button>
@endif
@if ($antrian->status == 'menunggu')
<a href="{{ route('user.antrian.cetak', $antrian->id) }}" target="_blank"
class="text-blue-600 hover:text-blue-900 text-sm font-medium">
Cetak
</a>
@else
<span class="text-gray-400 text-sm cursor-not-allowed"
title="Tidak dapat dicetak untuk status {{ ucfirst($antrian->status) }}">
Cetak
</span>
@endif
</div>
</div>
</div>
</div>
</div>
@empty
<div class="p-8 text-center text-gray-500">
<svg class="w-12 h-12 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z">
</path>
</svg>
<p class="text-lg font-medium">Belum ada antrian</p>
<p class="text-sm text-gray-400">Silakan ambil antrian baru di atas</p>
</div>
@endforelse
</div>
</div>
</div>
</div>
@ -474,6 +598,105 @@ function closeEditModal() {
});
@endif
// Handle form submission for adding queue
document.getElementById('addQueueForm').addEventListener('submit', function(e) {
e.preventDefault();
const submitBtn = document.getElementById('submitBtn');
const originalText = submitBtn.innerHTML;
// Disable button and show loading
submitBtn.disabled = true;
submitBtn.innerHTML = `
<svg class="animate-spin -ml-1 mr-3 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...
`;
const formData = new FormData(this);
formData.append('_token', '{{ csrf_token() }}');
fetch('{{ route('dashboard.add-queue') }}', {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Success case
Swal.fire({
icon: 'success',
title: 'Berhasil!',
text: data.message,
confirmButtonText: 'OK',
confirmButtonColor: '#10B981'
}).then(() => {
location.reload();
});
} else {
// Handle different error types
if (data.type === 'existing_queue') {
// User already has queue in same poli
Swal.fire({
icon: 'warning',
title: 'Antrian Sudah Ada!',
html: `
<div class="text-left">
<p class="mb-3">${data.message}</p>
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-3">
<div class="flex items-center">
<svg class="w-5 h-5 text-yellow-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
<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"></path>
</svg>
<span class="font-medium text-yellow-800">Detail Antrian:</span>
</div>
<div class="mt-2 text-sm text-yellow-700">
<p><strong>No. Antrian:</strong> ${data.existing_queue.no_antrian}</p>
<p><strong>Poli:</strong> ${data.existing_queue.poli_name}</p>
<p><strong>Status:</strong> ${data.existing_queue.status === 'menunggu' ? 'Menunggu' : 'Dipanggil'}</p>
<p><strong>Waktu Ambil:</strong> ${data.existing_queue.created_at}</p>
</div>
</div>
<p class="text-sm text-gray-600">Silakan tunggu hingga antrian Anda dipanggil atau batalkan antrian ini terlebih dahulu.</p>
</div>
`,
confirmButtonText: 'OK',
confirmButtonColor: '#F59E0B'
});
} else {
// Generic error
Swal.fire({
icon: 'error',
title: 'Error!',
text: data.message,
confirmButtonText: 'OK',
confirmButtonColor: '#EF4444'
});
}
}
})
.catch(error => {
console.error('Error:', error);
Swal.fire({
icon: 'error',
title: 'Error!',
text: 'Terjadi kesalahan saat mengambil antrian. Silakan coba lagi.',
confirmButtonText: 'OK',
confirmButtonColor: '#EF4444'
});
})
.finally(() => {
// Re-enable button and restore original text
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
});
});
// Function to confirm logout
function confirmLogout() {
Swal.fire({
@ -555,6 +778,23 @@ function batalAntrian(antrianId) {
}
});
}
// Function to toggle user dashboard details
function toggleUserDashboardDetails(antrianId) {
const detailsDiv = document.getElementById(`user-dashboard-details-${antrianId}`);
const buttonText = document.getElementById(`user-dashboard-btn-text-${antrianId}`);
const icon = document.getElementById(`user-dashboard-icon-${antrianId}`);
if (detailsDiv.classList.contains('hidden')) {
detailsDiv.classList.remove('hidden');
buttonText.textContent = 'Lebih Sedikit';
icon.classList.remove('transform', 'rotate-180');
} else {
detailsDiv.classList.add('hidden');
buttonText.textContent = 'Selengkapnya';
icon.classList.add('transform', 'rotate-180');
}
}
</script>
@endpush
@endsection

View File

@ -30,7 +30,7 @@
<div class="mt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Antrian Berikutnya:</h3>
<div class="space-y-3">
<div class="space-y-3" data-next-queue="poli-umum-next">
@forelse($poliUmumNext ?? [] as $antrian)
<div
class="bg-gray-50 rounded-xl p-4 border border-gray-200 hover:shadow-md transition duration-200">
@ -68,7 +68,7 @@ class="text-lg md:text-xl font-bold text-gray-900">{{ $antrian->no_antrian }}</s
<div class="mt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Antrian Berikutnya:</h3>
<div class="space-y-3">
<div class="space-y-3" data-next-queue="poli-gigi-next">
@forelse($poliGigiNext ?? [] as $antrian)
<div
class="bg-gray-50 rounded-xl p-4 border border-gray-200 hover:shadow-md transition duration-200">
@ -106,7 +106,7 @@ class="text-lg md:text-xl font-bold text-gray-900">{{ $antrian->no_antrian }}</s
<div class="mt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Antrian Berikutnya:</h3>
<div class="space-y-3">
<div class="space-y-3" data-next-queue="poli-jiwa-next">
@forelse($poliJiwaNext ?? [] as $antrian)
<div
class="bg-gray-50 rounded-xl p-4 border border-gray-200 hover:shadow-md transition duration-200">
@ -145,7 +145,7 @@ class="text-lg md:text-xl font-bold text-gray-900">{{ $antrian->no_antrian }}</s
<div class="mt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Antrian Berikutnya:</h3>
<div class="space-y-3">
<div class="space-y-3" data-next-queue="poli-tradisional-next">
@forelse($poliTradisionalNext ?? [] as $antrian)
<div
class="bg-gray-50 rounded-xl p-4 border border-gray-200 hover:shadow-md transition duration-200">
@ -206,9 +206,9 @@ class TTSAudioPlayer {
const audioItem = audioSequence[i];
await this.playAudioItem(audioItem);
// Wait between audio items
// Wait between audio items (1 second gap)
if (i < audioSequence.length - 1) {
await this.delay(500);
await this.delay(1000);
}
}
@ -257,7 +257,7 @@ class TTSAudioPlayer {
// Fallback timeout
setTimeout(() => {
resolve();
}, audioItem.duration || 4000);
}, audioItem.duration || 8000);
} else {
console.warn('Speech synthesis not supported');
resolve();
@ -282,7 +282,7 @@ class TTSAudioPlayer {
// Fallback timeout
setTimeout(() => {
resolve();
}, audioItem.duration || 3000);
}, audioItem.duration || 8000);
}
});
}
@ -362,10 +362,7 @@ class TTSAudioPlayer {
});
@endif
// Auto refresh every 5 seconds
setInterval(function() {
location.reload();
}, 5000);
// No more auto refresh - using real-time updates instead
// Add sound effect for new calls
function playNotificationSound() {
@ -386,7 +383,7 @@ function showNewCallNotification(poliName, number) {
Swal.fire({
icon: 'info',
title: 'Panggilan Baru!',
text: `Poli ${poliName} memanggil nomor ${number}`,
text: `Antrian selanjutnya poli ${poliName} nomor ${number}`,
confirmButtonText: 'OK',
confirmButtonColor: '#3B82F6',
timer: 5000,
@ -420,7 +417,98 @@ function addPulseAnimation() {
addPulseAnimation();
});
// Listen for TTS events from admin panel
// Check for new calls using polling (fallback for broadcast)
let lastCallTime = new Date().getTime();
function checkForNewCalls() {
fetch('/api/check-new-calls?last_check=' + lastCallTime)
.then(response => response.json())
.then(data => {
if (data.has_new_call && data.antrian) {
console.log('New call detected:', data.antrian);
// Play TTS for the called queue
ttsPlayer.playQueueCall(data.antrian.poli_name, data.antrian.queue_number);
// Show notification
showNewCallNotification(data.antrian.poli_name, data.antrian.queue_number);
// Add pulse animation to the current number
addPulseAnimation();
// Update last check time
lastCallTime = new Date().getTime();
// Update display without refresh
updateDisplayData();
}
})
.catch(error => {
console.log('Error checking for new calls:', error);
});
}
// Function to update display data without refresh
function updateDisplayData() {
fetch('/api/display-data')
.then(response => response.json())
.then(data => {
// Update current numbers
if (data.poliUmumCurrent) {
document.getElementById('poli-umum-current').textContent = data.poliUmumCurrent.no_antrian;
}
if (data.poliGigiCurrent) {
document.getElementById('poli-gigi-current').textContent = data.poliGigiCurrent.no_antrian;
}
if (data.poliJiwaCurrent) {
document.getElementById('poli-jiwa-current').textContent = data.poliJiwaCurrent.no_antrian;
}
if (data.poliTradisionalCurrent) {
document.getElementById('poli-tradisional-current').textContent = data.poliTradisionalCurrent
.no_antrian;
}
// Update next queues
updateNextQueue('poli-umum-next', data.poliUmumNext);
updateNextQueue('poli-gigi-next', data.poliGigiNext);
updateNextQueue('poli-jiwa-next', data.poliJiwaNext);
updateNextQueue('poli-tradisional-next', data.poliTradisionalNext);
})
.catch(error => {
console.log('Error updating display data:', error);
});
}
// Function to update next queue section
function updateNextQueue(containerId, nextQueues) {
const container = document.querySelector(`[data-next-queue="${containerId}"]`);
if (!container) return;
if (nextQueues && nextQueues.length > 0) {
container.innerHTML = nextQueues.map(antrian => `
<div class="bg-gray-50 rounded-xl p-4 border border-gray-200 hover:shadow-md transition duration-200">
<span class="text-lg md:text-xl font-bold text-gray-900">${antrian.no_antrian}</span>
</div>
`).join('');
} else {
container.innerHTML = `
<div class="text-gray-500 text-center py-6 bg-gray-50 rounded-xl">
<svg class="w-8 h-8 mx-auto mb-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<p class="text-sm">Tidak ada antrian</p>
</div>
`;
}
}
// Check for new calls every 3 seconds
setInterval(checkForNewCalls, 3000);
// Update display data every 10 seconds
setInterval(updateDisplayData, 10000);
// Fallback: Listen for TTS events from admin panel (if broadcast not available)
window.addEventListener('message', function(event) {
if (event.data.type === 'TTS_CALL') {
const {

View File

@ -7,6 +7,7 @@
use App\Http\Controllers\DisplayController;
use App\Http\Controllers\AdminController;
use App\Http\Controllers\TTSController;
use App\Http\Controllers\IndonesianTTSController;
// Landing Page
Route::get('/', [LandingController::class, 'index'])->name('landing');
@ -67,6 +68,8 @@
// User Management Routes
Route::get('/admin/users', [AdminController::class, 'manageUsers'])->name('admin.users.index');
Route::get('/admin/users/create', [AdminController::class, 'createUser'])->name('admin.users.create');
Route::post('/admin/users', [AdminController::class, 'storeUser'])->name('admin.users.store');
Route::get('/admin/users/{user}', [AdminController::class, 'showUser'])->name('admin.users.show');
Route::put('/admin/users/{user}', [AdminController::class, 'updateUser'])->name('admin.users.update');
Route::post('/admin/users/{user}/reset-password', [AdminController::class, 'resetUserPassword'])->name('admin.users.reset-password');
@ -83,8 +86,21 @@
Route::post('/admin/tts/generate', [TTSController::class, 'generateQueueCall'])->name('admin.tts.generate');
Route::post('/admin/tts/audio-sequence', [TTSController::class, 'getAudioSequence'])->name('admin.tts.audio-sequence');
Route::post('/admin/tts/play-sequence', [TTSController::class, 'playAudioSequence'])->name('admin.tts.play-sequence');
// Indonesian TTS Routes
Route::post('/admin/indonesian-tts/generate', [IndonesianTTSController::class, 'generateQueueCall'])->name('admin.indonesian-tts.generate');
Route::post('/admin/indonesian-tts/audio-sequence', [IndonesianTTSController::class, 'createAudioSequence'])->name('admin.indonesian-tts.audio-sequence');
Route::get('/admin/indonesian-tts/status', [IndonesianTTSController::class, 'checkStatus'])->name('admin.indonesian-tts.status');
Route::post('/admin/indonesian-tts/test', [IndonesianTTSController::class, 'testTTS'])->name('admin.indonesian-tts.test');
Route::get('/admin/indonesian-tts/install', [IndonesianTTSController::class, 'getInstallationInstructions'])->name('admin.indonesian-tts.install');
Route::get('/admin/indonesian-tts/download', [IndonesianTTSController::class, 'downloadModelFiles'])->name('admin.indonesian-tts.download');
Route::get('/admin/indonesian-tts', [IndonesianTTSController::class, 'index'])->name('admin.indonesian-tts.index');
});
// Public TTS Routes (for display)
Route::post('/tts/play-sequence', [TTSController::class, 'playAudioSequence'])->name('tts.play-sequence');
// API Routes for display
Route::get('/api/check-new-calls', [DisplayController::class, 'checkNewCalls'])->name('api.check-new-calls');
Route::get('/api/display-data', [DisplayController::class, 'getDisplayData'])->name('api.display-data');

54
test_audio_sequence.php Normal file
View File

@ -0,0 +1,54 @@
<?php
/**
* Test Audio Sequence - Puskesmas TTS System
*
* File ini untuk testing audio sequence yang sudah disesuaikan:
* 1. Attention Sound (4 detik)
* 2. Jeda (1 detik)
* 3. TTS (8 detik)
* 4. Selesai
*
* Total: 13 detik
*/
// Test function untuk memverifikasi timing
function testAudioSequence()
{
echo "=== TEST AUDIO SEQUENCE ===\n";
echo "Urutan yang diharapkan:\n";
echo "1. Attention Sound: 4 detik\n";
echo "2. Jeda: 1 detik\n";
echo "3. TTS: 8 detik\n";
echo "4. Selesai\n";
echo "Total: 13 detik\n\n";
echo "Status: Audio sequence sudah disesuaikan\n";
echo "- Attention sound kedua dihilangkan\n";
echo "- TTS durasi 8 detik untuk memastikan selesai\n";
echo "- Jeda antar audio 1 detik\n\n";
echo "Untuk test:\n";
echo "1. Buka halaman admin\n";
echo "2. Klik tombol 'Panggil' pada antrian\n";
echo "3. Dengarkan di halaman display\n";
echo "4. Pastikan urutan: Attention → Jeda → TTS → Selesai\n";
}
// Jalankan test
testAudioSequence();
echo "\n=== DETAIL PERUBAHAN ===\n";
echo "File yang diubah:\n";
echo "- app/Services/TTSService.php\n";
echo "- app/Services/IndonesianTTSService.php\n";
echo "- resources/views/display/index.blade.php\n";
echo "- AUDIO_TIMING_GUIDE.md\n\n";
echo "Perubahan utama:\n";
echo "1. Menghilangkan attention sound kedua\n";
echo "2. Menambah durasi TTS dari 6 detik menjadi 8 detik\n";
echo "3. Menambah jeda antar audio dari 500ms menjadi 1000ms\n";
echo "4. Update dokumentasi timing\n";
?>

View File

@ -0,0 +1,169 @@
<?php
/**
* Test Indonesian Pronunciation Conversion
*
* This file tests the Indonesian pronunciation conversion logic
* that converts alphanumeric queue numbers to Indonesian pronunciation.
*/
// Indonesian number words (same as in TTSService)
$indonesianNumbers = [
'0' => 'Nol',
'1' => 'Satu',
'2' => 'Dua',
'3' => 'Tiga',
'4' => 'Empat',
'5' => 'Lima',
'6' => 'Enam',
'7' => 'Tujuh',
'8' => 'Delapan',
'9' => 'Sembilan',
'10' => 'Sepuluh',
'11' => 'Sebelas',
'12' => 'Dua Belas',
'13' => 'Tiga Belas',
'14' => 'Empat Belas',
'15' => 'Lima Belas',
'16' => 'Enam Belas',
'17' => 'Tujuh Belas',
'18' => 'Delapan Belas',
'19' => 'Sembilan Belas',
'20' => 'Dua Puluh',
'30' => 'Tiga Puluh',
'40' => 'Empat Puluh',
'50' => 'Lima Puluh',
'60' => 'Enam Puluh',
'70' => 'Tujuh Puluh',
'80' => 'Delapan Puluh',
'90' => 'Sembilan Puluh',
'100' => 'Seratus'
];
/**
* Convert alphanumeric queue number to Indonesian pronunciation
* Example: "U5" becomes "U Lima", "A10" becomes "A Sepuluh"
*/
function convertQueueNumberToIndonesian($queueNumber) {
global $indonesianNumbers;
// If it's a pure number, convert it
if (is_numeric($queueNumber)) {
$number = (int)$queueNumber;
if (isset($indonesianNumbers[$number])) {
return $indonesianNumbers[$number];
} else {
// For numbers > 100, build the pronunciation
if ($number < 100) {
$tens = floor($number / 10) * 10;
$ones = $number % 10;
if ($ones == 0) {
return $indonesianNumbers[$tens];
} else {
return $indonesianNumbers[$tens] . ' ' . $indonesianNumbers[$ones];
}
} else {
return $number; // Fallback for large numbers
}
}
}
// For alphanumeric (like "U5", "A10"), convert the numeric part
$letters = '';
$numbers = '';
// Split into letters and numbers
for ($i = 0; $i < strlen($queueNumber); $i++) {
$char = $queueNumber[$i];
if (is_numeric($char)) {
$numbers .= $char;
} else {
$letters .= $char;
}
}
// If we have both letters and numbers
if ($letters && $numbers) {
$numberValue = (int)$numbers;
if (isset($indonesianNumbers[$numberValue])) {
return $letters . ' ' . $indonesianNumbers[$numberValue];
} else {
// For numbers > 100, build the pronunciation
if ($numberValue < 100) {
$tens = floor($numberValue / 10) * 10;
$ones = $numberValue % 10;
if ($ones == 0) {
return $letters . ' ' . $indonesianNumbers[$tens];
} else {
return $letters . ' ' . $indonesianNumbers[$tens] . ' ' . $indonesianNumbers[$ones];
}
} else {
return $queueNumber; // Fallback for large numbers
}
}
}
// If no conversion needed, return as is
return $queueNumber;
}
// Test cases
echo "=== Indonesian Pronunciation Conversion Test ===\n\n";
$testCases = [
// Pure numbers
'1' => 'Satu',
'5' => 'Lima',
'10' => 'Sepuluh',
'15' => 'Lima Belas',
'25' => 'Dua Puluh Lima',
'100' => 'Seratus',
// Alphanumeric queue numbers
'U5' => 'U Lima',
'A10' => 'A Sepuluh',
'B15' => 'B Lima Belas',
'C25' => 'C Dua Puluh Lima',
'D100' => 'D Seratus',
// Edge cases
'ABC123' => 'ABC Seratus Dua Puluh Tiga',
'X1' => 'X Satu',
'Y0' => 'Y Nol',
'Z99' => 'Z Sembilan Puluh Sembilan',
// Non-alphanumeric (should remain unchanged)
'POLI-UMUM' => 'POLI-UMUM',
'ANTRIAN-1' => 'ANTRIAN-1', // Mixed with dash
];
echo "Test Results:\n";
echo str_repeat('-', 50) . "\n";
foreach ($testCases as $input => $expected) {
$result = convertQueueNumberToIndonesian($input);
$status = ($result === $expected) ? '✓ PASS' : '✗ FAIL';
echo sprintf("%-15s → %-25s [%s]\n", $input, $result, $status);
if ($result !== $expected) {
echo " Expected: $expected\n";
}
}
echo "\n" . str_repeat('-', 50) . "\n";
echo "Test completed!\n\n";
// Additional examples for user verification
echo "=== Examples for User Verification ===\n";
echo "Queue Number → Indonesian Pronunciation\n";
echo str_repeat('-', 40) . "\n";
$examples = ['U5', 'A10', 'B15', 'C25', 'D100', 'X1', 'Y0', 'Z99'];
foreach ($examples as $example) {
$pronunciation = convertQueueNumberToIndonesian($example);
echo "$example$pronunciation\n";
}
echo "\nThese examples show how alphanumeric queue numbers\n";
echo "are converted to Indonesian pronunciation for TTS.\n";
echo "For example, 'U5' becomes 'U Lima' instead of 'U five'.\n";

View File

@ -1 +0,0 @@

View File

@ -1,35 +0,0 @@
<?php
/**
* Test file untuk fitur TTS
* Jalankan dengan: php test_tts.php
*/
require_once 'vendor/autoload.php';
use App\Services\TTSService;
echo "=== Test TTS Service ===\n";
try {
$ttsService = new TTSService();
echo "1. Testing generateQueueCall...\n";
$result = $ttsService->generateQueueCall('Poli Umum', 'A001');
echo "Result: " . json_encode($result, JSON_PRETTY_PRINT) . "\n\n";
echo "2. Testing createCompleteAudioSequence...\n";
$sequence = $ttsService->createCompleteAudioSequence('Poli Umum', 'A001');
echo "Sequence: " . json_encode($sequence, JSON_PRETTY_PRINT) . "\n\n";
echo "3. Testing dengan poli berbeda...\n";
$sequence2 = $ttsService->createCompleteAudioSequence('Poli Gigi', 'B002');
echo "Sequence 2: " . json_encode($sequence2, JSON_PRETTY_PRINT) . "\n\n";
echo "✅ TTS Service berfungsi dengan baik!\n";
} catch (Exception $e) {
echo "❌ Error: " . $e->getMessage() . "\n";
echo "Stack trace: " . $e->getTraceAsString() . "\n";
}
echo "\n=== Test Selesai ===\n";

View File

@ -1,53 +0,0 @@
<?php
/**
* Simple test untuk fitur TTS
* Test manual untuk memverifikasi logika TTS
*/
echo "=== Simple TTS Test ===\n";
// Test 1: Check file audio exists
$attentionSound = 'public/assets/music/call-to-attention-123107.mp3';
if (file_exists($attentionSound)) {
echo "✅ Attention sound file exists: $attentionSound\n";
} else {
echo "❌ Attention sound file not found: $attentionSound\n";
}
// Test 2: Check storage directory
$storageDir = 'public/storage/audio/queue_calls';
if (is_dir($storageDir)) {
echo "✅ Storage directory exists: $storageDir\n";
} else {
echo "❌ Storage directory not found: $storageDir\n";
}
// Test 3: Check symbolic link
$storageLink = 'public/storage';
if (is_link($storageLink)) {
echo "✅ Storage symbolic link exists: $storageLink\n";
} else {
echo "❌ Storage symbolic link not found: $storageLink\n";
}
// Test 4: Simulate TTS sequence
echo "\n=== Simulating TTS Sequence ===\n";
$poliName = 'Poli Umum';
$queueNumber = 'A001';
echo "1. Attention Sound: $attentionSound\n";
echo "2. TTS Text: \"Nomor antrian $queueNumber, silakan menuju ke $poliName\"\n";
echo "3. Attention Sound: $attentionSound\n";
echo "\n=== Test Configuration ===\n";
echo "Google TTS API Key: " . (getenv('GOOGLE_TTS_API_KEY') ? 'Set' : 'Not Set') . "\n";
echo "Browser TTS: Available (Speech Synthesis API)\n";
echo "\n=== Manual Testing Instructions ===\n";
echo "1. Buka halaman admin: /admin/poli/umum\n";
echo "2. Klik tombol 'Panggil' pada antrian\n";
echo "3. Periksa console browser untuk log\n";
echo "4. Buka halaman display: /display\n";
echo "5. Test cross-page communication\n";
echo "\n✅ Simple TTS Test Complete!\n";

View File

@ -1,80 +0,0 @@
<?php
/**
* Test script untuk memverifikasi konfigurasi suara TTS
*/
echo "=== TTS Voice Configuration Test ===\n\n";
// 1. Test Google TTS Configuration
echo "1. Google TTS Configuration:\n";
echo " - Voice: id-ID-Wavenet-A (Suara wanita Indonesia natural)\n";
echo " - Language: id-ID\n";
echo " - Gender: FEMALE\n";
echo " - Speaking Rate: 0.85 (sedikit lebih cepat untuk alur natural)\n";
echo " - Audio Encoding: MP3\n\n";
// 2. Available Google TTS Voices for Indonesian
echo "2. Available Google TTS Voices for Indonesian:\n";
echo " Wavenet Voices (Lebih Natural):\n";
echo " - id-ID-Wavenet-A ⭐ (DIGUNAKAN - Suara wanita natural)\n";
echo " - id-ID-Wavenet-B (Suara pria natural)\n";
echo " - id-ID-Wavenet-C (Suara wanita alternatif)\n";
echo " - id-ID-Wavenet-D (Suara pria alternatif)\n\n";
echo " Standard Voices (Lebih Cepat):\n";
echo " - id-ID-Standard-A (Suara wanita standar)\n";
echo " - id-ID-Standard-B (Suara pria standar)\n";
echo " - id-ID-Standard-C (Suara wanita alternatif)\n";
echo " - id-ID-Standard-D (Suara pria alternatif)\n\n";
// 3. Browser TTS Configuration
echo "3. Browser TTS Configuration:\n";
echo " - Language: id-ID\n";
echo " - Rate: 0.85\n";
echo " - Volume: 1.0\n";
echo " - Voice Selection: Auto-select female Indonesian voice if available\n\n";
// 4. Test Text
echo "4. Test Text:\n";
echo " \"Nomor antrian 001, silakan menuju ke Poli Umum\"\n\n";
// 5. Audio Sequence
echo "5. Audio Sequence:\n";
echo " 1. Attention sound (2 detik)\n";
echo " 2. TTS announcement (4 detik)\n";
echo " 3. Attention sound (2 detik)\n\n";
// 6. Voice Quality Comparison
echo "6. Voice Quality Comparison:\n";
echo " Wavenet Voices:\n";
echo " ✅ Suara lebih natural dan manusiawi\n";
echo " ✅ Intonasi yang lebih baik\n";
echo " ✅ Pengucapan yang lebih akurat\n";
echo " ⚠️ Lebih lambat dalam generate\n";
echo " ⚠️ Lebih mahal (2x lipat)\n\n";
echo " Standard Voices:\n";
echo " ✅ Lebih cepat dalam generate\n";
echo " ✅ Lebih murah\n";
echo " ⚠️ Suara lebih robotik\n";
echo " ⚠️ Intonasi kurang natural\n\n";
// 7. Recommendations
echo "7. Recommendations:\n";
echo " - Untuk Produksi: id-ID-Wavenet-A (suara wanita natural)\n";
echo " - Untuk Testing: id-ID-Standard-A (lebih cepat dan murah)\n";
echo " - Fallback: Browser TTS dengan konfigurasi yang sudah dioptimalkan\n\n";
// 8. How to test
echo "8. How to Test:\n";
echo " a. Google TTS: Set GOOGLE_TTS_API_KEY di .env\n";
echo " b. Browser TTS: Buka console browser dan jalankan:\n";
echo " const utterance = new SpeechSynthesisUtterance(\"Nomor antrian 001, silakan menuju ke Poli Umum\");\n";
echo " utterance.lang = 'id-ID';\n";
echo " utterance.rate = 0.85;\n";
echo " speechSynthesis.speak(utterance);\n\n";
echo "=== Test selesai ===\n";
echo "Konfigurasi saat ini menggunakan suara wanita Indonesia yang natural dan fasih.\n";
echo "Jika ingin mengubah suara, edit file app/Services/TTSService.php\n";
?>