Files
Ajjj ccbd23d474
Some checks failed
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
Tests / PHP 8.5 (push) Has been cancelled
feat: enhance module RBAC permissions, financial billing workflows, task evidence uploading, and role analytics
2026-08-13 18:46:26 +08:00

293 lines
11 KiB
PHP

<?php
namespace Modules\DocumentManagement\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Inertia\Inertia;
use Modules\DocumentManagement\Models\Document;
use Modules\DocumentManagement\Models\DocumentCategory;
use Modules\ProjectManagement\Models\Project;
class DocumentController extends Controller
{
public function index(Request $request)
{
$query = Document::with(['uploader:id,name,contractor_id', 'category', 'project', 'approvals.approver']);
if ($search = $request->search) {
$query->where('title', 'like', "%{$search}%");
}
if ($categoryId = $request->category_id) {
$query->where('category_id', $categoryId);
}
if ($projectId = $request->project_id) {
$query->where('project_id', $projectId);
}
if ($status = $request->status) {
$query->where('status', $status);
}
if ($type = $request->documentable_type) {
$query->where('documentable_type', $type);
}
$documents = $query->latest()->paginate(20)->withQueryString();
$categories = DocumentCategory::all();
$projects = Project::select('id', 'name')->get();
return Inertia::render('DocumentManagement::Documents/Index', [
'documents' => $documents,
'categories' => $categories,
'projects' => $projects,
'filters' => $request->only(['search', 'category_id', 'project_id', 'status', 'documentable_type']),
]);
}
public function upload(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'category_id' => 'required|exists:document_categories,id',
'project_id' => 'nullable|exists:projects,id',
'description' => 'nullable|string',
'documentable_type' => 'nullable|string',
'documentable_id' => 'nullable|integer',
'file' => 'required|file|max:51200|mimes:pdf',
]);
$file = $request->file('file');
$category = DocumentCategory::find($validated['category_id']);
$path = $file->store("documents/" . str()->slug($category->name), 'local');
$document = Document::create([
'title' => $validated['title'],
'category_id' => $validated['category_id'],
'project_id' => $validated['project_id'] ?? null,
'description' => $validated['description'] ?? null,
'documentable_type' => $validated['documentable_type'] ?? null,
'documentable_id' => $validated['documentable_id'] ?? null,
'status' => 'Approved',
'uploaded_by' => $request->user()->id,
'current_file_path' => $path,
'current_file_name' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
'file_size' => $file->getSize(),
'version_count' => 1,
]);
// Create initial version
$document->versions()->create([
'version_number' => 1,
'file_path' => $path,
'file_name' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
'file_size' => $file->getSize(),
'uploaded_by' => $request->user()->id,
'change_notes' => 'Initial upload',
]);
return back()->with('success', 'Document uploaded successfully.');
}
public function uploadVersion(Request $request, Document $document)
{
$validated = $request->validate([
'file' => 'required|file|max:51200|mimes:pdf',
'change_notes' => 'nullable|string|max:500',
]);
$file = $request->file('file');
$category = $document->category;
$path = $file->store("documents/" . str()->slug($category->name), 'local');
$document->addVersion(
$path,
$file->getClientOriginalName(),
$file->getMimeType(),
$file->getSize(),
$request->user()->id,
$validated['change_notes'] ?? null,
);
// Keep status as Approved when a new version is uploaded
$document->update(['status' => 'Approved']);
return back()->with('success', 'New document version uploaded successfully.');
}
public function download(Document $document)
{
$disk = $document->current_file_path ? $this->resolveDisk($document->current_file_path) : null;
if (!$disk) {
return back()->with('error', 'File not found.');
}
return $disk->download($document->current_file_path, $document->current_file_name);
}
public function preview(Document $document)
{
$disk = $document->current_file_path ? $this->resolveDisk($document->current_file_path) : null;
if (!$disk) {
abort(404, 'File not found.');
}
$mimeType = $document->mime_type ?: $disk->mimeType($document->current_file_path);
$extension = strtolower(pathinfo($document->current_file_name ?: $document->current_file_path, PATHINFO_EXTENSION));
$isDocx = $extension === 'docx' || $mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
if ($isDocx) {
return $this->renderDocxPreview($disk->path($document->current_file_path), $document->current_file_name ?: 'document.docx');
}
if (!in_array($mimeType, ['application/pdf'], true) && !str_starts_with((string) $mimeType, 'image/')) {
abort(404, 'This file type does not support inline preview.');
}
return response()->file($disk->path($document->current_file_path), [
'Content-Type' => $mimeType,
'Content-Disposition' => 'inline; filename="' . addcslashes($document->current_file_name ?: 'document', '"\\') . '"',
]);
}
private function renderDocxPreview(string $path, string $fileName)
{
if (!class_exists(\ZipArchive::class)) {
abort(501, 'DOCX preview requires the PHP ZIP extension.');
}
$archive = new \ZipArchive();
if ($archive->open($path) !== true) {
abort(422, 'The DOCX file could not be opened.');
}
$documentXml = $archive->getFromName('word/document.xml');
$archive->close();
if (!$documentXml) {
abort(422, 'The DOCX document content could not be read.');
}
$xml = new \DOMDocument();
$xml->loadXML($documentXml, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING);
$xpath = new \DOMXPath($xml);
$content = [];
$extractText = static function (\DOMNode $node) use ($xpath): string {
$text = '';
foreach ($xpath->query('.//*[local-name()="t"]', $node) as $textNode) {
$text .= $textNode->textContent;
}
return trim($text);
};
$body = $xpath->query('//*[local-name()="body"]')->item(0);
foreach ($body?->childNodes ?? [] as $block) {
if ($block->localName === 'p') {
$text = $extractText($block);
if ($text !== '') {
$content[] = '<p>' . e($text) . '</p>';
}
continue;
}
if ($block->localName === 'tbl') {
$rowsHtml = [];
foreach ($xpath->query('./*[local-name()="tr"]', $block) as $row) {
$cellsHtml = [];
foreach ($xpath->query('./*[local-name()="tc"]', $row) as $cell) {
$cellsHtml[] = '<td>' . e($extractText($cell)) . '</td>';
}
if ($cellsHtml !== []) {
$rowsHtml[] = '<tr>' . implode('', $cellsHtml) . '</tr>';
}
}
if ($rowsHtml !== []) {
$content[] = '<table><tbody>' . implode('', $rowsHtml) . '</tbody></table>';
}
}
}
$html = '<!doctype html><html><head><meta charset="utf-8"><title>' . e($fileName) . '</title>'
. '<style>*{box-sizing:border-box}html{background:#f3f4f6}body{font-family:Arial,sans-serif;line-height:1.6;color:#1f2937;width:8.5in;min-height:11in;margin:16px auto;padding:.6in;background:#fff;box-shadow:0 2px 12px rgba(15,23,42,.14)}p{margin:0 0 14px;white-space:pre-wrap}table{width:100%;margin:18px 0;border-collapse:collapse;font-size:12px}th,td{border:1px solid #cbd5e1;padding:6px 8px;text-align:left;vertical-align:top}td{min-width:80px}@media(max-width:900px){body{width:100%;min-height:100vh;margin:0;padding:28px 24px;box-shadow:none}}</style>'
. '</head><body>' . implode('', $content) . '</body></html>';
return response($html, 200, [
'Content-Type' => 'text/html; charset=UTF-8',
'Content-Disposition' => 'inline; filename="' . addcslashes(pathinfo($fileName, PATHINFO_FILENAME) . '.html', '"\\') . '"',
]);
}
private function resolveDisk(string $path)
{
foreach (['local', 'public'] as $diskName) {
$disk = Storage::disk($diskName);
if ($disk->exists($path)) {
return $disk;
}
}
return null;
}
public function downloadVersion(Document $document, int $versionId)
{
$version = $document->versions()->findOrFail($versionId);
$disk = $this->resolveDisk($version->file_path);
if (!$disk) {
return back()->with('error', 'Version file not found.');
}
return $disk->download($version->file_path, $version->file_name);
}
public function versions(Document $document)
{
$document->load(['versions.uploader:id,name', 'uploader:id,name', 'approvals.approver', 'category']);
return Inertia::render('DocumentManagement::Documents/Versions', [
'document' => $document,
]);
}
public function destroy(Document $document)
{
$user = auth()->user();
// Platform / Executive roles can delete any document
$isExecutive = is_null($user?->contractor_id)
|| in_array($user?->user_type, ['admin', 'super_admin'])
|| $user?->hasAnyRole(['Super Admin', 'Admin', 'Project Manager', 'Executive']);
if (!$isExecutive) {
// Contractors can delete documents uploaded by anyone in their contractor company
$uploaderContractorId = $document->uploader?->contractor_id;
$isOwnCompany = ((int)$document->uploaded_by === (int)$user->id)
|| ($user->contractor_id && $uploaderContractorId !== null && (int)$uploaderContractorId === (int)$user->contractor_id);
abort_unless($isOwnCompany, 403, 'Contractors can only delete documents uploaded by their company.');
}
// Delete all version files
foreach ($document->versions as $version) {
$disk = $this->resolveDisk($version->file_path);
$disk?->delete($version->file_path);
}
if ($document->current_file_path) {
$disk = $this->resolveDisk($document->current_file_path);
$disk?->delete($document->current_file_path);
}
$document->delete();
return back()->with('success', 'Document deleted.');
}
}