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

View File

@@ -0,0 +1,401 @@
<div style="flex:1;display:flex;flex-direction:column">
<x-topbar />
<div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto">
<div style="display:flex;flex-direction:column;gap:16px;max-width:1020px;margin:0 auto">
<a href="{{ route('operator.queue') }}" wire:navigate class="btn btn-ghost" style="align-self:flex-start;padding:0">&larr; Wróć do listy</a>
<div style="display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap">
<div class="main-col" style="display:flex;flex-direction:column;gap:16px">
<div class="card" style="padding:20px;gap:8px">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
<div class="card-kicker">Zgłoszenie #{{ $ticket->number }}</div>
@unless ($editingDetails)
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="toggleEditDetails">Edytuj</button>
@endunless
</div>
@unless ($editingDetails)
<h3 style="margin:2px 0 0">{{ $ticket->subject }}</h3>
<div class="card-meta">{{ $ticket->categoryLabel() }}</div>
<div style="white-space:pre-wrap;font-size:14px;margin-top:4px">{{ $ticket->body }}</div>
@foreach ($this->publicMessages->first()?->attachments ?? [] as $a)
<x-message-attachment :attachment="$a" />
@endforeach
@if (count($ticket->customFieldEntries()))
<div style="display:flex;flex-direction:column;gap:2px;margin-top:2px">
@foreach ($ticket->customFieldEntries() as $cf)
<div style="font-size:14px"><strong>{{ $cf['label'] }}:</strong> {{ $cf['value'] }}</div>
@endforeach
</div>
@endif
@else
<div class="field"><label>Temat</label><input class="input" wire:model="editDetailsForm.subject"></div>
<div class="field"><label>Treść</label><textarea class="input" wire:model="editDetailsForm.body"></textarea></div>
<div style="display:flex;gap:10px">
<div class="field" style="flex:1">
<label>Kategoria</label>
<select class="input" wire:model.live="editDetailsForm.categoryId">
<option value="">Wybierz</option>
@foreach ($this->categories as $c)
<option value="{{ $c->id }}">{{ $c->name }}</option>
@endforeach
</select>
</div>
<div class="field" style="flex:1">
<label>Podkategoria</label>
<select class="input" wire:model.live="editDetailsForm.subcategoryId" @disabled(! $editDetailsForm['categoryId'])>
<option value="">Wybierz</option>
@foreach ($this->editDetailsSubcategoryOptions as $s)
<option value="{{ $s->id }}">{{ $s->name }}</option>
@endforeach
</select>
</div>
</div>
@foreach ($this->editDetailsSelectedSubcategory?->customFields ?? [] as $field)
<x-custom-field-input :field="$field" wire-model="editDetailsForm.customValues" />
@endforeach
<div style="display:flex;gap:8px;margin-top:6px">
<button type="button" class="btn btn-secondary" wire:click="cancelEditDetails">Anuluj</button>
<button type="button" class="btn btn-primary" wire:click="saveDetails">Zapisz</button>
</div>
@endunless
</div>
<div class="card" style="padding:16px;gap:10px">
<div class="card-kicker">Notatki wewnętrzne</div>
@forelse ($this->internalMessages as $m)
<div style="padding:10px 12px;border-left:3px solid var(--color-accent);background:var(--color-surface);border-radius:4px">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
<div style="font-size:11px;opacity:0.65;margin-bottom:4px">{{ $m->author_name }} &middot; {{ \App\Support\Rel::format($m->created_at) }}</div>
@if ($m->role === 'operator')
<div style="display:flex;gap:6px;flex:none">
<span class="material-symbols-outlined" style="font-size:16px;cursor:pointer;opacity:0.7" wire:click="startEditNote({{ $m->id }}, @js($m->body))">edit</span>
<span class="material-symbols-outlined" style="font-size:16px;cursor:pointer;opacity:0.7" wire:click="requestDeleteMessage({{ $m->id }})">delete</span>
</div>
@endif
</div>
@if ($editingNoteId === $m->id)
<textarea class="input" wire:model="editingNoteDraft"></textarea>
<div style="display:flex;gap:8px;margin-top:6px">
<button type="button" class="btn btn-secondary" wire:click="cancelEditNote">Anuluj</button>
<button type="button" class="btn btn-primary" wire:click="saveEditNote">Zapisz</button>
</div>
@else
<div style="white-space:pre-wrap">{{ $m->body }}</div>
@endif
@foreach ($m->attachments as $a)
<x-message-attachment :attachment="$a" />
@endforeach
</div>
@empty
<p class="text-muted" style="font-size:13px;margin:0">Brak notatek wewnętrznych.</p>
@endforelse
@if ($addingNote)
<textarea class="input" placeholder="Dodaj notatkę widoczną tylko dla zespołu…" wire:model="noteDraft"></textarea>
<div style="display:flex;align-items:center;gap:10px">
<div style="flex:1;min-width:0;border:1px dashed var(--color-divider);border-radius:8px;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px">
<label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="noteAttachments"></label>
@forelse ($noteAttachments as $i => $file)
<span class="text-muted" style="font-size:13px;display:flex;align-items:center;gap:6px;min-width:0">
<span class="material-symbols-outlined" style="font-size:16px;flex:none">attach_file</span>
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $file->getClientOriginalName() }}</span>
<span class="material-symbols-outlined" style="font-size:16px;cursor:pointer;opacity:0.7;flex:none" wire:click="removeNoteAttachment({{ $i }})">close</span>
</span>
@empty
<span class="text-muted" style="font-size:13px">Brak załączników</span>
@endforelse
</div>
</div>
@error('noteAttachments') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
<div style="display:flex;gap:8px">
<button type="button" class="btn btn-secondary" wire:click="cancelAddNote">Anuluj</button>
<button type="button" class="btn btn-primary" wire:click="addNote">Dodaj notatkę</button>
</div>
@else
<button type="button" class="btn btn-secondary" wire:click="startAddNote">Dodaj notatkę</button>
@endif
</div>
@php $threadMessages = $this->publicMessages->skip(1); @endphp
<div style="display:flex;flex-direction:column;gap:10px">
@foreach ($threadMessages as $m)
@php $mine = $m->role === 'operator'; @endphp
<div style="display:flex;justify-content:{{ $mine ? 'flex-end' : 'flex-start' }}">
<div style="max-width:75%;padding:10px 14px;border-radius:12px;font-size:14px;background:{{ $mine ? 'var(--color-accent-800)' : 'var(--color-surface)' }};color:{{ $mine ? 'var(--color-accent-100)' : 'var(--color-text)' }};border:1px solid var(--color-divider)">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px">
<div style="font-size:11px;opacity:0.65;margin-bottom:4px">{{ $m->author_name }} &middot; {{ \App\Support\Rel::format($m->created_at) }}{{ $m->edited ? ' · edytowano' : '' }}</div>
@if ($m->role === 'operator')
<div style="display:flex;gap:6px;flex:none">
<span class="material-symbols-outlined" style="font-size:15px;cursor:pointer;opacity:0.7" wire:click="startEditMessage({{ $m->id }}, @js($m->body))">edit</span>
<span class="material-symbols-outlined" style="font-size:15px;cursor:pointer;opacity:0.7" wire:click="requestDeleteMessage({{ $m->id }})">delete</span>
</div>
@endif
</div>
@if ($editingMessageId === $m->id)
<textarea class="input" wire:model="editingMessageDraft"></textarea>
<div style="display:flex;gap:8px;margin-top:6px">
<button type="button" class="btn btn-secondary" wire:click="cancelEditMessage">Anuluj</button>
<button type="button" class="btn btn-primary" wire:click="saveEditMessage">Zapisz</button>
</div>
@else
<div style="white-space:pre-wrap">{{ $m->body }}</div>
@endif
@foreach ($m->attachments as $a)
<x-message-attachment :attachment="$a" />
@endforeach
</div>
</div>
@endforeach
</div>
<form wire:submit="sendReply" class="card" style="padding:16px;gap:10px">
<select class="input" wire:change="setTemplate($event.target.value)">
<option value="">Wstaw szablon odpowiedzi…</option>
@foreach ($this->responseTemplates as $rt)
<option value="{{ $rt->id }}" @selected($templateId === (string) $rt->id)>{{ $rt->label }}</option>
@endforeach
</select>
<textarea class="input" placeholder="Napisz odpowiedź do klienta…" wire:model="reply"></textarea>
<div style="display:flex;align-items:center;gap:10px">
<div style="flex:1;min-width:0;border:1px dashed var(--color-divider);border-radius:8px;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px">
<label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="replyAttachments"></label>
@forelse ($replyAttachments as $i => $file)
<span class="text-muted" style="font-size:13px;display:flex;align-items:center;gap:6px;min-width:0">
<span class="material-symbols-outlined" style="font-size:16px;flex:none">attach_file</span>
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $file->getClientOriginalName() }}</span>
<span class="material-symbols-outlined" style="font-size:16px;cursor:pointer;opacity:0.7;flex:none" wire:click="removeReplyAttachment({{ $i }})">close</span>
</span>
@empty
<span class="text-muted" style="font-size:13px">Brak załączników</span>
@endforelse
</div>
<div style="display:flex;position:relative;flex:none" x-data="{ open: false }" @click.outside="open = false">
<button class="btn btn-primary" style="border-top-right-radius:0;border-bottom-right-radius:0" type="submit">Wyślij odpowiedź</button>
<button class="btn btn-primary" type="button" style="border-top-left-radius:0;border-bottom-left-radius:0;border-left:1px solid var(--color-bg);padding:0 6px" @click="open = !open">
<span class="material-symbols-outlined" style="font-size:16px">expand_more</span>
</button>
<div x-show="open" x-cloak style="position:absolute;bottom:100%;right:0;margin-bottom:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);min-width:220px;overflow:hidden;z-index:10">
@forelse ($this->replyQuickActions as $qa)
<button type="button" class="theme-toggle-option" @click="open = false" wire:click="sendAndTransition({{ $qa->id }})">{{ $qa->label }}</button>
@empty
<div class="text-muted" style="font-size:12.5px;padding:8px 12px">Brak skonfigurowanych szybkich akcji.</div>
@endforelse
</div>
</div>
</div>
@error('replyAttachments') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
</form>
</div>
<div class="aside-col" style="display:flex;flex-direction:column;gap:14px">
<div class="card" style="padding:16px;gap:6px">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
<div class="card-kicker">Zgłaszający</div>
<span class="material-symbols-outlined" style="font-size:16px;cursor:pointer;opacity:0.7" wire:click="startEditReporter">edit</span>
</div>
<div style="font-weight:500">{{ $ticket->name }}</div>
<div class="text-muted" style="font-size:13px">{{ $ticket->email }}</div>
@if ($ticket->customer_id)
<a href="{{ route('operator.queue', ['filterCustomerId' => $ticket->customer_id]) }}" wire:navigate class="text-muted" style="font-size:12px;color:var(--color-accent);text-decoration:none">Zobacz zgłoszenia tej osoby &rarr;</a>
@php $reporterFields = $ticket->customer?->customFieldEntries() ?? []; @endphp
@if (count($reporterFields))
<div style="display:flex;flex-direction:column;gap:2px;margin-top:6px;padding-top:8px;border-top:1px solid var(--color-divider)">
@foreach ($reporterFields as $cf)
<div class="card-meta"><strong>{{ $cf['label'] }}:</strong> {{ $cf['value'] }}</div>
@endforeach
</div>
@endif
@endif
</div>
@if ($editingReporter)
<div class="card" style="padding:16px;gap:10px">
<div class="field">
<label>Zmień zgłaszającego</label>
<select class="input" wire:model="editReporterCustomerId">
<option value="">Wybierz klienta</option>
@foreach ($this->clients as $cl)
<option value="{{ $cl->id }}">{{ $cl->name }} {{ $cl->email }}</option>
@endforeach
</select>
</div>
<div style="display:flex;gap:8px">
<button type="button" class="btn btn-secondary" wire:click="cancelEditReporter">Anuluj</button>
<button type="button" class="btn btn-primary" wire:click="saveReporter">Zapisz</button>
</div>
</div>
@endif
<div class="card" style="padding:16px;gap:10px">
<div class="card-kicker">Status i przypisanie</div>
<div class="field">
<label>Status</label>
<select class="input" wire:change="setStatus($event.target.value)">
@foreach ($this->statuses as $s)
<option value="{{ $s->key }}" @selected($ticket->status_key === $s->key)>{{ $s->label }}</option>
@endforeach
</select>
</div>
<div class="field">
<label>Priorytet</label>
<select class="input" wire:change="setPriority($event.target.value)">
@foreach ($this->priorities as $p)
<option value="{{ $p->key }}" @selected($ticket->priority_key === $p->key)>{{ $p->label }}</option>
@endforeach
</select>
</div>
<div class="field">
<label>Zespół</label>
<select class="input" wire:change="setTeam($event.target.value)">
<option value="">Brak</option>
@foreach ($this->teams as $tm)
<option value="{{ $tm->id }}" @selected($ticket->team_id === $tm->id)>{{ $tm->name }}</option>
@endforeach
</select>
</div>
<div class="field">
<label>Przypisany do</label>
<div style="display:flex;gap:6px">
<select class="input" wire:change="setAssignee($event.target.value)" style="flex:1">
<option value="">Nieprzypisane</option>
@foreach ($this->operators as $o)
<option value="{{ $o->id }}" @selected($ticket->assignee_id === $o->id)>{{ $o->name }}</option>
@endforeach
</select>
@if ($ticket->assignee_id !== auth()->id())
<button type="button" class="btn btn-secondary" style="flex:none" wire:click="assignToMe">Przypisz do mnie</button>
@endif
</div>
</div>
</div>
<div class="card" style="padding:16px;gap:8px">
<div class="card-kicker">SLA</div>
<div style="font-size:12.5px">{{ $ticket->slaInfo()['text'] }}</div>
</div>
<div class="card" style="padding:16px;gap:8px">
<div class="card-kicker">MONITOR CZASU PRACY</div>
<div
style="display:flex;flex-direction:column;gap:6px"
x-data="{
seconds: {{ $ticket->timerElapsedSeconds() }},
running: @js((bool) $ticket->timer_started_at),
tick: null,
navigatingHandler: null,
pagehideHandler: null,
format() {
const h = Math.floor(this.seconds / 3600);
const m = Math.floor((this.seconds % 3600) / 60);
const s = this.seconds % 60;
return [h, m, s].map(n => String(n).padStart(2, '0')).join(':');
},
start() {
clearInterval(this.tick);
if (this.running) {
this.tick = setInterval(() => { this.seconds++ }, 1000);
}
},
init() {
this.start();
// The timer only tracks time this ticket is actually open in
// the browser: stop it server-side the moment the operator
// navigates elsewhere in the app (wire:navigate — a normal
// Livewire call, since the document itself isn't unloading)…
this.navigatingHandler = () => {
if (this.running) $wire.call('stopTimer');
};
document.addEventListener('livewire:navigating', this.navigatingHandler);
// …or closes/reloads the tab. Regular fetch/Livewire calls
// can get cut off mid-flight during an actual page unload, so
// this uses sendBeacon against a plain endpoint instead, which
// browsers guarantee to deliver.
this.pagehideHandler = () => {
if (this.running) {
navigator.sendBeacon(@js(route('operator.ticket.stop-timer', $ticket)));
}
};
window.addEventListener('pagehide', this.pagehideHandler);
},
destroy() {
clearInterval(this.tick);
document.removeEventListener('livewire:navigating', this.navigatingHandler);
window.removeEventListener('pagehide', this.pagehideHandler);
},
}"
>
@if ($editingTimer)
<div style="display:flex;align-items:center;gap:4px;font-size:12px">
<input class="input" type="number" min="0" style="width:60px" wire:model="editTimerHours"> godz.
<input class="input" type="number" min="0" max="59" style="width:52px" wire:model="editTimerMinutes"> min.
<input class="input" type="number" min="0" max="59" style="width:52px" wire:model="editTimerSeconds"> sek.
</div>
<div style="display:flex;gap:6px">
<button type="button" class="btn btn-secondary" wire:click="cancelEditTimer">Anuluj</button>
<button type="button" class="btn btn-primary" wire:click="saveEditTimer">Zapisz</button>
</div>
@else
<div style="font-size:12px;display:flex;align-items:center;gap:6px">
<span>Czas w zgłoszeniu: <strong x-text="format()"></strong></span>
<span class="material-symbols-outlined" style="font-size:16px;cursor:pointer;opacity:0.7" wire:click="startEditTimer">edit</span>
</div>
<div style="display:flex;gap:6px">
@if ($ticket->timer_started_at)
<button type="button" class="btn btn-secondary" wire:click="stopTimer" @click="running = false; clearInterval(tick)">Zatrzymaj</button>
@else
<button type="button" class="btn btn-secondary" wire:click="resumeTimer" @click="running = true; start()">Wznów</button>
@endif
<button type="button" class="btn btn-secondary" wire:click="resetTimer" @click="seconds = 0; running = false; clearInterval(tick)">Resetuj</button>
</div>
@endif
</div>
</div>
<div class="card" style="padding:16px;gap:8px">
<div class="card-kicker">Historia zmian</div>
@forelse ($ticket->histories as $h)
<div style="font-size:12.5px"><span>{{ $h->text }}</span><div class="text-muted" style="font-size:11px">{{ \App\Support\Rel::format($h->created_at) }}</div></div>
@empty
<p class="text-muted" style="font-size:12px;margin:0">Brak historii zmian.</p>
@endforelse
</div>
<div class="card" style="padding:16px;gap:8px">
<button type="button" class="btn btn-secondary btn-block" wire:click="requestDeleteTicket">Usuń zgłoszenie</button>
</div>
</div>
</div>
</div>
</div>
@if ($pendingDeleteMessageId)
<div class="dialog-backdrop">
<div class="dialog" style="max-width:400px">
<div class="dialog-title">Potwierdź usunięcie</div>
<div class="dialog-body">Czy na pewno usunąć wiadomość?</div>
<div class="dialog-actions">
<button type="button" class="btn btn-secondary" wire:click="cancelDeleteMessage">Anuluj</button>
<button type="button" class="btn btn-primary" wire:click="confirmDeleteMessage">Usuń</button>
</div>
</div>
</div>
@endif
@if ($pendingDeleteTicket)
<div class="dialog-backdrop">
<div class="dialog" style="max-width:400px">
<div class="dialog-title">Potwierdź usunięcie</div>
<div class="dialog-body">Czy na pewno usunąć zgłoszenie #{{ $ticket->number }}?</div>
<div class="dialog-actions">
<button type="button" class="btn btn-secondary" wire:click="cancelDeleteTicket">Anuluj</button>
<button type="button" class="btn btn-primary" wire:click="confirmDeleteTicket">Usuń</button>
</div>
</div>
</div>
@endif
</div>