From b3573ed390ea3382d6b432d5eac8b504050a6ae7 Mon Sep 17 00:00:00 2001 From: mwpn Date: Thu, 18 Dec 2025 11:21:40 +0700 Subject: [PATCH] Initial commit: Retribusi frontend dengan dashboard, event logs, dan settings --- API_JSON.md | 1813 +++++++++++++++++++++++ DASHBOARD_DEBUG.md | 111 ++ README.md | 60 + api/CORS_SETUP_GUIDE.md | 174 +++ api/INSTALASI_CORS.md | 84 ++ api/README_CORS_FIX.md | 118 ++ api/auth/login.php | 44 + api/auth/login.php.example | 45 + api/cors-handler.php | 30 + api/cors_handler.php | 21 + api/dashboard/chart.php | 43 + api/dashboard/chart.php.example | 20 + api/dashboard/chart_monthly.php | 45 + api/dashboard/chart_monthly.php.example | 20 + api/dashboard/events.php | 49 + api/dashboard/events.php.example | 20 + api/dashboard/summary.php | 44 + api/dashboard/summary.php.example | 42 + api/example-endpoint-with-cors.php | 76 + index.php | 110 ++ proxy/package.json | 22 + proxy/rtsp-websocket-proxy.js | 151 ++ proxy/start-proxy.bat | 39 + public/dashboard/css/app.css | 434 ++++++ public/dashboard/dashboard.html | 183 +++ public/dashboard/event.html | 847 +++++++++++ public/dashboard/js/README_CONFIG.md | 80 + public/dashboard/js/api.js | 176 +++ public/dashboard/js/auth.js | 86 ++ public/dashboard/js/charts.js | 193 +++ public/dashboard/js/config.js | 38 + public/dashboard/js/dashboard.js | 723 +++++++++ public/dashboard/js/realtime.js | 160 ++ public/dashboard/settings.html | 1262 ++++++++++++++++ public/index.php | 5 + 35 files changed, 7368 insertions(+) create mode 100644 API_JSON.md create mode 100644 DASHBOARD_DEBUG.md create mode 100644 README.md create mode 100644 api/CORS_SETUP_GUIDE.md create mode 100644 api/INSTALASI_CORS.md create mode 100644 api/README_CORS_FIX.md create mode 100644 api/auth/login.php create mode 100644 api/auth/login.php.example create mode 100644 api/cors-handler.php create mode 100644 api/cors_handler.php create mode 100644 api/dashboard/chart.php create mode 100644 api/dashboard/chart.php.example create mode 100644 api/dashboard/chart_monthly.php create mode 100644 api/dashboard/chart_monthly.php.example create mode 100644 api/dashboard/events.php create mode 100644 api/dashboard/events.php.example create mode 100644 api/dashboard/summary.php create mode 100644 api/dashboard/summary.php.example create mode 100644 api/example-endpoint-with-cors.php create mode 100644 index.php create mode 100644 proxy/package.json create mode 100644 proxy/rtsp-websocket-proxy.js create mode 100644 proxy/start-proxy.bat create mode 100644 public/dashboard/css/app.css create mode 100644 public/dashboard/dashboard.html create mode 100644 public/dashboard/event.html create mode 100644 public/dashboard/js/README_CONFIG.md create mode 100644 public/dashboard/js/api.js create mode 100644 public/dashboard/js/auth.js create mode 100644 public/dashboard/js/charts.js create mode 100644 public/dashboard/js/config.js create mode 100644 public/dashboard/js/dashboard.js create mode 100644 public/dashboard/js/realtime.js create mode 100644 public/dashboard/settings.html create mode 100644 public/index.php diff --git a/API_JSON.md b/API_JSON.md new file mode 100644 index 0000000..1442106 --- /dev/null +++ b/API_JSON.md @@ -0,0 +1,1813 @@ +{ +"openapi": "3.0.0", +"info": { +"title": "API Retribusi", +"description": "API Retribusi BAPENDA Kabupaten Garut untuk monitoring Retribusi", +"version": "1.0.0", +"contact": { +"name": "BTekno Development Team" +} +}, +"servers": [ +{ +"url": "https://api.btekno.cloud", +"description": "Production Server" +}, +{ +"url": "http://localhost", +"description": "Local Development" +} +], +"tags": [ +{ +"name": "Health", +"description": "Health check endpoint" +}, +{ +"name": "Authentication", +"description": "JWT authentication" +}, +{ +"name": "Ingest", +"description": "Event ingestion (mesin YOLO)" +}, +{ +"name": "Frontend", +"description": "Frontend CRUD operations" +}, +{ +"name": "Summary", +"description": "Data summary & aggregation" +}, +{ +"name": "Dashboard", +"description": "Dashboard visualization data" +}, +{ +"name": "Realtime", +"description": "Real-time events (SSE)" +} +], +"components": { +"securitySchemes": { +"BearerAuth": { +"type": "http", +"scheme": "bearer", +"bearerFormat": "JWT", +"description": "JWT token untuk frontend API" +}, +"ApiKeyAuth": { +"type": "apiKey", +"in": "header", +"name": "X-API-KEY", +"description": "API Key untuk ingest endpoint" +} +}, +"schemas": { +"Error": { +"type": "object", +"properties": { +"error": { +"type": "string", +"description": "Error code" +}, +"message": { +"type": "string", +"description": "Error message" +}, +"fields": { +"type": "object", +"description": "Validation errors (optional)" +} +} +}, +"Success": { +"type": "object", +"properties": { +"success": { +"type": "boolean", +"example": true +}, +"data": { +"type": "object" +}, +"timestamp": { +"type": "integer", +"description": "Unix timestamp" +} +} +} +} +}, +"paths": { +"/health": { +"get": { +"tags": ["Health"], +"summary": "Health check", +"description": "Check API health status", +"responses": { +"200": { +"description": "API is healthy", +"content": { +"application/json": { +"schema": { +"type": "object", +"properties": { +"status": { +"type": "string", +"example": "ok" +}, +"time": { +"type": "integer", +"example": 1735123456 +} +} +} +} +} +} +} +} +}, +"/auth/v1/login": { +"post": { +"tags": ["Authentication"], +"summary": "Login", +"description": "Authenticate user dan dapatkan JWT token", +"requestBody": { +"required": true, +"content": { +"application/json": { +"schema": { +"type": "object", +"required": ["username", "password"], +"properties": { +"username": { +"type": "string", +"example": "admin" +}, +"password": { +"type": "string", +"format": "password", +"example": "password123" +} +} +} +} +} +}, +"responses": { +"200": { +"description": "Login successful", +"content": { +"application/json": { +"schema": { +"allOf": [ +{"$ref": "#/components/schemas/Success"}, +{ +"type": "object", +"properties": { +"data": { +"type": "object", +"properties": { +"token": { +"type": "string" +}, +"expires_in": { +"type": "integer" +}, +"user": { +"type": "object", +"properties": { +"id": {"type": "integer"}, +"username": {"type": "string"}, +"role": {"type": "string"} +} +} +} +} +} +} +] +} +} +} +}, +"401": { +"description": "Unauthorized", +"content": { +"application/json": { +"schema": { +"$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden (user inactive)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" +} +} +} +} +} +} +}, +"/retribusi/v1/ingest": { +"post": { +"tags": ["Ingest"], +"summary": "Ingest event data", +"description": "Masukkan event data dari mesin YOLO", +"security": [ +{ +"ApiKeyAuth": [] +} +], +"requestBody": { +"required": true, +"content": { +"application/json": { +"schema": { +"type": "object", +"required": ["timestamp", "location_code", "gate_code", "category"], +"properties": { +"timestamp": { +"type": "integer", +"description": "Unix timestamp", +"example": 1735123456 +}, +"location_code": { +"type": "string", +"example": "kerkof_01" +}, +"gate_code": { +"type": "string", +"example": "gate01" +}, +"category": { +"type": "string", +"example": "motor" +} +} +} +} +} +}, +"responses": { +"200": { +"description": "Event stored successfully", +"content": { +"application/json": { +"schema": { +"$ref": "#/components/schemas/Success" + } + } + } + }, + "401": { + "description": "Unauthorized (invalid API key)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" +} +} +} +}, +"404": { +"description": "Not found (location/gate/tariff not found)", +"content": { +"application/json": { +"schema": { +"$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" +} +} +} +} +} +} +}, +"/retribusi/v1/frontend/locations": { +"get": { +"tags": ["Frontend"], +"summary": "Get locations list", +"description": "Mendapatkan daftar lokasi dengan pagination", +"security": [ +{ +"BearerAuth": [] +} +], +"parameters": [ +{ +"name": "page", +"in": "query", +"schema": { +"type": "integer", +"default": 1, +"minimum": 1 +}, +"description": "Page number" +}, +{ +"name": "limit", +"in": "query", +"schema": { +"type": "integer", +"default": 20, +"minimum": 1, +"maximum": 100 +}, +"description": "Items per page" +} +], +"responses": { +"200": { +"description": "Success", +"content": { +"application/json": { +"schema": { +"$ref": "#/components/schemas/Success" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" +} +} +} +} +} +}, +"post": { +"tags": ["Frontend"], +"summary": "Create location", +"description": "Membuat lokasi baru (operator/admin only)", +"security": [ +{ +"BearerAuth": [] +} +], +"requestBody": { +"required": true, +"content": { +"application/json": { +"schema": { +"type": "object", +"required": ["code", "name", "type", "is_active"], +"properties": { +"code": { +"type": "string", +"example": "kerkof_01" +}, +"name": { +"type": "string", +"example": "Kerkof Garut" +}, +"type": { +"type": "string", +"example": "parkir" +}, +"is_active": { +"type": "integer", +"enum": [0, 1], +"example": 1 +} +} +} +} +} +}, +"responses": { +"201": { +"description": "Location created", +"content": { +"application/json": { +"schema": { +"$ref": "#/components/schemas/Success" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" +} +} +} +}, +"403": { +"description": "Forbidden (insufficient permissions)", +"content": { +"application/json": { +"schema": { +"$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflict (code already exists)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" +} +} +} +} +} +} +}, +"/retribusi/v1/summary/daily": { +"get": { +"tags": ["Summary"], +"summary": "Get daily summary", +"description": "Mendapatkan rekap harian", +"security": [ +{ +"BearerAuth": [] +} +], +"parameters": [ +{ +"name": "date", +"in": "query", +"required": true, +"schema": { +"type": "string", +"format": "date", +"example": "2025-01-01" +}, +"description": "Date (Y-m-d format)" +}, +{ +"name": "location_code", +"in": "query", +"schema": { +"type": "string" +} +}, +{ +"name": "gate_code", +"in": "query", +"schema": { +"type": "string" +} +} +], +"responses": { +"200": { +"description": "Success", +"content": { +"application/json": { +"schema": { +"$ref": "#/components/schemas/Success" + } + } + } + } + } + } + }, + "/retribusi/v1/summary/hourly": { + "get": { + "tags": ["Summary"], + "summary": "Get hourly summary", + "description": "Mendapatkan rekap per jam untuk chart (24 jam)", + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "date", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date", + "example": "2025-01-01" + } + }, + { + "name": "location_code", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "gate_code", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Success" +} +} +} +} +} +} +}, +"/retribusi/v1/dashboard/daily": { +"get": { +"tags": ["Dashboard"], +"summary": "Get daily chart data", +"description": "Data untuk line chart harian", +"security": [ +{ +"BearerAuth": [] +} +], +"parameters": [ +{ +"name": "start_date", +"in": "query", +"required": true, +"schema": { +"type": "string", +"format": "date" +} +}, +{ +"name": "end_date", +"in": "query", +"required": true, +"schema": { +"type": "string", +"format": "date" +} +}, +{ +"name": "location_code", +"in": "query", +"schema": { +"type": "string" +} +}, +{ +"name": "gate_code", +"in": "query", +"schema": { +"type": "string" +} +} +], +"responses": { +"200": { +"description": "Success", +"content": { +"application/json": { +"schema": { +"$ref": "#/components/schemas/Success" + } + } + } + } + } + } + }, + "/retribusi/v1/dashboard/by-category": { + "get": { + "tags": ["Dashboard"], + "summary": "Get by category chart data", + "description": "Data chart grouped by category (bar/donut chart)", + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "date", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "location_code", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "gate_code", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Success" +} +} +} +} +} +} +}, +"/retribusi/v1/dashboard/summary": { +"get": { +"tags": ["Dashboard"], +"summary": "Get summary statistics", +"description": "Mendapatkan summary statistics (stat cards)", +"security": [ +{ +"BearerAuth": [] +} +], +"parameters": [ +{ +"name": "date", +"in": "query", +"required": true, +"schema": { +"type": "string", +"format": "date" +} +}, +{ +"name": "location_code", +"in": "query", +"schema": { +"type": "string" +} +} +], +"responses": { +"200": { +"description": "Success", +"content": { +"application/json": { +"schema": { +"$ref": "#/components/schemas/Success" + } + } + } + } + } + } + }, + "/retribusi/v1/realtime/stream": { + "get": { + "tags": ["Realtime"], + "summary": "SSE Stream", + "description": "Server-Sent Events stream untuk real-time updates", + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "last_id", + "in": "query", + "schema": { + "type": "integer" + }, + "description": "Last event ID received" + }, + { + "name": "location_code", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "SSE stream", + "content": { + "text/event-stream": { + "schema": { + "type": "string" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" +} +} +} +} +} +} +}, +"/retribusi/v1/frontend/locations/{code}": { +"get": { +"tags": ["Frontend"], +"summary": "Get location detail", +"description": "Mendapatkan detail location berdasarkan code", +"security": [ +{ +"BearerAuth": [] +} +], +"parameters": [ +{ +"name": "code", +"in": "path", +"required": true, +"schema": { +"type": "string" +}, +"description": "Location code" +} +], +"responses": { +"200": { +"description": "Success", +"content": { +"application/json": { +"schema": { +"$ref": "#/components/schemas/Success" + } + } + } + }, + "404": { + "description": "Location not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" +} +} +} +} +} +}, +"put": { +"tags": ["Frontend"], +"summary": "Update location", +"description": "Update location (code tidak bisa diubah)", +"security": [ +{ +"BearerAuth": [] +} +], +"parameters": [ +{ +"name": "code", +"in": "path", +"required": true, +"schema": { +"type": "string" +} +} +], +"requestBody": { +"required": true, +"content": { +"application/json": { +"schema": { +"type": "object", +"properties": { +"name": { +"type": "string" +}, +"type": { +"type": "string" +}, +"is_active": { +"type": "integer", +"enum": [0, 1] +} +} +} +} +} +}, +"responses": { +"200": { +"description": "Location updated", +"content": { +"application/json": { +"schema": { +"$ref": "#/components/schemas/Success" + } + } + } + }, + "404": { + "description": "Location not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" +} +} +} +} +} +}, +"delete": { +"tags": ["Frontend"], +"summary": "Delete location", +"description": "Soft delete location (admin only)", +"security": [ +{ +"BearerAuth": [] +} +], +"parameters": [ +{ +"name": "code", +"in": "path", +"required": true, +"schema": { +"type": "string" +} +} +], +"responses": { +"200": { +"description": "Location deleted", +"content": { +"application/json": { +"schema": { +"$ref": "#/components/schemas/Success" + } + } + } + }, + "403": { + "description": "Forbidden (admin only)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" +} +} +} +} +} +} +}, +"/retribusi/v1/frontend/gates": { +"get": { +"tags": ["Frontend"], +"summary": "Get gates list", +"description": "Mendapatkan daftar gates dengan pagination", +"security": [ +{ +"BearerAuth": [] +} +], +"parameters": [ +{ +"name": "page", +"in": "query", +"schema": { +"type": "integer", +"default": 1, +"minimum": 1 +} +}, +{ +"name": "limit", +"in": "query", +"schema": { +"type": "integer", +"default": 20, +"minimum": 1, +"maximum": 100 +} +}, +{ +"name": "location_code", +"in": "query", +"schema": { +"type": "string" +} +} +], +"responses": { +"200": { +"description": "Success", +"content": { +"application/json": { +"schema": { +"$ref": "#/components/schemas/Success" + } + } + } + } + } + }, + "post": { + "tags": ["Frontend"], + "summary": "Create gate", + "description": "Membuat gate baru (operator/admin only)", + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["location_code", "gate_code", "name", "direction", "is_active"], + "properties": { + "location_code": { + "type": "string", + "example": "kerkof_01" + }, + "gate_code": { + "type": "string", + "example": "gate01" + }, + "name": { + "type": "string", + "example": "Gate 01" + }, + "direction": { + "type": "string", + "enum": ["in", "out"], + "example": "in" + }, + "camera": { + "type": "string", + "description": "Camera URL (HLS, RTSP, HTTP) atau camera ID", + "example": "https://example.com/stream.m3u8", + "maxLength": 500 + }, + "is_active": { + "type": "integer", + "enum": [0, 1], + "example": 1 + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Gate created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Success" +} +} +} +}, +"409": { +"description": "Conflict (gate already exists)", +"content": { +"application/json": { +"schema": { +"$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/retribusi/v1/frontend/gates/{location_code}/{gate_code}": { + "get": { + "tags": ["Frontend"], + "summary": "Get gate detail", + "description": "Mendapatkan detail gate berdasarkan location_code dan gate_code", + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "location_code", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "gate_code", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Success" +} +} +} +}, +"404": { +"description": "Gate not found", +"content": { +"application/json": { +"schema": { +"$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "put": { + "tags": ["Frontend"], + "summary": "Update gate", + "description": "Update gate (location_code dan gate_code tidak bisa diubah)", + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "location_code", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "gate_code", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "direction": { + "type": "string", + "enum": ["in", "out"] + }, + "camera": { + "type": "string", + "description": "Camera URL (HLS, RTSP, HTTP) atau camera ID", + "maxLength": 500 + }, + "is_active": { + "type": "integer", + "enum": [0, 1] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Gate updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Success" +} +} +} +}, +"404": { +"description": "Gate not found", +"content": { +"application/json": { +"schema": { +"$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "tags": ["Frontend"], + "summary": "Delete gate", + "description": "Soft delete gate (admin only)", + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "location_code", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "gate_code", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Gate deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Success" +} +} +} +} +} +} +}, +"/retribusi/v1/frontend/tariffs": { +"get": { +"tags": ["Frontend"], +"summary": "Get tariffs list", +"description": "Mendapatkan daftar tariffs dengan pagination", +"security": [ +{ +"BearerAuth": [] +} +], +"parameters": [ +{ +"name": "page", +"in": "query", +"schema": { +"type": "integer", +"default": 1, +"minimum": 1 +} +}, +{ +"name": "limit", +"in": "query", +"schema": { +"type": "integer", +"default": 20, +"minimum": 1, +"maximum": 100 +} +}, +{ +"name": "location_code", +"in": "query", +"schema": { +"type": "string" +} +}, +{ +"name": "gate_code", +"in": "query", +"schema": { +"type": "string" +} +} +], +"responses": { +"200": { +"description": "Success", +"content": { +"application/json": { +"schema": { +"$ref": "#/components/schemas/Success" + } + } + } + } + } + }, + "post": { + "tags": ["Frontend"], + "summary": "Create tariff", + "description": "Membuat tariff baru (operator/admin only)", + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["location_code", "gate_code", "category", "price"], + "properties": { + "location_code": { + "type": "string" + }, + "gate_code": { + "type": "string" + }, + "category": { + "type": "string", + "enum": ["person_walk", "motor", "car"] + }, + "price": { + "type": "integer", + "minimum": 0 + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Tariff created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Success" +} +} +} +} +} +} +}, +"/retribusi/v1/frontend/tariffs/{location_code}/{gate_code}/{category}": { +"get": { +"tags": ["Frontend"], +"summary": "Get tariff detail", +"description": "Mendapatkan detail tariff berdasarkan location_code, gate_code, dan category", +"security": [ +{ +"BearerAuth": [] +} +], +"parameters": [ +{ +"name": "location_code", +"in": "path", +"required": true, +"schema": { +"type": "string" +} +}, +{ +"name": "gate_code", +"in": "path", +"required": true, +"schema": { +"type": "string" +} +}, +{ +"name": "category", +"in": "path", +"required": true, +"schema": { +"type": "string" +} +} +], +"responses": { +"200": { +"description": "Success", +"content": { +"application/json": { +"schema": { +"$ref": "#/components/schemas/Success" + } + } + } + }, + "404": { + "description": "Tariff not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" +} +} +} +} +} +}, +"put": { +"tags": ["Frontend"], +"summary": "Update tariff", +"description": "Update tariff price (operator/admin only)", +"security": [ +{ +"BearerAuth": [] +} +], +"parameters": [ +{ +"name": "location_code", +"in": "path", +"required": true, +"schema": { +"type": "string" +} +}, +{ +"name": "gate_code", +"in": "path", +"required": true, +"schema": { +"type": "string" +} +}, +{ +"name": "category", +"in": "path", +"required": true, +"schema": { +"type": "string" +} +} +], +"requestBody": { +"required": true, +"content": { +"application/json": { +"schema": { +"type": "object", +"required": ["price"], +"properties": { +"price": { +"type": "integer", +"minimum": 0 +} +} +} +} +} +}, +"responses": { +"200": { +"description": "Tariff updated", +"content": { +"application/json": { +"schema": { +"$ref": "#/components/schemas/Success" + } + } + } + } + } + }, + "delete": { + "tags": ["Frontend"], + "summary": "Delete tariff", + "description": "Hard delete tariff (admin only)", + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "location_code", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "gate_code", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "category", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Tariff deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Success" +} +} +} +} +} +} +}, +"/retribusi/v1/frontend/audit-logs": { +"get": { +"tags": ["Frontend"], +"summary": "Get audit logs", +"description": "Mendapatkan audit trail history dengan pagination dan filter", +"security": [ +{ +"BearerAuth": [] +} +], +"parameters": [ +{ +"name": "page", +"in": "query", +"schema": { +"type": "integer", +"default": 1, +"minimum": 1 +} +}, +{ +"name": "limit", +"in": "query", +"schema": { +"type": "integer", +"default": 20, +"minimum": 1, +"maximum": 100 +} +}, +{ +"name": "entity", +"in": "query", +"schema": { +"type": "string", +"enum": ["locations", "gates", "tariffs"] +} +}, +{ +"name": "action", +"in": "query", +"schema": { +"type": "string", +"enum": ["create", "update", "delete"] +} +}, +{ +"name": "entity_key", +"in": "query", +"schema": { +"type": "string" +} +}, +{ +"name": "start_date", +"in": "query", +"schema": { +"type": "string", +"format": "date" +} +}, +{ +"name": "end_date", +"in": "query", +"schema": { +"type": "string", +"format": "date" +} +} +], +"responses": { +"200": { +"description": "Success", +"content": { +"application/json": { +"schema": { +"$ref": "#/components/schemas/Success" + } + } + } + } + } + } + }, + "/retribusi/v1/frontend/entry-events": { + "get": { + "tags": ["Frontend"], + "summary": "Get entry events", + "description": "Mendapatkan daftar raw entry events (data mentah dari mesin)", + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "default": 1, + "minimum": 1 + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "default": 20, + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "location_code", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "gate_code", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "category", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "start_date", + "in": "query", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "end_date", + "in": "query", + "schema": { + "type": "string", + "format": "date" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Success" +} +} +} +} +} +} +}, +"/retribusi/v1/frontend/streams": { +"get": { +"tags": ["Frontend"], +"summary": "Get streams list", +"description": "Get list of streams (alias untuk gates)", +"security": [ +{ +"BearerAuth": [] +} +], +"parameters": [ +{ +"name": "page", +"in": "query", +"schema": { +"type": "integer", +"default": 1, +"minimum": 1 +} +}, +{ +"name": "limit", +"in": "query", +"schema": { +"type": "integer", +"default": 20, +"minimum": 1, +"maximum": 100 +} +}, +{ +"name": "location_code", +"in": "query", +"schema": { +"type": "string" +} +} +], +"responses": { +"200": { +"description": "Success", +"content": { +"application/json": { +"schema": { +"$ref": "#/components/schemas/Success" + } + } + } + } + } + } + }, + "/retribusi/v1/realtime/snapshot": { + "get": { + "tags": ["Realtime"], + "summary": "Get snapshot", + "description": "Mendapatkan snapshot data untuk dashboard cards", + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "date", + "in": "query", + "schema": { + "type": "string", + "format": "date" + }, + "description": "Date (default: today)" + }, + { + "name": "location_code", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Success" +} +} +} +} +} +} +}, +"/retribusi/v1/realtime/events": { +"get": { +"tags": ["Realtime"], +"summary": "Get realtime events list", +"description": "Mendapatkan daftar realtime events (history untuk SSE)", +"security": [ +{ +"BearerAuth": [] +} +], +"parameters": [ +{ +"name": "page", +"in": "query", +"schema": { +"type": "integer", +"default": 1, +"minimum": 1 +} +}, +{ +"name": "limit", +"in": "query", +"schema": { +"type": "integer", +"default": 20, +"minimum": 1, +"maximum": 100 +} +}, +{ +"name": "location_code", +"in": "query", +"schema": { +"type": "string" +} +}, +{ +"name": "gate_code", +"in": "query", +"schema": { +"type": "string" +} +}, +{ +"name": "category", +"in": "query", +"schema": { +"type": "string" +} +}, +{ +"name": "start_date", +"in": "query", +"schema": { +"type": "string", +"format": "date" +} +}, +{ +"name": "end_date", +"in": "query", +"schema": { +"type": "string", +"format": "date" +} +} +], +"responses": { +"200": { +"description": "Success", +"content": { +"application/json": { +"schema": { +"$ref": "#/components/schemas/Success" +} +} +} +} +} +} +} +} +} diff --git a/DASHBOARD_DEBUG.md b/DASHBOARD_DEBUG.md new file mode 100644 index 0000000..3ba224d --- /dev/null +++ b/DASHBOARD_DEBUG.md @@ -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) + diff --git a/README.md b/README.md new file mode 100644 index 0000000..5cdcc9b --- /dev/null +++ b/README.md @@ -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 + diff --git a/api/CORS_SETUP_GUIDE.md b/api/CORS_SETUP_GUIDE.md new file mode 100644 index 0000000..fbe735b --- /dev/null +++ b/api/CORS_SETUP_GUIDE.md @@ -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 + + 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" + + +# Handle OPTIONS request + + RewriteEngine On + RewriteCond %{REQUEST_METHOD} OPTIONS + RewriteRule ^(.*)$ $1 [R=200,L] + +``` + +### 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 + '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'] + ] +]); +*/ diff --git a/api/auth/login.php.example b/api/auth/login.php.example new file mode 100644 index 0000000..338e12d --- /dev/null +++ b/api/auth/login.php.example @@ -0,0 +1,45 @@ + 'Bearer xxxxx', + 'user' => [ + 'username' => 'admin', + 'role' => 'admin', + 'locations' => ['kerkof_01'] + ] +]; +echo json_encode($response); +*/ + diff --git a/api/cors-handler.php b/api/cors-handler.php new file mode 100644 index 0000000..a3ee302 --- /dev/null +++ b/api/cors-handler.php @@ -0,0 +1,30 @@ + '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) +]); +*/ diff --git a/api/dashboard/chart.php.example b/api/dashboard/chart.php.example new file mode 100644 index 0000000..1e0ccf0 --- /dev/null +++ b/api/dashboard/chart.php.example @@ -0,0 +1,20 @@ + '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) +]); +*/ diff --git a/api/dashboard/chart_monthly.php.example b/api/dashboard/chart_monthly.php.example new file mode 100644 index 0000000..382607f --- /dev/null +++ b/api/dashboard/chart_monthly.php.example @@ -0,0 +1,20 @@ + '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 +]); +*/ diff --git a/api/dashboard/events.php.example b/api/dashboard/events.php.example new file mode 100644 index 0000000..d967b7f --- /dev/null +++ b/api/dashboard/events.php.example @@ -0,0 +1,20 @@ + '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 +]); +*/ diff --git a/api/dashboard/summary.php.example b/api/dashboard/summary.php.example new file mode 100644 index 0000000..9c55c29 --- /dev/null +++ b/api/dashboard/summary.php.example @@ -0,0 +1,42 @@ + '2024-01-01', + 'location_code' => null, + 'total_vehicle' => 100, + 'total_person' => 250, + 'total_amount' => 5000000 +]; +echo json_encode($response); +*/ + diff --git a/api/example-endpoint-with-cors.php b/api/example-endpoint-with-cors.php new file mode 100644 index 0000000..da443d1 --- /dev/null +++ b/api/example-endpoint-with-cors.php @@ -0,0 +1,76 @@ + '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' +]); + diff --git a/index.php b/index.php new file mode 100644 index 0000000..c4d0aa5 --- /dev/null +++ b/index.php @@ -0,0 +1,110 @@ + + + + + + Login - Sistem Monitoring Retribusi + + + + + + + +
+
+
+

Sistem Monitoring Retribusi

+

Silakan login untuk melanjutkan

+
+
+
+ + +
+
+ + +
+ + +
+
+
+ + + + + + + diff --git a/proxy/package.json b/proxy/package.json new file mode 100644 index 0000000..6a17b52 --- /dev/null +++ b/proxy/package.json @@ -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" + } +} + diff --git a/proxy/rtsp-websocket-proxy.js b/proxy/rtsp-websocket-proxy.js new file mode 100644 index 0000000..8a00c14 --- /dev/null +++ b/proxy/rtsp-websocket-proxy.js @@ -0,0 +1,151 @@ +/** + * RTSP to WebSocket Proxy Server + * Convert RTSP stream ke WebSocket untuk jsmpeg player + * + * Usage: + * node rtsp-websocket-proxy.js + * + * 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'); + diff --git a/proxy/start-proxy.bat b/proxy/start-proxy.bat new file mode 100644 index 0000000..766f1c9 --- /dev/null +++ b/proxy/start-proxy.bat @@ -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 + diff --git a/public/dashboard/css/app.css b/public/dashboard/css/app.css new file mode 100644 index 0000000..e55a6a7 --- /dev/null +++ b/public/dashboard/css/app.css @@ -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; +} + + diff --git a/public/dashboard/dashboard.html b/public/dashboard/dashboard.html new file mode 100644 index 0000000..2ab846f --- /dev/null +++ b/public/dashboard/dashboard.html @@ -0,0 +1,183 @@ + + + + + + Dashboard - Btekno Retribusi Admin + + + + + + +
+
+
Dashboard Retribusi
+
+ + Events + Pengaturan + +
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+
+
Total Pendapatan
+
Rp 0
+
Nilai bruto sebelum pemotongan
+
+
Memuat data...
+
+
+
+
Jumlah Orang
+
0
+
Person walk (pejalan kaki)
+
+
+
+
+
Jumlah Motor
+
0
+
Kendaraan roda dua
+
+
+
+
+
Jumlah Mobil
+
0
+
Kendaraan roda empat
+
+
+
+ +
+
+
+

Trend Harian

+
+
+
+ +
+
+
+ +
+
+

Per Kategori

+
+
+
+ +
+
+
+
+ + +
+
+ + + + + + diff --git a/public/dashboard/event.html b/public/dashboard/event.html new file mode 100644 index 0000000..fb60353 --- /dev/null +++ b/public/dashboard/event.html @@ -0,0 +1,847 @@ + + + + + + Events - Btekno Retribusi Admin + + + + + +
+
+
Events
+
+ Dashboard + Pengaturan + +
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+
+
+

Daftar Events

+
+
+
+
Memuat data...
+
+
+ + + + + + + + + + + + + + + + +
WaktuLokasiGateKategoriJumlahPendapatan
+ Memuat data... +
+
+
+
+
+ + +
+
+
+ + + + diff --git a/public/dashboard/js/README_CONFIG.md b/public/dashboard/js/README_CONFIG.md new file mode 100644 index 0000000..ed7d9de --- /dev/null +++ b/public/dashboard/js/README_CONFIG.md @@ -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 +``` + diff --git a/public/dashboard/js/api.js b/public/dashboard/js/api.js new file mode 100644 index 0000000..bb829a2 --- /dev/null +++ b/public/dashboard/js/api.js @@ -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). + diff --git a/public/dashboard/js/auth.js b/public/dashboard/js/auth.js new file mode 100644 index 0000000..4291823 --- /dev/null +++ b/public/dashboard/js/auth.js @@ -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); + } +}); + + diff --git a/public/dashboard/js/charts.js b/public/dashboard/js/charts.js new file mode 100644 index 0000000..2f70d97 --- /dev/null +++ b/public/dashboard/js/charts.js @@ -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) + }); +} + + diff --git a/public/dashboard/js/config.js b/public/dashboard/js/config.js new file mode 100644 index 0000000..47a3512 --- /dev/null +++ b/public/dashboard/js/config.js @@ -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); +} + diff --git a/public/dashboard/js/dashboard.js b/public/dashboard/js/dashboard.js new file mode 100644 index 0000000..346a8ae --- /dev/null +++ b/public/dashboard/js/dashboard.js @@ -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 = ''; + + 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 = ''; + + 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(); +}); + + diff --git a/public/dashboard/js/realtime.js b/public/dashboard/js/realtime.js new file mode 100644 index 0000000..d5c0c8f --- /dev/null +++ b/public/dashboard/js/realtime.js @@ -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(); +}); + + diff --git a/public/dashboard/settings.html b/public/dashboard/settings.html new file mode 100644 index 0000000..2e26e6f --- /dev/null +++ b/public/dashboard/settings.html @@ -0,0 +1,1262 @@ + + + + + + Pengaturan - Btekno Retribusi Admin + + + + + +
+
+
Pengaturan
+
+ Dashboard + Events + +
+
+ +
+
+ +
+
+

Lokasi

+ +
+
+
Memuat data...
+
+
+ + +
+
+

Gate

+ +
+
+
Memuat data...
+
+
+
+
+
+ + + + + + + + + + diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..cd7881c --- /dev/null +++ b/public/index.php @@ -0,0 +1,5 @@ +