feat: i18n, language switcher fix, DataTable improvements, blade translations

- Translation system: lang/es/ PHP files (auth, validation, pagination, passwords)
- Rappasoft vendor translations published (lang/vendor/livewire-tables/es/)
- JSON files synced to 391 keys (EN + ES, full parity)
- APP_LOCALE changed to 'es', users.locale column default changed to 'es'
- Language switcher fixed: JS event + window.location.reload() avoids /livewire/update redirect
- SetLocale middleware fallback uses config('app.locale') instead of hardcoded 'en'
- setSortingPillsEnabled(false) on ProjectTable, CompanyTable, UserTable
- Translated 17 blade views: project-map, template-manager, layer-manager,
  company-management, phase-list, media-manager, reports-dashboard,
  client-projects, layer-upload, project-form, project-map-editor-tab,
  admin/users, projects/media, projects/templates, layouts/client
- Navigation 'Empresas' link uses __('Companies')
- Fixed typo key 'Fases and layers' -> 'Phases and layers'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 18:05:53 +02:00
parent 052e1397df
commit 7d854ffb0a
85 changed files with 8499 additions and 1339 deletions
+18 -24
View File
@@ -9,44 +9,38 @@ use Illuminate\Support\Facades\Auth;
class AdminUsers extends Component
{
public $users;
public string $search = '';
public $roles;
public function mount()
public function mount(): void
{
if (!Auth::user()->hasRole('Admin')) {
abort(403);
}
$this->roles = Role::all();
$this->loadUsers();
if (!Auth::user()->hasRole('Admin')) abort(403);
$this->roles = Role::orderBy('name')->get();
}
public function loadUsers()
public function getUsersProperty()
{
$this->users = User::with('roles')->orderBy('name')->get();
return User::with('roles')
->when($this->search, fn($q) =>
$q->where(fn($q2) => $q2
->where('name', 'like', '%' . $this->search . '%')
->orWhere('email', 'like', '%' . $this->search . '%')))
->orderBy('name')
->get();
}
public function updateRole($userId, $roleName)
public function deleteUser(int $userId): void
{
$user = Auth::user();
if (!$user->hasRole('Admin')) {
session()->flash('error', 'Solo administradores.');
if ($userId === Auth::id()) {
$this->dispatch('notify', 'No puedes eliminarte a ti mismo.');
return;
}
$targetUser = User::findOrFail($userId);
if ($targetUser->id === $user->id && $targetUser->hasRole('Admin')) {
session()->flash('error', 'No puedes cambiarte el rol a ti mismo.');
return;
}
$targetUser->syncRoles([$roleName]);
$this->loadUsers();
$this->dispatch('notify', 'Rol actualizado.');
User::findOrFail($userId)->delete();
$this->dispatch('notify', 'Usuario eliminado.');
}
public function render()
{
return view('livewire.admin-users');
}
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\WithFileUploads;
use Livewire\Attributes\Layout;
use App\Models\Company;
use Illuminate\Support\Facades\Storage;
#[Layout('layouts.app')]
class CompanyForm extends Component
{
use WithFileUploads;
public ?Company $company = null;
// Form fields
public string $name = '';
public string $apodo = '';
public string $tax_id = '';
public string $estado = 'activo';
public string $type = 'other';
public string $address = '';
public string $phone = '';
public string $email = '';
public string $website = '';
public string $notes = '';
public $logo = null;
public function mount(?Company $company = null): void
{
if ($company && $company->exists) {
$this->company = $company;
$this->name = $company->name;
$this->apodo = $company->apodo ?? '';
$this->tax_id = $company->tax_id ?? '';
$this->estado = $company->estado ?? 'activo';
$this->type = $company->type ?? 'other';
$this->address = $company->address ?? '';
$this->phone = $company->phone ?? '';
$this->email = $company->email ?? '';
$this->website = $company->website ?? '';
$this->notes = $company->notes ?? '';
}
}
protected function rules(): array
{
$id = $this->company?->id ?? 'NULL';
return [
'name' => 'required|string|max:255',
'apodo' => 'nullable|string|max:100',
'tax_id' => "nullable|string|max:50|unique:companies,tax_id,{$id}",
'estado' => 'required|in:activo,inactivo,suspendido',
'type' => 'required|in:owner,constructor,subcontractor,consultant,supplier,other',
'address' => 'nullable|string',
'phone' => 'nullable|string|max:30',
'email' => 'nullable|email|max:255',
'website' => 'nullable|url|max:255',
'notes' => 'nullable|string',
'logo' => 'nullable|image|max:2048',
];
}
public function save(): void
{
$this->validate();
$data = [
'name' => $this->name,
'apodo' => $this->apodo ?: null,
'tax_id' => $this->tax_id ?: null,
'estado' => $this->estado,
'type' => $this->type,
'address' => $this->address ?: null,
'phone' => $this->phone ?: null,
'email' => $this->email ?: null,
'website' => $this->website ?: null,
'notes' => $this->notes ?: null,
];
if ($this->logo) {
// Delete old logo when replacing
if ($this->company?->logo_path) {
Storage::disk('public')->delete($this->company->logo_path);
}
$data['logo_path'] = $this->logo->store('company-logos', 'public');
}
if ($this->company && $this->company->exists) {
$this->company->update($data);
session()->flash('notify', 'Empresa actualizada correctamente.');
} else {
Company::create($data);
session()->flash('notify', 'Empresa creada correctamente.');
}
$this->redirect(route('companies.manage'), navigate: true);
}
public function render()
{
return view('livewire.company-form');
}
}
+41 -210
View File
@@ -3,234 +3,65 @@
namespace App\Livewire;
use Livewire\Component;
use Livewire\WithFileUploads;
use Livewire\Attributes\Layout;
use App\Models\Company;
use Illuminate\Support\Facades\Storage;
use Illuminate\Http\Response;
#[Layout('layouts.app')]
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 string $search = '';
public string $filterType = '';
public string $filterEstado = '';
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();
return Company::when($this->search, function ($q) {
$s = '%' . $this->search . '%';
$q->where(fn($q2) => $q2
->where('name', 'like', $s)
->orWhere('apodo', 'like', $s)
->orWhere('tax_id', 'like', $s));
})
->when($this->filterType, fn($q) => $q->where('type', $this->filterType))
->when($this->filterEstado, fn($q) => $q->where('estado', $this->filterEstado))
->withCount('projects')
->orderBy('name')
->get();
}
public function deleteCompany(Company $company): void
{
if ($company->logo_path) {
Storage::disk('public')->delete($company->logo_path);
}
$company->delete();
$this->dispatch('notify', 'Empresa eliminada.');
}
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) {
return response()->streamDownload(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) {
fprintf($handle, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($handle, ['Nombre', 'Apodo', 'NIF/Tax ID', 'Tipo', 'Estado', 'Dirección', 'Teléfono', 'Email', 'Website', 'Proyectos', 'Creación']);
foreach ($companies as $c) {
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') : ''
$c->name, $c->apodo ?? '', $c->tax_id ?? '',
$c->type, $c->estado, $c->address ?? '',
$c->phone ?? '', $c->email ?? '', $c->website ?? '',
$c->projects_count ?? 0,
$c->created_at?->format('d/m/Y'),
]);
}
fclose($handle);
};
return response()->stream($callback, 200, $headers);
}, 'empresas.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
}
public function render()
{
return view('livewire.company-management');
}
}
}
+173
View File
@@ -0,0 +1,173 @@
<?php
namespace App\Livewire;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Rappasoft\LaravelLivewireTables\Views\Filters\SelectFilter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use App\Models\Company;
class CompanyTable extends DataTableComponent
{
protected $model = Company::class;
public function configure(): void
{
$this->setPrimaryKey('id')
->setDefaultSort('name', 'asc')
->setSortingPillsEnabled(false)
->setAdditionalSelects([
'companies.id as id',
'companies.apodo as apodo',
'companies.tax_id as tax_id',
'companies.phone as phone',
'companies.email as email',
'companies.logo_path as logo_path',
'companies.created_at as created_at',
]);
}
public function builder(): Builder
{
return Company::withCount('projects');
}
public function columns(): array
{
return [
Column::make('Empresa', 'name')
->sortable()
->searchable()
->format(function ($value, $row) {
$logoHtml = '';
if ($row->logo_path && Storage::disk('public')->exists($row->logo_path)) {
$url = Storage::disk('public')->url($row->logo_path);
$logoHtml = '<img src="'.e($url).'" class="w-9 h-9 rounded object-contain border border-base-300 shrink-0" />';
} else {
$logoHtml = '<div class="w-9 h-9 rounded bg-base-200 flex items-center justify-center shrink-0 text-gray-400">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-2 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg>
</div>';
}
$html = '<div class="flex items-center gap-3">'.$logoHtml.'<div>';
$html .= '<p class="font-semibold text-sm leading-tight">'.e($value).'</p>';
if ($row->apodo) $html .= '<p class="text-xs text-gray-500">'.e($row->apodo).'</p>';
if ($row->tax_id) $html .= '<p class="text-xs text-gray-400">NIF: '.e($row->tax_id).'</p>';
$html .= '</div></div>';
return $html;
})
->html(),
Column::make('Tipo', 'type')
->sortable()
->format(function ($value) {
$map = [
'owner' => ['badge-success', 'Promotor'],
'constructor' => ['badge-primary', 'Constructor'],
'subcontractor' => ['badge-secondary', 'Subcontratista'],
'consultant' => ['badge-info', 'Consultor'],
'supplier' => ['badge-warning', 'Proveedor'],
];
[$cls, $label] = $map[$value] ?? ['badge-ghost', 'Otro'];
return '<span class="badge badge-sm '.$cls.'">'.$label.'</span>';
})
->html(),
Column::make('Contacto', 'phone')
->format(function ($value, $row) {
$html = '';
if ($row->phone) {
$html .= '<div class="flex items-center gap-1 text-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 opacity-50 shrink-0" 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.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"/></svg>
'.e($row->phone).'</div>';
}
if ($row->email) {
$html .= '<div class="flex items-center gap-1 text-xs text-gray-500 max-w-[180px] truncate">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 opacity-50 shrink-0" 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 2v10a2 2 0 002 2z"/></svg>
'.e($row->email).'</div>';
}
return $html ?: '<span class="text-gray-300">—</span>';
})
->html(),
Column::make('Estado', 'estado')
->sortable()
->format(function ($value) {
$map = [
'activo' => ['badge-success', 'Activo'],
'inactivo' => ['badge-ghost', 'Inactivo'],
'suspendido' => ['badge-error', 'Suspendido'],
];
[$cls, $label] = $map[$value ?? 'activo'] ?? ['badge-ghost', ucfirst($value ?? 'activo')];
return '<span class="badge badge-sm '.$cls.'">'.$label.'</span>';
})
->html(),
Column::make('Proyectos')
->label(fn ($row) =>
'<span class="badge badge-outline badge-sm">'.(int)($row->projects_count ?? 0).'</span>'
)
->html(),
Column::make('Acciones')
->label(function ($row) {
$ver = route('companies.show', $row->id);
$editar = route('companies.edit', $row->id);
$name = addslashes($row->name);
$html = '<div class="flex items-center justify-end gap-1">';
$html .= '<a href="'.$ver.'" class="btn btn-xs btn-outline" title="Ver" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</a>';
$html .= '<a href="'.$editar.'" class="btn btn-xs btn-outline btn-info" title="Editar" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</a>';
$html .= '<button wire:click="deleteCompany('.$row->id.')"
wire:confirm="¿Eliminar \''.$name.'\'? Esta acción no se puede deshacer."
class="btn btn-xs btn-outline btn-error" title="Eliminar">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" 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-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>';
$html .= '</div>';
return $html;
})
->html(),
];
}
public function filters(): array
{
return [
SelectFilter::make('Tipo', 'type')
->options([
'' => 'Tipo: todos',
'owner' => 'Promotor',
'constructor' => 'Constructor',
'subcontractor' => 'Subcontratista',
'consultant' => 'Consultor',
'supplier' => 'Proveedor',
'other' => 'Otro',
])
->filter(fn (Builder $query, string $value) => $query->where('type', $value)),
SelectFilter::make('Estado', 'estado')
->options([
'' => 'Estado: todos',
'activo' => 'Activo',
'inactivo' => 'Inactivo',
'suspendido' => 'Suspendido',
])
->filter(fn (Builder $query, string $value) => $query->where('estado', $value)),
];
}
public function deleteCompany(int $id): void
{
$company = Company::findOrFail($id);
if ($company->logo_path) {
Storage::disk('public')->delete($company->logo_path);
}
$company->delete();
}
}
+157
View File
@@ -0,0 +1,157 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\Company;
use App\Models\Project;
use App\Models\User;
use App\Models\Issue;
use Illuminate\Support\Facades\Auth;
#[Layout('layouts.app')]
class CompanyView extends Component
{
public Company $company;
public string $activeTab = 'summary';
// Projects tab
public ?int $addProjectId = null;
public string $addProjectRole = '';
public $availableProjects;
// People tab
public ?int $assignUserId = null;
public $assignableUsers;
// Notes tab
public string $notes = '';
public bool $editingNotes = false;
// Stats (computed once in mount, refreshed on mutations)
public int $usersCount = 0;
public int $projectsCount = 0;
public float $avgProgress = 0.0;
public int $openIssues = 0;
public function mount(Company $company): void
{
if (!Auth::user()->hasRole('Admin')) abort(403);
$this->company = $company->load(['users.roles', 'projects.phases']);
$this->notes = $company->notes ?? '';
$this->loadAvailableProjects();
$this->loadAssignableUsers();
$this->computeStats();
}
// ── Helpers ───────────────────────────────────────────────────────────────
private function loadAvailableProjects(): void
{
$assignedIds = $this->company->projects->pluck('id');
$this->availableProjects = Project::whereNotIn('id', $assignedIds)
->orderBy('name')->get();
}
private function loadAssignableUsers(): void
{
$this->assignableUsers = User::where(function ($q) {
$q->where('company_id', '!=', $this->company->id)
->orWhereNull('company_id');
})->orderBy('name')->get();
}
private function computeStats(): void
{
$this->usersCount = $this->company->users->count();
$this->projectsCount = $this->company->projects->count();
$this->avgProgress = round(
$this->company->projects->flatMap(fn($p) => $p->phases)->avg('progress_percent') ?? 0
);
$userIds = $this->company->users->pluck('id');
$this->openIssues = $userIds->isNotEmpty()
? Issue::whereIn('reported_by', $userIds)->where('status', 'open')->count()
: 0;
}
// ── Tabs ─────────────────────────────────────────────────────────────────
public function setTab(string $tab): void
{
$this->activeTab = $tab;
}
// ── Projects ──────────────────────────────────────────────────────────────
public function assignProject(): void
{
$this->validate([
'addProjectId' => 'required|exists:projects,id',
'addProjectRole' => 'required|string|max:150',
], [], ['addProjectId' => 'proyecto', 'addProjectRole' => 'rol en proyecto']);
$this->company->projects()->attach($this->addProjectId, [
'role_in_project' => $this->addProjectRole,
]);
$this->company->load('projects.phases');
$this->addProjectId = null;
$this->addProjectRole = '';
$this->loadAvailableProjects();
$this->computeStats();
$this->dispatch('notify', 'Proyecto asignado correctamente.');
}
public function removeProject(int $projectId): void
{
$this->company->projects()->detach($projectId);
$this->company->load('projects.phases');
$this->loadAvailableProjects();
$this->computeStats();
$this->dispatch('notify', 'Proyecto desasignado.');
}
// ── People ────────────────────────────────────────────────────────────────
public function assignUser(): void
{
$this->validate([
'assignUserId' => 'required|exists:users,id',
], [], ['assignUserId' => 'usuario']);
User::find($this->assignUserId)?->update(['company_id' => $this->company->id]);
$this->company->load('users.roles');
$this->assignUserId = null;
$this->loadAssignableUsers();
$this->computeStats();
$this->dispatch('notify', 'Usuario vinculado a la empresa.');
}
public function removeUser(int $userId): void
{
User::find($userId)?->update(['company_id' => null]);
$this->company->load('users.roles');
$this->loadAssignableUsers();
$this->computeStats();
$this->dispatch('notify', 'Usuario desvinculado de la empresa.');
}
// ── Notes ─────────────────────────────────────────────────────────────────
public function saveNotes(): void
{
$this->validate(['notes' => 'nullable|string']);
$this->company->update(['notes' => $this->notes ?: null]);
$this->editingNotes = false;
$this->dispatch('notify', 'Notas guardadas.');
}
public function render()
{
return view('livewire.company-view');
}
}
+135
View File
@@ -0,0 +1,135 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\Attributes\Layout;
use Illuminate\Support\Facades\Auth;
use App\Models\Project;
use App\Models\Issue;
use App\Notifications\IssueReportedNotification;
#[Layout('layouts.app')]
class IssueManager extends Component
{
public Project $project;
public $editing = false;
public $editingId = null;
public $title = '';
public $description = '';
public $status = 'open';
public $priority = 'medium';
public $featureId = null;
public $inspectionId = null;
public $assignedTo = null;
public $issues = [];
public function mount(Project $project)
{
$this->project = $project;
$this->loadIssues();
}
public function loadIssues()
{
$this->issues = Issue::where('project_id', $this->project->id)
->with(['feature', 'reporter', 'assignee'])
->orderBy('created_at', 'desc')
->get();
}
public function create()
{
$this->reset(['title', 'description', 'status', 'priority', 'featureId', 'inspectionId', 'assignedTo', 'editingId']);
$this->status = 'open';
$this->priority = 'medium';
$this->editing = true;
}
public function edit($issueId)
{
$issue = Issue::findOrFail($issueId);
$this->editingId = $issue->id;
$this->title = $issue->title;
$this->description = $issue->description ?? '';
$this->status = $issue->status;
$this->priority = $issue->priority;
$this->featureId = $issue->feature_id;
$this->inspectionId = $issue->inspection_id;
$this->assignedTo = $issue->assigned_to;
$this->editing = true;
}
public function save()
{
$this->validate([
'title' => 'required|string|max:255',
'status' => 'required|in:' . implode(',', Issue::STATUSES),
'priority' => 'required|in:' . implode(',', Issue::PRIORITIES),
]);
if ($this->editingId) {
$issue = Issue::findOrFail($this->editingId);
$issue->update([
'title' => $this->title,
'description' => $this->description,
'status' => $this->status,
'priority' => $this->priority,
'feature_id' => $this->featureId,
'inspection_id' => $this->inspectionId,
'assigned_to' => $this->assignedTo,
]);
} else {
$issue = Issue::create([
'project_id' => $this->project->id,
'title' => $this->title,
'description' => $this->description,
'status' => $this->status,
'priority' => $this->priority,
'feature_id' => $this->featureId,
'inspection_id' => $this->inspectionId,
'reported_by' => Auth::id(),
'assigned_to' => $this->assignedTo,
]);
if ($issue->wasRecentlyCreated) {
$issue->load(['feature', 'assignee']);
$creator = $this->project->creator;
if ($creator && $creator->id !== Auth::id()) {
$creator->notify(new IssueReportedNotification($issue));
}
if ($issue->assignee && $issue->assignee->id !== Auth::id()) {
$issue->assignee->notify(new IssueReportedNotification($issue));
}
}
}
$this->editing = false;
$this->loadIssues();
$this->dispatch('notify', 'Issue guardado correctamente');
}
public function delete($issueId)
{
$issue = Issue::where('project_id', $this->project->id)->findOrFail($issueId);
$issue->delete();
$this->loadIssues();
$this->dispatch('notify', 'Issue eliminado');
}
public function cancel()
{
$this->editing = false;
$this->reset(['title', 'description', 'status', 'priority', 'featureId', 'inspectionId', 'assignedTo', 'editingId']);
}
public function render()
{
return view('livewire.issue-manager');
}
}
+7 -6
View File
@@ -9,20 +9,19 @@ use Illuminate\Support\Facades\Session;
class LanguageSwitcher extends Component
{
public $currentLocale;
public string $currentLocale;
public function mount()
public function mount(): void
{
$this->currentLocale = App::getLocale();
}
public function switchLanguage($locale)
public function switchLanguage(string $locale): void
{
if (!in_array($locale, ['en', 'es'])) {
return;
}
App::setLocale($locale);
Session::put('locale', $locale);
if (Auth::check()) {
@@ -31,8 +30,10 @@ class LanguageSwitcher extends Component
$user->save();
}
$this->currentLocale = $locale;
$this->dispatch('localeChanged', $locale);
// Dispatch a browser event — JavaScript reloads the page.
// PHP-side redirects break because $this->redirect() runs inside
// /livewire/update (the AJAX endpoint), not on the real page URL.
$this->dispatch('locale-changed');
}
public function render()
+245 -157
View File
@@ -8,9 +8,11 @@ use Livewire\Attributes\Layout;
use App\Models\Project;
use App\Models\Phase;
use App\Models\Layer;
use App\Services\SpatialFileConverter;
use App\Models\Feature;
use App\Models\InspectionTemplate;
use App\Services\SpatialFileConverter;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
#[Layout('layouts.app')]
@@ -19,97 +21,109 @@ class LayerManager extends Component
use WithFileUploads;
public Project $project;
public Phase $phase;
public Phase $phase;
public $layers;
public $selectedLayer = null;
public $visibleLayers = []; // IDs de capas visibles
public $visibleLayers = [];
public $uploadFile = null;
public $layerName = '';
public $layerColor = '#3b82f6';
public $manualGeojson = null;
public $drawingMode = false;
public $uploadFile = null;
public $layerName = '';
public $layerColor = '#3b82f6';
protected $rules = [
'uploadFile' => 'required|file|mimes:geojson,kmz,kml,shp,dwg,zip|max:51200',
'layerName' => 'required|string|max:255',
'layerColor' => 'nullable|string|size:7',
];
// Batch assign
public $templates = [];
public $batchTemplateId = null;
public $batchStatus = '';
public function mount(Project $project, Phase $phase)
{
$this->project = $project;
$this->phase = $phase;
$this->loadLayers();
if ($this->phase->project_id !== $this->project->id) {
abort(404);
$this->phase = $phase;
if ($this->phase->project_id !== $this->project->id) abort(404);
$user = Auth::user();
if (!$user->hasRole('Admin') && !$project->users()->where('user_id', $user->id)->exists()) {
abort(403);
}
// Por defecto todas visibles
$this->templates = InspectionTemplate::where('project_id', $this->project->id)->get();
$this->loadLayers();
$this->visibleLayers = $this->layers->pluck('id')->toArray();
$this->emitInitialLayersData();
}
// ── Data loaders ──────────────────────────────────────────────────────────
public function loadLayers()
{
$this->layers = Layer::with('features')->where('phase_id', $this->phase->id)->latest()->get();
$this->visibleLayers = array_intersect($this->visibleLayers, $this->layers->pluck('id')->toArray());
$this->layers = Layer::withCount('features')
->withAvg('features', 'progress')
->where('phase_id', $this->phase->id)
->latest()
->get();
$this->visibleLayers = array_values(
array_intersect($this->visibleLayers, $this->layers->pluck('id')->toArray())
);
}
private function buildLayerPayload(Layer $layer): array
{
$color = $layer->color ?: '#3b82f6';
$features = ($layer->relationLoaded('features') ? $layer->features : $layer->features()->get())
->map(fn($f) => [
'type' => 'Feature',
'id' => $f->id,
'geometry' => $f->geometry,
'properties' => [
'name' => $f->name ?? 'Elemento',
'progress' => $f->progress,
'status' => $f->status ?? 'planned',
'responsible' => $f->responsible,
'template_id' => $f->template_id,
],
])->values()->toArray();
return [
'id' => $layer->id,
'color' => $color,
'geojson' => [
'type' => 'FeatureCollection',
'features' => $features,
'style' => ['color' => $color],
],
];
}
private function emitInitialLayersData()
{
$layersData = $this->layers->map(function($layer) {
// Usar el color guardado en BD o el color del formulario
$color = $layer->color ?: ($this->layerColor ?: '#3b82f6');
// Construir FeatureCollection a partir de los features de esta capa
$features = $layer->features->map(function($feature) {
return [
'type' => 'Feature',
'id' => $feature->id,
'geometry' => $feature->geometry,
'properties' => [
'name' => $feature->name,
'progress' => $feature->progress,
'responsible' => $feature->responsible,
'template_id' => $feature->template_id,
]
];
})->values()->toArray();
$geojson = [
'type' => 'FeatureCollection',
'features' => $features,
'style' => ['color' => $color]
];
return [
'id' => $layer->id,
'geojson' => $geojson,
'color' => $color,
];
});
$this->layers->loadMissing('features');
$this->dispatch('initialLayersData', [
'layers' => $layersData,
'visibleLayers' => $this->visibleLayers,
'layers' => $this->layers->map(fn($l) => $this->buildLayerPayload($l)),
'visibleLayers' => $this->visibleLayers,
'selectedLayerId' => $this->selectedLayer?->id,
]);
}
// ── Visibility ────────────────────────────────────────────────────────────
public function toggleLayerVisibility($layerId)
{
if ($this->selectedLayer && $this->selectedLayer->id == $layerId) {
session()->flash('info', 'No puedes ocultar la capa que estás editando.');
$this->dispatch('notify', 'No puedes ocultar la capa que estás editando');
return;
}
if (in_array($layerId, $this->visibleLayers)) {
$this->visibleLayers = array_diff($this->visibleLayers, [$layerId]);
$this->visibleLayers = array_values(array_diff($this->visibleLayers, [$layerId]));
} else {
$this->visibleLayers[] = $layerId;
}
$this->dispatch('visibilityChanged', $this->visibleLayers);
}
// ── Select ────────────────────────────────────────────────────────────────
public function selectLayer($layerId)
{
$this->selectedLayer = Layer::with('features')->find($layerId);
@@ -120,185 +134,259 @@ class LayerManager extends Component
$this->dispatch('visibilityChanged', $this->visibleLayers);
}
// Construir el GeoJSON desde los features de la capa seleccionada
$features = $this->selectedLayer->features->map(function($feature) {
return [
'type' => 'Feature',
'id' => $feature->id,
'geometry' => $feature->geometry,
'properties' => [
'name' => $feature->name,
'progress' => $feature->progress,
'responsible' => $feature->responsible,
'template_id' => $feature->template_id,
]
];
})->values()->toArray();
$color = $this->selectedLayer->color ?: ($this->layerColor ?: '#3b82f6');
$geojson = [
'type' => 'FeatureCollection',
'features' => $features,
'style' => ['color' => $color]
];
$payload = $this->buildLayerPayload($this->selectedLayer);
$this->dispatch('layerSelectedForEdit', [
'layerId' => $layerId,
'geojson' => $geojson,
'color' => $color,
'geojson' => $payload['geojson'],
'color' => $payload['color'],
]);
session()->flash('info', 'Editando capa: ' . $this->selectedLayer->name);
$this->dispatch('notify', 'Editando: ' . $this->selectedLayer->name);
}
// ── Import file ───────────────────────────────────────────────────────────
public function importFile()
{
$user = Auth::user();
if (!$user->can('upload layers') && !$user->hasRole('Admin')) {
session()->flash('error', 'Sin permisos.');
$this->dispatch('notify', 'Sin permisos para subir capas');
return;
}
// Validar campos obligatorios y tamaño máximo
$this->validate([
'uploadFile' => 'required|file|max:51200',
'layerName' => 'required|string|max:255',
'layerName' => 'required|string|max:255',
'layerColor' => 'nullable|string|size:7',
]);
$extension = strtolower($this->uploadFile->getClientOriginalExtension());
$mime = $this->uploadFile->getMimeType();
$allowedExtensions = ['geojson', 'kmz', 'kml', 'shp', 'dwg', 'zip'];
$allowedMimes = [
'application/vnd.google-earth.kml+xml',
'application/vnd.google-earth.kmz',
'application/zip',
'application/x-zip-compressed',
'application/x-shapefile',
'image/vnd.dwg',
'application/acad',
'application/geo+json',
'text/xml', // ✅ Aceptar KML con text/xml
'application/xml', // ✅ Alternativa
];
if (!in_array($extension, $allowedExtensions) && !in_array($mime, $allowedMimes)) {
session()->flash('error', 'Tipo de archivo no permitido. Extensiones válidas: ' . implode(', ', $allowedExtensions));
$ext = strtolower($this->uploadFile->getClientOriginalExtension());
$allowed = ['geojson', 'json', 'kmz', 'kml', 'shp', 'dwg', 'zip'];
if (!in_array($ext, $allowed)) {
$this->dispatch('notify', 'Extensión no permitida. Válidas: ' . implode(', ', $allowed));
return;
}
$projectDir = "uploads/projects/{$this->project->id}/layers";
$originalPath = $this->uploadFile->store($projectDir, 'public');
$geojson = SpatialFileConverter::convertToGeoJson($this->uploadFile);
if (!$geojson) {
session()->flash('error', 'Conversión fallida. Asegúrate de que el archivo sea válido (KML, GeoJSON, etc.).');
$this->dispatch('notify', 'No se pudo convertir el archivo. Comprueba que sea GeoJSON, KML o Shapefile válido.');
return;
}
$layerColor = $this->layerColor ?: '#3b82f6';
$geojson['style'] = ['color' => $layerColor];
$layerName = $this->layerName;
$layer = Layer::create([
'project_id' => $this->project->id,
'phase_id' => $this->phase->id,
'name' => $this->layerName,
'color' => $layerColor,
'original_file' => $originalPath,
'uploaded_by' => $user->id,
]);
try {
DB::transaction(function () use ($geojson, $layerColor, $layerName, $user) {
$path = $this->uploadFile->store(
"uploads/projects/{$this->project->id}/layers", 'public'
);
// Crear features a partir del GeoJSON
if (isset($geojson['features'])) {
foreach ($geojson['features'] as $featureData) {
Feature::create([
'layer_id' => $layer->id,
'name' => $featureData['properties']['name'] ?? null,
'geometry' => $featureData['geometry'],
'properties' => $featureData['properties'] ?? [],
'template_id' => $featureData['properties']['template_id'] ?? null,
'progress' => $featureData['properties']['progress'] ?? 0,
'responsible' => $featureData['properties']['responsible'] ?? null,
$layer = Layer::create([
'project_id' => $this->project->id,
'phase_id' => $this->phase->id,
'name' => $layerName,
'color' => $layerColor,
'original_file' => $path,
'uploaded_by' => $user->id,
]);
}
$idx = 0;
foreach ($geojson['features'] ?? [] as $fd) {
$idx++;
$name = trim($fd['properties']['name'] ?? '');
if ($name === '') $name = $layerName . ' — Elemento ' . $idx;
Feature::create([
'layer_id' => $layer->id,
'name' => $name,
'geometry' => $fd['geometry'],
'properties' => $fd['properties'] ?? [],
'template_id' => $fd['properties']['template_id'] ?? null,
'progress' => $fd['properties']['progress'] ?? 0,
'status' => in_array($fd['properties']['status'] ?? '', Feature::STATUSES)
? $fd['properties']['status']
: 'planned',
'responsible' => $fd['properties']['responsible'] ?? null,
]);
}
$this->visibleLayers[] = $layer->id;
});
} catch (\Throwable $e) {
$this->dispatch('notify', 'Error al importar: ' . $e->getMessage());
return;
}
$this->loadLayers();
$this->visibleLayers[] = $layer->id;
$this->reset(['uploadFile', 'layerName']);
$this->emitInitialLayersData();
session()->flash('message', 'Capa importada correctamente.');
$this->dispatch('notify', 'Capa importada correctamente');
}
// ── Create empty layer ────────────────────────────────────────────────────
public function createEmptyLayer()
{
$user = Auth::user();
if (!$user->can('upload layers') && !$user->hasRole('Admin')) {
$this->dispatch('notify', 'Sin permisos para crear capas');
return;
}
$layer = Layer::create([
'project_id' => $this->project->id,
'phase_id' => $this->phase->id,
'name' => $this->layerName ?: 'Nueva capa',
'color' => $this->layerColor ?: '#3b82f6',
'project_id' => $this->project->id,
'phase_id' => $this->phase->id,
'name' => $this->layerName ?: 'Nueva capa',
'color' => $this->layerColor ?: '#3b82f6',
'original_file' => null,
'uploaded_by' => $user->id,
'uploaded_by' => $user->id,
]);
$this->loadLayers();
$this->visibleLayers[] = $layer->id;
$this->selectLayer($layer->id);
$this->emitInitialLayersData();
session()->flash('message', 'Capa vacía creada. Usa el editor para añadir elementos.');
$this->dispatch('notify', 'Capa vacía creada. Dibuja elementos en el mapa.');
}
// ── Save drawn GeoJSON ────────────────────────────────────────────────────
public function saveManualGeojson($geojsonString)
{
if (!$this->selectedLayer) {
session()->flash('error', 'No hay capa seleccionada.');
$this->dispatch('notify', 'No hay capa seleccionada');
return;
}
$geojson = json_decode($geojsonString, true);
if (json_last_error() !== JSON_ERROR_NONE || !isset($geojson['features'])) {
session()->flash('error', 'GeoJSON inválido.');
$this->dispatch('notify', 'GeoJSON inválido');
return;
}
// Eliminar todos los features existentes de esta capa
$this->selectedLayer->features()->delete();
$layerId = $this->selectedLayer->id;
$layerName = $this->selectedLayer->name;
// Crear nuevos features a partir del GeoJSON
foreach ($geojson['features'] as $featureData) {
Feature::create([
'layer_id' => $this->selectedLayer->id,
'name' => $featureData['properties']['name'] ?? null,
'geometry' => $featureData['geometry'],
'properties' => $featureData['properties'] ?? [],
'template_id' => $featureData['properties']['template_id'] ?? null,
'progress' => $featureData['properties']['progress'] ?? 0,
'responsible' => $featureData['properties']['responsible'] ?? null,
]);
try {
DB::transaction(function () use ($geojson, $layerId, $layerName) {
// forceDelete: reemplazamos completamente los elementos de la capa
Feature::where('layer_id', $layerId)->forceDelete();
$idx = 0;
foreach ($geojson['features'] as $fd) {
$idx++;
$name = trim($fd['properties']['name'] ?? '');
if ($name === '') $name = $layerName . ' — Elemento ' . $idx;
Feature::create([
'layer_id' => $layerId,
'name' => $name,
'geometry' => $fd['geometry'],
'properties' => $fd['properties'] ?? [],
'template_id' => $fd['properties']['template_id'] ?? null,
'progress' => $fd['properties']['progress'] ?? 0,
'status' => in_array($fd['properties']['status'] ?? '', Feature::STATUSES)
? $fd['properties']['status']
: 'planned',
'responsible' => $fd['properties']['responsible'] ?? null,
]);
}
});
} catch (\Throwable $e) {
$this->dispatch('notify', 'Error al guardar: ' . $e->getMessage());
return;
}
$this->loadLayers();
$this->selectLayer($this->selectedLayer->id);
$this->emitInitialLayersData();
session()->flash('message', 'Capa guardada con ' . count($geojson['features']) . ' elementos.');
$this->dispatch('notify', count($geojson['features']) . ' elementos guardados');
}
// ── Delete layer ──────────────────────────────────────────────────────────
public function deleteLayer($layerId)
{
$user = Auth::user();
if (!$user->can('delete layers') && !$user->hasRole('Admin')) abort(403);
$layer = Layer::find($layerId);
// Verify it belongs to this phase (prevents cross-project deletion)
$layer = Layer::where('id', $layerId)->where('phase_id', $this->phase->id)->first();
if (!$layer) return;
if ($layer->original_file) Storage::disk('public')->delete($layer->original_file);
$layer->features()->delete(); // opcional, si no usas cascade
$layer->features()->delete();
$layer->delete();
$this->loadLayers();
if ($this->selectedLayer && $this->selectedLayer->id == $layerId) {
$this->selectedLayer = null;
$this->dispatch('layerSelectedForEdit', null);
}
$this->emitInitialLayersData();
session()->flash('message', 'Capa eliminada.');
$this->dispatch('notify', 'Capa eliminada');
}
// ── Export GeoJSON ────────────────────────────────────────────────────────
public function exportLayer($layerId)
{
$layer = Layer::with('features')
->where('id', $layerId)
->where('phase_id', $this->phase->id)
->first();
if (!$layer) return;
$fc = [
'type' => 'FeatureCollection',
'name' => $layer->name,
'features' => $layer->features->map(fn($f) => [
'type' => 'Feature',
'geometry' => $f->geometry,
'properties' => array_merge($f->properties ?? [], [
'name' => $f->name,
'progress' => $f->progress,
'status' => $f->status,
'responsible' => $f->responsible,
'template_id' => $f->template_id,
]),
])->values()->toArray(),
];
$filename = preg_replace('/[^a-z0-9_\-]/i', '_', $layer->name) . '.geojson';
return response()->streamDownload(function () use ($fc) {
echo json_encode($fc, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}, $filename, ['Content-Type' => 'application/geo+json']);
}
// ── Batch assign template / status ────────────────────────────────────────
public function batchAssign($layerId)
{
$layer = Layer::where('id', $layerId)->where('phase_id', $this->phase->id)->first();
if (!$layer) return;
$data = [];
if ($this->batchStatus && in_array($this->batchStatus, Feature::STATUSES)) {
$data['status'] = $this->batchStatus;
}
if ($this->batchTemplateId) {
$data['template_id'] = (int) $this->batchTemplateId;
}
if (empty($data)) {
$this->dispatch('notify', 'Selecciona un estado o template para asignar');
return;
}
$count = $layer->features()->update($data);
$this->loadLayers();
$this->emitInitialLayersData();
$this->dispatch('notify', "$count elemento(s) actualizados");
}
// ── Cancel editing ────────────────────────────────────────────────────────
public function cancelEditing()
{
$this->selectedLayer = null;
@@ -309,4 +397,4 @@ class LayerManager extends Component
{
return view('livewire.layers.layer-manager');
}
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Illuminate\Support\Facades\Auth;
class NotificationBell extends Component
{
public $notifications = [];
public $unreadCount = 0;
public $showDropdown = false;
public function mount()
{
$this->loadNotifications();
}
public function loadNotifications()
{
$user = Auth::user();
$this->notifications = $user->notifications()->latest()->take(10)->get()->toArray();
$this->unreadCount = $user->unreadNotifications()->count();
}
public function markAsRead($id)
{
Auth::user()->notifications()->where('id', $id)->update(['read_at' => now()]);
$this->loadNotifications();
}
public function markAllAsRead()
{
Auth::user()->unreadNotifications->markAsRead();
$this->loadNotifications();
}
public function render()
{
return view('livewire.notification-bell');
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\Project;
use Illuminate\Support\Facades\Auth;
#[Layout('layouts.app')]
class PhaseGantt extends Component
{
public Project $project;
public $ganttData = [];
public function mount(Project $project)
{
$user = Auth::user();
if (!$user->hasRole('Admin') && !$project->users()->where('user_id', $user->id)->exists()) {
abort(403);
}
$this->project = $project;
$this->loadGanttData();
}
public function loadGanttData()
{
$phases = $this->project->phases()->with(['layers.features'])->orderBy('order')->get();
$projectStart = $this->project->start_date ?? now()->startOfMonth();
$projectEnd = $this->project->end_date_estimated ?? now()->addMonths(6);
$this->ganttData = $phases->map(function($phase) use ($projectStart, $projectEnd) {
$planned_start = $phase->planned_start ?? $projectStart;
$planned_end = $phase->planned_end ?? $projectEnd;
$actual_start = $phase->actual_start;
$actual_end = $phase->actual_end;
$totalDays = max(1, $projectStart->diffInDays($projectEnd));
$pStartOffset = max(0, $projectStart->diffInDays($planned_start));
$pDuration = max(1, $planned_start->diffInDays($planned_end));
$pStartPct = round(($pStartOffset / $totalDays) * 100, 2);
$pWidthPct = round(($pDuration / $totalDays) * 100, 2);
$aStartPct = null; $aWidthPct = null;
if ($actual_start) {
$aStart = max(0, $projectStart->diffInDays($actual_start));
$aEnd = $actual_end ?? now();
$aDuration = max(1, $actual_start->diffInDays($aEnd));
$aStartPct = round(($aStart / $totalDays) * 100, 2);
$aWidthPct = round(($aDuration / $totalDays) * 100, 2);
}
$isDelayed = $phase->planned_end && $phase->planned_end->isPast() && $phase->progress_percent < 100;
return [
'id' => $phase->id,
'name' => $phase->name,
'color' => $phase->color ?? '#3b82f6',
'progress' => $phase->progress_percent,
'planned_start' => $planned_start->format('d/m/Y'),
'planned_end' => $planned_end->format('d/m/Y'),
'actual_start' => $actual_start?->format('d/m/Y'),
'actual_end' => $actual_end?->format('d/m/Y'),
'p_start_pct' => $pStartPct,
'p_width_pct' => min($pWidthPct, 100 - $pStartPct),
'a_start_pct' => $aStartPct,
'a_width_pct' => $aWidthPct ? min($aWidthPct, 100 - $aStartPct) : null,
'is_delayed' => $isDelayed,
'features_count' => $phase->layers->sum(fn($l) => $l->features->count()),
];
})->toArray();
}
public function updatePhaseDates($phaseId, $plannedStart, $plannedEnd, $actualStart = null, $actualEnd = null)
{
$phase = $this->project->phases()->findOrFail($phaseId);
$phase->update([
'planned_start' => $plannedStart ?: null,
'planned_end' => $plannedEnd ?: null,
'actual_start' => $actualStart ?: null,
'actual_end' => $actualEnd ?: null,
]);
$this->loadGanttData();
$this->dispatch('notify', 'Fechas actualizadas');
}
public function render()
{
return view('livewire.projects.phase-gantt', [
'project' => $this->project,
'phases' => $this->project->phases()->orderBy('order')->get(),
]);
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\Project;
use App\Models\Phase;
use App\Models\Feature;
use App\Models\Inspection;
use App\Models\Issue;
use Illuminate\Support\Facades\Auth;
#[Layout('layouts.app')]
class ProjectDashboard extends Component
{
public Project $project;
// Computed stats (cached as properties after mount)
public array $stats = [];
public $phases;
public $recentInspections;
public $recentIssues;
public $teamMembers;
public $companies;
public function mount(Project $project): void
{
$this->project = $project;
$this->checkAccess();
$this->loadData();
}
private function checkAccess(): void
{
$user = Auth::user();
if ($user->hasRole('Admin')) return;
if (!$this->project->users()->where('user_id', $user->id)->exists()) abort(403);
}
private function loadData(): void
{
$pid = $this->project->id;
$this->phases = Phase::where('project_id', $pid)
->withCount('layers')
->with(['layers' => fn($q) => $q->withCount('features')])
->orderBy('order')
->get();
$totalFeatures = Feature::whereHas('layer.phase', fn($q) => $q->where('project_id', $pid))->count();
$completedFeatures = Feature::whereHas('layer.phase', fn($q) => $q->where('project_id', $pid))
->where('status', 'completed')->count();
$verifiedFeatures = Feature::whereHas('layer.phase', fn($q) => $q->where('project_id', $pid))
->where('status', 'verified')->count();
$openIssues = Issue::where('project_id', $pid)->where('status', 'open')->count();
$closedIssues = Issue::where('project_id', $pid)->where('status', 'closed')->count();
$criticalIssues = Issue::where('project_id', $pid)->where('status', 'open')->where('priority', 'critical')->count();
$totalInspections = Inspection::where('project_id', $pid)->count();
$passedInspections = Inspection::where('project_id', $pid)->where('result', 'pass')->count();
$failedInspections = Inspection::where('project_id', $pid)->where('result', 'fail')->count();
$globalProgress = $this->phases->avg('progress_percent') ?? 0;
$delayedPhases = $this->phases->filter(fn($p) =>
$p->planned_end && $p->planned_end < now() && $p->progress_percent < 100
)->count();
$this->stats = [
'global_progress' => round($globalProgress),
'total_phases' => $this->phases->count(),
'delayed_phases' => $delayedPhases,
'total_features' => $totalFeatures,
'completed_features' => $completedFeatures,
'verified_features' => $verifiedFeatures,
'open_issues' => $openIssues,
'closed_issues' => $closedIssues,
'critical_issues' => $criticalIssues,
'total_inspections' => $totalInspections,
'passed_inspections' => $passedInspections,
'failed_inspections' => $failedInspections,
];
$this->recentInspections = Inspection::where('project_id', $pid)
->with(['feature', 'template', 'user'])
->latest()->take(6)->get();
$this->recentIssues = Issue::where('project_id', $pid)
->with(['feature', 'reporter'])
->where('status', '!=', 'closed')
->orderByRaw("FIELD(priority,'critical','high','medium','low')")
->take(6)->get();
$this->teamMembers = $this->project->users()->with('roles')->get();
$this->companies = $this->project->companies()->get();
}
public function render()
{
return view('livewire.projects.project-dashboard', [
'project' => $this->project,
]);
}
}
+86 -52
View File
@@ -3,79 +3,113 @@
namespace App\Livewire;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\Project;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
#[Layout('layouts.app')]
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';
public ?Project $project = null;
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',
];
// Identification
public string $name = '';
public string $reference = '';
public string $status = 'planning';
public function mount($projectId = null)
// Location
public string $address = '';
public string $country = '';
public string $lat = '';
public string $lng = '';
// Planning
public string $startDate = '';
public string $endDateEstimated = '';
public function mount(?Project $project = null): void
{
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.
if ($project && $project->exists) {
Gate::authorize('edit projects', $project);
$this->project = $project;
$this->name = $project->name;
$this->reference = $project->reference ?? '';
$this->status = $project->status;
$this->address = $project->address;
$this->country = $project->country ?? '';
$this->lat = (string) ($project->lat ?? '');
$this->lng = (string) ($project->lng ?? '');
$this->startDate = $project->start_date->format('Y-m-d');
$this->endDateEstimated = $project->end_date_estimated?->format('Y-m-d') ?? '';
} else {
Gate::authorize('create projects');
$this->startDate = today()->format('Y-m-d');
}
}
public function setCoordinates($lat, $lng)
// Called from JS after map click / marker drag + reverse geocode
public function setLocation(string $lat, string $lng, string $address = '', string $country = ''): void
{
$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);
if ($address) $this->address = $address;
if ($country) $this->country = strtolower($country);
}
public function save()
protected function rules(): array
{
return [
'name' => 'required|string|max:255',
'reference' => 'nullable|string|max:100',
'status' => 'required|in:planning,in_progress,paused,completed',
'address' => 'required|string',
'country' => 'nullable|string|size:2',
'lat' => 'nullable|numeric|between:-90,90',
'lng' => 'nullable|numeric|between:-180,180',
'startDate' => 'required|date',
'endDateEstimated' => 'nullable|date|after_or_equal:startDate',
];
}
protected $validationAttributes = [
'name' => 'nombre',
'reference' => 'referencia',
'status' => 'estado',
'address' => 'dirección',
'country' => 'país',
'lat' => 'latitud',
'lng' => 'longitud',
'startDate' => 'fecha de inicio',
'endDateEstimated' => 'fecha de fin estimada',
];
public function save(): void
{
$this->validate();
if ($this->projectId) {
$project = Project::findOrFail($this->projectId);
$data = [
'name' => $this->name,
'reference' => $this->reference ?: null,
'status' => $this->status,
'address' => $this->address,
'country' => $this->country ?: null,
'lat' => $this->lat ?: null,
'lng' => $this->lng ?: null,
'start_date' => $this->startDate,
'end_date_estimated' => $this->endDateEstimated ?: null,
];
if ($this->project && $this->project->exists) {
$this->project->update($data);
session()->flash('notify', 'Proyecto actualizado correctamente.');
} else {
$project = new Project();
$project->created_by = auth()->id();
$project = Project::create(array_merge($data, ['created_by' => Auth::id()]));
$project->users()->attach(Auth::id(), ['role_in_project' => 'supervisor']);
session()->flash('notify', 'Proyecto creado correctamente.');
}
$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');
$this->redirect(route('projects.index'), navigate: true);
}
public function render()
+237 -122
View File
@@ -10,27 +10,28 @@ use App\Models\Layer;
use App\Models\Feature;
use App\Models\Inspection;
use App\Models\InspectionTemplate;
use App\Models\Issue;
class ProjectMap extends Component
{
public Project $project;
public $phases;
public $activeLayers = [];
public $activeLayers = []; // Now stores Layer IDs (not Phase IDs)
public $showLayerModal = false;
// Editor properties
public $selectedFeature = null; // será instancia de Feature
public $selectedFeature = null;
public $selectedPhaseId = null;
public $editProgress = 0;
public $editComment = '';
public $editResponsible = '';
public $editPhotos = [];
public $formFullscreen = false;
// Tab management
public $activeTab = 'edit'; // edit, features, inspections
public $allFeatures = [];
public $allInspections = [];
// Tab management
public $activeTab = 'edit';
public $allFeatures;
public $allInspections;
// Templates e inspecciones
public $templates = [];
@@ -42,19 +43,61 @@ class ProjectMap extends Component
public $showFeatureImages = false;
public $featureImageMarkers = [];
// Tab management
public $activeTab = 'edit'; // edit or list
// Filters
public $filterStatus = '';
public $filterResponsible = '';
public $filterProgressMin = 0;
public $filterProgressMax = 100;
public $showFilters = false;
// Inspection workflow
public $inspectionResult = '';
public $inspectionNotes = '';
// Issues
public $openIssuesCount = 0;
// Inspection viewer
public $viewingInspection = null;
public function mount(Project $project)
{
$this->project = $project;
// Cargar fases con sus capas y los features de esas capas (para mostrarlos en el mapa)
$this->phases = $project->phases()->with(['layers' => function ($q) {
$q->withCount('features');
}, 'layers.features'])->get();
// Por defecto mostrar todas las capas activas (todas las fases que tengan alguna capa con features)
$this->activeLayers = $this->phases->pluck('id')->toArray();
$this->authorizeProjectAccess();
$this->phases = $project->phases()->with([
'layers' => fn($q) => $q->withCount('features'),
'layers.features',
'layers.features.images',
])->get();
// Initialize activeLayers with ALL layer IDs (not phase IDs)
$this->activeLayers = $this->phases
->flatMap(fn($p) => $p->layers->pluck('id'))
->map(fn($id) => (int) $id)
->toArray();
$this->loadTemplates();
$this->allFeatures = Feature::whereHas('layer.phase', function($q) use ($project) {
$q->where('project_id', $project->id);
})->with(['layer.phase', 'template'])->get();
$this->allInspections = Inspection::where('project_id', $project->id)
->with(['feature.layer.phase', 'template', 'user'])
->orderBy('created_at', 'desc')
->get();
$this->openIssuesCount = Issue::where('project_id', $project->id)
->where('status', 'open')
->count();
}
private function authorizeProjectAccess(): void
{
$user = Auth::user();
if ($user->hasRole('Admin')) return;
if (!$this->project->users()->where('user_id', $user->id)->exists()) abort(403);
}
public function loadTemplates()
@@ -62,90 +105,129 @@ class ProjectMap extends Component
$this->templates = InspectionTemplate::where('project_id', $this->project->id)->get();
}
public function toggleLayer($phaseId)
// ─── Layer / Phase visibility ────────────────────────────────────────────────
public function toggleLayer($layerId)
{
if (in_array($phaseId, $this->activeLayers)) {
$this->activeLayers = array_diff($this->activeLayers, [$phaseId]);
$layerId = (int) $layerId;
if (in_array($layerId, $this->activeLayers)) {
$this->activeLayers = array_values(array_diff($this->activeLayers, [$layerId]));
} else {
$this->activeLayers[] = $phaseId;
$this->activeLayers[] = $layerId;
}
$this->dispatch('layersUpdated', $this->activeLayers);
}
public function openLayerModal()
public function togglePhase($phaseId)
{
$this->showLayerModal = true;
$phase = $this->phases->find($phaseId);
if (!$phase) return;
$layerIds = $phase->layers->pluck('id')->map(fn($id) => (int) $id)->toArray();
$allActive = !empty($layerIds) && collect($layerIds)->every(fn($id) => in_array($id, $this->activeLayers));
if ($allActive) {
$this->activeLayers = array_values(array_diff($this->activeLayers, $layerIds));
} else {
$this->activeLayers = array_values(array_unique(array_merge($this->activeLayers, $layerIds)));
}
$this->dispatch('layersUpdated', $this->activeLayers);
}
public function closeLayerModal()
public function openLayerModal() { $this->showLayerModal = true; }
public function closeLayerModal() { $this->showLayerModal = false; }
// ─── Filters ────────────────────────────────────────────────────────────────
public function updatedFilterStatus() { $this->applyFilters(); }
public function updatedFilterResponsible() { $this->applyFilters(); }
public function updatedFilterProgressMin() { $this->applyFilters(); }
public function updatedFilterProgressMax() { $this->applyFilters(); }
public function applyFilters()
{
$this->showLayerModal = false;
$filtered = $this->allFeatures->filter(function($f) {
if ($this->filterStatus && $f->status !== $this->filterStatus) return false;
if ($this->filterResponsible && !str_contains(strtolower($f->responsible ?? ''), strtolower($this->filterResponsible))) return false;
if ($f->progress < $this->filterProgressMin || $f->progress > $this->filterProgressMax) return false;
return true;
});
$this->dispatch('filtersChanged', $filtered->pluck('id')->values()->toArray());
}
public function clearFilters()
{
$this->filterStatus = '';
$this->filterResponsible = '';
$this->filterProgressMin = 0;
$this->filterProgressMax = 100;
$this->dispatch('filtersChanged', $this->allFeatures->pluck('id')->values()->toArray());
}
// ─── Feature status ─────────────────────────────────────────────────────────
public function editFeatureStatus($status)
{
if (!$this->selectedFeature) return;
$feature = Feature::with('layer.phase')->findOrFail($this->selectedFeature->id);
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
$feature->status = $status;
if ($status === 'completed') $feature->progress = 100;
if ($status === 'planned') $feature->progress = 0;
$feature->save();
$this->selectedFeature = $feature;
$this->editProgress = $feature->progress;
$this->allFeatures = $this->allFeatures->map(fn($f) => $f->id === $feature->id ? $feature : $f);
$this->dispatch('featureStatusChanged', $feature->id, $feature->status, $feature->status_color);
$this->dispatch('notify', 'Estado actualizado');
}
/**
* Actualizar el progreso de un Feature y recalcular el progreso de la fase.
*/
public function updateProgress($featureId, $newProgress, $comment = null)
{
$feature = Feature::findOrFail($featureId);
$feature = Feature::with('layer.phase')->findOrFail($featureId);
$user = Auth::user();
if (!$user->can('update progress') && !$user->hasRole('Admin')) {
$this->dispatch('notify', 'Sin permisos');
return;
}
$oldProgress = $feature->progress;
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
$feature->progress = min(100, max(0, $newProgress));
$feature->save();
// Recalcular el progreso de la fase (promedio de todos sus features)
$phase = Phase::find($feature->layer->phase_id);
$phase = $feature->layer->phase;
$phase->progress_percent = $phase->features()->avg('progress') ?: 0;
$phase->save();
// Registrar la actualización en progress_updates
$phase->progressUpdates()->create([
'user_id' => $user->id,
'user_id' => $user->id,
'progress_percent' => $phase->progress_percent,
'comment' => $comment,
'comment' => $comment,
]);
$this->dispatch('progressUpdated', $featureId, $feature->progress);
$this->dispatch('notify', 'Progreso actualizado');
// Si el feature seleccionado es el mismo, actualizar la propiedad local
if ($this->selectedFeature && $this->selectedFeature->id == $featureId) {
$this->selectedFeature->progress = $feature->progress;
$this->editProgress = $feature->progress;
}
}
/**
* Seleccionar un Feature al hacer clic en el mapa.
*/
public function selectFeature($featureId)
{
$this->selectedFeature = null;
$feature = Feature::with('template')->find($featureId);
$feature = Feature::with(['template', 'layer.phase'])->find($featureId);
if (!$feature) return;
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
$this->selectedFeature = $feature;
$this->selectedPhaseId = $feature->layer->phase_id;
$this->editProgress = $feature->progress;
$this->editResponsible = $feature->responsible ?? '';
$this->editPhotos = $feature->properties['photos'] ?? [];
$this->selectedFeature = $feature;
$this->selectedPhaseId = $feature->layer->phase_id;
$this->editProgress = $feature->progress;
$this->editResponsible = $feature->responsible ?? '';
$this->editPhotos = $feature->properties['photos'] ?? [];
$this->selectedTemplateId = $feature->template_id;
$this->activeTab = 'edit';
$this->loadInspectionHistory();
$this->resetInspectionForm();
$this->dispatch('featureSelected', $featureId);
$this->dispatch('featureSelected', $featureId, $feature->name);
}
/**
* Cargar el historial de inspecciones del feature seleccionado.
*/
public function loadInspectionHistory()
{
if (!$this->selectedFeature) {
@@ -158,12 +240,11 @@ class ProjectMap extends Component
->get();
}
/**
* Reiniciar el formulario de inspección según el template seleccionado.
*/
public function resetInspectionForm()
{
$this->inspectionFormData = [];
$this->inspectionResult = '';
$this->inspectionNotes = '';
if ($this->selectedTemplateId) {
$template = InspectionTemplate::find($this->selectedTemplateId);
if ($template) {
@@ -174,19 +255,16 @@ class ProjectMap extends Component
}
}
/**
* Guardar una nueva inspección.
*/
public function saveInspection()
{
if (!$this->selectedFeature || !$this->selectedTemplateId) {
$this->dispatch('notify', 'Selecciona un elemento y un template.');
return;
}
$feature = Feature::with('layer.phase')->find($this->selectedFeature->id);
if (!$feature || $feature->layer->phase->project_id !== $this->project->id) abort(403);
$this->validate([
'selectedTemplateId' => 'required|exists:inspection_templates,id',
]);
$this->validate(['selectedTemplateId' => 'required|exists:inspection_templates,id']);
$template = InspectionTemplate::find($this->selectedTemplateId);
foreach ($template->fields as $field) {
@@ -197,70 +275,117 @@ class ProjectMap extends Component
}
$inspection = Inspection::create([
'project_id' => $this->project->id,
'layer_id' => $this->selectedFeature->layer_id,
'feature_id' => $this->selectedFeature->id,
'template_id' => $this->selectedTemplateId,
'user_id' => auth()->id(),
'data' => $this->inspectionFormData,
'project_id' => $this->project->id,
'layer_id' => $this->selectedFeature->layer_id,
'feature_id' => $this->selectedFeature->id,
'template_id' => $this->selectedTemplateId,
'user_id' => auth()->id(),
'inspector_user_id' => auth()->id(),
'status' => 'completed',
'completed_at' => now(),
'result' => $this->inspectionResult ?: null,
'notes' => $this->inspectionNotes ?: null,
'data' => $this->inspectionFormData,
]);
// Si el template tiene un campo llamado 'progress', actualizar el progreso del feature
if (isset($this->inspectionFormData['progress'])) {
$this->updateProgress($this->selectedFeature->id, (int)$this->inspectionFormData['progress'], 'Inspección registrada');
if ($this->inspectionResult === 'fail') {
Issue::create([
'project_id' => $this->project->id,
'feature_id' => $this->selectedFeature->id,
'inspection_id' => $inspection->id,
'title' => 'Fallo en inspección: ' . ($template->name ?? 'Sin nombre'),
'description' => $this->inspectionNotes,
'priority' => 'high',
'status' => 'open',
'reported_by' => auth()->id(),
]);
$this->openIssuesCount = Issue::where('project_id', $this->project->id)
->where('status', 'open')->count();
$this->dispatch('notify', 'Inspección fallida — Issue creado automáticamente');
} else {
if (isset($this->inspectionFormData['progress'])) {
$this->updateProgress($this->selectedFeature->id, (int)$this->inspectionFormData['progress'], 'Inspección registrada');
}
$this->dispatch('notify', 'Inspección guardada correctamente');
}
// Reload global list
$this->allInspections = Inspection::where('project_id', $this->project->id)
->with(['feature.layer.phase', 'template', 'user'])
->orderBy('created_at', 'desc')
->get();
$this->loadInspectionHistory();
$this->resetInspectionForm();
$this->dispatch('notify', 'Inspección guardada correctamente');
}
/**
* Asignar un template al feature seleccionado.
*/
public function assignTemplateToFeature($templateId)
{
if (!$this->selectedFeature) return;
$this->selectedFeature->template_id = $templateId;
$this->selectedFeature->save();
$template = InspectionTemplate::where('id', $templateId)
->where('project_id', $this->project->id)->first();
if (!$template) abort(403);
$feature = Feature::findOrFail($this->selectedFeature->id);
$feature->template_id = $templateId;
$feature->save();
$this->selectedFeature = $feature;
$this->selectedTemplateId = $templateId;
$this->resetInspectionForm();
$this->dispatch('notify', 'Template asignado al elemento');
}
/**
* Guardar progreso y responsable del feature seleccionado.
*/
public function saveFeatureProgress()
{
if (!$this->selectedFeature) return;
$this->selectedFeature->progress = min(100, max(0, (int)$this->editProgress));
$this->selectedFeature->responsible = $this->editResponsible;
$this->selectedFeature->save();
// Recalcular progreso de la fase
$phase = Phase::find($this->selectedFeature->layer->phase_id);
$feature = Feature::with('layer.phase')->findOrFail($this->selectedFeature->id);
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
$feature->progress = min(100, max(0, (int)$this->editProgress));
$feature->responsible = $this->editResponsible;
$feature->save();
$this->selectedFeature = $feature;
$phase = $feature->layer->phase;
$phase->progress_percent = $phase->features()->avg('progress') ?: 0;
$phase->save();
$this->dispatch('progressUpdated', $phase->id, $phase->progress_percent);
$this->dispatch('notify', 'Progreso guardado');
}
/**
* Cuando cambia el template seleccionado, reiniciar el formulario.
*/
public function onTemplateChange()
{
$this->resetInspectionForm();
}
/**
* Toggle mostrar imágenes en el mapa.
*/
// ─── Inspection viewer ───────────────────────────────────────────────────────
public function viewInspection($id)
{
$ins = Inspection::where('project_id', $this->project->id)
->with(['feature.layer.phase', 'template', 'user'])
->find($id);
if (!$ins) return;
$this->viewingInspection = [
'id' => $ins->id,
'feature_name' => $ins->feature?->name ?? '—',
'layer_name' => $ins->feature?->layer?->name ?? '—',
'phase_name' => $ins->feature?->layer?->phase?->name ?? '—',
'template_name' => $ins->template?->name ?? '—',
'user_name' => $ins->user?->name ?? '—',
'date' => $ins->created_at->format('d/m/Y H:i'),
'status' => $ins->status,
'result' => $ins->result,
'notes' => $ins->notes,
'data' => $ins->data ?? [],
'fields' => $ins->template?->fields ?? [],
];
}
public function closeViewInspection()
{
$this->viewingInspection = null;
}
// ─── Feature images ──────────────────────────────────────────────────────────
public function toggleFeatureImages()
{
$this->showFeatureImages = !$this->showFeatureImages;
@@ -268,44 +393,31 @@ class ProjectMap extends Component
$this->dispatch('featureImagesToggled', $this->showFeatureImages, $this->featureImageMarkers);
}
/**
* Cargar marcadores de imágenes para el mapa.
*/
public function loadFeatureImageMarkers()
{
if (!$this->showFeatureImages) {
$this->featureImageMarkers = [];
return;
}
if (!$this->showFeatureImages) { $this->featureImageMarkers = []; return; }
$markers = [];
foreach ($this->phases as $phase) {
foreach ($phase->layers as $layer) {
foreach ($layer->features as $feature) {
$image = $feature->images()->first();
$image = $feature->images->first();
if ($image) {
$geo = $feature->geometry;
$geo = $feature->geometry;
$coords = null;
if ($geo && isset($geo['coordinates'])) {
if ($geo['type'] === 'Point') {
$coords = [
'lat' => $geo['coordinates'][1],
'lng' => $geo['coordinates'][0],
];
$coords = ['lat' => $geo['coordinates'][1], 'lng' => $geo['coordinates'][0]];
} elseif (in_array($geo['type'], ['Polygon', 'LineString'])) {
$coords = [
'lat' => $geo['coordinates'][0][1] ?? null,
'lng' => $geo['coordinates'][0][0] ?? null,
];
$coords = ['lat' => $geo['coordinates'][0][1] ?? null, 'lng' => $geo['coordinates'][0][0] ?? null];
}
}
if ($coords && $coords['lat'] && $coords['lng']) {
$markers[] = [
'feature_id' => $feature->id,
'name' => $feature->name,
'lat' => $coords['lat'],
'lng' => $coords['lng'],
'image_url' => $image->url,
'name' => $feature->name,
'lat' => $coords['lat'],
'lng' => $coords['lng'],
'image_url' => $image->url,
'image_name' => $image->name,
];
}
@@ -319,16 +431,19 @@ class ProjectMap extends Component
public function toggleFullscreen()
{
$this->formFullscreen = !$this->formFullscreen;
if (!$this->formFullscreen) {
$this->dispatch('mapResize');
}
if (!$this->formFullscreen) $this->dispatch('mapResize');
}
public function setActiveTab($tab)
{
$this->activeTab = $tab;
}
public function render()
{
return view('livewire.projects.project-map', [
'project' => $this->project,
'phases' => $this->phases,
'phases' => $this->phases,
]);
}
}
}
+77 -62
View File
@@ -4,9 +4,8 @@ namespace App\Livewire;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Rappasoft\LaravelLivewireTables\Views\Columns\{BooleanColumn, ButtonGroupColumn, LinkColumn, ImageColumn};
use Rappasoft\LaravelLivewireTables\Views\Filters\{DateFilter, MultiSelectFilter, SelectFilter};
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use App\Models\Project;
class ProjectTable extends DataTableComponent
@@ -17,86 +16,102 @@ class ProjectTable extends DataTableComponent
{
$this->setPrimaryKey('id')
->setDefaultSort('created_at', 'desc')
->setTableAttributes(['class' => 'table-auto w-full']);
->setSortingPillsEnabled(false)
->setAdditionalSelects(['projects.id as id', 'projects.created_at as created_at']);
}
$this->setThAttributes(function(Column $column) {
return ['class' => 'px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider'];
});
$this->setTdAttributes(function(Column $column) {
return ['class' => 'px-4 py-2 whitespace-nowrap text-sm text-gray-900'];
});
public function builder(): Builder
{
return Project::accessibleBy(Auth::user())
->with('phases');
}
public function columns(): array
{
return [
Column::make(__('ID'), 'id')
Column::make('Referencia', 'reference')
->sortable()
->searchable()
->format(function ($value, $row) {
$url = route('projects.dashboard', $row->id);
return $value
? '<a href="'.$url.'" class="font-mono text-xs text-primary hover:underline" wire:navigate>'.e($value).'</a>'
: '<span class="text-gray-300">—</span>';
})
->html(),
Column::make(__('Name'), 'name')
->sortable()
->searchable(),
Column::make(__('Project Name'), 'name')
->sortable()
->searchable(),
Column::make(__('Address'), 'address')
->sortable()
->searchable(),
->searchable()
->format(fn ($value) => $value
? '<span class="truncate block max-w-xs" title="'.e($value).'">'.e($value).'</span>'
: '<span class="text-gray-400">—</span>')
->html(),
Column::make(__('Status'), 'status')
->sortable(),
->sortable()
->format(function ($value) {
$map = [
'planning' => ['badge-ghost', 'Planificación'],
'in_progress' => ['badge-primary', 'En progreso'],
'paused' => ['badge-warning', 'Pausado'],
'completed' => ['badge-success', 'Completado'],
];
[$cls, $label] = $map[$value] ?? ['badge-ghost', ucfirst($value)];
return '<span class="badge '.$cls.'">'.$label.'</span>';
})
->html(),
Column::make(__('Progress'))
->label(function ($row) {
$avg = $row->phases->avg('progress_percent') ?? 0;
$pct = round($avg);
return '
<div class="flex items-center gap-2 min-w-[100px]">
<div class="flex-1 bg-gray-200 rounded-full h-2">
<div class="bg-primary h-2 rounded-full" style="width:'.$pct.'%"></div>
</div>
<span class="text-xs text-gray-500 w-8 text-right">'.$pct.'%</span>
</div>';
})
->html(),
Column::make(__('Start Date'), 'start_date')
->sortable()
->format(fn ($value, $row, $column) => $value ? $value->format('Y-m-d') : ''),
->format(fn ($value) => $value ? $value->format('d/m/Y') : ''),
Column::make(__('Estimated End Date'), 'end_date_estimated')
Column::make(__('Est. End'), 'end_date_estimated')
->sortable()
->format(fn ($value, $row, $column) => $value ? $value->format('Y-m-d') : ''),
->format(fn ($value) => $value ? $value->format('d/m/Y') : ''),
Column::make(__('Actions'))
->label(function ($row) {
$confirm = __('Are you sure you want to delete this project?');
return '
<div class="flex space-x-2">
<a href="'.route('projects.edit', $row->id).'" class="btn btn-sm">'.__('Edit').'</a>
<form action="'.route('projects.destroy', $row->id).'" method="POST" onsubmit="return confirm(\''.$confirm.'\');">
'.csrf_field().'
<input type="hidden" name="_method" value="DELETE">
<button type="submit" class="btn btn-sm">'.__('Delete').'</button>
</form>
</div>';
})
->html(),
->label(function ($row) {
$dashboard = route('projects.dashboard', $row->id);
$map = route('projects.map', $row->id);
$edit = route('projects.edit', $row->id);
ButtonGroupColumn::make(__('Actions'))
->attributes(function($row) {
return [
'class' => 'space-x-2',
];
})
->buttons([
LinkColumn::make('Edit')
->title(fn($row) => __('Edit'))
->location(fn($row) => route('projects.edit', $row->id))
->attributes(function($row) {
return [
'target' => '_blank',
'class' => 'text-blue-500 hover:underline',
];
}),
$canEdit = Auth::user()->can('edit projects');
LinkColumn::make('View') // make() has no effect in this case but needs to be set anyway
->title(fn($row) => __('View'))
->location(fn($row) => route('projects.map', $row->id))
->attributes(function($row) {
return [
'class' => 'text-blue-500 hover:underline',
];
}),
]),
$html = '<div class="flex items-center gap-1">';
$html .= '<a href="'.$dashboard.'" class="btn btn-xs btn-outline" title="Dashboard" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>
</a>';
$html .= '<a href="'.$map.'" class="btn btn-xs btn-outline" title="Mapa" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
</a>';
if ($canEdit) {
$html .= '<a href="'.$edit.'" class="btn btn-xs btn-warning" title="Editar" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</a>';
}
$html .= '</div>';
return $html;
})
->html(),
];
}
@@ -104,4 +119,4 @@ class ProjectTable extends DataTableComponent
{
return [];
}
}
}
+336 -44
View File
@@ -3,35 +3,55 @@
namespace App\Livewire;
use Livewire\Component;
use Livewire\WithFileUploads;
use App\Models\InspectionTemplate;
use App\Models\Project;
use App\Models\Phase;
use Illuminate\Support\Facades\Auth;
use PhpOffice\PhpSpreadsheet\IOFactory;
class TemplateManager extends Component
{
use WithFileUploads;
public $project;
public $templates;
public $phases;
// ── Formulario principal ───────────────────────────────────────────────
public $editingTemplate = null;
public $showForm = false; // Controla si mostrar el formulario
public $showForm = false;
public $form = [
'name' => '',
'name' => '',
'description' => '',
'phase_id' => null,
'fields' => [],
];
public $fieldTypes = [
'text' => 'Texto corto',
'textarea' => 'Texto largo',
'integer' => 'Número entero',
'decimal' => 'Número decimal',
'percentage' => 'Porcentaje (0-100)',
'boolean' => 'Sí/No (checkbox)',
'date' => 'Fecha',
'select' => 'Lista desplegable',
'phase_id' => null,
'fields' => [],
];
protected $listeners = ['showTemplateForm' => 'newTemplate'];
// ── Importar desde CSV/Excel ───────────────────────────────────────────
public $showImportFileModal = false;
public $importFile = null;
public $importPreviewFields = [];
public $importTemplateName = '';
public $importError = '';
// ── Importar desde otro proyecto ──────────────────────────────────────
public $showImportProjectModal = false;
public $availableProjects = [];
public $importProjectId = null;
public $importableTemplates = [];
public $selectedImportTemplateIds = [];
public $fieldTypes = [
'text' => 'Texto corto',
'textarea' => 'Texto largo',
'integer' => 'Número entero',
'decimal' => 'Número decimal',
'percentage' => 'Porcentaje (0-100)',
'boolean' => 'Sí/No (checkbox)',
'date' => 'Fecha',
'select' => 'Lista desplegable',
];
public function mount(Project $project)
{
@@ -47,20 +67,28 @@ class TemplateManager extends Component
public function loadTemplates()
{
$this->templates = InspectionTemplate::where('project_id', $this->project->id)->get();
$this->templates = InspectionTemplate::where('project_id', $this->project->id)
->with('phase')
->get();
}
// ── Formulario manual ─────────────────────────────────────────────────
public function newTemplate()
{
$this->resetForm();
$this->editingTemplate = null;
$this->showForm = true;
}
public function editTemplate($id)
{
$template = InspectionTemplate::find($id);
$this->form = $template->only(['name', 'description', 'phase_id', 'fields']);
$template = InspectionTemplate::findOrFail($id);
$this->form = [
'name' => $template->name,
'description' => $template->description ?? '',
'phase_id' => $template->phase_id,
'fields' => $template->fields ?? [],
];
$this->editingTemplate = $id;
$this->showForm = true;
}
@@ -74,10 +102,10 @@ class TemplateManager extends Component
public function resetForm()
{
$this->form = [
'name' => '',
'name' => '',
'description' => '',
'phase_id' => null,
'fields' => [],
'phase_id' => null,
'fields' => [],
];
$this->editingTemplate = null;
}
@@ -85,14 +113,14 @@ class TemplateManager extends Component
public function addField()
{
$this->form['fields'][] = [
'name' => '',
'label' => '',
'type' => 'text',
'options' => [],
'name' => '',
'label' => '',
'type' => 'text',
'options' => '',
'required' => false,
'min' => null,
'max' => null,
'step' => null,
'min' => null,
'max' => null,
'step' => null,
];
}
@@ -105,24 +133,25 @@ class TemplateManager extends Component
public function saveTemplate()
{
$this->validate([
'form.name' => 'required|string|max:255',
'form.name' => 'required|string|max:255',
'form.phase_id' => 'nullable|exists:phases,id',
'form.fields' => 'array',
'form.fields' => 'array',
]);
$data = [
'name' => $this->form['name'],
'description' => $this->form['description'],
'project_id' => $this->project->id,
'phase_id' => $this->form['phase_id'] ?: null,
'fields' => array_values($this->form['fields']),
];
if ($this->editingTemplate) {
$template = InspectionTemplate::find($this->editingTemplate);
$template->update($this->form);
session()->flash('message', 'Template actualizado');
InspectionTemplate::findOrFail($this->editingTemplate)->update($data);
$this->dispatch('notify', 'Template actualizado correctamente');
} else {
InspectionTemplate::create([
'name' => $this->form['name'],
'description' => $this->form['description'],
'project_id' => $this->project->id,
'phase_id' => $this->form['phase_id'],
'fields' => $this->form['fields'],
]);
session()->flash('message', 'Template creado');
InspectionTemplate::create($data);
$this->dispatch('notify', 'Template creado correctamente');
}
$this->cancelForm();
@@ -131,9 +160,272 @@ class TemplateManager extends Component
public function deleteTemplate($id)
{
InspectionTemplate::find($id)->delete();
InspectionTemplate::findOrFail($id)->delete();
$this->loadTemplates();
session()->flash('message', 'Template eliminado');
$this->dispatch('notify', 'Template eliminado');
}
// ── Exportar template a CSV ────────────────────────────────────────────
public function exportTemplate($id)
{
$template = InspectionTemplate::findOrFail($id);
$rows = [];
$rows[] = ['name', 'label', 'type', 'required', 'options', 'min', 'max', 'step'];
foreach ($template->fields as $field) {
$rows[] = [
$field['name'] ?? '',
$field['label'] ?? '',
$field['type'] ?? 'text',
($field['required'] ?? false) ? '1' : '0',
$field['options'] ?? '',
$field['min'] ?? '',
$field['max'] ?? '',
$field['step'] ?? '',
];
}
$filename = preg_replace('/[^a-z0-9_\-]/i', '_', $template->name) . '.csv';
return response()->streamDownload(function () use ($rows) {
$out = fopen('php://output', 'w');
// BOM para Excel con UTF-8
fwrite($out, "\xEF\xBB\xBF");
foreach ($rows as $row) {
fputcsv($out, $row);
}
fclose($out);
}, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
}
public function downloadExampleCsv()
{
$rows = [
['name', 'label', 'type', 'required', 'options', 'min', 'max', 'step'],
['altura_viga', 'Altura de viga (mm)', 'decimal', '1', '', '0', '2000', '1'],
['estado_armado', 'Estado del armado', 'select', '1', 'Conforme,No conforme,Obs.', '', '', ''],
['fotos_tomadas', '¿Fotos tomadas?', 'boolean', '1', '', '', '', ''],
['observaciones', 'Observaciones generales','textarea', '0', '', '', '', ''],
['fecha_visita', 'Fecha de visita', 'date', '0', '', '', '', ''],
['avance_pct', 'Avance medido (%)', 'percentage', '0', '', '0', '100', '1'],
];
return response()->streamDownload(function () use ($rows) {
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
foreach ($rows as $row) {
fputcsv($out, $row);
}
fclose($out);
}, 'ejemplo-template.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
}
// ── Importar desde CSV / Excel ─────────────────────────────────────────
public function openImportFileModal()
{
$this->importFile = null;
$this->importPreviewFields = [];
$this->importTemplateName = '';
$this->importError = '';
$this->showImportFileModal = true;
}
public function parseImportFile()
{
$this->importError = '';
$this->validate([
'importFile' => 'required|file|mimes:csv,txt,xlsx,xls|max:5120',
'importTemplateName' => 'required|string|max:255',
], [
'importFile.required' => 'Selecciona un archivo.',
'importFile.mimes' => 'Solo se aceptan archivos CSV o Excel (xlsx/xls).',
'importTemplateName.required' => 'Escribe un nombre para el template.',
]);
try {
$rows = $this->readFileRows();
} catch (\Throwable $e) {
$this->importError = 'No se pudo leer el archivo: ' . $e->getMessage();
return;
}
$fields = $this->parseRows($rows);
if (empty($fields)) {
$this->importError = 'No se encontraron filas de datos válidas. Revisa que el archivo tenga el formato correcto.';
return;
}
$this->importPreviewFields = $fields;
$this->dispatch('notify', count($fields) . ' campos detectados. Revisa la vista previa.');
}
public function confirmImportFile()
{
if (empty($this->importPreviewFields) || empty($this->importTemplateName)) return;
InspectionTemplate::create([
'name' => $this->importTemplateName,
'description' => 'Importado desde archivo',
'project_id' => $this->project->id,
'phase_id' => null,
'fields' => array_values($this->importPreviewFields),
]);
$this->showImportFileModal = false;
$this->importPreviewFields = [];
$this->importTemplateName = '';
$this->importFile = null;
$this->loadTemplates();
$this->dispatch('notify', 'Template importado correctamente desde archivo');
}
private function readFileRows(): array
{
$ext = strtolower($this->importFile->getClientOriginalExtension());
$path = $this->importFile->getRealPath();
if ($ext === 'xlsx' || $ext === 'xls') {
$spreadsheet = IOFactory::load($path);
$sheet = $spreadsheet->getActiveSheet();
$rows = $sheet->toArray(null, true, true, false);
array_shift($rows); // quitar cabecera
return array_filter($rows, fn($r) => !empty($r[0]));
}
// CSV / TXT
$rows = [];
$handle = fopen($path, 'r');
// Detectar y descartar BOM UTF-8
$bom = fread($handle, 3);
if ($bom !== "\xEF\xBB\xBF") rewind($handle);
fgetcsv($handle); // cabecera
while (($row = fgetcsv($handle)) !== false) {
if (!empty($row[0])) $rows[] = $row;
}
fclose($handle);
return $rows;
}
private function parseRows(array $rows): array
{
$fields = [];
foreach ($rows as $row) {
$row = array_values((array) $row);
$rawName = trim($row[0] ?? '');
if ($rawName === '') continue;
$fields[] = [
'name' => $this->slugify($rawName),
'label' => trim($row[1] ?? $rawName),
'type' => $this->normalizeType($row[2] ?? 'text'),
'required' => in_array(strtolower(trim($row[3] ?? '0')), ['1', 'si', 'sí', 'yes', 'true']),
'options' => trim($row[4] ?? ''),
'min' => $row[5] !== '' && $row[5] !== null ? $row[5] : null,
'max' => $row[6] !== '' && $row[6] !== null ? $row[6] : null,
'step' => $row[7] !== '' && $row[7] !== null ? $row[7] : null,
];
}
return $fields;
}
private function slugify(string $str): string
{
$str = mb_strtolower(trim($str));
$str = preg_replace('/\s+/', '_', $str);
$str = preg_replace('/[^a-z0-9_]/i', '', $str);
return trim($str, '_') ?: 'campo';
}
private function normalizeType(string $type): string
{
$map = [
'texto' => 'text', 'text' => 'text', 'string' => 'text', 'corto' => 'text',
'textarea' => 'textarea', 'largo' => 'textarea', 'parrafo' => 'textarea',
'integer' => 'integer', 'entero' => 'integer', 'int' => 'integer', 'numero' => 'integer',
'decimal' => 'decimal', 'float' => 'decimal', 'number' => 'decimal', 'numerico' => 'decimal',
'percentage' => 'percentage', 'porcentaje' => 'percentage', 'pct' => 'percentage', '%' => 'percentage',
'boolean' => 'boolean', 'bool' => 'boolean', 'checkbox' => 'boolean', 'sino' => 'boolean', 'si/no' => 'boolean',
'date' => 'date', 'fecha' => 'date',
'select' => 'select', 'lista' => 'select', 'dropdown' => 'select', 'opciones' => 'select',
];
return $map[strtolower(trim($type))] ?? 'text';
}
// ── Importar desde otro proyecto ──────────────────────────────────────
public function openImportProjectModal()
{
$user = Auth::user();
$this->availableProjects = Project::accessibleBy($user)
->where('id', '!=', $this->project->id)
->orderBy('name')
->get();
$this->importProjectId = null;
$this->importableTemplates = [];
$this->selectedImportTemplateIds = [];
$this->showImportProjectModal = true;
}
public function updatedImportProjectId()
{
$this->selectedImportTemplateIds = [];
if (!$this->importProjectId) {
$this->importableTemplates = [];
return;
}
// Solo mostrar templates de proyectos accesibles
$user = Auth::user();
$allowed = Project::accessibleBy($user)->pluck('id');
if (!$allowed->contains($this->importProjectId)) {
$this->importableTemplates = [];
return;
}
$this->importableTemplates = InspectionTemplate::where('project_id', $this->importProjectId)->get();
}
public function importFromProject()
{
if (empty($this->selectedImportTemplateIds)) {
$this->dispatch('notify', 'Selecciona al menos un template.');
return;
}
// Verificar que los templates pertenecen a un proyecto accesible
$user = Auth::user();
$allowed = Project::accessibleBy($user)->pluck('id');
$imported = 0;
foreach ($this->selectedImportTemplateIds as $templateId) {
$source = InspectionTemplate::find($templateId);
if (!$source || !$allowed->contains($source->project_id)) continue;
// Evitar duplicados por nombre
$name = $source->name;
if (InspectionTemplate::where('project_id', $this->project->id)->where('name', $name)->exists()) {
$name .= ' (copia)';
}
InspectionTemplate::create([
'name' => $name,
'description' => $source->description,
'project_id' => $this->project->id,
'phase_id' => null,
'fields' => $source->fields,
]);
$imported++;
}
$this->showImportProjectModal = false;
$this->importProjectId = null;
$this->importableTemplates = [];
$this->selectedImportTemplateIds = [];
$this->loadTemplates();
$this->dispatch('notify', "$imported template(s) importado(s) desde otro proyecto");
}
public function render()
+165
View File
@@ -0,0 +1,165 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\User;
use App\Models\Company;
use Spatie\Permission\Models\Role;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
#[Layout('layouts.app')]
class UserForm extends Component
{
public ?User $user = null;
// Información personal
public string $title = '';
public string $lastName = '';
public string $firstName = '';
// Validación
public string $userStatus = 'active';
public string $validFrom = '';
public string $validUntil = '';
public string $formPassword = '';
// Contacto
public ?int $companyId = null;
public string $address = '';
public string $phone = '';
public string $email = '';
// Permisos
public string $formRole = '';
// Notas
public string $notes = '';
// Catálogos
public $roles;
public $companies;
public function mount(?User $user = null): void
{
if (!Auth::user()->hasRole('Admin')) abort(403);
$this->roles = Role::orderBy('name')->get();
$this->companies = Company::where('estado', 'activo')->orderBy('name')->get();
$this->formRole = $this->roles->first()?->name ?? '';
if ($user && $user->exists) {
$this->user = $user;
$this->title = $user->title ?? '';
$this->lastName = $user->last_name ?? '';
$this->firstName = $user->first_name ?? '';
$this->userStatus = $user->status ?? 'active';
$this->validFrom = $user->valid_from?->format('Y-m-d') ?? '';
$this->validUntil = $user->valid_until?->format('Y-m-d') ?? '';
$this->companyId = $user->company_id;
$this->address = $user->address ?? '';
$this->phone = $user->phone ?? '';
$this->email = $user->email;
$this->notes = $user->notes ?? '';
$this->formRole = $user->roles->first()?->name ?? $this->formRole;
}
}
protected function rules(): array
{
$id = $this->user?->id ?? 'NULL';
$rules = [
'lastName' => 'required|string|max:100',
'firstName' => 'required|string|max:100',
'title' => 'nullable|string|max:20',
'userStatus' => 'required|in:active,inactive,suspended',
'validFrom' => 'nullable|date',
'validUntil' => 'nullable|date|after_or_equal:validFrom',
'companyId' => 'required|exists:companies,id',
'address' => 'nullable|string',
'phone' => 'nullable|string|max:30',
'email' => "required|email|max:255|unique:users,email,{$id}",
'formRole' => 'required|exists:roles,name',
];
if (!$this->user) {
$rules['formPassword'] = ['required', Password::min(8)->letters()->mixedCase()->numbers()];
} elseif ($this->formPassword !== '') {
$rules['formPassword'] = [Password::min(8)->letters()->mixedCase()->numbers()];
}
return $rules;
}
protected $validationAttributes = [
'lastName' => 'apellidos',
'firstName' => 'nombre',
'userStatus' => 'estado',
'validFrom' => 'fecha de inicio',
'validUntil' => 'fecha de fin',
'companyId' => 'empresa',
'formPassword'=> 'contraseña',
'formRole' => 'rol',
];
public function copyCompanyAddress(): void
{
if (!$this->companyId) return;
$company = Company::find($this->companyId);
if ($company?->address) {
$this->address = $company->address;
}
}
public function save(): void
{
$this->validate();
if ($this->user && $this->user->id === Auth::id()
&& $this->user->hasRole('Admin') && $this->formRole !== 'Admin') {
$this->addError('formRole', 'No puedes quitarte el rol Admin a ti mismo.');
return;
}
$fullName = trim($this->firstName . ' ' . $this->lastName);
$data = [
'name' => $fullName,
'title' => $this->title ?: null,
'first_name' => $this->firstName,
'last_name' => $this->lastName,
'status' => $this->userStatus,
'valid_from' => $this->validFrom ?: null,
'valid_until'=> $this->validUntil ?: null,
'company_id' => $this->companyId,
'address' => $this->address ?: null,
'phone' => $this->phone ?: null,
'email' => $this->email,
'notes' => $this->notes ?: null,
];
if ($this->formPassword !== '') {
$data['password'] = Hash::make($this->formPassword);
}
if ($this->user && $this->user->exists) {
$this->user->update($data);
$this->user->syncRoles([$this->formRole]);
session()->flash('notify', 'Usuario actualizado correctamente.');
} else {
$user = User::create($data);
$user->assignRole($this->formRole);
session()->flash('notify', 'Usuario creado correctamente.');
}
$this->redirect(route('admin.users'), navigate: true);
}
public function render()
{
return view('livewire.user-form');
}
}
+156
View File
@@ -0,0 +1,156 @@
<?php
namespace App\Livewire;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Rappasoft\LaravelLivewireTables\Views\Filters\SelectFilter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Spatie\Permission\Models\Role;
use App\Models\User;
class UserTable extends DataTableComponent
{
protected $model = User::class;
public function configure(): void
{
$this->setPrimaryKey('id')
->setDefaultSort('name', 'asc')
->setSortingPillsEnabled(false)
->setAdditionalSelects([
'users.id as id',
'users.email as email',
'users.email_verified_at as email_verified_at',
'users.status as status',
'users.phone as phone',
'users.company_id as company_id',
'users.created_at as created_at',
]);
}
public function builder(): Builder
{
return User::with(['roles', 'company']);
}
public function columns(): array
{
return [
Column::make('Usuario', 'name')
->sortable()
->searchable()
->format(function ($value, $row) {
$initial = strtoupper(mb_substr($value, 0, 1));
$html = '<div class="flex items-center gap-3">';
$html .= '<div class="avatar placeholder shrink-0">
<div class="bg-neutral text-neutral-content rounded-full w-8">
<span class="text-xs font-semibold">'.$initial.'</span>
</div>
</div>';
$html .= '<div>';
$html .= '<p class="font-semibold text-sm leading-tight">'.e($value).'</p>';
$html .= '<p class="text-xs text-gray-500">'.e($row->email).'</p>';
$html .= '</div></div>';
return $html;
})
->html(),
Column::make('Empresa')
->label(fn ($row) =>
$row->company
? '<span class="text-sm">'.e($row->company->name).'</span>'
: '<span class="text-gray-300 text-sm">—</span>'
)
->html(),
Column::make('Rol')
->label(function ($row) {
if ($row->roles->isEmpty()) {
return '<span class="badge badge-sm badge-ghost">Sin rol</span>';
}
return $row->roles->map(fn ($role) =>
'<span class="badge badge-sm '.($role->name === 'Admin' ? 'badge-error' : 'badge-primary').'">'.e($role->name).'</span>'
)->implode(' ');
})
->html(),
Column::make('Estado', 'status')
->sortable()
->format(function ($value) {
$map = [
'active' => ['badge-success', 'Activo'],
'inactive' => ['badge-ghost', 'Inactivo'],
'suspended' => ['badge-error', 'Suspendido'],
];
[$cls, $label] = $map[$value ?? 'active'] ?? ['badge-ghost', ucfirst($value ?? '')];
return '<span class="badge badge-sm '.$cls.'">'.$label.'</span>';
})
->html(),
Column::make('Verificado', 'email_verified_at')
->sortable()
->format(fn ($value) =>
$value
? '<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>'
: '<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>'
)
->html(),
Column::make('Acciones')
->label(function ($row) {
$ver = route('admin.users.show', $row->id);
$editar = route('admin.users.edit', $row->id);
$name = addslashes($row->name);
$isSelf = $row->id === Auth::id();
$html = '<div class="flex items-center justify-end gap-1">';
$html .= '<a href="'.$ver.'" class="btn btn-xs btn-outline" title="Ver" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</a>';
$html .= '<a href="'.$editar.'" class="btn btn-xs btn-outline btn-info" title="Editar" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</a>';
if (! $isSelf) {
$html .= '<button wire:click="deleteUser('.$row->id.')"
wire:confirm="¿Eliminar a \''.$name.'\'? Se perderán todos sus datos."
class="btn btn-xs btn-outline btn-error" title="Eliminar">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" 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-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>';
}
$html .= '</div>';
return $html;
})
->html(),
];
}
public function filters(): array
{
$roleOptions = [''] + Role::orderBy('name')->pluck('name', 'name')->prepend('Rol: todos', '')->toArray();
return [
SelectFilter::make('Rol')
->options($roleOptions)
->filter(fn (Builder $query, string $value) =>
$query->whereHas('roles', fn ($q) => $q->where('name', $value))
),
SelectFilter::make('Estado', 'status')
->options([
'' => 'Estado: todos',
'active' => 'Activo',
'inactive' => 'Inactivo',
'suspended' => 'Suspendido',
])
->filter(fn (Builder $query, string $value) => $query->where('status', $value)),
];
}
public function deleteUser(int $id): void
{
if ($id === Auth::id()) return;
User::findOrFail($id)->delete();
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\User;
use App\Models\Project;
use App\Models\Inspection;
use App\Models\Issue;
use Illuminate\Support\Facades\Auth;
#[Layout('layouts.app')]
class UserView extends Component
{
public User $user;
public string $activeTab = 'permissions';
// Projects tab
public ?int $addProjectId = null;
public string $addProjectRole = '';
public $availableProjects;
// Notes tab
public string $notes = '';
public bool $editingNotes = false;
// Recent activity (loaded once)
public $recentInspections;
public $recentIssues;
public function mount(User $user): void
{
if (!Auth::user()->hasRole('Admin')) abort(403);
$this->user = $user->load(['roles', 'company', 'projects.phases']);
$this->notes = $user->notes ?? '';
$this->loadAvailableProjects();
$this->loadActivity();
}
private function loadAvailableProjects(): void
{
$assignedIds = $this->user->projects->pluck('id');
$this->availableProjects = Project::whereNotIn('id', $assignedIds)
->orderBy('name')->get();
}
private function loadActivity(): void
{
$this->recentInspections = Inspection::where('user_id', $this->user->id)
->with(['feature.layer.phase.project', 'template'])
->latest()->take(8)->get();
$this->recentIssues = Issue::where('reported_by', $this->user->id)
->with(['feature', 'project'])
->latest()->take(8)->get();
}
// ── Tabs ─────────────────────────────────────────────────────────────────
public function setTab(string $tab): void
{
$this->activeTab = $tab;
}
// ── Projects ──────────────────────────────────────────────────────────────
public function assignProject(): void
{
$this->validate([
'addProjectId' => 'required|exists:projects,id',
'addProjectRole' => 'nullable|string|max:100',
], [], ['addProjectId' => 'proyecto', 'addProjectRole' => 'rol en proyecto']);
$this->user->projects()->attach($this->addProjectId, [
'role_in_project' => $this->addProjectRole ?: null,
]);
$this->user->load('projects.phases');
$this->addProjectId = null;
$this->addProjectRole = '';
$this->loadAvailableProjects();
$this->dispatch('notify', 'Proyecto asignado.');
}
public function removeProject(int $projectId): void
{
$this->user->projects()->detach($projectId);
$this->user->load('projects.phases');
$this->loadAvailableProjects();
$this->dispatch('notify', 'Proyecto desasignado.');
}
// ── Notes ─────────────────────────────────────────────────────────────────
public function saveNotes(): void
{
$this->validate(['notes' => 'nullable|string']);
$this->user->update(['notes' => $this->notes ?: null]);
$this->editingNotes = false;
$this->dispatch('notify', 'Notas guardadas.');
}
public function render()
{
return view('livewire.user-view');
}
}