From 34569840ae5dd6aa2782288150caf5582ac507ec Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Tue, 25 Aug 2026 22:36:39 +0100 Subject: [PATCH] settings updates, fixed --- Modules/Documentation/Config/config.php | 33 + ..._15_000000_create_documentation_tables.php | 79 +++ .../DocumentationCategoryController.php | 82 +++ .../Controllers/DocumentationController.php | 319 +++++++++ .../StoreDocumentationCategoryRequest.php | 35 + .../StoreDocumentationDocumentRequest.php | 41 ++ .../UpdateDocumentationCategoryRequest.php | 39 ++ .../UpdateDocumentationDocumentRequest.php | 45 ++ .../Models/DocumentationAttachment.php | 69 ++ .../Models/DocumentationCategory.php | 89 +++ .../Models/DocumentationDocument.php | 156 +++++ .../DocumentationServiceProvider.php | 58 ++ .../Providers/RouteServiceProvider.php | 67 ++ .../views/categories/create.blade.php | 20 + .../Resources/views/categories/edit.blade.php | 20 + .../views/categories/index.blade.php | 71 ++ .../views/documents/create.blade.php | 20 + .../Resources/views/documents/edit.blade.php | 30 + .../Resources/views/documents/index.blade.php | 105 +++ .../Resources/views/documents/show.blade.php | 70 ++ .../Resources/views/index.blade.php | 1 + .../Resources/views/layouts/master.blade.php | 659 ++++++++++++++++++ .../views/partials/category-form.blade.php | 61 ++ .../views/partials/document-form.blade.php | 147 ++++ .../Resources/views/partials/hero.blade.php | 21 + Modules/Documentation/Routes/api.php | 12 + Modules/Documentation/Routes/web.php | 27 + .../Tests/Feature/DocumentationModuleTest.php | 186 +++++ Modules/Documentation/changelog.yaml | 32 + Modules/Documentation/composer.json | 27 + Modules/Documentation/module.json | 19 + .../themes/backend/assets/css/helpdesk.css | 260 +++++++ .../views/layouts/app/sidebar.blade.php | 2 +- .../helpdesk/livewire/kanban-board.blade.php | 28 +- .../livewire/settings-manager.blade.php | 2 +- .../helpdesk/livewire/ticket-create.blade.php | 2 +- .../helpdesk/livewire/ticket-index.blade.php | 2 +- .../helpdesk/livewire/ticket-show.blade.php | 2 +- vite.config.js | 76 +- 39 files changed, 3007 insertions(+), 7 deletions(-) create mode 100644 Modules/Documentation/Config/config.php create mode 100644 Modules/Documentation/Database/Migrations/2026_06_15_000000_create_documentation_tables.php create mode 100644 Modules/Documentation/Http/Controllers/DocumentationCategoryController.php create mode 100644 Modules/Documentation/Http/Controllers/DocumentationController.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/Providers/DocumentationServiceProvider.php create mode 100644 Modules/Documentation/Providers/RouteServiceProvider.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/index.blade.php create mode 100644 Modules/Documentation/Resources/views/layouts/master.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/Routes/api.php create mode 100644 Modules/Documentation/Routes/web.php create mode 100644 Modules/Documentation/Tests/Feature/DocumentationModuleTest.php create mode 100644 Modules/Documentation/changelog.yaml create mode 100644 Modules/Documentation/composer.json create mode 100644 Modules/Documentation/module.json diff --git a/Modules/Documentation/Config/config.php b/Modules/Documentation/Config/config.php new file mode 100644 index 0000000..262e77a --- /dev/null +++ b/Modules/Documentation/Config/config.php @@ -0,0 +1,33 @@ + 'Documentation', + 'storage_disk' => env('DOCUMENTATION_DISK', 'public'), + 'upload_path' => env('DOCUMENTATION_UPLOAD_PATH', 'documentation'), + 'max_upload_kb' => (int) env('DOCUMENTATION_MAX_UPLOAD_KB', 20480), + 'max_markdown_import_bytes' => (int) env('DOCUMENTATION_MAX_MARKDOWN_IMPORT_BYTES', 1048576), + 'import_markdown_attachments' => true, + 'allowed_upload_mimes' => [ + 'pdf', + 'doc', + 'docx', + 'xls', + 'xlsx', + 'ppt', + 'pptx', + 'txt', + 'csv', + 'md', + 'markdown', + 'png', + 'jpg', + 'jpeg', + 'webp', + 'zip', + ], + 'statuses' => [ + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived', + ], +]; diff --git a/Modules/Documentation/Database/Migrations/2026_06_15_000000_create_documentation_tables.php b/Modules/Documentation/Database/Migrations/2026_06_15_000000_create_documentation_tables.php new file mode 100644 index 0000000..9438b1b --- /dev/null +++ b/Modules/Documentation/Database/Migrations/2026_06_15_000000_create_documentation_tables.php @@ -0,0 +1,79 @@ +id(); + $table->string('name', 150); + $table->string('slug', 180)->unique(); + $table->string('icon', 80)->nullable(); + $table->string('colour', 32)->default('blue'); + $table->text('description')->nullable(); + $table->unsignedInteger('sort_order')->default(0)->index(); + $table->boolean('is_active')->default(true)->index(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + }); + } + + if (! Schema::hasTable('documentation_documents')) { + Schema::create('documentation_documents', function (Blueprint $table): void { + $table->id(); + $table->foreignId('documentation_category_id')->nullable()->constrained('documentation_categories')->nullOnDelete(); + $table->string('title', 220); + $table->string('slug', 240)->unique(); + $table->string('summary', 500)->nullable(); + $table->longText('body')->nullable(); + $table->string('status', 40)->default('draft')->index(); + $table->boolean('is_featured')->default(false)->index(); + $table->string('version', 50)->default('1.0.0'); + $table->string('reference', 100)->nullable()->index(); + $table->string('attachment_disk')->nullable(); + $table->string('attachment_path')->nullable(); + $table->string('attachment_name')->nullable(); + $table->string('attachment_mime')->nullable(); + $table->unsignedBigInteger('attachment_size')->nullable(); + $table->timestamp('published_at')->nullable()->index(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + if (! Schema::hasTable('documentation_attachments')) { + Schema::create('documentation_attachments', function (Blueprint $table): void { + $table->id(); + $table->foreignId('documentation_document_id')->constrained('documentation_documents')->cascadeOnDelete(); + $table->string('disk')->default('public'); + $table->string('path'); + $table->string('original_name'); + $table->string('mime_type')->nullable(); + $table->unsignedBigInteger('size')->nullable(); + $table->foreignId('uploaded_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('documentation_attachments'); + Schema::dropIfExists('documentation_documents'); + Schema::dropIfExists('documentation_categories'); + } +}; diff --git a/Modules/Documentation/Http/Controllers/DocumentationCategoryController.php b/Modules/Documentation/Http/Controllers/DocumentationCategoryController.php new file mode 100644 index 0000000..ff4e8e6 --- /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 new file mode 100644 index 0000000..86927fb --- /dev/null +++ b/Modules/Documentation/Http/Controllers/DocumentationController.php @@ -0,0 +1,319 @@ +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/Modules/Documentation/Http/Requests/StoreDocumentationCategoryRequest.php b/Modules/Documentation/Http/Requests/StoreDocumentationCategoryRequest.php new file mode 100644 index 0000000..e098b99 --- /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 0000000..b2931ec --- /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 0000000..b003465 --- /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 0000000..6d64014 --- /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 0000000..9722f36 --- /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 0000000..0e87de1 --- /dev/null +++ b/Modules/Documentation/Models/DocumentationCategory.php @@ -0,0 +1,89 @@ + */ + 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 0000000..edb0f32 --- /dev/null +++ b/Modules/Documentation/Models/DocumentationDocument.php @@ -0,0 +1,156 @@ + */ + 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/DocumentationServiceProvider.php b/Modules/Documentation/Providers/DocumentationServiceProvider.php new file mode 100644 index 0000000..8f33222 --- /dev/null +++ b/Modules/Documentation/Providers/DocumentationServiceProvider.php @@ -0,0 +1,58 @@ +app->register(RouteServiceProvider::class); + + $this->mergeConfigFrom( + module_path('Documentation', 'Config/config.php'), + 'documentation', + ); + } + + /** + * Bootstrap module services. + */ + public function boot(): void + { + $this->registerViews(); + $this->loadMigrationsFrom(module_path('Documentation', 'Database/Migrations')); + } + + /** + * Register module views and anonymous components. + */ + private function registerViews(): void + { + $sourcePath = module_path('Documentation', 'Resources/views'); + $overridePath = resource_path('views/modules/documentation'); + + $this->publishes([ + $sourcePath => $overridePath, + ], ['views', 'documentation-module-views']); + + $paths = [$sourcePath]; + + foreach (Config::get('view.paths', []) as $path) { + if (is_dir($path . '/modules/documentation')) { + array_unshift($paths, $path . '/modules/documentation'); + } + } + + $this->loadViewsFrom($paths, 'documentation'); + Blade::anonymousComponentPath($sourcePath . '/components', 'documentation'); + } +} diff --git a/Modules/Documentation/Providers/RouteServiceProvider.php b/Modules/Documentation/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..6ea712a --- /dev/null +++ b/Modules/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/Modules/Documentation/Resources/views/categories/create.blade.php b/Modules/Documentation/Resources/views/categories/create.blade.php new file mode 100644 index 0000000..663adc0 --- /dev/null +++ b/Modules/Documentation/Resources/views/categories/create.blade.php @@ -0,0 +1,20 @@ +@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 0000000..944f9ec --- /dev/null +++ b/Modules/Documentation/Resources/views/categories/edit.blade.php @@ -0,0 +1,20 @@ +@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 0000000..9bb1f7d --- /dev/null +++ b/Modules/Documentation/Resources/views/categories/index.blade.php @@ -0,0 +1,71 @@ +@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 0000000..a285e93 --- /dev/null +++ b/Modules/Documentation/Resources/views/documents/create.blade.php @@ -0,0 +1,20 @@ +@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 0000000..d706f58 --- /dev/null +++ b/Modules/Documentation/Resources/views/documents/edit.blade.php @@ -0,0 +1,30 @@ +@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 0000000..b914aa8 --- /dev/null +++ b/Modules/Documentation/Resources/views/documents/index.blade.php @@ -0,0 +1,105 @@ +@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 0000000..7153e0e --- /dev/null +++ b/Modules/Documentation/Resources/views/documents/show.blade.php @@ -0,0 +1,70 @@ +@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 new file mode 100644 index 0000000..1c568d7 --- /dev/null +++ b/Modules/Documentation/Resources/views/index.blade.php @@ -0,0 +1 @@ +@extends('documentation::documents.index') diff --git a/Modules/Documentation/Resources/views/layouts/master.blade.php b/Modules/Documentation/Resources/views/layouts/master.blade.php new file mode 100644 index 0000000..ca1af18 --- /dev/null +++ b/Modules/Documentation/Resources/views/layouts/master.blade.php @@ -0,0 +1,659 @@ + + + +
+ @yield('content') +
+ + +
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 0000000..35c4156 --- /dev/null +++ b/Modules/Documentation/Resources/views/partials/category-form.blade.php @@ -0,0 +1,61 @@ +@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 0000000..1165596 --- /dev/null +++ b/Modules/Documentation/Resources/views/partials/document-form.blade.php @@ -0,0 +1,147 @@ +@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 0000000..8dbf885 --- /dev/null +++ b/Modules/Documentation/Resources/views/partials/hero.blade.php @@ -0,0 +1,21 @@ +
+
+
+
+

