first commit

This commit is contained in:
Endyfadlullah 2025-08-08 21:41:17 +07:00
commit e0306d5579
108 changed files with 18571 additions and 0 deletions

18
.editorconfig Normal file
View File

@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[docker-compose.yml]
indent_size = 4

65
.env.example Normal file
View File

@ -0,0 +1,65 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"

11
.gitattributes vendored Normal file
View File

@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.fleet
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
Homestead.json
Homestead.yaml
Thumbs.db

202
README.md Normal file
View File

@ -0,0 +1,202 @@
# Sistem Antrian Puskesmas
Sistem antrian digital untuk Puskesmas yang dibangun dengan Laravel dan Tailwind CSS.
## 🚀 Fitur
- **Landing Page** - Halaman utama yang menarik dengan informasi layanan
- **Sistem Login/Register** - Autentikasi pengguna dengan validasi
- **Dashboard Admin** - Panel admin untuk mengelola antrian
- **Display Antrian** - Layar display untuk menampilkan antrian yang sedang dipanggil
- **Responsive Design** - Tampilan yang responsif untuk semua perangkat
## 🛠️ Teknologi
- **Backend**: Laravel 11
- **Frontend**: Tailwind CSS
- **Database**: MySQL
- **Authentication**: Laravel Built-in Auth
## 📋 Struktur Database
### Tabel Users
- `id` - Primary Key
- `nama` - Nama lengkap pasien
- `alamat` - Alamat pasien
- `jenis_kelamin` - Laki-laki/Perempuan
- `no_hp` - Nomor HP
- `no_ktp` - Nomor KTP (unique)
- `poli_id` - Foreign key ke tabel polis
- `pekerjaan` - Pekerjaan pasien
- `password` - Password untuk login
- `remember_token` - Token untuk remember me
### Tabel Polis
- `id` - Primary Key
- `nama_poli` - Nama poli (umum, gigi, kesehatan jiwa, kesehatan tradisional)
### Tabel Lokets
- `id` - Primary Key
- `nama_loket` - Nama loket
### Tabel Antrians
- `id` - Primary Key
- `user_id` - Foreign key ke users
- `no_antrian` - Nomor antrian
- `tanggal_antrian` - Tanggal antrian
- `is_call` - Status dipanggil
- `status` - Status antrian (menunggu, dipanggil, selesai, batal)
- `waktu_panggil` - Waktu dipanggil
- `loket_id` - Foreign key ke lokets
### Tabel Riwayat Panggilan
- `id` - Primary Key
- `antrian_id` - Foreign key ke antrians
- `waktu_panggilan` - Waktu panggilan
## 🚀 Instalasi
1. **Clone repository**
```bash
git clone <repository-url>
cd Puskesmas
```
2. **Install dependencies**
```bash
composer install
npm install
```
3. **Setup environment**
```bash
cp .env.example .env
php artisan key:generate
```
4. **Konfigurasi database**
- Edit file `.env` dan sesuaikan konfigurasi database
```env
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=puskesmas
DB_USERNAME=root
DB_PASSWORD=
```
5. **Jalankan migrasi dan seeder**
```bash
php artisan migrate:fresh --seed
```
6. **Jalankan server development**
```bash
php artisan serve
```
## 📱 Halaman yang Tersedia
### 1. Landing Page (`/`)
- Halaman utama dengan informasi layanan
- Navigasi ke login dan register
- Informasi tentang cara kerja sistem
### 2. Login (`/login`)
- Form login dengan email dan password
- Remember me functionality
- Link ke halaman register
### 3. Register (`/register`)
- Form pendaftaran dengan data lengkap
- Validasi input
- Pilihan poli
### 4. Dashboard (`/dashboard`)
- Panel admin dengan statistik
- Quick actions untuk mengelola antrian
- Tabel antrian terbaru
- Logout functionality
### 5. Display (`/display`)
- Layar display untuk antrian
- Auto-refresh setiap 5 detik
- Tampilan antrian per poli
- Antrian berikutnya
## 👤 Akun Default
Setelah menjalankan seeder, tersedia akun default:
**Admin:**
- Username: `admin`
- Password: `password`
**User:**
- Nama: `Budi Santoso`
- No KTP: `1234567890123456`
- Password: `password`
## 🎨 Customization
### Warna
Sistem menggunakan warna custom yang dapat diubah di `resources/views/layouts/app.blade.php`:
```javascript
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#3B82F6', // Blue
secondary: '#1E40AF', // Dark Blue
accent: '#10B981' // Green
}
}
}
}
```
### Layout
Layout utama dapat dimodifikasi di `resources/views/layouts/app.blade.php`
## 🔧 Development
### Menambah Poli Baru
1. Tambahkan data di seeder `AntrianPuskesmasSeeder.php`
2. Update controller `DisplayController.php` untuk menampilkan poli baru
3. Update view `display/index.blade.php` untuk menampilkan poli baru
### Menambah Fitur Baru
1. Buat controller baru di `app/Http/Controllers/`
2. Buat view di `resources/views/`
3. Tambahkan route di `routes/web.php`
4. Update navigasi di layout
## 📊 Monitoring
Sistem menyediakan monitoring real-time untuk:
- Total pasien terdaftar
- Antrian yang sedang menunggu
- Antrian yang sudah selesai
- Poli yang aktif
## 🔒 Security
- Password di-hash menggunakan bcrypt
- CSRF protection aktif
- Validasi input pada semua form
- Session management yang aman
## 📞 Support
Untuk bantuan atau pertanyaan, silakan hubungi:
- Email: support@puskesmas.com
- Phone: (021) 1234-5678
## 📄 License
Proyek ini dilisensikan di bawah MIT License.
---
**Dibuat dengan ❤️ untuk Puskesmas**

194
README_TTS.md Normal file
View File

@ -0,0 +1,194 @@
# 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

166
README_TTS_VOICES.md Normal file
View File

@ -0,0 +1,166 @@
# 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

63
TTS_CONFIG.md Normal file
View File

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

View File

@ -0,0 +1,504 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use App\Models\User;
use App\Models\Admin;
use App\Models\Antrian;
use App\Models\Poli;
use App\Models\RiwayatPanggilan;
use App\Services\TTSService;
use Barryvdh\DomPDF\Facade\Pdf;
class AdminController extends Controller
{
public function dashboard()
{
$totalUsers = User::count();
$totalAntrian = Antrian::count();
$antrianHariIni = Antrian::whereDate('created_at', today())->count();
$polis = Poli::count();
// Get counts for each poli
$poliUmumCount = Antrian::whereHas('poli', function ($query) {
$query->where('nama_poli', 'umum');
})->where('status', 'menunggu')->count();
$poliGigiCount = Antrian::whereHas('poli', function ($query) {
$query->where('nama_poli', 'gigi');
})->where('status', 'menunggu')->count();
$poliJiwaCount = Antrian::whereHas('poli', function ($query) {
$query->where('nama_poli', 'kesehatan jiwa');
})->where('status', 'menunggu')->count();
$poliTradisionalCount = Antrian::whereHas('poli', function ($query) {
$query->where('nama_poli', 'kesehatan tradisional');
})->where('status', 'menunggu')->count();
// Get recent antrian
$antrianTerbaru = Antrian::with(['user', 'poli'])
->orderBy('created_at', 'desc')
->limit(10)
->get();
return view('admin.dashboard', compact(
'totalUsers',
'totalAntrian',
'antrianHariIni',
'polis',
'poliUmumCount',
'poliGigiCount',
'poliJiwaCount',
'poliTradisionalCount',
'antrianTerbaru'
));
}
public function manageUsers(Request $request)
{
$query = User::with(['antrians.poli']);
// Search functionality
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('nama', 'like', "%{$search}%")
->orWhere('no_ktp', 'like', "%{$search}%")
->orWhere('no_hp', 'like', "%{$search}%")
->orWhere('alamat', 'like', "%{$search}%")
->orWhere('pekerjaan', 'like', "%{$search}%")
->orWhere('jenis_kelamin', 'like', "%{$search}%");
});
}
$users = $query->orderBy('created_at', 'desc')->get();
return view('admin.users.index', compact('users'));
}
public function showUser(User $user)
{
$user->load(['antrians.poli']);
return view('admin.users.show', compact('user'));
}
public function updateUser(Request $request, User $user)
{
$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|max:50|unique:users,no_ktp,' . $user->id,
'pekerjaan' => 'required|string|max:100',
]);
try {
$user->update([
'nama' => $request->nama,
'alamat' => $request->alamat,
'jenis_kelamin' => $request->jenis_kelamin,
'no_hp' => $request->no_hp,
'no_ktp' => $request->no_ktp,
'pekerjaan' => $request->pekerjaan,
]);
return response()->json([
'success' => true,
'message' => 'Data user berhasil diperbarui!'
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Terjadi kesalahan: ' . $e->getMessage()
], 500);
}
}
public function resetUserPassword(Request $request, User $user)
{
$request->validate([
'new_password' => 'required|string|min:8',
]);
try {
$user->update([
'password' => Hash::make($request->new_password)
]);
return response()->json([
'success' => true,
'message' => 'Password user berhasil direset!'
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Terjadi kesalahan: ' . $e->getMessage()
], 500);
}
}
public function laporan(Request $request)
{
$query = Antrian::with(['user', 'poli']);
// Filter berdasarkan tanggal
if ($request->filled('tanggal_mulai')) {
$query->whereDate('created_at', '>=', $request->tanggal_mulai);
}
if ($request->filled('tanggal_akhir')) {
$query->whereDate('created_at', '<=', $request->tanggal_akhir);
}
// Filter berdasarkan poli
if ($request->filled('poli_id')) {
$query->where('poli_id', $request->poli_id);
}
// Filter berdasarkan status
if ($request->filled('status')) {
$query->where('status', $request->status);
}
// Filter berdasarkan jenis kelamin
if ($request->filled('jenis_kelamin')) {
$query->whereHas('user', function ($q) use ($request) {
$q->where('jenis_kelamin', $request->jenis_kelamin);
});
}
$antrian = $query->orderBy('created_at', 'desc')->get();
$polis = Poli::all();
// Statistik
$totalAntrian = $antrian->count();
$antrianSelesai = $antrian->where('status', 'selesai')->count();
$antrianMenunggu = $antrian->where('status', 'menunggu')->count();
$antrianSedang = $antrian->where('status', 'sedang')->count();
return view('admin.laporan.index', compact('antrian', 'polis', 'totalAntrian', 'antrianSelesai', 'antrianMenunggu', 'antrianSedang'));
}
public function exportPDF(Request $request)
{
$query = Antrian::with(['user', 'poli']);
// Filter berdasarkan tanggal
if ($request->filled('tanggal_mulai')) {
$query->whereDate('created_at', '>=', $request->tanggal_mulai);
}
if ($request->filled('tanggal_akhir')) {
$query->whereDate('created_at', '<=', $request->tanggal_akhir);
}
// Filter berdasarkan poli
if ($request->filled('poli_id')) {
$query->where('poli_id', $request->poli_id);
}
// Filter berdasarkan status
if ($request->filled('status')) {
$query->where('status', $request->status);
}
// Filter berdasarkan jenis kelamin
if ($request->filled('jenis_kelamin')) {
$query->whereHas('user', function ($q) use ($request) {
$q->where('jenis_kelamin', $request->jenis_kelamin);
});
}
$antrian = $query->orderBy('created_at', 'desc')->get();
$pdf = Pdf::loadView('admin.laporan.pdf', compact('antrian'));
return $pdf->download('laporan-antrian-' . date('Y-m-d') . '.pdf');
}
public function exportExcel(Request $request)
{
$query = Antrian::with(['user', 'poli']);
// Filter berdasarkan tanggal
if ($request->filled('tanggal_mulai')) {
$query->whereDate('created_at', '>=', $request->tanggal_mulai);
}
if ($request->filled('tanggal_akhir')) {
$query->whereDate('created_at', '<=', $request->tanggal_akhir);
}
// Filter berdasarkan poli
if ($request->filled('poli_id')) {
$query->where('poli_id', $request->poli_id);
}
// Filter berdasarkan status
if ($request->filled('status')) {
$query->where('status', $request->status);
}
// Filter berdasarkan jenis kelamin
if ($request->filled('jenis_kelamin')) {
$query->whereHas('user', function ($q) use ($request) {
$q->where('jenis_kelamin', $request->jenis_kelamin);
});
}
$antrian = $query->orderBy('created_at', 'desc')->get();
$filename = 'laporan-antrian-' . date('Y-m-d') . '.csv';
$headers = [
'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
];
$callback = function () use ($antrian) {
$file = fopen('php://output', 'w');
// Header CSV
fputcsv($file, [
'No Antrian',
'Nama Pasien',
'No KTP',
'Jenis Kelamin',
'Poli',
'Status',
'Tanggal Daftar',
'Waktu Daftar',
'Waktu Panggil'
]);
// Data CSV
foreach ($antrian as $item) {
fputcsv($file, [
$item->no_antrian,
$item->user->nama,
$item->user->no_ktp,
$item->user->jenis_kelamin,
$item->poli->nama_poli,
ucfirst($item->status),
$item->created_at ? $item->created_at->format('d/m/Y') : '-',
$item->created_at ? $item->created_at->format('H:i') : '-',
$item->waktu_panggil ? $item->waktu_panggil->format('H:i') : '-'
]);
}
fclose($file);
};
return response()->stream($callback, 200, $headers);
}
public function poliUmum()
{
$antrians = Antrian::with(['user', 'poli'])
->whereHas('poli', function ($query) {
$query->where('nama_poli', 'umum');
})
->orderBy('created_at', 'asc')
->get();
$title = 'Poli Umum';
return view('admin.poli.index', compact('antrians', 'title'));
}
public function poliGigi()
{
$antrians = Antrian::with(['user', 'poli'])
->whereHas('poli', function ($query) {
$query->where('nama_poli', 'gigi');
})
->orderBy('created_at', 'asc')
->get();
$title = 'Poli Gigi';
return view('admin.poli.index', compact('antrians', 'title'));
}
public function poliJiwa()
{
$antrians = Antrian::with(['user', 'poli'])
->whereHas('poli', function ($query) {
$query->where('nama_poli', 'kesehatan jiwa');
})
->orderBy('created_at', 'asc')
->get();
$title = 'Poli Jiwa';
return view('admin.poli.index', compact('antrians', 'title'));
}
public function poliTradisional()
{
$antrians = Antrian::with(['user', 'poli'])
->whereHas('poli', function ($query) {
$query->where('nama_poli', 'kesehatan tradisional');
})
->orderBy('created_at', 'asc')
->get();
$title = 'Poli Tradisional';
return view('admin.poli.index', compact('antrians', 'title'));
}
public function panggilAntrian(Request $request)
{
try {
// Get the next waiting queue
$antrian = Antrian::where('status', 'menunggu')
->orderBy('created_at', 'asc')
->first();
if (!$antrian) {
return response()->json([
'success' => false,
'message' => 'Tidak ada antrian yang menunggu'
]);
}
// Update status to 'dipanggil'
$antrian->update(['status' => 'dipanggil']);
// Record call history
RiwayatPanggilan::create([
'antrian_id' => $antrian->id,
'waktu_panggilan' => now()
]);
return response()->json([
'success' => true,
'message' => 'Antrian ' . $antrian->no_antrian . ' dipanggil'
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Terjadi kesalahan: ' . $e->getMessage()
], 500);
}
}
public function panggilAntrianById(Antrian $antrian)
{
try {
if ($antrian->status !== 'menunggu') {
return response()->json([
'success' => false,
'message' => 'Antrian ini tidak dalam status menunggu'
]);
}
// Update status to 'dipanggil'
$antrian->update(['status' => 'dipanggil']);
// Record call history
RiwayatPanggilan::create([
'antrian_id' => $antrian->id,
'waktu_panggilan' => now()
]);
// Generate TTS audio sequence
$ttsService = new TTSService();
$audioSequence = $ttsService->createCompleteAudioSequence(
$antrian->poli->nama_poli,
$antrian->no_antrian
);
return response()->json([
'success' => true,
'message' => 'Antrian ' . $antrian->no_antrian . ' dipanggil',
'audio_sequence' => $audioSequence,
'poli_name' => $antrian->poli->nama_poli,
'queue_number' => $antrian->no_antrian
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Terjadi kesalahan: ' . $e->getMessage()
], 500);
}
}
public function selesaiAntrian(Request $request)
{
try {
$request->validate([
'antrian_id' => 'required|exists:antrians,id'
]);
$antrian = Antrian::findOrFail($request->antrian_id);
if ($antrian->status !== 'dipanggil') {
return response()->json([
'success' => false,
'message' => 'Antrian ini tidak dalam status dipanggil'
]);
}
// Update status to 'selesai'
$antrian->update(['status' => 'selesai']);
return response()->json([
'success' => true,
'message' => 'Antrian ' . $antrian->no_antrian . ' selesai'
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Terjadi kesalahan: ' . $e->getMessage()
], 500);
}
}
public function batalAntrian(Request $request)
{
try {
$request->validate([
'antrian_id' => 'required|exists:antrians,id'
]);
$antrian = Antrian::findOrFail($request->antrian_id);
if ($antrian->status === 'selesai') {
return response()->json([
'success' => false,
'message' => 'Antrian yang sudah selesai tidak dapat dibatalkan'
]);
}
// Update status to 'batal'
$antrian->update(['status' => 'batal']);
return response()->json([
'success' => true,
'message' => 'Antrian ' . $antrian->no_antrian . ' dibatalkan'
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Terjadi kesalahan: ' . $e->getMessage()
], 500);
}
}
public function cetakAntrian(Antrian $antrian)
{
try {
$antrian->load(['user', 'poli']);
$pdf = Pdf::loadView('admin.antrian.print', compact('antrian'));
return $pdf->stream('antrian-' . $antrian->no_antrian . '.pdf');
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Terjadi kesalahan: ' . $e->getMessage()
], 500);
}
}
}

View File

@ -0,0 +1,175 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use App\Models\User;
use App\Models\Admin;
use App\Models\Poli;
class AuthController extends Controller
{
public function showLogin()
{
return view('auth.login');
}
public function login(Request $request)
{
$credentials = $request->validate([
'email_or_ktp' => 'required|string',
'password' => 'required',
]);
$emailOrKtp = $request->email_or_ktp;
$password = $request->password;
// First, try to find admin by username
$admin = Admin::where('username', $emailOrKtp)->first();
if ($admin && Hash::check($password, $admin->password)) {
Auth::guard('admin')->login($admin, $request->remember);
$request->session()->regenerate();
return redirect()->intended('/admin/dashboard')->with('success', 'Selamat datang, Admin!');
}
// If not admin, try to find user by nama (username) or no_ktp
$user = User::where('nama', $emailOrKtp)
->orWhere('no_ktp', $emailOrKtp)
->first();
if ($user && Hash::check($password, $user->password)) {
Auth::login($user, $request->remember);
$request->session()->regenerate();
return redirect()->intended('/dashboard')->with('success', 'Selamat datang, ' . $user->nama . '!');
}
// If neither admin nor user found, return error
return back()->withErrors([
'email_or_ktp' => 'Username/Nama/No KTP atau password salah.',
])->withInput($request->only('email_or_ktp'));
}
public function showRegister()
{
return view('auth.register');
}
public function register(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.',
]);
$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),
]);
Auth::login($user);
return redirect('/dashboard')->with('success', 'Akun berhasil dibuat!');
}
public function logout(Request $request)
{
// Check if admin is logged in
if (Auth::guard('admin')->check()) {
Auth::guard('admin')->logout();
} else {
Auth::logout();
}
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/')->with('success', 'Anda berhasil logout!');
}
public function showForgotPassword()
{
return view('auth.forgot-password');
}
public function forgotPassword(Request $request)
{
$request->validate([
'nama' => 'required|string|max:255',
'no_ktp' => 'required|string|max:50',
]);
$nama = $request->nama;
$noKtp = $request->no_ktp;
// Cari user berdasarkan nama dan no_ktp
$user = User::where('nama', $nama)
->where('no_ktp', $noKtp)
->first();
if ($user) {
// Jika kedua data benar, simpan user_id di session dan arahkan ke reset password
$request->session()->put('reset_user_id', $user->id);
return redirect()->route('reset-password')->with('success', 'Verifikasi berhasil! Silakan masukkan password baru.');
} else {
// Jika salah satu atau keduanya salah, berikan pesan error
return back()->withErrors([
'nama' => 'Nama atau nomor KTP tidak ditemukan. Silakan hubungi admin untuk verifikasi data.',
])->withInput($request->only('nama', 'no_ktp'));
}
}
public function showResetPassword(Request $request)
{
// Cek apakah ada user_id di session
if (!$request->session()->has('reset_user_id')) {
return redirect()->route('forgot-password')->with('error', 'Sesi verifikasi tidak valid. Silakan verifikasi ulang.');
}
return view('auth.reset-password');
}
public function resetPassword(Request $request)
{
$request->validate([
'user_id' => 'required|exists:users,id',
'password' => 'required|string|min:8|confirmed',
]);
// Cek apakah user_id di session sama dengan yang dikirim
if ($request->session()->get('reset_user_id') != $request->user_id) {
return redirect()->route('forgot-password')->with('error', 'Sesi verifikasi tidak valid. Silakan verifikasi ulang.');
}
$user = User::find($request->user_id);
if (!$user) {
return redirect()->route('forgot-password')->with('error', 'User tidak ditemukan.');
}
// Update password
$user->update([
'password' => Hash::make($request->password)
]);
// Hapus session reset_user_id
$request->session()->forget('reset_user_id');
return redirect()->route('login')->with('success', 'Password berhasil direset! Silakan login dengan password baru.');
}
}

