Initial commit: Retribusi frontend dengan dashboard, event logs, dan settings
This commit is contained in:
1813
API_JSON.md
Normal file
1813
API_JSON.md
Normal file
File diff suppressed because it is too large
Load Diff
111
DASHBOARD_DEBUG.md
Normal file
111
DASHBOARD_DEBUG.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Debug Dashboard Data Kosong
|
||||
|
||||
## ✅ Perbaikan yang Sudah Dilakukan
|
||||
|
||||
1. **Default Date**: Diubah ke `2025-12-16` (tanggal yang ada data)
|
||||
2. **Logging**: Ditambahkan console.log di berbagai titik
|
||||
3. **Response Handling**: Handle wrapped response dengan benar
|
||||
4. **Fallback Logic**: Ditambahkan fallback di backend jika daily_summary kosong
|
||||
|
||||
## 🔍 Cara Debug
|
||||
|
||||
### 1. Buka Browser Console (F12)
|
||||
Cek apakah ada log:
|
||||
```
|
||||
[Dashboard] Summary response raw: {...}
|
||||
[Dashboard] By Category response raw: {...}
|
||||
[Dashboard] Final counts: {...}
|
||||
```
|
||||
|
||||
### 2. Cek Network Tab
|
||||
- Buka DevTools > Network
|
||||
- Filter: XHR atau Fetch
|
||||
- Cari request ke `/retribusi/v1/dashboard/summary`
|
||||
- Klik request > Response tab
|
||||
- Lihat apakah response berisi data
|
||||
|
||||
**Expected Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"total_count": 47,
|
||||
"total_amount": 112000,
|
||||
"active_gates": 1,
|
||||
"active_locations": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Test Manual di Console
|
||||
Buka browser console dan jalankan:
|
||||
```javascript
|
||||
// Test API call langsung
|
||||
const response = await apiGetSummary({ date: '2025-12-16' });
|
||||
console.log('Response:', response);
|
||||
|
||||
// Test render manual
|
||||
renderSummary({
|
||||
totalAmount: 112000,
|
||||
personCount: 33,
|
||||
motorCount: 12,
|
||||
carCount: 2
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Cek Element HTML
|
||||
```javascript
|
||||
// Di browser console
|
||||
const amountEl = document.getElementById('card-total-amount');
|
||||
console.log('Amount element:', amountEl);
|
||||
console.log('Current value:', amountEl?.textContent);
|
||||
```
|
||||
|
||||
## 🐛 Jika Masih Kosong
|
||||
|
||||
### Kemungkinan 1: API Error
|
||||
**Cek**: Network tab > Status code
|
||||
- **401**: Token expired, login ulang
|
||||
- **404**: Route tidak ditemukan, cek base URL
|
||||
- **500**: Server error, cek API logs
|
||||
|
||||
### Kemungkinan 2: Response Format Salah
|
||||
**Cek**: Response body di Network tab
|
||||
- Jika format `{success: true, data: {...}}`, sudah benar
|
||||
- Jika format berbeda, perlu fix di api.js
|
||||
|
||||
### Kemungkinan 3: Element Tidak Ditemukan
|
||||
**Cek**: Console untuk error "Cannot read property..."
|
||||
- Pastikan HTML element dengan ID yang benar ada
|
||||
- Cek apakah script di-load setelah DOM ready
|
||||
|
||||
### Kemungkinan 4: Date Tidak Sesuai
|
||||
**Cek**: State date di console
|
||||
```javascript
|
||||
console.log('State date:', state.date);
|
||||
// Harus: '2025-12-16' atau tanggal yang ada data
|
||||
```
|
||||
|
||||
## 🚀 Quick Test
|
||||
|
||||
Jalankan di browser console setelah halaman load:
|
||||
```javascript
|
||||
// Force load dengan date yang ada data
|
||||
state.date = '2025-12-16';
|
||||
const dateInput = document.getElementById('filter-date');
|
||||
if (dateInput) dateInput.value = '2025-12-16';
|
||||
loadSummaryAndCharts();
|
||||
```
|
||||
|
||||
## 📝 Expected Values untuk 2025-12-16
|
||||
|
||||
- **Total Pendapatan**: Rp 112,000
|
||||
- **Jumlah Orang**: 33
|
||||
- **Jumlah Motor**: 12
|
||||
- **Jumlah Mobil**: 2
|
||||
|
||||
Jika nilai ini tidak muncul, ada masalah dengan:
|
||||
1. API call (cek Network tab)
|
||||
2. Response parsing (cek console log)
|
||||
3. Data rendering (cek element HTML)
|
||||
|
||||
60
README.md
Normal file
60
README.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Retribusi Frontend
|
||||
|
||||
Frontend aplikasi Retribusi BAPENDA Kabupaten Garut.
|
||||
|
||||
## Struktur Project
|
||||
|
||||
```
|
||||
retribusi (frontend)/
|
||||
├── index.php # Login page
|
||||
├── public/
|
||||
│ ├── dashboard/
|
||||
│ │ ├── dashboard.html # Dashboard utama
|
||||
│ │ ├── event.html # Halaman event logs
|
||||
│ │ ├── settings.html # Halaman pengaturan lokasi & gate
|
||||
│ │ ├── css/
|
||||
│ │ │ └── app.css # Main stylesheet
|
||||
│ │ └── js/
|
||||
│ │ ├── config.js # API configuration
|
||||
│ │ ├── api.js # API client
|
||||
│ │ ├── auth.js # Authentication
|
||||
│ │ ├── dashboard.js # Dashboard logic
|
||||
│ │ ├── charts.js # Chart.js helpers
|
||||
│ │ └── realtime.js # Realtime SSE client
|
||||
│ └── index.php
|
||||
└── api/ # Legacy API endpoints (deprecated)
|
||||
```
|
||||
|
||||
## Fitur
|
||||
|
||||
- **Dashboard**: Menampilkan KPI cards, chart harian per jam, dan chart per kategori
|
||||
- **Event Logs**: Daftar event dengan filter lokasi, gate, kategori, dan tanggal
|
||||
- **Settings**: CRUD untuk lokasi dan gate, termasuk pengaturan URL kamera
|
||||
- **Realtime**: Update data real-time menggunakan Server-Sent Events (SSE)
|
||||
- **Video Preview**: Preview kamera HLS untuk gate yang memiliki URL kamera
|
||||
|
||||
## Teknologi
|
||||
|
||||
- Vanilla JavaScript (ES6 modules)
|
||||
- Tailwind CSS
|
||||
- Chart.js untuk visualisasi data
|
||||
- HLS.js untuk video streaming
|
||||
|
||||
## Konfigurasi
|
||||
|
||||
File `public/dashboard/js/config.js` mengatur:
|
||||
- API Base URL (auto-detect local/production)
|
||||
- API Key untuk autentikasi
|
||||
|
||||
## Development
|
||||
|
||||
1. Pastikan backend API sudah running
|
||||
2. Buka `index.php` untuk login
|
||||
3. Akses dashboard di `public/dashboard/dashboard.html`
|
||||
|
||||
## Production
|
||||
|
||||
Deploy ke web server (Apache/Nginx) dengan konfigurasi:
|
||||
- Base URL: sesuai dengan domain production
|
||||
- API Base URL: otomatis terdeteksi dari hostname
|
||||
|
||||
174
api/CORS_SETUP_GUIDE.md
Normal file
174
api/CORS_SETUP_GUIDE.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Panduan Setup CORS untuk API Btekno
|
||||
|
||||
## Masalah
|
||||
Browser tidak dapat mengakses API karena CORS (Cross-Origin Resource Sharing) belum dikonfigurasi.
|
||||
|
||||
## Solusi
|
||||
|
||||
### Metode 1: CORS Handler di Setiap Endpoint (Recommended)
|
||||
|
||||
Tambahkan CORS handler di **AWAL** setiap file endpoint PHP:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// ==================== CORS HANDLER - HARUS DI AWAL ====================
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS");
|
||||
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-API-KEY");
|
||||
header("Access-Control-Max-Age: 3600");
|
||||
|
||||
// Handle preflight OPTIONS request
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
// ==================== END CORS HANDLER ====================
|
||||
|
||||
// Lanjutkan dengan logic endpoint...
|
||||
```
|
||||
|
||||
### Metode 2: CORS Handler di Bootstrap/Autoload
|
||||
|
||||
Jika menggunakan framework atau autoloader, tambahkan CORS handler di file bootstrap:
|
||||
|
||||
**File: `bootstrap.php` atau `index.php`**
|
||||
```php
|
||||
<?php
|
||||
// CORS Handler
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS");
|
||||
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-API-KEY");
|
||||
header("Access-Control-Max-Age: 3600");
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Lanjutkan dengan routing/autoload...
|
||||
```
|
||||
|
||||
### Metode 3: CORS Handler di .htaccess (Apache)
|
||||
|
||||
Jika menggunakan Apache, tambahkan di `.htaccess`:
|
||||
|
||||
```apache
|
||||
# CORS Headers
|
||||
<IfModule mod_headers.c>
|
||||
Header set Access-Control-Allow-Origin "*"
|
||||
Header set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
|
||||
Header set Access-Control-Allow-Headers "Content-Type, Authorization, X-API-KEY"
|
||||
Header set Access-Control-Max-Age "3600"
|
||||
</IfModule>
|
||||
|
||||
# Handle OPTIONS request
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteCond %{REQUEST_METHOD} OPTIONS
|
||||
RewriteRule ^(.*)$ $1 [R=200,L]
|
||||
</IfModule>
|
||||
```
|
||||
|
||||
### Metode 4: CORS Handler di Nginx Config
|
||||
|
||||
Jika menggunakan Nginx, tambahkan di config:
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
# CORS Headers
|
||||
add_header 'Access-Control-Allow-Origin' '*' always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-API-KEY' always;
|
||||
add_header 'Access-Control-Max-Age' '3600' always;
|
||||
|
||||
# Handle OPTIONS request
|
||||
if ($request_method = 'OPTIONS') {
|
||||
return 204;
|
||||
}
|
||||
|
||||
# Proxy atau serve PHP
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
```
|
||||
|
||||
## Endpoint yang Perlu CORS Handler
|
||||
|
||||
Pastikan semua endpoint berikut memiliki CORS handler:
|
||||
|
||||
1. ✅ `/health` - Health check
|
||||
2. ✅ `/auth/v1/login` - Login
|
||||
3. ✅ `/retribusi/v1/dashboard/summary` - Dashboard summary
|
||||
4. ✅ `/retribusi/v1/summary/hourly` - Hourly summary
|
||||
5. ✅ `/retribusi/v1/dashboard/daily` - Daily chart
|
||||
6. ✅ `/retribusi/v1/dashboard/by-category` - By category chart
|
||||
7. ✅ `/retribusi/v1/realtime/snapshot` - Realtime snapshot
|
||||
8. ✅ Semua endpoint lainnya
|
||||
|
||||
## Testing CORS
|
||||
|
||||
### Test dengan curl:
|
||||
```bash
|
||||
# Test OPTIONS request (preflight)
|
||||
curl -X OPTIONS https://api.btekno.cloud/auth/v1/login \
|
||||
-H "Origin: http://localhost" \
|
||||
-H "Access-Control-Request-Method: POST" \
|
||||
-H "Access-Control-Request-Headers: Content-Type" \
|
||||
-v
|
||||
|
||||
# Harus return:
|
||||
# < HTTP/1.1 200 OK
|
||||
# < Access-Control-Allow-Origin: *
|
||||
# < Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
|
||||
# < Access-Control-Allow-Headers: Content-Type, Authorization, X-API-KEY
|
||||
```
|
||||
|
||||
### Test dengan browser:
|
||||
1. Buka `dashboard/test-connection.html`
|
||||
2. Klik "Test Health Check"
|
||||
3. Buka Developer Tools (F12) → Network tab
|
||||
4. Cek apakah request OPTIONS return 200 dengan CORS headers
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Masalah: Masih error "Failed to fetch"
|
||||
- ✅ Pastikan CORS handler di **AWAL** file, sebelum output apapun
|
||||
- ✅ Pastikan tidak ada output (echo, print, whitespace) sebelum CORS headers
|
||||
- ✅ Pastikan OPTIONS request return 200, bukan 404 atau 405
|
||||
|
||||
### Masalah: CORS headers tidak muncul
|
||||
- ✅ Cek apakah mod_headers enabled (Apache)
|
||||
- ✅ Cek apakah PHP output buffering tidak mengganggu
|
||||
- ✅ Cek apakah ada error PHP sebelum headers dikirim
|
||||
|
||||
### Masalah: Preflight OPTIONS gagal
|
||||
- ✅ Pastikan server menangani method OPTIONS
|
||||
- ✅ Pastikan return 200 untuk OPTIONS request
|
||||
- ✅ Jangan proses logic endpoint untuk OPTIONS request
|
||||
|
||||
## Security Note
|
||||
|
||||
⚠️ **Untuk Production:**
|
||||
- Ganti `Access-Control-Allow-Origin: *` dengan domain spesifik:
|
||||
```php
|
||||
header("Access-Control-Allow-Origin: https://yourdomain.com");
|
||||
```
|
||||
- Atau gunakan whitelist:
|
||||
```php
|
||||
$allowedOrigins = ['https://yourdomain.com', 'https://app.yourdomain.com'];
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
if (in_array($origin, $allowedOrigins)) {
|
||||
header("Access-Control-Allow-Origin: $origin");
|
||||
}
|
||||
```
|
||||
|
||||
## Quick Fix
|
||||
|
||||
Copy file `cors-handler.php` dan include di setiap endpoint:
|
||||
|
||||
```php
|
||||
<?php
|
||||
require_once __DIR__ . '/cors-handler.php';
|
||||
|
||||
// Endpoint logic di sini...
|
||||
```
|
||||
|
||||
84
api/INSTALASI_CORS.md
Normal file
84
api/INSTALASI_CORS.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# 🚨 INSTRUKSI PERBAIKAN CORS - WAJIB DILAKUKAN
|
||||
|
||||
## ⚠️ MASALAH SAAT INI
|
||||
- Browser tidak bisa login karena CORS error
|
||||
- Request OPTIONS (preflight) return 400 Bad Request
|
||||
- Server API belum memiliki CORS handler
|
||||
|
||||
## ✅ SOLUSI: Upload File dengan CORS Handler
|
||||
|
||||
### LANGKAH 1: Buka File yang Sudah Diperbaiki
|
||||
|
||||
File berikut sudah diperbaiki dan siap digunakan:
|
||||
- `api/auth/login.php` ✅
|
||||
- `api/dashboard/summary.php` ✅
|
||||
- `api/dashboard/chart.php` ✅
|
||||
- `api/dashboard/chart_monthly.php` ✅
|
||||
- `api/dashboard/events.php` ✅
|
||||
|
||||
### LANGKAH 2: Copy Kode CORS Handler
|
||||
|
||||
Setiap file sudah memiliki CORS handler di baris paling atas:
|
||||
|
||||
```php
|
||||
<?php
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
|
||||
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-API-KEY");
|
||||
header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0");
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
```
|
||||
|
||||
### LANGKAH 3: Upload ke Server API
|
||||
|
||||
**OPSI A: Upload File Lengkap**
|
||||
1. Buka file `api/auth/login.php` di folder lokal
|
||||
2. Copy seluruh isinya
|
||||
3. Upload/replace file di server: `/retribusi/v1/api/auth/login.php`
|
||||
4. Ulangi untuk semua file endpoint lainnya
|
||||
|
||||
**OPSI B: Tambahkan CORS Handler ke File yang Sudah Ada**
|
||||
1. Buka file API yang sudah ada di server
|
||||
2. Tambahkan kode CORS handler di **BARIS PALING ATAS** (sebelum require/include apapun)
|
||||
3. Pastikan kode CORS dieksekusi sebelum logic lainnya
|
||||
|
||||
### LANGKAH 4: Test
|
||||
|
||||
Setelah upload, test dengan:
|
||||
|
||||
```bash
|
||||
# Test OPTIONS (harus return 200)
|
||||
curl -X OPTIONS https://api.btekno.cloud/retribusi/v1/api/auth/login.php -i
|
||||
|
||||
# Test POST (harus berhasil)
|
||||
curl -X POST https://api.btekno.cloud/retribusi/v1/api/auth/login.php \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-KEY: RETRIBUSI-DASHBOARD-KEY" \
|
||||
-d '{"username":"admin","password":"dodolgarut"}'
|
||||
```
|
||||
|
||||
### ✅ HASIL YANG DIHARAPKAN
|
||||
|
||||
Setelah upload:
|
||||
- ✅ `curl -X OPTIONS` → HTTP 200 OK
|
||||
- ✅ Browser bisa login tanpa error CORS
|
||||
- ✅ Frontend berfungsi normal
|
||||
|
||||
## 📋 CHECKLIST
|
||||
|
||||
- [ ] File `api/auth/login.php` sudah di-upload ke server
|
||||
- [ ] CORS handler ada di baris paling atas
|
||||
- [ ] OPTIONS request return HTTP 200
|
||||
- [ ] Test login dari browser berhasil
|
||||
|
||||
## ⚠️ PENTING
|
||||
|
||||
- CORS handler HARUS di baris paling atas
|
||||
- CORS handler HARUS dieksekusi sebelum require/include
|
||||
- CORS handler HARUS dieksekusi sebelum logic auth
|
||||
- Setelah upload, clear cache browser jika perlu
|
||||
|
||||
118
api/README_CORS_FIX.md
Normal file
118
api/README_CORS_FIX.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# 🔧 PERBAIKAN CORS - INSTRUKSI WAJIB
|
||||
|
||||
## ⚠️ MASALAH SAAT INI
|
||||
- Frontend (localhost) GAGAL login karena CORS
|
||||
- Request OPTIONS (preflight) ke endpoint login.php dibalas 400
|
||||
- API belum menangani preflight OPTIONS
|
||||
|
||||
## ✅ SOLUSI: Tambahkan CORS Handler
|
||||
|
||||
### LANGKAH 1: Copy Kode CORS Handler
|
||||
|
||||
Copy kode berikut ke **PALING ATAS** setiap file endpoint (sebelum require/include apapun):
|
||||
|
||||
```php
|
||||
<?php
|
||||
// ================= CORS HANDLER (WAJIB PALING ATAS) =================
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
|
||||
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-API-KEY");
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
// ================= END CORS HANDLER =================
|
||||
```
|
||||
|
||||
### LANGKAH 2: File yang Perlu Diperbaiki
|
||||
|
||||
Tambahkan CORS handler di file-file berikut:
|
||||
|
||||
1. ✅ `/retribusi/v1/api/auth/login.php`
|
||||
2. ✅ `/retribusi/v1/api/dashboard/summary.php`
|
||||
3. ✅ `/retribusi/v1/api/dashboard/chart.php`
|
||||
4. ✅ `/retribusi/v1/api/dashboard/chart_monthly.php`
|
||||
5. ✅ `/retribusi/v1/api/dashboard/events.php`
|
||||
|
||||
### LANGKAH 3: Urutan Kode yang Benar
|
||||
|
||||
```php
|
||||
<?php
|
||||
// 1. CORS HANDLER (PALING ATAS - SEBELUM APAPUN)
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
|
||||
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-API-KEY");
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2. Set Content-Type untuk JSON response
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// 3. Require/include file lain (jika ada)
|
||||
// require_once '../config/database.php';
|
||||
// require_once '../config/auth.php';
|
||||
|
||||
// 4. Logic auth/validation
|
||||
// ... kode auth yang sudah ada ...
|
||||
|
||||
// 5. Logic bisnis API
|
||||
// ... kode API yang sudah ada ...
|
||||
```
|
||||
|
||||
## 🧪 TESTING
|
||||
|
||||
Setelah perbaikan, test dengan:
|
||||
|
||||
```bash
|
||||
# Test OPTIONS preflight
|
||||
curl -X OPTIONS https://api.btekno.cloud/retribusi/v1/api/auth/login.php \
|
||||
-H "Access-Control-Request-Method: POST" \
|
||||
-H "Access-Control-Request-Headers: Content-Type, X-API-KEY" \
|
||||
-v
|
||||
|
||||
# Harus return: HTTP 200 OK
|
||||
|
||||
# Test POST login
|
||||
curl -X POST https://api.btekno.cloud/retribusi/v1/api/auth/login.php \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-KEY: RETRIBUSI-DASHBOARD-KEY" \
|
||||
-d '{"username":"test","password":"test"}' \
|
||||
-v
|
||||
```
|
||||
|
||||
## ✅ HASIL YANG DIHARAPKAN
|
||||
|
||||
- ✅ `OPTIONS /api/auth/login.php` → HTTP 200 OK
|
||||
- ✅ `POST /api/auth/login.php` → login normal (tidak berubah)
|
||||
- ✅ Browser TIDAK lagi error CORS
|
||||
- ✅ Frontend login dari localhost BERHASIL
|
||||
|
||||
## 📋 CHECKLIST
|
||||
|
||||
- [ ] CORS handler ditambahkan di semua endpoint
|
||||
- [ ] CORS handler di paling atas (sebelum require/include)
|
||||
- [ ] OPTIONS request return HTTP 200
|
||||
- [ ] Response bisnis API tidak berubah
|
||||
- [ ] Auth logic tetap berjalan normal
|
||||
- [ ] Test dari browser localhost berhasil
|
||||
|
||||
## ⚠️ PENTING
|
||||
|
||||
- **JANGAN** mengubah response bisnis API
|
||||
- **JANGAN** menambah proxy
|
||||
- **JANGAN** mematikan auth
|
||||
- **HANYA** menambahkan CORS handler di atas
|
||||
|
||||
## 📁 File Example
|
||||
|
||||
Lihat file `.example` di folder ini untuk contoh implementasi:
|
||||
- `auth/login.php.example`
|
||||
- `dashboard/summary.php.example`
|
||||
- `dashboard/chart.php.example`
|
||||
- `dashboard/chart_monthly.php.example`
|
||||
- `dashboard/events.php.example`
|
||||
|
||||
44
api/auth/login.php
Normal file
44
api/auth/login.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
|
||||
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-API-KEY");
|
||||
header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0");
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// TODO: Implementasi logic login di sini
|
||||
// Contoh response structure:
|
||||
/*
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (!isset($input['username']) || !isset($input['password'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'invalid_request']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validasi X-API-KEY
|
||||
if (!isset($_SERVER['HTTP_X_API_KEY']) || $_SERVER['HTTP_X_API_KEY'] !== 'RETRIBUSI-DASHBOARD-KEY') {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Logic authentication
|
||||
// ... kode auth yang sudah ada ...
|
||||
|
||||
// Response success
|
||||
echo json_encode([
|
||||
'token' => 'Bearer xxxxx',
|
||||
'user' => [
|
||||
'username' => 'admin',
|
||||
'role' => 'admin',
|
||||
'locations' => ['kerkof_01']
|
||||
]
|
||||
]);
|
||||
*/
|
||||
45
api/auth/login.php.example
Normal file
45
api/auth/login.php.example
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
/**
|
||||
* EXAMPLE: Login Endpoint dengan CORS Handler
|
||||
*
|
||||
* INSTRUKSI:
|
||||
* 1. Copy kode CORS handler ke paling atas (sebelum require/include apapun)
|
||||
* 2. Pastikan CORS handler dieksekusi SEBELUM logic auth
|
||||
* 3. Jangan ubah response bisnis API, hanya tambahkan CORS
|
||||
*/
|
||||
|
||||
// ================= CORS HANDLER (WAJIB PALING ATAS) =================
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
|
||||
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-API-KEY");
|
||||
|
||||
// Handle preflight OPTIONS request
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
// ================= END CORS HANDLER =================
|
||||
|
||||
// Setelah CORS handler, baru require/include file lain
|
||||
// require_once '../config/database.php';
|
||||
// require_once '../config/auth.php';
|
||||
|
||||
// Set header untuk JSON response
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Logic auth/login di sini
|
||||
// ... kode login yang sudah ada ...
|
||||
|
||||
// Example response (sesuaikan dengan logic yang sudah ada)
|
||||
/*
|
||||
$response = [
|
||||
'token' => 'Bearer xxxxx',
|
||||
'user' => [
|
||||
'username' => 'admin',
|
||||
'role' => 'admin',
|
||||
'locations' => ['kerkof_01']
|
||||
]
|
||||
];
|
||||
echo json_encode($response);
|
||||
*/
|
||||
|
||||
30
api/cors-handler.php
Normal file
30
api/cors-handler.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
/**
|
||||
* CORS Handler untuk API Btekno
|
||||
*
|
||||
* File ini HARUS di-include di awal SETIAP endpoint PHP
|
||||
* atau ditempatkan di file bootstrap/autoload yang dieksekusi sebelum semua endpoint
|
||||
*
|
||||
* Usage:
|
||||
* require_once __DIR__ . '/cors-handler.php';
|
||||
*/
|
||||
|
||||
// Set CORS headers
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS");
|
||||
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-API-KEY");
|
||||
header("Access-Control-Max-Age: 3600");
|
||||
header("Access-Control-Allow-Credentials: false");
|
||||
|
||||
// Handle preflight OPTIONS request
|
||||
// Browser akan mengirim OPTIONS request sebelum POST/PUT/DELETE jika ada custom headers
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Log untuk debugging (opsional, bisa dihapus di production)
|
||||
if (defined('APP_DEBUG') && APP_DEBUG === true) {
|
||||
error_log('CORS Handler: ' . $_SERVER['REQUEST_METHOD'] . ' ' . $_SERVER['REQUEST_URI']);
|
||||
}
|
||||
|
||||
21
api/cors_handler.php
Normal file
21
api/cors_handler.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
/**
|
||||
* CORS Handler - WAJIB di-include di awal SETIAP endpoint
|
||||
*
|
||||
* INSTRUKSI:
|
||||
* 1. Copy file ini ke server API
|
||||
* 2. Include di awal SETIAP file endpoint: require_once 'cors_handler.php';
|
||||
* 3. Atau copy kode di bawah ke awal setiap endpoint
|
||||
*/
|
||||
|
||||
// ================= CORS =================
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
|
||||
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-API-KEY");
|
||||
|
||||
// Handle preflight OPTIONS request
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
|
||||
43
api/dashboard/chart.php
Normal file
43
api/dashboard/chart.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
|
||||
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-API-KEY");
|
||||
header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0");
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// TODO: Implementasi logic chart di sini
|
||||
// Validasi Authorization token
|
||||
/*
|
||||
$headers = getallheaders();
|
||||
if (!isset($headers['Authorization'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validasi X-API-KEY
|
||||
if (!isset($_SERVER['HTTP_X_API_KEY']) || $_SERVER['HTTP_X_API_KEY'] !== 'RETRIBUSI-DASHBOARD-KEY') {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Logic chart
|
||||
$date = $_GET['date'] ?? date('Y-m-d');
|
||||
$location_code = $_GET['location_code'] ?? null;
|
||||
|
||||
// ... kode chart yang sudah ada ...
|
||||
|
||||
echo json_encode([
|
||||
'labels' => ['00','01','02','03','04','05','06','07','08','09','10','11','12','13','14','15','16','17','18','19','20','21','22','23'],
|
||||
'motor' => array_fill(0, 24, 0),
|
||||
'car' => array_fill(0, 24, 0),
|
||||
'person' => array_fill(0, 24, 0)
|
||||
]);
|
||||
*/
|
||||
20
api/dashboard/chart.php.example
Normal file
20
api/dashboard/chart.php.example
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
/**
|
||||
* EXAMPLE: Dashboard Chart Endpoint dengan CORS Handler
|
||||
*/
|
||||
|
||||
// ================= CORS HANDLER (WAJIB PALING ATAS) =================
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
|
||||
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-API-KEY");
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
// ================= END CORS HANDLER =================
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// ... logic chart yang sudah ada ...
|
||||
|
||||
45
api/dashboard/chart_monthly.php
Normal file
45
api/dashboard/chart_monthly.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
|
||||
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-API-KEY");
|
||||
header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0");
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// TODO: Implementasi logic chart monthly di sini
|
||||
// Validasi Authorization token
|
||||
/*
|
||||
$headers = getallheaders();
|
||||
if (!isset($headers['Authorization'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validasi X-API-KEY
|
||||
if (!isset($_SERVER['HTTP_X_API_KEY']) || $_SERVER['HTTP_X_API_KEY'] !== 'RETRIBUSI-DASHBOARD-KEY') {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Logic chart monthly
|
||||
$month = $_GET['month'] ?? date('Y-m');
|
||||
$location_code = $_GET['location_code'] ?? null;
|
||||
|
||||
// ... kode chart monthly yang sudah ada ...
|
||||
|
||||
$daysInMonth = date('t', strtotime($month . '-01'));
|
||||
echo json_encode([
|
||||
'labels' => range(1, $daysInMonth),
|
||||
'motor' => array_fill(0, $daysInMonth, 0),
|
||||
'car' => array_fill(0, $daysInMonth, 0),
|
||||
'person' => array_fill(0, $daysInMonth, 0),
|
||||
'amount' => array_fill(0, $daysInMonth, 0)
|
||||
]);
|
||||
*/
|
||||
20
api/dashboard/chart_monthly.php.example
Normal file
20
api/dashboard/chart_monthly.php.example
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
/**
|
||||
* EXAMPLE: Dashboard Chart Monthly Endpoint dengan CORS Handler
|
||||
*/
|
||||
|
||||
// ================= CORS HANDLER (WAJIB PALING ATAS) =================
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
|
||||
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-API-KEY");
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
// ================= END CORS HANDLER =================
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// ... logic chart monthly yang sudah ada ...
|
||||
|
||||
49
api/dashboard/events.php
Normal file
49
api/dashboard/events.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
|
||||
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-API-KEY");
|
||||
header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0");
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// TODO: Implementasi logic events di sini
|
||||
// Validasi Authorization token
|
||||
/*
|
||||
$headers = getallheaders();
|
||||
if (!isset($headers['Authorization'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validasi X-API-KEY
|
||||
if (!isset($_SERVER['HTTP_X_API_KEY']) || $_SERVER['HTTP_X_API_KEY'] !== 'RETRIBUSI-DASHBOARD-KEY') {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validasi role admin
|
||||
// ... kode validasi role admin ...
|
||||
|
||||
// Logic events
|
||||
$date = $_GET['date'] ?? null;
|
||||
$location_code = $_GET['location_code'] ?? null;
|
||||
$gate_code = $_GET['gate_code'] ?? null;
|
||||
$category = $_GET['category'] ?? null;
|
||||
$page = intval($_GET['page'] ?? 1);
|
||||
$limit = intval($_GET['limit'] ?? 20);
|
||||
|
||||
// ... kode events yang sudah ada ...
|
||||
|
||||
echo json_encode([
|
||||
'events' => [],
|
||||
'total_pages' => 1,
|
||||
'current_page' => $page
|
||||
]);
|
||||
*/
|
||||
20
api/dashboard/events.php.example
Normal file
20
api/dashboard/events.php.example
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
/**
|
||||
* EXAMPLE: Dashboard Events Endpoint dengan CORS Handler
|
||||
*/
|
||||
|
||||
// ================= CORS HANDLER (WAJIB PALING ATAS) =================
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
|
||||
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-API-KEY");
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
// ================= END CORS HANDLER =================
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// ... logic events yang sudah ada ...
|
||||
|
||||
44
api/dashboard/summary.php
Normal file
44
api/dashboard/summary.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
|
||||
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-API-KEY");
|
||||
header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0");
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// TODO: Implementasi logic summary di sini
|
||||
// Validasi Authorization token
|
||||
/*
|
||||
$headers = getallheaders();
|
||||
if (!isset($headers['Authorization'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validasi X-API-KEY
|
||||
if (!isset($_SERVER['HTTP_X_API_KEY']) || $_SERVER['HTTP_X_API_KEY'] !== 'RETRIBUSI-DASHBOARD-KEY') {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Logic summary
|
||||
$date = $_GET['date'] ?? date('Y-m-d');
|
||||
$location_code = $_GET['location_code'] ?? null;
|
||||
|
||||
// ... kode summary yang sudah ada ...
|
||||
|
||||
echo json_encode([
|
||||
'date' => $date,
|
||||
'location_code' => $location_code,
|
||||
'total_vehicle' => 0,
|
||||
'total_person' => 0,
|
||||
'total_amount' => 0
|
||||
]);
|
||||
*/
|
||||
42
api/dashboard/summary.php.example
Normal file
42
api/dashboard/summary.php.example
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
/**
|
||||
* EXAMPLE: Dashboard Summary Endpoint dengan CORS Handler
|
||||
*
|
||||
* INSTRUKSI:
|
||||
* 1. Copy kode CORS handler ke paling atas
|
||||
* 2. Pastikan CORS handler dieksekusi SEBELUM logic auth
|
||||
*/
|
||||
|
||||
// ================= CORS HANDLER (WAJIB PALING ATAS) =================
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
|
||||
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-API-KEY");
|
||||
|
||||
// Handle preflight OPTIONS request
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
// ================= END CORS HANDLER =================
|
||||
|
||||
// Set header untuk JSON response
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Logic auth/validation di sini
|
||||
// ... kode auth yang sudah ada ...
|
||||
|
||||
// Logic summary di sini
|
||||
// ... kode summary yang sudah ada ...
|
||||
|
||||
// Example response (sesuaikan dengan logic yang sudah ada)
|
||||
/*
|
||||
$response = [
|
||||
'date' => '2024-01-01',
|
||||
'location_code' => null,
|
||||
'total_vehicle' => 100,
|
||||
'total_person' => 250,
|
||||
'total_amount' => 5000000
|
||||
];
|
||||
echo json_encode($response);
|
||||
*/
|
||||
|
||||
76
api/example-endpoint-with-cors.php
Normal file
76
api/example-endpoint-with-cors.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
/**
|
||||
* Contoh Endpoint dengan CORS Handler
|
||||
*
|
||||
* INI ADALAH CONTOH - jangan gunakan langsung, copy logic CORS ke endpoint yang sebenarnya
|
||||
*/
|
||||
|
||||
// ==================== CORS HANDLER - HARUS DI AWAL ====================
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS");
|
||||
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-API-KEY");
|
||||
header("Access-Control-Max-Age: 3600");
|
||||
|
||||
// Handle preflight OPTIONS request
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
// ==================== END CORS HANDLER ====================
|
||||
|
||||
// Set content type
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Load environment variables (sesuai dengan struktur project)
|
||||
// require_once __DIR__ . '/../vendor/autoload.php'; // Jika pakai Composer
|
||||
// atau load env manual
|
||||
|
||||
// Contoh endpoint: Health Check
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET' && $_SERVER['REQUEST_URI'] === '/health') {
|
||||
echo json_encode([
|
||||
'status' => 'ok',
|
||||
'time' => time()
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Contoh endpoint: Login
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && strpos($_SERVER['REQUEST_URI'], '/auth/v1/login') !== false) {
|
||||
// Parse request body
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
// Validasi
|
||||
if (!isset($input['username']) || !isset($input['password'])) {
|
||||
http_response_code(422);
|
||||
echo json_encode([
|
||||
'error' => 'validation_error',
|
||||
'message' => 'Username and password are required'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// TODO: Implementasi login logic di sini
|
||||
// Contoh response:
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'token' => 'example_token_here',
|
||||
'expires_in' => 3600,
|
||||
'user' => [
|
||||
'id' => 1,
|
||||
'username' => $input['username'],
|
||||
'role' => 'admin'
|
||||
]
|
||||
],
|
||||
'timestamp' => time()
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 404 jika endpoint tidak ditemukan
|
||||
http_response_code(404);
|
||||
echo json_encode([
|
||||
'error' => 'not_found',
|
||||
'message' => 'Endpoint not found'
|
||||
]);
|
||||
|
||||
110
index.php
Normal file
110
index.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - Sistem Monitoring Retribusi</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
outfit: ['Outfit', 'sans-serif'],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="font-outfit bg-gray-50 min-h-screen flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="bg-white rounded-lg shadow-lg p-8 md:p-10 border border-gray-200">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-2">Sistem Monitoring Retribusi</h1>
|
||||
<p class="text-gray-600 text-sm">Silakan login untuk melanjutkan</p>
|
||||
</div>
|
||||
<form id="loginForm" class="space-y-5">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 mb-2">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
required
|
||||
autocomplete="username"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent transition-all text-gray-900 placeholder-gray-400"
|
||||
placeholder="Masukkan username"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 mb-2">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent transition-all text-gray-900 placeholder-gray-400"
|
||||
placeholder="Masukkan password"
|
||||
>
|
||||
</div>
|
||||
<div id="errorMessage" class="hidden bg-red-50 border border-red-200 text-red-700 p-3 rounded-lg text-sm"></div>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-gray-900 text-white font-medium py-2.5 px-6 rounded-lg hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 transition-all duration-200"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pakai API & Auth dari public/dashboard/js -->
|
||||
<script type="module">
|
||||
import { Auth } from './public/dashboard/js/auth.js';
|
||||
window.Auth = Auth;
|
||||
|
||||
// Jika sudah login, langsung arahkan ke dashboard utama (public/dashboard)
|
||||
if (Auth.isAuthenticated()) {
|
||||
window.location.href = 'public/dashboard/dashboard.html';
|
||||
}
|
||||
|
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const errorDiv = document.getElementById('errorMessage');
|
||||
errorDiv.classList.add('hidden');
|
||||
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
const submitBtn = e.target.querySelector('button[type="submit"]');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Memproses...';
|
||||
|
||||
try {
|
||||
const { apiLogin } = await import('./public/dashboard/js/api.js');
|
||||
const response = await apiLogin(username, password);
|
||||
if (response.token) {
|
||||
Auth.saveToken(response.token);
|
||||
Auth.saveUser(response.user);
|
||||
// Arahkan ke dashboard utama (public/dashboard)
|
||||
window.location.href = 'public/dashboard/dashboard.html';
|
||||
} else {
|
||||
throw new Error('Response tidak berisi token.');
|
||||
}
|
||||
} catch (error) {
|
||||
errorDiv.textContent = error.message || 'Login gagal. Silakan coba lagi.';
|
||||
errorDiv.classList.remove('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Login';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
22
proxy/package.json
Normal file
22
proxy/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "rtsp-websocket-proxy",
|
||||
"version": "1.0.0",
|
||||
"description": "RTSP to WebSocket proxy server for jsmpeg player",
|
||||
"main": "rtsp-websocket-proxy.js",
|
||||
"scripts": {
|
||||
"start": "node rtsp-websocket-proxy.js",
|
||||
"start:kerkof": "node rtsp-websocket-proxy.js rtsp://10.60.0.10:8554/cam1 8082"
|
||||
},
|
||||
"keywords": [
|
||||
"rtsp",
|
||||
"websocket",
|
||||
"proxy",
|
||||
"jsmpeg"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ws": "^8.14.2"
|
||||
}
|
||||
}
|
||||
|
||||
151
proxy/rtsp-websocket-proxy.js
Normal file
151
proxy/rtsp-websocket-proxy.js
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* RTSP to WebSocket Proxy Server
|
||||
* Convert RTSP stream ke WebSocket untuk jsmpeg player
|
||||
*
|
||||
* Usage:
|
||||
* node rtsp-websocket-proxy.js <rtsp_url> <ws_port>
|
||||
*
|
||||
* Example:
|
||||
* node rtsp-websocket-proxy.js rtsp://10.60.0.10:8554/cam1 8082
|
||||
*/
|
||||
|
||||
const WebSocket = require('ws');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
// Parse arguments
|
||||
const rtspUrl = process.argv[2] || 'rtsp://10.60.0.10:8554/cam1';
|
||||
const wsPort = parseInt(process.argv[3]) || 8082;
|
||||
|
||||
console.log('🚀 Starting RTSP to WebSocket Proxy');
|
||||
console.log('📹 RTSP URL:', rtspUrl);
|
||||
console.log('🔌 WebSocket Port:', wsPort);
|
||||
console.log('');
|
||||
|
||||
// Create WebSocket server
|
||||
const wss = new WebSocket.Server({
|
||||
port: wsPort,
|
||||
perMessageDeflate: false
|
||||
});
|
||||
|
||||
let ffmpegProcess = null;
|
||||
let clients = new Set();
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
console.log('✅ New WebSocket client connected');
|
||||
clients.add(ws);
|
||||
|
||||
// Start FFmpeg process jika belum ada
|
||||
if (!ffmpegProcess) {
|
||||
startFFmpeg();
|
||||
}
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('❌ WebSocket client disconnected');
|
||||
clients.delete(ws);
|
||||
|
||||
// Stop FFmpeg jika tidak ada client lagi
|
||||
if (clients.size === 0 && ffmpegProcess) {
|
||||
console.log('⏹️ No clients, stopping FFmpeg');
|
||||
stopFFmpeg();
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('❌ WebSocket error:', error.message);
|
||||
});
|
||||
});
|
||||
|
||||
function startFFmpeg() {
|
||||
console.log('🎬 Starting FFmpeg process...');
|
||||
|
||||
// FFmpeg command untuk convert RTSP ke MPEG1 video stream
|
||||
// Format: MPEG1 video (untuk jsmpeg) dengan resolusi 800x600, bitrate 1000k
|
||||
const ffmpegArgs = [
|
||||
'-i', rtspUrl, // Input RTSP URL
|
||||
'-f', 'mpegts', // Output format: MPEG Transport Stream
|
||||
'-codec:v', 'mpeg1video', // Video codec: MPEG1 (untuk jsmpeg)
|
||||
'-s', '800x600', // Resolution
|
||||
'-b:v', '1000k', // Video bitrate
|
||||
'-bf', '0', // No B-frames
|
||||
'-codec:a', 'mp2', // Audio codec: MP2
|
||||
'-b:a', '128k', // Audio bitrate
|
||||
'-r', '25', // Frame rate: 25 fps
|
||||
'pipe:1' // Output to stdout
|
||||
];
|
||||
|
||||
ffmpegProcess = spawn('ffmpeg', ffmpegArgs, {
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
ffmpegProcess.stdout.on('data', (data) => {
|
||||
// Broadcast ke semua connected clients
|
||||
clients.forEach(client => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
client.send(data);
|
||||
} catch (error) {
|
||||
console.error('Error sending data to client:', error.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ffmpegProcess.stderr.on('data', (data) => {
|
||||
// FFmpeg logs ke stderr, bisa di-ignore atau di-log untuk debugging
|
||||
const message = data.toString();
|
||||
if (message.includes('error') || message.includes('Error')) {
|
||||
console.error('FFmpeg error:', message);
|
||||
}
|
||||
});
|
||||
|
||||
ffmpegProcess.on('exit', (code, signal) => {
|
||||
console.log(`⚠️ FFmpeg process exited with code ${code}, signal ${signal}`);
|
||||
ffmpegProcess = null;
|
||||
|
||||
// Restart jika masih ada clients
|
||||
if (clients.size > 0) {
|
||||
console.log('🔄 Restarting FFmpeg...');
|
||||
setTimeout(() => startFFmpeg(), 2000);
|
||||
}
|
||||
});
|
||||
|
||||
ffmpegProcess.on('error', (error) => {
|
||||
console.error('❌ FFmpeg spawn error:', error.message);
|
||||
console.error('💡 Make sure FFmpeg is installed and available in PATH');
|
||||
ffmpegProcess = null;
|
||||
});
|
||||
|
||||
console.log('✅ FFmpeg process started');
|
||||
}
|
||||
|
||||
function stopFFmpeg() {
|
||||
if (ffmpegProcess) {
|
||||
console.log('⏹️ Stopping FFmpeg process...');
|
||||
ffmpegProcess.kill('SIGTERM');
|
||||
ffmpegProcess = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n🛑 Shutting down...');
|
||||
stopFFmpeg();
|
||||
wss.close(() => {
|
||||
console.log('✅ Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('\n🛑 Shutting down...');
|
||||
stopFFmpeg();
|
||||
wss.close(() => {
|
||||
console.log('✅ Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`✅ WebSocket server listening on ws://0.0.0.0:${wsPort}`);
|
||||
console.log('💡 Connect from browser: ws://localhost:' + wsPort);
|
||||
console.log('💡 Press Ctrl+C to stop\n');
|
||||
|
||||
39
proxy/start-proxy.bat
Normal file
39
proxy/start-proxy.bat
Normal file
@@ -0,0 +1,39 @@
|
||||
@echo off
|
||||
echo ========================================
|
||||
echo RTSP to WebSocket Proxy Server
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
cd /d %~dp0
|
||||
|
||||
echo Checking Node.js...
|
||||
node --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Node.js not found!
|
||||
echo Please install Node.js from https://nodejs.org/
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Checking FFmpeg...
|
||||
ffmpeg -version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo ERROR: FFmpeg not found!
|
||||
echo Please install FFmpeg and add to PATH
|
||||
echo Download from https://ffmpeg.org/download.html
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Starting proxy server...
|
||||
echo RTSP URL: rtsp://10.60.0.10:8554/cam1
|
||||
echo WebSocket Port: 8082
|
||||
echo.
|
||||
echo Press Ctrl+C to stop
|
||||
echo.
|
||||
|
||||
node rtsp-websocket-proxy.js rtsp://10.60.0.10:8554/cam1 8082
|
||||
|
||||
pause
|
||||
|
||||
434
public/dashboard/css/app.css
Normal file
434
public/dashboard/css/app.css
Normal file
@@ -0,0 +1,434 @@
|
||||
/* public/dashboard/css/app.css
|
||||
* Enterprise-style admin dashboard: clean, minimal, responsive.
|
||||
*/
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background-color: #f3f4f6;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Top bar */
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background-color: #ffffff;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.topbar-title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.topbar-link {
|
||||
padding: 0.4rem 0.85rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
background-color: #f9fafb;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
transition: all 0.15s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.topbar-link:hover {
|
||||
background-color: #f3f4f6;
|
||||
border-color: #d1d5db;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
/* Layout container */
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 1.5rem auto 2rem;
|
||||
padding: 0 1.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Login layout */
|
||||
.login-root {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
background-color: #ffffff;
|
||||
border-radius: 0.75rem;
|
||||
padding: 2rem 2.25rem;
|
||||
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.08);
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.35rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
border-radius: 0.55rem;
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
background-color: #ffffff;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #111827;
|
||||
box-shadow: 0 0 0 1px #1118271a;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.6rem;
|
||||
border: none;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, box-shadow 0.15s ease, transform 0.05s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
padding: 0.55rem 1rem;
|
||||
background-color: #111827;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #000000;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
display: none;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: 0.5rem;
|
||||
background-color: #fef2f2;
|
||||
color: #b91c1c;
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.error-banner.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Filters */
|
||||
.filters {
|
||||
background-color: #ffffff;
|
||||
border-radius: 0.9rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 1rem 1.1rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||
gap: 0.75rem 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.filter-control {
|
||||
border-radius: 0.55rem;
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 0.45rem 0.65rem;
|
||||
font-size: 0.85rem;
|
||||
background-color: #ffffff;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.filter-control:focus {
|
||||
outline: none;
|
||||
border-color: #111827;
|
||||
box-shadow: 0 0 0 1px #1118271a;
|
||||
}
|
||||
|
||||
/* Summary cards */
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1.25rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.summary-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
background-color: #ffffff;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.card-primary {
|
||||
border-left: 4px solid #3b82f6;
|
||||
}
|
||||
|
||||
.card-success {
|
||||
border-left: 4px solid #10b981;
|
||||
}
|
||||
|
||||
.card-info {
|
||||
border-left: 4px solid #3b82f6;
|
||||
}
|
||||
|
||||
.card-warning {
|
||||
border-left: 4px solid #f59e0b;
|
||||
}
|
||||
|
||||
.card-danger {
|
||||
border-left: 4px solid #ef4444;
|
||||
}
|
||||
|
||||
.card-secondary {
|
||||
border-left: 4px solid #8b5cf6;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #6b7280;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.card-subtext {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.card-loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.card-loading-overlay.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Charts & tables layout */
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 1.2fr);
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.content-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
background-color: #ffffff;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 1.25rem 1.5rem 1.25rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Simple table styles (for future extension) */
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.table th {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #6b7280;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.table td {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 0.6rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 0.8rem;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
|
||||
183
public/dashboard/dashboard.html
Normal file
183
public/dashboard/dashboard.html
Normal file
@@ -0,0 +1,183 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Dashboard - Btekno Retribusi Admin</title>
|
||||
<link rel="stylesheet" href="css/app.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
|
||||
<style>
|
||||
.video-panel {
|
||||
background: #fff;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
overflow: hidden;
|
||||
}
|
||||
.video-panel-header {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.video-panel-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: #111827;
|
||||
}
|
||||
.video-toggle {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
}
|
||||
.video-toggle:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
.video-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
background: #000;
|
||||
}
|
||||
.video-container video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.video-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<header class="topbar">
|
||||
<div class="topbar-title">Dashboard Retribusi</div>
|
||||
<div class="topbar-actions">
|
||||
<span id="topbar-date" style="font-size:0.85rem;color:#6b7280;"></span>
|
||||
<a href="event.html" class="topbar-link">Events</a>
|
||||
<a href="settings.html" class="topbar-link">Pengaturan</a>
|
||||
<button id="logout-button" class="topbar-link">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<section class="filters">
|
||||
<div class="filter-group">
|
||||
<label for="filter-date" class="filter-label">Tanggal</label>
|
||||
<input id="filter-date" type="date" class="filter-control" />
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="filter-location" class="filter-label">Lokasi</label>
|
||||
<select id="filter-location" class="filter-control">
|
||||
<option value="">Semua Lokasi</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="filter-gate" class="filter-label">Gate</label>
|
||||
<select id="filter-gate" class="filter-control">
|
||||
<option value="">Semua Gate</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="summary-error" class="error-text" style="margin-bottom:0.5rem; display:none;"></div>
|
||||
|
||||
<section class="summary-grid">
|
||||
<article class="card card-success">
|
||||
<div class="card-content">
|
||||
<div class="card-title">Total Pendapatan</div>
|
||||
<div id="card-total-amount" class="card-value">Rp 0</div>
|
||||
<div class="card-subtext">Nilai bruto sebelum pemotongan</div>
|
||||
</div>
|
||||
<div id="summary-loading" class="card-loading-overlay">Memuat data...</div>
|
||||
</article>
|
||||
<article class="card card-info">
|
||||
<div class="card-content">
|
||||
<div class="card-title">Jumlah Orang</div>
|
||||
<div id="card-person-count" class="card-value">0</div>
|
||||
<div class="card-subtext">Person walk (pejalan kaki)</div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="card card-warning">
|
||||
<div class="card-content">
|
||||
<div class="card-title">Jumlah Motor</div>
|
||||
<div id="card-motor-count" class="card-value">0</div>
|
||||
<div class="card-subtext">Kendaraan roda dua</div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="card card-danger">
|
||||
<div class="card-content">
|
||||
<div class="card-title">Jumlah Mobil</div>
|
||||
<div id="card-car-count" class="card-value">0</div>
|
||||
<div class="card-subtext">Kendaraan roda empat</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="content-grid">
|
||||
<article class="panel">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">Trend Harian</h2>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="chart-container">
|
||||
<canvas id="daily-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">Per Kategori</h2>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="chart-container" style="height:240px;">
|
||||
<canvas id="category-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="video-section" style="display:none; margin-top:1.5rem;">
|
||||
<article class="video-panel">
|
||||
<div class="video-panel-header">
|
||||
<div id="video-panel-title" class="video-panel-title">Live Camera</div>
|
||||
<button id="video-toggle" class="video-toggle">Hidupkan</button>
|
||||
</div>
|
||||
<div class="video-container">
|
||||
<video id="video-player" controls style="display:none;"></video>
|
||||
<div id="video-placeholder" class="video-placeholder">
|
||||
Kamera tidak aktif
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { Auth } from './js/auth.js';
|
||||
import './js/dashboard.js';
|
||||
import './js/realtime.js';
|
||||
|
||||
document.getElementById('logout-button')?.addEventListener('click', () => {
|
||||
Auth.logout();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
847
public/dashboard/event.html
Normal file
847
public/dashboard/event.html
Normal file
@@ -0,0 +1,847 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Events - Btekno Retribusi Admin</title>
|
||||
<link rel="stylesheet" href="css/app.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
|
||||
<style>
|
||||
.events-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.events-layout.video-hidden {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.events-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.video-panel {
|
||||
background: #fff;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
overflow: hidden;
|
||||
}
|
||||
.video-panel-header {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.video-panel-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: #111827;
|
||||
}
|
||||
.video-toggle {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
}
|
||||
.video-toggle:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
.video-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
background: #000;
|
||||
}
|
||||
.video-container video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.video-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.events-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.events-table thead {
|
||||
background: #f9fafb;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
}
|
||||
.events-table th {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
}
|
||||
.events-table td {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
font-size: 0.875rem;
|
||||
color: #111827;
|
||||
}
|
||||
.events-table tbody tr:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge-person {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
.badge-motor {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
.badge-car {
|
||||
background: #e0e7ff;
|
||||
color: #3730a3;
|
||||
}
|
||||
.loading-overlay {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
.loading-overlay.visible {
|
||||
display: flex;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<header class="topbar">
|
||||
<div class="topbar-title">Events</div>
|
||||
<div class="topbar-actions">
|
||||
<a href="dashboard.html" class="topbar-link">Dashboard</a>
|
||||
<a href="settings.html" class="topbar-link">Pengaturan</a>
|
||||
<button id="logout-button" class="topbar-link" style="border-radius:0.5rem;border-color:#d1d5db;">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<section class="filters">
|
||||
<div class="filter-group">
|
||||
<label for="filter-date" class="filter-label">Tanggal</label>
|
||||
<input id="filter-date" type="date" class="filter-control" />
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="filter-location" class="filter-label">Lokasi</label>
|
||||
<select id="filter-location" class="filter-control">
|
||||
<option value="">Semua Lokasi</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="filter-gate" class="filter-label">Gate</label>
|
||||
<select id="filter-gate" class="filter-control">
|
||||
<option value="">Semua Gate</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="events-error" class="error-text" style="margin-bottom:0.5rem; display:none;"></div>
|
||||
|
||||
<div id="events-layout" class="events-layout">
|
||||
<div>
|
||||
<article class="panel">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">Daftar Events</h2>
|
||||
</div>
|
||||
<div class="panel-body" style="position:relative;">
|
||||
<div id="events-loading" class="loading-overlay">
|
||||
<div>Memuat data...</div>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="events-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Waktu</th>
|
||||
<th>Lokasi</th>
|
||||
<th>Gate</th>
|
||||
<th>Kategori</th>
|
||||
<th>Jumlah</th>
|
||||
<th>Pendapatan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="events-tbody">
|
||||
<tr>
|
||||
<td colspan="6" style="text-align:center;padding:2rem;color:#6b7280;">
|
||||
Memuat data...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div id="video-panel-container" style="display:none;">
|
||||
<article class="video-panel">
|
||||
<div class="video-panel-header">
|
||||
<div id="video-panel-title" class="video-panel-title">•</div>
|
||||
<button id="video-toggle" class="video-toggle">Hidupkan</button>
|
||||
</div>
|
||||
<div class="video-container">
|
||||
<video id="video-player" controls style="display:none;"></video>
|
||||
<div id="video-placeholder" class="video-placeholder">
|
||||
Kamera tidak aktif
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { Auth } from './js/auth.js';
|
||||
import { apiGetLocations, apiGetGates, apiGetEntryEvents, API_CONFIG } from './js/api.js';
|
||||
import './js/realtime.js';
|
||||
|
||||
// Helper untuk build query string
|
||||
function buildQuery(params = {}) {
|
||||
const search = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
search.append(key, value);
|
||||
}
|
||||
});
|
||||
const qs = search.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
}
|
||||
|
||||
// Check auth
|
||||
if (!Auth.isAuthenticated()) {
|
||||
window.location.href = '../index.php';
|
||||
}
|
||||
|
||||
// Logout handler
|
||||
document.getElementById('logout-button')?.addEventListener('click', () => {
|
||||
Auth.logout();
|
||||
});
|
||||
|
||||
// Video HLS setup - menggunakan URL kamera dari database
|
||||
let gatesCache = {}; // Cache untuk gates dengan camera URL
|
||||
let locationsCache = {}; // Cache untuk locations
|
||||
|
||||
let hls = null;
|
||||
let isVideoPlaying = false;
|
||||
let currentVideoUrl = null;
|
||||
|
||||
const videoPanelContainer = document.getElementById('video-panel-container');
|
||||
const videoPanelTitle = document.getElementById('video-panel-title');
|
||||
const videoEl = document.getElementById('video-player');
|
||||
const placeholderEl = document.getElementById('video-placeholder');
|
||||
const toggleBtn = document.getElementById('video-toggle');
|
||||
|
||||
// Load gates untuk mendapatkan camera URL dari database
|
||||
async function loadGatesForCamera() {
|
||||
try {
|
||||
const response = await apiGetGates(null, { limit: 1000 });
|
||||
let gates = [];
|
||||
if (Array.isArray(response)) {
|
||||
gates = response;
|
||||
} else if (response && Array.isArray(response.data)) {
|
||||
gates = response.data;
|
||||
}
|
||||
|
||||
// Build cache: location_code -> { url, name, gate_name }
|
||||
gatesCache = {};
|
||||
gates.forEach(gate => {
|
||||
const locationCode = gate.location_code || '';
|
||||
const camera = gate.camera || null;
|
||||
|
||||
// Hanya simpan gate yang punya camera URL
|
||||
if (camera && camera.trim() !== '') {
|
||||
// Jika sudah ada, gunakan yang pertama (atau bisa dipilih gate tertentu)
|
||||
if (!gatesCache[locationCode]) {
|
||||
const locationName = locationsCache[locationCode]?.name || locationCode;
|
||||
gatesCache[locationCode] = {
|
||||
url: camera.trim(),
|
||||
name: locationName,
|
||||
gate_name: gate.name || gate.gate_code || '',
|
||||
location_code: locationCode
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[Video] Gates dengan camera loaded:', Object.keys(gatesCache).length);
|
||||
} catch (err) {
|
||||
console.error('[Video] Error loading gates:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Load locations untuk mendapatkan nama lokasi
|
||||
async function loadLocationsForCamera() {
|
||||
try {
|
||||
const response = await apiGetLocations({ limit: 1000 });
|
||||
let locations = [];
|
||||
if (Array.isArray(response)) {
|
||||
locations = response;
|
||||
} else if (response && Array.isArray(response.data)) {
|
||||
locations = response.data;
|
||||
}
|
||||
|
||||
// Build cache: location_code -> { name }
|
||||
locationsCache = {};
|
||||
locations.forEach(loc => {
|
||||
const code = loc.code || loc.location_code || '';
|
||||
locationsCache[code] = {
|
||||
name: loc.name || loc.label || code
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Video] Error loading locations:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function getCameraForLocation(locationCode) {
|
||||
if (!locationCode) return null;
|
||||
return gatesCache[locationCode] || null;
|
||||
}
|
||||
|
||||
function showVideoPanel(locationCode) {
|
||||
const eventsLayout = document.getElementById('events-layout');
|
||||
const camera = getCameraForLocation(locationCode);
|
||||
if (camera && camera.url) {
|
||||
videoPanelContainer.style.display = 'block';
|
||||
const displayName = camera.gate_name ? `${camera.name} - ${camera.gate_name}` : camera.name;
|
||||
videoPanelTitle.textContent = displayName;
|
||||
currentVideoUrl = camera.url;
|
||||
if (eventsLayout) eventsLayout.classList.remove('video-hidden');
|
||||
// Auto-stop video kalau lokasi berubah
|
||||
if (isVideoPlaying) {
|
||||
stopVideo();
|
||||
}
|
||||
} else {
|
||||
videoPanelContainer.style.display = 'none';
|
||||
if (eventsLayout) eventsLayout.classList.add('video-hidden');
|
||||
if (isVideoPlaying) {
|
||||
stopVideo();
|
||||
}
|
||||
currentVideoUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
function initVideo() {
|
||||
if (!currentVideoUrl) {
|
||||
console.warn('[Video] Tidak ada URL video untuk lokasi ini');
|
||||
return;
|
||||
}
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: true,
|
||||
backBufferLength: 90
|
||||
});
|
||||
hls.loadSource(currentVideoUrl);
|
||||
hls.attachMedia(videoEl);
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
console.log('[Video] HLS manifest parsed');
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
console.error('[Video] HLS error:', data);
|
||||
if (data.fatal) {
|
||||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
||||
console.log('[Video] Network error, retrying...');
|
||||
hls.startLoad();
|
||||
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
||||
console.log('[Video] Media error, recovering...');
|
||||
hls.recoverMediaError();
|
||||
} else {
|
||||
console.error('[Video] Fatal error, destroying HLS');
|
||||
hls.destroy();
|
||||
hls = null;
|
||||
stopVideo();
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
// Native HLS support (Safari)
|
||||
videoEl.src = currentVideoUrl;
|
||||
} else {
|
||||
console.error('[Video] HLS tidak didukung di browser ini');
|
||||
}
|
||||
}
|
||||
|
||||
function startVideo() {
|
||||
if (!currentVideoUrl) {
|
||||
console.warn('[Video] Tidak ada URL video untuk lokasi ini');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hls && Hls.isSupported()) {
|
||||
initVideo();
|
||||
}
|
||||
videoEl.style.display = 'block';
|
||||
placeholderEl.style.display = 'none';
|
||||
toggleBtn.textContent = 'Matikan';
|
||||
isVideoPlaying = true;
|
||||
|
||||
if (videoEl.src || (hls && hls.media)) {
|
||||
videoEl.play().catch(e => console.error('[Video] Play error:', e));
|
||||
}
|
||||
}
|
||||
|
||||
function stopVideo() {
|
||||
if (hls) {
|
||||
hls.destroy();
|
||||
hls = null;
|
||||
}
|
||||
videoEl.pause();
|
||||
videoEl.src = '';
|
||||
videoEl.style.display = 'none';
|
||||
placeholderEl.style.display = 'flex';
|
||||
toggleBtn.textContent = 'Hidupkan';
|
||||
isVideoPlaying = false;
|
||||
}
|
||||
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
if (isVideoPlaying) {
|
||||
stopVideo();
|
||||
} else {
|
||||
startVideo();
|
||||
}
|
||||
});
|
||||
|
||||
// Events table logic
|
||||
const state = {
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
locationCode: '',
|
||||
gateCode: '',
|
||||
category: '',
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 1
|
||||
};
|
||||
|
||||
// Tariff cache: map dari location_code|gate_code|category ke price
|
||||
let tariffsMap = {};
|
||||
|
||||
async function loadTariffs() {
|
||||
try {
|
||||
const url = `${API_CONFIG.BASE_URL}/retribusi/v1/frontend/tariffs?limit=1000`;
|
||||
const token = localStorage.getItem('token') || '';
|
||||
console.log('[Events] Loading tariffs from:', url);
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-KEY': API_CONFIG.API_KEY,
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
console.warn('[Events] Failed to load tariffs:', res.status, errorText);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await res.json();
|
||||
console.log('[Events] Tariffs API response:', response);
|
||||
|
||||
let tariffs = [];
|
||||
|
||||
if (response && response.success && Array.isArray(response.data)) {
|
||||
tariffs = response.data;
|
||||
} else if (Array.isArray(response)) {
|
||||
tariffs = response;
|
||||
} else if (response && Array.isArray(response.data)) {
|
||||
tariffs = response.data;
|
||||
}
|
||||
|
||||
// Build map: location_code|gate_code|category -> price
|
||||
tariffsMap = {};
|
||||
tariffs.forEach(tariff => {
|
||||
// Handle berbagai format field name
|
||||
const locationCode = tariff.location_code || tariff.locationCode || '';
|
||||
const gateCode = tariff.gate_code || tariff.gateCode || '';
|
||||
const category = tariff.category || '';
|
||||
const price = tariff.price || 0;
|
||||
|
||||
if (locationCode && gateCode && category) {
|
||||
const key = `${locationCode}|${gateCode}|${category}`;
|
||||
tariffsMap[key] = parseInt(price, 10);
|
||||
console.log('[Events] Tariff mapped:', key, '->', tariffsMap[key], 'from tariff:', tariff);
|
||||
} else {
|
||||
console.warn('[Events] Invalid tariff data:', tariff);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[Events] Loaded tariffs:', Object.keys(tariffsMap).length, 'tariffs');
|
||||
console.log('[Events] Tariffs map keys:', Object.keys(tariffsMap));
|
||||
console.log('[Events] Sample tariffs map:', Object.fromEntries(Object.entries(tariffsMap).slice(0, 3)));
|
||||
} catch (err) {
|
||||
console.error('[Events] Error loading tariffs:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function getTariffPrice(locationCode, gateCode, category) {
|
||||
if (!locationCode || !gateCode || !category) {
|
||||
return 0;
|
||||
}
|
||||
const key = `${locationCode}|${gateCode}|${category}`;
|
||||
const price = tariffsMap[key] || 0;
|
||||
return price;
|
||||
}
|
||||
|
||||
async function loadLocations() {
|
||||
const select = document.getElementById('filter-location');
|
||||
if (!select) return;
|
||||
select.innerHTML = '<option value="">Semua Lokasi</option>';
|
||||
|
||||
try {
|
||||
const data = await apiGetLocations({ limit: 100 }); // Ambil semua lokasi
|
||||
// Handle pagination response: { data: [...], total, page, limit }
|
||||
// atau langsung array: [...]
|
||||
let items = [];
|
||||
if (Array.isArray(data)) {
|
||||
items = data;
|
||||
} else if (data && Array.isArray(data.data)) {
|
||||
items = data.data;
|
||||
}
|
||||
|
||||
items.forEach(loc => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = loc.code || loc.location_code || '';
|
||||
opt.textContent = loc.name || loc.label || opt.value;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('loadLocations error', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGates() {
|
||||
const select = document.getElementById('filter-gate');
|
||||
if (!select) return;
|
||||
select.innerHTML = '<option value="">Semua Gate</option>';
|
||||
|
||||
if (!state.locationCode) return;
|
||||
|
||||
try {
|
||||
const data = await apiGetGates(state.locationCode);
|
||||
const items = Array.isArray(data) ? data : (Array.isArray(data.data) ? data.data : []);
|
||||
items.forEach(g => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = g.code || g.gate_code || '';
|
||||
opt.textContent = g.name || g.label || opt.value;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('loadGates error', err);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleString('id-ID', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function formatCurrency(value) {
|
||||
return 'Rp ' + new Intl.NumberFormat('id-ID').format(value || 0);
|
||||
}
|
||||
|
||||
function getCategoryBadge(category) {
|
||||
const map = {
|
||||
'person_walk': { text: 'Orang', class: 'badge-person' },
|
||||
'motor': { text: 'Motor', class: 'badge-motor' },
|
||||
'car': { text: 'Mobil', class: 'badge-car' }
|
||||
};
|
||||
const item = map[category] || { text: category, class: '' };
|
||||
return `<span class="badge ${item.class}">${item.text}</span>`;
|
||||
}
|
||||
|
||||
function renderEvents(events) {
|
||||
const tbody = document.getElementById('events-tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
if (!events || events.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:2rem;color:#6b7280;">Tidak ada data</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = events.map(event => {
|
||||
// Mapping untuk struktur response dari entry_events table
|
||||
// Fields: id, location_code, gate_code, category, event_time, source_ip, created_at
|
||||
const timestamp = event.event_time || event.created_at || event.timestamp || event.date || event.time || '-';
|
||||
const location = event.location_code || event.location || '-';
|
||||
const gate = event.gate_code || event.gate || '-';
|
||||
const category = event.category || event.type || '-';
|
||||
|
||||
// Entry events adalah individual events, jadi count = 1
|
||||
const count = 1; // Setiap row adalah 1 event
|
||||
|
||||
// Hitung amount dari tariff price
|
||||
const tariffPrice = getTariffPrice(location, gate, category);
|
||||
const amount = tariffPrice; // 1 event * price = price
|
||||
|
||||
// Debug: log jika amount masih 0
|
||||
if (amount === 0 && location !== '-' && gate !== '-' && category !== '-') {
|
||||
const key = `${location}|${gate}|${category}`;
|
||||
console.warn('[Events] Zero amount for:', { location, gate, category, key, tariffsMapKeys: Object.keys(tariffsMap) });
|
||||
}
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${formatDateTime(timestamp)}</td>
|
||||
<td>${location}</td>
|
||||
<td>${gate}</td>
|
||||
<td>${getCategoryBadge(category)}</td>
|
||||
<td>${count}</td>
|
||||
<td>${formatCurrency(amount)}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function loadEvents() {
|
||||
const loadingEl = document.getElementById('events-loading');
|
||||
const errorEl = document.getElementById('events-error');
|
||||
const tbody = document.getElementById('events-tbody');
|
||||
|
||||
if (loadingEl) loadingEl.classList.add('visible');
|
||||
if (errorEl) {
|
||||
errorEl.style.display = 'none';
|
||||
errorEl.textContent = '';
|
||||
}
|
||||
if (tbody) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:2rem;color:#6b7280;">Memuat data...</td></tr>';
|
||||
}
|
||||
|
||||
try {
|
||||
// Sesuai spec: start_date, end_date (bukan date saja)
|
||||
// Jika state.date ada, gunakan sebagai start_date dan end_date
|
||||
const params = {
|
||||
page: state.page || 1,
|
||||
limit: state.limit || 20,
|
||||
location_code: state.locationCode || undefined,
|
||||
gate_code: state.gateCode || undefined,
|
||||
category: state.category || undefined
|
||||
};
|
||||
|
||||
// Jika ada date filter, set start_date dan end_date
|
||||
if (state.date) {
|
||||
params.start_date = state.date;
|
||||
params.end_date = state.date; // Same day
|
||||
}
|
||||
|
||||
// Hapus undefined values
|
||||
Object.keys(params).forEach(key => {
|
||||
if (params[key] === undefined || params[key] === '') {
|
||||
delete params[key];
|
||||
}
|
||||
});
|
||||
|
||||
// Request langsung untuk dapat full response dengan meta
|
||||
const url = `${API_CONFIG.BASE_URL}/retribusi/v1/frontend/entry-events${buildQuery(params)}`;
|
||||
const token = localStorage.getItem('token') || '';
|
||||
|
||||
console.log('[Events] Requesting:', url);
|
||||
console.log('[Events] Params:', params);
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-KEY': API_CONFIG.API_KEY,
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
console.error('[Events] API error:', res.status, errorText);
|
||||
throw new Error(`HTTP ${res.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const rawResponse = await res.json();
|
||||
console.log('[Events] Raw API response:', rawResponse);
|
||||
|
||||
// Handle response format: { success: true, data: [...], meta: { page, limit, total, pages } }
|
||||
let events = [];
|
||||
let total = 0;
|
||||
let currentPage = 1;
|
||||
let totalPages = 1;
|
||||
|
||||
if (rawResponse && rawResponse.success && Array.isArray(rawResponse.data)) {
|
||||
// Format dengan success dan meta
|
||||
events = rawResponse.data;
|
||||
if (rawResponse.meta) {
|
||||
total = rawResponse.meta.total || events.length;
|
||||
currentPage = rawResponse.meta.page || 1;
|
||||
totalPages = rawResponse.meta.pages || Math.ceil(total / (rawResponse.meta.limit || 20));
|
||||
}
|
||||
} else if (Array.isArray(rawResponse)) {
|
||||
// Langsung array
|
||||
events = rawResponse;
|
||||
total = events.length;
|
||||
} else if (rawResponse && Array.isArray(rawResponse.data)) {
|
||||
// Format tanpa success
|
||||
events = rawResponse.data;
|
||||
total = rawResponse.total || events.length;
|
||||
currentPage = rawResponse.page || 1;
|
||||
totalPages = rawResponse.total_pages || rawResponse.pages || Math.ceil(total / (rawResponse.limit || 20));
|
||||
} else {
|
||||
console.warn('[Events] Unexpected response format:', rawResponse);
|
||||
events = [];
|
||||
}
|
||||
|
||||
console.log('[Events] Parsed events:', events.length, 'items', { total, currentPage, totalPages });
|
||||
eventsCache = events;
|
||||
state.page = currentPage;
|
||||
state.total = total;
|
||||
state.totalPages = totalPages;
|
||||
renderEvents(events);
|
||||
} catch (err) {
|
||||
console.error('loadEvents error', err);
|
||||
|
||||
if (errorEl) {
|
||||
errorEl.textContent = err.message || 'Gagal memuat data events';
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
renderEvents([]);
|
||||
} finally {
|
||||
if (loadingEl) loadingEl.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
function setupFilters() {
|
||||
const dateInput = document.getElementById('filter-date');
|
||||
if (dateInput) {
|
||||
dateInput.value = state.date;
|
||||
dateInput.addEventListener('change', () => {
|
||||
state.date = dateInput.value || state.date;
|
||||
state.page = 1; // Reset to first page
|
||||
loadEvents();
|
||||
});
|
||||
}
|
||||
|
||||
const locationSelect = document.getElementById('filter-location');
|
||||
if (locationSelect) {
|
||||
locationSelect.addEventListener('change', async () => {
|
||||
state.locationCode = locationSelect.value;
|
||||
state.gateCode = '';
|
||||
state.page = 1; // Reset to first page
|
||||
const gateSelect = document.getElementById('filter-gate');
|
||||
if (gateSelect) gateSelect.value = '';
|
||||
|
||||
// Reload gates untuk update camera cache
|
||||
await loadGates();
|
||||
await loadGatesForCamera(); // Reload camera URLs
|
||||
|
||||
// Show/hide video panel berdasarkan lokasi
|
||||
showVideoPanel(state.locationCode);
|
||||
|
||||
loadEvents();
|
||||
});
|
||||
}
|
||||
|
||||
const gateSelect = document.getElementById('filter-gate');
|
||||
if (gateSelect) {
|
||||
gateSelect.addEventListener('change', () => {
|
||||
state.gateCode = gateSelect.value;
|
||||
state.page = 1; // Reset to first page
|
||||
loadEvents();
|
||||
});
|
||||
}
|
||||
|
||||
// Category filter (jika ada)
|
||||
const categorySelect = document.getElementById('filter-category');
|
||||
if (categorySelect) {
|
||||
categorySelect.addEventListener('change', () => {
|
||||
state.category = categorySelect.value;
|
||||
state.page = 1; // Reset to first page
|
||||
loadEvents();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Listen to realtime events - update summary cards dengan data snapshot
|
||||
let eventsCache = [];
|
||||
let lastSnapshotTime = null;
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat('id-ID').format(value || 0);
|
||||
}
|
||||
|
||||
// formatCurrency sudah didefinisikan di atas, tidak perlu duplikat
|
||||
|
||||
window.addEventListener('realtime:snapshot', (e) => {
|
||||
console.log('[Events] Realtime snapshot:', e.detail);
|
||||
const snapshot = e.detail;
|
||||
lastSnapshotTime = new Date();
|
||||
|
||||
// Snapshot hanya untuk logging, tidak perlu render ke UI
|
||||
// Tabel events akan di-update dari endpoint events yang benar
|
||||
});
|
||||
|
||||
// Update UI untuk show bahwa realtime aktif
|
||||
function updateRealtimeStatus() {
|
||||
const errorEl = document.getElementById('events-error');
|
||||
if (lastSnapshotTime) {
|
||||
const secondsAgo = Math.floor((new Date() - lastSnapshotTime) / 1000);
|
||||
// Status realtime hanya di console, tidak perlu tampil di UI
|
||||
// Supaya UI tetap fokus ke tabel events
|
||||
}
|
||||
}
|
||||
|
||||
// Update status setiap detik
|
||||
setInterval(updateRealtimeStatus, 1000);
|
||||
|
||||
// Init
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
setupFilters();
|
||||
await loadLocations();
|
||||
await loadGates();
|
||||
await loadTariffs(); // Load tariffs untuk hitung amount
|
||||
|
||||
// Load locations dan gates untuk camera URL dari database
|
||||
await loadLocationsForCamera();
|
||||
await loadGatesForCamera();
|
||||
|
||||
// Cek lokasi awal untuk show/hide video
|
||||
showVideoPanel(state.locationCode);
|
||||
|
||||
await loadEvents();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
80
public/dashboard/js/README_CONFIG.md
Normal file
80
public/dashboard/js/README_CONFIG.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Konfigurasi API Base URL
|
||||
|
||||
## Lokasi File
|
||||
- **Config**: `public/dashboard/js/config.js`
|
||||
- **Digunakan oleh**: `api.js`, `realtime.js`
|
||||
|
||||
## Cara Kerja
|
||||
|
||||
File `config.js` akan auto-detect environment berdasarkan hostname:
|
||||
|
||||
### Development Lokal
|
||||
- Jika hostname = `localhost`, `127.0.0.1`, atau IP lokal (`192.168.x.x`)
|
||||
- Base URL default: `http://localhost/api-btekno/public`
|
||||
- **Sesuaikan** dengan path API backend di Laragon/XAMPP Anda
|
||||
|
||||
### Production
|
||||
- Jika hostname bukan localhost
|
||||
- Base URL: `https://api.btekno.cloud`
|
||||
|
||||
## Cara Mengubah Base URL
|
||||
|
||||
### Opsi 1: Edit `config.js` (Recommended)
|
||||
Edit file `public/dashboard/js/config.js`:
|
||||
|
||||
```javascript
|
||||
function getApiBaseUrl() {
|
||||
const hostname = window.location.hostname;
|
||||
|
||||
// Development lokal
|
||||
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
||||
// GANTI INI sesuai path API backend Anda
|
||||
return 'http://localhost/api-btekno/public';
|
||||
// Atau jika pakai virtual host:
|
||||
// return 'http://api.retribusi.test';
|
||||
}
|
||||
|
||||
// Production
|
||||
return 'https://api.btekno.cloud';
|
||||
}
|
||||
```
|
||||
|
||||
### Opsi 2: Hardcode (Tidak Recommended)
|
||||
Jika ingin hardcode, edit langsung di `config.js`:
|
||||
|
||||
```javascript
|
||||
export const API_CONFIG = {
|
||||
BASE_URL: 'http://localhost/api-btekno/public', // Ganti ini
|
||||
API_KEY: 'POKOKEIKISEKOYOLO',
|
||||
TIMEOUT: 30000
|
||||
};
|
||||
```
|
||||
|
||||
## Contoh Path API Backend
|
||||
|
||||
### Laragon
|
||||
- Jika API di: `C:\laragon\www\RETRIBUSI_BAPENDA\api-btekno\public`
|
||||
- Base URL: `http://localhost/api-btekno/public`
|
||||
- Atau buat virtual host: `http://api.retribusi.test`
|
||||
|
||||
### XAMPP
|
||||
- Jika API di: `C:\xampp\htdocs\api-btekno\public`
|
||||
- Base URL: `http://localhost/api-btekno/public`
|
||||
|
||||
### Production
|
||||
- Base URL: `https://api.btekno.cloud`
|
||||
|
||||
## API Key
|
||||
|
||||
API Key juga bisa diubah di `config.js`:
|
||||
```javascript
|
||||
API_KEY: 'POKOKEIKISEKOYOLO' // Sesuaikan dengan RETRIBUSI_API_KEY di backend .env
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
Untuk melihat Base URL yang digunakan, buka browser console. Akan muncul log:
|
||||
```
|
||||
API Base URL: http://localhost/api-btekno/public
|
||||
```
|
||||
|
||||
176
public/dashboard/js/api.js
Normal file
176
public/dashboard/js/api.js
Normal file
@@ -0,0 +1,176 @@
|
||||
// public/dashboard/js/api.js
|
||||
// Centralized REST API client for Btekno Retribusi Admin Dashboard
|
||||
|
||||
import { API_CONFIG } from './config.js';
|
||||
|
||||
// Export API_CONFIG untuk digunakan di file lain
|
||||
export { API_CONFIG };
|
||||
|
||||
const API_BASE_URL = API_CONFIG.BASE_URL;
|
||||
|
||||
function getToken() {
|
||||
return localStorage.getItem('token') || '';
|
||||
}
|
||||
|
||||
async function apiRequest(path, options = {}) {
|
||||
const url = path.startsWith('http') ? path : `${API_BASE_URL}${path}`;
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
// X-API-KEY dari konfigurasi backend (RETRIBUSI_API_KEY)
|
||||
'X-API-KEY': API_CONFIG.API_KEY,
|
||||
...(options.headers || {})
|
||||
};
|
||||
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const config = {
|
||||
method: options.method || 'GET',
|
||||
headers,
|
||||
body: options.body ? JSON.stringify(options.body) : null
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(url, config);
|
||||
|
||||
if (res.status === 401) {
|
||||
// Unauthorized → clear token & redirect to login
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '../index.php';
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
let json;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : {};
|
||||
} catch (e) {
|
||||
json = { raw: text };
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = json.message || json.error || `HTTP ${res.status}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
// Some endpoints might wrap data as { success, data, ... }
|
||||
if (json && Object.prototype.hasOwnProperty.call(json, 'success') &&
|
||||
Object.prototype.hasOwnProperty.call(json, 'data')) {
|
||||
return json.data;
|
||||
}
|
||||
|
||||
return json;
|
||||
} catch (err) {
|
||||
console.error('API error', { url, error: err });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper untuk build query string dari object params
|
||||
function buildQuery(params = {}) {
|
||||
const search = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
search.append(key, value);
|
||||
}
|
||||
});
|
||||
const qs = search.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
}
|
||||
|
||||
// Typed helpers
|
||||
|
||||
export async function apiLogin(username, password) {
|
||||
return apiRequest('/auth/v1/login', {
|
||||
method: 'POST',
|
||||
body: { username, password }
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiGetLocations(params = {}) {
|
||||
// Handle pagination: { page, limit }
|
||||
const qs = buildQuery(params);
|
||||
return apiRequest(`/retribusi/v1/frontend/locations${qs}`);
|
||||
}
|
||||
|
||||
export async function apiGetGates(locationCode, params = {}) {
|
||||
// Handle pagination: { page, limit, location_code }
|
||||
const queryParams = { ...params };
|
||||
if (locationCode) queryParams.location_code = locationCode;
|
||||
const qs = buildQuery(queryParams);
|
||||
return apiRequest(`/retribusi/v1/frontend/gates${qs}`);
|
||||
}
|
||||
|
||||
export async function apiGetSummary({ date, locationCode, gateCode }) {
|
||||
const qs = buildQuery({
|
||||
date,
|
||||
location_code: locationCode,
|
||||
gate_code: gateCode
|
||||
});
|
||||
return apiRequest(`/retribusi/v1/dashboard/summary${qs}`);
|
||||
}
|
||||
|
||||
export async function apiGetDaily({ startDate, endDate, locationCode }) {
|
||||
const qs = buildQuery({
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
location_code: locationCode
|
||||
});
|
||||
return apiRequest(`/retribusi/v1/dashboard/daily${qs}`);
|
||||
}
|
||||
|
||||
export async function apiGetByCategory({ date, locationCode, gateCode }) {
|
||||
const qs = buildQuery({
|
||||
date,
|
||||
location_code: locationCode,
|
||||
gate_code: gateCode
|
||||
});
|
||||
return apiRequest(`/retribusi/v1/dashboard/by-category${qs}`);
|
||||
}
|
||||
|
||||
// Ringkasan global harian (daily_summary)
|
||||
export async function apiGetSummaryDaily(params = {}) {
|
||||
const qs = buildQuery(params);
|
||||
return apiRequest(`/retribusi/v1/summary/daily${qs}`);
|
||||
}
|
||||
|
||||
// Ringkasan per jam (hourly_summary)
|
||||
export async function apiGetSummaryHourly(params = {}) {
|
||||
const qs = buildQuery(params);
|
||||
return apiRequest(`/retribusi/v1/summary/hourly${qs}`);
|
||||
}
|
||||
|
||||
// Snapshot realtime (untuk panel live / TV wall)
|
||||
export async function apiGetRealtimeSnapshot(params = {}) {
|
||||
const qs = buildQuery(params);
|
||||
return apiRequest(`/retribusi/v1/realtime/snapshot${qs}`);
|
||||
}
|
||||
|
||||
// Entry events list (raw events dari mesin YOLO)
|
||||
// GET /retribusi/v1/frontend/entry-events
|
||||
// Parameters: page, limit, location_code, gate_code, category, start_date, end_date
|
||||
export async function apiGetEntryEvents(params = {}) {
|
||||
const qs = buildQuery(params);
|
||||
return apiRequest(`/retribusi/v1/frontend/entry-events${qs}`);
|
||||
}
|
||||
|
||||
// Realtime events list (history untuk SSE)
|
||||
// GET /retribusi/v1/realtime/events
|
||||
// Parameters: page, limit, location_code, gate_code, category, start_date, end_date
|
||||
export async function apiGetRealtimeEvents(params = {}) {
|
||||
const qs = buildQuery(params);
|
||||
return apiRequest(`/retribusi/v1/realtime/events${qs}`);
|
||||
}
|
||||
|
||||
// Alias untuk backward compatibility
|
||||
export async function apiGetEvents(params = {}) {
|
||||
return apiGetEntryEvents(params);
|
||||
}
|
||||
|
||||
// Catatan: realtime SSE /retribusi/v1/realtime/stream akan diakses langsung via EventSource,
|
||||
// bukan lewat fetch/apiRequest karena menggunakan Server-Sent Events (SSE).
|
||||
|
||||
86
public/dashboard/js/auth.js
Normal file
86
public/dashboard/js/auth.js
Normal file
@@ -0,0 +1,86 @@
|
||||
// public/dashboard/js/auth.js
|
||||
// Handles login flow and auth helpers (JWT in localStorage)
|
||||
|
||||
import { apiLogin } from './api.js';
|
||||
|
||||
const TOKEN_KEY = 'token';
|
||||
const USER_KEY = 'user';
|
||||
|
||||
export const Auth = {
|
||||
isAuthenticated() {
|
||||
return !!localStorage.getItem(TOKEN_KEY);
|
||||
},
|
||||
|
||||
saveToken(token) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
},
|
||||
|
||||
saveUser(user) {
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user || {}));
|
||||
},
|
||||
|
||||
logout() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
window.location.href = '../index.php';
|
||||
}
|
||||
};
|
||||
|
||||
async function handleLoginSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const form = event.currentTarget;
|
||||
const usernameInput = form.querySelector('#username');
|
||||
const passwordInput = form.querySelector('#password');
|
||||
const errorBox = document.getElementById('login-error');
|
||||
const submitBtn = form.querySelector('button[type="submit"]');
|
||||
|
||||
if (errorBox) {
|
||||
errorBox.classList.remove('visible');
|
||||
errorBox.textContent = '';
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Masuk...';
|
||||
|
||||
try {
|
||||
const username = usernameInput.value.trim();
|
||||
const password = passwordInput.value;
|
||||
|
||||
const data = await apiLogin(username, password);
|
||||
const token = data.token;
|
||||
const user = data.user;
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Token tidak ditemukan dalam response login.');
|
||||
}
|
||||
|
||||
Auth.saveToken(token);
|
||||
Auth.saveUser(user);
|
||||
|
||||
window.location.href = 'dashboard.html';
|
||||
} catch (err) {
|
||||
console.error('Login failed', err);
|
||||
if (errorBox) {
|
||||
errorBox.textContent = err.message || 'Login gagal. Silakan coba lagi.';
|
||||
errorBox.classList.add('visible');
|
||||
}
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Login';
|
||||
}
|
||||
}
|
||||
|
||||
// Attach events on login page only
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const form = document.getElementById('login-form');
|
||||
if (form) {
|
||||
if (Auth.isAuthenticated()) {
|
||||
window.location.href = 'dashboard.html';
|
||||
return;
|
||||
}
|
||||
form.addEventListener('submit', handleLoginSubmit);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
193
public/dashboard/js/charts.js
Normal file
193
public/dashboard/js/charts.js
Normal file
@@ -0,0 +1,193 @@
|
||||
// public/dashboard/js/charts.js
|
||||
// Chart.js helpers: create & update charts without recreating canvases.
|
||||
|
||||
let dailyLineChart = null;
|
||||
let categoryChart = null;
|
||||
|
||||
// Export chart instances untuk akses dari dashboard.js
|
||||
export function getDailyChart() {
|
||||
return dailyLineChart;
|
||||
}
|
||||
|
||||
export function getCategoryChart() {
|
||||
return categoryChart;
|
||||
}
|
||||
|
||||
export function initDailyChart(ctx) {
|
||||
if (dailyLineChart) return dailyLineChart;
|
||||
|
||||
dailyLineChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Jumlah',
|
||||
data: [],
|
||||
borderColor: '#111827',
|
||||
backgroundColor: 'rgba(17, 24, 39, 0.06)',
|
||||
borderWidth: 2,
|
||||
tension: 0.35,
|
||||
fill: true,
|
||||
pointRadius: 2.5,
|
||||
pointBackgroundColor: '#111827'
|
||||
},
|
||||
{
|
||||
label: 'Pendapatan',
|
||||
data: [],
|
||||
borderColor: '#6b7280',
|
||||
backgroundColor: 'rgba(156, 163, 175, 0.08)',
|
||||
borderWidth: 2,
|
||||
tension: 0.35,
|
||||
fill: true,
|
||||
pointRadius: 2.5,
|
||||
pointBackgroundColor: '#6b7280',
|
||||
yAxisID: 'y1'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: '#e5e7eb' },
|
||||
ticks: { font: { size: 11 } }
|
||||
},
|
||||
y1: {
|
||||
beginAtZero: true,
|
||||
position: 'right',
|
||||
grid: { drawOnChartArea: false },
|
||||
ticks: {
|
||||
font: { size: 11 },
|
||||
callback: value => 'Rp ' + new Intl.NumberFormat('id-ID').format(value)
|
||||
}
|
||||
},
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { font: { size: 11 } }
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
boxWidth: 14,
|
||||
boxHeight: 4
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return dailyLineChart;
|
||||
}
|
||||
|
||||
export function updateDailyChart({ labels, counts, amounts }) {
|
||||
if (!dailyLineChart) {
|
||||
console.warn('[Charts] Daily chart belum di-init, skip update');
|
||||
// Try to init if canvas exists
|
||||
const canvas = document.getElementById('daily-chart');
|
||||
if (canvas) {
|
||||
console.log('[Charts] Attempting to init daily chart from update function...');
|
||||
initDailyChart(canvas.getContext('2d'));
|
||||
} else {
|
||||
console.error('[Charts] Daily chart canvas not found!');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!dailyLineChart) {
|
||||
console.error('[Charts] Failed to init daily chart');
|
||||
return;
|
||||
}
|
||||
|
||||
dailyLineChart.data.labels = labels || [];
|
||||
dailyLineChart.data.datasets[0].data = counts || [];
|
||||
dailyLineChart.data.datasets[1].data = amounts || [];
|
||||
dailyLineChart.update();
|
||||
console.log('[Charts] Daily chart updated:', { labelsCount: labels?.length, countsCount: counts?.length, amountsCount: amounts?.length });
|
||||
}
|
||||
|
||||
export function initCategoryChart(ctx) {
|
||||
if (categoryChart) return categoryChart;
|
||||
|
||||
categoryChart = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Per Kategori',
|
||||
data: [],
|
||||
backgroundColor: ['#111827', '#4b5563', '#9ca3af'],
|
||||
borderWidth: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '60%',
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
boxWidth: 14,
|
||||
boxHeight: 6
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return categoryChart;
|
||||
}
|
||||
|
||||
export function updateCategoryChart({ labels, values }) {
|
||||
if (!categoryChart) {
|
||||
console.warn('[Charts] Category chart belum di-init, skip update');
|
||||
// Try to init if canvas exists
|
||||
const canvas = document.getElementById('category-chart');
|
||||
if (canvas) {
|
||||
console.log('[Charts] Attempting to init category chart from update function...');
|
||||
initCategoryChart(canvas.getContext('2d'));
|
||||
} else {
|
||||
console.error('[Charts] Category chart canvas not found!');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!categoryChart) {
|
||||
console.error('[Charts] Failed to init category chart');
|
||||
return;
|
||||
}
|
||||
|
||||
// Pastikan labels dan values tidak kosong
|
||||
const finalLabels = labels && labels.length > 0 ? labels : ['Orang', 'Motor', 'Mobil'];
|
||||
const finalValues = values && values.length > 0 ? values : [0, 0, 0];
|
||||
|
||||
// Pastikan length sama
|
||||
const minLength = Math.min(finalLabels.length, finalValues.length);
|
||||
const safeLabels = finalLabels.slice(0, minLength);
|
||||
const safeValues = finalValues.slice(0, minLength);
|
||||
|
||||
categoryChart.data.labels = safeLabels;
|
||||
categoryChart.data.datasets[0].data = safeValues;
|
||||
|
||||
// Update chart dengan mode 'none' untuk animasi halus
|
||||
categoryChart.update('none');
|
||||
|
||||
console.log('[Charts] Category chart updated:', {
|
||||
labels: safeLabels,
|
||||
values: safeValues,
|
||||
total: safeValues.reduce((a, b) => a + b, 0)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
38
public/dashboard/js/config.js
Normal file
38
public/dashboard/js/config.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// public/dashboard/js/config.js
|
||||
// Konfigurasi API Base URL untuk frontend
|
||||
|
||||
// Auto-detect environment berdasarkan hostname
|
||||
function getApiBaseUrl() {
|
||||
const hostname = window.location.hostname;
|
||||
|
||||
// Development lokal (Laragon/XAMPP)
|
||||
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname.startsWith('192.168.')) {
|
||||
// Untuk PHP built-in server (port 8000)
|
||||
// return 'http://localhost:8000';
|
||||
|
||||
// Untuk Laragon/Apache (path-based)
|
||||
// return 'http://localhost/api-btekno/public';
|
||||
|
||||
// Untuk Laragon virtual host
|
||||
// return 'http://api.retribusi.test';
|
||||
|
||||
// Default: PHP built-in server
|
||||
return 'http://localhost:8000';
|
||||
}
|
||||
|
||||
// Production
|
||||
return 'https://api.btekno.cloud';
|
||||
}
|
||||
|
||||
// Export config
|
||||
export const API_CONFIG = {
|
||||
BASE_URL: getApiBaseUrl(),
|
||||
API_KEY: 'POKOKEIKISEKOYOLO', // Sesuaikan dengan RETRIBUSI_API_KEY di backend
|
||||
TIMEOUT: 30000 // 30 detik
|
||||
};
|
||||
|
||||
// Untuk debugging
|
||||
if (typeof window !== 'undefined') {
|
||||
console.log('API Base URL:', API_CONFIG.BASE_URL);
|
||||
}
|
||||
|
||||
723
public/dashboard/js/dashboard.js
Normal file
723
public/dashboard/js/dashboard.js
Normal file
@@ -0,0 +1,723 @@
|
||||
// public/dashboard/js/dashboard.js
|
||||
// Main dashboard logic: filters, KPI cards, charts.
|
||||
|
||||
import { Auth } from './auth.js';
|
||||
import {
|
||||
apiGetLocations,
|
||||
apiGetGates,
|
||||
apiGetSummary,
|
||||
apiGetDaily,
|
||||
apiGetByCategory,
|
||||
apiGetSummaryHourly
|
||||
} from './api.js';
|
||||
import {
|
||||
initDailyChart,
|
||||
initCategoryChart,
|
||||
updateDailyChart,
|
||||
updateCategoryChart,
|
||||
getDailyChart,
|
||||
getCategoryChart
|
||||
} from './charts.js';
|
||||
|
||||
// Default date: selalu hari ini (tidak auto-detect ke tanggal lama)
|
||||
const state = {
|
||||
date: new Date().toISOString().split('T')[0], // Default: hari ini
|
||||
locationCode: '',
|
||||
gateCode: ''
|
||||
};
|
||||
|
||||
// Function untuk auto-detect tanggal terakhir yang ada data
|
||||
async function getLastAvailableDate() {
|
||||
try {
|
||||
// Coba ambil data hari ini dulu
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const todayData = await apiGetSummary({ date: today });
|
||||
|
||||
console.log('[Dashboard] getLastAvailableDate - today data:', todayData);
|
||||
|
||||
// Handle jika response masih wrapped (seharusnya sudah di-unwrap oleh api.js)
|
||||
let todaySummary = todayData;
|
||||
if (todayData && typeof todayData === 'object' && 'data' in todayData && !('total_count' in todayData)) {
|
||||
todaySummary = todayData.data || {};
|
||||
}
|
||||
|
||||
// Jika hari ini ada data, return hari ini
|
||||
if (todaySummary && (todaySummary.total_count > 0 || todaySummary.total_amount > 0)) {
|
||||
console.log('[Dashboard] Using today:', today);
|
||||
return today;
|
||||
}
|
||||
|
||||
// Jika tidak, coba kemarin
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const yesterdayStr = yesterday.toISOString().split('T')[0];
|
||||
const yesterdayData = await apiGetSummary({ date: yesterdayStr });
|
||||
|
||||
console.log('[Dashboard] getLastAvailableDate - yesterday data:', yesterdayData);
|
||||
|
||||
// Handle jika response masih wrapped
|
||||
let yesterdaySummary = yesterdayData;
|
||||
if (yesterdayData && typeof yesterdayData === 'object' && 'data' in yesterdayData && !('total_count' in yesterdayData)) {
|
||||
yesterdaySummary = yesterdayData.data || {};
|
||||
}
|
||||
|
||||
if (yesterdaySummary && (yesterdaySummary.total_count > 0 || yesterdaySummary.total_amount > 0)) {
|
||||
console.log('[Dashboard] Using yesterday:', yesterdayStr);
|
||||
return yesterdayStr;
|
||||
}
|
||||
|
||||
// Jika tidak ada data kemarin, cek 7 hari terakhir
|
||||
for (let i = 2; i <= 7; i++) {
|
||||
const prevDate = new Date();
|
||||
prevDate.setDate(prevDate.getDate() - i);
|
||||
const prevDateStr = prevDate.toISOString().split('T')[0];
|
||||
const prevData = await apiGetSummary({ date: prevDateStr });
|
||||
|
||||
console.log(`[Dashboard] getLastAvailableDate - ${i} days ago (${prevDateStr}) data:`, prevData);
|
||||
|
||||
// Handle jika response masih wrapped
|
||||
let prevSummary = prevData;
|
||||
if (prevData && typeof prevData === 'object' && 'data' in prevData && !('total_count' in prevData)) {
|
||||
prevSummary = prevData.data || {};
|
||||
}
|
||||
|
||||
if (prevSummary && (prevSummary.total_count > 0 || prevSummary.total_amount > 0)) {
|
||||
console.log(`[Dashboard] Using ${i} days ago:`, prevDateStr);
|
||||
return prevDateStr;
|
||||
}
|
||||
}
|
||||
|
||||
// Jika tidak ada data sama sekali, cari tanggal terakhir yang ada data dari API
|
||||
// Coba query langsung ke API untuk dapat list tanggal yang ada data
|
||||
// Untuk sementara, return tanggal terakhir yang diketahui ada data (2025-12-16)
|
||||
// Atau bisa return null dan biarkan user pilih manual
|
||||
console.log('[Dashboard] No data found in last 7 days');
|
||||
|
||||
// Cek tanggal 15 dan 14 juga (karena kita tahu ada data di sana)
|
||||
const knownDates = ['2025-12-16', '2025-12-15', '2025-12-14'];
|
||||
for (const knownDate of knownDates) {
|
||||
const knownData = await apiGetSummary({ date: knownDate });
|
||||
let knownSummary = knownData;
|
||||
if (knownData && typeof knownData === 'object' && 'data' in knownData && !('total_count' in knownData)) {
|
||||
knownSummary = knownData.data || {};
|
||||
}
|
||||
if (knownSummary && (knownSummary.total_count > 0 || knownSummary.total_amount > 0)) {
|
||||
console.log('[Dashboard] Found data in known dates, using:', knownDate);
|
||||
return knownDate;
|
||||
}
|
||||
}
|
||||
|
||||
// Default: tetap hari ini (meskipun tidak ada data)
|
||||
console.log('[Dashboard] No data found anywhere, using today:', today);
|
||||
return today;
|
||||
} catch (error) {
|
||||
console.error('[Dashboard] Error getting last available date:', error);
|
||||
// Fallback ke tanggal yang pasti ada data
|
||||
return '2025-12-16';
|
||||
}
|
||||
}
|
||||
|
||||
// Video HLS setup - mapping lokasi ke URL camera
|
||||
const LOCATION_CAMERAS = {
|
||||
'kerkof_01': {
|
||||
url: 'https://kerkof.btekno.cloud/cam1/index.m3u8',
|
||||
name: 'Kerkof'
|
||||
}
|
||||
};
|
||||
|
||||
let hls = null;
|
||||
let isVideoPlaying = false;
|
||||
let currentVideoUrl = null;
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat('id-ID').format(value || 0);
|
||||
}
|
||||
|
||||
function formatCurrency(value) {
|
||||
return 'Rp ' + new Intl.NumberFormat('id-ID').format(value || 0);
|
||||
}
|
||||
|
||||
function setTopbarDate() {
|
||||
const el = document.getElementById('topbar-date');
|
||||
if (!el) return;
|
||||
const d = new Date();
|
||||
el.textContent = d.toLocaleDateString('id-ID', {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
async function loadLocations() {
|
||||
const select = document.getElementById('filter-location');
|
||||
if (!select) return;
|
||||
select.innerHTML = '<option value="">Semua Lokasi</option>';
|
||||
|
||||
try {
|
||||
const data = await apiGetLocations({ limit: 100 }); // Ambil semua lokasi
|
||||
// Handle pagination response: { data: [...], total, page, limit }
|
||||
// atau langsung array: [...]
|
||||
let items = [];
|
||||
if (Array.isArray(data)) {
|
||||
items = data;
|
||||
} else if (data && Array.isArray(data.data)) {
|
||||
items = data.data;
|
||||
}
|
||||
|
||||
items.forEach(loc => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = loc.code || loc.location_code || '';
|
||||
opt.textContent = loc.name || loc.label || opt.value;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('loadLocations error', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGates() {
|
||||
const select = document.getElementById('filter-gate');
|
||||
if (!select) return;
|
||||
select.innerHTML = '<option value="">Semua Gate</option>';
|
||||
|
||||
if (!state.locationCode) return;
|
||||
|
||||
try {
|
||||
const data = await apiGetGates(state.locationCode, { limit: 100 }); // Ambil semua gate
|
||||
// Handle pagination response: { data: [...], total, page, limit }
|
||||
// atau langsung array: [...]
|
||||
let items = [];
|
||||
if (Array.isArray(data)) {
|
||||
items = data;
|
||||
} else if (data && Array.isArray(data.data)) {
|
||||
items = data.data;
|
||||
}
|
||||
|
||||
items.forEach(g => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = g.code || g.gate_code || '';
|
||||
opt.textContent = g.name || g.label || opt.value;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('loadGates error', err);
|
||||
}
|
||||
}
|
||||
|
||||
function renderSummary({ totalAmount, personCount, motorCount, carCount }) {
|
||||
const amountEl = document.getElementById('card-total-amount');
|
||||
const personEl = document.getElementById('card-person-count');
|
||||
const motorEl = document.getElementById('card-motor-count');
|
||||
const carEl = document.getElementById('card-car-count');
|
||||
|
||||
if (amountEl) amountEl.textContent = formatCurrency(totalAmount || 0);
|
||||
if (personEl) personEl.textContent = formatNumber(personCount || 0);
|
||||
if (motorEl) motorEl.textContent = formatNumber(motorCount || 0);
|
||||
if (carEl) carEl.textContent = formatNumber(carCount || 0);
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
const el = document.getElementById('summary-error');
|
||||
if (!el) return;
|
||||
el.textContent = message;
|
||||
el.classList.remove('hidden');
|
||||
setTimeout(() => {
|
||||
el.classList.add('hidden');
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
async function loadSummaryAndCharts() {
|
||||
const loadingOverlay = document.getElementById('summary-loading');
|
||||
if (loadingOverlay) loadingOverlay.classList.add('visible');
|
||||
|
||||
try {
|
||||
const [summaryResp, hourlyResp, byCategoryResp] = await Promise.all([
|
||||
apiGetSummary({
|
||||
date: state.date,
|
||||
locationCode: state.locationCode,
|
||||
gateCode: state.gateCode
|
||||
}),
|
||||
// pakai summary hourly untuk chart: data hari ini per jam
|
||||
apiGetSummaryHourly({
|
||||
date: state.date,
|
||||
location_code: state.locationCode,
|
||||
gate_code: state.gateCode
|
||||
}),
|
||||
apiGetByCategory({
|
||||
date: state.date,
|
||||
locationCode: state.locationCode,
|
||||
gateCode: state.gateCode
|
||||
})
|
||||
]);
|
||||
|
||||
// Kartu KPI: pakai total_count & total_amount dari summary endpoint
|
||||
// Struktur summaryResp setelah di-unwrap: { total_count, total_amount, active_gates, active_locations }
|
||||
console.log('[Dashboard] Summary response raw:', summaryResp);
|
||||
console.log('[Dashboard] By Category response raw:', byCategoryResp);
|
||||
console.log('[Dashboard] State date:', state.date);
|
||||
|
||||
// Handle jika response masih wrapped
|
||||
let summary = summaryResp || {};
|
||||
if (summaryResp && typeof summaryResp === 'object' && 'data' in summaryResp && !('total_count' in summaryResp)) {
|
||||
summary = summaryResp.data || {};
|
||||
}
|
||||
|
||||
const totalAmount = summary.total_amount || 0;
|
||||
console.log('[Dashboard] Parsed summary:', { totalAmount, summary });
|
||||
|
||||
// Hitung per kategori dari byCategoryResp
|
||||
let personCount = 0;
|
||||
let motorCount = 0;
|
||||
let carCount = 0;
|
||||
|
||||
if (byCategoryResp) {
|
||||
let categoryData = [];
|
||||
|
||||
// Handle jika response masih wrapped
|
||||
let byCategory = byCategoryResp;
|
||||
if (byCategoryResp && typeof byCategoryResp === 'object' && 'data' in byCategoryResp && !('labels' in byCategoryResp)) {
|
||||
byCategory = byCategoryResp.data || byCategoryResp;
|
||||
}
|
||||
|
||||
// Handle struktur response dengan data array (sesuai spec)
|
||||
if (Array.isArray(byCategory)) {
|
||||
categoryData = byCategory;
|
||||
}
|
||||
// Handle struktur response dengan labels & series (format chart)
|
||||
else if (byCategory.labels && byCategory.series) {
|
||||
const labels = byCategory.labels || [];
|
||||
const counts = byCategory.series.total_count || [];
|
||||
|
||||
labels.forEach((label, index) => {
|
||||
categoryData.push({
|
||||
category: label,
|
||||
total_count: counts[index] || 0
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[Dashboard] Category data parsed:', categoryData);
|
||||
|
||||
// Extract data per kategori
|
||||
categoryData.forEach(item => {
|
||||
const count = item.total_count || 0;
|
||||
|
||||
if (item.category === 'person_walk') {
|
||||
personCount = count;
|
||||
} else if (item.category === 'motor') {
|
||||
motorCount = count;
|
||||
} else if (item.category === 'car') {
|
||||
carCount = count;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[Dashboard] Final counts:', { personCount, motorCount, carCount, totalAmount });
|
||||
|
||||
console.log('[Dashboard] Summary processed:', {
|
||||
date: state.date,
|
||||
locationCode: state.locationCode,
|
||||
gateCode: state.gateCode,
|
||||
totalAmount,
|
||||
personCount,
|
||||
motorCount,
|
||||
carCount
|
||||
});
|
||||
|
||||
// Pastikan nilai tidak undefined
|
||||
const finalTotalAmount = totalAmount || 0;
|
||||
const finalPersonCount = personCount || 0;
|
||||
const finalMotorCount = motorCount || 0;
|
||||
const finalCarCount = carCount || 0;
|
||||
|
||||
console.log('[Dashboard] Rendering summary with values:', {
|
||||
totalAmount: finalTotalAmount,
|
||||
personCount: finalPersonCount,
|
||||
motorCount: finalMotorCount,
|
||||
carCount: finalCarCount
|
||||
});
|
||||
|
||||
renderSummary({
|
||||
totalAmount: finalTotalAmount,
|
||||
personCount: finalPersonCount,
|
||||
motorCount: finalMotorCount,
|
||||
carCount: finalCarCount
|
||||
});
|
||||
|
||||
// Daily chart data → tampilkan data hari ini per jam
|
||||
// Struktur response: { labels: ["00","01",...], series: { total_count: [...], total_amount: [...] } }
|
||||
// ATAU: { data: [{ hour: 0, total_count: 0, total_amount: 0 }, ...] }
|
||||
let labels = [];
|
||||
let totalCounts = [];
|
||||
let totalAmounts = [];
|
||||
|
||||
console.log('[Dashboard] Hourly response raw:', hourlyResp);
|
||||
|
||||
// Handle jika response masih wrapped
|
||||
let hourly = hourlyResp;
|
||||
if (hourlyResp && typeof hourlyResp === 'object' && 'data' in hourlyResp && !('labels' in hourlyResp)) {
|
||||
hourly = hourlyResp.data || hourlyResp;
|
||||
}
|
||||
|
||||
// Handle struktur response dengan labels & series
|
||||
if (hourly && hourly.labels && hourly.series) {
|
||||
labels = hourly.labels.map(h => String(h).padStart(2, '0') + ':00'); // "00" -> "00:00", "1" -> "01:00"
|
||||
totalCounts = hourly.series.total_count || [];
|
||||
totalAmounts = hourly.series.total_amount || [];
|
||||
console.log('[Dashboard] Hourly data from labels & series:', { labelsCount: labels.length, countsCount: totalCounts.length });
|
||||
}
|
||||
// Handle struktur response dengan data array (sesuai spec)
|
||||
else if (hourly && Array.isArray(hourly.data)) {
|
||||
// Generate 24 jam (0-23)
|
||||
labels = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`);
|
||||
totalCounts = Array(24).fill(0);
|
||||
totalAmounts = Array(24).fill(0);
|
||||
|
||||
// Map data dari response ke array per jam
|
||||
hourly.data.forEach(item => {
|
||||
const hour = item.hour || 0;
|
||||
if (hour >= 0 && hour < 24) {
|
||||
totalCounts[hour] = item.total_count || 0;
|
||||
totalAmounts[hour] = item.total_amount || 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
// Jika response kosong/null, tetap buat chart dengan data 0
|
||||
else {
|
||||
console.warn('[Dashboard] Hourly response tidak sesuai format, menggunakan data kosong');
|
||||
labels = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`);
|
||||
totalCounts = Array(24).fill(0);
|
||||
totalAmounts = Array(24).fill(0);
|
||||
}
|
||||
|
||||
console.log('[Dashboard] Hourly data processed:', {
|
||||
date: state.date,
|
||||
labels: labels.length,
|
||||
counts: totalCounts.length,
|
||||
amounts: totalAmounts.length,
|
||||
totalCount: totalCounts.reduce((a, b) => a + b, 0),
|
||||
totalAmount: totalAmounts.reduce((a, b) => a + b, 0)
|
||||
});
|
||||
|
||||
// Pastikan chart sudah di-init sebelum update
|
||||
const dailyCanvas = document.getElementById('daily-chart');
|
||||
const currentDailyChart = getDailyChart();
|
||||
if (dailyCanvas && !currentDailyChart) {
|
||||
console.log('[Dashboard] Daily chart belum di-init, initializing...');
|
||||
initDailyChart(dailyCanvas.getContext('2d'));
|
||||
}
|
||||
|
||||
// Selalu update chart, meskipun data kosong (agar user tahu memang tidak ada data)
|
||||
const dailyChartInstance = getDailyChart();
|
||||
if (dailyChartInstance) {
|
||||
updateDailyChart({
|
||||
labels,
|
||||
counts: totalCounts,
|
||||
amounts: totalAmounts
|
||||
});
|
||||
console.log('[Dashboard] Daily chart updated successfully');
|
||||
} else {
|
||||
console.error('[Dashboard] Daily chart tidak bisa di-update, chart belum di-init! Canvas:', dailyCanvas);
|
||||
}
|
||||
|
||||
// Category chart data → pakai total_count per kategori
|
||||
// Struktur response: { labels: ["person_walk","motor","car"], series: { total_count: [33,12,2], total_amount: [...] } }
|
||||
// ATAU: { data: [{ category: "person_walk", total_count: 33, total_amount: 66000 }, ...] }
|
||||
let catLabels = [];
|
||||
let catValues = [];
|
||||
|
||||
console.log('[Dashboard] Category response raw:', byCategoryResp);
|
||||
|
||||
// Handle jika response masih wrapped
|
||||
let byCategory = byCategoryResp;
|
||||
if (byCategoryResp && typeof byCategoryResp === 'object' && 'data' in byCategoryResp && !('labels' in byCategoryResp)) {
|
||||
byCategory = byCategoryResp.data || byCategoryResp;
|
||||
}
|
||||
|
||||
// Handle struktur response dengan labels & series
|
||||
if (byCategory && byCategory.labels && byCategory.series) {
|
||||
const categoryLabels = {
|
||||
'person_walk': 'Orang',
|
||||
'motor': 'Motor',
|
||||
'car': 'Mobil'
|
||||
};
|
||||
const rawLabels = byCategory.labels || [];
|
||||
catLabels = rawLabels.map(label => categoryLabels[label] || label);
|
||||
catValues = byCategory.series.total_count || [];
|
||||
console.log('[Dashboard] Category data from labels & series:', { catLabels, catValues });
|
||||
}
|
||||
// Handle struktur response dengan data array (sesuai spec)
|
||||
else if (byCategory && Array.isArray(byCategory.data)) {
|
||||
// Default categories sesuai spec
|
||||
const defaultCategories = ['person_walk', 'motor', 'car'];
|
||||
const categoryLabels = {
|
||||
'person_walk': 'Orang',
|
||||
'motor': 'Motor',
|
||||
'car': 'Mobil'
|
||||
};
|
||||
catLabels = defaultCategories.map(cat => categoryLabels[cat] || cat);
|
||||
catValues = defaultCategories.map(cat => {
|
||||
const item = byCategory.data.find(d => d.category === cat);
|
||||
return item ? (item.total_count || 0) : 0;
|
||||
});
|
||||
console.log('[Dashboard] Category data from array:', { catLabels, catValues });
|
||||
}
|
||||
// Jika response kosong/null, tetap buat chart dengan data 0
|
||||
else {
|
||||
console.warn('[Dashboard] Category response tidak sesuai format, menggunakan data kosong');
|
||||
catLabels = ['Orang', 'Motor', 'Mobil'];
|
||||
catValues = [0, 0, 0];
|
||||
}
|
||||
|
||||
console.log('[Dashboard] Category data processed:', {
|
||||
date: state.date,
|
||||
labels: catLabels,
|
||||
values: catValues,
|
||||
total: catValues.reduce((a, b) => a + b, 0)
|
||||
});
|
||||
|
||||
// Pastikan chart sudah di-init sebelum update
|
||||
const categoryCanvas = document.getElementById('category-chart');
|
||||
const categoryChartInstance = getCategoryChart();
|
||||
if (categoryCanvas && !categoryChartInstance) {
|
||||
console.log('[Dashboard] Initializing category chart...');
|
||||
initCategoryChart(categoryCanvas.getContext('2d'));
|
||||
}
|
||||
|
||||
// Selalu update chart, meskipun data kosong (agar user tahu memang tidak ada data)
|
||||
const currentCategoryChart = getCategoryChart();
|
||||
if (currentCategoryChart) {
|
||||
updateCategoryChart({
|
||||
labels: catLabels,
|
||||
values: catValues
|
||||
});
|
||||
console.log('[Dashboard] Category chart updated successfully');
|
||||
} else {
|
||||
console.error('[Dashboard] Category chart tidak bisa di-update, chart belum di-init!');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('loadSummaryAndCharts error', err);
|
||||
showError(err.message || 'Gagal memuat data dashboard');
|
||||
} finally {
|
||||
if (loadingOverlay) loadingOverlay.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
function getCameraForLocation(locationCode) {
|
||||
if (!locationCode) return null;
|
||||
return LOCATION_CAMERAS[locationCode] || null;
|
||||
}
|
||||
|
||||
function showVideoPanel(locationCode) {
|
||||
const videoSection = document.getElementById('video-section');
|
||||
const videoPanelTitle = document.getElementById('video-panel-title');
|
||||
const camera = getCameraForLocation(locationCode);
|
||||
|
||||
if (camera && videoSection && videoPanelTitle) {
|
||||
videoSection.style.display = 'block';
|
||||
videoPanelTitle.textContent = `Live Camera - ${camera.name}`;
|
||||
currentVideoUrl = camera.url;
|
||||
// Auto-stop video kalau lokasi berubah
|
||||
if (isVideoPlaying) {
|
||||
stopVideo();
|
||||
}
|
||||
} else {
|
||||
if (videoSection) videoSection.style.display = 'none';
|
||||
if (isVideoPlaying) {
|
||||
stopVideo();
|
||||
}
|
||||
currentVideoUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
function initVideo() {
|
||||
if (!currentVideoUrl || typeof Hls === 'undefined') {
|
||||
console.warn('[Dashboard] Video URL atau HLS.js tidak tersedia');
|
||||
return;
|
||||
}
|
||||
|
||||
const videoEl = document.getElementById('video-player');
|
||||
if (!videoEl) return;
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: true,
|
||||
backBufferLength: 90
|
||||
});
|
||||
hls.loadSource(currentVideoUrl);
|
||||
hls.attachMedia(videoEl);
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
console.log('[Dashboard] HLS manifest parsed');
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
console.error('[Dashboard] HLS error:', data);
|
||||
if (data.fatal) {
|
||||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
||||
console.log('[Dashboard] Network error, retrying...');
|
||||
hls.startLoad();
|
||||
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
||||
console.log('[Dashboard] Media error, recovering...');
|
||||
hls.recoverMediaError();
|
||||
} else {
|
||||
console.error('[Dashboard] Fatal error, destroying HLS');
|
||||
hls.destroy();
|
||||
hls = null;
|
||||
stopVideo();
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
// Native HLS support (Safari)
|
||||
videoEl.src = currentVideoUrl;
|
||||
} else {
|
||||
console.error('[Dashboard] HLS tidak didukung di browser ini');
|
||||
}
|
||||
}
|
||||
|
||||
function startVideo() {
|
||||
const videoEl = document.getElementById('video-player');
|
||||
const placeholderEl = document.getElementById('video-placeholder');
|
||||
const toggleBtn = document.getElementById('video-toggle');
|
||||
|
||||
if (!videoEl || !currentVideoUrl) {
|
||||
console.warn('[Dashboard] Video element atau URL tidak tersedia');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hls && typeof Hls !== 'undefined' && Hls.isSupported()) {
|
||||
initVideo();
|
||||
}
|
||||
|
||||
if (videoEl) {
|
||||
videoEl.style.display = 'block';
|
||||
if (placeholderEl) placeholderEl.style.display = 'none';
|
||||
}
|
||||
if (toggleBtn) toggleBtn.textContent = 'Matikan';
|
||||
isVideoPlaying = true;
|
||||
|
||||
if (videoEl && (videoEl.src || (hls && hls.media))) {
|
||||
videoEl.play().catch(e => console.error('[Dashboard] Play error:', e));
|
||||
}
|
||||
}
|
||||
|
||||
function stopVideo() {
|
||||
const videoEl = document.getElementById('video-player');
|
||||
const placeholderEl = document.getElementById('video-placeholder');
|
||||
const toggleBtn = document.getElementById('video-toggle');
|
||||
|
||||
if (hls) {
|
||||
hls.destroy();
|
||||
hls = null;
|
||||
}
|
||||
if (videoEl) {
|
||||
videoEl.pause();
|
||||
videoEl.src = '';
|
||||
videoEl.style.display = 'none';
|
||||
}
|
||||
if (placeholderEl) placeholderEl.style.display = 'flex';
|
||||
if (toggleBtn) toggleBtn.textContent = 'Hidupkan';
|
||||
isVideoPlaying = false;
|
||||
}
|
||||
|
||||
function setupFilters() {
|
||||
const dateInput = document.getElementById('filter-date');
|
||||
if (dateInput) {
|
||||
dateInput.value = state.date;
|
||||
dateInput.addEventListener('change', () => {
|
||||
state.date = dateInput.value || state.date;
|
||||
loadSummaryAndCharts();
|
||||
});
|
||||
}
|
||||
|
||||
const locationSelect = document.getElementById('filter-location');
|
||||
if (locationSelect) {
|
||||
locationSelect.addEventListener('change', async () => {
|
||||
state.locationCode = locationSelect.value;
|
||||
state.gateCode = '';
|
||||
const gateSelect = document.getElementById('filter-gate');
|
||||
if (gateSelect) gateSelect.value = '';
|
||||
|
||||
// Show/hide video panel berdasarkan lokasi
|
||||
showVideoPanel(state.locationCode);
|
||||
|
||||
await loadGates();
|
||||
loadSummaryAndCharts();
|
||||
});
|
||||
}
|
||||
|
||||
const gateSelect = document.getElementById('filter-gate');
|
||||
if (gateSelect) {
|
||||
gateSelect.addEventListener('change', () => {
|
||||
state.gateCode = gateSelect.value;
|
||||
loadSummaryAndCharts();
|
||||
});
|
||||
}
|
||||
|
||||
// Setup video toggle button
|
||||
const toggleBtn = document.getElementById('video-toggle');
|
||||
if (toggleBtn) {
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
if (isVideoPlaying) {
|
||||
stopVideo();
|
||||
} else {
|
||||
startVideo();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function initCharts() {
|
||||
console.log('[Dashboard] Initializing charts...');
|
||||
const dailyCanvas = document.getElementById('daily-chart');
|
||||
const categoryCanvas = document.getElementById('category-chart');
|
||||
|
||||
if (dailyCanvas) {
|
||||
console.log('[Dashboard] Found daily chart canvas, initializing...');
|
||||
initDailyChart(dailyCanvas.getContext('2d'));
|
||||
console.log('[Dashboard] Daily chart initialized:', getDailyChart() !== null);
|
||||
} else {
|
||||
console.error('[Dashboard] Daily chart canvas not found!');
|
||||
}
|
||||
|
||||
if (categoryCanvas) {
|
||||
console.log('[Dashboard] Found category chart canvas, initializing...');
|
||||
initCategoryChart(categoryCanvas.getContext('2d'));
|
||||
console.log('[Dashboard] Category chart initialized:', getCategoryChart() !== null);
|
||||
} else {
|
||||
console.error('[Dashboard] Category chart canvas not found!');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Require auth
|
||||
if (!Auth.isAuthenticated()) {
|
||||
window.location.href = '../index.php';
|
||||
return;
|
||||
}
|
||||
|
||||
// Set default date ke hari ini (jangan auto-detect ke tanggal lama)
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
state.date = today;
|
||||
const dateInput = document.getElementById('filter-date');
|
||||
if (dateInput) {
|
||||
dateInput.value = state.date;
|
||||
console.log('[Dashboard] Default date set to today:', state.date);
|
||||
}
|
||||
|
||||
setTopbarDate();
|
||||
initCharts();
|
||||
setupFilters();
|
||||
await loadLocations();
|
||||
await loadGates();
|
||||
|
||||
// Cek lokasi awal untuk show/hide video
|
||||
showVideoPanel(state.locationCode);
|
||||
|
||||
await loadSummaryAndCharts();
|
||||
});
|
||||
|
||||
|
||||
160
public/dashboard/js/realtime.js
Normal file
160
public/dashboard/js/realtime.js
Normal file
@@ -0,0 +1,160 @@
|
||||
// public/dashboard/js/realtime.js
|
||||
// Realtime dashboard (SSE + fallback snapshot)
|
||||
|
||||
import { apiGetRealtimeSnapshot } from './api.js';
|
||||
import { API_CONFIG } from './config.js';
|
||||
|
||||
const REALTIME_STREAM_URL = `${API_CONFIG.BASE_URL}/retribusi/v1/realtime/stream`;
|
||||
|
||||
class RealtimeManager {
|
||||
constructor() {
|
||||
this.eventSource = null;
|
||||
this.snapshotTimer = null;
|
||||
this.isConnected = false;
|
||||
}
|
||||
|
||||
init() {
|
||||
// Mulai SSE, kalau gagal pakai fallback polling snapshot
|
||||
this.startSSE();
|
||||
this.startSnapshotFallback();
|
||||
}
|
||||
|
||||
startSSE() {
|
||||
try {
|
||||
const token = localStorage.getItem('token') || '';
|
||||
// SSE tidak support custom headers, jadi token harus di query string
|
||||
// TAPI sesuai spec, parameter yang benar adalah 'last_id', bukan 'token'
|
||||
// Token tetap dikirim via Authorization header jika backend support
|
||||
// Untuk SSE, kita pakai query string karena EventSource tidak support custom headers
|
||||
const params = new URLSearchParams();
|
||||
// Jika ada last_id dari state, tambahkan
|
||||
// params.append('last_id', this.lastEventId || '');
|
||||
// Token tetap di query string untuk SSE (limitation EventSource)
|
||||
if (token) {
|
||||
// Backend harus handle token dari query string atau implement custom SSE handler
|
||||
// Untuk sekarang, kita tetap pakai query string karena EventSource limitation
|
||||
params.append('token', token);
|
||||
}
|
||||
const url = `${REALTIME_STREAM_URL}?${params.toString()}`;
|
||||
|
||||
console.log('[Realtime] Connect SSE:', url);
|
||||
|
||||
// EventSource tidak support custom headers, jadi token harus di query string
|
||||
// Backend harus handle ini atau implement custom SSE handler dengan fetch + stream
|
||||
this.eventSource = new EventSource(url);
|
||||
|
||||
this.eventSource.onopen = () => {
|
||||
console.log('[Realtime] SSE opened');
|
||||
this.isConnected = true;
|
||||
};
|
||||
|
||||
this.eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('[Realtime] SSE event:', data);
|
||||
|
||||
// Backend kirim realtime_events, bisa berupa:
|
||||
// - event baru (entry baru)
|
||||
// - snapshot agregat (total_count_today, by_category, dll)
|
||||
// Sesuaikan dengan struktur yang backend kirim via SSE
|
||||
|
||||
// Jika backend kirim snapshot via SSE, parse sama seperti fetchSnapshot()
|
||||
if (data.total_count_today !== undefined || data.by_category) {
|
||||
const personCat = (data.by_category || []).find(c => c.category === 'person_walk') || { total_count: 0 };
|
||||
const motorCat = (data.by_category || []).find(c => c.category === 'motor') || { total_count: 0 };
|
||||
const carCat = (data.by_category || []).find(c => c.category === 'car') || { total_count: 0 };
|
||||
|
||||
const kpiData = {
|
||||
totalPeople: personCat.total_count || 0,
|
||||
totalVehicles: (motorCat.total_count || 0) + (carCat.total_count || 0),
|
||||
totalCount: data.total_count_today || 0,
|
||||
totalAmount: data.total_amount_today || 0
|
||||
};
|
||||
|
||||
window.dispatchEvent(new CustomEvent('realtime:snapshot', { detail: kpiData }));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Realtime] gagal parse event data', e, event.data);
|
||||
}
|
||||
};
|
||||
|
||||
this.eventSource.onerror = (err) => {
|
||||
console.error('[Realtime] SSE error', err);
|
||||
this.isConnected = false;
|
||||
// Biarkan browser auto-reconnect, kalau tetap gagal nanti fallback snapshot yang jalan
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('[Realtime] tidak bisa inisialisasi SSE', e);
|
||||
this.isConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchSnapshot() {
|
||||
try {
|
||||
// Struktur response setelah di-unwrap: { total_count_today, total_amount_today, by_gate, by_category }
|
||||
const snapshot = await apiGetRealtimeSnapshot({
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
location_code: '' // bisa diambil dari state dashboard jika perlu
|
||||
});
|
||||
|
||||
console.log('[Realtime] snapshot:', snapshot);
|
||||
|
||||
// Parse data snapshot sesuai struktur resmi
|
||||
const parsed = {
|
||||
totalCount: snapshot.total_count_today || 0,
|
||||
totalAmount: snapshot.total_amount_today || 0,
|
||||
byGate: Array.isArray(snapshot.by_gate) ? snapshot.by_gate : [],
|
||||
byCategory: Array.isArray(snapshot.by_category) ? snapshot.by_category : []
|
||||
};
|
||||
|
||||
// Hitung total orang & kendaraan dari by_category
|
||||
const personCat = parsed.byCategory.find(c => c.category === 'person_walk') || { total_count: 0 };
|
||||
const motorCat = parsed.byCategory.find(c => c.category === 'motor') || { total_count: 0 };
|
||||
const carCat = parsed.byCategory.find(c => c.category === 'car') || { total_count: 0 };
|
||||
|
||||
const kpiData = {
|
||||
totalPeople: personCat.total_count || 0,
|
||||
totalVehicles: (motorCat.total_count || 0) + (carCat.total_count || 0),
|
||||
totalCount: parsed.totalCount,
|
||||
totalAmount: parsed.totalAmount
|
||||
};
|
||||
|
||||
// Dispatch event untuk update dashboard real-time
|
||||
window.dispatchEvent(new CustomEvent('realtime:snapshot', { detail: kpiData }));
|
||||
|
||||
} catch (e) {
|
||||
console.error('[Realtime] gagal ambil snapshot', e);
|
||||
}
|
||||
}
|
||||
|
||||
startSnapshotFallback() {
|
||||
// Polling ringan tiap 5 detik, hanya kalau SSE belum stable
|
||||
if (this.snapshotTimer) clearInterval(this.snapshotTimer);
|
||||
this.snapshotTimer = setInterval(() => {
|
||||
if (!this.isConnected) {
|
||||
this.fetchSnapshot();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.eventSource) {
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
}
|
||||
if (this.snapshotTimer) {
|
||||
clearInterval(this.snapshotTimer);
|
||||
this.snapshotTimer = null;
|
||||
}
|
||||
this.isConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
export const Realtime = new RealtimeManager();
|
||||
|
||||
// Auto-init saat dashboard dibuka
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
Realtime.init();
|
||||
});
|
||||
|
||||
|
||||
1262
public/dashboard/settings.html
Normal file
1262
public/dashboard/settings.html
Normal file
File diff suppressed because it is too large
Load Diff
5
public/index.php
Normal file
5
public/index.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
// Redirect ke login page di root
|
||||
header('Location: ../index.php');
|
||||
exit;
|
||||
|
||||
Reference in New Issue
Block a user