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();