View File

@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@ -0,0 +1,215 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Antrian;
use App\Models\User;
use App\Models\Poli;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Auth;
class DashboardController extends Controller
{
public function index()
{
// Get user's own queues
$antrianSaya = Antrian::with(['user', 'poli'])
->where('user_id', Auth::id())
->whereDate('created_at', today())
->orderBy('created_at', 'desc')
->get();
return view('dashboard.index', compact('antrianSaya'));
}
public function addQueue(Request $request)
{
$request->validate([
'poli_id' => 'required|exists:polis,id'
]);
try {
DB::beginTransaction();
$user = Auth::user();
// Check if user already has a queue today for the same poli
$existingQueue = Antrian::where('user_id', $user->id)
->where('poli_id', $request->poli_id)
->whereDate('created_at', today())
->whereIn('status', ['menunggu', 'dipanggil'])
->first();
if ($existingQueue) {
// Get poli name for error message
$poliName = Poli::find($request->poli_id)->nama_poli;
return redirect()->back()->with('error', 'Anda sudah memiliki antrian di ' . $poliName . ' hari ini.');
}
// Get poli info for prefix
$poli = Poli::find($request->poli_id);
$prefix = $this->getPoliPrefix($poli->nama_poli);
// Get next queue number for the poli
$lastQueue = Antrian::where('poli_id', $request->poli_id)
->whereDate('created_at', today())
->max('no_antrian');
// Extract number from last queue (remove prefix)
$lastNumber = 0;
if ($lastQueue) {
$lastNumber = (int) preg_replace('/[^0-9]/', '', $lastQueue);
}
$nextNumber = $lastNumber + 1;
$nextQueueNumber = $prefix . $nextNumber;
// Create new queue using current user's data
$antrian = Antrian::create([
'user_id' => $user->id,
'poli_id' => $request->poli_id,
'no_antrian' => $nextQueueNumber,
'tanggal_antrian' => now()->toDateString(),
'status' => 'menunggu'
]);
DB::commit();
// Get poli name for success message
$poliName = $poli->nama_poli;
return redirect()->back()->with('success', 'Antrian berhasil diambil! Nomor antrian Anda di ' . $poliName . ': ' . $nextQueueNumber);
} catch (\Exception $e) {
DB::rollback();
return redirect()->back()->with('error', 'Terjadi kesalahan saat mengambil antrian: ' . $e->getMessage());
}
}
public function updateProfile(Request $request)
{
$request->validate([
'nama' => 'required|string|max:255',
'no_hp' => 'required|string|max:15',
'no_ktp' => 'required|string|max:16',
'jenis_kelamin' => 'required|in:laki-laki,perempuan',
'alamat' => 'required|string',
'pekerjaan' => 'required|string|max:100'
]);
try {
$user = Auth::user();
// Check if KTP number is already used by another user
$existingUser = User::where('no_ktp', $request->no_ktp)
->where('id', '!=', $user->id)
->first();
if ($existingUser) {
return response()->json([
'success' => false,
'message' => 'Nomor KTP sudah digunakan oleh user lain.'
]);
}
$user->nama = $request->nama;
$user->no_hp = $request->no_hp;
$user->no_ktp = $request->no_ktp;
$user->jenis_kelamin = $request->jenis_kelamin;
$user->alamat = $request->alamat;
$user->pekerjaan = $request->pekerjaan;
$user->save();
return response()->json([
'success' => true,
'message' => 'Data diri berhasil diperbarui!'
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Terjadi kesalahan saat memperbarui data: ' . $e->getMessage()
]);
}
}
private function getPoliPrefix($namaPoli)
{
switch (strtolower($namaPoli)) {
case 'umum':
return 'U';
case 'gigi':
return 'G';
case 'kesehatan jiwa':
return 'J';
case 'kesehatan tradisional':
return 'T';
default:
return 'A'; // Default prefix
}
}
public function batalAntrian(Request $request)
{
try {
$request->validate([
'antrian_id' => 'required|exists:antrians,id'
]);
$antrian = Antrian::findOrFail($request->antrian_id);
// Check if the antrian belongs to the authenticated user
if ($antrian->user_id !== Auth::id()) {
return response()->json([
'success' => false,
'message' => 'Anda tidak memiliki akses untuk membatalkan antrian ini'
], 403);
}
if ($antrian->status === 'selesai') {
return response()->json([
'success' => false,
'message' => 'Antrian yang sudah selesai tidak dapat dibatalkan'
]);
}
// Update status to 'batal'
$antrian->update(['status' => 'batal']);
return response()->json([
'success' => true,
'message' => 'Antrian ' . $antrian->no_antrian . ' berhasil dibatalkan'
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Terjadi kesalahan: ' . $e->getMessage()
], 500);
}
}
public function cetakAntrian(Antrian $antrian)
{
try {
// Check if the antrian belongs to the authenticated user
if ($antrian->user_id !== Auth::id()) {
abort(403, 'Anda tidak memiliki akses untuk mencetak antrian ini');
}
// Check if antrian can be printed (only 'menunggu' status can be printed)
if ($antrian->status !== 'menunggu') {
abort(400, 'Antrian dengan status "' . ucfirst($antrian->status) . '" tidak dapat dicetak');
}
$antrian->load(['user', 'poli']);
$pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('user.antrian.print', compact('antrian'));
return $pdf->stream('antrian-' . $antrian->no_antrian . '.pdf');
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Terjadi kesalahan: ' . $e->getMessage()
], 500);
}
}
}

View File

@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Antrian;
use App\Models\Poli;
class DisplayController extends Controller
{
public function index()
{
// 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 view('display.index', compact(
'poliUmumCurrent',
'poliGigiCurrent',
'poliJiwaCurrent',
'poliTradisionalCurrent',
'poliUmumNext',
'poliGigiNext',
'poliJiwaNext',
'poliTradisionalNext'
));
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class LandingController extends Controller
{
public function index()
{
return view('landing');
}
}

View File

@ -0,0 +1,101 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Services\TTSService;
use App\Models\Antrian;
class TTSController extends Controller
{
private $ttsService;
public function __construct()
{
$this->ttsService = new TTSService();
}
/**
* Generate TTS for queue call
*/
public function generateQueueCall(Request $request)
{
$request->validate([
'poli_name' => 'required|string',
'queue_number' => 'required|string'
]);
try {
$result = $this->ttsService->generateQueueCall(
$request->poli_name,
$request->queue_number
);
return response()->json($result);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Error generating TTS: ' . $e->getMessage()
], 500);
}
}
/**
* Get complete audio sequence for queue call
*/
public function getAudioSequence(Request $request)
{
$request->validate([
'poli_name' => 'required|string',
'queue_number' => 'required|string'
]);
try {
$audioSequence = $this->ttsService->createCompleteAudioSequence(
$request->poli_name,
$request->queue_number
);
return response()->json([
'success' => true,
'audio_sequence' => $audioSequence
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Error creating audio sequence: ' . $e->getMessage()
], 500);
}
}
/**
* Play audio sequence on display
*/
public function playAudioSequence(Request $request)
{
$request->validate([
'antrian_id' => 'required|exists:antrians,id'
]);
try {
$antrian = Antrian::with('poli')->findOrFail($request->antrian_id);
$audioSequence = $this->ttsService->createCompleteAudioSequence(
$antrian->poli->nama_poli,
$antrian->no_antrian
);
return response()->json([
'success' => true,
'audio_sequence' => $audioSequence,
'poli_name' => $antrian->poli->nama_poli,
'queue_number' => $antrian->no_antrian
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Error playing audio sequence: ' . $e->getMessage()
], 500);
}
}
}

31
app/Models/Admin.php Normal file
View File

@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class Admin extends Authenticatable
{
use HasFactory, Notifiable;
protected $table = 'admins';
protected $fillable = [
'username',
'password',
];
protected $hidden = [
'password',
'remember_token',
];
protected function casts(): array
{
return [
'password' => 'hashed',
];
}
}

49
app/Models/Antrian.php Normal file
View File

@ -0,0 +1,49 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Antrian extends Model
{
use HasFactory;
protected $table = 'antrians';
protected $fillable = [
'user_id',
'poli_id',
'no_antrian',
'tanggal_antrian',
'is_call',
'status',
'waktu_panggil',
'loket_id'
];
protected $casts = [
'tanggal_antrian' => 'date',
'waktu_panggil' => 'datetime',
'is_call' => 'boolean',
];
public function user()
{
return $this->belongsTo(User::class);
}
public function poli()
{
return $this->belongsTo(Poli::class);
}
public function loket()
{
return $this->belongsTo(Loket::class);
}
public function riwayatPanggilan()
{
return $this->hasMany(RiwayatPanggilan::class);
}
}

19
app/Models/Loket.php Normal file
View File

@ -0,0 +1,19 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Loket extends Model
{
use HasFactory;
protected $table = 'lokets';
protected $fillable = ['nama_loket'];
public function antrians()
{
return $this->hasMany(Antrian::class);
}
}

19
app/Models/Poli.php Normal file
View File

@ -0,0 +1,19 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Poli extends Model
{
use HasFactory;
protected $table = 'polis';
protected $fillable = ['nama_poli'];
public function users()
{
return $this->hasMany(User::class);
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class RiwayatPanggilan extends Model
{
use HasFactory;
protected $table = 'riwayat_panggilan';
protected $fillable = ['antrian_id', 'waktu_panggilan', 'created_at', 'updated_at'];
protected $casts = [
'waktu_panggilan' => 'datetime',
];
public function antrian()
{
return $this->belongsTo(Antrian::class);
}
}

57
app/Models/User.php Normal file
View File

@ -0,0 +1,57 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'nama',
'alamat',
'jenis_kelamin',
'no_hp',
'no_ktp',
'pekerjaan',
'password',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
public function antrians()
{
return $this->hasMany(Antrian::class);
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}

148
app/Services/TTSService.php Normal file
View File

@ -0,0 +1,148 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
class TTSService
{
private $apiKey;
private $baseUrl = 'https://texttospeech.googleapis.com/v1/text:synthesize';
public function __construct()
{
$this->apiKey = config('services.google.tts_api_key');
}
/**
* Generate TTS audio for queue call
*/
public function generateQueueCall($poliName, $queueNumber)
{
try {
// Create the text to be spoken
$text = "Nomor antrian {$queueNumber}, silakan menuju ke {$poliName}";
// Generate TTS audio
$audioContent = $this->synthesizeSpeech($text);
if ($audioContent) {
// Save audio file
$filename = "queue_call_{$queueNumber}_{$poliName}_" . time() . ".mp3";
$filepath = "audio/queue_calls/" . $filename;
Storage::disk('public')->put($filepath, base64_decode($audioContent));
return [
'success' => true,
'audio_url' => asset('storage/' . $filepath),
'filename' => $filename
];
}
// Fallback: return browser TTS info
return [
'success' => true,
'audio_url' => null,
'filename' => null,
'text' => $text,
'use_browser_tts' => true
];
} catch (\Exception $e) {
return [
'success' => false,
'message' => 'Error generating TTS: ' . $e->getMessage()
];
}
}
/**
* Synthesize speech using Google TTS API
*/
private function synthesizeSpeech($text)
{
if (!$this->apiKey) {
// Fallback: use browser's built-in TTS
return null;
}
$requestData = [
'input' => [
'text' => $text
],
'voice' => [
'languageCode' => 'id-ID',
'name' => 'id-ID-Wavenet-A', // More natural and fluent Indonesian female voice
'ssmlGender' => 'FEMALE'
],
'audioConfig' => [
'audioEncoding' => 'MP3',
'speakingRate' => 0.85, // Slightly faster for more natural flow
'pitch' => 0,
'volumeGainDb' => 0
]
];
try {
$response = Http::withHeaders([
'Content-Type' => 'application/json',
])->post($this->baseUrl . '?key=' . $this->apiKey, $requestData);
if ($response->successful()) {
$data = $response->json();
return $data['audioContent'] ?? null;
}
return null;
} catch (\Exception $e) {
return null;
}
}
/**
* Create complete audio sequence for queue call
*/
public function createCompleteAudioSequence($poliName, $queueNumber)
{
$audioFiles = [];
// 1. Attention sound
$attentionSound = asset('assets/music/call-to-attention-123107.mp3');
$audioFiles[] = [
'type' => 'attention',
'url' => $attentionSound,
'duration' => 2000 // 2 seconds
];
// 2. TTS for poli name and number
$ttsResult = $this->generateQueueCall($poliName, $queueNumber);
if ($ttsResult['success']) {
if ($ttsResult['use_browser_tts']) {
// Use browser TTS
$audioFiles[] = [
'type' => 'browser_tts',
'text' => $ttsResult['text'],
'duration' => 4000 // 4 seconds
];
} else {
// Use generated audio file
$audioFiles[] = [
'type' => 'tts',
'url' => $ttsResult['audio_url'],
'duration' => 4000 // 4 seconds
];
}
}
// 3. Final attention sound
$audioFiles[] = [
'type' => 'attention',
'url' => $attentionSound,
'duration' => 2000 // 2 seconds
];
return $audioFiles;
}
}

18
artisan Normal file
View File

@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);

18
bootstrap/app.php Normal file
View File

@ -0,0 +1,18 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create();

2
bootstrap/cache/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

5
bootstrap/providers.php Normal file
View File

@ -0,0 +1,5 @@
<?php
return [
App\Providers\AppServiceProvider::class,
];

78
composer.json Normal file
View File

@ -0,0 +1,78 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.2",
"barryvdh/laravel-dompdf": "^3.1",
"laravel/framework": "^12.0",
"laravel/sanctum": "^4.2",
"laravel/tinker": "^2.10.1"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.13",
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^11.5.3"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"post-autoload-dump": [
"@php artisan config:clear",
"@php artisan clear-compiled",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite"
],
"test": [
"@php artisan config:clear --ansi",
"@php artisan test"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

8583
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

126
config/app.php Normal file
View File

@ -0,0 +1,126 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];

118
config/auth.php Normal file
View File

@ -0,0 +1,118 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
'admin' => [
'driver' => 'session',
'provider' => 'admins',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', App\Models\User::class),
],
'admins' => [
'driver' => 'eloquent',
'model' => App\Models\Admin::class,
],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];

108
config/cache.php Normal file
View File

@ -0,0 +1,108 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'database'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "octane", "null"
|
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
];

174
config/database.php Normal file
View File

@ -0,0 +1,174 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => 'prefer',
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
],
],
];

80
config/filesystems.php Normal file
View File

@ -0,0 +1,80 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => env('APP_URL').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];

132
config/logging.php Normal file
View File

@ -0,0 +1,132 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
];

118
config/mail.php Normal file
View File

@ -0,0 +1,118 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => env('MAIL_MAILER', 'log'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all emails sent by your application to be sent from
| the same address. Here you may specify a name and address that is
| used globally for all emails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', 'Example'),
],
];

112
config/queue.php Normal file
View File

@ -0,0 +1,112 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
|
*/
'default' => env('QUEUE_CONNECTION', 'database'),
/*
|--------------------------------------------------------------------------
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection options for every queue backend
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null"
|
*/
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
'block_for' => 0,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
],
/*
|--------------------------------------------------------------------------
| Job Batching
|--------------------------------------------------------------------------
|
| The following options configure the database and table that store job
| batching information. These options can be updated to any database
| connection and table which has been defined by your application.
|
*/
'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'job_batches',
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control how and where failed jobs are stored. Laravel ships with
| support for storing failed jobs in a simple file or in a database.
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
*/
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs',
],
];

84
config/sanctum.php Normal file
View File

@ -0,0 +1,84 @@
<?php
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort(),
// Sanctum::currentRequestHost(),
))),
/*
|--------------------------------------------------------------------------
| Sanctum Guards
|--------------------------------------------------------------------------
|
| This array contains the authentication guards that will be checked when
| Sanctum is trying to authenticate a request. If none of these guards
| are able to authenticate the request, Sanctum will use the bearer
| token that's present on an incoming request for authentication.
|
*/
'guard' => ['web'],
/*
|--------------------------------------------------------------------------
| Expiration Minutes
|--------------------------------------------------------------------------
|
| This value controls the number of minutes until an issued token will be
| considered expired. This will override any values set in the token's
| "expires_at" attribute, but first-party sessions are not affected.
|
*/
'expiration' => null,
/*
|--------------------------------------------------------------------------
| Token Prefix
|--------------------------------------------------------------------------
|
| Sanctum can prefix new tokens in order to take advantage of numerous
| security scanning initiatives maintained by open source platforms
| that notify developers if they commit tokens into repositories.
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
*/
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Sanctum Middleware
|--------------------------------------------------------------------------
|
| When authenticating your first-party SPA with Sanctum you may need to
| customize some of the middleware Sanctum uses while processing the
| request. You may change the middleware listed below as required.
|
*/
'middleware' => [
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
],
];

42
config/services.php Normal file
View File

