From 16d8ae1b8cef742d6749da402e0a9088b0ca912f Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Sun, 14 Jun 2026 15:38:53 +0100 Subject: [PATCH] Updated the helpdesk module --- Modules/Documentation/Config/config.php | 23 +- ..._create_documentation_categories_table.php | 35 ++ ...0_create_documentation_documents_table.php | 47 +++ ...create_documentation_attachments_table.php | 35 ++ .../DocumentationCategoryController.php | 82 +++++ .../Controllers/DocumentationController.php | 311 ++++++++++++++++-- .../StoreDocumentationCategoryRequest.php | 35 ++ .../StoreDocumentationDocumentRequest.php | 41 +++ .../UpdateDocumentationCategoryRequest.php | 39 +++ .../UpdateDocumentationDocumentRequest.php | 45 +++ .../Models/DocumentationAttachment.php | 69 ++++ .../Models/DocumentationCategory.php | 87 +++++ .../Models/DocumentationDocument.php | 152 +++++++++ .../Providers/RouteServiceProvider.php | 2 - .../views/categories/create.blade.php | 18 + .../Resources/views/categories/edit.blade.php | 18 + .../views/categories/index.blade.php | 57 ++++ .../views/documents/create.blade.php | 18 + .../Resources/views/documents/edit.blade.php | 25 ++ .../Resources/views/documents/index.blade.php | 92 ++++++ .../Resources/views/documents/show.blade.php | 53 +++ .../Resources/views/index.blade.php | 10 +- .../Resources/views/layouts/master.blade.php | 146 +++++++- .../views/partials/category-form.blade.php | 43 +++ .../views/partials/document-form.blade.php | 109 ++++++ .../Resources/views/partials/hero.blade.php | 13 + Modules/Documentation/Routes/api.php | 23 +- Modules/Documentation/Routes/web.php | 33 +- .../Tests/Feature/DocumentationModuleTest.php | 176 ++++++++++ Modules/Documentation/changelog.yaml | 12 + Modules/Documentation/composer.json | 14 +- Modules/Documentation/module.json | 11 +- .../views/livewire/ticket-edit.blade.php | 171 ++++++++-- 33 files changed, 1914 insertions(+), 131 deletions(-) create mode 100644 Modules/Documentation/Database/Migrations/2026_06_12_000000_create_documentation_categories_table.php create mode 100644 Modules/Documentation/Database/Migrations/2026_06_12_000010_create_documentation_documents_table.php create mode 100644 Modules/Documentation/Database/Migrations/2026_06_12_000020_create_documentation_attachments_table.php create mode 100644 Modules/Documentation/Http/Controllers/DocumentationCategoryController.php create mode 100644 Modules/Documentation/Http/Requests/StoreDocumentationCategoryRequest.php create mode 100644 Modules/Documentation/Http/Requests/StoreDocumentationDocumentRequest.php create mode 100644 Modules/Documentation/Http/Requests/UpdateDocumentationCategoryRequest.php create mode 100644 Modules/Documentation/Http/Requests/UpdateDocumentationDocumentRequest.php create mode 100644 Modules/Documentation/Models/DocumentationAttachment.php create mode 100644 Modules/Documentation/Models/DocumentationCategory.php create mode 100644 Modules/Documentation/Models/DocumentationDocument.php create mode 100644 Modules/Documentation/Resources/views/categories/create.blade.php create mode 100644 Modules/Documentation/Resources/views/categories/edit.blade.php create mode 100644 Modules/Documentation/Resources/views/categories/index.blade.php create mode 100644 Modules/Documentation/Resources/views/documents/create.blade.php create mode 100644 Modules/Documentation/Resources/views/documents/edit.blade.php create mode 100644 Modules/Documentation/Resources/views/documents/index.blade.php create mode 100644 Modules/Documentation/Resources/views/documents/show.blade.php create mode 100644 Modules/Documentation/Resources/views/partials/category-form.blade.php create mode 100644 Modules/Documentation/Resources/views/partials/document-form.blade.php create mode 100644 Modules/Documentation/Resources/views/partials/hero.blade.php create mode 100644 Modules/Documentation/Tests/Feature/DocumentationModuleTest.php create mode 100644 Modules/Documentation/changelog.yaml diff --git a/Modules/Documentation/Config/config.php b/Modules/Documentation/Config/config.php index 9b7d94dc..4cc6008f 100644 --- a/Modules/Documentation/Config/config.php +++ b/Modules/Documentation/Config/config.php @@ -2,7 +2,24 @@ return [ 'name' => 'Documentation', - 'features' => - [ - ], + 'route_prefix' => 'documentation', + 'middleware' => ['web', 'auth'], + 'storage_disk' => env('DOCUMENTATION_DISK', 'public'), + 'upload_path' => env('DOCUMENTATION_UPLOAD_PATH', 'documentation'), + 'allowed_upload_mimes' => [ + 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md', 'csv', 'png', 'jpg', 'jpeg', 'webp', + ], + 'max_upload_kb' => 20480, + 'import_markdown_attachments' => true, + 'max_markdown_import_bytes' => 1048576, + 'editor' => [ + 'markdown_toolbar' => true, + 'supports_code_blocks' => true, + 'supports_tables' => true, + ], + 'statuses' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], ]; diff --git a/Modules/Documentation/Database/Migrations/2026_06_12_000000_create_documentation_categories_table.php b/Modules/Documentation/Database/Migrations/2026_06_12_000000_create_documentation_categories_table.php new file mode 100644 index 00000000..4d932cff --- /dev/null +++ b/Modules/Documentation/Database/Migrations/2026_06_12_000000_create_documentation_categories_table.php @@ -0,0 +1,35 @@ +id(); + $table->string('name')->comment('Human-readable category name.'); + $table->string('slug')->unique()->comment('URL-safe category identifier.'); + $table->string('icon')->nullable()->comment('Optional UI icon class or key.'); + $table->string('colour', 32)->default('emerald')->comment('Display colour token.'); + $table->text('description')->nullable()->comment('Category description shown in the documentation UI.'); + $table->unsignedInteger('sort_order')->default(0)->index()->comment('Manual display order.'); + $table->boolean('is_active')->default(true)->index()->comment('Whether the category is visible and usable.'); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()->comment('User that created the category.'); + $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete()->comment('Last user that updated the category.'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('documentation_categories'); + } +}; diff --git a/Modules/Documentation/Database/Migrations/2026_06_12_000010_create_documentation_documents_table.php b/Modules/Documentation/Database/Migrations/2026_06_12_000010_create_documentation_documents_table.php new file mode 100644 index 00000000..5a05c111 --- /dev/null +++ b/Modules/Documentation/Database/Migrations/2026_06_12_000010_create_documentation_documents_table.php @@ -0,0 +1,47 @@ +id(); + $table->foreignId('documentation_category_id')->nullable()->constrained('documentation_categories')->nullOnDelete()->comment('Optional owning documentation category.'); + $table->string('title')->comment('Document title.'); + $table->string('slug')->unique()->comment('URL-safe document identifier.'); + $table->string('summary', 500)->nullable()->comment('Short document summary.'); + $table->longText('body')->nullable()->comment('Full document body, markdown or HTML-safe text depending on editor implementation.'); + $table->string('status', 32)->default('draft')->index()->comment('Workflow status: draft, published, or archived.'); + $table->boolean('is_featured')->default(false)->index()->comment('Whether this document is highlighted on the documentation dashboard.'); + $table->string('version', 50)->default('1.0.0')->comment('Document version label.'); + $table->string('reference', 100)->nullable()->index()->comment('Optional business reference or document code.'); + $table->string('attachment_disk')->nullable()->comment('Storage disk used for the attached file.'); + $table->string('attachment_path')->nullable()->comment('Storage path for the attached file.'); + $table->string('attachment_name')->nullable()->comment('Original uploaded file name.'); + $table->string('attachment_mime')->nullable()->comment('Uploaded file MIME type.'); + $table->unsignedBigInteger('attachment_size')->nullable()->comment('Uploaded file size in bytes.'); + $table->timestamp('published_at')->nullable()->index()->comment('Date/time the document was published.'); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()->comment('User that created the document.'); + $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete()->comment('Last user that updated the document.'); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['status', 'is_featured']); + $table->index(['documentation_category_id', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('documentation_documents'); + } +}; diff --git a/Modules/Documentation/Database/Migrations/2026_06_12_000020_create_documentation_attachments_table.php b/Modules/Documentation/Database/Migrations/2026_06_12_000020_create_documentation_attachments_table.php new file mode 100644 index 00000000..c0764708 --- /dev/null +++ b/Modules/Documentation/Database/Migrations/2026_06_12_000020_create_documentation_attachments_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('documentation_document_id')->constrained('documentation_documents')->cascadeOnDelete()->comment('Owning documentation document.'); + $table->string('disk')->comment('Storage disk used for the uploaded attachment.'); + $table->string('path')->comment('Storage path for the uploaded attachment.'); + $table->string('original_name')->comment('Original uploaded file name.'); + $table->string('mime_type')->nullable()->comment('Uploaded file MIME type.'); + $table->unsignedBigInteger('size')->nullable()->comment('Uploaded file size in bytes.'); + $table->foreignId('uploaded_by')->nullable()->constrained('users')->nullOnDelete()->comment('User that uploaded the attachment.'); + $table->timestamps(); + + $table->index(['documentation_document_id', 'created_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('documentation_attachments'); + } +}; diff --git a/Modules/Documentation/Http/Controllers/DocumentationCategoryController.php b/Modules/Documentation/Http/Controllers/DocumentationCategoryController.php new file mode 100644 index 00000000..ff4e8e63 --- /dev/null +++ b/Modules/Documentation/Http/Controllers/DocumentationCategoryController.php @@ -0,0 +1,82 @@ + DocumentationCategory::query()->withCount('documents')->ordered()->paginate(15), + ]); + } + + /** + * Show create form. + */ + public function create(): View + { + return view('documentation::categories.create', [ + 'category' => new DocumentationCategory(['colour' => 'emerald', 'is_active' => true]), + ]); + } + + /** + * Store a category. + */ + public function store(StoreDocumentationCategoryRequest $request): RedirectResponse + { + $data = $request->validated(); + $data['is_active'] = $request->boolean('is_active', true); + $data['created_by'] = $request->user()?->id; + $data['updated_by'] = $request->user()?->id; + + DocumentationCategory::query()->create($data); + + return redirect()->route('documentation.categories.index')->with('status', 'Category created successfully.'); + } + + /** + * Show edit form. + */ + public function edit(DocumentationCategory $category): View + { + return view('documentation::categories.edit', [ + 'category' => $category, + ]); + } + + /** + * Update a category. + */ + public function update(UpdateDocumentationCategoryRequest $request, DocumentationCategory $category): RedirectResponse + { + $data = $request->validated(); + $data['is_active'] = $request->boolean('is_active'); + $data['updated_by'] = $request->user()?->id; + + $category->update($data); + + return redirect()->route('documentation.categories.index')->with('status', 'Category updated successfully.'); + } + + /** + * Delete a category. + */ + public function destroy(DocumentationCategory $category): RedirectResponse + { + $category->delete(); + + return redirect()->route('documentation.categories.index')->with('status', 'Category deleted successfully.'); + } +} diff --git a/Modules/Documentation/Http/Controllers/DocumentationController.php b/Modules/Documentation/Http/Controllers/DocumentationController.php index ddcf8a08..62071bea 100644 --- a/Modules/Documentation/Http/Controllers/DocumentationController.php +++ b/Modules/Documentation/Http/Controllers/DocumentationController.php @@ -2,78 +2,319 @@ namespace Modules\Documentation\Http\Controllers; -use Illuminate\Contracts\Support\Renderable; +use Illuminate\Contracts\View\View; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller; +use Illuminate\Support\Facades\Storage; +use Modules\Documentation\Http\Requests\StoreDocumentationDocumentRequest; +use Modules\Documentation\Http\Requests\UpdateDocumentationDocumentRequest; +use Modules\Documentation\Models\DocumentationCategory; +use Modules\Documentation\Models\DocumentationDocument; +use Modules\Documentation\Models\DocumentationAttachment; class DocumentationController extends Controller { /** - * Display a listing of the resource. - * @return Renderable + * Display the documentation dashboard and document list. */ - public function index() + public function index(Request $request): View { - return view('documentation::index'); + $query = DocumentationDocument::query()->with(['category', 'attachments'])->latest(); + + if ($request->filled('status')) { + $query->where('status', $request->string('status')->toString()); + } + + 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.index', [ + 'documents' => $query->paginate(15)->withQueryString(), + 'categories' => DocumentationCategory::query()->ordered()->get(), + 'featuredDocuments' => DocumentationDocument::query()->with('category')->featured()->latest()->limit(4)->get(), + 'stats' => [ + 'total' => DocumentationDocument::query()->count(), + 'published' => DocumentationDocument::query()->published()->count(), + 'drafts' => DocumentationDocument::query()->where('status', DocumentationDocument::STATUS_DRAFT)->count(), + 'categories' => DocumentationCategory::query()->count(), + ], + 'statuses' => config('documentation.statuses', []), + ]); } /** - * Show the form for creating a new resource. - * @return Renderable + * Show the create document form. */ - public function create() + public function create(): View { - return view('documentation::create'); + return view('documentation::documents.create', [ + 'document' => new DocumentationDocument(['status' => DocumentationDocument::STATUS_DRAFT, 'version' => '1.0.0']), + 'categories' => DocumentationCategory::query()->active()->ordered()->get(), + 'statuses' => config('documentation.statuses', []), + ]); } /** - * Store a newly created resource in storage. - * @param Request $request - * @return Renderable + * Store a newly created document. */ - public function store(Request $request) + public function store(StoreDocumentationDocumentRequest $request): RedirectResponse { - // + $data = $request->validated(); + $data['is_featured'] = $request->boolean('is_featured'); + $data['created_by'] = $request->user()?->id; + $data['updated_by'] = $request->user()?->id; + + if ($request->hasFile('attachment')) { + if (blank($data['body'] ?? null)) { + $data['body'] = $this->extractMarkdownBody($request) ?: ($data['body'] ?? null); + } + + $data = array_merge($data, $this->storeAttachment($request)); + } + + unset($data['attachment'], $data['attachments']); + + $document = DocumentationDocument::query()->create($data); + $this->storeAttachments($request, $document); + + return redirect()->route('documentation.documents.show', $document)->with('status', 'Document created successfully.'); } /** - * Show the specified resource. - * @param int $id - * @return Renderable + * Display a document. */ - public function show($id) + public function show(DocumentationDocument $document): View { - return view('documentation::show'); + return view('documentation::documents.show', [ + 'document' => $document->load(['category', 'attachments']), + ]); } /** - * Show the form for editing the specified resource. - * @param int $id - * @return Renderable + * Show the edit document form. */ - public function edit($id) + public function edit(DocumentationDocument $document): View { - return view('documentation::edit'); + return view('documentation::documents.edit', [ + 'document' => $document->load('attachments'), + 'categories' => DocumentationCategory::query()->active()->ordered()->get(), + 'statuses' => config('documentation.statuses', []), + ]); } /** - * Update the specified resource in storage. - * @param Request $request - * @param int $id - * @return Renderable + * Update an existing document. */ - public function update(Request $request, $id) + public function update(UpdateDocumentationDocumentRequest $request, DocumentationDocument $document): RedirectResponse { - // + $data = $request->validated(); + $data['is_featured'] = $request->boolean('is_featured'); + $data['updated_by'] = $request->user()?->id; + + if ($request->boolean('remove_attachment')) { + $this->deleteAttachment($document); + $data = array_merge($data, $this->emptyAttachmentData()); + } + + if ($request->hasFile('attachment')) { + $this->deleteAttachment($document); + + if (blank($data['body'] ?? null)) { + $data['body'] = $this->extractMarkdownBody($request) ?: ($data['body'] ?? null); + } + + $data = array_merge($data, $this->storeAttachment($request)); + } + + unset($data['attachment'], $data['attachments'], $data['remove_attachment']); + $document->update($data); + $this->storeAttachments($request, $document); + + return redirect()->route('documentation.documents.show', $document)->with('status', 'Document updated successfully.'); } /** - * Remove the specified resource from storage. - * @param int $id - * @return Renderable + * Remove a document. */ - public function destroy($id) + public function destroy(DocumentationDocument $document): RedirectResponse { - // + $document->load('attachments'); + $this->deleteAttachment($document); + + foreach ($document->attachments as $attachment) { + Storage::disk($attachment->disk)->delete($attachment->path); + $attachment->delete(); + } + + $document->delete(); + + return redirect()->route('documentation.documents.index')->with('status', 'Document archived successfully.'); + } + + /** + * Download the attached document file. + */ + public function download(DocumentationDocument $document) + { + abort_if(blank($document->attachment_path), 404); + + return Storage::disk($document->attachment_disk ?: config('documentation.storage_disk', 'public')) + ->download($document->attachment_path, $document->attachment_name); + } + + /** + * Download one of the document's additional attachments. + */ + public function downloadAttachment(DocumentationDocument $document, DocumentationAttachment $attachment) + { + abort_if($attachment->documentation_document_id !== $document->id, 404); + + return Storage::disk($attachment->disk)->download($attachment->path, $attachment->original_name); + } + + /** + * Delete one of the document's additional attachments. + */ + public function destroyAttachment(DocumentationDocument $document, DocumentationAttachment $attachment): RedirectResponse + { + abort_if($attachment->documentation_document_id !== $document->id, 404); + + Storage::disk($attachment->disk)->delete($attachment->path); + $attachment->delete(); + + return redirect()->route('documentation.documents.edit', $document)->with('status', 'Attachment removed successfully.'); + } + + + /** + * Extract body content from an uploaded Markdown file when requested. + */ + private function extractMarkdownBody(Request $request): ?string + { + if (! config('documentation.import_markdown_attachments', true)) { + return null; + } + + $file = $request->file('attachment'); + + if ($file === null || ! $this->isMarkdownUpload($request)) { + return null; + } + + if ($file->getSize() > (int) config('documentation.max_markdown_import_bytes', 1048576)) { + return null; + } + + $content = file_get_contents($file->getRealPath()); + + if ($content === false) { + return null; + } + + return trim($content); + } + + /** + * Determine whether the uploaded attachment is a Markdown file. + */ + private function isMarkdownUpload(Request $request): bool + { + $file = $request->file('attachment'); + + if ($file === null) { + return false; + } + + $extension = strtolower($file->getClientOriginalExtension()); + $mime = strtolower((string) $file->getMimeType()); + + return $extension === 'md' || in_array($mime, ['text/markdown', 'text/x-markdown'], true); + } + + /** + * Store an uploaded attachment and return persisted metadata. + * + * @return array + */ + private function storeAttachment(Request $request): array + { + $file = $request->file('attachment'); + $disk = config('documentation.storage_disk', 'public'); + $path = $file->store(config('documentation.upload_path', 'documentation'), $disk); + + return [ + 'attachment_disk' => $disk, + 'attachment_path' => $path, + 'attachment_name' => $file->getClientOriginalName(), + 'attachment_mime' => $file->getMimeType(), + 'attachment_size' => $file->getSize(), + ]; + } + + /** + * Store all additional uploaded attachments for a document. + */ + private function storeAttachments(Request $request, DocumentationDocument $document): void + { + if (! $request->hasFile('attachments')) { + return; + } + + $disk = config('documentation.storage_disk', 'public'); + + foreach ((array) $request->file('attachments') as $file) { + if ($file === null || ! $file->isValid()) { + continue; + } + + $path = $file->store(config('documentation.upload_path', 'documentation'), $disk); + + $document->attachments()->create([ + 'disk' => $disk, + 'path' => $path, + 'original_name' => $file->getClientOriginalName(), + 'mime_type' => $file->getMimeType(), + 'size' => $file->getSize(), + 'uploaded_by' => $request->user()?->id, + ]); + } + } + + /** + * Delete a stored attachment if it exists. + */ + private function deleteAttachment(DocumentationDocument $document): void + { + if (filled($document->attachment_path)) { + Storage::disk($document->attachment_disk ?: config('documentation.storage_disk', 'public'))->delete($document->attachment_path); + } + } + + /** + * Empty attachment metadata. + * + * @return array + */ + private function emptyAttachmentData(): array + { + return [ + 'attachment_disk' => null, + 'attachment_path' => null, + 'attachment_name' => null, + 'attachment_mime' => null, + 'attachment_size' => null, + ]; } } diff --git a/Modules/Documentation/Http/Requests/StoreDocumentationCategoryRequest.php b/Modules/Documentation/Http/Requests/StoreDocumentationCategoryRequest.php new file mode 100644 index 00000000..e098b99d --- /dev/null +++ b/Modules/Documentation/Http/Requests/StoreDocumentationCategoryRequest.php @@ -0,0 +1,35 @@ + + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:150'], + 'slug' => ['nullable', 'string', 'max:180', Rule::unique('documentation_categories', 'slug')], + 'icon' => ['nullable', 'string', 'max:80'], + 'colour' => ['required', 'string', 'max:32'], + 'description' => ['nullable', 'string', 'max:2000'], + 'sort_order' => ['nullable', 'integer', 'min:0', 'max:999999'], + 'is_active' => ['nullable', 'boolean'], + ]; + } +} diff --git a/Modules/Documentation/Http/Requests/StoreDocumentationDocumentRequest.php b/Modules/Documentation/Http/Requests/StoreDocumentationDocumentRequest.php new file mode 100644 index 00000000..b2931ecf --- /dev/null +++ b/Modules/Documentation/Http/Requests/StoreDocumentationDocumentRequest.php @@ -0,0 +1,41 @@ + + */ + public function rules(): array + { + return [ + 'documentation_category_id' => ['nullable', 'integer', 'exists:documentation_categories,id'], + 'title' => ['required', 'string', 'max:220'], + 'slug' => ['nullable', 'string', 'max:240', Rule::unique('documentation_documents', 'slug')], + 'summary' => ['nullable', 'string', 'max:500'], + 'body' => ['nullable', 'string'], + 'status' => ['required', Rule::in([DocumentationDocument::STATUS_DRAFT, DocumentationDocument::STATUS_PUBLISHED, DocumentationDocument::STATUS_ARCHIVED])], + 'is_featured' => ['nullable', 'boolean'], + 'version' => ['required', 'string', 'max:50'], + 'reference' => ['nullable', 'string', 'max:100'], + 'attachment' => ['nullable', 'file', 'max:' . config('documentation.max_upload_kb', 20480), 'mimes:' . implode(',', config('documentation.allowed_upload_mimes', []))], + 'attachments' => ['nullable', 'array'], + 'attachments.*' => ['file', 'max:' . config('documentation.max_upload_kb', 20480), 'mimes:' . implode(',', config('documentation.allowed_upload_mimes', []))], + ]; + } +} diff --git a/Modules/Documentation/Http/Requests/UpdateDocumentationCategoryRequest.php b/Modules/Documentation/Http/Requests/UpdateDocumentationCategoryRequest.php new file mode 100644 index 00000000..b003465d --- /dev/null +++ b/Modules/Documentation/Http/Requests/UpdateDocumentationCategoryRequest.php @@ -0,0 +1,39 @@ + + */ + public function rules(): array + { + /** @var DocumentationCategory|null $category */ + $category = $this->route('category'); + + return [ + 'name' => ['required', 'string', 'max:150'], + 'slug' => ['nullable', 'string', 'max:180', Rule::unique('documentation_categories', 'slug')->ignore($category?->id)], + 'icon' => ['nullable', 'string', 'max:80'], + 'colour' => ['required', 'string', 'max:32'], + 'description' => ['nullable', 'string', 'max:2000'], + 'sort_order' => ['nullable', 'integer', 'min:0', 'max:999999'], + 'is_active' => ['nullable', 'boolean'], + ]; + } +} diff --git a/Modules/Documentation/Http/Requests/UpdateDocumentationDocumentRequest.php b/Modules/Documentation/Http/Requests/UpdateDocumentationDocumentRequest.php new file mode 100644 index 00000000..6d64014f --- /dev/null +++ b/Modules/Documentation/Http/Requests/UpdateDocumentationDocumentRequest.php @@ -0,0 +1,45 @@ + + */ + public function rules(): array + { + /** @var DocumentationDocument|null $document */ + $document = $this->route('document'); + + return [ + 'documentation_category_id' => ['nullable', 'integer', 'exists:documentation_categories,id'], + 'title' => ['required', 'string', 'max:220'], + 'slug' => ['nullable', 'string', 'max:240', Rule::unique('documentation_documents', 'slug')->ignore($document?->id)], + 'summary' => ['nullable', 'string', 'max:500'], + 'body' => ['nullable', 'string'], + 'status' => ['required', Rule::in([DocumentationDocument::STATUS_DRAFT, DocumentationDocument::STATUS_PUBLISHED, DocumentationDocument::STATUS_ARCHIVED])], + 'is_featured' => ['nullable', 'boolean'], + 'version' => ['required', 'string', 'max:50'], + 'reference' => ['nullable', 'string', 'max:100'], + 'attachment' => ['nullable', 'file', 'max:' . config('documentation.max_upload_kb', 20480), 'mimes:' . implode(',', config('documentation.allowed_upload_mimes', []))], + 'attachments' => ['nullable', 'array'], + 'attachments.*' => ['file', 'max:' . config('documentation.max_upload_kb', 20480), 'mimes:' . implode(',', config('documentation.allowed_upload_mimes', []))], + 'remove_attachment' => ['nullable', 'boolean'], + ]; + } +} diff --git a/Modules/Documentation/Models/DocumentationAttachment.php b/Modules/Documentation/Models/DocumentationAttachment.php new file mode 100644 index 00000000..9f36876f --- /dev/null +++ b/Modules/Documentation/Models/DocumentationAttachment.php @@ -0,0 +1,69 @@ + */ + protected $fillable = [ + 'documentation_document_id', + 'disk', + 'path', + 'original_name', + 'mime_type', + 'size', + 'uploaded_by', + ]; + + /** @var array */ + protected $casts = [ + 'size' => 'integer', + ]; + + /** + * Owning document. + * + * @return BelongsTo + */ + public function document(): BelongsTo + { + return $this->belongsTo(DocumentationDocument::class, 'documentation_document_id'); + } + + /** + * Return a readable file size label. + */ + public function getSizeLabelAttribute(): string + { + $bytes = (int) $this->size; + + if ($bytes <= 0) { + return 'Unknown size'; + } + + if ($bytes < 1024) { + return $bytes . ' B'; + } + + if ($bytes < 1048576) { + return number_format($bytes / 1024, 1) . ' KB'; + } + + return number_format($bytes / 1048576, 1) . ' MB'; + } +} diff --git a/Modules/Documentation/Models/DocumentationCategory.php b/Modules/Documentation/Models/DocumentationCategory.php new file mode 100644 index 00000000..3b74f2db --- /dev/null +++ b/Modules/Documentation/Models/DocumentationCategory.php @@ -0,0 +1,87 @@ + */ + protected $fillable = [ + 'name', + 'slug', + 'icon', + 'colour', + 'description', + 'sort_order', + 'is_active', + 'created_by', + 'updated_by', + ]; + + /** @var array */ + protected $casts = [ + 'is_active' => 'boolean', + 'sort_order' => 'integer', + ]; + + /** + * Bootstrap the model. + */ + protected static function booted(): void + { + static::saving(function (DocumentationCategory $category): void { + if (blank($category->slug)) { + $category->slug = Str::slug($category->name); + } + }); + } + + /** + * Documents assigned to this category. + * + * @return HasMany + */ + public function documents(): HasMany + { + return $this->hasMany(DocumentationDocument::class, 'documentation_category_id'); + } + + /** + * Active category scope. + * + * @param Builder $query + * @return Builder + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } + + /** + * Ordered category scope. + * + * @param Builder $query + * @return Builder + */ + public function scopeOrdered(Builder $query): Builder + { + return $query->orderBy('sort_order')->orderBy('name'); + } +} diff --git a/Modules/Documentation/Models/DocumentationDocument.php b/Modules/Documentation/Models/DocumentationDocument.php new file mode 100644 index 00000000..77136f91 --- /dev/null +++ b/Modules/Documentation/Models/DocumentationDocument.php @@ -0,0 +1,152 @@ + */ + protected $fillable = [ + 'documentation_category_id', + 'title', + 'slug', + 'summary', + 'body', + 'status', + 'is_featured', + 'version', + 'reference', + 'attachment_disk', + 'attachment_path', + 'attachment_name', + 'attachment_mime', + 'attachment_size', + 'published_at', + 'created_by', + 'updated_by', + ]; + + /** @var array */ + protected $casts = [ + 'is_featured' => 'boolean', + 'published_at' => 'datetime', + 'attachment_size' => 'integer', + ]; + + /** + * Bootstrap the model. + */ + protected static function booted(): void + { + static::saving(function (DocumentationDocument $document): void { + if (blank($document->slug)) { + $document->slug = Str::slug($document->title); + } + + if ($document->status === self::STATUS_PUBLISHED && blank($document->published_at)) { + $document->published_at = now(); + } + }); + } + + /** + * Owning category. + * + * @return BelongsTo + */ + public function category(): BelongsTo + { + return $this->belongsTo(DocumentationCategory::class, 'documentation_category_id'); + } + + /** + * Document attachments. + * + * @return HasMany + */ + public function attachments(): HasMany + { + return $this->hasMany(DocumentationAttachment::class, 'documentation_document_id')->latest(); + } + + /** + * Published document scope. + * + * @param Builder $query + * @return Builder + */ + public function scopePublished(Builder $query): Builder + { + return $query->where('status', self::STATUS_PUBLISHED); + } + + /** + * Featured document scope. + * + * @param Builder $query + * @return Builder + */ + public function scopeFeatured(Builder $query): Builder + { + return $query->where('is_featured', true); + } + /** + * Render the document body as safe Markdown HTML for display. + */ + public function getRenderedBodyAttribute(): string + { + $body = trim((string) $this->body); + + if ($body === '') { + return '

No document body has been added yet.

'; + } + + if (method_exists(Str::class, 'markdown')) { + return (string) Str::markdown($body, [ + 'html_input' => 'strip', + 'allow_unsafe_links' => false, + ]); + } + + return '

' . nl2br(e($body), false) . '

'; + } + + /** + * Determine whether the attached file is a Markdown document. + */ + public function hasMarkdownAttachment(): bool + { + $name = strtolower((string) $this->attachment_name); + $mime = strtolower((string) $this->attachment_mime); + + return str_ends_with($name, '.md') || in_array($mime, ['text/markdown', 'text/x-markdown', 'text/plain'], true); + } + +} diff --git a/Modules/Documentation/Providers/RouteServiceProvider.php b/Modules/Documentation/Providers/RouteServiceProvider.php index 2702af67..bc8373f8 100644 --- a/Modules/Documentation/Providers/RouteServiceProvider.php +++ b/Modules/Documentation/Providers/RouteServiceProvider.php @@ -48,7 +48,6 @@ class RouteServiceProvider extends ServiceProvider protected function mapWebRoutes() { Route::middleware('web') - ->namespace($this->moduleNamespace) ->group(module_path('Documentation', '/Routes/web.php')); } @@ -63,7 +62,6 @@ class RouteServiceProvider extends ServiceProvider { Route::prefix('api') ->middleware('api') - ->namespace($this->moduleNamespace) ->group(module_path('Documentation', '/Routes/api.php')); } } diff --git a/Modules/Documentation/Resources/views/categories/create.blade.php b/Modules/Documentation/Resources/views/categories/create.blade.php new file mode 100644 index 00000000..c6c90cd3 --- /dev/null +++ b/Modules/Documentation/Resources/views/categories/create.blade.php @@ -0,0 +1,18 @@ +@extends('documentation::layouts.master') + +@section('title', 'Create Category') + +@section('content') + @include('documentation::partials.hero', [ + 'title' => 'Create category', + 'subtitle' => 'Add a professional grouping for documents and operational knowledge.', + ]) +
+
Category details
+
+
+ @include('documentation::partials.category-form') +
+
+
+@endsection diff --git a/Modules/Documentation/Resources/views/categories/edit.blade.php b/Modules/Documentation/Resources/views/categories/edit.blade.php new file mode 100644 index 00000000..1abbaaff --- /dev/null +++ b/Modules/Documentation/Resources/views/categories/edit.blade.php @@ -0,0 +1,18 @@ +@extends('documentation::layouts.master') + +@section('title', 'Edit Category') + +@section('content') + @include('documentation::partials.hero', [ + 'title' => 'Edit category', + 'subtitle' => 'Update category naming, ordering, visibility, and presentation metadata.', + ]) +
+
{{ $category->name }}
+
+
+ @include('documentation::partials.category-form') +
+
+
+@endsection diff --git a/Modules/Documentation/Resources/views/categories/index.blade.php b/Modules/Documentation/Resources/views/categories/index.blade.php new file mode 100644 index 00000000..10a43602 --- /dev/null +++ b/Modules/Documentation/Resources/views/categories/index.blade.php @@ -0,0 +1,57 @@ +@extends('documentation::layouts.master') + +@section('title', 'Document Categories') + +@section('content') + @include('documentation::partials.hero', [ + 'title' => 'Document categories', + 'subtitle' => 'Organise documents by policy, process, onboarding, operational support, or technical area.', + ]) + + @if(session('status')) +
{{ session('status') }}
+ @endif + +
+
+ Categories + New category +
+
+ + + + + + + + + + + + + @forelse($categories as $category) + + + + + + + + + @empty + + @endforelse + +
NameDescriptionDocumentsOrderStatus
{{ $category->name }}
{{ $category->slug }}
{{ $category->description ?: 'No description' }}{{ $category->documents_count }}{{ $category->sort_order }}{{ $category->is_active ? 'Active' : 'Inactive' }} + Edit +
+ @csrf + @method('DELETE') + +
+
No categories found.
+
{{ $categories->links() }}
+
+
+@endsection diff --git a/Modules/Documentation/Resources/views/documents/create.blade.php b/Modules/Documentation/Resources/views/documents/create.blade.php new file mode 100644 index 00000000..cc6d574f --- /dev/null +++ b/Modules/Documentation/Resources/views/documents/create.blade.php @@ -0,0 +1,18 @@ +@extends('documentation::layouts.master') + +@section('title', 'Create Document') + +@section('content') + @include('documentation::partials.hero', [ + 'title' => 'Create document', + 'subtitle' => 'Add a controlled document, guide, policy, SOP, upload, or operational note.', + ]) +
+
Document details
+
+
+ @include('documentation::partials.document-form') +
+
+
+@endsection diff --git a/Modules/Documentation/Resources/views/documents/edit.blade.php b/Modules/Documentation/Resources/views/documents/edit.blade.php new file mode 100644 index 00000000..28b0bd98 --- /dev/null +++ b/Modules/Documentation/Resources/views/documents/edit.blade.php @@ -0,0 +1,25 @@ +@extends('documentation::layouts.master') + +@section('title', 'Edit Document') + +@section('content') + @include('documentation::partials.hero', [ + 'title' => 'Edit document', + 'subtitle' => 'Maintain document metadata, workflow status, version and attachment details.', + ]) +
+
{{ $document->title }}
+
+
+ @include('documentation::partials.document-form') +
+
+
+ + @foreach($document->attachments as $attachment) +
+ @csrf + @method('DELETE') +
+ @endforeach +@endsection diff --git a/Modules/Documentation/Resources/views/documents/index.blade.php b/Modules/Documentation/Resources/views/documents/index.blade.php new file mode 100644 index 00000000..a30c84a4 --- /dev/null +++ b/Modules/Documentation/Resources/views/documents/index.blade.php @@ -0,0 +1,92 @@ +@extends('documentation::layouts.master') + +@section('title', 'Documentation') + +@section('content') + @include('documentation::partials.hero', [ + 'title' => 'Documentation Centre', + 'subtitle' => 'Create, control, publish, and archive operational documents from a clean professional register.', + ]) + + @if(session('status')) +
{{ session('status') }}
+ @endif + +
+
{{ $stats['total'] }}Total documents
+
{{ $stats['published'] }}Published
+
{{ $stats['drafts'] }}Drafts
+
{{ $stats['categories'] }}Categories
+
+ + @if($featuredDocuments->isNotEmpty()) +
+
Featured documents
+ +
+ @endif + +
+
+ Document register + New document +
+
+
+ + + + +
+ + + + + + + + + + + + + + @forelse($documents as $document) + + + + + + + + + @empty + + @endforelse + +
DocumentCategoryStatusVersionUpdated
+ {{ $document->title }} +
{{ $document->reference ?: 'No reference' }} · {{ $document->summary ?: 'No summary' }}
+
{{ $document->category?->name ?: 'Uncategorised' }}{{ ucfirst($document->status) }}{{ $document->version }}{{ $document->updated_at?->format('d M Y H:i') }}Edit
No documents found.
+
{{ $documents->links() }}
+
+
+@endsection diff --git a/Modules/Documentation/Resources/views/documents/show.blade.php b/Modules/Documentation/Resources/views/documents/show.blade.php new file mode 100644 index 00000000..9f2dbeb0 --- /dev/null +++ b/Modules/Documentation/Resources/views/documents/show.blade.php @@ -0,0 +1,53 @@ +@extends('documentation::layouts.master') + +@section('title', $document->title) + +@section('content') + @include('documentation::partials.hero', [ + 'title' => $document->title, + 'subtitle' => $document->summary ?: 'Controlled documentation record with versioning, attachments and Markdown rendering.', + 'actions' => [ + ['label' => 'Edit document', 'url' => route('documentation.documents.edit', $document), 'class' => 'primary'], + ['label' => 'Back to documents', 'url' => route('documentation.documents.index'), 'class' => 'secondary'], + ], + ]) + +
+
+
+ {{ $document->reference ?: 'Document' }} +
Version {{ $document->version }} @if($document->category) · {{ $document->category->name }} @endif
+
+ {{ ucfirst($document->status) }} +
+
+
{!! $document->rendered_body !!}
+
+
+ + @if($document->attachment_name || $document->attachments->isNotEmpty()) +
+
Attachments
+
+
+ @if($document->attachment_name) +
+ +
+ @endif + @foreach($document->attachments as $attachment) +
+
+ {{ $attachment->original_name }} + {{ $attachment->size_label }} +
+
+ @endforeach +
+
+
+ @endif +@endsection diff --git a/Modules/Documentation/Resources/views/index.blade.php b/Modules/Documentation/Resources/views/index.blade.php index 17aa4228..1c568d76 100644 --- a/Modules/Documentation/Resources/views/index.blade.php +++ b/Modules/Documentation/Resources/views/index.blade.php @@ -1,9 +1 @@ -@extends('documentation::layouts.master') - -@section('content') -

Hello World

- -

- This view is loaded from module: {!! config('documentation.name') !!} -

-@endsection +@extends('documentation::documents.index') diff --git a/Modules/Documentation/Resources/views/layouts/master.blade.php b/Modules/Documentation/Resources/views/layouts/master.blade.php index 9e5b6e8d..8d46ba1e 100644 --- a/Modules/Documentation/Resources/views/layouts/master.blade.php +++ b/Modules/Documentation/Resources/views/layouts/master.blade.php @@ -1,19 +1,141 @@ - - + + - - Module Documentation - - {{-- Laravel Mix - CSS File --}} - {{-- --}} - + {{ trim($__env->yieldContent('title', 'Documentation')) }} - {{ config('app.name', 'Laravel') }} + - - @yield('content') + +
+ @yield('content') +
+ --}} + if (! button) { + return; + } + + const editor = button.closest('[data-documentation-editor]'); + const textarea = editor ? editor.querySelector('[data-markdown-textarea]') : null; + + if (! textarea) { + return; + } + + const before = button.getAttribute('data-md-before') || ''; + const after = button.getAttribute('data-md-after') || ''; + const start = textarea.selectionStart || 0; + const end = textarea.selectionEnd || 0; + const selected = textarea.value.substring(start, end); + const replacement = before + selected + after; + + textarea.setRangeText(replacement, start, end, 'end'); + textarea.focus(); + }); + diff --git a/Modules/Documentation/Resources/views/partials/category-form.blade.php b/Modules/Documentation/Resources/views/partials/category-form.blade.php new file mode 100644 index 00000000..14657b2f --- /dev/null +++ b/Modules/Documentation/Resources/views/partials/category-form.blade.php @@ -0,0 +1,43 @@ +@csrf +@if($category->exists) + @method('PUT') +@endif +
+
+ + + @error('name')
{{ $message }}
@enderror +
+
+ + + @error('slug')
{{ $message }}
@enderror +
+
+ + + @error('icon')
{{ $message }}
@enderror +
+
+ + + @error('colour')
{{ $message }}
@enderror +
+
+ + + @error('sort_order')
{{ $message }}
@enderror +
+
+ +
+
+ + + @error('description')
{{ $message }}
@enderror +
+
+
+ + Cancel +
diff --git a/Modules/Documentation/Resources/views/partials/document-form.blade.php b/Modules/Documentation/Resources/views/partials/document-form.blade.php new file mode 100644 index 00000000..b49c33cb --- /dev/null +++ b/Modules/Documentation/Resources/views/partials/document-form.blade.php @@ -0,0 +1,109 @@ +@csrf +@if($document->exists) + @method('PUT') +@endif + +
+
+ + + @error('title')
{{ $message }}
@enderror +
+
+ + + @error('slug')
{{ $message }}
@enderror +
+
+ + + @error('documentation_category_id')
{{ $message }}
@enderror +
+
+ + + @error('status')
{{ $message }}
@enderror +
+
+ + + @error('version')
{{ $message }}
@enderror +
+
+ + + @error('reference')
{{ $message }}
@enderror +
+
+ + + @error('summary')
{{ $message }}
@enderror +
+
+ +
+
+ + + + + + + + + + +
+ +

