364 lines
9.6 KiB
PHP
364 lines
9.6 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
|
|
class Issue extends Model
|
|
{
|
|
use HasFactory, SoftDeletes;
|
|
|
|
// Status constants
|
|
public const STATUS_NEW = 'new';
|
|
public const STATUS_ASSIGNED = 'assigned';
|
|
public const STATUS_IN_PROGRESS = 'in_progress';
|
|
public const STATUS_REVIEW = 'review';
|
|
public const STATUS_CLOSED = 'closed';
|
|
|
|
// Issue type constants
|
|
public const TYPE_WORK_ITEM = 'work_item';
|
|
public const TYPE_PROJECT_TASK = 'project_task';
|
|
public const TYPE_MAINTENANCE = 'maintenance';
|
|
public const TYPE_MEMBER_REQUEST = 'member_request';
|
|
|
|
// Priority constants
|
|
public const PRIORITY_LOW = 'low';
|
|
public const PRIORITY_MEDIUM = 'medium';
|
|
public const PRIORITY_HIGH = 'high';
|
|
public const PRIORITY_URGENT = 'urgent';
|
|
|
|
protected $fillable = [
|
|
'issue_number',
|
|
'title',
|
|
'description',
|
|
'issue_type',
|
|
'status',
|
|
'priority',
|
|
'created_by_user_id',
|
|
'assigned_to_user_id',
|
|
'reviewer_id',
|
|
'member_id',
|
|
'parent_issue_id',
|
|
'due_date',
|
|
'closed_at',
|
|
'estimated_hours',
|
|
'actual_hours',
|
|
];
|
|
|
|
protected $casts = [
|
|
'due_date' => 'date',
|
|
'closed_at' => 'datetime',
|
|
'estimated_hours' => 'decimal:2',
|
|
'actual_hours' => 'decimal:2',
|
|
];
|
|
|
|
protected static function boot()
|
|
{
|
|
parent::boot();
|
|
|
|
// Auto-generate issue number on create
|
|
static::creating(function ($issue) {
|
|
if (!$issue->issue_number) {
|
|
$year = now()->year;
|
|
$lastIssue = static::whereYear('created_at', $year)
|
|
->orderBy('id', 'desc')
|
|
->first();
|
|
|
|
$nextNumber = $lastIssue ? ((int) substr($lastIssue->issue_number, -3)) + 1 : 1;
|
|
$issue->issue_number = sprintf('ISS-%d-%03d', $year, $nextNumber);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ==================== Relationships ====================
|
|
|
|
public function creator(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class, 'created_by_user_id');
|
|
}
|
|
|
|
public function assignee(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class, 'assigned_to_user_id');
|
|
}
|
|
|
|
public function reviewer(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class, 'reviewer_id');
|
|
}
|
|
|
|
public function member(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Member::class);
|
|
}
|
|
|
|
public function parentIssue(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Issue::class, 'parent_issue_id');
|
|
}
|
|
|
|
public function subTasks(): HasMany
|
|
{
|
|
return $this->hasMany(Issue::class, 'parent_issue_id');
|
|
}
|
|
|
|
public function comments(): HasMany
|
|
{
|
|
return $this->hasMany(IssueComment::class);
|
|
}
|
|
|
|
public function attachments(): HasMany
|
|
{
|
|
return $this->hasMany(IssueAttachment::class);
|
|
}
|
|
|
|
public function labels(): BelongsToMany
|
|
{
|
|
return $this->belongsToMany(IssueLabel::class, 'issue_label_pivot');
|
|
}
|
|
|
|
public function watchers(): BelongsToMany
|
|
{
|
|
return $this->belongsToMany(User::class, 'issue_watchers');
|
|
}
|
|
|
|
public function timeLogs(): HasMany
|
|
{
|
|
return $this->hasMany(IssueTimeLog::class);
|
|
}
|
|
|
|
public function relationships(): HasMany
|
|
{
|
|
return $this->hasMany(IssueRelationship::class);
|
|
}
|
|
|
|
public function relatedIssues()
|
|
{
|
|
return $this->belongsToMany(Issue::class, 'issue_relationships', 'issue_id', 'related_issue_id')
|
|
->withPivot('relationship_type')
|
|
->withTimestamps();
|
|
}
|
|
|
|
public function customFieldValues(): MorphMany
|
|
{
|
|
return $this->morphMany(CustomFieldValue::class, 'customizable');
|
|
}
|
|
|
|
// ==================== Status Helpers ====================
|
|
|
|
public function isNew(): bool
|
|
{
|
|
return $this->status === self::STATUS_NEW;
|
|
}
|
|
|
|
public function isAssigned(): bool
|
|
{
|
|
return $this->status === self::STATUS_ASSIGNED;
|
|
}
|
|
|
|
public function isInProgress(): bool
|
|
{
|
|
return $this->status === self::STATUS_IN_PROGRESS;
|
|
}
|
|
|
|
public function inReview(): bool
|
|
{
|
|
return $this->status === self::STATUS_REVIEW;
|
|
}
|
|
|
|
public function isClosed(): bool
|
|
{
|
|
return $this->status === self::STATUS_CLOSED;
|
|
}
|
|
|
|
public function isOpen(): bool
|
|
{
|
|
return !$this->isClosed();
|
|
}
|
|
|
|
// ==================== Workflow Methods ====================
|
|
|
|
public function canBeAssigned(): bool
|
|
{
|
|
return $this->isNew() || $this->isAssigned();
|
|
}
|
|
|
|
public function canMoveToInProgress(): bool
|
|
{
|
|
return $this->isAssigned() && $this->assigned_to_user_id !== null;
|
|
}
|
|
|
|
public function canMoveToReview(): bool
|
|
{
|
|
return $this->isInProgress();
|
|
}
|
|
|
|
public function canBeClosed(): bool
|
|
{
|
|
return in_array($this->status, [
|
|
self::STATUS_REVIEW,
|
|
self::STATUS_IN_PROGRESS,
|
|
self::STATUS_ASSIGNED,
|
|
]);
|
|
}
|
|
|
|
public function canBeReopened(): bool
|
|
{
|
|
return $this->isClosed();
|
|
}
|
|
|
|
// ==================== Accessors ====================
|
|
|
|
public function getStatusLabelAttribute(): string
|
|
{
|
|
return match($this->status) {
|
|
self::STATUS_NEW => __('New'),
|
|
self::STATUS_ASSIGNED => __('Assigned'),
|
|
self::STATUS_IN_PROGRESS => __('In Progress'),
|
|
self::STATUS_REVIEW => __('Review'),
|
|
self::STATUS_CLOSED => __('Closed'),
|
|
default => $this->status,
|
|
};
|
|
}
|
|
|
|
public function getIssueTypeLabelAttribute(): string
|
|
{
|
|
return match($this->issue_type) {
|
|
self::TYPE_WORK_ITEM => __('Work Item'),
|
|
self::TYPE_PROJECT_TASK => __('Project Task'),
|
|
self::TYPE_MAINTENANCE => __('Maintenance'),
|
|
self::TYPE_MEMBER_REQUEST => __('Member Request'),
|
|
default => $this->issue_type,
|
|
};
|
|
}
|
|
|
|
public function getPriorityLabelAttribute(): string
|
|
{
|
|
return match($this->priority) {
|
|
self::PRIORITY_LOW => __('Low'),
|
|
self::PRIORITY_MEDIUM => __('Medium'),
|
|
self::PRIORITY_HIGH => __('High'),
|
|
self::PRIORITY_URGENT => __('Urgent'),
|
|
default => $this->priority,
|
|
};
|
|
}
|
|
|
|
public function getPriorityBadgeColorAttribute(): string
|
|
{
|
|
return match($this->priority) {
|
|
self::PRIORITY_LOW => 'gray',
|
|
self::PRIORITY_MEDIUM => 'blue',
|
|
self::PRIORITY_HIGH => 'orange',
|
|
self::PRIORITY_URGENT => 'red',
|
|
default => 'gray',
|
|
};
|
|
}
|
|
|
|
public function getStatusBadgeColorAttribute(): string
|
|
{
|
|
return match($this->status) {
|
|
self::STATUS_NEW => 'blue',
|
|
self::STATUS_ASSIGNED => 'purple',
|
|
self::STATUS_IN_PROGRESS => 'yellow',
|
|
self::STATUS_REVIEW => 'orange',
|
|
self::STATUS_CLOSED => 'green',
|
|
default => 'gray',
|
|
};
|
|
}
|
|
|
|
public function getProgressPercentageAttribute(): int
|
|
{
|
|
return match($this->status) {
|
|
self::STATUS_NEW => 0,
|
|
self::STATUS_ASSIGNED => 20,
|
|
self::STATUS_IN_PROGRESS => 50,
|
|
self::STATUS_REVIEW => 80,
|
|
self::STATUS_CLOSED => 100,
|
|
default => 0,
|
|
};
|
|
}
|
|
|
|
public function getIsOverdueAttribute(): bool
|
|
{
|
|
return $this->due_date &&
|
|
$this->due_date->isPast() &&
|
|
!$this->isClosed();
|
|
}
|
|
|
|
public function getDaysUntilDueAttribute(): ?int
|
|
{
|
|
if (!$this->due_date) {
|
|
return null;
|
|
}
|
|
|
|
return now()->startOfDay()->diffInDays($this->due_date->startOfDay(), false);
|
|
}
|
|
|
|
public function getTotalTimeLoggedAttribute(): float
|
|
{
|
|
return (float) $this->timeLogs()->sum('hours');
|
|
}
|
|
|
|
// ==================== Scopes ====================
|
|
|
|
public function scopeOpen($query)
|
|
{
|
|
return $query->where('status', '!=', self::STATUS_CLOSED);
|
|
}
|
|
|
|
public function scopeClosed($query)
|
|
{
|
|
return $query->where('status', self::STATUS_CLOSED);
|
|
}
|
|
|
|
public function scopeByType($query, string $type)
|
|
{
|
|
return $query->where('issue_type', $type);
|
|
}
|
|
|
|
public function scopeByPriority($query, string $priority)
|
|
{
|
|
return $query->where('priority', $priority);
|
|
}
|
|
|
|
public function scopeByStatus($query, string $status)
|
|
{
|
|
return $query->where('status', $status);
|
|
}
|
|
|
|
public function scopeOverdue($query)
|
|
{
|
|
return $query->where('due_date', '<', now())
|
|
->where('status', '!=', self::STATUS_CLOSED);
|
|
}
|
|
|
|
public function scopeAssignedTo($query, int $userId)
|
|
{
|
|
return $query->where('assigned_to_user_id', $userId);
|
|
}
|
|
|
|
public function scopeCreatedBy($query, int $userId)
|
|
{
|
|
return $query->where('created_by_user_id', $userId);
|
|
}
|
|
|
|
public function scopeDueWithin($query, int $days)
|
|
{
|
|
return $query->whereBetween('due_date', [now(), now()->addDays($days)])
|
|
->where('status', '!=', self::STATUS_CLOSED);
|
|
}
|
|
|
|
public function scopeWithLabel($query, int $labelId)
|
|
{
|
|
return $query->whereHas('labels', function ($q) use ($labelId) {
|
|
$q->where('issue_labels.id', $labelId);
|
|
});
|
|
}
|
|
}
|