@ -0,0 +1,42 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
| This file is for storing the credentials for third party services such
| as Mailgun, Postmark, AWS and more. This file provides the de facto
| location for this type of information, allowing packages to have
| a conventional file to locate the various service credentials.
|
*/
'postmark' => [
'token' => env('POSTMARK_TOKEN'),
],
'resend' => [
'key' => env('RESEND_KEY'),
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'slack' => [
'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
'google' => [
'tts_api_key' => env('GOOGLE_TTS_API_KEY'),
],
];

217
config/session.php Normal file
View File

@ -0,0 +1,217 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Session Driver
|--------------------------------------------------------------------------
|
| This option determines the default session driver that is utilized for
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
|
| Supported: "file", "cookie", "database", "memcached",
| "redis", "dynamodb", "array"
|
*/
'driver' => env('SESSION_DRIVER', 'database'),
/*
|--------------------------------------------------------------------------
| Session Lifetime
|--------------------------------------------------------------------------
|
| Here you may specify the number of minutes that you wish the session
| to be allowed to remain idle before it expires. If you want them
| to expire immediately when the browser is closed then you may
| indicate that via the expire_on_close configuration option.
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
/*
|--------------------------------------------------------------------------
| Session Encryption
|--------------------------------------------------------------------------
|
| This option allows you to easily specify that all of your session data
| should be encrypted before it's stored. All encryption is performed
| automatically by Laravel and you may use the session like normal.
|
*/
'encrypt' => env('SESSION_ENCRYPT', false),
/*
|--------------------------------------------------------------------------
| Session File Location
|--------------------------------------------------------------------------
|
| When utilizing the "file" session driver, the session files are placed
| on disk. The default storage location is defined here; however, you
| are free to provide another location where they should be stored.
|
*/
'files' => storage_path('framework/sessions'),
/*
|--------------------------------------------------------------------------
| Session Database Connection
|--------------------------------------------------------------------------
|
| When using the "database" or "redis" session drivers, you may specify a
| connection that should be used to manage these sessions. This should
| correspond to a connection in your database configuration options.
|
*/
'connection' => env('SESSION_CONNECTION'),
/*
|--------------------------------------------------------------------------
| Session Database Table
|--------------------------------------------------------------------------
|
| When using the "database" session driver, you may specify the table to
| be used to store sessions. Of course, a sensible default is defined
| for you; however, you're welcome to change this to another table.
|
*/
'table' => env('SESSION_TABLE', 'sessions'),
/*
|--------------------------------------------------------------------------
| Session Cache Store
|--------------------------------------------------------------------------
|
| When using one of the framework's cache driven session backends, you may
| define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores.
|
| Affects: "dynamodb", "memcached", "redis"
|
*/
'store' => env('SESSION_STORE'),
/*
|--------------------------------------------------------------------------
| Session Sweeping Lottery
|--------------------------------------------------------------------------
|
| Some session drivers must manually sweep their storage location to get
| rid of old sessions from storage. Here are the chances that it will
| happen on a given request. By default, the odds are 2 out of 100.
|
*/
'lottery' => [2, 100],
/*
|--------------------------------------------------------------------------
| Session Cookie Name
|--------------------------------------------------------------------------
|
| Here you may change the name of the session cookie that is created by
| the framework. Typically, you should not need to change this value
| since doing so does not grant a meaningful security improvement.
|
*/
'cookie' => env(
'SESSION_COOKIE',
Str::slug(env('APP_NAME', 'laravel')).'-session'
),
/*
|--------------------------------------------------------------------------
| Session Cookie Path
|--------------------------------------------------------------------------
|
| The session cookie path determines the path for which the cookie will
| be regarded as available. Typically, this will be the root path of
| your application, but you're free to change this when necessary.
|
*/
'path' => env('SESSION_PATH', '/'),
/*
|--------------------------------------------------------------------------
| Session Cookie Domain
|--------------------------------------------------------------------------
|
| This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root
| domain and all subdomains. Typically, this shouldn't be changed.
|
*/
'domain' => env('SESSION_DOMAIN'),
/*
|--------------------------------------------------------------------------
| HTTPS Only Cookies
|--------------------------------------------------------------------------
|
| By setting this option to true, session cookies will only be sent back
| to the server if the browser has a HTTPS connection. This will keep
| the cookie from being sent to you when it can't be done securely.
|
*/
'secure' => env('SESSION_SECURE_COOKIE'),
/*
|--------------------------------------------------------------------------
| HTTP Access Only
|--------------------------------------------------------------------------
|
| Setting this value to true will prevent JavaScript from accessing the
| value of the cookie and the cookie will only be accessible through
| the HTTP protocol. It's unlikely you should disable this option.
|
*/
'http_only' => env('SESSION_HTTP_ONLY', true),
/*
|--------------------------------------------------------------------------
| Same-Site Cookies
|--------------------------------------------------------------------------
|
| This option determines how your cookies behave when cross-site requests
| take place, and can be used to mitigate CSRF attacks. By default, we
| will set this value to "lax" to permit secure cross-site requests.
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
| Supported: "lax", "strict", "none", null
|
*/
'same_site' => env('SESSION_SAME_SITE', 'lax'),
/*
|--------------------------------------------------------------------------
| Partitioned Cookies
|--------------------------------------------------------------------------
|
| Setting this value to true will tie the cookie to the top-level site for
| a cross-site context. Partitioned cookies are accepted by the browser
| when flagged "secure" and the Same-Site attribute is set to "none".
|
*/
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
];

1
database/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
*.sqlite*

View File

@ -0,0 +1,44 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}

View File

@ -0,0 +1,38 @@
<?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('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
}
};

View File

@ -0,0 +1,34 @@
<?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('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->integer('expiration');
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->integer('expiration');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};

View File

@ -0,0 +1,28 @@
<?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('admins', function (Blueprint $table) {
$table->id();
$table->string('username')->unique();
$table->string('password');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('admins');
}
};

View File

@ -0,0 +1,27 @@
<?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('polis', function (Blueprint $table) {
$table->id();
$table->string('nama_poli', 100);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('polis');
}
};

View File

@ -0,0 +1,27 @@
<?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('lokets', function (Blueprint $table) {
$table->id();
$table->string('nama_loket', 100);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('lokets');
}
};

View File

@ -0,0 +1,51 @@
<?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
{
// Drop the existing users table
Schema::dropIfExists('users');
// Create new users table with correct structure
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('nama');
$table->text('alamat');
$table->enum('jenis_kelamin', ['laki-laki', 'perempuan']);
$table->string('no_hp', 20);
$table->string('no_ktp', 50)->unique();
$table->foreignId('poli_id')->constrained('polis')->onDelete('cascade');
$table->string('pekerjaan', 100);
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// Drop the new users table
Schema::dropIfExists('users');
// Recreate the original users table
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
}
};

View File

@ -0,0 +1,33 @@
<?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('antrians', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained('users')->onDelete('cascade');
$table->string('no_antrian', 20);
$table->date('tanggal_antrian');
$table->boolean('is_call')->default(false);
$table->enum('status', ['menunggu', 'dipanggil', 'selesai', 'batal'])->default('menunggu');
$table->timestamp('waktu_panggil')->nullable();
$table->foreignId('loket_id')->nullable()->constrained('lokets')->onDelete('set null');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('antrians');
}
};

View File

@ -0,0 +1,28 @@
<?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('riwayat_panggilan', function (Blueprint $table) {
$table->id();
$table->foreignId('antrian_id')->constrained('antrians')->onDelete('cascade');
$table->timestamp('waktu_panggilan')->useCurrent();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('riwayat_panggilan');
}
};

View File

@ -0,0 +1,33 @@
<?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::table('admins', function (Blueprint $table) {
if (!Schema::hasColumn('admins', 'remember_token')) {
$table->rememberToken()->nullable();
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('admins', function (Blueprint $table) {
if (Schema::hasColumn('admins', 'remember_token')) {
$table->dropColumn('remember_token');
}
});
}
};

View File

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB; // Added this import for DB facade
return new class extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('antrians', function (Blueprint $table) {
$table->foreignId('poli_id')->after('user_id')->nullable()->constrained('polis')->onDelete('cascade');
});
// Update existing records to have poli_id based on user's poli
DB::statement('UPDATE antrians SET poli_id = (SELECT poli_id FROM users WHERE users.id = antrians.user_id)');
// Make poli_id not nullable after updating existing data
Schema::table('antrians', function (Blueprint $table) {
$table->foreignId('poli_id')->nullable(false)->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('antrians', function (Blueprint $table) {
$table->dropForeign(['poli_id']);
$table->dropColumn('poli_id');
});
}
};

View File

@ -0,0 +1,28 @@
<?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::table('users', function (Blueprint $table) {
$table->dropForeign(['poli_id']);
$table->dropColumn('poli_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->foreignId('poli_id')->constrained('polis')->onDelete('cascade');
});
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('tokenable');
$table->text('name');
$table->string('token', 64)->unique();
$table->text('abilities')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('expires_at')->nullable()->index();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('personal_access_tokens');
}
};

View File

@ -0,0 +1,30 @@
<?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('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('sessions');
}
};

View File

@ -0,0 +1,29 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use App\Models\Admin;
use Illuminate\Support\Facades\Hash;
class AdminSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
// Ensure single admin record with consistent credentials
Admin::updateOrCreate(
['username' => 'admin'],
['password' => Hash::make('admin123')]
);
// Optional additional admin (email style username)
Admin::updateOrCreate(
['username' => 'admin@puskesmas.com'],
['password' => Hash::make('admin123')]
);
}
}

View File

@ -0,0 +1,122 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
class AntrianPuskesmasSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
// Admin data is seeded in AdminSeeder to avoid duplication
// Seed polis table with upsert to avoid duplicates
$polis = [
['id' => 1, 'nama_poli' => 'umum'],
['id' => 2, 'nama_poli' => 'gigi'],
['id' => 3, 'nama_poli' => 'kesehatan jiwa'],
['id' => 4, 'nama_poli' => 'kesehatan tradisional'],
];
foreach ($polis as $poli) {
DB::table('polis')->updateOrInsert(
['id' => $poli['id']],
['nama_poli' => $poli['nama_poli'], 'updated_at' => now()]
);
}
// Seed lokets table with upsert
$lokets = [
['id' => 1, 'nama_loket' => 'Loket 1'],
['id' => 2, 'nama_loket' => 'Loket 2'],
];
foreach ($lokets as $loket) {
DB::table('lokets')->updateOrInsert(
['id' => $loket['id']],
['nama_loket' => $loket['nama_loket'], 'updated_at' => now()]
);
}
// Seed users table with upsert
DB::table('users')->updateOrInsert(
['id' => 1],
[
'nama' => 'Budi Santoso',
'alamat' => 'Jl. Merdeka No.1',
'jenis_kelamin' => 'laki-laki',
'no_hp' => '08123456789',
'no_ktp' => '1234567890123456',
'pekerjaan' => 'Petani',
'password' => Hash::make('password'),
'updated_at' => now(),
]
);
// Seed antrians table with upsert
DB::table('antrians')->updateOrInsert(
['id' => 1],
[
'user_id' => 1,
'poli_id' => 1,
'no_antrian' => 'U1',
'tanggal_antrian' => now()->toDateString(),
'is_call' => 0,
'status' => 'menunggu',
'waktu_panggil' => null,
'loket_id' => 1,
'updated_at' => now(),
]
);
// Add more antrian data for testing
DB::table('antrians')->updateOrInsert(
['id' => 2],
[
'user_id' => 1,
'poli_id' => 2,
'no_antrian' => 'G1',
'tanggal_antrian' => now()->toDateString(),
'is_call' => 0,
'status' => 'menunggu',
'waktu_panggil' => null,
'loket_id' => 1,
'updated_at' => now(),
]
);
DB::table('antrians')->updateOrInsert(
['id' => 3],
[
'user_id' => 1,
'poli_id' => 3,
'no_antrian' => 'J1',
'tanggal_antrian' => now()->toDateString(),
'is_call' => 0,
'status' => 'dipanggil',
'waktu_panggil' => now(),
'loket_id' => 1,
'updated_at' => now(),
]
);
DB::table('antrians')->updateOrInsert(
['id' => 4],
[
'user_id' => 1,
'poli_id' => 4,
'no_antrian' => 'T1',
'tanggal_antrian' => now()->toDateString(),
'is_call' => 0,
'status' => 'menunggu',
'waktu_panggil' => null,
'loket_id' => 1,
'updated_at' => now(),
]
);
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace Database\Seeders;
use App\Models\User;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
/**
* Seed the application's database.
*/
public function run(): void
{
$this->call([
AntrianPuskesmasSeeder::class,
AdminSeeder::class,
]);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\Loket;
class LoketSeeder extends Seeder
{
public function run(): void
{
$lokets = [
['nama_loket' => 'Loket 1'],
['nama_loket' => 'Loket 2'],
['nama_loket' => 'Loket 3'],
];
foreach ($lokets as $loket) {
Loket::create($loket);
}
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\Poli;
class PoliSeeder extends Seeder
{
public function run(): void
{
$polis = [
['nama_poli' => 'Poli Umum'],
['nama_poli' => 'Poli Gigi'],
['nama_poli' => 'Poli Jiwa'],
['nama_poli' => 'Poli Tradisional'],
];
foreach ($polis as $poli) {
Poli::create($poli);
}
}
}

17
package.json Normal file
View File

@ -0,0 +1,17 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"axios": "^1.11.0",
"concurrently": "^9.0.1",
"laravel-vite-plugin": "^2.0.0",
"tailwindcss": "^4.0.0",
"vite": "^7.0.4"
}
}

34
phpunit.xml Normal file
View File

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
</php>
</phpunit>

25
public/.htaccess Normal file
View File

@ -0,0 +1,25 @@
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews -Indexes
</IfModule>
RewriteEngine On
# Handle Authorization Header
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
# Handle X-XSRF-Token Header
RewriteCond %{HTTP:x-xsrf-token} .
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
# Redirect Trailing Slashes If Not A Folder...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Send Requests To Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>

Binary file not shown.

0
public/favicon.ico Normal file
View File

20
public/index.php Normal file
View File

@ -0,0 +1,20 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
// Determine if the application is in maintenance mode...
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
// Register the Composer autoloader...
require __DIR__.'/../vendor/autoload.php';
// Bootstrap Laravel and handle the request...
/** @var Application $app */
$app = require_once __DIR__.'/../bootstrap/app.php';
$app->handleRequest(Request::capture());

2
public/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-agent: *
Disallow:

BIN
puskesmas Normal file

Binary file not shown.

40
resources/css/app.css Normal file
View File

@ -0,0 +1,40 @@
@import 'tailwindcss';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@source '../**/*.blade.php';
@source '../**/*.js';
@theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
}
/* Custom Animations */
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fade-in 0.6s ease-out;
}
.animate-slide-up {
animation: slide-up 0.6s ease-out;
}

1
resources/js/app.js Normal file
View File

@ -0,0 +1 @@
import './bootstrap';

4
resources/js/bootstrap.js vendored Normal file
View File

@ -0,0 +1,4 @@
import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';

View File

@ -0,0 +1,167 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nomor Antrian - {{ $antrian->no_antrian }}</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #f8f9fa;
}
.ticket {
background: white;
border: 2px solid #333;
border-radius: 10px;
padding: 30px;
max-width: 400px;
margin: 0 auto;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
border-bottom: 2px solid #333;
padding-bottom: 20px;
margin-bottom: 20px;
}
.hospital-name {
font-size: 24px;
font-weight: bold;
color: #2563eb;
margin-bottom: 5px;
}
.hospital-subtitle {
font-size: 14px;
color: #666;
}
.queue-number {
text-align: center;
margin: 30px 0;
}
.number {
font-size: 48px;
font-weight: bold;
color: #dc2626;
margin-bottom: 10px;
}
.queue-label {
font-size: 18px;
color: #333;
font-weight: bold;
}
.patient-info {
border-top: 1px solid #ddd;
padding-top: 20px;
margin-top: 20px;
}
.info-row {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
font-size: 14px;
}
.info-label {
font-weight: bold;
color: #333;
}
.info-value {
color: #666;
}
.footer {
text-align: center;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #ddd;
font-size: 12px;
color: #666;
}
.date-time {
font-size: 12px;
color: #666;
text-align: center;
margin-top: 10px;
}
@media print {
body {
background-color: white;
}
.ticket {
box-shadow: none;
border: 1px solid #333;
}
}
</style>
</head>
<body>
<div class="ticket">
<div class="header">
<div class="hospital-name">🏥 PUSKESMAS</div>
<div class="hospital-subtitle">Sistem Antrian Digital</div>
</div>
<div class="queue-number">
<div class="number">{{ $antrian->no_antrian }}</div>
<div class="queue-label">NOMOR ANTRIAN</div>
</div>
<div class="patient-info">
<div class="info-row">
<span class="info-label">Nama:</span>
<span class="info-value">{{ $antrian->user->nama }}</span>
</div>
<div class="info-row">
<span class="info-label">Poli:</span>
<span class="info-value">{{ $antrian->poli->nama_poli }}</span>
</div>
<div class="info-row">
<span class="info-label">Status:</span>
<span class="info-value">{{ ucfirst($antrian->status) }}</span>
</div>
<div class="info-row">
<span class="info-label">Tanggal:</span>
<span class="info-value">{{ $antrian->created_at->format('d/m/Y') }}</span>
</div>
<div class="info-row">
<span class="info-label">Waktu:</span>
<span class="info-value">{{ $antrian->created_at->format('H:i') }}</span>
</div>
</div>
<div class="footer">
<div>Terima kasih telah menggunakan layanan kami</div>
<div>Mohon menunggu panggilan di layar display</div>
</div>
<div class="date-time">
Dicetak pada: {{ now()->format('d/m/Y H:i:s') }}
</div>
</div>
<script>
// Auto print when page loads
window.onload = function() {
window.print();
}
</script>
</body>
</html>

View File

@ -0,0 +1,411 @@
@extends('layouts.app')
@section('title', 'Admin Dashboard - Sistem Antrian Puskesmas')
@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>
<!-- 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">Admin Dashboard</h1>
<p class="text-gray-600 text-lg">Kelola sistem antrian Puskesmas</p>
</div>
<!-- Poli Summary -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div class="bg-white rounded-2xl shadow-xl p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-blue-100 text-blue-600">
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
</div>
<div class="ml-4">
<p class="text-sm text-gray-500">Poli Umum</p>
<p class="text-2xl font-bold text-gray-900">{{ $poliUmumCount ?? 0 }}</p>
<p class="text-xs text-gray-400">Antrian menunggu</p>
</div>
</div>
</div>
<div class="bg-white rounded-2xl shadow-xl p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-green-100 text-green-600">
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
</div>
<div class="ml-4">
<p class="text-sm text-gray-500">Poli Gigi</p>
<p class="text-2xl font-bold text-gray-900">{{ $poliGigiCount ?? 0 }}</p>
<p class="text-xs text-gray-400">Antrian menunggu</p>
</div>
</div>
</div>
<div class="bg-white rounded-2xl shadow-xl p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-purple-100 text-purple-600">
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
</div>
<div class="ml-4">
<p class="text-sm text-gray-500">Poli Jiwa</p>
<p class="text-2xl font-bold text-gray-900">{{ $poliJiwaCount ?? 0 }}</p>
<p class="text-xs text-gray-400">Antrian menunggu</p>
</div>
</div>
</div>
<div class="bg-white rounded-2xl shadow-xl p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-yellow-100 text-yellow-600">
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
</div>
<div class="ml-4">
<p class="text-sm text-gray-500">Poli Tradisional</p>
<p class="text-2xl font-bold text-gray-900">{{ $poliTradisionalCount ?? 0 }}</p>
<p class="text-xs text-gray-400">Antrian menunggu</p>
</div>
</div>
</div>
</div>
<!-- Recent Queue Table -->
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-900">Antrian Terbaru</h3>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
No. Antrian</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Nama</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Poli</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Waktu</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@forelse($antrianTerbaru ?? [] as $antrian)
<tr class="hover:bg-gray-50 transition duration-200">
<td class="px-6 py-4 whitespace-nowrap">
<span
class="text-lg font-semibold text-primary">{{ $antrian->no_antrian ?? 'N/A' }}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">
{{ $antrian->user?->nama ?? 'N/A' }}
</div>
<div class="text-sm text-gray-500">{{ $antrian->user?->no_hp ?? 'N/A' }}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<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>
</td>
<td class="px-6 py-4 whitespace-nowrap">
@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
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ $antrian->created_at?->format('H:i') ?? 'N/A' }}
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-6 py-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>
</td>
</tr>
@endforelse
</tbody>
</table>
</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');
});
// Show SweetAlert2 for success messages
@if (session('success'))
Swal.fire({
icon: 'success',
title: 'Berhasil!',
text: '{{ session('success') }}',
confirmButtonText: 'OK',
confirmButtonColor: '#10B981',
timer: 3000,
timerProgressBar: true
});
@endif
// Show welcome message for admin
@if (session('success') && str_contains(session('success'), 'Selamat datang'))
Swal.fire({
icon: 'success',
title: 'Selamat Datang!',
text: '{{ session('success') }}',
confirmButtonText: 'OK',
confirmButtonColor: '#10B981',
timer: 4000,
timerProgressBar: true,
showClass: {
popup: 'animate__animated animate__fadeInDown'
},
hideClass: {
popup: 'animate__animated animate__fadeOutUp'
}
});
@endif
// Show SweetAlert2 for error messages
@if (session('error'))
Swal.fire({
icon: 'error',
title: 'Error!',
text: '{{ session('error') }}',
confirmButtonText: 'OK',
confirmButtonColor: '#EF4444',
timer: 4000,
timerProgressBar: true
});
@endif
// Function to confirm logout
function confirmLogout() {
Swal.fire({
title: 'Konfirmasi Logout',
text: 'Apakah Anda yakin ingin keluar dari sistem?',
icon: 'question',
showCancelButton: true,
confirmButtonText: 'Ya, Logout',
cancelButtonText: 'Batal',
confirmButtonColor: '#EF4444',
cancelButtonColor: '#6B7280'
}).then((result) => {
if (result.isConfirmed) {
const form = document.createElement('form');
form.method = 'POST';
form.action = '{{ route('logout') }}';
const csrfToken = document.createElement('input');
csrfToken.type = 'hidden';
csrfToken.name = '_token';
csrfToken.value = '{{ csrf_token() }}';
form.appendChild(csrfToken);
document.body.appendChild(form);
form.submit();
}
});
}
</script>
@endpush
@endsection