Markdown is supported, including fenced code blocks such as ```php, tables, lists, links, and headings.

+
+ @error('body')
{{ $message }}
@enderror +
+
+ + +

Primary Markdown files can be imported into the document body when the body is blank.

+ @if($document->attachment_name) +

Current primary file: {{ $document->attachment_name }}

+ + @endif + @error('attachment')
{{ $message }}
@enderror +
+
+ + +

Attach supporting files such as PDFs, screenshots, spreadsheets, Word documents, Markdown, CSV, and images.

+ @error('attachments')
{{ $message }}
@enderror + @error('attachments.*')
{{ $message }}
@enderror + + @if($document->exists && $document->attachments->isNotEmpty()) +
+ @foreach($document->attachments as $attachment) +
+
+ {{ $attachment->original_name }} + {{ $attachment->size_label }} +
+ +
+ @endforeach +
+ @endif +
+
+ +
+
+
+ + Cancel +
diff --git a/Modules/Documentation/Resources/views/partials/hero.blade.php b/Modules/Documentation/Resources/views/partials/hero.blade.php new file mode 100644 index 00000000..782c9c31 --- /dev/null +++ b/Modules/Documentation/Resources/views/partials/hero.blade.php @@ -0,0 +1,13 @@ +
+
+

{{ $eyebrow ?? 'Documentation Suite' }}

+

{{ $title ?? 'Documentation' }}

+

{{ $subtitle ?? 'Manage controlled documents, policies, guides, files, and operational knowledge in one professional workspace.' }}

+ +
+ +
diff --git a/Modules/Documentation/Routes/api.php b/Modules/Documentation/Routes/api.php index 368fb113..f8e55aef 100644 --- a/Modules/Documentation/Routes/api.php +++ b/Modules/Documentation/Routes/api.php @@ -1,18 +1,11 @@ get('/documentation', function (Request $request) { - return $request->user(); -}); \ No newline at end of file +Route::prefix('documentation')->name('api.documentation.')->group(function (): void { + Route::get('/status', static fn (): array => [ + 'module' => 'documentation', + 'status' => 'ok', + 'version' => '1.1.0', + ])->name('status'); +}); diff --git a/Modules/Documentation/Routes/web.php b/Modules/Documentation/Routes/web.php index 99edccc4..246f75cd 100644 --- a/Modules/Documentation/Routes/web.php +++ b/Modules/Documentation/Routes/web.php @@ -1,16 +1,23 @@ group(function() { - Route::get('/', 'DocumentationController@index'); -}); +Route::prefix(config('documentation.route_prefix', 'documentation')) + ->name('documentation.') + ->middleware(config('documentation.middleware', ['web', 'auth'])) + ->group(function (): void { + Route::get('/', [DocumentationController::class, 'index'])->name('documents.index'); + 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'); + Route::get('/documents/{document}/edit', [DocumentationController::class, 'edit'])->name('documents.edit'); + Route::put('/documents/{document}', [DocumentationController::class, 'update'])->name('documents.update'); + Route::delete('/documents/{document}', [DocumentationController::class, 'destroy'])->name('documents.destroy'); + Route::get('/documents/{document}/download', [DocumentationController::class, 'download'])->name('documents.download'); + Route::get('/documents/{document}/attachments/{attachment}/download', [DocumentationController::class, 'downloadAttachment'])->name('documents.attachments.download'); + Route::delete('/documents/{document}/attachments/{attachment}', [DocumentationController::class, 'destroyAttachment'])->name('documents.attachments.destroy'); + + Route::resource('categories', DocumentationCategoryController::class)->except(['show']); + }); diff --git a/Modules/Documentation/Tests/Feature/DocumentationModuleTest.php b/Modules/Documentation/Tests/Feature/DocumentationModuleTest.php new file mode 100644 index 00000000..608049c8 --- /dev/null +++ b/Modules/Documentation/Tests/Feature/DocumentationModuleTest.php @@ -0,0 +1,176 @@ +create(); + + $this->actingAs($user) + ->get(route('documentation.documents.index')) + ->assertOk() + ->assertSee('Documentation Centre'); + } + + /** + * Documents can be created through the CRUD endpoint. + */ + public function test_document_can_be_created(): void + { + $user = \App\Models\User::factory()->create(); + $category = DocumentationCategory::query()->create([ + 'name' => 'Operations', + 'slug' => 'operations', + 'colour' => 'emerald', + 'is_active' => true, + ]); + + $response = $this->actingAs($user)->post(route('documentation.documents.store'), [ + 'documentation_category_id' => $category->id, + 'title' => 'Goods In Process', + 'slug' => 'goods-in-process', + 'summary' => 'Inbound warehouse process.', + 'body' => 'Step-by-step goods in process.', + 'status' => DocumentationDocument::STATUS_PUBLISHED, + 'version' => '1.0.0', + 'reference' => 'SOP-001', + 'is_featured' => '1', + ]); + + $response->assertRedirect(); + $this->assertDatabaseHas('documentation_documents', [ + 'title' => 'Goods In Process', + 'status' => DocumentationDocument::STATUS_PUBLISHED, + 'reference' => 'SOP-001', + 'is_featured' => true, + ]); + } + + + /** + * Markdown attachments are stored and imported into the document body. + */ + public function test_markdown_attachment_is_imported_and_rendered(): void + { + Storage::fake('public'); + + $user = \App\Models\User::factory()->create(); + $file = UploadedFile::fake()->createWithContent('goods-in.md', "# Goods In\n\n- Scan delivery note\n- Confirm quantities"); + + $response = $this->actingAs($user)->post(route('documentation.documents.store'), [ + 'title' => 'Goods In Markdown', + 'slug' => 'goods-in-markdown', + 'summary' => 'Markdown controlled document.', + 'body' => '', + 'status' => DocumentationDocument::STATUS_PUBLISHED, + 'version' => '1.0.0', + 'attachment' => $file, + ]); + + $response->assertRedirect(); + + $document = DocumentationDocument::query()->where('slug', 'goods-in-markdown')->firstOrFail(); + + $this->assertStringContainsString('# Goods In', (string) $document->body); + $this->assertStringContainsString('

Goods In

', $document->rendered_body); + Storage::disk('public')->assertExists($document->attachment_path); + } + + + + /** + * Multiple supporting attachments can be uploaded, downloaded and removed. + */ + public function test_document_supports_multiple_attachments(): void + { + Storage::fake('public'); + + $user = \App\Models\User::factory()->create(); + + $response = $this->actingAs($user)->post(route('documentation.documents.store'), [ + 'title' => 'Attachment Pack', + 'slug' => 'attachment-pack', + 'summary' => 'Document with supporting files.', + 'body' => 'Attach evidence and supporting material.', + 'status' => DocumentationDocument::STATUS_PUBLISHED, + 'version' => '1.0.0', + 'attachments' => [ + UploadedFile::fake()->createWithContent('setup.md', "```php\necho 'ok';\n```"), + UploadedFile::fake()->create('process.pdf', 64, 'application/pdf'), + ], + ]); + + $response->assertRedirect(); + + $document = DocumentationDocument::query()->where('slug', 'attachment-pack')->firstOrFail(); + + $this->assertCount(2, $document->attachments()->get()); + + $attachment = $document->attachments()->where('original_name', 'setup.md')->firstOrFail(); + Storage::disk('public')->assertExists($attachment->path); + + $this->actingAs($user) + ->get(route('documentation.documents.attachments.download', [$document, $attachment])) + ->assertOk(); + + $this->actingAs($user) + ->delete(route('documentation.documents.attachments.destroy', [$document, $attachment])) + ->assertRedirect(route('documentation.documents.edit', $document)); + + $this->assertDatabaseMissing('documentation_attachments', ['id' => $attachment->id]); + Storage::disk('public')->assertMissing($attachment->path); + } + + /** + * The editor renders Markdown controls for code blocks and tables. + */ + public function test_document_editor_has_markdown_code_controls(): void + { + $user = \App\Models\User::factory()->create(); + + $this->actingAs($user) + ->get(route('documentation.documents.create')) + ->assertOk() + ->assertSee('data-documentation-editor', false) + ->assertSee('Code block') + ->assertSee('Markdown is supported'); + } + + /** + * Categories can be created through the CRUD endpoint. + */ + public function test_category_can_be_created(): void + { + $user = \App\Models\User::factory()->create(); + + $this->actingAs($user)->post(route('documentation.categories.store'), [ + 'name' => 'Policies', + 'slug' => 'policies', + 'colour' => 'emerald', + 'description' => 'Controlled business policies.', + 'sort_order' => 1, + 'is_active' => '1', + ])->assertRedirect(route('documentation.categories.index')); + + $this->assertDatabaseHas('documentation_categories', [ + 'name' => 'Policies', + 'slug' => 'policies', + 'is_active' => true, + ]); + } +} diff --git a/Modules/Documentation/changelog.yaml b/Modules/Documentation/changelog.yaml new file mode 100644 index 00000000..ab2d7121 --- /dev/null +++ b/Modules/Documentation/changelog.yaml @@ -0,0 +1,12 @@ +version: 1.3.0 +date: 2026-06-13 +changes: + - Added professional documentation dashboard and document CRUD. + - Added document categories with status, ordering, colour, and icon support. + - Added migrations, Eloquent models, form requests, routes, and feature coverage. + - Added self-contained Abbott-style module views with accessible responsive styling. + - Added Markdown upload processing for .md files with safe rendered HTML preview. + - Added Markdown import coverage and styling for headings, code blocks, tables, and blockquotes. + - Added multiple supporting attachments per document with upload, download, removal, storage cleanup, and tests. + - Added a free built-in Markdown editor toolbar with controls for headings, bold, italic, quotes, lists, links, inline code, fenced code blocks, and tables. + - Fixed module composer metadata and synced module.json version. diff --git a/Modules/Documentation/composer.json b/Modules/Documentation/composer.json index 4c04ff19..c5500bce 100644 --- a/Modules/Documentation/composer.json +++ b/Modules/Documentation/composer.json @@ -1,6 +1,7 @@ { "name": "mpba/documentation", - "description": "", + "description": "Professional documentation and document management module.", + "type": "laravel-module", "authors": [ { "name": "Mark Gregory", @@ -10,20 +11,17 @@ "extra": { "laravel": { "providers": [], - "aliases": { - - } + "aliases": {} } }, "autoload": { "psr-4": { "Modules\\Documentation\\": "" - }, + } + }, "autoload-dev": { "psr-4": { - "Tests\\": "tests/", - "Modules\\Documentation\\Tests\\": "modules/Documentation/Tests/" - } + "Modules\\Documentation\\Tests\\": "Tests/" } } } diff --git a/Modules/Documentation/module.json b/Modules/Documentation/module.json index 96c53c88..1d6170d3 100644 --- a/Modules/Documentation/module.json +++ b/Modules/Documentation/module.json @@ -1,9 +1,14 @@ { "name": "Documentation", "alias": "documentation", - "version": "1.0.0", - "description": "", - "keywords": [], + "version": "1.3.0", + "description": "Professional document management and knowledge-base module with categories, CRUD, publishing, Markdown editing/import support, code blocks, and attachments.", + "keywords": [ + "documentation", + "documents", + "knowledge-base", + "crud" + ], "priority": 0, "providers": [ "Modules\\Documentation\\Providers\\DocumentationServiceProvider" diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-edit.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-edit.blade.php index 30924488..48320221 100644 --- a/Modules/Helpdesk/Resources/views/livewire/ticket-edit.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-edit.blade.php @@ -1,4 +1,28 @@
+ + @include('helpdesk::partials.page-header', [ 'eyebrow' => 'Helpdesk', 'title' => 'Edit Ticket', @@ -10,112 +34,197 @@
-

{{ $ticket->reference }} · Ticket controls

-

Mandatory fields are marked with *. Assignment and status updates trigger configured workflow messages.

+
+

+ {{ $ticket->reference }} · Ticket controls +

+ + + + Owner: + {{ $ticket->owner?->name ?? 'Unknown' }} + +
+ +

+ Mandatory fields are marked with *. + Assignment and status updates trigger configured workflow messages. +

- Back to ticket + + + Back to ticket +
- - - @error('subject')

{{ $message }}

@enderror + + + + + @error('subject') +

{{ $message }}

+ @enderror
@if($companies->isNotEmpty()) -
- +
+ + - @error('company_id')

{{ $message }}

@enderror + + @error('company_id') +

{{ $message }}

+ @enderror
@endif -
- +
+ + - @error('status_id')

{{ $message }}

@enderror + + @error('status_id') +

{{ $message }}

+ @enderror
-
- +
+ + - @error('priority_id')

{{ $message }}

@enderror + + @error('priority_id') +

{{ $message }}

+ @enderror
-
+
+ - @error('agent_id')

{{ $message }}

@enderror + + @error('agent_id') +

{{ $message }}

+ @enderror
-
- +
+ + - @error('category_id')

{{ $message }}

@enderror + + @error('category_id') +

{{ $message }}

+ @enderror
-
- +
+ + - @error('project_id')

{{ $message }}

@enderror + + @error('project_id') +

{{ $message }}

+ @enderror
-
- +
+ + - @error('team_id')

{{ $message }}

@enderror + + @error('team_id') +

{{ $message }}

+ @enderror
-
+
+ - @error('due_at')

{{ $message }}

@enderror + + @error('due_at') +

{{ $message }}

+ @enderror
- @error('body')

{{ $message }}

@enderror + + @error('body') +

{{ $message }}

+ @enderror
- Cancel -