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:
@@ -1,173 +1,138 @@
|
||||
<div>
|
||||
<div class="max-w-2xl mx-auto p-4">
|
||||
<h1 class="text-2xl font-bold mb-6">{{ $project ? __('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 class="max-w-4xl mx-auto p-4">
|
||||
<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>
|
||||
|
||||
@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>
|
||||
<label class="block text-sm font-medium mb-2">{{ __('Address') }}</label>
|
||||
<input type="text" wire:model="address" class="input input-bordered w-full" placeholder="{{ __('Address') }}">
|
||||
|
||||
<div x-show="tab==='data'">
|
||||
@include('livewire.projects.partials.project-data-form')
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">{{ __('Coordinates') }}</label>
|
||||
<input type="text" wire:model="country" class="input input-bordered w-full" placeholder="{{ __('No country') }}" readonly>
|
||||
<div x-show="tab==='phases'" x-cloak>
|
||||
<livewire:phase-list :project="$project" :key="'phases-'.$project->id" />
|
||||
</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 x-show="tab==='users'" x-cloak>
|
||||
<livewire:project-users :project="$project" :key="'users-'.$project->id" />
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium mb-2">{{ __('Estimated end date') }}</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 x-show="tab==='companies'" x-cloak>
|
||||
<livewire:project-companies :project="$project" :key="'companies-'.$project->id" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-lg p-4">
|
||||
<h2 class="text-xl font-bold mb-4">{{ __('Location') }}</h2>
|
||||
<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>
|
||||
@else
|
||||
{{-- Alta de proyecto: solo el formulario de datos --}}
|
||||
@include('livewire.projects.partials.project-data-form')
|
||||
@endif
|
||||
</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);
|
||||
});
|
||||
@push('scripts')
|
||||
<script>
|
||||
(function () {
|
||||
let pmap = null, pmarker = null;
|
||||
|
||||
function setStatus(msg) {
|
||||
const s = document.getElementById('geocode-status');
|
||||
if (s) s.textContent = msg || '';
|
||||
}
|
||||
|
||||
// Handle map clicks to place marker
|
||||
map.on('click', function(e) {
|
||||
const pos = e.latlng;
|
||||
if (marker) {
|
||||
marker.setLatLng(pos);
|
||||
|
||||
function placeMarker(lat, lng) {
|
||||
if (!pmap) return;
|
||||
if (pmarker) {
|
||||
pmarker.setLatLng([lat, lng]);
|
||||
} else {
|
||||
marker = L.marker(pos, {
|
||||
draggable: true
|
||||
}).addTo(map);
|
||||
|
||||
marker.on('dragend', function(e) {
|
||||
const pos = marker.getLatLng();
|
||||
updateCoordinates(pos.lat, pos.lng);
|
||||
pmarker = L.marker([lat, lng], { draggable: true }).addTo(pmap);
|
||||
pmarker.on('dragend', () => {
|
||||
const p = pmarker.getLatLng();
|
||||
pickLocation(p.lat, p.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
|
||||
|
||||
async function pickLocation(lat, lng) {
|
||||
setStatus('{{ __('Loading...') }}');
|
||||
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('');
|
||||
}
|
||||
|
||||
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: '© <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