View File

@ -0,0 +1,497 @@
@extends('layouts.app')
@section('title', 'Laporan Antrian - 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>
<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>
<!-- 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">Laporan Antrian</h1>
<p class="text-gray-600 text-lg">Kelola dan ekspor data antrian</p>
</div>
<!-- Export Buttons -->
<div class="flex flex-wrap gap-3 mb-8">
<a href="{{ route('admin.laporan.export-pdf', request()->query()) }}"
class="inline-flex items-center px-4 py-2 border border-red-300 rounded-md shadow-sm text-sm font-medium text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition duration-200">
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6zm5 6a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V8z"
clip-rule="evenodd"></path>
</svg>
Export PDF
</a>
<a href="{{ route('admin.laporan.export-excel', request()->query()) }}"
class="inline-flex items-center px-4 py-2 border border-green-300 rounded-md shadow-sm text-sm font-medium text-green-700 bg-white hover:bg-green-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition duration-200">
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z"
clip-rule="evenodd"></path>
</svg>
Export CSV
</a>
</div>
<!-- Filter Section -->
<div class="bg-white rounded-2xl shadow-xl border p-6 mb-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Filter Laporan</h2>
<form method="GET" action="{{ route('admin.laporan.index') }}"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- Tanggal Mulai -->
<div>
<label for="tanggal_mulai" class="block text-sm font-medium text-gray-700 mb-1">Tanggal
Mulai</label>
<input type="date" id="tanggal_mulai" name="tanggal_mulai" value="{{ request('tanggal_mulai') }}"
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>
<!-- Tanggal Akhir -->
<div>
<label for="tanggal_akhir" class="block text-sm font-medium text-gray-700 mb-1">Tanggal
Akhir</label>
<input type="date" id="tanggal_akhir" name="tanggal_akhir" value="{{ request('tanggal_akhir') }}"
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>
<!-- Poli -->
<div>
<label for="poli_id" class="block text-sm font-medium text-gray-700 mb-1">Poli</label>
<select id="poli_id" name="poli_id"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
<option value="">Semua Poli</option>
@foreach ($polis as $poli)
<option value="{{ $poli->id }}" {{ request('poli_id') == $poli->id ? 'selected' : '' }}>
{{ $poli->nama_poli }}
</option>
@endforeach
</select>
</div>
<!-- Status -->
<div>
<label for="status" class="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select id="status" name="status"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
<option value="">Semua Status</option>
<option value="menunggu" {{ request('status') == 'menunggu' ? 'selected' : '' }}>Menunggu
</option>
<option value="sedang" {{ request('status') == 'sedang' ? 'selected' : '' }}>Sedang</option>
<option value="selesai" {{ request('status') == 'selesai' ? 'selected' : '' }}>Selesai</option>
<option value="batal" {{ request('status') == 'batal' ? 'selected' : '' }}>Batal</option>
</select>
</div>
<!-- Jenis Kelamin -->
<div>
<label for="jenis_kelamin" class="block text-sm font-medium text-gray-700 mb-1">Jenis
Kelamin</label>
<select id="jenis_kelamin" name="jenis_kelamin"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
<option value="">Semua</option>
<option value="laki-laki" {{ request('jenis_kelamin') == 'laki-laki' ? 'selected' : '' }}>
Laki-laki</option>
<option value="perempuan" {{ request('jenis_kelamin') == 'perempuan' ? 'selected' : '' }}>
Perempuan</option>
</select>
</div>
<!-- Buttons -->
<div class="md:col-span-2 lg:col-span-4 flex space-x-3">
<button type="submit"
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
Filter
</button>
<a href="{{ route('admin.laporan.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-primary transition duration-200">
Reset
</a>
</div>
</form>
</div>
<!-- Statistics Cards -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="bg-white rounded-2xl shadow-xl border p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
</div>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">Total Antrian</p>
<p class="text-2xl font-bold text-gray-900">{{ $totalAntrian }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-2xl shadow-xl border p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-yellow-100 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-yellow-600" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">Menunggu</p>
<p class="text-2xl font-bold text-gray-900">{{ $antrianMenunggu }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-2xl shadow-xl border p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-green-100 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">Selesai</p>
<p class="text-2xl font-bold text-gray-900">{{ $antrianSelesai }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-2xl shadow-xl border p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-purple-100 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
</div>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">Sedang</p>
<p class="text-2xl font-bold text-gray-900">{{ $antrianSedang }}</p>
</div>
</div>
</div>
</div>
<!-- Data Table -->
<div class="bg-white rounded-2xl shadow-xl border">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-900">Data Antrian</h3>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
No Antrian</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Nama Pasien</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Poli</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Tanggal</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Waktu Daftar</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Waktu Panggil</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@forelse($antrian as $item)
<tr class="hover:bg-gray-50 transition duration-200">
<td class="px-6 py-4 whitespace-nowrap">
<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>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div>
<div class="text-sm font-medium text-gray-900">{{ $item->user->nama }}</div>
<div class="text-sm text-gray-500">{{ $item->user->no_ktp }}</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<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>
</td>
<td class="px-6 py-4 whitespace-nowrap">
@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
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ $item->created_at ? $item->created_at->format('d/m/Y') : '-' }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ $item->created_at ? $item->created_at->format('H:i') : '-' }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ $item->waktu_panggil ? $item->waktu_panggil->format('H:i') : '-' }}
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-6 py-4 text-center text-gray-500">
Tidak ada data antrian yang ditemukan
</td>
</tr>
@endforelse
</tbody>
</table>
</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');
});
// Function to confirm logout
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();
}
});
}
// Show success message
@if (session('success'))
Swal.fire({
icon: 'success',
title: 'Berhasil!',
text: '{{ session('success') }}',
confirmButtonText: 'OK',
confirmButtonColor: '#10B981'
});
@endif
// Show error message
@if (session('error'))
Swal.fire({
icon: 'error',
title: 'Error!',
text: '{{ session('error') }}',
confirmButtonText: 'OK',
confirmButtonColor: '#EF4444'
});
@endif
</script>
@endpush
@endsection

View File

@ -0,0 +1,143 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Laporan Antrian Puskesmas</title>
<style>
body {
font-family: Arial, sans-serif;
font-size: 12px;
line-height: 1.4;
color: #333;
}
.header {
text-align: center;
margin-bottom: 30px;
border-bottom: 2px solid #333;
padding-bottom: 10px;
}
.header h1 {
margin: 0;
font-size: 18px;
font-weight: bold;
}
.header p {
margin: 5px 0 0 0;
font-size: 12px;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
th,
td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
th {
background-color: #f5f5f5;
font-weight: bold;
}
.status-menunggu {
background-color: #fef3c7;
color: #92400e;
}
.status-sedang {
background-color: #e9d5ff;
color: #7c3aed;
}
.status-selesai {
background-color: #d1fae5;
color: #065f46;
}
.status-batal {
background-color: #fee2e2;
color: #991b1b;
}
.summary {
margin-top: 20px;
padding: 10px;
background-color: #f9f9f9;
border: 1px solid #ddd;
}
.summary h3 {
margin: 0 0 10px 0;
font-size: 14px;
}
.summary p {
margin: 5px 0;
}
</style>
</head>
<body>
<div class="header">
<h1>LAPORAN ANTRIAN PUSKESMAS</h1>
<p>Tanggal Cetak: {{ date('d/m/Y H:i') }}</p>
<p>Total Data: {{ $antrian->count() }} antrian</p>
</div>
<table>
<thead>
<tr>
<th>No</th>
<th>No Antrian</th>
<th>Nama Pasien</th>
<th>No KTP</th>
<th>Poli</th>
<th>Status</th>
<th>Tanggal</th>
<th>Waktu Daftar</th>
<th>Waktu Panggil</th>
</tr>
</thead>
<tbody>
@forelse($antrian as $index => $item)
<tr>
<td>{{ $index + 1 }}</td>
<td>{{ $item->no_antrian }}</td>
<td>{{ $item->user->nama }}</td>
<td>{{ $item->user->no_ktp }}</td>
<td>{{ $item->poli->nama_poli }}</td>
<td class="status-{{ $item->status }}">
{{ ucfirst($item->status) }}
</td>
<td>{{ $item->created_at ? $item->created_at->format('d/m/Y') : '-' }}</td>
<td>{{ $item->created_at ? $item->created_at->format('H:i') : '-' }}</td>
<td>{{ $item->waktu_panggil ? $item->waktu_panggil->format('H:i') : '-' }}</td>
</tr>
@empty
<tr>
<td colspan="9" style="text-align: center;">Tidak ada data antrian</td>
</tr>
@endforelse
</tbody>
</table>
<div class="summary">
<h3>Ringkasan Statistik:</h3>
<p><strong>Total Antrian:</strong> {{ $antrian->count() }}</p>
<p><strong>Menunggu:</strong> {{ $antrian->where('status', 'menunggu')->count() }}</p>
<p><strong>Sedang:</strong> {{ $antrian->where('status', 'sedang')->count() }}</p>
<p><strong>Selesai:</strong> {{ $antrian->where('status', 'selesai')->count() }}</p>
<p><strong>Batal:</strong> {{ $antrian->where('status', 'batal')->count() }}</p>
</div>
</body>
</html>

View File

@ -0,0 +1,486 @@
@extends('layouts.app')
@section('title', $title)
@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>
<!-- 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">{{ $title }}</h1>
<p class="text-gray-600 text-lg">Kelola antrian untuk {{ $title }}</p>
</div>
<!-- Table -->
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
No Antrian</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Nama</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Alamat</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Jenis Kelamin</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Nomor HP</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Nomor KTP</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Aksi</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@forelse($antrians as $antrian)
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap font-semibold text-gray-900">
{{ $antrian->no_antrian }}</td>
<td class="px-6 py-4 whitespace-nowrap text-gray-900">
{{ $antrian->user?->nama }}</td>
<td class="px-6 py-4 text-gray-700">{{ $antrian->user?->alamat }}</td>
<td class="px-6 py-4 whitespace-nowrap text-gray-700">
{{ $antrian->user?->jenis_kelamin }}</td>
<td class="px-6 py-4 whitespace-nowrap text-gray-700">
{{ $antrian->user?->no_hp }}</td>
<td class="px-6 py-4 whitespace-nowrap text-gray-700">
{{ $antrian->user?->no_ktp }}</td>
<td class="px-6 py-4 whitespace-nowrap">
@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
</td>
<td class="px-6 py-4 whitespace-nowrap">
@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
</td>
</tr>
@empty
<tr>
<td colspan="8" class="px-6 py-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">Antrian akan muncul di sini setelah ada
pendaftaran</p>
</td>
</tr>
@endforelse
</tbody>
</table>
</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');
});
function panggil(url) {
Swal.fire({
title: 'Panggil Antrian?',
icon: 'question',
showCancelButton: true,
confirmButtonText: 'Ya',
cancelButtonText: 'Batal'
}).then((res) => {
if (!res.isConfirmed) return;
fetch(url, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
}
})
.then(r => r.json())
.then(d => {
if (d.success) {
// Play TTS on display page
if (d.audio_sequence && d.poli_name && d.queue_number) {
// Send message to display page if it's open
if (window.opener && !window.opener.closed) {
window.opener.postMessage({
type: 'TTS_CALL',
poliName: d.poli_name,
queueNumber: d.queue_number,
audioSequence: d.audio_sequence
}, '*');
}
// Also try to play locally if display is not open
playTTSLocally(d.poli_name, d.queue_number);
}
Swal.fire({
icon: 'success',
title: 'Berhasil',
text: d.message
}).then(() => location.reload());
} else {
Swal.fire({
icon: 'warning',
title: 'Gagal',
text: d.message
});
}
})
.catch(() => Swal.fire({
icon: 'error',
title: 'Error',
text: 'Terjadi kesalahan'
}));
});
}
// Function to play TTS locally (fallback)
function playTTSLocally(poliName, queueNumber) {
// Create audio sequence manually
const attentionSound = '{{ asset('assets/music/call-to-attention-123107.mp3') }}';
// Play attention sound first
const audio1 = new Audio(attentionSound);
audio1.play();
// After attention sound, use browser TTS for poli and number
setTimeout(() => {
const text = `Nomor antrian ${queueNumber}, silakan menuju ke ${poliName}`;
if ('speechSynthesis' in window) {
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = 'id-ID';
utterance.rate = 0.85; // Slightly faster for more natural flow
utterance.volume = 1.0;
// Try to select a female Indonesian voice if available
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;
}
speechSynthesis.speak(utterance);
}
// Play final attention sound
setTimeout(() => {
const audio2 = new Audio(attentionSound);
audio2.play();
}, 3000);
}, 2000);
}
function selesai(url, antrianId) {
Swal.fire({
title: 'Selesai Antrian?',
icon: 'question',
showCancelButton: true,
confirmButtonText: 'Ya',
cancelButtonText: 'Batal'
}).then((res) => {
if (!res.isConfirmed) return;
fetch(url, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Content-Type': 'application/json'
},
body: JSON.stringify({
antrian_id: antrianId
})
})
.then(r => r.json())
.then(d => {
Swal.fire({
icon: d.success ? 'success' : 'warning',
title: d.success ? 'Berhasil' : 'Gagal',
text: d.message
})
.then(() => location.reload());
})
.catch(() => Swal.fire({
icon: 'error',
title: 'Error',
text: 'Terjadi kesalahan'
}));
});
}
function batal(url, antrianId) {
Swal.fire({
title: 'Batal Antrian?',
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Ya',
cancelButtonText: 'Tidak'
}).then((res) => {
if (!res.isConfirmed) return;
fetch(url, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Content-Type': 'application/json'
},
body: JSON.stringify({
antrian_id: antrianId
})
})
.then(r => r.json())
.then(d => {
Swal.fire({
icon: d.success ? 'success' : 'warning',
title: d.success ? 'Berhasil' : 'Gagal',
text: d.message
})
.then(() => location.reload());
})
.catch(() => Swal.fire({
icon: 'error',
title: 'Error',
text: 'Terjadi kesalahan'
}));
});
}
function confirmLogout() {
Swal.fire({
title: 'Konfirmasi Logout',
text: 'Apakah Anda yakin ingin keluar dari sistem?',
icon: 'question',
showCancelButton: true,
confirmButtonText: 'Ya, Logout',
cancelButtonText: 'Batal',
confirmButtonColor: '#EF4444',
cancelButtonColor: '#6B7280'
}).then((result) => {
if (result.isConfirmed) {
const form = document.createElement('form');
form.method = 'POST';
form.action = '{{ route('logout') }}';
const csrfToken = document.createElement('input');
csrfToken.type = 'hidden';
csrfToken.name = '_token';
csrfToken.value = '{{ csrf_token() }}';
form.appendChild(csrfToken);
document.body.appendChild(form);
form.submit();
}
});
}
</script>
@endpush
@endsection

View File

@ -0,0 +1,534 @@
@extends('layouts.app')
@section('title', 'Kelola User - 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>
<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>
<!-- 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>
<!-- Sidebar Overlay -->
<div id="sidebar-overlay"
class="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden hidden transition duration-200 ease-in-out">
</div>
<!-- Main Content -->
<div class="flex-1 lg:ml-0">
<div class="max-w-7xl mx-auto 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">Kelola Data User</h1>
<p class="text-gray-600 text-lg">Kelola data pengguna sistem antrian Puskesmas</p>
</div>
<!-- Search Box -->
<div class="bg-white rounded-2xl shadow-xl p-6 mb-8">
<form method="GET" action="{{ route('admin.users.index') }}" class="flex flex-col md:flex-row gap-4">
<div class="flex-1">
<label for="search" class="block text-sm font-medium text-gray-700 mb-2">Cari User</label>
<div class="relative">
<input type="text" id="search" name="search" value="{{ request('search') }}"
class="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition duration-200"
placeholder="Cari berdasarkan nama, KTP, HP, alamat, pekerjaan, atau jenis kelamin...">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</div>
</div>
</div>
<div class="flex items-end space-x-3">
<button type="submit"
class="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-medium transition duration-200 flex items-center">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
Cari
</button>
<a href="{{ route('admin.users.index') }}"
class="px-6 py-3 border border-gray-300 rounded-xl text-gray-700 font-medium hover:bg-gray-50 transition duration-200">
Reset
</a>
</div>
</form>
</div>
<!-- Users Table -->
<div class="bg-white rounded-2xl shadow-xl overflow-hidden animate-slide-up">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-900">Daftar User</h3>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 table-fixed">
<thead class="bg-gray-50">
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-1/4">
Nama & Alamat</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-1/6">
No. KTP</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-1/6">
No. HP</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-1/6">
Jenis Kelamin</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-1/6">
Pekerjaan</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-1/6">
Aksi</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@forelse($users as $user)
<tr class="hover:bg-gray-50 transition duration-200">
<td class="px-6 py-4">
<div class="text-sm font-medium text-gray-900">{{ $user->nama }}</div>
<div class="text-sm text-gray-500 max-w-xs truncate hover:text-gray-700"
title="{{ $user->alamat }}">
{{ Str::limit($user->alamat, 50) }}
@if (strlen($user->alamat) > 50)
<span class="text-blue-500">...</span>
@endif
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ $user->no_ktp }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ $user->no_hp }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<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>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ $user->pekerjaan }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button onclick="viewUser({{ $user->id }})"
class="text-blue-600 hover:text-blue-900 mr-3">Detail</button>
<button onclick="resetPassword({{ $user->id }})"
class="text-green-600 hover:text-green-900">Reset Password</button>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-6 py-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>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- User Detail Modal -->
<div id="userModal" class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden">
<div class="flex items-center justify-center min-h-screen p-4">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-2xl max-h-screen overflow-y-auto">
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-bold text-gray-900">Detail User</h3>
<button onclick="closeUserModal()" class="text-gray-400 hover:text-gray-600">
<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>
<div id="userDetailContent">
<!-- User detail content will be loaded here -->
</div>
</div>
</div>
</div>
</div>
<!-- Reset Password Modal -->
<div id="resetPasswordModal" class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden">
<div class="flex items-center justify-center min-h-screen p-4">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md">
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-bold text-gray-900">Reset Password User</h3>
<button onclick="closeResetPasswordModal()" class="text-gray-400 hover:text-gray-600">
<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>
<form id="resetPasswordForm" class="space-y-6">
@csrf
<input type="hidden" id="resetUserId" name="user_id">
<div>
<label for="new_password" class="block text-sm font-medium text-gray-700 mb-2">
Password Baru
</label>
<input type="password" name="new_password" id="new_password" required
class="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition duration-200"
placeholder="Masukkan password baru (min. 8 karakter)">
</div>
<div class="flex justify-end space-x-3 pt-6">
<button type="button" onclick="closeResetPasswordModal()"
class="px-6 py-3 border border-gray-300 rounded-xl text-gray-700 font-medium hover:bg-gray-50 transition duration-200">
Batal
</button>
<button type="submit"
class="px-6 py-3 bg-green-600 hover:bg-green-700 text-white rounded-xl font-medium transition duration-200">
Reset Password
</button>
</div>
</form>
</div>
</div>
</div>
</div>
@push('scripts')
<script>
// Sidebar functionality
document.addEventListener('DOMContentLoaded', function() {
const sidebar = document.getElementById('sidebar');
const sidebarToggle = document.getElementById('sidebar-toggle');
const sidebarClose = document.getElementById('sidebar-close');
const sidebarOverlay = document.getElementById('sidebar-overlay');
// Toggle sidebar on mobile
sidebarToggle.addEventListener('click', function() {
sidebar.classList.remove('-translate-x-full');
sidebarOverlay.classList.remove('hidden');
});
// Close sidebar
sidebarClose.addEventListener('click', function() {
sidebar.classList.add('-translate-x-full');
sidebarOverlay.classList.add('hidden');
});
// Close sidebar when clicking overlay
sidebarOverlay.addEventListener('click', function() {
sidebar.classList.add('-translate-x-full');
sidebarOverlay.classList.add('hidden');
});
});
// View user detail
function viewUser(userId) {
fetch(`/admin/users/${userId}`)
.then(response => response.text())
.then(html => {
document.getElementById('userDetailContent').innerHTML = html;
document.getElementById('userModal').classList.remove('hidden');
document.body.style.overflow = 'hidden';
})
.catch(error => {
console.error('Error:', error);
Swal.fire({
icon: 'error',
title: 'Error!',
text: 'Terjadi kesalahan saat memuat data user',
confirmButtonText: 'OK'
});
});
}
// Close user modal
function closeUserModal() {
document.getElementById('userModal').classList.add('hidden');
document.body.style.overflow = 'auto';
}
// Reset password
function resetPassword(userId) {
document.getElementById('resetUserId').value = userId;
document.getElementById('resetPasswordModal').classList.remove('hidden');
document.body.style.overflow = 'hidden';
}
// Close reset password modal
function closeResetPasswordModal() {
document.getElementById('resetPasswordModal').classList.add('hidden');
document.body.style.overflow = 'auto';
document.getElementById('resetPasswordForm').reset();
}
// Close modals when clicking outside
document.getElementById('userModal').addEventListener('click', function(e) {
if (e.target === this) {
closeUserModal();
}
});
document.getElementById('resetPasswordModal').addEventListener('click', function(e) {
if (e.target === this) {
closeResetPasswordModal();
}
});
// Reset password form submission
document.getElementById('resetPasswordForm').addEventListener('submit', function(e) {
e.preventDefault();
const userId = document.getElementById('resetUserId').value;
const newPassword = document.getElementById('new_password').value;
if (newPassword.length < 8) {
Swal.fire({
icon: 'warning',
title: 'Password Terlalu Pendek!',
text: 'Password harus minimal 8 karakter.',
confirmButtonText: 'OK',
confirmButtonColor: '#F59E0B'
});
return;
}
const formData = new FormData(this);
fetch(`/admin/users/${userId}/reset-password`, {
method: 'POST',
body: formData,
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
Swal.fire({
icon: 'success',
title: 'Berhasil!',
text: data.message,
confirmButtonText: 'OK'
}).then(() => {
closeResetPasswordModal();
});
} else {
Swal.fire({
icon: 'error',
title: 'Error!',
text: data.message,
confirmButtonText: 'OK'
});
}
})
.catch(error => {
Swal.fire({
icon: 'error',
title: 'Error!',
text: 'Terjadi kesalahan saat reset password',
confirmButtonText: 'OK'
});
});
});
// Function to confirm logout
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();
}
});
}
// Show SweetAlert2 for success messages
@if (session('success'))
Swal.fire({
icon: 'success',
title: 'Berhasil!',
text: '{{ session('success') }}',
confirmButtonText: 'OK',
confirmButtonColor: '#10B981',
timer: 3000,
timerProgressBar: true
});
@endif
// Show SweetAlert2 for error messages
@if (session('error'))
Swal.fire({
icon: 'error',
title: 'Error!',
text: '{{ session('error') }}',
confirmButtonText: 'OK',
confirmButtonColor: '#EF4444',
timer: 4000,
timerProgressBar: true
});
@endif
</script>
@endpush
@endsection