{{ $eyebrow ?? 'Documentation workflow' }}

+

{{ $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 new file mode 100644 index 0000000..119a57a --- /dev/null +++ b/Modules/Documentation/Routes/api.php @@ -0,0 +1,12 @@ +prefix('documentation') + ->name('api.documentation.') + ->group(function (): void { + // Reserved for tenant-aware documentation API endpoints. + }); diff --git a/Modules/Documentation/Routes/web.php b/Modules/Documentation/Routes/web.php new file mode 100644 index 0000000..06c6080 --- /dev/null +++ b/Modules/Documentation/Routes/web.php @@ -0,0 +1,27 @@ +prefix('documentation') + ->name('documentation.') + ->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/Modules/Documentation/Tests/Feature/DocumentationModuleTest.php b/Modules/Documentation/Tests/Feature/DocumentationModuleTest.php new file mode 100644 index 0000000..66dd66c --- /dev/null +++ b/Modules/Documentation/Tests/Feature/DocumentationModuleTest.php @@ -0,0 +1,186 @@ + $payload + * + * @return array + */ + private function csrfPayload(array $payload): array + { + return array_merge(['_token' => 'test-token'], $payload); + } + + /** + * The documentation dashboard renders for authenticated users. + */ + public function test_documentation_dashboard_renders(): void + { + $user = User::factory()->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 = User::factory()->create(); + $category = DocumentationCategory::query()->create([ + 'name' => 'Operations', + 'slug' => 'operations', + 'colour' => 'emerald', + 'is_active' => true, + ]); + + $response = $this->actingAs($user)->withSession(['_token' => 'test-token'])->post(route('documentation.documents.store'), $this->csrfPayload([ + '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 = User::factory()->create(); + $file = UploadedFile::fake()->createWithContent('goods-in.md', "# Goods In\n\n- Scan delivery note\n- Confirm quantities"); + + $response = $this->actingAs($user)->withSession(['_token' => 'test-token'])->post(route('documentation.documents.store'), $this->csrfPayload([ + '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 = User::factory()->create(); + + $response = $this->actingAs($user)->withSession(['_token' => 'test-token'])->post(route('documentation.documents.store'), $this->csrfPayload([ + '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) + ->withSession(['_token' => 'test-token']) + ->delete(route('documentation.documents.attachments.destroy', [$document, $attachment]), ['_token' => 'test-token']) + ->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 = 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 = User::factory()->create(); + + $this->actingAs($user)->withSession(['_token' => 'test-token'])->post(route('documentation.categories.store'), $this->csrfPayload([ + '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 0000000..21e5457 --- /dev/null +++ b/Modules/Documentation/changelog.yaml @@ -0,0 +1,32 @@ +- version: 1.6.5 + date: '2026-06-16' + changes: + - Reworked Helpdesk action styling so header, support, settings, table, save, reset, edit and delete buttons use reliable coloured native styling. + - Replaced settings section card dynamic Tailwind colour classes with inline styles so card colours always render. + - Improved Kanban board dropdown styling. + - Hardened ticket create inputs and Select2 controls to prevent white flashes during save. + +1.6.1: + date: 2026-06-15 + changes: + - Added missing Documentation RouteServiceProvider so Composer package discovery and artisan commands can boot cleanly. + +releases: + - version: 1.6.1 + date: 2026-06-15 + changes: + - Fixed missing DocumentationCategoryController binding failure for documentation category routes. + - Added explicit DocumentationServiceProvider, route files, config, and guarded migrations so the module registers cleanly in nwidart/laravel-modules. + - Preserved WMS shell styling and Documentation CRUD/Markdown/attachment functionality. + +1.6.2: + date: 2026-06-15 + changes: + - Applied explicit coloured action buttons for Documentation create, category management, register, save, cancel, filter, edit and delete actions. + - Preserved existing Documentation CRUD, Markdown editor and attachment behaviour. + +releases: + - version: 1.6.2 + date: 2026-06-15 + changes: + - Applied explicit coloured Documentation action buttons across pages and forms. diff --git a/Modules/Documentation/composer.json b/Modules/Documentation/composer.json new file mode 100644 index 0000000..c5500bc --- /dev/null +++ b/Modules/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/Modules/Documentation/module.json b/Modules/Documentation/module.json new file mode 100644 index 0000000..21d1c09 --- /dev/null +++ b/Modules/Documentation/module.json @@ -0,0 +1,19 @@ +{ + "name": "Documentation", + "alias": "documentation", + "version": "1.6.5", + "description": "Professional document management and knowledge-base module with WMS shell styling, categories, CRUD, publishing, Markdown editing/import support, code blocks, attachments, and Support navigation integration.", + "keywords": [ + "documentation", + "documents", + "knowledge-base", + "crud" + ], + "priority": 0, + "providers": [ + "Modules\\Documentation\\Providers\\DocumentationServiceProvider" + ], + "aliases": {}, + "files": [], + "requires": [] +} diff --git a/resources/themes/backend/assets/css/helpdesk.css b/resources/themes/backend/assets/css/helpdesk.css index dc1f936..4f25b6f 100644 --- a/resources/themes/backend/assets/css/helpdesk.css +++ b/resources/themes/backend/assets/css/helpdesk.css @@ -1005,3 +1005,263 @@ span.flatpickr-weekday { flex-direction: column; justify-content: center; } + + +/* ========================================================================== + Helpdesk mobile / tablet responsive layer + Keeps the Core desktop presentation while making every Helpdesk screen + usable on touch devices and narrow viewports. + ========================================================================== */ + +.hd-mobile-header { + min-height: 3.75rem; +} + +@media (max-width: 1023px) { + .hd-helpdesk-surface { + max-width: 100% !important; + width: 100% !important; + } + + .hd-helpdesk-surface > * { + min-width: 0; + } +} + +@media (max-width: 767px) { + html, + body { + overflow-x: hidden; + } + + .hd-helpdesk-surface, + .hd-helpdesk-surface.hd-mobile-ready { + width: 100%; + max-width: 100% !important; + padding-left: .75rem; + padding-right: .75rem; + margin-left: auto; + margin-right: auto; + gap: 1rem; + } + + /* Hero/page headers */ + .hd-helpdesk-surface .rounded-3xl { + border-radius: 1.15rem; + } + + .hd-helpdesk-surface .bg-gradient-to-br { + padding: 1.1rem !important; + } + + .hd-helpdesk-surface .bg-gradient-to-br h1 { + font-size: 1.65rem !important; + line-height: 1.15 !important; + } + + .hd-helpdesk-surface .bg-gradient-to-br p { + max-width: 100% !important; + } + + /* All action rows become thumb-friendly full-width grids. */ + .hd-helpdesk-surface .hd-btn { + min-height: 2.75rem; + justify-content: center; + width: 100%; + padding-inline: .9rem; + } + + .hd-helpdesk-surface .bg-gradient-to-br .flex.flex-wrap.gap-2 { + display: grid !important; + grid-template-columns: repeat(2, minmax(0, 1fr)); + width: 100%; + gap: .55rem !important; + } + + /* Dashboard metrics */ + .hd-helpdesk-surface .grid.md\:grid-cols-2, + .hd-helpdesk-surface .grid.lg\:grid-cols-4, + .hd-helpdesk-surface .grid.xl\:grid-cols-4 { + grid-template-columns: 1fr !important; + } + + /* Settings cards: two compact coloured cards per row, one on very small phones. */ + .hd-core-settings > .grid, + .hd-helpdesk-surface[dusk="helpdesk-settings-manager"] > .grid { + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + gap: .55rem !important; + } + + .hd-core-settings > .grid > a, + .hd-helpdesk-surface[dusk="helpdesk-settings-manager"] > .grid > a { + min-width: 0; + min-height: 4.4rem; + padding: .75rem !important; + border-radius: .8rem !important; + } + + .hd-core-settings > .grid > a .text-2xl, + .hd-helpdesk-surface[dusk="helpdesk-settings-manager"] > .grid > a .text-2xl { + font-size: 1.25rem !important; + } + + /* Configuration forms */ + .hd-core-settings form .grid, + .hd-helpdesk-surface form .grid { + grid-template-columns: 1fr !important; + } + + .hd-core-settings input, + .hd-core-settings select, + .hd-core-settings textarea, + .hd-helpdesk-surface input, + .hd-helpdesk-surface select, + .hd-helpdesk-surface textarea { + min-height: 2.8rem; + font-size: 16px !important; /* prevents iOS input zoom */ + } + + .hd-helpdesk-surface textarea { + min-height: 8rem; + } + + /* Search/filter bars */ + .hd-helpdesk-surface .grid.md\:grid-cols-4, + .hd-helpdesk-surface .grid.lg\:grid-cols-4, + .hd-helpdesk-surface .grid.xl\:grid-cols-4 { + grid-template-columns: 1fr !important; + } + + /* Tables remain usable rather than crushing columns. */ + .hd-helpdesk-surface .overflow-x-auto, + .hd-core-settings .overflow-x-auto { + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; + } + + .hd-helpdesk-surface table, + .hd-core-settings table { + min-width: 760px; + } + + .hd-helpdesk-surface th, + .hd-helpdesk-surface td, + .hd-core-settings th, + .hd-core-settings td { + white-space: nowrap; + } + + /* Ticket list table gets a slightly smaller minimum than config tables. */ + .hd-helpdesk-surface[dusk="helpdesk-ticket-index"] table { + min-width: 680px; + } + + /* Action button groups wrap instead of overflowing. */ + .hd-helpdesk-surface td .flex, + .hd-core-settings td .flex { + flex-wrap: wrap; + } + + /* Ticket show page */ + .hd-helpdesk-surface .xl\:grid-cols-\[minmax\(0\,1fr\)_20rem\], + .hd-helpdesk-surface .lg\:grid-cols-\[minmax\(0\,1fr\)_20rem\] { + grid-template-columns: 1fr !important; + } + + /* Attachments/code blocks/content */ + .hd-helpdesk-surface pre, + .hd-helpdesk-surface code, + .hd-helpdesk-surface .prose { + max-width: 100%; + overflow-x: auto; + word-break: break-word; + } + + /* Kanban: touch-friendly horizontal lanes with scroll snapping. */ + [data-helpdesk-kanban-board] { + display: flex !important; + gap: .75rem !important; + overflow-x: auto !important; + overscroll-behavior-inline: contain; + scroll-snap-type: x mandatory; + scroll-padding-left: .75rem; + padding-bottom: 1rem !important; + margin-inline: -.75rem; + padding-inline: .75rem; + -webkit-overflow-scrolling: touch; + } + + [data-helpdesk-kanban-board] > section { + width: min(88vw, 330px) !important; + min-width: min(88vw, 330px) !important; + min-height: calc(100dvh - 16rem) !important; + scroll-snap-align: start; + border-radius: 1rem !important; + } + + [data-helpdesk-kanban-column] { + min-height: 24rem !important; + } + + [data-helpdesk-kanban-ticket] { + cursor: default !important; + touch-action: pan-y; + } + + .hd-kanban-mobile-move { + display: block; + } + + /* Board selector */ + .hd-select-wrap, + .hd-ticket-board-select-wrap { + width: 100% !important; + } + + /* Modal/dialog safety */ + [role="dialog"], + [data-flux-modal] { + max-width: calc(100vw - 1.5rem) !important; + max-height: calc(100dvh - 1.5rem) !important; + overflow-y: auto; + } +} + +@media (max-width: 479px) { + .hd-helpdesk-surface, + .hd-helpdesk-surface.hd-mobile-ready { + padding-left: .55rem; + padding-right: .55rem; + } + + .hd-helpdesk-surface .bg-gradient-to-br .flex.flex-wrap.gap-2 { + grid-template-columns: 1fr; + } + + .hd-core-settings > .grid, + .hd-helpdesk-surface[dusk="helpdesk-settings-manager"] > .grid { + grid-template-columns: 1fr !important; + } + + [data-helpdesk-kanban-board] > section { + width: 91vw !important; + min-width: 91vw !important; + } + + .hd-helpdesk-surface .text-3xl, + .hd-helpdesk-surface .text-4xl { + font-size: 1.55rem !important; + } +} + +/* Touch-only devices: rely on mobile status selector instead of requiring DnD. */ +@media (hover: none) and (pointer: coarse) { + [data-helpdesk-kanban-ticket] { + -webkit-user-drag: none; + user-select: none; + } + + .hd-kanban-mobile-move { + display: block !important; + } +} diff --git a/resources/themes/backend/views/layouts/app/sidebar.blade.php b/resources/themes/backend/views/layouts/app/sidebar.blade.php index 258def4..5b64820 100644 --- a/resources/themes/backend/views/layouts/app/sidebar.blade.php +++ b/resources/themes/backend/views/layouts/app/sidebar.blade.php @@ -85,7 +85,7 @@ - +
@@ -197,6 +197,32 @@ SLA breach
@endif + @can('update', $ticket) +
+ + +
+ @endcan +
+
diff --git a/resources/themes/backend/views/modules/helpdesk/livewire/ticket-create.blade.php b/resources/themes/backend/views/modules/helpdesk/livewire/ticket-create.blade.php index d235e5b..b6c41de 100644 --- a/resources/themes/backend/views/modules/helpdesk/livewire/ticket-create.blade.php +++ b/resources/themes/backend/views/modules/helpdesk/livewire/ticket-create.blade.php @@ -5,7 +5,7 @@ ->unique() ->implode(','); @endphp -
+
@include('modules.helpdesk.partials.page-header', [ 'eyebrow' => 'Helpdesk', 'title' => 'Create ticket', diff --git a/resources/themes/backend/views/modules/helpdesk/livewire/ticket-index.blade.php b/resources/themes/backend/views/modules/helpdesk/livewire/ticket-index.blade.php index ef80e14..55cf205 100644 --- a/resources/themes/backend/views/modules/helpdesk/livewire/ticket-index.blade.php +++ b/resources/themes/backend/views/modules/helpdesk/livewire/ticket-index.blade.php @@ -1,4 +1,4 @@ -
+
diff --git a/resources/themes/backend/views/modules/helpdesk/livewire/ticket-show.blade.php b/resources/themes/backend/views/modules/helpdesk/livewire/ticket-show.blade.php index ec6acb1..95f9d61 100644 --- a/resources/themes/backend/views/modules/helpdesk/livewire/ticket-show.blade.php +++ b/resources/themes/backend/views/modules/helpdesk/livewire/ticket-show.blade.php @@ -23,7 +23,7 @@ @endphp -
+
diff --git a/vite.config.js b/vite.config.js index 5317530..19ce398 100644 --- a/vite.config.js +++ b/vite.config.js @@ -2,6 +2,68 @@ import { defineConfig } from 'vite'; import laravel from 'laravel-vite-plugin'; import { bunny } from 'laravel-vite-plugin/fonts'; import tailwindcss from '@tailwindcss/vite'; +import { existsSync, readdirSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +const projectRoot = process.cwd(); + +/** + * Discover module CSS/JS entry points. + * + * Supported module layouts: + * + * Modules/Foo/Resources/assets/css/app.css + * Modules/Foo/Resources/assets/js/app.js + * + * and the lower-case equivalent: + * + * Modules/Foo/resources/assets/css/app.css + * Modules/Foo/resources/assets/js/app.js + */ +function discoverModuleInputs() { + const modulesPath = join(projectRoot, 'Modules'); + + if (!existsSync(modulesPath)) { + return []; + } + + const inputs = []; + + for (const moduleName of readdirSync(modulesPath)) { + const modulePath = join(modulesPath, moduleName); + + const candidates = [ + join(modulePath, 'Resources/assets/css/app.css'), + join(modulePath, 'Resources/assets/js/app.js'), + join(modulePath, 'resources/assets/css/app.css'), + join(modulePath, 'resources/assets/js/app.js'), + ]; + + for (const candidate of candidates) { + if (existsSync(candidate)) { + inputs.push(relative(projectRoot, candidate)); + } + } + } + + return inputs; +} + +const moduleInputs = discoverModuleInputs(); + +console.log('[Vite] Backend theme inputs:'); +console.log(' resources/themes/backend/assets/css/app.css'); +console.log(' resources/themes/backend/assets/js/passkeys.js'); + +if (moduleInputs.length > 0) { + console.log('[Vite] Module inputs:'); + + for (const input of moduleInputs) { + console.log(` ${input}`); + } +} else { + console.log('[Vite] No module asset entry points discovered.'); +} export default defineConfig({ plugins: [ @@ -9,26 +71,38 @@ export default defineConfig({ input: [ 'resources/themes/backend/assets/css/app.css', 'resources/themes/backend/assets/js/passkeys.js', + ...moduleInputs, ], + refresh: [ 'resources/themes/backend/views/**/*.blade.php', 'resources/themes/backend/assets/**/*', - 'Modules/Helpdesk/**/*.php', + + 'Modules/*/Resources/views/**/*.blade.php', + 'Modules/*/Resources/assets/**/*', + 'Modules/*/resources/views/**/*.blade.php', + 'Modules/*/resources/assets/**/*', + + 'Modules/**/*.php', ], + fonts: [ bunny('Instrument Sans', { weights: [400, 500, 600], }), ], }), + tailwindcss(), ], server: { cors: true, + watch: { ignored: [ '**/storage/framework/views/**', + '**/Modules/*/node_modules/**', ], }, },