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:
parent
e0306d5579
commit
879ee41c76
|
@ -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.
|
194
README_TTS.md
194
README_TTS.md
|
@ -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
|
|
|
@ -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
|
|
|
@ -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
|
|
|
@ -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
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
|
@ -86,6 +86,45 @@ public function showUser(User $user)
|
||||||
return view('admin.users.show', compact('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)
|
public function updateUser(Request $request, User $user)
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
|
@ -363,8 +402,11 @@ public function panggilAntrian(Request $request)
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update status to 'dipanggil'
|
// Update status to 'dipanggil' and set call time
|
||||||
$antrian->update(['status' => 'dipanggil']);
|
$antrian->update([
|
||||||
|
'status' => 'dipanggil',
|
||||||
|
'waktu_panggil' => now()
|
||||||
|
]);
|
||||||
|
|
||||||
// Record call history
|
// Record call history
|
||||||
RiwayatPanggilan::create([
|
RiwayatPanggilan::create([
|
||||||
|
@ -394,8 +436,11 @@ public function panggilAntrianById(Antrian $antrian)
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update status to 'dipanggil'
|
// Update status to 'dipanggil' and set call time
|
||||||
$antrian->update(['status' => 'dipanggil']);
|
$antrian->update([
|
||||||
|
'status' => 'dipanggil',
|
||||||
|
'waktu_panggil' => now()
|
||||||
|
]);
|
||||||
|
|
||||||
// Record call history
|
// Record call history
|
||||||
RiwayatPanggilan::create([
|
RiwayatPanggilan::create([
|
||||||
|
@ -403,17 +448,12 @@ public function panggilAntrianById(Antrian $antrian)
|
||||||
'waktu_panggilan' => now()
|
'waktu_panggilan' => now()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Generate TTS audio sequence
|
// Broadcast event for display page
|
||||||
$ttsService = new TTSService();
|
event(new \App\Events\AntrianDipanggil($antrian));
|
||||||
$audioSequence = $ttsService->createCompleteAudioSequence(
|
|
||||||
$antrian->poli->nama_poli,
|
|
||||||
$antrian->no_antrian
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => 'Antrian ' . $antrian->no_antrian . ' dipanggil',
|
'message' => 'Antrian ' . $antrian->no_antrian . ' dipanggil',
|
||||||
'audio_sequence' => $audioSequence,
|
|
||||||
'poli_name' => $antrian->poli->nama_poli,
|
'poli_name' => $antrian->poli->nama_poli,
|
||||||
'queue_number' => $antrian->no_antrian
|
'queue_number' => $antrian->no_antrian
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -33,6 +33,8 @@ public function addQueue(Request $request)
|
||||||
DB::beginTransaction();
|
DB::beginTransaction();
|
||||||
|
|
||||||
$user = Auth::user();
|
$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
|
// Check if user already has a queue today for the same poli
|
||||||
$existingQueue = Antrian::where('user_id', $user->id)
|
$existingQueue = Antrian::where('user_id', $user->id)
|
||||||
|
@ -42,13 +44,31 @@ public function addQueue(Request $request)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($existingQueue) {
|
if ($existingQueue) {
|
||||||
// Get poli name for error message
|
// Check if request wants JSON response
|
||||||
$poliName = Poli::find($request->poli_id)->nama_poli;
|
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.');
|
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
|
// Get poli info for prefix
|
||||||
$poli = Poli::find($request->poli_id);
|
|
||||||
$prefix = $this->getPoliPrefix($poli->nama_poli);
|
$prefix = $this->getPoliPrefix($poli->nama_poli);
|
||||||
|
|
||||||
// Get next queue number for the poli
|
// Get next queue number for the poli
|
||||||
|
@ -76,12 +96,31 @@ public function addQueue(Request $request)
|
||||||
|
|
||||||
DB::commit();
|
DB::commit();
|
||||||
|
|
||||||
// Get poli name for success message
|
if ($request->expectsJson()) {
|
||||||
$poliName = $poli->nama_poli;
|
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);
|
return redirect()->back()->with('success', 'Antrian berhasil diambil! Nomor antrian Anda di ' . $poliName . ': ' . $nextQueueNumber);
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
DB::rollback();
|
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());
|
return redirect()->back()->with('error', 'Terjadi kesalahan saat mengambil antrian: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -202,7 +241,7 @@ public function cetakAntrian(Antrian $antrian)
|
||||||
}
|
}
|
||||||
|
|
||||||
$antrian->load(['user', 'poli']);
|
$antrian->load(['user', 'poli']);
|
||||||
|
|
||||||
$pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('user.antrian.print', compact('antrian'));
|
$pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('user.antrian.print', compact('antrian'));
|
||||||
return $pdf->stream('antrian-' . $antrian->no_antrian . '.pdf');
|
return $pdf->stream('antrian-' . $antrian->no_antrian . '.pdf');
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
|
|
@ -75,4 +75,100 @@ public function index()
|
||||||
'poliTradisionalNext'
|
'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
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
|
@ -74,22 +74,21 @@ public function getAudioSequence(Request $request)
|
||||||
public function playAudioSequence(Request $request)
|
public function playAudioSequence(Request $request)
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'antrian_id' => 'required|exists:antrians,id'
|
'poli_name' => 'required|string',
|
||||||
|
'queue_number' => 'required|string'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$antrian = Antrian::with('poli')->findOrFail($request->antrian_id);
|
|
||||||
|
|
||||||
$audioSequence = $this->ttsService->createCompleteAudioSequence(
|
$audioSequence = $this->ttsService->createCompleteAudioSequence(
|
||||||
$antrian->poli->nama_poli,
|
$request->poli_name,
|
||||||
$antrian->no_antrian
|
$request->queue_number
|
||||||
);
|
);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'audio_sequence' => $audioSequence,
|
'audio_sequence' => $audioSequence,
|
||||||
'poli_name' => $antrian->poli->nama_poli,
|
'poli_name' => $request->poli_name,
|
||||||
'queue_number' => $antrian->no_antrian
|
'queue_number' => $request->queue_number
|
||||||
]);
|
]);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|
|
@ -46,4 +46,22 @@ public function riwayatPanggilan()
|
||||||
{
|
{
|
||||||
return $this->hasMany(RiwayatPanggilan::class);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -15,14 +15,116 @@ public function __construct()
|
||||||
$this->apiKey = config('services.google.tts_api_key');
|
$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
|
* Generate TTS audio for queue call
|
||||||
*/
|
*/
|
||||||
public function generateQueueCall($poliName, $queueNumber)
|
public function generateQueueCall($poliName, $queueNumber)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
// Convert queue number to Indonesian pronunciation
|
||||||
|
$indonesianQueueNumber = $this->convertQueueNumberToIndonesian($queueNumber);
|
||||||
|
|
||||||
// Create the text to be spoken
|
// Create the text to be spoken
|
||||||
$text = "Nomor antrian {$queueNumber}, silakan menuju ke {$poliName}";
|
$text = "antrian selanjutnya poli {$poliName} nomor {$indonesianQueueNumber}";
|
||||||
|
|
||||||
// Generate TTS audio
|
// Generate TTS audio
|
||||||
$audioContent = $this->synthesizeSpeech($text);
|
$audioContent = $this->synthesizeSpeech($text);
|
||||||
|
@ -108,15 +210,15 @@ public function createCompleteAudioSequence($poliName, $queueNumber)
|
||||||
{
|
{
|
||||||
$audioFiles = [];
|
$audioFiles = [];
|
||||||
|
|
||||||
// 1. Attention sound
|
// 1. Attention sound (4 seconds - actual file duration)
|
||||||
$attentionSound = asset('assets/music/call-to-attention-123107.mp3');
|
$attentionSound = asset('assets/music/call-to-attention-123107.mp3');
|
||||||
$audioFiles[] = [
|
$audioFiles[] = [
|
||||||
'type' => 'attention',
|
'type' => 'attention',
|
||||||
'url' => $attentionSound,
|
'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);
|
$ttsResult = $this->generateQueueCall($poliName, $queueNumber);
|
||||||
if ($ttsResult['success']) {
|
if ($ttsResult['success']) {
|
||||||
if ($ttsResult['use_browser_tts']) {
|
if ($ttsResult['use_browser_tts']) {
|
||||||
|
@ -124,25 +226,18 @@ public function createCompleteAudioSequence($poliName, $queueNumber)
|
||||||
$audioFiles[] = [
|
$audioFiles[] = [
|
||||||
'type' => 'browser_tts',
|
'type' => 'browser_tts',
|
||||||
'text' => $ttsResult['text'],
|
'text' => $ttsResult['text'],
|
||||||
'duration' => 4000 // 4 seconds
|
'duration' => 8000 // 8 seconds - longer for natural speech
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
// Use generated audio file
|
// Use generated audio file
|
||||||
$audioFiles[] = [
|
$audioFiles[] = [
|
||||||
'type' => 'tts',
|
'type' => 'tts',
|
||||||
'url' => $ttsResult['audio_url'],
|
'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;
|
return $audioFiles;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,8 @@
|
||||||
"barryvdh/laravel-dompdf": "^3.1",
|
"barryvdh/laravel-dompdf": "^3.1",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/sanctum": "^4.2",
|
"laravel/sanctum": "^4.2",
|
||||||
"laravel/tinker": "^2.10.1"
|
"laravel/tinker": "^2.10.1",
|
||||||
|
"pusher/pusher-php-server": "^7.2"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "efff1cd36f67794eb042a3b6c431b4a9",
|
"content-hash": "6a7319662ab12a24a54850a3e8a7b468",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "barryvdh/laravel-dompdf",
|
"name": "barryvdh/laravel-dompdf",
|
||||||
|
@ -2873,6 +2873,97 @@
|
||||||
],
|
],
|
||||||
"time": "2025-05-08T08:14:37+00:00"
|
"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",
|
"name": "phpoption/phpoption",
|
||||||
"version": "1.9.3",
|
"version": "1.9.3",
|
||||||
|
@ -3438,6 +3529,67 @@
|
||||||
},
|
},
|
||||||
"time": "2025-08-04T12:39:37+00:00"
|
"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",
|
"name": "ralouphie/getallheaders",
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
|
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
|
@ -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! 🚀"
|
|
@ -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">
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
<h3 class="text-lg font-semibold text-gray-900">Antrian Terbaru</h3>
|
<h3 class="text-lg font-semibold text-gray-900">Antrian Terbaru</h3>
|
||||||
</div>
|
</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 class="min-w-full divide-y divide-gray-200">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -309,6 +311,102 @@ class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-red-100 text-
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</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>
|
</script>
|
||||||
@endpush
|
@endpush
|
||||||
@endsection
|
@endsection
|
||||||
|
|
|
@ -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
|
|
@ -182,7 +182,8 @@ class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="tanggal_mulai" class="block text-sm font-medium text-gray-700 mb-1">Tanggal
|
<label for="tanggal_mulai" class="block text-sm font-medium text-gray-700 mb-1">Tanggal
|
||||||
Mulai</label>
|
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">
|
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>
|
</div>
|
||||||
|
|
||||||
|
@ -190,7 +191,8 @@ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none foc
|
||||||
<div>
|
<div>
|
||||||
<label for="tanggal_akhir" class="block text-sm font-medium text-gray-700 mb-1">Tanggal
|
<label for="tanggal_akhir" class="block text-sm font-medium text-gray-700 mb-1">Tanggal
|
||||||
Akhir</label>
|
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">
|
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>
|
</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">
|
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>
|
<option value="">Semua Poli</option>
|
||||||
@foreach ($polis as $poli)
|
@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 }}
|
{{ $poli->nama_poli }}
|
||||||
</option>
|
</option>
|
||||||
@endforeach
|
@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"
|
<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">
|
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="">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>
|
||||||
<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>
|
</select>
|
||||||
</div>
|
</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"
|
<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">
|
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="">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>
|
Laki-laki</option>
|
||||||
<option value="perempuan" {{ request('jenis_kelamin') == 'perempuan' ? 'selected' : '' }}>
|
<option value="perempuan"
|
||||||
|
{{ request('jenis_kelamin') == 'perempuan' ? 'selected' : '' }}>
|
||||||
Perempuan</option>
|
Perempuan</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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 items-center">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
|
<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"
|
<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">
|
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>
|
</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">
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
<h3 class="text-lg font-semibold text-gray-900">Data Antrian</h3>
|
<h3 class="text-lg font-semibold text-gray-900">Data Antrian</h3>
|
||||||
</div>
|
</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 class="min-w-full divide-y divide-gray-200">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<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>
|
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>
|
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>
|
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>
|
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>
|
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>
|
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>
|
Waktu Panggil</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</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>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<div>
|
<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 class="text-sm text-gray-500">{{ $item->user->no_ktp }}</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
@ -418,6 +438,105 @@ class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium b
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -492,6 +611,23 @@ function confirmLogout() {
|
||||||
confirmButtonColor: '#EF4444'
|
confirmButtonColor: '#EF4444'
|
||||||
});
|
});
|
||||||
@endif
|
@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>
|
</script>
|
||||||
@endpush
|
@endpush
|
||||||
@endsection
|
@endsection
|
||||||
|
|
|
@ -153,7 +153,8 @@ class="flex items-center px-4 py-3 rounded-xl text-gray-700 hover:bg-gray-50 tra
|
||||||
|
|
||||||
<!-- Table -->
|
<!-- Table -->
|
||||||
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
|
<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">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<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">
|
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>
|
</path>
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-lg font-medium">Belum ada antrian</p>
|
<p class="text-lg font-medium">Tidak ada antrian</p>
|
||||||
<p class="text-sm text-gray-400">Antrian akan muncul di sini setelah ada
|
<p class="text-sm text-gray-400">Belum ada antrian yang terdaftar</p>
|
||||||
pendaftaran</p>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@endforelse
|
@endforelse
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -305,27 +423,30 @@ function panggil(url) {
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(d => {
|
.then(d => {
|
||||||
if (d.success) {
|
if (d.success) {
|
||||||
// Play TTS on display page
|
// Show success alert with sound
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
icon: 'success',
|
icon: 'success',
|
||||||
title: 'Berhasil',
|
title: 'Antrian Dipanggil!',
|
||||||
text: d.message
|
text: `Antrian selanjutnya poli ${d.poli_name} nomor ${d.queue_number}`,
|
||||||
}).then(() => location.reload());
|
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 {
|
} else {
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
icon: 'warning',
|
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 to play TTS locally (fallback)
|
||||||
function playTTSLocally(poliName, queueNumber) {
|
function playTTSLocally(poliName, queueNumber) {
|
||||||
// Create audio sequence manually
|
// Create audio sequence manually
|
||||||
|
@ -353,7 +577,9 @@ function playTTSLocally(poliName, queueNumber) {
|
||||||
|
|
||||||
// After attention sound, use browser TTS for poli and number
|
// After attention sound, use browser TTS for poli and number
|
||||||
setTimeout(() => {
|
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) {
|
if ('speechSynthesis' in window) {
|
||||||
const utterance = new SpeechSynthesisUtterance(text);
|
const utterance = new SpeechSynthesisUtterance(text);
|
||||||
utterance.lang = 'id-ID';
|
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>
|
</script>
|
||||||
@endpush
|
@endpush
|
||||||
@endsection
|
@endsection
|
||||||
|
|
|
@ -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
|
|
@ -155,16 +155,20 @@ class="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden hidden transition dur
|
||||||
|
|
||||||
<!-- Search Box -->
|
<!-- Search Box -->
|
||||||
<div class="bg-white rounded-2xl shadow-xl p-6 mb-8">
|
<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">
|
<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">
|
<div class="relative">
|
||||||
<input type="text" id="search" name="search" value="{{ request('search') }}"
|
<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"
|
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...">
|
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">
|
<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">
|
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</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"
|
<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">
|
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">
|
<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>
|
</svg>
|
||||||
Cari
|
Cari
|
||||||
</button>
|
</button>
|
||||||
|
@ -187,10 +192,20 @@ class="px-6 py-3 border border-gray-300 rounded-xl text-gray-700 font-medium hov
|
||||||
|
|
||||||
<!-- Users Table -->
|
<!-- Users Table -->
|
||||||
<div class="bg-white rounded-2xl shadow-xl overflow-hidden animate-slide-up">
|
<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>
|
<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>
|
||||||
<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">
|
<table class="min-w-full divide-y divide-gray-200 table-fixed">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -266,6 +281,85 @@ class="text-green-600 hover:text-green-900">Reset Password</button>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -529,6 +623,23 @@ function confirmLogout() {
|
||||||
timerProgressBar: true
|
timerProgressBar: true
|
||||||
});
|
});
|
||||||
@endif
|
@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>
|
</script>
|
||||||
@endpush
|
@endpush
|
||||||
@endsection
|
@endsection
|
||||||
|
|
|
@ -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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form action="{{ route('dashboard.add-queue') }}" method="POST" class="space-y-6">
|
<form id="addQueueForm" class="space-y-6">
|
||||||
@csrf
|
@csrf
|
||||||
<div class="max-w-md">
|
<div class="max-w-md">
|
||||||
<div>
|
<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>
|
||||||
|
|
||||||
<div class="flex justify-end">
|
<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">
|
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">
|
<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"
|
<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">
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
<h3 class="text-lg font-semibold text-gray-900">Antrian Saya</h3>
|
<h3 class="text-lg font-semibold text-gray-900">Antrian Saya</h3>
|
||||||
</div>
|
</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 class="min-w-full divide-y divide-gray-200">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -239,27 +241,38 @@ class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-red-100 text-
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
@if($antrian->status == 'menunggu')
|
@if ($antrian->status == 'menunggu')
|
||||||
<button onclick="batalAntrian({{ $antrian->id }})"
|
<button onclick="batalAntrian({{ $antrian->id }})"
|
||||||
class="text-red-600 hover:text-red-900 text-xs">
|
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">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Batal
|
Batal
|
||||||
</button>
|
</button>
|
||||||
@endif
|
@endif
|
||||||
@if($antrian->status == 'menunggu')
|
@if ($antrian->status == 'menunggu')
|
||||||
<a href="{{ route('user.antrian.cetak', $antrian->id) }}" target="_blank"
|
<a href="{{ route('user.antrian.cetak', $antrian->id) }}" target="_blank"
|
||||||
class="text-blue-600 hover:text-blue-900 text-xs">
|
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">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor"
|
||||||
<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>
|
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>
|
</svg>
|
||||||
Cetak
|
Cetak
|
||||||
</a>
|
</a>
|
||||||
@else
|
@else
|
||||||
<span class="text-gray-400 text-xs cursor-not-allowed" title="Tidak dapat dicetak untuk status {{ ucfirst($antrian->status) }}">
|
<span class="text-gray-400 text-xs cursor-not-allowed"
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
title="Tidak dapat dicetak untuk status {{ ucfirst($antrian->status) }}">
|
||||||
<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>
|
</svg>
|
||||||
Cetak
|
Cetak
|
||||||
</span>
|
</span>
|
||||||
|
@ -284,6 +297,117 @@ class="text-blue-600 hover:text-blue-900 text-xs">
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -474,6 +598,105 @@ function closeEditModal() {
|
||||||
});
|
});
|
||||||
@endif
|
@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 to confirm logout
|
||||||
function confirmLogout() {
|
function confirmLogout() {
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
|
@ -521,40 +744,57 @@ function batalAntrian(antrianId) {
|
||||||
formData.append('_token', document.querySelector('meta[name="csrf-token"]').content);
|
formData.append('_token', document.querySelector('meta[name="csrf-token"]').content);
|
||||||
|
|
||||||
fetch('/user/antrian/batal', {
|
fetch('/user/antrian/batal', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
icon: 'success',
|
icon: 'success',
|
||||||
title: 'Berhasil!',
|
title: 'Berhasil!',
|
||||||
text: data.message,
|
text: data.message,
|
||||||
confirmButtonText: 'OK'
|
confirmButtonText: 'OK'
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
location.reload();
|
location.reload();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
Swal.fire({
|
||||||
|
icon: 'error',
|
||||||
|
title: 'Error!',
|
||||||
|
text: data.message,
|
||||||
|
confirmButtonText: 'OK'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
icon: 'error',
|
icon: 'error',
|
||||||
title: 'Error!',
|
title: 'Error!',
|
||||||
text: data.message,
|
text: 'Terjadi kesalahan saat membatalkan antrian',
|
||||||
confirmButtonText: 'OK'
|
confirmButtonText: 'OK'
|
||||||
});
|
});
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
Swal.fire({
|
|
||||||
icon: 'error',
|
|
||||||
title: 'Error!',
|
|
||||||
text: 'Terjadi kesalahan saat membatalkan antrian',
|
|
||||||
confirmButtonText: 'OK'
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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>
|
</script>
|
||||||
@endpush
|
@endpush
|
||||||
@endsection
|
@endsection
|
||||||
|
|
|
@ -30,7 +30,7 @@
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Antrian Berikutnya:</h3>
|
<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)
|
@forelse($poliUmumNext ?? [] as $antrian)
|
||||||
<div
|
<div
|
||||||
class="bg-gray-50 rounded-xl p-4 border border-gray-200 hover:shadow-md transition duration-200">
|
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">
|
<div class="mt-6">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Antrian Berikutnya:</h3>
|
<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)
|
@forelse($poliGigiNext ?? [] as $antrian)
|
||||||
<div
|
<div
|
||||||
class="bg-gray-50 rounded-xl p-4 border border-gray-200 hover:shadow-md transition duration-200">
|
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">
|
<div class="mt-6">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Antrian Berikutnya:</h3>
|
<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)
|
@forelse($poliJiwaNext ?? [] as $antrian)
|
||||||
<div
|
<div
|
||||||
class="bg-gray-50 rounded-xl p-4 border border-gray-200 hover:shadow-md transition duration-200">
|
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">
|
<div class="mt-6">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Antrian Berikutnya:</h3>
|
<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)
|
@forelse($poliTradisionalNext ?? [] as $antrian)
|
||||||
<div
|
<div
|
||||||
class="bg-gray-50 rounded-xl p-4 border border-gray-200 hover:shadow-md transition duration-200">
|
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];
|
const audioItem = audioSequence[i];
|
||||||
await this.playAudioItem(audioItem);
|
await this.playAudioItem(audioItem);
|
||||||
|
|
||||||
// Wait between audio items
|
// Wait between audio items (1 second gap)
|
||||||
if (i < audioSequence.length - 1) {
|
if (i < audioSequence.length - 1) {
|
||||||
await this.delay(500);
|
await this.delay(1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -257,7 +257,7 @@ class TTSAudioPlayer {
|
||||||
// Fallback timeout
|
// Fallback timeout
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
resolve();
|
resolve();
|
||||||
}, audioItem.duration || 4000);
|
}, audioItem.duration || 8000);
|
||||||
} else {
|
} else {
|
||||||
console.warn('Speech synthesis not supported');
|
console.warn('Speech synthesis not supported');
|
||||||
resolve();
|
resolve();
|
||||||
|
@ -282,7 +282,7 @@ class TTSAudioPlayer {
|
||||||
// Fallback timeout
|
// Fallback timeout
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
resolve();
|
resolve();
|
||||||
}, audioItem.duration || 3000);
|
}, audioItem.duration || 8000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -362,10 +362,7 @@ class TTSAudioPlayer {
|
||||||
});
|
});
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
// Auto refresh every 5 seconds
|
// No more auto refresh - using real-time updates instead
|
||||||
setInterval(function() {
|
|
||||||
location.reload();
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
// Add sound effect for new calls
|
// Add sound effect for new calls
|
||||||
function playNotificationSound() {
|
function playNotificationSound() {
|
||||||
|
@ -386,7 +383,7 @@ function showNewCallNotification(poliName, number) {
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
icon: 'info',
|
icon: 'info',
|
||||||
title: 'Panggilan Baru!',
|
title: 'Panggilan Baru!',
|
||||||
text: `Poli ${poliName} memanggil nomor ${number}`,
|
text: `Antrian selanjutnya poli ${poliName} nomor ${number}`,
|
||||||
confirmButtonText: 'OK',
|
confirmButtonText: 'OK',
|
||||||
confirmButtonColor: '#3B82F6',
|
confirmButtonColor: '#3B82F6',
|
||||||
timer: 5000,
|
timer: 5000,
|
||||||
|
@ -420,7 +417,98 @@ function addPulseAnimation() {
|
||||||
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) {
|
window.addEventListener('message', function(event) {
|
||||||
if (event.data.type === 'TTS_CALL') {
|
if (event.data.type === 'TTS_CALL') {
|
||||||
const {
|
const {
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
use App\Http\Controllers\DisplayController;
|
use App\Http\Controllers\DisplayController;
|
||||||
use App\Http\Controllers\AdminController;
|
use App\Http\Controllers\AdminController;
|
||||||
use App\Http\Controllers\TTSController;
|
use App\Http\Controllers\TTSController;
|
||||||
|
use App\Http\Controllers\IndonesianTTSController;
|
||||||
|
|
||||||
// Landing Page
|
// Landing Page
|
||||||
Route::get('/', [LandingController::class, 'index'])->name('landing');
|
Route::get('/', [LandingController::class, 'index'])->name('landing');
|
||||||
|
@ -67,6 +68,8 @@
|
||||||
|
|
||||||
// User Management Routes
|
// User Management Routes
|
||||||
Route::get('/admin/users', [AdminController::class, 'manageUsers'])->name('admin.users.index');
|
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::get('/admin/users/{user}', [AdminController::class, 'showUser'])->name('admin.users.show');
|
||||||
Route::put('/admin/users/{user}', [AdminController::class, 'updateUser'])->name('admin.users.update');
|
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');
|
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/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/audio-sequence', [TTSController::class, 'getAudioSequence'])->name('admin.tts.audio-sequence');
|
||||||
Route::post('/admin/tts/play-sequence', [TTSController::class, 'playAudioSequence'])->name('admin.tts.play-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)
|
// Public TTS Routes (for display)
|
||||||
Route::post('/tts/play-sequence', [TTSController::class, 'playAudioSequence'])->name('tts.play-sequence');
|
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');
|
||||||
|
|
||||||
|
|
|
@ -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";
|
||||||
|
|
||||||
|
?>
|
|
@ -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";
|
|
@ -1 +0,0 @@
|
||||||
|
|
35
test_tts.php
35
test_tts.php
|
@ -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";
|
|
|
@ -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";
|
|
|
@ -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";
|
|
||||||
?>
|
|
Loading…
Reference in New Issue