View File

@ -0,0 +1,255 @@
<div class="space-y-6">
<!-- User Info -->
<div class="bg-gray-50 rounded-xl p-6">
<h4 class="text-lg font-semibold text-gray-900 mb-4">Informasi User</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Nama Lengkap</label>
<div class="px-4 py-3 bg-white rounded-xl text-gray-900 font-medium border">
{{ $user->nama }}
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Nomor KTP</label>
<div class="px-4 py-3 bg-white rounded-xl text-gray-900 font-medium border">
{{ $user->no_ktp }}
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Nomor HP</label>
<div class="px-4 py-3 bg-white rounded-xl text-gray-900 font-medium border">
{{ $user->no_hp }}
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Jenis Kelamin</label>
<div class="px-4 py-3 bg-white rounded-xl text-gray-900 font-medium border">
{{ $user->jenis_kelamin == 'laki-laki' ? 'Laki-laki' : 'Perempuan' }}
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Pekerjaan</label>
<div class="px-4 py-3 bg-white rounded-xl text-gray-900 font-medium border">
{{ $user->pekerjaan }}
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Tanggal Registrasi</label>
<div class="px-4 py-3 bg-white rounded-xl text-gray-900 font-medium border">
{{ $user->created_at ? $user->created_at->format('d/m/Y H:i') : 'N/A' }}
</div>
</div>
</div>
<div class="mt-6">
<label class="block text-sm font-medium text-gray-700 mb-2">Alamat</label>
<div class="px-4 py-3 bg-white rounded-xl text-gray-900 font-medium border">
{{ $user->alamat }}
</div>
</div>
</div>
<!-- Edit User Form -->
<div class="bg-gray-50 rounded-xl p-6">
<h4 class="text-lg font-semibold text-gray-900 mb-4">Edit Data User</h4>
<form id="editUserForm" action="{{ route('admin.users.update', $user->id) }}" method="POST" class="space-y-6">
@csrf
@method('PUT')
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="edit_nama" class="block text-sm font-medium text-gray-700 mb-2">Nama Lengkap</label>
<input type="text" name="nama" id="edit_nama" value="{{ $user->nama }}" required
class="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition duration-200">
</div>
<div>
<label for="edit_no_hp" class="block text-sm font-medium text-gray-700 mb-2">Nomor HP</label>
<input type="tel" name="no_hp" id="edit_no_hp" value="{{ $user->no_hp }}" required
class="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition duration-200">
</div>
<div>
<label for="edit_no_ktp" class="block text-sm font-medium text-gray-700 mb-2">Nomor KTP</label>
<input type="text" name="no_ktp" id="edit_no_ktp" value="{{ $user->no_ktp }}" required
class="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition duration-200">
</div>
<div>
<label for="edit_jenis_kelamin" class="block text-sm font-medium text-gray-700 mb-2">Jenis
Kelamin</label>
<select name="jenis_kelamin" id="edit_jenis_kelamin" required
class="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition duration-200">
<option value="laki-laki" {{ $user->jenis_kelamin == 'laki-laki' ? 'selected' : '' }}>
Laki-laki</option>
<option value="perempuan" {{ $user->jenis_kelamin == 'perempuan' ? 'selected' : '' }}>
Perempuan</option>
</select>
</div>
<div class="md:col-span-2">
<label for="edit_alamat" class="block text-sm font-medium text-gray-700 mb-2">Alamat</label>
<textarea name="alamat" id="edit_alamat" rows="3" required
class="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition duration-200">{{ $user->alamat }}</textarea>
</div>
<div class="md:col-span-2">
<label for="edit_pekerjaan" class="block text-sm font-medium text-gray-700 mb-2">Pekerjaan</label>
<input type="text" name="pekerjaan" id="edit_pekerjaan" value="{{ $user->pekerjaan }}" required
class="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition duration-200">
</div>
</div>
<div class="flex justify-end space-x-3 pt-6">
<button type="button" onclick="closeUserModal()"
class="px-6 py-3 border border-gray-300 rounded-xl text-gray-700 font-medium hover:bg-gray-50 transition duration-200">
Batal
</button>
<button type="submit"
class="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-medium transition duration-200">
Simpan Perubahan
</button>
</div>
</form>
</div>
<!-- User Statistics -->
<div class="bg-gray-50 rounded-xl p-6">
<h4 class="text-lg font-semibold text-gray-900 mb-4">Statistik Antrian User</h4>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="bg-white rounded-xl p-4 border">
<div class="text-2xl font-bold text-blue-600">
{{ $user->antrians->where('status', 'menunggu')->count() }}</div>
<div class="text-sm text-gray-600">Antrian Menunggu</div>
</div>
<div class="bg-white rounded-xl p-4 border">
<div class="text-2xl font-bold text-green-600">
{{ $user->antrians->where('status', 'selesai')->count() }}</div>
<div class="text-sm text-gray-600">Antrian Selesai</div>
</div>
<div class="bg-white rounded-xl p-4 border">
<div class="text-2xl font-bold text-red-600">{{ $user->antrians->where('status', 'batal')->count() }}
</div>
<div class="text-sm text-gray-600">Antrian Batal</div>
</div>
<div class="bg-white rounded-xl p-4 border">
<div class="text-2xl font-bold text-gray-600">{{ $user->antrians->count() }}</div>
<div class="text-sm text-gray-600">Total Antrian</div>
</div>
</div>
</div>
<!-- Recent Queues -->
@if ($user->antrians->count() > 0)
<div class="bg-gray-50 rounded-xl p-6">
<h4 class="text-lg font-semibold text-gray-900 mb-4">Riwayat Antrian Terbaru</h4>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-white">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
No. Antrian</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Poli</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Tanggal</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@foreach ($user->antrians->take(5) as $antrian)
<tr class="hover:bg-gray-50 transition duration-200">
<td class="px-4 py-3 whitespace-nowrap">
<span class="text-lg font-semibold text-primary">{{ $antrian->no_antrian }}</span>
</td>
<td class="px-4 py-3 whitespace-nowrap">
<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>
</td>
<td class="px-4 py-3 whitespace-nowrap">
@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
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
{{ $antrian->created_at ? $antrian->created_at->format('d/m/Y H:i') : 'N/A' }}
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endif
</div>
<script>
// Edit user form submission
document.getElementById('editUserForm').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
fetch(this.action, {
method: 'POST',
body: formData,
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
Swal.fire({
icon: 'success',
title: 'Berhasil!',
text: data.message,
confirmButtonText: 'OK'
}).then(() => {
location.reload();
});
} else {
Swal.fire({
icon: 'error',
title: 'Error!',
text: data.message,
confirmButtonText: 'OK'
});
}
})
.catch(error => {
Swal.fire({
icon: 'error',
title: 'Error!',
text: 'Terjadi kesalahan saat menyimpan data',
confirmButtonText: 'OK'
});
});
});
</script>

View File

@ -0,0 +1,180 @@
@extends('layouts.app')
@section('title', 'Lupa Password - Sistem Antrian Puskesmas')
@section('content')
<div
class="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary via-blue-600 to-secondary py-6 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 animate-fade-in">
<!-- Header -->
<div class="text-center">
<div class="mx-auto h-20 w-20 bg-white rounded-full flex items-center justify-center mb-6 shadow-lg">
<span class="text-4xl">🔐</span>
</div>
<h2 class="text-3xl md:text-4xl font-bold text-white mb-3">Lupa Password</h2>
<p class="text-blue-100 text-lg">Verifikasi data diri untuk reset password</p>
</div>
<!-- Forgot Password Form -->
<div class="bg-white rounded-2xl shadow-2xl p-6 md:p-8 animate-slide-up">
<form method="POST" action="{{ route('forgot-password') }}" class="space-y-6">
@csrf
<!-- Nama Field -->
<div>
<label for="nama" class="block text-sm font-semibold text-gray-700 mb-2">
Nama Lengkap
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
</div>
<input id="nama" name="nama" type="text" required
class="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition duration-200 text-sm"
placeholder="Masukkan nama lengkap sesuai KTP" value="{{ old('nama') }}">
</div>
</div>
<!-- No KTP Field -->
<div>
<label for="no_ktp" class="block text-sm font-semibold text-gray-700 mb-2">
Nomor KTP
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V4a2 2 0 114 0v2m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2"></path>
</svg>
</div>
<input id="no_ktp" name="no_ktp" type="text" required
class="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition duration-200 text-sm"
placeholder="Masukkan nomor KTP" value="{{ old('no_ktp') }}">
</div>
</div>
<!-- Submit Button -->
<div>
<button type="submit"
class="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-semibold rounded-xl text-white bg-primary hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary transition duration-200 transform hover:scale-105">
<span class="absolute left-0 inset-y-0 flex items-center pl-3">
<svg class="h-5 w-5 text-blue-200 group-hover:text-blue-100 transition duration-200"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z"
clip-rule="evenodd" />
</svg>
</span>
Verifikasi Data
</button>
</div>
<!-- Back to Login Link -->
<div class="text-center">
<p class="text-sm text-gray-600">
Ingat password?
<a href="{{ route('login') }}"
class="font-semibold text-primary hover:text-secondary transition duration-200">
Login di sini
</a>
</p>
</div>
</form>
</div>
<!-- Back to Home -->
<div class="text-center">
<a href="{{ route('landing') }}"
class="text-blue-100 hover:text-white text-sm transition duration-200 flex items-center justify-center">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
Kembali ke Beranda
</a>
</div>
</div>
</div>
@push('scripts')
<script>
// Show SweetAlert2 for errors
@if ($errors->any())
Swal.fire({
icon: 'error',
title: 'Oops...',
text: '{{ $errors->first() }}',
confirmButtonText: 'OK',
confirmButtonColor: '#EF4444'
});
@endif
// Show SweetAlert2 for session errors
@if (session('error'))
Swal.fire({
icon: 'error',
title: 'Error!',
text: '{{ session('error') }}',
confirmButtonText: 'OK',
confirmButtonColor: '#EF4444',
timer: 4000,
timerProgressBar: true
});
@endif
// Show SweetAlert2 for success messages
@if (session('success'))
Swal.fire({
icon: 'success',
title: 'Berhasil!',
text: '{{ session('success') }}',
confirmButtonText: 'OK',
confirmButtonColor: '#10B981',
timer: 3000,
timerProgressBar: true
});
@endif
// Form validation with SweetAlert2
let hasConfirmedSubmission = false;
document.querySelector('form').addEventListener('submit', function(e) {
const nama = document.getElementById('nama').value.trim();
const noKtp = document.getElementById('no_ktp').value.trim();
if (!nama || !noKtp) {
e.preventDefault();
Swal.fire({
icon: 'warning',
title: 'Perhatian!',
text: 'Mohon lengkapi semua field yang diperlukan.',
confirmButtonText: 'OK',
confirmButtonColor: '#F59E0B'
});
return;
}
if (!hasConfirmedSubmission) {
e.preventDefault();
Swal.fire({
title: 'Konfirmasi Verifikasi',
text: 'Apakah Anda yakin data yang dimasukkan sudah benar?',
icon: 'question',
showCancelButton: true,
confirmButtonText: 'Ya, Verifikasi',
cancelButtonText: 'Batal',
confirmButtonColor: '#3B82F6',
cancelButtonColor: '#6B7280'
}).then((result) => {
if (result.isConfirmed) {
hasConfirmedSubmission = true;
document.querySelector('form').submit();
}
});
}
});
</script>
@endpush
@endsection

View File

@ -0,0 +1,227 @@
@extends('layouts.app')
@section('title', 'Login - Sistem Antrian Puskesmas')
@section('content')
<div
class="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary via-blue-600 to-secondary py-6 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 animate-fade-in">
<!-- Header -->
<div class="text-center">
<div class="mx-auto h-20 w-20 bg-white rounded-full flex items-center justify-center mb-6 shadow-lg">
<span class="text-4xl">🏥</span>
</div>
<h2 class="text-3xl md:text-4xl font-bold text-white mb-3">Login</h2>
<p class="text-blue-100 text-lg">Masuk ke sistem antrian Puskesmas</p>
</div>
<!-- Login Form -->
<div class="bg-white rounded-2xl shadow-2xl p-6 md:p-8 animate-slide-up">
<form method="POST" action="{{ route('login') }}" class="space-y-6">
@csrf
<!-- Username/No KTP Field -->
<div>
<label for="email_or_ktp" class="block text-sm font-semibold text-gray-700 mb-2">
Username atau No KTP
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
</div>
<input id="email_or_ktp" name="email_or_ktp" type="text" required
class="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition duration-200 text-sm"
placeholder="Admin: username | User: nama atau No KTP" value="{{ old('email_or_ktp') }}">
</div>
</div>
<!-- Password Field -->
<div>
<label for="password" class="block text-sm font-semibold text-gray-700 mb-2">
Password
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z">
</path>
</svg>
</div>
<input id="password" name="password" type="password" required
class="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition duration-200 text-sm"
placeholder="Masukkan password Anda">
</div>
</div>
<!-- Remember Me -->
<div class="flex items-center">
<input id="remember" name="remember" type="checkbox"
class="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded">
<label for="remember" class="ml-2 block text-sm text-gray-700">
Ingat saya
</label>
</div>
<!-- Submit Button -->
<div>
<button type="submit"
class="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-semibold rounded-xl text-white bg-primary hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary transition duration-200 transform hover:scale-105">
<span class="absolute left-0 inset-y-0 flex items-center pl-3">
<svg class="h-5 w-5 text-blue-200 group-hover:text-blue-100 transition duration-200"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z"
clip-rule="evenodd" />
</svg>
</span>
Masuk
</button>
</div>
<!-- Register Link -->
<div class="text-center">
<p class="text-sm text-gray-600">
Belum punya akun?
<a href="{{ route('register') }}"
class="font-semibold text-primary hover:text-secondary transition duration-200">
Daftar di sini
</a>
</p>
</div>
<!-- Forgot Password Link -->
<div class="text-center">
<p class="text-sm text-gray-600">
Lupa password?
<a href="{{ route('forgot-password') }}"
class="font-semibold text-primary hover:text-secondary transition duration-200">
Reset password di sini
</a>
</p>
</div>
</form>
</div>
<!-- Back to Home -->
<div class="text-center">
<a href="{{ route('landing') }}"
class="text-blue-100 hover:text-white text-sm transition duration-200 flex items-center justify-center">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
Kembali ke Beranda
</a>
</div>
</div>
</div>
@push('scripts')
<script>
// Show SweetAlert2 for errors
@if ($errors->any())
Swal.fire({
icon: 'error',
title: 'Oops...',
text: '{{ $errors->first() }}',
confirmButtonText: 'OK',
confirmButtonColor: '#EF4444'
});
@endif
// Show SweetAlert2 for session errors
@if (session('error'))
Swal.fire({
icon: 'error',
title: 'Error!',
text: '{{ session('error') }}',
confirmButtonText: 'OK',
confirmButtonColor: '#EF4444',
timer: 4000,
timerProgressBar: true
});
@endif
// Show login error message
@if (session('error') && str_contains(session('error'), 'Username/Nama/No KTP'))
Swal.fire({
icon: 'error',
title: 'Login Gagal!',
text: '{{ session('error') }}',
confirmButtonText: 'OK',
confirmButtonColor: '#EF4444',
timer: 4000,
timerProgressBar: true
});
@endif
// Show SweetAlert2 for success messages
@if (session('success'))
Swal.fire({
icon: 'success',
title: 'Berhasil!',
text: '{{ session('success') }}',
confirmButtonText: 'OK',
confirmButtonColor: '#10B981',
timer: 3000,
timerProgressBar: true
});
@endif
// Show register success message
@if (session('success') && str_contains(session('success'), 'Akun berhasil'))
Swal.fire({
icon: 'success',
title: 'Pendaftaran Berhasil!',
text: '{{ session('success') }}',
confirmButtonText: 'OK',
confirmButtonColor: '#10B981',
timer: 4000,
timerProgressBar: true
});
@endif
// Form validation with SweetAlert2
let hasConfirmedSubmission = false;
document.querySelector('form').addEventListener('submit', function(e) {
const emailOrKtp = document.getElementById('email_or_ktp').value.trim();
const password = document.getElementById('password').value.trim();
if (!emailOrKtp || !password) {
e.preventDefault();
Swal.fire({
icon: 'warning',
title: 'Perhatian!',
text: 'Mohon lengkapi semua field yang diperlukan.',
confirmButtonText: 'OK',
confirmButtonColor: '#F59E0B'
});
return;
}
if (!hasConfirmedSubmission) {
e.preventDefault();
Swal.fire({
title: 'Konfirmasi Login',
text: 'Apakah Anda yakin ingin masuk ke sistem?',
icon: 'question',
showCancelButton: true,
confirmButtonText: 'Ya, Login',
cancelButtonText: 'Batal',
confirmButtonColor: '#3B82F6',
cancelButtonColor: '#6B7280'
}).then((result) => {
if (result.isConfirmed) {
hasConfirmedSubmission = true;
document.querySelector('form').submit();
}
});
}
});
</script>
@endpush
@endsection

