refactor(livewire): organizar componentes y vistas por dominio en subnamespaces
- app/Livewire: 34 componentes agrupados en Issues/, Projects/, Phases/, Companies/, Users/, Admin/, Inspections/, Layers/, Media/, Common/ (Client/, Reports/, Forms/, Actions/ ya estaban). Namespaces actualizados. - resources/views/livewire: vistas sueltas movidas a subcarpetas espejo (companies/, users/, phases/, roles/, inspections/, media/, common/); render() actualizado. - Referencias actualizadas sin romper nada: rutas (FQN, nombres de ruta intactos), tags <livewire:...>/@livewire() a alias con punto, y use de los tests. - No tocado: Volt de Breeze (auth/profile/navigation), y el portal cliente (user-nav/client-projects) que ya tenía referencias inconsistentes. Verificado: 69 rutas OK, vistas compilan, suite 69 passing (solo 2 pre-existentes sqlite). autoload regenerado con --ignore-platform-reqs (PHP 8.2). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Companies;
|
||||
|
||||
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.companies.company-form');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Companies;
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\Attributes\Layout;
|
||||
use App\Models\Company;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class CompanyManagement extends Component
|
||||
{
|
||||
public string $search = '';
|
||||
public string $filterType = '';
|
||||
public string $filterEstado = '';
|
||||
|
||||
public function getCompaniesProperty()
|
||||
{
|
||||
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();
|
||||
|
||||
return response()->streamDownload(function () use ($companies) {
|
||||
$handle = fopen('php://output', 'w');
|
||||
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, [
|
||||
$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);
|
||||
}, 'empresas.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.companies.company-management');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Companies;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Companies;
|
||||
|
||||
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
|
||||
{
|
||||
abort_unless(Auth::user()->can('view companies'), 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.companies.company-view');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user