google maps

This commit is contained in:
jouel88 2026-03-29 20:08:13 +07:00
parent 0216c572cf
commit 4bdfba7ea3
11 changed files with 722 additions and 235 deletions

View File

@ -16,7 +16,7 @@ class PoinController extends Controller
public function getExpiringPoints()
{
try {
$userId = auth()->user()->id;
$userId = auth()->id();
$poinService = new PoinService();
$poinExpiring = $poinService->getExpiringPoints($userId);
@ -45,7 +45,7 @@ public function getExpiringPoints()
public function getPointHistory()
{
try {
$userId = auth()->user()->id;
$userId = auth()->id();
$poinService = new PoinService();
$history = $poinService->getPointHistory($userId);
@ -72,7 +72,7 @@ public function redeem(Request $request)
}
try {
$userId = auth()->user()->id;
$userId = auth()->id();
$jumlah = $request->jumlah;
$keterangan = $request->keterangan;
@ -86,6 +86,21 @@ public function redeem(Request $request)
preg_match('/\[(\d{4}-\d{2}-\d{2})\]/', $keterangan, $matches);
$tanggalPenggunaan = isset($matches[1]) ? $matches[1] : now()->format('Y-m-d');
$jadwal = \App\Models\JadwalKerja::with('shift')
->where('id_user', $userId)
->where('tanggal', $tanggalPenggunaan)
->first();
if (!$jadwal || !$jadwal->shift) {
return ApiResponse::error('Jadwal kerja tidak ditemukan untuk tanggal ' . Carbon::parse($tanggalPenggunaan)->format('d/m/Y') . '.', 400);
}
$waktuBatasPengajuan = Carbon::parse($tanggalPenggunaan . ' ' . $jadwal->shift->jam_mulai)->subHour();
if (now()->greaterThan($waktuBatasPengajuan)) {
return ApiResponse::error('Pengajuan poin harus dilakukan maksimal 1 jam sebelum jam kerja dimulai (Batas: ' . $waktuBatasPengajuan->format('d/m/Y H:i') . ').', 400);
}
PenggunaanPoin::create([
'id_user' => $userId,
'tanggal_penggunaan' => $tanggalPenggunaan,

View File

@ -169,6 +169,7 @@ public function store(Request $request)
'foto' => 'required|image|max:2048',
'status' => 'required|in:masuk,pulang',
'keterangan_luar_radius' => 'nullable|string|max:500',
'alasan_telat' => 'nullable|string|max:500',
]);
$user = Auth::user();
@ -277,6 +278,12 @@ public function history(Request $request)
'verifikasi_wajah' => (bool) $item->verifikasi_wajah,
'status_validasi' => $item->id_validasi,
'alasan_penolakan' => $item->alasan_penolakan,
'foto_masuk_url' => $item->foto_wajah_masuk
? asset('storage/' . $item->foto_wajah_masuk)
: null,
'foto_pulang_url' => $item->foto_wajah_pulang
? asset('storage/' . $item->foto_wajah_pulang)
: null,
];
});

View File

@ -15,6 +15,7 @@ public function rules(): array
{
return [
'nama_kantor' => ['required', 'string', 'max:255'],
'tipe' => ['required', 'string', 'in:Pusat,Cabang'],
'alamat' => ['nullable', 'string'],
'latitude' => ['required', 'numeric', 'between:-90,90'],

View File

@ -15,6 +15,7 @@ public function rules(): array
{
return [
'nama_kantor' => ['required', 'string', 'max:255'],
'tipe' => ['required', 'string', 'in:Pusat,Cabang'],
'alamat' => ['nullable', 'string'],
'latitude' => ['required', 'numeric', 'between:-90,90'],

View File

@ -23,4 +23,8 @@
],
],
'google_maps' => [
'key' => env('GOOGLE_MAPS_API_KEY'),
],
];

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('kantor', function (Blueprint $table) {
$table->string('tipe', 20)->default('Cabang')->after('nama_kantor');
});
}
public function down(): void
{
Schema::table('kantor', function (Blueprint $table) {
$table->dropColumn('tipe');
});
}
};

View File