View File

@ -0,0 +1,350 @@
@extends('layouts.app')
@section('title', 'Daftar - Sistem Antrian Puskesmas')
@section('content')
<div
class="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary via-blue-600 to-secondary py-6 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 animate-fade-in">
<!-- Header -->
<div class="text-center">
<div class="mx-auto h-20 w-20 bg-white rounded-full flex items-center justify-center mb-6 shadow-lg">
<span class="text-4xl">🏥</span>
</div>
<h2 class="text-3xl md:text-4xl font-bold text-white mb-3">Daftar</h2>
<p class="text-blue-100 text-lg">Buat akun untuk mengakses sistem antrian</p>
</div>
<!-- Register Form -->
<div class="bg-white rounded-2xl shadow-2xl p-6 md:p-8 animate-slide-up">
<form method="POST" action="{{ route('register') }}" class="space-y-6">
@csrf
<!-- Nama Lengkap Field -->
<div>
<label for="nama" class="block text-sm font-semibold text-gray-700 mb-2">
Nama Lengkap
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
</div>
<input id="nama" name="nama" type="text" required
class="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition duration-200 text-sm"
placeholder="Masukkan nama lengkap" value="{{ old('nama') }}">
</div>
</div>
<!-- Alamat Field -->
<div>
<label for="alamat" class="block text-sm font-semibold text-gray-700 mb-2">
Alamat
</label>
<textarea id="alamat" name="alamat" required rows="3"
class="block w-full px-3 py-3 border border-gray-300 rounded-xl placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition duration-200 text-sm resize-none"
placeholder="Masukkan alamat lengkap">{{ old('alamat') }}</textarea>
</div>
<!-- Jenis Kelamin Field -->
<div>
<label for="jenis_kelamin" class="block text-sm font-semibold text-gray-700 mb-2">
Jenis Kelamin
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
</div>
<select id="jenis_kelamin" name="jenis_kelamin" required
class="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition duration-200 text-sm appearance-none">
<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 class="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 9l-7 7-7-7"></path>
</svg>
</div>
</div>
</div>
<!-- No HP Field -->
<div>
<label for="no_hp" class="block text-sm font-semibold text-gray-700 mb-2">
Nomor HP
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z">
</path>
</svg>
</div>
<input id="no_hp" name="no_hp" type="tel" required
class="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition duration-200 text-sm"
placeholder="Masukkan nomor HP" value="{{ old('no_hp') }}">
</div>
</div>
<!-- No KTP Field -->
<div>
<label for="no_ktp" class="block text-sm font-semibold text-gray-700 mb-2">
Nomor KTP <span class="text-red-500">*</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="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>
</div>
<input id="no_ktp" name="no_ktp" type="text" required maxlength="16" minlength="16"
class="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition duration-200 text-sm"
placeholder="Masukkan 16 digit nomor KTP" value="{{ old('no_ktp') }}"
oninput="this.value = this.value.replace(/[^0-9]/g, '')">
</div>
<p class="text-xs text-gray-500 mt-1">NIK harus tepat 16 digit angka</p>
</div>
<!-- Pekerjaan Field -->
<div>
<label for="pekerjaan" class="block text-sm font-semibold text-gray-700 mb-2">
Pekerjaan
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2-2v2m8 0V6a2 2 0 012 2v6a2 2 0 01-2 2H8a2 2 0 01-2-2V8a2 2 0 012-2V6">
</path>
</svg>
</div>
<input id="pekerjaan" name="pekerjaan" type="text" required
class="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition duration-200 text-sm"
placeholder="Masukkan pekerjaan" value="{{ old('pekerjaan') }}">
</div>
</div>
<!-- Password Field -->
<div>
<label for="password" class="block text-sm font-semibold text-gray-700 mb-2">
Password
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z">
</path>
</svg>
</div>
<input id="password" name="password" type="password" required
class="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition duration-200 text-sm"
placeholder="Masukkan password">
</div>
</div>
<!-- Password Confirmation Field -->
<div>
<label for="password_confirmation" class="block text-sm font-semibold text-gray-700 mb-2">
Konfirmasi Password
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z">
</path>
</svg>
</div>
<input id="password_confirmation" name="password_confirmation" type="password" required
class="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition duration-200 text-sm"
placeholder="Konfirmasi password">
</div>
</div>
<!-- Submit Button -->
<div>
<button type="submit"
class="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-semibold rounded-xl text-white bg-primary hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary transition duration-200 transform hover:scale-105">
<span class="absolute left-0 inset-y-0 flex items-center pl-3">
<svg class="h-5 w-5 text-blue-200 group-hover:text-blue-100 transition duration-200"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path
d="M8 9a3 3 0 100-6 3 3 0 000 6zM8 11a6 6 0 016 6H2a6 6 0 016-6zM16 7a1 1 0 10-2 0v1h-1a1 1 0 100 2h1v1a1 1 0 102 0v-1h1a1 1 0 100-2h-1V7z" />
</svg>
</span>
Daftar
</button>
</div>
<!-- Login Link -->
<div class="text-center">
<p class="text-sm text-gray-600">
Sudah punya akun?
<a href="{{ route('login') }}"
class="font-semibold text-primary hover:text-secondary transition duration-200">
Login di sini
</a>
</p>
</div>
</form>
</div>
<!-- Back to Home -->
<div class="text-center">
<a href="{{ route('landing') }}"
class="text-blue-100 hover:text-white text-sm transition duration-200 flex items-center justify-center">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
Kembali ke Beranda
</a>
</div>
</div>
</div>
@push('scripts')
<script>
// Show SweetAlert2 for errors
@if ($errors->any())
Swal.fire({
icon: 'error',
title: 'Oops...',
text: '{{ $errors->first() }}',
confirmButtonText: 'OK',
confirmButtonColor: '#EF4444'
});
@endif
// Show SweetAlert2 for session errors
@if (session('error'))
Swal.fire({
icon: 'error',
title: 'Error!',
text: '{{ session('error') }}',
confirmButtonText: 'OK',
confirmButtonColor: '#EF4444',
timer: 4000,
timerProgressBar: true
});
@endif
// Show register error message
@if (session('error') && str_contains(session('error'), 'No KTP'))
Swal.fire({
icon: 'error',
title: 'Pendaftaran Gagal!',
text: '{{ session('error') }}',
confirmButtonText: 'OK',
confirmButtonColor: '#EF4444',
timer: 4000,
timerProgressBar: true
});
@endif
// Show SweetAlert2 for success messages
@if (session('success'))
Swal.fire({
icon: 'success',
title: 'Berhasil!',
text: '{{ session('success') }}',
confirmButtonText: 'OK',
confirmButtonColor: '#10B981',
timer: 3000,
timerProgressBar: true
});
@endif
// Form validation with SweetAlert2
document.querySelector('form').addEventListener('submit', function(e) {
const requiredFields = ['nama', 'alamat', 'jenis_kelamin', 'no_hp', 'no_ktp', 'pekerjaan',
'password', 'password_confirmation'
];
let emptyFields = [];
requiredFields.forEach(field => {
const element = document.getElementById(field);
if (!element.value.trim()) {
emptyFields.push(field);
}
});
if (emptyFields.length > 0) {
e.preventDefault();
Swal.fire({
icon: 'warning',
title: 'Perhatian!',
text: 'Mohon lengkapi semua field yang diperlukan.',
confirmButtonText: 'OK',
confirmButtonColor: '#F59E0B'
});
return;
}
// Check NIK validation
const nik = document.getElementById('no_ktp').value;
if (nik.length !== 16) {
e.preventDefault();
Swal.fire({
icon: 'error',
title: 'NIK Tidak Valid!',
text: 'Nomor KTP harus tepat 16 digit.',
confirmButtonText: 'OK',
confirmButtonColor: '#EF4444'
});
return;
}
// Check password confirmation
const password = document.getElementById('password').value;
const passwordConfirmation = document.getElementById('password_confirmation').value;
if (password !== passwordConfirmation) {
e.preventDefault();
Swal.fire({
icon: 'error',
title: 'Password Tidak Cocok!',
text: 'Konfirmasi password tidak sama dengan password.',
confirmButtonText: 'OK',
confirmButtonColor: '#EF4444'
});
return;
}
// Show confirmation dialog
e.preventDefault();
Swal.fire({
title: 'Konfirmasi Pendaftaran',
text: 'Apakah Anda yakin ingin mendaftar dengan data yang telah diisi?',
icon: 'question',
showCancelButton: true,
confirmButtonText: 'Ya, Daftar',
cancelButtonText: 'Batal',
confirmButtonColor: '#10B981',
cancelButtonColor: '#6B7280'
}).then((result) => {
if (result.isConfirmed) {
// Submit the form
document.querySelector('form').submit();
}
});
});
</script>
@endpush
@endsection

View File

@ -0,0 +1,207 @@
@extends('layouts.app')
@section('title', 'Reset Password - Sistem Antrian Puskesmas')
@section('content')
<div
class="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary via-blue-600 to-secondary py-6 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 animate-fade-in">
<!-- Header -->
<div class="text-center">
<div class="mx-auto h-20 w-20 bg-white rounded-full flex items-center justify-center mb-6 shadow-lg">
<span class="text-4xl">🔑</span>
</div>
<h2 class="text-3xl md:text-4xl font-bold text-white mb-3">Reset Password</h2>
<p class="text-blue-100 text-lg">Masukkan password baru Anda</p>
</div>
<!-- Reset Password Form -->
<div class="bg-white rounded-2xl shadow-2xl p-6 md:p-8 animate-slide-up">
<form method="POST" action="{{ route('reset-password') }}" class="space-y-6">
@csrf
<input type="hidden" name="user_id" value="{{ session('reset_user_id') }}">
<!-- New Password Field -->
<div>
<label for="password" class="block text-sm font-semibold text-gray-700 mb-2">
Password Baru
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z">
</path>
</svg>
</div>
<input id="password" name="password" type="password" required
class="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition duration-200 text-sm"
placeholder="Masukkan password baru (min. 8 karakter)">
</div>
</div>
<!-- Confirm Password Field -->
<div>
<label for="password_confirmation" class="block text-sm font-semibold text-gray-700 mb-2">
Konfirmasi Password Baru
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z">
</path>
</svg>
</div>
<input id="password_confirmation" name="password_confirmation" type="password" required
class="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition duration-200 text-sm"
placeholder="Konfirmasi password baru">
</div>
</div>
<!-- Submit Button -->
<div>
<button type="submit"
class="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-semibold rounded-xl text-white bg-primary hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary transition duration-200 transform hover:scale-105">
<span class="absolute left-0 inset-y-0 flex items-center pl-3">
<svg class="h-5 w-5 text-blue-200 group-hover:text-blue-100 transition duration-200"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z"
clip-rule="evenodd" />
</svg>
</span>
Reset Password
</button>
</div>
<!-- Back to Login Link -->
<div class="text-center">
<p class="text-sm text-gray-600">
Ingat password lama?
<a href="{{ route('login') }}"
class="font-semibold text-primary hover:text-secondary transition duration-200">
Login di sini
</a>
</p>
</div>
</form>
</div>
<!-- Back to Home -->
<div class="text-center">
<a href="{{ route('landing') }}"
class="text-blue-100 hover:text-white text-sm transition duration-200 flex items-center justify-center">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
Kembali ke Beranda
</a>
</div>
</div>
</div>
@push('scripts')
<script>
// Show SweetAlert2 for errors
@if ($errors->any())
Swal.fire({
icon: 'error',
title: 'Oops...',
text: '{{ $errors->first() }}',
confirmButtonText: 'OK',
confirmButtonColor: '#EF4444'
});
@endif
// Show SweetAlert2 for session errors
@if (session('error'))
Swal.fire({
icon: 'error',
title: 'Error!',
text: '{{ session('error') }}',
confirmButtonText: 'OK',
confirmButtonColor: '#EF4444',
timer: 4000,
timerProgressBar: true
});
@endif
// Show SweetAlert2 for success messages
@if (session('success'))
Swal.fire({
icon: 'success',
title: 'Berhasil!',
text: '{{ session('success') }}',
confirmButtonText: 'OK',
confirmButtonColor: '#10B981',
timer: 3000,
timerProgressBar: true
});
@endif
// Form validation with SweetAlert2
let hasConfirmedSubmission = false;
document.querySelector('form').addEventListener('submit', function(e) {
const password = document.getElementById('password').value.trim();
const passwordConfirmation = document.getElementById('password_confirmation').value.trim();
if (!password || !passwordConfirmation) {
e.preventDefault();
Swal.fire({
icon: 'warning',
title: 'Perhatian!',
text: 'Mohon lengkapi semua field yang diperlukan.',
confirmButtonText: 'OK',
confirmButtonColor: '#F59E0B'
});
return;
}
if (password.length < 8) {
e.preventDefault();
Swal.fire({
icon: 'warning',
title: 'Password Terlalu Pendek!',
text: 'Password harus minimal 8 karakter.',
confirmButtonText: 'OK',
confirmButtonColor: '#F59E0B'
});
return;
}
if (password !== passwordConfirmation) {
e.preventDefault();
Swal.fire({
icon: 'error',
title: 'Password Tidak Cocok!',
text: 'Konfirmasi password tidak sama dengan password baru.',
confirmButtonText: 'OK',
confirmButtonColor: '#EF4444'
});
return;
}
if (!hasConfirmedSubmission) {
e.preventDefault();
Swal.fire({
title: 'Konfirmasi Reset Password',
text: 'Apakah Anda yakin ingin mereset password?',
icon: 'question',
showCancelButton: true,
confirmButtonText: 'Ya, Reset Password',
cancelButtonText: 'Batal',
confirmButtonColor: '#3B82F6',
cancelButtonColor: '#6B7280'
}).then((result) => {
if (result.isConfirmed) {
hasConfirmedSubmission = true;
document.querySelector('form').submit();
}
});
}
});
</script>
@endpush
@endsection

View File

@ -0,0 +1,560 @@
@extends('layouts.app')
@section('title', 'Dashboard - Sistem Antrian Puskesmas')
@section('content')
<div class="min-h-screen bg-gray-50">
<!-- 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">
<div class="flex-shrink-0">
<h1 class="text-xl md:text-2xl font-bold text-primary">🏥 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, {{ auth()->user()->nama ?? 'User' }}</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>
<!-- Mobile menu button -->
<div class="md:hidden flex items-center">
<button id="mobile-menu-button" class="text-gray-700 hover:text-primary">
<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>
</div>
<!-- Mobile menu -->
<div id="mobile-menu" class="hidden md:hidden">
<div class="px-2 pt-2 pb-3 space-y-1 sm:px-3 bg-white border-t">
<a href="{{ route('display') }}"
class="text-gray-700 hover:text-primary block px-3 py-2 rounded-md text-base font-medium transition duration-200">Display</a>
<span class="text-gray-700 block px-3 py-2 text-base">Selamat datang,
{{ auth()->user()->nama ?? 'User' }}</span>
<button onclick="confirmLogout()"
class="bg-red-500 hover:bg-red-600 text-white block w-full text-left px-3 py-2 rounded-md text-base font-medium transition duration-200">
Logout
</button>
</div>
</div>
</div>
</nav>
<div class="max-w-7xl mx-auto 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">Dashboard</h1>
<p class="text-gray-600 text-lg">Kelola sistem antrian Puskesmas</p>
</div>
<!-- User Info Card -->
<div class="bg-white rounded-2xl shadow-xl p-6 mb-8 animate-slide-up">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-xl font-bold text-gray-900">Data Diri</h2>
<p class="text-gray-600">Informasi pribadi Anda</p>
</div>
<div class="flex items-center space-x-3">
<div class="p-3 rounded-full bg-green-100 text-green-600">
<svg class="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
</div>
<button onclick="openEditModal()"
class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition duration-200 flex items-center">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z">
</path>
</svg>
Edit Data
</button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Nama Lengkap</label>
<div class="px-4 py-3 bg-gray-50 rounded-xl text-gray-900 font-medium">
{{ auth()->user()->nama ?? 'N/A' }}
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Nomor HP</label>
<div class="px-4 py-3 bg-gray-50 rounded-xl text-gray-900 font-medium">
{{ auth()->user()->no_hp ?? 'N/A' }}
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Nomor KTP</label>
<div class="px-4 py-3 bg-gray-50 rounded-xl text-gray-900 font-medium">
{{ auth()->user()->no_ktp ?? 'N/A' }}
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Jenis Kelamin</label>
<div class="px-4 py-3 bg-gray-50 rounded-xl text-gray-900 font-medium">
{{ auth()->user()->jenis_kelamin == 'laki-laki' ? 'Laki-laki' : 'Perempuan' }}
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Alamat</label>
<div class="px-4 py-3 bg-gray-50 rounded-xl text-gray-900 font-medium">
{{ auth()->user()->alamat ?? 'N/A' }}
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Pekerjaan</label>
<div class="px-4 py-3 bg-gray-50 rounded-xl text-gray-900 font-medium">
{{ auth()->user()->pekerjaan ?? 'N/A' }}
</div>
</div>
</div>
</div>
<!-- Add Queue Form -->
<div class="bg-white rounded-2xl shadow-xl p-6 mb-8 animate-slide-up" style="animation-delay: 0.1s;">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-xl font-bold text-gray-900">Ambil Antrian</h2>
<p class="text-gray-600">Pilih poli untuk mengambil antrian baru</p>
</div>
<div class="p-3 rounded-full bg-blue-100 text-blue-600">
<svg class="h-8 w-8" 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>
</div>
</div>
<form action="{{ route('dashboard.add-queue') }}" method="POST" class="space-y-6">
@csrf
<div class="max-w-md">
<div>
<label for="poli_id" class="block text-sm font-medium text-gray-700 mb-2">Pilih Poli</label>
<select name="poli_id" id="poli_id" required
class="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition duration-200">
<option value="">Pilih poli yang dituju</option>
<option value="1">Poli Umum</option>
<option value="2">Poli Gigi</option>
<option value="3">Poli Jiwa</option>
<option value="4">Poli Tradisional</option>
</select>
</div>
</div>
<div class="flex justify-end">
<button type="submit"
class="bg-blue-600 hover:bg-blue-700 text-white px-8 py-3 rounded-xl font-semibold transition duration-200 transform hover:scale-105 shadow-lg">
<svg class="w-5 h-5 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
Ambil Antrian
</button>
</div>
</form>
</div>
<!-- Recent Queue Table -->
<div class="bg-white rounded-2xl shadow-xl overflow-hidden animate-slide-up" style="animation-delay: 0.2s;">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-900">Antrian Saya</h3>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
No. Antrian</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Nama</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Poli</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Waktu</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Aksi</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@forelse($antrianSaya ?? [] as $antrian)
<tr class="hover:bg-gray-50 transition duration-200">
<td class="px-6 py-4 whitespace-nowrap">
<span
class="text-lg font-semibold text-primary">{{ $antrian->no_antrian ?? 'N/A' }}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">{{ $antrian->user->nama ?? 'N/A' }}
</div>
<div class="text-sm text-gray-500">{{ $antrian->user->no_hp ?? 'N/A' }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<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>
</td>
<td class="px-6 py-4 whitespace-nowrap">
@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
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ $antrian->created_at ? $antrian->created_at->format('H:i') : 'N/A' }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div class="flex space-x-2">
@if($antrian->status == 'menunggu')
<button onclick="batalAntrian({{ $antrian->id }})"
class="text-red-600 hover:text-red-900 text-xs">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
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-xs">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"></path>
</svg>
Cetak
</a>
@else
<span class="text-gray-400 text-xs cursor-not-allowed" title="Tidak dapat dicetak untuk status {{ ucfirst($antrian->status) }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"></path>
</svg>
Cetak
</span>
@endif
</div>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-6 py-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>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Edit Profile Modal -->
<div id="editModal" class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden">
<div class="flex items-center justify-center min-h-screen p-4">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-2xl max-h-screen overflow-y-auto">
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-bold text-gray-900">Edit Data Diri</h3>
<button onclick="closeEditModal()" class="text-gray-400 hover:text-gray-600">
<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>
<form id="editForm" action="{{ route('dashboard.update-profile') }}" method="POST"
class="space-y-6">
@csrf
@method('PUT')
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="edit_nama" class="block text-sm font-medium text-gray-700 mb-2">Nama
Lengkap</label>
<input type="text" name="nama" id="edit_nama" value="{{ auth()->user()->nama }}"
required
class="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition duration-200">
</div>
<div>
<label for="edit_no_hp" class="block text-sm font-medium text-gray-700 mb-2">Nomor
HP</label>
<input type="tel" name="no_hp" id="edit_no_hp"
value="{{ auth()->user()->no_hp }}" required
class="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition duration-200">
</div>
<div>
<label for="edit_no_ktp" class="block text-sm font-medium text-gray-700 mb-2">Nomor
KTP</label>
<input type="text" name="no_ktp" id="edit_no_ktp"
value="{{ auth()->user()->no_ktp }}" required
class="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition duration-200">
</div>
<div>
<label for="edit_jenis_kelamin" class="block text-sm font-medium text-gray-700 mb-2">Jenis
Kelamin</label>
<select name="jenis_kelamin" id="edit_jenis_kelamin" required
class="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition duration-200">
<option value="laki-laki"
{{ auth()->user()->jenis_kelamin == 'laki-laki' ? 'selected' : '' }}>
Laki-laki</option>
<option value="perempuan"
{{ auth()->user()->jenis_kelamin == 'perempuan' ? 'selected' : '' }}>
Perempuan</option>
</select>
</div>
<div class="md:col-span-2">
<label for="edit_alamat"
class="block text-sm font-medium text-gray-700 mb-2">Alamat</label>
<textarea name="alamat" id="edit_alamat" rows="3" required
class="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition duration-200">{{ auth()->user()->alamat }}</textarea>
</div>
<div class="md:col-span-2">
<label for="edit_pekerjaan"
class="block text-sm font-medium text-gray-700 mb-2">Pekerjaan</label>
<input type="text" name="pekerjaan" id="edit_pekerjaan"
value="{{ auth()->user()->pekerjaan }}" required
class="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition duration-200">
</div>
</div>
<div class="flex justify-end space-x-3 pt-6">
<button type="button" onclick="closeEditModal()"
class="px-6 py-3 border border-gray-300 rounded-xl text-gray-700 font-medium hover:bg-gray-50 transition duration-200">
Batal
</button>
<button type="submit"
class="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-medium transition duration-200">
Simpan Perubahan
</button>
</div>
</form>
</div>
</div>
</div>
</div>
@push('scripts')
<script>
// Mobile menu toggle
document.getElementById('mobile-menu-button').addEventListener('click', function() {
const mobileMenu = document.getElementById('mobile-menu');
mobileMenu.classList.toggle('hidden');
});
// Modal functions
function openEditModal() {
document.getElementById('editModal').classList.remove('hidden');
document.body.style.overflow = 'hidden';
}
function closeEditModal() {
document.getElementById('editModal').classList.add('hidden');
document.body.style.overflow = 'auto';
}
// Close modal when clicking outside
document.getElementById('editModal').addEventListener('click', function(e) {
if (e.target === this) {
closeEditModal();
}
});
// Form submission
document.getElementById('editForm').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
fetch(this.action, {
method: 'POST',
body: formData,
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
Swal.fire({
icon: 'success',
title: 'Berhasil!',
text: data.message,
confirmButtonText: 'OK'
}).then(() => {
location.reload();
});
} else {
Swal.fire({
icon: 'error',
title: 'Error!',
text: data.message,
confirmButtonText: 'OK'
});
}
})
.catch(error => {
Swal.fire({
icon: 'error',
title: 'Error!',
text: 'Terjadi kesalahan saat menyimpan data',
confirmButtonText: 'OK'
});
});
});
// Show SweetAlert2 for success messages
@if (session('success'))
Swal.fire({
icon: 'success',
title: 'Berhasil!',
text: '{{ session('success') }}',
confirmButtonText: 'OK',
confirmButtonColor: '#10B981',
timer: 3000,
timerProgressBar: true
});
@endif
// Show SweetAlert2 for error messages
@if (session('error'))
Swal.fire({
icon: 'error',
title: 'Error!',
text: '{{ session('error') }}',
confirmButtonText: 'OK',
confirmButtonColor: '#EF4444',
timer: 4000,
timerProgressBar: true
});
@endif
// Function to confirm logout
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();
}
});
}
// Function to cancel queue
function batalAntrian(antrianId) {
Swal.fire({
title: 'Konfirmasi Pembatalan',
text: 'Apakah Anda yakin ingin membatalkan antrian ini?',
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Ya, Batalkan',
cancelButtonText: 'Tidak',
confirmButtonColor: '#EF4444',
cancelButtonColor: '#6B7280'
}).then((result) => {
if (result.isConfirmed) {
const formData = new FormData();
formData.append('antrian_id', antrianId);
formData.append('_token', document.querySelector('meta[name="csrf-token"]').content);
fetch('/user/antrian/batal', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
Swal.fire({
icon: 'success',
title: 'Berhasil!',
text: data.message,
confirmButtonText: 'OK'
}).then(() => {
location.reload();
});
} else {
Swal.fire({
icon: 'error',
title: 'Error!',
text: data.message,
confirmButtonText: 'OK'
});
}
})
.catch(error => {
Swal.fire({
icon: 'error',
title: 'Error!',
text: 'Terjadi kesalahan saat membatalkan antrian',
confirmButtonText: 'OK'
});
});
}
});
}
</script>
@endpush
@endsection

