From 80fa96097cb526457aab4e04e389997c0c15cf66 Mon Sep 17 00:00:00 2001 From: Ajjj Date: Wed, 5 Aug 2026 03:26:06 +0800 Subject: [PATCH] feat: implement initial project, document, and resource management modules with routing, controllers, and UI components --- .../resources/js/Pages/Contractors/Index.tsx | 8 +- .../Http/Controllers/DocumentController.php | 126 ++++++- .../resources/js/Pages/Documents/Index.tsx | 357 +++++++++--------- Modules/DocumentManagement/routes/web.php | 1 + .../MaterialRequisitionController.php | 39 +- .../Controllers/PurchaseOrderController.php | 37 +- .../Http/Controllers/ProjectController.php | 16 + .../resources/js/Layouts/ProjectLayout.tsx | 2 +- .../resources/js/Pages/Projects/Index.tsx | 71 ++-- .../resources/js/Pages/Projects/Wizard.tsx | 52 +-- Modules/ProjectManagement/routes/web.php | 1 + .../app/Http/Controllers/UserController.php | 12 + docs/OVERALL_WORKFLOW_AUDIT.md | 128 +++++++ resources/js/Components/ui/table.tsx | 12 +- tests/Feature/DocumentMediaPreviewTest.php | 151 ++++++++ tests/Feature/ProjectWizardFlowTest.php | 25 ++ 16 files changed, 718 insertions(+), 320 deletions(-) create mode 100644 docs/OVERALL_WORKFLOW_AUDIT.md create mode 100644 tests/Feature/DocumentMediaPreviewTest.php diff --git a/Modules/ContractorManagement/resources/js/Pages/Contractors/Index.tsx b/Modules/ContractorManagement/resources/js/Pages/Contractors/Index.tsx index c73ffbd..7cec99c 100644 --- a/Modules/ContractorManagement/resources/js/Pages/Contractors/Index.tsx +++ b/Modules/ContractorManagement/resources/js/Pages/Contractors/Index.tsx @@ -30,7 +30,9 @@ const statusVariant = (s: string) => { }; export default function Index({ contractors, filters }: Props) { - const { flash } = usePage().props; + const { flash, auth } = usePage().props; + const canCreateContractor = auth.roles?.some((role: string) => ['Project Manager', 'admin', 'Super Admin'].includes(role)) + || auth.user?.user_type === 'admin'; const [search, setSearch] = useState(filters.search || ''); const [statusFilter, setStatusFilter] = useState(filters.status || 'all'); @@ -87,11 +89,11 @@ export default function Index({ contractors, filters }: Props) { } - actions={ + actions={canCreateContractor ? ( - } + ) : undefined} /> diff --git a/Modules/DocumentManagement/app/Http/Controllers/DocumentController.php b/Modules/DocumentManagement/app/Http/Controllers/DocumentController.php index 6b1ee43..6da77fa 100644 --- a/Modules/DocumentManagement/app/Http/Controllers/DocumentController.php +++ b/Modules/DocumentManagement/app/Http/Controllers/DocumentController.php @@ -118,22 +118,134 @@ class DocumentController extends Controller public function download(Document $document) { - if (!$document->current_file_path || !Storage::disk('local')->exists($document->current_file_path)) { + $disk = $document->current_file_path ? $this->resolveDisk($document->current_file_path) : null; + + if (!$disk) { return back()->with('error', 'File not found.'); } - return Storage::disk('local')->download($document->current_file_path, $document->current_file_name); + 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[] = '

' . e($text) . '

'; + } + 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[] = ''; + } + if ($cellsHtml !== []) { + $rowsHtml[] = '' . implode('', $cellsHtml) . ''; + } + } + + if ($rowsHtml !== []) { + $content[] = '
' . e($extractText($cell)) . '
' . implode('', $rowsHtml) . '
'; + } + } + } + + $html = '' . e($fileName) . '' + . '' + . '' . implode('', $content) . ''; + + 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); - if (!Storage::disk('local')->exists($version->file_path)) { + $disk = $this->resolveDisk($version->file_path); + + if (!$disk) { return back()->with('error', 'Version file not found.'); } - return Storage::disk('local')->download($version->file_path, $version->file_name); + return $disk->download($version->file_path, $version->file_name); } public function versions(Document $document) @@ -149,10 +261,12 @@ class DocumentController extends Controller { // Delete all version files foreach ($document->versions as $version) { - Storage::disk('local')->delete($version->file_path); + $disk = $this->resolveDisk($version->file_path); + $disk?->delete($version->file_path); } if ($document->current_file_path) { - Storage::disk('local')->delete($document->current_file_path); + $disk = $this->resolveDisk($document->current_file_path); + $disk?->delete($document->current_file_path); } $document->delete(); diff --git a/Modules/DocumentManagement/resources/js/Pages/Documents/Index.tsx b/Modules/DocumentManagement/resources/js/Pages/Documents/Index.tsx index e0c3710..e830226 100644 --- a/Modules/DocumentManagement/resources/js/Pages/Documents/Index.tsx +++ b/Modules/DocumentManagement/resources/js/Pages/Documents/Index.tsx @@ -32,6 +32,14 @@ interface DocItem { project?: Project; } +interface PreviewDocument { + url: string; + downloadUrl: string; + title: string; + mime?: string; + fileName?: string; +} + interface Props extends PageProps { documents: PaginatedData; categories: Category[]; @@ -39,13 +47,39 @@ interface Props extends PageProps { filters: { search?: string; category_id?: string; project_id?: string; status?: string }; } -const formatSize = (bytes: number) => { - if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB'; - if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB'; - return bytes + ' B'; -}; - const isImage = (mime?: string) => mime?.startsWith('image/'); +const isPdf = (mime?: string) => mime === 'application/pdf' || mime?.includes('/pdf'); +const isDocx = (mime?: string, fileName?: string) => mime === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || fileName?.toLowerCase().endsWith('.docx'); + +let pdfJsPromise: Promise | null = null; + +const getPdfJs = async () => { + if ((window as any).pdfjsLib) return (window as any).pdfjsLib; + + if (!pdfJsPromise) { + pdfJsPromise = new Promise((resolve, reject) => { + const existingScript = document.querySelector('script[data-pdfjs]'); + if (existingScript) { + existingScript.addEventListener('load', () => resolve((window as any).pdfjsLib)); + existingScript.addEventListener('error', () => reject(new Error('Failed to load PDFJS script'))); + return; + } + + const script = document.createElement('script'); + script.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.4.120/pdf.min.js'; + script.dataset.pdfjs = 'true'; + script.onload = () => resolve((window as any).pdfjsLib); + script.onerror = () => reject(new Error('Failed to load PDFJS script')); + document.head.appendChild(script); + }).then((pdfjsLib: any) => { + if (!pdfjsLib) throw new Error('PDFJS not loaded'); + pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.4.120/pdf.worker.min.js'; + return pdfjsLib; + }); + } + + return pdfJsPromise; +}; const StatusBadge = ({ status }: { status: string }) => { switch (status) { @@ -62,107 +96,6 @@ const getCategoryName = (category: any) => { return category; // It's a legacy string column value }; -// Dynamic CDN PDF.js worker and renderer component -export function PDFThumbnail({ url, className }: { url: string; className?: string }) { - const canvasRef = useRef(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(false); - - useEffect(() => { - let isMounted = true; - - const initPDF = async () => { - try { - if (!(window as any).pdfjsLib) { - await new Promise((resolve, reject) => { - const script = document.createElement('script'); - script.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.4.120/pdf.min.js'; - script.onload = () => resolve(); - script.onerror = () => reject(new Error('Failed to load PDFJS script')); - document.body.appendChild(script); - }); - } - - if ((window as any).pdfjsLib && !(window as any).pdfjsLib.GlobalWorkerOptions.workerSrc) { - (window as any).pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.4.120/pdf.worker.min.js'; - } - - const pdfjsLib = (window as any).pdfjsLib; - if (!pdfjsLib) throw new Error('PDFJS not loaded'); - - const response = await fetch(url, { credentials: 'include' }); - if (!response.ok) throw new Error('Failed to fetch PDF'); - const arrayBuffer = await response.arrayBuffer(); - - if (!isMounted) return; - - const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer }); - const pdf = await loadingTask.promise; - const page = await pdf.getPage(1); - - if (!isMounted) return; - - const canvas = canvasRef.current; - if (!canvas) return; - - const context = canvas.getContext('2d'); - if (!context) return; - - const viewport = page.getViewport({ scale: 1 }); - const scale = 200 / viewport.height; - const scaledViewport = page.getViewport({ scale }); - - canvas.height = scaledViewport.height; - canvas.width = scaledViewport.width; - - await page.render({ - canvasContext: context, - viewport: scaledViewport, - }).promise; - - if (isMounted) setLoading(false); - } catch (err) { - console.error('Error rendering PDF thumbnail:', err); - if (isMounted) { - setError(true); - setLoading(false); - } - } - }; - - initPDF(); - - return () => { - isMounted = false; - }; - }, [url]); - - if (loading) { - return ( -
- Loading Preview... -
- ); - } - - if (error) { - return ( -
-
- - Preview Error -
-
- ); - } - - return ( -
- -
- ); -} - // Lightbox Modal for PDF Viewing export function PDFLightboxModal({ url, title, isOpen, onClose }: { url: string; title: string; isOpen: boolean; onClose: () => void }) { const canvasRef = useRef(null); @@ -183,8 +116,7 @@ export function PDFLightboxModal({ url, title, isOpen, onClose }: { url: string; const loadPDF = async () => { try { - const pdfjsLib = (window as any).pdfjsLib; - if (!pdfjsLib) throw new Error('PDFJS not loaded'); + const pdfjsLib = await getPdfJs(); const response = await fetch(url, { credentials: 'include' }); if (!response.ok) throw new Error('Failed to load PDF'); @@ -255,7 +187,7 @@ export function PDFLightboxModal({ url, title, isOpen, onClose }: { url: string; return ( { if (!open) onClose(); }}> - +
{title} @@ -345,6 +277,96 @@ export function PDFLightboxModal({ url, title, isOpen, onClose }: { url: string; ); } +function DocumentPreviewModal({ document, onClose }: { document: PreviewDocument; onClose: () => void }) { + const [scale, setScale] = useState(0.5); + const [paperHeight, setPaperHeight] = useState(1120); + + if (isPdf(document.mime)) { + return ; + } + + if (isDocx(document.mime, document.fileName)) { + return ( + { if (!open) onClose(); }}> + + +
+ {document.title} +

Document Preview

+
+
+ +
+
+
+ + Page 1 of 1 + +
+
+ + {Math.round(scale * 100)}% + +
+ + + +
+ +
+
+