@ -1,45 +1,47 @@
let isFormModified = false;
(function() {
let isFormModified = false;
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('DOMContentLoaded', function() {
const form = document.querySelector('form');
const submitBtn = document.getElementById('submitBtn');
const submitText = document.getElementById('submitText');
const submitSpinner = document.getElementById('submitSpinner');
const form = document.querySelector('form');
const submitBtn = document.getElementById('submitBtn');
const submitText = document.getElementById('submitText');
const submitSpinner = document.getElementById('submitSpinner');
if (form) {
form.addEventListener('change', () => {
isFormModified = true;
});
if (form) {
form.addEventListener('change', () => {
isFormModified = true;
});
form.addEventListener('submit', function() {
isFormModified = false;
form.addEventListener('submit', function() {
isFormModified = false;
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.classList.add('opacity-75', 'cursor-not-allowed');
}
if (submitSpinner) submitSpinner.classList.remove('hidden');
if (submitText) submitText.textContent = 'Menyimpan...';
});
}
window.addEventListener('beforeunload', function(e) {
if (isFormModified) {
e.preventDefault();
e.returnValue = '';
}
});
const backLinks = document.querySelectorAll('.btn-back');
backLinks.forEach(link => {
link.addEventListener('click', function(e) {
if (isFormModified) {
const confirmLeave = confirm('Anda memiliki perubahan yang belum disimpan. Yakin ingin keluar?');
if (!confirmLeave) {
e.preventDefault();
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.classList.add('opacity-75', 'cursor-not-allowed');
}
if (submitSpinner) submitSpinner.classList.remove('hidden');
if (submitText) submitText.textContent = 'Menyimpan...';
});
}
window.addEventListener('beforeunload', function(e) {
if (isFormModified) {
e.preventDefault();
e.returnValue = '';
}
});
const backLinks = document.querySelectorAll('.btn-back');
backLinks.forEach(link => {
link.addEventListener('click', function(e) {
if (isFormModified) {
const confirmLeave = confirm('Anda memiliki perubahan yang belum disimpan. Yakin ingin keluar?');
if (!confirmLeave) {
e.preventDefault();
}
}
});
});
});
});
})();

175
public/js/image-cropper.js Normal file
View File

@ -0,0 +1,175 @@
/**
* Image Cropper - Profile Photo
* Menggunakan Cropper.js untuk crop foto profil berbentuk lingkaran
*/
document.addEventListener('DOMContentLoaded', function () {
const fotoInput = document.getElementById('foto-input');
const cropModal = document.getElementById('crop-modal');
const cropImage = document.getElementById('crop-image');
const cropSaveBtn = document.getElementById('crop-save-btn');
const cropCancelBtn = document.getElementById('crop-cancel-btn');
const cropRotateLeftBtn = document.getElementById('crop-rotate-left');
const cropRotateRightBtn = document.getElementById('crop-rotate-right');
const cropResetBtn = document.getElementById('crop-reset');
const cropZoomInBtn = document.getElementById('crop-zoom-in');
const cropZoomOutBtn = document.getElementById('crop-zoom-out');
let cropper = null;
if (!fotoInput || !cropModal) return;
fotoInput.addEventListener('change', function (e) {
const file = e.target.files[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
alert('File harus berupa gambar.');
fotoInput.value = '';
return;
}
const reader = new FileReader();
reader.onload = function (event) {
cropImage.src = event.target.result;
cropModal.classList.remove('hidden');
if (cropper) {
cropper.destroy();
}
setTimeout(function () {
cropper = new Cropper(cropImage, {
aspectRatio: 1,
viewMode: 1,
dragMode: 'move',
autoCropArea: 0.85,
cropBoxResizable: false,
cropBoxMovable: false,
guides: false,
center: true,
highlight: false,
background: false,
responsive: true,
restore: false,
checkCrossOrigin: false,
ready: function () {
const containerData = cropper.getContainerData();
const size = Math.min(containerData.width, containerData.height) * 0.8;
cropper.setCropBoxData({
left: (containerData.width - size) / 2,
top: (containerData.height - size) / 2,
width: size,
height: size,
});
},
});
}, 100);
};
reader.readAsDataURL(file);
});
if (cropRotateLeftBtn) {
cropRotateLeftBtn.addEventListener('click', function () {
if (cropper) cropper.rotate(-90);
});
}
if (cropRotateRightBtn) {
cropRotateRightBtn.addEventListener('click', function () {
if (cropper) cropper.rotate(90);
});
}
if (cropResetBtn) {
cropResetBtn.addEventListener('click', function () {
if (cropper) cropper.reset();
});
}
if (cropZoomInBtn) {
cropZoomInBtn.addEventListener('click', function () {
if (cropper) cropper.zoom(0.1);
});
}
if (cropZoomOutBtn) {
cropZoomOutBtn.addEventListener('click', function () {
if (cropper) cropper.zoom(-0.1);
});
}
cropSaveBtn.addEventListener('click', function () {
if (!cropper) return;
cropSaveBtn.disabled = true;
cropSaveBtn.innerHTML = `
<svg class="animate-spin h-4 w-4 mr-2" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Memproses...
`;
const canvas = cropper.getCroppedCanvas({
width: 512,
height: 512,
fillColor: '#fff',
imageSmoothingEnabled: true,
imageSmoothingQuality: 'high',
});
canvas.toBlob(function (blob) {
const croppedFile = new File([blob], 'profile-photo.jpg', {
type: 'image/jpeg',
});
const dataTransfer = new DataTransfer();
dataTransfer.items.add(croppedFile);
fotoInput.files = dataTransfer.files;
const preview = document.getElementById('preview-foto');
const previewNew = document.getElementById('preview-foto-new');
const placeholder = document.getElementById('placeholder-foto');
const url = URL.createObjectURL(blob);
if (preview) {
preview.src = url;
} else {
if (placeholder) placeholder.classList.add('hidden');
if (previewNew) {
previewNew.src = url;
previewNew.classList.remove('hidden');
}
}
closeCropModal();
}, 'image/jpeg', 0.9);
});
cropCancelBtn.addEventListener('click', function () {
fotoInput.value = '';
closeCropModal();
});
cropModal.addEventListener('click', function (e) {
if (e.target === cropModal) {
fotoInput.value = '';
closeCropModal();
}
});
function closeCropModal() {
cropModal.classList.add('hidden');
if (cropper) {
cropper.destroy();
cropper = null;
}
cropSaveBtn.disabled = false;
cropSaveBtn.innerHTML = `
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
Simpan
`;
}
});

View File

@ -1,9 +1,23 @@
(function () {
const defaultLoc = [-7.9797, 112.6304];
let maps = { create: null, edit: null };
let markers = { create: null, edit: null };
let circles = { create: null, edit: null };
let geocoders = { create: null, edit: null };
const defaultLoc = { lat: -7.9797, lng: 112.6304 };
let maps = { create: null, edit: null, detail: null };
let markers = { create: null, edit: null, detail: null };
let circles = { create: null, edit: null, detail: null };
let autocompletes = { create: null, edit: null };
let geocoder = null;
function getGeocoder() {
if (!geocoder) geocoder = new google.maps.Geocoder();
return geocoder;
}
function reverseGeocode(lat, lng, type) {
getGeocoder().geocode({ location: { lat, lng } }, function (results, status) {
if (status === 'OK' && results[0]) {
setVal(`${type === 'create' ? '' : 'edit-'}alamat`, results[0].formatted_address);
}
});
}
function setVal(id, v) {
const el = document.getElementById(id);
@ -12,176 +26,181 @@
function locateUser(type) {
if (!navigator.geolocation) {
Swal.fire({
icon: 'error',
title: 'Tidak Didukung',
text: 'Browser Anda tidak mendukung fitur lokasi.',
confirmButtonColor: '#1e293b',
});
Swal.fire({ icon: 'error', title: 'Tidak Didukung', text: 'Browser Anda tidak mendukung fitur lokasi.', confirmButtonColor: '#1e293b' });
return;
}
Swal.fire({
title: 'Mencari Lokasi...',
text: 'Mengambil posisi GPS perangkat Anda',
allowOutsideClick: false,
showConfirmButton: false,
didOpen: () => Swal.showLoading()
});
Swal.fire({ title: 'Mencari Lokasi...', text: 'Mengambil posisi GPS perangkat Anda', allowOutsideClick: false, showConfirmButton: false, didOpen: () => Swal.showLoading() });
navigator.geolocation.getCurrentPosition(
(pos) => {
Swal.close();
const lat = pos.coords.latitude;
const lng = pos.coords.longitude;
const position = { lat, lng };
if (maps[type]) {
maps[type].setView([lat, lng], 16);
if (markers[type]) markers[type].setLatLng([lat, lng]);
if (circles[type]) circles[type].setLatLng([lat, lng]);
maps[type].panTo(position);
maps[type].setZoom(16);
if (markers[type]) markers[type].setPosition(position);
if (circles[type]) circles[type].setCenter(position);
setVal(`${type}-lat`, lat.toFixed(6));
setVal(`${type}-long`, lng.toFixed(6));
reverseGeocode(lat, lng, type);
}
},
(error) => {
Swal.close();
let msg = 'Gagal mendapatkan lokasi.';
if (error.code === 1) msg = 'Izin lokasi ditolak oleh pengguna. Aktifkan izin lokasi di pengaturan browser.';
if (error.code === 1) msg = 'Izin lokasi ditolak. Aktifkan di pengaturan browser.';
else if (error.code === 2) msg = 'Posisi tidak dapat ditentukan. Pastikan GPS aktif.';
else if (error.code === 3) msg = 'Waktu permintaan lokasi habis. Coba lagi.';
Swal.fire({
icon: 'warning',
title: 'Lokasi Tidak Ditemukan',
text: msg,
confirmButtonColor: '#1e293b',
confirmButtonText: 'OK'
});
Swal.fire({ icon: 'warning', title: 'Lokasi Tidak Ditemukan', text: msg, confirmButtonColor: '#1e293b' });
},
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }
);
}
function removeExtraMarkers(type) {
if (!maps[type]) return;
maps[type].eachLayer(function(layer) {
if (layer instanceof L.Marker && layer !== markers[type]) {
maps[type].removeLayer(layer);
}
});
function getCircleColor(type) {
if (type === 'create') return '#3b82f6';
if (type === 'detail') return '#1e293b';
return '#f59e0b';
}
function initMap(type, lat, lng, radius) {
const container = document.getElementById(`map-${type}`);
if (!container) return;
const position = { lat: parseFloat(lat), lng: parseFloat(lng) };
const isDraggable = type !== 'detail';
if (maps[type]) {
const pos = [lat, lng];
maps[type].setView(pos, 15);
if (markers[type]) markers[type].setLatLng(pos);
if (circles[type]) circles[type].setLatLng(pos);
if (circles[type]) circles[type].setRadius(radius);
removeExtraMarkers(type);
setTimeout(() => maps[type].invalidateSize(), 300);
maps[type].panTo(position);
maps[type].setZoom(15);
if (markers[type]) markers[type].setPosition(position);
if (circles[type]) {
circles[type].setCenter(position);
circles[type].setRadius(parseFloat(radius));
}
google.maps.event.trigger(maps[type], 'resize');
return;
}
maps[type] = L.map(`map-${type}`).setView([lat, lng], 15);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(maps[type]);
maps[type] = new google.maps.Map(container, {
center: position,
zoom: 15,
mapTypeControl: false,
streetViewControl: false,
fullscreenControl: false,
gestureHandling: 'greedy',
styles: [{ featureType: 'poi', elementType: 'labels', stylers: [{ visibility: 'off' }] }]
});
markers[type] = L.marker([lat, lng], { draggable: true }).addTo(maps[type]);
circles[type] = L.circle([lat, lng], {
color: type === 'create' ? '#3b82f6' : (type === 'detail' ? '#1e293b' : '#f59e0b'),
markers[type] = new google.maps.Marker({
position: position,
map: maps[type],
draggable: isDraggable,
animation: google.maps.Animation.DROP,
});
circles[type] = new google.maps.Circle({
map: maps[type],
center: position,
radius: parseFloat(radius),
fillColor: getCircleColor(type),
fillOpacity: 0.1,
radius: radius
}).addTo(maps[type]);
strokeColor: getCircleColor(type),
strokeWeight: 2,
clickable: false,
});
const update = (e) => {
const latlng = e.target ? e.target.getLatLng() : e.latlng;
if (markers[type]) markers[type].setLatLng([latlng.lat, latlng.lng]);
if (circles[type]) circles[type].setLatLng([latlng.lat, latlng.lng]);
setVal(`${type}-lat`, latlng.lat.toFixed(6));
setVal(`${type}-long`, latlng.lng.toFixed(6));
removeExtraMarkers(type);
};
if (isDraggable) {
markers[type].addListener('dragend', function () {
const pos = markers[type].getPosition();
circles[type].setCenter(pos);
setVal(`${type}-lat`, pos.lat().toFixed(6));
setVal(`${type}-long`, pos.lng().toFixed(6));
reverseGeocode(pos.lat(), pos.lng(), type);
});
markers[type].on('dragend', update);
maps[type].on('click', update);
maps[type].addListener('click', function (e) {
const pos = e.latLng;
markers[type].setPosition(pos);
circles[type].setCenter(pos);
setVal(`${type}-lat`, pos.lat().toFixed(6));
setVal(`${type}-long`, pos.lng().toFixed(6));
reverseGeocode(pos.lat(), pos.lng(), type);
});
if (typeof L.Control.Geocoder !== 'undefined') {
geocoders[type] = L.Control.geocoder({
placeholder: 'Cari lokasi...',
errorMessage: 'Lokasi tidak ditemukan',
collapsed: false,
defaultMarkGeocode: false,
geocoder: L.Control.Geocoder.nominatim({
geocodingQueryParams: {
countrycodes: 'id',
'accept-language': 'id'
// Google Places Autocomplete (search lokasi)
const searchInput = document.getElementById(`search-${type}`);
if (searchInput && !autocompletes[type]) {
const autocomplete = new google.maps.places.Autocomplete(searchInput, {
fields: ['geometry', 'name', 'formatted_address'],
componentRestrictions: { country: 'id' },
});
autocomplete.bindTo('bounds', maps[type]);
autocomplete.addListener('place_changed', function () {
const place = autocomplete.getPlace();
if (!place.geometry || !place.geometry.location) {
console.warn("Tempat tidak ditemukan:", place);
return;
}
})
}).on('markgeocode', function (e) {
const { center } = e.geocode;
maps[type].setView(center, 16);
if (markers[type]) markers[type].setLatLng(center);
if (circles[type]) circles[type].setLatLng(center);
setVal(`${type}-lat`, center.lat.toFixed(6));
setVal(`${type}-long`, center.lng.toFixed(6));
removeExtraMarkers(type);
const loc = place.geometry.location;
maps[type].panTo(loc);
maps[type].setZoom(16);
markers[type].setPosition(loc);
circles[type].setCenter(loc);
setVal(`${type}-lat`, loc.lat().toFixed(6));
setVal(`${type}-long`, loc.lng().toFixed(6));
setTimeout(() => {
removeExtraMarkers(type);
}, 200);
}).addTo(maps[type]);
}
if (place.formatted_address) {
setVal(`${type === 'create' ? '' : 'edit-'}alamat`, place.formatted_address);
}
});
const locateBtn = L.control({ position: 'topleft' });
locateBtn.onAdd = function() {
const div = L.DomUtil.create('div', 'leaflet-bar leaflet-control leaflet-control-custom');
div.style.backgroundColor = 'white';
div.style.width = '30px';
div.style.height = '30px';
div.style.display = 'flex';
div.style.alignItems = 'center';
div.style.justifyContent = 'center';
div.style.cursor = 'pointer';
div.innerHTML = '<svg class="w-4 h-4 text-slate-700" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd"></path></svg>';
div.onclick = function(e) {
// Cegah Enter submit form
searchInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') e.preventDefault();
});
autocompletes[type] = autocomplete;
}
// Tombol "Lokasi Saya"
const locateBtn = document.createElement('button');
locateBtn.type = 'button';
locateBtn.innerHTML = '<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd"></path></svg>';
locateBtn.style.cssText = 'background:white;border:none;width:40px;height:40px;display:flex;align-items:center;justify-content:center;cursor:pointer;margin:10px;border-radius:8px;box-shadow:0 2px 6px rgba(0,0,0,0.15);color:#475569;transition:all 0.2s;';
locateBtn.addEventListener('mouseenter', function () { this.style.background = '#f1f5f9'; });
locateBtn.addEventListener('mouseleave', function () { this.style.background = 'white'; });
locateBtn.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
locateUser(type);
};
return div;
};
locateBtn.addTo(maps[type]);
setTimeout(() => maps[type].invalidateSize(), 300);
});
maps[type].controls[google.maps.ControlPosition.RIGHT_BOTTOM].push(locateBtn);
}
}
function setupRadiusListeners() {
['create', 'edit'].forEach(t => {
const el = document.getElementById(`${t}-radius`);
if (el) el.addEventListener('input', (e) => circles[t] ? circles[t].setRadius(e.target.value) : null);
});
}
function cleanupMaps() {
['create', 'edit', 'detail'].forEach(t => {
if (maps[t]) {
maps[t].remove();
maps[t] = null;
markers[t] = null;
circles[t] = null;
geocoders[t] = null;
}
if (el) el.addEventListener('input', (e) => {
if (circles[t]) circles[t].setRadius(parseFloat(e.target.value) || 0);
});
});
}
window.addEventListener('open-modal', function (e) {
if (e.detail == 'create-kantor') {
setVal('create-lat', defaultLoc[0]);
setVal('create-long', defaultLoc[1]);
setVal('create-lat', defaultLoc.lat);
setVal('create-long', defaultLoc.lng);
setTimeout(() => {
initMap('create', defaultLoc[0], defaultLoc[1], 50);
initMap('create', defaultLoc.lat, defaultLoc.lng, 50);
locateUser('create');
}, 300);
}
@ -205,7 +224,7 @@
setTimeout(() => initMap('edit', parseFloat(d.lat), parseFloat(d.long), parseInt(d.radius)), 300);
};
window.openDetailModal = function(btn) {
window.openDetailModal = function (btn) {
const data = btn.dataset;
document.getElementById('detail-nama').innerText = data.nama;
document.getElementById('detail-tipe').innerText = data.tipe;
@ -214,14 +233,11 @@
document.getElementById('detail-radius').innerText = data.radius + ' m';
document.getElementById('detail-alamat').innerText = data.alamat || '-';
window.dispatchEvent(new CustomEvent('open-modal', {
detail: 'detail-kantor'
}));
window.dispatchEvent(new CustomEvent('open-modal', { detail: 'detail-kantor' }));
setTimeout(() => {
initMap('detail', parseFloat(data.lat), parseFloat(data.long), parseInt(data.radius));
}, 300);
}
};
setupRadiusListeners();
@ -229,7 +245,12 @@
window.__kantorTurboAttached = true;
document.addEventListener('turbo:before-cache', function () {
cleanupMaps();
['create', 'edit', 'detail'].forEach(t => {
maps[t] = null;
markers[t] = null;
circles[t] = null;
autocompletes[t] = null;
});
});
document.addEventListener('turbo:load', function () {

View File

@ -3,29 +3,143 @@
@section('title', 'Data Kantor')
@section('style')
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
<link rel="stylesheet" href="https://unpkg.com/leaflet-control-geocoder@2.4.0/dist/Control.Geocoder.css" />
<style>
#map-create,
#map-edit {
#map-edit,
#map-detail {
height: 300px !important;
width: 100% !important;
border-radius: 0.75rem;
z-index: 1;
border: 1px solid #e2e8f0;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
}
.map-search-wrapper {
position: relative;
margin-bottom: 10px;
}
.map-search-wrapper svg {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
color: #94a3b8;
pointer-events: none;
z-index: 2;
}
.map-search-input {
width: 100%;
padding: 11px 14px 11px 40px;
border: 1px solid #e2e8f0;
border-radius: 0.75rem;
font-size: 14px;
outline: none;
transition: all 0.25s ease;
background: #f8fafc;
color: #334155;
}
.map-search-input::placeholder {
color: #94a3b8;
}
.map-search-input:focus {
border-color: #3b82f6;
background: white;
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
}
.pac-container {
z-index: 99999 !important;
border-radius: 0.75rem;
border: 1px solid #e2e8f0;
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
margin-top: 4px;
overflow: hidden;
font-family: inherit;
}
.pac-item {
padding: 10px 14px;
cursor: pointer;
border-top: 1px solid #f1f5f9;
font-size: 13px;
line-height: 1.5;
}
.pac-item:first-child {
border-top: none;
}
.pac-item:hover {
background: #f0f9ff;
}
.pac-item-selected {
background: #eff6ff;
}
.pac-icon {
margin-right: 8px;
}
.pac-item-query {
font-size: 13px;
color: #1e293b;
font-weight: 500;
}
.map-section-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #475569;
margin-bottom: 10px;
}
.map-section-label svg {
width: 16px;
height: 16px;
color: #3b82f6;
}
.coord-chip {
display: inline-flex;
align-items: center;
gap: 4px;
font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
font-size: 11px;
padding: 4px 10px;
border-radius: 8px;
background: linear-gradient(135deg, #f8fafc, #f1f5f9);
border: 1px solid #e2e8f0;
color: #475569;
}
.coord-chip svg {
width: 12px;
height: 12px;
color: #3b82f6;
}
.detail-info-card {
background: #f8fafc;
border-radius: 12px;
padding: 14px 16px;
border: 1px solid #f1f5f9;
}
.detail-info-label {
font-size: 10px;
font-weight: 700;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 4px;
}
.detail-info-value {
font-size: 14px;
font-weight: 600;
color: #1e293b;
}
</style>
@endsection
@section('script')
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script src="https://unpkg.com/leaflet-control-geocoder@2.4.0/dist/Control.Geocoder.js"></script>
<script src="https://maps.googleapis.com/maps/api/js?key={{ config('services.google_maps.key') }}&libraries=places"></script>
<script>
window.kantorUpdateUrl = "{{ route('kantor.update', ':id') }}";
</script>
<script src="{{ asset('js/kantor-map.js') }}"></script>
<script src="{{ asset('js/kantor-map.js') }}?v={{ time() }}"></script>
@endsection
@section('content')
@ -60,10 +174,13 @@ class="px-5 py-2.5 bg-primary text-white text-sm font-semibold rounded-xl hover:
</div>
</td>
<td class="px-6 py-4 text-left">
<code
class="text-[11px] font-mono bg-slate-100 px-2 py-1 rounded border border-slate-200 text-slate-600">
{{ number_format($k->latitude, 5) }}, {{ number_format($k->longitude, 5) }}
</code>
<span class="coord-chip">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
{{ number_format($k->latitude, 5) }}, {{ number_format($k->longitude, 5) }}
</span>
</td>
<td class="px-6 py-4 text-center">
<x-badge color="blue">
@ -108,11 +225,24 @@ class="p-2 bg-slate-100 text-slate-600 rounded-lg hover:bg-slate-200 transition"
</div>
</div>
{{-- Modal Tambah Kantor --}}
<x-modal name="create-kantor" title="Tambah Lokasi Kantor">
<form action="{{ route('kantor.store') }}" method="POST">
@csrf
<div class="mb-4">
<label class="block text-sm font-semibold text-slate-700 mb-2">Tentukan Lokasi</label>
<div class="map-section-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
Tentukan Lokasi
</div>
<div class="map-search-wrapper">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
<input type="text" id="search-create" class="map-search-input" placeholder="Cari lokasi... (contoh: Jl. Merdeka, Malang)" autocomplete="off">
</div>
<div id="map-create"></div>
</div>
<div class="flex flex-col md:flex-row gap-4">
@ -123,8 +253,8 @@ class="p-2 bg-slate-100 text-slate-600 rounded-lg hover:bg-slate-200 transition"
</x-select></div>
</div>
<div class="flex flex-col md:flex-row gap-4">
<div class="flex-1"><x-input label="Lat" name="latitude" id="create-lat" readonly required /></div>
<div class="flex-1"><x-input label="Long" name="longitude" id="create-long" readonly required /></div>
<div class="flex-1"><x-input label="Latitude" name="latitude" id="create-lat" readonly required /></div>
<div class="flex-1"><x-input label="Longitude" name="longitude" id="create-long" readonly required /></div>
<div class="w-full md:w-32"><x-input type="number" label="Radius (m)" name="radius" id="create-radius" value="50" required /></div>
</div>
<x-textarea label="Alamat" name="alamat" rows="2" />
@ -137,10 +267,17 @@ class="px-4 py-2 text-sm bg-primary text-white rounded-xl hover:bg-primary/90">S
</form>
</x-modal>
{{-- Modal Edit Kantor --}}
<x-modal name="edit-kantor" title="Edit Data Kantor">
<form id="form-edit" action="#" method="POST">
@csrf @method('PUT')
<div class="mb-4">
<div class="map-search-wrapper">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
<input type="text" id="search-edit" class="map-search-input" placeholder="Cari lokasi..." autocomplete="off">
</div>
<div id="map-edit"></div>
</div>
<div class="flex flex-col md:flex-row gap-4">
@ -151,8 +288,8 @@ class="px-4 py-2 text-sm bg-primary text-white rounded-xl hover:bg-primary/90">S
</x-select></div>
</div>
<div class="flex flex-col md:flex-row gap-4">
<div class="flex-1"><x-input label="Lat" name="latitude" id="edit-lat" readonly required /></div>
<div class="flex-1"><x-input label="Long" name="longitude" id="edit-long" readonly required /></div>
<div class="flex-1"><x-input label="Latitude" name="latitude" id="edit-lat" readonly required /></div>
<div class="flex-1"><x-input label="Longitude" name="longitude" id="edit-long" readonly required /></div>
<div class="w-full md:w-32"><x-input type="number" label="Radius (m)" name="radius" id="edit-radius" required /></div>
</div>
<x-textarea label="Alamat" name="alamat" id="edit-alamat" rows="2" />
@ -164,38 +301,44 @@ class="px-4 py-2 text-sm bg-amber-500 text-white rounded-xl hover:bg-amber-600">
</div>
</form>
</x-modal>
{{-- Modal Detail Kantor --}}
<x-modal name="detail-kantor" title="Detail Lokasi Kantor">
<div class="p-1 space-y-4">
<div id="map-detail" class="h-64 rounded-xl border border-slate-200 mb-4"></div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Nama Kantor</label>
<p id="detail-nama" class="text-slate-800 font-bold"></p>
<div class="space-y-4">
<div id="map-detail" class="rounded-xl border border-slate-200"></div>
<div class="grid grid-cols-2 gap-3">
<div class="detail-info-card">
<div class="detail-info-label">Nama Kantor</div>
<p id="detail-nama" class="detail-info-value"></p>
</div>
<div>
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Tipe</label>
<p id="detail-tipe" class="text-slate-800"></p>
<div class="detail-info-card">
<div class="detail-info-label">Tipe Kantor</div>
<p id="detail-tipe" class="detail-info-value"></p>
</div>
</div>
<div class="grid grid-cols-3 gap-4">
<div>
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Latitude</label>
<p id="detail-lat" class="text-slate-800 font-mono text-xs"></p>
<div class="grid grid-cols-3 gap-3">
<div class="detail-info-card">
<div class="detail-info-label">Latitude</div>
<p id="detail-lat" class="detail-info-value font-mono text-xs"></p>
</div>
<div>
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Longitude</label>
<p id="detail-long" class="text-slate-800 font-mono text-xs"></p>
<div class="detail-info-card">
<div class="detail-info-label">Longitude</div>
<p id="detail-long" class="detail-info-value font-mono text-xs"></p>
</div>
<div>
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Radius</label>
<p id="detail-radius" class="text-slate-800"></p>
<div class="detail-info-card">
<div class="detail-info-label">Radius</div>
<p id="detail-radius" class="detail-info-value text-blue-600"></p>
</div>
</div>
<div>
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Alamat</label>
<p id="detail-alamat" class="text-slate-700 text-sm leading-relaxed"></p>
<div class="detail-info-card">
<div class="detail-info-label">Alamat Lengkap</div>
<p id="detail-alamat" class="text-slate-700 text-sm leading-relaxed mt-1"></p>
</div>
<div class="flex justify-end pt-4 border-t border-slate-100">
<div class="flex justify-end pt-3 border-t border-slate-100">
<button type="button" x-data @click="$dispatch('close-modal', 'detail-kantor')"
class="px-5 py-2 text-sm bg-slate-800 text-white rounded-xl hover:bg-slate-700 transition shadow-sm">Tutup</button>
</div>

View File

@ -1,4 +1,4 @@
@extends('layouts.app')
@extends('layouts.app')
@section('title', 'Pengaturan Akun')
@ -45,8 +45,7 @@ class="absolute bottom-0 right-0 p-2 bg-white rounded-full shadow-lg border bord
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
</label>
<input type="file" id="foto-input" name="foto" class="hidden" accept="image/*"
onchange="previewImage(this)">
<input type="file" id="foto-input" name="foto" class="hidden" accept="image/*">
</div>
<div class="text-center">
<h3 class="font-bold text-slate-800">{{ auth()->user()->nama_lengkap }}</h3>
@ -86,27 +85,121 @@ class="px-6 py-2.5 bg-primary text-white font-semibold rounded-xl hover:bg-prima
</div>
</div>
<script>
function previewImage(input) {
if (input.files && input.files[0]) {
var reader = new FileReader();
reader.onload = function (e) {
const preview = document.getElementById('preview-foto');
const previewNew = document.getElementById('preview-foto-new');
const placeholder = document.getElementById('placeholder-foto');
{{-- Modal Crop Foto --}}
<div id="crop-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-gray-500/50 p-4">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden" onclick="event.stopPropagation()">
{{-- Header --}}
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-100">
<div>
<h3 class="text-lg font-bold text-slate-800">Atur Foto Profil</h3>
<p class="text-xs text-slate-500 mt-0.5">Geser dan zoom untuk mengatur posisi foto</p>
</div>
<button id="crop-cancel-btn" type="button"
class="p-2 rounded-lg hover:bg-slate-100 transition text-slate-400 hover:text-slate-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
if (preview) {
preview.src = e.target.result;
} else {
if (placeholder) placeholder.classList.add('hidden');
if (previewNew) {
previewNew.src = e.target.result;
previewNew.classList.remove('hidden');
}
}
}
reader.readAsDataURL(input.files[0]);
}
{{-- Crop Area --}}
<div class="relative bg-slate-900 flex items-center justify-center" style="height: 380px;">
<img id="crop-image" src="" alt="Crop Preview" class="max-w-full max-h-full">
</div>
{{-- Toolbar --}}
<div class="flex items-center justify-center gap-2 px-6 py-3 bg-slate-50 border-t border-b border-slate-100">
<button id="crop-rotate-left" type="button"
class="p-2.5 rounded-xl hover:bg-white hover:shadow-sm transition text-slate-500 hover:text-slate-700"
title="Putar Kiri">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h1l1-2m0 0l2-2m-2 2l2 2m6-8a9 9 0 11-4.2 1.1"></path>
</svg>
</button>
<button id="crop-rotate-right" type="button"
class="p-2.5 rounded-xl hover:bg-white hover:shadow-sm transition text-slate-500 hover:text-slate-700"
title="Putar Kanan">
<svg class="w-5 h-5 transform scale-x-[-1]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h1l1-2m0 0l2-2m-2 2l2 2m6-8a9 9 0 11-4.2 1.1"></path>
</svg>
</button>
<div class="w-px h-6 bg-slate-200 mx-1"></div>
<button id="crop-zoom-in" type="button"
class="p-2.5 rounded-xl hover:bg-white hover:shadow-sm transition text-slate-500 hover:text-slate-700"
title="Zoom In">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v6m3-3H7"></path>
</svg>
</button>
<button id="crop-zoom-out" type="button"
class="p-2.5 rounded-xl hover:bg-white hover:shadow-sm transition text-slate-500 hover:text-slate-700"
title="Zoom Out">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM13 10H7"></path>
</svg>
</button>
<div class="w-px h-6 bg-slate-200 mx-1"></div>
<button id="crop-reset" type="button"
class="p-2.5 rounded-xl hover:bg-white hover:shadow-sm transition text-slate-500 hover:text-slate-700"
title="Reset">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
</button>
</div>
{{-- Footer --}}
<div class="flex items-center justify-end gap-3 px-6 py-4">
<button id="crop-cancel-btn-footer" type="button"
class="px-5 py-2.5 text-sm font-semibold text-slate-600 bg-slate-100 rounded-xl hover:bg-slate-200 transition"
onclick="document.getElementById('crop-cancel-btn').click()">
Batal
</button>
<button id="crop-save-btn" type="button"
class="inline-flex items-center px-5 py-2.5 text-sm font-semibold text-white bg-primary rounded-xl hover:bg-primary/90 transition shadow-lg shadow-indigo-500/20">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
Simpan
</button>
</div>
</div>
</div>
{{-- Cropper.js CDN --}}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.js"></script>
<script src="{{ asset('js/image-cropper.js') }}"></script>
{{-- Cropper circular overlay --}}
<style>
.cropper-view-box,
.cropper-face {
border-radius: 50%;
}
</script>
.cropper-view-box {
outline: 3px solid rgba(255, 255, 255, 0.8);
outline-offset: 0;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
}
.cropper-face {
background-color: transparent !important;
}
.cropper-dashed,
.cropper-point,
.cropper-line {
display: none !important;
}
.cropper-crop-box {
border-radius: 50%;
}
.cropper-modal {
background-color: rgba(15, 23, 42, 0.85);
}
</style>
@endsection