420 lines
19 KiB
PHP
420 lines
19 KiB
PHP
@extends('admin.layouts.app')
|
|
|
|
@section('title', 'Setting Absen')
|
|
|
|
@push('styles')
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
|
|
<style>
|
|
.soft-card {
|
|
border: 0;
|
|
border-radius: 16px;
|
|
box-shadow: 0 10px 30px rgba(16, 24, 40, .06);
|
|
}
|
|
|
|
.btn-simpan {
|
|
background: #0f766e;
|
|
color: #fff;
|
|
}
|
|
|
|
.btn-simpan:hover {
|
|
background: #0f766e;
|
|
color: #fff;
|
|
opacity: .92;
|
|
}
|
|
|
|
.workday-item {
|
|
min-width: 120px;
|
|
}
|
|
|
|
.map-card {
|
|
overflow: hidden;
|
|
}
|
|
|
|
#attendance-map {
|
|
min-height: 420px;
|
|
border-radius: 16px;
|
|
border: 1px solid #dbe4ea;
|
|
}
|
|
|
|
.map-note {
|
|
color: #64748b;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.metric-box {
|
|
border: 1px solid #dbe4ea;
|
|
border-radius: 14px;
|
|
padding: 14px 16px;
|
|
background: #f8fafc;
|
|
height: 100%;
|
|
}
|
|
|
|
.metric-label {
|
|
font-size: 12px;
|
|
color: #64748b;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.metric-value {
|
|
font-size: 20px;
|
|
font-weight: 700;
|
|
color: #0f172a;
|
|
}
|
|
|
|
.leaflet-control-layers {
|
|
border-radius: 12px !important;
|
|
box-shadow: 0 10px 30px rgba(16, 24, 40, .12) !important;
|
|
border: 1px solid #dbe4ea !important;
|
|
}
|
|
</style>
|
|
@endpush
|
|
|
|
@php
|
|
$effectiveWorkdays = old('effective_workdays', $setting->effective_workdays ?? [1, 2, 3, 4, 5]);
|
|
$effectiveWorkdays = collect($effectiveWorkdays)->map(fn($d) => (int) $d)->all();
|
|
|
|
$dayLabels = [
|
|
1 => 'Senin',
|
|
2 => 'Selasa',
|
|
3 => 'Rabu',
|
|
4 => 'Kamis',
|
|
5 => 'Jumat',
|
|
6 => 'Sabtu',
|
|
7 => 'Minggu',
|
|
];
|
|
|
|
$toTimeValue = static function ($value, $fallback = '') {
|
|
if (empty($value)) {
|
|
return $fallback;
|
|
}
|
|
|
|
return \Illuminate\Support\Str::of((string) $value)->substr(0, 5)->value();
|
|
};
|
|
|
|
$lat = old('office_latitude', $setting->office_latitude ?? -7.5992153);
|
|
$lng = old('office_longitude', $setting->office_longitude ?? 112.1035051);
|
|
$radius = old('attendance_radius_meters', $setting->attendance_radius_meters ?? 90);
|
|
@endphp
|
|
|
|
@section('content')
|
|
<div class="row g-4">
|
|
<div class="col-12 col-xl-5">
|
|
<div class="card soft-card h-100">
|
|
<div class="card-body">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<div>
|
|
<h4 class="mb-0">Setting Absensi</h4>
|
|
<small style="color:#0f766e">Pengaturan waktu, aturan, dan radius lokasi absensi</small>
|
|
</div>
|
|
</div>
|
|
|
|
@if (session('success'))
|
|
<div class="alert alert-success">{{ session('success') }}</div>
|
|
@endif
|
|
|
|
@if ($errors->any())
|
|
<div class="alert alert-danger">
|
|
<ul class="mb-0">
|
|
@foreach ($errors->all() as $error)
|
|
<li>{{ $error }}</li>
|
|
@endforeach
|
|
</ul>
|
|
</div>
|
|
@endif
|
|
|
|
<form action="{{ route('admin.attendance.setting.store') }}" method="POST">
|
|
@csrf
|
|
|
|
<div class="row g-3">
|
|
<div class="col-12 pt-3">
|
|
<h6 class="mb-1">Lokasi dan Radius</h6>
|
|
<small class="map-note">Titik ini dipakai sebagai pusat validasi absensi perangkat.</small>
|
|
</div>
|
|
|
|
<div class="col-12">
|
|
<label class="form-label">Latitude Kantor/Desa</label>
|
|
<input type="number" step="0.0000001" name="office_latitude" id="office_latitude"
|
|
class="form-control @error('office_latitude') is-invalid @enderror"
|
|
value="{{ $lat }}" placeholder="-7.5992153">
|
|
@error('office_latitude')
|
|
<div class="invalid-feedback">{{ $message }}</div>
|
|
@enderror
|
|
</div>
|
|
|
|
<div class="col-12">
|
|
<label class="form-label">Longitude Kantor/Desa</label>
|
|
<input type="number" step="0.0000001" name="office_longitude" id="office_longitude"
|
|
class="form-control @error('office_longitude') is-invalid @enderror"
|
|
value="{{ $lng }}" placeholder="112.1035051">
|
|
@error('office_longitude')
|
|
<div class="invalid-feedback">{{ $message }}</div>
|
|
@enderror
|
|
</div>
|
|
|
|
<div class="col-12">
|
|
<label class="form-label">Radius Absensi (meter)</label>
|
|
<input type="range" min="10" max="5000" step="10" id="attendance_radius_slider"
|
|
class="form-range" value="{{ $radius }}">
|
|
<input type="number" min="10" max="5000" step="10" name="attendance_radius_meters"
|
|
id="attendance_radius_meters"
|
|
class="form-control @error('attendance_radius_meters') is-invalid @enderror"
|
|
value="{{ $radius }}">
|
|
@error('attendance_radius_meters')
|
|
<div class="invalid-feedback">{{ $message }}</div>
|
|
@enderror
|
|
<div class="map-note mt-2">Klik peta untuk memindahkan titik pusat. Marker juga bisa digeser.</div>
|
|
</div>
|
|
|
|
<div class="col-12">
|
|
<div class="row g-3">
|
|
<div class="col-6">
|
|
<div class="metric-box">
|
|
<div class="metric-label">Radius Aktif</div>
|
|
<div class="metric-value"><span id="radius-preview">{{ (int) $radius }}</span> m</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6">
|
|
<div class="metric-box">
|
|
<div class="metric-label">Titik Pusat</div>
|
|
<div class="metric-value" id="coordinate-preview">
|
|
{{ number_format((float) $lat, 5) }}, {{ number_format((float) $lng, 5) }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<div class="col-12 mt-5">
|
|
<h6 class="mb-1">Waktu Absensi</h6>
|
|
<small class="map-note">Atur jam check-in, check-out, dan kebijakan keterlambatan.</small>
|
|
</div>
|
|
|
|
<div class="col-12 col-md-4">
|
|
<label class="form-label">Mulai Check-in</label>
|
|
<input type="time" name="checkin_start"
|
|
class="form-control @error('checkin_start') is-invalid @enderror"
|
|
value="{{ old('checkin_start', $toTimeValue($setting->checkin_start, '07:00')) }}">
|
|
@error('checkin_start')
|
|
<div class="invalid-feedback">{{ $message }}</div>
|
|
@enderror
|
|
</div>
|
|
|
|
<div class="col-12 col-md-4">
|
|
<label class="form-label">Batas Check-in</label>
|
|
<input type="time" name="checkin_end"
|
|
class="form-control @error('checkin_end') is-invalid @enderror"
|
|
value="{{ old('checkin_end', $toTimeValue($setting->checkin_end, '09:00')) }}">
|
|
@error('checkin_end')
|
|
<div class="invalid-feedback">{{ $message }}</div>
|
|
@enderror
|
|
</div>
|
|
|
|
<div class="col-12 col-md-4">
|
|
<label class="form-label">Mulai Check-out</label>
|
|
<input type="time" name="checkout_start"
|
|
class="form-control @error('checkout_start') is-invalid @enderror"
|
|
value="{{ old('checkout_start', $toTimeValue($setting->checkout_start, '15:00')) }}">
|
|
@error('checkout_start')
|
|
<div class="invalid-feedback">{{ $message }}</div>
|
|
@enderror
|
|
</div>
|
|
|
|
<div class="col-12 col-md-4">
|
|
<label class="form-label">Toleransi Telat (menit)</label>
|
|
<input type="number" min="0" max="1440" name="late_grace_minutes"
|
|
class="form-control @error('late_grace_minutes') is-invalid @enderror"
|
|
value="{{ old('late_grace_minutes', $setting->late_grace_minutes ?? 0) }}">
|
|
@error('late_grace_minutes')
|
|
<div class="invalid-feedback">{{ $message }}</div>
|
|
@enderror
|
|
</div>
|
|
|
|
<div class="col-12">
|
|
<label class="form-label d-block mb-2">Hari Kerja Efektif</label>
|
|
<div class="d-flex flex-wrap gap-3">
|
|
@foreach ($dayLabels as $dayNumber => $dayLabel)
|
|
<div class="form-check workday-item">
|
|
<input class="form-check-input" type="checkbox" name="effective_workdays[]"
|
|
value="{{ $dayNumber }}" id="day-{{ $dayNumber }}"
|
|
{{ in_array($dayNumber, $effectiveWorkdays, true) ? 'checked' : '' }}>
|
|
<label class="form-check-label" for="day-{{ $dayNumber }}">
|
|
{{ $dayLabel }}
|
|
</label>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-12 col-md-6">
|
|
<div class="form-check form-switch">
|
|
<input class="form-check-input" type="checkbox" name="allow_checkin_after_end"
|
|
id="allow_checkin_after_end" value="1"
|
|
{{ old('allow_checkin_after_end', $setting->allow_checkin_after_end) ? 'checked' : '' }}>
|
|
<label class="form-check-label" for="allow_checkin_after_end">
|
|
Izinkan check-in setelah batas check-in
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-12 col-md-6">
|
|
<div class="form-check form-switch">
|
|
<input class="form-check-input" type="checkbox" name="require_checkout"
|
|
id="require_checkout" value="1"
|
|
{{ old('require_checkout', $setting->require_checkout) ? 'checked' : '' }}>
|
|
<label class="form-check-label" for="require_checkout">
|
|
Wajib check-out
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<div class="text-center mt-4">
|
|
<button type="submit" class="btn btn-simpan px-5">Simpan Pengaturan</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-12 col-xl-7">
|
|
<div class="card soft-card map-card h-100">
|
|
<div class="card-body">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<div>
|
|
<h5 class="mb-0">Peta Radius Absensi</h5>
|
|
<small class="map-note">Lingkaran menunjukkan area yang diizinkan untuk melakukan absensi.</small>
|
|
</div>
|
|
</div>
|
|
<div id="attendance-map"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
|
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const latInput = document.getElementById('office_latitude');
|
|
const lngInput = document.getElementById('office_longitude');
|
|
const radiusInput = document.getElementById('attendance_radius_meters');
|
|
const radiusSlider = document.getElementById('attendance_radius_slider');
|
|
const radiusPreview = document.getElementById('radius-preview');
|
|
const coordinatePreview = document.getElementById('coordinate-preview');
|
|
|
|
const getLatLng = () => ({
|
|
lat: Number.parseFloat(latInput.value || '-7.5992153'),
|
|
lng: Number.parseFloat(lngInput.value || '112.1035051'),
|
|
});
|
|
|
|
const formatCoordinate = (value) => Number.parseFloat(value).toFixed(5);
|
|
const initial = getLatLng();
|
|
const initialRadius = Number.parseInt(radiusInput.value || '90', 10);
|
|
|
|
const map = L.map('attendance-map', {
|
|
zoomControl: false,
|
|
});
|
|
|
|
const streetLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
maxZoom: 19,
|
|
attribution: '© OpenStreetMap contributors'
|
|
});
|
|
|
|
const topoLayer = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
|
|
maxZoom: 17,
|
|
attribution: 'Map data: © OpenStreetMap contributors, SRTM | Map style: © OpenTopoMap'
|
|
});
|
|
|
|
const hotLayer = L.tileLayer('https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', {
|
|
maxZoom: 20,
|
|
attribution: '© OpenStreetMap contributors, Tiles style by Humanitarian OpenStreetMap Team'
|
|
});
|
|
|
|
streetLayer.addTo(map);
|
|
|
|
L.control.layers({
|
|
'OSM Standard': streetLayer,
|
|
'OSM Topografi': topoLayer,
|
|
'OSM Humanitarian': hotLayer,
|
|
}, {}, {
|
|
collapsed: false
|
|
}).addTo(map);
|
|
|
|
const marker = L.marker([initial.lat, initial.lng], {
|
|
draggable: true
|
|
}).addTo(map);
|
|
|
|
const circle = L.circle([initial.lat, initial.lng], {
|
|
radius: initialRadius,
|
|
color: '#0f766e',
|
|
fillColor: '#14b8a6',
|
|
fillOpacity: 0.18,
|
|
}).addTo(map);
|
|
|
|
map.setView([initial.lat, initial.lng], 17);
|
|
|
|
function syncPreview(lat, lng, radius) {
|
|
coordinatePreview.textContent = `${formatCoordinate(lat)}, ${formatCoordinate(lng)}`;
|
|
radiusPreview.textContent = radius;
|
|
}
|
|
|
|
function updateMap(lat, lng, radius, moveView = false) {
|
|
marker.setLatLng([lat, lng]);
|
|
circle.setLatLng([lat, lng]);
|
|
circle.setRadius(radius);
|
|
syncPreview(lat, lng, radius);
|
|
|
|
if (moveView) {
|
|
map.setView([lat, lng], map.getZoom() < 17 ? 17 : map.getZoom());
|
|
}
|
|
}
|
|
|
|
function applyMarkerPosition(latlng, moveView = false) {
|
|
latInput.value = Number(latlng.lat).toFixed(7);
|
|
lngInput.value = Number(latlng.lng).toFixed(7);
|
|
updateMap(Number(latInput.value), Number(lngInput.value), Number(radiusInput.value), moveView);
|
|
}
|
|
|
|
function applyRadius(radius) {
|
|
const safeRadius = Math.max(10, Number.parseInt(radius || '90', 10));
|
|
radiusInput.value = safeRadius;
|
|
radiusSlider.value = safeRadius;
|
|
updateMap(Number.parseFloat(latInput.value), Number.parseFloat(lngInput.value), safeRadius, false);
|
|
}
|
|
|
|
marker.on('dragend', function(event) {
|
|
applyMarkerPosition(event.target.getLatLng(), false);
|
|
});
|
|
|
|
map.on('click', function(event) {
|
|
applyMarkerPosition(event.latlng, true);
|
|
});
|
|
|
|
[latInput, lngInput].forEach((input) => {
|
|
input.addEventListener('change', function() {
|
|
const lat = Number.parseFloat(latInput.value);
|
|
const lng = Number.parseFloat(lngInput.value);
|
|
if (Number.isFinite(lat) && Number.isFinite(lng)) {
|
|
updateMap(lat, lng, Number.parseInt(radiusInput.value || '90', 10), true);
|
|
}
|
|
});
|
|
});
|
|
|
|
radiusSlider.addEventListener('input', function() {
|
|
applyRadius(radiusSlider.value);
|
|
});
|
|
|
|
radiusInput.addEventListener('input', function() {
|
|
applyRadius(radiusInput.value);
|
|
});
|
|
|
|
syncPreview(initial.lat, initial.lng, initialRadius);
|
|
});
|
|
</script>
|
|
@endpush
|