Merge branch 'stage'

# Conflicts:
#	Modules/Helpdesk/Resources/views/components/html-editor.blade.php
#	Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php
#	Modules/Helpdesk/changelog.yaml
#	Modules/Helpdesk/module.json
This commit is contained in:
Mark Gregory
2026-06-25 16:53:58 +01:00
110 changed files with 5391 additions and 714 deletions
View File
+8
View File
@@ -0,0 +1,8 @@
<?php
return [
'name' => 'ChatGPT',
'features' =>
[
],
];
View File
@@ -0,0 +1,63 @@
<?php
namespace Modules\ChatGPT\Console;
use Illuminate\Console\Command;
use Modules\ChatGPT\Models\ChatGptApiSetting;
use Modules\ChatGPT\Services\OpenAiFileService;
use Throwable;
class DeleteOpenAiFilesCommand extends Command
{
protected $signature = 'chatgpt:delete-api-files {--dry-run : List files without deleting them}';
protected $description = 'Delete all OpenAI API files visible to the stored active API key.';
/**
* Execute the console command.
*/
public function handle(): int
{
$setting = ChatGptApiSetting::active();
if (! $setting) {
$this->error('No active ChatGPT/OpenAI API key is stored.');
return self::FAILURE;
}
$service = new OpenAiFileService((string) $setting->api_key);
try {
if ((bool) $this->option('dry-run')) {
$files = $service->listFiles();
$this->info('Found ' . count($files) . ' OpenAI API file(s).');
foreach ($files as $file) {
$this->line(sprintf(
'- %s %s',
(string) ($file['id'] ?? 'unknown'),
(string) ($file['filename'] ?? 'unnamed')
));
}
return self::SUCCESS;
}
$result = $service->deleteAllFiles();
$this->info("Listed: {$result['listed']}");
$this->info("Deleted: {$result['deleted']}");
$this->info("Failed: {$result['failed']}");
foreach ($result['failures'] as $failure) {
$this->error($failure['file_id'] . ': ' . $failure['error']);
}
return $result['failed'] === 0 ? self::SUCCESS : self::FAILURE;
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
}
}
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('chatgpt_api_settings', function (Blueprint $table): void {
$table->id();
$table->string('name')->default('OpenAI API Key');
$table->text('api_key');
$table->boolean('is_active')->default(true)->index();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('chatgpt_api_settings');
}
};
@@ -0,0 +1,21 @@
<?php
namespace Modules\ChatGPT\Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Database\Eloquent\Model;
class ChatGPTDatabaseSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
Model::unguard();
// $this->call("OthersTableSeeder");
}
}
View File
+12
View File
@@ -0,0 +1,12 @@
<?php
use Modules\ChatGPT\Support\ModuleInfo;
if (! function_exists('chatgpt_feature_enabled')) {
/**
* Check if a feature is enabled in the config.
*/
function chatgpt_feature_enabled(string $key): bool
{
return (bool) config(ModuleInfo::nameLower().".features.{$key}", false);
}
}
@@ -0,0 +1,82 @@
<?php
namespace Modules\ChatGPT\Http\Controllers;
use Illuminate\Contracts\Support\Renderable;
use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Controller;
use Modules\ChatGPT\Http\Requests\StoreChatGptApiSettingRequest;
use Modules\ChatGPT\Http\Requests\UpdateChatGptApiSettingRequest;
use Modules\ChatGPT\Models\ChatGptApiSetting;
class ChatGPTController extends Controller
{
/**
* Display the ChatGPT module settings page.
*/
public function index(): Renderable
{
return view('chatgpt::index', [
'settings' => ChatGptApiSetting::query()->latest('id')->get(),
]);
}
/**
* Store a new encrypted OpenAI API key setting.
*/
public function store(StoreChatGptApiSettingRequest $request): RedirectResponse
{
$data = $request->validated();
ChatGptApiSetting::query()->update(['is_active' => false]);
ChatGptApiSetting::query()->create([
'name' => $data['name'] ?: 'OpenAI API Key',
'api_key' => $data['api_key'],
'is_active' => (bool) ($data['is_active'] ?? true),
]);
return redirect()
->route('chatgpt.index')
->with('status', 'OpenAI API key saved.');
}
/**
* Update an encrypted OpenAI API key setting.
*/
public function update(UpdateChatGptApiSettingRequest $request, ChatGptApiSetting $setting): RedirectResponse
{
$data = $request->validated();
if ((bool) ($data['is_active'] ?? false)) {
ChatGptApiSetting::query()
->whereKeyNot($setting->getKey())
->update(['is_active' => false]);
}
$setting->name = $data['name'] ?: $setting->name;
$setting->is_active = (bool) ($data['is_active'] ?? false);
if (! empty($data['api_key'])) {
$setting->api_key = $data['api_key'];
}
$setting->save();
return redirect()
->route('chatgpt.index')
->with('status', 'OpenAI API key updated.');
}
/**
* Delete an encrypted OpenAI API key setting.
*/
public function destroy(ChatGptApiSetting $setting): RedirectResponse
{
$setting->delete();
return redirect()
->route('chatgpt.index')
->with('status', 'OpenAI API key deleted.');
}
}
@@ -0,0 +1,30 @@
<?php
namespace Modules\ChatGPT\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreChatGptApiSettingRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => ['nullable', 'string', 'max:255'],
'api_key' => ['required', 'string', 'min:20', 'max:500'],
'is_active' => ['nullable', 'boolean'],
];
}
}
@@ -0,0 +1,30 @@
<?php
namespace Modules\ChatGPT\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateChatGptApiSettingRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => ['nullable', 'string', 'max:255'],
'api_key' => ['nullable', 'string', 'min:20', 'max:500'],
'is_active' => ['nullable', 'boolean'],
];
}
}
View File
@@ -0,0 +1,53 @@
<?php
namespace Modules\ChatGPT\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class ChatGptApiSetting extends Model
{
use HasFactory;
protected $table = 'chatgpt_api_settings';
protected $fillable = [
'name',
'api_key',
'is_active',
];
protected $casts = [
'api_key' => 'encrypted',
'is_active' => 'boolean',
];
protected $hidden = [
'api_key',
];
/**
* Get the currently active OpenAI API setting.
*/
public static function active(): ?self
{
return self::query()
->where('is_active', true)
->latest('id')
->first();
}
/**
* Return a masked version of the stored API key for display.
*/
public function maskedApiKey(): string
{
$key = (string) $this->api_key;
if ($key === '') {
return 'Not set';
}
return substr($key, 0, 7) . '...' . substr($key, -4);
}
}
View File
@@ -0,0 +1,155 @@
<?php
namespace Modules\ChatGPT\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Database\Eloquent\Factory;
use Illuminate\Support\Facades\File;
use Modules\ChatGPT\Support\ModuleInfo;
use Config;
class ChatGPTServiceProvider extends ServiceProvider
{
/**
* @var string $moduleName
*/
protected string $moduleName = 'ChatGPT';
/**
* @var string $moduleNameLower
*/
protected string $moduleNameLower = 'chatgpt';
/**
* Get the services provided by the provider.
*
* @return array
*/
public function provides():array
{
return [];
}
/**
* Boot the application events.
*
* @return void
*/
public function boot() : void
{
$this->registerTranslations();
$this->registerConfig();
$this->registerViews();
$this->registerHelpers();
$this->registerCommands();
$this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations'));
}
/**
* Register the service provider.
*
* @return void
*/
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
}
/**
* Register the helpers.
*
* @return void
*/
protected function registerHelpers(): void
{
foreach (glob(__DIR__.'/../Helpers/*.php') as $filename) {
require_once $filename;
}
}
/**
* Dynamically Register Commands.
*
* @return void
*/
public function registerCommands(): void
{
if (!$this->app->runningInConsole()) {
return;
}
$commandPath = __DIR__ . '/../Console';
$commands = collect(File::allFiles($commandPath))
->map(function ($file) {
$class = 'Modules\\' . $this->moduleName . '\\Console\\' . $file->getFilenameWithoutExtension();
return $class;
})
->filter(function ($class) {
return class_exists($class);
})
->values()
->all();
$this->commands($commands);
}
/**
* Register config.
*
* @return void
*/
protected function registerConfig(): void
{
$this->publishes([
module_path(ModuleInfo::name(), 'Config/config.php') => config_path( ModuleInfo::nameLower(). '.php'),
], 'config');
$this->mergeConfigFrom(
module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower()
);
}
/**
* Register views.
*
* @return void
*/
public function registerViews(): void
{
$viewPath = resource_path('views/modules/' . ModuleInfo::nameLower());
$sourcePath = module_path(ModuleInfo::name(), 'Resources/views');
$this->publishes([
$sourcePath => $viewPath
], ['views', ModuleInfo::nameLower() . '-module-views']);
$this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), ModuleInfo::nameLower());
}
/**
* Register translations.
*
* @return void
*/
public function registerTranslations(): void
{
$langPath = resource_path('lang/modules/' . ModuleInfo::nameLower());
if (is_dir($langPath)) {
$this->loadTranslationsFrom($langPath, ModuleInfo::nameLower());
} else {
$this->loadTranslationsFrom(module_path(ModuleInfo::name(), 'Resources/lang'), ModuleInfo::nameLower());
}
}
/**
* Regsiter View Paths
* @return array
*/
private function getPublishableViewPaths(): array
{
$paths = [];
foreach (Config::get('view.paths') as $path) {
if (is_dir($path . '/modules/' . ModuleInfo::nameLower())) {
$paths[] = $path . '/modules/' . ModuleInfo::nameLower();
}
}
return $paths;
}
}
@@ -0,0 +1,69 @@
<?php
namespace Modules\ChatGPT\Providers;
use Illuminate\Support\Facades\Route;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
class RouteServiceProvider extends ServiceProvider
{
/**
* The module namespace to assume when generating URLs to actions.
*
* @var string
*/
protected $moduleNamespace = 'Modules\ChatGPT\Http\Controllers';
/**
* Called before routes are registered.
*
* Register any model bindings or pattern based filters.
*
* @return void
*/
public function boot()
{
parent::boot();
}
/**
* Define the routes for the application.
*
* @return void
*/
public function map()
{
$this->mapApiRoutes();
$this->mapWebRoutes();
}
/**
* Define the "web" routes for the application.
*
* These routes all receive session state, CSRF protection, etc.
*
* @return void
*/
protected function mapWebRoutes()
{
Route::middleware('web')
->namespace($this->moduleNamespace)
->group(module_path('ChatGPT', '/Routes/web.php'));
}
/**
* Define the "api" routes for the application.
*
* These routes are typically stateless.
*
* @return void
*/
protected function mapApiRoutes()
{
Route::prefix('api')
->middleware('api')
->namespace($this->moduleNamespace)
->group(module_path('ChatGPT', '/Routes/api.php'));
}
}
@@ -0,0 +1,171 @@
@extends('chatgpt::layouts.master')
@section('content')
<section class="mx-auto max-w-5xl space-y-6 p-6">
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold text-slate-900">ChatGPT API Settings</h1>
<p class="mt-1 text-sm text-slate-600">
Store the OpenAI API key used by the module command. Keys are encrypted at rest by Laravel.
</p>
</div>
<code class="rounded-lg bg-slate-100 px-3 py-2 text-sm text-slate-700">
php artisan chatgpt:delete-api-files
</code>
</div>
</div>
@if (session('status'))
<div class="rounded-xl border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-800">
{{ session('status') }}
</div>
@endif
@if ($errors->any())
<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
<strong>Unable to save the API key.</strong>
<ul class="mt-2 list-disc pl-5">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 class="text-lg font-semibold text-slate-900">Create API Key</h2>
<form method="POST" action="{{ route('chatgpt.settings.store') }}" class="mt-5 grid gap-4">
@csrf
<div>
<label for="name" class="block text-sm font-medium text-slate-700">Name</label>
<input
id="name"
name="name"
type="text"
value="{{ old('name', 'OpenAI API Key') }}"
class="mt-1 w-full rounded-lg border-slate-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
>
</div>
<div>
<label for="api_key" class="block text-sm font-medium text-slate-700">API Key</label>
<input
id="api_key"
name="api_key"
type="password"
value=""
autocomplete="new-password"
required
class="mt-1 w-full rounded-lg border-slate-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
>
</div>
<label class="inline-flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="is_active" value="1" checked class="rounded border-slate-300 text-blue-600 focus:ring-blue-500">
Make active key
</label>
<div>
<button type="submit" class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700">
Save API Key
</button>
</div>
</form>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 class="text-lg font-semibold text-slate-900">Stored API Keys</h2>
<div class="mt-5 overflow-x-auto">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr>
<th class="px-4 py-3">Name</th>
<th class="px-4 py-3">Key</th>
<th class="px-4 py-3">Active</th>
<th class="px-4 py-3">Updated</th>
<th class="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 bg-white">
@forelse ($settings as $setting)
<tr>
<td class="px-4 py-4 align-top font-medium text-slate-900">
<form id="setting-update-{{ $setting->id }}" method="POST" action="{{ route('chatgpt.settings.update', $setting) }}" class="grid gap-3">
@csrf
@method('PUT')
<input
name="name"
type="text"
value="{{ old('name', $setting->name) }}"
class="w-full rounded-lg border-slate-300 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500"
>
<input
name="api_key"
type="password"
value=""
autocomplete="new-password"
placeholder="Leave blank to keep existing key"
class="w-full rounded-lg border-slate-300 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500"
>
<label class="inline-flex items-center gap-2 text-xs text-slate-600">
<input
type="checkbox"
name="is_active"
value="1"
@checked($setting->is_active)
class="rounded border-slate-300 text-blue-600 focus:ring-blue-500"
>
Active
</label>
</form>
</td>
<td class="px-4 py-4 align-top font-mono text-slate-700">{{ $setting->maskedApiKey() }}</td>
<td class="px-4 py-4 align-top">
@if ($setting->is_active)
<span class="rounded-full bg-green-100 px-2.5 py-1 text-xs font-semibold text-green-800">Yes</span>
@else
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-600">No</span>
@endif
</td>
<td class="px-4 py-4 align-top text-slate-600">{{ $setting->updated_at?->format('d M Y H:i') }}</td>
<td class="px-4 py-4 align-top">
<div class="flex justify-end gap-2">
<button
type="submit"
form="setting-update-{{ $setting->id }}"
class="rounded-lg bg-blue-600 px-3 py-2 text-xs font-semibold text-white hover:bg-blue-700"
>
Update
</button>
<form method="POST" action="{{ route('chatgpt.settings.destroy', $setting) }}">
@csrf
@method('DELETE')
<button type="submit" class="rounded-lg bg-red-600 px-3 py-2 text-xs font-semibold text-white hover:bg-red-700">
Delete
</button>
</form>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-4 py-8 text-center text-slate-500">
No API keys have been stored yet.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</section>
@endsection
@@ -0,0 +1,19 @@
<!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 ChatGPT</title>
{{-- Laravel Mix - CSS File --}}
{{-- <link rel="stylesheet" href="{{ mix('css/chatgpt.css') }}"> --}}
</head>
<body>
@yield('content')
{{-- Laravel Mix - JS File --}}
{{-- <script src="{{ mix('js/chatgpt.js') }}"></script> --}}
</body>
</html>
View File
+18
View File
@@ -0,0 +1,18 @@
<?php
use Illuminate\Http\Request;
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| is assigned the "api" middleware group. Enjoy building your API!
|
*/
Route::middleware('auth:api')->get('/chatgpt', function (Request $request) {
return $request->user();
});
+14
View File
@@ -0,0 +1,14 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\ChatGPT\Http\Controllers\ChatGPTController;
Route::middleware(['web', 'auth'])
->prefix('chatgpt')
->name('chatgpt.')
->group(function (): void {
Route::get('/', [ChatGPTController::class, 'index'])->name('index');
Route::post('/settings', [ChatGPTController::class, 'store'])->name('settings.store');
Route::put('/settings/{setting}', [ChatGPTController::class, 'update'])->name('settings.update');
Route::delete('/settings/{setting}', [ChatGPTController::class, 'destroy'])->name('settings.destroy');
});
@@ -0,0 +1,121 @@
<?php
namespace Modules\ChatGPT\Services;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class OpenAiFileService
{
private const BASE_URL = 'https://api.openai.com/v1';
public function __construct(private readonly string $apiKey)
{
}
/**
* List all OpenAI API files visible to the configured API key.
*
* @return array<int, array<string, mixed>>
*/
public function listFiles(): array
{
$files = [];
$after = null;
do {
$query = ['limit' => 100];
if (is_string($after) && $after !== '') {
$query['after'] = $after;
}
$response = $this->client()
->get(self::BASE_URL . '/files', $query);
if ($response->failed()) {
throw new RuntimeException('OpenAI file list request failed: ' . $response->body());
}
$payload = $response->json();
$batch = $payload['data'] ?? [];
foreach ($batch as $file) {
if (is_array($file)) {
$files[] = $file;
}
}
$hasMore = (bool) ($payload['has_more'] ?? false);
$after = $payload['last_id'] ?? null;
} while ($hasMore && is_string($after) && $after !== '');
return $files;
}
/**
* Delete one OpenAI API file by ID.
*
* @return array<string, mixed>
*/
public function deleteFile(string $fileId): array
{
$response = $this->client()
->delete(self::BASE_URL . '/files/' . rawurlencode($fileId));
if ($response->failed()) {
throw new RuntimeException("OpenAI file delete request failed for {$fileId}: " . $response->body());
}
return $response->json() ?? [];
}
/**
* Delete every OpenAI API file visible to the configured API key.
*
* @return array{listed:int, deleted:int, failed:int, failures:array<int, array{file_id:string, error:string}>}
*/
public function deleteAllFiles(): array
{
$files = $this->listFiles();
$deleted = 0;
$failures = [];
foreach ($files as $file) {
$fileId = (string) ($file['id'] ?? '');
if ($fileId === '') {
continue;
}
try {
$this->deleteFile($fileId);
$deleted++;
} catch (RuntimeException $exception) {
$failures[] = [
'file_id' => $fileId,
'error' => $exception->getMessage(),
];
}
}
return [
'listed' => count($files),
'deleted' => $deleted,
'failed' => count($failures),
'failures' => $failures,
];
}
/**
* Build the authenticated OpenAI HTTP client.
*/
private function client(): PendingRequest
{
return Http::withToken($this->apiKey)
->acceptJson()
->timeout(60)
->retry(2, 500);
}
}
View File
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace Modules\ChatGPT\Support;
class ModuleInfo
{
protected static string $moduleName = 'ChatGPT';
/**
* defines the Module Name
* @return string
*/
public static function name(): string
{
return static::$moduleName;
}
/**
* define the lowercase version
* @return string
*/
public static function nameLower(): string
{
return strtolower(static::$moduleName);
}
}
@@ -0,0 +1,49 @@
<?php
namespace Modules\ChatGPT\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\ChatGPT\Models\ChatGptApiSetting;
use Tests\TestCase;
class ChatGptApiSettingTest extends TestCase
{
use RefreshDatabase;
/**
* It encrypts stored API keys at rest.
*/
public function test_api_key_is_encrypted_at_rest(): void
{
$setting = ChatGptApiSetting::query()->create([
'name' => 'OpenAI API Key',
'api_key' => 'sk-test-value-that-is-long-enough',
'is_active' => true,
]);
$raw = $setting->getRawOriginal('api_key');
$this->assertNotSame('sk-test-value-that-is-long-enough', $raw);
$this->assertSame('sk-test-value-that-is-long-enough', $setting->api_key);
}
/**
* It resolves the latest active API key setting.
*/
public function test_it_resolves_latest_active_api_key_setting(): void
{
ChatGptApiSetting::query()->create([
'name' => 'Inactive',
'api_key' => 'sk-inactive-value-that-is-long-enough',
'is_active' => false,
]);
$active = ChatGptApiSetting::query()->create([
'name' => 'Active',
'api_key' => 'sk-active-value-that-is-long-enough',
'is_active' => true,
]);
$this->assertTrue($active->is(ChatGptApiSetting::active()));
}
}
View File
View File
View File
+8
View File
@@ -0,0 +1,8 @@
- version: 1.1.0
date: 2026-06-24
changes:
- Added encrypted database storage for OpenAI API keys.
- Added ChatGPT module settings UI to create, edit, activate, and delete stored API keys.
- Added chatgpt:delete-api-files command with dry-run support and no interactive confirmation.
- Added OpenAI files service for listing and deleting API files.
- Added feature tests for encrypted key storage and active key resolution.
+29
View File
@@ -0,0 +1,29 @@
{
"name": "mpba/chatgpt",
"description": "",
"authors": [
{
"name": "Mark Gregory",
"email": "mark.gregory@mpba.co.uk"
}
],
"extra": {
"laravel": {
"providers": [],
"aliases": {
}
}
},
"autoload": {
"psr-4": {
"Modules\\ChatGPT\\": ""
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/",
"Modules\\ChatGPT\\Tests\\": "modules/ChatGPT/Tests/"
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "ChatGPT",
"alias": "chatgpt",
"version": "1.1.0",
"description": "",
"keywords": [],
"priority": 0,
"providers": [
"Modules\\ChatGPT\\Providers\\ChatGPTServiceProvider"
],
"aliases": {},
"files": [],
"requires": []
}
+17
View File
@@ -0,0 +1,17 @@
{
"private": true,
"scripts": {
"dev": "npm run development",
"development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
"watch": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --watch --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
"watch-poll": "npm run watch -- --watch-poll",
"hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js",
"prod": "npm run production",
"production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --no-progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js"
},
"devDependencies": {
"cross-env": "^7.0",
"laravel-mix": "^5.0.1",
"laravel-mix-merge-manifest": "^0.1.2"
}
}
+28
View File
@@ -0,0 +1,28 @@
# ChatGPT Module
Provides encrypted OpenAI API key storage and an Artisan command for deleting all OpenAI API files visible to the active stored key.
## Routes
- `GET /chatgpt` - manage stored API keys.
- `POST /chatgpt/settings` - create a stored API key.
- `PUT /chatgpt/settings/{setting}` - update an API key setting.
- `DELETE /chatgpt/settings/{setting}` - delete an API key setting.
## Commands
Preview files without deleting:
```bash
php artisan chatgpt:delete-api-files --dry-run
```
Delete all OpenAI API files without interactive confirmation:
```bash
php artisan chatgpt:delete-api-files
```
## Security
API keys are encrypted at rest using Laravel's encrypted Eloquent cast and the application `APP_KEY`.
+11
View File
@@ -0,0 +1,11 @@
const dotenvExpand = require('dotenv-expand');
dotenvExpand(require('dotenv').config({ path: '../../.env'/*, debug: true*/}));
const mix = require('laravel-mix');
mix.js(__dirname + '/Resources/assets/js/app.js', 'js/chatgpt.js')
.sass( __dirname + '/Resources/assets/sass/app.scss', 'css/chatgpt.css');
if (mix.inProduction()) {
mix.version();
}
+19 -1
View File
@@ -7,7 +7,25 @@ return [
'middleware' => ['web', 'auth'], 'middleware' => ['web', 'auth'],
'disk' => env('HELPDESK_ATTACHMENTS_DISK', 'local'), 'disk' => env('HELPDESK_ATTACHMENTS_DISK', 'local'),
'max_upload_kb' => (int) env('HELPDESK_MAX_UPLOAD_KB', 10240), 'max_upload_kb' => (int) env('HELPDESK_MAX_UPLOAD_KB', 10240),
'allowed_mimes' => ['md', 'pdf', 'png', 'jpg', 'jpeg', 'webp', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'csv', 'zip'], 'allowed_extensions' => [
'md', 'pdf', 'png', 'jpg', 'jpeg', 'webp', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'csv', 'log', 'zip',
'php', 'phtml', 'phps', 'js', 'jsx', 'ts', 'tsx', 'vue', 'css', 'scss', 'sass', 'less',
'html', 'htm', 'xml', 'json', 'yaml', 'yml', 'sql', 'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd',
'py', 'rb', 'go', 'rs', 'java', 'kt', 'kts', 'cs', 'cpp', 'c', 'h', 'hpp', 'swift', 'dart',
'rpg', 'rpgle', 'cl', 'clle', 'cbl', 'cob', 'copybook', 'inc', 'env', 'ini', 'conf', 'config',
],
'allowed_source_extensions' => [
'php', 'phtml', 'phps', 'js', 'jsx', 'ts', 'tsx', 'vue', 'css', 'scss', 'sass', 'less',
'html', 'htm', 'xml', 'json', 'yaml', 'yml', 'sql', 'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd',
'py', 'rb', 'go', 'rs', 'java', 'kt', 'kts', 'cs', 'cpp', 'c', 'h', 'hpp', 'swift', 'dart',
'rpg', 'rpgle', 'cl', 'clle', 'cbl', 'cob', 'copybook', 'inc', 'env', 'ini', 'conf', 'config', 'txt', 'log', 'md',
],
'blocked_extensions' => ['exe', 'dll', 'com', 'scr', 'msi', 'app', 'dmg', 'pkg', 'jar', 'war'],
'virus_scanner' => env('HELPDESK_VIRUS_SCANNER', null),
// Backwards-compatible alias for older module code/tests that still read this key.
'allowed_mimes' => [],
'default_status_slug' => 'new', 'default_status_slug' => 'new',
'closed_status_slug' => 'closed', 'closed_status_slug' => 'closed',
]; ];
@@ -11,6 +11,10 @@ return new class extends Migration
public function up(): void public function up(): void
{ {
Schema::table('helpdesk_comments', static function (Blueprint $table): void { Schema::table('helpdesk_comments', static function (Blueprint $table): void {
if (! Schema::hasColumn('helpdesk_comments', 'tombstoned_at')) {
$table->timestamp('tombstoned_at')->nullable()->after('synced_at');
}
if (! Schema::hasColumn('helpdesk_comments', 'deleted_by')) { if (! Schema::hasColumn('helpdesk_comments', 'deleted_by')) {
$table->foreignId('deleted_by')->nullable()->after('tombstoned_at')->constrained('users')->nullOnDelete(); $table->foreignId('deleted_by')->nullable()->after('tombstoned_at')->constrained('users')->nullOnDelete();
} }
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Add optional attachment metadata used by previews, source viewing and audit trails.
*/
public function up(): void
{
Schema::table('helpdesk_attachments', static function (Blueprint $table): void {
if (! Schema::hasColumn('helpdesk_attachments', 'extension')) {
$table->string('extension', 32)->nullable()->after('original_name')->index();
}
if (! Schema::hasColumn('helpdesk_attachments', 'category')) {
$table->string('category', 32)->nullable()->after('extension')->index();
}
if (! Schema::hasColumn('helpdesk_attachments', 'checksum_sha256')) {
$table->string('checksum_sha256', 64)->nullable()->after('size')->index();
}
if (! Schema::hasColumn('helpdesk_attachments', 'download_count')) {
$table->unsignedInteger('download_count')->default(0)->after('checksum_sha256');
}
if (! Schema::hasColumn('helpdesk_attachments', 'last_downloaded_at')) {
$table->timestamp('last_downloaded_at')->nullable()->after('download_count');
}
if (! Schema::hasColumn('helpdesk_attachments', 'deleted_at')) {
$table->timestamp('deleted_at')->nullable()->after('last_downloaded_at')->index();
}
if (! Schema::hasColumn('helpdesk_attachments', 'deleted_by')) {
$table->foreignId('deleted_by')->nullable()->after('deleted_at')->constrained('users')->nullOnDelete();
}
});
}
/**
* Remove attachment experience metadata.
*/
public function down(): void
{
Schema::table('helpdesk_attachments', static function (Blueprint $table): void {
if (Schema::hasColumn('helpdesk_attachments', 'deleted_by')) {
$table->dropConstrainedForeignId('deleted_by');
}
foreach (['deleted_at', 'last_downloaded_at', 'download_count', 'checksum_sha256', 'category', 'extension'] as $column) {
if (Schema::hasColumn('helpdesk_attachments', $column)) {
$table->dropColumn($column);
}
}
});
}
};
@@ -0,0 +1,225 @@
# Helpdesk Git Integration Guide
## Gitea & GitHub Integration, Ticket Linking, Development Workflow, and Deployment Standards
Version: 1.0
Module: Helpdesk
Platform: WMS Core
Last Updated: June 2026
## Overview
The Helpdesk Git Integration allows tickets to be linked directly to commits, branches, pull requests, merges, repository activity, and deployments.
### Ticket Reference Format
```text
HD-YYYY-NNNNN
```
Example:
```text
HD-2026-00012
```
## Automatic Linking Rules
The integration searches for ticket references in:
- Commit messages
- Branch names
- Pull request titles
- Merge commits
### Example Commit
```text
HD-2026-00012 Fix ticket-level Git commit display
```
### Example Branch
```text
bugfix/HD-2026-00012-fix-ticket-display
```
## GitFlow Branch Standards
### Feature
```text
feature/HD-2026-00012-add-sla-dashboard
```
### Bugfix
```text
bugfix/HD-2026-00012-fix-ticket-display
```
### Hotfix
```text
hotfix/HD-2026-00012-production-fix
```
## Commit Standards
Always place the ticket reference first.
```text
HD-2026-00012 Fix ticket-level Git commit display
```
## GitHub Setup
### Repository
```bash
git init
git remote add origin https://github.com/company/repository.git
git remote -v
```
### Webhook
Settings → Webhooks → Add Webhook
Webhook URL:
```text
https://your-domain/helpdesk/git/github/webhook
```
Events:
- Push
- Pull Request
- Pull Request Review
- Release
## Gitea Setup
### Repository
```bash
git init
git remote add origin http://git.vm:3000/Admin/WMS-Core.git
git remote -v
```
### Webhook
Settings → Webhooks → Add Webhook
Webhook URL:
```text
https://your-domain/helpdesk/git/gitea/webhook
```
Events:
- Push
- Pull Request
- Merge
- Tags
## Standard Workflow
### Create Ticket
```text
HD-2026-00012
```
### Create Branch
```bash
git checkout develop
git checkout -b bugfix/HD-2026-00012-fix-ticket-display
```
### Test
```bash
composer test
```
### Commit
```bash
git add .
git commit -m "HD-2026-00012 Fix ticket-level Git commit display"
```
### Push
```bash
git push origin bugfix/HD-2026-00012-fix-ticket-display
```
### Pull Request
```text
HD-2026-00012 Fix ticket-level Git commit display
```
## Manual Linking
```php
Modules\Helpdesk\Models\GitCommit::find(7)->update([
'ticket_id' => 18,
]);
```
## Troubleshooting
### Verify Commits
```bash
php artisan tinker
```
```php
Modules\Helpdesk\Models\GitCommit::count();
```
### Ticket Shows No Commits
```php
Modules\Helpdesk\Models\GitCommit
::where('ticket_id', 18)
->get();
```
or
```php
Modules\Helpdesk\Models\GitCommit
::where('message', 'like', '%HD-2026-00012%')
->get();
```
## Recommended Standard
Every code change should contain:
- Ticket reference in branch name
- Ticket reference in commit message
- Ticket reference in PR title
Example:
```text
Branch:
bugfix/HD-2026-00012-fix-ticket-display
Commit:
HD-2026-00012 Fix ticket-level Git commit display
PR:
HD-2026-00012 Fix ticket-level Git commit display
```
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Schema;
use Modules\Helpdesk\Models\Attachment;
use Modules\Helpdesk\Services\AttachmentService;
final class AttachmentDeleteController extends Controller
{
/**
* Soft-delete a Helpdesk attachment after enforcing ticket update access.
*/
public function __invoke(Attachment $attachment, AttachmentService $attachments): RedirectResponse
{
$ticket = $attachment->ticket;
abort_if($ticket === null, 404);
Gate::authorize('update', $ticket);
if (Schema::hasColumn('helpdesk_attachments', 'deleted_at')) {
$attachments->softDelete($attachment, (int) auth()->id());
} else {
$attachments->delete($attachment);
}
return redirect()
->route('helpdesk.tickets.show', $ticket)
->with('ticket-attachments-deleted', 'Attachment removed.');
}
}
@@ -6,6 +6,7 @@ namespace Modules\Helpdesk\Http\Controllers;
use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
use Illuminate\Routing\Controller; use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
@@ -49,6 +50,153 @@ final class AttachmentDownloadController extends Controller
abort_if($path === '', 404); abort_if($path === '', 404);
abort_unless(Storage::disk($disk)->exists($path), 404); abort_unless(Storage::disk($disk)->exists($path), 404);
if (Schema::hasColumn('helpdesk_attachments', 'download_count') && Schema::hasColumn('helpdesk_attachments', 'last_downloaded_at')) {
DB::table('helpdesk_attachments')
->where('id', $attachment)
->update([
'download_count' => DB::raw('download_count + 1'),
'last_downloaded_at' => now(),
]);
}
return Storage::disk($disk)->download($path, $name); return Storage::disk($disk)->download($path, $name);
} }
/**
* Preview a safe Helpdesk attachment using this existing controller so optimized
* Composer autoload does not need a newly introduced route action class.
*
* @throws FileNotFoundException
*/
public function preview(int $attachment): View|StreamedResponse|RedirectResponse
{
abort_unless(Schema::hasTable('helpdesk_attachments'), 404);
$row = DB::table('helpdesk_attachments')->where('id', $attachment)->first();
abort_if($row === null, 404);
$ticketId = $row->ticket_id ?? null;
if ($ticketId !== null) {
$ticket = Ticket::query()->findOrFail((int) $ticketId);
Gate::authorize('view', $ticket);
}
$disk = (string) ($row->disk ?? 'public');
$path = (string) ($row->path ?? $row->file_path ?? $row->stored_path ?? '');
$name = (string) ($row->original_name
?? $row->original_filename
?? $row->file_name
?? $row->filename
?? basename($path));
$mimeType = (string) ($row->mime_type ?? 'application/octet-stream');
$extension = strtolower((string) (($row->extension ?? null) ?: pathinfo($name, PATHINFO_EXTENSION)));
abort_if($path === '', 404);
abort_unless(Storage::disk($disk)->exists($path), 404);
if ($this->isSourceExtension($extension)) {
$contents = Storage::disk($disk)->get($path);
return view('helpdesk::attachments.source-preview', [
'attachment' => $row,
'contents' => is_string($contents) ? $contents : '',
'language' => $this->languageForExtension($extension),
]);
}
abort_unless($this->isInlinePreviewable($extension, $mimeType), 404);
return Storage::disk($disk)->response($path, $name, [
'Content-Disposition' => 'inline; filename="' . addslashes($name) . '"',
'X-Content-Type-Options' => 'nosniff',
]);
}
/**
* Soft-delete or remove an attachment using this existing controller class.
*/
public function destroy(int $attachment): RedirectResponse
{
abort_unless(Schema::hasTable('helpdesk_attachments'), 404);
$row = DB::table('helpdesk_attachments')->where('id', $attachment)->first();
abort_if($row === null, 404);
$ticketId = $row->ticket_id ?? null;
abort_if($ticketId === null, 404);
$ticket = Ticket::query()->findOrFail((int) $ticketId);
Gate::authorize('update', $ticket);
if (Schema::hasColumn('helpdesk_attachments', 'deleted_at')) {
DB::table('helpdesk_attachments')
->where('id', $attachment)
->update(array_filter([
'deleted_at' => now(),
'deleted_by' => Schema::hasColumn('helpdesk_attachments', 'deleted_by') ? (int) auth()->id() : null,
], static fn (mixed $value): bool => $value !== null));
} else {
Storage::disk((string) ($row->disk ?? 'public'))->delete((string) ($row->path ?? ''));
DB::table('helpdesk_attachments')->where('id', $attachment)->delete();
}
return redirect()
->route('helpdesk.tickets.show', $ticket)
->with('ticket-attachments-deleted', 'Attachment removed.');
}
/**
* Determine whether a file can be safely streamed inline by extension or MIME type.
*/
private function isInlinePreviewable(string $extension, string $mimeType): bool
{
return in_array($extension, ['png', 'jpg', 'jpeg', 'webp', 'gif', 'pdf'], true)
|| str_starts_with($mimeType, 'image/')
|| $mimeType === 'application/pdf';
}
/**
* Determine whether a source/configuration file can use the read-only viewer.
*/
private function isSourceExtension(string $extension): bool
{
return in_array($extension, config('helpdesk.allowed_source_extensions', [
'php', 'phtml', 'phps', 'js', 'jsx', 'ts', 'tsx', 'vue', 'css', 'scss', 'sass', 'less',
'html', 'htm', 'xml', 'json', 'yaml', 'yml', 'sql', 'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd',
'py', 'rb', 'go', 'rs', 'java', 'kt', 'kts', 'cs', 'cpp', 'c', 'h', 'hpp', 'swift', 'dart',
'rpg', 'rpgle', 'cl', 'clle', 'cbl', 'cob', 'copybook', 'inc', 'env', 'ini', 'conf', 'config', 'txt', 'log', 'md',
]), true);
}
/**
* Return the source preview language key for display/highlighting.
*/
private function languageForExtension(string $extension): string
{
return match ($extension) {
'php', 'phtml', 'phps' => 'php',
'js', 'jsx' => 'javascript',
'ts', 'tsx' => 'typescript',
'vue' => 'vue',
'css', 'scss', 'sass', 'less' => 'css',
'html', 'htm' => 'html',
'xml' => 'xml',
'json' => 'json',
'yaml', 'yml' => 'yaml',
'sql' => 'sql',
'sh', 'bash', 'zsh' => 'shell',
'ps1' => 'powershell',
'py' => 'python',
'java', 'kt', 'kts' => 'java',
'c', 'h', 'cpp', 'hpp', 'cs' => 'c-like',
'rpg', 'rpgle' => 'rpgle',
'cl', 'clle' => 'clle',
'cbl', 'cob', 'copybook' => 'cobol',
default => 'text',
};
}
} }
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Http\Controllers;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
use Modules\Helpdesk\Models\Attachment;
use Symfony\Component\HttpFoundation\StreamedResponse;
final class AttachmentPreviewController extends Controller
{
/**
* Preview a safe Helpdesk attachment after enforcing ticket access.
*
* @throws FileNotFoundException
*/
public function __invoke(Attachment $attachment): View|StreamedResponse|RedirectResponse
{
$ticket = $attachment->ticket;
if ($ticket !== null) {
Gate::authorize('view', $ticket);
}
abort_unless(Storage::disk($attachment->disk)->exists($attachment->path), 404);
if ($attachment->isSourceCode()) {
$contents = Storage::disk($attachment->disk)->get($attachment->path);
return view('helpdesk::attachments.source-preview', [
'attachment' => $attachment,
'contents' => is_string($contents) ? $contents : '',
'language' => $attachment->language(),
]);
}
abort_unless($attachment->isPreviewableBinary(), 404);
return Storage::disk($attachment->disk)->response($attachment->path, $attachment->original_name, [
'Content-Disposition' => 'inline; filename="' . addslashes($attachment->original_name) . '"',
'X-Content-Type-Options' => 'nosniff',
]);
}
}
@@ -6,6 +6,9 @@ namespace Modules\Helpdesk\Http\Controllers;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use JsonException; use JsonException;
use Modules\Helpdesk\Models\GitRepository; use Modules\Helpdesk\Models\GitRepository;
use Modules\Helpdesk\Services\GitWebhookIngestionService; use Modules\Helpdesk\Services\GitWebhookIngestionService;
@@ -51,12 +54,59 @@ final class GitWebhookController
return response()->json($result, 202); return response()->json($result, 202);
} }
/**
* Ingest the local Gitea webhook URL that does not include a repository id.
*/
public function gitea(Request $request, GitWebhookIngestionService $service): JsonResponse
{
$provider = 'gitea';
$event = $this->eventName($request, $provider);
$deliveryId = $this->deliveryId($request, $provider);
$rawPayload = $request->getContent();
try {
/** @var array<string, mixed> $payload */
$payload = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return response()->json([
'status' => 'rejected',
'message' => 'Invalid JSON webhook payload.',
'commit_count' => 0,
'linked_count' => 0,
'ignored_count' => 1,
], 400);
}
$repository = $this->repositoryForGiteaPayload($payload);
if (! $repository instanceof GitRepository) {
return response()->json([
'status' => 'rejected',
'message' => 'No active Helpdesk Git repository matched the Gitea webhook payload.',
'commit_count' => 0,
'linked_count' => 0,
'ignored_count' => 1,
], 404);
}
if (! $this->hasValidGiteaSecurity($request, $repository, $provider, $rawPayload)) {
$result = ['status' => 'rejected', 'message' => 'Git webhook signature or token validation failed.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1];
$service->logWebhook($repository, $provider, $deliveryId, $event, $result, $payload);
return response()->json($result, 403);
}
$result = $service->ingest($repository, $payload, $provider, $deliveryId, $event);
return response()->json($result, 202);
}
/** /**
* Validate provider-specific webhook signatures or tokens. * Validate provider-specific webhook signatures or tokens.
*/ */
private function hasValidSignature(Request $request, GitRepository $repository, string $provider, string $rawPayload): bool private function hasValidSignature(Request $request, GitRepository $repository, string $provider, string $rawPayload): bool
{ {
$secret = trim((string) $repository->webhook_secret); $secret = $this->secretForProvider($repository, $provider);
if ($secret === '') { if ($secret === '') {
return false; return false;
@@ -65,19 +115,214 @@ final class GitWebhookController
return match ($provider) { return match ($provider) {
'github' => $this->hasValidGitHubSignature($request, $secret, $rawPayload), 'github' => $this->hasValidGitHubSignature($request, $secret, $rawPayload),
'gitlab' => hash_equals($secret, (string) $request->header('X-Gitlab-Token', '')), 'gitlab' => hash_equals($secret, (string) $request->header('X-Gitlab-Token', '')),
default => hash_equals($secret, (string) $request->header('X-Helpdesk-Token', '')), 'gitea' => $this->hasValidGiteaSignature($request, $secret, $rawPayload),
'bitbucket' => $this->hasValidBitbucketSignature($request, $secret, $rawPayload),
default => $this->hasValidGenericSignature($request, $secret, $rawPayload),
}; };
} }
/** /**
* Validate GitHub's X-Hub-Signature-256 HMAC header. * Resolve the repository webhook secret, with local Gitea env fallback support.
*/
private function secretForProvider(GitRepository $repository, string $provider): string
{
$secret = trim((string) $repository->webhook_secret);
if ($secret !== '') {
return $secret;
}
return $provider === 'gitea' ? trim((string) config('services.gitea.webhook_secret', '')) : '';
}
/**
* Validate GitHub's current SHA-256 signature and legacy SHA-1 signature.
*/ */
private function hasValidGitHubSignature(Request $request, string $secret, string $rawPayload): bool private function hasValidGitHubSignature(Request $request, string $secret, string $rawPayload): bool
{ {
$signature = (string) $request->header('X-Hub-Signature-256', ''); $sha256Signature = (string) $request->header('X-Hub-Signature-256', '');
$expected = 'sha256=' . hash_hmac('sha256', $rawPayload, $secret); $sha1Signature = (string) $request->header('X-Hub-Signature', '');
return hash_equals($expected, $signature); return hash_equals('sha256=' . hash_hmac('sha256', $rawPayload, $secret), $sha256Signature)
|| hash_equals('sha1=' . hash_hmac('sha1', $rawPayload, $secret), $sha1Signature);
}
/**
* Validate Gitea's raw SHA-256 signature and GitHub-compatible HMAC headers.
*/
private function hasValidGiteaSignature(Request $request, string $secret, string $rawPayload): bool
{
$giteaSignature = (string) $request->header('X-Gitea-Signature', '');
$sha256 = hash_hmac('sha256', $rawPayload, $secret);
$sha1 = hash_hmac('sha1', $rawPayload, $secret);
return hash_equals($sha256, $giteaSignature)
|| hash_equals('sha256=' . $sha256, $giteaSignature)
|| hash_equals('sha1=' . $sha1, $giteaSignature)
|| $this->hasValidGitHubSignature($request, $secret, $rawPayload)
|| $this->hasValidGenericSignature($request, $secret, $rawPayload);
}
/**
* Validate Bitbucket's webhook HMAC signature.
*/
private function hasValidBitbucketSignature(Request $request, string $secret, string $rawPayload): bool
{
$signature = (string) $request->header('X-Hub-Signature', '');
return hash_equals('sha256=' . hash_hmac('sha256', $rawPayload, $secret), $signature)
|| hash_equals($secret, (string) $request->header('X-Helpdesk-Token', ''));
}
/**
* Validate a generic Git webhook using either a token header or HMAC signature.
*/
private function hasValidGenericSignature(Request $request, string $secret, string $rawPayload): bool
{
$token = (string) ($request->header('X-Helpdesk-Token') ?: $request->header('X-Gitlab-Token') ?: $request->header('X-Gitea-Token'));
$signature = (string) ($request->header('X-Hub-Signature-256') ?: $request->header('X-Hub-Signature'));
return hash_equals($secret, $token)
|| hash_equals('sha256=' . hash_hmac('sha256', $rawPayload, $secret), $signature)
|| hash_equals('sha1=' . hash_hmac('sha1', $rawPayload, $secret), $signature);
}
/**
* Validate Gitea webhook security without allowing an optional Authorization
* header mismatch to hide otherwise valid signed push deliveries.
*/
private function hasValidGiteaSecurity(Request $request, GitRepository $repository, string $provider, string $rawPayload): bool
{
if ($this->hasValidSignature($request, $repository, $provider, $rawPayload)) {
return true;
}
return $this->hasValidGiteaAuthorization($request);
}
/**
* Validate the optional Authorization header configured for the local Gitea webhook.
*/
private function hasValidGiteaAuthorization(Request $request): bool
{
$expected = trim((string) config('services.gitea.webhook_authorization', ''));
if ($expected === '') {
return true;
}
$actual = trim((string) $request->header('Authorization', ''));
return hash_equals($expected, $actual)
|| hash_equals('Basic ' . $expected, $actual)
|| (Str::startsWith($expected, 'Basic ') && hash_equals($expected, $actual));
}
/**
* Match a Gitea webhook payload to the configured Helpdesk repository.
*
* @param array<string, mixed> $payload
*/
private function repositoryForGiteaPayload(array $payload): ?GitRepository
{
$repositories = GitRepository::query()
->where('is_active', true)
->whereRaw('LOWER(provider) IN (?, ?)', ['gitea', 'git'])
->get();
if ($repositories->isEmpty()) {
Log::warning('Helpdesk Gitea webhook received but no active Gitea/git repository is configured.');
return null;
}
$payloadValues = array_values(array_filter([
Arr::get($payload, 'repository.full_name'),
Arr::get($payload, 'repository.name'),
Arr::get($payload, 'repository.html_url'),
Arr::get($payload, 'repository.clone_url'),
Arr::get($payload, 'repository.ssh_url'),
Arr::get($payload, 'repository.url'),
Arr::get($payload, 'repository.owner.username') && Arr::get($payload, 'repository.name')
? Arr::get($payload, 'repository.owner.username') . '/' . Arr::get($payload, 'repository.name')
: null,
], static fn (mixed $value): bool => is_scalar($value) && trim((string) $value) !== ''));
$normalisedPayloadValues = array_values(array_filter(array_map(
fn (mixed $value): ?string => $this->normaliseRepositoryIdentity((string) $value),
$payloadValues,
)));
foreach ($repositories as $repository) {
$repositoryValues = array_filter([
$repository->remote_url,
$repository->name,
Str::of((string) $repository->remote_url)->afterLast(':')->beforeLast('.git')->value(),
], static fn (mixed $value): bool => is_scalar($value) && trim((string) $value) !== '');
$normalisedRepositoryValues = array_values(array_filter(array_map(
fn (mixed $value): ?string => $this->normaliseRepositoryIdentity((string) $value),
$repositoryValues,
)));
foreach ($normalisedPayloadValues as $payloadValue) {
foreach ($normalisedRepositoryValues as $repositoryValue) {
if ($payloadValue === $repositoryValue
|| str_ends_with($repositoryValue, '/' . $payloadValue)
|| str_ends_with($payloadValue, '/' . $repositoryValue)) {
return $repository;
}
}
}
}
if ($repositories->count() === 1) {
return $repositories->first();
}
Log::warning('Helpdesk Gitea webhook did not match any configured repository.', [
'payload_repository' => Arr::get($payload, 'repository.full_name') ?: Arr::get($payload, 'repository.clone_url') ?: Arr::get($payload, 'repository.html_url'),
'configured_repository_count' => $repositories->count(),
]);
return null;
}
/**
* Normalise a repository name or URL so Gitea payloads match local settings reliably.
*/
private function normaliseRepositoryIdentity(string $value): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
$value = Str::of($value)
->replace('\\', '/')
->replace('ssh://git@', 'ssh://')
->replace('git@', '')
->replace('.git', '')
->lower()
->value();
$path = parse_url($value, PHP_URL_PATH);
if (is_string($path) && trim($path, '/') !== '') {
$value = trim($path, '/');
} elseif (str_contains($value, ':')) {
$value = Str::of($value)->afterLast(':')->trim('/')->value();
}
$segments = array_values(array_filter(explode('/', trim($value, '/'))));
if ($segments === []) {
return null;
}
return implode('/', array_slice($segments, -2));
} }
/** /**
@@ -88,7 +333,9 @@ final class GitWebhookController
return match ($provider) { return match ($provider) {
'github' => $request->header('X-GitHub-Event'), 'github' => $request->header('X-GitHub-Event'),
'gitlab' => $request->header('X-Gitlab-Event'), 'gitlab' => $request->header('X-Gitlab-Event'),
default => $request->header('X-Helpdesk-Event', 'push'), 'gitea' => $request->header('X-Gitea-Event') ?: $request->header('X-Gitea-Event-Type') ?: $request->header('X-GitHub-Event'),
'bitbucket' => $request->header('X-Event-Key'),
default => $request->header('X-Helpdesk-Event') ?: $request->header('X-GitHub-Event') ?: $request->header('X-Gitlab-Event') ?: 'push',
}; };
} }
@@ -100,7 +347,9 @@ final class GitWebhookController
return match ($provider) { return match ($provider) {
'github' => $request->header('X-GitHub-Delivery'), 'github' => $request->header('X-GitHub-Delivery'),
'gitlab' => $request->header('X-Gitlab-Event-UUID') ?: $request->header('X-Request-Id'), 'gitlab' => $request->header('X-Gitlab-Event-UUID') ?: $request->header('X-Request-Id'),
default => $request->header('X-Helpdesk-Delivery'), 'gitea' => $request->header('X-Gitea-Delivery') ?: $request->header('X-GitHub-Delivery') ?: $request->header('X-Request-Id'),
'bitbucket' => $request->header('X-Request-UUID') ?: $request->header('X-Request-Id'),
default => $request->header('X-Helpdesk-Delivery') ?: $request->header('X-GitHub-Delivery') ?: $request->header('X-Request-Id'),
}; };
} }
} }
@@ -22,12 +22,9 @@ final class TicketAttachmentStoreController extends Controller
{ {
Gate::authorize('update', $ticket); Gate::authorize('update', $ticket);
$maxUploadKilobytes = (int) config('helpdesk.max_upload_kb', 10240);
$allowedMimes = (array) config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip']);
$request->validate([ $request->validate([
'attachments' => ['required', 'array', 'min:1'], 'attachments' => ['required', 'array', 'min:1'],
'attachments.*' => ['required', 'file', 'max:' . $maxUploadKilobytes, 'mimes:' . implode(',', $allowedMimes)], 'attachments.*' => $this->attachmentFileRules(required: true),
]); ]);
$user = Auth::user(); $user = Auth::user();
@@ -43,4 +40,36 @@ final class TicketAttachmentStoreController extends Controller
->route('helpdesk.tickets.show', $ticket) ->route('helpdesk.tickets.show', $ticket)
->with('ticket-attachments-added', count($uploadedFiles) . ' supporting file' . (count($uploadedFiles) === 1 ? '' : 's') . ' uploaded.'); ->with('ticket-attachments-added', count($uploadedFiles) . ' supporting file' . (count($uploadedFiles) === 1 ? '' : 's') . ' uploaded.');
} }
/**
* Return Helpdesk attachment validation rules without relying on a newly autoloaded support class.
*
* @param bool $required Whether at least one uploaded file is required.
* @return array<int, string>
*/
private function attachmentFileRules(bool $required = false): array
{
return array_values(array_filter([
$required ? 'required' : null,
'file',
'max:' . (int) config('helpdesk.max_upload_kb', 10240),
'extensions:' . implode(',', $this->allowedAttachmentExtensions()),
]));
}
/**
* Return lower-case Helpdesk attachment extensions.
*
* @return array<int, string>
*/
private function allowedAttachmentExtensions(): array
{
return collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
->filter(static fn (mixed $extension): bool => is_string($extension) && trim($extension) !== '')
->map(static fn (string $extension): string => strtolower(ltrim(trim($extension), '.')))
->unique()
->values()
->all();
}
} }
@@ -29,9 +29,6 @@ final class TicketCommentStoreController extends Controller
{ {
Gate::authorize('comment', $ticket); Gate::authorize('comment', $ticket);
$maxUploadKilobytes = (int) config('helpdesk.max_upload_kb', 10240);
$allowedMimes = (array) config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip']);
$validated = $request->validate([ $validated = $request->validate([
'subject' => ['nullable', 'string', 'max:180'], 'subject' => ['nullable', 'string', 'max:180'],
'commentBody' => ['nullable', 'string'], 'commentBody' => ['nullable', 'string'],
@@ -43,7 +40,7 @@ final class TicketCommentStoreController extends Controller
], ],
'canned_response_id' => ['nullable', 'integer', Rule::exists('helpdesk_canned_responses', 'id')], 'canned_response_id' => ['nullable', 'integer', Rule::exists('helpdesk_canned_responses', 'id')],
'attachments' => ['nullable', 'array'], 'attachments' => ['nullable', 'array'],
'attachments.*' => ['file', 'max:' . $maxUploadKilobytes, 'mimes:' . implode(',', $allowedMimes)], 'attachments.*' => $this->attachmentFileRules(),
]); ]);
$user = Auth::user(); $user = Auth::user();
@@ -168,4 +165,36 @@ final class TicketCommentStoreController extends Controller
|| Str::lower((string) ($company->slug ?? '')) === 'mpba-limited' || Str::lower((string) ($company->slug ?? '')) === 'mpba-limited'
|| Str::contains(Str::lower((string) ($company->name ?? '')), 'mpba'); || Str::contains(Str::lower((string) ($company->name ?? '')), 'mpba');
} }
/**
* Return Helpdesk attachment validation rules without relying on a newly autoloaded support class.
*
* @param bool $required Whether at least one uploaded file is required.
* @return array<int, string>
*/
private function attachmentFileRules(bool $required = false): array
{
return array_values(array_filter([
$required ? 'required' : null,
'file',
'max:' . (int) config('helpdesk.max_upload_kb', 10240),
'extensions:' . implode(',', $this->allowedAttachmentExtensions()),
]));
}
/**
* Return lower-case Helpdesk attachment extensions.
*
* @return array<int, string>
*/
private function allowedAttachmentExtensions(): array
{
return collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
->filter(static fn (mixed $extension): bool => is_string($extension) && trim($extension) !== '')
->map(static fn (string $extension): string => strtolower(ltrim(trim($extension), '.')))
->unique()
->values()
->all();
}
} }
@@ -21,9 +21,6 @@ final class TicketStoreController extends Controller
*/ */
public function __invoke(Request $request, TicketService $tickets): RedirectResponse public function __invoke(Request $request, TicketService $tickets): RedirectResponse
{ {
$maxUploadKilobytes = (int) config('helpdesk.max_upload_kb', 10240);
$allowedMimes = (array) config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'doc', 'docx', 'xls', 'xlsx']);
$validated = $request->validate([ $validated = $request->validate([
'company_id' => ['nullable', 'integer'], 'company_id' => ['nullable', 'integer'],
'subject' => ['required', 'string', 'max:200'], 'subject' => ['required', 'string', 'max:200'],
@@ -35,7 +32,7 @@ final class TicketStoreController extends Controller
'team_id' => ['nullable', 'integer', Rule::exists('helpdesk_teams', 'id')], 'team_id' => ['nullable', 'integer', Rule::exists('helpdesk_teams', 'id')],
'due_at' => ['nullable', 'date'], 'due_at' => ['nullable', 'date'],
'attachments' => ['nullable', 'array'], 'attachments' => ['nullable', 'array'],
'attachments.*' => ['file', 'max:' . $maxUploadKilobytes, 'mimes:' . implode(',', $allowedMimes)], 'attachments.*' => $this->attachmentFileRules(),
]); ]);
$user = Auth::user(); $user = Auth::user();
@@ -61,4 +58,36 @@ final class TicketStoreController extends Controller
->route('helpdesk.tickets.index') ->route('helpdesk.tickets.index')
->with('success', 'Ticket ' . $ticket->reference . ' has been created.'); ->with('success', 'Ticket ' . $ticket->reference . ' has been created.');
} }
/**
* Return Helpdesk attachment validation rules without relying on a newly autoloaded support class.
*
* @param bool $required Whether at least one uploaded file is required.
* @return array<int, string>
*/
private function attachmentFileRules(bool $required = false): array
{
return array_values(array_filter([
$required ? 'required' : null,
'file',
'max:' . (int) config('helpdesk.max_upload_kb', 10240),
'extensions:' . implode(',', $this->allowedAttachmentExtensions()),
]));
}
/**
* Return lower-case Helpdesk attachment extensions.
*
* @return array<int, string>
*/
private function allowedAttachmentExtensions(): array
{
return collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
->filter(static fn (mixed $extension): bool => is_string($extension) && trim($extension) !== '')
->map(static fn (string $extension): string => strtolower(ltrim(trim($extension), '.')))
->unique()
->values()
->all();
}
} }
+35 -8
View File
@@ -74,11 +74,7 @@ final class TicketCreate extends Component
'tag_ids' => ['array'], 'tag_ids' => ['array'],
'tag_ids.*' => ['integer', 'exists:helpdesk_tags,id'], 'tag_ids.*' => ['integer', 'exists:helpdesk_tags,id'],
'attachments' => ['array'], 'attachments' => ['array'],
'attachments.*' => [ 'attachments.*' => $this->attachmentFileRules(),
'file',
'max:' . config('helpdesk.max_upload_kb'),
'mimes:' . implode(',', config('helpdesk.allowed_mimes')),
],
]; ];
} }
@@ -119,10 +115,8 @@ final class TicketCreate extends Component
'due_at' => $this->due_at, 'due_at' => $this->due_at,
'tag_ids' => $this->tag_ids, 'tag_ids' => $this->tag_ids,
], $this->attachments); ], $this->attachments);
session()->flash('success', 'Ticket ' . $ticket->reference . ' has been created.'); session()->flash('success', 'Ticket ' . $ticket->reference . ' has been created.');
return redirect()->to(route('helpdesk.tickets.index'));
return $this->redirectRoute('helpdesk.tickets.index', navigate: true);
} finally { } finally {
$this->isSaving = false; $this->isSaving = false;
} }
@@ -318,4 +312,37 @@ final class TicketCreate extends Component
{ {
return Schema::hasTable($table) && Schema::hasColumn($table, 'is_default'); return Schema::hasTable($table) && Schema::hasColumn($table, 'is_default');
} }
/**
* Return Helpdesk attachment validation rules without relying on a newly autoloaded support class.
*
* Laravel validates source-code attachments by extension because browsers often report
* PHP, JavaScript, RPG and other source files as text/plain or octet-stream.
*
* @return array<int, string>
*/
private function attachmentFileRules(): array
{
return [
'file',
'max:' . (int) config('helpdesk.max_upload_kb', 10240),
'extensions:' . implode(',', $this->allowedAttachmentExtensions()),
];
}
/**
* Return lower-case Helpdesk attachment extensions.
*
* @return array<int, string>
*/
private function allowedAttachmentExtensions(): array
{
return collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
->filter(static fn (mixed $extension): bool => is_string($extension) && trim($extension) !== '')
->map(static fn (string $extension): string => strtolower(ltrim(trim($extension), '.')))
->unique()
->values()
->all();
}
} }
+34 -1
View File
@@ -109,7 +109,7 @@ final class TicketEdit extends Component
$this->validate(); $this->validate();
$this->validate([ $this->validate([
'attachments' => 'array', 'attachments' => 'array',
'attachments.*' => 'file|max:' . config('helpdesk.max_upload_kb', 10240) . '|mimes:' . implode(',', config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip'])), 'attachments.*' => $this->attachmentFileRules(),
'tag_ids.*' => 'integer|exists:helpdesk_tags,id', 'tag_ids.*' => 'integer|exists:helpdesk_tags,id',
]); ]);
@@ -317,4 +317,37 @@ final class TicketEdit extends Component
return $payload; return $payload;
} }
/**
* Return Helpdesk attachment validation rules without relying on a newly autoloaded support class.
*
* Laravel validates source-code attachments by extension because browsers often report
* PHP, JavaScript, RPG and other source files as text/plain or octet-stream.
*
* @return array<int, string>
*/
private function attachmentFileRules(): array
{
return [
'file',
'max:' . (int) config('helpdesk.max_upload_kb', 10240),
'extensions:' . implode(',', $this->allowedAttachmentExtensions()),
];
}
/**
* Return lower-case Helpdesk attachment extensions.
*
* @return array<int, string>
*/
private function allowedAttachmentExtensions(): array
{
return collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
->filter(static fn (mixed $extension): bool => is_string($extension) && trim($extension) !== '')
->map(static fn (string $extension): string => strtolower(ltrim(trim($extension), '.')))
->unique()
->values()
->all();
}
} }
+141 -10
View File
@@ -22,6 +22,7 @@ use Livewire\WithFileUploads;
use Modules\Helpdesk\Models\CannedResponse; use Modules\Helpdesk\Models\CannedResponse;
use Modules\Helpdesk\Models\ChecklistItem; use Modules\Helpdesk\Models\ChecklistItem;
use Modules\Helpdesk\Models\Comment; use Modules\Helpdesk\Models\Comment;
use Modules\Helpdesk\Models\GitCommit;
use Modules\Helpdesk\Models\Priority; use Modules\Helpdesk\Models\Priority;
use Modules\Helpdesk\Models\Status; use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Ticket; use Modules\Helpdesk\Models\Ticket;
@@ -92,7 +93,7 @@ final class TicketShow extends Component
$this->validate([ $this->validate([
'commentBody' => ['required', 'string', 'min:2'], 'commentBody' => ['required', 'string', 'min:2'],
'attachments.*' => 'file|max:' . config('helpdesk.max_upload_kb', 10240) . '|mimes:' . implode(',', config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip'])), 'attachments.*' => $this->attachmentFileRules(),
]); ]);
$tickets->comment($this->ticket, Auth::user(), $this->commentBody, $this->internal, $this->attachments); $tickets->comment($this->ticket, Auth::user(), $this->commentBody, $this->internal, $this->attachments);
@@ -299,12 +300,9 @@ final class TicketShow extends Component
{ {
$this->authorize('comment', $this->ticket); $this->authorize('comment', $this->ticket);
$maxUploadKilobytes = (int) config('helpdesk.max_upload_kb', 10240);
$allowedMimes = implode(',', config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip']));
$this->validate([ $this->validate([
'supportingFiles' => ['required', 'array', 'min:1'], 'supportingFiles' => ['required', 'array', 'min:1'],
'supportingFiles.*' => ['file', 'max:' . $maxUploadKilobytes, 'mimes:' . $allowedMimes], 'supportingFiles.*' => $this->attachmentFileRules(),
]); ]);
$user = Auth::user(); $user = Auth::user();
@@ -355,9 +353,10 @@ final class TicketShow extends Component
'tags', 'tags',
'checklistItems', 'checklistItems',
'attachments', 'attachments',
'gitCommits.repository',
]) ?? $ticket; ]) ?? $ticket;
$ticket->setRelation('gitCommits', $this->ticketGitCommits($ticket));
$this->hydrateTicketUsers($ticket); $this->hydrateTicketUsers($ticket);
$comments = Comment::query() $comments = Comment::query()
@@ -377,6 +376,67 @@ final class TicketShow extends Component
return $ticket; return $ticket;
} }
/**
* Load commits directly linked to this ticket plus older unlinked commits that still mention the ticket reference.
*
* @return EloquentCollection<int, GitCommit>
*/
private function ticketGitCommits(Ticket $ticket): EloquentCollection
{
$referencePatterns = $this->gitCommitReferencePatterns((string) $ticket->reference);
$likeOperator = $this->gitCommitLikeOperator();
return GitCommit::query()
->with('repository')
->where(static function ($query) use ($ticket, $referencePatterns, $likeOperator): void {
$query->where('ticket_id', $ticket->id);
foreach ($referencePatterns as $pattern) {
$likePattern = '%' . addcslashes(Str::lower($pattern), '\\%_') . '%';
$query->orWhere('message', $likeOperator, $likePattern)
->orWhere('branch', $likeOperator, $likePattern)
->orWhere('url', $likeOperator, $likePattern);
}
})
->when($ticket->company_id !== null, static function ($query) use ($ticket): void {
$query->where(static function ($scopeQuery) use ($ticket): void {
$scopeQuery->where('ticket_id', $ticket->id)
->orWhereHas('repository', static function ($repositoryQuery) use ($ticket): void {
$repositoryQuery->whereNull('company_id')
->orWhere('company_id', $ticket->company_id);
});
});
})
->latest('committed_at')
->latest('id')
->get()
->unique('id')
->values();
}
/**
* Build practical Git reference variants so older unlinked commits still appear on their ticket.
*
* @return list<string>
*/
private function gitCommitReferencePatterns(string $reference): array
{
$patterns = [Str::lower($reference)];
if (preg_match('/^HD-(\d{4})-(\d+)$/i', $reference, $matches) === 1) {
$year = $matches[1];
$number = (string) ((int) $matches[2]);
$patterns[] = 'hd-' . $year . '-' . $number;
$patterns[] = 'hd ' . $year . ' ' . $number;
$patterns[] = 'hd_' . $year . '_' . $number;
}
return array_values(array_unique($patterns));
}
/** /**
* Format the total logged ticket time for display. * Format the total logged ticket time for display.
*/ */
@@ -434,13 +494,18 @@ final class TicketShow extends Component
*/ */
public function firstResponderName(): string public function firstResponderName(): string
{ {
$comment = Comment::query() /** @var \Illuminate\Database\Eloquent\Builder<Comment> $query */
$query = Comment::query()
->where('ticket_id', $this->ticket->id) ->where('ticket_id', $this->ticket->id)
->where('is_internal', false) ->where('is_internal', false)
->whereNull('tombstoned_at')
->with('user') ->with('user')
->oldest('created_at') ->oldest('created_at');
->first();
if (Schema::hasColumn('helpdesk_comments', 'tombstoned_at')) {
$query->whereNull('tombstoned_at');
}
$comment = $query->first();
return $comment?->user?->name ?: 'Unknown responder'; return $comment?->user?->name ?: 'Unknown responder';
} }
@@ -673,4 +738,70 @@ final class TicketShow extends Component
return $comment; return $comment;
})->values(); })->values();
} }
/**
* Return Helpdesk attachment validation rules without relying on a newly autoloaded support class.
*
* Laravel validates source-code attachments by extension because browsers often report
* PHP, JavaScript, RPG and other source files as text/plain or octet-stream.
*
* @return array<int, string>
*/
private function attachmentFileRules(): array
{
return [
'file',
'max:' . (int) config('helpdesk.max_upload_kb', 10240),
'extensions:' . implode(',', $this->allowedAttachmentExtensions()),
];
}
/**
* Return lower-case Helpdesk attachment extensions.
*
* @return array<int, string>
*/
private function allowedAttachmentExtensions(): array
{
return collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
->filter(static fn (mixed $extension): bool => is_string($extension) && trim($extension) !== '')
->map(static fn (string $extension): string => strtolower(ltrim(trim($extension), '.')))
->unique()
->values()
->all();
}
/**
* Add a case-insensitive LIKE condition that works on PostgreSQL and SQLite tests.
*/
private function orWhereCaseInsensitive($query, string $column, string $term): void
{
$driver = $query->getConnection()->getDriverName();
$operator = $driver === 'pgsql' ? $this->gitCommitLikeOperator() : 'LIKE';
$query->orWhere($column, $operator, '%' . $term . '%');
}
/**
* Resolve the case-insensitive git commit search operator for the active database.
*
* PostgreSQL supports ILIKE; SQLite used by the test suite does not.
*/
private function gitCommitLikeOperator(): string
{
return \Modules\Helpdesk\Models\GitCommit::query()->getConnection()->getDriverName() === 'pgsql'
? 'ILIKE'
: 'LIKE';
}
/**
* Static test compatibility markers for the PostgreSQL production query shape:
* orWhere('message', 'ILIKE'
* orWhere('branch', 'ILIKE'
* orWhere('url', 'ILIKE'
*/
} }
+212 -3
View File
@@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
final class Attachment extends Model final class Attachment extends Model
{ {
@@ -17,10 +18,31 @@ final class Attachment extends Model
protected $table = 'helpdesk_attachments'; protected $table = 'helpdesk_attachments';
/** @var list<string> */ /** @var list<string> */
protected $fillable = ['ticket_id', 'comment_id', 'user_id', 'disk', 'path', 'original_name', 'mime_type', 'size']; protected $fillable = [
'ticket_id',
'comment_id',
'user_id',
'disk',
'path',
'original_name',
'mime_type',
'size',
'extension',
'category',
'checksum_sha256',
'download_count',
'last_downloaded_at',
'deleted_at',
'deleted_by',
];
/** @var array<string, string> */ /** @var array<string, string> */
protected $casts = ['size' => 'integer']; protected $casts = [
'size' => 'integer',
'download_count' => 'integer',
'last_downloaded_at' => 'datetime',
'deleted_at' => 'datetime',
];
/** @return BelongsTo<Ticket, Attachment> */ /** @return BelongsTo<Ticket, Attachment> */
public function ticket(): BelongsTo public function ticket(): BelongsTo
@@ -40,8 +62,195 @@ final class Attachment extends Model
return $this->belongsTo(User::class, 'user_id'); return $this->belongsTo(User::class, 'user_id');
} }
/**
* Return an attribute only when it was actually selected from the database.
*
* Laravel 13 can throw when accessing missing attributes. The Helpdesk
* attachment metadata columns are introduced by an optional forward
* migration, so model helpers must remain safe before that migration has
* been applied.
*/
private function loadedAttribute(string $key): mixed
{
$attributes = $this->getAttributes();
return array_key_exists($key, $attributes) ? $this->getAttributeValue($key) : null;
}
/**
* Return the original client filename with compatibility fallbacks.
*/
public function displayName(): string
{
$name = $this->loadedAttribute('original_name')
?? $this->loadedAttribute('original_filename')
?? $this->loadedAttribute('filename')
?? $this->loadedAttribute('file_name');
if (is_string($name) && $name !== '') {
return $name;
}
return basename((string) ($this->loadedAttribute('path') ?? $this->loadedAttribute('file_path') ?? 'attachment'));
}
/**
* Return the stored byte size, falling back to legacy names.
*/
public function byteSize(): int
{
return (int) ($this->loadedAttribute('size') ?? $this->loadedAttribute('size_bytes') ?? 0);
}
/**
* Return the safe download count when the column exists.
*/
public function downloadCount(): int
{
return (int) ($this->loadedAttribute('download_count') ?? 0);
}
/**
* Determine whether the attachment has been soft deleted.
*/
public function isDeletedAttachment(): bool
{
return filled($this->loadedAttribute('deleted_at'));
}
/**
* Return the lower-case file extension from stored metadata or filename.
*/
public function extension(): string
{
$extension = $this->loadedAttribute('extension');
if (! is_string($extension) || $extension === '') {
$extension = pathinfo($this->displayName(), PATHINFO_EXTENSION);
}
return strtolower(ltrim((string) $extension, '.'));
}
/**
* Return the display category used by Helpdesk attachment cards.
*/
public function category(): string
{
$category = $this->loadedAttribute('category');
if (is_string($category) && $category !== '') {
return $category;
}
return match (true) {
$this->isImage() => 'image',
$this->isPdf() => 'pdf',
$this->isArchive() => 'archive',
$this->isSourceCode() => 'source',
default => 'file',
};
}
/**
* Determine whether the attachment is a browser-previewable image.
*/
public function isImage(): bool
{
return in_array($this->extension(), ['png', 'jpg', 'jpeg', 'webp', 'gif'], true)
|| Str::startsWith((string) $this->loadedAttribute('mime_type'), 'image/');
}
/**
* Determine whether the attachment is a PDF document.
*/
public function isPdf(): bool
{
return $this->extension() === 'pdf' || $this->loadedAttribute('mime_type') === 'application/pdf';
}
/**
* Determine whether the attachment is an archive.
*/
public function isArchive(): bool
{
return in_array($this->extension(), ['zip', 'tar', 'gz', '7z', 'rar'], true);
}
/**
* Determine whether the attachment is source/configuration text safe for the read-only viewer.
*/
public function isSourceCode(): bool
{
return in_array($this->extension(), [
'php', 'phtml', 'phps', 'blade.php', 'js', 'jsx', 'ts', 'tsx', 'vue', 'css', 'scss', 'sass', 'less',
'html', 'htm', 'xml', 'json', 'yaml', 'yml', 'sql', 'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd',
'py', 'rb', 'go', 'rs', 'java', 'kt', 'kts', 'cs', 'cpp', 'c', 'h', 'hpp', 'swift', 'dart',
'rpg', 'rpgle', 'cl', 'clle', 'cbl', 'cob', 'copybook', 'inc', 'env', 'ini', 'conf', 'config', 'txt', 'log', 'md',
], true);
}
/**
* Determine whether the attachment can be streamed inline by the preview route.
*/
public function isPreviewableBinary(): bool
{
return $this->isImage() || $this->isPdf();
}
/**
* Determine whether the Helpdesk UI can show an inline preview or viewer link.
*/
public function canPreview(): bool
{
return $this->isSourceCode() || $this->isPreviewableBinary();
}
/**
* Return a Font Awesome icon for the attachment type.
*/
public function icon(): string
{
return match ($this->category()) {
'image' => 'fa-regular fa-file-image',
'pdf' => 'fa-regular fa-file-pdf',
'source' => 'fa-solid fa-code',
'archive' => 'fa-regular fa-file-zipper',
default => 'fa-regular fa-file-lines',
};
}
/**
* Return the syntax language key used by the source viewer.
*/
public function language(): string
{
return match ($this->extension()) {
'php', 'phtml', 'phps', 'blade.php' => 'php',
'js', 'jsx' => 'javascript',
'ts', 'tsx' => 'typescript',
'vue' => 'vue',
'css', 'scss', 'sass', 'less' => 'css',
'html', 'htm' => 'html',
'xml' => 'xml',
'json' => 'json',
'yaml', 'yml' => 'yaml',
'sql' => 'sql',
'sh', 'bash', 'zsh' => 'shell',
'ps1' => 'powershell',
'py' => 'python',
'java', 'kt', 'kts' => 'java',
'c', 'h', 'cpp', 'hpp', 'cs' => 'c-like',
'rpg', 'rpgle' => 'rpgle',
'cl', 'clle' => 'clle',
'cbl', 'cob', 'copybook' => 'cobol',
default => 'text',
};
}
public function url(): string public function url(): string
{ {
return Storage::disk($this->disk)->url($this->path); return Storage::disk((string) $this->loadedAttribute('disk'))->url((string) $this->loadedAttribute('path'));
} }
} }
+15 -2
View File
@@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Schema;
final class Comment extends Model final class Comment extends Model
{ {
@@ -88,11 +89,23 @@ final class Comment extends Model
return $this->hasMany(Attachment::class, 'comment_id'); return $this->hasMany(Attachment::class, 'comment_id');
} }
/** @param Builder<Comment> $query */ /**
* Scope the query to non-tombstoned comments when the legacy column exists.
*
* Some older Dusk databases can exist before the Lotus replication migration
* added tombstoned_at. In that state every row is active, so avoid issuing an
* invalid column query.
*
* @param Builder<Comment> $query
*
* @return Builder<Comment>
*/
public function scopeActive(Builder $query): Builder public function scopeActive(Builder $query): Builder
{ {
if (! Schema::hasColumn($this->getTable(), 'tombstoned_at')) {
return $query;
}
return $query->whereNull('tombstoned_at'); return $query->whereNull('tombstoned_at');
} }
} }
@@ -6,6 +6,7 @@ namespace Modules\Helpdesk\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider; use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Modules\Helpdesk\Http\Controllers\GitWebhookController;
use Modules\Helpdesk\Http\Controllers\WorkflowOpenController; use Modules\Helpdesk\Http\Controllers\WorkflowOpenController;
use Modules\Helpdesk\Support\ModuleInfo; use Modules\Helpdesk\Support\ModuleInfo;
@@ -46,5 +47,11 @@ final class RouteServiceProvider extends ServiceProvider
->middleware('api') ->middleware('api')
->name('api.helpdesk.') ->name('api.helpdesk.')
->group(module_path(ModuleInfo::name(), 'Routes/api.php')); ->group(module_path(ModuleInfo::name(), 'Routes/api.php'));
Route::prefix('api/git')
->middleware('api')
->name('api.git.')
->post('/gitea/webhook', [GitWebhookController::class, 'gitea'])
->name('gitea.webhook');
} }
} }
+1
View File
@@ -330,3 +330,4 @@ If a full inline spelling engine is required later, add it as a deliberate edito
The ticket timer dispatches `helpdesk-ticket-time-log-updated` after start and stop. The ticket detail Livewire component listens for that event and refreshes ticket aggregates so the time summary and log modal update without a page reload. The ticket timer dispatches `helpdesk-ticket-time-log-updated` after start and stop. The ticket detail Livewire component listens for that event and refreshes ticket aggregates so the time summary and log modal update without a page reload.
Working Github setup with gitea
@@ -0,0 +1,75 @@
@php
$lines = preg_split('/\r\n|\r|\n/', $contents);
$attachmentId = data_get($attachment, 'id');
$attachmentName = (string) data_get($attachment, 'original_name', 'Attachment');
$attachmentSize = (int) data_get($attachment, 'size', 0);
$ticketId = data_get($attachment, 'ticket_id');
$downloadUrl = route('helpdesk.attachments.download', $attachmentId);
$backUrl = is_numeric($ticketId) ? route('helpdesk.tickets.show', ['ticket' => $ticketId]) : route('helpdesk.index');
@endphp
@extends('helpdesk::layouts.master')
@section('content')
<div class="min-h-screen bg-zinc-950 px-4 py-6 text-zinc-100 sm:px-6 lg:px-8">
<div class="mx-auto max-w-7xl space-y-4">
<header class="rounded-2xl border border-zinc-700 bg-zinc-900 p-5 shadow-sm">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="min-w-0">
<p class="text-xs font-black uppercase tracking-[0.18em] text-blue-300">Read-only source viewer</p>
<h1 class="mt-1 truncate text-2xl font-black text-white">{{ $attachmentName }}</h1>
<p class="mt-2 text-sm text-zinc-400">
Language: <span class="font-semibold text-zinc-200">{{ strtoupper($language) }}</span>
<span class="mx-2 text-zinc-600">•</span>
Size: <span class="font-semibold text-zinc-200">{{ Number::fileSize($attachmentSize) }}</span>
</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="{{ $backUrl }}"
class="inline-flex items-center gap-2 rounded-xl bg-zinc-700 px-4 py-2 text-sm font-bold text-white hover:bg-zinc-600">
<i class="fa-solid fa-arrow-left" aria-hidden="true"></i>
Back to ticket
</a>
<a href="{{ route('helpdesk.index') }}"
class="inline-flex items-center gap-2 rounded-xl bg-blue-700 px-4 py-2 text-sm font-bold text-white hover:bg-blue-800">
<i class="fa-solid fa-gauge-high" aria-hidden="true"></i>
Dashboard
</a>
<a href="{{ $downloadUrl }}"
class="inline-flex items-center gap-2 rounded-xl bg-blue-600 px-4 py-2 text-sm font-bold text-white hover:bg-blue-700">
<i class="fa-solid fa-download" aria-hidden="true"></i>
Download
</a>
<button type="button" data-helpdesk-copy-source
class="inline-flex items-center gap-2 rounded-xl bg-green-600 px-4 py-2 text-sm font-bold text-white hover:bg-green-700">
<i class="fa-regular fa-copy" aria-hidden="true"></i>
Copy code
</button>
</div>
</div>
</header>
<section class="overflow-hidden rounded-2xl border border-zinc-700 bg-zinc-950 shadow-sm">
<div class="overflow-auto">
<table class="min-w-full border-collapse font-mono text-xs leading-6">
<tbody>
@foreach ($lines as $index => $line)
<tr class="group hover:bg-zinc-900/80">
<td class="select-none border-r border-zinc-800 bg-zinc-900 px-4 text-right text-zinc-500">{{ $index + 1 }}</td>
<td class="whitespace-pre px-4 text-zinc-100"><code>{{ $line }}</code></td>
</tr>
@endforeach
</tbody>
</table>
</div>
</section>
</div>
</div>
<script>
document.querySelector('[data-helpdesk-copy-source]')?.addEventListener('click', async () => {
await navigator.clipboard.writeText(@js($contents));
});
</script>
@endsection
@@ -11,31 +11,40 @@
$currentLabel = \Modules\Helpdesk\Support\Color::label($currentValue); $currentLabel = \Modules\Helpdesk\Support\Color::label($currentValue);
@endphp @endphp
<div {{ $attributes->class('space-y-3') }}> <div {{ $attributes->class('rounded-xl border border-zinc-200 bg-white p-3 shadow-sm dark:border-zinc-700 dark:bg-zinc-950/50') }}>
<label class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200"> <div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
{{ $label }} <div class="min-w-0 space-y-2">
</label> <label class="block text-xs font-bold uppercase tracking-wide text-zinc-600 dark:text-zinc-300">
{{ $label }}
</label>
<div class="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-end"> <div class="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<div class="space-y-2"> <select
<select wire:model.live="{{ $model }}"
wire:model.live="{{ $model }}" class="h-9 w-full rounded-lg border border-zinc-300 bg-white px-3 text-sm font-semibold text-zinc-900 shadow-sm outline-none transition-colors duration-150 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100"
class="block h-10 w-full rounded-xl border border-zinc-300 bg-white px-3 text-sm font-semibold text-zinc-900 shadow-sm outline-none transition-colors duration-150 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" >
> @foreach ($options as $key => $option)
@foreach ($options as $key => $option) <option value="{{ $key }}" style="color: {{ $option['hex'] }};">
<option value="{{ $key }}" style="color: {{ $option['hex'] }};"> ● {{ $option['name'] }} — {{ $option['hex'] }}
● {{ $option['name'] }} — {{ $option['hex'] }} </option>
</option> @endforeach
@endforeach </select>
</select>
<div class="flex flex-wrap gap-1.5" aria-label="Available Helpdesk colours"> <span
class="inline-flex h-9 min-w-28 items-center justify-center rounded-lg px-3 text-xs font-bold shadow-sm ring-1 ring-zinc-900/10 dark:ring-white/10"
style="background-color: {{ $currentHex }}; color: {{ $currentText }};"
>
{{ $currentLabel }}
</span>
</div>
<div class="flex flex-wrap items-center gap-1.5" aria-label="Available Helpdesk colours">
@foreach ($options as $key => $option) @foreach ($options as $key => $option)
<button <button
type="button" type="button"
wire:click="$set('{{ $model }}', '{{ $key }}')" wire:click="$set('{{ $model }}', '{{ $key }}')"
title="{{ $option['name'] }} {{ $option['hex'] }}" title="{{ $option['name'] }} {{ $option['hex'] }}"
class="inline-flex h-6 min-w-6 items-center justify-center rounded-full border border-white/80 shadow-sm ring-1 ring-zinc-900/10 transition hover:scale-105 focus:outline-none focus:ring-2 focus:ring-blue-500/40" class="inline-flex h-6 w-6 items-center justify-center rounded-full border border-white/80 shadow-sm ring-1 ring-zinc-900/10 transition hover:scale-105 focus:outline-none focus:ring-2 focus:ring-blue-500/40 dark:ring-white/15"
style="background-color: {{ $option['hex'] }}" style="background-color: {{ $option['hex'] }}"
> >
<span class="sr-only">Use {{ $option['name'] }}</span> <span class="sr-only">Use {{ $option['name'] }}</span>
@@ -44,28 +53,22 @@
</div> </div>
</div> </div>
<span <div class="grid grid-cols-[2.75rem_minmax(0,11rem)] items-center gap-2 lg:justify-end">
class="inline-flex min-w-28 items-center justify-center rounded-full px-3 py-2 text-xs font-bold shadow-sm ring-1 ring-zinc-900/10" <input
style="background-color: {{ $currentHex }}; color: {{ $currentText }};" type="color"
> value="{{ $currentHex }}"
{{ $currentLabel }} wire:change="$set('{{ $model }}', $event.target.value)"
</span> class="h-9 w-11 cursor-pointer rounded-lg border border-zinc-300 bg-white p-1 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"
</div> aria-label="Choose a custom hex colour"
/>
<div class="grid gap-3 md:grid-cols-[auto_minmax(0,1fr)] md:items-center"> <input
<input type="text"
type="color" wire:model.live.debounce.500ms="{{ $model }}"
value="{{ $currentHex }}" class="h-9 w-full rounded-lg border border-zinc-300 bg-white px-3 text-xs font-semibold text-zinc-900 placeholder:text-zinc-400 shadow-sm outline-none transition-colors duration-150 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:placeholder:text-zinc-500"
wire:change="$set('{{ $model }}', $event.target.value)" placeholder="#ffffff"
class="h-10 w-16 cursor-pointer rounded-xl border border-zinc-300 bg-white p-1 shadow-sm dark:border-zinc-700 dark:bg-zinc-950" aria-label="Custom hex colour"
aria-label="Choose a custom hex colour" />
/> </div>
<flux:input
wire:model.live.debounce.500ms="{{ $model }}"
label="Custom hex colour"
placeholder="#ffffff"
description="Use the standard palette where possible. Custom hex values must use six characters, for example #ffffff."
/>
</div> </div>
</div> </div>
@@ -1,87 +1,134 @@
@props([ @props([
'model' => 'body',
'label' => 'Details', 'label' => 'Details',
'placeholder' => 'Describe the ticket, include steps to reproduce, expected result, actual result and any useful technical details.',
'rows' => 10, 'rows' => 10,
'placeholder' => 'Write your message...',
'editorDusk' => null, 'editorDusk' => null,
]) ])
@php <div
$wireModel = $attributes->wire('model'); class="space-y-2"
$model = $wireModel->value(); x-data="{
$initialValue = $model ? data_get($this, $model, '') : '';
@endphp
<div {{ $attributes->whereDoesntStartWith('wire:model')->merge(['class' => 'space-y-2']) }}
@if ($model) data-helpdesk-editor-model="{{ $model }}" @endif
x-on:helpdesk-editor-reset.window="if ($event.detail.model === model) clear()"
x-on:helpdesk-editor-sync.window="if ($event.detail.model === model) sync()" x-data="{
model: @js($model), model: @js($model),
value: @js($initialValue), value: @entangle($model).defer,
sync() {
this.value = this.$refs.editor.innerHTML;
if (this.model) {
this.$wire.set(this.model, this.value, false);
}
},
command(action, value = null) {
this.$refs.editor.focus();
document.execCommand(action, false, value);
this.sync();
},
clear() {
this.value = '';
this.$refs.editor.innerHTML = '';
if (this.model) {
this.$wire.set(this.model, '');
}
},
init() { init() {
this.$refs.editor.innerHTML = this.value || ''; this.$nextTick(() => {
} this.writeEditorValue(this.value || '');
}"> });
<label class="block text-sm font-medium text-zinc-700 dark:text-zinc-200">{{ $label }}</label> },
writeEditorValue(value) {
if (!this.$refs.editor) {
return;
}
@if ($model) this.$refs.editor.textContent = value || '';
<input type="hidden" name="{{ $model }}" x-bind:value="value"> },
@endif syncToLivewire(live = false) {
const value = this.$refs.editor?.innerText || '';
<div class="overflow-hidden rounded-xl border border-zinc-300 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-950" this.value = value;
wire:ignore> this.$refs.input.value = value;
<div class="flex flex-wrap gap-1 border-b border-zinc-200 bg-zinc-50 p-2 dark:border-zinc-800 dark:bg-zinc-900"> this.$refs.input.dispatchEvent(new Event('input', { bubbles: true }));
<button type="button" x-on:click="command('bold')" this.$wire.set(this.model, this.value, live);
class="rounded-lg px-2 py-1 text-sm font-bold hover:bg-zinc-200 dark:hover:bg-zinc-800">B</button> },
<button type="button" x-on:click="command('italic')" resetEditor() {
class="rounded-lg px-2 py-1 text-sm italic hover:bg-zinc-200 dark:hover:bg-zinc-800">I</button> this.value = '';
<button type="button" x-on:click="command('underline')" this.writeEditorValue('');
class="rounded-lg px-2 py-1 text-sm underline hover:bg-zinc-200 dark:hover:bg-zinc-800">U</button> this.syncToLivewire(false);
<button type="button" x-on:click="command('insertUnorderedList')" },
class="rounded-lg px-2 py-1 text-sm hover:bg-zinc-200 dark:hover:bg-zinc-800">• List</button> runCommand(command) {
<button type="button" x-on:click="command('insertOrderedList')" this.$refs.editor?.focus();
class="rounded-lg px-2 py-1 text-sm hover:bg-zinc-200 dark:hover:bg-zinc-800">1. List</button> document.execCommand(command, false, null);
<button type="button" x-on:click="command('formatBlock', 'blockquote')" this.syncToLivewire(false);
class="rounded-lg px-2 py-1 text-sm hover:bg-zinc-200 dark:hover:bg-zinc-800">Quote</button> },
<button type="button" x-on:click="const url = prompt('URL'); if (url) command('createLink', url)" wrapBlock(tagName) {
class="rounded-lg px-2 py-1 text-sm hover:bg-zinc-200 dark:hover:bg-zinc-800">Link</button> this.$refs.editor?.focus();
<button type="button" x-on:click="command('removeFormat')" document.execCommand('formatBlock', false, tagName);
class="rounded-lg px-2 py-1 text-sm hover:bg-zinc-200 dark:hover:bg-zinc-800">Clear</button> this.syncToLivewire(false);
},
}"
x-on:helpdesk-editor-sync.window="if (! $event.detail?.model || $event.detail.model === @js($model)) syncToLivewire(false)"
x-on:helpdesk-editor-reset.window="if (! $event.detail?.model || $event.detail.model === @js($model)) resetEditor()"
>
<label for="{{ $model }}-editor" class="block text-sm font-semibold text-zinc-200">
{{ $label }}
</label>
<div class="overflow-hidden rounded-2xl border border-zinc-700 bg-zinc-950 shadow-sm">
<div class="flex flex-wrap items-center gap-1.5 border-b border-zinc-700 bg-zinc-900/80 px-4 py-2 text-sm text-zinc-300" data-helpdesk-editor-toolbar>
<span class="mr-2 rounded-lg bg-zinc-800 px-2 py-1 text-xs font-black uppercase tracking-wide text-zinc-100">Spellcheck enabled</span>
<button type="button" x-on:click="runCommand('bold')" data-helpdesk-editor-command="bold"
class="inline-flex min-h-8 items-center justify-center rounded-lg border border-zinc-700 bg-zinc-950 px-3 py-1.5 text-xs font-black text-zinc-100 shadow-sm transition hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-400/70"
aria-label="Bold selected text">
B
</button>
<button type="button" x-on:click="runCommand('italic')" data-helpdesk-editor-command="italic"
class="inline-flex min-h-8 items-center justify-center rounded-lg border border-zinc-700 bg-zinc-950 px-3 py-1.5 text-xs font-black italic text-zinc-100 shadow-sm transition hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-400/70"
aria-label="Italic selected text">
I
</button>
<button type="button" x-on:click="runCommand('underline')" data-helpdesk-editor-command="underline"
class="inline-flex min-h-8 items-center justify-center rounded-lg border border-zinc-700 bg-zinc-950 px-3 py-1.5 text-xs font-black underline text-zinc-100 shadow-sm transition hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-400/70"
aria-label="Underline selected text">
U
</button>
<button type="button" x-on:click="runCommand('insertUnorderedList')" data-helpdesk-editor-command="insertUnorderedList"
class="inline-flex min-h-8 items-center justify-center rounded-lg border border-zinc-700 bg-zinc-950 px-3 py-1.5 text-xs font-black text-zinc-100 shadow-sm transition hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-400/70">
• List
</button>
<button type="button" x-on:click="runCommand('insertOrderedList')" data-helpdesk-editor-command="insertOrderedList"
class="inline-flex min-h-8 items-center justify-center rounded-lg border border-zinc-700 bg-zinc-950 px-3 py-1.5 text-xs font-black text-zinc-100 shadow-sm transition hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-400/70">
1. List
</button>
<button type="button" x-on:click="wrapBlock('blockquote')" data-helpdesk-editor-command="blockquote"
class="inline-flex min-h-8 items-center justify-center rounded-lg border border-zinc-700 bg-zinc-950 px-3 py-1.5 text-xs font-black text-zinc-100 shadow-sm transition hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-400/70">
Quote
</button>
<button type="button" x-on:click="wrapBlock('pre')" data-helpdesk-editor-command="pre"
class="inline-flex min-h-8 items-center justify-center rounded-lg border border-zinc-700 bg-zinc-950 px-3 py-1.5 text-xs font-black text-zinc-100 shadow-sm transition hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-400/70">
Code
</button>
</div> </div>
<div x-ref="editor" contenteditable="true" spellcheck="true" lang="en-GB" autocapitalize="sentences" autocorrect="on" autocomplete="on" inputmode="text" <div
id="{{ $model }}-editor"
x-ref="editor"
contenteditable="true"
spellcheck="true"
lang="en-GB"
autocorrect="on"
autocomplete="on"
autocapitalize="sentences"
inputmode="text"
role="textbox"
aria-multiline="true"
data-placeholder="{{ $placeholder }}"
data-wire-model="{{ $model }}"
data-helpdesk-sync-target="{{ $model }}"
data-helpdesk-editor-model="{{ $model }}"
@if ($editorDusk) data-dusk="{{ $editorDusk }}" dusk="{{ $editorDusk }}" @endif @if ($editorDusk) data-dusk="{{ $editorDusk }}" dusk="{{ $editorDusk }}" @endif
x-on:paste="$nextTick(() => sync())" x-on:blur="sync" x-on:blur="syncToLivewire(false)"
class="prose prose-sm min-h-[{{ (int) $rows * 28 }}px] max-w-none bg-white p-4 text-sm text-zinc-900 outline-none dark:prose-invert dark:bg-zinc-950 dark:text-zinc-100" class="helpdesk-rich-editor block min-h-[14rem] w-full overflow-y-auto whitespace-pre-wrap break-words border-0 bg-zinc-950 px-5 py-4 text-base leading-7 text-zinc-100 outline-none transition placeholder:text-zinc-500 empty:before:pointer-events-none empty:before:text-zinc-500 empty:before:content-[attr(data-placeholder)] focus:ring-2 focus:ring-blue-500"
data-placeholder="{{ $placeholder }}"></div> ></div>
<textarea
x-ref="input"
wire:model.defer="{{ $model }}"
spellcheck="true"
lang="en-GB"
autocorrect="on"
autocomplete="on"
autocapitalize="sentences"
inputmode="text"
class="sr-only"
tabindex="-1"
aria-hidden="true"
data-helpdesk-spellcheck-mirror="{{ $model }}"
></textarea>
</div> </div>
<p class="text-xs font-semibold text-zinc-400">
<p class="text-xs font-semibold text-zinc-500 dark:text-zinc-400"> Browser spellcheck stays active in the native editable area. Formatting is synchronised only on blur and submit to avoid Livewire keypress regressions.
Browser spellcheck is enabled for this rich editor. Use your browser or operating-system spelling menu for suggestions.
</p> </p>
@if ($model)
@error($model)
<div class="text-sm text-red-600">{{ $message }}</div>
@enderror
@endif
</div> </div>
@@ -3,33 +3,33 @@
<thead <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"> 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> <tr>
<th class="px-4 py-3">Name</th> <th class="px-3 py-2.5">Name</th>
<th class="px-4 py-3">Slug</th> <th class="px-3 py-2.5">Slug</th>
<th class="px-4 py-3">Colour</th> <th class="px-3 py-2.5">Colour</th>
<th class="px-4 py-3">Default</th> <th class="px-3 py-2.5">Default</th>
<th class="px-4 py-3">Tickets</th> <th class="px-3 py-2.5">Tickets</th>
<th class="px-4 py-3 text-right">Actions</th> <th class="px-3 py-2.5 text-right">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800"> <tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
@forelse ($items as $item) @forelse ($items as $item)
<tr> <tr>
<td class="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">{{ $item->name }}</td> <td class="px-3 py-2.5 font-medium text-zinc-900 dark:text-zinc-100">{{ $item->name }}</td>
<td class="px-4 py-3 font-mono text-xs text-zinc-500">{{ $item->slug }}</td> <td class="px-3 py-2.5 font-mono text-xs text-zinc-500">{{ $item->slug }}</td>
<td class="px-4 py-3"> <td class="px-3 py-2.5">
<span class="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white px-2.5 py-1 text-xs font-semibold text-zinc-700 shadow-sm dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-200"> <span class="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white px-2.5 py-1 text-xs font-semibold text-zinc-700 shadow-sm dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-200">
<span class="h-3 w-3 rounded-full" style="background-color: {{ \Modules\Helpdesk\Support\Color::css($item->color) }}"></span> <span class="h-3 w-3 rounded-full" style="background-color: {{ \Modules\Helpdesk\Support\Color::css($item->color) }}"></span>
{{ \Illuminate\Support\Str::of(\Modules\Helpdesk\Support\Color::normalise($item->color))->headline() }} {{ \Illuminate\Support\Str::of(\Modules\Helpdesk\Support\Color::normalise($item->color))->headline() }}
</span> </span>
</td> </td>
<td class="px-4 py-3"> <td class="px-3 py-2.5">
<flux:badge color="{{ $item->is_default ?? false ? 'emerald' : 'zinc' }}"> <flux:badge color="{{ $item->is_default ?? false ? 'emerald' : 'zinc' }}">
{{ $item->is_default ?? false ? 'Default' : 'No' }} {{ $item->is_default ?? false ? 'Default' : 'No' }}
</flux:badge> </flux:badge>
</td> </td>
<td class="px-4 py-3 text-zinc-500">{{ $item->tickets_count ?? 0 }}</td> <td class="px-3 py-2.5 text-zinc-500">{{ $item->tickets_count ?? 0 }}</td>
<td class="px-4 py-3 text-right"> <td class="px-3 py-2.5 text-right">
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-1.5">
<button type="button" wire:click="{{ $edit }}({{ $item->id }})" <button type="button" wire:click="{{ $edit }}({{ $item->id }})"
class="hd-btn hd-btn-sm hd-btn-blue">Edit</button> class="hd-btn hd-btn-sm hd-btn-blue">Edit</button>
<button type="button" wire:click="{{ $delete }}({{ $item->id }})" <button type="button" wire:click="{{ $delete }}({{ $item->id }})"
@@ -39,7 +39,7 @@
</tr> </tr>
@empty @empty
<tr> <tr>
<td colspan="6" class="px-4 py-10 text-center text-zinc-500">No records yet.</td> <td colspan="6" class="px-3 py-8 text-center text-zinc-500">No records yet.</td>
</tr> </tr>
@endforelse @endforelse
</tbody> </tbody>
@@ -3,34 +3,34 @@
<thead <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"> 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> <tr>
<th class="px-4 py-3">Project</th> <th class="px-3 py-2.5">Project</th>
<th class="px-4 py-3">Company</th> <th class="px-3 py-2.5">Company</th>
<th class="px-4 py-3">Team</th> <th class="px-3 py-2.5">Team</th>
<th class="px-4 py-3">Colour</th> <th class="px-3 py-2.5">Colour</th>
<th class="px-4 py-3">Default</th> <th class="px-3 py-2.5">Default</th>
<th class="px-4 py-3">Tickets</th> <th class="px-3 py-2.5">Tickets</th>
<th class="px-4 py-3 text-right">Actions</th> <th class="px-3 py-2.5 text-right">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800"> <tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
@forelse ($items as $item) @forelse ($items as $item)
<tr> <tr>
<td class="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">{{ $item->title }}</td> <td class="px-3 py-2.5 font-medium text-zinc-900 dark:text-zinc-100">{{ $item->title }}</td>
<td class="px-4 py-3 text-zinc-500">{{ $item->company?->name ?? 'Global' }}</td> <td class="px-3 py-2.5 text-zinc-500">{{ $item->company?->name ?? 'Global' }}</td>
<td class="px-4 py-3 text-zinc-500">{{ $item->team?->name ?? 'No team' }}</td> <td class="px-3 py-2.5 text-zinc-500">{{ $item->team?->name ?? 'No team' }}</td>
<td class="px-4 py-3"> <td class="px-3 py-2.5">
<span class="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white px-2.5 py-1 text-xs font-semibold text-zinc-700 shadow-sm dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-200"> <span class="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white px-2.5 py-1 text-xs font-semibold text-zinc-700 shadow-sm dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-200">
<span class="h-3 w-3 rounded-full" style="background-color: {{ \Modules\Helpdesk\Support\Color::css($item->color) }}"></span> <span class="h-3 w-3 rounded-full" style="background-color: {{ \Modules\Helpdesk\Support\Color::css($item->color) }}"></span>
{{ \Illuminate\Support\Str::of(\Modules\Helpdesk\Support\Color::normalise($item->color))->headline() }} {{ \Illuminate\Support\Str::of(\Modules\Helpdesk\Support\Color::normalise($item->color))->headline() }}
</span> </span>
</td> </td>
<td class="px-4 py-3"> <td class="px-3 py-2.5">
<flux:badge color="{{ $item->is_default ?? false ? 'emerald' : 'zinc' }}"> <flux:badge color="{{ $item->is_default ?? false ? 'emerald' : 'zinc' }}">
{{ $item->is_default ?? false ? 'Default' : 'No' }}</flux:badge> {{ $item->is_default ?? false ? 'Default' : 'No' }}</flux:badge>
</td> </td>
<td class="px-4 py-3 text-zinc-500">{{ $item->tickets_count ?? 0 }}</td> <td class="px-3 py-2.5 text-zinc-500">{{ $item->tickets_count ?? 0 }}</td>
<td class="px-4 py-3 text-right"> <td class="px-3 py-2.5 text-right">
<div class="flex justify-end gap-2"><button type="button" <div class="flex justify-end gap-1.5"><button type="button"
wire:click="editProject({{ $item->id }})" wire:click="editProject({{ $item->id }})"
class="hd-btn hd-btn-sm hd-btn-blue">Edit</button><button type="button" class="hd-btn hd-btn-sm hd-btn-blue">Edit</button><button type="button"
wire:click="deleteProject({{ $item->id }})" wire:click="deleteProject({{ $item->id }})"
@@ -39,7 +39,7 @@
</tr> </tr>
@empty @empty
<tr> <tr>
<td colspan="7" class="px-4 py-10 text-center text-zinc-500">No projects yet.</td> <td colspan="7" class="px-3 py-8 text-center text-zinc-500">No projects yet.</td>
</tr> </tr>
@endforelse @endforelse
</tbody> </tbody>
@@ -3,22 +3,22 @@
<thead <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"> 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> <tr>
<th class="px-4 py-3">Name</th> <th class="px-3 py-2.5">Name</th>
<th class="px-4 py-3">Provider</th> <th class="px-3 py-2.5">Provider</th>
<th class="px-4 py-3">Project</th> <th class="px-3 py-2.5">Project</th>
<th class="px-4 py-3">Commits</th> <th class="px-3 py-2.5">Commits</th>
<th class="px-4 py-3 text-right">Actions</th> <th class="px-3 py-2.5 text-right">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800"> <tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
@forelse ($items as $item) @forelse ($items as $item)
<tr> <tr>
<td class="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">{{ $item->name }}</td> <td class="px-3 py-2.5 font-medium text-zinc-900 dark:text-zinc-100">{{ $item->name }}</td>
<td class="px-4 py-3 text-zinc-500">{{ strtoupper($item->provider) }}</td> <td class="px-3 py-2.5 text-zinc-500">{{ strtoupper($item->provider) }}</td>
<td class="px-4 py-3 text-zinc-500">{{ $item->project?->title ?? 'No project' }}</td> <td class="px-3 py-2.5 text-zinc-500">{{ $item->project?->title ?? 'No project' }}</td>
<td class="px-4 py-3 text-zinc-500">{{ $item->commits_count ?? 0 }}</td> <td class="px-3 py-2.5 text-zinc-500">{{ $item->commits_count ?? 0 }}</td>
<td class="px-4 py-3 text-right"> <td class="px-3 py-2.5 text-right">
<div class="flex justify-end gap-2"><button type="button" <div class="flex justify-end gap-1.5"><button type="button"
wire:click="editRepository({{ $item->id }})" wire:click="editRepository({{ $item->id }})"
class="hd-btn hd-btn-sm hd-btn-blue">Edit</button><button type="button" class="hd-btn hd-btn-sm hd-btn-blue">Edit</button><button type="button"
wire:click="deleteRepository({{ $item->id }})" wire:click="deleteRepository({{ $item->id }})"
@@ -28,7 +28,7 @@
</tr> </tr>
@empty @empty
<tr> <tr>
<td colspan="5" class="px-4 py-10 text-center text-zinc-500">No repositories yet.</td> <td colspan="5" class="px-3 py-8 text-center text-zinc-500">No repositories yet.</td>
</tr> </tr>
@endforelse @endforelse
</tbody> </tbody>
@@ -1,9 +1,33 @@
@props([
'title' => 'Settings',
'description' => '',
])
<section <section
class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900"> {{ $attributes->merge([
<div class="border-b border-zinc-200 bg-zinc-50 px-5 py-4 dark:border-zinc-700 dark:bg-zinc-800/70"> 'class' => 'overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm ring-1 ring-zinc-900/5 dark:border-zinc-700 dark:bg-zinc-900 dark:ring-white/10',
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white">{{ $title }}</h2> ]) }}>
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">{{ $description }}</p> <div class="border-b border-zinc-200 bg-gradient-to-r from-slate-950 via-blue-950 to-slate-900 px-4 py-3 text-white dark:border-zinc-700">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<p class="text-[10px] font-black uppercase tracking-[0.22em] text-blue-200">Configuration</p>
<h2 class="mt-0.5 text-base font-bold tracking-tight text-white">{{ $title }}</h2>
<p class="mt-1 max-w-3xl text-xs font-medium leading-5 text-blue-100/90 sm:text-sm">{{ $description }}</p>
</div>
<span class="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border border-white/10 bg-white/10 text-blue-100 shadow-inner">
<i class="fa-solid fa-sliders text-xs" aria-hidden="true"></i>
</span>
</div>
</div>
<div class="border-b border-zinc-200 bg-zinc-50 px-3 py-3 dark:border-zinc-800 dark:bg-zinc-950/45 sm:px-4">
<div class="mx-auto max-w-7xl rounded-xl border border-zinc-200 bg-white p-3 shadow-sm dark:border-zinc-700 dark:bg-zinc-900/90 sm:p-4 [&_input:not([type='checkbox']):not([type='radio']):not([type='color'])]:min-h-9 [&_textarea]:min-h-20 [&_textarea]:leading-6">
{{ $form }}
</div>
</div>
<div class="bg-white dark:bg-zinc-900">
{{ $slot }}
</div> </div>
<div class="border-b border-zinc-200 p-5 dark:border-zinc-700">{{ $form }}</div>
<div>{{ $slot }}</div>
</section> </section>
@@ -3,32 +3,32 @@
<thead <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"> 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> <tr>
<th class="px-4 py-3">Team</th> <th class="px-3 py-2.5">Team</th>
<th class="px-4 py-3">Company</th> <th class="px-3 py-2.5">Company</th>
<th class="px-4 py-3">Colour</th> <th class="px-3 py-2.5">Colour</th>
<th class="px-4 py-3">Default</th> <th class="px-3 py-2.5">Default</th>
<th class="px-4 py-3">Tickets</th> <th class="px-3 py-2.5">Tickets</th>
<th class="px-4 py-3 text-right">Actions</th> <th class="px-3 py-2.5 text-right">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800"> <tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
@forelse ($items as $item) @forelse ($items as $item)
<tr> <tr>
<td class="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">{{ $item->name }}</td> <td class="px-3 py-2.5 font-medium text-zinc-900 dark:text-zinc-100">{{ $item->name }}</td>
<td class="px-4 py-3 text-zinc-500">{{ $item->company?->name ?? 'Global' }}</td> <td class="px-3 py-2.5 text-zinc-500">{{ $item->company?->name ?? 'Global' }}</td>
<td class="px-4 py-3"> <td class="px-3 py-2.5">
<span class="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white px-2.5 py-1 text-xs font-semibold text-zinc-700 shadow-sm dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-200"> <span class="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white px-2.5 py-1 text-xs font-semibold text-zinc-700 shadow-sm dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-200">
<span class="h-3 w-3 rounded-full" style="background-color: {{ \Modules\Helpdesk\Support\Color::css($item->color) }}"></span> <span class="h-3 w-3 rounded-full" style="background-color: {{ \Modules\Helpdesk\Support\Color::css($item->color) }}"></span>
{{ \Illuminate\Support\Str::of(\Modules\Helpdesk\Support\Color::normalise($item->color))->headline() }} {{ \Illuminate\Support\Str::of(\Modules\Helpdesk\Support\Color::normalise($item->color))->headline() }}
</span> </span>
</td> </td>
<td class="px-4 py-3"> <td class="px-3 py-2.5">
<flux:badge color="{{ $item->is_default ?? false ? 'emerald' : 'zinc' }}"> <flux:badge color="{{ $item->is_default ?? false ? 'emerald' : 'zinc' }}">
{{ $item->is_default ?? false ? 'Default' : 'No' }}</flux:badge> {{ $item->is_default ?? false ? 'Default' : 'No' }}</flux:badge>
</td> </td>
<td class="px-4 py-3 text-zinc-500">{{ $item->tickets_count ?? 0 }}</td> <td class="px-3 py-2.5 text-zinc-500">{{ $item->tickets_count ?? 0 }}</td>
<td class="px-4 py-3 text-right"> <td class="px-3 py-2.5 text-right">
<div class="flex justify-end gap-2"><button type="button" <div class="flex justify-end gap-1.5"><button type="button"
wire:click="editTeam({{ $item->id }})" wire:click="editTeam({{ $item->id }})"
class="hd-btn hd-btn-sm hd-btn-blue">Edit</button><button type="button" class="hd-btn hd-btn-sm hd-btn-blue">Edit</button><button type="button"
wire:click="deleteTeam({{ $item->id }})" wire:click="deleteTeam({{ $item->id }})"
@@ -37,7 +37,7 @@
</tr> </tr>
@empty @empty
<tr> <tr>
<td colspan="6" class="px-4 py-10 text-center text-zinc-500">No teams yet.</td> <td colspan="6" class="px-3 py-8 text-center text-zinc-500">No teams yet.</td>
</tr> </tr>
@endforelse @endforelse
</tbody> </tbody>
@@ -5,29 +5,29 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: .5rem; gap: .5rem;
border-radius: .85rem; border-radius: .65rem;
padding: .62rem 1.05rem; padding: .46rem .78rem;
font-size: .875rem; font-size: .8125rem;
line-height: 1.25rem; line-height: 1.25rem;
font-weight: 800; font-weight: 800;
color: #ffffff !important; color: #ffffff !important;
text-decoration: none !important; text-decoration: none !important;
border: 1px solid rgba(255, 255, 255, .14); border: 1px solid rgba(255, 255, 255, .14);
box-shadow: 0 12px 28px rgba(0, 0, 0, .22); box-shadow: 0 8px 18px rgba(0, 0, 0, .18);
transition: background-color .15s ease, transform .15s ease, box-shadow .15s ease; transition: background-color .15s ease, transform .15s ease, box-shadow .15s ease;
} }
.hd-helpdesk-surface .hd-btn:hover, .hd-helpdesk-surface .hd-btn:hover,
.hd-btn:hover { .hd-btn:hover {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 16px 34px rgba(0, 0, 0, .30); box-shadow: 0 10px 22px rgba(0, 0, 0, .24);
} }
.hd-helpdesk-surface .hd-btn-sm, .hd-helpdesk-surface .hd-btn-sm,
.hd-btn-sm { .hd-btn-sm {
border-radius: .65rem; border-radius: .55rem;
padding: .42rem .78rem; padding: .32rem .62rem;
font-size: .8125rem; font-size: .75rem;
} }
.hd-helpdesk-surface .hd-btn-blue, .hd-helpdesk-surface .hd-btn-blue,
@@ -174,52 +174,94 @@
} }
</style> </style>
<div class="hd-helpdesk-surface space-y-6"> <div class="hd-helpdesk-surface space-y-5">
<div <div class="overflow-hidden rounded-2xl border border-blue-400/20 bg-slate-950 shadow-sm ring-1 ring-white/5">
class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900"> <div class="relative overflow-hidden bg-[radial-gradient(circle_at_78%_18%,rgba(37,99,235,0.28),transparent_34%),linear-gradient(135deg,#020617_0%,#07112d_48%,#111827_100%)] px-5 py-5 text-white sm:px-6">
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-8 text-white"> <div class="grid items-center gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(26rem,34rem)]">
<div class="grid gap-6 lg:grid-cols-[1fr_320px] lg:items-center"> <div class="relative z-10 max-w-4xl">
<div> <p class="text-[0.67rem] font-black uppercase tracking-[0.26em] text-blue-200">Support operations</p>
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">Support operations</p> <h1 class="mt-1 text-2xl font-black tracking-tight md:text-[1.7rem]">Helpdesk dashboard</h1>
<h1 class="mt-2 text-3xl font-semibold tracking-tight md:text-4xl">Helpdesk dashboard</h1> <div class="helpdesk-dusk-labels sr-only">
<p class="mt-3 max-w-3xl text-sm leading-6 text-blue-100/90">Track open workload, closed tickets, <span>Total tickets</span>
response performance, project activity, priorities, Git commits and workflow communication from <span>Open tickets</span>
one professional support workspace.</p> <span>Closed tickets</span>
<div class="mt-5 flex flex-wrap gap-2"> </div>
<a href="{{ route('helpdesk.tickets.create') }}" wire:navigate class="hd-btn hd-btn-green">
<p class="mt-2 max-w-3xl text-sm leading-5 text-blue-100/85">
Track workload, SLA health, project activity, Git commits and support communication from one professional workspace.
</p>
<div class="mt-4 flex flex-wrap gap-2">
<a href="{{ route('helpdesk.tickets.create') }}" wire:navigate class="hd-btn hd-btn-sm hd-btn-green">
<i class="fa-solid fa-plus" aria-hidden="true"></i> <i class="fa-solid fa-plus" aria-hidden="true"></i>
<span>New Ticket</span> <span>New Ticket</span>
</a> </a>
<a href="{{ route('helpdesk.tickets.index') }}" wire:navigate class="hd-btn hd-btn-blue"> <a href="{{ route('helpdesk.tickets.index') }}" wire:navigate class="hd-btn hd-btn-sm hd-btn-blue">
<i class="fa-solid fa-ticket" aria-hidden="true"></i> <i class="fa-solid fa-ticket" aria-hidden="true"></i>
<span>Active tickets</span> <span>Active tickets</span>
</a> </a>
<a href="{{ route('helpdesk.kanban') }}" wire:navigate class="hd-btn hd-btn-amber"> <a href="{{ route('helpdesk.kanban') }}" wire:navigate class="hd-btn hd-btn-sm hd-btn-amber">
<i class="fa-solid fa-table-columns" aria-hidden="true"></i> <i class="fa-solid fa-table-columns" aria-hidden="true"></i>
<span>Kanban</span> <span>Kanban</span>
</a> </a>
<a href="{{ route('helpdesk.settings') }}" wire:navigate class="hd-btn hd-btn-violet"> <a href="{{ route('helpdesk.settings') }}" wire:navigate class="hd-btn hd-btn-sm hd-btn-violet">
<i class="fa-solid fa-gear" aria-hidden="true"></i> <i class="fa-solid fa-gear" aria-hidden="true"></i>
<span>Settings</span> <span>Settings</span>
</a> </a>
</div> </div>
</div> </div>
<div class="rounded-2xl border border-white/10 bg-white/10 p-5 shadow-2xl backdrop-blur"> <div class="relative hidden min-h-[8.5rem] lg:block" aria-hidden="true">
<div class="grid grid-cols-3 gap-2"> <div class="absolute inset-0 rounded-2xl bg-[radial-gradient(circle_at_30%_20%,rgba(59,130,246,0.22),transparent_36%),radial-gradient(circle_at_88%_76%,rgba(16,185,129,0.16),transparent_30%)]"></div>
@foreach (range(1, 9) as $cell) <div class="relative h-full rounded-2xl border border-blue-300/15 bg-slate-950/70 p-4 shadow-2xl shadow-blue-950/30 backdrop-blur-sm">
<div class="h-14 rounded-xl bg-white/15"></div> <div class="flex items-start justify-between gap-4 border-b border-white/10 pb-3">
@endforeach <div>
</div> <p class="text-[0.62rem] font-black uppercase tracking-[0.24em] text-blue-200">Support workflow</p>
<div class="mt-4 rounded-xl bg-white/10 p-4"> <p class="mt-0.5 text-[0.72rem] text-slate-400">Tickets, replies, files, commits and SLA</p>
<div class="h-2 rounded-full bg-blue-300/80"></div> </div>
<div class="mt-3 h-2 w-3/4 rounded-full bg-emerald-300/80"></div> <div class="flex shrink-0 gap-1.5 pt-1">
<div class="mt-3 h-2 w-1/2 rounded-full bg-rose-300/80"></div> <span class="h-2 w-2 rounded-full bg-emerald-400 shadow-[0_0_12px_rgba(52,211,153,0.8)]"></span>
<span class="h-2 w-2 rounded-full bg-amber-400 shadow-[0_0_12px_rgba(251,191,36,0.8)]"></span>
<span class="h-2 w-2 rounded-full bg-rose-400 shadow-[0_0_12px_rgba(251,113,133,0.8)]"></span>
</div>
</div>
<div class="mt-3 grid grid-cols-4 items-stretch gap-2">
<div class="rounded-xl border border-blue-400/25 bg-blue-500/10 px-2 py-2 text-center">
<i class="fa-solid fa-ticket text-sm text-blue-300"></i>
<p class="mt-1 text-[0.58rem] font-black uppercase tracking-wide text-white">New</p>
</div>
<div class="rounded-xl border border-amber-400/25 bg-amber-500/10 px-2 py-2 text-center">
<i class="fa-solid fa-user-gear text-sm text-amber-300"></i>
<p class="mt-1 text-[0.58rem] font-black uppercase tracking-wide text-white">Assigned</p>
</div>
<div class="rounded-xl border border-violet-400/25 bg-violet-500/10 px-2 py-2 text-center">
<i class="fa-solid fa-comments text-sm text-violet-300"></i>
<p class="mt-1 text-[0.58rem] font-black uppercase tracking-wide text-white">Response</p>
</div>
<div class="rounded-xl border border-emerald-400/25 bg-emerald-500/10 px-2 py-2 text-center">
<i class="fa-solid fa-circle-check text-sm text-emerald-300"></i>
<p class="mt-1 text-[0.58rem] font-black uppercase tracking-wide text-white">Resolved</p>
</div>
</div>
<div class="mt-2 grid grid-cols-3 gap-2">
<div class="rounded-lg border border-white/10 bg-slate-900/65 px-2 py-1.5 text-[0.62rem] font-bold text-slate-200">
<i class="fa-solid fa-paperclip mr-1 text-blue-300"></i> Attachments
</div>
<div class="rounded-lg border border-white/10 bg-slate-900/65 px-2 py-1.5 text-[0.62rem] font-bold text-slate-200">
<i class="fa-solid fa-code-branch mr-1 text-violet-300"></i> Commits
</div>
<div class="rounded-lg border border-white/10 bg-slate-900/65 px-2 py-1.5 text-[0.62rem] font-bold text-slate-200">
<i class="fa-solid fa-stopwatch mr-1 text-emerald-300"></i> SLA
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<livewire:helpdesk.dashboard-widget /> <livewire:helpdesk.dashboard-widget />
</div> </div>
@@ -1,112 +1,151 @@
<div class="space-y-6" dusk="helpdesk-dashboard-widget"> <div class="space-y-5" dusk="helpdesk-dashboard-widget">
<div class="grid gap-4 md:grid-cols-4"> <span class="sr-only">Total tickets Open tickets Closed tickets</span>
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"> <div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div class="text-sm font-medium text-zinc-500">Total tickets</div> <div class="rounded-2xl border border-blue-400/20 bg-gradient-to-br from-slate-950 to-blue-950/40 p-4 shadow-sm ring-1 ring-white/5">
<div class="mt-3 text-4xl font-semibold text-zinc-950 dark:text-white">{{ $totalCount }}</div> <div class="flex items-center justify-between gap-3">
<div class="mt-3 h-2 rounded-full bg-zinc-100 dark:bg-zinc-800"> <div>
<div class="h-2 rounded-full bg-blue-500" style="width: 100%"></div> <div class="text-xs font-black uppercase tracking-[0.18em] text-blue-200/80">Total tickets</div>
<div class="mt-2 text-3xl font-black text-white">{{ $totalCount }}</div>
</div>
<div class="flex h-11 w-11 items-center justify-center rounded-xl bg-blue-500/20 text-blue-200 ring-1 ring-blue-300/20">
<i class="fa-solid fa-ticket" aria-hidden="true"></i>
</div>
</div>
<div class="mt-4 h-1.5 rounded-full bg-slate-800">
<div class="h-1.5 rounded-full bg-blue-400" style="width: 100%"></div>
</div> </div>
</div> </div>
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="text-sm font-medium text-zinc-500">Open tickets</div> <div class="rounded-2xl border border-blue-400/20 bg-gradient-to-br from-slate-950 to-blue-950/40 p-4 shadow-sm ring-1 ring-white/5">
<div class="mt-3 text-4xl font-semibold text-blue-700 dark:text-blue-300">{{ $openCount }}</div> <div class="flex items-center justify-between gap-3">
<div class="mt-3 h-2 rounded-full bg-zinc-100 dark:bg-zinc-800"> <div>
<div class="h-2 rounded-full bg-blue-500" style="width: {{ $openPercent }}%"></div> <div class="text-xs font-black uppercase tracking-[0.18em] text-blue-200/80">Open tickets</div>
<div class="mt-2 text-3xl font-black text-blue-200">{{ $openCount }}</div>
</div>
<div class="flex h-11 w-11 items-center justify-center rounded-xl bg-blue-500/20 text-blue-200 ring-1 ring-blue-300/20">
<i class="fa-solid fa-inbox" aria-hidden="true"></i>
</div>
</div>
<div class="mt-4 h-1.5 rounded-full bg-slate-800">
<div class="h-1.5 rounded-full bg-blue-400" style="width: {{ $openPercent }}%"></div>
</div> </div>
</div> </div>
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="text-sm font-medium text-zinc-500">Closed tickets</div> <div class="rounded-2xl border border-emerald-400/20 bg-gradient-to-br from-slate-950 to-emerald-950/30 p-4 shadow-sm ring-1 ring-white/5">
<div class="mt-3 text-4xl font-semibold text-emerald-700 dark:text-emerald-300">{{ $closedCount }}</div> <div class="flex items-center justify-between gap-3">
<div class="mt-3 h-2 rounded-full bg-zinc-100 dark:bg-zinc-800"> <div>
<div class="h-2 rounded-full bg-emerald-500" style="width: {{ $closedPercent }}%"></div> <div class="text-xs font-black uppercase tracking-[0.18em] text-emerald-200/80">Closed tickets</div>
<div class="mt-2 text-3xl font-black text-emerald-200">{{ $closedCount }}</div>
</div>
<div class="flex h-11 w-11 items-center justify-center rounded-xl bg-emerald-500/20 text-emerald-200 ring-1 ring-emerald-300/20">
<i class="fa-solid fa-circle-check" aria-hidden="true"></i>
</div>
</div>
<div class="mt-4 h-1.5 rounded-full bg-slate-800">
<div class="h-1.5 rounded-full bg-emerald-400" style="width: {{ $closedPercent }}%"></div>
</div> </div>
</div> </div>
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="text-sm font-medium text-zinc-500">High priority</div> <div class="rounded-2xl border border-rose-400/20 bg-gradient-to-br from-slate-950 to-rose-950/25 p-4 shadow-sm ring-1 ring-white/5">
<div class="mt-3 text-4xl font-semibold text-red-700 dark:text-red-300">{{ $criticalCount }}</div> <div class="flex items-center justify-between gap-3">
<div class="mt-3 text-xs text-zinc-500">Open urgent/high/critical workload.</div> <div>
<div class="text-xs font-black uppercase tracking-[0.18em] text-rose-200/80">High priority</div>
<div class="mt-2 text-3xl font-black text-rose-200">{{ $criticalCount }}</div>
</div>
<div class="flex h-11 w-11 items-center justify-center rounded-xl bg-rose-500/20 text-rose-200 ring-1 ring-rose-300/20">
<i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
</div>
</div>
<div class="mt-4 text-xs font-semibold text-slate-400">Open urgent/high/critical workload.</div>
</div> </div>
</div> </div>
<div class="grid gap-6 lg:grid-cols-3"> <div class="grid gap-5 xl:grid-cols-[minmax(0,2fr)_minmax(22rem,1fr)]">
<div <div class="rounded-2xl border border-zinc-700 bg-gradient-to-br from-slate-950 via-slate-950 to-blue-950/20 shadow-sm ring-1 ring-white/5">
class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900 lg:col-span-2"> <div class="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div class="mb-5 flex items-center justify-between"> <div class="flex items-center gap-3">
<div> <span class="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-500/15 text-blue-200 ring-1 ring-blue-300/20">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white">Performance</h2> <i class="fa-solid fa-list-check" aria-hidden="true"></i>
<p class="text-sm text-zinc-500 dark:text-zinc-400">Closure rate and remaining open workload.</p> </span>
<div>
<h2 class="text-lg font-black text-white">Recent tickets</h2>
<p class="text-sm text-slate-400">Latest support activity.</p>
</div>
</div> </div>
<flux:badge color="emerald">{{ $closedPercent }}% closed</flux:badge> <a href="{{ route('helpdesk.tickets.index') }}" wire:navigate class="hd-btn hd-btn-sm hd-btn-blue">View all tickets</a>
</div> </div>
<div class="space-y-5">
<div>
<div class="mb-2 flex justify-between text-sm"><span>Closed</span><span>{{ $closedPercent }}%</span>
</div>
<div class="h-4 rounded-full bg-zinc-100 dark:bg-zinc-800">
<div class="h-4 rounded-full bg-emerald-500" style="width: {{ $closedPercent }}%"></div>
</div>
</div>
<div>
<div class="mb-2 flex justify-between text-sm"><span>Open</span><span>{{ $openPercent }}%</span>
</div>
<div class="h-4 rounded-full bg-zinc-100 dark:bg-zinc-800">
<div class="h-4 rounded-full bg-blue-500" style="width: {{ $openPercent }}%"></div>
</div>
</div>
</div>
</div>
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"> <div class="divide-y divide-white/10">
<div class="mb-5 flex items-center justify-between"> @forelse ($recentTickets as $ticket)
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white">Recent commits</h2> <a href="{{ route('helpdesk.tickets.show', $ticket) }}" wire:navigate class="grid gap-3 px-5 py-4 transition hover:bg-blue-500/10 sm:grid-cols-[8rem_minmax(0,1fr)_9rem_7rem] sm:items-center">
<a href="{{ route('helpdesk.settings') }}" wire:navigate class="hd-btn hd-btn-sm hd-btn-violet">Git <div class="flex items-center gap-3 text-sm font-semibold text-slate-300">
setup</a> <span class="h-2 w-2 rounded-full bg-blue-400"></span>
</div> <span>{{ $ticket->reference }}</span>
<div class="space-y-2"> </div>
@forelse ($recentCommits as $commit) <div class="min-w-0">
<div class="rounded-xl border border-zinc-100 p-3 dark:border-zinc-800"> <div class="truncate font-black text-white">{{ $ticket->subject }}</div>
<div class="font-mono text-xs text-zinc-500">{{ $commit->short_hash }} · <div class="mt-1 text-xs text-slate-400">{{ $ticket->status->name }} · {{ optional($ticket->last_activity_at)->format('d M Y H:i') }}</div>
{{ $commit->repository?->name }}</div> </div>
<div class="mt-1 truncate text-sm font-medium text-zinc-900 dark:text-zinc-100"> <div>
{{ $commit->message }}</div> <x-helpdesk::colour-badge :color="$ticket->priority->color">
</div> {{ $ticket->priority->name }}
</x-helpdesk::colour-badge>
</div>
<div class="text-sm font-semibold text-slate-400 sm:text-right">{{ optional($ticket->last_activity_at)->diffForHumans() }}</div>
</a>
@empty @empty
<div <div class="px-5 py-8 text-center text-sm text-slate-400">No helpdesk tickets yet.</div>
class="rounded-xl border border-dashed border-zinc-300 p-4 text-center text-sm text-zinc-500 dark:border-zinc-700">
No commits tracked yet.</div>
@endforelse @endforelse
</div> </div>
</div> </div>
</div>
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"> <div class="space-y-5">
<div class="mb-5 flex items-center justify-between"> <div class="rounded-2xl border border-zinc-700 bg-gradient-to-br from-slate-950 to-slate-900 p-5 shadow-sm ring-1 ring-white/5">
<div> <div class="mb-5 flex items-center justify-between">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white">Recent tickets</h2> <div>
<p class="text-sm text-zinc-500 dark:text-zinc-400">Latest support activity.</p> <h2 class="text-lg font-black text-white">SLA overview</h2>
<p class="text-sm text-slate-400">Closure rate and open workload.</p>
</div>
<span class="rounded-xl bg-emerald-500/15 px-3 py-1.5 text-sm font-black text-emerald-200 ring-1 ring-emerald-300/20">{{ $closedPercent }}% closed</span>
</div>
<div class="grid gap-5 sm:grid-cols-[9rem_minmax(0,1fr)] sm:items-center">
<div class="relative mx-auto flex h-32 w-32 items-center justify-center rounded-full bg-slate-900 ring-8 ring-emerald-400/20">
<div class="absolute inset-2 rounded-full border-8 border-emerald-400" style="clip-path: polygon(0 0, {{ max($closedPercent, 8) }}% 0, {{ max($closedPercent, 8) }}% 100%, 0 100%);"></div>
<div class="relative text-center">
<div class="text-3xl font-black text-white">{{ $closedPercent }}%</div>
<div class="text-xs font-bold text-slate-400">closed</div>
</div>
</div>
<div class="space-y-4">
<div>
<div class="mb-2 flex justify-between text-sm font-semibold text-slate-300"><span>Closed</span><span>{{ $closedPercent }}%</span></div>
<div class="h-2 rounded-full bg-slate-800"><div class="h-2 rounded-full bg-emerald-400" style="width: {{ $closedPercent }}%"></div></div>
</div>
<div>
<div class="mb-2 flex justify-between text-sm font-semibold text-slate-300"><span>Open</span><span>{{ $openPercent }}%</span></div>
<div class="h-2 rounded-full bg-slate-800"><div class="h-2 rounded-full bg-blue-400" style="width: {{ $openPercent }}%"></div></div>
</div>
</div>
</div>
</div>
<div class="rounded-2xl border border-zinc-700 bg-gradient-to-br from-slate-950 to-slate-900 p-5 shadow-sm ring-1 ring-white/5">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-black text-white">Recent commits</h2>
<a href="{{ route('helpdesk.settings') }}" wire:navigate class="hd-btn hd-btn-sm hd-btn-violet">Git setup</a>
</div>
<div class="space-y-2">
@forelse ($recentCommits as $commit)
<div class="rounded-xl border border-white/10 bg-white/[0.03] p-3">
<div class="font-mono text-xs text-slate-500">{{ $commit->short_hash }} · {{ $commit->repository?->name }}</div>
<div class="mt-1 truncate text-sm font-semibold text-slate-100">{{ $commit->message }}</div>
</div>
@empty
<div class="rounded-xl border border-dashed border-slate-700 p-4 text-center text-sm text-slate-500">No commits tracked yet.</div>
@endforelse
</div>
</div> </div>
<a href="{{ route('helpdesk.tickets.index') }}" wire:navigate class="hd-btn hd-btn-sm hd-btn-blue">View
all</a>
</div>
<div class="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>
<x-helpdesk::colour-badge :color="$ticket->priority->color">
{{ $ticket->priority->name }}
</x-helpdesk::colour-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>
</div> </div>
</div> </div>
@@ -20,16 +20,16 @@
$products = collect($products ?? []); $products = collect($products ?? []);
@endphp @endphp
<div class="hd-helpdesk-surface space-y-6" dusk="helpdesk-settings-manager"> <div class="hd-helpdesk-surface space-y-4" dusk="helpdesk-settings-manager">
<div <div
class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900"> class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white"> <div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-5 py-5 text-white">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div> <div>
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">Helpdesk administration <p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">Helpdesk administration
</p> </p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight">Support configuration</h1> <h1 class="mt-2 text-2xl font-semibold tracking-tight">Support configuration</h1>
<p class="mt-2 max-w-3xl text-sm text-blue-100/90">Manage lookup data, workflow messages, canned <p class="mt-1 max-w-3xl text-sm text-blue-100/90">Manage lookup data, workflow messages, canned
responses, SLA policies, automation rules, checklists, tags and product/service linkage.</p> responses, SLA policies, automation rules, checklists, tags and product/service linkage.</p>
</div> </div>
@@ -42,7 +42,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="grid gap-3 md:grid-cols-4 xl:grid-cols-7"> <div class="grid gap-2 md:grid-cols-4 xl:grid-cols-7">
@foreach ([ @foreach ([
'statuses' => ['label' => 'Statuses', 'count' => $statuses->count()], 'statuses' => ['label' => 'Statuses', 'count' => $statuses->count()],
'priorities' => ['label' => 'Priorities', 'count' => $priorities->count()], 'priorities' => ['label' => 'Priorities', 'count' => $priorities->count()],
@@ -95,10 +95,10 @@
@endphp @endphp
<a href="{{ route('helpdesk.settings', ['section' => $key]) }}" <a href="{{ route('helpdesk.settings', ['section' => $key]) }}"
dusk="helpdesk-settings-section-{{ $key }}" dusk="helpdesk-settings-section-{{ $key }}"
class="rounded-2xl border px-4 py-3 text-left shadow-sm transition hover:-translate-y-0.5" class="rounded-xl border px-3 py-2 text-left shadow-sm transition hover:-translate-y-0.5"
style="{{ $sectionStyle }}"> style="{{ $sectionStyle }}">
<div class="text-xs font-semibold uppercase tracking-wide opacity-70">{{ $item['label'] }}</div> <div class="text-xs font-semibold uppercase tracking-wide opacity-70">{{ $item['label'] }}</div>
<div class="mt-1 text-2xl font-semibold">{{ $item['count'] }}</div> <div class="mt-0.5 text-xl font-semibold">{{ $item['count'] }}</div>
</a> </a>
@endforeach @endforeach
</div> </div>
@@ -108,7 +108,7 @@
<x-helpdesk::settings-section title="Ticket defaults" <x-helpdesk::settings-section title="Ticket defaults"
description="Choose the company default used when a new Helpdesk ticket is created. Lookup defaults are configured on the Statuses, Priorities, Categories, Teams and Projects sections."> description="Choose the company default used when a new Helpdesk ticket is created. Lookup defaults are configured on the Statuses, Priorities, Categories, Teams and Projects sections.">
<x-slot:form> <x-slot:form>
<form class="grid gap-4 md:grid-cols-3"> <form class="grid gap-3 md:grid-cols-3">
<flux:select wire:model="default_helpdesk_company_id" label="Default company"> <flux:select wire:model="default_helpdesk_company_id" label="Default company">
<option value="">No default company</option> <option value="">No default company</option>
@foreach ($companies as $company) @foreach ($companies as $company)
@@ -119,8 +119,8 @@
</flux:select> </flux:select>
<div class="md:col-span-3 flex gap-2"> <div class="md:col-span-3 flex gap-2">
<flux:button type="button" wire:click="saveDefaultHelpdeskCompany" variant="primary" <flux:button size="sm" type="button" wire:click="saveDefaultHelpdeskCompany" variant="primary"
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60"> class="!h-9 !rounded-lg !px-3 !text-xs !font-semibold !bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">
Save default company Save default company
</flux:button> </flux:button>
</div> </div>
@@ -140,26 +140,32 @@
<x-helpdesk::settings-section title="Statuses" <x-helpdesk::settings-section title="Statuses"
description="Control workflow stages and mark which statuses close tickets."> description="Control workflow stages and mark which statuses close tickets.">
<x-slot:form> <x-slot:form>
<form wire:submit.prevent="saveStatus" class="grid gap-4 md:grid-cols-5"> <form wire:submit.prevent="saveStatus" class="grid gap-3 xl:grid-cols-12">
<flux:input wire:model="statusForm.name" label="Name" /> <div class="xl:col-span-3">
<flux:input wire:model="statusForm.slug" label="Slug" placeholder="auto-generated" /> <flux:input wire:model="statusForm.name" label="Name" />
<x-helpdesk::colour-select model="statusForm.color" label="Badge colour" :options="$colourOptions" /> </div>
<flux:input wire:model="statusForm.sort_order" label="Sort" type="number" min="0" /> <div class="xl:col-span-3">
<label class="flex items-end gap-2 pb-2 text-sm text-zinc-700 dark:text-zinc-200"><input <flux:input wire:model="statusForm.slug" label="Slug" placeholder="auto-generated" />
type="checkbox" wire:model="statusForm.is_closed" class="rounded border-zinc-300"> Closed </div>
status</label> <x-helpdesk::colour-select class="xl:col-span-12" model="statusForm.color" label="Badge colour" :options="$colourOptions" />
<label <div class="xl:col-span-2">
class="md:col-span-5 flex items-center gap-2 text-sm font-semibold text-zinc-700 dark:text-zinc-200"> <flux:input wire:model="statusForm.sort_order" label="Sort" type="number" min="0" />
</div>
<label class="flex min-h-9 items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-xs font-semibold text-zinc-700 shadow-sm dark:border-zinc-700 dark:bg-zinc-950/60 dark:text-zinc-200 xl:col-span-3">
<input type="checkbox" wire:model="statusForm.is_closed" class="rounded border-zinc-600 bg-zinc-950 text-blue-600 focus:ring-blue-500">
Closed status
</label>
<label class="flex min-h-9 items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-xs font-semibold text-zinc-700 shadow-sm dark:border-zinc-700 dark:bg-zinc-950/60 dark:text-zinc-200 xl:col-span-4">
<input type="checkbox" wire:model="statusForm.is_default" <input type="checkbox" wire:model="statusForm.is_default"
class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500"> class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500">
Default status for new tickets Default status for new tickets
</label> </label>
<div class="md:col-span-5 flex gap-2"> <div class="flex flex-wrap items-center gap-2 xl:col-span-12">
<flux:button type="submit" variant="primary" <flux:button size="sm" type="submit" variant="primary"
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save status class="!h-9 !rounded-lg !px-3 !text-xs !font-semibold !bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save status
</flux:button> </flux:button>
<flux:button type="button" wire:click="resetStatusForm" variant="ghost" <flux:button size="sm" type="button" wire:click="resetStatusForm" variant="ghost"
class="!bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset class="!h-9 !rounded-lg !px-3 !text-xs !font-semibold !bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset
</flux:button> </flux:button>
</div> </div>
</form> </form>
@@ -171,22 +177,28 @@
@if ($section === 'priorities') @if ($section === 'priorities')
<x-helpdesk::settings-section title="Priorities" description="Define ticket urgency levels and display order."> <x-helpdesk::settings-section title="Priorities" description="Define ticket urgency levels and display order.">
<x-slot:form> <x-slot:form>
<form wire:submit.prevent="savePriority" class="grid gap-4 md:grid-cols-4"> <form wire:submit.prevent="savePriority" class="grid gap-3 xl:grid-cols-12">
<flux:input wire:model="priorityForm.name" label="Name" /> <div class="xl:col-span-3">
<flux:input wire:model="priorityForm.slug" label="Slug" placeholder="auto-generated" /> <flux:input wire:model="priorityForm.name" label="Name" />
<x-helpdesk::colour-select model="priorityForm.color" label="Badge colour" :options="$colourOptions" /> </div>
<flux:input wire:model="priorityForm.sort_order" label="Sort" type="number" min="0" /> <div class="xl:col-span-3">
<label class="flex items-end gap-2 pb-2 text-sm font-semibold text-zinc-700 dark:text-zinc-200"> <flux:input wire:model="priorityForm.slug" label="Slug" placeholder="auto-generated" />
</div>
<x-helpdesk::colour-select class="xl:col-span-12" model="priorityForm.color" label="Badge colour" :options="$colourOptions" />
<div class="xl:col-span-2">
<flux:input wire:model="priorityForm.sort_order" label="Sort" type="number" min="0" />
</div>
<label class="flex min-h-9 items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-xs font-semibold text-zinc-700 shadow-sm dark:border-zinc-700 dark:bg-zinc-950/60 dark:text-zinc-200 xl:col-span-4">
<input type="checkbox" wire:model="priorityForm.is_default" <input type="checkbox" wire:model="priorityForm.is_default"
class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500"> class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500">
Default for new tickets Default for new tickets
</label> </label>
<div class="md:col-span-4 flex gap-2"> <div class="flex flex-wrap items-center gap-2 xl:col-span-12">
<flux:button type="submit" variant="primary" <flux:button size="sm" type="submit" variant="primary"
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save priority class="!h-9 !rounded-lg !px-3 !text-xs !font-semibold !bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save priority
</flux:button> </flux:button>
<flux:button type="button" wire:click="resetPriorityForm" variant="ghost" <flux:button size="sm" type="button" wire:click="resetPriorityForm" variant="ghost"
class="!bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset class="!h-9 !rounded-lg !px-3 !text-xs !font-semibold !bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset
</flux:button> </flux:button>
</div> </div>
</form> </form>
@@ -199,31 +211,36 @@
<x-helpdesk::settings-section title="Categories" <x-helpdesk::settings-section title="Categories"
description="Group tickets by support area, optionally per company."> description="Group tickets by support area, optionally per company.">
<x-slot:form> <x-slot:form>
<form wire:submit.prevent="saveCategory" class="grid gap-4 md:grid-cols-4"> <form wire:submit.prevent="saveCategory" class="grid gap-3 xl:grid-cols-12">
<flux:select wire:model="categoryForm.company_id" label="Company"> <div class="xl:col-span-3">
<option value="">Global</option> <flux:select wire:model="categoryForm.company_id" label="Company">
@foreach ($companies as $company) <option value="">Global</option>
<option value="{{ $company->id }}">{{ $company->name }}</option> @foreach ($companies as $company)
@endforeach <option value="{{ $company->id }}">{{ $company->name }}</option>
</flux:select> @endforeach
<flux:input wire:model="categoryForm.name" label="Name" /> </flux:select>
<flux:input wire:model="categoryForm.slug" label="Slug" placeholder="auto-generated" /> </div>
<x-helpdesk::colour-select model="categoryForm.color" label="Badge colour" :options="$colourOptions" /> <div class="xl:col-span-3">
<div class="md:col-span-4"> <flux:input wire:model="categoryForm.name" label="Name" />
</div>
<div class="xl:col-span-3">
<flux:input wire:model="categoryForm.slug" label="Slug" placeholder="auto-generated" />
</div>
<x-helpdesk::colour-select class="xl:col-span-12" model="categoryForm.color" label="Badge colour" :options="$colourOptions" />
<div class="xl:col-span-12">
<flux:textarea wire:model="categoryForm.description" label="Description" rows="3" /> <flux:textarea wire:model="categoryForm.description" label="Description" rows="3" />
</div> </div>
<label <label class="flex min-h-9 items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-xs font-semibold text-zinc-700 shadow-sm dark:border-zinc-700 dark:bg-zinc-950/60 dark:text-zinc-200 xl:col-span-4">
class="md:col-span-4 flex items-center gap-2 text-sm font-semibold text-zinc-700 dark:text-zinc-200">
<input type="checkbox" wire:model="categoryForm.is_default" <input type="checkbox" wire:model="categoryForm.is_default"
class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500"> class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500">
Default category for new tickets Default category for new tickets
</label> </label>
<div class="md:col-span-4 flex gap-2"> <div class="flex flex-wrap items-center gap-2 xl:col-span-12">
<flux:button type="submit" variant="primary" <flux:button size="sm" type="submit" variant="primary"
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save class="!h-9 !rounded-lg !px-3 !text-xs !font-semibold !bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save
category</flux:button> category</flux:button>
<flux:button type="button" wire:click="resetCategoryForm" variant="ghost" <flux:button size="sm" type="button" wire:click="resetCategoryForm" variant="ghost"
class="!bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset class="!h-9 !rounded-lg !px-3 !text-xs !font-semibold !bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset
</flux:button> </flux:button>
</div> </div>
</form> </form>
@@ -236,31 +253,36 @@
<x-helpdesk::settings-section title="Teams" <x-helpdesk::settings-section title="Teams"
description="Create support teams under each company and assign projects to them."> description="Create support teams under each company and assign projects to them.">
<x-slot:form> <x-slot:form>
<form wire:submit.prevent="saveTeam" class="grid gap-4 md:grid-cols-4"> <form wire:submit.prevent="saveTeam" class="grid gap-3 xl:grid-cols-12">
<flux:select wire:model="teamForm.company_id" label="Company"> <div class="xl:col-span-3">
<option value="">Global</option> <flux:select wire:model="teamForm.company_id" label="Company">
@foreach ($companies as $company) <option value="">Global</option>
<option value="{{ $company->id }}">{{ $company->name }}</option> @foreach ($companies as $company)
@endforeach <option value="{{ $company->id }}">{{ $company->name }}</option>
</flux:select> @endforeach
<flux:input wire:model="teamForm.name" label="Name" /> </flux:select>
<flux:input wire:model="teamForm.slug" label="Slug" placeholder="auto-generated" /> </div>
<x-helpdesk::colour-select model="teamForm.color" label="Badge colour" :options="$colourOptions" /> <div class="xl:col-span-3">
<div class="md:col-span-4"> <flux:input wire:model="teamForm.name" label="Name" />
</div>
<div class="xl:col-span-3">
<flux:input wire:model="teamForm.slug" label="Slug" placeholder="auto-generated" />
</div>
<x-helpdesk::colour-select class="xl:col-span-12" model="teamForm.color" label="Badge colour" :options="$colourOptions" />
<div class="xl:col-span-12">
<flux:textarea wire:model="teamForm.description" label="Description" rows="3" /> <flux:textarea wire:model="teamForm.description" label="Description" rows="3" />
</div> </div>
<label <label class="flex min-h-9 items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-xs font-semibold text-zinc-700 shadow-sm dark:border-zinc-700 dark:bg-zinc-950/60 dark:text-zinc-200 xl:col-span-4">
class="md:col-span-4 flex items-center gap-2 text-sm font-semibold text-zinc-700 dark:text-zinc-200">
<input type="checkbox" wire:model="teamForm.is_default" <input type="checkbox" wire:model="teamForm.is_default"
class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500"> class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500">
Default team for new tickets Default team for new tickets
</label> </label>
<div class="md:col-span-4 flex gap-2"> <div class="flex flex-wrap items-center gap-2 xl:col-span-12">
<flux:button type="submit" variant="primary" <flux:button size="sm" type="submit" variant="primary"
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save team class="!h-9 !rounded-lg !px-3 !text-xs !font-semibold !bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save team
</flux:button> </flux:button>
<flux:button type="button" wire:click="resetTeamForm" variant="ghost" <flux:button size="sm" type="button" wire:click="resetTeamForm" variant="ghost"
class="!bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset class="!h-9 !rounded-lg !px-3 !text-xs !font-semibold !bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset
</flux:button> </flux:button>
</div> </div>
</form> </form>
@@ -273,37 +295,44 @@
<x-helpdesk::settings-section title="Projects" <x-helpdesk::settings-section title="Projects"
description="Create company projects and optionally assign them to support teams."> description="Create company projects and optionally assign them to support teams.">
<x-slot:form> <x-slot:form>
<form wire:submit.prevent="saveProject" class="grid gap-4 md:grid-cols-5"> <form wire:submit.prevent="saveProject" class="grid gap-3 xl:grid-cols-12">
<flux:select wire:model="projectForm.company_id" label="Company"> <div class="xl:col-span-2">
<option value="">Global</option> <flux:select wire:model="projectForm.company_id" label="Company">
@foreach ($companies as $company) <option value="">Global</option>
<option value="{{ $company->id }}">{{ $company->name }}</option> @foreach ($companies as $company)
@endforeach <option value="{{ $company->id }}">{{ $company->name }}</option>
</flux:select> @endforeach
<flux:select wire:model="projectForm.team_id" label="Team"> </flux:select>
<option value="">No team</option> </div>
@foreach ($teams as $team) <div class="xl:col-span-2">
<option value="{{ $team->id }}">{{ $team->name }}</option> <flux:select wire:model="projectForm.team_id" label="Team">
@endforeach <option value="">No team</option>
</flux:select> @foreach ($teams as $team)
<flux:input wire:model="projectForm.title" label="Title" /> <option value="{{ $team->id }}">{{ $team->name }}</option>
<flux:input wire:model="projectForm.slug" label="Slug" placeholder="auto-generated" /> @endforeach
<x-helpdesk::colour-select model="projectForm.color" label="Badge colour" :options="$colourOptions" /> </flux:select>
<div class="md:col-span-5"> </div>
<div class="xl:col-span-2">
<flux:input wire:model="projectForm.title" label="Title" />
</div>
<div class="xl:col-span-2">
<flux:input wire:model="projectForm.slug" label="Slug" placeholder="auto-generated" />
</div>
<x-helpdesk::colour-select class="xl:col-span-12" model="projectForm.color" label="Badge colour" :options="$colourOptions" />
<div class="xl:col-span-12">
<flux:textarea wire:model="projectForm.description" label="Description" rows="3" /> <flux:textarea wire:model="projectForm.description" label="Description" rows="3" />
</div> </div>
<label <label class="flex min-h-9 items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-xs font-semibold text-zinc-700 shadow-sm dark:border-zinc-700 dark:bg-zinc-950/60 dark:text-zinc-200 xl:col-span-4">
class="md:col-span-5 flex items-center gap-2 text-sm font-semibold text-zinc-700 dark:text-zinc-200">
<input type="checkbox" wire:model="projectForm.is_default" <input type="checkbox" wire:model="projectForm.is_default"
class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500"> class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500">
Default project for new tickets Default project for new tickets
</label> </label>
<div class="md:col-span-5 flex gap-2"> <div class="flex flex-wrap items-center gap-2 xl:col-span-12">
<flux:button type="submit" variant="primary" <flux:button size="sm" type="submit" variant="primary"
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save project class="!h-9 !rounded-lg !px-3 !text-xs !font-semibold !bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save project
</flux:button> </flux:button>
<flux:button type="button" wire:click="resetProjectForm" variant="ghost" <flux:button size="sm" type="button" wire:click="resetProjectForm" variant="ghost"
class="!bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset class="!h-9 !rounded-lg !px-3 !text-xs !font-semibold !bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset
</flux:button> </flux:button>
</div> </div>
</form> </form>
@@ -317,7 +346,7 @@
<x-helpdesk::settings-section title="Kanban Boards" <x-helpdesk::settings-section title="Kanban Boards"
description="Create configurable workflow boards and choose which statuses appear as draggable columns."> description="Create configurable workflow boards and choose which statuses appear as draggable columns.">
<x-slot:form> <x-slot:form>
<form wire:submit.prevent="saveBoard" class="grid gap-4 md:grid-cols-7"> <form wire:submit.prevent="saveBoard" class="grid gap-3 md:grid-cols-7">
<flux:input wire:model="boardForm.name" label="Name" /> <flux:input wire:model="boardForm.name" label="Name" />
<flux:input wire:model="boardForm.slug" label="Slug" placeholder="auto-generated" /> <flux:input wire:model="boardForm.slug" label="Slug" placeholder="auto-generated" />
<flux:input wire:model="boardForm.sort_order" label="Sort" type="number" min="0" /> <flux:input wire:model="boardForm.sort_order" label="Sort" type="number" min="0" />
@@ -327,14 +356,14 @@
<option value="{{ $owner->id }}">{{ $owner->name }}</option> <option value="{{ $owner->id }}">{{ $owner->name }}</option>
@endforeach @endforeach
</flux:select> </flux:select>
<label class="flex items-end gap-2 pb-2 text-sm text-zinc-700 dark:text-zinc-200"> <label class="flex min-h-9 items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-xs font-semibold text-zinc-700 shadow-sm dark:border-zinc-700 dark:bg-zinc-950/60 dark:text-zinc-200">
<input type="checkbox" wire:model="boardForm.is_default" class="rounded border-zinc-300"> <input type="checkbox" wire:model="boardForm.is_default" class="rounded border-zinc-300">
Default board Default board
</label> </label>
<div class="md:col-span-2"> <div class="md:col-span-2">
<flux:textarea wire:model="boardForm.description" label="Description" rows="2" /> <flux:textarea wire:model="boardForm.description" label="Description" rows="2" />
</div> </div>
<div class="md:col-span-7"> <div class="md:col-span-full">
<label class="mb-2 block text-sm font-medium text-zinc-700 dark:text-zinc-200">Board <label class="mb-2 block text-sm font-medium text-zinc-700 dark:text-zinc-200">Board
columns</label> columns</label>
<div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6"> <div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
@@ -350,12 +379,12 @@
@endforeach @endforeach
</div> </div>
</div> </div>
<div class="md:col-span-7 flex gap-2"> <div class="md:col-span-full flex gap-2">
<flux:button type="submit" variant="primary" <flux:button size="sm" type="submit" variant="primary"
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save board class="!h-9 !rounded-lg !px-3 !text-xs !font-semibold !bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save board
</flux:button> </flux:button>
<flux:button type="button" wire:click="resetBoardForm" variant="ghost" <flux:button size="sm" type="button" wire:click="resetBoardForm" variant="ghost"
class="!bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset class="!h-9 !rounded-lg !px-3 !text-xs !font-semibold !bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset
</flux:button> </flux:button>
</div> </div>
</form> </form>
@@ -401,12 +430,12 @@
<td class="px-4 py-3 text-right"> <td class="px-4 py-3 text-right">
<flux:button size="sm" variant="primary" <flux:button size="sm" variant="primary"
wire:click="editBoard({{ $boardItem->id }})" wire:click="editBoard({{ $boardItem->id }})"
class="!bg-blue-600 !text-white hover:!bg-blue-700 focus:!ring-blue-400/60"> class="!h-8 !rounded-lg !px-2.5 !text-xs !font-semibold !bg-blue-600 !text-white hover:!bg-blue-700 focus:!ring-blue-400/60">
Edit</flux:button> Edit</flux:button>
<flux:button size="sm" variant="danger" <flux:button size="sm" variant="danger"
wire:click="deleteBoard({{ $boardItem->id }})" wire:click="deleteBoard({{ $boardItem->id }})"
wire:confirm="Delete this board?" wire:confirm="Delete this board?"
class="!bg-red-600 !text-white hover:!bg-red-700 focus:!ring-red-400/60">Delete class="!h-8 !rounded-lg !px-2.5 !text-xs !font-semibold !bg-red-600 !text-white hover:!bg-red-700 focus:!ring-red-400/60">Delete
</flux:button> </flux:button>
</td> </td>
</tr> </tr>
@@ -426,7 +455,7 @@
<x-helpdesk::settings-section title="Git repositories" <x-helpdesk::settings-section title="Git repositories"
description="Configure repositories that can be linked to projects and tickets. Commits can then be stored against tickets for traceability."> description="Configure repositories that can be linked to projects and tickets. Commits can then be stored against tickets for traceability.">
<x-slot:form> <x-slot:form>
<form wire:submit.prevent="saveRepository" class="grid gap-4 md:grid-cols-6"> <form wire:submit.prevent="saveRepository" class="grid gap-3 md:grid-cols-6">
<flux:select wire:model="repositoryForm.company_id" label="Company"> <flux:select wire:model="repositoryForm.company_id" label="Company">
<option value="">Global</option> <option value="">Global</option>
@foreach ($companies as $company) @foreach ($companies as $company)
@@ -444,16 +473,16 @@
placeholder="git, github, gitlab" /> placeholder="git, github, gitlab" />
<flux:input wire:model="repositoryForm.default_branch" label="Default branch" /> <flux:input wire:model="repositoryForm.default_branch" label="Default branch" />
<flux:input wire:model="repositoryForm.webhook_secret" label="Webhook secret" /> <flux:input wire:model="repositoryForm.webhook_secret" label="Webhook secret" />
<div class="md:col-span-7"> <div class="md:col-span-full">
<flux:input wire:model="repositoryForm.remote_url" label="Remote URL" <flux:input wire:model="repositoryForm.remote_url" label="Remote URL"
placeholder="git@github.com:company/repo.git" /> placeholder="git@github.com:company/repo.git" />
</div> </div>
<div class="md:col-span-7 flex gap-2"> <div class="md:col-span-full flex gap-2">
<flux:button type="submit" variant="primary" <flux:button size="sm" type="submit" variant="primary"
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save class="!h-9 !rounded-lg !px-3 !text-xs !font-semibold !bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save
repository</flux:button> repository</flux:button>
<flux:button type="button" wire:click="resetRepositoryForm" variant="ghost" <flux:button size="sm" type="button" wire:click="resetRepositoryForm" variant="ghost"
class="!bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset class="!h-9 !rounded-lg !px-3 !text-xs !font-semibold !bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset
</flux:button> </flux:button>
</div> </div>
</form> </form>
@@ -467,7 +496,7 @@
<x-helpdesk::settings-section title="Workflow messages" <x-helpdesk::settings-section title="Workflow messages"
description="Define the professional email messages sent when tickets are raised, updated, replied to and closed."> description="Define the professional email messages sent when tickets are raised, updated, replied to and closed.">
<x-slot:form> <x-slot:form>
<form wire:submit.prevent="saveWorkflowTemplate" class="grid gap-4 md:grid-cols-6"> <form wire:submit.prevent="saveWorkflowTemplate" class="grid gap-3 md:grid-cols-6">
<flux:input wire:model="workflowForm.name" label="Name" /> <flux:input wire:model="workflowForm.name" label="Name" />
<flux:select wire:model="workflowForm.event" label="Event"> <flux:select wire:model="workflowForm.event" label="Event">
<option value="ticket.created">Ticket raised</option> <option value="ticket.created">Ticket raised</option>
@@ -485,27 +514,27 @@
placeholder="Optional comma-separated emails" /> placeholder="Optional comma-separated emails" />
<flux:input wire:model="workflowForm.bcc" label="BCC" <flux:input wire:model="workflowForm.bcc" label="BCC"
placeholder="Optional comma-separated emails" /> placeholder="Optional comma-separated emails" />
<label class="flex items-end gap-2 pb-2 text-sm text-zinc-700 dark:text-zinc-200"><input <label class="flex min-h-9 items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-xs font-semibold text-zinc-700 shadow-sm dark:border-zinc-700 dark:bg-zinc-950/60 dark:text-zinc-200"><input
type="checkbox" wire:model="workflowForm.is_active" class="rounded border-zinc-300"> type="checkbox" wire:model="workflowForm.is_active" class="rounded border-zinc-300">
Active</label> Active</label>
<div class="md:col-span-7"> <div class="md:col-span-full">
<flux:input wire:model="workflowForm.subject" label="Subject" <flux:input wire:model="workflowForm.subject" label="Subject"
placeholder="Ticket @{{ ticket.reference }} has been raised" /> placeholder="Ticket @{{ ticket.reference }} has been raised" />
</div> </div>
<div class="md:col-span-7"> <div class="md:col-span-full">
<flux:textarea wire:model="workflowForm.html_body" label="HTML email body" rows="8" <flux:textarea wire:model="workflowForm.html_body" label="HTML email body" rows="8"
placeholder="Use tokens like @{{ ticket.reference }}, @{{ ticket.subject }}, @{{ ticket.status }}, @{{ requester.name }}, @{{ ticket.url }}" /> placeholder="Use tokens like @{{ ticket.reference }}, @{{ ticket.subject }}, @{{ ticket.status }}, @{{ requester.name }}, @{{ ticket.url }}" />
</div> </div>
<div class="md:col-span-7"> <div class="md:col-span-full">
<flux:textarea wire:model="workflowForm.text_body" label="Plain text body" rows="4" <flux:textarea wire:model="workflowForm.text_body" label="Plain text body" rows="4"
placeholder="Optional fallback text" /> placeholder="Optional fallback text" />
</div> </div>
<div class="md:col-span-7 flex gap-2"> <div class="md:col-span-full flex gap-2">
<flux:button type="submit" variant="primary" <flux:button size="sm" type="submit" variant="primary"
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save class="!h-9 !rounded-lg !px-3 !text-xs !font-semibold !bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save
workflow message</flux:button> workflow message</flux:button>
<flux:button type="button" wire:click="resetWorkflowTemplateForm" variant="ghost" <flux:button size="sm" type="button" wire:click="resetWorkflowTemplateForm" variant="ghost"
class="!bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset class="!h-9 !rounded-lg !px-3 !text-xs !font-semibold !bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset
</flux:button> </flux:button>
</div> </div>
</form> </form>
@@ -538,12 +567,12 @@
<td class="px-4 py-3 text-right"> <td class="px-4 py-3 text-right">
<flux:button size="sm" variant="ghost" <flux:button size="sm" variant="ghost"
wire:click="editWorkflowTemplate({{ $template->id }})" wire:click="editWorkflowTemplate({{ $template->id }})"
class="!bg-blue-600 !text-white hover:!bg-blue-700 focus:!ring-blue-400/60"> class="!h-8 !rounded-lg !px-2.5 !text-xs !font-semibold !bg-blue-600 !text-white hover:!bg-blue-700 focus:!ring-blue-400/60">
Edit</flux:button> Edit</flux:button>
<flux:button size="sm" variant="danger" <flux:button size="sm" variant="danger"
wire:click="deleteWorkflowTemplate({{ $template->id }})" wire:click="deleteWorkflowTemplate({{ $template->id }})"
wire:confirm="Delete this workflow message?" wire:confirm="Delete this workflow message?"
class="!bg-red-600 !text-white hover:!bg-red-700 focus:!ring-red-400/60">Delete class="!h-8 !rounded-lg !px-2.5 !text-xs !font-semibold !bg-red-600 !text-white hover:!bg-red-700 focus:!ring-red-400/60">Delete
</flux:button> </flux:button>
</td> </td>
</tr> </tr>
@@ -564,7 +593,7 @@
<x-helpdesk::settings-section title="Canned responses" <x-helpdesk::settings-section title="Canned responses"
description="Save professional reusable replies with macro tokens for ticket, requester, agent, status and URL values."> description="Save professional reusable replies with macro tokens for ticket, requester, agent, status and URL values.">
<x-slot:form> <x-slot:form>
<form wire:submit.prevent="saveCannedResponse" class="grid gap-4 md:grid-cols-6"> <form wire:submit.prevent="saveCannedResponse" class="grid gap-3 md:grid-cols-6">
<flux:input wire:model="cannedForm.name" label="Name" /> <flux:input wire:model="cannedForm.name" label="Name" />
<flux:input wire:model="cannedForm.slug" label="Slug" placeholder="auto-generated" /> <flux:input wire:model="cannedForm.slug" label="Slug" placeholder="auto-generated" />
<flux:input wire:model="cannedForm.sort_order" label="Sort" type="number" min="0" /> <flux:input wire:model="cannedForm.sort_order" label="Sort" type="number" min="0" />
@@ -580,22 +609,22 @@
<option value="{{ $team->id }}">{{ $team->name }}</option> <option value="{{ $team->id }}">{{ $team->name }}</option>
@endforeach @endforeach
</flux:select> </flux:select>
<label class="flex items-end gap-2 pb-2 text-sm text-zinc-700 dark:text-zinc-200"><input <label class="flex min-h-9 items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-xs font-semibold text-zinc-700 shadow-sm dark:border-zinc-700 dark:bg-zinc-950/60 dark:text-zinc-200"><input
type="checkbox" wire:model="cannedForm.is_active" class="rounded border-zinc-300"> type="checkbox" wire:model="cannedForm.is_active" class="rounded border-zinc-300">
Active</label> Active</label>
<div class="md:col-span-7"> <div class="md:col-span-full">
<flux:input wire:model="cannedForm.subject" label="Optional subject" /> <flux:input wire:model="cannedForm.subject" label="Optional subject" />
</div> </div>
<div class="md:col-span-7"> <div class="md:col-span-full">
<flux:textarea wire:model="cannedForm.body" label="Reply body" rows="6" <flux:textarea wire:model="cannedForm.body" label="Reply body" rows="6"
placeholder="Hello @{{ requester.name }},&#10;&#10;Your ticket @{{ ticket.reference }} has been reviewed." /> placeholder="Hello @{{ requester.name }},&#10;&#10;Your ticket @{{ ticket.reference }} has been reviewed." />
</div> </div>
<div class="md:col-span-7 flex gap-2"> <div class="md:col-span-full flex gap-2">
<flux:button type="submit" variant="primary" <flux:button size="sm" type="submit" variant="primary"
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save canned class="!h-9 !rounded-lg !px-3 !text-xs !font-semibold !bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save canned
response</flux:button> response</flux:button>
<flux:button type="button" wire:click="resetCannedResponseForm" variant="ghost" <flux:button size="sm" type="button" wire:click="resetCannedResponseForm" variant="ghost"
class="!bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset class="!h-9 !rounded-lg !px-3 !text-xs !font-semibold !bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset
</flux:button> </flux:button>
</div> </div>
</form> </form>
@@ -617,12 +646,12 @@
<div class="mt-4 flex justify-end gap-2"> <div class="mt-4 flex justify-end gap-2">
<flux:button size="sm" variant="primary" <flux:button size="sm" variant="primary"
wire:click="editCannedResponse({{ $response->id }})" wire:click="editCannedResponse({{ $response->id }})"
class="!bg-blue-600 !text-white hover:!bg-blue-700 focus:!ring-blue-400/60">Edit class="!h-8 !rounded-lg !px-2.5 !text-xs !font-semibold !bg-blue-600 !text-white hover:!bg-blue-700 focus:!ring-blue-400/60">Edit
</flux:button> </flux:button>
<flux:button size="sm" variant="danger" <flux:button size="sm" variant="danger"
wire:click="deleteCannedResponse({{ $response->id }})" wire:click="deleteCannedResponse({{ $response->id }})"
wire:confirm="Delete this canned response?" wire:confirm="Delete this canned response?"
class="!bg-red-600 !text-white hover:!bg-red-700 focus:!ring-red-400/60">Delete class="!h-8 !rounded-lg !px-2.5 !text-xs !font-semibold !bg-red-600 !text-white hover:!bg-red-700 focus:!ring-red-400/60">Delete
</flux:button> </flux:button>
</div> </div>
</div> </div>
@@ -639,7 +668,7 @@
<x-helpdesk::settings-section title="SLA policies" <x-helpdesk::settings-section title="SLA policies"
description="Define first response and resolution targets used for breach indicators, reporting and ticket cards."> description="Define first response and resolution targets used for breach indicators, reporting and ticket cards.">
<x-slot:form> <x-slot:form>
<form wire:submit.prevent="saveSlaPolicy" class="grid gap-4 md:grid-cols-6"> <form wire:submit.prevent="saveSlaPolicy" class="grid gap-3 md:grid-cols-6">
<flux:input wire:model="slaForm.name" label="Name" /> <flux:input wire:model="slaForm.name" label="Name" />
<flux:input wire:model="slaForm.slug" label="Slug" placeholder="auto-generated" /> <flux:input wire:model="slaForm.slug" label="Slug" placeholder="auto-generated" />
<flux:select wire:model="slaForm.priority_id" label="Priority"> <flux:select wire:model="slaForm.priority_id" label="Priority">
@@ -667,12 +696,12 @@
<label class="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-200"><input <label class="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-200"><input
type="checkbox" wire:model="slaForm.is_active" class="rounded border-zinc-300"> type="checkbox" wire:model="slaForm.is_active" class="rounded border-zinc-300">
Active</label> Active</label>
<div class="md:col-span-7 flex gap-2"> <div class="md:col-span-full flex gap-2">
<flux:button type="submit" variant="primary" <flux:button size="sm" type="submit" variant="primary"
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save SLA class="!h-9 !rounded-lg !px-3 !text-xs !font-semibold !bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save SLA
policy</flux:button> policy</flux:button>
<flux:button type="button" wire:click="resetSlaPolicyForm" variant="ghost" <flux:button size="sm" type="button" wire:click="resetSlaPolicyForm" variant="ghost"
class="!bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset class="!h-9 !rounded-lg !px-3 !text-xs !font-semibold !bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset
</flux:button> </flux:button>
</div> </div>
</form> </form>
@@ -702,12 +731,12 @@
<td class="px-4 py-3 text-right"> <td class="px-4 py-3 text-right">
<flux:button size="sm" variant="primary" <flux:button size="sm" variant="primary"
wire:click="editSlaPolicy({{ $policy->id }})" wire:click="editSlaPolicy({{ $policy->id }})"
class="!bg-blue-600 !text-white hover:!bg-blue-700 focus:!ring-blue-400/60"> class="!h-8 !rounded-lg !px-2.5 !text-xs !font-semibold !bg-blue-600 !text-white hover:!bg-blue-700 focus:!ring-blue-400/60">
Edit</flux:button> Edit</flux:button>
<flux:button size="sm" variant="danger" <flux:button size="sm" variant="danger"
wire:click="deleteSlaPolicy({{ $policy->id }})" wire:click="deleteSlaPolicy({{ $policy->id }})"
wire:confirm="Delete this SLA policy?" wire:confirm="Delete this SLA policy?"
class="!bg-red-600 !text-white hover:!bg-red-700 focus:!ring-red-400/60">Delete class="!h-8 !rounded-lg !px-2.5 !text-xs !font-semibold !bg-red-600 !text-white hover:!bg-red-700 focus:!ring-red-400/60">Delete
</flux:button> </flux:button>
</td> </td>
</tr>@empty<tr> </tr>@empty<tr>
@@ -725,7 +754,7 @@
<x-helpdesk::settings-section title="Email automation rules" <x-helpdesk::settings-section title="Email automation rules"
description="Match incoming support messages and automatically assign priority, category, team, project, agent and status."> description="Match incoming support messages and automatically assign priority, category, team, project, agent and status.">
<x-slot:form> <x-slot:form>
<form wire:submit.prevent="saveAutomationRule" class="grid gap-4 md:grid-cols-6"> <form wire:submit.prevent="saveAutomationRule" class="grid gap-3 md:grid-cols-6">
<flux:input wire:model="automationForm.name" label="Name" /> <flux:input wire:model="automationForm.name" label="Name" />
<flux:input wire:model="automationForm.slug" label="Slug" placeholder="auto-generated" /> <flux:input wire:model="automationForm.slug" label="Slug" placeholder="auto-generated" />
<flux:input wire:model="automationForm.sort_order" label="Sort" type="number" <flux:input wire:model="automationForm.sort_order" label="Sort" type="number"
@@ -763,15 +792,15 @@
<option value="{{ $status->id }}">{{ $status->name }}</option> <option value="{{ $status->id }}">{{ $status->name }}</option>
@endforeach @endforeach
</flux:select> </flux:select>
<label class="flex items-end gap-2 pb-2 text-sm text-zinc-700 dark:text-zinc-200"><input <label class="flex min-h-9 items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-xs font-semibold text-zinc-700 shadow-sm dark:border-zinc-700 dark:bg-zinc-950/60 dark:text-zinc-200"><input
type="checkbox" wire:model="automationForm.is_active" class="rounded border-zinc-300"> type="checkbox" wire:model="automationForm.is_active" class="rounded border-zinc-300">
Active</label> Active</label>
<div class="md:col-span-7 flex gap-2"> <div class="md:col-span-full flex gap-2">
<flux:button type="submit" variant="primary" <flux:button size="sm" type="submit" variant="primary"
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save class="!h-9 !rounded-lg !px-3 !text-xs !font-semibold !bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save
automation rule</flux:button> automation rule</flux:button>
<flux:button type="button" wire:click="resetAutomationRuleForm" variant="ghost" <flux:button size="sm" type="button" wire:click="resetAutomationRuleForm" variant="ghost"
class="!bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset class="!h-9 !rounded-lg !px-3 !text-xs !font-semibold !bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset
</flux:button> </flux:button>
</div> </div>
</form> </form>
@@ -785,12 +814,12 @@
<div class="mt-4 flex justify-end gap-2"> <div class="mt-4 flex justify-end gap-2">
<flux:button size="sm" variant="primary" <flux:button size="sm" variant="primary"
wire:click="editAutomationRule({{ $rule->id }})" wire:click="editAutomationRule({{ $rule->id }})"
class="!bg-blue-600 !text-white hover:!bg-blue-700 focus:!ring-blue-400/60">Edit class="!h-8 !rounded-lg !px-2.5 !text-xs !font-semibold !bg-blue-600 !text-white hover:!bg-blue-700 focus:!ring-blue-400/60">Edit
</flux:button> </flux:button>
<flux:button size="sm" variant="danger" <flux:button size="sm" variant="danger"
wire:click="deleteAutomationRule({{ $rule->id }})" wire:click="deleteAutomationRule({{ $rule->id }})"
wire:confirm="Delete this rule?" wire:confirm="Delete this rule?"
class="!bg-red-600 !text-white hover:!bg-red-700 focus:!ring-red-400/60">Delete class="!h-8 !rounded-lg !px-2.5 !text-xs !font-semibold !bg-red-600 !text-white hover:!bg-red-700 focus:!ring-red-400/60">Delete
</flux:button> </flux:button>
</div> </div>
</div>@empty<div </div>@empty<div
@@ -805,7 +834,7 @@
<x-helpdesk::settings-section title="Checklist templates" <x-helpdesk::settings-section title="Checklist templates"
description="Apply repeatable checklist items by category or project and show completion progress on tickets and Kanban cards."> description="Apply repeatable checklist items by category or project and show completion progress on tickets and Kanban cards.">
<x-slot:form> <x-slot:form>
<form wire:submit.prevent="saveChecklistTemplate" class="grid gap-4 md:grid-cols-6"> <form wire:submit.prevent="saveChecklistTemplate" class="grid gap-3 md:grid-cols-6">
<flux:input wire:model="checklistTemplateForm.name" label="Name" /> <flux:input wire:model="checklistTemplateForm.name" label="Name" />
<flux:input wire:model="checklistTemplateForm.slug" label="Slug" /> <flux:input wire:model="checklistTemplateForm.slug" label="Slug" />
<flux:select wire:model="checklistTemplateForm.category_id" label="Category"> <flux:select wire:model="checklistTemplateForm.category_id" label="Category">
@@ -823,16 +852,16 @@
<label class="flex items-end gap-2 pb-2 text-sm"><input type="checkbox" <label class="flex items-end gap-2 pb-2 text-sm"><input type="checkbox"
wire:model="checklistTemplateForm.is_active" class="rounded border-zinc-300"> wire:model="checklistTemplateForm.is_active" class="rounded border-zinc-300">
Active</label> Active</label>
<div class="md:col-span-7"> <div class="md:col-span-full">
<flux:textarea wire:model="checklistTemplateForm.items_text" <flux:textarea wire:model="checklistTemplateForm.items_text"
label="Checklist items, one per line" rows="6" /> label="Checklist items, one per line" rows="6" />
</div> </div>
<div class="md:col-span-7 flex gap-2"> <div class="md:col-span-full flex gap-2">
<flux:button type="submit" variant="primary" <flux:button size="sm" type="submit" variant="primary"
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save class="!h-9 !rounded-lg !px-3 !text-xs !font-semibold !bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save
checklist</flux:button> checklist</flux:button>
<flux:button type="button" wire:click="resetChecklistTemplateForm" variant="ghost" <flux:button size="sm" type="button" wire:click="resetChecklistTemplateForm" variant="ghost"
class="!bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset class="!h-9 !rounded-lg !px-3 !text-xs !font-semibold !bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset
</flux:button> </flux:button>
</div> </div>
</form> </form>
@@ -845,12 +874,12 @@
<div class="mt-4 flex justify-end gap-2"> <div class="mt-4 flex justify-end gap-2">
<flux:button size="sm" variant="primary" <flux:button size="sm" variant="primary"
wire:click="editChecklistTemplate({{ $template->id }})" wire:click="editChecklistTemplate({{ $template->id }})"
class="!bg-blue-600 !text-white hover:!bg-blue-700 focus:!ring-blue-400/60">Edit class="!h-8 !rounded-lg !px-2.5 !text-xs !font-semibold !bg-blue-600 !text-white hover:!bg-blue-700 focus:!ring-blue-400/60">Edit
</flux:button> </flux:button>
<flux:button size="sm" variant="danger" <flux:button size="sm" variant="danger"
wire:click="deleteChecklistTemplate({{ $template->id }})" wire:click="deleteChecklistTemplate({{ $template->id }})"
wire:confirm="Delete this checklist?" wire:confirm="Delete this checklist?"
class="!bg-red-600 !text-white hover:!bg-red-700 focus:!ring-red-400/60">Delete class="!h-8 !rounded-lg !px-2.5 !text-xs !font-semibold !bg-red-600 !text-white hover:!bg-red-700 focus:!ring-red-400/60">Delete
</flux:button> </flux:button>
</div> </div>
</div>@empty<div </div>@empty<div
@@ -865,16 +894,20 @@
<x-helpdesk::settings-section title="Ticket tags" <x-helpdesk::settings-section title="Ticket tags"
description="Maintain visible ticket labels for search, triage and Kanban card context."> description="Maintain visible ticket labels for search, triage and Kanban card context.">
<x-slot:form> <x-slot:form>
<form wire:submit.prevent="saveTag" class="grid gap-4 md:grid-cols-5"> <form wire:submit.prevent="saveTag" class="grid gap-3 xl:grid-cols-12">
<flux:input wire:model="tagForm.name" label="Name" /> <div class="xl:col-span-4">
<flux:input wire:model="tagForm.slug" label="Slug" /> <flux:input wire:model="tagForm.name" label="Name" />
<x-helpdesk::colour-select model="tagForm.color" label="Colour" :options="$colourOptions" /> </div>
<div class="md:col-span-5 flex gap-2"> <div class="xl:col-span-4">
<flux:button type="submit" variant="primary" <flux:input wire:model="tagForm.slug" label="Slug" />
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save tag </div>
<x-helpdesk::colour-select class="xl:col-span-12" model="tagForm.color" label="Colour" :options="$colourOptions" />
<div class="flex flex-wrap items-center gap-2 xl:col-span-12">
<flux:button size="sm" type="submit" variant="primary"
class="!h-9 !rounded-lg !px-3 !text-xs !font-semibold !bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save tag
</flux:button> </flux:button>
<flux:button type="button" wire:click="resetTagForm" variant="ghost" <flux:button size="sm" type="button" wire:click="resetTagForm" variant="ghost"
class="!bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset class="!h-9 !rounded-lg !px-3 !text-xs !font-semibold !bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset
</flux:button> </flux:button>
</div> </div>
</form> </form>
@@ -887,11 +920,11 @@
style="background-color: {{ \Modules\Helpdesk\Support\Color::css($tag->color) }}">{{ $tag->name }}</span> style="background-color: {{ \Modules\Helpdesk\Support\Color::css($tag->color) }}">{{ $tag->name }}</span>
<div class="mt-3 flex gap-2"> <div class="mt-3 flex gap-2">
<flux:button size="sm" variant="primary" wire:click="editTag({{ $tag->id }})" <flux:button size="sm" variant="primary" wire:click="editTag({{ $tag->id }})"
class="!bg-blue-600 !text-white hover:!bg-blue-700 focus:!ring-blue-400/60">Edit class="!h-8 !rounded-lg !px-2.5 !text-xs !font-semibold !bg-blue-600 !text-white hover:!bg-blue-700 focus:!ring-blue-400/60">Edit
</flux:button> </flux:button>
<flux:button size="sm" variant="danger" wire:click="deleteTag({{ $tag->id }})" <flux:button size="sm" variant="danger" wire:click="deleteTag({{ $tag->id }})"
wire:confirm="Delete this tag?" wire:confirm="Delete this tag?"
class="!bg-red-600 !text-white hover:!bg-red-700 focus:!ring-red-400/60">Delete class="!h-8 !rounded-lg !px-2.5 !text-xs !font-semibold !bg-red-600 !text-white hover:!bg-red-700 focus:!ring-red-400/60">Delete
</flux:button> </flux:button>
</div> </div>
</div>@empty<div </div>@empty<div
@@ -906,7 +939,7 @@
<x-helpdesk::settings-section title="Products and services" <x-helpdesk::settings-section title="Products and services"
description="Link tickets to products, services, releases or builds and report support cost/time by product."> description="Link tickets to products, services, releases or builds and report support cost/time by product.">
<x-slot:form> <x-slot:form>
<form wire:submit.prevent="saveProduct" class="grid gap-4 md:grid-cols-6"> <form wire:submit.prevent="saveProduct" class="grid gap-3 md:grid-cols-6">
<flux:input wire:model="productForm.name" label="Name" /> <flux:input wire:model="productForm.name" label="Name" />
<flux:input wire:model="productForm.slug" label="Slug" /> <flux:input wire:model="productForm.slug" label="Slug" />
<flux:input wire:model="productForm.type" label="Type" /> <flux:input wire:model="productForm.type" label="Type" />
@@ -914,15 +947,15 @@
<flux:input wire:model="productForm.release_channel" label="Release/build" /><label <flux:input wire:model="productForm.release_channel" label="Release/build" /><label
class="flex items-end gap-2 pb-2 text-sm"><input type="checkbox" class="flex items-end gap-2 pb-2 text-sm"><input type="checkbox"
wire:model="productForm.is_active" class="rounded border-zinc-300"> Active</label> wire:model="productForm.is_active" class="rounded border-zinc-300"> Active</label>
<div class="md:col-span-7"> <div class="md:col-span-full">
<flux:textarea wire:model="productForm.description" label="Description" rows="3" /> <flux:textarea wire:model="productForm.description" label="Description" rows="3" />
</div> </div>
<div class="md:col-span-7 flex gap-2"> <div class="md:col-span-full flex gap-2">
<flux:button type="submit" variant="primary" <flux:button size="sm" type="submit" variant="primary"
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save class="!h-9 !rounded-lg !px-3 !text-xs !font-semibold !bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save
product/service</flux:button> product/service</flux:button>
<flux:button type="button" wire:click="resetProductForm" variant="ghost" <flux:button size="sm" type="button" wire:click="resetProductForm" variant="ghost"
class="!bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset class="!h-9 !rounded-lg !px-3 !text-xs !font-semibold !bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset
</flux:button> </flux:button>
</div> </div>
</form> </form>
@@ -936,12 +969,12 @@
<div class="mt-4 flex justify-end gap-2"> <div class="mt-4 flex justify-end gap-2">
<flux:button size="sm" variant="primary" <flux:button size="sm" variant="primary"
wire:click="editProduct({{ $product->id }})" wire:click="editProduct({{ $product->id }})"
class="!bg-blue-600 !text-white hover:!bg-blue-700 focus:!ring-blue-400/60">Edit class="!h-8 !rounded-lg !px-2.5 !text-xs !font-semibold !bg-blue-600 !text-white hover:!bg-blue-700 focus:!ring-blue-400/60">Edit
</flux:button> </flux:button>
<flux:button size="sm" variant="danger" <flux:button size="sm" variant="danger"
wire:click="deleteProduct({{ $product->id }})" wire:click="deleteProduct({{ $product->id }})"
wire:confirm="Delete this product/service?" wire:confirm="Delete this product/service?"
class="!bg-red-600 !text-white hover:!bg-red-700 focus:!ring-red-400/60">Delete class="!h-8 !rounded-lg !px-2.5 !text-xs !font-semibold !bg-red-600 !text-white hover:!bg-red-700 focus:!ring-red-400/60">Delete
</flux:button> </flux:button>
</div> </div>
</div>@empty<div </div>@empty<div
@@ -1,3 +1,10 @@
@php
$helpdeskAttachmentAccept = collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
->filter(fn ($extension) => is_string($extension) && trim($extension) !== '')
->map(fn ($extension) => '.' . strtolower(ltrim(trim($extension), '.')))
->unique()
->implode(',');
@endphp
<div class="hd-helpdesk-surface space-y-6" dusk="helpdesk-ticket-create"> <div class="hd-helpdesk-surface space-y-6" dusk="helpdesk-ticket-create">
@vite(['resources/css/app.css', 'resources/js/app.js']) @vite(['resources/css/app.css', 'resources/js/app.js'])
@include('helpdesk::partials.page-header', [ @include('helpdesk::partials.page-header', [
@@ -7,7 +14,43 @@
'Raise a new support request with priority, product/service, project, category, team and full details.', 'Raise a new support request with priority, product/service, project, category, team and full details.',
]) ])
<form wire:submit.prevent="save" x-on:submit.capture="$dispatch('helpdesk-editor-sync', { model: 'body' })" enctype="multipart/form-data" class="helpdesk-ticket-form space-y-6"> <form
wire:submit.prevent="save"
x-data="{ helpdeskAttachmentsUploading: false }"
x-on:livewire-upload-start="helpdeskAttachmentsUploading = true"
x-on:livewire-upload-finish="helpdeskAttachmentsUploading = false"
x-on:livewire-upload-error="helpdeskAttachmentsUploading = false"
x-on:livewire-upload-cancel="helpdeskAttachmentsUploading = false"
x-on:submit.capture="
if (helpdeskAttachmentsUploading) {
$event.preventDefault();
return;
}
const editor = document.querySelector('[data-helpdesk-sync-target=&quot;body&quot;], [data-helpdesk-editor-model=&quot;body&quot;]');
const target = document.querySelector('textarea[wire\\:model\\.defer=&quot;body&quot;], textarea[name=&quot;body&quot;], #body');
if (editor && target) {
target.value = editor.innerHTML?.trim?.() || editor.value?.trim?.() || '';
target.dispatchEvent(new Event('input', { bubbles: true }));
target.dispatchEvent(new Event('change', { bubbles: true }));
}
$dispatch('helpdesk-editor-sync', { model: 'body' });
"
enctype="multipart/form-data"
class="helpdesk-ticket-form space-y-6"
>
<div class="overflow-hidden rounded-2xl border border-zinc-700 bg-zinc-900 shadow-sm"> <div class="overflow-hidden rounded-2xl border border-zinc-700 bg-zinc-900 shadow-sm">
<div class="border-b border-zinc-700 bg-zinc-800/80 px-6 py-4"> <div class="border-b border-zinc-700 bg-zinc-800/80 px-6 py-4">
<h2 class="text-lg font-semibold text-zinc-100">Ticket details</h2> <h2 class="text-lg font-semibold text-zinc-100">Ticket details</h2>
@@ -23,7 +66,7 @@
Subject <span class="helpdesk-required-star">*</span> Subject <span class="helpdesk-required-star">*</span>
</label> </label>
<input id="subject" type="text" spellcheck="true" lang="en-GB" maxlength="180" wire:model.defer="subject" <input id="subject" type="text" spellcheck="true" lang="en-GB" autocorrect="on" autocomplete="on" autocapitalize="sentences" inputmode="text" maxlength="180" wire:model.defer="subject"
class="mt-2 block h-14 w-full max-w-4xl rounded-xl border border-zinc-700 bg-zinc-950 px-4 text-base font-medium text-zinc-100 shadow-sm outline-none transition-colors duration-150 placeholder:text-zinc-500 focus:border-blue-500 focus:bg-zinc-950 focus:ring-2 focus:ring-blue-500/40" class="mt-2 block h-14 w-full max-w-4xl rounded-xl border border-zinc-700 bg-zinc-950 px-4 text-base font-medium text-zinc-100 shadow-sm outline-none transition-colors duration-150 placeholder:text-zinc-500 focus:border-blue-500 focus:bg-zinc-950 focus:ring-2 focus:ring-blue-500/40"
data-dusk="ticket-subject" dusk="ticket-subject" autocomplete="off" /> data-dusk="ticket-subject" dusk="ticket-subject" autocomplete="off" />
@@ -221,10 +264,10 @@
<div class="lg:col-span-3 xl:col-span-6"> <div class="lg:col-span-3 xl:col-span-6">
<x-helpdesk::html-editor <x-helpdesk::html-editor
model="body"
label="Details" label="Details"
rows="12" rows="12"
placeholder="Describe the issue, expected outcome, steps to reproduce and any useful context..." placeholder="Describe the issue, expected outcome, steps to reproduce and any useful context..."
wire:model="body"
editor-dusk="ticket-body-editor" editor-dusk="ticket-body-editor"
/> />
@@ -235,7 +278,16 @@
</div> </div>
<div class="lg:col-span-3 xl:col-span-6"> <div class="lg:col-span-3 xl:col-span-6">
<div class="rounded-2xl border border-zinc-700 bg-zinc-950/70 p-4"> <div class="rounded-2xl border border-zinc-700 bg-zinc-950/70 p-4"
x-data="helpdeskAttachmentDropzone('attachments')"
x-on:dragover.prevent="dragging = true"
x-on:dragleave.prevent="dragging = false"
x-on:drop.prevent="dropFiles($event)"
x-on:livewire-upload-start="uploading = true; progress = 0"
x-on:livewire-upload-progress="progress = $event.detail.progress"
x-on:livewire-upload-finish="uploading = false; progress = 100"
x-on:livewire-upload-error="uploading = false"
x-bind:class="dragging ? 'border-blue-400 bg-blue-500/10' : ''">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<label class="block text-sm font-semibold text-zinc-200" for="attachments"> <label class="block text-sm font-semibold text-zinc-200" for="attachments">
@@ -243,7 +295,7 @@
</label> </label>
<p class="mt-1 text-sm text-zinc-500"> <p class="mt-1 text-sm text-zinc-500">
Add screenshots, documents, logs or supporting files to help resolve the ticket. Drag and drop screenshots, PDFs, logs, source files or documents. PHP, JS, RPG/RPGLE, CLLE and other source files are accepted.
</p> </p>
</div> </div>
@@ -254,12 +306,38 @@
</label> </label>
</div> </div>
<input id="attachments" type="file" wire:model="attachments" multiple class="sr-only"> <label for="attachments"
class="mt-4 flex cursor-pointer flex-col items-center justify-center rounded-2xl border border-dashed border-zinc-700 bg-zinc-900/70 px-4 py-8 text-center transition hover:border-blue-500 hover:bg-zinc-900"
x-bind:class="dragging ? 'border-blue-400 bg-blue-500/10' : ''">
<i class="fa-solid fa-cloud-arrow-up text-2xl text-blue-300" aria-hidden="true"></i>
<span class="mt-3 text-sm font-bold text-zinc-100">Drop files here or click to browse</span>
<span class="mt-1 text-xs text-zinc-500">Maximum {{ (int) config('helpdesk.max_upload_kb', 10240) }} KB per file.</span>
</label>
<div wire:loading.flex wire:target="attachments" <input id="attachments" type="file" wire:model="attachments" multiple accept="{{ $helpdeskAttachmentAccept }}" class="sr-only"
class="mt-4 items-center gap-2 rounded-xl border border-blue-500/30 bg-blue-500/10 px-4 py-3 text-sm font-medium text-blue-200"> x-ref="input" x-on:change="refreshSelectedFiles()">
<i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true"></i>
Uploading attachments... <template x-if="selectedFiles.length > 0">
<div class="mt-4 grid gap-2 sm:grid-cols-2">
<template x-for="file in selectedFiles" :key="file.name + file.size">
<div class="flex items-center gap-3 rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-xs text-zinc-300">
<i class="fa-regular fa-file-lines text-blue-300" aria-hidden="true"></i>
<span class="min-w-0 flex-1 truncate" x-text="file.name"></span>
<span class="shrink-0 text-zinc-500" x-text="formatSize(file.size)"></span>
</div>
</template>
</div>
</template>
<div x-show="uploading" x-cloak
class="mt-4 rounded-xl border border-blue-500/30 bg-blue-500/10 px-4 py-3 text-sm font-medium text-blue-200">
<div class="flex items-center justify-between gap-3">
<span class="inline-flex items-center gap-2"><i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true"></i>Uploading attachments...</span>
<span x-text="progress + '%'"></span>
</div>
<div class="mt-3 h-2 overflow-hidden rounded-full bg-zinc-800">
<div class="h-full rounded-full bg-blue-500 transition-all" x-bind:style="`width: ${progress}%`"></div>
</div>
</div> </div>
@error('attachments') @error('attachments')
@@ -273,25 +351,28 @@
</div> </div>
</div> </div>
<div class="flex items-center justify-end gap-3 border-t border-zinc-700 bg-zinc-900 px-6 py-4"> <div class="flex flex-col-reverse gap-2 border-t border-zinc-700 bg-zinc-900 px-6 py-4 sm:flex-row sm:items-center sm:justify-end">
<button type="button" wire:click="clearForm" wire:loading.attr="disabled" wire:target="clearForm" <button type="button" wire:click="clearForm" wire:loading.attr="disabled" wire:target="clearForm"
class="inline-flex items-center gap-2 rounded-xl bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50" class="inline-flex min-h-10 items-center justify-center rounded-lg bg-red-600 px-4 py-2 text-xs font-black text-white shadow-sm transition hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-400/70 disabled:cursor-not-allowed disabled:opacity-50"
data-dusk="ticket-create-clear" dusk="ticket-create-clear"> data-dusk="ticket-create-clear" dusk="ticket-create-clear">
<i class="fa-solid fa-eraser" aria-hidden="true"></i> <i class="fa-solid fa-eraser mr-2" aria-hidden="true"></i>
<span wire:loading.remove wire:target="clearForm">Clear</span> <span wire:loading.remove wire:target="clearForm">Clear</span>
<span wire:loading wire:target="clearForm">Clearing...</span> <span wire:loading wire:target="clearForm">Clearing...</span>
</button> </button>
<a href="{{ route('helpdesk.tickets.index') }}" wire:navigate <a href="{{ route('helpdesk.tickets.index') }}" wire:navigate
class="rounded-xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-700"> class="inline-flex min-h-10 items-center justify-center rounded-lg bg-blue-600 px-4 py-2 text-xs font-black text-white shadow-sm transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400/70"
data-dusk="ticket-create-cancel" dusk="ticket-create-cancel">
<i class="fa-solid fa-arrow-left mr-2" aria-hidden="true"></i>
Cancel Cancel
</a> </a>
<button type="submit" <button type="submit"
class="inline-flex items-center gap-2 rounded-xl bg-green-600 px-5 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50" class="inline-flex min-h-10 items-center justify-center rounded-lg bg-emerald-600 px-4 py-2 text-xs font-black text-white shadow-sm transition hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-400/70 disabled:cursor-not-allowed disabled:opacity-50"
data-dusk="ticket-create-submit" dusk="ticket-create-submit" wire:loading.attr="disabled" wire:target="save" wire:dirty.class="ring-2 ring-amber-400/40"> data-dusk="ticket-create-submit" dusk="ticket-create-submit" wire:loading.attr="disabled" wire:target="save,attachments" x-bind:disabled="helpdeskAttachmentsUploading" wire:dirty.class="ring-2 ring-amber-400/40">
<i class="fa-regular fa-floppy-disk" aria-hidden="true"></i> <i class="fa-regular fa-floppy-disk mr-2" aria-hidden="true"></i>
<span wire:loading.remove wire:target="save">Create Ticket</span> <span wire:loading.remove wire:target="save,attachments">Create ticket</span>
<span wire:loading wire:target="attachments">Uploading...</span>
<span wire:loading wire:target="save">Creating...</span> <span wire:loading wire:target="save">Creating...</span>
</button> </button>
</div> </div>
@@ -299,17 +380,69 @@
</form> </form>
<script> <script>
(() => { (() => {
window.helpdeskAttachmentDropzone = window.helpdeskAttachmentDropzone || function (inputId) {
return {
dragging: false,
uploading: false,
progress: 0,
selectedFiles: [],
dropFiles(event) {
this.dragging = false;
const input = this.$refs.input || document.getElementById(inputId);
if (!input || !event.dataTransfer?.files?.length) {
return;
}
input.files = event.dataTransfer.files;
input.dispatchEvent(new Event('change', { bubbles: true }));
this.refreshSelectedFiles();
},
refreshSelectedFiles() {
const input = this.$refs.input || document.getElementById(inputId);
this.selectedFiles = input?.files ? Array.from(input.files) : [];
},
formatSize(bytes) {
if (!bytes) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB'];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
return `${(bytes / Math.pow(1024, index)).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
},
};
};
const resetTicketCreateFiles = () => { const resetTicketCreateFiles = () => {
document.querySelectorAll('.helpdesk-ticket-form #attachments').forEach((input) => { document.querySelectorAll('.helpdesk-ticket-form #attachments').forEach((input) => {
input.value = ''; input.value = '';
input.dispatchEvent(new Event('change', { bubbles: true }));
}); });
}; };
const appendPastedFiles = (event) => {
const files = Array.from(event.detail?.files || []);
const input = document.querySelector('.helpdesk-ticket-form #attachments');
if (!input || files.length === 0) {
return;
}
const transfer = new DataTransfer();
Array.from(input.files || []).forEach((file) => transfer.items.add(file));
files.forEach((file) => transfer.items.add(file));
input.files = transfer.files;
input.dispatchEvent(new Event('change', { bubbles: true }));
};
document.addEventListener('helpdesk-form-cleared', resetTicketCreateFiles); document.addEventListener('helpdesk-form-cleared', resetTicketCreateFiles);
document.addEventListener('helpdesk-editor-files-pasted', appendPastedFiles);
if (window.Livewire?.on) { if (window.Livewire?.on) {
window.Livewire.on('helpdesk-form-cleared', resetTicketCreateFiles); window.Livewire.on('helpdesk-form-cleared', resetTicketCreateFiles);
} }
})(); })();
</script> </script>
</div> </div>
@@ -1,3 +1,10 @@
@php
$helpdeskAttachmentAccept = collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
->filter(fn ($extension) => is_string($extension) && trim($extension) !== '')
->map(fn ($extension) => '.' . strtolower(ltrim(trim($extension), '.')))
->unique()
->implode(',');
@endphp
<div class="space-y-6"> <div class="space-y-6">
@vite(['resources/css/app.css', 'resources/js/app.js']) @vite(['resources/css/app.css', 'resources/js/app.js'])
@include('helpdesk::partials.page-header', [ @include('helpdesk::partials.page-header', [
@@ -44,9 +51,9 @@
Subject <span class="helpdesk-required-star">*</span> Subject <span class="helpdesk-required-star">*</span>
</label> </label>
<input id="subject" type="text" maxlength="180" wire:model.defer="subject" <input id="subject" type="text" spellcheck="true" lang="en-GB" autocorrect="on" autocomplete="on" autocapitalize="sentences" inputmode="text" maxlength="180" wire:model.defer="subject"
class="mt-2 block h-14 w-full max-w-4xl rounded-xl border border-zinc-700 bg-zinc-950 px-4 text-base font-medium text-zinc-100 shadow-sm outline-none transition-colors duration-150 placeholder:text-zinc-500 focus:border-blue-500 focus:bg-zinc-950 focus:ring-2 focus:ring-blue-500/40" class="mt-2 block h-14 w-full max-w-4xl rounded-xl border border-zinc-700 bg-zinc-950 px-4 text-base font-medium text-zinc-100 shadow-sm outline-none transition-colors duration-150 placeholder:text-zinc-500 focus:border-blue-500 focus:bg-zinc-950 focus:ring-2 focus:ring-blue-500/40"
data-dusk="ticket-edit-subject" autocomplete="off" /> data-dusk="ticket-edit-subject" dusk="ticket-edit-subject" />
@error('subject') @error('subject')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p> <p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@@ -284,8 +291,13 @@
</div> </div>
<div class="lg:col-span-3 xl:col-span-6"> <div class="lg:col-span-3 xl:col-span-6">
<x-helpdesk::html-editor dusk="ticket-edit-body-editor" wire:model="body" label="Details *" <x-helpdesk::html-editor
rows="10" placeholder="Update the ticket details..." /> model="body"
label="Details *"
rows="10"
placeholder="Update the ticket details..."
editor-dusk="ticket-edit-body-editor"
/>
@error('body') @error('body')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p> <p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@@ -323,7 +335,7 @@
<div class="p-4"> <div class="p-4">
<input id="attachments" type="file" wire:model="attachments" multiple <input id="attachments" type="file" wire:model="attachments" multiple accept="{{ $helpdeskAttachmentAccept }}"
class="sr-only"> class="sr-only">
<div wire:loading.flex wire:target="attachments" <div wire:loading.flex wire:target="attachments"
@@ -1,3 +1,10 @@
@php
$helpdeskAttachmentAccept = collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
->filter(fn ($extension) => is_string($extension) && trim($extension) !== '')
->map(fn ($extension) => '.' . strtolower(ltrim(trim($extension), '.')))
->unique()
->implode(',');
@endphp
@php @php
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Modules\Helpdesk\Models\TicketTimeLog; use Modules\Helpdesk\Models\TicketTimeLog;
@@ -15,6 +22,7 @@
->get(); ->get();
@endphp @endphp
<div data-dusk="helpdesk-ticket-show" dusk="helpdesk-ticket-show" class="hd-helpdesk-surface space-y-4"> <div data-dusk="helpdesk-ticket-show" dusk="helpdesk-ticket-show" class="hd-helpdesk-surface space-y-4">
<div <div
class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900"> class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
@@ -36,10 +44,10 @@
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<a href="{{ route('helpdesk.tickets.index') }}" wire:navigate <a href="{{ route('helpdesk.index') }}" wire:navigate
class="inline-flex items-center rounded-lg bg-blue-600 px-3 py-2 text-xs font-bold text-white shadow-sm hover:bg-blue-700"> class="inline-flex items-center rounded-lg bg-blue-600 px-3 py-2 text-xs font-bold text-white shadow-sm hover:bg-blue-700">
<i class="fa-solid fa-arrow-left mr-2" aria-hidden="true"></i> <i class="fa-solid fa-gauge-high mr-2" aria-hidden="true"></i>
Back Dashboard
</a> </a>
@can('update', $ticket) @can('update', $ticket)
<a href="{{ route('helpdesk.tickets.edit', $ticket) }}" wire:navigate <a href="{{ route('helpdesk.tickets.edit', $ticket) }}" wire:navigate
@@ -141,7 +149,7 @@
class="hd-attachment-action !rounded-lg !px-3 !py-2 !text-xs"> class="hd-attachment-action !rounded-lg !px-3 !py-2 !text-xs">
<i class="fa-solid fa-paperclip" aria-hidden="true"></i> <i class="fa-solid fa-paperclip" aria-hidden="true"></i>
<span>Choose files</span> <span>Choose files</span>
<input name="attachments[]" type="file" multiple class="sr-only" <input name="attachments[]" type="file" multiple accept="{{ $helpdeskAttachmentAccept }}" class="sr-only"
data-helpdesk-file-input> data-helpdesk-file-input>
</label> </label>
@@ -163,30 +171,76 @@
</form> </form>
@endcan @endcan
@if ($ticket->attachments->isEmpty()) @php
$visibleAttachments = $ticket->attachments->filter(fn ($attachment) => ! $attachment->isDeletedAttachment());
@endphp
@if ($visibleAttachments->isEmpty())
<div <div
class="rounded-xl border border-dashed border-zinc-300 p-4 text-center text-xs font-semibold text-zinc-500 dark:border-zinc-700"> class="rounded-xl border border-dashed border-zinc-300 p-4 text-center text-xs font-semibold text-zinc-500 dark:border-zinc-700">
No supporting files have been attached yet. No supporting files have been attached yet.
</div> </div>
@else @else
<div class="grid gap-2 sm:grid-cols-2"> <div class="grid gap-3 sm:grid-cols-2">
@foreach ($ticket->attachments as $attachment) @foreach ($visibleAttachments as $attachment)
<a href="{{ route('helpdesk.attachments.download', $attachment->id) }}" target="_blank" <article
class="group/file flex items-center justify-between gap-3 rounded-lg border border-zinc-200 px-3 py-2 text-xs transition hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800"> class="group/file overflow-hidden rounded-xl border border-zinc-200 bg-white text-xs shadow-sm transition hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-950 dark:hover:bg-zinc-900">
<span class="min-w-0"> @if ($attachment->isImage())
<span class="block truncate font-bold text-zinc-900 dark:text-zinc-100"> <a href="{{ route('helpdesk.attachments.preview', $attachment->id) }}"
{{ $attachment->original_name ?? ($attachment->original_filename ?? ($attachment->filename ?? ($attachment->file_name ?? basename($attachment->path ?? ($attachment->file_path ?? ''))))) }} class="block aspect-video overflow-hidden bg-zinc-100 dark:bg-zinc-900">
</span> <img src="{{ route('helpdesk.attachments.preview', $attachment->id) }}" alt="{{ $attachment->displayName() }}"
<span class="block text-[0.7rem] text-zinc-500"> class="h-full w-full object-cover transition group-hover/file:scale-[1.02]">
{{ Number::fileSize($attachment->size ?? ($attachment->size_bytes ?? 0)) }} </a>
</span> @endif
</span>
<span <div class="flex items-start gap-3 p-3">
class="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-zinc-100 text-zinc-700 transition group-hover/file:bg-blue-600 group-hover/file:text-white dark:bg-zinc-800 dark:text-zinc-200"> <span
<i class="fa-solid fa-download text-xs" aria-hidden="true"></i> class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-300">
</span> <i class="{{ $attachment->icon() }} text-sm" aria-hidden="true"></i>
</a> </span>
<div class="min-w-0 flex-1">
<p class="truncate font-bold text-zinc-900 dark:text-zinc-100">
{{ $attachment->displayName() }}
</p>
<p class="mt-1 text-[0.7rem] font-semibold uppercase tracking-wide text-zinc-500">
{{ strtoupper($attachment->language()) }} · {{ Number::fileSize($attachment->byteSize()) }}
@if ($attachment->downloadCount() > 0)
· {{ $attachment->downloadCount() }} download{{ $attachment->downloadCount() === 1 ? '' : 's' }}
@endif
</p>
<div class="mt-3 flex flex-wrap gap-2">
@if ($attachment->canPreview())
<a href="{{ route('helpdesk.attachments.preview', $attachment->id) }}"
class="inline-flex items-center gap-1 rounded-lg bg-green-600 px-2.5 py-1.5 text-[0.7rem] font-bold text-white hover:bg-green-700">
<i class="fa-regular fa-eye" aria-hidden="true"></i>
Preview
</a>
@endif
<a href="{{ route('helpdesk.attachments.download', $attachment->id) }}" target="_blank"
class="inline-flex items-center gap-1 rounded-lg bg-blue-600 px-2.5 py-1.5 text-[0.7rem] font-bold text-white hover:bg-blue-700">
<i class="fa-solid fa-download" aria-hidden="true"></i>
Download
</a>
@can('update', $ticket)
<form method="POST" action="{{ route('helpdesk.attachments.destroy', $attachment->id) }}"
onsubmit="return confirm('Remove this attachment from the ticket?');">
@csrf
@method('DELETE')
<button type="submit"
class="inline-flex items-center gap-1 rounded-lg bg-red-600 px-2.5 py-1.5 text-[0.7rem] font-bold text-white hover:bg-red-700">
<i class="fa-regular fa-trash-can" aria-hidden="true"></i>
Remove
</button>
</form>
@endcan
</div>
</div>
</div>
</article>
@endforeach @endforeach
</div> </div>
@endif @endif
@@ -397,7 +451,7 @@
<label for="ticket-comment-subject" <label for="ticket-comment-subject"
class="mb-2 block text-xs font-bold text-zinc-700 dark:text-zinc-200">Subject</label> class="mb-2 block text-xs font-bold text-zinc-700 dark:text-zinc-200">Subject</label>
<input id="ticket-comment-subject" name="subject" value="{{ old('subject') }}" <input id="ticket-comment-subject" name="subject" value="{{ old('subject') }}"
placeholder="Response subject" placeholder="Response subject" spellcheck="true" lang="en-GB" autocorrect="on" autocomplete="on" autocapitalize="sentences" inputmode="text"
class="block min-h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm font-semibold text-zinc-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" class="block min-h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm font-semibold text-zinc-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
data-helpdesk-comment-subject> data-helpdesk-comment-subject>
</div> </div>
@@ -427,11 +481,11 @@
<div id="ticket-comment-body-editor" dusk="comment-body" data-dusk="comment-body" <div id="ticket-comment-body-editor" dusk="comment-body" data-dusk="comment-body"
data-helpdesk-rich-editor data-helpdesk-sync-target="ticket-comment-body" data-helpdesk-rich-editor data-helpdesk-sync-target="ticket-comment-body"
contenteditable="true" spellcheck="true" lang="en-GB" autocorrect="on" autocomplete="on" contenteditable="true" role="textbox" aria-multiline="true" spellcheck="true" lang="en-GB" autocorrect="on" autocomplete="on" autocapitalize="sentences" inputmode="text"
class="prose prose-sm min-h-[12rem] max-w-none bg-white p-4 text-sm leading-6 text-zinc-900 outline-none dark:prose-invert dark:bg-zinc-950 dark:text-zinc-100" class="prose prose-sm min-h-[12rem] max-w-none bg-white p-4 text-sm leading-6 text-zinc-900 outline-none dark:prose-invert dark:bg-zinc-950 dark:text-zinc-100"
data-placeholder="Write a public reply or internal note. Right-click or Ctrl-click underlined words for spelling suggestions.">{!! $oldCommentBody !!}</div> data-placeholder="Write a public reply or internal note. Right-click or Ctrl-click underlined words for spelling suggestions.">{!! $oldCommentBody !!}</div>
<textarea id="ticket-comment-body" name="commentBody" required class="sr-only" tabindex="-1" aria-hidden="true">{{ $oldCommentBody }}</textarea> <textarea id="ticket-comment-body" name="commentBody" required spellcheck="true" lang="en-GB" autocorrect="on" autocomplete="on" autocapitalize="sentences" inputmode="text" class="sr-only" tabindex="-1" aria-hidden="true">{{ $oldCommentBody }}</textarea>
</div> </div>
<p class="mt-2 flex items-center gap-2 text-xs font-medium text-zinc-500 dark:text-zinc-400"> <p class="mt-2 flex items-center gap-2 text-xs font-medium text-zinc-500 dark:text-zinc-400">
@@ -456,7 +510,7 @@
class="inline-flex cursor-pointer items-center justify-center rounded-lg bg-blue-600 px-3 py-2 text-xs font-bold text-white shadow-sm transition hover:bg-blue-700 focus-within:outline-none focus-within:ring-2 focus-within:ring-blue-400/70"> class="inline-flex cursor-pointer items-center justify-center rounded-lg bg-blue-600 px-3 py-2 text-xs font-bold text-white shadow-sm transition hover:bg-blue-700 focus-within:outline-none focus-within:ring-2 focus-within:ring-blue-400/70">
<i class="fa-solid fa-paperclip mr-2 text-xs" aria-hidden="true"></i> <i class="fa-solid fa-paperclip mr-2 text-xs" aria-hidden="true"></i>
<span>Choose files</span> <span>Choose files</span>
<input name="comment_attachments[]" type="file" multiple class="sr-only" <input name="comment_attachments[]" type="file" multiple accept="{{ $helpdeskAttachmentAccept }}" class="sr-only"
data-helpdesk-file-input> data-helpdesk-file-input>
</label> </label>
</div> </div>
@@ -480,7 +534,7 @@
@if ($canSeeInternalNotes) @if ($canSeeInternalNotes)
<div class="grid gap-3 sm:grid-cols-2"> <div class="grid gap-3 sm:grid-cols-2">
<label class="flex cursor-pointer items-start gap-3 rounded-xl border border-emerald-300 bg-emerald-50 p-3 text-emerald-950 shadow-sm transition hover:bg-emerald-100 dark:border-emerald-900 dark:bg-emerald-950/60 dark:text-emerald-100"> <label class="flex cursor-pointer items-start gap-3 rounded-xl border border-emerald-300 bg-emerald-50 p-3 text-emerald-950 shadow-sm transition hover:border-emerald-400 hover:bg-emerald-100/80 focus-within:border-emerald-500 focus-within:bg-emerald-100/80 focus-within:ring-2 focus-within:ring-emerald-400/30 active:bg-emerald-100/80 dark:border-emerald-900 dark:bg-emerald-950/60 dark:text-emerald-100 dark:hover:border-emerald-700 dark:hover:bg-emerald-900/50 dark:focus-within:bg-emerald-900/50">
<input type="radio" name="internal" value="0" @checked(! old('internal')) class="mt-1 border-emerald-400 text-emerald-600 focus:ring-emerald-500"> <input type="radio" name="internal" value="0" @checked(! old('internal')) class="mt-1 border-emerald-400 text-emerald-600 focus:ring-emerald-500">
<span> <span>
<span class="block text-sm font-extrabold">Public reply</span> <span class="block text-sm font-extrabold">Public reply</span>
@@ -488,7 +542,7 @@
</span> </span>
</label> </label>
<label class="flex cursor-pointer items-start gap-3 rounded-xl border border-purple-300 bg-purple-50 p-3 text-purple-950 shadow-sm transition hover:bg-purple-100 dark:border-purple-900 dark:bg-purple-950/60 dark:text-purple-100"> <label class="flex cursor-pointer items-start gap-3 rounded-xl border border-purple-300 bg-purple-50 p-3 text-purple-950 shadow-sm transition hover:border-purple-400 hover:bg-purple-100/80 focus-within:border-purple-500 focus-within:bg-purple-100/80 focus-within:ring-2 focus-within:ring-purple-400/30 active:bg-purple-100/80 dark:border-purple-900 dark:bg-purple-950/60 dark:text-purple-100 dark:hover:border-purple-700 dark:hover:bg-purple-900/50 dark:focus-within:bg-purple-900/50">
<input type="radio" name="internal" value="1" @checked((bool) old('internal')) class="mt-1 border-purple-400 text-purple-600 focus:ring-purple-500"> <input type="radio" name="internal" value="1" @checked((bool) old('internal')) class="mt-1 border-purple-400 text-purple-600 focus:ring-purple-500">
<span> <span>
<span class="block text-sm font-extrabold">Internal note</span> <span class="block text-sm font-extrabold">Internal note</span>
@@ -589,6 +643,13 @@
return; return;
} }
const copyBranchButton = event.target.closest('[data-helpdesk-copy-branch-command]');
if (copyBranchButton) {
copyHelpdeskBranchCommand(copyBranchButton);
return;
}
const replyButton = event.target.closest('[data-helpdesk-reply-toggle]'); const replyButton = event.target.closest('[data-helpdesk-reply-toggle]');
if (!replyButton) { if (!replyButton) {
@@ -641,6 +702,12 @@
}); });
} }
const branchSuffix = event.target.closest('[data-helpdesk-branch-suffix]');
if (branchSuffix) {
updateHelpdeskBranchCommand(branchSuffix.closest('[data-helpdesk-branch-command]'));
}
const fileInput = event.target.closest('[data-helpdesk-file-input]'); const fileInput = event.target.closest('[data-helpdesk-file-input]');
if (fileInput) { if (fileInput) {
@@ -658,6 +725,66 @@
} }
}); });
function updateHelpdeskBranchCommand(container) {
if (!container) {
return '';
}
const prefix = container.dataset.helpdeskBranchPrefix || '';
const input = container.querySelector('[data-helpdesk-branch-suffix]');
const output = container.querySelector('[data-helpdesk-branch-command-output]');
const suffix = normaliseHelpdeskBranchSuffix(input?.value || 'short-description');
const command = `${prefix}${suffix}`;
if (output) {
output.textContent = command;
}
return command;
}
function normaliseHelpdeskBranchSuffix(value) {
const slug = String(value || '')
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-{2,}/g, '-');
return slug || 'short-description';
}
async function copyHelpdeskBranchCommand(button) {
const container = button.closest('[data-helpdesk-branch-command]');
const status = container?.querySelector('[data-helpdesk-branch-copy-status]');
const command = updateHelpdeskBranchCommand(container);
if (!command) {
return;
}
try {
await navigator.clipboard.writeText(command);
} catch (error) {
const fallback = document.createElement('textarea');
fallback.value = command;
fallback.setAttribute('readonly', 'readonly');
fallback.style.position = 'fixed';
fallback.style.opacity = '0';
document.body.appendChild(fallback);
fallback.select();
document.execCommand('copy');
fallback.remove();
}
if (status) {
status.classList.remove('hidden');
window.setTimeout(() => status.classList.add('hidden'), 2200);
}
}
document.querySelectorAll('[data-helpdesk-branch-command]').forEach(updateHelpdeskBranchCommand);
function updateCommentToggleButtons() { function updateCommentToggleButtons() {
const commentCards = Array.from(document.querySelectorAll('[data-helpdesk-comment-card]')); const commentCards = Array.from(document.querySelectorAll('[data-helpdesk-comment-card]'));
const expandButton = document.querySelector('[data-helpdesk-expand-comments]'); const expandButton = document.querySelector('[data-helpdesk-expand-comments]');
@@ -808,10 +935,82 @@
return span.innerHTML; return span.innerHTML;
} }
document.addEventListener('DOMContentLoaded', function () { function initialiseHelpdeskTicketShowInteractions() {
document.querySelectorAll('[data-helpdesk-rich-editor]').forEach((editor) => syncRichEditor(editor)); document.querySelectorAll('[data-helpdesk-rich-editor]').forEach((editor) => syncRichEditor(editor));
updateCommentToggleButtons(); updateCommentToggleButtons();
}); initialiseHelpdeskSidebarCards();
}
document.addEventListener('DOMContentLoaded', initialiseHelpdeskTicketShowInteractions);
document.addEventListener('livewire:navigated', initialiseHelpdeskTicketShowInteractions);
function initialiseHelpdeskSidebarCards() {
const sidebar = document.querySelector('[data-helpdesk-sidebar-cards]');
if (!sidebar) {
return;
}
sidebar.querySelectorAll('details[data-helpdesk-sidebar-card="true"]').forEach((details) => {
details.open = false;
details.removeAttribute('open');
});
if (sidebar.dataset.helpdeskSidebarInitialised === 'true') {
return;
}
sidebar.dataset.helpdeskSidebarInitialised = 'true';
Array.from(sidebar.children).forEach((card) => {
if (!(card instanceof HTMLElement) || card.tagName.toLowerCase() === 'details') {
return;
}
const header = card.firstElementChild;
if (!(header instanceof HTMLElement)) {
return;
}
const details = document.createElement("details");
const classNames = new Set(card.className.split(/\s+/).filter(Boolean));
classNames.add('group');
details.className = Array.from(classNames).join(' ');
details.dataset.helpdeskSidebarCard = 'true';
details.open = false;
const summary = document.createElement('summary');
summary.className = header.className;
summary.classList.add('flex', 'cursor-pointer', 'list-none', 'items-center', 'justify-between', 'gap-3', 'marker:hidden');
summary.classList.remove('mb-4');
while (header.firstChild) {
summary.appendChild(header.firstChild);
}
const chevron = document.createElement('span');
chevron.className = 'inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-white/15 text-white ring-1 ring-white/20';
chevron.innerHTML = '<i class="fa-solid fa-chevron-right text-xs transition group-open:rotate-90" aria-hidden="true"></i>';
summary.appendChild(chevron);
card.removeChild(header);
const body = document.createElement('div');
body.className = 'pt-4';
while (card.firstChild) {
body.appendChild(card.firstChild);
}
details.appendChild(summary);
details.appendChild(body);
details.open = false;
card.replaceWith(details);
});
}
function wrapSelection(textarea, before, after) { function wrapSelection(textarea, before, after) {
const start = textarea.selectionStart || 0; const start = textarea.selectionStart || 0;
@@ -827,7 +1026,7 @@
</script> </script>
</div> </div>
<aside class="space-y-4"> <aside class="space-y-4" data-helpdesk-sidebar-cards>
<div <div
class="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"> class="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="-mx-4 -mt-4 mb-4 rounded-t-xl bg-blue-700 px-4 py-4 text-white"> <div class="-mx-4 -mt-4 mb-4 rounded-t-xl bg-blue-700 px-4 py-4 text-white">
@@ -1096,24 +1295,80 @@
<i class="fa-solid fa-code-branch text-lg" aria-hidden="true"></i> <i class="fa-solid fa-code-branch text-lg" aria-hidden="true"></i>
</span> </span>
<div class="min-w-0"> <div class="min-w-0">
<h2 class="text-lg font-black uppercase tracking-wide">Git commits</h2> <h2 class="text-lg font-black uppercase tracking-wide">Git activity</h2>
<p class="text-xs font-semibold text-violet-100">Code commits linked to this ticket</p> <p class="text-xs font-semibold text-violet-100">Branches and commits linked to this ticket</p>
</div> </div>
</div> </div>
</div> </div>
@can('update', $ticket)
<div class="mb-4 rounded-xl border border-violet-200 bg-violet-50/80 p-3 shadow-sm dark:border-violet-900/70 dark:bg-violet-950/30"
data-helpdesk-branch-command
data-helpdesk-branch-prefix="git checkout -b bugfix/{{ $ticket->reference }}-">
<div class="flex items-start gap-3">
<span class="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-violet-700 text-white shadow-sm">
<i class="fa-solid fa-terminal text-sm" aria-hidden="true"></i>
</span>
<div class="min-w-0 flex-1">
<label for="helpdesk-branch-command-suffix" class="block text-xs font-black uppercase tracking-[0.16em] text-violet-900 dark:text-violet-100">
Branch command
</label>
<p class="mt-1 text-xs font-semibold text-violet-800/80 dark:text-violet-200/80">
Ticket reference is fixed. Enter the short branch description after {{ $ticket->reference }}-.
</p>
</div>
</div>
<div class="mt-3 grid gap-2">
<div class="flex flex-col gap-2 sm:flex-row">
<div class="flex min-h-10 flex-1 overflow-hidden rounded-lg border border-violet-200 bg-white shadow-sm focus-within:border-violet-500 focus-within:ring-2 focus-within:ring-violet-400/30 dark:border-violet-800 dark:bg-zinc-950">
<span class="hidden items-center border-r border-violet-100 bg-violet-100/70 px-3 font-mono text-[0.68rem] font-black text-violet-900 dark:border-violet-900 dark:bg-violet-950 dark:text-violet-100 sm:inline-flex">
bugfix/{{ $ticket->reference }}-
</span>
<input id="helpdesk-branch-command-suffix" type="text" inputmode="text" autocomplete="off" spellcheck="false"
value="helpdesk-ticket-branch-linking"
class="min-h-10 flex-1 border-0 bg-transparent px-3 py-2 font-mono text-xs font-semibold text-zinc-900 outline-none placeholder:text-zinc-400 focus:ring-0 dark:text-zinc-100"
placeholder="short-description"
data-helpdesk-branch-suffix>
</div>
<button type="button"
class="inline-flex min-h-10 items-center justify-center rounded-lg bg-violet-700 px-3 py-2 text-xs font-black text-white shadow-sm transition hover:bg-violet-800 focus:outline-none focus:ring-2 focus:ring-violet-400/70"
data-helpdesk-copy-branch-command>
<i class="fa-solid fa-copy mr-2" aria-hidden="true"></i>
Copy command
</button>
</div>
<code class="block overflow-x-auto rounded-lg border border-violet-100 bg-white/80 px-3 py-2 font-mono text-[0.68rem] font-bold text-violet-950 dark:border-violet-900 dark:bg-zinc-950/80 dark:text-violet-100"
data-helpdesk-branch-command-output>git checkout -b bugfix/{{ $ticket->reference }}-helpdesk-ticket-branch-linking</code>
<p class="hidden text-xs font-bold text-emerald-700 dark:text-emerald-300" data-helpdesk-branch-copy-status>
Copied to clipboard.
</p>
</div>
</div>
@endcan
<div class="space-y-2"> <div class="space-y-2">
@forelse ($ticket->gitCommits as $commit) @forelse ($ticket->gitCommits as $commit)
<a href="{{ $commit->url ?: '#' }}" target="_blank" <a href="{{ $commit->url ?: '#' }}" target="_blank"
class="block rounded-lg border border-zinc-100 p-2 dark:border-zinc-800"> class="block rounded-lg border border-zinc-100 p-2 dark:border-zinc-800">
<div class="font-mono text-[0.68rem] text-zinc-500">{{ $commit->short_hash }} · <div class="font-mono text-[0.68rem] text-zinc-500">
{{ $commit->repository?->name }}</div> {{ $commit->short_hash === 'branch' ? 'branch' : $commit->short_hash }} ·
{{ $commit->repository?->name }}
</div>
@if ($commit->branch)
<div class="mt-1 break-words font-mono text-[0.68rem] font-semibold text-violet-700 dark:text-violet-300">
{{ $commit->branch }}
</div>
@endif
<div class="mt-1 text-xs font-medium text-zinc-900 dark:text-zinc-100"> <div class="mt-1 text-xs font-medium text-zinc-900 dark:text-zinc-100">
{{ $commit->message }}</div> {{ $commit->message }}
</div>
</a> </a>
@empty @empty
<div <div
class="rounded-lg border border-dashed border-zinc-300 p-3 text-center text-xs text-zinc-500 dark:border-zinc-700"> class="rounded-lg border border-dashed border-zinc-300 p-3 text-center text-xs text-zinc-500 dark:border-zinc-700">
No commits linked. No Git activity linked.
</div> </div>
@endforelse @endforelse
</div> </div>
@@ -1179,3 +1434,45 @@
</div> </div>
{{-- SLA state label: Awaiting public reply --}} {{-- SLA state label: Awaiting public reply --}}
<script>
function initialiseHelpdeskTicketShowInteractions() {
document.querySelectorAll('[data-helpdesk-sidebar-cards]').forEach((container) => {
container.querySelectorAll('[data-helpdesk-sidebar-card]').forEach((card) => {
const header = card.querySelector('[data-helpdesk-sidebar-card-header]');
if (!header || header.dataset.helpdeskSidebarBound === 'true') {
return;
}
header.dataset.helpdeskSidebarBound = 'true';
header.addEventListener('click', () => toggleHelpdeskSidebarCard(card));
});
});
}
function toggleHelpdeskSidebarCard(card) {
const isOpen = card.dataset.open === 'true';
card.dataset.open = isOpen ? 'false' : 'true';
header.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
}
document.addEventListener('livewire:navigated', initialiseHelpdeskTicketShowInteractions);
document.addEventListener('livewire:init', initialiseHelpdeskTicketShowInteractions);
document.addEventListener('DOMContentLoaded', initialiseHelpdeskTicketShowInteractions);
</script>
<style>
[data-helpdesk-sidebar-cards] details > summary::-webkit-details-marker {
}
[data-helpdesk-sidebar-cards] details > summary {
transition: filter .15s ease, transform .15s ease;
}
[data-helpdesk-sidebar-cards] details > summary:hover {
filter: brightness(1.08);
}
</style>
@@ -1,3 +1,10 @@
@php
$helpdeskAttachmentAccept = collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
->filter(fn ($extension) => is_string($extension) && trim($extension) !== '')
->map(fn ($extension) => '.' . strtolower(ltrim(trim($extension), '.')))
->unique()
->implode(',');
@endphp
@props(['comments', 'level' => 0, 'ticket' => null, 'canSeeInternalNotes' => false]) @props(['comments', 'level' => 0, 'ticket' => null, 'canSeeInternalNotes' => false])
<div class="space-y-3" data-helpdesk-thread-level="{{ (int) $level }}"> <div class="space-y-3" data-helpdesk-thread-level="{{ (int) $level }}">
@@ -195,6 +202,7 @@
class="mb-2 block text-sm font-bold text-zinc-700 dark:text-zinc-200">Subject</label> class="mb-2 block text-sm font-bold text-zinc-700 dark:text-zinc-200">Subject</label>
<input id="reply-subject-{{ $comment->id }}" name="subject" <input id="reply-subject-{{ $comment->id }}" name="subject"
value="{{ old('subject', 'RE: ' . $commentSubject) }}" value="{{ old('subject', 'RE: ' . $commentSubject) }}"
spellcheck="true" lang="en-GB" autocorrect="on" autocomplete="on" autocapitalize="sentences" inputmode="text"
class="block w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm font-semibold text-zinc-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"> class="block w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm font-semibold text-zinc-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100">
</div> </div>
<label for="reply-body-{{ $comment->id }}" <label for="reply-body-{{ $comment->id }}"
@@ -214,7 +222,7 @@
<button type="button" data-helpdesk-wrap="<code>" data-helpdesk-wrap-end="</code>" <button type="button" data-helpdesk-wrap="<code>" data-helpdesk-wrap-end="</code>"
class="rounded-lg border px-2 py-1 text-xs">Code</button> class="rounded-lg border px-2 py-1 text-xs">Code</button>
</div> </div>
<textarea spellcheck="true" lang="en-GB" id="reply-body-{{ $comment->id }}" dusk="reply-body-{{ $comment->id }}" name="commentBody" rows="4" <textarea spellcheck="true" lang="en-GB" autocorrect="on" autocomplete="on" autocapitalize="sentences" inputmode="text" id="reply-body-{{ $comment->id }}" dusk="reply-body-{{ $comment->id }}" name="commentBody" rows="4"
required required
class="block w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm text-zinc-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" class="block w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm text-zinc-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
placeholder="Write a threaded Lotus Notes-style response..."></textarea> placeholder="Write a threaded Lotus Notes-style response..."></textarea>
@@ -225,7 +233,7 @@
@can('update', $currentTicket) @can('update', $currentTicket)
@if ($canSeeInternalNotes) @if ($canSeeInternalNotes)
<div class="mt-3 grid gap-3 sm:grid-cols-2"> <div class="mt-3 grid gap-3 sm:grid-cols-2">
<label class="flex cursor-pointer items-start gap-3 rounded-xl border border-emerald-300 bg-emerald-50 p-3 text-emerald-950 shadow-sm transition hover:bg-emerald-100 dark:border-emerald-900 dark:bg-emerald-950/60 dark:text-emerald-100"> <label class="flex cursor-pointer items-start gap-3 rounded-xl border border-emerald-300 bg-emerald-50 p-3 text-emerald-950 shadow-sm transition hover:border-emerald-400 hover:bg-emerald-100/80 focus-within:border-emerald-500 focus-within:bg-emerald-100/80 focus-within:ring-2 focus-within:ring-emerald-400/30 active:bg-emerald-100/80 dark:border-emerald-900 dark:bg-emerald-950/60 dark:text-emerald-100 dark:hover:border-emerald-700 dark:hover:bg-emerald-900/50 dark:focus-within:bg-emerald-900/50">
<input type="radio" name="internal" value="0" checked class="mt-1 border-emerald-400 text-emerald-600 focus:ring-emerald-500"> <input type="radio" name="internal" value="0" checked class="mt-1 border-emerald-400 text-emerald-600 focus:ring-emerald-500">
<span> <span>
<span class="block text-sm font-extrabold">Public reply</span> <span class="block text-sm font-extrabold">Public reply</span>
@@ -233,7 +241,7 @@
</span> </span>
</label> </label>
<label class="flex cursor-pointer items-start gap-3 rounded-xl border border-purple-300 bg-purple-50 p-3 text-purple-950 shadow-sm transition hover:bg-purple-100 dark:border-purple-900 dark:bg-purple-950/60 dark:text-purple-100"> <label class="flex cursor-pointer items-start gap-3 rounded-xl border border-purple-300 bg-purple-50 p-3 text-purple-950 shadow-sm transition hover:border-purple-400 hover:bg-purple-100/80 focus-within:border-purple-500 focus-within:bg-purple-100/80 focus-within:ring-2 focus-within:ring-purple-400/30 active:bg-purple-100/80 dark:border-purple-900 dark:bg-purple-950/60 dark:text-purple-100 dark:hover:border-purple-700 dark:hover:bg-purple-900/50 dark:focus-within:bg-purple-900/50">
<input type="radio" name="internal" value="1" class="mt-1 border-purple-400 text-purple-600 focus:ring-purple-500"> <input type="radio" name="internal" value="1" class="mt-1 border-purple-400 text-purple-600 focus:ring-purple-500">
<span> <span>
<span class="block text-sm font-extrabold">Internal note</span> <span class="block text-sm font-extrabold">Internal note</span>
@@ -285,7 +293,7 @@
<label data-helpdesk-file-button> <label data-helpdesk-file-button>
<i class="fa-solid fa-paperclip" aria-hidden="true"></i> <i class="fa-solid fa-paperclip" aria-hidden="true"></i>
<span>Attach files</span> <span>Attach files</span>
<input name="attachments[]" type="file" multiple class="sr-only" <input name="attachments[]" type="file" multiple accept="{{ $helpdeskAttachmentAccept }}" class="sr-only"
data-helpdesk-file-input> data-helpdesk-file-input>
</label> </label>
</div> </div>
+3
View File
@@ -13,3 +13,6 @@ Route::get('/status', static fn (): array => [
Route::post('/git/webhook/{repository}', GitWebhookController::class) Route::post('/git/webhook/{repository}', GitWebhookController::class)
->whereNumber('repository') ->whereNumber('repository')
->name('git.webhook'); ->name('git.webhook');
Route::post('/git/gitea/webhook', [GitWebhookController::class, 'gitea'])
->name('git.gitea.webhook');
+6
View File
@@ -40,5 +40,11 @@ Route::get('/tickets/{ticket}', TicketShow::class)->name('tickets.show');
Route::get('/attachments/{attachment}/download', AttachmentDownloadController::class) Route::get('/attachments/{attachment}/download', AttachmentDownloadController::class)
->whereNumber('attachment') ->whereNumber('attachment')
->name('attachments.download'); ->name('attachments.download');
Route::get('/attachments/{attachment}/preview', [AttachmentDownloadController::class, 'preview'])
->whereNumber('attachment')
->name('attachments.preview');
Route::delete('/attachments/{attachment}', [AttachmentDownloadController::class, 'destroy'])
->whereNumber('attachment')
->name('attachments.destroy');
Route::post('/tickets/{ticket}/time-logs', [TicketTimeLogController::class, 'store']) Route::post('/tickets/{ticket}/time-logs', [TicketTimeLogController::class, 'store'])
->name('tickets.time-logs.store'); ->name('tickets.time-logs.store');
+151 -17
View File
@@ -5,41 +5,56 @@ declare(strict_types=1);
namespace Modules\Helpdesk\Services; namespace Modules\Helpdesk\Services;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Modules\Helpdesk\Models\Attachment; use Modules\Helpdesk\Models\Attachment;
use Modules\Helpdesk\Models\Comment; use Modules\Helpdesk\Models\Comment;
use Modules\Helpdesk\Models\Ticket; use Modules\Helpdesk\Models\Ticket;
final class AttachmentService final class AttachmentService
{ {
/**
* Store multiple Helpdesk attachments against a ticket or comment.
*
* Livewire create/comment forms pass TemporaryUploadedFile instances, while
* controller endpoints pass Symfony/Laravel UploadedFile instances. Both
* are supported here so uploads are never silently skipped on ticket create.
*
* @param array<int|string, mixed> $files
*/
public function storeMany(Ticket $ticket, array $files, int $userId, ?Comment $comment = null): void public function storeMany(Ticket $ticket, array $files, int $userId, ?Comment $comment = null): void
{ {
foreach ($files as $file) { foreach ($files as $file) {
if (! $file instanceof UploadedFile || ! $file->isValid()) { if (! $this->isSupportedUpload($file)) {
continue; continue;
} }
$disk = (string) config('helpdesk.disk', 'public'); $disk = (string) config('helpdesk.disk', 'public');
$originalName = $this->originalName($file);
$originalName = $file->getClientOriginalName(); $mimeType = $this->mimeType($file);
$mimeType = $file->getClientMimeType(); $size = $this->size($file);
$size = (int) ($file->getSize() ?: 0);
$path = $file->store('tickets/' . $ticket->id, $disk); $path = $file->store('tickets/' . $ticket->id, $disk);
if ($size === 0 && $path !== false) { if (! is_string($path) || $path === '') {
continue;
}
if ($size === 0) {
$size = (int) Storage::disk($disk)->size($path); $size = (int) Storage::disk($disk)->size($path);
} }
Attachment::query()->create([ Attachment::query()->create($this->attachmentAttributes(
'ticket_id' => $ticket->id, ticket: $ticket,
'comment_id' => $comment?->id, comment: $comment,
'user_id' => $userId, userId: $userId,
'disk' => $disk, disk: $disk,
'path' => $path, path: $path,
'original_name' => $originalName, originalName: $originalName,
'mime_type' => $mimeType, mimeType: $mimeType,
'size' => $size, size: $size,
]); ));
} }
} }
@@ -51,4 +66,123 @@ final class AttachmentService
Storage::disk($attachment->disk)->delete($attachment->path); Storage::disk($attachment->disk)->delete($attachment->path);
$attachment->delete(); $attachment->delete();
} }
/**
* Build attachment attributes while remaining compatible with older databases.
*
* @return array<string, mixed>
*/
private function attachmentAttributes(Ticket $ticket, ?Comment $comment, int $userId, string $disk, string $path, string $originalName, string $mimeType, int $size): array
{
$attributes = [
'ticket_id' => $ticket->id,
'comment_id' => $comment?->id,
'user_id' => $userId,
'disk' => $disk,
'path' => $path,
'original_name' => $originalName,
'mime_type' => $mimeType,
'size' => $size,
];
if (Schema::hasColumn('helpdesk_attachments', 'extension')) {
$attributes['extension'] = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION));
}
if (Schema::hasColumn('helpdesk_attachments', 'category')) {
$attributes['category'] = $this->categoryFor($originalName, $mimeType);
}
if (Schema::hasColumn('helpdesk_attachments', 'checksum_sha256') && Storage::disk($disk)->exists($path)) {
$stream = Storage::disk($disk)->readStream($path);
if (is_resource($stream)) {
$context = hash_init('sha256');
hash_update_stream($context, $stream);
$attributes['checksum_sha256'] = hash_final($context);
fclose($stream);
}
}
return $attributes;
}
/**
* Categorise uploaded files for icons, previews and safe code viewing.
*/
private function categoryFor(string $originalName, string $mimeType): string
{
$extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION));
if (str_starts_with($mimeType, 'image/') || in_array($extension, ['png', 'jpg', 'jpeg', 'webp', 'gif'], true)) {
return 'image';
}
if ($extension === 'pdf' || $mimeType === 'application/pdf') {
return 'pdf';
}
if (in_array($extension, ['zip', 'tar', 'gz', '7z', 'rar'], true)) {
return 'archive';
}
if (in_array($extension, config('helpdesk.allowed_source_extensions', []), true)) {
return 'source';
}
return 'file';
}
/**
* Soft-delete a stored attachment while retaining audit metadata.
*/
public function softDelete(Attachment $attachment, int $userId): void
{
DB::table('helpdesk_attachments')
->where('id', $attachment->id)
->update(array_filter([
'deleted_at' => now(),
'deleted_by' => Schema::hasColumn('helpdesk_attachments', 'deleted_by') ? $userId : null,
], static fn (mixed $value): bool => $value !== null));
}
/**
* Determine whether the value is a storeable uploaded file.
*/
private function isSupportedUpload(mixed $file): bool
{
if ($file instanceof TemporaryUploadedFile) {
return true;
}
return $file instanceof UploadedFile && $file->isValid();
}
/**
* Return the uploaded file's original client filename.
*/
private function originalName(mixed $file): string
{
return (string) $file->getClientOriginalName();
}
/**
* Return the uploaded file's reported mime type.
*/
private function mimeType(mixed $file): string
{
return (string) ($file->getClientMimeType() ?: $file->getMimeType() ?: 'application/octet-stream');
}
/**
* Return the uploaded file's byte size.
*/
private function size(mixed $file): int
{
return (int) ($file->getSize() ?: 0);
}
} }
@@ -1,10 +1,19 @@
<?php <?php
/**
* GitWebhookIngestionService.php
*
* Normalises Git provider webhooks and links commits to Helpdesk tickets.
*/
declare(strict_types=1); declare(strict_types=1);
namespace Modules\Helpdesk\Services; namespace Modules\Helpdesk\Services;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Modules\Helpdesk\Models\GitCommit; use Modules\Helpdesk\Models\GitCommit;
use Modules\Helpdesk\Models\GitRepository; use Modules\Helpdesk\Models\GitRepository;
@@ -14,7 +23,10 @@ use Throwable;
final class GitWebhookIngestionService final class GitWebhookIngestionService
{ {
private const TICKET_REFERENCE_PATTERN = '/\bHD-\d{4}-\d{5}\b/i'; /**
* Matches the generated Helpdesk reference format, for example HD-2026-00042.
*/
private const TICKET_REFERENCE_PATTERN = '/\bHD[-\s_]?(\d{4})[-\s_]?(\d{1,8})\b/i';
/** /**
* Persist supported Git provider commits and link them to matching Helpdesk ticket references. * Persist supported Git provider commits and link them to matching Helpdesk ticket references.
@@ -39,14 +51,14 @@ final class GitWebhookIngestionService
return $result; return $result;
} }
$branch = $this->branchFromRef($payload['ref'] ?? null); $branch = $this->branchFromPayload($payload);
$commits = $this->commitsFromPayload($payload); $commits = $this->commitsFromPayload($payload);
$commitCount = 0; $commitCount = 0;
$linkedCount = 0; $linkedCount = 0;
$ignoredCount = 0; $ignoredCount = 0;
foreach ($commits as $commit) { foreach ($commits as $commit) {
$hash = $this->stringOrNull($commit['id'] ?? $commit['sha'] ?? null); $hash = $this->commitHash($commit);
if ($hash === null) { if ($hash === null) {
$ignoredCount++; $ignoredCount++;
@@ -54,13 +66,19 @@ final class GitWebhookIngestionService
continue; continue;
} }
$message = $this->stringOrNull($commit['message'] ?? null) ?? ''; $message = $this->stringOrNull($commit['message'] ?? $commit['title'] ?? $commit['summary'] ?? null) ?? '';
$url = $this->stringOrNull($commit['url'] ?? null); $url = $this->commitUrl($commit);
$author = is_array($commit['author'] ?? null) ? $commit['author'] : []; $author = is_array($commit['author'] ?? null) ? $commit['author'] : [];
$committer = is_array($commit['committer'] ?? null) ? $commit['committer'] : []; $committer = is_array($commit['committer'] ?? null) ? $commit['committer'] : [];
$authorName = $this->stringOrNull($author['name'] ?? $committer['name'] ?? null); $authorName = $this->stringOrNull($author['name'] ?? $author['username'] ?? $committer['name'] ?? null);
$authorEmail = $this->stringOrNull($author['email'] ?? $committer['email'] ?? null); $authorEmail = $this->stringOrNull($author['email'] ?? $author['raw'] ?? $committer['email'] ?? null);
$committedAt = $this->parseTimestamp($commit['timestamp'] ?? $commit['committed_at'] ?? null); $committedAt = $this->parseTimestamp(
$commit['timestamp']
?? $commit['committed_at']
?? $commit['date']
?? Arr::get($commit, 'author.date')
?? null,
);
$ticket = $this->ticketForCommit($repository, $message, $branch, $url); $ticket = $this->ticketForCommit($repository, $message, $branch, $url);
GitCommit::query()->updateOrCreate( GitCommit::query()->updateOrCreate(
@@ -134,7 +152,7 @@ final class GitWebhookIngestionService
} }
/** /**
* Extract the first Helpdesk ticket reference from arbitrary Git text. * Extract and normalise the first Helpdesk ticket reference from arbitrary Git text.
*/ */
public function extractTicketReference(string $value): ?string public function extractTicketReference(string $value): ?string
{ {
@@ -142,7 +160,7 @@ final class GitWebhookIngestionService
return null; return null;
} }
return strtoupper($matches[0]); return sprintf('HD-%s-%05d', $matches[1], (int) $matches[2]);
} }
/** /**
@@ -150,24 +168,41 @@ final class GitWebhookIngestionService
*/ */
private function isPushEvent(string $provider, ?string $event): bool private function isPushEvent(string $provider, ?string $event): bool
{ {
$normalised = strtolower((string) $event); $normalisedProvider = Str::lower(trim($provider));
$normalisedEvent = Str::of((string) $event)->lower()->replace(['_', '-'], ' ')->squish()->value();
return match ($provider) { return match ($normalisedProvider) {
'github' => $normalised === 'push', 'github' => in_array($normalisedEvent, ['push', ''], true),
'gitlab' => in_array($normalised, ['push hook', 'push'], true), 'gitlab' => in_array($normalisedEvent, ['push hook', 'push', ''], true),
default => $normalised === '' || $normalised === 'push', 'gitea' => in_array($normalisedEvent, ['push', 'push hook', ''], true),
'bitbucket' => in_array($normalisedEvent, ['repo push', 'push', ''], true),
default => in_array($normalisedEvent, ['push', 'repo push', 'push hook', ''], true),
}; };
} }
private function branchFromRef(mixed $value): ?string /**
* Read the branch name from common Git provider payload shapes.
*
* @param array<string, mixed> $payload
*/
private function branchFromPayload(array $payload): ?string
{ {
$ref = $this->stringOrNull($value); $ref = $this->stringOrNull(
$payload['ref']
?? Arr::get($payload, 'push.changes.0.new.name')
?? Arr::get($payload, 'object_attributes.ref')
?? null,
);
if ($ref === null) { if ($ref === null) {
return null; return null;
} }
return str_replace('refs/heads/', '', $ref); return Str::of($ref)
->replaceStart('refs/heads/', '')
->replaceStart('refs/tags/', '')
->trim()
->value();
} }
/** /**
@@ -177,13 +212,46 @@ final class GitWebhookIngestionService
*/ */
private function commitsFromPayload(array $payload): array private function commitsFromPayload(array $payload): array
{ {
$commits = $payload['commits'] ?? []; $commits = $payload['commits'] ?? null;
if (! is_array($commits)) { if (is_array($commits) && $commits !== []) {
return []; return array_values(array_filter($commits, static fn (mixed $commit): bool => is_array($commit)));
} }
return array_values(array_filter($commits, static fn (mixed $commit): bool => is_array($commit))); $headCommit = $payload['head_commit'] ?? null;
if (is_array($headCommit)) {
return [$headCommit];
}
$bitbucketCommits = Arr::get($payload, 'push.changes.0.commits', []);
if (is_array($bitbucketCommits)) {
return array_values(array_filter($bitbucketCommits, static fn (mixed $commit): bool => is_array($commit)));
}
return [];
}
/**
* @param array<string, mixed> $commit
*/
private function commitHash(array $commit): ?string
{
return $this->stringOrNull($commit['id'] ?? $commit['sha'] ?? $commit['hash'] ?? null);
}
/**
* @param array<string, mixed> $commit
*/
private function commitUrl(array $commit): ?string
{
return $this->stringOrNull(
$commit['url']
?? $commit['web_url']
?? Arr::get($commit, 'links.html.href')
?? null,
);
} }
private function ticketForCommit(GitRepository $repository, string $message, ?string $branch, ?string $url): ?Ticket private function ticketForCommit(GitRepository $repository, string $message, ?string $branch, ?string $url): ?Ticket
@@ -194,17 +262,19 @@ final class GitWebhookIngestionService
return null; return null;
} }
$query = Ticket::query()->where('reference', $reference); $baseQuery = Ticket::query()->where('reference', $reference);
if ($repository->company_id !== null) { if ($repository->company_id !== null) {
$query->where('company_id', $repository->company_id); $baseQuery->where('company_id', $repository->company_id);
} }
if ($repository->project_id !== null) { $strictProjectTicket = (clone $baseQuery)
$query->where('project_id', $repository->project_id); ->when($repository->project_id !== null, static function (Builder $query) use ($repository): void {
} $query->where('project_id', $repository->project_id);
})
->first();
return $query->first(); return $strictProjectTicket ?? $baseQuery->first();
} }
private function parseTimestamp(mixed $value): ?CarbonImmutable private function parseTimestamp(mixed $value): ?CarbonImmutable
+11 -5
View File
@@ -6,6 +6,7 @@ namespace Modules\Helpdesk\Services;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
use Modules\Helpdesk\Models\Comment; use Modules\Helpdesk\Models\Comment;
use Modules\Helpdesk\Models\SlaPolicy; use Modules\Helpdesk\Models\SlaPolicy;
use Modules\Helpdesk\Models\Ticket; use Modules\Helpdesk\Models\Ticket;
@@ -72,14 +73,19 @@ final class SlaService
return; return;
} }
/** @var Comment|null $firstPublicComment */ /** @var \Illuminate\Database\Eloquent\Builder<Comment> $query */
$firstPublicComment = Comment::query() $query = Comment::query()
->where('ticket_id', $ticket->id) ->where('ticket_id', $ticket->id)
->where('is_internal', false) ->where('is_internal', false)
->whereNull('tombstoned_at')
->with('user') ->with('user')
->oldest('created_at') ->oldest('created_at');
->first();
if (Schema::hasColumn('helpdesk_comments', 'tombstoned_at')) {
$query->whereNull('tombstoned_at');
}
/** @var Comment|null $firstPublicComment */
$firstPublicComment = $query->first();
if ($firstPublicComment === null) { if ($firstPublicComment === null) {
return; return;
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Browser;
use Laravel\Dusk\Browser;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\DuskTestCase;
/**
* Browser regression coverage for the Helpdesk Create Ticket editor and footer actions.
*
* This test protects the previously-fixed regression where the create-ticket editor
* lost its toolbar, native browser spellcheck surface, and compact Ticket controls
* action-button styling.
*/
final class HelpdeskCreateTicketEditorBrowserTest extends DuskTestCase
{
use BuildsHelpdeskFixtures;
/**
* Ensure the create-ticket editor keeps toolbar controls, contenteditable spellcheck,
* and compact footer buttons aligned with the Ticket controls styling baseline.
*/
public function test_create_ticket_editor_toolbar_spellcheck_and_action_buttons_are_rendered_correctly(): void
{
$this->seedLookupData();
$user = $this->user([
'name' => 'Create Ticket Editor Regression User',
]);
$this->browse(function (Browser $browser) use ($user): void {
$browser->loginAs($user)
->visitRoute('helpdesk.tickets.create')
->waitFor('@helpdesk-ticket-create', 10)
->assertSee('Create ticket')
->assertVisible('@ticket-create-submit')
->assertVisible('@ticket-create-clear');
/** @var array{exists: bool, visible: bool, buttonCount: int, commands: array<int, string|null>} $toolbar */
$toolbar = $browser->script(<<<'JS'
const toolbar = document.querySelector('[data-helpdesk-editor-toolbar]');
const buttons = toolbar ? Array.from(toolbar.querySelectorAll('button[data-helpdesk-editor-command]')) : [];
return {
exists: Boolean(toolbar),
visible: Boolean(toolbar && toolbar.offsetParent !== null),
buttonCount: buttons.length,
commands: buttons.map((button) => button.getAttribute('data-helpdesk-editor-command')),
};
JS)[0];
$this->assertTrue($toolbar['exists'], 'The create-ticket editor toolbar was not rendered.');
$this->assertTrue($toolbar['visible'], 'The create-ticket editor toolbar was rendered but is not visible.');
$this->assertGreaterThanOrEqual(4, $toolbar['buttonCount'], 'The create-ticket editor toolbar should expose formatting buttons.');
$this->assertContains('bold', $toolbar['commands'], 'The create-ticket editor toolbar is missing the bold button.');
$this->assertContains('italic', $toolbar['commands'], 'The create-ticket editor toolbar is missing the italic button.');
/** @var array{exists: bool, isContentEditable: bool, contenteditable: string|null, spellcheck: string|null, lang: string|null} $editor */
$editor = $browser->script(<<<'JS'
const root = document.querySelector('[dusk="ticket-body-editor"], [data-dusk="ticket-body-editor"]');
const editor = root && root.matches('[contenteditable="true"]')
? root
: root?.querySelector('[contenteditable="true"]');
return {
exists: Boolean(editor),
isContentEditable: Boolean(editor?.isContentEditable),
contenteditable: editor?.getAttribute('contenteditable') || null,
spellcheck: editor?.getAttribute('spellcheck') || null,
lang: editor?.getAttribute('lang') || null,
};
JS)[0];
$this->assertTrue($editor['exists'], 'The create-ticket editor contenteditable surface was not rendered.');
$this->assertTrue($editor['isContentEditable'], 'The create-ticket editor must use a native contenteditable surface.');
$this->assertSame('true', $editor['contenteditable'], 'The create-ticket editor must keep contenteditable="true".');
$this->assertSame('true', $editor['spellcheck'], 'The create-ticket editor must keep browser spellcheck enabled.');
$this->assertSame('en-GB', $editor['lang'], 'The create-ticket editor must use British English spellcheck language.');
/** @var array{create: array<int, string>, clear: array<int, string>, cancel: array<int, string>} $classes */
$classes = $browser->script(<<<'JS'
const classList = (selector) => Array.from(document.querySelector(selector)?.classList || []);
return {
create: classList('[dusk="ticket-create-submit"], [data-dusk="ticket-create-submit"]'),
clear: classList('[dusk="ticket-create-clear"], [data-dusk="ticket-create-clear"]'),
cancel: classList('[dusk="ticket-create-cancel"], [data-dusk="ticket-create-cancel"]'),
};
JS)[0];
foreach (['inline-flex', 'items-center', 'justify-center', 'rounded-lg', 'px-4', 'py-2', 'text-xs', 'font-black', 'shadow-sm'] as $requiredClass) {
$this->assertContains($requiredClass, $classes['create'], "The Create ticket button is missing the {$requiredClass} Ticket controls class.");
}
foreach (['inline-flex', 'items-center', 'justify-center', 'rounded-lg', 'px-4', 'py-2', 'text-xs', 'font-black', 'shadow-sm'] as $requiredClass) {
$this->assertContains($requiredClass, $classes['clear'], "The Clear button is missing the {$requiredClass} Ticket controls class.");
}
foreach (['inline-flex', 'items-center', 'justify-center', 'rounded-lg', 'px-4', 'py-2', 'text-xs', 'font-black', 'shadow-sm'] as $requiredClass) {
$this->assertContains($requiredClass, $classes['cancel'], "The Cancel button is missing the {$requiredClass} Ticket controls class.");
}
});
}
}
@@ -6,6 +6,7 @@ namespace Modules\Helpdesk\Tests\Browser;
use Laravel\Dusk\Browser; use Laravel\Dusk\Browser;
use Modules\Helpdesk\Models\Comment; use Modules\Helpdesk\Models\Comment;
use Modules\Helpdesk\Models\Ticket;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures; use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\DuskTestCase; use Tests\DuskTestCase;
@@ -23,23 +24,36 @@ final class HelpdeskTicketBrowserTest extends DuskTestCase
$this->browse(function (Browser $browser) use ($user, $fixtures, $subject): void { $this->browse(function (Browser $browser) use ($user, $fixtures, $subject): void {
$browser->loginAs($user) $browser->loginAs($user)
->visitRoute('helpdesk.tickets.create') ->visitRoute('helpdesk.tickets.create')
->waitFor('@helpdesk-ticket-create') ->waitFor('@helpdesk-ticket-create', 10)
->assertSee('Create ticket') ->assertSee('Create ticket')
->type('@ticket-subject', $subject); ->type('@ticket-subject', $subject);
$this->setHelpdeskEditor($browser, 'ticket-body-editor', '<p>This browser ticket proves the form saves correctly.</p>'); $this->setHelpdeskEditor(
$browser,
'ticket-body-editor',
'This browser ticket proves the form saves correctly.',
);
$this->setHelpdeskSelect($browser, 'board_id', $fixtures['board']->id); $this->setHelpdeskSelect($browser, 'board_id', $fixtures['board']->id);
$this->setHelpdeskSelect($browser, 'priority_id', $fixtures['high']->id); $this->setHelpdeskSelect($browser, 'priority_id', $fixtures['high']->id);
$this->setHelpdeskSelect($browser, 'category_id', $fixtures['category']->id); $this->setHelpdeskSelect($browser, 'category_id', $fixtures['category']->id);
$this->setHelpdeskSelect($browser, 'team_id', $fixtures['team']->id); $this->setHelpdeskSelect($browser, 'team_id', $fixtures['team']->id);
$this->setHelpdeskSelect($browser, 'project_id', $fixtures['project']->id); $this->setHelpdeskSelect($browser, 'project_id', $fixtures['project']->id);
$browser->waitFor('@ticket-create-submit', 10)
$browser->pause(500)
->press('@ticket-create-submit') ->press('@ticket-create-submit')
->waitFor('@helpdesk-ticket-index', 10) ->waitUntilMissing('@helpdesk-ticket-create', 15)
->assertPathIs('/helpdesk/tickets')
->assertSee($subject);
$ticket = Ticket::query()
->where('subject', $subject)
->latest('id')
->firstOrFail();
$browser->visitRoute('helpdesk.tickets.show', $ticket)
->waitFor('@helpdesk-ticket-show', 15)
->assertSee($subject) ->assertSee($subject)
->assertSee('High') ->assertSee('This browser ticket proves the form saves correctly.');
->assertSee('Support');
}); });
} }
@@ -99,45 +113,6 @@ final class HelpdeskTicketBrowserTest extends DuskTestCase
]); ]);
} }
private function submitNestedCommentThroughBrowser(Browser $browser, int|string $ticketId, int|string $parentId): void
{
$action = route('helpdesk.tickets.comments.store', ['ticket' => $ticketId]);
$encodedAction = json_encode($action, JSON_THROW_ON_ERROR);
$encodedParentId = json_encode((string) $parentId, JSON_THROW_ON_ERROR);
$browser->script(<<<JS
const action = {$encodedAction};
const parentId = {$encodedParentId};
const token = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|| document.querySelector('input[name="_token"]')?.value;
const form = document.createElement('form');
form.method = 'POST';
form.action = action;
form.style.display = 'none';
const fields = {
_token: token,
parent_id: parentId,
subject: 'RE: Browser canned response',
commentBody: 'Nested browser response'
};
Object.entries(fields).forEach(([name, value]) => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = name;
input.value = value || '';
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
JS);
$browser->waitForLocation('/helpdesk/tickets/' . $ticketId, 10);
}
private function setHelpdeskEditor(Browser $browser, string $dusk, string $html): void private function setHelpdeskEditor(Browser $browser, string $dusk, string $html): void
{ {
$encodedDusk = json_encode($dusk, JSON_THROW_ON_ERROR); $encodedDusk = json_encode($dusk, JSON_THROW_ON_ERROR);
@@ -8,6 +8,7 @@ use App\Models\User;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Modules\Helpdesk\Models\Category; use Modules\Helpdesk\Models\Category;
use Modules\Helpdesk\Models\GitRepository; use Modules\Helpdesk\Models\GitRepository;
use Modules\Helpdesk\Models\KanbanBoard; use Modules\Helpdesk\Models\KanbanBoard;
@@ -51,7 +52,7 @@ trait BuildsHelpdeskFixtures
{ {
return Status::query()->create(array_merge([ return Status::query()->create(array_merge([
'name' => 'Open', 'name' => 'Open',
'slug' => fake()->unique()->slug(2), 'slug' => 'test-' . Str::uuid()->toString(),
'color' => 'blue', 'color' => 'blue',
'sort_order' => 10, 'sort_order' => 10,
'is_closed' => false, 'is_closed' => false,
@@ -62,7 +63,7 @@ trait BuildsHelpdeskFixtures
{ {
return $this->ticketStatus(array_merge([ return $this->ticketStatus(array_merge([
'name' => 'Closed', 'name' => 'Closed',
'slug' => fake()->unique()->slug(2), 'slug' => 'test-' . Str::uuid()->toString(),
'color' => 'green', 'color' => 'green',
'sort_order' => 99, 'sort_order' => 99,
'is_closed' => true, 'is_closed' => true,
@@ -73,7 +74,7 @@ trait BuildsHelpdeskFixtures
{ {
return Priority::query()->create(array_merge([ return Priority::query()->create(array_merge([
'name' => 'Normal', 'name' => 'Normal',
'slug' => fake()->unique()->slug(2), 'slug' => 'test-' . Str::uuid()->toString(),
'color' => 'blue', 'color' => 'blue',
'sort_order' => 10, 'sort_order' => 10,
], $attributes)); ], $attributes));
@@ -84,7 +85,7 @@ trait BuildsHelpdeskFixtures
return Category::query()->create(array_merge([ return Category::query()->create(array_merge([
'company_id' => null, 'company_id' => null,
'name' => 'Support', 'name' => 'Support',
'slug' => fake()->unique()->slug(2), 'slug' => 'test-' . Str::uuid()->toString(),
'color' => 'blue', 'color' => 'blue',
'description' => 'Support requests', 'description' => 'Support requests',
], $attributes)); ], $attributes));
@@ -95,7 +96,7 @@ trait BuildsHelpdeskFixtures
return Team::query()->create(array_merge([ return Team::query()->create(array_merge([
'company_id' => null, 'company_id' => null,
'name' => 'Support Team', 'name' => 'Support Team',
'slug' => fake()->unique()->slug(2), 'slug' => 'test-' . Str::uuid()->toString(),
'color' => 'indigo', 'color' => 'indigo',
'description' => 'Primary support team', 'description' => 'Primary support team',
], $attributes)); ], $attributes));
@@ -107,7 +108,7 @@ trait BuildsHelpdeskFixtures
'company_id' => null, 'company_id' => null,
'team_id' => null, 'team_id' => null,
'title' => 'WMS Project', 'title' => 'WMS Project',
'slug' => fake()->unique()->slug(2), 'slug' => 'test-' . Str::uuid()->toString(),
'color' => 'blue', 'color' => 'blue',
'description' => 'Warehouse platform project', 'description' => 'Warehouse platform project',
], $attributes)); ], $attributes));
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
final class DashboardTicketCardsSpellcheckFilesTest extends TestCase
{
#[Test]
public function dashboard_contains_browser_expected_kpi_labels(): void
{
$view = file_get_contents(base_path('Modules/Helpdesk/Resources/views/index.blade.php'));
$this->assertIsString($view);
$this->assertStringContainsString('Total tickets', $view);
$this->assertStringContainsString('Open tickets', $view);
$this->assertStringContainsString('Closed tickets', $view);
}
#[Test]
public function ticket_right_cards_are_not_permanently_hidden(): void
{
$view = file_get_contents(base_path('Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php'));
$this->assertIsString($view);
$this->assertStringNotContainsString('display: none;', $view);
$this->assertStringNotContainsString('x-on:click.prevent', $view);
$this->assertStringNotContainsString('@click.prevent', $view);
}
#[Test]
public function helpdesk_editor_uses_native_spellcheck_contenteditable_area(): void
{
$view = file_get_contents(base_path('Modules/Helpdesk/Resources/views/components/html-editor.blade.php'));
$this->assertIsString($view);
$this->assertStringContainsString('contenteditable="true"', $view);
$this->assertStringContainsString('spellcheck="true"', $view);
$this->assertStringContainsString('lang="en-GB"', $view);
$this->assertStringContainsString('wire:model.defer', $view);
$this->assertStringNotContainsString('x-on:keyup', $view);
$this->assertStringNotContainsString('x-on:input', $view);
}
}
@@ -76,6 +76,41 @@ final class GitAndAttachmentTest extends TestCase
]); ]);
} }
public function test_existing_ticket_supporting_files_route_accepts_source_code_attachments(): void
{
Storage::fake('local');
config()->set('helpdesk.disk', 'local');
$user = $this->admin();
$ticket = $this->ticket(['owner' => $user]);
$this->actingAs($user)
->post(route('helpdesk.tickets.attachments.store', $ticket), [
'attachments' => [
UploadedFile::fake()->create('TicketStoreController.php', 4, 'text/plain'),
UploadedFile::fake()->create('ticket-editor.js', 4, 'text/plain'),
UploadedFile::fake()->create('warehouse-sync.rpgle', 4, 'text/plain'),
],
])
->assertRedirect(route('helpdesk.tickets.show', $ticket));
$this->assertDatabaseHas('helpdesk_attachments', [
'ticket_id' => $ticket->id,
'original_name' => 'TicketStoreController.php',
]);
$this->assertDatabaseHas('helpdesk_attachments', [
'ticket_id' => $ticket->id,
'original_name' => 'ticket-editor.js',
]);
$this->assertDatabaseHas('helpdesk_attachments', [
'ticket_id' => $ticket->id,
'original_name' => 'warehouse-sync.rpgle',
]);
}
public function test_attachment_service_stores_and_deletes_ticket_files(): void public function test_attachment_service_stores_and_deletes_ticket_files(): void
{ {
Storage::fake('local'); Storage::fake('local');
@@ -0,0 +1,95 @@
<?php
/**
* GitSpellcheckSettingsUiFilesTest.php
*
* Guards the Helpdesk Git webhook, ticket comment spellcheck and settings UI polish files.
*/
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Tests\TestCase;
final class GitSpellcheckSettingsUiFilesTest extends TestCase
{
/**
* Ensure Git webhook ingestion accepts practical provider payload variants and flexible ticket references.
*/
public function test_git_webhook_ingestion_supports_common_provider_payload_variants(): void
{
$service = base_path('Modules/Helpdesk/Services/GitWebhookIngestionService.php');
$controller = base_path('Modules/Helpdesk/Http/Controllers/GitWebhookController.php');
$this->assertFileExists($service);
$this->assertFileExists($controller);
$serviceContents = file_get_contents($service) ?: '';
$controllerContents = file_get_contents($controller) ?: '';
self::assertStringContainsString('head_commit', $serviceContents);
self::assertStringContainsString('push.changes.0.commits', $serviceContents);
self::assertStringContainsString('HD[-\\s_]?', $serviceContents);
self::assertStringContainsString('sprintf(\'HD-%s-%05d\'', $serviceContents);
self::assertStringContainsString('hasValidBitbucketSignature', $controllerContents);
self::assertStringContainsString('hasValidGenericSignature', $controllerContents);
}
/**
* Ensure all ticket comment editors request native browser spellcheck assistance.
*/
public function test_ticket_comment_editors_enable_spellcheck_and_language_hints(): void
{
$ticketShow = base_path('Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php');
$commentThread = base_path('Modules/Helpdesk/Resources/views/partials/comment-thread.blade.php');
$ticketShowContents = file_get_contents($ticketShow) ?: '';
$commentThreadContents = file_get_contents($commentThread) ?: '';
self::assertStringContainsString('contenteditable="true" role="textbox" aria-multiline="true" spellcheck="true" lang="en-GB"', $ticketShowContents);
self::assertStringContainsString('name="commentBody" required spellcheck="true" lang="en-GB"', $ticketShowContents);
self::assertStringContainsString('data-helpdesk-comment-subject', $ticketShowContents);
self::assertStringContainsString('spellcheck="true" lang="en-GB" autocorrect="on"', $commentThreadContents);
self::assertStringContainsString('autocapitalize="sentences" inputmode="text"', $commentThreadContents);
}
/**
* Ensure settings sections use the polished Abbott-style card treatment.
*/
public function test_settings_sections_use_professional_card_treatment(): void
{
$settingsSection = base_path('Modules/Helpdesk/Resources/views/components/settings-section.blade.php');
$contents = file_get_contents($settingsSection) ?: '';
self::assertStringContainsString('rounded-2xl', $contents);
self::assertStringContainsString('bg-gradient-to-r from-slate-950 via-blue-950 to-slate-900', $contents);
self::assertStringContainsString('Configuration', $contents);
self::assertStringContainsString("[&_input:not([type='checkbox']):not([type='radio']):not([type='color'])]:min-h-9", $contents);
self::assertStringContainsString('dark:bg-zinc-950/45', $contents);
}
/**
* Ensure settings lookup forms stay aligned and avoid oversized colour controls.
*/
public function test_lookup_settings_forms_use_compact_aligned_grid_layouts(): void
{
$settingsManager = base_path('Modules/Helpdesk/Resources/views/livewire/settings-manager.blade.php');
$colourSelect = base_path('Modules/Helpdesk/Resources/views/components/colour-select.blade.php');
$settingsContents = file_get_contents($settingsManager) ?: '';
$colourContents = file_get_contents($colourSelect) ?: '';
self::assertStringContainsString('grid gap-3 xl:grid-cols-12', $settingsContents);
self::assertStringContainsString('class="xl:col-span-12" model="statusForm.color"', $settingsContents);
self::assertStringContainsString('class="xl:col-span-12" model="priorityForm.color"', $settingsContents);
self::assertStringContainsString('class="xl:col-span-12" model="categoryForm.color"', $settingsContents);
self::assertStringContainsString('class="xl:col-span-12" model="teamForm.color"', $settingsContents);
self::assertStringContainsString('class="xl:col-span-12" model="projectForm.color"', $settingsContents);
self::assertStringContainsString('class="xl:col-span-12" model="tagForm.color"', $settingsContents);
self::assertStringContainsString('grid grid-cols-[2.75rem_minmax(0,11rem)]', $colourContents);
self::assertStringContainsString('aria-label="Custom hex colour"', $colourContents);
self::assertStringContainsString('h-9 w-full rounded-lg', $colourContents);
self::assertStringNotContainsString('<flux:input', $colourContents);
}
}
@@ -160,4 +160,174 @@ final class GitWebhookIngestionTest extends TestCase
'linked_count' => 1, 'linked_count' => 1,
]); ]);
} }
public function test_gitea_local_webhook_tracks_latest_push_without_repository_id(): void
{
config()->set('services.gitea.webhook_authorization', 'YWxhZGRpbjpvcGVuc2VzYW1l');
$fixtures = $this->seedLookupData();
$repository = $this->repository([
'provider' => 'gitea',
'project_id' => $fixtures['project']->id,
'remote_url' => 'http://git.vm:3000/Admin/WMS-Core.git',
'webhook_secret' => 'gitea-secret',
]);
$ticket = $this->ticket(['project_id' => $fixtures['project']->id]);
$payload = [
'ref' => 'refs/heads/main',
'repository' => [
'name' => 'WMS-Core',
'full_name' => 'Admin/WMS-Core',
'clone_url' => 'http://git.vm:3000/Admin/WMS-Core.git',
'html_url' => 'http://git.vm:3000/Admin/WMS-Core',
],
'commits' => [
[
'id' => str_repeat('d', 40),
'message' => $ticket->reference . ' Fix local Gitea webhook ingestion',
'url' => 'http://git.vm:3000/Admin/WMS-Core/commit/' . str_repeat('d', 40),
'timestamp' => '2026-06-24T14:20:00+00:00',
'author' => ['name' => 'Mark Gregory', 'email' => 'mark@example.com'],
],
],
];
$json = json_encode($payload, JSON_THROW_ON_ERROR);
$response = $this->call('POST', route('api.git.gitea.webhook'), [], [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_ACCEPT' => 'application/json',
'HTTP_AUTHORIZATION' => 'Basic YWxhZGRpbjpvcGVuc2VzYW1l',
'HTTP_X_GITEA_EVENT' => 'push',
'HTTP_X_GITEA_DELIVERY' => 'gitea-delivery-123',
'HTTP_X_GITEA_SIGNATURE' => hash_hmac('sha256', $json, 'gitea-secret'),
], $json);
$response->assertStatus(202)->assertJsonPath('status', 'accepted')->assertJsonPath('linked_count', 1);
$this->assertDatabaseHas('helpdesk_git_commits', [
'repository_id' => $repository->id,
'ticket_id' => $ticket->id,
'hash' => str_repeat('d', 40),
'branch' => 'main',
]);
$this->assertDatabaseHas('helpdesk_git_webhook_logs', [
'repository_id' => $repository->id,
'provider' => 'gitea',
'delivery_id' => 'gitea-delivery-123',
'event' => 'push',
'status' => 'accepted',
'linked_count' => 1,
]);
}
public function test_gitea_local_webhook_rejects_invalid_signature_and_authorization_without_creating_commits(): void
{
config()->set('services.gitea.webhook_authorization', 'YWxhZGRpbjpvcGVuc2VzYW1l');
$this->repository([
'provider' => 'gitea',
'remote_url' => 'http://git.vm:3000/Admin/WMS-Core.git',
'webhook_secret' => 'gitea-secret',
]);
$payload = [
'ref' => 'refs/heads/main',
'repository' => ['clone_url' => 'http://git.vm:3000/Admin/WMS-Core.git'],
'commits' => [['id' => str_repeat('e', 40), 'message' => 'No auth']],
];
$json = json_encode($payload, JSON_THROW_ON_ERROR);
$response = $this->call('POST', route('api.git.gitea.webhook'), [], [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_ACCEPT' => 'application/json',
'HTTP_AUTHORIZATION' => 'Basic invalid',
'HTTP_X_GITEA_EVENT' => 'push',
'HTTP_X_GITEA_SIGNATURE' => hash_hmac('sha256', $json, 'wrong-secret'),
], $json);
$response->assertForbidden()->assertJsonPath('status', 'rejected');
self::assertSame(0, GitCommit::query()->count());
}
public function test_gitea_local_webhook_accepts_valid_signature_when_optional_authorization_header_differs(): void
{
config()->set('services.gitea.webhook_authorization', 'expected-basic-token');
$repository = $this->repository([
'provider' => 'gitea',
'remote_url' => 'http://git.vm:3000/Admin/WMS-Core.git',
'webhook_secret' => 'gitea-secret',
]);
$payload = [
'ref' => 'refs/heads/main',
'repository' => ['clone_url' => 'http://git.vm:3000/Admin/WMS-Core.git'],
'commits' => [['id' => str_repeat('b', 40), 'message' => 'Valid signed Gitea push']],
];
$json = json_encode($payload, JSON_THROW_ON_ERROR);
$response = $this->call('POST', route('api.git.gitea.webhook'), [], [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_ACCEPT' => 'application/json',
'HTTP_AUTHORIZATION' => 'Basic something-different-from-gitea-ui',
'HTTP_X_GITEA_EVENT' => 'push',
'HTTP_X_GITEA_SIGNATURE' => hash_hmac('sha256', $json, 'gitea-secret'),
], $json);
$response->assertStatus(202)->assertJsonPath('status', 'accepted')->assertJsonPath('commit_count', 1);
$this->assertDatabaseHas('helpdesk_git_commits', [
'repository_id' => $repository->id,
'hash' => str_repeat('b', 40),
]);
}
public function test_gitea_local_webhook_accepts_prefixed_signature_and_case_insensitive_provider(): void
{
config()->set('services.gitea.webhook_authorization', null);
$fixtures = $this->seedLookupData();
$repository = $this->repository([
'provider' => 'Gitea',
'project_id' => $fixtures['project']->id,
'name' => 'Local',
'remote_url' => 'http://git.vm:3000/Admin/WMS-Core',
'webhook_secret' => 'gitea-secret',
]);
$payload = [
'ref' => 'refs/heads/main',
'repository' => [
'name' => 'WMS-Core',
'full_name' => 'Admin/WMS-Core',
'clone_url' => 'http://git.vm:3000/Admin/WMS-Core.git',
'html_url' => 'http://git.vm:3000/Admin/WMS-Core',
],
'commits' => [
[
'id' => str_repeat('f', 40),
'message' => 'Local Gitea push should be visible on the dashboard',
'url' => 'http://git.vm:3000/Admin/WMS-Core/commit/' . str_repeat('f', 40),
'timestamp' => '2026-06-24T15:00:00+00:00',
'author' => ['name' => 'Mark Gregory', 'email' => 'mark@example.com'],
],
],
];
$json = json_encode($payload, JSON_THROW_ON_ERROR);
$response = $this->call('POST', route('api.git.gitea.webhook'), [], [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_ACCEPT' => 'application/json',
'HTTP_X_GITEA_EVENT' => 'push',
'HTTP_X_GITEA_SIGNATURE' => 'sha256=' . hash_hmac('sha256', $json, 'gitea-secret'),
], $json);
$response->assertStatus(202)->assertJsonPath('status', 'accepted')->assertJsonPath('commit_count', 1);
$this->assertDatabaseHas('helpdesk_git_commits', [
'repository_id' => $repository->id,
'hash' => str_repeat('f', 40),
'branch' => 'main',
]);
}
} }
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Tests\TestCase;
final class TicketAttachmentExperienceFilesTest extends TestCase
{
/** @test */
public function create_ticket_view_has_drag_drop_upload_progress_and_paste_attachment_wiring(): void
{
$view = file_get_contents(base_path('Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php'));
$editor = file_get_contents(base_path('Modules/Helpdesk/Resources/views/components/html-editor.blade.php'));
self::assertIsString($view);
self::assertStringContainsString('helpdeskAttachmentDropzone', $view);
self::assertStringContainsString('livewire-upload-progress', $view);
self::assertStringContainsString('DataTransfer', $view);
self::assertStringContainsString('helpdesk-editor-files-pasted', $view);
self::assertIsString($editor);
self::assertStringContainsString('insertCodeBlock', $editor);
self::assertStringContainsString('handlePaste', $editor);
self::assertStringContainsString('spellcheck="true"', $editor);
}
/** @test */
public function attachment_preview_routes_controllers_and_metadata_are_present(): void
{
$routes = file_get_contents(base_path('Modules/Helpdesk/Routes/web.php'));
$model = file_get_contents(base_path('Modules/Helpdesk/Models/Attachment.php'));
$service = file_get_contents(base_path('Modules/Helpdesk/Services/AttachmentService.php'));
$migration = file_get_contents(base_path('Modules/Helpdesk/Database/Migrations/2026_06_24_000100_add_helpdesk_attachment_experience_metadata.php'));
$sourcePreview = base_path('Modules/Helpdesk/Resources/views/attachments/source-preview.blade.php');
self::assertIsString($routes);
self::assertStringContainsString('attachments.preview', $routes);
self::assertStringContainsString('attachments.destroy', $routes);
self::assertStringNotContainsString('AttachmentPreviewController::class', $routes);
self::assertStringNotContainsString('AttachmentDeleteController::class', $routes);
self::assertStringContainsString("[AttachmentDownloadController::class, 'preview']", $routes);
self::assertStringContainsString("[AttachmentDownloadController::class, 'destroy']", $routes);
self::assertFileExists($sourcePreview);
self::assertStringContainsString('Read-only source viewer', (string) file_get_contents($sourcePreview));
self::assertIsString($model);
self::assertStringContainsString('function isSourceCode', $model);
self::assertStringContainsString('function canPreview', $model);
self::assertStringContainsString('function language', $model);
self::assertStringContainsString('function loadedAttribute', $model);
self::assertStringContainsString('function displayName', $model);
self::assertStringContainsString('function byteSize', $model);
self::assertStringContainsString('function downloadCount', $model);
self::assertStringContainsString('function isDeletedAttachment', $model);
self::assertStringNotContainsString('$this->extension ?: pathinfo', $model);
self::assertStringNotContainsString('is_string($this->category)', $model);
self::assertIsString($service);
self::assertStringContainsString('checksum_sha256', $service);
self::assertStringContainsString('function softDelete', $service);
self::assertIsString($migration);
self::assertStringContainsString('download_count', $migration);
self::assertStringContainsString('deleted_at', $migration);
}
}
@@ -0,0 +1,78 @@
<?php
/**
* TicketBranchCommandUiFilesTest.php
*
* Guards the ticket-level Git activity branch command builder and comment UI polish.
*/
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use PHPUnit\Framework\TestCase;
final class TicketBranchCommandUiFilesTest extends TestCase
{
/**
* Ensure ticket Git activity exposes a professional branch command copy workflow.
*/
public function test_ticket_git_activity_has_branch_command_builder_and_copy_button(): void
{
$viewPath = dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-show.blade.php';
self::assertFileExists($viewPath);
$view = file_get_contents($viewPath);
self::assertIsString($view);
self::assertStringContainsString('data-helpdesk-branch-command', $view);
self::assertStringContainsString('git checkout -b bugfix/{{ $ticket->reference }}-', $view);
self::assertStringContainsString('data-helpdesk-branch-suffix', $view);
self::assertStringContainsString('data-helpdesk-copy-branch-command', $view);
self::assertStringContainsString('copyHelpdeskBranchCommand', $view);
self::assertStringContainsString('normaliseHelpdeskBranchSuffix', $view);
self::assertStringContainsString('Copied to clipboard.', $view);
}
/**
* Ensure visibility selectors keep their accent backgrounds during interaction.
*/
public function test_visibility_controls_keep_accent_backgrounds_on_hover_and_focus(): void
{
$ticketViewPath = dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-show.blade.php';
$threadViewPath = dirname(__DIR__, 2) . '/Resources/views/partials/comment-thread.blade.php';
$ticketView = file_get_contents($ticketViewPath);
$threadView = file_get_contents($threadViewPath);
self::assertIsString($ticketView);
self::assertIsString($threadView);
self::assertStringContainsString('hover:bg-emerald-100/80', $ticketView);
self::assertStringContainsString('focus-within:bg-emerald-100/80', $ticketView);
self::assertStringContainsString('hover:bg-purple-100/80', $ticketView);
self::assertStringContainsString('focus-within:bg-purple-100/80', $ticketView);
self::assertStringContainsString('hover:bg-emerald-100/80', $threadView);
self::assertStringContainsString('hover:bg-purple-100/80', $threadView);
}
/**
* Ensure ticket comments and threaded replies expose browser spelling hints.
*/
public function test_ticket_comment_subjects_and_bodies_enable_spellcheck(): void
{
$ticketViewPath = dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-show.blade.php';
$threadViewPath = dirname(__DIR__, 2) . '/Resources/views/partials/comment-thread.blade.php';
$ticketView = file_get_contents($ticketViewPath);
$threadView = file_get_contents($threadViewPath);
self::assertIsString($ticketView);
self::assertIsString($threadView);
self::assertStringContainsString('data-helpdesk-comment-subject', $ticketView);
self::assertStringContainsString('spellcheck="true" lang="en-GB" autocorrect="on" autocomplete="on" autocapitalize="sentences" inputmode="text"', $ticketView);
self::assertStringContainsString('role="textbox" aria-multiline="true" spellcheck="true"', $ticketView);
self::assertStringContainsString('id="reply-subject-{{ $comment->id }}"', $threadView);
self::assertStringContainsString('spellcheck="true" lang="en-GB" autocorrect="on" autocomplete="on" autocapitalize="sentences" inputmode="text"', $threadView);
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Tests\TestCase;
final class TicketCreateAttachmentPersistenceTest extends TestCase
{
/**
* The create form must not submit while Livewire is still uploading files,
* otherwise the ticket can be created before attachments reach the component.
*/
public function test_ticket_create_submit_is_blocked_while_attachments_are_uploading(): void
{
$view = file_get_contents(base_path('Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php'));
$this->assertIsString($view);
$this->assertStringContainsString('x-on:livewire-upload-start="helpdeskAttachmentsUploading = true"', $view);
$this->assertStringContainsString('x-on:livewire-upload-finish="helpdeskAttachmentsUploading = false"', $view);
$this->assertStringContainsString('x-on:livewire-upload-error="helpdeskAttachmentsUploading = false"', $view);
$this->assertStringContainsString('x-on:livewire-upload-cancel="helpdeskAttachmentsUploading = false"', $view);
$this->assertStringContainsString('wire:target="save,attachments"', $view);
$this->assertStringContainsString('x-bind:disabled="helpdeskAttachmentsUploading"', $view);
$this->assertStringContainsString('$event.preventDefault();', $view);
}
/**
* The shared attachment service must explicitly support Livewire temporary
* uploads as well as controller UploadedFile instances.
*/
public function test_attachment_service_supports_livewire_temporary_uploads(): void
{
$service = file_get_contents(base_path('Modules/Helpdesk/Services/AttachmentService.php'));
$this->assertIsString($service);
$this->assertStringContainsString('TemporaryUploadedFile', $service);
$this->assertStringContainsString('isSupportedUpload', $service);
$this->assertStringContainsString('$file->store(\'tickets/\' . $ticket->id, $disk)', $service);
$this->assertStringNotContainsString('! $file instanceof UploadedFile || ! $file->isValid()', $service);
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use PHPUnit\Framework\TestCase;
/**
* Guards browser-native spellcheck wiring on Helpdesk ticket create and edit forms.
*/
final class TicketCreateEditSpellcheckFilesTest extends TestCase
{
/**
* Ensure the create form subject and body editor remain wired for native browser spellcheck.
*/
public function test_create_ticket_subject_and_body_keep_native_spellcheck_hints(): void
{
$view = file_get_contents(__DIR__ . '/../../Resources/views/livewire/ticket-create.blade.php');
$editor = file_get_contents(__DIR__ . '/../../Resources/views/components/html-editor.blade.php');
self::assertIsString($view);
self::assertIsString($editor);
self::assertStringContainsString('id="subject" type="text" spellcheck="true" lang="en-GB" autocorrect="on" autocomplete="on" autocapitalize="sentences" inputmode="text"', $view);
self::assertStringContainsString('model="body"', $view);
self::assertStringContainsString('editor-dusk="ticket-body-editor"', $view);
self::assertStringContainsString('contenteditable="true"', $editor);
self::assertStringContainsString('spellcheck="true"', $editor);
self::assertStringContainsString('lang="en-GB"', $editor);
self::assertStringContainsString('autocorrect="on"', $editor);
self::assertStringContainsString('autocomplete="on"', $editor);
self::assertStringContainsString('autocapitalize="sentences"', $editor);
self::assertStringContainsString('data-helpdesk-spellcheck-mirror="{{ $model }}"', $editor);
}
/**
* Ensure the edit form subject and body editor use the same spellcheck baseline as create.
*/
public function test_edit_ticket_subject_and_body_keep_native_spellcheck_hints(): void
{
$view = file_get_contents(__DIR__ . '/../../Resources/views/livewire/ticket-edit.blade.php');
$editor = file_get_contents(__DIR__ . '/../../Resources/views/components/html-editor.blade.php');
self::assertIsString($view);
self::assertIsString($editor);
self::assertStringContainsString('id="subject" type="text" spellcheck="true" lang="en-GB" autocorrect="on" autocomplete="on" autocapitalize="sentences" inputmode="text"', $view);
self::assertStringContainsString('model="body"', $view);
self::assertStringContainsString('editor-dusk="ticket-edit-body-editor"', $view);
self::assertStringContainsString('data-helpdesk-spellcheck-mirror="{{ $model }}"', $editor);
}
}
@@ -9,24 +9,53 @@ use Tests\TestCase;
final class TicketCreateEditorSyncFilesTest extends TestCase final class TicketCreateEditorSyncFilesTest extends TestCase
{ {
/** /**
* The ticket create editor must bind the details body through Livewire * The ticket create editor must not send Livewire requests while typing.
* without eager live validation while the user is typing. * Eager key/input sync causes disabled submit states, random content
* replacement, and prevents native spellcheck from settling.
*/ */
public function test_ticket_create_editor_pushes_details_body_to_livewire_before_validation(): void public function test_ticket_create_editor_does_not_sync_livewire_on_keypress_or_input(): void
{
$editor = file_get_contents(base_path('Modules/Helpdesk/Resources/views/components/html-editor.blade.php'));
$this->assertIsString($editor);
$this->assertStringNotContainsString('x-on:input', $editor);
$this->assertStringNotContainsString('x-on:keyup', $editor);
$this->assertStringContainsString('contenteditable="true"', $editor);
$this->assertStringContainsString('spellcheck="true"', $editor);
$this->assertStringContainsString('lang="en-GB"', $editor);
$this->assertStringContainsString('this.$wire.set(this.model, this.value, live)', $editor);
$this->assertStringContainsString('syncToLivewire(false)', $editor);
}
/**
* The create form must push the rich editor body to the Livewire property
* during submit capture, before the save action validates the body field.
*/
public function test_ticket_create_form_syncs_body_before_submit_validation(): void
{ {
$view = file_get_contents(base_path('Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php')); $view = file_get_contents(base_path('Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php'));
$this->assertIsString($view); $this->assertIsString($view);
$this->assertStringContainsString('wire:model="body"', $view); $this->assertStringContainsString('model="body"', $view);
$this->assertStringContainsString('x-on:submit.capture', $view);
$this->assertStringContainsString('helpdesk-editor-sync', $view);
$this->assertStringNotContainsString('wire:model.live="body"', $view); $this->assertStringNotContainsString('wire:model.live="body"', $view);
$this->assertStringContainsString('submit', $view); }
$this->assertTrue(
str_contains($view, 'Livewire') /**
|| str_contains($view, '$wire') * The create-ticket footer buttons should match the compact professional
|| str_contains($view, '@this') * pattern already used by Ticket controls.
|| str_contains($view, 'wire:model="body"'), */
'Ticket create editor must keep the body field synchronised with Livewire before validation.' public function test_ticket_create_footer_buttons_match_ticket_controls_style(): void
); {
$view = file_get_contents(base_path('Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php'));
$this->assertIsString($view);
$this->assertStringContainsString('inline-flex min-h-10 items-center justify-center rounded-lg bg-red-600 px-4 py-2 text-xs font-black', $view);
$this->assertStringContainsString('inline-flex min-h-10 items-center justify-center rounded-lg bg-blue-600 px-4 py-2 text-xs font-black', $view);
$this->assertStringContainsString('inline-flex min-h-10 items-center justify-center rounded-lg bg-emerald-600 px-4 py-2 text-xs font-black', $view);
$this->assertStringContainsString('Create ticket', $view);
$this->assertStringNotContainsString('rounded-xl bg-green-600 px-5 py-2 text-sm font-semibold', $view);
} }
/** /**
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use PHPUnit\Framework\TestCase;
final class TicketGitCommitDisplayFilesTest extends TestCase
{
public function test_ticket_show_uses_safe_bound_git_commit_lookup_without_raw_like_sql(): void
{
$component = file_get_contents(__DIR__ . '/../../Livewire/TicketShow.php');
self::assertIsString($component);
self::assertStringContainsString("setRelation('gitCommits'", $component);
self::assertStringContainsString("orWhere('message', 'ILIKE'", $component);
self::assertStringContainsString("orWhere('branch', 'ILIKE'", $component);
self::assertStringContainsString("orWhere('url', 'ILIKE'", $component);
self::assertStringNotContainsString('orWhereRaw', $component);
self::assertStringNotContainsString('lower(message) like', strtolower($component));
}
/**
* Directly linked commits must still display even when historical repository ownership is inconsistent.
*/
public function test_ticket_show_keeps_directly_linked_git_commits_visible_before_repository_scope(): void
{
$component = file_get_contents(__DIR__ . '/../../Livewire/TicketShow.php');
self::assertIsString($component);
self::assertStringContainsString("\$scopeQuery->where('ticket_id', \$ticket->id)", $component);
self::assertStringContainsString("->orWhereHas('repository'", $component);
self::assertStringContainsString("HD-2026-00026", "HD-2026-00026");
}
public function test_ticket_show_preserves_livewire_safe_expandable_sidebar_cards(): void
{
$view = file_get_contents(__DIR__ . '/../../Resources/views/livewire/ticket-show.blade.php');
self::assertIsString($view);
self::assertStringContainsString('data-helpdesk-sidebar-cards', $view);
self::assertStringContainsString('livewire:navigated', $view);
self::assertStringContainsString('data-helpdesk-sidebar-card-header', $view);
self::assertStringNotContainsString("document.createElement('details')", $view);
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Tests\TestCase;
/**
* Verifies that Helpdesk ticket sidebar cards use the same expandable header pattern as Ticket controls.
*/
final class TicketSidebarCardIconsFilesTest extends TestCase
{
/**
* Sidebar cards must use a real summary chevron control, matching the Ticket controls card.
*/
public function test_ticket_sidebar_cards_use_ticket_controls_chevron_pattern(): void
{
$view = file_get_contents(module_path('Helpdesk', 'Resources/views/livewire/ticket-show.blade.php'));
$this->assertIsString($view);
$this->assertStringContainsString("details.className = Array.from(classNames).join(' ');", $view);
$this->assertStringContainsString("classNames.add('group');", $view);
$this->assertStringContainsString("'justify-between'", $view);
$this->assertStringContainsString("'marker:hidden'", $view);
$this->assertStringContainsString('inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-white/15 text-white ring-1 ring-white/20', $view);
$this->assertStringContainsString('fa-solid fa-chevron-right text-xs transition group-open:rotate-90', $view);
}
/**
* The old pseudo-element fallback produced inconsistent square icons and must stay removed.
*/
public function test_ticket_sidebar_cards_do_not_use_pseudo_element_chevrons(): void
{
$view = file_get_contents(module_path('Helpdesk', 'Resources/views/livewire/ticket-show.blade.php'));
$this->assertIsString($view);
$this->assertStringNotContainsString('[data-helpdesk-sidebar-cards] > div > :first-child::after', $view);
$this->assertStringNotContainsString('border-right: .16rem solid currentColor;', $view);
$this->assertStringNotContainsString('content: "\\f078";', $view);
}
/**
* Livewire navigated ticket pages must initialise the sidebar cards again.
*/
public function test_ticket_sidebar_cards_initialise_after_livewire_navigation(): void
{
$view = file_get_contents(module_path('Helpdesk', 'Resources/views/livewire/ticket-show.blade.php'));
$this->assertIsString($view);
$this->assertStringContainsString('initialiseHelpdeskTicketShowInteractions', $view);
$this->assertStringContainsString("document.addEventListener('livewire:navigated', initialiseHelpdeskTicketShowInteractions);", $view);
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use PHPUnit\Framework\TestCase;
final class TicketSidebarCardsExpandableFilesTest extends TestCase
{
/**
* Ticket show sidebar cards must be expandable after normal page loads and Livewire navigation.
*/
public function test_ticket_show_sidebar_cards_have_livewire_safe_expand_toggle(): void
{
$viewPath = dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-show.blade.php';
$view = file_get_contents($viewPath);
self::assertIsString($view);
self::assertStringContainsString('data-helpdesk-sidebar-cards', $view);
self::assertStringContainsString('data-helpdesk-sidebar-card-header', $view);
self::assertStringContainsString('toggleHelpdeskSidebarCard', $view);
self::assertStringContainsString("document.addEventListener('livewire:navigated', initialiseHelpdeskTicketShowInteractions);", $view);
self::assertStringContainsString("document.addEventListener('livewire:init', initialiseHelpdeskTicketShowInteractions);", $view);
self::assertStringContainsString("card.dataset.open = isOpen ? 'false' : 'true';", $view);
self::assertStringContainsString("header.setAttribute('aria-expanded', isOpen ? 'false' : 'true');", $view);
}
/**
* Inline styles must stay inside the Livewire root element so the component root remains stable.
*/
public function test_ticket_show_does_not_place_style_before_livewire_root(): void
{
$viewPath = dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-show.blade.php';
$view = file_get_contents($viewPath);
self::assertIsString($view);
self::assertLessThan(
strpos($view, '<style>'),
strpos($view, '<div data-dusk="helpdesk-ticket-show"'),
);
}
}

Some files were not shown because too many files have changed in this diff Show More