feat(issues): incidencias enriquecidas (tareas/comentarios/fotos/verificación) + tabla Rappasoft + logo

Web:
- IssueTask + IssueComment (modelos, migraciones, soft-deletes, campos de sync).
  Issue gana tasks()/comments() y accessor de % de avance derivado de tareas.
- IssueDetail (página): checklist con asignado/fecha límite/progreso, hilo de
  comentarios con foto por comentario, galería de fotos de la incidencia y flujo
  de verificación open→in_review→resolved/closed (+reabrir) con notas.
- Creación/edición en páginas propias (IssueForm), sin modal; al guardar redirige
  al detalle. Rutas projects.issues.create/edit/show.
- Listado con tabla Rappasoft (IssueTable): filtros por estado/prioridad, búsqueda,
  barra de progreso y acciones por fila gateadas por permisos; IssueManager queda
  como contenedor (cabecera + stats) que embebe la tabla.
- Seguridad: pertenencia al proyecto + permisos por acción (view/create/edit/delete
  issues, upload/delete media) en todos los componentes.

API móvil (offline):
- /sync: issue_task.create/update y issue_comment.create (idempotente, LWW).
- /media: parent_entity issue_task / issue_comment.
- bundle + tombstones incluyen issue_tasks / issue_comments.
- openapi.yaml + MOBILE_SYNC_PROTOCOL.md actualizados.

Tests: MobileApiTest 23 passing (+5); IssuesTablePageTest (3) smoke de la tabla.

