This commit is contained in:
2026-07-21 23:39:19 +02:00
commit b33b217bdb
217 changed files with 32076 additions and 0 deletions

337
src/app/Models/Ticket.php Normal file
View File

@@ -0,0 +1,337 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
#[Fillable([
'number', 'customer_id', 'email', 'name', 'subcategory_id', 'subject', 'body',
'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id',
'sla_notified_at', 'time_spent_seconds', 'timer_started_at', 'created_at', 'updated_at',
])]
class Ticket extends Model
{
protected function casts(): array
{
return [
'custom_fields' => 'array',
'sla_notified_at' => 'datetime',
'time_spent_seconds' => 'integer',
'timer_started_at' => 'datetime',
];
}
public function customer(): BelongsTo
{
return $this->belongsTo(User::class, 'customer_id');
}
public function assignee(): BelongsTo
{
return $this->belongsTo(User::class, 'assignee_id');
}
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
public function apiClient(): BelongsTo
{
return $this->belongsTo(ApiClient::class);
}
public function subcategory(): BelongsTo
{
return $this->belongsTo(Subcategory::class);
}
public function status(): BelongsTo
{
return $this->belongsTo(Status::class, 'status_key');
}
public function priority(): BelongsTo
{
return $this->belongsTo(Priority::class, 'priority_key');
}
public function messages(): HasMany
{
return $this->hasMany(TicketMessage::class)->orderBy('created_at');
}
public function publicMessages(): HasMany
{
return $this->messages()->where('internal', false);
}
public function internalMessages(): HasMany
{
return $this->messages()->where('internal', true);
}
public function attachments(): HasMany
{
return $this->hasMany(TicketAttachment::class);
}
public function histories(): HasMany
{
return $this->hasMany(TicketHistory::class)->orderByDesc('created_at');
}
public static function nextNumber(): string
{
$max = static::query()->pluck('number')->map(fn ($n) => (int) $n)->max();
return (string) (($max ?: 1000) + 1);
}
public function categoryLabel(): string
{
return $this->subcategory?->label() ?? '';
}
/**
* Non-admin operators only see tickets belonging to one of their own
* teams (or not yet routed to any team), plus anything assigned to them
* personally regardless of team. Admins see everything.
*/
public function scopeVisibleToOperator(Builder $query, User $user): Builder
{
if ($user->isAdmin()) {
return $query;
}
$teamIds = $user->teams->pluck('id')->all();
return $query->where(function ($q) use ($teamIds, $user) {
$q->whereNull('team_id')
->orWhereIn('team_id', $teamIds)
->orWhere('assignee_id', $user->id);
});
}
public function isVisibleToOperator(User $user): bool
{
if ($user->isAdmin()) {
return true;
}
return $this->team_id === null
|| $user->teams->pluck('id')->contains($this->team_id)
|| $this->assignee_id === $user->id;
}
public function addHistory(string $text): TicketHistory
{
return $this->histories()->create(['text' => $text, 'created_at' => now()]);
}
public function statusLabel(): string
{
return Status::labelFor($this->status_key);
}
public function priorityLabel(): string
{
return Priority::labelFor($this->priority_key);
}
public function statusStyle(): string
{
return static::tagStyleFromColor(Status::colorFor($this->status_key));
}
public function priorityStyle(): string
{
return static::tagStyleFromColor(Priority::colorFor($this->priority_key));
}
public static function tagStyleFromColor(string $hex): string
{
return 'display:inline-flex;align-items:center;font-size:11px;letter-spacing:0.02em;padding:3px 10px;border-radius:6px;'.
"background:color-mix(in srgb, {$hex} 20%, transparent);color:{$hex};".
"border:1px solid color-mix(in srgb, {$hex} 45%, transparent)";
}
public function isClosed(): bool
{
return Status::stageFor($this->status_key) === 'closed';
}
/**
* A resolution time of 0 minutes means "no SLA" for that priority, not
* "due instantly" such tickets never count down and never breach.
*/
protected function resolutionDeadline(): ?Carbon
{
$rule = $this->priority?->slaRule;
return $rule && $rule->resolution_mins > 0
? $this->created_at->clone()->addMinutes($rule->resolution_mins)
: null;
}
/**
* Whether the SLA resolution deadline has already passed used by the
* scheduled SLA-breach check, kept separate from slaInfo() so it doesn't
* depend on parsing that method's display text.
*/
public function isOverdue(): bool
{
if ($this->isClosed()) {
return false;
}
$deadline = $this->resolutionDeadline();
return $deadline !== null && now()->isAfter($deadline);
}
/**
* Mirrors the design prototype's slaInfo(): overdue / remaining-hours / closed.
*/
public function slaInfo(): array
{
if ($this->isClosed()) {
return ['text' => 'Zamknięte', 'short' => '—', 'cls' => 'tag tag-neutral'];
}
$rule = $this->priority?->slaRule;
$deadline = $this->resolutionDeadline();
if (! $rule || $deadline === null) {
return ['text' => 'Brak SLA', 'short' => 'Brak SLA', 'cls' => 'tag tag-neutral'];
}
$diffMinutes = now()->diffInMinutes($deadline, false);
if ($diffMinutes < 0) {
return ['text' => 'Przekroczono SLA', 'short' => 'Przekroczono', 'cls' => 'tag tag-accent'];
}
$hrs = (int) round($diffMinutes / 60);
$warn = $diffMinutes < ($rule->resolution_mins * 0.25);
return [
'text' => "Pozostało {$hrs} godz.",
'short' => "{$hrs} godz.",
'cls' => $warn ? 'tag tag-outline' : 'tag tag-neutral',
];
}
/**
* Total seconds an operator has spent with this ticket's detail view
* open, including the currently running segment (if the timer is active).
*/
public function timerElapsedSeconds(): int
{
$running = $this->timer_started_at ? $this->secondsSinceTimerStarted() : 0;
return $this->time_spent_seconds + $running;
}
/**
* Whole seconds between timer_started_at and now, via raw timestamp
* subtraction rather than diffInSeconds() Carbon 3 made the sign and
* float-vs-int return type of diffInSeconds() depend on argument order,
* which is easy to get backwards.
*/
protected function secondsSinceTimerStarted(): int
{
return max(0, now()->getTimestamp() - $this->timer_started_at->getTimestamp());
}
public function timeSpentLabel(): string
{
$seconds = $this->timerElapsedSeconds();
$hrs = intdiv($seconds, 3600);
$mins = intdiv($seconds % 3600, 60);
return $hrs > 0 ? "{$hrs} godz. {$mins} min." : "{$mins} min.";
}
/**
* Checkpoints the running segment into the stored total without pausing
* called on every operator action so progress survives even if the
* ticket is never explicitly stopped.
*/
public function flushTimer(): void
{
if ($this->timer_started_at) {
$this->update([
'time_spent_seconds' => $this->time_spent_seconds + $this->secondsSinceTimerStarted(),
'timer_started_at' => now(),
]);
}
}
public function stopTimer(): void
{
if ($this->timer_started_at) {
$this->update([
'time_spent_seconds' => $this->time_spent_seconds + $this->secondsSinceTimerStarted(),
'timer_started_at' => null,
]);
}
}
public function resumeTimer(): void
{
if (! $this->timer_started_at) {
$this->update(['timer_started_at' => now()]);
}
}
public function resetTimer(): void
{
$this->update(['time_spent_seconds' => 0, 'timer_started_at' => null]);
}
/**
* Manually overrides the tracked total (an operator correcting the
* timer). If it's currently running, the running segment is re-based to
* start now, so the edited value takes effect immediately instead of
* being added to on top of the segment already in progress.
*/
public function setTimeSpent(int $seconds): void
{
$this->update([
'time_spent_seconds' => max(0, $seconds),
'timer_started_at' => $this->timer_started_at ? now() : null,
]);
}
/**
* Custom field values for this ticket's subcategory, in display order, skipping blanks.
*
* @return array<int, array{id: int, label: string, value: string}>
*/
public function customFieldEntries(): array
{
if (! $this->subcategory_id) {
return [];
}
$values = $this->custom_fields ?? [];
return $this->subcategory->customFields
->filter(fn (CustomField $field) => array_key_exists($field->id, $values) && $values[$field->id] !== '' && $values[$field->id] !== null)
->map(fn (CustomField $field) => [
'id' => $field->id,
'label' => $field->label,
'value' => $field->type === 'checkbox'
? ($values[$field->id] ? 'Tak' : 'Nie')
: (string) $values[$field->id],
])
->values()
->all();
}
}