updaetd to roles and permissions
This commit is contained in:
@@ -49,11 +49,42 @@ class DocumentationController extends Controller
|
|||||||
'published' => DocumentationDocument::query()->published()->count(),
|
'published' => DocumentationDocument::query()->published()->count(),
|
||||||
'drafts' => DocumentationDocument::query()->where('status', DocumentationDocument::STATUS_DRAFT)->count(),
|
'drafts' => DocumentationDocument::query()->where('status', DocumentationDocument::STATUS_DRAFT)->count(),
|
||||||
'categories' => DocumentationCategory::query()->count(),
|
'categories' => DocumentationCategory::query()->count(),
|
||||||
|
'archived' => DocumentationDocument::onlyTrashed()->count(),
|
||||||
],
|
],
|
||||||
'statuses' => config('documentation.statuses', []),
|
'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.
|
* 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);
|
$this->deleteAttachment($document);
|
||||||
|
|
||||||
foreach ($document->attachments as $attachment) {
|
foreach ($document->attachments as $attachment) {
|
||||||
@@ -158,9 +233,11 @@ class DocumentationController extends Controller
|
|||||||
$attachment->delete();
|
$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.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
@extends('documentation::layouts.master')
|
||||||
|
|
||||||
|
@section('title', 'Archived Documents')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="doc-page">
|
||||||
|
@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'))
|
||||||
|
<div class="doc-alert">{{ session('status') }}</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<section class="doc-card">
|
||||||
|
<div class="doc-card-header">
|
||||||
|
<div>
|
||||||
|
<strong>Archive</strong>
|
||||||
|
<div class="doc-muted">
|
||||||
|
Restore documents or permanently remove them and their stored files.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="doc-badge archived">{{ $documents->total() }} archived</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="doc-card-body">
|
||||||
|
<form class="doc-filter" method="GET" action="{{ route('documentation.documents.archived') }}">
|
||||||
|
<input
|
||||||
|
class="doc-input"
|
||||||
|
name="search"
|
||||||
|
value="{{ request('search') }}"
|
||||||
|
placeholder="Search archived documents"
|
||||||
|
>
|
||||||
|
|
||||||
|
<select class="doc-select" name="category">
|
||||||
|
<option value="">All categories</option>
|
||||||
|
@foreach ($categories as $category)
|
||||||
|
<option value="{{ $category->id }}" @selected((string) request('category') === (string) $category->id)>
|
||||||
|
{{ $category->name }}
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button class="doc-button primary" type="submit">Filter</button>
|
||||||
|
|
||||||
|
@if (request()->filled('search') || request()->filled('category'))
|
||||||
|
<a class="doc-button secondary" href="{{ route('documentation.documents.archived') }}">Clear</a>
|
||||||
|
@endif
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<table class="doc-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Document</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Version</th>
|
||||||
|
<th>Archived</th>
|
||||||
|
<th>Files</th>
|
||||||
|
<th style="text-align:right;">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse ($documents as $document)
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{{ $document->title }}</strong>
|
||||||
|
<div class="doc-muted">
|
||||||
|
{{ $document->reference ?: 'No reference' }} ·
|
||||||
|
{{ $document->summary ?: 'No summary' }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ $document->category?->name ?: 'Uncategorised' }}</td>
|
||||||
|
<td>{{ $document->version }}</td>
|
||||||
|
<td>{{ $document->deleted_at?->format('d M Y H:i') }}</td>
|
||||||
|
<td>
|
||||||
|
{{ ($document->attachment_name ? 1 : 0) + $document->attachments->count() }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="doc-actions" style="justify-content:flex-end;">
|
||||||
|
<form method="POST"
|
||||||
|
action="{{ route('documentation.documents.restore', $document->id) }}">
|
||||||
|
@csrf
|
||||||
|
@method('PATCH')
|
||||||
|
<button class="doc-button success" type="submit">Restore</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="POST"
|
||||||
|
action="{{ route('documentation.documents.force-destroy', $document->id) }}"
|
||||||
|
onsubmit="return confirm('Permanently delete this document? This will also delete every stored attachment and cannot be undone.')">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button class="doc-button danger" type="submit">Delete permanently</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="doc-muted">No archived documents found.</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div style="margin-top:18px;">
|
||||||
|
{{ $documents->links() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -18,6 +18,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div class="doc-actions" style="margin-top:18px;justify-content:flex-end;">
|
||||||
|
<form method="POST"
|
||||||
|
action="{{ route('documentation.documents.destroy', $document) }}"
|
||||||
|
onsubmit="return confirm('Archive this document? Its files and attachments will be retained.')">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button class="doc-button warning" type="submit">Archive document</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
@foreach ($document->attachments as $attachment)
|
@foreach ($document->attachments as $attachment)
|
||||||
<form id="delete-attachment-{{ $attachment->id }}" method="POST"
|
<form id="delete-attachment-{{ $attachment->id }}" method="POST"
|
||||||
action="{{ route('documentation.documents.attachments.destroy', [$document, $attachment]) }}"
|
action="{{ route('documentation.documents.attachments.destroy', [$document, $attachment]) }}"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
<article class="doc-card doc-stat"><strong>{{ $stats['published'] }}</strong><span>Published</span></article>
|
<article class="doc-card doc-stat"><strong>{{ $stats['published'] }}</strong><span>Published</span></article>
|
||||||
<article class="doc-card doc-stat"><strong>{{ $stats['drafts'] }}</strong><span>Drafts</span></article>
|
<article class="doc-card doc-stat"><strong>{{ $stats['drafts'] }}</strong><span>Drafts</span></article>
|
||||||
<article class="doc-card doc-stat"><strong>{{ $stats['categories'] }}</strong><span>Categories</span></article>
|
<article class="doc-card doc-stat"><strong>{{ $stats['categories'] }}</strong><span>Categories</span></article>
|
||||||
|
<article class="doc-card doc-stat"><strong>{{ $stats['archived'] }}</strong><span>Archived</span></article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@if ($featuredDocuments->isNotEmpty())
|
@if ($featuredDocuments->isNotEmpty())
|
||||||
@@ -40,7 +41,12 @@
|
|||||||
<section class="doc-card">
|
<section class="doc-card">
|
||||||
<div class="doc-card-header">
|
<div class="doc-card-header">
|
||||||
<strong>Document register</strong>
|
<strong>Document register</strong>
|
||||||
<a class="doc-button success" href="{{ route('documentation.documents.create') }}">New document</a>
|
<div class="doc-actions">
|
||||||
|
<a class="doc-button secondary" href="{{ route('documentation.documents.archived') }}">
|
||||||
|
Archived documents ({{ $stats['archived'] }})
|
||||||
|
</a>
|
||||||
|
<a class="doc-button success" href="{{ route('documentation.documents.create') }}">New document</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="doc-card-body">
|
<div class="doc-card-body">
|
||||||
<form class="doc-filter" method="GET" action="{{ route('documentation.documents.index') }}">
|
<form class="doc-filter" method="GET" action="{{ route('documentation.documents.index') }}">
|
||||||
@@ -88,8 +94,19 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>{{ $document->version }}</td>
|
<td>{{ $document->version }}</td>
|
||||||
<td>{{ $document->updated_at?->format('d M Y H:i') }}</td>
|
<td>{{ $document->updated_at?->format('d M Y H:i') }}</td>
|
||||||
<td><a class="doc-link"
|
<td>
|
||||||
href="{{ route('documentation.documents.edit', $document) }}">Edit</a></td>
|
<div class="doc-actions" style="justify-content:flex-end;">
|
||||||
|
<a class="doc-button primary"
|
||||||
|
href="{{ route('documentation.documents.edit', $document) }}">Edit</a>
|
||||||
|
<form method="POST"
|
||||||
|
action="{{ route('documentation.documents.destroy', $document) }}"
|
||||||
|
onsubmit="return confirm('Archive this document? Its files will be retained and it can be restored later.')">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button class="doc-button warning" type="submit">Archive</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@empty
|
@empty
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -23,6 +23,20 @@
|
|||||||
],
|
],
|
||||||
])
|
])
|
||||||
|
|
||||||
|
@if (session('status'))
|
||||||
|
<div class="doc-alert">{{ session('status') }}</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="doc-actions" style="margin-bottom:18px;justify-content:flex-end;">
|
||||||
|
<form method="POST"
|
||||||
|
action="{{ route('documentation.documents.destroy', $document) }}"
|
||||||
|
onsubmit="return confirm('Archive this document? Its files and attachments will be retained.')">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button class="doc-button warning" type="submit">Archive document</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="doc-card">
|
<section class="doc-card">
|
||||||
<div class="doc-card-header">
|
<div class="doc-card-header">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ Route::middleware(['web', 'auth'])
|
|||||||
Route::get('/', [DocumentationController::class, 'index'])->name('index');
|
Route::get('/', [DocumentationController::class, 'index'])->name('index');
|
||||||
|
|
||||||
Route::get('/documents', [DocumentationController::class, 'index'])->name('documents.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::get('/documents/create', [DocumentationController::class, 'create'])->name('documents.create');
|
||||||
Route::post('/documents', [DocumentationController::class, 'store'])->name('documents.store');
|
Route::post('/documents', [DocumentationController::class, 'store'])->name('documents.store');
|
||||||
Route::get('/documents/{document}', [DocumentationController::class, 'show'])->name('documents.show');
|
Route::get('/documents/{document}', [DocumentationController::class, 'show'])->name('documents.show');
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Modules\Documentation\Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Modules\Documentation\Models\DocumentationAttachment;
|
||||||
|
use Modules\Documentation\Models\DocumentationDocument;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
final class DocumentationArchiveTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_archiving_soft_deletes_document_but_retains_files(): void
|
||||||
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
|
||||||
|
$user = User::factory()->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -242,6 +242,7 @@ final class SettingsManager extends Component
|
|||||||
|
|
||||||
public function saveTeam(): void
|
public function saveTeam(): void
|
||||||
{
|
{
|
||||||
|
$this->resetValidation();
|
||||||
$this->normalizeNullableFormValues('teamForm', []);
|
$this->normalizeNullableFormValues('teamForm', []);
|
||||||
|
|
||||||
$data = $this->validate([
|
$data = $this->validate([
|
||||||
@@ -254,27 +255,80 @@ final class SettingsManager extends Component
|
|||||||
'teamForm.user_ids.*' => ['integer', 'distinct', 'exists:users,id'],
|
'teamForm.user_ids.*' => ['integer', 'distinct', 'exists:users,id'],
|
||||||
])['teamForm'];
|
])['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']);
|
unset($data['user_ids']);
|
||||||
|
|
||||||
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
|
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
|
||||||
$data = $this->guardDefaultColumn('helpdesk_teams', $data);
|
$data = $this->guardDefaultColumn('helpdesk_teams', $data);
|
||||||
|
|
||||||
if ((bool) ($data['is_default'] ?? false)) {
|
if ((bool) ($data['is_default'] ?? false)) {
|
||||||
$this->clearOtherDefaults(Team::class, $this->teamForm['id']);
|
$this->clearOtherDefaults(Team::class, $teamId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var Team $team */
|
try {
|
||||||
$team = $this->persistSettingModel(Team::class, $this->teamForm['id'], $data);
|
if ($teamId !== null) {
|
||||||
$team->users()->sync($userIds);
|
/** @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();
|
$this->resetTeamForm();
|
||||||
session()->flash('success', 'Team saved.');
|
$this->section = 'teams';
|
||||||
|
|
||||||
|
session()->flash('success', 'Team saved successfully.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function editTeam(int $id): void
|
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 = $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['user_ids'] = $team->users->pluck('id')->map(static fn ($id): int => (int) $id)->all();
|
||||||
$this->teamForm['color'] = Color::normalise($this->teamForm['color'] ?? 'indigo');
|
$this->teamForm['color'] = Color::normalise($this->teamForm['color'] ?? 'indigo');
|
||||||
|
|||||||
@@ -107,8 +107,8 @@
|
|||||||
wire:key="kanban-dropzone-{{ $board?->id ?? 0 }}-{{ $status->id }}-{{ $refreshNonce }}">
|
wire:key="kanban-dropzone-{{ $board?->id ?? 0 }}-{{ $status->id }}-{{ $refreshNonce }}">
|
||||||
@forelse ($tickets as $ticket)
|
@forelse ($tickets as $ticket)
|
||||||
<article
|
<article
|
||||||
class="group rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md active:cursor-grabbing dark:border-zinc-800 dark:bg-zinc-900"
|
class="group cursor-grab rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md active:cursor-grabbing dark:border-zinc-800 dark:bg-zinc-900"
|
||||||
draggable="false" data-helpdesk-kanban-ticket data-ticket-id="{{ $ticket->id }}"
|
draggable="true" data-helpdesk-kanban-ticket data-ticket-id="{{ $ticket->id }}"
|
||||||
wire:key="kanban-ticket-{{ $ticket->id }}-{{ $ticket->status_id }}-{{ $ticket->board_id }}-{{ optional($ticket->updated_at)->timestamp }}"
|
wire:key="kanban-ticket-{{ $ticket->id }}-{{ $ticket->status_id }}-{{ $ticket->board_id }}-{{ optional($ticket->updated_at)->timestamp }}"
|
||||||
dusk="kanban-ticket-{{ $ticket->id }}">
|
dusk="kanban-ticket-{{ $ticket->id }}">
|
||||||
<div class="mb-2 flex justify-end">
|
<div class="mb-2 flex justify-end">
|
||||||
@@ -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) => {
|
componentRoot.addEventListener('dragstart', (event) => {
|
||||||
const handle = event.target.closest('[data-kanban-drag-handle]');
|
const card = cardFor(event.target);
|
||||||
if (!handle) { event.preventDefault(); return; }
|
|
||||||
const card = cardFor(handle);
|
if (!card || isInteractive(event.target)) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const column = columnFor(card);
|
const column = columnFor(card);
|
||||||
if (!card || !column) { event.preventDefault(); return; }
|
|
||||||
|
if (!column) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
drag = {
|
drag = {
|
||||||
card,
|
card,
|
||||||
@@ -284,8 +298,10 @@
|
|||||||
originalParent: card.parentElement,
|
originalParent: card.parentElement,
|
||||||
originalNext: card.nextElementSibling,
|
originalNext: card.nextElementSibling,
|
||||||
};
|
};
|
||||||
|
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
event.dataTransfer.setData('text/plain', String(drag.ticketId));
|
event.dataTransfer.setData('text/plain', String(drag.ticketId));
|
||||||
|
|
||||||
requestAnimationFrame(() => card.classList.add('hd-is-dragging'));
|
requestAnimationFrame(() => card.classList.add('hd-is-dragging'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,19 @@
|
|||||||
class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500">
|
class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500">
|
||||||
Default status for new tickets
|
Default status for new tickets
|
||||||
</label>
|
</label>
|
||||||
|
@error('teamForm.name')
|
||||||
|
<div class="xl:col-span-12 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
@error('teamForm.slug')
|
||||||
|
<div class="xl:col-span-12 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
@error('teamForm.color')
|
||||||
|
<div class="xl:col-span-12 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
@error('teamForm.user_ids.*')
|
||||||
|
<div class="xl:col-span-12 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2 xl:col-span-12">
|
<div class="flex flex-wrap items-center gap-2 xl:col-span-12">
|
||||||
<flux:button size="sm" type="submit" variant="primary"
|
<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
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user