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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user