From c378ab5884f2ea3d25acb19164ceb47d9209276b Mon Sep 17 00:00:00 2001 From: javier Date: Thu, 18 Jun 2026 13:56:05 +0200 Subject: [PATCH] feat(phases): modal crear/editar fase + tabla Rappasoft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- app/Livewire/PhaseList.php | 131 ++++++++++++++-- app/Livewire/PhaseTable.php | 120 +++++++++++++++ resources/views/livewire/phase-list.blade.php | 140 ++++++++++++++---- tests/Feature/PhaseManagementTest.php | 115 ++++++++++++++ 4 files changed, 464 insertions(+), 42 deletions(-) create mode 100644 app/Livewire/PhaseTable.php create mode 100644 tests/Feature/PhaseManagementTest.php diff --git a/app/Livewire/PhaseList.php b/app/Livewire/PhaseList.php index 4bbd6ec..f284cf7 100644 --- a/app/Livewire/PhaseList.php +++ b/app/Livewire/PhaseList.php @@ -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'); } -} \ No newline at end of file +} diff --git a/app/Livewire/PhaseTable.php b/app/Livewire/PhaseTable.php new file mode 100644 index 0000000..c794fac --- /dev/null +++ b/app/Livewire/PhaseTable.php @@ -0,0 +1,120 @@ +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 = ''.e($value).''; + if ($row->description) { + $html .= '
'.e(\Illuminate\Support\Str::limit($row->description, 60)).'
'; + } + return $html; + }) + ->html(), + + Column::make('Progreso', 'progress_percent') + ->sortable() + ->format(fn ($value) => + '
+ + '.(int) $value.'% +
') + ->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 ''; + } + return ''.($ps ?: '?').' → '.($pe ?: '?').''; + }) + ->html(), + + Column::make('Color', 'color') + ->format(fn ($value) => + '
') + ->html(), + + Column::make('Acciones') + ->label(function ($row) { + $user = Auth::user(); + $progress = route('phases.progress', $row->id); + + $html = '
'; + $html .= ' + + '; + if ($user->can('manage phases')) { + $html .= ''; + $html .= ''; + } + $html .= '
'; + 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'); + } +} diff --git a/resources/views/livewire/phase-list.blade.php b/resources/views/livewire/phase-list.blade.php index 046fae6..cff490b 100644 --- a/resources/views/livewire/phase-list.blade.php +++ b/resources/views/livewire/phase-list.blade.php @@ -1,29 +1,115 @@
- @if(session()->has('message')) -
{{ session('message') }}
- @endif - - - - - - @foreach($phases as $phase) - - - - - - - @endforeach - -
{{ __('Name') }}{{ __('Progress') }}{{ __('Color') }}{{ __('Actions') }}
{{ $phase->name }} -
-
+
+
+

{{ __('Phases') }}

+

Fases del proyecto y su progreso

+
+ @can('manage phases') + + @endcan +
+ + {{-- Tabla Rappasoft de fases --}} + + + {{-- ================================================================ + MODAL crear / editar fase + ================================================================ --}} + @if($showForm) +
+
+
+
+

{{ $editingId ? 'Editar fase' : 'Nueva fase' }}

+ +
+ +
+ {{-- Nombre --}} +
+ + + @error('name')@enderror +
+ + {{-- Descripción --}} +
+ + +
+ + {{-- Orden + Color + Progreso --}} +
+
+ + + @error('order')@enderror
- {{ $phase->progress_percent }}% -
- {{ __('Update') }} - -
- -
\ No newline at end of file +
+ + + @error('color')@enderror +
+
+ + + @error('progressPercent')@enderror +
+ + + {{-- Fechas previstas --}} +
+
+ + + @error('plannedStart')@enderror +
+
+ + + @error('plannedEnd')@enderror +
+
+ + {{-- Fechas reales --}} +
+
+ + + @error('actualStart')@enderror +
+
+ + + @error('actualEnd')@enderror +
+
+ +
+ + +
+ + + + @endif + diff --git a/tests/Feature/PhaseManagementTest.php b/tests/Feature/PhaseManagementTest.php new file mode 100644 index 0000000..28aacda --- /dev/null +++ b/tests/Feature/PhaseManagementTest.php @@ -0,0 +1,115 @@ +user = User::factory()->create(); + $this->user->givePermissionTo(['manage phases', 'edit projects']); + + $this->project = Project::create([ + 'reference' => 'PH-1', + 'name' => 'Proyecto Fases', + 'address' => 'Calle 1', + 'lat' => 40.0, + 'lng' => -3.0, + 'start_date' => now()->toDateString(), + 'end_date_estimated' => now()->addMonths(3)->toDateString(), + 'status' => 'in_progress', + 'created_by' => $this->user->id, + ]); + $this->project->users()->attach($this->user->id, ['role_in_project' => 'supervisor']); + } + + public function test_create_phase_via_modal_persists_all_fields(): void + { + Livewire::actingAs($this->user) + ->test(PhaseList::class, ['project' => $this->project]) + ->call('openForm') + ->set('name', 'Cimentación') + ->set('description', 'Zapatas y muros') + ->set('color', '#ff0000') + ->set('order', 2) + ->set('progressPercent', 30) + ->set('plannedStart', '2026-07-01') + ->set('plannedEnd', '2026-08-01') + ->call('save') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('phases', [ + 'project_id' => $this->project->id, + 'name' => 'Cimentación', + 'color' => '#ff0000', + 'order' => 2, + 'progress_percent' => 30, + ]); + } + + public function test_edit_phase_loads_and_updates(): void + { + $phase = $this->project->phases()->create([ + 'name' => 'Vieja', 'order' => 1, 'color' => '#3b82f6', 'progress_percent' => 0, + ]); + + Livewire::actingAs($this->user) + ->test(PhaseList::class, ['project' => $this->project]) + ->call('editPhase', $phase->id) + ->assertSet('name', 'Vieja') + ->set('name', 'Nueva') + ->set('progressPercent', 80) + ->call('save') + ->assertHasNoErrors(); + + $phase->refresh(); + $this->assertEquals('Nueva', $phase->name); + $this->assertEquals(80, $phase->progress_percent); + } + + public function test_phase_table_lists_and_deletes(): void + { + $phase = $this->project->phases()->create([ + 'name' => 'Estructura', 'order' => 1, 'color' => '#3b82f6', 'progress_percent' => 0, + ]); + + Livewire::actingAs($this->user) + ->test(PhaseTable::class, ['projectId' => $this->project->id]) + ->assertOk() + ->assertSee('Estructura') + ->call('deletePhase', $phase->id); + + $this->assertDatabaseMissing('phases', ['id' => $phase->id, 'deleted_at' => null]); + } + + public function test_create_phase_requires_manage_phases_permission(): void + { + $outsider = User::factory()->create(); + $this->project->users()->attach($outsider->id, ['role_in_project' => 'viewer']); + + Livewire::actingAs($outsider) + ->test(PhaseList::class, ['project' => $this->project]) + ->call('openForm') + ->assertForbidden(); + } +}