feat(phases): modal crear/editar fase + tabla Rappasoft

- PhaseList pasa a contenedor: botón "Agregar fase" + modal crear/editar con todos
  los parámetros (nombre, descripción, orden, color, progreso, fechas previstas y
  reales) y validación. Antes "Agregar fase" creaba directamente 'Nueva fase'.
- PhaseTable (Rappasoft): orden, nombre+descripción, barra de progreso, fechas,
  color y acciones (editar abre el modal vía evento, actualizar progreso, eliminar);
  búsqueda y ordenación. Gateado por 'manage phases' + acceso al proyecto.

Tests: PhaseManagementTest (4). Suite 65 passing (solo 2 pre-existentes sqlite).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 13:56:05 +02:00
parent 3d0f4d5cad
commit c378ab5884
4 changed files with 464 additions and 42 deletions
+116 -15
View File
@@ -2,40 +2,141 @@
namespace App\Livewire;
use Livewire\Component;
use App\Models\Project;
use App\Models\Phase;
use App\Models\Project;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On;
use Livewire\Component;
class PhaseList extends Component
{
public Project $project;
public $phases;
// Modal state
public bool $showForm = false;
public $editingId = null;
// Form fields
public string $name = '';
public string $description = '';
public string $color = '#3b82f6';
public int $order = 1;
public int $progressPercent = 0;
public string $plannedStart = '';
public string $plannedEnd = '';
public string $actualStart = '';
public string $actualEnd = '';
public function mount(Project $project)
{
$this->project = $project;
$this->phases = $project->phases;
}
public function addPhase()
protected function rules(): array
{
$this->project->phases()->create([
'name' => 'Nueva fase',
'order' => $this->phases->count() + 1,
'color' => '#'.substr(md5(rand()), 0, 6)
return [
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'color' => 'required|string|max:7',
'order' => 'required|integer|min:0',
'progressPercent' => 'required|integer|min:0|max:100',
'plannedStart' => 'nullable|date',
'plannedEnd' => 'nullable|date|after_or_equal:plannedStart',
'actualStart' => 'nullable|date',
'actualEnd' => 'nullable|date|after_or_equal:actualStart',
];
}
protected $validationAttributes = [
'name' => 'nombre',
'color' => 'color',
'order' => 'orden',
'progressPercent' => 'progreso',
'plannedStart' => 'inicio previsto',
'plannedEnd' => 'fin previsto',
'actualStart' => 'inicio real',
'actualEnd' => 'fin real',
];
public function openForm($phaseId = null): void
{
abort_unless(Auth::user()->can('manage phases'), 403);
$this->resetForm();
if ($phaseId) {
$phase = $this->project->phases()->findOrFail($phaseId);
$this->editingId = $phase->id;
$this->name = $phase->name;
$this->description = $phase->description ?? '';
$this->color = $phase->color ?? '#3b82f6';
$this->order = (int) $phase->order;
$this->progressPercent = (int) $phase->progress_percent;
$this->plannedStart = $phase->planned_start?->format('Y-m-d') ?? '';
$this->plannedEnd = $phase->planned_end?->format('Y-m-d') ?? '';
$this->actualStart = $phase->actual_start?->format('Y-m-d') ?? '';
$this->actualEnd = $phase->actual_end?->format('Y-m-d') ?? '';
} else {
$this->order = (int) $this->project->phases()->max('order') + 1;
$this->color = '#' . substr(md5((string) rand()), 0, 6);
}
$this->showForm = true;
}
/** Opened from the table's edit button. */
#[On('phase-edit')]
public function editPhase($id): void
{
$this->openForm($id);
}
public function closeForm(): void
{
$this->showForm = false;
$this->resetForm();
}
private function resetForm(): void
{
$this->reset([
'editingId', 'name', 'description', 'plannedStart', 'plannedEnd', 'actualStart', 'actualEnd',
]);
$this->phases = $this->project->phases()->get();
session()->flash('message', 'Fase agregada');
$this->color = '#3b82f6';
$this->order = 1;
$this->progressPercent = 0;
$this->resetErrorBag();
}
public function deletePhase($phaseId)
public function save(): void
{
Phase::find($phaseId)->delete();
$this->phases = $this->project->phases()->get();
abort_unless(Auth::user()->can('manage phases'), 403);
$this->validate();
$data = [
'name' => $this->name,
'description' => $this->description ?: null,
'color' => $this->color,
'order' => $this->order,
'progress_percent' => $this->progressPercent,
'planned_start' => $this->plannedStart ?: null,
'planned_end' => $this->plannedEnd ?: null,
'actual_start' => $this->actualStart ?: null,
'actual_end' => $this->actualEnd ?: null,
];
if ($this->editingId) {
$this->project->phases()->findOrFail($this->editingId)->update($data);
} else {
$this->project->phases()->create($data);
}
$this->closeForm();
$this->dispatch('phases-changed');
$this->dispatch('notify', 'Fase guardada correctamente');
}
public function render()
{
return view('livewire.phase-list');
}
}
}
+120
View File
@@ -0,0 +1,120 @@
<?php
namespace App\Livewire;
use App\Models\Phase;
use App\Models\Project;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
class PhaseTable extends DataTableComponent
{
protected $model = Phase::class;
public int $projectId;
public function configure(): void
{
$this->setPrimaryKey('id')
->setDefaultSort('order', 'asc')
->setSortingPillsEnabled(false)
->setAdditionalSelects(['phases.id as id', 'phases.order as order']);
}
/** Re-render when the parent (PhaseList) creates/edits a phase. */
#[On('phases-changed')]
public function refreshRows(): void
{
// no-op: triggers a re-render so the builder re-runs with fresh data.
}
public function builder(): Builder
{
$user = Auth::user();
abort_unless(
$user->can('manage all') || $user->can('edit projects') ||
Project::whereKey($this->projectId)->whereHas('users', fn ($q) => $q->where('user_id', $user->id))->exists(),
403
);
return Phase::where('phases.project_id', $this->projectId);
}
public function columns(): array
{
return [
Column::make('Orden', 'order')->sortable(),
Column::make('Nombre', 'name')
->sortable()
->searchable()
->format(function ($value, $row) {
$html = '<span class="font-medium">'.e($value).'</span>';
if ($row->description) {
$html .= '<div class="text-xs text-base-content/50 truncate max-w-xs">'.e(\Illuminate\Support\Str::limit($row->description, 60)).'</div>';
}
return $html;
})
->html(),
Column::make('Progreso', 'progress_percent')
->sortable()
->format(fn ($value) =>
'<div class="flex items-center gap-2 min-w-[110px]">
<progress class="progress progress-primary w-24 h-2" value="'.(int) $value.'" max="100"></progress>
<span class="text-xs text-base-content/60 w-8 text-right">'.(int) $value.'%</span>
</div>')
->html(),
Column::make('Fechas')
->label(function ($row) {
$ps = $row->planned_start?->format('d/m/Y');
$pe = $row->planned_end?->format('d/m/Y');
if (! $ps && ! $pe) {
return '<span class="text-base-content/30 text-xs">—</span>';
}
return '<span class="text-xs">'.($ps ?: '?').' → '.($pe ?: '?').'</span>';
})
->html(),
Column::make('Color', 'color')
->format(fn ($value) =>
'<div class="w-6 h-6 rounded border border-base-300" style="background:'.e($value).'" title="'.e($value).'"></div>')
->html(),
Column::make('Acciones')
->label(function ($row) {
$user = Auth::user();
$progress = route('phases.progress', $row->id);
$html = '<div class="flex items-center justify-end gap-1">';
$html .= '<a href="'.$progress.'" class="btn btn-xs btn-outline btn-info" title="Actualizar progreso" 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="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
</a>';
if ($user->can('manage phases')) {
$html .= '<button wire:click="$dispatch(\'phase-edit\', { id: '.$row->id.' })" 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>
</button>';
$html .= '<button wire:click="deletePhase('.$row->id.')" wire:confirm="¿Eliminar la fase \''.e($row->name).'\'? 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 deletePhase(int $id): void
{
abort_unless(Auth::user()->can('manage phases'), 403);
Phase::where('project_id', $this->projectId)->findOrFail($id)->delete();
$this->dispatch('phases-changed');
$this->dispatch('notify', 'Fase eliminada');
}
}