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:
2026-06-17 13:42:42 +02:00
parent 75c07aa0d4
commit fe57388f05
2 changed files with 146 additions and 159 deletions
+23 -1
View File
@@ -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>
<form wire:submit.prevent="save" class="space-y-6"> <a href="{{ route('projects.index') }}" class="btn btn-ghost btn-sm gap-1" wire:navigate>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <x-heroicon-o-arrow-left class="w-4 h-4" /> {{ __('Back') }}
<div> </a>
<label class="block text-sm font-medium mb-2">{{ __('Name') }}</label> </div>
<input type="text" wire:model="name" class="input input-bordered w-full" placeholder="{{ __('Project name') }}" required>
@if($project)
{{-- Editor con pestañas para el resto de parámetros del proyecto --}}
<div x-data="{ tab: 'data' }">
<div class="flex flex-wrap gap-1 mb-4">
<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: '&copy; <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
} }
}
async function pickLocation(lat, lng) {
// Initialize map when component is ready setStatus('{{ __('Loading...') }}');
document.addEventListener('Livewire:load', function() { let address = '', country = '';
initMap(); try {
}); const r = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&zoom=18&addressdetails=1`);
if (r.ok) {
// Also initialize on DOMContentLoaded as fallback const d = await r.json();
document.addEventListener('DOMContentLoaded', function() { address = d.display_name || '';
initMap(); country = (d.address && d.address.country_code) ? d.address.country_code : '';
}); }
</script> } catch (e) { /* geocoding optional */ }
@endpush @this.setLocation(String(lat), String(lng), address, country);
setStatus('');
}
async function searchLocation(q) {
if (!q || !q.trim() || !pmap) return;
setStatus('{{ __('Searching...') }}');
try {
const r = await fetch(`https://nominatim.openstreetmap.org/search?format=json&limit=1&addressdetails=1&q=${encodeURIComponent(q)}`);
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: '&copy; <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>