Files
construprogress/app/Models/Task.php
T
Javier Braña ba614bddbc feat(tasks): complete task management system with Kanban, Calendar, notifications
- Add Task model with subtasks, priorities, status transitions, dates, hours
- Add Comment polymorphic model for tasks/issues/projects
- Livewire components: TaskManager (list+filters), TaskForm (modal), TaskDetail, TaskKanban (drag&drop), TaskCalendar (FullCalendar)
- TaskPolicy with permissions (view/create/edit/delete/assign/manage all)
- Notifications: assigned, status change, overdue, comment added
- Daily overdue notification job scheduled
- Dashboard widget unifies IssueTask + Task
- i18n: en/es/fr/ru (393 keys each)
- Routes, navigation, offline sync support
- 101 tests passing, Pint compliant on new files
2026-08-03 13:44:10 +02:00

327 lines
9.0 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');
}
// ============================================================
// 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',
];
}
}