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:
@@ -28,10 +28,28 @@ class Issue extends Model
|
||||
public function reporter() { return $this->belongsTo(User::class, 'reported_by'); }
|
||||
public function assignee() { return $this->belongsTo(User::class, 'assigned_to'); }
|
||||
public function media() { return $this->morphMany(Media::class, 'mediable'); }
|
||||
public function tasks() { return $this->hasMany(IssueTask::class)->orderBy('order')->orderBy('id'); }
|
||||
public function comments() { return $this->hasMany(IssueComment::class)->orderBy('created_at'); }
|
||||
|
||||
public function scopeOpen($q) { return $q->where('status', 'open'); }
|
||||
public function scopeCritical($q) { return $q->where('priority', 'critical'); }
|
||||
|
||||
/** Resolution progress derived from the checklist: done tasks / total. */
|
||||
public function getProgressAttribute(): int
|
||||
{
|
||||
$total = $this->tasks->count();
|
||||
if ($total === 0) {
|
||||
return in_array($this->status, ['resolved', 'closed'], true) ? 100 : 0;
|
||||
}
|
||||
return (int) round($this->tasks->where('is_done', true)->count() / $total * 100);
|
||||
}
|
||||
|
||||
/** True when there is at least one task and all of them are done. */
|
||||
public function getTasksCompleteAttribute(): bool
|
||||
{
|
||||
return $this->tasks->count() > 0 && $this->tasks->every(fn ($t) => $t->is_done);
|
||||
}
|
||||
|
||||
public function getPriorityColorAttribute(): string
|
||||
{
|
||||
return match($this->priority) {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class IssueComment extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'issue_id', 'user_id', 'body',
|
||||
'uuid', 'client_updated_at',
|
||||
];
|
||||
|
||||
public function issue() { return $this->belongsTo(Issue::class); }
|
||||
public function user() { return $this->belongsTo(User::class); }
|
||||
public function media() { return $this->morphMany(Media::class, 'mediable'); }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class IssueTask extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'issue_id', 'title', 'is_done', 'done_at', 'done_by',
|
||||
'assigned_to', 'due_date', 'order',
|
||||
'uuid', 'client_updated_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_done' => 'boolean',
|
||||
'done_at' => 'datetime',
|
||||
'due_date' => 'date',
|
||||
];
|
||||
|
||||
public function issue() { return $this->belongsTo(Issue::class); }
|
||||
public function assignee() { return $this->belongsTo(User::class, 'assigned_to'); }
|
||||
public function completer() { return $this->belongsTo(User::class, 'done_by'); }
|
||||
public function media() { return $this->morphMany(Media::class, 'mediable'); }
|
||||
|
||||
/** Overdue = has a due date in the past and not yet done. */
|
||||
public function getIsOverdueAttribute(): bool
|
||||
{
|
||||
return ! $this->is_done && $this->due_date && $this->due_date->isPast();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user