View File

@ -0,0 +1,435 @@
@extends('layouts.app')
@section('title', 'Display Antrian - Puskesmas')
@section('content')
<div class="min-h-screen bg-gradient-to-br from-blue-900 via-blue-800 to-blue-700">
<div class="container mx-auto px-4 py-6 md:py-8">
<!-- Header -->
<div class="text-center mb-8 md:mb-12 animate-fade-in">
<h1 class="text-3xl md:text-5xl lg:text-6xl font-bold text-white mb-3">🏥 PUSKESMAS</h1>
<p class="text-blue-200 text-lg md:text-xl">Sistem Antrian Digital</p>
<div class="w-24 h-1 bg-white mx-auto mt-4 rounded-full"></div>
</div>
<!-- Current Queue Display -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 md:gap-8">
<!-- Poli Umum -->
<div class="bg-white rounded-2xl shadow-2xl p-6 md:p-8 animate-slide-up">
<div class="text-center mb-6">
<h2 class="text-xl md:text-2xl font-bold text-gray-900 mb-3">Poli Umum</h2>
<div class="w-16 h-1 bg-blue-500 mx-auto rounded-full"></div>
</div>
<div class="text-center mb-6">
<div class="text-4xl md:text-6xl lg:text-7xl font-bold text-blue-600 mb-3" id="poli-umum-current">
{{ $poliUmumCurrent?->no_antrian ?? '---' }}
</div>
<p class="text-gray-600 text-sm md:text-base font-medium">Sedang Dipanggil</p>
</div>
<div class="mt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Antrian Berikutnya:</h3>
<div class="space-y-3">
@forelse($poliUmumNext ?? [] as $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>
@empty
<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>
@endforelse
</div>
</div>
</div>
<!-- Poli Gigi -->
<div class="bg-white rounded-2xl shadow-2xl p-6 md:p-8 animate-slide-up" style="animation-delay: 0.1s;">
<div class="text-center mb-6">
<h2 class="text-xl md:text-2xl font-bold text-gray-900 mb-3">Poli Gigi</h2>
<div class="w-16 h-1 bg-green-500 mx-auto rounded-full"></div>
</div>
<div class="text-center mb-6">
<div class="text-4xl md:text-6xl lg:text-7xl font-bold text-green-600 mb-3" id="poli-gigi-current">
{{ $poliGigiCurrent?->no_antrian ?? '---' }}
</div>
<p class="text-gray-600 text-sm md:text-base font-medium">Sedang Dipanggil</p>
</div>
<div class="mt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Antrian Berikutnya:</h3>
<div class="space-y-3">
@forelse($poliGigiNext ?? [] as $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>
@empty
<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>
@endforelse
</div>
</div>
</div>
<!-- Poli Jiwa -->
<div class="bg-white rounded-2xl shadow-2xl p-6 md:p-8 animate-slide-up" style="animation-delay: 0.2s;">
<div class="text-center mb-6">
<h2 class="text-xl md:text-2xl font-bold text-gray-900 mb-3">Poli Jiwa</h2>
<div class="w-16 h-1 bg-pink-500 mx-auto rounded-full"></div>
</div>
<div class="text-center mb-6">
<div class="text-4xl md:text-6xl lg:text-7xl font-bold text-pink-600 mb-3" id="poli-jiwa-current">
{{ $poliJiwaCurrent?->no_antrian ?? '---' }}
</div>
<p class="text-gray-600 text-sm md:text-base font-medium">Sedang Dipanggil</p>
</div>
<div class="mt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Antrian Berikutnya:</h3>
<div class="space-y-3">
@forelse($poliJiwaNext ?? [] as $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>
@empty
<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>
@endforelse
</div>
</div>
</div>
<!-- Poli Tradisional -->
<div class="bg-white rounded-2xl shadow-2xl p-6 md:p-8 animate-slide-up" style="animation-delay: 0.3s;">
<div class="text-center mb-6">
<h2 class="text-xl md:text-2xl font-bold text-gray-900 mb-3">Poli Tradisional</h2>
<div class="w-16 h-1 bg-yellow-500 mx-auto rounded-full"></div>
</div>
<div class="text-center mb-6">
<div class="text-4xl md:text-6xl lg:text-7xl font-bold text-yellow-600 mb-3"
id="poli-tradisional-current">
{{ $poliTradisionalCurrent?->no_antrian ?? '---' }}
</div>
<p class="text-gray-600 text-sm md:text-base font-medium">Sedang Dipanggil</p>
</div>
<div class="mt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Antrian Berikutnya:</h3>
<div class="space-y-3">
@forelse($poliTradisionalNext ?? [] as $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>
@empty
<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" />
</svg>
<p class="text-sm">Tidak ada antrian</p>
</div>
@endforelse
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="text-center mt-12 md:mt-16">
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-4 md:p-6 inline-block">
<p class="text-blue-200 text-sm md:text-base font-medium">
<svg class="w-4 h-4 inline mr-2" 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>
{{ now()->format('d F Y H:i:s') }}
</p>
</div>
</div>
</div>
</div>
@push('scripts')
<script>
// TTS Audio Player
class TTSAudioPlayer {
constructor() {
this.audioQueue = [];
this.isPlaying = false;
this.currentAudio = null;
}
// Play complete audio sequence
async playAudioSequence(audioSequence) {
if (this.isPlaying) {
console.log('Audio already playing, queueing...');
this.audioQueue.push(audioSequence);
return;
}
this.isPlaying = true;
for (let i = 0; i < audioSequence.length; i++) {
const audioItem = audioSequence[i];
await this.playAudioItem(audioItem);
// Wait between audio items
if (i < audioSequence.length - 1) {
await this.delay(500);
}
}
this.isPlaying = false;
// Play next in queue if available
if (this.audioQueue.length > 0) {
const nextSequence = this.audioQueue.shift();
this.playAudioSequence(nextSequence);
}
}
// Play single audio item
playAudioItem(audioItem) {
return new Promise((resolve, reject) => {
if (audioItem.type === 'browser_tts') {
// Use browser TTS
if ('speechSynthesis' in window) {
const utterance = new SpeechSynthesisUtterance(audioItem.text);
utterance.lang = 'id-ID';
utterance.rate = 0.85; // Slightly faster for more natural flow
utterance.volume = 1.0;
// Try to select a female Indonesian voice if available
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;
}
utterance.addEventListener('end', () => {
resolve();
});
utterance.addEventListener('error', (error) => {
console.error('TTS error:', error);
resolve();
});
speechSynthesis.speak(utterance);
// Fallback timeout
setTimeout(() => {
resolve();
}, audioItem.duration || 4000);
} else {
console.warn('Speech synthesis not supported');
resolve();
}
} else {
// Play audio file
const audio = new Audio(audioItem.url);
audio.addEventListener('loadeddata', () => {
audio.play();
});
audio.addEventListener('ended', () => {
resolve();
});
audio.addEventListener('error', (error) => {
console.error('Audio playback error:', error);
resolve(); // Continue even if audio fails
});
// Fallback timeout
setTimeout(() => {
resolve();
}, audioItem.duration || 3000);
}
});
}
// Utility function for delays
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Play TTS for queue call
async playQueueCall(poliName, queueNumber) {
try {
const response = await fetch('{{ route('tts.play-sequence') }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({
poli_name: poliName,
queue_number: queueNumber
})
});
const data = await response.json();
if (data.success && data.audio_sequence) {
await this.playAudioSequence(data.audio_sequence);
} else {
console.error('Failed to get audio sequence:', data.message);
}
} catch (error) {
console.error('Error playing TTS:', error);
}
}
}
// Initialize TTS Audio Player
const ttsPlayer = new TTSAudioPlayer();
// Show SweetAlert2 for success messages
@if (session('success'))
Swal.fire({
icon: 'success',
title: 'Berhasil!',
text: '{{ session('success') }}',
confirmButtonText: 'OK',
confirmButtonColor: '#10B981',
timer: 3000,
timerProgressBar: true
});
@endif
// Show SweetAlert2 for error messages
@if (session('error'))
Swal.fire({
icon: 'error',
title: 'Error!',
text: '{{ session('error') }}',
confirmButtonText: 'OK',
confirmButtonColor: '#EF4444',
timer: 4000,
timerProgressBar: true
});
@endif
// Show display error message
@if (session('error') && str_contains(session('error'), 'display'))
Swal.fire({
icon: 'error',
title: 'Error Display!',
text: '{{ session('error') }}',
confirmButtonText: 'OK',
confirmButtonColor: '#EF4444',
timer: 4000,
timerProgressBar: true
});
@endif
// Auto refresh every 5 seconds
setInterval(function() {
location.reload();
}, 5000);
// Add sound effect for new calls
function playNotificationSound() {
const audio = new Audio(
'data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2/LDciUFLIHO8tiJNwgZaLvt559NEAxQp+PwtmMcBjiR1/LMeSwFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBSuBzvLZiTYIG2m98OScTgwOUarm7blmGgU7k9n1unEiBC13yO/eizEIHWq+8+OWT'
);
audio.play();
}
// Check for new calls every 2 seconds
setInterval(function() {
// This would typically make an AJAX call to check for new calls
// For now, we'll just reload the page
}, 2000);
// Show notification when new call is detected
function showNewCallNotification(poliName, number) {
Swal.fire({
icon: 'info',
title: 'Panggilan Baru!',
text: `Poli ${poliName} memanggil nomor ${number}`,
confirmButtonText: 'OK',
confirmButtonColor: '#3B82F6',
timer: 5000,
timerProgressBar: true,
toast: true,
position: 'top-end',
showConfirmButton: false,
background: '#3B82F6',
color: '#ffffff'
});
// Play notification sound
playNotificationSound();
}
// Add pulse animation to current numbers
function addPulseAnimation() {
const currentNumbers = document.querySelectorAll('[id$="-current"]');
currentNumbers.forEach(element => {
if (element.textContent !== '---') {
element.classList.add('animate-pulse');
setTimeout(() => {
element.classList.remove('animate-pulse');
}, 2000);
}
});
}
// Initialize animations
document.addEventListener('DOMContentLoaded', function() {
addPulseAnimation();
});
// Listen for TTS events from admin panel
window.addEventListener('message', function(event) {
if (event.data.type === 'TTS_CALL') {
const {
poliName,
queueNumber
} = event.data;
ttsPlayer.playQueueCall(poliName, queueNumber);
}
});
</script>
@endpush
@endsection

View File

