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
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Notifications\TaskCommentNotification;
|
||||
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\MorphTo;
|
||||
|
||||
class Comment extends Model
|
||||
{
|
||||
use HasFactory, LogsActivity;
|
||||
|
||||
protected $fillable = [
|
||||
'commentable_type',
|
||||
'commentable_id',
|
||||
'user_id',
|
||||
'body',
|
||||
'parent_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'body' => 'string',
|
||||
];
|
||||
|
||||
// ============================================================
|
||||
// Events / Observers
|
||||
// ============================================================
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::created(function (Comment $comment) {
|
||||
// Notify if comment is on a Task
|
||||
if ($comment->commentable_type === Task::class) {
|
||||
$task = $comment->commentable;
|
||||
if ($task) {
|
||||
// Notify creator and assignee (except the commenter)
|
||||
$usersToNotify = collect([$task->creator, $task->assignee])
|
||||
->unique('id')
|
||||
->filter(fn ($u) => $u && $u->id !== $comment->user_id);
|
||||
|
||||
foreach ($usersToNotify as $user) {
|
||||
$user->notify(new TaskCommentNotification($task, $comment));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Relationships
|
||||
// ============================================================
|
||||
|
||||
public function commentable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Comment::class, 'parent_id');
|
||||
}
|
||||
|
||||
public function replies(): HasMany
|
||||
{
|
||||
return $this->hasMany(Comment::class, 'parent_id')->orderBy('created_at');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Scopes
|
||||
// ============================================================
|
||||
|
||||
public function scopeTopLevel($query)
|
||||
{
|
||||
return $query->whereNull('parent_id');
|
||||
}
|
||||
|
||||
public function scopeWithReplies($query)
|
||||
{
|
||||
return $query->with('replies.user');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Accessors
|
||||
// ============================================================
|
||||
|
||||
public function getExcerptAttribute(): string
|
||||
{
|
||||
return strlen($this->body) > 150
|
||||
? substr($this->body, 0, 150).'...'
|
||||
: $this->body;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,14 @@ class Inspection extends Model
|
||||
{
|
||||
use SoftDeletes, LogsActivity;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::deleting(function ($inspection) {
|
||||
// Cascada: borrar media asociado cuando se elimina la inspección
|
||||
$inspection->media()->get()->each->delete();
|
||||
});
|
||||
}
|
||||
|
||||
const STATUSES = ['pending', 'in_progress', 'completed', 'approved', 'rejected'];
|
||||
const RESULTS = ['pass', 'fail', 'conditional'];
|
||||
|
||||
|
||||
+49
-12
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
@@ -9,28 +11,63 @@ class Phase extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'project_id', 'name', 'description', 'order', 'color', 'progress_percent',
|
||||
'planned_start', 'planned_end', 'actual_start', 'actual_end'
|
||||
'planned_start', 'planned_end', 'actual_start', 'actual_end',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'planned_start' => 'date',
|
||||
'planned_end' => 'date',
|
||||
'actual_start' => 'date',
|
||||
'actual_end' => 'date',
|
||||
'planned_end' => 'date',
|
||||
'actual_start' => 'date',
|
||||
'actual_end' => 'date',
|
||||
];
|
||||
|
||||
public function project() { return $this->belongsTo(Project::class); }
|
||||
public function layers() { return $this->hasMany(Layer::class); }
|
||||
public function progressUpdates() { return $this->hasMany(ProgressUpdate::class); }
|
||||
public function currentLayer() { return $this->hasOne(Layer::class)->latestOfMany(); }
|
||||
public function features() { return $this->hasManyThrough(Feature::class, Layer::class); }
|
||||
public function media() { return $this->morphMany(Media::class, 'mediable'); }
|
||||
public function images() { return $this->morphMany(Media::class, 'mediable')->where('category', 'image'); }
|
||||
public function project()
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
public function layers()
|
||||
{
|
||||
return $this->hasMany(Layer::class);
|
||||
}
|
||||
|
||||
public function progressUpdates()
|
||||
{
|
||||
return $this->hasMany(ProgressUpdate::class);
|
||||
}
|
||||
|
||||
public function currentLayer()
|
||||
{
|
||||
return $this->hasOne(Layer::class)->latestOfMany();
|
||||
}
|
||||
|
||||
public function features()
|
||||
{
|
||||
return $this->hasManyThrough(Feature::class, Layer::class);
|
||||
}
|
||||
|
||||
public function media()
|
||||
{
|
||||
return $this->morphMany(Media::class, 'mediable');
|
||||
}
|
||||
|
||||
public function images()
|
||||
{
|
||||
return $this->morphMany(Media::class, 'mediable')->where('category', 'image');
|
||||
}
|
||||
|
||||
public function tasks()
|
||||
{
|
||||
return $this->hasMany(Task::class);
|
||||
}
|
||||
|
||||
public function getDeviationDaysAttribute(): ?int
|
||||
{
|
||||
if (!$this->planned_end) return null;
|
||||
if (! $this->planned_end) {
|
||||
return null;
|
||||
}
|
||||
$end = $this->actual_end ?? now();
|
||||
|
||||
return $this->planned_end->diffInDays($end, false);
|
||||
}
|
||||
}
|
||||
|
||||
+11
-5
@@ -16,8 +16,8 @@ class Project extends Model
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
"start_date" => "date",
|
||||
"end_date_estimated" => "date",
|
||||
'start_date' => 'date',
|
||||
'end_date_estimated' => 'date',
|
||||
];
|
||||
|
||||
public function changeOrders()
|
||||
@@ -44,8 +44,8 @@ class Project extends Model
|
||||
public function companies()
|
||||
{
|
||||
return $this->belongsToMany(Company::class, 'company_project')
|
||||
->withPivot('role_in_project')
|
||||
->withTimestamps();
|
||||
->withPivot('role_in_project')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function creator()
|
||||
@@ -69,14 +69,20 @@ class Project extends Model
|
||||
return $this->morphMany(Media::class, 'mediable')->where('category', 'image');
|
||||
}
|
||||
|
||||
public function tasks()
|
||||
{
|
||||
return $this->hasMany(Task::class);
|
||||
}
|
||||
|
||||
// Scope to filter accessible projects for non-admin users
|
||||
public function scopeAccessibleBy($query, User $user)
|
||||
{
|
||||
if ($user->can('manage all')) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->whereHas('users', function ($q) use ($user) {
|
||||
$q->where('user_id', $user->id);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
<?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',
|
||||
];
|
||||
}
|
||||
}
|
||||
+22
-5
@@ -13,7 +13,7 @@ use Spatie\Permission\Traits\HasRoles;
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable, HasRoles, HasApiTokens;
|
||||
use HasApiTokens, HasFactory, HasRoles, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
@@ -47,16 +47,17 @@ class User extends Authenticatable
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'valid_from' => 'date',
|
||||
'valid_until' => 'date',
|
||||
'password' => 'hashed',
|
||||
'valid_from' => 'date',
|
||||
'valid_until' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
public function company()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Company::class);
|
||||
return $this->belongsTo(Company::class);
|
||||
}
|
||||
|
||||
// Many-to-many with projects
|
||||
public function projects()
|
||||
{
|
||||
@@ -68,4 +69,20 @@ class User extends Authenticatable
|
||||
{
|
||||
return $this->hasMany(ProgressUpdate::class);
|
||||
}
|
||||
|
||||
// Tasks
|
||||
public function createdTasks()
|
||||
{
|
||||
return $this->hasMany(Task::class, 'created_by');
|
||||
}
|
||||
|
||||
public function assignedTasks()
|
||||
{
|
||||
return $this->hasMany(Task::class, 'assigned_to');
|
||||
}
|
||||
|
||||
public function completedTasks()
|
||||
{
|
||||
return $this->hasMany(Task::class, 'completed_by');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user