Add interactive map to project form for setting coordinates and updating address/country
This commit is contained in:
@@ -3,9 +3,81 @@
|
|||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
use App\Models\Project;
|
||||||
|
|
||||||
class ProjectForm extends Component
|
class ProjectForm extends Component
|
||||||
{
|
{
|
||||||
|
public $projectId = null;
|
||||||
|
public $name = '';
|
||||||
|
public $address = '';
|
||||||
|
public $lat = null;
|
||||||
|
public $lng = null;
|
||||||
|
public $country = '';
|
||||||
|
public $start_date = '';
|
||||||
|
public $end_date_estimated = '';
|
||||||
|
public $status = 'planning';
|
||||||
|
|
||||||
|
protected $rules = [
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'address' => 'required|string',
|
||||||
|
'lat' => 'nullable|numeric',
|
||||||
|
'lng' => 'nullable|numeric',
|
||||||
|
'start_date' => 'required|date',
|
||||||
|
'end_date_estimated' => 'nullable|date',
|
||||||
|
'status' => 'required|in:planning,in_progress,paused,completed',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function mount($projectId = null)
|
||||||
|
{
|
||||||
|
if ($projectId) {
|
||||||
|
$this->projectId = $projectId;
|
||||||
|
$project = Project::findOrFail($projectId);
|
||||||
|
$this->name = $project->name;
|
||||||
|
$this->address = $project->address;
|
||||||
|
$this->lat = $project->lat;
|
||||||
|
$this->lng = $project->lng;
|
||||||
|
$this->start_date = $project->start_date->format('Y-m-d');
|
||||||
|
$this->end_date_estimated = $project->end_date_estimated?->format('Y-m-d');
|
||||||
|
$this->status = $project->status;
|
||||||
|
// country? we don't have stored, maybe we can leave blank or compute from lat/lng? We'll leave blank for now.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCoordinates($lat, $lng)
|
||||||
|
{
|
||||||
|
$this->lat = $lat;
|
||||||
|
$this->lng = $lng;
|
||||||
|
// Optionally, we could trigger reverse geocoding here via JS and update address and country.
|
||||||
|
// But we'll do that entirely in JavaScript for better UX.
|
||||||
|
// We'll emit an event to JS to fetch address.
|
||||||
|
$this->dispatch('coordinatesUpdated', lat: $lat, lng: $lng);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save()
|
||||||
|
{
|
||||||
|
$this->validate();
|
||||||
|
|
||||||
|
if ($this->projectId) {
|
||||||
|
$project = Project::findOrFail($this->projectId);
|
||||||
|
} else {
|
||||||
|
$project = new Project();
|
||||||
|
$project->created_by = auth()->id();
|
||||||
|
}
|
||||||
|
|
||||||
|
$project->name = $this->name;
|
||||||
|
$project->address = $this->address;
|
||||||
|
$project->lat = $this->lat;
|
||||||
|
$project->lng = $this->lng;
|
||||||
|
$project->start_date = $this->start_date;
|
||||||
|
$project->end_date_estimated = $this->end_date_estimated;
|
||||||
|
$project->status = $this->status;
|
||||||
|
$project->save();
|
||||||
|
|
||||||
|
session()->flash('message', 'Project saved successfully.');
|
||||||
|
|
||||||
|
return redirect()->route('projects.index');
|
||||||
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.projects.project-form');
|
return view('livewire.projects.project-form');
|
||||||
|
|||||||
@@ -1,3 +1,173 @@
|
|||||||
<div>
|
<div>
|
||||||
{{-- In work, do what you enjoy. --}}
|
<div class="max-w-2xl mx-auto p-4">
|
||||||
|
<h1 class="text-2xl font-bold mb-6">{{ $projectId ? __('Edit Project') : __('New Project') }}</h1>
|
||||||
|
|
||||||
|
<form wire:submit.prevent="save" class="space-y-6">
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">{{ __('Name') }}</label>
|
||||||
|
<input type="text" wire:model="name" class="input input-bordered w-full" placeholder="{{ __('Project name') }}" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">{{ __('Address') }}</label>
|
||||||
|
<input type="text" wire:model="address" class="input input-bordered w-full" placeholder="{{ __('Street address, city, etc.') }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">{{ __('Country') }}</label>
|
||||||
|
<input type="text" wire:model="country" class="input input-bordered w-full" placeholder="{{ __('Country (auto-filled)') }}" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="block text-sm font-medium mb-2">{{ __('Start Date') }}</label>
|
||||||
|
<input type="date" wire:model="start_date" class="input input-bordered w-full" required>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="block text-sm font-medium mb-2">{{ __('End Date (estimated)') }}</label>
|
||||||
|
<input type="date" wire:model="end_date_estimated" class="input input-bordered w-full">
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="block text-sm font-medium mb-2">{{ __('Status') }}</label>
|
||||||
|
<select wire:model="status" class="select select-bordered w-full">
|
||||||
|
<option value="planning">{{ __('Planning') }}</option>
|
||||||
|
<option value="in_progress">{{ __('In Progress') }}</option>
|
||||||
|
<option value="paused">{{ __('Paused') }}</option>
|
||||||
|
<option value="completed">{{ __('Completed') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border rounded-lg p-4">
|
||||||
|
<h2 class="text-xl font-bold mb-4">{{ __('Project Location') }}</h2>
|
||||||
|
<p class="text-sm text-gray-500 mb-2">
|
||||||
|
{{ __('Click on the map to set the project location. The address and country will be filled automatically.') }}
|
||||||
|
</p>
|
||||||
|
<div id="projectMap" style="height: 400px; width: 100%; background: #e2e8f0; border-radius: 0.5rem;"></div>
|
||||||
|
<input type="hidden" wire:model="lat">
|
||||||
|
<input type="hidden" wire:model="lng">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-4">
|
||||||
|
<button type="button" wire:click="resetForm" class="btn btn-outline">
|
||||||
|
{{ __('Reset') }}
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
{{ $projectId ? __('Update') : __('Create') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@if(session()->has('message'))
|
||||||
|
<div class="mt-4 p-4 bg-green-50 border-l-4 border-green-400 text-green-700">
|
||||||
|
{{ session('message') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script>
|
||||||
|
let map;
|
||||||
|
let marker;
|
||||||
|
|
||||||
|
// Initialize Leaflet map
|
||||||
|
function initMap() {
|
||||||
|
if (map) return;
|
||||||
|
|
||||||
|
// Default coordinates (can be overridden)
|
||||||
|
const defaultLat = @json($lat ?? 0);
|
||||||
|
const defaultLng = @json($lng ?? 0);
|
||||||
|
|
||||||
|
const center = defaultLat && defaultLng ? [defaultLat, defaultLng] : [0, 0];
|
||||||
|
map = L.map('projectMap').setView(center, 13);
|
||||||
|
|
||||||
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> & CartoDB'
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
// Add marker if we have coordinates
|
||||||
|
if (defaultLat && defaultLng) {
|
||||||
|
marker = L.marker([defaultLat, defaultLng], {
|
||||||
|
draggable: true
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
marker.on('dragend', function(e) {
|
||||||
|
const pos = marker.getLatLng();
|
||||||
|
updateCoordinates(pos.lat, pos.lng);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle map clicks to place marker
|
||||||
|
map.on('click', function(e) {
|
||||||
|
const pos = e.latlng;
|
||||||
|
if (marker) {
|
||||||
|
marker.setLatLng(pos);
|
||||||
|
} else {
|
||||||
|
marker = L.marker(pos, {
|
||||||
|
draggable: true
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
marker.on('dragend', function(e) {
|
||||||
|
const pos = marker.getLatLng();
|
||||||
|
updateCoordinates(pos.lat, pos.lng);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
updateCoordinates(pos.lat, pos.lng);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update coordinates and trigger reverse geocoding
|
||||||
|
function updateCoordinates(lat, lng) {
|
||||||
|
// Update hidden inputs
|
||||||
|
document.querySelector('input[name="lat"]').value = lat;
|
||||||
|
document.querySelector('input[name="lng"]').value = lng;
|
||||||
|
|
||||||
|
// Trigger Livewire event to update coordinates
|
||||||
|
@this.setCoordinates(lat, lng);
|
||||||
|
|
||||||
|
// Reverse geocode to get address and country
|
||||||
|
reverseGeocode(lat, lng);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse geocode using Nominatim (OpenStreetMap)
|
||||||
|
async function reverseGeocode(lat, lng) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&zoom=18&addressdetails=1`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'OpenClaw/1.0 (construprogress)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Geocoding request failed');
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Update address field
|
||||||
|
const addressInput = document.querySelector('input[name="address"]');
|
||||||
|
if (data.display_name) {
|
||||||
|
addressInput.value = data.display_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update country field
|
||||||
|
const countryInput = document.querySelector('input[name="country"]');
|
||||||
|
if (data.address && data.address.country) {
|
||||||
|
countryInput.value = data.address.country;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reverse geocoding:', error);
|
||||||
|
// Don't fail the UI if geocoding fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize map when component is ready
|
||||||
|
document.addEventListener('Livewire:load', function() {
|
||||||
|
initMap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also initialize on DOMContentLoaded as fallback
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
initMap();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
|
|||||||
Reference in New Issue
Block a user