feat(project-form): wire the rich data form (labels-left) + edit tabs
The edit/create project page used a stripped-down inline form. Rewired it to the existing-but-orphaned pieces: - Project Data uses the rich partial project-data-form (labels-left/field-right layout, sections Identification/Location/Planning, address search + Leaflet map with draggable marker + reverse/forward geocoding, country dropdown) - When editing, tabs added for Phases / Users / Companies (nested Livewire components phase-list / project-users / project-companies) - ProjectForm now provides $countryList (the partial's country dropdown needs it) - Added the map JS the partial was missing: inits #project-location-map, search box, and calls $this->setLocation(lat,lng,address,country) so the wire:model fields update Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -114,6 +114,28 @@ class ProjectForm extends Component
|
|||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.projects.project-form');
|
return view('livewire.projects.project-form', [
|
||||||
|
'countryList' => $this->countryList(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ISO alpha-2 (lowercase, matches flagcdn) => display name.
|
||||||
|
*/
|
||||||
|
private function countryList(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'es' => 'España', 'pt' => 'Portugal', 'fr' => 'Francia', 'it' => 'Italia',
|
||||||
|
'de' => 'Alemania', 'gb' => 'Reino Unido', 'ie' => 'Irlanda', 'nl' => 'Países Bajos',
|
||||||
|
'be' => 'Bélgica', 'ch' => 'Suiza', 'at' => 'Austria', 'lu' => 'Luxemburgo',
|
||||||
|
'se' => 'Suecia', 'no' => 'Noruega', 'dk' => 'Dinamarca', 'fi' => 'Finlandia',
|
||||||
|
'pl' => 'Polonia', 'cz' => 'Chequia', 'gr' => 'Grecia', 'ro' => 'Rumanía',
|
||||||
|
'us' => 'Estados Unidos', 'ca' => 'Canadá', 'mx' => 'México', 'gt' => 'Guatemala',
|
||||||
|
'cr' => 'Costa Rica', 'pa' => 'Panamá', 'co' => 'Colombia', 've' => 'Venezuela',
|
||||||
|
'ec' => 'Ecuador', 'pe' => 'Perú', 'bo' => 'Bolivia', 'cl' => 'Chile',
|
||||||
|
'ar' => 'Argentina', 'uy' => 'Uruguay', 'py' => 'Paraguay', 'br' => 'Brasil',
|
||||||
|
'do' => 'República Dominicana', 'ma' => 'Marruecos', 'gq' => 'Guinea Ecuatorial',
|
||||||
|
'ao' => 'Angola', 'cv' => 'Cabo Verde', 'us' => 'Estados Unidos',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,173 +1,138 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="max-w-2xl mx-auto p-4">
|
<div class="max-w-4xl mx-auto p-4">
|
||||||
<h1 class="text-2xl font-bold mb-6">{{ $project ? __('Edit Project') : __('New Project') }}</h1>
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h1 class="text-2xl font-bold">{{ $project ? __('Edit Project') : __('New Project') }}</h1>
|
||||||
|
<a href="{{ route('projects.index') }}" class="btn btn-ghost btn-sm gap-1" wire:navigate>
|
||||||
|
<x-heroicon-o-arrow-left class="w-4 h-4" /> {{ __('Back') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form wire:submit.prevent="save" class="space-y-6">
|
@if($project)
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
{{-- Editor con pestañas para el resto de parámetros del proyecto --}}
|
||||||
<div>
|
<div x-data="{ tab: 'data' }">
|
||||||
<label class="block text-sm font-medium mb-2">{{ __('Name') }}</label>
|
<div class="flex flex-wrap gap-1 mb-4">
|
||||||
<input type="text" wire:model="name" class="input input-bordered w-full" placeholder="{{ __('Project name') }}" required>
|
<button type="button" @click="tab='data'" :class="tab==='data' ? 'btn-primary' : 'btn-ghost'" class="btn btn-sm">{{ __('Project Data') }}</button>
|
||||||
|
<button type="button" @click="tab='phases'" :class="tab==='phases' ? 'btn-primary' : 'btn-ghost'" class="btn btn-sm">{{ __('Phases') }}</button>
|
||||||
|
<button type="button" @click="tab='users'" :class="tab==='users' ? 'btn-primary' : 'btn-ghost'" class="btn btn-sm">{{ __('Users') }}</button>
|
||||||
|
<button type="button" @click="tab='companies'" :class="tab==='companies' ? 'btn-primary' : 'btn-ghost'" class="btn btn-sm">{{ __('Companies') }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-2">{{ __('Address') }}</label>
|
<div x-show="tab==='data'">
|
||||||
<input type="text" wire:model="address" class="input input-bordered w-full" placeholder="{{ __('Address') }}">
|
@include('livewire.projects.partials.project-data-form')
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div x-show="tab==='phases'" x-cloak>
|
||||||
<label class="block text-sm font-medium mb-2">{{ __('Coordinates') }}</label>
|
<livewire:phase-list :project="$project" :key="'phases-'.$project->id" />
|
||||||
<input type="text" wire:model="country" class="input input-bordered w-full" placeholder="{{ __('No country') }}" readonly>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="md:col-span-2">
|
<div x-show="tab==='users'" x-cloak>
|
||||||
<label class="block text-sm font-medium mb-2">{{ __('Start date') }}</label>
|
<livewire:project-users :project="$project" :key="'users-'.$project->id" />
|
||||||
<input type="date" wire:model="start_date" class="input input-bordered w-full" required>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="md:col-span-2">
|
<div x-show="tab==='companies'" x-cloak>
|
||||||
<label class="block text-sm font-medium mb-2">{{ __('Estimated end date') }}</label>
|
<livewire:project-companies :project="$project" :key="'companies-'.$project->id" />
|
||||||
<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>
|
</div>
|
||||||
|
@else
|
||||||
<div class="border rounded-lg p-4">
|
{{-- Alta de proyecto: solo el formulario de datos --}}
|
||||||
<h2 class="text-xl font-bold mb-4">{{ __('Location') }}</h2>
|
@include('livewire.projects.partials.project-data-form')
|
||||||
<p class="text-sm text-gray-500 mb-2">
|
|
||||||
{{ __('Click on the map or drag the marker to update the location') }}
|
|
||||||
</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">
|
|
||||||
{{ __('Cancel') }}
|
|
||||||
</button>
|
|
||||||
<button type="submit" class="btn btn-primary">
|
|
||||||
{{ $project ? __('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
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
@push('scripts')
|
@push('scripts')
|
||||||
<script>
|
<script>
|
||||||
let map;
|
(function () {
|
||||||
let marker;
|
let pmap = null, pmarker = null;
|
||||||
|
|
||||||
// Initialize Leaflet map
|
function setStatus(msg) {
|
||||||
function initMap() {
|
const s = document.getElementById('geocode-status');
|
||||||
if (map) return;
|
if (s) s.textContent = msg || '';
|
||||||
|
|
||||||
// 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
|
function placeMarker(lat, lng) {
|
||||||
map.on('click', function(e) {
|
if (!pmap) return;
|
||||||
const pos = e.latlng;
|
if (pmarker) {
|
||||||
if (marker) {
|
pmarker.setLatLng([lat, lng]);
|
||||||
marker.setLatLng(pos);
|
|
||||||
} else {
|
} else {
|
||||||
marker = L.marker(pos, {
|
pmarker = L.marker([lat, lng], { draggable: true }).addTo(pmap);
|
||||||
draggable: true
|
pmarker.on('dragend', () => {
|
||||||
}).addTo(map);
|
const p = pmarker.getLatLng();
|
||||||
|
pickLocation(p.lat, p.lng);
|
||||||
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
|
async function pickLocation(lat, lng) {
|
||||||
document.addEventListener('Livewire:load', function() {
|
setStatus('{{ __('Loading...') }}');
|
||||||
initMap();
|
let address = '', country = '';
|
||||||
});
|
try {
|
||||||
|
const r = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&zoom=18&addressdetails=1`);
|
||||||
|
if (r.ok) {
|
||||||
|
const d = await r.json();
|
||||||
|
address = d.display_name || '';
|
||||||
|
country = (d.address && d.address.country_code) ? d.address.country_code : '';
|
||||||
|
}
|
||||||
|
} catch (e) { /* geocoding optional */ }
|
||||||
|
@this.setLocation(String(lat), String(lng), address, country);
|
||||||
|
setStatus('');
|
||||||
|
}
|
||||||
|
|
||||||
// Also initialize on DOMContentLoaded as fallback
|
async function searchLocation(q) {
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
if (!q || !q.trim() || !pmap) return;
|
||||||
initMap();
|
setStatus('{{ __('Searching...') }}');
|
||||||
});
|
try {
|
||||||
</script>
|
const r = await fetch(`https://nominatim.openstreetmap.org/search?format=json&limit=1&addressdetails=1&q=${encodeURIComponent(q)}`);
|
||||||
@endpush
|
const arr = await r.json();
|
||||||
|
if (arr && arr.length) {
|
||||||
|
const lat = parseFloat(arr[0].lat), lng = parseFloat(arr[0].lon);
|
||||||
|
pmap.setView([lat, lng], 16);
|
||||||
|
placeMarker(lat, lng);
|
||||||
|
const address = arr[0].display_name || '';
|
||||||
|
const country = (arr[0].address && arr[0].address.country_code) ? arr[0].address.country_code : '';
|
||||||
|
@this.setLocation(String(lat), String(lng), address, country);
|
||||||
|
setStatus('');
|
||||||
|
} else {
|
||||||
|
setStatus('{{ __('No results') }}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setStatus('{{ __('No results') }}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initProjectLocationMap() {
|
||||||
|
const el = document.getElementById('project-location-map');
|
||||||
|
if (!el || el._leafletInit) return;
|
||||||
|
el._leafletInit = true;
|
||||||
|
|
||||||
|
const dLat = parseFloat(el.dataset.lat);
|
||||||
|
const dLng = parseFloat(el.dataset.lng);
|
||||||
|
const hasCoords = !isNaN(dLat) && !isNaN(dLng) && (dLat !== 0 || dLng !== 0);
|
||||||
|
const lat = hasCoords ? dLat : 40.4168;
|
||||||
|
const lng = hasCoords ? dLng : -3.7038;
|
||||||
|
|
||||||
|
pmap = L.map('project-location-map').setView([lat, lng], hasCoords ? 16 : 5);
|
||||||
|
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(pmap);
|
||||||
|
|
||||||
|
if (hasCoords) placeMarker(lat, lng);
|
||||||
|
|
||||||
|
pmap.on('click', (e) => {
|
||||||
|
placeMarker(e.latlng.lat, e.latlng.lng);
|
||||||
|
pickLocation(e.latlng.lat, e.latlng.lng);
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = document.getElementById('map-search-input');
|
||||||
|
const btn = document.getElementById('map-search-btn');
|
||||||
|
if (btn) btn.addEventListener('click', () => searchLocation(input ? input.value : ''));
|
||||||
|
if (input) input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); searchLocation(input.value); }
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => pmap.invalidateSize(), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('livewire:navigated', initProjectLocationMap);
|
||||||
|
document.addEventListener('DOMContentLoaded', initProjectLocationMap);
|
||||||
|
setTimeout(initProjectLocationMap, 300);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user