Branding: logo RTE International — MAI Group (public/images/logo-rte.png) en login
y navegación; application-logo pasa de SVG por defecto a <img>.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 12:12:39 +02:00
parent 14758136b6
commit 3f240e5277
25 changed files with 1604 additions and 566 deletions
+123 -1
View File
@@ -6,6 +6,8 @@ use App\Models\Feature;
use App\Models\Inspection;
use App\Models\InspectionTemplate;
use App\Models\Issue;
use App\Models\IssueComment;
use App\Models\IssueTask;
use App\Models\Layer;
use App\Models\Phase;
use App\Models\Project;
@@ -28,7 +30,7 @@ class MobileApiTest extends TestCase
protected function setUp(): void
{
parent::setUp();
foreach (['update progress', 'manage all', 'create inspections', 'create issues', 'edit issues', 'upload media'] as $p) {
foreach (['update progress', 'manage all', 'create inspections', 'view issues', 'create issues', 'edit issues', 'upload media'] as $p) {
Permission::findOrCreate($p);
}
}
@@ -407,6 +409,126 @@ class MobileApiTest extends TestCase
$this->assertEquals(1, \App\Models\Media::where('uuid', $uuid)->count());
}
// ── Issue tasks / comments (enriquecimiento incidencias) ─────────────────────
private function makeIssue(Project $project, ?User $reporter = null): Issue
{
return Issue::create([
'project_id' => $project->id,
'title' => 'Incidencia test',
'status' => 'open',
'priority' => 'medium',
'reported_by' => $reporter?->id ?? $project->created_by,
]);
}
public function test_sync_creates_and_updates_an_issue_task(): void
{
$user = User::factory()->create();
$user->givePermissionTo('edit issues');
$project = $this->makeProject($user);
$issue = $this->makeIssue($project, $user);
Sanctum::actingAs($user, ['mobile-sync']);
// create task
$uuid = (string) Str::uuid();
$this->postJson('/api/v1/sync', ['operations' => [[
'entity' => 'issue_task', 'op' => 'create', 'uuid' => $uuid,
'data' => ['issue_id' => $issue->id, 'title' => 'Sanear zona'],
]]])->assertOk()->assertJsonPath('results.0.status', 'applied');
$task = IssueTask::where('uuid', $uuid)->firstOrFail();
$this->assertFalse($task->is_done);
// mark done via update
$this->postJson('/api/v1/sync', ['operations' => [[
'entity' => 'issue_task', 'op' => 'update', 'uuid' => (string) Str::uuid(),
'data' => ['id' => $task->id, 'is_done' => true],
]]])->assertOk()->assertJsonPath('results.0.status', 'applied');
$task->refresh();
$this->assertTrue($task->is_done);
$this->assertEquals($user->id, $task->done_by);
$this->assertNotNull($task->done_at);
}
public function test_sync_issue_task_is_forbidden_without_edit_permission(): void
{
$user = User::factory()->create(); // member but no 'edit issues'
$project = $this->makeProject($user);
$issue = $this->makeIssue($project, $user);
Sanctum::actingAs($user, ['mobile-sync']);
$this->postJson('/api/v1/sync', ['operations' => [[
'entity' => 'issue_task', 'op' => 'create', 'uuid' => (string) Str::uuid(),
'data' => ['issue_id' => $issue->id, 'title' => 'X'],
]]])->assertOk()->assertJsonPath('results.0.status', 'error');
$this->assertDatabaseCount('issue_tasks', 0);
}
public function test_sync_creates_an_issue_comment(): void
{
$user = User::factory()->create();
$user->givePermissionTo('view issues');
$project = $this->makeProject($user);
$issue = $this->makeIssue($project, $user);
Sanctum::actingAs($user, ['mobile-sync']);
$uuid = (string) Str::uuid();
$this->postJson('/api/v1/sync', ['operations' => [[
'entity' => 'issue_comment', 'op' => 'create', 'uuid' => $uuid,
'data' => ['issue_id' => $issue->id, 'body' => 'Revisado en obra'],
]]])->assertOk()->assertJsonPath('results.0.status', 'applied');
$this->assertDatabaseHas('issue_comments', [
'uuid' => $uuid, 'issue_id' => $issue->id, 'user_id' => $user->id, 'body' => 'Revisado en obra',
]);
}
public function test_media_uploads_to_issue_task_and_comment(): void
{
Storage::fake('public');
$user = User::factory()->create();
$user->givePermissionTo('upload media');
$project = $this->makeProject($user);
$issue = $this->makeIssue($project, $user);
$task = $issue->tasks()->create(['title' => 'T', 'uuid' => (string) Str::uuid()]);
$comment = $issue->comments()->create(['user_id' => $user->id, 'body' => 'c', 'uuid' => (string) Str::uuid()]);
Sanctum::actingAs($user, ['mobile-sync']);
$this->post('/api/v1/media', [
'uuid' => (string) Str::uuid(), 'parent_entity' => 'issue_task', 'parent_id' => $task->id,
'file' => UploadedFile::fake()->image('a.jpg'),
])->assertOk()->assertJsonPath('status', 'applied');
$this->post('/api/v1/media', [
'uuid' => (string) Str::uuid(), 'parent_entity' => 'issue_comment', 'parent_id' => $comment->id,
'file' => UploadedFile::fake()->image('b.jpg'),
])->assertOk()->assertJsonPath('status', 'applied');
$this->assertDatabaseHas('media', ['mediable_type' => IssueTask::class, 'mediable_id' => $task->id]);
$this->assertDatabaseHas('media', ['mediable_type' => IssueComment::class, 'mediable_id' => $comment->id]);
}
public function test_bundle_includes_issue_tasks_and_comments(): void
{
$user = User::factory()->create();
$project = $this->makeProject($user);
$issue = $this->makeIssue($project, $user);
$task = $issue->tasks()->create(['title' => 'Tarea bundle', 'uuid' => (string) Str::uuid()]);
$comment = $issue->comments()->create(['user_id' => $user->id, 'body' => 'Comentario bundle', 'uuid' => (string) Str::uuid()]);
Sanctum::actingAs($user, ['mobile-sync']);
$res = $this->getJson("/api/v1/projects/{$project->id}/bundle")->assertOk();
$this->assertTrue(collect($res->json('issue_tasks'))->pluck('id')->contains($task->id));
$this->assertTrue(collect($res->json('issue_comments'))->pluck('id')->contains($comment->id));
}
public function test_sync_operation_is_idempotent_via_sync_logs(): void
{
$user = User::factory()->create();
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace Tests\Feature;
use App\Livewire\IssueManager;
use App\Livewire\IssueTable;
use App\Models\Issue;
use App\Models\Project;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Spatie\Permission\Models\Permission;
use Tests\TestCase;
class IssuesTablePageTest extends TestCase
{
use RefreshDatabase;
private function memberWithIssue(): array
{
foreach (['view issues', 'create issues', 'edit issues', 'delete issues'] as $p) {
Permission::findOrCreate($p);
}
$user = User::factory()->create();
$user->givePermissionTo(['view issues', 'edit issues', 'delete issues']);
$project = Project::create([
'reference' => 'TBL-1',
'name' => 'Proyecto Tabla',
'address' => 'Calle Falsa 123',
'lat' => 40.0,
'lng' => -3.0,
'start_date' => now()->toDateString(),
'end_date_estimated' => now()->addMonths(6)->toDateString(),
'status' => 'in_progress',
'created_by' => $user->id,
]);
$project->users()->attach($user->id, ['role_in_project' => 'supervisor']);
$issue = Issue::create([
'project_id' => $project->id,
'title' => 'Grieta en muro',
'status' => 'open',
'priority' => 'high',
'reported_by' => $user->id,
]);
return [$user, $project, $issue];
}
public function test_issue_manager_renders_stats_and_table(): void
{
[$user, $project, $issue] = $this->memberWithIssue();
Livewire::actingAs($user)
->test(IssueManager::class, ['project' => $project])
->assertOk()
->assertSee('Incidencias del proyecto')
->assertSeeLivewire(IssueTable::class);
}
public function test_issue_table_lists_and_resolves_an_issue(): void
{
[$user, $project, $issue] = $this->memberWithIssue();
Livewire::actingAs($user)
->test(IssueTable::class, ['projectId' => $project->id])
->assertOk()
->assertSee('Grieta en muro')
->call('resolve', $issue->id);
$this->assertEquals('resolved', $issue->fresh()->status);
}
public function test_issue_table_forbidden_for_non_member(): void
{
[, $project] = $this->memberWithIssue();
$outsider = User::factory()->create();
$outsider->givePermissionTo('view issues');
Livewire::actingAs($outsider)
->test(IssueTable::class, ['projectId' => $project->id])
->assertForbidden();
}
}