From 04f8124e6501af9cefc6f7328834705d93fe5306 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Mon, 15 Jun 2026 07:54:26 +0100 Subject: [PATCH] Added Documentation --- Documentation/Config/config.php | 25 ++ ..._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 | 320 ++++++++++++++++++ .../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 | 67 ++++ .../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 +++ Documentation/Resources/views/index.blade.php | 1 + .../Resources/views/layouts/master.blade.php | 141 ++++++++ .../views/partials/category-form.blade.php | 43 +++ .../views/partials/document-form.blade.php | 109 ++++++ .../Resources/views/partials/hero.blade.php | 13 + Documentation/Routes/api.php | 11 + Documentation/Routes/web.php | 24 ++ .../Tests/Feature/DocumentationModuleTest.php | 176 ++++++++++ Documentation/changelog.yaml | 14 + Documentation/composer.json | 27 ++ Documentation/module.json | 19 ++ .../partials/wms-support-navigation-usage.md | 9 + .../partials/wms-support-navigation.blade.php | 98 ++++++ 34 files changed, 2045 insertions(+) create mode 100644 Documentation/Config/config.php create mode 100644 Documentation/Database/Migrations/2026_06_12_000000_create_documentation_categories_table.php create mode 100644 Documentation/Database/Migrations/2026_06_12_000010_create_documentation_documents_table.php create mode 100644 Documentation/Database/Migrations/2026_06_12_000020_create_documentation_attachments_table.php create mode 100644 Documentation/Http/Controllers/DocumentationCategoryController.php create mode 100644 Documentation/Http/Controllers/DocumentationController.php create mode 100644 Documentation/Http/Requests/StoreDocumentationCategoryRequest.php create mode 100644 Documentation/Http/Requests/StoreDocumentationDocumentRequest.php create mode 100644 Documentation/Http/Requests/UpdateDocumentationCategoryRequest.php create mode 100644 Documentation/Http/Requests/UpdateDocumentationDocumentRequest.php create mode 100644 Documentation/Models/DocumentationAttachment.php create mode 100644 Documentation/Models/DocumentationCategory.php create mode 100644 Documentation/Models/DocumentationDocument.php create mode 100644 Documentation/Providers/RouteServiceProvider.php create mode 100644 Documentation/Resources/views/categories/create.blade.php create mode 100644 Documentation/Resources/views/categories/edit.blade.php create mode 100644 Documentation/Resources/views/categories/index.blade.php create mode 100644 Documentation/Resources/views/documents/create.blade.php create mode 100644 Documentation/Resources/views/documents/edit.blade.php create mode 100644 Documentation/Resources/views/documents/index.blade.php create mode 100644 Documentation/Resources/views/documents/show.blade.php create mode 100644 Documentation/Resources/views/index.blade.php create mode 100644 Documentation/Resources/views/layouts/master.blade.php create mode 100644 Documentation/Resources/views/partials/category-form.blade.php create mode 100644 Documentation/Resources/views/partials/document-form.blade.php create mode 100644 Documentation/Resources/views/partials/hero.blade.php create mode 100644 Documentation/Routes/api.php create mode 100644 Documentation/Routes/web.php create mode 100644 Documentation/Tests/Feature/DocumentationModuleTest.php create mode 100644 Documentation/changelog.yaml create mode 100644 Documentation/composer.json create mode 100644 Documentation/module.json create mode 100644 resources/views/partials/wms-support-navigation-usage.md create mode 100644 resources/views/partials/wms-support-navigation.blade.php diff --git a/Documentation/Config/config.php b/Documentation/Config/config.php new file mode 100644 index 00000000..4cc6008f --- /dev/null +++ b/Documentation/Config/config.php @@ -0,0 +1,25 @@ + 'Documentation', + '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/Documentation/Database/Migrations/2026_06_12_000000_create_documentation_categories_table.php b/Documentation/Database/Migrations/2026_06_12_000000_create_documentation_categories_table.php new file mode 100644 index 00000000..4d932cff --- /dev/null +++ b/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/Documentation/Database/Migrations/2026_06_12_000010_create_documentation_documents_table.php b/Documentation/Database/Migrations/2026_06_12_000010_create_documentation_documents_table.php new file mode 100644 index 00000000..5a05c111 --- /dev/null +++ b/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/Documentation/Database/Migrations/2026_06_12_000020_create_documentation_attachments_table.php b/Documentation/Database/Migrations/2026_06_12_000020_create_documentation_attachments_table.php new file mode 100644 index 00000000..c0764708 --- /dev/null +++ b/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/Documentation/Http/Controllers/DocumentationCategoryController.php b/Documentation/Http/Controllers/DocumentationCategoryController.php new file mode 100644 index 00000000..ff4e8e63 --- /dev/null +++ b/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/Documentation/Http/Controllers/DocumentationController.php b/Documentation/Http/Controllers/DocumentationController.php new file mode 100644 index 00000000..62071bea --- /dev/null +++ b/Documentation/Http/Controllers/DocumentationController.php @@ -0,0 +1,320 @@ +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 create document form. + */ + public function create(): View + { + 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 document. + */ + 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.'); + } + + /** + * Display a document. + */ + public function show(DocumentationDocument $document): View + { + return view('documentation::documents.show', [ + 'document' => $document->load(['category', 'attachments']), + ]); + } + + /** + * Show the edit document form. + */ + public function edit(DocumentationDocument $document): View + { + return view('documentation::documents.edit', [ + 'document' => $document->load('attachments'), + 'categories' => DocumentationCategory::query()->active()->ordered()->get(), + 'statuses' => config('documentation.statuses', []), + ]); + } + + /** + * Update an existing document. + */ + 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 a document. + */ + 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/Documentation/Http/Requests/StoreDocumentationCategoryRequest.php b/Documentation/Http/Requests/StoreDocumentationCategoryRequest.php new file mode 100644 index 00000000..e098b99d --- /dev/null +++ b/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/Documentation/Http/Requests/StoreDocumentationDocumentRequest.php b/Documentation/Http/Requests/StoreDocumentationDocumentRequest.php new file mode 100644 index 00000000..b2931ecf --- /dev/null +++ b/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/Documentation/Http/Requests/UpdateDocumentationCategoryRequest.php b/Documentation/Http/Requests/UpdateDocumentationCategoryRequest.php new file mode 100644 index 00000000..b003465d --- /dev/null +++ b/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/Documentation/Http/Requests/UpdateDocumentationDocumentRequest.php b/Documentation/Http/Requests/UpdateDocumentationDocumentRequest.php new file mode 100644 index 00000000..6d64014f --- /dev/null +++ b/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/Documentation/Models/DocumentationAttachment.php b/Documentation/Models/DocumentationAttachment.php new file mode 100644 index 00000000..9f36876f --- /dev/null +++ b/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/Documentation/Models/DocumentationCategory.php b/Documentation/Models/DocumentationCategory.php new file mode 100644 index 00000000..3b74f2db --- /dev/null +++ b/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/Documentation/Models/DocumentationDocument.php b/Documentation/Models/DocumentationDocument.php new file mode 100644 index 00000000..77136f91 --- /dev/null +++ b/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/Documentation/Providers/RouteServiceProvider.php b/Documentation/Providers/RouteServiceProvider.php new file mode 100644 index 00000000..bc8373f8 --- /dev/null +++ b/Documentation/Providers/RouteServiceProvider.php @@ -0,0 +1,67 @@ +mapApiRoutes(); + + $this->mapWebRoutes(); + } + + /** + * Define the "web" routes for the application. + * + * These routes all receive session state, CSRF protection, etc. + * + * @return void + */ + protected function mapWebRoutes() + { + Route::middleware('web') + ->group(module_path('Documentation', '/Routes/web.php')); + } + + /** + * Define the "api" routes for the application. + * + * These routes are typically stateless. + * + * @return void + */ + protected function mapApiRoutes() + { + Route::prefix('api') + ->middleware('api') + ->group(module_path('Documentation', '/Routes/api.php')); + } +} diff --git a/Documentation/Resources/views/categories/create.blade.php b/Documentation/Resources/views/categories/create.blade.php new file mode 100644 index 00000000..c6c90cd3 --- /dev/null +++ b/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/Documentation/Resources/views/categories/edit.blade.php b/Documentation/Resources/views/categories/edit.blade.php new file mode 100644 index 00000000..1abbaaff --- /dev/null +++ b/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/Documentation/Resources/views/categories/index.blade.php b/Documentation/Resources/views/categories/index.blade.php new file mode 100644 index 00000000..10a43602 --- /dev/null +++ b/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/Documentation/Resources/views/documents/create.blade.php b/Documentation/Resources/views/documents/create.blade.php new file mode 100644 index 00000000..cc6d574f --- /dev/null +++ b/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/Documentation/Resources/views/documents/edit.blade.php b/Documentation/Resources/views/documents/edit.blade.php new file mode 100644 index 00000000..28b0bd98 --- /dev/null +++ b/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/Documentation/Resources/views/documents/index.blade.php b/Documentation/Resources/views/documents/index.blade.php new file mode 100644 index 00000000..a30c84a4 --- /dev/null +++ b/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/Documentation/Resources/views/documents/show.blade.php b/Documentation/Resources/views/documents/show.blade.php new file mode 100644 index 00000000..9f2dbeb0 --- /dev/null +++ b/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/Documentation/Resources/views/index.blade.php b/Documentation/Resources/views/index.blade.php new file mode 100644 index 00000000..1c568d76 --- /dev/null +++ b/Documentation/Resources/views/index.blade.php @@ -0,0 +1 @@ +@extends('documentation::documents.index') diff --git a/Documentation/Resources/views/layouts/master.blade.php b/Documentation/Resources/views/layouts/master.blade.php new file mode 100644 index 00000000..8d46ba1e --- /dev/null +++ b/Documentation/Resources/views/layouts/master.blade.php @@ -0,0 +1,141 @@ + + + + + + {{ trim($__env->yieldContent('title', 'Documentation')) }} - {{ config('app.name', 'Laravel') }} + + + +
+ @yield('content') +
+ + + diff --git a/Documentation/Resources/views/partials/category-form.blade.php b/Documentation/Resources/views/partials/category-form.blade.php new file mode 100644 index 00000000..14657b2f --- /dev/null +++ b/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/Documentation/Resources/views/partials/document-form.blade.php b/Documentation/Resources/views/partials/document-form.blade.php new file mode 100644 index 00000000..b49c33cb --- /dev/null +++ b/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/Documentation/Resources/views/partials/hero.blade.php b/Documentation/Resources/views/partials/hero.blade.php new file mode 100644 index 00000000..782c9c31 --- /dev/null +++ b/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/Documentation/Routes/api.php b/Documentation/Routes/api.php new file mode 100644 index 00000000..f8e55aef --- /dev/null +++ b/Documentation/Routes/api.php @@ -0,0 +1,11 @@ +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/Documentation/Routes/web.php b/Documentation/Routes/web.php new file mode 100644 index 00000000..eca35047 --- /dev/null +++ b/Documentation/Routes/web.php @@ -0,0 +1,24 @@ +name('documentation.') + ->middleware(config('documentation.middleware', ['web', 'auth'])) + ->group(function (): void { + Route::get('/', [DocumentationController::class, 'index'])->name('index'); + Route::get('/documents', [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/Documentation/Tests/Feature/DocumentationModuleTest.php b/Documentation/Tests/Feature/DocumentationModuleTest.php new file mode 100644 index 00000000..608049c8 --- /dev/null +++ b/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/Documentation/changelog.yaml b/Documentation/changelog.yaml new file mode 100644 index 00000000..83197344 --- /dev/null +++ b/Documentation/changelog.yaml @@ -0,0 +1,14 @@ +version: 1.4.0 +date: 2026-06-15 +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. + - Added WMS Support sidebar integration for Documentation, New Document, and Categories links using the existing sidebar colour scheme and active-state conventions. + - Added a root documentation.index route while preserving documentation.documents.index for CRUD navigation. + - Fixed module composer metadata and synced module.json version. diff --git a/Documentation/composer.json b/Documentation/composer.json new file mode 100644 index 00000000..c5500bce --- /dev/null +++ b/Documentation/composer.json @@ -0,0 +1,27 @@ +{ + "name": "mpba/documentation", + "description": "Professional documentation and document management module.", + "type": "laravel-module", + "authors": [ + { + "name": "Mark Gregory", + "email": "mark.gregory@mpba.co.uk" + } + ], + "extra": { + "laravel": { + "providers": [], + "aliases": {} + } + }, + "autoload": { + "psr-4": { + "Modules\\Documentation\\": "" + } + }, + "autoload-dev": { + "psr-4": { + "Modules\\Documentation\\Tests\\": "Tests/" + } + } +} diff --git a/Documentation/module.json b/Documentation/module.json new file mode 100644 index 00000000..0564e5c4 --- /dev/null +++ b/Documentation/module.json @@ -0,0 +1,19 @@ +{ + "name": "Documentation", + "alias": "documentation", + "version": "1.4.0", + "description": "Professional document management and knowledge-base module with categories, CRUD, publishing, Markdown editing/import support, code blocks, attachments, and WMS Support navigation integration.", + "keywords": [ + "documentation", + "documents", + "knowledge-base", + "crud" + ], + "priority": 0, + "providers": [ + "Modules\\Documentation\\Providers\\DocumentationServiceProvider" + ], + "aliases": {}, + "files": [], + "requires": [] +} diff --git a/resources/views/partials/wms-support-navigation-usage.md b/resources/views/partials/wms-support-navigation-usage.md new file mode 100644 index 00000000..22f62015 --- /dev/null +++ b/resources/views/partials/wms-support-navigation-usage.md @@ -0,0 +1,9 @@ +# WMS support navigation integration + +Replace the existing `{{-- Support Portal --}} ... {{-- Support Portal end --}}` block in the application shell with: + +```blade +@include('partials.wms-support-navigation') +``` + +The partial keeps the existing WMS sidebar classes, FontAwesome icon pattern, `wire:navigate`, mobile-close behaviour, and active-state conventions. diff --git a/resources/views/partials/wms-support-navigation.blade.php b/resources/views/partials/wms-support-navigation.blade.php new file mode 100644 index 00000000..433ecec5 --- /dev/null +++ b/resources/views/partials/wms-support-navigation.blade.php @@ -0,0 +1,98 @@ +{{-- Support Portal + Documentation --}} +@php + $helpdeskEnabled = Route::has('helpdesk.index') || Route::has('helpdesk.tickets.index'); + $documentationEnabled = Route::has('documentation.documents.index') || Route::has('documentation.categories.index'); + + $helpdeskSettingsRoute = Route::has('helpdesk.settings') + ? 'helpdesk.settings' + : (Route::has('helpdesk.settings.index') ? 'helpdesk.settings.index' : null); + + $helpdeskKanbanRoute = Route::has('helpdesk.kanban') + ? 'helpdesk.kanban' + : (Route::has('helpdesk.kanban.index') ? 'helpdesk.kanban.index' : null); + + $helpdeskGitRoute = Route::has('helpdesk.git.index') + ? 'helpdesk.git.index' + : null; + + $helpdeskWorkflowRoute = Route::has('helpdesk.workflow-templates.index') + ? 'helpdesk.workflow-templates.index' + : null; +@endphp + +@if($helpdeskEnabled || $documentationEnabled) +
+
{{ __('Support') }}
+ + @if(Route::has('helpdesk.index')) + + + {{ __('Dashboard') }} + + @endif + + @if(Route::has('helpdesk.tickets.index')) + + + {{ __('Tickets') }} + + @endif + + @if(Route::has('helpdesk.tickets.create')) + + + {{ __('Raise Ticket') }} + + @endif + + @if($helpdeskKanbanRoute) + + + {{ __('Kanban') }} + + @endif + + @if($documentationEnabled) + + + {{ __('Documentation') }} + + @endif + + @if(Route::has('documentation.documents.create')) + + + {{ __('New Document') }} + + @endif + + @if(Route::has('documentation.categories.index')) + + + {{ __('Categories') }} + + @endif + + @if($helpdeskGitRoute) + + + {{ __('Git Tracking') }} + + @endif + + @if($helpdeskWorkflowRoute) + + + {{ __('Workflow Emails') }} + + @endif + + @if($helpdeskSettingsRoute) + + + {{ __('Settings') }} + + @endif +
+@endif +{{-- Support Portal + Documentation end --}}