diff --git a/Modules/Documentation/Http/Controllers/DocumentationController.php b/Modules/Documentation/Http/Controllers/DocumentationController.php index 86927fb..c2f88c5 100644 --- a/Modules/Documentation/Http/Controllers/DocumentationController.php +++ b/Modules/Documentation/Http/Controllers/DocumentationController.php @@ -49,11 +49,42 @@ class DocumentationController extends Controller 'published' => DocumentationDocument::query()->published()->count(), 'drafts' => DocumentationDocument::query()->where('status', DocumentationDocument::STATUS_DRAFT)->count(), 'categories' => DocumentationCategory::query()->count(), + 'archived' => DocumentationDocument::onlyTrashed()->count(), ], 'statuses' => config('documentation.statuses', []), ]); } + /** + * Display soft-deleted documents. + */ + public function archived(Request $request): View + { + $query = DocumentationDocument::onlyTrashed() + ->with(['category', 'attachments']) + ->latest('deleted_at'); + + if ($request->filled('category')) { + $query->where('documentation_category_id', $request->integer('category')); + } + + if ($request->filled('search')) { + $search = '%'.$request->string('search')->trim()->toString().'%'; + + $query->where(function ($builder) use ($search): void { + $builder->where('title', 'like', $search) + ->orWhere('summary', 'like', $search) + ->orWhere('reference', 'like', $search) + ->orWhere('body', 'like', $search); + }); + } + + return view('documentation::documents.archived', [ + 'documents' => $query->paginate(15)->withQueryString(), + 'categories' => DocumentationCategory::query()->ordered()->get(), + ]); + } + /** * Show the create document form. */ @@ -146,11 +177,55 @@ class DocumentationController extends Controller } /** - * Remove a document. + * Archive a document using Laravel soft deletes. + * + * Stored files and attachment records are deliberately retained. They are + * only removed when an archived document is permanently deleted. */ - public function destroy(DocumentationDocument $document): RedirectResponse + public function destroy(Request $request, DocumentationDocument $document): RedirectResponse { - $document->load('attachments'); + $document->forceFill([ + 'status' => DocumentationDocument::STATUS_ARCHIVED, + 'updated_by' => $request->user()?->id, + ])->save(); + + $document->delete(); + + return redirect() + ->route('documentation.documents.index') + ->with('status', 'Document archived successfully.'); + } + + /** + * Restore a previously archived document. + */ + public function restore(Request $request, int $document): RedirectResponse + { + $document = DocumentationDocument::onlyTrashed()->findOrFail($document); + + $document->restore(); + + // Restored documents return to Draft so they cannot unexpectedly + // become publicly/operationally published again. + $document->forceFill([ + 'status' => DocumentationDocument::STATUS_DRAFT, + 'updated_by' => $request->user()?->id, + ])->save(); + + return redirect() + ->route('documentation.documents.archived') + ->with('status', 'Document restored as a draft.'); + } + + /** + * Permanently delete an archived document and all stored files. + */ + public function forceDestroy(int $document): RedirectResponse + { + $document = DocumentationDocument::onlyTrashed() + ->with('attachments') + ->findOrFail($document); + $this->deleteAttachment($document); foreach ($document->attachments as $attachment) { @@ -158,9 +233,11 @@ class DocumentationController extends Controller $attachment->delete(); } - $document->delete(); + $document->forceDelete(); - return redirect()->route('documentation.documents.index')->with('status', 'Document archived successfully.'); + return redirect() + ->route('documentation.documents.archived') + ->with('status', 'Document permanently deleted.'); } /** diff --git a/Modules/Documentation/Resources/views/documents/archived.blade.php b/Modules/Documentation/Resources/views/documents/archived.blade.php new file mode 100644 index 0000000..2b8e17f --- /dev/null +++ b/Modules/Documentation/Resources/views/documents/archived.blade.php @@ -0,0 +1,120 @@ +@extends('documentation::layouts.master') + +@section('title', 'Archived Documents') + +@section('content') +
+ @include('documentation::partials.hero', [ + 'title' => 'Archived documents', + 'subtitle' => + 'Soft-deleted documents are retained here with their files and attachments until permanently deleted.', + 'actions' => [ + [ + 'label' => 'Active documents', + 'url' => route('documentation.documents.index'), + 'class' => 'primary', + ], + ], + ]) + + @if (session('status')) +
{{ session('status') }}
+ @endif + +
+
+
+ Archive +
+ Restore documents or permanently remove them and their stored files. +
+
+ {{ $documents->total() }} archived +
+ +
+
+ + + + + + + @if (request()->filled('search') || request()->filled('category')) + Clear + @endif +
+ + + + + + + + + + + + + + @forelse ($documents as $document) + + + + + + + + + @empty + + + + @endforelse + +
DocumentCategoryVersionArchivedFilesActions
+ {{ $document->title }} +
+ {{ $document->reference ?: 'No reference' }} ยท + {{ $document->summary ?: 'No summary' }} +
+
{{ $document->category?->name ?: 'Uncategorised' }}{{ $document->version }}{{ $document->deleted_at?->format('d M Y H:i') }} + {{ ($document->attachment_name ? 1 : 0) + $document->attachments->count() }} + +
+
+ @csrf + @method('PATCH') + +
+ +
+ @csrf + @method('DELETE') + +
+
+
No archived documents found.
+ +
+ {{ $documents->links() }} +
+
+
+
+@endsection diff --git a/Modules/Documentation/Resources/views/documents/edit.blade.php b/Modules/Documentation/Resources/views/documents/edit.blade.php index d706f58..a4bfe26 100644 --- a/Modules/Documentation/Resources/views/documents/edit.blade.php +++ b/Modules/Documentation/Resources/views/documents/edit.blade.php @@ -18,6 +18,16 @@ +
+
+ @csrf + @method('DELETE') + +
+
+ @foreach ($document->attachments as $attachment)
{{ $stats['published'] }}Published
{{ $stats['drafts'] }}Drafts
{{ $stats['categories'] }}Categories
+
{{ $stats['archived'] }}Archived
@if ($featuredDocuments->isNotEmpty()) @@ -40,7 +41,12 @@
@@ -88,8 +94,19 @@ {{ $document->version }} {{ $document->updated_at?->format('d M Y H:i') }} - Edit + +
+ Edit + + @csrf + @method('DELETE') + + +
+ @empty diff --git a/Modules/Documentation/Resources/views/documents/show.blade.php b/Modules/Documentation/Resources/views/documents/show.blade.php index 7153e0e..a6f2248 100644 --- a/Modules/Documentation/Resources/views/documents/show.blade.php +++ b/Modules/Documentation/Resources/views/documents/show.blade.php @@ -23,6 +23,20 @@ ], ]) + @if (session('status')) +
{{ session('status') }}
+ @endif + +
+
+ @csrf + @method('DELETE') + +
+
+
diff --git a/Modules/Documentation/Routes/web.php b/Modules/Documentation/Routes/web.php index 06c6080..e74709a 100644 --- a/Modules/Documentation/Routes/web.php +++ b/Modules/Documentation/Routes/web.php @@ -13,6 +13,13 @@ Route::middleware(['web', 'auth']) Route::get('/', [DocumentationController::class, 'index'])->name('index'); Route::get('/documents', [DocumentationController::class, 'index'])->name('documents.index'); + Route::get('/documents/archived', [DocumentationController::class, 'archived'])->name('documents.archived'); + Route::patch('/documents/{document}/restore', [DocumentationController::class, 'restore']) + ->whereNumber('document') + ->name('documents.restore'); + Route::delete('/documents/{document}/force', [DocumentationController::class, 'forceDestroy']) + ->whereNumber('document') + ->name('documents.force-destroy'); Route::get('/documents/create', [DocumentationController::class, 'create'])->name('documents.create'); Route::post('/documents', [DocumentationController::class, 'store'])->name('documents.store'); Route::get('/documents/{document}', [DocumentationController::class, 'show'])->name('documents.show'); diff --git a/Modules/Documentation/Tests/Feature/DocumentationArchiveTest.php b/Modules/Documentation/Tests/Feature/DocumentationArchiveTest.php new file mode 100644 index 0000000..460c3eb --- /dev/null +++ b/Modules/Documentation/Tests/Feature/DocumentationArchiveTest.php @@ -0,0 +1,107 @@ +create(); + $document = DocumentationDocument::query()->create([ + 'title' => 'Archive me', + 'slug' => 'archive-me', + 'status' => DocumentationDocument::STATUS_PUBLISHED, + 'version' => '1.0.0', + 'attachment_disk' => 'public', + 'attachment_path' => 'documentation/primary.pdf', + 'attachment_name' => 'primary.pdf', + ]); + + Storage::disk('public')->put('documentation/primary.pdf', 'primary'); + + $attachment = $document->attachments()->create([ + 'disk' => 'public', + 'path' => 'documentation/support.pdf', + 'original_name' => 'support.pdf', + ]); + Storage::disk('public')->put($attachment->path, 'support'); + + $this->actingAs($user) + ->delete(route('documentation.documents.destroy', $document)) + ->assertRedirect(route('documentation.documents.index')); + + $this->assertSoftDeleted('documentation_documents', ['id' => $document->id]); + Storage::disk('public')->assertExists('documentation/primary.pdf'); + Storage::disk('public')->assertExists('documentation/support.pdf'); + $this->assertDatabaseHas('documentation_attachments', ['id' => $attachment->id]); + } + + public function test_archived_document_can_be_restored_as_draft(): void + { + $user = User::factory()->create(); + $document = DocumentationDocument::query()->create([ + 'title' => 'Restore me', + 'slug' => 'restore-me', + 'status' => DocumentationDocument::STATUS_ARCHIVED, + 'version' => '1.0.0', + ]); + $document->delete(); + + $this->actingAs($user) + ->patch(route('documentation.documents.restore', $document->id)) + ->assertRedirect(route('documentation.documents.archived')); + + $restored = DocumentationDocument::query()->findOrFail($document->id); + + $this->assertSame(DocumentationDocument::STATUS_DRAFT, $restored->status); + $this->assertNull($restored->deleted_at); + } + + public function test_permanent_delete_removes_archived_record_and_files(): void + { + Storage::fake('public'); + + $user = User::factory()->create(); + $document = DocumentationDocument::query()->create([ + 'title' => 'Delete me', + 'slug' => 'delete-me', + 'status' => DocumentationDocument::STATUS_ARCHIVED, + 'version' => '1.0.0', + 'attachment_disk' => 'public', + 'attachment_path' => 'documentation/primary.pdf', + 'attachment_name' => 'primary.pdf', + ]); + + Storage::disk('public')->put('documentation/primary.pdf', 'primary'); + + $document->attachments()->create([ + 'disk' => 'public', + 'path' => 'documentation/support.pdf', + 'original_name' => 'support.pdf', + ]); + Storage::disk('public')->put('documentation/support.pdf', 'support'); + + $document->delete(); + + $this->actingAs($user) + ->delete(route('documentation.documents.force-destroy', $document->id)) + ->assertRedirect(route('documentation.documents.archived')); + + $this->assertDatabaseMissing('documentation_documents', ['id' => $document->id]); + Storage::disk('public')->assertMissing('documentation/primary.pdf'); + Storage::disk('public')->assertMissing('documentation/support.pdf'); + } +} diff --git a/Modules/Helpdesk/Livewire/SettingsManager.php b/Modules/Helpdesk/Livewire/SettingsManager.php index ea8a7f2..f31a4e7 100644 --- a/Modules/Helpdesk/Livewire/SettingsManager.php +++ b/Modules/Helpdesk/Livewire/SettingsManager.php @@ -242,6 +242,7 @@ final class SettingsManager extends Component public function saveTeam(): void { + $this->resetValidation(); $this->normalizeNullableFormValues('teamForm', []); $data = $this->validate([ @@ -254,27 +255,80 @@ final class SettingsManager extends Component 'teamForm.user_ids.*' => ['integer', 'distinct', 'exists:users,id'], ])['teamForm']; - $userIds = array_values(array_unique(array_map('intval', $data['user_ids'] ?? []))); + $teamId = filled($this->teamForm['id'] ?? null) + ? (int) $this->teamForm['id'] + : null; + + $userIds = collect($data['user_ids'] ?? []) + ->map(static fn ($id): int => (int) $id) + ->unique() + ->values() + ->all(); + unset($data['user_ids']); $data['slug'] = $this->slug($data['slug'] ?: $data['name']); $data = $this->guardDefaultColumn('helpdesk_teams', $data); if ((bool) ($data['is_default'] ?? false)) { - $this->clearOtherDefaults(Team::class, $this->teamForm['id']); + $this->clearOtherDefaults(Team::class, $teamId); } - /** @var Team $team */ - $team = $this->persistSettingModel(Team::class, $this->teamForm['id'], $data); - $team->users()->sync($userIds); + try { + if ($teamId !== null) { + /** @var Team $team */ + $team = Team::withTrashed()->findOrFail($teamId); + + if ($team->trashed()) { + $team->restore(); + } + + $team->fill($data); + $team->save(); + } else { + // Team slugs are unique even after soft deletion. Recreating a + // previously deleted team should restore that record instead + // of failing with a duplicate-key exception. + /** @var Team|null $team */ + $team = Team::withTrashed() + ->where('slug', $data['slug']) + ->first(); + + if ($team !== null) { + if (! $team->trashed()) { + throw ValidationException::withMessages([ + 'teamForm.slug' => 'A team with this slug already exists.', + ]); + } + + $team->restore(); + $team->fill($data); + $team->save(); + } else { + $team = Team::query()->create($data); + } + } + + if (Schema::hasTable('helpdesk_team_user')) { + $team->users()->sync($userIds); + } + } catch (ValidationException $exception) { + throw $exception; + } catch (QueryException $exception) { + throw ValidationException::withMessages([ + 'teamForm.slug' => 'The team could not be saved. Check that the name/slug is unique and try again.', + ]); + } $this->resetTeamForm(); - session()->flash('success', 'Team saved.'); + $this->section = 'teams'; + + session()->flash('success', 'Team saved successfully.'); } public function editTeam(int $id): void { - $team = Team::query()->with('users:id')->findOrFail($id); + $team = Team::withTrashed()->with('users:id')->findOrFail($id); $this->teamForm = $team->only(['id', 'name', 'slug', 'color', 'description', 'is_default', 'archived_at']); $this->teamForm['user_ids'] = $team->users->pluck('id')->map(static fn ($id): int => (int) $id)->all(); $this->teamForm['color'] = Color::normalise($this->teamForm['color'] ?? 'indigo'); diff --git a/resources/themes/backend/views/modules/helpdesk/livewire/kanban-board.blade.php b/resources/themes/backend/views/modules/helpdesk/livewire/kanban-board.blade.php index 56e67b4..cc89cfc 100644 --- a/resources/themes/backend/views/modules/helpdesk/livewire/kanban-board.blade.php +++ b/resources/themes/backend/views/modules/helpdesk/livewire/kanban-board.blade.php @@ -107,8 +107,8 @@ wire:key="kanban-dropzone-{{ $board?->id ?? 0 }}-{{ $status->id }}-{{ $refreshNonce }}"> @forelse ($tickets as $ticket)
@@ -270,12 +270,26 @@ }); }; + const isInteractive = (element) => Boolean( + element?.closest?.( + 'a, button, input, select, textarea, option, form, label, [contenteditable="true"], [data-no-card-drag]' + ) + ); + componentRoot.addEventListener('dragstart', (event) => { - const handle = event.target.closest('[data-kanban-drag-handle]'); - if (!handle) { event.preventDefault(); return; } - const card = cardFor(handle); + const card = cardFor(event.target); + + if (!card || isInteractive(event.target)) { + event.preventDefault(); + return; + } + const column = columnFor(card); - if (!card || !column) { event.preventDefault(); return; } + + if (!column) { + event.preventDefault(); + return; + } drag = { card, @@ -284,8 +298,10 @@ originalParent: card.parentElement, originalNext: card.nextElementSibling, }; + event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('text/plain', String(drag.ticketId)); + requestAnimationFrame(() => card.classList.add('hd-is-dragging')); }); diff --git a/resources/themes/backend/views/modules/helpdesk/livewire/settings-manager.blade.php b/resources/themes/backend/views/modules/helpdesk/livewire/settings-manager.blade.php index dcc8303..4bf374c 100644 --- a/resources/themes/backend/views/modules/helpdesk/livewire/settings-manager.blade.php +++ b/resources/themes/backend/views/modules/helpdesk/livewire/settings-manager.blade.php @@ -124,6 +124,19 @@ class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500"> Default status for new tickets + @error('teamForm.name') +
{{ $message }}
+ @enderror + @error('teamForm.slug') +
{{ $message }}
+ @enderror + @error('teamForm.color') +
{{ $message }}
+ @enderror + @error('teamForm.user_ids.*') +
{{ $message }}
+ @enderror +
Save status