Compare commits

..

4 Commits

11 changed files with 974 additions and 1 deletions
+236
View File
@@ -0,0 +1,236 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\WithFileUploads;
use App\Models\Company;
use Illuminate\Support\Facades\Storage;
use Illuminate\Http\Response;
class CompanyManagement extends Component
{
use WithFileUploads;
// Form state
public $name = '';
public $tax_id = '';
public $address = '';
public $email = '';
public $website = '';
public $type = 'other';
public $notes = '';
public $apodo = '';
public $estado = 'activo';
public $logo = null;
// UI state
public $showCreateForm = false;
public $showEditForm = false;
public $editingCompanyId = null;
public $search = '';
// Filter state
public $filterType = '';
public $filterEstado = '';
// Validation rules
protected $rules = [
'name' => 'required|string|max:255',
'apodo' => 'nullable|string|max:100',
'tax_id' => 'nullable|string|max:50|unique:companies,tax_id,',
'estado' => 'required|in:activo,inactivo,suspendido',
'address' => 'nullable|string',
'phone' => 'nullable|string|max:20',
'email' => 'nullable|email|max:255',
'website' => 'nullable|url|max:255',
'type' => 'required|in:owner,constructor,subcontractor,consultant,supplier,other',
'notes' => 'nullable|string',
'logo' => 'nullable|image|max:2048', // 2MB max
];
public function mount()
{
$this->resetForm();
}
public function resetForm()
{
$this->name = '';
$this->tax_id = '';
$this->address = '';
$this->phone = '';
$this->email = '';
$this->website = '';
$this->type = 'other';
$this->notes = '';
$this->apodo = '';
$this->estado = 'activo';
$this->logo = null;
$this->editingCompanyId = null;
$this->showCreateForm = false;
$this->showEditForm = false;
$this->resetErrorBag();
$this->resetValidation();
}
public function resetFilters()
{
$this->search = '';
$this->filterType = '';
$this->filterEstado = '';
}
public function toggleCreateForm()
{
$this->showCreateForm = !$this->showCreateForm;
if ($this->showCreateForm) {
$this->showEditForm = false;
$this->resetForm();
}
}
public function editCompany(Company $company)
{
$this->editingCompanyId = $company->id;
$this->name = $company->name;
$this->tax_id = $company->tax_id;
$this->address = $company->address;
$this->phone = $company->phone;
$this->email = $company->email;
$this->website = $company->website;
$this->type = $company->type;
$this->notes = $company->notes;
$this->apodo = $company->apodo;
$this->estado = $company->estado;
// Note: logo is not populated for security reasons
$this->showEditForm = true;
$this->showCreateForm = false;
}
public function updateCompany()
{
$this->validate();
$company = Company::findOrFail($this->editingCompanyId);
$data = [
'name' => $this->name,
'tax_id' => $this->tax_id,
'address' => $this->address,
'phone' => $this->phone,
'email' => $this->email,
'website' => $this->website,
'type' => $this->type,
'notes' => $this->notes,
];
if ($this->logo) {
$logoPath = $this->logo->store('company-logos', 'public');
$data['logo_path'] = $logoPath;
}
$company->update($data);
session()->flash('message', 'Empresa actualizada correctamente.');
$this->resetForm();
}
public function createCompany()
{
$this->validate();
$data = [
'name' => $this->name,
'tax_id' => $this->tax_id,
'address' => $this->address,
'phone' => $this->phone,
'email' => $this->email,
'website' => $this->website,
'type' => $this->type,
'notes' => $this->notes,
];
if ($this->logo) {
$logoPath = $this->logo->store('company-logos', 'public');
$data['logo_path'] = $logoPath;
}
Company::create($data);
session()->flash('message', 'Empresa creada correctamente.');
$this->resetForm();
}
public function deleteCompany(Company $company)
{
$company->delete(); // Soft delete
session()->flash('message', 'Empresa eliminada correctamente.');
}
public function getCompaniesProperty()
{
return Company::when($this->search, function ($query) {
$query->where('name', 'like', '%' . $this->search . '%')
->orWhere('apodo', 'like', '%' . $this->search . '%')
->orWhere('tax_id', 'like', '%' . $this->search . '%');
})
->when($this->filterType, function ($query) {
$query->where('type', $this->filterType);
})
->when($this->filterEstado, function ($query) {
$query->where('estado', $this->filterEstado);
})
->withCount('projects') // Eager load project count
->orderBy('name')
->get();
}
public function exportCsv()
{
$companies = $this->getCompaniesProperty();
// Create CSV content
$headers = [
"Content-type: text/csv",
"Content-Disposition: attachment; filename=empresas.csv",
"Pragma: no-cache",
"Cache-Control: must-revalidate, post-check=0, pre-check=0",
"Expires: 0"
];
$callback = function() use ($companies) {
$handle = fopen('php://output', 'w');
// Add BOM for UTF-8 in Excel
fprintf($handle, chr(0xEF).chr(0xBB).chr(0xBF));
// Header row
fputcsv($handle, ['Nombre', 'Apodo', 'NIF/Tax ID', 'Tipo', 'Estado', 'Dirección', 'Teléfono', 'Email', 'Website', 'Proyectos Asociados', 'Fecha Creación']);
foreach ($companies as $company) {
fputcsv($handle, [
$company->name,
$company->apodo ?? '',
$company->tax_id ?? '',
$company->type,
$company->estado,
$company->address ?? '',
$company->phone ?? '',
$company->email ?? '',
$company->website ?? '',
$company->projects_count ?? 0,
$company->created_at ? $company->created_at->format('d/m/Y H:i') : ''
]);
}
fclose($handle);
};
return response()->stream($callback, 200, $headers);
}
public function render()
{
return view('livewire.company-management');
}
}
+3
View File
@@ -10,6 +10,9 @@ class Company extends Model
use HasFactory;
protected $fillable = [
'apodo',
'estado',
'logo_path',
'name',
'tax_id',
'address',
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('companies', function (Blueprint $table) {
$table->string('logo_path')->nullable()->after('notes');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('companies', function (Blueprint $table) {
$table->dropColumn('logo_path');
});
}
};
@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('companies', function (Blueprint $table) {
$table->string('apodo')->nullable()->after('name');
$table->enum('estado', ['activo', 'inactivo', 'suspendido'])->default('activo')->after('apodo');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('companies', function (Blueprint $table) {
$table->dropColumn(['apodo', 'estado']);
});
}
};
+13 -1
View File
@@ -16,6 +16,12 @@ window.queueOfflineAction = function(action, payload) {
.then(data => {
if (data.queued) {
console.log('Action queued:', action);
// Register background sync to process the queue
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
return navigator.serviceWorker.ready.then(registration => {
return registration.sync.register('offline-sync');
});
}
} else {
console.error('Failed to queue action:', data);
}
@@ -30,11 +36,17 @@ window.queueOfflineAction = function(action, payload) {
list.push(pendingAction);
localforage.setItem('pendingOffline', list);
console.log('Action stored offline:', action);
// Register background sync for when we come back online
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
return navigator.serviceWorker.ready.then(registration => {
return registration.sync.register('offline-sync');
});
}
}).catch(err => {
console.error('Error storing offline action:', err);
});
}
};
}
// Function to store offline progress update (for backward compatibility)
window.offlineProgressUpdate = function(phaseId, progress, comment, location) {
+3
View File
@@ -83,6 +83,9 @@
<livewire:user-nav />
</div>
</div>
<div class="hidden md:block">
@livewire('language-switcher')
</div>
</div>
</div>
</header>
@@ -0,0 +1,327 @@
<div>
<div class="mb-6">
<h2 class="text-2xl font-bold text-gray-800 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-blue-500 mr-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h10m-9-3h8m-7 0h7M8 13v2a2 2 0 002 2h5a2 2 0 002-2v-2m0 0V9a2 2 0 00-2-2H5a2 2 0 00-2 2v2Z" />
</svg>
Gestión de Empresas
</h2>
<p class="text-gray-600 mt-2">Gestione las empresas que participan en los proyectos</p>
</div>
@if(session('message'))
<div class="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg text-green-800">
{{ session('message') }}
</div>
@endif
<div class="space-y-6">
<!-- Búsqueda y Botón de Nueva Empresa -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between">
<div class="w-full md:w-1/2">
<input type="text"
wire:model.live="search"
placeholder="Buscar empresas por nombre o NIF..."
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors">
</div>
<div class="w-full md:w-1/3 mt-4 md:mt-0">
<button wire:click="toggleCreateForm"
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 rounded-lg flex items-center justify-center transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Nueva Empresa
</button>
</div>
</div>
<!-- Formulario de Creación/Edición -->
<div wire:ignore.self x-cloak>
<div x-show="@entangle('showCreateForm')" x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class="bg-white rounded-lg shadow-md p-6">
<div class="mb-4">
<h3 class="text-xl font-semibold text-gray-800 flex items-center">
{{ $editingCompanyId ? 'Editar Empresa' : 'Crear Nueva Empresa' }}
</h3>
<p class="text-gray-600 mt-1">
Complete la información de la empresa. Los campos marcados con * son obligatorios.
</p>
</div>
@if($errors->any())
<div class="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<strong>Errores de validación:</strong>
<ul class="list-disc pl-5 mt-2 text-sm text-red-600">
@foreach($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<form wire:submit.prevent="{{$editingCompanyId ? 'updateCompany' : 'createCompany'}}"
enctype="multipart/form-data"
class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Nombre *</label>
<input type="text"
wire:model="name"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">NIF/NIE/CIF *</label>
<input type="text"
wire:model="tax_id"
placeholder="Ej: B12345678"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors">
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Apodo</label>
<input type="text"
wire:model="apodo"
placeholder="Ej: Acme Construct"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Estado *</label>
<select wire:model="estado"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors">
<option value="">Seleccione un estado</option>
<option value="activo">Activo</option>
<option value="inactivo">Inactivo</option>
<option value="suspendido">Suspendido</option>
</select>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Dirección</label>
<textarea wire:model="address"
rows="3"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Tipo de Empresa *</label>
<select wire:model="type"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors">
<option value="">Seleccione un tipo</option>
<option value="owner">Promotor/Propietario</option>
<option value="constructor">Constructor Principal</option>
<option value="subcontractor">Subcontratista</option>
<option value="consultant">Consultor/Ingeniería</option>
<option value="supplier">Proveedor</option>
<option value="other">Otro</option>
</select>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Teléfono</label>
<input type="tel"
wire:model="phone"
placeholder="+34 600 123 456"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input type="email"
wire:model="email"
placeholder="contacto@empresa.com"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Sitio Web</label>
<input type="url"
wire:model="website"
placeholder="https://www.empresa.com"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Logo de la Empresa</label>
<div class="flex flex-col">
<label class="cursor-pointer text-blue-600 hover:text-blue-800">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 inline" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16v-2a2 2 0 012-2h2a2 2 0 012 2v2m-4 0h.01M12 12a2 2 0 100-4 2 2 0 000 4zm4.5-6.75a.75.75 0 100-1.5.75.75 0 000 1.5zm0 0h.01M7 10h.01M14 10h.01M10.363 5.636a.75.75 0 10-1.06-1.06l-.47 1.242A12.038 12.038 0 0112 9.042c1.373 0 2.702.28 3.901.784l1.242-.47a.75.75 0 10-1.06-1.06l-.469-1.241a9.038 9.038 0 00-2.342-.348z" />
</svg>
Seleccionar archivo...
</label>
<input type="file"
wire:model="logo"
accept="image/*"
class="mt-2 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
@if($logo)
<div class="mt-3 flex items-center">
<img src="{{ $logo->temporaryUrl() }}"
alt="Vista previa del logo"
class="h-12 w-12 object-contain border border-gray-200 rounded-lg">
<button type="button"
wire:click="logo = null"
class="ml-3 text-xs text-red-600 hover:text-red-800">
Eliminar
</button>
</div>
@endif
</div>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Notas Adicionales</label>
<textarea wire:model="notes"
rows="4"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"></textarea>
</div>
<div class="flex items-center justify-end pt-4 space-x-3">
<button type="button"
wire:click="resetForm"
class="px-5 py-3 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors">
Cancelar
</button>
<button type="submit"
class="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-lg transition-colors">
{{$editingCompanyId ? 'Actualizar Empresa' : 'Crear Empresa'}}
</button>
</div>
</form>
</div>
</div>
<!-- Lista de Empresas -->
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-800 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2z" />
</svg>
Lista de Empresas ({{ $companies->count() }})
</h3>
</div>
@if($companies->isEmpty())
<div class="px-6 py-8 text-center text-gray-500">
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-12 w-12 text-gray-300 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2z" />
</svg>
<p class="mt-2">No hay empresas registradas. Cree su primera empresa usando el botón de arriba.</p>
</div>
@else
<div class="divide-y divide-gray-200">
@foreach($companies as $company)
<div class="px-6 py-4 flex flex-col md:flex-row md:items-start md:justify-between">
<div class="flex-1 md:w-1/2">
<div class="flex items-start space-x-3">
@if($company->logo_path && Storage::disk('public')->exists($company->logo_path))
<img src="{{ Storage::disk('public')->url($company->logo_path) }}"
alt="Logo de {{ $company->name }}"
class="h-12 w-12 object-contain border border-gray-200 rounded-lg flex-shrink-0">
@else
<div class="h-12 w-12 flex items-center justify-center bg-gray-100 rounded-lg text-gray-400 flex-shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 2.25c-1.236 0-2.241.404-3.038 1.08a9.027 9.027 0 00-2.481 7.35c.178.404.317.845.418 1.306a4.42 4.42 0 001.266 2.05c.703.073 1.415.112 2.125.112a4.417 4.417 0 002.125-.112c.703 0 1.415-.039 2.125-.112a4.42 4.42 0 001.266-2.05a4.415 4.415 0 00.418-1.306c.797-.676 1.797-1.076 2.481-1.076A9.027 9.027 0 0018.978 9.68a11.025 11.025 0 01-4.597-.45z" />
</svg>
</div>
@endif
<div>
<h4 class="font-semibold text-gray-900">{{ $company->name }}</h4>
<p class="text-sm text-gray-600 truncate">
@if($company->tax_id)
{{ $company->tax_id }}
@else
Sin NIF/CIF
@endif
</p>
@if($company->type)
<span class="inline-block mt-1 px-2 py-0.5 text-xs font-medium
@if($company->type === 'owner') bg-green-100 text-green-800
@elseif($company->type === 'constructor') bg-blue-100 text-blue-800
@elseif($company->type === 'subcontractor') bg-purple-100 text-purple-800
@elseif($company->type === 'consultant') bg-indigo-100 text-indigo-800
@elseif($company->type === 'supplier') bg-yellow-100 text-yellow-800
@else bg-gray-100 text-gray-800
endif
rounded">
{{ ucfirst($company->type) }}
</span>
@endif
</div>
</div>
</div>
<div class="mt-4 md:mt-0 md:w-1/2 text-right space-y-2">
<div class="text-sm text-gray-500 space-y-1">
@if($company->address)
<div class="flex items-start">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mt-0.5 text-gray-400 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.5 1.5 0 01-2.121-1.06L7 12.764l-.646.647a1 1 0 01-1.415-1.415l1.22-1.22a1.5 1.5 0 012.121-.39l3.707 3.707a1.5 1.5 0 011.06 2.12z" />
</svg>
<span>{{ $company->address }}</span>
</div>
@endif
@if($company->phone)
<div class="flex items-start">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mt-0.5 text-gray-400 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.8.52l1.68-1.4a1 1 0 01.82-.52h4a2 2 0 012 2v5.5a2 2 0 01-2 2H5a2 2 0 01-2-2V5z" />
</svg>
<span>{{ $company->phone }}</span>
</div>
@endif
@if($company->email)
<div class="flex items-start">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mt-0.5 text-gray-400 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12z" />
</svg>
<span>{{ $company->email }}</span>
</div>
@endif
</div>
<div class="flex justify-end space-x-2">
<button wire:click="editCompany({{ $company->id }})"
class="text-sm text-blue-600 hover:text-blue-800 font-medium flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
Editar
</button>
<button wire:click="deleteCompany({{ $company->id }})"
class="text-sm text-red-600 hover:text-red-800 font-medium flex items-center"
onclick="return confirm('¿Está seguro de que desea eliminar esta empresa? Esta acción no se puede deshacer.')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10h-3a2 2 0 00-2 2v2a2 2 0 002 2h3zm-3-4h1a2 2 0 012 2v2a2 2 0 01-2 2h-1V9a2 2 0 012-2z" />
</svg>
Eliminar
</button>
</div>
</div>
</div>
@if(!$loop->last)
<div class="border-t border-gray-200"></div>
@endforeach
</div>
@endif
</div>
</div>
</div>
@@ -0,0 +1,122 @@
{{-- Feature seleccionado --}}
@if($selectedFeature)
<div class="border rounded-lg p-3 mb-3 bg-base-200">
<h3 class="font-bold text-sm">{{ $selectedFeature->name ?? 'Elemento' }}</h3>
<p class="text-xs text-gray-500">Fase: {{ $selectedFeature->layer?->phase?->name ?? '—' }}</p>
<p class="text-xs text-gray-500">Capa: {{ $selectedFeature->layer?->name ?? '—' }}</p>
</div>
{{-- {{ __("Progress") }} --}}
<div class="form-control mb-3">
<label class="label-text">{{ __("Progress") }}: {{ $editProgress }}%</label>
<input type="range" min="0" max="100" wire:model.live="editProgress" class="range range-primary range-sm" />
<div class="flex justify-between text-xs">
<span>0%</span><span>50%</span><span>100%</span>
</div>
</div>
<div class="form-control mb-3">
<label class="label-text">{{ __("Responsible") }}</label>
<input type="text" wire:model="editResponsible" class="input input-bordered input-sm" placeholder="Nombre" />
</div>
<button wire:click="saveFeatureProgress" class="btn btn-primary btn-sm w-full mb-3">
💾 {{ __("Save progress") }}
</button>
{{-- Gestor de archivos del feature --}}
<details class="mb-3 border rounded-lg">
<summary class="text-xs font-semibold cursor-pointer p-2 bg-base-200 rounded-t-lg">
📎 {{ __("Files of element") }}
</summary>
<div class="p-2">
@livewire('media-manager', [
'mediableType' => 'App\\Models\\Feature',
'mediableId' => $selectedFeature->id,
], key('media-feature-' . $selectedFeature->id))
</div>
</details>
{{-- Templates / Inspecciones --}}
@if($templates->isNotEmpty())
<div class="divider text-xs">{{ __("Inspection") }}</div>
<div class="form-control mb-2">
<label class="label-text">Plantilla</label>
<select wire:model.live="selectedTemplateId" wire:change="onTemplateChange" class="select select-bordered select-sm">
<option value="">Seleccionar plantilla...</option>
@foreach($templates as $t)
<option value="{{ $t->id }}">{{ $t->name }}</option>
@endforeach
</select>
</div>
@if($selectedTemplateId && !empty($inspectionFormData))
@php $template = $templates->firstWhere('id', $selectedTemplateId); @endphp
@if($template)
@foreach($template->fields as $field)
<div class="mb-2">
<label class="label-text text-xs">{{ $field['label'] }} @if($field['required'] ?? false)<span class="text-error">*</span>@endif</label>
@switch($field['type'] ?? 'text')
@case('percentage')
<div class="flex items-center gap-1">
<input type="number" wire:model="inspectionFormData.{{ $field['name'] }}" min="0" max="100" class="input input-bordered input-sm w-16" />
<span class="text-xs">%</span>
<input type="range" min="0" max="100" wire:model.live="inspectionFormData.{{ $field['name'] }}" class="range range-primary range-xs flex-1" />
</div>
@break
@case('boolean')
<input type="checkbox" wire:model="inspectionFormData.{{ $field['name'] }}" class="checkbox checkbox-sm" />
@break
@case('select')
<select wire:model="inspectionFormData.{{ $field['name'] }}" class="select select-bordered select-sm w-full">
<option value="">Seleccionar</option>
@foreach(explode(',', $field['options'] ?? '') as $opt)
<option value="{{ trim($opt) }}">{{ trim($opt) }}</option>
@endforeach
</select>
@break
@case('textarea')
<textarea wire:model="inspectionFormData.{{ $field['name'] }}" rows="2" class="textarea textarea-bordered textarea-sm w-full"></textarea>
@break
@default
<input type="{{ $field['type'] ?? 'text' }}" wire:model="inspectionFormData.{{ $field['name'] }}" class="input input-bordered input-sm w-full" />
@endswitch
</div>
@endforeach
<button wire:click="saveInspection" class="btn btn-primary btn-xs w-full mt-1">{{ __("Register inspection") }}</button>
@endif
@endif
{{-- {{ __("History") }} de inspecciones --}}
@if($inspectionHistory->isNotEmpty())
<div class="divider text-xs">{{ __("History") }}</div>
<div class="space-y-1 max-h-40 overflow-y-auto">
@foreach($inspectionHistory as $ins)
<div class="border rounded p-2 text-xs">
<div class="flex justify-between">
<span class="font-medium">{{ $ins->template?->name ?? '{{ __("Inspection") }}' }}</span>
<span class="text-gray-400">{{ $ins->created_at->diffForHumans() }}</span>
</div>
@if($ins->user)<span class="text-gray-500">por {{ $ins->user->name }}</span>@endif
</div>
@endforeach
</div>
@endif
@else
<div role="alert" class="alert alert-vertical sm:alert-horizontal">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-info h-6 w-6 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<h3 class="font-bold">{{ __("No templates yet") }}</h3>
<div class="text-xs">{{ __("Create an inspection template") }}.</div>
</div>
<a href="{{ route('projects.templates', $project) }}" class="btn btn-primary btn-sm">{{ __("Create") }}</a>
</div>
@endif
@else
<div class="text-center text-gray-400 py-8">
<p class="text-lg">👆</p>
<p>Haz clic en un elemento del mapa para editarlo</p>
</div>
@endif
@@ -0,0 +1,90 @@
<div class="space-y-4">
<!-- Selector de feature -->
<div class="mb-4">
<label class="label-text">{{ __("Select Element") }}</label>
<div class="flex space-x-2">
<input
type="text"
wire:model="searchFeatures"
placeholder="{{ __("Search by name, layer or phase...") }}"
class="input input-bordered input-sm flex-1"
/>
<button
wire:click="searchFeatures = ''"
class="btn btn-sm btn-outline"
>
{{ __("Clear") }}
</button>
</div>
</div>
<!-- Lista de features filtrados -->
<div class="max-h-96 overflow-y-auto border rounded border-base-200">
@if($filteredFeatures->isEmpty())
<div class="text-center text-gray-400 py-4">
<p>{{ __("No elements found") }}</p>
</div>
@else
<div class="space-y-1">
@foreach($filteredFeatures as $feature)
<div
wire:click="selectFeatureFromList({{ $feature->id }})"
class="cursor-pointer px-3 py-2 border-b border-base-100 hover:bg-base-50"
:class="{'bg-base-200': selectedFeature && selectedFeature->id == {{$feature->id}}}"
>
<div class="flex justify-between">
<div>
<strong class="text-sm">{{ $feature->name }}</strong><br>
<span class="text-xs text-gray-500">
{{ optional(optional($feature->layer)->phase)->name ?? '—' }} >
{{ optional($feature->layer)->name ?? '—' }}
</span>
</div>
<div class="text-xs text-right">
<span class="badge badge-sm {{ $feature->progress >= 100 ? 'badge-success' : 'badge-ghost' }}">
{{ $feature->progress }}%
</span>
</div>
</div>
</div>
@endforeach
</div>
@endif
</div>
</div>
{{-- Historial de inspecciones --}}
@if($selectedFeature)
<div class="mt-6">
<h3 class="font-semibold text-lg mb-3">{{ __("Inspection History") }}</h3>
@if($inspectionHistory->isEmpty())
<div class="text-center text-gray-400 py-4">
<p>{{ __("No inspections yet for this element") }}</p>
</div>
@else
<div class="space-y-2 max-h-96 overflow-y-auto">
@foreach($inspectionHistory as $inspection)
<div
wire:click="openInspectionViewer({{ $inspection->id }})"
class="cursor-pointer border rounded p-3 hover:shadow-md transition-shadow"
>
<div class="flex justify-between items-start mb-2">
<div class="flex-1">
<strong>{{ $inspection->template->name ?? 'Inspection' }}</strong><br>
<span class="text-xs text-gray-500">
{{ $inspection->created_at->diffForHumans() }} {{ __("ago") }}
</span>
@if($inspection->user)
<span class="text-xs text-gray-400">{{ __("by") }} {{ $inspection->user->name }}</span>
@endif
</div>
<div class="text-center">
<span class="badge badge-sm">{{ __("View") }}</span>
</div>
</div>
</div>
@endforeach
</div>
@endif
</div>
@endif
@@ -0,0 +1,122 @@
<div class="space-y-4">
<!-- Selector de feature -->
<div class="mb-4">
<label class="label-text">{{ __("Select Element") }}</label>
<div class="flex space-x-2">
<input
type="text"
wire:model="searchFeatures"
placeholder="{{ __("Search by name, layer or phase...") }}"
class="input input-bordered input-sm flex-1"
/>
<button
wire:click="searchFeatures = ''"
class="btn btn-sm btn-outline"
>
{{ __("Clear") }}
</button>
</div>
</div>
<!-- Lista de features filtrados -->
<div class="max-h-96 overflow-y-auto border rounded border-base-200">
@if($filteredFeatures->isEmpty())
<div class="text-center text-gray-400 py-4">
<p>{{ __("No elements found") }}</p>
</div>
@else
<div class="space-y-1">
@foreach($filteredFeatures as $feature)
<div
wire:click="selectFeatureFromList({{ $feature->id }})"
class="cursor-pointer px-3 py-2 border-b border-base-100 hover:bg-base-50"
:class="{'bg-base-200': selectedFeature && selectedFeature->id == {{$feature->id}}}"
>
<div class="flex justify-between">
<div>
<strong class="text-sm">{{ $feature->name }}</strong><br>
<span class="text-xs text-gray-500">
{{ optional(optional($feature->layer)->phase)->name ?? '—' }} >
{{ optional($feature->layer)->name ?? '—' }}
</span>
</div>
<div class="text-xs text-right">
<span class="badge badge-sm {{ $feature->progress >= 100 ? 'badge-success' : 'badge-ghost' }}">
{{ $feature->progress }}%
</span>
</div>
</div>
</div>
@endforeach
</div>
@endif
</div>
</div>
{{-- Media del proyecto --}}
@if($selectedFeature)
<div class="mt-6">
<h3 class="font-semibold text-lg mb-3">{{ __("Media for this element") }}</h3>
@if($selectedFeature->media()->exists())
<div class="space-y-2 max-h-96 overflow-y-auto">
@foreach($selectedFeature->media as $media)
<div
wire:click="openMediaViewer({{ $media->id }})"
class="cursor-pointer border rounded p-3 hover:shadow-md transition-shadow"
>
<div class="flex justify-between items-start">
<div class="flex-1">
<strong>{{ $media->name }}</strong><br>
<span class="text-xs text-gray-500">
{{ $media->mime_type }} {{ $media->created_at->diffForHumans() }} {{ __("ago") }}
</span>
</div>
<div class="text-center">
<span class="badge badge-sm">{{ __("View") }}</span>
</div>
</div>
</div>
@endforeach
</div>
@else
<div class="text-center text-gray-400 py-4">
<p>{{ __("No media for this element yet") }}</p>
</div>
@endif
</div>
@else
<div class="mt-6">
<h3 class="font-semibold text-lg mb-3">{{ __("Project Media") }}</h3>
@if($projectMedia->isEmpty())
<div class="text-center text-gray-400 py-4">
<p>{{ __("No project media yet") }}</p>
</div>
@else
<div class="space-y-2 max-h-96 overflow-y-auto">
@foreach($projectMedia as $media)
<div
wire:click="openMediaViewer({{ $media->id }})"
class="cursor-pointer border rounded p-3 hover:shadow-md transition-shadow"
>
<div class="flex justify-between items-start">
<div class="flex-1">
<strong>{{ $media->name }}</strong><br>
<span class="text-xs text-gray-500">
{{ $media->mime_type }} {{ $media->created_at->diffForHumans() }} {{ __("ago") }}
</span>
@if($media->model_type === 'App\\Models\\Feature')
<span class="text-xs text-gray-400">{{ __("Feature:") }} {{ optional($media->model)->name ?? '' }}</span>
@elseif($media->model_type === 'App\\Models\\Inspection')
<span class="text-xs text-gray-400">{{ __("Inspection:") }} {{ optional($media->model)->template->name ?? '' }}</span>
@endif
</div>
<div class="text-center">
<span class="badge badge-sm">{{ __("View") }}</span>
</div>
</div>
</div>
@endforeach
</div>
@endif
</div>
@endif
+1
View File
@@ -113,6 +113,7 @@ Route::get('/reports/dashboard', ReportsDashboard::class)->name('reports.dashboa
Route::get('/projects/{project}/media', function (\App\Models\Project $project) {
return view('projects.media', compact('project'));
})->name('projects.media');
Route::get('/companies', \App\Livewire\CompanyManagement::class)->name('companies.manage');
// ------------------------------------------------------------
// Sincronización offline (para trabajadores en campo)