new helpdesk module
This commit is contained in:
@@ -0,0 +1 @@
|
||||
.helpdesk-module{}
|
||||
@@ -1 +1,3 @@
|
||||
window.Helpdesk = window.Helpdesk || {};
|
||||
import './bootstrap';
|
||||
|
||||
console.info('Helpdesk module assets loaded');
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import axios from 'axios';
|
||||
|
||||
window.axios = window.axios || axios;
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
@@ -0,0 +1 @@
|
||||
<livewire:helpdesk.dashboard-widget />
|
||||
@@ -1,9 +1,3 @@
|
||||
@extends('helpdesk::layouts.master')
|
||||
|
||||
@section('content')
|
||||
<h1>Hello World</h1>
|
||||
|
||||
<p>
|
||||
This view is loaded from module: {!! config('helpdesk.name') !!}
|
||||
</p>
|
||||
@endsection
|
||||
<x-layouts.app :title="__('Helpdesk')">
|
||||
<livewire:helpdesk.ticket-index />
|
||||
</x-layouts.app>
|
||||
|
||||
@@ -1,17 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Module Helpdesk</title>
|
||||
|
||||
@vite([
|
||||
'Resources/assets/sass/app.scss',
|
||||
'Resources/assets/js/app.js',
|
||||
], 'build/helpdesk')
|
||||
</head>
|
||||
<body>
|
||||
@yield('content')
|
||||
</body>
|
||||
</html>
|
||||
<x-layouts.app :title="$title ?? __('Helpdesk')">
|
||||
{{ $slot ?? '' }}
|
||||
@yield('content')
|
||||
</x-layouts.app>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900" dusk="helpdesk-dashboard-widget">
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white">Helpdesk</h2>
|
||||
<p class="text-sm text-zinc-500 dark:text-zinc-400">Your current support workload.</p>
|
||||
</div>
|
||||
<flux:button :href="route('helpdesk.tickets.index')" wire:navigate size="sm" variant="ghost">View all</flux:button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<div class="rounded-xl bg-blue-50 p-4 dark:bg-blue-950/40">
|
||||
<div class="text-2xl font-semibold text-blue-700 dark:text-blue-200">{{ $openCount }}</div>
|
||||
<div class="text-xs font-medium uppercase tracking-wide text-blue-700/70 dark:text-blue-200/70">Open</div>
|
||||
</div>
|
||||
<div class="rounded-xl bg-red-50 p-4 dark:bg-red-950/40">
|
||||
<div class="text-2xl font-semibold text-red-700 dark:text-red-200">{{ $criticalCount }}</div>
|
||||
<div class="text-xs font-medium uppercase tracking-wide text-red-700/70 dark:text-red-200/70">Critical</div>
|
||||
</div>
|
||||
<div class="rounded-xl bg-green-50 p-4 dark:bg-green-950/40">
|
||||
<div class="text-2xl font-semibold text-green-700 dark:text-green-200">{{ $closedCount }}</div>
|
||||
<div class="text-xs font-medium uppercase tracking-wide text-green-700/70 dark:text-green-200/70">Closed</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 space-y-2">
|
||||
@forelse ($recentTickets as $ticket)
|
||||
<a href="{{ route('helpdesk.tickets.show', $ticket) }}" wire:navigate class="block rounded-xl border border-zinc-100 p-3 hover:bg-zinc-50 dark:border-zinc-800 dark:hover:bg-zinc-800">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="truncate text-sm font-medium text-zinc-900 dark:text-zinc-100">{{ $ticket->subject }}</span>
|
||||
<flux:badge color="{{ $ticket->priority->color }}" size="sm">{{ $ticket->priority->name }}</flux:badge>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-zinc-500">{{ $ticket->reference }} · {{ $ticket->status->name }}</div>
|
||||
</a>
|
||||
@empty
|
||||
<div class="rounded-xl border border-dashed border-zinc-300 p-4 text-center text-sm text-zinc-500 dark:border-zinc-700">No helpdesk tickets yet.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,55 @@
|
||||
<div class="mx-auto max-w-5xl space-y-6" dusk="helpdesk-ticket-create">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-white">Create ticket</h1>
|
||||
<p class="text-sm text-zinc-500 dark:text-zinc-400">Raise a support request with optional attachments.</p>
|
||||
</div>
|
||||
|
||||
<form wire:submit="save" class="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div class="grid gap-6 md:grid-cols-3">
|
||||
<div class="space-y-6 md:col-span-2">
|
||||
<flux:input wire:model="subject" label="Subject" placeholder="Short summary" required />
|
||||
<flux:textarea wire:model="body" label="Details" rows="10" placeholder="What happened? What did you expect? Include steps to reproduce where useful." required />
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<flux:select wire:model="priority_id" label="Priority" required>
|
||||
<option value="">Select priority</option>
|
||||
@foreach ($priorities as $priority)
|
||||
<option value="{{ $priority->id }}">{{ $priority->name }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
|
||||
<flux:select wire:model="category_id" label="Category">
|
||||
<option value="">Select category</option>
|
||||
@foreach ($categories as $category)
|
||||
<option value="{{ $category->id }}">{{ $category->name }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
|
||||
<flux:select wire:model="project_id" label="Project">
|
||||
<option value="">No project</option>
|
||||
@foreach ($projects as $project)
|
||||
<option value="{{ $project->id }}">{{ $project->title }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
|
||||
<flux:input wire:model="due_at" type="date" label="Due date" />
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-zinc-700 dark:text-zinc-200">Attachments</label>
|
||||
<input wire:model="attachments" type="file" multiple class="block w-full rounded-lg border border-zinc-300 bg-white p-2 text-sm dark:border-zinc-700 dark:bg-zinc-950" />
|
||||
<div class="mt-2 text-xs text-zinc-500">Allowed: {{ implode(', ', config('helpdesk.allowed_mimes')) }}. Max {{ config('helpdesk.max_upload_kb') }}KB each.</div>
|
||||
@error('attachments.*') <div class="mt-2 text-sm text-red-600">{{ $message }}</div> @enderror
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-end gap-3 border-t border-zinc-200 pt-6 dark:border-zinc-700">
|
||||
<flux:button :href="route('helpdesk.tickets.index')" wire:navigate variant="ghost">Cancel</flux:button>
|
||||
<flux:button type="submit" variant="primary" icon="paper-airplane" wire:loading.attr="disabled">
|
||||
<span wire:loading.remove>Create ticket</span>
|
||||
<span wire:loading>Creating...</span>
|
||||
</flux:button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,90 @@
|
||||
<div class="space-y-6" dusk="helpdesk-ticket-index">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-white">Helpdesk</h1>
|
||||
<p class="text-sm text-zinc-500 dark:text-zinc-400">Search, triage and manage support tickets.</p>
|
||||
</div>
|
||||
|
||||
<flux:button :href="route('helpdesk.tickets.create')" wire:navigate icon="plus" variant="primary">
|
||||
New ticket
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
@if (session('success'))
|
||||
<flux:callout variant="success" icon="check-circle">{{ session('success') }}</flux:callout>
|
||||
@endif
|
||||
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<flux:input wire:model.live.debounce.350ms="search" icon="magnifying-glass" placeholder="Search reference, subject or body" />
|
||||
|
||||
<flux:select wire:model.live="scope">
|
||||
<option value="open">Open tickets</option>
|
||||
<option value="closed">Closed tickets</option>
|
||||
<option value="all">All tickets</option>
|
||||
</flux:select>
|
||||
|
||||
<flux:select wire:model.live="status">
|
||||
<option value="">All statuses</option>
|
||||
@foreach ($statuses as $status)
|
||||
<option value="{{ $status->id }}">{{ $status->name }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
|
||||
<flux:select wire:model.live="priority">
|
||||
<option value="">All priorities</option>
|
||||
@foreach ($priorities as $priority)
|
||||
<option value="{{ $priority->id }}">{{ $priority->name }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div wire:loading.flex class="items-center gap-2 border-b border-zinc-200 bg-blue-50 px-4 py-3 text-sm text-blue-800 dark:border-zinc-700 dark:bg-blue-950 dark:text-blue-200">
|
||||
<flux:icon.arrow-path class="size-4 animate-spin" /> Refreshing tickets...
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700">
|
||||
<thead class="bg-zinc-50 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Reference</th>
|
||||
<th class="px-4 py-3">Subject</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Priority</th>
|
||||
<th class="px-4 py-3">Agent</th>
|
||||
<th class="px-4 py-3">Last activity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
@forelse ($tickets as $ticket)
|
||||
<tr class="hover:bg-zinc-50 dark:hover:bg-zinc-800/70">
|
||||
<td class="whitespace-nowrap px-4 py-3 font-mono text-xs">
|
||||
<a href="{{ route('helpdesk.tickets.show', $ticket) }}" wire:navigate class="font-semibold text-blue-700 hover:underline dark:text-blue-300">
|
||||
{{ $ticket->reference }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-medium text-zinc-900 dark:text-zinc-100">{{ $ticket->subject }}</div>
|
||||
<div class="text-xs text-zinc-500">{{ $ticket->category?->name ?? 'No category' }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3"><flux:badge color="{{ $ticket->status->color }}">{{ $ticket->status->name }}</flux:badge></td>
|
||||
<td class="px-4 py-3"><flux:badge color="{{ $ticket->priority->color }}">{{ $ticket->priority->name }}</flux:badge></td>
|
||||
<td class="px-4 py-3 text-zinc-600 dark:text-zinc-300">{{ $ticket->agent?->name ?? 'Unassigned' }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-zinc-500">{{ $ticket->last_activity_at?->diffForHumans() ?? $ticket->created_at->diffForHumans() }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-12 text-center text-zinc-500">No tickets matched your filters.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-zinc-200 p-4 dark:border-zinc-700">
|
||||
{{ $tickets->links() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,119 @@
|
||||
<div class="space-y-6" dusk="helpdesk-ticket-show">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div class="mb-2 flex flex-wrap items-center gap-2">
|
||||
<span class="font-mono text-xs text-zinc-500">{{ $ticket->reference }}</span>
|
||||
<flux:badge color="{{ $ticket->status->color }}">{{ $ticket->status->name }}</flux:badge>
|
||||
<flux:badge color="{{ $ticket->priority->color }}">{{ $ticket->priority->name }}</flux:badge>
|
||||
</div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-white">{{ $ticket->subject }}</h1>
|
||||
<p class="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Raised by {{ $ticket->owner?->name ?? 'Unknown user' }} · Assigned to {{ $ticket->agent?->name ?? 'Unassigned' }}
|
||||
</p>
|
||||
</div>
|
||||
<flux:button :href="route('helpdesk.tickets.index')" wire:navigate icon="arrow-left" variant="ghost">Back</flux:button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="space-y-6 lg:col-span-2">
|
||||
<article class="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div class="prose max-w-none dark:prose-invert">
|
||||
{!! nl2br(e($ticket->body)) !!}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@if ($ticket->attachments->isNotEmpty())
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">Ticket attachments</h2>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
@foreach ($ticket->attachments as $attachment)
|
||||
<a href="{{ $attachment->url() }}" target="_blank" class="rounded-xl border border-zinc-200 p-3 text-sm hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800">
|
||||
<div class="font-medium text-zinc-900 dark:text-zinc-100">{{ $attachment->original_name }}</div>
|
||||
<div class="text-xs text-zinc-500">{{ Number::fileSize($attachment->size) }}</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white">Conversation</h2>
|
||||
|
||||
@forelse ($ticket->comments as $comment)
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900 {{ $comment->is_internal ? 'ring-2 ring-amber-300' : '' }}">
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-medium text-zinc-900 dark:text-zinc-100">{{ $comment->user?->name ?? 'Unknown user' }}</div>
|
||||
<div class="text-xs text-zinc-500">{{ $comment->created_at->diffForHumans() }}</div>
|
||||
</div>
|
||||
@if ($comment->is_internal)
|
||||
<flux:badge color="amber">Internal note</flux:badge>
|
||||
@endif
|
||||
</div>
|
||||
<div class="prose max-w-none text-sm dark:prose-invert">{!! nl2br(e($comment->body)) !!}</div>
|
||||
|
||||
@if ($comment->attachments->isNotEmpty())
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
@foreach ($comment->attachments as $attachment)
|
||||
<a href="{{ $attachment->url() }}" target="_blank" class="rounded-full bg-zinc-100 px-3 py-1 text-xs text-zinc-700 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-200">
|
||||
{{ $attachment->original_name }}
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<div class="rounded-2xl border border-dashed border-zinc-300 p-8 text-center text-zinc-500 dark:border-zinc-700">No comments yet.</div>
|
||||
@endforelse
|
||||
</section>
|
||||
|
||||
<form wire:submit="addComment" class="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">Add reply</h2>
|
||||
<flux:textarea wire:model="commentBody" label="Comment" rows="5" required />
|
||||
|
||||
@can('update', $ticket)
|
||||
<div class="mt-4">
|
||||
<flux:checkbox wire:model="internal" label="Internal note only" />
|
||||
</div>
|
||||
@endcan
|
||||
|
||||
<div class="mt-4">
|
||||
<label class="mb-2 block text-sm font-medium text-zinc-700 dark:text-zinc-200">Attach files</label>
|
||||
<input wire:model="attachments" type="file" multiple class="block w-full rounded-lg border border-zinc-300 bg-white p-2 text-sm dark:border-zinc-700 dark:bg-zinc-950" />
|
||||
@error('attachments.*') <div class="mt-2 text-sm text-red-600">{{ $message }}</div> @enderror
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex justify-end">
|
||||
<flux:button type="submit" variant="primary" icon="chat-bubble-left-right" wire:loading.attr="disabled">
|
||||
<span wire:loading.remove>Post reply</span>
|
||||
<span wire:loading>Posting...</span>
|
||||
</flux:button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<aside class="space-y-6">
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">Ticket details</h2>
|
||||
<dl class="space-y-3 text-sm">
|
||||
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Category</dt><dd>{{ $ticket->category?->name ?? 'None' }}</dd></div>
|
||||
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Project</dt><dd>{{ $ticket->project?->title ?? 'None' }}</dd></div>
|
||||
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Created</dt><dd>{{ $ticket->created_at->format('d M Y H:i') }}</dd></div>
|
||||
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Due</dt><dd>{{ $ticket->due_at?->format('d M Y') ?? 'No due date' }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
@can('update', $ticket)
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">Agent actions</h2>
|
||||
<flux:select wire:model="status_id" label="Status">
|
||||
@foreach ($statuses as $status)
|
||||
<option value="{{ $status->id }}">{{ $status->name }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:button wire:click="updateStatus" class="mt-4 w-full" variant="primary" icon="check">Update status</flux:button>
|
||||
</div>
|
||||
@endcan
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user