'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 => 'gray', default => 'gray', }; } public function getProgressPercentageAttribute(): int { return match($this->status) { self::STATUS_NEW => 0, self::STATUS_ASSIGNED => 25, self::STATUS_IN_PROGRESS => 50, self::STATUS_REVIEW => 75, 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); }); } }