@ -0,0 +1,336 @@
@extends('layouts.app')
@section('title', 'Sistem Antrian Puskesmas - Beranda')
@section('content')
@if (session('success'))
<div id="success-message"
class="fixed top-4 right-4 bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded shadow-lg z-50">
{{ session('success') }}
</div>
@endif
<!-- 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">
<div class="flex-shrink-0">
<h1 class="text-xl md:text-2xl font-bold text-primary">🏥 Puskesmas</h1>
</div>
</div>
<div class="hidden md:flex items-center space-x-4">
<a href="#layanan"
class="text-gray-700 hover:text-primary px-3 py-2 rounded-md text-sm font-medium transition duration-200">Layanan</a>
<a href="#tentang"
class="text-gray-700 hover:text-primary px-3 py-2 rounded-md text-sm font-medium transition duration-200">Tentang</a>
<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>
<a href="{{ route('login') }}"
class="bg-primary hover:bg-secondary text-white px-4 py-2 rounded-md text-sm font-medium transition duration-200">Login</a>
</div>
<!-- Mobile menu button -->
<div class="md:hidden flex items-center">
<button id="mobile-menu-button" class="text-gray-700 hover:text-primary">
<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>
</div>
<!-- Mobile menu -->
<div id="mobile-menu" class="hidden md:hidden">
<div class="px-2 pt-2 pb-3 space-y-1 sm:px-3 bg-white border-t">
<a href="#layanan"
class="text-gray-700 hover:text-primary block px-3 py-2 rounded-md text-base font-medium transition duration-200">Layanan</a>
<a href="#tentang"
class="text-gray-700 hover:text-primary block px-3 py-2 rounded-md text-base font-medium transition duration-200">Tentang</a>
<a href="{{ route('display') }}"
class="text-gray-700 hover:text-primary block px-3 py-2 rounded-md text-base font-medium transition duration-200">Display</a>
<a href="{{ route('login') }}"
class="bg-primary hover:bg-secondary text-white block px-3 py-2 rounded-md text-base font-medium transition duration-200">Login</a>
</div>
</div>
</div>
</nav>
<!-- Hero Section -->
<div class="bg-gradient-to-r from-primary via-blue-600 to-secondary text-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 md:py-24">
<div class="text-center animate-fade-in">
<h1 class="text-4xl md:text-6xl lg:text-7xl font-bold mb-6 leading-tight">
Sistem Antrian Puskesmas
</h1>
<p class="text-xl md:text-2xl mb-8 text-blue-100 max-w-4xl mx-auto">
Antrian digital yang memudahkan pelayanan kesehatan masyarakat
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="{{ route('register') }}"
class="bg-white text-primary hover:bg-gray-100 px-8 py-4 rounded-xl text-lg font-semibold inline-block transition duration-200 transform hover:scale-105 shadow-lg">
Daftar Antrian
</a>
<a href="#layanan"
class="border-2 border-white text-white hover:bg-white hover:text-primary px-8 py-4 rounded-xl text-lg font-semibold inline-block transition duration-200 transform hover:scale-105">
Lihat Layanan
</a>
</div>
</div>
</div>
</div>
<!-- Features Section -->
<div id="layanan" class="py-16 md:py-24 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12 md:mb-16 animate-slide-up">
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 mb-4">Layanan Kami</h2>
<p class="text-lg md:text-xl text-gray-600 max-w-3xl mx-auto">Berbagai layanan kesehatan yang tersedia</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-12">
<div
class="bg-white p-6 md:p-8 rounded-2xl shadow-xl border border-gray-200 hover:shadow-2xl transition duration-300 transform hover:-translate-y-2 animate-slide-up">
<div class="text-4xl md:text-5xl mb-4">👨‍⚕️</div>
<h3 class="text-xl md:text-2xl font-semibold mb-3">Poli Umum</h3>
<p class="text-gray-600 text-sm md:text-base">Layanan pemeriksaan kesehatan umum untuk semua usia</p>
</div>
<div class="bg-white p-6 md:p-8 rounded-2xl shadow-xl border border-gray-200 hover:shadow-2xl transition duration-300 transform hover:-translate-y-2 animate-slide-up"
style="animation-delay: 0.1s;">
<div class="text-4xl md:text-5xl mb-4">👶</div>
<h3 class="text-xl md:text-2xl font-semibold mb-3">Poli Anak</h3>
<p class="text-gray-600 text-sm md:text-base">Layanan kesehatan khusus untuk anak-anak</p>
</div>
<div class="bg-white p-6 md:p-8 rounded-2xl shadow-xl border border-gray-200 hover:shadow-2xl transition duration-300 transform hover:-translate-y-2 animate-slide-up"
style="animation-delay: 0.2s;">
<div class="text-4xl md:text-5xl mb-4">🤰</div>
<h3 class="text-xl md:text-2xl font-semibold mb-3">Poli Ibu Hamil</h3>
<p class="text-gray-600 text-sm md:text-base">Layanan kesehatan untuk ibu hamil dan keluarga berencana
</p>
</div>
</div>
</div>
</div>
<!-- How It Works -->
<div class="py-16 md:py-24 bg-gray-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12 md:mb-16 animate-slide-up">
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 mb-4">Cara Kerja</h2>
<p class="text-lg md:text-xl text-gray-600 max-w-3xl mx-auto">Langkah-langkah menggunakan sistem antrian
digital</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 md:gap-12">
<div class="text-center animate-slide-up">
<div
class="bg-primary text-white rounded-full w-16 h-16 md:w-20 md:h-20 flex items-center justify-center text-2xl font-bold mx-auto mb-4 shadow-lg">
1</div>
<h3 class="text-lg md:text-xl font-semibold mb-3">Daftar Online</h3>
<p class="text-gray-600 text-sm md:text-base">Isi formulir pendaftaran dengan data lengkap</p>
</div>
<div class="text-center animate-slide-up" style="animation-delay: 0.1s;">
<div
class="bg-primary text-white rounded-full w-16 h-16 md:w-20 md:h-20 flex items-center justify-center text-2xl font-bold mx-auto mb-4 shadow-lg">
2</div>
<h3 class="text-lg md:text-xl font-semibold mb-3">Dapatkan Nomor</h3>
<p class="text-gray-600 text-sm md:text-base">Sistem akan memberikan nomor antrian</p>
</div>
<div class="text-center animate-slide-up" style="animation-delay: 0.2s;">
<div
class="bg-primary text-white rounded-full w-16 h-16 md:w-20 md:h-20 flex items-center justify-center text-2xl font-bold mx-auto mb-4 shadow-lg">
3</div>
<h3 class="text-lg md:text-xl font-semibold mb-3">Tunggu Panggilan</h3>
<p class="text-gray-600 text-sm md:text-base">Monitor layar atau tunggu panggilan</p>
</div>
<div class="text-center animate-slide-up" style="animation-delay: 0.3s;">
<div
class="bg-primary text-white rounded-full w-16 h-16 md:w-20 md:h-20 flex items-center justify-center text-2xl font-bold mx-auto mb-4 shadow-lg">
4</div>
<h3 class="text-lg md:text-xl font-semibold mb-3">Layanan</h3>
<p class="text-gray-600 text-sm md:text-base">Dapatkan pelayanan kesehatan</p>
</div>
</div>
</div>
</div>
<!-- About Section -->
<div id="tentang" class="py-16 md:py-24 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-16 items-center">
<div class="animate-slide-up">
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 mb-6">Tentang Kami</h2>
<p class="text-lg md:text-xl text-gray-600 mb-4">
Puskesmas kami berkomitmen untuk memberikan pelayanan kesehatan yang berkualitas
kepada masyarakat dengan sistem antrian digital yang modern dan efisien.
</p>
<p class="text-lg md:text-xl text-gray-600 mb-8">
Dengan teknologi terkini, kami memastikan proses antrian berjalan lancar
dan mengurangi waktu tunggu pasien.
</p>
<div class="space-y-3">
<div class="flex items-center">
<div class="w-3 h-3 bg-primary rounded-full mr-4"></div>
<span class="text-gray-700 text-base md:text-lg">Pelayanan 24/7</span>
</div>
<div class="flex items-center">
<div class="w-3 h-3 bg-primary rounded-full mr-4"></div>
<span class="text-gray-700 text-base md:text-lg">Sistem Antrian Digital</span>
</div>
<div class="flex items-center">
<div class="w-3 h-3 bg-primary rounded-full mr-4"></div>
<span class="text-gray-700 text-base md:text-lg">Tim Medis Profesional</span>
</div>
</div>
</div>
<div class="bg-gradient-to-br from-blue-50 to-blue-100 rounded-2xl p-8 md:p-12 animate-slide-up"
style="animation-delay: 0.2s;">
<div class="text-center">
<div class="text-6xl md:text-8xl mb-6">🏥</div>
<h3 class="text-2xl md:text-3xl font-bold text-gray-900 mb-4">Puskesmas Modern</h3>
<p class="text-gray-600 text-base md:text-lg">Melayani dengan teknologi terkini</p>
</div>
</div>
</div>
</div>
</div>
<!-- Footer -->
<footer class="bg-gray-900 text-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 md:gap-12">
<div>
<h3 class="text-xl md:text-2xl font-bold mb-4">🏥 Puskesmas</h3>
<p class="text-gray-400 text-sm md:text-base">Sistem antrian digital untuk pelayanan kesehatan yang
lebih baik.</p>
</div>
<div>
<h4 class="text-lg md:text-xl font-semibold mb-4">Layanan</h4>
<ul class="space-y-2 text-gray-400 text-sm md:text-base">
<li>Poli Umum</li>
<li>Poli Anak</li>
<li>Poli Ibu Hamil</li>
</ul>
</div>
<div>
<h4 class="text-lg md:text-xl font-semibold mb-4">Kontak</h4>
<ul class="space-y-2 text-gray-400 text-sm md:text-base">
<li>📞 (021) 1234-5678</li>
<li>📧 info@puskesmas.com</li>
<li>📍 Jl. Kesehatan No. 123</li>
</ul>
</div>
<div>
<h4 class="text-lg md:text-xl font-semibold mb-4">Jam Operasional</h4>
<ul class="space-y-2 text-gray-400 text-sm md:text-base">
<li>Senin - Jumat: 08:00 - 16:00</li>
<li>Sabtu: 08:00 - 12:00</li>
<li>Minggu: Tutup</li>
</ul>
</div>
</div>
<div class="border-t border-gray-800 mt-8 md:mt-12 pt-8 text-center text-gray-400">
<p class="text-sm md:text-base">&copy; 2024 Sistem Antrian Puskesmas. All rights reserved.</p>
</div>
</div>
</footer>
@push('scripts')
<script>
// Mobile menu toggle
document.getElementById('mobile-menu-button').addEventListener('click', function() {
const mobileMenu = document.getElementById('mobile-menu');
mobileMenu.classList.toggle('hidden');
});
// Smooth scrolling for anchor links
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function(e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute('href'));
if (target) {
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
});
// Show SweetAlert2 for success messages
@if (session('success'))
Swal.fire({
icon: 'success',
title: 'Berhasil!',
text: '{{ session('success') }}',
confirmButtonText: 'OK',
confirmButtonColor: '#10B981',
timer: 3000,
timerProgressBar: true
});
@endif
// Show logout success message
@if (session('success') && str_contains(session('success'), 'logout'))
Swal.fire({
icon: 'success',
title: 'Logout Berhasil!',
text: '{{ session('success') }}',
confirmButtonText: 'OK',
confirmButtonColor: '#10B981',
timer: 3000,
timerProgressBar: true
});
@endif
// Show SweetAlert2 for error messages
@if (session('error'))
Swal.fire({
icon: 'error',
title: 'Error!',
text: '{{ session('error') }}',
confirmButtonText: 'OK',
confirmButtonColor: '#EF4444',
timer: 4000,
timerProgressBar: true
});
@endif
// Show system error message
@if (session('error') && str_contains(session('error'), 'sistem'))
Swal.fire({
icon: 'error',
title: 'Error Sistem!',
text: '{{ session('error') }}',
confirmButtonText: 'OK',
confirmButtonColor: '#EF4444',
timer: 4000,
timerProgressBar: true
});
@endif
// Intersection Observer for animations
const observerOptions = {
threshold: 0.1,
rootMargin: '0px 0px -50px 0px'
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('animate-fade-in');
}
});
}, observerOptions);
// Observe all sections
document.querySelectorAll('section, .animate-slide-up').forEach(el => {
observer.observe(el);
});
</script>
@endpush
@endsection

View File

@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>@yield('title', 'Sistem Antrian Puskesmas')</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#3B82F6',
secondary: '#1E40AF',
accent: '#10B981',
success: '#10B981',
warning: '#F59E0B',
error: '#EF4444',
info: '#3B82F6'
},
fontFamily: {
sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
},
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'bounce-in': 'bounceIn 0.6s ease-out',
},
keyframes: {
fadeIn: {
'0%': {
opacity: '0'
},
'100%': {
opacity: '1'
},
},
slideUp: {
'0%': {
transform: 'translateY(10px)',
opacity: '0'
},
'100%': {
transform: 'translateY(0)',
opacity: '1'
},
},
bounceIn: {
'0%': {
transform: 'scale(0.3)',
opacity: '0'
},
'50%': {
transform: 'scale(1.05)'
},
'70%': {
transform: 'scale(0.9)'
},
'100%': {
transform: 'scale(1)',
opacity: '1'
},
},
}
}
}
}
</script>
<!-- SweetAlert2 -->
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<!-- Custom Styles -->
<style>
.swal2-popup {
font-family: 'Inter', sans-serif;
}
.swal2-title {
color: #1F2937 !important;
}
.swal2-content {
color: #6B7280 !important;
}
.swal2-confirm {
background-color: #3B82F6 !important;
}
.swal2-cancel {
background-color: #6B7280 !important;
}
.swal2-success {
background-color: #10B981 !important;
}
.swal2-error {
background-color: #EF4444 !important;
}
.swal2-warning {
background-color: #F59E0B !important;
}
.swal2-info {
background-color: #3B82F6 !important;
}
</style>
@stack('styles')
</head>
<body class="bg-gray-50 font-sans antialiased">
@yield('content')
@stack('scripts')
</body>
</html>

View File

@ -0,0 +1,167 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nomor Antrian - {{ $antrian->no_antrian }}</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #f8f9fa;
}
.ticket {
background: white;
border: 2px solid #333;
border-radius: 10px;
padding: 30px;
max-width: 400px;
margin: 0 auto;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
border-bottom: 2px solid #333;
padding-bottom: 20px;
margin-bottom: 20px;
}
.hospital-name {
font-size: 24px;
font-weight: bold;
color: #2563eb;
margin-bottom: 5px;
}
.hospital-subtitle {
font-size: 14px;
color: #666;
}
.queue-number {
text-align: center;
margin: 30px 0;
}
.number {
font-size: 48px;
font-weight: bold;
color: #dc2626;
margin-bottom: 10px;
}
.queue-label {
font-size: 18px;
color: #333;
font-weight: bold;
}
.patient-info {
border-top: 1px solid #ddd;
padding-top: 20px;
margin-top: 20px;
}
.info-row {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
font-size: 14px;
}
.info-label {
font-weight: bold;
color: #333;
}
.info-value {
color: #666;
}
.footer {
text-align: center;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #ddd;
font-size: 12px;
color: #666;
}
.date-time {
font-size: 12px;
color: #666;
text-align: center;
margin-top: 10px;
}
@media print {
body {
background-color: white;
}
.ticket {
box-shadow: none;
border: 1px solid #333;
}
}
</style>
</head>
<body>
<div class="ticket">
<div class="header">
<div class="hospital-name">🏥 PUSKESMAS</div>
<div class="hospital-subtitle">Sistem Antrian Digital</div>
</div>
<div class="queue-number">
<div class="number">{{ $antrian->no_antrian }}</div>
<div class="queue-label">NOMOR ANTRIAN</div>
</div>
<div class="patient-info">
<div class="info-row">
<span class="info-label">Nama:</span>
<span class="info-value">{{ $antrian->user->nama }}</span>
</div>
<div class="info-row">
<span class="info-label">Poli:</span>
<span class="info-value">{{ $antrian->poli->nama_poli }}</span>
</div>
<div class="info-row">
<span class="info-label">Status:</span>
<span class="info-value">{{ ucfirst($antrian->status) }}</span>
</div>
<div class="info-row">
<span class="info-label">Tanggal:</span>
<span class="info-value">{{ $antrian->created_at->format('d/m/Y') }}</span>
</div>
<div class="info-row">
<span class="info-label">Waktu:</span>
<span class="info-value">{{ $antrian->created_at->format('H:i') }}</span>
</div>
</div>
<div class="footer">
<div>Terima kasih telah menggunakan layanan kami</div>
<div>Mohon menunggu panggilan di layar display</div>
</div>
<div class="date-time">
Dicetak pada: {{ now()->format('d/m/Y H:i:s') }}
</div>
</div>
<script>
// Auto print when page loads
window.onload = function() {
window.print();
}
</script>
</body>
</html>

View File

@ -0,0 +1,83 @@
@extends('layouts.app')
@section('title', 'Sistem Antrian Puskesmas')
@section('content')
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary via-blue-600 to-secondary">
<div class="text-center text-white animate-fade-in">
<div class="mb-8">
<div class="text-6xl md:text-8xl mb-6">🏥</div>
<h1 class="text-4xl md:text-6xl font-bold mb-4">Sistem Antrian Puskesmas</h1>
<p class="text-xl md:text-2xl text-blue-100 mb-8">Memudahkan pelayanan kesehatan masyarakat</p>
</div>
<div class="space-y-4">
<a href="{{ route('landing') }}"
class="inline-block bg-white text-primary hover:bg-gray-100 px-8 py-4 rounded-xl text-lg font-semibold transition duration-200 transform hover:scale-105 shadow-lg">
Masuk ke Beranda
</a>
</div>
<div class="mt-12 text-blue-100 text-sm">
<p>Mengalihkan ke beranda dalam <span id="countdown">3</span> detik...</p>
</div>
</div>
</div>
@push('scripts')
<script>
// Show SweetAlert2 for success messages
@if (session('success'))
Swal.fire({
icon: 'success',
title: 'Berhasil!',
text: '{{ session('success') }}',
confirmButtonText: 'OK',
confirmButtonColor: '#10B981',
timer: 3000,
timerProgressBar: true
});
@endif
// Show SweetAlert2 for error messages
@if (session('error'))
Swal.fire({
icon: 'error',
title: 'Error!',
text: '{{ session('error') }}',
confirmButtonText: 'OK',
confirmButtonColor: '#EF4444',
timer: 4000,
timerProgressBar: true
});
@endif
// Show welcome error message
@if (session('error') && str_contains(session('error'), 'welcome'))
Swal.fire({
icon: 'error',
title: 'Error Welcome!',
text: '{{ session('error') }}',
confirmButtonText: 'OK',
confirmButtonColor: '#EF4444',
timer: 4000,
timerProgressBar: true
});
@endif
// Auto redirect after 3 seconds
let countdown = 3;
const countdownElement = document.getElementById('countdown');
const timer = setInterval(() => {
countdown--;
countdownElement.textContent = countdown;
if (countdown <= 0) {
clearInterval(timer);
window.location.href = '{{ route('landing') }}';
}
}, 1000);
</script>
@endpush
@endsection

8
routes/console.php Normal file
View File

@ -0,0 +1,8 @@
<?php
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');

90
routes/web.php Normal file
View File

@ -0,0 +1,90 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\LandingController;
use App\Http\Controllers\AuthController;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\DisplayController;
use App\Http\Controllers\AdminController;
use App\Http\Controllers\TTSController;
// Landing Page
Route::get('/', [LandingController::class, 'index'])->name('landing');
// Display Page (Public)
Route::get('/display', [DisplayController::class, 'index'])->name('display');
// Authentication Routes
Route::get('/login', [AuthController::class, 'showLogin'])->name('login');
Route::post('/login', [AuthController::class, 'login']);
Route::get('/register', [AuthController::class, 'showRegister'])->name('register');
Route::post('/register', [AuthController::class, 'register']);
Route::post('/logout', [AuthController::class, 'logout'])->name('logout');
// Forgot Password Routes
Route::get('/forgot-password', [AuthController::class, 'showForgotPassword'])->name('forgot-password');
Route::post('/forgot-password', [AuthController::class, 'forgotPassword']);
Route::get('/reset-password', [AuthController::class, 'showResetPassword'])->name('reset-password');
Route::post('/reset-password', [AuthController::class, 'resetPassword']);
// Protected Routes (User)
Route::middleware('auth')->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
Route::post('/dashboard/add-queue', [DashboardController::class, 'addQueue'])->name('dashboard.add-queue');
Route::put('/dashboard/update-profile', [DashboardController::class, 'updateProfile'])->name('dashboard.update-profile');
// User Antrian Routes
Route::post('/user/antrian/batal', [DashboardController::class, 'batalAntrian'])->name('user.batal-antrian');
Route::get('/user/antrian/{antrian}/cetak', [DashboardController::class, 'cetakAntrian'])->name('user.antrian.cetak');
// Placeholder routes for future features
Route::get('/antrian/create', function () {
return redirect('/dashboard');
})->name('antrian.create');
Route::get('/antrian', function () {
return redirect('/dashboard');
})->name('antrian.index');
Route::get('/panggilan', function () {
return redirect('/dashboard');
})->name('panggilan.index');
Route::get('/laporan', function () {
return redirect('/dashboard');
})->name('laporan.index');
});
// Protected Routes (Admin)
Route::middleware('auth:admin')->group(function () {
Route::get('/admin/dashboard', [AdminController::class, 'dashboard'])->name('admin.dashboard');
// Per-poli pages
Route::get('/admin/poli/umum', [AdminController::class, 'poliUmum'])->name('admin.poli.umum');
Route::get('/admin/poli/gigi', [AdminController::class, 'poliGigi'])->name('admin.poli.gigi');
Route::get('/admin/poli/jiwa', [AdminController::class, 'poliJiwa'])->name('admin.poli.jiwa');
Route::get('/admin/poli/tradisional', [AdminController::class, 'poliTradisional'])->name('admin.poli.tradisional');
Route::post('/admin/panggil-antrian', [AdminController::class, 'panggilAntrian'])->name('admin.panggil-antrian');
Route::post('/admin/selesai-antrian', [AdminController::class, 'selesaiAntrian'])->name('admin.selesai-antrian');
Route::post('/admin/antrian/batal', [AdminController::class, 'batalAntrian'])->name('admin.batal-antrian');
Route::post('/admin/panggil-antrian/{antrian}', [AdminController::class, 'panggilAntrianById'])->name('admin.panggil-antrian-id');
// User Management Routes
Route::get('/admin/users', [AdminController::class, 'manageUsers'])->name('admin.users.index');
Route::get('/admin/users/{user}', [AdminController::class, 'showUser'])->name('admin.users.show');
Route::put('/admin/users/{user}', [AdminController::class, 'updateUser'])->name('admin.users.update');
Route::post('/admin/users/{user}/reset-password', [AdminController::class, 'resetUserPassword'])->name('admin.users.reset-password');
// Laporan Routes
Route::get('/admin/laporan', [AdminController::class, 'laporan'])->name('admin.laporan.index');
Route::get('/admin/laporan/export-pdf', [AdminController::class, 'exportPDF'])->name('admin.laporan.export-pdf');
Route::get('/admin/laporan/export-excel', [AdminController::class, 'exportExcel'])->name('admin.laporan.export-excel');
// Antrian Routes
Route::get('/admin/antrian/{antrian}/cetak', [AdminController::class, 'cetakAntrian'])->name('admin.antrian.cetak');
// TTS Routes
Route::post('/admin/tts/generate', [TTSController::class, 'generateQueueCall'])->name('admin.tts.generate');
Route::post('/admin/tts/audio-sequence', [TTSController::class, 'getAudioSequence'])->name('admin.tts.audio-sequence');
Route::post('/admin/tts/play-sequence', [TTSController::class, 'playAudioSequence'])->name('admin.tts.play-sequence');
});
// Public TTS Routes (for display)
Route::post('/tts/play-sequence', [TTSController::class, 'playAudioSequence'])->name('tts.play-sequence');

4
storage/app/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
*
!private/
!public/
!.gitignore

2
storage/app/private/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

2
storage/app/public/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

9
storage/framework/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
compiled.php
config.php
down
events.scanned.php
maintenance.php
routes.php
routes.scanned.php
schedule-*
services.json

3
storage/framework/cache/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
*
!data/
!.gitignore

View File

@ -0,0 +1,2 @@
*
!.gitignore

2
storage/framework/sessions/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

2
storage/framework/testing/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

2
storage/framework/views/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

2
storage/logs/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

Some files were not shown because too many files have changed in this diff Show More