feat(tasks): complete task management system with Kanban, Calendar, notifications

- Add Task model with subtasks, priorities, status transitions, dates, hours
- Add Comment polymorphic model for tasks/issues/projects
- Livewire components: TaskManager (list+filters), TaskForm (modal), TaskDetail, TaskKanban (drag&drop), TaskCalendar (FullCalendar)
- TaskPolicy with permissions (view/create/edit/delete/assign/manage all)
- Notifications: assigned, status change, overdue, comment added
- Daily overdue notification job scheduled
- Dashboard widget unifies IssueTask + Task
- i18n: en/es/fr/ru (393 keys each)
- Routes, navigation, offline sync support
- 101 tests passing, Pint compliant on new files
This commit is contained in:
Javier Braña
2026-08-03 13:44:10 +02:00
parent a65d4b12f2
commit ba614bddbc
62 changed files with 5878 additions and 382 deletions
+278 -5
View File
@@ -7,6 +7,7 @@ use App\Models\Feature;
use App\Models\Inspection;
use App\Models\InspectionTemplate;
use App\Models\Layer;
use App\Models\Media;
use App\Models\Phase;
use App\Models\Project;
use App\Models\User;
@@ -20,14 +21,16 @@ class InspectionFormTest extends TestCase
{
use RefreshDatabase;
public function test_register_inspection_with_comments_and_photo(): void
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
// Seed roles and permissions
$this->seed(\Database\Seeders\RolesAndPermissionsSeeder::class);
$this->seed(\Database\Seeders\PermissionCatalogSeeder::class);
}
private function createTestData(): array
{
$user = User::factory()->create();
$user->assignRole('Supervisor');
@@ -50,10 +53,15 @@ class InspectionFormTest extends TestCase
['group' => 'Geometría', 'name' => 'altura', 'label' => 'Altura', 'question' => '¿Cota OK?', 'type' => 'text', 'required' => false, 'help' => 'Medir'],
],
]);
// Attach template to project (pivot required for loadTemplates)
$project->inspectionTemplates()->attach($template->id);
return [$user, $project, $feature, $template];
}
public function test_register_inspection_with_comments_and_photo(): void
{
[$user, $project, $feature, $template] = $this->createTestData();
Livewire::actingAs($user)
->test(ProjectMap::class, ['project' => $project])
->call('selectFeature', $feature->id)
@@ -69,4 +77,269 @@ class InspectionFormTest extends TestCase
$this->assertEquals('5.2', $ins->data['altura']);
$this->assertCount(1, $ins->media);
}
public function test_edit_inspection_updates_data_and_photos(): void
{
[$user, $project, $feature, $template] = $this->createTestData();
// Create initial inspection
$inspection = Inspection::create([
'project_id' => $project->id,
'layer_id' => $feature->layer_id,
'feature_id' => $feature->id,
'template_id' => $template->id,
'user_id' => $user->id,
'inspector_user_id' => $user->id,
'status' => 'completed',
'completed_at' => now(),
'result' => 'pass',
'notes' => 'Original note',
'data' => ['altura' => '5.0'],
]);
$photo1 = Media::create([
'mediable_type' => Inspection::class,
'mediable_id' => $inspection->id,
'name' => 'original.jpg',
'file_path' => 'uploads/inspections/' . $inspection->id . '/original.jpg',
'file_type' => 'image/jpeg',
'file_extension' => 'jpg',
'file_size' => 1000,
'category' => 'image',
'uploaded_by' => $user->id,
]);
Livewire::actingAs($user)
->test(ProjectMap::class, ['project' => $project])
->call('selectFeature', $feature->id)
->call('editInspection', $inspection->id)
->assertSet('editingInspection.id', $inspection->id)
->assertSet('editInspectionFormData.altura', '5.0')
->assertSet('editInspectionResult', 'pass')
->assertSet('editInspectionNotes', 'Original note')
->set('editInspectionFormData.altura', '5.5')
->set('editInspectionResult', 'fail')
->set('editInspectionNotes', 'Updated note')
->set('editInspectionPhotos', [UploadedFile::fake()->image('new.jpg')])
->call('saveEditInspection');
$ins = $inspection->fresh();
$this->assertEquals('5.5', $ins->data['altura']);
$this->assertEquals('fail', $ins->result);
$this->assertEquals('Updated note', $ins->notes);
$this->assertCount(2, $ins->media); // original + new
}
public function test_edit_inspection_delete_existing_photo(): void
{
[$user, $project, $feature, $template] = $this->createTestData();
$inspection = Inspection::create([
'project_id' => $project->id,
'layer_id' => $feature->layer_id,
'feature_id' => $feature->id,
'template_id' => $template->id,
'user_id' => $user->id,
'inspector_user_id' => $user->id,
'status' => 'completed',
'completed_at' => now(),
'result' => 'pass',
'notes' => 'Note',
'data' => ['altura' => '5.0'],
]);
$photo1 = Media::create([
'mediable_type' => Inspection::class,
'mediable_id' => $inspection->id,
'name' => 'to_delete.jpg',
'file_path' => 'uploads/inspections/' . $inspection->id . '/to_delete.jpg',
'file_type' => 'image/jpeg',
'file_extension' => 'jpg',
'file_size' => 1000,
'category' => 'image',
'uploaded_by' => $user->id,
]);
$photo2 = Media::create([
'mediable_type' => Inspection::class,
'mediable_id' => $inspection->id,
'name' => 'keep.jpg',
'file_path' => 'uploads/inspections/' . $inspection->id . '/keep.jpg',
'file_type' => 'image/jpeg',
'file_extension' => 'jpg',
'file_size' => 1000,
'category' => 'image',
'uploaded_by' => $user->id,
]);
Livewire::actingAs($user)
->test(ProjectMap::class, ['project' => $project])
->call('selectFeature', $feature->id)
->call('editInspection', $inspection->id)
->call('deleteEditPhoto', 0) // delete first photo (to_delete.jpg)
->call('saveEditInspection');
$this->assertCount(1, $inspection->fresh()->media);
$this->assertEquals('keep.jpg', $inspection->fresh()->media->first()->name);
$this->assertDatabaseMissing('media', ['id' => $photo1->id]);
}
public function test_edit_inspection_forbidden_without_permission(): void
{
[$user, $project, $feature, $template] = $this->createTestData();
$user->syncRoles([]);
$user->givePermissionTo(['view projects', 'view inspections']); // only view
$inspection = Inspection::create([
'project_id' => $project->id,
'layer_id' => $feature->layer_id,
'feature_id' => $feature->id,
'template_id' => $template->id,
'user_id' => $user->id,
'inspector_user_id' => $user->id,
'status' => 'completed',
'completed_at' => now(),
'result' => 'pass',
'notes' => 'Note',
'data' => ['altura' => '5.0'],
]);
// Modal opens but save is blocked
Livewire::actingAs($user)
->test(ProjectMap::class, ['project' => $project])
->call('selectFeature', $feature->id)
->call('editInspection', $inspection->id)
->set('editInspectionNotes', 'Intento editar')
->call('saveEditInspection')
->assertDispatched('notify', 'Sin permisos para editar inspecciones.');
// Data should not have changed
$this->assertEquals('Note', $inspection->fresh()->notes);
}
public function test_create_inspection_forbidden_without_permission(): void
{
[$user, $project, $feature, $template] = $this->createTestData();
$user->syncRoles([]);
$user->givePermissionTo(['view projects', 'view inspections']);
Livewire::actingAs($user)
->test(ProjectMap::class, ['project' => $project])
->call('selectFeature', $feature->id)
->set('selectedTemplateId', $template->id)
->set('inspectionFormData.altura', '5.2')
->set('inspectionPhotos', [UploadedFile::fake()->image('foto.jpg')])
->call('saveInspection')
->assertDispatched('notify', 'Sin permisos para crear inspecciones.');
$this->assertDatabaseMissing('inspections', ['feature_id' => $feature->id]);
}
public function test_delete_inspection_removes_inspection_and_media(): void
{
[$user, $project, $feature, $template] = $this->createTestData();
$inspection = Inspection::create([
'project_id' => $project->id,
'layer_id' => $feature->layer_id,
'feature_id' => $feature->id,
'template_id' => $template->id,
'user_id' => $user->id,
'inspector_user_id' => $user->id,
'status' => 'completed',
'completed_at' => now(),
'result' => 'pass',
'notes' => 'Note',
'data' => ['altura' => '5.0'],
]);
$photo = Media::create([
'mediable_type' => Inspection::class,
'mediable_id' => $inspection->id,
'name' => 'test.jpg',
'file_path' => 'uploads/inspections/' . $inspection->id . '/test.jpg',
'file_type' => 'image/jpeg',
'file_extension' => 'jpg',
'file_size' => 1000,
'category' => 'image',
'uploaded_by' => $user->id,
]);
// Test direct model delete first
$inspection->delete();
$this->assertSoftDeleted('inspections', ['id' => $inspection->id]);
$this->assertDatabaseMissing('media', ['id' => $photo->id]);
// Restore for the actual test
$inspection->restore();
$this->assertDatabaseHas('inspections', ['id' => $inspection->id, 'deleted_at' => null]);
// Now test through Livewire component
$inspection2 = Inspection::create([
'project_id' => $project->id,
'layer_id' => $feature->layer_id,
'feature_id' => $feature->id,
'template_id' => $template->id,
'user_id' => $user->id,
'inspector_user_id' => $user->id,
'status' => 'completed',
'completed_at' => now(),
'result' => 'pass',
'notes' => 'Note',
'data' => ['altura' => '5.0'],
]);
$photo2 = Media::create([
'mediable_type' => Inspection::class,
'mediable_id' => $inspection2->id,
'name' => 'test2.jpg',
'file_path' => 'uploads/inspections/' . $inspection2->id . '/test2.jpg',
'file_type' => 'image/jpeg',
'file_extension' => 'jpg',
'file_size' => 1000,
'category' => 'image',
'uploaded_by' => $user->id,
]);
$component = Livewire::actingAs($user)
->test(ProjectMap::class, ['project' => $project])
->call('selectFeature', $feature->id);
// Debug: check if component is instantiated
$component->assertSet('project.id', $project->id);
$component->assertSet('selectedFeature.id', $feature->id);
// Call deleteInspection and capture events
$component->call('deleteInspection', $inspection2->id);
// Check if notify was dispatched
$component->assertDispatched('notify', 'Inspección eliminada correctamente');
$this->assertSoftDeleted('inspections', ['id' => $inspection2->id]);
$this->assertDatabaseMissing('media', ['id' => $photo2->id]);
}
public function test_delete_inspection_forbidden_without_permission(): void
{
[$user, $project, $feature, $template] = $this->createTestData();
$user->syncRoles([]);
$user->givePermissionTo(['view projects', 'view inspections']);
$inspection = Inspection::create([
'project_id' => $project->id,
'layer_id' => $feature->layer_id,
'feature_id' => $feature->id,
'template_id' => $template->id,
'user_id' => $user->id,
'inspector_user_id' => $user->id,
'status' => 'completed',
'completed_at' => now(),
'result' => 'pass',
'notes' => 'Note',
'data' => ['altura' => '5.0'],
]);
Livewire::actingAs($user)
->test(ProjectMap::class, ['project' => $project])
->call('selectFeature', $feature->id)
->call('deleteInspection', $inspection->id)
->assertDispatched('notify', 'Sin permisos para eliminar inspecciones.');
$this->assertDatabaseHas('inspections', ['id' => $inspection->id, 'deleted_at' => null]);
}
}