diff --git a/android/wisata_app/lib/config/api_config.dart b/android/wisata_app/lib/config/api_config.dart
index a6f0453..88da5e9 100644
--- a/android/wisata_app/lib/config/api_config.dart
+++ b/android/wisata_app/lib/config/api_config.dart
@@ -1,5 +1,5 @@
class ApiConfig {
- static const String host = '192.168.1.10';
+ static const String host = '192.168.1.36';
static const String port = '8000';
static String get baseUrl {
diff --git a/website/wisata_web/app/Http/Controllers/Admin/DashboardController.php b/website/wisata_web/app/Http/Controllers/Admin/DashboardController.php
index e1b2642..6e22ef4 100644
--- a/website/wisata_web/app/Http/Controllers/Admin/DashboardController.php
+++ b/website/wisata_web/app/Http/Controllers/Admin/DashboardController.php
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
+use App\Models\Kategori;
use App\Models\User;
use App\Models\Wisata;
use Illuminate\View\View;
@@ -11,11 +12,30 @@ class DashboardController extends Controller
{
public function __invoke(): View
{
+ $wisatas = Wisata::query()
+ ->with('kategori')
+ ->whereNotNull('latitude')
+ ->whereNotNull('longitude')
+ ->orderBy('title')
+ ->get();
+
return view('admin.dashboard.index', [
'totalWisata' => Wisata::query()->count(),
- 'totalWisataAr' => Wisata::query()->whereNotNull('model_path')->count(),
- 'totalWisataVideo' => Wisata::query()->where('is_video', true)->count(),
'totalUser' => User::query()->where('role', 'user')->count(),
+ 'totalKategori' => Kategori::query()->count(),
+ 'mapWisatas' => $wisatas->map(fn (Wisata $wisata): array => [
+ 'id' => $wisata->id,
+ 'title' => $wisata->title,
+ 'location' => $wisata->location,
+ 'latitude' => (float) $wisata->latitude,
+ 'longitude' => (float) $wisata->longitude,
+ 'imageUrl' => $wisata->image_url,
+ 'rating' => number_format((float) $wisata->rating, 1),
+ 'category' => $wisata->kategori?->nama ?? 'Tanpa Kategori',
+ 'mediaType' => $wisata->model_path ? 'ar' : ($wisata->video_path ? 'video' : 'ar'),
+ 'mediaLabel' => $wisata->model_path ? 'Augmented Reality' : ($wisata->video_path ? 'Video Wisata' : 'Augmented Reality'),
+ 'editUrl' => route('admin.wisata.edit', $wisata),
+ ])->values(),
]);
}
}
diff --git a/website/wisata_web/public/css/admin-dashboard-map.css b/website/wisata_web/public/css/admin-dashboard-map.css
new file mode 100644
index 0000000..be61efa
--- /dev/null
+++ b/website/wisata_web/public/css/admin-dashboard-map.css
@@ -0,0 +1,205 @@
+.admin-map-card .card-body {
+ padding: 1.25rem;
+}
+
+.map-actions {
+ display: grid;
+ grid-template-columns: minmax(220px, 1fr) minmax(130px, .42fr) auto auto;
+ gap: .75rem;
+ width: min(900px, 100%);
+}
+
+.map-shell {
+ position: relative;
+ height: 600px;
+ min-height: 600px;
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ overflow: hidden;
+ background: #e9f2f6;
+ box-shadow: 0 18px 42px rgba(15, 76, 129, .1);
+}
+
+.tourism-map {
+ width: 100%;
+ height: 100%;
+ z-index: 1;
+}
+
+.leaflet-container {
+ width: 100%;
+ height: 100%;
+ position: relative;
+}
+
+.tourism-map::before {
+ content: attr(data-empty-message);
+ display: none;
+ place-items: center;
+ height: 100%;
+ padding: 2rem;
+ color: #64748b;
+ text-align: center;
+}
+
+.tourism-map.is-empty::before {
+ display: grid;
+}
+
+.map-legend {
+ position: absolute;
+ right: 14px;
+ bottom: 14px;
+ z-index: 500;
+ width: min(300px, calc(100% - 28px));
+ padding: .85rem 1rem;
+ border-radius: 12px;
+ background: rgba(255, 255, 255, .96);
+ border: 1px solid rgba(15, 76, 129, .14);
+ box-shadow: 0 14px 34px rgba(15, 76, 129, .16);
+ font-size: .9rem;
+}
+
+.legend-dot {
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ display: inline-block;
+ box-shadow: 0 0 0 4px rgba(15, 76, 129, .08);
+}
+
+.legend-dot-ar {
+ background: #dc2626;
+}
+
+.legend-dot-video {
+ background: #2563eb;
+}
+
+.map-stat {
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 1rem;
+ background: #fff;
+ box-shadow: 0 10px 26px rgba(15, 76, 129, .07);
+}
+
+.map-stat small {
+ display: block;
+ color: #64748b;
+ font-weight: 700;
+ margin-bottom: .35rem;
+}
+
+.map-stat strong {
+ display: block;
+ color: var(--ink);
+ font-size: 1.8rem;
+ line-height: 1;
+}
+
+.tourism-marker {
+ background: transparent;
+ border: 0;
+}
+
+.tourism-marker-pin {
+ position: relative;
+ display: block;
+ width: 30px;
+ height: 30px;
+ border-radius: 50% 50% 50% 0;
+ background: var(--marker-color);
+ border: 3px solid #fff;
+ transform: rotate(-45deg);
+ box-shadow: 0 10px 24px rgba(15, 23, 42, .28);
+}
+
+.tourism-marker-pin::after {
+ content: "";
+ position: absolute;
+ inset: 7px;
+ border-radius: 50%;
+ background: #fff;
+}
+
+.leaflet-popup-content-wrapper {
+ border-radius: 14px;
+ padding: 0;
+ overflow: hidden;
+ box-shadow: 0 18px 42px rgba(15, 23, 42, .2);
+}
+
+.leaflet-popup-content {
+ margin: 0;
+}
+
+.map-popup-card {
+ width: 280px;
+ border-radius: 14px;
+ overflow: hidden;
+}
+
+.map-popup-card .card-img-top {
+ width: 100%;
+ height: 140px;
+ object-fit: cover;
+}
+
+.map-popup-card .card-body {
+ padding: .9rem;
+}
+
+.map-popup-title {
+ font-size: 1rem;
+ font-weight: 800;
+ line-height: 1.25;
+}
+
+.map-popup-row {
+ display: flex;
+ align-items: flex-start;
+ gap: .45rem;
+ color: #475569;
+ font-size: .86rem;
+ margin-top: .4rem;
+}
+
+.map-popup-row i {
+ color: var(--ocean);
+ line-height: 1.35;
+}
+
+.fullscreen-control {
+ width: 34px;
+ height: 34px;
+ display: grid;
+ place-items: center;
+ border: 0;
+ border-bottom: 1px solid #ccc;
+ background: #fff;
+ color: #111827;
+ cursor: pointer;
+ font-size: 1rem;
+}
+
+.fullscreen-control:hover {
+ background: #f3f4f6;
+}
+
+@media (max-width: 991.98px) {
+ .map-actions {
+ grid-template-columns: 1fr;
+ width: 100%;
+ }
+
+ .map-shell {
+ height: 600px;
+ min-height: 600px;
+ }
+
+ .map-legend {
+ right: 12px;
+ bottom: 12px;
+ }
+}
diff --git a/website/wisata_web/public/js/admin-dashboard-map.js b/website/wisata_web/public/js/admin-dashboard-map.js
new file mode 100644
index 0000000..3e02e5e
--- /dev/null
+++ b/website/wisata_web/public/js/admin-dashboard-map.js
@@ -0,0 +1,328 @@
+(function () {
+ const lumajangCenter = [-8.1269, 113.2248];
+ const lumajangBounds = L.latLngBounds(
+ L.latLng(-8.55, 112.75),
+ L.latLng(-7.70, 113.55),
+ );
+ const defaultZoom = 10;
+ const singleMarkerZoom = 14;
+ const minZoom = 10;
+ const maxZoom = 18;
+ const markerColors = {
+ ar: '#dc2626',
+ video: '#2563eb',
+ };
+
+ let map;
+ let markerLayer;
+ let markers = [];
+ let activeMedia = 'all';
+ let searchQuery = '';
+
+ function escapeHtml(value) {
+ return String(value ?? '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
+ function createMarkerIcon(mediaType) {
+ const color = markerColors[mediaType] || markerColors.ar;
+
+ return L.divIcon({
+ className: 'tourism-marker',
+ html: ``,
+ iconSize: [30, 42],
+ iconAnchor: [15, 42],
+ popupAnchor: [0, -38],
+ });
+ }
+
+ function buildPopupContent(item) {
+ const isVideo = item.mediaType === 'video';
+ const badgeClass = isVideo ? 'text-bg-primary' : 'text-bg-danger';
+
+ return `
+
+
})
+
+
+
+ ${escapeHtml(item.mediaLabel)}
+
+
+
+
Lihat Detail
+
+
+ `;
+ }
+
+ function markerMatches(marker) {
+ const item = marker.tourismData;
+ const matchesMedia = activeMedia === 'all' || item.mediaType === activeMedia;
+ const haystack = `${item.title} ${item.location} ${item.category}`.toLowerCase();
+ const matchesSearch = !searchQuery || haystack.includes(searchQuery);
+
+ return matchesMedia && matchesSearch;
+ }
+
+ function getVisibleMarkers() {
+ return markers.filter((marker) => markerLayer.hasLayer(marker));
+ }
+
+ function setMarkerVisibility() {
+ markers.forEach((marker) => {
+ const shouldShow = markerMatches(marker);
+ const isShown = markerLayer.hasLayer(marker);
+
+ if (shouldShow && !isShown) {
+ markerLayer.addLayer(marker);
+ }
+
+ if (!shouldShow && isShown) {
+ markerLayer.removeLayer(marker);
+ }
+ });
+
+ const visibleMarkers = getVisibleMarkers();
+ updateStats();
+
+ if (!visibleMarkers.length) {
+ map.closePopup();
+ return;
+ }
+
+ const exactSearchMatch = searchQuery
+ ? visibleMarkers.find((marker) => marker.tourismData.title.toLowerCase().includes(searchQuery))
+ : null;
+
+ if (exactSearchMatch) {
+ focusMarker(exactSearchMatch);
+ return;
+ }
+
+ fitMarkers(visibleMarkers);
+ }
+
+ function updateStats() {
+ const totalElement = document.getElementById('mapTotalMarkers');
+ const arElement = document.getElementById('mapArMarkers');
+ const videoElement = document.getElementById('mapVideoMarkers');
+
+ const arCount = markers.filter((marker) => marker.tourismData.mediaType === 'ar').length;
+ const videoCount = markers.filter((marker) => marker.tourismData.mediaType === 'video').length;
+
+ if (totalElement) totalElement.textContent = markers.length;
+ if (arElement) arElement.textContent = arCount;
+ if (videoElement) videoElement.textContent = videoCount;
+ }
+
+ function fitMarkers(targetMarkers) {
+ if (!targetMarkers.length) {
+ map.setView(lumajangCenter, defaultZoom);
+ return;
+ }
+
+ if (targetMarkers.length === 1) {
+ map.setView(targetMarkers[0].getLatLng(), singleMarkerZoom);
+ return;
+ }
+
+ const group = L.featureGroup(targetMarkers);
+
+ map.fitBounds(group.getBounds().pad(0.2), {
+ maxZoom,
+ });
+ }
+
+ function refreshMapSize(callback, delay = 140) {
+ window.setTimeout(() => {
+ if (!map) {
+ return;
+ }
+
+ map.invalidateSize(true);
+
+ if (typeof callback === 'function') {
+ callback();
+ }
+ }, delay);
+ }
+
+ function scheduleMapInvalidation() {
+ setTimeout(() => {
+ map.invalidateSize(true);
+ }, 500);
+
+ setTimeout(() => {
+ map.invalidateSize(true);
+ }, 1000);
+
+ setTimeout(() => {
+ map.invalidateSize(true);
+ }, 2000);
+ }
+
+ function focusMarker(marker) {
+ map.setView(marker.getLatLng(), singleMarkerZoom);
+ marker.openPopup();
+ }
+
+ function resetView() {
+ searchQuery = '';
+ activeMedia = 'all';
+
+ const searchInput = document.getElementById('tourismMapSearch');
+ const mediaSelect = document.getElementById('tourismMapMedia');
+
+ if (searchInput) searchInput.value = '';
+ if (mediaSelect) mediaSelect.value = 'all';
+
+ markers.forEach((marker) => {
+ if (!markerLayer.hasLayer(marker)) {
+ markerLayer.addLayer(marker);
+ }
+ });
+
+ map.closePopup();
+ updateStats();
+ refreshMapSize(() => map.setView(lumajangCenter, defaultZoom), 120);
+ }
+
+ function addFullscreenControl() {
+ const FullscreenControl = L.Control.extend({
+ options: { position: 'topleft' },
+ onAdd() {
+ const button = L.DomUtil.create('button', 'leaflet-control fullscreen-control');
+ button.type = 'button';
+ button.title = 'Fullscreen';
+ button.setAttribute('aria-label', 'Fullscreen');
+ button.innerHTML = '';
+
+ L.DomEvent.disableClickPropagation(button);
+ L.DomEvent.on(button, 'click', () => {
+ const container = map.getContainer();
+
+ if (document.fullscreenElement) {
+ document.exitFullscreen();
+ } else if (container.requestFullscreen) {
+ container.requestFullscreen();
+ }
+ });
+
+ return button;
+ },
+ });
+
+ map.addControl(new FullscreenControl());
+
+ document.addEventListener('fullscreenchange', () => {
+ refreshMapSize(() => fitMarkers(getVisibleMarkers()), 180);
+ });
+ }
+
+ function bindControls() {
+ document.getElementById('tourismMapSearch')?.addEventListener('input', (event) => {
+ searchQuery = event.target.value.trim().toLowerCase();
+ setMarkerVisibility();
+ });
+
+ document.getElementById('tourismMapMedia')?.addEventListener('change', (event) => {
+ activeMedia = event.target.value;
+ setMarkerVisibility();
+ });
+
+ document.getElementById('tourismMapFit')?.addEventListener('click', () => {
+ fitMarkers(getVisibleMarkers());
+ });
+
+ document.getElementById('tourismMapReset')?.addEventListener('click', resetView);
+
+ window.addEventListener('resize', () => {
+ window.clearTimeout(window.tourismMapResizeTimer);
+ window.tourismMapResizeTimer = window.setTimeout(() => {
+ refreshMapSize(() => fitMarkers(getVisibleMarkers()), 40);
+ }, 180);
+ });
+
+ window.addEventListener('load', () => {
+ refreshMapSize(() => fitMarkers(getVisibleMarkers()), 220);
+ });
+ }
+
+ function initTourismMap() {
+ const mapElement = document.getElementById('tourismMap');
+ const data = Array.isArray(window.tourismMapData) ? window.tourismMapData : [];
+
+ if (!mapElement || typeof L === 'undefined') {
+ return;
+ }
+
+ if (mapElement._leaflet_id) return;
+
+ map = L.map(mapElement, {
+ center: lumajangCenter,
+ zoom: defaultZoom,
+ minZoom,
+ maxZoom,
+ maxBounds: lumajangBounds,
+ maxBoundsViscosity: 1,
+ zoomControl: true,
+ scrollWheelZoom: true,
+ });
+
+ map.setMaxBounds(lumajangBounds);
+
+ L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
+ subdomains: 'abcd',
+ maxZoom: 20,
+ noWrap: true,
+ attribution: '© OpenStreetMap contributors © CARTO',
+ }).addTo(map);
+
+ scheduleMapInvalidation();
+
+ markerLayer = L.layerGroup().addTo(map);
+
+ markers = data.map((item) => {
+ const marker = L.marker([Number(item.latitude), Number(item.longitude)], {
+ icon: createMarkerIcon(item.mediaType),
+ title: item.title,
+ });
+
+ marker.tourismData = item;
+ marker.bindPopup(buildPopupContent(item), {
+ className: 'tourism-popup',
+ maxWidth: 300,
+ minWidth: 260,
+ });
+ markerLayer.addLayer(marker);
+
+ return marker;
+ });
+
+ addFullscreenControl();
+ bindControls();
+ updateStats();
+
+ if (markers.length) {
+ refreshMapSize(() => map.setView(lumajangCenter, defaultZoom), 180);
+ } else {
+ mapElement.classList.add('is-empty');
+ refreshMapSize(() => map.setView(lumajangCenter, defaultZoom), 180);
+ }
+ }
+
+ function bootTourismMap() {
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initTourismMap, { once: true });
+ } else {
+ initTourismMap();
+ }
+ }
+
+ bootTourismMap();
+})();
diff --git a/website/wisata_web/resources/views/admin/dashboard/index.blade.php b/website/wisata_web/resources/views/admin/dashboard/index.blade.php
index aaa0309..2d65e68 100644
--- a/website/wisata_web/resources/views/admin/dashboard/index.blade.php
+++ b/website/wisata_web/resources/views/admin/dashboard/index.blade.php
@@ -1,112 +1,119 @@
@extends('admin.layouts.app')
@section('title', 'Dashboard')
-@section('page_title', 'Dashboard')
-@section('page_subtitle', 'Pusat manajemen data Explore Lumajang')
+@section('page_title', 'Dashboard Admin')
+@section('page_subtitle', 'Ringkasan data dan peta persebaran wisata Kabupaten Lumajang')
@section('content')
-
-
-
-
-
-
-
Total Wisata
-
{{ $totalWisata }}
-
-
-
-
+
+
+
+
+
+
Total Wisata
+
{{ number_format($totalWisata) }}
-
Semua destinasi yang tersedia di aplikasi.
+
-
-
-
-
-
-
-
Total Wisata AR
-
{{ $totalWisataAr }}
-
-
-
-
+
+
+
+
+
Total User
+
{{ number_format($totalUser) }}
-
Destinasi dengan model 3D untuk fitur AR.
+
-
-
-
-
-
-
-
Total Wisata Video
-
{{ $totalWisataVideo }}
-
-
-
-
+
+
+
+
+
Total Kategori
+
{{ number_format($totalKategori) }}
-
Destinasi yang menampilkan media video.
-
-
-
-
-
-
-
-
-
-
Total User
-
{{ $totalUser }}
-
-
-
-
-
-
Pengguna aplikasi Flutter yang terdaftar.
+
-
-
-
-
Kelola Data Wisata
-
Tambah, ubah, dan lengkapi media destinasi untuk kebutuhan aplikasi Flutter.
+
+
+
+
+
Peta Persebaran Wisata Kabupaten Lumajang
+
Visualisasi lokasi seluruh destinasi wisata berdasarkan data yang tersimpan pada sistem.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Legenda
+
Wisata Augmented Reality
+
Wisata Video
+
+
+
+
+
+
+ Jumlah Marker
+ 0
+
+
+
+
+ Jumlah Wisata AR
+ 0
+
+
+
+
+ Jumlah Wisata Video
+ 0
+
+
-
- Buka Data Wisata
-
@endsection
@push('styles')
-
+
+
+@endpush
+
+
+@push('scripts')
+
+
+
+
+
+
+
@endpush