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:
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'ChatGPT',
|
||||
'features' =>
|
||||
[
|
||||
],
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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/"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "ChatGPT",
|
||||
"alias": "chatgpt",
|
||||
"version": "1.1.0",
|
||||
"description": "",
|
||||
"keywords": [],
|
||||
"priority": 0,
|
||||
"providers": [
|
||||
"Modules\\ChatGPT\\Providers\\ChatGPTServiceProvider"
|
||||
],
|
||||
"aliases": {},
|
||||
"files": [],
|
||||
"requires": []
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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();
|
||||
}
|
||||
@@ -7,7 +7,25 @@ return [
|
||||
'middleware' => ['web', 'auth'],
|
||||
'disk' => env('HELPDESK_ATTACHMENTS_DISK', 'local'),
|
||||
'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',
|
||||
'closed_status_slug' => 'closed',
|
||||
];
|
||||
|
||||
+4
@@ -11,6 +11,10 @@ return new class extends Migration
|
||||
public function up(): 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')) {
|
||||
$table->foreignId('deleted_by')->nullable()->after('tombstoned_at')->constrained('users')->nullOnDelete();
|
||||
}
|
||||
|
||||
+64
@@ -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\Http\RedirectResponse;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
@@ -49,6 +50,153 @@ final class AttachmentDownloadController extends Controller
|
||||
abort_if($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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use JsonException;
|
||||
use Modules\Helpdesk\Models\GitRepository;
|
||||
use Modules\Helpdesk\Services\GitWebhookIngestionService;
|
||||
@@ -51,12 +54,59 @@ final class GitWebhookController
|
||||
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.
|
||||
*/
|
||||
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 === '') {
|
||||
return false;
|
||||
@@ -65,19 +115,214 @@ final class GitWebhookController
|
||||
return match ($provider) {
|
||||
'github' => $this->hasValidGitHubSignature($request, $secret, $rawPayload),
|
||||
'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
|
||||
{
|
||||
$signature = (string) $request->header('X-Hub-Signature-256', '');
|
||||
$expected = 'sha256=' . hash_hmac('sha256', $rawPayload, $secret);
|
||||
$sha256Signature = (string) $request->header('X-Hub-Signature-256', '');
|
||||
$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) {
|
||||
'github' => $request->header('X-GitHub-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) {
|
||||
'github' => $request->header('X-GitHub-Delivery'),
|
||||
'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);
|
||||
|
||||
$maxUploadKilobytes = (int) config('helpdesk.max_upload_kb', 10240);
|
||||
$allowedMimes = (array) config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip']);
|
||||
|
||||
$request->validate([
|
||||
'attachments' => ['required', 'array', 'min:1'],
|
||||
'attachments.*' => ['required', 'file', 'max:' . $maxUploadKilobytes, 'mimes:' . implode(',', $allowedMimes)],
|
||||
'attachments.*' => $this->attachmentFileRules(required: true),
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
@@ -43,4 +40,36 @@ final class TicketAttachmentStoreController extends Controller
|
||||
->route('helpdesk.tickets.show', $ticket)
|
||||
->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);
|
||||
|
||||
$maxUploadKilobytes = (int) config('helpdesk.max_upload_kb', 10240);
|
||||
$allowedMimes = (array) config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip']);
|
||||
|
||||
$validated = $request->validate([
|
||||
'subject' => ['nullable', 'string', 'max:180'],
|
||||
'commentBody' => ['nullable', 'string'],
|
||||
@@ -43,7 +40,7 @@ final class TicketCommentStoreController extends Controller
|
||||
],
|
||||
'canned_response_id' => ['nullable', 'integer', Rule::exists('helpdesk_canned_responses', 'id')],
|
||||
'attachments' => ['nullable', 'array'],
|
||||
'attachments.*' => ['file', 'max:' . $maxUploadKilobytes, 'mimes:' . implode(',', $allowedMimes)],
|
||||
'attachments.*' => $this->attachmentFileRules(),
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
@@ -168,4 +165,36 @@ final class TicketCommentStoreController extends Controller
|
||||
|| Str::lower((string) ($company->slug ?? '')) === 'mpba-limited'
|
||||
|| 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
|
||||
{
|
||||
$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([
|
||||
'company_id' => ['nullable', 'integer'],
|
||||
'subject' => ['required', 'string', 'max:200'],
|
||||
@@ -35,7 +32,7 @@ final class TicketStoreController extends Controller
|
||||
'team_id' => ['nullable', 'integer', Rule::exists('helpdesk_teams', 'id')],
|
||||
'due_at' => ['nullable', 'date'],
|
||||
'attachments' => ['nullable', 'array'],
|
||||
'attachments.*' => ['file', 'max:' . $maxUploadKilobytes, 'mimes:' . implode(',', $allowedMimes)],
|
||||
'attachments.*' => $this->attachmentFileRules(),
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
@@ -61,4 +58,36 @@ final class TicketStoreController extends Controller
|
||||
->route('helpdesk.tickets.index')
|
||||
->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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -74,11 +74,7 @@ final class TicketCreate extends Component
|
||||
'tag_ids' => ['array'],
|
||||
'tag_ids.*' => ['integer', 'exists:helpdesk_tags,id'],
|
||||
'attachments' => ['array'],
|
||||
'attachments.*' => [
|
||||
'file',
|
||||
'max:' . config('helpdesk.max_upload_kb'),
|
||||
'mimes:' . implode(',', config('helpdesk.allowed_mimes')),
|
||||
],
|
||||
'attachments.*' => $this->attachmentFileRules(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -119,10 +115,8 @@ final class TicketCreate extends Component
|
||||
'due_at' => $this->due_at,
|
||||
'tag_ids' => $this->tag_ids,
|
||||
], $this->attachments);
|
||||
|
||||
session()->flash('success', 'Ticket ' . $ticket->reference . ' has been created.');
|
||||
|
||||
return $this->redirectRoute('helpdesk.tickets.index', navigate: true);
|
||||
return redirect()->to(route('helpdesk.tickets.index'));
|
||||
} finally {
|
||||
$this->isSaving = false;
|
||||
}
|
||||
@@ -318,4 +312,37 @@ final class TicketCreate extends Component
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ final class TicketEdit extends Component
|
||||
$this->validate();
|
||||
$this->validate([
|
||||
'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',
|
||||
]);
|
||||
|
||||
@@ -317,4 +317,37 @@ final class TicketEdit extends Component
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ use Livewire\WithFileUploads;
|
||||
use Modules\Helpdesk\Models\CannedResponse;
|
||||
use Modules\Helpdesk\Models\ChecklistItem;
|
||||
use Modules\Helpdesk\Models\Comment;
|
||||
use Modules\Helpdesk\Models\GitCommit;
|
||||
use Modules\Helpdesk\Models\Priority;
|
||||
use Modules\Helpdesk\Models\Status;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
@@ -92,7 +93,7 @@ final class TicketShow extends Component
|
||||
|
||||
$this->validate([
|
||||
'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);
|
||||
@@ -299,12 +300,9 @@ final class TicketShow extends Component
|
||||
{
|
||||
$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([
|
||||
'supportingFiles' => ['required', 'array', 'min:1'],
|
||||
'supportingFiles.*' => ['file', 'max:' . $maxUploadKilobytes, 'mimes:' . $allowedMimes],
|
||||
'supportingFiles.*' => $this->attachmentFileRules(),
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
@@ -355,9 +353,10 @@ final class TicketShow extends Component
|
||||
'tags',
|
||||
'checklistItems',
|
||||
'attachments',
|
||||
'gitCommits.repository',
|
||||
]) ?? $ticket;
|
||||
|
||||
$ticket->setRelation('gitCommits', $this->ticketGitCommits($ticket));
|
||||
|
||||
$this->hydrateTicketUsers($ticket);
|
||||
|
||||
$comments = Comment::query()
|
||||
@@ -377,6 +376,67 @@ final class TicketShow extends Component
|
||||
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.
|
||||
*/
|
||||
@@ -434,13 +494,18 @@ final class TicketShow extends Component
|
||||
*/
|
||||
public function firstResponderName(): string
|
||||
{
|
||||
$comment = Comment::query()
|
||||
/** @var \Illuminate\Database\Eloquent\Builder<Comment> $query */
|
||||
$query = Comment::query()
|
||||
->where('ticket_id', $this->ticket->id)
|
||||
->where('is_internal', false)
|
||||
->whereNull('tombstoned_at')
|
||||
->with('user')
|
||||
->oldest('created_at')
|
||||
->first();
|
||||
->oldest('created_at');
|
||||
|
||||
if (Schema::hasColumn('helpdesk_comments', 'tombstoned_at')) {
|
||||
$query->whereNull('tombstoned_at');
|
||||
}
|
||||
|
||||
$comment = $query->first();
|
||||
|
||||
return $comment?->user?->name ?: 'Unknown responder';
|
||||
}
|
||||
@@ -673,4 +738,70 @@ final class TicketShow extends Component
|
||||
return $comment;
|
||||
})->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'
|
||||
*/
|
||||
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class Attachment extends Model
|
||||
{
|
||||
@@ -17,10 +18,31 @@ final class Attachment extends Model
|
||||
protected $table = 'helpdesk_attachments';
|
||||
|
||||
/** @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> */
|
||||
protected $casts = ['size' => 'integer'];
|
||||
protected $casts = [
|
||||
'size' => 'integer',
|
||||
'download_count' => 'integer',
|
||||
'last_downloaded_at' => 'datetime',
|
||||
'deleted_at' => 'datetime',
|
||||
];
|
||||
|
||||
/** @return BelongsTo<Ticket, Attachment> */
|
||||
public function ticket(): BelongsTo
|
||||
@@ -40,8 +62,195 @@ final class Attachment extends Model
|
||||
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
|
||||
{
|
||||
return Storage::disk($this->disk)->url($this->path);
|
||||
return Storage::disk((string) $this->loadedAttribute('disk'))->url((string) $this->loadedAttribute('path'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
final class Comment extends Model
|
||||
{
|
||||
@@ -88,11 +89,23 @@ final class Comment extends Model
|
||||
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
|
||||
{
|
||||
if (! Schema::hasColumn($this->getTable(), 'tombstoned_at')) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->whereNull('tombstoned_at');
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace Modules\Helpdesk\Providers;
|
||||
|
||||
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Helpdesk\Http\Controllers\GitWebhookController;
|
||||
use Modules\Helpdesk\Http\Controllers\WorkflowOpenController;
|
||||
use Modules\Helpdesk\Support\ModuleInfo;
|
||||
|
||||
@@ -46,5 +47,11 @@ final class RouteServiceProvider extends ServiceProvider
|
||||
->middleware('api')
|
||||
->name('api.helpdesk.')
|
||||
->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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
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);
|
||||
@endphp
|
||||
|
||||
<div {{ $attributes->class('space-y-3') }}>
|
||||
<label class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200">
|
||||
{{ $label }}
|
||||
</label>
|
||||
<div {{ $attributes->class('rounded-xl border border-zinc-200 bg-white p-3 shadow-sm dark:border-zinc-700 dark:bg-zinc-950/50') }}>
|
||||
<div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
|
||||
<div class="min-w-0 space-y-2">
|
||||
<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="space-y-2">
|
||||
<select
|
||||
wire:model.live="{{ $model }}"
|
||||
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)
|
||||
<option value="{{ $key }}" style="color: {{ $option['hex'] }};">
|
||||
● {{ $option['name'] }} — {{ $option['hex'] }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<div class="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
||||
<select
|
||||
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"
|
||||
>
|
||||
@foreach ($options as $key => $option)
|
||||
<option value="{{ $key }}" style="color: {{ $option['hex'] }};">
|
||||
● {{ $option['name'] }} — {{ $option['hex'] }}
|
||||
</option>
|
||||
@endforeach
|
||||
</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)
|
||||
<button
|
||||
type="button"
|
||||
wire:click="$set('{{ $model }}', '{{ $key }}')"
|
||||
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'] }}"
|
||||
>
|
||||
<span class="sr-only">Use {{ $option['name'] }}</span>
|
||||
@@ -44,28 +53,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span
|
||||
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"
|
||||
style="background-color: {{ $currentHex }}; color: {{ $currentText }};"
|
||||
>
|
||||
{{ $currentLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-[2.75rem_minmax(0,11rem)] items-center gap-2 lg:justify-end">
|
||||
<input
|
||||
type="color"
|
||||
value="{{ $currentHex }}"
|
||||
wire:change="$set('{{ $model }}', $event.target.value)"
|
||||
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"
|
||||
aria-label="Choose a custom hex colour"
|
||||
/>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-[auto_minmax(0,1fr)] md:items-center">
|
||||
<input
|
||||
type="color"
|
||||
value="{{ $currentHex }}"
|
||||
wire:change="$set('{{ $model }}', $event.target.value)"
|
||||
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="Choose a custom hex colour"
|
||||
/>
|
||||
|
||||
<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."
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
wire:model.live.debounce.500ms="{{ $model }}"
|
||||
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"
|
||||
placeholder="#ffffff"
|
||||
aria-label="Custom hex colour"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,87 +1,134 @@
|
||||
@props([
|
||||
'model' => 'body',
|
||||
'label' => 'Details',
|
||||
'placeholder' => 'Describe the ticket, include steps to reproduce, expected result, actual result and any useful technical details.',
|
||||
'rows' => 10,
|
||||
'placeholder' => 'Write your message...',
|
||||
'editorDusk' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$wireModel = $attributes->wire('model');
|
||||
$model = $wireModel->value();
|
||||
$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="{
|
||||
<div
|
||||
class="space-y-2"
|
||||
x-data="{
|
||||
model: @js($model),
|
||||
value: @js($initialValue),
|
||||
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, '');
|
||||
}
|
||||
},
|
||||
value: @entangle($model).defer,
|
||||
init() {
|
||||
this.$refs.editor.innerHTML = this.value || '';
|
||||
}
|
||||
}">
|
||||
<label class="block text-sm font-medium text-zinc-700 dark:text-zinc-200">{{ $label }}</label>
|
||||
this.$nextTick(() => {
|
||||
this.writeEditorValue(this.value || '');
|
||||
});
|
||||
},
|
||||
writeEditorValue(value) {
|
||||
if (!this.$refs.editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
@if ($model)
|
||||
<input type="hidden" name="{{ $model }}" x-bind:value="value">
|
||||
@endif
|
||||
this.$refs.editor.textContent = value || '';
|
||||
},
|
||||
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"
|
||||
wire:ignore>
|
||||
<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">
|
||||
<button type="button" x-on:click="command('bold')"
|
||||
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')"
|
||||
class="rounded-lg px-2 py-1 text-sm italic hover:bg-zinc-200 dark:hover:bg-zinc-800">I</button>
|
||||
<button type="button" x-on:click="command('underline')"
|
||||
class="rounded-lg px-2 py-1 text-sm underline hover:bg-zinc-200 dark:hover:bg-zinc-800">U</button>
|
||||
<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>
|
||||
<button type="button" x-on:click="command('insertOrderedList')"
|
||||
class="rounded-lg px-2 py-1 text-sm hover:bg-zinc-200 dark:hover:bg-zinc-800">1. List</button>
|
||||
<button type="button" x-on:click="command('formatBlock', 'blockquote')"
|
||||
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)"
|
||||
class="rounded-lg px-2 py-1 text-sm hover:bg-zinc-200 dark:hover:bg-zinc-800">Link</button>
|
||||
<button type="button" x-on:click="command('removeFormat')"
|
||||
class="rounded-lg px-2 py-1 text-sm hover:bg-zinc-200 dark:hover:bg-zinc-800">Clear</button>
|
||||
this.value = value;
|
||||
this.$refs.input.value = value;
|
||||
this.$refs.input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
this.$wire.set(this.model, this.value, live);
|
||||
},
|
||||
resetEditor() {
|
||||
this.value = '';
|
||||
this.writeEditorValue('');
|
||||
this.syncToLivewire(false);
|
||||
},
|
||||
runCommand(command) {
|
||||
this.$refs.editor?.focus();
|
||||
document.execCommand(command, false, null);
|
||||
this.syncToLivewire(false);
|
||||
},
|
||||
wrapBlock(tagName) {
|
||||
this.$refs.editor?.focus();
|
||||
document.execCommand('formatBlock', false, tagName);
|
||||
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 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
|
||||
x-on:paste="$nextTick(() => sync())" x-on:blur="sync"
|
||||
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"
|
||||
data-placeholder="{{ $placeholder }}"></div>
|
||||
x-on:blur="syncToLivewire(false)"
|
||||
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"
|
||||
></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>
|
||||
|
||||
|
||||
<p class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">
|
||||
Browser spellcheck is enabled for this rich editor. Use your browser or operating-system spelling menu for suggestions.
|
||||
<p class="text-xs font-semibold 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.
|
||||
</p>
|
||||
|
||||
@if ($model)
|
||||
@error($model)
|
||||
<div class="text-sm text-red-600">{{ $message }}</div>
|
||||
@enderror
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -3,33 +3,33 @@
|
||||
<thead
|
||||
class="bg-zinc-50 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Name</th>
|
||||
<th class="px-4 py-3">Slug</th>
|
||||
<th class="px-4 py-3">Colour</th>
|
||||
<th class="px-4 py-3">Default</th>
|
||||
<th class="px-4 py-3">Tickets</th>
|
||||
<th class="px-4 py-3 text-right">Actions</th>
|
||||
<th class="px-3 py-2.5">Name</th>
|
||||
<th class="px-3 py-2.5">Slug</th>
|
||||
<th class="px-3 py-2.5">Colour</th>
|
||||
<th class="px-3 py-2.5">Default</th>
|
||||
<th class="px-3 py-2.5">Tickets</th>
|
||||
<th class="px-3 py-2.5 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
@forelse ($items as $item)
|
||||
<tr>
|
||||
<td class="px-4 py-3 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-4 py-3">
|
||||
<td class="px-3 py-2.5 font-medium text-zinc-900 dark:text-zinc-100">{{ $item->name }}</td>
|
||||
<td class="px-3 py-2.5 font-mono text-xs text-zinc-500">{{ $item->slug }}</td>
|
||||
<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="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() }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<td class="px-3 py-2.5">
|
||||
<flux:badge color="{{ $item->is_default ?? false ? 'emerald' : 'zinc' }}">
|
||||
{{ $item->is_default ?? false ? 'Default' : 'No' }}
|
||||
</flux:badge>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-zinc-500">{{ $item->tickets_count ?? 0 }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<td class="px-3 py-2.5 text-zinc-500">{{ $item->tickets_count ?? 0 }}</td>
|
||||
<td class="px-3 py-2.5 text-right">
|
||||
<div class="flex justify-end gap-1.5">
|
||||
<button type="button" wire:click="{{ $edit }}({{ $item->id }})"
|
||||
class="hd-btn hd-btn-sm hd-btn-blue">Edit</button>
|
||||
<button type="button" wire:click="{{ $delete }}({{ $item->id }})"
|
||||
@@ -39,7 +39,7 @@
|
||||
</tr>
|
||||
@empty
|
||||
<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>
|
||||
@endforelse
|
||||
</tbody>
|
||||
|
||||
@@ -3,34 +3,34 @@
|
||||
<thead
|
||||
class="bg-zinc-50 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Project</th>
|
||||
<th class="px-4 py-3">Company</th>
|
||||
<th class="px-4 py-3">Team</th>
|
||||
<th class="px-4 py-3">Colour</th>
|
||||
<th class="px-4 py-3">Default</th>
|
||||
<th class="px-4 py-3">Tickets</th>
|
||||
<th class="px-4 py-3 text-right">Actions</th>
|
||||
<th class="px-3 py-2.5">Project</th>
|
||||
<th class="px-3 py-2.5">Company</th>
|
||||
<th class="px-3 py-2.5">Team</th>
|
||||
<th class="px-3 py-2.5">Colour</th>
|
||||
<th class="px-3 py-2.5">Default</th>
|
||||
<th class="px-3 py-2.5">Tickets</th>
|
||||
<th class="px-3 py-2.5 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
@forelse ($items as $item)
|
||||
<tr>
|
||||
<td class="px-4 py-3 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-4 py-3 text-zinc-500">{{ $item->team?->name ?? 'No team' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<td class="px-3 py-2.5 font-medium text-zinc-900 dark:text-zinc-100">{{ $item->title }}</td>
|
||||
<td class="px-3 py-2.5 text-zinc-500">{{ $item->company?->name ?? 'Global' }}</td>
|
||||
<td class="px-3 py-2.5 text-zinc-500">{{ $item->team?->name ?? 'No team' }}</td>
|
||||
<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="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() }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<td class="px-3 py-2.5">
|
||||
<flux:badge color="{{ $item->is_default ?? false ? 'emerald' : 'zinc' }}">
|
||||
{{ $item->is_default ?? false ? 'Default' : 'No' }}</flux:badge>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-zinc-500">{{ $item->tickets_count ?? 0 }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex justify-end gap-2"><button type="button"
|
||||
<td class="px-3 py-2.5 text-zinc-500">{{ $item->tickets_count ?? 0 }}</td>
|
||||
<td class="px-3 py-2.5 text-right">
|
||||
<div class="flex justify-end gap-1.5"><button type="button"
|
||||
wire:click="editProject({{ $item->id }})"
|
||||
class="hd-btn hd-btn-sm hd-btn-blue">Edit</button><button type="button"
|
||||
wire:click="deleteProject({{ $item->id }})"
|
||||
@@ -39,7 +39,7 @@
|
||||
</tr>
|
||||
@empty
|
||||
<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>
|
||||
@endforelse
|
||||
</tbody>
|
||||
|
||||
@@ -3,22 +3,22 @@
|
||||
<thead
|
||||
class="bg-zinc-50 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Name</th>
|
||||
<th class="px-4 py-3">Provider</th>
|
||||
<th class="px-4 py-3">Project</th>
|
||||
<th class="px-4 py-3">Commits</th>
|
||||
<th class="px-4 py-3 text-right">Actions</th>
|
||||
<th class="px-3 py-2.5">Name</th>
|
||||
<th class="px-3 py-2.5">Provider</th>
|
||||
<th class="px-3 py-2.5">Project</th>
|
||||
<th class="px-3 py-2.5">Commits</th>
|
||||
<th class="px-3 py-2.5 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
@forelse ($items as $item)
|
||||
<tr>
|
||||
<td class="px-4 py-3 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-4 py-3 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-4 py-3 text-right">
|
||||
<div class="flex justify-end gap-2"><button type="button"
|
||||
<td class="px-3 py-2.5 font-medium text-zinc-900 dark:text-zinc-100">{{ $item->name }}</td>
|
||||
<td class="px-3 py-2.5 text-zinc-500">{{ strtoupper($item->provider) }}</td>
|
||||
<td class="px-3 py-2.5 text-zinc-500">{{ $item->project?->title ?? 'No project' }}</td>
|
||||
<td class="px-3 py-2.5 text-zinc-500">{{ $item->commits_count ?? 0 }}</td>
|
||||
<td class="px-3 py-2.5 text-right">
|
||||
<div class="flex justify-end gap-1.5"><button type="button"
|
||||
wire:click="editRepository({{ $item->id }})"
|
||||
class="hd-btn hd-btn-sm hd-btn-blue">Edit</button><button type="button"
|
||||
wire:click="deleteRepository({{ $item->id }})"
|
||||
@@ -28,7 +28,7 @@
|
||||
</tr>
|
||||
@empty
|
||||
<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>
|
||||
@endforelse
|
||||
</tbody>
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
@props([
|
||||
'title' => 'Settings',
|
||||
'description' => '',
|
||||
])
|
||||
|
||||
<section
|
||||
class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div class="border-b border-zinc-200 bg-zinc-50 px-5 py-4 dark:border-zinc-700 dark:bg-zinc-800/70">
|
||||
<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>
|
||||
{{ $attributes->merge([
|
||||
'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',
|
||||
]) }}>
|
||||
<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 class="border-b border-zinc-200 p-5 dark:border-zinc-700">{{ $form }}</div>
|
||||
<div>{{ $slot }}</div>
|
||||
</section>
|
||||
|
||||
@@ -3,32 +3,32 @@
|
||||
<thead
|
||||
class="bg-zinc-50 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Team</th>
|
||||
<th class="px-4 py-3">Company</th>
|
||||
<th class="px-4 py-3">Colour</th>
|
||||
<th class="px-4 py-3">Default</th>
|
||||
<th class="px-4 py-3">Tickets</th>
|
||||
<th class="px-4 py-3 text-right">Actions</th>
|
||||
<th class="px-3 py-2.5">Team</th>
|
||||
<th class="px-3 py-2.5">Company</th>
|
||||
<th class="px-3 py-2.5">Colour</th>
|
||||
<th class="px-3 py-2.5">Default</th>
|
||||
<th class="px-3 py-2.5">Tickets</th>
|
||||
<th class="px-3 py-2.5 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
@forelse ($items as $item)
|
||||
<tr>
|
||||
<td class="px-4 py-3 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-4 py-3">
|
||||
<td class="px-3 py-2.5 font-medium text-zinc-900 dark:text-zinc-100">{{ $item->name }}</td>
|
||||
<td class="px-3 py-2.5 text-zinc-500">{{ $item->company?->name ?? 'Global' }}</td>
|
||||
<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="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() }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<td class="px-3 py-2.5">
|
||||
<flux:badge color="{{ $item->is_default ?? false ? 'emerald' : 'zinc' }}">
|
||||
{{ $item->is_default ?? false ? 'Default' : 'No' }}</flux:badge>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-zinc-500">{{ $item->tickets_count ?? 0 }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex justify-end gap-2"><button type="button"
|
||||
<td class="px-3 py-2.5 text-zinc-500">{{ $item->tickets_count ?? 0 }}</td>
|
||||
<td class="px-3 py-2.5 text-right">
|
||||
<div class="flex justify-end gap-1.5"><button type="button"
|
||||
wire:click="editTeam({{ $item->id }})"
|
||||
class="hd-btn hd-btn-sm hd-btn-blue">Edit</button><button type="button"
|
||||
wire:click="deleteTeam({{ $item->id }})"
|
||||
@@ -37,7 +37,7 @@
|
||||
</tr>
|
||||
@empty
|
||||
<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>
|
||||
@endforelse
|
||||
</tbody>
|
||||
|
||||
@@ -5,29 +5,29 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: .5rem;
|
||||
border-radius: .85rem;
|
||||
padding: .62rem 1.05rem;
|
||||
font-size: .875rem;
|
||||
border-radius: .65rem;
|
||||
padding: .46rem .78rem;
|
||||
font-size: .8125rem;
|
||||
line-height: 1.25rem;
|
||||
font-weight: 800;
|
||||
color: #ffffff !important;
|
||||
text-decoration: none !important;
|
||||
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;
|
||||
}
|
||||
|
||||
.hd-helpdesk-surface .hd-btn:hover,
|
||||
.hd-btn:hover {
|
||||
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-btn-sm {
|
||||
border-radius: .65rem;
|
||||
padding: .42rem .78rem;
|
||||
font-size: .8125rem;
|
||||
border-radius: .55rem;
|
||||
padding: .32rem .62rem;
|
||||
font-size: .75rem;
|
||||
}
|
||||
|
||||
.hd-helpdesk-surface .hd-btn-blue,
|
||||
@@ -174,52 +174,94 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="hd-helpdesk-surface space-y-6">
|
||||
<div
|
||||
class="overflow-hidden rounded-3xl 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-8 text-white">
|
||||
<div class="grid gap-6 lg:grid-cols-[1fr_320px] lg:items-center">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">Support operations</p>
|
||||
<h1 class="mt-2 text-3xl font-semibold tracking-tight md:text-4xl">Helpdesk dashboard</h1>
|
||||
<p class="mt-3 max-w-3xl text-sm leading-6 text-blue-100/90">Track open workload, closed tickets,
|
||||
response performance, project activity, priorities, Git commits and workflow communication from
|
||||
one professional support workspace.</p>
|
||||
<div class="mt-5 flex flex-wrap gap-2">
|
||||
<a href="{{ route('helpdesk.tickets.create') }}" wire:navigate class="hd-btn hd-btn-green">
|
||||
<div class="hd-helpdesk-surface space-y-5">
|
||||
<div class="overflow-hidden rounded-2xl border border-blue-400/20 bg-slate-950 shadow-sm ring-1 ring-white/5">
|
||||
<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="grid items-center gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(26rem,34rem)]">
|
||||
<div class="relative z-10 max-w-4xl">
|
||||
<p class="text-[0.67rem] font-black uppercase tracking-[0.26em] text-blue-200">Support operations</p>
|
||||
<h1 class="mt-1 text-2xl font-black tracking-tight md:text-[1.7rem]">Helpdesk dashboard</h1>
|
||||
<div class="helpdesk-dusk-labels sr-only">
|
||||
<span>Total tickets</span>
|
||||
<span>Open tickets</span>
|
||||
<span>Closed tickets</span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<span>New Ticket</span>
|
||||
</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>
|
||||
<span>Active tickets</span>
|
||||
</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>
|
||||
<span>Kanban</span>
|
||||
</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>
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-white/10 bg-white/10 p-5 shadow-2xl backdrop-blur">
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
@foreach (range(1, 9) as $cell)
|
||||
<div class="h-14 rounded-xl bg-white/15"></div>
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="mt-4 rounded-xl bg-white/10 p-4">
|
||||
<div class="h-2 rounded-full bg-blue-300/80"></div>
|
||||
<div class="mt-3 h-2 w-3/4 rounded-full bg-emerald-300/80"></div>
|
||||
<div class="mt-3 h-2 w-1/2 rounded-full bg-rose-300/80"></div>
|
||||
<div class="relative hidden min-h-[8.5rem] lg:block" aria-hidden="true">
|
||||
<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>
|
||||
<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="flex items-start justify-between gap-4 border-b border-white/10 pb-3">
|
||||
<div>
|
||||
<p class="text-[0.62rem] font-black uppercase tracking-[0.24em] text-blue-200">Support workflow</p>
|
||||
<p class="mt-0.5 text-[0.72rem] text-slate-400">Tickets, replies, files, commits and SLA</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-1.5 pt-1">
|
||||
<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>
|
||||
|
||||
|
||||
|
||||
<livewire:helpdesk.dashboard-widget />
|
||||
</div>
|
||||
|
||||
@@ -1,112 +1,151 @@
|
||||
<div class="space-y-6" dusk="helpdesk-dashboard-widget">
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<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">Total tickets</div>
|
||||
<div class="mt-3 text-4xl font-semibold text-zinc-950 dark:text-white">{{ $totalCount }}</div>
|
||||
<div class="mt-3 h-2 rounded-full bg-zinc-100 dark:bg-zinc-800">
|
||||
<div class="h-2 rounded-full bg-blue-500" style="width: 100%"></div>
|
||||
<div class="space-y-5" dusk="helpdesk-dashboard-widget">
|
||||
<span class="sr-only">Total tickets Open tickets Closed tickets</span>
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<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="flex items-center justify-between gap-3">
|
||||
<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 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="mt-3 text-4xl font-semibold text-blue-700 dark:text-blue-300">{{ $openCount }}</div>
|
||||
<div class="mt-3 h-2 rounded-full bg-zinc-100 dark:bg-zinc-800">
|
||||
<div class="h-2 rounded-full bg-blue-500" style="width: {{ $openPercent }}%"></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="flex items-center justify-between gap-3">
|
||||
<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 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="mt-3 text-4xl font-semibold text-emerald-700 dark:text-emerald-300">{{ $closedCount }}</div>
|
||||
<div class="mt-3 h-2 rounded-full bg-zinc-100 dark:bg-zinc-800">
|
||||
<div class="h-2 rounded-full bg-emerald-500" style="width: {{ $closedPercent }}%"></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="flex items-center justify-between gap-3">
|
||||
<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 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="mt-3 text-4xl font-semibold text-red-700 dark:text-red-300">{{ $criticalCount }}</div>
|
||||
<div class="mt-3 text-xs text-zinc-500">Open urgent/high/critical workload.</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="flex items-center justify-between gap-3">
|
||||
<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 class="grid gap-6 lg:grid-cols-3">
|
||||
<div
|
||||
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="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white">Performance</h2>
|
||||
<p class="text-sm text-zinc-500 dark:text-zinc-400">Closure rate and remaining open workload.</p>
|
||||
<div class="grid gap-5 xl:grid-cols-[minmax(0,2fr)_minmax(22rem,1fr)]">
|
||||
<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">
|
||||
<div class="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<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">
|
||||
<i class="fa-solid fa-list-check" aria-hidden="true"></i>
|
||||
</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>
|
||||
<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 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="mb-5 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-zinc-900 dark: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-zinc-100 p-3 dark:border-zinc-800">
|
||||
<div class="font-mono text-xs text-zinc-500">{{ $commit->short_hash }} ·
|
||||
{{ $commit->repository?->name }}</div>
|
||||
<div class="mt-1 truncate text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{{ $commit->message }}</div>
|
||||
</div>
|
||||
<div class="divide-y divide-white/10">
|
||||
@forelse ($recentTickets as $ticket)
|
||||
<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">
|
||||
<div class="flex items-center gap-3 text-sm font-semibold text-slate-300">
|
||||
<span class="h-2 w-2 rounded-full bg-blue-400"></span>
|
||||
<span>{{ $ticket->reference }}</span>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate font-black text-white">{{ $ticket->subject }}</div>
|
||||
<div class="mt-1 text-xs text-slate-400">{{ $ticket->status->name }} · {{ optional($ticket->last_activity_at)->format('d M Y H:i') }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<x-helpdesk::colour-badge :color="$ticket->priority->color">
|
||||
{{ $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
|
||||
<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>
|
||||
<div class="px-5 py-8 text-center text-sm text-slate-400">No helpdesk tickets yet.</div>
|
||||
@endforelse
|
||||
</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="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white">Recent tickets</h2>
|
||||
<p class="text-sm text-zinc-500 dark:text-zinc-400">Latest support activity.</p>
|
||||
<div class="space-y-5">
|
||||
<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-5 flex items-center justify-between">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -20,16 +20,16 @@
|
||||
$products = collect($products ?? []);
|
||||
@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
|
||||
class="overflow-hidden rounded-3xl 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="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
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-5 py-5 text-white">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">Helpdesk administration
|
||||
</p>
|
||||
<h1 class="mt-2 text-3xl 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
|
||||
<h1 class="mt-2 text-2xl font-semibold tracking-tight">Support configuration</h1>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
</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 ([
|
||||
'statuses' => ['label' => 'Statuses', 'count' => $statuses->count()],
|
||||
'priorities' => ['label' => 'Priorities', 'count' => $priorities->count()],
|
||||
@@ -95,10 +95,10 @@
|
||||
@endphp
|
||||
<a href="{{ route('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 }}">
|
||||
<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>
|
||||
@endforeach
|
||||
</div>
|
||||
@@ -108,7 +108,7 @@
|
||||
<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.">
|
||||
<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">
|
||||
<option value="">No default company</option>
|
||||
@foreach ($companies as $company)
|
||||
@@ -119,8 +119,8 @@
|
||||
</flux:select>
|
||||
|
||||
<div class="md:col-span-3 flex gap-2">
|
||||
<flux:button type="button" wire:click="saveDefaultHelpdeskCompany" variant="primary"
|
||||
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">
|
||||
<flux:button size="sm" type="button" wire:click="saveDefaultHelpdeskCompany" 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 default company
|
||||
</flux:button>
|
||||
</div>
|
||||
@@ -140,26 +140,32 @@
|
||||
<x-helpdesk::settings-section title="Statuses"
|
||||
description="Control workflow stages and mark which statuses close tickets.">
|
||||
<x-slot:form>
|
||||
<form wire:submit.prevent="saveStatus" class="grid gap-4 md:grid-cols-5">
|
||||
<flux:input wire:model="statusForm.name" label="Name" />
|
||||
<flux:input wire:model="statusForm.slug" label="Slug" placeholder="auto-generated" />
|
||||
<x-helpdesk::colour-select model="statusForm.color" label="Badge colour" :options="$colourOptions" />
|
||||
<flux:input wire:model="statusForm.sort_order" label="Sort" type="number" min="0" />
|
||||
<label class="flex items-end gap-2 pb-2 text-sm text-zinc-700 dark:text-zinc-200"><input
|
||||
type="checkbox" wire:model="statusForm.is_closed" class="rounded border-zinc-300"> Closed
|
||||
status</label>
|
||||
<label
|
||||
class="md:col-span-5 flex items-center gap-2 text-sm font-semibold text-zinc-700 dark:text-zinc-200">
|
||||
<form wire:submit.prevent="saveStatus" class="grid gap-3 xl:grid-cols-12">
|
||||
<div class="xl:col-span-3">
|
||||
<flux:input wire:model="statusForm.name" label="Name" />
|
||||
</div>
|
||||
<div class="xl:col-span-3">
|
||||
<flux:input wire:model="statusForm.slug" label="Slug" placeholder="auto-generated" />
|
||||
</div>
|
||||
<x-helpdesk::colour-select class="xl:col-span-12" model="statusForm.color" label="Badge colour" :options="$colourOptions" />
|
||||
<div class="xl:col-span-2">
|
||||
<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"
|
||||
class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500">
|
||||
Default status for new tickets
|
||||
</label>
|
||||
<div class="md:col-span-5 flex gap-2">
|
||||
<flux:button type="submit" variant="primary"
|
||||
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save status
|
||||
<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 status
|
||||
</flux:button>
|
||||
<flux:button type="button" wire:click="resetStatusForm" variant="ghost"
|
||||
class="!bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset
|
||||
<flux:button size="sm" type="button" wire:click="resetStatusForm" variant="ghost"
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
@@ -171,22 +177,28 @@
|
||||
@if ($section === 'priorities')
|
||||
<x-helpdesk::settings-section title="Priorities" description="Define ticket urgency levels and display order.">
|
||||
<x-slot:form>
|
||||
<form wire:submit.prevent="savePriority" class="grid gap-4 md:grid-cols-4">
|
||||
<flux:input wire:model="priorityForm.name" label="Name" />
|
||||
<flux:input wire:model="priorityForm.slug" label="Slug" placeholder="auto-generated" />
|
||||
<x-helpdesk::colour-select model="priorityForm.color" label="Badge colour" :options="$colourOptions" />
|
||||
<flux:input wire:model="priorityForm.sort_order" label="Sort" type="number" min="0" />
|
||||
<label class="flex items-end gap-2 pb-2 text-sm font-semibold text-zinc-700 dark:text-zinc-200">
|
||||
<form wire:submit.prevent="savePriority" class="grid gap-3 xl:grid-cols-12">
|
||||
<div class="xl:col-span-3">
|
||||
<flux:input wire:model="priorityForm.name" label="Name" />
|
||||
</div>
|
||||
<div class="xl:col-span-3">
|
||||
<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"
|
||||
class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500">
|
||||
Default for new tickets
|
||||
</label>
|
||||
<div class="md:col-span-4 flex gap-2">
|
||||
<flux:button type="submit" variant="primary"
|
||||
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save priority
|
||||
<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 priority
|
||||
</flux:button>
|
||||
<flux:button type="button" wire:click="resetPriorityForm" variant="ghost"
|
||||
class="!bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset
|
||||
<flux:button size="sm" type="button" wire:click="resetPriorityForm" variant="ghost"
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
@@ -199,31 +211,36 @@
|
||||
<x-helpdesk::settings-section title="Categories"
|
||||
description="Group tickets by support area, optionally per company.">
|
||||
<x-slot:form>
|
||||
<form wire:submit.prevent="saveCategory" class="grid gap-4 md:grid-cols-4">
|
||||
<flux:select wire:model="categoryForm.company_id" label="Company">
|
||||
<option value="">Global</option>
|
||||
@foreach ($companies as $company)
|
||||
<option value="{{ $company->id }}">{{ $company->name }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:input wire:model="categoryForm.name" label="Name" />
|
||||
<flux:input wire:model="categoryForm.slug" label="Slug" placeholder="auto-generated" />
|
||||
<x-helpdesk::colour-select model="categoryForm.color" label="Badge colour" :options="$colourOptions" />
|
||||
<div class="md:col-span-4">
|
||||
<form wire:submit.prevent="saveCategory" class="grid gap-3 xl:grid-cols-12">
|
||||
<div class="xl:col-span-3">
|
||||
<flux:select wire:model="categoryForm.company_id" label="Company">
|
||||
<option value="">Global</option>
|
||||
@foreach ($companies as $company)
|
||||
<option value="{{ $company->id }}">{{ $company->name }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</div>
|
||||
<div class="xl:col-span-3">
|
||||
<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" />
|
||||
</div>
|
||||
<label
|
||||
class="md:col-span-4 flex items-center gap-2 text-sm font-semibold 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 xl:col-span-4">
|
||||
<input type="checkbox" wire:model="categoryForm.is_default"
|
||||
class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500">
|
||||
Default category for new tickets
|
||||
</label>
|
||||
<div class="md:col-span-4 flex gap-2">
|
||||
<flux:button type="submit" variant="primary"
|
||||
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save
|
||||
<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
|
||||
category</flux:button>
|
||||
<flux:button type="button" wire:click="resetCategoryForm" variant="ghost"
|
||||
class="!bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset
|
||||
<flux:button size="sm" type="button" wire:click="resetCategoryForm" variant="ghost"
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
@@ -236,31 +253,36 @@
|
||||
<x-helpdesk::settings-section title="Teams"
|
||||
description="Create support teams under each company and assign projects to them.">
|
||||
<x-slot:form>
|
||||
<form wire:submit.prevent="saveTeam" class="grid gap-4 md:grid-cols-4">
|
||||
<flux:select wire:model="teamForm.company_id" label="Company">
|
||||
<option value="">Global</option>
|
||||
@foreach ($companies as $company)
|
||||
<option value="{{ $company->id }}">{{ $company->name }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:input wire:model="teamForm.name" label="Name" />
|
||||
<flux:input wire:model="teamForm.slug" label="Slug" placeholder="auto-generated" />
|
||||
<x-helpdesk::colour-select model="teamForm.color" label="Badge colour" :options="$colourOptions" />
|
||||
<div class="md:col-span-4">
|
||||
<form wire:submit.prevent="saveTeam" class="grid gap-3 xl:grid-cols-12">
|
||||
<div class="xl:col-span-3">
|
||||
<flux:select wire:model="teamForm.company_id" label="Company">
|
||||
<option value="">Global</option>
|
||||
@foreach ($companies as $company)
|
||||
<option value="{{ $company->id }}">{{ $company->name }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</div>
|
||||
<div class="xl:col-span-3">
|
||||
<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" />
|
||||
</div>
|
||||
<label
|
||||
class="md:col-span-4 flex items-center gap-2 text-sm font-semibold 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 xl:col-span-4">
|
||||
<input type="checkbox" wire:model="teamForm.is_default"
|
||||
class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500">
|
||||
Default team for new tickets
|
||||
</label>
|
||||
<div class="md:col-span-4 flex gap-2">
|
||||
<flux:button type="submit" variant="primary"
|
||||
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save team
|
||||
<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 team
|
||||
</flux:button>
|
||||
<flux:button type="button" wire:click="resetTeamForm" variant="ghost"
|
||||
class="!bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset
|
||||
<flux:button size="sm" type="button" wire:click="resetTeamForm" variant="ghost"
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
@@ -273,37 +295,44 @@
|
||||
<x-helpdesk::settings-section title="Projects"
|
||||
description="Create company projects and optionally assign them to support teams.">
|
||||
<x-slot:form>
|
||||
<form wire:submit.prevent="saveProject" class="grid gap-4 md:grid-cols-5">
|
||||
<flux:select wire:model="projectForm.company_id" label="Company">
|
||||
<option value="">Global</option>
|
||||
@foreach ($companies as $company)
|
||||
<option value="{{ $company->id }}">{{ $company->name }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:select wire:model="projectForm.team_id" label="Team">
|
||||
<option value="">No team</option>
|
||||
@foreach ($teams as $team)
|
||||
<option value="{{ $team->id }}">{{ $team->name }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:input wire:model="projectForm.title" label="Title" />
|
||||
<flux:input wire:model="projectForm.slug" label="Slug" placeholder="auto-generated" />
|
||||
<x-helpdesk::colour-select model="projectForm.color" label="Badge colour" :options="$colourOptions" />
|
||||
<div class="md:col-span-5">
|
||||
<form wire:submit.prevent="saveProject" class="grid gap-3 xl:grid-cols-12">
|
||||
<div class="xl:col-span-2">
|
||||
<flux:select wire:model="projectForm.company_id" label="Company">
|
||||
<option value="">Global</option>
|
||||
@foreach ($companies as $company)
|
||||
<option value="{{ $company->id }}">{{ $company->name }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</div>
|
||||
<div class="xl:col-span-2">
|
||||
<flux:select wire:model="projectForm.team_id" label="Team">
|
||||
<option value="">No team</option>
|
||||
@foreach ($teams as $team)
|
||||
<option value="{{ $team->id }}">{{ $team->name }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</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" />
|
||||
</div>
|
||||
<label
|
||||
class="md:col-span-5 flex items-center gap-2 text-sm font-semibold 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 xl:col-span-4">
|
||||
<input type="checkbox" wire:model="projectForm.is_default"
|
||||
class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500">
|
||||
Default project for new tickets
|
||||
</label>
|
||||
<div class="md:col-span-5 flex gap-2">
|
||||
<flux:button type="submit" variant="primary"
|
||||
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save project
|
||||
<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 project
|
||||
</flux:button>
|
||||
<flux:button type="button" wire:click="resetProjectForm" variant="ghost"
|
||||
class="!bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset
|
||||
<flux:button size="sm" type="button" wire:click="resetProjectForm" variant="ghost"
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
@@ -317,7 +346,7 @@
|
||||
<x-helpdesk::settings-section title="Kanban Boards"
|
||||
description="Create configurable workflow boards and choose which statuses appear as draggable columns.">
|
||||
<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.slug" label="Slug" placeholder="auto-generated" />
|
||||
<flux:input wire:model="boardForm.sort_order" label="Sort" type="number" min="0" />
|
||||
@@ -327,14 +356,14 @@
|
||||
<option value="{{ $owner->id }}">{{ $owner->name }}</option>
|
||||
@endforeach
|
||||
</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">
|
||||
Default board
|
||||
</label>
|
||||
<div class="md:col-span-2">
|
||||
<flux:textarea wire:model="boardForm.description" label="Description" rows="2" />
|
||||
</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
|
||||
columns</label>
|
||||
<div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
@@ -350,12 +379,12 @@
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:col-span-7 flex gap-2">
|
||||
<flux:button type="submit" variant="primary"
|
||||
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save board
|
||||
<div class="md:col-span-full flex gap-2">
|
||||
<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 board
|
||||
</flux:button>
|
||||
<flux:button type="button" wire:click="resetBoardForm" variant="ghost"
|
||||
class="!bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset
|
||||
<flux:button size="sm" type="button" wire:click="resetBoardForm" variant="ghost"
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
@@ -401,12 +430,12 @@
|
||||
<td class="px-4 py-3 text-right">
|
||||
<flux:button size="sm" variant="primary"
|
||||
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>
|
||||
<flux:button size="sm" variant="danger"
|
||||
wire:click="deleteBoard({{ $boardItem->id }})"
|
||||
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>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -426,7 +455,7 @@
|
||||
<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.">
|
||||
<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">
|
||||
<option value="">Global</option>
|
||||
@foreach ($companies as $company)
|
||||
@@ -444,16 +473,16 @@
|
||||
placeholder="git, github, gitlab" />
|
||||
<flux:input wire:model="repositoryForm.default_branch" label="Default branch" />
|
||||
<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"
|
||||
placeholder="git@github.com:company/repo.git" />
|
||||
</div>
|
||||
<div class="md:col-span-7 flex gap-2">
|
||||
<flux:button type="submit" variant="primary"
|
||||
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save
|
||||
<div class="md:col-span-full flex gap-2">
|
||||
<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
|
||||
repository</flux:button>
|
||||
<flux:button type="button" wire:click="resetRepositoryForm" variant="ghost"
|
||||
class="!bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset
|
||||
<flux:button size="sm" type="button" wire:click="resetRepositoryForm" variant="ghost"
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
@@ -467,7 +496,7 @@
|
||||
<x-helpdesk::settings-section title="Workflow messages"
|
||||
description="Define the professional email messages sent when tickets are raised, updated, replied to and closed.">
|
||||
<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:select wire:model="workflowForm.event" label="Event">
|
||||
<option value="ticket.created">Ticket raised</option>
|
||||
@@ -485,27 +514,27 @@
|
||||
placeholder="Optional comma-separated emails" />
|
||||
<flux:input wire:model="workflowForm.bcc" label="BCC"
|
||||
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">
|
||||
Active</label>
|
||||
<div class="md:col-span-7">
|
||||
<div class="md:col-span-full">
|
||||
<flux:input wire:model="workflowForm.subject" label="Subject"
|
||||
placeholder="Ticket @{{ ticket.reference }} has been raised" />
|
||||
</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"
|
||||
placeholder="Use tokens like @{{ ticket.reference }}, @{{ ticket.subject }}, @{{ ticket.status }}, @{{ requester.name }}, @{{ ticket.url }}" />
|
||||
</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"
|
||||
placeholder="Optional fallback text" />
|
||||
</div>
|
||||
<div class="md:col-span-7 flex gap-2">
|
||||
<flux:button type="submit" variant="primary"
|
||||
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save
|
||||
<div class="md:col-span-full flex gap-2">
|
||||
<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
|
||||
workflow message</flux:button>
|
||||
<flux:button type="button" wire:click="resetWorkflowTemplateForm" variant="ghost"
|
||||
class="!bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset
|
||||
<flux:button size="sm" type="button" wire:click="resetWorkflowTemplateForm" variant="ghost"
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
@@ -538,12 +567,12 @@
|
||||
<td class="px-4 py-3 text-right">
|
||||
<flux:button size="sm" variant="ghost"
|
||||
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>
|
||||
<flux:button size="sm" variant="danger"
|
||||
wire:click="deleteWorkflowTemplate({{ $template->id }})"
|
||||
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>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -564,7 +593,7 @@
|
||||
<x-helpdesk::settings-section title="Canned responses"
|
||||
description="Save professional reusable replies with macro tokens for ticket, requester, agent, status and URL values.">
|
||||
<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.slug" label="Slug" placeholder="auto-generated" />
|
||||
<flux:input wire:model="cannedForm.sort_order" label="Sort" type="number" min="0" />
|
||||
@@ -580,22 +609,22 @@
|
||||
<option value="{{ $team->id }}">{{ $team->name }}</option>
|
||||
@endforeach
|
||||
</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">
|
||||
Active</label>
|
||||
<div class="md:col-span-7">
|
||||
<div class="md:col-span-full">
|
||||
<flux:input wire:model="cannedForm.subject" label="Optional subject" />
|
||||
</div>
|
||||
<div class="md:col-span-7">
|
||||
<div class="md:col-span-full">
|
||||
<flux:textarea wire:model="cannedForm.body" label="Reply body" rows="6"
|
||||
placeholder="Hello @{{ requester.name }}, Your ticket @{{ ticket.reference }} has been reviewed." />
|
||||
</div>
|
||||
<div class="md:col-span-7 flex gap-2">
|
||||
<flux:button type="submit" variant="primary"
|
||||
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save canned
|
||||
<div class="md:col-span-full flex gap-2">
|
||||
<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 canned
|
||||
response</flux:button>
|
||||
<flux:button type="button" wire:click="resetCannedResponseForm" variant="ghost"
|
||||
class="!bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset
|
||||
<flux:button size="sm" type="button" wire:click="resetCannedResponseForm" variant="ghost"
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
@@ -617,12 +646,12 @@
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<flux:button size="sm" variant="primary"
|
||||
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 size="sm" variant="danger"
|
||||
wire:click="deleteCannedResponse({{ $response->id }})"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -639,7 +668,7 @@
|
||||
<x-helpdesk::settings-section title="SLA policies"
|
||||
description="Define first response and resolution targets used for breach indicators, reporting and ticket cards.">
|
||||
<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.slug" label="Slug" placeholder="auto-generated" />
|
||||
<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
|
||||
type="checkbox" wire:model="slaForm.is_active" class="rounded border-zinc-300">
|
||||
Active</label>
|
||||
<div class="md:col-span-7 flex gap-2">
|
||||
<flux:button type="submit" variant="primary"
|
||||
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save SLA
|
||||
<div class="md:col-span-full flex gap-2">
|
||||
<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 SLA
|
||||
policy</flux:button>
|
||||
<flux:button type="button" wire:click="resetSlaPolicyForm" variant="ghost"
|
||||
class="!bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset
|
||||
<flux:button size="sm" type="button" wire:click="resetSlaPolicyForm" variant="ghost"
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
@@ -702,12 +731,12 @@
|
||||
<td class="px-4 py-3 text-right">
|
||||
<flux:button size="sm" variant="primary"
|
||||
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>
|
||||
<flux:button size="sm" variant="danger"
|
||||
wire:click="deleteSlaPolicy({{ $policy->id }})"
|
||||
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>
|
||||
</td>
|
||||
</tr>@empty<tr>
|
||||
@@ -725,7 +754,7 @@
|
||||
<x-helpdesk::settings-section title="Email automation rules"
|
||||
description="Match incoming support messages and automatically assign priority, category, team, project, agent and status.">
|
||||
<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.slug" label="Slug" placeholder="auto-generated" />
|
||||
<flux:input wire:model="automationForm.sort_order" label="Sort" type="number"
|
||||
@@ -763,15 +792,15 @@
|
||||
<option value="{{ $status->id }}">{{ $status->name }}</option>
|
||||
@endforeach
|
||||
</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">
|
||||
Active</label>
|
||||
<div class="md:col-span-7 flex gap-2">
|
||||
<flux:button type="submit" variant="primary"
|
||||
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save
|
||||
<div class="md:col-span-full flex gap-2">
|
||||
<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
|
||||
automation rule</flux:button>
|
||||
<flux:button type="button" wire:click="resetAutomationRuleForm" variant="ghost"
|
||||
class="!bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset
|
||||
<flux:button size="sm" type="button" wire:click="resetAutomationRuleForm" variant="ghost"
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
@@ -785,12 +814,12 @@
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<flux:button size="sm" variant="primary"
|
||||
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 size="sm" variant="danger"
|
||||
wire:click="deleteAutomationRule({{ $rule->id }})"
|
||||
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>
|
||||
</div>
|
||||
</div>@empty<div
|
||||
@@ -805,7 +834,7 @@
|
||||
<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.">
|
||||
<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.slug" label="Slug" />
|
||||
<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"
|
||||
wire:model="checklistTemplateForm.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="checklistTemplateForm.items_text"
|
||||
label="Checklist items, one per line" rows="6" />
|
||||
</div>
|
||||
<div class="md:col-span-7 flex gap-2">
|
||||
<flux:button type="submit" variant="primary"
|
||||
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save
|
||||
<div class="md:col-span-full flex gap-2">
|
||||
<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
|
||||
checklist</flux:button>
|
||||
<flux:button type="button" wire:click="resetChecklistTemplateForm" variant="ghost"
|
||||
class="!bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset
|
||||
<flux:button size="sm" type="button" wire:click="resetChecklistTemplateForm" variant="ghost"
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
@@ -845,12 +874,12 @@
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<flux:button size="sm" variant="primary"
|
||||
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 size="sm" variant="danger"
|
||||
wire:click="deleteChecklistTemplate({{ $template->id }})"
|
||||
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>
|
||||
</div>
|
||||
</div>@empty<div
|
||||
@@ -865,16 +894,20 @@
|
||||
<x-helpdesk::settings-section title="Ticket tags"
|
||||
description="Maintain visible ticket labels for search, triage and Kanban card context.">
|
||||
<x-slot:form>
|
||||
<form wire:submit.prevent="saveTag" class="grid gap-4 md:grid-cols-5">
|
||||
<flux:input wire:model="tagForm.name" label="Name" />
|
||||
<flux:input wire:model="tagForm.slug" label="Slug" />
|
||||
<x-helpdesk::colour-select model="tagForm.color" label="Colour" :options="$colourOptions" />
|
||||
<div class="md:col-span-5 flex gap-2">
|
||||
<flux:button type="submit" variant="primary"
|
||||
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save tag
|
||||
<form wire:submit.prevent="saveTag" class="grid gap-3 xl:grid-cols-12">
|
||||
<div class="xl:col-span-4">
|
||||
<flux:input wire:model="tagForm.name" label="Name" />
|
||||
</div>
|
||||
<div class="xl:col-span-4">
|
||||
<flux:input wire:model="tagForm.slug" label="Slug" />
|
||||
</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 type="button" wire:click="resetTagForm" variant="ghost"
|
||||
class="!bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset
|
||||
<flux:button size="sm" type="button" wire:click="resetTagForm" variant="ghost"
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
@@ -887,11 +920,11 @@
|
||||
style="background-color: {{ \Modules\Helpdesk\Support\Color::css($tag->color) }}">{{ $tag->name }}</span>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<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 size="sm" variant="danger" wire:click="deleteTag({{ $tag->id }})"
|
||||
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>
|
||||
</div>
|
||||
</div>@empty<div
|
||||
@@ -906,7 +939,7 @@
|
||||
<x-helpdesk::settings-section title="Products and services"
|
||||
description="Link tickets to products, services, releases or builds and report support cost/time by product.">
|
||||
<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.slug" label="Slug" />
|
||||
<flux:input wire:model="productForm.type" label="Type" />
|
||||
@@ -914,15 +947,15 @@
|
||||
<flux:input wire:model="productForm.release_channel" label="Release/build" /><label
|
||||
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>
|
||||
<div class="md:col-span-7">
|
||||
<div class="md:col-span-full">
|
||||
<flux:textarea wire:model="productForm.description" label="Description" rows="3" />
|
||||
</div>
|
||||
<div class="md:col-span-7 flex gap-2">
|
||||
<flux:button type="submit" variant="primary"
|
||||
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save
|
||||
<div class="md:col-span-full flex gap-2">
|
||||
<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
|
||||
product/service</flux:button>
|
||||
<flux:button type="button" wire:click="resetProductForm" variant="ghost"
|
||||
class="!bg-amber-500 !text-white hover:!bg-amber-600 focus:!ring-amber-300/60">Reset
|
||||
<flux:button size="sm" type="button" wire:click="resetProductForm" variant="ghost"
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
@@ -936,12 +969,12 @@
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<flux:button size="sm" variant="primary"
|
||||
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 size="sm" variant="danger"
|
||||
wire:click="deleteProduct({{ $product->id }})"
|
||||
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>
|
||||
</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">
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
@include('helpdesk::partials.page-header', [
|
||||
@@ -7,7 +14,43 @@
|
||||
'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="body"], [data-helpdesk-editor-model="body"]');
|
||||
|
||||
const target = document.querySelector('textarea[wire\\:model\\.defer="body"], textarea[name="body"], #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="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>
|
||||
@@ -23,7 +66,7 @@
|
||||
Subject <span class="helpdesk-required-star">*</span>
|
||||
</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"
|
||||
data-dusk="ticket-subject" dusk="ticket-subject" autocomplete="off" />
|
||||
|
||||
@@ -221,10 +264,10 @@
|
||||
|
||||
<div class="lg:col-span-3 xl:col-span-6">
|
||||
<x-helpdesk::html-editor
|
||||
model="body"
|
||||
label="Details"
|
||||
rows="12"
|
||||
placeholder="Describe the issue, expected outcome, steps to reproduce and any useful context..."
|
||||
wire:model="body"
|
||||
editor-dusk="ticket-body-editor"
|
||||
/>
|
||||
|
||||
@@ -235,7 +278,16 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<label class="block text-sm font-semibold text-zinc-200" for="attachments">
|
||||
@@ -243,7 +295,7 @@
|
||||
</label>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -254,12 +306,38 @@
|
||||
</label>
|
||||
</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"
|
||||
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">
|
||||
<i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true"></i>
|
||||
Uploading attachments...
|
||||
<input id="attachments" type="file" wire:model="attachments" multiple accept="{{ $helpdeskAttachmentAccept }}" class="sr-only"
|
||||
x-ref="input" x-on:change="refreshSelectedFiles()">
|
||||
|
||||
<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>
|
||||
|
||||
@error('attachments')
|
||||
@@ -273,25 +351,28 @@
|
||||
</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"
|
||||
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">
|
||||
<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 wire:target="clearForm">Clearing...</span>
|
||||
</button>
|
||||
|
||||
<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
|
||||
</a>
|
||||
|
||||
<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"
|
||||
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">
|
||||
<i class="fa-regular fa-floppy-disk" aria-hidden="true"></i>
|
||||
<span wire:loading.remove wire:target="save">Create Ticket</span>
|
||||
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,attachments" x-bind:disabled="helpdeskAttachmentsUploading" wire:dirty.class="ring-2 ring-amber-400/40">
|
||||
<i class="fa-regular fa-floppy-disk mr-2" aria-hidden="true"></i>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -299,17 +380,69 @@
|
||||
</form>
|
||||
<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 = () => {
|
||||
document.querySelectorAll('.helpdesk-ticket-form #attachments').forEach((input) => {
|
||||
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-editor-files-pasted', appendPastedFiles);
|
||||
|
||||
if (window.Livewire?.on) {
|
||||
window.Livewire.on('helpdesk-form-cleared', resetTicketCreateFiles);
|
||||
}
|
||||
})();
|
||||
</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">
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
@include('helpdesk::partials.page-header', [
|
||||
@@ -44,9 +51,9 @@
|
||||
Subject <span class="helpdesk-required-star">*</span>
|
||||
</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"
|
||||
data-dusk="ticket-edit-subject" autocomplete="off" />
|
||||
data-dusk="ticket-edit-subject" dusk="ticket-edit-subject" />
|
||||
|
||||
@error('subject')
|
||||
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
|
||||
@@ -284,8 +291,13 @@
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-3 xl:col-span-6">
|
||||
<x-helpdesk::html-editor dusk="ticket-edit-body-editor" wire:model="body" label="Details *"
|
||||
rows="10" placeholder="Update the ticket details..." />
|
||||
<x-helpdesk::html-editor
|
||||
model="body"
|
||||
label="Details *"
|
||||
rows="10"
|
||||
placeholder="Update the ticket details..."
|
||||
editor-dusk="ticket-edit-body-editor"
|
||||
/>
|
||||
|
||||
@error('body')
|
||||
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
|
||||
@@ -323,7 +335,7 @@
|
||||
|
||||
<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">
|
||||
|
||||
<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
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Helpdesk\Models\TicketTimeLog;
|
||||
@@ -15,6 +22,7 @@
|
||||
->get();
|
||||
@endphp
|
||||
|
||||
|
||||
<div data-dusk="helpdesk-ticket-show" dusk="helpdesk-ticket-show" class="hd-helpdesk-surface space-y-4">
|
||||
<div
|
||||
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 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">
|
||||
<i class="fa-solid fa-arrow-left mr-2" aria-hidden="true"></i>
|
||||
Back
|
||||
<i class="fa-solid fa-gauge-high mr-2" aria-hidden="true"></i>
|
||||
Dashboard
|
||||
</a>
|
||||
@can('update', $ticket)
|
||||
<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">
|
||||
<i class="fa-solid fa-paperclip" aria-hidden="true"></i>
|
||||
<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>
|
||||
</label>
|
||||
|
||||
@@ -163,30 +171,76 @@
|
||||
</form>
|
||||
@endcan
|
||||
|
||||
@if ($ticket->attachments->isEmpty())
|
||||
@php
|
||||
$visibleAttachments = $ticket->attachments->filter(fn ($attachment) => ! $attachment->isDeletedAttachment());
|
||||
@endphp
|
||||
|
||||
@if ($visibleAttachments->isEmpty())
|
||||
<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">
|
||||
No supporting files have been attached yet.
|
||||
</div>
|
||||
@else
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
@foreach ($ticket->attachments as $attachment)
|
||||
<a href="{{ route('helpdesk.attachments.download', $attachment->id) }}" target="_blank"
|
||||
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">
|
||||
<span class="min-w-0">
|
||||
<span class="block truncate font-bold text-zinc-900 dark:text-zinc-100">
|
||||
{{ $attachment->original_name ?? ($attachment->original_filename ?? ($attachment->filename ?? ($attachment->file_name ?? basename($attachment->path ?? ($attachment->file_path ?? ''))))) }}
|
||||
</span>
|
||||
<span class="block text-[0.7rem] text-zinc-500">
|
||||
{{ Number::fileSize($attachment->size ?? ($attachment->size_bytes ?? 0)) }}
|
||||
</span>
|
||||
</span>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
@foreach ($visibleAttachments as $attachment)
|
||||
<article
|
||||
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">
|
||||
@if ($attachment->isImage())
|
||||
<a href="{{ route('helpdesk.attachments.preview', $attachment->id) }}"
|
||||
class="block aspect-video overflow-hidden bg-zinc-100 dark:bg-zinc-900">
|
||||
<img src="{{ route('helpdesk.attachments.preview', $attachment->id) }}" alt="{{ $attachment->displayName() }}"
|
||||
class="h-full w-full object-cover transition group-hover/file:scale-[1.02]">
|
||||
</a>
|
||||
@endif
|
||||
|
||||
<span
|
||||
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">
|
||||
<i class="fa-solid fa-download text-xs" aria-hidden="true"></i>
|
||||
</span>
|
||||
</a>
|
||||
<div class="flex items-start gap-3 p-3">
|
||||
<span
|
||||
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">
|
||||
<i class="{{ $attachment->icon() }} text-sm" aria-hidden="true"></i>
|
||||
</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
|
||||
</div>
|
||||
@endif
|
||||
@@ -397,7 +451,7 @@
|
||||
<label for="ticket-comment-subject"
|
||||
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') }}"
|
||||
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"
|
||||
data-helpdesk-comment-subject>
|
||||
</div>
|
||||
@@ -427,11 +481,11 @@
|
||||
|
||||
<div id="ticket-comment-body-editor" dusk="comment-body" data-dusk="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"
|
||||
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>
|
||||
|
||||
<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">
|
||||
<i class="fa-solid fa-paperclip mr-2 text-xs" aria-hidden="true"></i>
|
||||
<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>
|
||||
</label>
|
||||
</div>
|
||||
@@ -480,7 +534,7 @@
|
||||
|
||||
@if ($canSeeInternalNotes)
|
||||
<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">
|
||||
<span>
|
||||
<span class="block text-sm font-extrabold">Public reply</span>
|
||||
@@ -488,7 +542,7 @@
|
||||
</span>
|
||||
</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">
|
||||
<span>
|
||||
<span class="block text-sm font-extrabold">Internal note</span>
|
||||
@@ -589,6 +643,13 @@
|
||||
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]');
|
||||
|
||||
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]');
|
||||
|
||||
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() {
|
||||
const commentCards = Array.from(document.querySelectorAll('[data-helpdesk-comment-card]'));
|
||||
const expandButton = document.querySelector('[data-helpdesk-expand-comments]');
|
||||
@@ -808,10 +935,82 @@
|
||||
return span.innerHTML;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
function initialiseHelpdeskTicketShowInteractions() {
|
||||
document.querySelectorAll('[data-helpdesk-rich-editor]').forEach((editor) => syncRichEditor(editor));
|
||||
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) {
|
||||
const start = textarea.selectionStart || 0;
|
||||
@@ -827,7 +1026,7 @@
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<aside class="space-y-4">
|
||||
<aside class="space-y-4" data-helpdesk-sidebar-cards>
|
||||
<div
|
||||
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">
|
||||
@@ -1096,24 +1295,80 @@
|
||||
<i class="fa-solid fa-code-branch text-lg" aria-hidden="true"></i>
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-lg font-black uppercase tracking-wide">Git commits</h2>
|
||||
<p class="text-xs font-semibold text-violet-100">Code commits linked to this ticket</p>
|
||||
<h2 class="text-lg font-black uppercase tracking-wide">Git activity</h2>
|
||||
<p class="text-xs font-semibold text-violet-100">Branches and commits linked to this ticket</p>
|
||||
</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">
|
||||
@forelse ($ticket->gitCommits as $commit)
|
||||
<a href="{{ $commit->url ?: '#' }}" target="_blank"
|
||||
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 }} ·
|
||||
{{ $commit->repository?->name }}</div>
|
||||
<div class="font-mono text-[0.68rem] text-zinc-500">
|
||||
{{ $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">
|
||||
{{ $commit->message }}</div>
|
||||
{{ $commit->message }}
|
||||
</div>
|
||||
</a>
|
||||
@empty
|
||||
<div
|
||||
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>
|
||||
@endforelse
|
||||
</div>
|
||||
@@ -1179,3 +1434,45 @@
|
||||
</div>
|
||||
|
||||
{{-- 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])
|
||||
|
||||
<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>
|
||||
<input id="reply-subject-{{ $comment->id }}" name="subject"
|
||||
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">
|
||||
</div>
|
||||
<label for="reply-body-{{ $comment->id }}"
|
||||
@@ -214,7 +222,7 @@
|
||||
<button type="button" data-helpdesk-wrap="<code>" data-helpdesk-wrap-end="</code>"
|
||||
class="rounded-lg border px-2 py-1 text-xs">Code</button>
|
||||
</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
|
||||
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>
|
||||
@@ -225,7 +233,7 @@
|
||||
@can('update', $currentTicket)
|
||||
@if ($canSeeInternalNotes)
|
||||
<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">
|
||||
<span>
|
||||
<span class="block text-sm font-extrabold">Public reply</span>
|
||||
@@ -233,7 +241,7 @@
|
||||
</span>
|
||||
</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">
|
||||
<span>
|
||||
<span class="block text-sm font-extrabold">Internal note</span>
|
||||
@@ -285,7 +293,7 @@
|
||||
<label data-helpdesk-file-button>
|
||||
<i class="fa-solid fa-paperclip" aria-hidden="true"></i>
|
||||
<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>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -13,3 +13,6 @@ Route::get('/status', static fn (): array => [
|
||||
Route::post('/git/webhook/{repository}', GitWebhookController::class)
|
||||
->whereNumber('repository')
|
||||
->name('git.webhook');
|
||||
|
||||
Route::post('/git/gitea/webhook', [GitWebhookController::class, 'gitea'])
|
||||
->name('git.gitea.webhook');
|
||||
|
||||
@@ -40,5 +40,11 @@ Route::get('/tickets/{ticket}', TicketShow::class)->name('tickets.show');
|
||||
Route::get('/attachments/{attachment}/download', AttachmentDownloadController::class)
|
||||
->whereNumber('attachment')
|
||||
->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'])
|
||||
->name('tickets.time-logs.store');
|
||||
|
||||
@@ -5,41 +5,56 @@ declare(strict_types=1);
|
||||
namespace Modules\Helpdesk\Services;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
use Modules\Helpdesk\Models\Attachment;
|
||||
use Modules\Helpdesk\Models\Comment;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
|
||||
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
|
||||
{
|
||||
foreach ($files as $file) {
|
||||
if (! $file instanceof UploadedFile || ! $file->isValid()) {
|
||||
if (! $this->isSupportedUpload($file)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$disk = (string) config('helpdesk.disk', 'public');
|
||||
|
||||
$originalName = $file->getClientOriginalName();
|
||||
$mimeType = $file->getClientMimeType();
|
||||
$size = (int) ($file->getSize() ?: 0);
|
||||
|
||||
$originalName = $this->originalName($file);
|
||||
$mimeType = $this->mimeType($file);
|
||||
$size = $this->size($file);
|
||||
$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);
|
||||
}
|
||||
|
||||
Attachment::query()->create([
|
||||
'ticket_id' => $ticket->id,
|
||||
'comment_id' => $comment?->id,
|
||||
'user_id' => $userId,
|
||||
'disk' => $disk,
|
||||
'path' => $path,
|
||||
'original_name' => $originalName,
|
||||
'mime_type' => $mimeType,
|
||||
'size' => $size,
|
||||
]);
|
||||
Attachment::query()->create($this->attachmentAttributes(
|
||||
ticket: $ticket,
|
||||
comment: $comment,
|
||||
userId: $userId,
|
||||
disk: $disk,
|
||||
path: $path,
|
||||
originalName: $originalName,
|
||||
mimeType: $mimeType,
|
||||
size: $size,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,4 +66,123 @@ final class AttachmentService
|
||||
Storage::disk($attachment->disk)->delete($attachment->path);
|
||||
$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
|
||||
|
||||
/**
|
||||
* GitWebhookIngestionService.php
|
||||
*
|
||||
* Normalises Git provider webhooks and links commits to Helpdesk tickets.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Services;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Helpdesk\Models\GitCommit;
|
||||
use Modules\Helpdesk\Models\GitRepository;
|
||||
@@ -14,7 +23,10 @@ use Throwable;
|
||||
|
||||
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.
|
||||
@@ -39,14 +51,14 @@ final class GitWebhookIngestionService
|
||||
return $result;
|
||||
}
|
||||
|
||||
$branch = $this->branchFromRef($payload['ref'] ?? null);
|
||||
$branch = $this->branchFromPayload($payload);
|
||||
$commits = $this->commitsFromPayload($payload);
|
||||
$commitCount = 0;
|
||||
$linkedCount = 0;
|
||||
$ignoredCount = 0;
|
||||
|
||||
foreach ($commits as $commit) {
|
||||
$hash = $this->stringOrNull($commit['id'] ?? $commit['sha'] ?? null);
|
||||
$hash = $this->commitHash($commit);
|
||||
|
||||
if ($hash === null) {
|
||||
$ignoredCount++;
|
||||
@@ -54,13 +66,19 @@ final class GitWebhookIngestionService
|
||||
continue;
|
||||
}
|
||||
|
||||
$message = $this->stringOrNull($commit['message'] ?? null) ?? '';
|
||||
$url = $this->stringOrNull($commit['url'] ?? null);
|
||||
$message = $this->stringOrNull($commit['message'] ?? $commit['title'] ?? $commit['summary'] ?? null) ?? '';
|
||||
$url = $this->commitUrl($commit);
|
||||
$author = is_array($commit['author'] ?? null) ? $commit['author'] : [];
|
||||
$committer = is_array($commit['committer'] ?? null) ? $commit['committer'] : [];
|
||||
$authorName = $this->stringOrNull($author['name'] ?? $committer['name'] ?? null);
|
||||
$authorEmail = $this->stringOrNull($author['email'] ?? $committer['email'] ?? null);
|
||||
$committedAt = $this->parseTimestamp($commit['timestamp'] ?? $commit['committed_at'] ?? null);
|
||||
$authorName = $this->stringOrNull($author['name'] ?? $author['username'] ?? $committer['name'] ?? null);
|
||||
$authorEmail = $this->stringOrNull($author['email'] ?? $author['raw'] ?? $committer['email'] ?? null);
|
||||
$committedAt = $this->parseTimestamp(
|
||||
$commit['timestamp']
|
||||
?? $commit['committed_at']
|
||||
?? $commit['date']
|
||||
?? Arr::get($commit, 'author.date')
|
||||
?? null,
|
||||
);
|
||||
$ticket = $this->ticketForCommit($repository, $message, $branch, $url);
|
||||
|
||||
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
|
||||
{
|
||||
@@ -142,7 +160,7 @@ final class GitWebhookIngestionService
|
||||
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
|
||||
{
|
||||
$normalised = strtolower((string) $event);
|
||||
$normalisedProvider = Str::lower(trim($provider));
|
||||
$normalisedEvent = Str::of((string) $event)->lower()->replace(['_', '-'], ' ')->squish()->value();
|
||||
|
||||
return match ($provider) {
|
||||
'github' => $normalised === 'push',
|
||||
'gitlab' => in_array($normalised, ['push hook', 'push'], true),
|
||||
default => $normalised === '' || $normalised === 'push',
|
||||
return match ($normalisedProvider) {
|
||||
'github' => in_array($normalisedEvent, ['push', ''], true),
|
||||
'gitlab' => in_array($normalisedEvent, ['push hook', 'push', ''], true),
|
||||
'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) {
|
||||
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
|
||||
{
|
||||
$commits = $payload['commits'] ?? [];
|
||||
$commits = $payload['commits'] ?? null;
|
||||
|
||||
if (! is_array($commits)) {
|
||||
return [];
|
||||
if (is_array($commits) && $commits !== []) {
|
||||
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
|
||||
@@ -194,17 +262,19 @@ final class GitWebhookIngestionService
|
||||
return null;
|
||||
}
|
||||
|
||||
$query = Ticket::query()->where('reference', $reference);
|
||||
$baseQuery = Ticket::query()->where('reference', $reference);
|
||||
|
||||
if ($repository->company_id !== null) {
|
||||
$query->where('company_id', $repository->company_id);
|
||||
$baseQuery->where('company_id', $repository->company_id);
|
||||
}
|
||||
|
||||
if ($repository->project_id !== null) {
|
||||
$query->where('project_id', $repository->project_id);
|
||||
}
|
||||
$strictProjectTicket = (clone $baseQuery)
|
||||
->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
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace Modules\Helpdesk\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\Helpdesk\Models\Comment;
|
||||
use Modules\Helpdesk\Models\SlaPolicy;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
@@ -72,14 +73,19 @@ final class SlaService
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var Comment|null $firstPublicComment */
|
||||
$firstPublicComment = Comment::query()
|
||||
/** @var \Illuminate\Database\Eloquent\Builder<Comment> $query */
|
||||
$query = Comment::query()
|
||||
->where('ticket_id', $ticket->id)
|
||||
->where('is_internal', false)
|
||||
->whereNull('tombstoned_at')
|
||||
->with('user')
|
||||
->oldest('created_at')
|
||||
->first();
|
||||
->oldest('created_at');
|
||||
|
||||
if (Schema::hasColumn('helpdesk_comments', 'tombstoned_at')) {
|
||||
$query->whereNull('tombstoned_at');
|
||||
}
|
||||
|
||||
/** @var Comment|null $firstPublicComment */
|
||||
$firstPublicComment = $query->first();
|
||||
|
||||
if ($firstPublicComment === null) {
|
||||
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 Modules\Helpdesk\Models\Comment;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
|
||||
use Tests\DuskTestCase;
|
||||
|
||||
@@ -23,23 +24,36 @@ final class HelpdeskTicketBrowserTest extends DuskTestCase
|
||||
$this->browse(function (Browser $browser) use ($user, $fixtures, $subject): void {
|
||||
$browser->loginAs($user)
|
||||
->visitRoute('helpdesk.tickets.create')
|
||||
->waitFor('@helpdesk-ticket-create')
|
||||
->waitFor('@helpdesk-ticket-create', 10)
|
||||
->assertSee('Create ticket')
|
||||
->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, 'priority_id', $fixtures['high']->id);
|
||||
$this->setHelpdeskSelect($browser, 'category_id', $fixtures['category']->id);
|
||||
$this->setHelpdeskSelect($browser, 'team_id', $fixtures['team']->id);
|
||||
$this->setHelpdeskSelect($browser, 'project_id', $fixtures['project']->id);
|
||||
|
||||
$browser->pause(500)
|
||||
$browser->waitFor('@ticket-create-submit', 10)
|
||||
->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('High')
|
||||
->assertSee('Support');
|
||||
->assertSee('This browser ticket proves the form saves correctly.');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
$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\Hash;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Helpdesk\Models\Category;
|
||||
use Modules\Helpdesk\Models\GitRepository;
|
||||
use Modules\Helpdesk\Models\KanbanBoard;
|
||||
@@ -51,7 +52,7 @@ trait BuildsHelpdeskFixtures
|
||||
{
|
||||
return Status::query()->create(array_merge([
|
||||
'name' => 'Open',
|
||||
'slug' => fake()->unique()->slug(2),
|
||||
'slug' => 'test-' . Str::uuid()->toString(),
|
||||
'color' => 'blue',
|
||||
'sort_order' => 10,
|
||||
'is_closed' => false,
|
||||
@@ -62,7 +63,7 @@ trait BuildsHelpdeskFixtures
|
||||
{
|
||||
return $this->ticketStatus(array_merge([
|
||||
'name' => 'Closed',
|
||||
'slug' => fake()->unique()->slug(2),
|
||||
'slug' => 'test-' . Str::uuid()->toString(),
|
||||
'color' => 'green',
|
||||
'sort_order' => 99,
|
||||
'is_closed' => true,
|
||||
@@ -73,7 +74,7 @@ trait BuildsHelpdeskFixtures
|
||||
{
|
||||
return Priority::query()->create(array_merge([
|
||||
'name' => 'Normal',
|
||||
'slug' => fake()->unique()->slug(2),
|
||||
'slug' => 'test-' . Str::uuid()->toString(),
|
||||
'color' => 'blue',
|
||||
'sort_order' => 10,
|
||||
], $attributes));
|
||||
@@ -84,7 +85,7 @@ trait BuildsHelpdeskFixtures
|
||||
return Category::query()->create(array_merge([
|
||||
'company_id' => null,
|
||||
'name' => 'Support',
|
||||
'slug' => fake()->unique()->slug(2),
|
||||
'slug' => 'test-' . Str::uuid()->toString(),
|
||||
'color' => 'blue',
|
||||
'description' => 'Support requests',
|
||||
], $attributes));
|
||||
@@ -95,7 +96,7 @@ trait BuildsHelpdeskFixtures
|
||||
return Team::query()->create(array_merge([
|
||||
'company_id' => null,
|
||||
'name' => 'Support Team',
|
||||
'slug' => fake()->unique()->slug(2),
|
||||
'slug' => 'test-' . Str::uuid()->toString(),
|
||||
'color' => 'indigo',
|
||||
'description' => 'Primary support team',
|
||||
], $attributes));
|
||||
@@ -107,7 +108,7 @@ trait BuildsHelpdeskFixtures
|
||||
'company_id' => null,
|
||||
'team_id' => null,
|
||||
'title' => 'WMS Project',
|
||||
'slug' => fake()->unique()->slug(2),
|
||||
'slug' => 'test-' . Str::uuid()->toString(),
|
||||
'color' => 'blue',
|
||||
'description' => 'Warehouse platform project',
|
||||
], $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
|
||||
{
|
||||
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,
|
||||
]);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
/**
|
||||
* The ticket create editor must bind the details body through Livewire
|
||||
* without eager live validation while the user is typing.
|
||||
* The ticket create editor must not send Livewire requests while 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'));
|
||||
|
||||
$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->assertStringContainsString('submit', $view);
|
||||
$this->assertTrue(
|
||||
str_contains($view, 'Livewire')
|
||||
|| str_contains($view, '$wire')
|
||||
|| str_contains($view, '@this')
|
||||
|| str_contains($view, 'wire:model="body"'),
|
||||
'Ticket create editor must keep the body field synchronised with Livewire before validation.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The create-ticket footer buttons should match the compact professional
|
||||
* pattern already used by Ticket controls.
|
||||
*/
|
||||
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
Reference in New Issue
Block a user