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:
2026-06-19 16:54:09 +02:00
parent 9c164bb7ef
commit 7d390872c3
68 changed files with 191 additions and 107 deletions
@@ -0,0 +1,109 @@
<?php
namespace App\Livewire\Issues;
use App\Models\IssueChecklistTemplate;
use App\Models\Project;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class IssueChecklistManager extends Component
{
public Project $project;
public $templates = [];
public bool $showForm = false;
public $editingId = null;
public string $name = '';
public array $items = [''];
public function mount(Project $project)
{
$this->project = $project;
abort_unless($this->canAccessProject() && Auth::user()->can('edit issues'), 403);
$this->loadTemplates();
}
private function canAccessProject(): bool
{
$user = Auth::user();
return $user->can('manage all')
|| $this->project->users()->where('user_id', $user->id)->exists();
}
public function loadTemplates(): void
{
$this->templates = IssueChecklistTemplate::where('project_id', $this->project->id)
->orderBy('name')->get();
}
public function newTemplate(): void
{
$this->reset(['editingId', 'name']);
$this->items = [''];
$this->resetErrorBag();
$this->showForm = true;
}
public function edit($id): void
{
$t = IssueChecklistTemplate::where('project_id', $this->project->id)->findOrFail($id);
$this->editingId = $t->id;
$this->name = $t->name;
$this->items = array_values($t->items ?: ['']) ?: [''];
$this->resetErrorBag();
$this->showForm = true;
}
public function addItemLine(): void
{
$this->items[] = '';
}
public function removeItemLine(int $i): void
{
unset($this->items[$i]);
$this->items = array_values($this->items);
if (empty($this->items)) {
$this->items = [''];
}
}
public function save(): void
{
$this->validate([
'name' => 'required|string|max:255',
'items' => 'required|array',
'items.*' => 'nullable|string|max:255',
]);
$items = array_values(array_filter(array_map('trim', $this->items), fn ($v) => $v !== ''));
if (empty($items)) {
$this->addError('items', 'Añade al menos una tarea.');
return;
}
IssueChecklistTemplate::updateOrCreate(
['id' => $this->editingId, 'project_id' => $this->project->id],
['name' => $this->name, 'items' => $items, 'project_id' => $this->project->id],
);
$this->showForm = false;
$this->loadTemplates();
$this->dispatch('notify', 'Plantilla guardada');
}
public function delete($id): void
{
IssueChecklistTemplate::where('project_id', $this->project->id)->findOrFail($id)->delete();
$this->loadTemplates();
$this->dispatch('notify', 'Plantilla eliminada');
}
public function render()
{
return view('livewire.issues.issue-checklist-manager');
}
}
+295
View File
@@ -0,0 +1,295 @@
<?php
namespace App\Livewire\Issues;
use App\Models\Issue;
use App\Models\IssueChecklistTemplate;
use App\Models\IssueComment;
use App\Models\IssueTask;
use App\Models\Project;
use App\Notifications\IssueCommentedNotification;
use App\Notifications\IssueStatusChangedNotification;
use App\Notifications\IssueTaskAssignedNotification;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithFileUploads;
#[Layout('layouts.app')]
class IssueDetail extends Component
{
use WithFileUploads;
public Project $project;
public Issue $issue;
// New task form
public string $newTaskTitle = '';
public $newTaskAssignee = '';
public $newTaskDue = '';
// Checklist templates
public $checklistTemplates = [];
public $applyTemplateId = '';
// New comment form
public string $newComment = '';
public $commentPhoto = null; // single optional photo on a comment
// Issue-level photos
public $issuePhotos = []; // multiple
// Verification / resolution
public string $resolutionNotes = '';
public $projectUsers = [];
public function mount(Project $project, Issue $issue)
{
abort_unless($issue->project_id === $project->id, 404);
$this->project = $project;
$this->issue = $issue;
abort_unless($this->canAccessProject() && Auth::user()->can('view issues'), 403);
$this->resolutionNotes = $issue->resolution_notes ?? '';
$this->projectUsers = $project->users()->orderBy('name')->get();
$this->checklistTemplates = IssueChecklistTemplate::where('project_id', $project->id)->orderBy('name')->get();
$this->refreshIssue();
}
private function canAccessProject(): bool
{
$user = Auth::user();
return $user->can('manage all')
|| $this->project->users()->where('user_id', $user->id)->exists();
}
private function canEdit(): bool
{
return Auth::user()->can('edit issues');
}
/** Notify the issue's stakeholders (reporter + assignee), excluding the current actor. */
private function notifyStakeholders($notification): void
{
$this->issue->loadMissing(['reporter', 'assignee']);
collect([$this->issue->reporter, $this->issue->assignee])
->filter()
->unique('id')
->reject(fn ($u) => $u->id === Auth::id())
->each(fn ($u) => $u->notify($notification));
}
public function refreshIssue(): void
{
$this->issue->load([
'reporter', 'assignee', 'feature',
'tasks.assignee', 'tasks.completer',
'comments.user', 'comments.media',
'media.uploader',
]);
}
// ── Checklist ────────────────────────────────────────────────────────────────
public function addTask(): void
{
abort_unless($this->canEdit(), 403);
$this->validate([
'newTaskTitle' => 'required|string|max:255',
'newTaskAssignee' => 'nullable|exists:users,id',
'newTaskDue' => 'nullable|date',
]);
$task = $this->issue->tasks()->create([
'title' => $this->newTaskTitle,
'assigned_to' => $this->newTaskAssignee ?: null,
'due_date' => $this->newTaskDue ?: null,
'order' => ((int) $this->issue->tasks()->max('order')) + 1,
'uuid' => (string) \Illuminate\Support\Str::uuid(),
]);
// Notify the assignee (unless they assigned it to themselves).
if ($task->assigned_to && $task->assigned_to !== Auth::id()) {
$task->loadMissing('issue');
$task->assignee?->notify(new IssueTaskAssignedNotification($task));
}
$this->reset(['newTaskTitle', 'newTaskAssignee', 'newTaskDue']);
$this->refreshIssue();
$this->dispatch('notify', 'Tarea añadida');
}
public function applyTemplate(): void
{
abort_unless($this->canEdit(), 403);
$this->validate(['applyTemplateId' => 'required|exists:issue_checklist_templates,id']);
$template = IssueChecklistTemplate::where('project_id', $this->project->id)
->findOrFail($this->applyTemplateId);
$order = (int) $this->issue->tasks()->max('order');
foreach ($template->items ?: [] as $title) {
$this->issue->tasks()->create([
'title' => $title,
'order' => ++$order,
'uuid' => (string) \Illuminate\Support\Str::uuid(),
]);
}
$this->applyTemplateId = '';
$this->refreshIssue();
$this->dispatch('notify', 'Plantilla aplicada');
}
public function toggleTask($taskId): void
{
abort_unless($this->canEdit(), 403);
$task = $this->issue->tasks()->findOrFail($taskId);
$done = ! $task->is_done;
$task->update([
'is_done' => $done,
'done_at' => $done ? now() : null,
'done_by' => $done ? Auth::id() : null,
]);
$this->refreshIssue();
}
public function deleteTask($taskId): void
{
abort_unless($this->canEdit(), 403);
$this->issue->tasks()->findOrFail($taskId)->delete();
$this->refreshIssue();
$this->dispatch('notify', 'Tarea eliminada');
}
// ── Comments + photos ──────────────────────────────────────────────────────────
public function addComment(): void
{
// Anyone who can view the issue (and is a member) may comment / report progress.
$this->validate([
'newComment' => 'nullable|string|max:5000',
'commentPhoto' => 'nullable|image|max:20480', // 20 MB
]);
if (trim($this->newComment) === '' && ! $this->commentPhoto) {
throw ValidationException::withMessages([
'newComment' => 'Escribe un comentario o adjunta una foto.',
]);
}
$comment = $this->issue->comments()->create([
'user_id' => Auth::id(),
'body' => trim($this->newComment) ?: '(foto)',
'uuid' => (string) \Illuminate\Support\Str::uuid(),
]);
if ($this->commentPhoto) {
abort_unless(Auth::user()->can('upload media'), 403);
$this->storeUpload($this->commentPhoto, $comment, 'comment');
}
$comment->setRelation('issue', $this->issue)->setRelation('user', Auth::user());
$this->notifyStakeholders(new IssueCommentedNotification($comment));
$this->reset(['newComment', 'commentPhoto']);
$this->refreshIssue();
$this->dispatch('notify', 'Comentario añadido');
}
public function uploadIssuePhotos(): void
{
abort_unless(Auth::user()->can('upload media'), 403);
$this->validate(['issuePhotos.*' => 'required|image|max:20480']);
foreach ($this->issuePhotos as $photo) {
$this->storeUpload($photo, $this->issue, 'issue');
}
$this->reset('issuePhotos');
$this->refreshIssue();
$this->dispatch('notify', 'Fotos subidas');
}
public function deleteMedia($mediaId): void
{
$media = \App\Models\Media::findOrFail($mediaId);
$user = Auth::user();
abort_unless($user->can('delete media') || $media->uploaded_by === $user->id, 403);
$media->delete();
$this->refreshIssue();
$this->dispatch('notify', 'Foto eliminada');
}
/** Store an uploaded file on the public disk and attach it to a parent model. */
private function storeUpload($file, $parent, string $entity): void
{
$mime = $file->getMimeType();
$path = $file->store("uploads/issues/{$this->issue->id}/{$entity}", 'public');
$parent->media()->create([
'name' => $file->getClientOriginalName(),
'file_path' => $path,
'file_type' => $mime,
'file_extension' => $file->getClientOriginalExtension(),
'file_size' => $file->getSize(),
'category' => str_starts_with($mime, 'image/') ? 'image' : 'document',
'uploaded_by' => Auth::id(),
'uuid' => (string) \Illuminate\Support\Str::uuid(),
]);
}
// ── Status workflow (verification) ───────────────────────────────────────────────
public function sendToReview(): void
{
abort_unless($this->canEdit(), 403);
$this->issue->update(['status' => 'in_review']);
$this->notifyStakeholders(new IssueStatusChangedNotification($this->issue, 'in_review'));
$this->refreshIssue();
$this->dispatch('notify', 'Incidencia enviada a revisión');
}
public function verifyResolve(): void
{
abort_unless($this->canEdit(), 403);
$this->issue->update([
'status' => 'resolved',
'resolved_at' => $this->issue->resolved_at ?? now(),
'resolution_notes' => $this->resolutionNotes ?: null,
]);
$this->notifyStakeholders(new IssueStatusChangedNotification($this->issue, 'resolved'));
$this->refreshIssue();
$this->dispatch('notify', 'Incidencia validada y resuelta');
}
public function closeIssue(): void
{
abort_unless($this->canEdit(), 403);
$this->issue->update([
'status' => 'closed',
'resolved_at' => $this->issue->resolved_at ?? now(),
]);
$this->notifyStakeholders(new IssueStatusChangedNotification($this->issue, 'closed'));
$this->refreshIssue();
$this->dispatch('notify', 'Incidencia cerrada');
}
public function reopen(): void
{
abort_unless($this->canEdit(), 403);
$this->issue->update(['status' => 'open', 'resolved_at' => null]);
$this->notifyStakeholders(new IssueStatusChangedNotification($this->issue, 'open'));
$this->refreshIssue();
$this->dispatch('notify', 'Incidencia reabierta');
}
public function render()
{
return view('livewire.issues.issue-detail', [
'canEdit' => $this->canEdit(),
]);
}
}
+146
View File
@@ -0,0 +1,146 @@
<?php
namespace App\Livewire\Issues;
use App\Models\Issue;
use App\Models\Project;
use App\Notifications\IssueAssignedNotification;
use App\Notifications\IssueReportedNotification;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class IssueForm extends Component
{
public Project $project;
public ?Issue $issue = null; // null = create, set = edit
public $projectUsers = [];
// Form fields
public $title = '';
public $description = '';
public $status = 'open';
public $priority = 'medium';
public $type = 'defect';
public $assignedTo = '';
public $resolutionNotes = '';
// Optional context (e.g. when reporting from a map feature)
public $featureId = null;
public $inspectionId = null;
public $featureName = null; // shown when the issue is pre-linked to a map element
public function mount(Project $project, ?Issue $issue = null)
{
$this->project = $project;
if ($issue) {
abort_unless($issue->project_id === $project->id, 404);
}
abort_unless($this->canAccessProject() && Auth::user()->can($issue ? 'edit issues' : 'create issues'), 403);
$this->projectUsers = $project->users()->orderBy('name')->get();
if ($issue) {
$this->issue = $issue;
$this->title = $issue->title;
$this->description = $issue->description ?? '';
$this->status = $issue->status;
$this->priority = $issue->priority;
$this->type = $issue->type ?? 'defect';
$this->assignedTo = $issue->assigned_to ?? '';
$this->resolutionNotes = $issue->resolution_notes ?? '';
$this->featureId = $issue->feature_id;
$this->inspectionId = $issue->inspection_id;
$this->featureName = $issue->feature?->name;
} elseif ($featureId = request()->integer('feature')) {
// Pre-link to a map element when reporting from the project map.
$feature = \App\Models\Feature::with('layer.phase')->find($featureId);
if ($feature && $feature->layer?->phase?->project_id === $project->id) {
$this->featureId = $feature->id;
$this->featureName = $feature->name;
}
}
}
private function canAccessProject(): bool
{
$user = Auth::user();
return $user->can('manage all')
|| $this->project->users()->where('user_id', $user->id)->exists();
}
protected function rules(): array
{
return [
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'status' => 'required|in:' . implode(',', Issue::STATUSES),
'priority' => 'required|in:' . implode(',', Issue::PRIORITIES),
'type' => 'required|in:' . implode(',', Issue::TYPES),
'assignedTo' => 'nullable|exists:users,id',
'resolutionNotes' => 'nullable|string',
];
}
public function save()
{
abort_unless(Auth::user()->can($this->issue ? 'edit issues' : 'create issues'), 403);
$this->validate();
$payload = [
'title' => $this->title,
'description' => $this->description,
'status' => $this->status,
'priority' => $this->priority,
'type' => $this->type,
'feature_id' => $this->featureId,
'inspection_id' => $this->inspectionId,
'assigned_to' => $this->assignedTo ?: null,
'resolution_notes' => $this->resolutionNotes ?: null,
];
// Keep resolved_at in sync with the status
$payload['resolved_at'] = in_array($this->status, ['resolved', 'closed']) ? now() : null;
if ($this->issue) {
$previousAssignee = $this->issue->assigned_to;
// Don't overwrite an existing resolved date
if ($this->issue->resolved_at && in_array($this->status, ['resolved', 'closed'])) {
unset($payload['resolved_at']);
}
$this->issue->update($payload);
$issue = $this->issue;
// Notify a newly assigned user (when it changed and isn't the current actor).
if ($issue->assigned_to && $issue->assigned_to !== $previousAssignee && $issue->assigned_to !== Auth::id()) {
$issue->assignee?->notify(new IssueAssignedNotification($issue));
}
} else {
$issue = Issue::create(array_merge($payload, [
'project_id' => $this->project->id,
'reported_by' => Auth::id(),
]));
$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));
}
}
session()->flash('message', $this->issue ? 'Incidencia actualizada' : 'Incidencia creada');
return $this->redirectRoute('projects.issues.show', ['project' => $this->project, 'issue' => $issue], navigate: true);
}
public function render()
{
return view('livewire.issues.issue-form');
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Livewire\Issues;
use Livewire\Component;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Illuminate\Support\Facades\Auth;
use App\Models\Project;
use App\Models\Issue;
#[Layout('layouts.app')]
class IssueManager extends Component
{
public Project $project;
public function mount(Project $project)
{
$this->project = $project;
abort_unless($this->canAccessProject() && Auth::user()->can('view issues'), 403);
}
/** The current user must be a project member (or super-admin) to touch issues. */
private function canAccessProject(): bool
{
$user = Auth::user();
return $user->can('manage all')
|| $this->project->users()->where('user_id', $user->id)->exists();
}
/** Re-render the stats bar after the embedded table changes an issue. */
#[On('issuesChanged')]
public function refreshStats(): void
{
// No state to mutate — the listener simply triggers a re-render so the
// stat counters recompute from the database in render().
}
public function render()
{
$counts = Issue::where('project_id', $this->project->id)
->selectRaw('status, count(*) as c')
->groupBy('status')
->pluck('c', 'status');
return view('livewire.issues.issue-manager', [
'countOpen' => (int) ($counts['open'] ?? 0),
'countInReview' => (int) ($counts['in_review'] ?? 0),
'countResolved' => (int) ($counts['resolved'] ?? 0),
'countClosed' => (int) ($counts['closed'] ?? 0),
'countTotal' => (int) $counts->sum(),
]);
}
}
+233
View File
@@ -0,0 +1,233 @@
<?php
namespace App\Livewire\Issues;
use App\Models\Issue;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Rappasoft\LaravelLivewireTables\Views\Filters\SelectFilter;
class IssueTable extends DataTableComponent
{
protected $model = Issue::class;
public int $projectId;
public function configure(): void
{
$this->setPrimaryKey('id')
->setDefaultSort('created_at', 'desc')
->setSortingPillsEnabled(false)
->setAdditionalSelects(['issues.id as id', 'issues.created_at as created_at']);
}
public function builder(): Builder
{
// Defence in depth: only members (or super-admin) may list a project's issues.
$user = Auth::user();
abort_unless(
$user->can('view issues') && (
$user->can('manage all')
|| \App\Models\Project::whereKey($this->projectId)->whereHas('users', fn ($q) => $q->where('user_id', $user->id))->exists()
),
403
);
return Issue::where('issues.project_id', $this->projectId)
->with(['feature', 'reporter', 'assignee'])
->withCount([
'comments',
'media',
'tasks',
'tasks as tasks_done_count' => fn (Builder $q) => $q->where('is_done', true),
'tasks as overdue_tasks_count' => fn (Builder $q) => $q->where('is_done', false)
->whereNotNull('due_date')
->whereDate('due_date', '<', now()->toDateString()),
]);
}
public function columns(): array
{
return [
Column::make('Prioridad', 'priority')
->sortable()
->format(function ($value, $row) {
$label = ['low' => 'Bajo', 'medium' => 'Medio', 'high' => 'Alto', 'critical' => 'Crítico'][$value] ?? ucfirst($value);
$textColor = in_array($value, ['critical', 'high']) ? '#fff' : '#1f2937';
return '<span class="badge badge-sm font-semibold" style="background-color:'.$row->priority_color.';color:'.$textColor.';border-color:transparent;">'.$label.'</span>';
})
->html(),
Column::make('Título', 'title')
->sortable()
->searchable()
->format(function ($value, $row) {
$url = route('projects.issues.show', [$this->projectId, $row->id]);
$html = '<a href="'.$url.'" wire:navigate class="font-medium text-sm link link-hover">'.e($value).'</a>';
if ($row->description) {
$html .= '<div class="text-xs text-base-content/50 truncate max-w-xs">'.e(Str::limit($row->description, 60)).'</div>';
}
$meta = [];
if ($row->reporter) $meta[] = 'Reportado por '.e($row->reporter->name);
if ($row->comments_count) $meta[] = '💬 '.$row->comments_count;
if ($row->media_count) $meta[] = '📷 '.$row->media_count;
if ($meta) {
$html .= '<div class="text-xs text-base-content/40 mt-0.5">'.implode(' · ', $meta).'</div>';
}
if ($row->tasks_count) {
$pct = (int) round($row->tasks_done_count / $row->tasks_count * 100);
$html .= '<div class="flex items-center gap-2 mt-1 max-w-xs">
<progress class="progress progress-success w-24 h-1.5" value="'.$pct.'" max="100"></progress>
<span class="text-xs text-base-content/50">'.$row->tasks_done_count.'/'.$row->tasks_count.' tareas</span>
</div>';
}
if ($row->overdue_tasks_count) {
$html .= '<div class="mt-1"><span class="badge badge-error badge-sm gap-1">⏰ '.$row->overdue_tasks_count.' vencida'.($row->overdue_tasks_count > 1 ? 's' : '').'</span></div>';
}
return $html;
})
->html(),
Column::make('Tipo', 'type')
->sortable()
->format(fn ($value, $row) =>
'<span class="badge badge-sm" style="background-color:'.$row->type_color.';color:#fff;border-color:transparent;">'.e($row->type_label).'</span>')
->html(),
Column::make('Feature')
->label(fn ($row) => $row->feature
? '<span class="badge badge-outline badge-sm">'.e($row->feature->name).'</span>'
: '<span class="text-base-content/30 text-xs">—</span>')
->html(),
Column::make('Estado', 'status')
->sortable()
->format(function ($value, $row) {
$label = ['open' => 'Abierto', 'in_review' => 'En revisión', 'resolved' => 'Resuelto', 'closed' => 'Cerrado'][$value] ?? ucfirst($value);
return '<span class="badge badge-sm" style="background-color:'.$row->status_color.';color:#fff;border-color:transparent;">'.$label.'</span>';
})
->html(),
Column::make('Asignado a')
->label(fn ($row) => $row->assignee
? '<span class="text-sm">'.e($row->assignee->name).'</span>'
: '<span class="text-base-content/30 text-xs">Sin asignar</span>')
->html(),
Column::make('Fecha', 'created_at')
->sortable()
->format(function ($value, $row) {
$html = $row->created_at->format('d/m/Y');
if ($row->resolved_at) {
$html .= '<div class="text-success text-xs">Res. '.$row->resolved_at->format('d/m/Y').'</div>';
}
return $html;
})
->html(),
Column::make('Acciones')
->label(function ($row) {
$user = Auth::user();
$detail = route('projects.issues.show', [$this->projectId, $row->id]);
$edit = route('projects.issues.edit', [$this->projectId, $row->id]);
$html = '<div class="flex items-center justify-end gap-1 flex-wrap">';
$html .= '<a href="'.$detail.'" wire:navigate class="btn btn-xs btn-ghost" title="Abrir detalle">
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</a>';
if ($user->can('edit issues')) {
$html .= '<a href="'.$edit.'" wire:navigate class="btn btn-xs btn-ghost" title="Editar">
<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 (in_array($row->status, ['open', 'in_review'])) {
$html .= '<button wire:click="resolve('.$row->id.')" class="btn btn-xs btn-success" title="Marcar como resuelto">
<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="M5 13l4 4L19 7"/></svg>
</button>';
}
if ($row->status !== 'closed') {
$html .= '<button wire:click="close('.$row->id.')" class="btn btn-xs btn-neutral" title="Cerrar">
<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="M6 18L18 6M6 6l12 12"/></svg>
</button>';
}
}
if ($user->can('delete issues')) {
$html .= '<button wire:click="deleteIssue('.$row->id.')" wire:confirm="¿Eliminar esta incidencia? Esta acción no se puede deshacer."
class="btn btn-xs btn-error btn-outline" 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('Estado', 'status')
->options([
'' => 'Estado: todos',
'open' => 'Abierto',
'in_review' => 'En revisión',
'resolved' => 'Resuelto',
'closed' => 'Cerrado',
])
->filter(fn (Builder $query, string $value) => $query->where('issues.status', $value)),
SelectFilter::make('Prioridad', 'priority')
->options([
'' => 'Prioridad: todas',
'critical' => 'Crítica',
'high' => 'Alta',
'medium' => 'Media',
'low' => 'Baja',
])
->filter(fn (Builder $query, string $value) => $query->where('issues.priority', $value)),
SelectFilter::make('Tipo', 'type')
->options(['' => 'Tipo: todos'] + \App\Models\Issue::typeLabels())
->filter(fn (Builder $query, string $value) => $query->where('issues.type', $value)),
];
}
// ── Row actions ────────────────────────────────────────────────────────────────
private function findIssue(int $id): Issue
{
return Issue::where('project_id', $this->projectId)->findOrFail($id);
}
public function resolve(int $id): void
{
abort_unless(Auth::user()->can('edit issues'), 403);
$issue = $this->findIssue($id);
$issue->update(['status' => 'resolved', 'resolved_at' => $issue->resolved_at ?? now()]);
$this->dispatch('issuesChanged');
$this->dispatch('notify', 'Incidencia marcada como resuelta');
}
public function close(int $id): void
{
abort_unless(Auth::user()->can('edit issues'), 403);
$issue = $this->findIssue($id);
$issue->update(['status' => 'closed', 'resolved_at' => $issue->resolved_at ?? now()]);
$this->dispatch('issuesChanged');
$this->dispatch('notify', 'Incidencia cerrada');
}
public function deleteIssue(int $id): void
{
abort_unless(Auth::user()->can('delete issues'), 403);
$this->findIssue($id)->delete();
$this->dispatch('issuesChanged');
$this->dispatch('notify', 'Incidencia eliminada');
}
}