- Added planned/actual/baseline dates to Phase and Feature models
- Created ProgressSnapshot model + migration for historical tracking
- Implemented CaptureDailyProgressSnapshot job (scheduled daily at 06:30)
- Added DeviationCalculator logic (SPI, planned progress, deviation days)
- ReportGenerator service with complete data aggregation
- ReportController with builder, preview, generate (HTML/Excel)
- ReportBuilder Livewire component with date range, entity selection, format
- ProjectReportExport with 10 sheets (Summary, Phases, Features, Inspections, Issues, Tasks, Deviations, Media, Progress Curve, Parameters)
- Blade partials for all sections (header, summary, phases, features, inspections, issues, tasks, media, deviations, progress curve)
- New routes: /projects/{project}/reports/*
- Added 'generate reports' permission
- All 101 tests passing
332 lines
9.1 KiB
PHP
332 lines
9.1 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use App\Notifications\TaskAssignedNotification;
|
|
use App\Notifications\TaskStatusChangedNotification;
|
|
use App\Traits\LogsActivity;
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
|
|
class Task extends Model
|
|
{
|
|
use HasFactory, LogsActivity, SoftDeletes;
|
|
|
|
protected $fillable = [
|
|
'project_id',
|
|
'phase_id',
|
|
'parent_task_id',
|
|
'title',
|
|
'description',
|
|
'status',
|
|
'priority',
|
|
'created_by',
|
|
'assigned_to',
|
|
'due_date',
|
|
'start_date',
|
|
'completed_at',
|
|
'completed_by',
|
|
'estimated_hours',
|
|
'actual_hours',
|
|
'order',
|
|
'uuid',
|
|
'client_updated_at',
|
|
];
|
|
|
|
protected $casts = [
|
|
'due_date' => 'date',
|
|
'start_date' => 'date',
|
|
'completed_at' => 'datetime',
|
|
'estimated_hours' => 'integer',
|
|
'actual_hours' => 'integer',
|
|
'order' => 'integer',
|
|
];
|
|
|
|
// ============================================================
|
|
// Events / Observers
|
|
// ============================================================
|
|
|
|
protected static function booted(): void
|
|
{
|
|
static::created(function (Task $task) {
|
|
if ($task->assigned_to) {
|
|
$assignee = $task->assignee;
|
|
if ($assignee && $assignee->id !== auth()->id()) {
|
|
$assignee->notify(new TaskAssignedNotification($task));
|
|
}
|
|
}
|
|
});
|
|
|
|
static::updated(function (Task $task) {
|
|
// Notify on assignment change
|
|
if ($task->isDirty('assigned_to')) {
|
|
$newAssigneeId = $task->assigned_to;
|
|
if ($newAssigneeId) {
|
|
$assignee = User::find($newAssigneeId);
|
|
if ($assignee && $assignee->id !== auth()->id()) {
|
|
$assignee->notify(new TaskAssignedNotification($task));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Notify on status change
|
|
if ($task->isDirty('status')) {
|
|
$oldStatus = $task->getOriginal('status');
|
|
$task->creator?->notify(new TaskStatusChangedNotification($task, $oldStatus));
|
|
if ($task->assigned_to && $task->assigned_to !== $task->created_by) {
|
|
$task->assignee?->notify(new TaskStatusChangedNotification($task, $oldStatus));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// Relationships
|
|
// ============================================================
|
|
|
|
public function project(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Project::class);
|
|
}
|
|
|
|
public function phase(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Phase::class);
|
|
}
|
|
|
|
public function parent(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Task::class, 'parent_task_id');
|
|
}
|
|
|
|
public function subtasks(): HasMany
|
|
{
|
|
return $this->hasMany(Task::class, 'parent_task_id')->orderBy('order')->orderBy('id');
|
|
}
|
|
|
|
public function creator(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class, 'created_by');
|
|
}
|
|
|
|
public function assignee(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class, 'assigned_to');
|
|
}
|
|
|
|
public function completer(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class, 'completed_by');
|
|
}
|
|
|
|
public function media(): MorphMany
|
|
{
|
|
return $this->morphMany(Media::class, 'mediable');
|
|
}
|
|
|
|
public function comments(): MorphMany
|
|
{
|
|
return $this->morphMany(Comment::class, 'commentable')->orderBy('created_at');
|
|
}
|
|
|
|
public function activityLogs(): MorphMany
|
|
{
|
|
return $this->morphMany(ActivityLog::class, 'model');
|
|
}
|
|
|
|
public function progressSnapshots(): MorphMany
|
|
{
|
|
return $this->morphMany(ProgressSnapshot::class, 'trackable');
|
|
}
|
|
|
|
// ============================================================
|
|
// Scopes
|
|
// ============================================================
|
|
|
|
public function scopePending($query)
|
|
{
|
|
return $query->where('status', 'pending');
|
|
}
|
|
|
|
public function scopeInProgress($query)
|
|
{
|
|
return $query->where('status', 'in_progress');
|
|
}
|
|
|
|
public function scopeCompleted($query)
|
|
{
|
|
return $query->where('status', 'completed');
|
|
}
|
|
|
|
public function scopeCancelled($query)
|
|
{
|
|
return $query->where('status', 'cancelled');
|
|
}
|
|
|
|
public function scopeNotCompleted($query)
|
|
{
|
|
return $query->where('status', '!=', 'completed');
|
|
}
|
|
|
|
public function scopeOverdue($query)
|
|
{
|
|
return $query->where('status', '!=', 'completed')
|
|
->whereNotNull('due_date')
|
|
->whereDate('due_date', '<', now()->toDateString());
|
|
}
|
|
|
|
public function scopeDueToday($query)
|
|
{
|
|
return $query->where('status', '!=', 'completed')
|
|
->whereDate('due_date', now()->toDateString());
|
|
}
|
|
|
|
public function scopeAccessibleBy($query, User $user)
|
|
{
|
|
if ($user->can('manage all tasks')) {
|
|
return $query;
|
|
}
|
|
|
|
return $query->whereHas('project', function ($q) use ($user) {
|
|
$q->whereHas('users', function ($sub) use ($user) {
|
|
$sub->where('user_id', $user->id);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// Accessors / Helpers
|
|
// ============================================================
|
|
|
|
public function getIsOverdueAttribute(): bool
|
|
{
|
|
return $this->status !== 'completed'
|
|
&& $this->due_date
|
|
&& $this->due_date->isPast();
|
|
}
|
|
|
|
public function getIsDueTodayAttribute(): bool
|
|
{
|
|
return $this->status !== 'completed'
|
|
&& $this->due_date
|
|
&& $this->due_date->isToday();
|
|
}
|
|
|
|
public function getProgressAttribute(): int
|
|
{
|
|
$total = $this->subtasks->count();
|
|
if ($total === 0) {
|
|
return in_array($this->status, ['completed'], true) ? 100 : 0;
|
|
}
|
|
|
|
return (int) round($this->subtasks->where('status', 'completed')->count() / $total * 100);
|
|
}
|
|
|
|
public function getPriorityColorAttribute(): string
|
|
{
|
|
return match ($this->priority) {
|
|
'low' => '#6b7280', // gray
|
|
'medium' => '#f59e0b', // amber
|
|
'high' => '#ef4444', // red
|
|
'critical' => '#7c3aed', // purple
|
|
default => '#6b7280',
|
|
};
|
|
}
|
|
|
|
public function getStatusColorAttribute(): string
|
|
{
|
|
return match ($this->status) {
|
|
'pending' => '#6b7280', // gray
|
|
'in_progress' => '#3b82f6', // blue
|
|
'completed' => '#10b981', // green
|
|
'cancelled' => '#ef4444', // red
|
|
default => '#6b7280',
|
|
};
|
|
}
|
|
|
|
public function getStatusLabelAttribute(): string
|
|
{
|
|
return match ($this->status) {
|
|
'pending' => 'Pendiente',
|
|
'in_progress' => 'En progreso',
|
|
'completed' => 'Completada',
|
|
'cancelled' => 'Cancelada',
|
|
default => ucfirst((string) $this->status),
|
|
};
|
|
}
|
|
|
|
public function getPriorityLabelAttribute(): string
|
|
{
|
|
return match ($this->priority) {
|
|
'low' => 'Baja',
|
|
'medium' => 'Media',
|
|
'high' => 'Alta',
|
|
'critical' => 'Crítica',
|
|
default => ucfirst((string) $this->priority),
|
|
};
|
|
}
|
|
|
|
// ============================================================
|
|
// State Transitions
|
|
// ============================================================
|
|
|
|
public function start(): void
|
|
{
|
|
$this->update([
|
|
'status' => 'in_progress',
|
|
'start_date' => $this->start_date ?? now()->toDateString(),
|
|
]);
|
|
}
|
|
|
|
public function complete(?User $user = null): void
|
|
{
|
|
$this->update([
|
|
'status' => 'completed',
|
|
'completed_at' => now(),
|
|
'completed_by' => $user?->id ?? auth()->id(),
|
|
]);
|
|
}
|
|
|
|
public function reopen(): void
|
|
{
|
|
$this->update([
|
|
'status' => 'pending',
|
|
'completed_at' => null,
|
|
'completed_by' => null,
|
|
]);
|
|
}
|
|
|
|
public function cancel(): void
|
|
{
|
|
$this->update(['status' => 'cancelled']);
|
|
}
|
|
|
|
// ============================================================
|
|
// Static Helpers
|
|
// ============================================================
|
|
|
|
public static function statusOptions(): array
|
|
{
|
|
return [
|
|
'pending' => 'Pendiente',
|
|
'in_progress' => 'En progreso',
|
|
'completed' => 'Completada',
|
|
'cancelled' => 'Cancelada',
|
|
];
|
|
}
|
|
|
|
public static function priorityOptions(): array
|
|
{
|
|
return [
|
|
'low' => 'Baja',
|
|
'medium' => 'Media',
|
|
'high' => 'Alta',
|
|
'critical' => 'Crítica',
|
|
];
|
|
}
|
|
}
|